From 3fab89c61af66056b50006a6404b5e7ac2c6b93d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:04:43 +0100 Subject: [PATCH] fix(cli): a resumed session keeps its registration and its key (bug1, bug3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug1 — the registration belonged to the launch WRAPPER (its pid, a token in its tmpdir). When claude outlived it (restart, re-exec, resume) the reaper dropped the entry, the token vanished, and every send from that session went out silently under the MEMBER key; replies to it fanned out to all of the member's sessions. - services/session/reattach.ts: ensureSessionRegistered re-registers the session with its persisted keypair, a fresh attestation and claude's pid (nearest `claude` ancestor). Called by the MCP at startup (moves the anchor off the wrapper), by `send` when the session is unknown (auto-heal), and by the new `claudemesh session reattach`. - The session token lives next to the keypair (~/.claudemesh/sessions//.token) and survives the wrapper; token readers fall back to it via CLAUDEMESH_SESSION_ID (+ the new CLAUDEMESH_MESH_SLUG, or the mesh holding the keypair file). - `send` inside a session that can't be re-attached refuses unless --as-member; the daemon answers 401 session_unknown to a send whose session token it doesn't know. - The registry's absolute 24 h TTL is gone (liveness = pid + start time). bug3 — sessions reappeared under a new pubkey: - launch keys the keypair, the registration and the token on ONE id (it used to register a random UUID next to a keypair keyed elsewhere). - non-UUID session ids persist under a hashed stem instead of a throwaway keypair per call. - the daemon's boot reload is load-only — it never mints a key a live session didn't have. - a same-token, same-key re-register only moves the pid anchor, so the MCP's startup re-attach doesn't close/reopen the session WS. Tests: session-reattach.test.ts (2/5 fail on the old registry), keypair-store.test.ts updated to the new non-UUID rule. Spec: .artifacts/specs/2026-09-26-session-identity-and-routing.md §2, §4 Co-Authored-By: Claude Opus 5.5 --- ...2026-09-26-session-identity-and-routing.md | 23 +++- apps/cli/src/cli/argv.ts | 1 + apps/cli/src/commands/launch.ts | 50 ++++--- apps/cli/src/commands/send.ts | 24 ++++ apps/cli/src/commands/session-reattach.ts | 34 +++++ apps/cli/src/daemon/ipc/server.ts | 15 ++- apps/cli/src/daemon/run.ts | 17 ++- apps/cli/src/daemon/session-registry.ts | 35 +++-- apps/cli/src/entrypoints/cli.ts | 21 ++- apps/cli/src/mcp/server.ts | 15 +++ apps/cli/src/services/bridge/daemon-route.ts | 2 +- .../cli/src/services/session/keypair-store.ts | 48 ++++++- apps/cli/src/services/session/reattach.ts | 127 ++++++++++++++++++ apps/cli/src/services/session/resolve.ts | 5 + apps/cli/src/services/session/token.ts | 21 ++- apps/cli/tests/unit/keypair-store.test.ts | 22 ++- apps/cli/tests/unit/session-reattach.test.ts | 92 +++++++++++++ 17 files changed, 504 insertions(+), 48 deletions(-) create mode 100644 apps/cli/src/commands/session-reattach.ts create mode 100644 apps/cli/src/services/session/reattach.ts create mode 100644 apps/cli/tests/unit/session-reattach.test.ts diff --git a/.artifacts/specs/2026-09-26-session-identity-and-routing.md b/.artifacts/specs/2026-09-26-session-identity-and-routing.md index 67d49d1..d6211bc 100644 --- a/.artifacts/specs/2026-09-26-session-identity-and-routing.md +++ b/.artifacts/specs/2026-09-26-session-identity-and-routing.md @@ -30,11 +30,16 @@ Result: replies fan out to sibling sessions → noise. Fix all three; each alone (not a silent `session=null`). - `send`: when the process is inside a launched session (`CLAUDEMESH_SESSION_ID` set) but the daemon doesn't know the session → error with fix hint, unless `--as-member`. -- Re-attach: `POST /v1/sessions/reattach {session_id, mesh, display_name, pid}` — daemon loads the - persisted keypair (never mints), signs the attestation (same as boot reload), registers with the - caller's pid. Called by the MCP server at startup when its token is missing/unknown. -- Token lives in a stable per-session path (`~/.claudemesh/sessions//.token`), not the - launch tmpdir, so a resumed claude finds it. +- Re-attach (implemented client-side over the existing `POST /v1/sessions/register`, no new route): + `services/session/reattach.ts ensureSessionRegistered` loads the session's persisted keypair, + signs a fresh attestation with the member key and registers with **claude's pid** (nearest + `claude` ancestor). Called by the MCP at startup (always, to move the anchor off the wrapper), + by `claudemesh send` when the session is unknown (auto-heal), and by `claudemesh session reattach`. +- `send` inside a session (`CLAUDEMESH_SESSION_ID` set) that can't be re-attached refuses unless + `--as-member`; the daemon answers 401 `session_unknown` to a send whose session token it doesn't know. +- Token lives in a stable per-session path (`~/.claudemesh/sessions//.token`), not the + launch tmpdir, so a resumed claude finds it; token readers fall back to it via + `CLAUDEMESH_SESSION_ID` (+ new `CLAUDEMESH_MESH_SLUG`, or the mesh holding the keypair file). - The 24 h absolute cap is removed for entries whose pid+start-time still match. - Tokenless MCP subscribes to **no** message events (today: all of them). @@ -54,7 +59,13 @@ Result: replies fan out to sibling sessions → noise. Fix all three; each alone - `launch`: mint the UUID first; the same id drives keypair + registration; no throwaway keypair when a session id exists (non-UUID ids persisted under sha256). - Boot reload: load-only; a missing key file → skip + log, never mint. -- Supervisor: a session client that ends `replaced`/closed while its registry entry is alive is rebuilt. +- ~~Supervisor that rebuilds a `replaced` session client~~ — **dropped during implementation.** + 1.37.1 made `session_replaced` terminal on purpose (two daemons holding one identity kicked each + other at 1 s, 2026-09-08 drill). With keys no longer re-minted (reload is load-only, one id per + launch, non-UUID ids persisted) the only replacer is the session's own re-register, which already + swaps the client. A supervisor would re-open that ping-pong for no gain. +- Same-token + same-key re-register only moves the pid anchor (no hooks → no WS close/reopen). +- The registry's absolute 24 h TTL is removed; liveness = pid + start time. ### §5 bug4 — shipped in `dd351a7` (1.37.1, unpublished) — releases with 1.38.0. diff --git a/apps/cli/src/cli/argv.ts b/apps/cli/src/cli/argv.ts index c2d7992..0151f75 100644 --- a/apps/cli/src/cli/argv.ts +++ b/apps/cli/src/cli/argv.ts @@ -14,6 +14,7 @@ export interface ParsedArgs { command: string; positionals: string[]; flags: Rec const BOOLEAN_FLAGS = new Set([ "self", "fanout", + "as-member", "json", // also accepts --json=a,b,c form below "all", "yes", "y", diff --git a/apps/cli/src/commands/launch.ts b/apps/cli/src/commands/launch.ts index 76c971a..b9bb919 100644 --- a/apps/cli/src/commands/launch.ts +++ b/apps/cli/src/commands/launch.ts @@ -16,9 +16,9 @@ import { spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs"; import { tmpdir, hostname, homedir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { createInterface } from "node:readline"; import { readConfig, getConfigPath } from "~/services/config/facade.js"; import type { Config, JoinedMesh, GroupEntry } from "~/services/config/facade.js"; @@ -815,9 +815,25 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise< const passSessionIdFlag = !isResume; let sessionTokenFilePath: string | null = null; let sessionTokenForCleanup: string | null = null; + // 1.38.0 (spec 2026-09-26 §2/§4): ONE id drives the keypair, the + // registration and the token path. It used to register a random UUID + // while keying the keypair on another id (or a throwaway), so a daemon + // restart re-keyed the session. + const sessionIdForRegister = claudeSessionId ?? randomUUID(); try { const { mintSessionToken, TOKEN_FILE_ENV } = await import("~/services/session/token.js"); - const minted = mintSessionToken(tmpDir); + const { sessionTokenPath } = await import("~/services/session/keypair-store.js"); + // The token lives next to the session's keypair, not in the launch + // tmpdir: a claude that outlives this wrapper (restart, re-exec, + // resume) must still find it — the tmpdir is shredded on our exit. + const stablePath = sessionTokenPath(mesh.slug, sessionIdForRegister); + let minted: { token: string; filePath: string }; + if (stablePath) { + mkdirSync(dirname(stablePath), { recursive: true, mode: 0o700 }); + minted = mintSessionToken(dirname(stablePath), basename(stablePath)); + } else { + minted = mintSessionToken(tmpDir); + } sessionTokenFilePath = minted.filePath; sessionTokenForCleanup = minted.token; @@ -835,15 +851,12 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise< }; } | undefined; try { - const { generateKeypair } = await import("~/services/crypto/facade.js"); const { signParentAttestation } = await import("~/services/broker/session-hello-sig.js"); - // Persisted, UUID-anchored keypair so relaunch/--resume reuse the - // same sessionPubkey (queued DMs route AND decrypt). Falls back to - // an ephemeral keypair when we couldn't resolve a stable UUID - // (e.g. --continue with no prior session in this cwd). - const sessionKp = claudeSessionId - ? await (await import("~/services/session/keypair-store.js")).loadOrCreateSessionKeypair(mesh.slug, claudeSessionId) - : await generateKeypair(); + // Persisted keypair anchored on the registered session id, so + // relaunch/--resume reuse the same sessionPubkey (queued DMs route + // AND decrypt) and a daemon restart reloads that same key. + const sessionKp = await (await import("~/services/session/keypair-store.js")) + .loadOrCreateSessionKeypair(mesh.slug, sessionIdForRegister); const att = await signParentAttestation({ parentMemberPubkey: mesh.pubkey, parentSecretKey: mesh.secretKey, @@ -868,7 +881,6 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise< // Register with the daemon. Best-effort: a daemon failure here // means the session falls back to user-level scope, which is fine. const { ipc } = await import("~/daemon/ipc/client.js"); - const sessionIdForRegister = claudeSessionId ?? randomUUID(); await ipc({ method: "POST", path: "/v1/sessions/register", @@ -1027,13 +1039,10 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise< writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8"); } catch { /* best effort */ } } - // The token's session-token file lives inside tmpDir; rmSync below - // shreds the secret. The daemon's session reaper notices the - // launched session's pid is gone within 30s and drops the registry - // entry. Explicit DELETE on /v1/sessions is feasible only from an - // async exit hook, which adds complexity for ~30s of memory the - // reaper will reclaim anyway. Leaving as-is; revisit if the - // registry ever grows persistence. + // 1.38.0: the session token lives next to the session keypair (not + // in tmpDir) and is deliberately kept — a claude that outlives this + // wrapper re-attaches with it. The daemon's reaper drops the registry + // entry once the registered pid (claude's, set by its MCP) is gone. // Ephemeral config dir (also drops the session-token file) try { rmSync(tmpDir, { recursive: true, force: true }); @@ -1095,7 +1104,8 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise< ...process.env, CLAUDEMESH_CONFIG_DIR: tmpDir, CLAUDEMESH_DISPLAY_NAME: displayName, - ...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}), + CLAUDEMESH_SESSION_ID: sessionIdForRegister, + CLAUDEMESH_MESH_SLUG: mesh.slug, ...(sessionTokenFilePath ? { CLAUDEMESH_IPC_TOKEN_FILE: sessionTokenFilePath } : {}), MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000", MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000", diff --git a/apps/cli/src/commands/send.ts b/apps/cli/src/commands/send.ts index e6f5e18..3264726 100644 --- a/apps/cli/src/commands/send.ts +++ b/apps/cli/src/commands/send.ts @@ -32,6 +32,9 @@ export interface SendFlags { * (spec 2026-09-26 §3) — the default fan-out was the "messages land on * the wrong peers" noise. */ fanout?: boolean; + /** 1.38.0: send as the member (not a session) on purpose, from inside a + * launched session whose registration can't be restored. */ + asMember?: boolean; } export async function runSend(flags: SendFlags, to: string, message: string): Promise { @@ -294,6 +297,27 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr const offlineHint = "It's queued and delivers when that session reconnects. If the session was relaunched it has a new key — re-fetch a live target with `claudemesh peer list --json`."; + // 1.38.0 (spec 2026-09-26 §2, bug1): inside a launched session, never + // send silently under the member key. A session that lost its daemon + // registration (claude restarted/resumed after the wrapper exited) used + // to do exactly that — and every reply to the member key fanned out to + // all of the member's sessions. Re-attach first; refuse if that fails. + if (process.env.CLAUDEMESH_SESSION_ID && !flags.asMember) { + const { getSessionInfo, resetSessionInfoCache } = await import("~/services/session/resolve.js"); + const { daemonReachable } = await import("~/services/bridge/daemon-route.js"); + if (await daemonReachable() && !(await getSessionInfo())) { + const { ensureSessionRegistered, findClaudeAncestorPid } = await import("~/services/session/reattach.js"); + const outcome = await ensureSessionRegistered({ pid: findClaudeAncestorPid() ?? process.ppid }); + resetSessionInfoCache(); + if (outcome.kind === "failed" || !(await getSessionInfo())) { + render.err("This session isn't registered with the daemon, so this message would go out as your member key."); + render.hint(`${outcome.kind === "failed" ? outcome.reason + " — " : ""}run \`claudemesh session reattach\`, or pass --as-member to send as the member on purpose.`); + process.exit(1); + } + if (!flags.json) render.info(dim("re-attached this session to the daemon")); + } + } + // Daemon path — preferred when a long-lived daemon is local. UDS at // ~/.claudemesh/daemon/daemon.sock; ~1ms round-trip; persists outbox // across CLI invocations so a `claudemesh send` survives a daemon diff --git a/apps/cli/src/commands/session-reattach.ts b/apps/cli/src/commands/session-reattach.ts new file mode 100644 index 0000000..853bc09 --- /dev/null +++ b/apps/cli/src/commands/session-reattach.ts @@ -0,0 +1,34 @@ +/** + * `claudemesh session reattach` — restore this session's daemon + * registration after claude restarted or was resumed outside the launch + * wrapper (spec 2026-09-26 §2, bug1). Keeps the session's persisted key, + * so peers holding its pubkey can still reach it. + */ + +import { render } from "~/ui/render.js"; +import { dim } from "~/ui/styles.js"; + +export async function runSessionReattach(flags: { mesh?: string; json?: boolean }): Promise { + const { ensureSessionRegistered, findClaudeAncestorPid } = await import("~/services/session/reattach.js"); + const env = flags.mesh ? { ...process.env, CLAUDEMESH_MESH_SLUG: flags.mesh } : process.env; + const pid = findClaudeAncestorPid() ?? process.ppid; + const outcome = await ensureSessionRegistered({ pid, env, refreshPid: true }); + if (flags.json) { + console.log(JSON.stringify({ ...outcome, pid })); + return outcome.kind === "failed" || outcome.kind === "not_a_session" ? 1 : 0; + } + switch (outcome.kind) { + case "not_a_session": + render.err("Not inside a claudemesh-launched session (CLAUDEMESH_SESSION_ID / CLAUDEMESH_DISPLAY_NAME unset)."); + return 1; + case "failed": + render.err(`reattach failed: ${outcome.reason}`); + return 1; + case "registered": + render.ok("session already registered", dim(`pid ${pid}`)); + return 0; + case "reattached": + render.ok(`session re-attached on "${outcome.mesh}"`, dim(`pid ${pid}`)); + return 0; + } +} diff --git a/apps/cli/src/daemon/ipc/server.ts b/apps/cli/src/daemon/ipc/server.ts index 2ba9a00..181a49f 100644 --- a/apps/cli/src/daemon/ipc/server.ts +++ b/apps/cli/src/daemon/ipc/server.ts @@ -239,10 +239,14 @@ function makeHandler(opts: { // it to a SessionInfo that downstream routes use for default-mesh // scoping and attribution. let session: SessionInfo | null = null; + let sessionHeaderUnresolved = false; { const authz = req.headers.authorization ?? ""; const sm = /^ClaudeMesh-Session\s+([0-9a-f]{64})$/i.exec(authz.trim()); - if (sm && sm[1]) session = resolveToken(sm[1].toLowerCase()); + if (sm && sm[1]) { + session = resolveToken(sm[1].toLowerCase()); + sessionHeaderUnresolved = session === null; + } } /** Pick mesh from explicit body/query first, then session default. */ const meshFromCtx = (explicit?: string | null): string | null => @@ -889,6 +893,15 @@ function makeHandler(opts: { respond(res, 503, { error: "outbox not initialised" }); return; } + // 1.38.0 (spec 2026-09-26 §2): a caller that presents a session token + // the registry doesn't know must not be sent as the member key. + if (sessionHeaderUnresolved) { + respond(res, 401, { + error: "session_unknown", + detail: "this session's token isn't registered with the daemon — run `claudemesh session reattach`", + }); + return; + } try { const body = await readJsonBody(req, 256 * 1024); const parsed = parseSendRequest(body, req.headers["idempotency-key"]); diff --git a/apps/cli/src/daemon/run.ts b/apps/cli/src/daemon/run.ts index 4d4028f..f9f42e5 100644 --- a/apps/cli/src/daemon/run.ts +++ b/apps/cli/src/daemon/run.ts @@ -445,7 +445,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { try { const persisted = readPersistedSessions(DAEMON_PATHS.SESSIONS_FILE); if (persisted.length > 0) { - const { loadOrCreateSessionKeypair } = await import("~/services/session/keypair-store.js"); + const { loadSessionKeypair } = await import("~/services/session/keypair-store.js"); const { signParentAttestation } = await import("~/services/broker/session-hello-sig.js"); const { isPidAlive, getProcessStartTimes } = await import("./process-info.js"); const liveStartTimes = await getProcessStartTimes(persisted.map((p) => p.pid)).catch(() => new Map()); @@ -459,7 +459,20 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { const meshConfig = meshConfigs.get(s.mesh); if (!meshConfig) continue; // mesh no longer joined try { - const kp = await loadOrCreateSessionKeypair(meshConfig.slug, s.sessionId); + // 1.38.0 (spec 2026-09-26 §4): load-only. Minting here gave a + // live session a key it never had — it reappeared under a new + // pubkey and holders of the old one couldn't reach it (bug3). + // Without its key file the session re-attaches itself (MCP + // startup / `claudemesh session reattach`). + const kp = loadSessionKeypair(meshConfig.slug, s.sessionId); + if (!kp) { + process.stderr.write(JSON.stringify({ + level: "warn", msg: "session_rehydrate_no_keypair", + token: s.token.slice(0, 8), mesh: s.mesh, session_id: s.sessionId, + ts: new Date().toISOString(), + }) + "\n"); + continue; + } const att = await signParentAttestation({ parentMemberPubkey: meshConfig.pubkey, parentSecretKey: meshConfig.secretKey, diff --git a/apps/cli/src/daemon/session-registry.ts b/apps/cli/src/daemon/session-registry.ts index 39a1d68..c11b1e0 100644 --- a/apps/cli/src/daemon/session-registry.ts +++ b/apps/cli/src/daemon/session-registry.ts @@ -78,7 +78,6 @@ export interface RegistryHooks { onDeregister?: (info: SessionInfo) => void; } -const TTL_MS = 24 * 60 * 60 * 1000; const REAPER_INTERVAL_MS = 5 * 1000; const byToken = new Map(); @@ -168,6 +167,28 @@ export function setRegistryHooks(next: RegistryHooks): void { } export function registerSession(info: Omit): SessionInfo { + // 1.38.0 (spec 2026-09-26 §2): a re-attach from the SAME session (same + // token, same session key) — e.g. its MCP starting inside a resumed + // claude — only moves the liveness anchor to the new pid. Firing the + // hooks here would close and reopen the session's broker WS, which the + // broker sees as a replacement (the churn behind bug3). + const same = byToken.get(info.token); + if ( + same && same.sessionId === info.sessionId && + (same.presence?.sessionPubkey ?? null) === (info.presence?.sessionPubkey ?? null) + ) { + const pidChanged = same.pid !== info.pid; + same.pid = info.pid; + if (info.cwd) same.cwd = info.cwd; + same.displayName = info.displayName; + if (pidChanged) { + same.startTime = info.startTime; + if (same.startTime === undefined) void captureStartTimeAsync(info.token, info.pid); + } + persist(); + return same; + } + // Replace any prior entry under the same sessionId. const priorToken = bySessionId.get(info.sessionId); if (priorToken && priorToken !== info.token) { @@ -216,13 +237,10 @@ export function deregisterByToken(token: string): boolean { } export function resolveToken(token: string): SessionInfo | null { - const entry = byToken.get(token); - if (!entry) return null; - if (Date.now() - entry.registeredAt > TTL_MS) { - deregisterByToken(token); - return null; - } - return entry; + // 1.38.0: no absolute TTL. Liveness is the reaper's job (pid + start + // time); the old 24 h cap dropped long-running sessions whose pid was + // alive, and they silently fell back to the member key. + return byToken.get(token) ?? null; } export function listSessions(): SessionInfo[] { @@ -238,7 +256,6 @@ async function reapDead(): Promise { const dead: string[] = []; const survivors: Array<[string, SessionInfo]> = []; for (const [token, info] of entries) { - if (Date.now() - info.registeredAt > TTL_MS) { dead.push(token); continue; } if (!isPidAlive(info.pid)) { dead.push(token); continue; } survivors.push([token, info]); } diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index fdb9d11..2764cba 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -91,11 +91,17 @@ Peer (resource form, recommended) claudemesh peer bans list banned members (alias: bans) claudemesh peer verify [p] safety numbers (alias: verify) +Session + claudemesh session reattach restore this session's registration after a claude + restart/resume (keeps its pubkey) [--mesh ] + Message (resource form) claudemesh message send send a message (alias: send) flags: [--priority now|next|low] [--mesh ] [--self] (allow targeting your own member/session pubkey; fans out to every sibling session of your member) + [--fanout] (a member-key target reaches ALL its sessions) + [--as-member] (send as the member from an unregistered session) [--json] (machine-readable result) claudemesh message inbox read persisted inbox (alias: inbox) flags: [--mesh ] [--limit N] [--unread] [--json] @@ -403,7 +409,7 @@ async function main(): Promise { // Messaging case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: flags.json as boolean | string | undefined, all: !!flags.all }); break; } - case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self, fanout: !!flags.fanout }, positionals[0] ?? "", positionals.slice(1).join(" ")); break; } + case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self, fanout: !!flags.fanout, asMember: !!flags["as-member"] }, positionals[0] ?? "", positionals.slice(1).join(" ")); break; } case "inbox": { const sub = positionals[0]; if (sub === "flush") { @@ -554,6 +560,17 @@ async function main(): Promise { // The legacy verbs (`send`, `peers`, `kick`, `remember`, ...) keep // working so old scripts never break. Spec: 2026-05-02 commitment #2. + case "session": { + const sub = positionals[0]; + if (sub === "reattach") { + const { runSessionReattach } = await import("~/commands/session-reattach.js"); + process.exit(await runSessionReattach({ mesh: flags.mesh as string | undefined, json: !!flags.json })); + } + console.error("Usage: claudemesh session reattach [--mesh ] [--json]"); + process.exit(2); + break; + } + case "peer": { const sub = positionals[0]; const f = { mesh: flags.mesh as string, json: flags.json as boolean | string | undefined, all: !!flags.all }; @@ -571,7 +588,7 @@ async function main(): Promise { case "message": { const sub = positionals[0]; - if (sub === "send") { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self, fanout: !!flags.fanout }, positionals[1] ?? "", positionals.slice(2).join(" ")); } + if (sub === "send") { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json, self: !!flags.self, fanout: !!flags.fanout, asMember: !!flags["as-member"] }, positionals[1] ?? "", positionals.slice(2).join(" ")); } else if (sub === "inbox") { const sub2 = positionals[1]; if (sub2 === "flush") { diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index fbf6382..85933d9 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -427,6 +427,21 @@ export async function startMcpServer(): Promise { // resolve mesh scoping if it ever needs to. We read it once here and // capture it in the closure since the MCP runs for the lifetime of // the session; the env var doesn't rotate mid-process. + // 1.38.0 (spec 2026-09-26 §2, bug1): (re)attach this session before + // reading its token. The MCP runs inside claude, so this anchors the + // registry on claude's pid — not the launch wrapper's, which may be + // gone (restart / re-exec / resume) — and restores a dropped + // registration with the session's persisted key. + try { + const { ensureSessionRegistered, findClaudeAncestorPid } = await import("~/services/session/reattach.js"); + const outcome = await ensureSessionRegistered({ + pid: findClaudeAncestorPid(process.ppid) ?? process.ppid, + refreshPid: true, + }); + mcpLog("session_attach", { outcome: outcome.kind, ...(outcome.kind === "failed" ? { reason: outcome.reason } : {}) }); + } catch (e) { + mcpLog("session_attach", { outcome: "error", err: String(e) }); + } const { readSessionTokenFromEnv } = await import("~/services/session/token.js"); const sessionTokenForSeen = readSessionTokenFromEnv(); diff --git a/apps/cli/src/services/bridge/daemon-route.ts b/apps/cli/src/services/bridge/daemon-route.ts index 3585f4e..d2816d0 100644 --- a/apps/cli/src/services/bridge/daemon-route.ts +++ b/apps/cli/src/services/bridge/daemon-route.ts @@ -28,7 +28,7 @@ function meshQuery(mesh?: string): string { * --no-daemon short-circuits to false; --strict's enforcement lives at * the cold-path entry point (`withMesh` in commands/connect.ts) so a * single chokepoint covers every verb. */ -async function daemonReachable(): Promise { +export async function daemonReachable(): Promise { const policy = getDaemonPolicy(); if (policy.mode === "no-daemon") return false; const res = await ensureDaemonReady({ noAutoSpawn: false }); diff --git a/apps/cli/src/services/session/keypair-store.ts b/apps/cli/src/services/session/keypair-store.ts index 596a8f5..f014f58 100644 --- a/apps/cli/src/services/session/keypair-store.ts +++ b/apps/cli/src/services/session/keypair-store.ts @@ -28,8 +28,8 @@ * delivery. `CLAUDEMESH_SESSIONS_DIR` overrides the root for tests. */ -import { randomBytes } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -57,8 +57,48 @@ export function sessionsDir(): string { ); } +/** Printable, bounded ids (claude session names) that aren't UUIDs. */ +const SAFE_ID_RE = /^[\x21-\x7e]{1,200}$/; + +/** File stem for a session id. UUIDs map to themselves; any other usable + * id maps to `id-` so it persists too. 1.38.0 (spec 2026-09-26 + * §4): a non-UUID id used to get a throwaway keypair on EVERY call, so a + * daemon restart re-keyed the session behind its back (bug3). */ +export function sessionFileStem(sessionId: string): string | null { + if (UUID_RE.test(sessionId)) return sessionId; + if (SAFE_ID_RE.test(sessionId)) return `id-${createHash("sha256").update(sessionId).digest("hex").slice(0, 32)}`; + return null; +} + function keyFilePath(meshSlug: string, sessionId: string): string { - return join(sessionsDir(), meshSlug, `${sessionId}.json`); + return join(sessionsDir(), meshSlug, `${sessionFileStem(sessionId) ?? "invalid"}.json`); +} + +/** Load-only: the persisted keypair, or null. Never mints — callers that + * must not change a live session's identity (daemon boot reload) use this. */ +export function loadSessionKeypair(meshSlug: string, sessionId: string): Ed25519Keypair | null { + if (!SLUG_RE.test(meshSlug) || !sessionFileStem(sessionId)) return null; + return readValidKeypair(keyFilePath(meshSlug, sessionId)); +} + +/** Stable per-session IPC token path, next to the keypair. Outlives the + * launch wrapper's tmpdir so a resumed claude can still authenticate. */ +export function sessionTokenPath(meshSlug: string, sessionId: string): string | null { + const stem = sessionFileStem(sessionId); + if (!SLUG_RE.test(meshSlug) || !stem) return null; + return join(sessionsDir(), meshSlug, `${stem}.token`); +} + +/** Which joined mesh holds a persisted keypair for this session id. */ +export function findSessionMesh(sessionId: string): string | null { + const stem = sessionFileStem(sessionId); + if (!stem) return null; + try { + for (const slug of readdirSync(sessionsDir())) { + if (SLUG_RE.test(slug) && existsSync(join(sessionsDir(), slug, `${stem}.json`))) return slug; + } + } catch { /* no sessions dir yet */ } + return null; } /** Read a persisted keypair, returning null (never throwing) when the @@ -98,7 +138,7 @@ export async function loadOrCreateSessionKeypair( ): Promise { // Defensive validation: these compose into a filesystem path, so a // malformed slug/uuid must never escape the sessions dir. - if (!SLUG_RE.test(meshSlug) || !UUID_RE.test(sessionId)) { + if (!SLUG_RE.test(meshSlug) || !sessionFileStem(sessionId)) { return generateKeypair(); } diff --git a/apps/cli/src/services/session/reattach.ts b/apps/cli/src/services/session/reattach.ts new file mode 100644 index 0000000..2af4859 --- /dev/null +++ b/apps/cli/src/services/session/reattach.ts @@ -0,0 +1,127 @@ +/** + * Re-attach a launched session to the daemon when its registration is + * missing — spec 2026-09-26 §2 (bug1). + * + * `claudemesh launch` registers the session, but the registration was + * anchored on the WRAPPER's pid and its token lived in the wrapper's + * tmpdir. When claude outlived the wrapper (restart, re-exec, resume) the + * reaper dropped the entry, the token file vanished, and every send from + * that session silently went out under the MEMBER key — so replies fanned + * out to all of the member's sessions. + * + * The session's own processes (its MCP server at startup, `claudemesh + * send`, `claudemesh session reattach`) call `ensureSessionRegistered`, + * which re-registers with the SAME persisted keypair (never a new one) + * and the same stable token, anchored on claude's pid. + */ + +import { mkdirSync } from "node:fs"; +import { basename, dirname } from "node:path"; +import { execFileSync } from "node:child_process"; + +import { ipc } from "~/daemon/ipc/client.js"; +import { readConfig } from "~/services/config/facade.js"; +import { signParentAttestation } from "~/services/broker/session-hello-sig.js"; +import { findSessionMesh, loadOrCreateSessionKeypair, sessionTokenPath } from "./keypair-store.js"; +import { mintSessionToken, readSessionTokenFromEnv } from "./token.js"; + +export type ReattachOutcome = + | { kind: "registered" } // already known to the daemon + | { kind: "reattached"; mesh: string } // we re-registered it + | { kind: "not_a_session" } // not inside a launched session + | { kind: "failed"; reason: string }; + +/** Walk up from `start` to the nearest `claude` process. That pid is what + * the registry must watch: a Bash-tool subprocess dies in milliseconds. */ +export function findClaudeAncestorPid(start: number = process.ppid): number | null { + let pid = start; + for (let hops = 0; hops < 12 && pid > 1; hops++) { + let line = ""; + try { + line = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { encoding: "utf8" }).trim(); + } catch { return null; } + const m = /^(\d+)\s+(.+)$/.exec(line); + if (!m) return null; + const comm = m[2]!.trim(); + if (/(^|\/)claude$/.test(comm)) return pid; + pid = Number(m[1]); + } + return null; +} + +export async function ensureSessionRegistered(opts: { + /** Pid to anchor liveness on (claude's). */ + pid: number; + env?: NodeJS.ProcessEnv; + /** Re-register even when the daemon already knows the token — used by + * the MCP at startup to move the anchor from the wrapper to claude. */ + refreshPid?: boolean; +}): Promise { + const env = opts.env ?? process.env; + const sessionId = env.CLAUDEMESH_SESSION_ID; + const displayName = env.CLAUDEMESH_DISPLAY_NAME; + if (!sessionId || !displayName) return { kind: "not_a_session" }; + + const config = readConfig(); + const meshSlug = + env.CLAUDEMESH_MESH_SLUG || + findSessionMesh(sessionId) || + (config.meshes.length === 1 ? config.meshes[0]!.slug : null); + if (!meshSlug) return { kind: "failed", reason: "can't tell which mesh this session belongs to (set --mesh)" }; + const mesh = config.meshes.find((m) => m.slug === meshSlug); + if (!mesh) return { kind: "failed", reason: `mesh "${meshSlug}" isn't joined on this machine` }; + + let token = readSessionTokenFromEnv(env); + if (token && !opts.refreshPid) { + try { + const me = await ipc<{ session?: unknown }>({ path: "/v1/sessions/me", timeoutMs: 1_500 }); + if (me.status === 200 && me.body.session) return { kind: "registered" }; + } catch (e) { + return { kind: "failed", reason: `daemon unreachable: ${String(e)}` }; + } + } + + if (!token) { + const path = sessionTokenPath(meshSlug, sessionId); + if (!path) return { kind: "failed", reason: `unusable session id "${sessionId}"` }; + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + token = mintSessionToken(dirname(path), basename(path)).token; + } + + const kp = await loadOrCreateSessionKeypair(meshSlug, sessionId); + const att = await signParentAttestation({ + parentMemberPubkey: mesh.pubkey, + parentSecretKey: mesh.secretKey, + sessionPubkey: kp.publicKey, + }); + try { + const res = await ipc<{ ok?: boolean; error?: string }>({ + method: "POST", + path: "/v1/sessions/register", + timeoutMs: 3_000, + body: { + token, + session_id: sessionId, + mesh: meshSlug, + display_name: displayName, + pid: opts.pid, + cwd: process.cwd(), + ...(env.CLAUDEMESH_ROLE ? { role: env.CLAUDEMESH_ROLE } : {}), + presence: { + session_pubkey: kp.publicKey, + session_secret_key: kp.secretKey, + parent_attestation: { + session_pubkey: att.sessionPubkey, + parent_member_pubkey: att.parentMemberPubkey, + expires_at: att.expiresAt, + signature: att.signature, + }, + }, + }, + }); + if (res.status !== 200) return { kind: "failed", reason: res.body.error ?? `daemon http ${res.status}` }; + } catch (e) { + return { kind: "failed", reason: `daemon unreachable: ${String(e)}` }; + } + return { kind: "reattached", mesh: meshSlug }; +} diff --git a/apps/cli/src/services/session/resolve.ts b/apps/cli/src/services/session/resolve.ts index 322d46c..f865606 100644 --- a/apps/cli/src/services/session/resolve.ts +++ b/apps/cli/src/services/session/resolve.ts @@ -62,3 +62,8 @@ export async function getSessionInfo(): Promise { export function _resetSessionCache(): void { cached = undefined; } + +/** Drop the per-invocation cache — after a re-attach the answer changes. */ +export function resetSessionInfoCache(): void { + cached = undefined; +} diff --git a/apps/cli/src/services/session/token.ts b/apps/cli/src/services/session/token.ts index 18fbe60..ccd626b 100644 --- a/apps/cli/src/services/session/token.ts +++ b/apps/cli/src/services/session/token.ts @@ -18,6 +18,8 @@ import { randomBytes } from "node:crypto"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { findSessionMesh, sessionTokenPath } from "./keypair-store.js"; + const ENV_TOKEN_FILE = "CLAUDEMESH_IPC_TOKEN_FILE"; export interface MintedToken { @@ -40,7 +42,24 @@ export function mintSessionToken(dir: string, fileName = "session-token"): Minte export function readSessionTokenFromEnv(env: NodeJS.ProcessEnv = process.env): string | null { const direct = env.CLAUDEMESH_IPC_TOKEN; if (direct && /^[0-9a-f]{64}$/i.test(direct)) return direct.toLowerCase(); - const path = env[ENV_TOKEN_FILE]; + const fromEnvPath = readTokenFile(env[ENV_TOKEN_FILE]); + if (fromEnvPath) return fromEnvPath; + // 1.38.0 (spec 2026-09-26 §2): the env path pointed into a launch tmpdir + // that is gone once the wrapper exits (claude restart / re-exec). Fall + // back to the session's stable token next to its keypair. + return readTokenFile(stableTokenPathFromEnv(env)); +} + +/** `~/.claudemesh/sessions//.token` for the session this + * process belongs to, from CLAUDEMESH_SESSION_ID (+ mesh when known). */ +export function stableTokenPathFromEnv(env: NodeJS.ProcessEnv = process.env): string | null { + const sessionId = env.CLAUDEMESH_SESSION_ID; + if (!sessionId) return null; + const mesh = env.CLAUDEMESH_MESH_SLUG || findSessionMesh(sessionId); + return mesh ? sessionTokenPath(mesh, sessionId) : null; +} + +function readTokenFile(path: string | null | undefined): string | null { if (!path) return null; try { if (!existsSync(path)) return null; diff --git a/apps/cli/tests/unit/keypair-store.test.ts b/apps/cli/tests/unit/keypair-store.test.ts index 9dafee5..db32821 100644 --- a/apps/cli/tests/unit/keypair-store.test.ts +++ b/apps/cli/tests/unit/keypair-store.test.ts @@ -18,7 +18,9 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { + findSessionMesh, loadOrCreateSessionKeypair, + loadSessionKeypair, sessionsDir, } from "../../src/services/session/keypair-store.js"; @@ -65,15 +67,31 @@ describe("loadOrCreateSessionKeypair", () => { expect(a.publicKey).not.toBe(b.publicKey); }); - test("malformed uuid falls back to ephemeral, writes nothing", async () => { + // 1.38.0 (spec 2026-09-26 §4): a non-UUID id (e.g. a claude session + // name) persists under a hashed stem instead of getting a throwaway key + // on every call — the throwaway re-keyed live sessions on daemon restart. + test("non-uuid id persists under a hashed stem, stable across calls", async () => { const a = await loadOrCreateSessionKeypair("flexicar", "not-a-uuid"); const b = await loadOrCreateSessionKeypair("flexicar", "not-a-uuid"); expect(a.publicKey).toMatch(/^[0-9a-f]{64}$/); - // Ephemeral → not persisted → each call is fresh. + expect(a.publicKey).toBe(b.publicKey); + expect(readdirSync(join(dir, "flexicar"))).toEqual([expect.stringMatching(/^id-[0-9a-f]{32}\.json$/)]); + }); + + test("an unusable id (control chars / oversize) still falls back to ephemeral", async () => { + const a = await loadOrCreateSessionKeypair("flexicar", "bad\nid"); + const b = await loadOrCreateSessionKeypair("flexicar", "x".repeat(201)); expect(a.publicKey).not.toBe(b.publicKey); expect(existsSync(join(dir, "flexicar"))).toBe(false); }); + test("loadSessionKeypair never mints", async () => { + expect(loadSessionKeypair("flexicar", UUID_A)).toBeNull(); + const kp = await loadOrCreateSessionKeypair("flexicar", UUID_A); + expect(loadSessionKeypair("flexicar", UUID_A)).toEqual(kp); + expect(findSessionMesh(UUID_A)).toBe("flexicar"); + }); + test("path-traversal slug is rejected (ephemeral, no escape)", async () => { const a = await loadOrCreateSessionKeypair("../../etc", UUID_A); expect(a.publicKey).toMatch(/^[0-9a-f]{64}$/); diff --git a/apps/cli/tests/unit/session-reattach.test.ts b/apps/cli/tests/unit/session-reattach.test.ts new file mode 100644 index 0000000..7ea1965 --- /dev/null +++ b/apps/cli/tests/unit/session-reattach.test.ts @@ -0,0 +1,92 @@ +/** + * Spec 2026-09-26 §2/§4 (bug1 + bug3). A resumed claude re-attaches with + * the same token + key: the registry must only move its pid anchor (no + * WS churn), and the token must be findable after the launch tmpdir is + * gone. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { _resetRegistry, registerSession, resolveToken, setRegistryHooks } from "../../src/daemon/session-registry.js"; +import { readSessionTokenFromEnv } from "../../src/services/session/token.js"; +import { loadOrCreateSessionKeypair, sessionTokenPath } from "../../src/services/session/keypair-store.js"; + +const TOKEN = "e".repeat(64); +const PRESENCE = { + sessionPubkey: "a".repeat(64), + sessionSecretKey: "b".repeat(128), + parentAttestation: { + sessionPubkey: "a".repeat(64), + parentMemberPubkey: "c".repeat(64), + expiresAt: Date.now() + 3_600_000, + signature: "d".repeat(128), + }, +}; + +describe("registry: same-session re-register", () => { + afterEach(() => { _resetRegistry(); setRegistryHooks({}); }); + + test("only moves the pid anchor — no deregister/register (no WS churn)", () => { + const onRegister = vi.fn(); + const onDeregister = vi.fn(); + setRegistryHooks({ onRegister, onDeregister }); + const base = { token: TOKEN, sessionId: "s-1", mesh: "flexicar", displayName: "front", presence: PRESENCE }; + registerSession({ ...base, pid: 111, startTime: "a" }); + registerSession({ ...base, pid: 222, startTime: "b" }); + expect(onRegister).toHaveBeenCalledTimes(1); + expect(onDeregister).not.toHaveBeenCalled(); + expect(resolveToken(TOKEN)?.pid).toBe(222); + }); + + test("a different session key under the same token still replaces", () => { + const onRegister = vi.fn(); + setRegistryHooks({ onRegister }); + const base = { token: TOKEN, sessionId: "s-1", mesh: "flexicar", displayName: "front", pid: 1, startTime: "a" }; + registerSession({ ...base, presence: PRESENCE }); + registerSession({ ...base, presence: { ...PRESENCE, sessionPubkey: "f".repeat(64) } }); + expect(onRegister).toHaveBeenCalledTimes(2); + }); + + test("no absolute TTL: an old entry still resolves", () => { + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); + registerSession({ token: TOKEN, sessionId: "s-1", mesh: "m", displayName: "x", pid: 1, startTime: "a" }); + vi.setSystemTime(now + 3 * 24 * 3_600_000); + expect(resolveToken(TOKEN)).not.toBeNull(); + vi.useRealTimers(); + }); +}); + +describe("token: stable fallback", () => { + let dir: string; + const UUID = "11111111-2222-3333-4444-555555555555"; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "cmh-sessions-")); + process.env.CLAUDEMESH_SESSIONS_DIR = dir; + }); + afterEach(() => { + delete process.env.CLAUDEMESH_SESSIONS_DIR; + rmSync(dir, { recursive: true, force: true }); + }); + + test("the launch tmpdir token is gone → the stable token next to the keypair is used", async () => { + await loadOrCreateSessionKeypair("flexicar", UUID); + const stable = sessionTokenPath("flexicar", UUID)!; + mkdirSync(join(dir, "flexicar"), { recursive: true }); + writeFileSync(stable, TOKEN); + const env = { + CLAUDEMESH_IPC_TOKEN_FILE: join(dir, "gone-tmpdir", "session-token"), + CLAUDEMESH_SESSION_ID: UUID, + } as NodeJS.ProcessEnv; + // mesh discovered from the persisted keypair (1.37 launches set no CLAUDEMESH_MESH_SLUG) + expect(readSessionTokenFromEnv(env)).toBe(TOKEN); + }); + + test("no session id → no fallback", () => { + expect(readSessionTokenFromEnv({ CLAUDEMESH_IPC_TOKEN_FILE: "/nope" } as NodeJS.ProcessEnv)).toBeNull(); + }); +});