fix(cli): a resumed session keeps its registration and its key (bug1, bug3)

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/<mesh>/<stem>.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 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-09-26 21:04:43 +01:00
parent 9075ae05df
commit 3fab89c61a
17 changed files with 504 additions and 48 deletions

View File

@@ -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/<mesh>/<uuid>.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/<mesh>/<stem>.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.

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<void> {
@@ -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

View File

@@ -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<number> {
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;
}
}

View File

@@ -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"]);

View File

@@ -445,7 +445,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
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<number, string>());
@@ -459,7 +459,20 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
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,

View File

@@ -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<string, SessionInfo>();
@@ -168,6 +167,28 @@ export function setRegistryHooks(next: RegistryHooks): void {
}
export function registerSession(info: Omit<SessionInfo, "registeredAt">): 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<void> {
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]);
}

View File

@@ -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 <slug>]
Message (resource form)
claudemesh message send <to> <m> send a message (alias: send)
flags: [--priority now|next|low] [--mesh <slug>]
[--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 <slug>] [--limit N] [--unread] [--json]
@@ -403,7 +409,7 @@ async function main(): Promise<void> {
// 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<void> {
// 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 <slug>] [--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<void> {
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") {

View File

@@ -427,6 +427,21 @@ export async function startMcpServer(): Promise<void> {
// 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();

View File

@@ -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<boolean> {
export async function daemonReachable(): Promise<boolean> {
const policy = getDaemonPolicy();
if (policy.mode === "no-daemon") return false;
const res = await ensureDaemonReady({ noAutoSpawn: false });

View File

@@ -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-<sha256/32>` 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<Ed25519Keypair> {
// 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();
}

View File

@@ -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<ReattachOutcome> {
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 };
}

View File

@@ -62,3 +62,8 @@ export async function getSessionInfo(): Promise<ResolvedSession | null> {
export function _resetSessionCache(): void {
cached = undefined;
}
/** Drop the per-invocation cache — after a re-attach the answer changes. */
export function resetSessionInfoCache(): void {
cached = undefined;
}

View File

@@ -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/<mesh>/<stem>.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;

View File

@@ -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}$/);

View File

@@ -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();
});
});