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`). (not a silent `session=null`).
- `send`: when the process is inside a launched session (`CLAUDEMESH_SESSION_ID` set) but the - `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`. 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 - Re-attach (implemented client-side over the existing `POST /v1/sessions/register`, no new route):
persisted keypair (never mints), signs the attestation (same as boot reload), registers with the `services/session/reattach.ts ensureSessionRegistered` loads the session's persisted keypair,
caller's pid. Called by the MCP server at startup when its token is missing/unknown. signs a fresh attestation with the member key and registers with **claude's pid** (nearest
- Token lives in a stable per-session path (`~/.claudemesh/sessions/<mesh>/<uuid>.token`), not the `claude` ancestor). Called by the MCP at startup (always, to move the anchor off the wrapper),
launch tmpdir, so a resumed claude finds it. 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. - 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). - 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 - `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). when a session id exists (non-UUID ids persisted under sha256).
- Boot reload: load-only; a missing key file → skip + log, never mint. - 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. ### §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([ const BOOLEAN_FLAGS = new Set([
"self", "self",
"fanout", "fanout",
"as-member",
"json", // also accepts --json=a,b,c form below "json", // also accepts --json=a,b,c form below
"all", "all",
"yes", "y", "yes", "y",

View File

@@ -16,9 +16,9 @@
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto"; 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 { tmpdir, hostname, homedir } from "node:os";
import { join } from "node:path"; import { basename, dirname, join } from "node:path";
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import { readConfig, getConfigPath } from "~/services/config/facade.js"; import { readConfig, getConfigPath } from "~/services/config/facade.js";
import type { Config, JoinedMesh, GroupEntry } 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; const passSessionIdFlag = !isResume;
let sessionTokenFilePath: string | null = null; let sessionTokenFilePath: string | null = null;
let sessionTokenForCleanup: 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 { try {
const { mintSessionToken, TOKEN_FILE_ENV } = await import("~/services/session/token.js"); 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; sessionTokenFilePath = minted.filePath;
sessionTokenForCleanup = minted.token; sessionTokenForCleanup = minted.token;
@@ -835,15 +851,12 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
}; };
} | undefined; } | undefined;
try { try {
const { generateKeypair } = await import("~/services/crypto/facade.js");
const { signParentAttestation } = await import("~/services/broker/session-hello-sig.js"); const { signParentAttestation } = await import("~/services/broker/session-hello-sig.js");
// Persisted, UUID-anchored keypair so relaunch/--resume reuse the // Persisted keypair anchored on the registered session id, so
// same sessionPubkey (queued DMs route AND decrypt). Falls back to // relaunch/--resume reuse the same sessionPubkey (queued DMs route
// an ephemeral keypair when we couldn't resolve a stable UUID // AND decrypt) and a daemon restart reloads that same key.
// (e.g. --continue with no prior session in this cwd). const sessionKp = await (await import("~/services/session/keypair-store.js"))
const sessionKp = claudeSessionId .loadOrCreateSessionKeypair(mesh.slug, sessionIdForRegister);
? await (await import("~/services/session/keypair-store.js")).loadOrCreateSessionKeypair(mesh.slug, claudeSessionId)
: await generateKeypair();
const att = await signParentAttestation({ const att = await signParentAttestation({
parentMemberPubkey: mesh.pubkey, parentMemberPubkey: mesh.pubkey,
parentSecretKey: mesh.secretKey, 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 // Register with the daemon. Best-effort: a daemon failure here
// means the session falls back to user-level scope, which is fine. // means the session falls back to user-level scope, which is fine.
const { ipc } = await import("~/daemon/ipc/client.js"); const { ipc } = await import("~/daemon/ipc/client.js");
const sessionIdForRegister = claudeSessionId ?? randomUUID();
await ipc({ await ipc({
method: "POST", method: "POST",
path: "/v1/sessions/register", 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"); writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
} catch { /* best effort */ } } catch { /* best effort */ }
} }
// The token's session-token file lives inside tmpDir; rmSync below // 1.38.0: the session token lives next to the session keypair (not
// shreds the secret. The daemon's session reaper notices the // in tmpDir) and is deliberately kept — a claude that outlives this
// launched session's pid is gone within 30s and drops the registry // wrapper re-attaches with it. The daemon's reaper drops the registry
// entry. Explicit DELETE on /v1/sessions is feasible only from an // entry once the registered pid (claude's, set by its MCP) is gone.
// 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.
// Ephemeral config dir (also drops the session-token file) // Ephemeral config dir (also drops the session-token file)
try { try {
rmSync(tmpDir, { recursive: true, force: true }); rmSync(tmpDir, { recursive: true, force: true });
@@ -1095,7 +1104,8 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
...process.env, ...process.env,
CLAUDEMESH_CONFIG_DIR: tmpDir, CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName, CLAUDEMESH_DISPLAY_NAME: displayName,
...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}), CLAUDEMESH_SESSION_ID: sessionIdForRegister,
CLAUDEMESH_MESH_SLUG: mesh.slug,
...(sessionTokenFilePath ? { CLAUDEMESH_IPC_TOKEN_FILE: sessionTokenFilePath } : {}), ...(sessionTokenFilePath ? { CLAUDEMESH_IPC_TOKEN_FILE: sessionTokenFilePath } : {}),
MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000", MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000",
MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000", 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 * (spec 2026-09-26 §3) — the default fan-out was the "messages land on
* the wrong peers" noise. */ * the wrong peers" noise. */
fanout?: boolean; 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> { 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 = 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`."; "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 // Daemon path — preferred when a long-lived daemon is local. UDS at
// ~/.claudemesh/daemon/daemon.sock; ~1ms round-trip; persists outbox // ~/.claudemesh/daemon/daemon.sock; ~1ms round-trip; persists outbox
// across CLI invocations so a `claudemesh send` survives a daemon // 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 // it to a SessionInfo that downstream routes use for default-mesh
// scoping and attribution. // scoping and attribution.
let session: SessionInfo | null = null; let session: SessionInfo | null = null;
let sessionHeaderUnresolved = false;
{ {
const authz = req.headers.authorization ?? ""; const authz = req.headers.authorization ?? "";
const sm = /^ClaudeMesh-Session\s+([0-9a-f]{64})$/i.exec(authz.trim()); 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. */ /** Pick mesh from explicit body/query first, then session default. */
const meshFromCtx = (explicit?: string | null): string | null => const meshFromCtx = (explicit?: string | null): string | null =>
@@ -889,6 +893,15 @@ function makeHandler(opts: {
respond(res, 503, { error: "outbox not initialised" }); respond(res, 503, { error: "outbox not initialised" });
return; 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 { try {
const body = await readJsonBody(req, 256 * 1024); const body = await readJsonBody(req, 256 * 1024);
const parsed = parseSendRequest(body, req.headers["idempotency-key"]); const parsed = parseSendRequest(body, req.headers["idempotency-key"]);

View File

@@ -445,7 +445,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
try { try {
const persisted = readPersistedSessions(DAEMON_PATHS.SESSIONS_FILE); const persisted = readPersistedSessions(DAEMON_PATHS.SESSIONS_FILE);
if (persisted.length > 0) { 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 { signParentAttestation } = await import("~/services/broker/session-hello-sig.js");
const { isPidAlive, getProcessStartTimes } = await import("./process-info.js"); const { isPidAlive, getProcessStartTimes } = await import("./process-info.js");
const liveStartTimes = await getProcessStartTimes(persisted.map((p) => p.pid)).catch(() => new Map<number, string>()); 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); const meshConfig = meshConfigs.get(s.mesh);
if (!meshConfig) continue; // mesh no longer joined if (!meshConfig) continue; // mesh no longer joined
try { 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({ const att = await signParentAttestation({
parentMemberPubkey: meshConfig.pubkey, parentMemberPubkey: meshConfig.pubkey,
parentSecretKey: meshConfig.secretKey, parentSecretKey: meshConfig.secretKey,

View File

@@ -78,7 +78,6 @@ export interface RegistryHooks {
onDeregister?: (info: SessionInfo) => void; onDeregister?: (info: SessionInfo) => void;
} }
const TTL_MS = 24 * 60 * 60 * 1000;
const REAPER_INTERVAL_MS = 5 * 1000; const REAPER_INTERVAL_MS = 5 * 1000;
const byToken = new Map<string, SessionInfo>(); const byToken = new Map<string, SessionInfo>();
@@ -168,6 +167,28 @@ export function setRegistryHooks(next: RegistryHooks): void {
} }
export function registerSession(info: Omit<SessionInfo, "registeredAt">): SessionInfo { 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. // Replace any prior entry under the same sessionId.
const priorToken = bySessionId.get(info.sessionId); const priorToken = bySessionId.get(info.sessionId);
if (priorToken && priorToken !== info.token) { if (priorToken && priorToken !== info.token) {
@@ -216,13 +237,10 @@ export function deregisterByToken(token: string): boolean {
} }
export function resolveToken(token: string): SessionInfo | null { export function resolveToken(token: string): SessionInfo | null {
const entry = byToken.get(token); // 1.38.0: no absolute TTL. Liveness is the reaper's job (pid + start
if (!entry) return null; // time); the old 24 h cap dropped long-running sessions whose pid was
if (Date.now() - entry.registeredAt > TTL_MS) { // alive, and they silently fell back to the member key.
deregisterByToken(token); return byToken.get(token) ?? null;
return null;
}
return entry;
} }
export function listSessions(): SessionInfo[] { export function listSessions(): SessionInfo[] {
@@ -238,7 +256,6 @@ async function reapDead(): Promise<void> {
const dead: string[] = []; const dead: string[] = [];
const survivors: Array<[string, SessionInfo]> = []; const survivors: Array<[string, SessionInfo]> = [];
for (const [token, info] of entries) { 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; } if (!isPidAlive(info.pid)) { dead.push(token); continue; }
survivors.push([token, info]); survivors.push([token, info]);
} }

View File

@@ -91,11 +91,17 @@ Peer (resource form, recommended)
claudemesh peer bans list banned members (alias: bans) claudemesh peer bans list banned members (alias: bans)
claudemesh peer verify [p] safety numbers (alias: verify) 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) Message (resource form)
claudemesh message send <to> <m> send a message (alias: send) claudemesh message send <to> <m> send a message (alias: send)
flags: [--priority now|next|low] [--mesh <slug>] flags: [--priority now|next|low] [--mesh <slug>]
[--self] (allow targeting your own member/session pubkey; [--self] (allow targeting your own member/session pubkey;
fans out to every sibling session of your member) 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) [--json] (machine-readable result)
claudemesh message inbox read persisted inbox (alias: inbox) claudemesh message inbox read persisted inbox (alias: inbox)
flags: [--mesh <slug>] [--limit N] [--unread] [--json] flags: [--mesh <slug>] [--limit N] [--unread] [--json]
@@ -403,7 +409,7 @@ async function main(): Promise<void> {
// Messaging // 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 "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": { case "inbox": {
const sub = positionals[0]; const sub = positionals[0];
if (sub === "flush") { if (sub === "flush") {
@@ -554,6 +560,17 @@ async function main(): Promise<void> {
// The legacy verbs (`send`, `peers`, `kick`, `remember`, ...) keep // The legacy verbs (`send`, `peers`, `kick`, `remember`, ...) keep
// working so old scripts never break. Spec: 2026-05-02 commitment #2. // 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": { case "peer": {
const sub = positionals[0]; const sub = positionals[0];
const f = { mesh: flags.mesh as string, json: flags.json as boolean | string | undefined, all: !!flags.all }; 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": { case "message": {
const sub = positionals[0]; 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") { else if (sub === "inbox") {
const sub2 = positionals[1]; const sub2 = positionals[1];
if (sub2 === "flush") { 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 // 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 // capture it in the closure since the MCP runs for the lifetime of
// the session; the env var doesn't rotate mid-process. // 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 { readSessionTokenFromEnv } = await import("~/services/session/token.js");
const sessionTokenForSeen = readSessionTokenFromEnv(); 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 * --no-daemon short-circuits to false; --strict's enforcement lives at
* the cold-path entry point (`withMesh` in commands/connect.ts) so a * the cold-path entry point (`withMesh` in commands/connect.ts) so a
* single chokepoint covers every verb. */ * single chokepoint covers every verb. */
async function daemonReachable(): Promise<boolean> { export async function daemonReachable(): Promise<boolean> {
const policy = getDaemonPolicy(); const policy = getDaemonPolicy();
if (policy.mode === "no-daemon") return false; if (policy.mode === "no-daemon") return false;
const res = await ensureDaemonReady({ noAutoSpawn: false }); const res = await ensureDaemonReady({ noAutoSpawn: false });

View File

@@ -28,8 +28,8 @@
* delivery. `CLAUDEMESH_SESSIONS_DIR` overrides the root for tests. * delivery. `CLAUDEMESH_SESSIONS_DIR` overrides the root for tests.
*/ */
import { randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; 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 { 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 /** Read a persisted keypair, returning null (never throwing) when the
@@ -98,7 +138,7 @@ export async function loadOrCreateSessionKeypair(
): Promise<Ed25519Keypair> { ): Promise<Ed25519Keypair> {
// Defensive validation: these compose into a filesystem path, so a // Defensive validation: these compose into a filesystem path, so a
// malformed slug/uuid must never escape the sessions dir. // 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(); 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 { export function _resetSessionCache(): void {
cached = undefined; 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 { randomBytes } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { findSessionMesh, sessionTokenPath } from "./keypair-store.js";
const ENV_TOKEN_FILE = "CLAUDEMESH_IPC_TOKEN_FILE"; const ENV_TOKEN_FILE = "CLAUDEMESH_IPC_TOKEN_FILE";
export interface MintedToken { 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 { export function readSessionTokenFromEnv(env: NodeJS.ProcessEnv = process.env): string | null {
const direct = env.CLAUDEMESH_IPC_TOKEN; const direct = env.CLAUDEMESH_IPC_TOKEN;
if (direct && /^[0-9a-f]{64}$/i.test(direct)) return direct.toLowerCase(); 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; if (!path) return null;
try { try {
if (!existsSync(path)) return null; 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 { afterEach, beforeEach, describe, expect, test } from "vitest";
import { import {
findSessionMesh,
loadOrCreateSessionKeypair, loadOrCreateSessionKeypair,
loadSessionKeypair,
sessionsDir, sessionsDir,
} from "../../src/services/session/keypair-store.js"; } from "../../src/services/session/keypair-store.js";
@@ -65,15 +67,31 @@ describe("loadOrCreateSessionKeypair", () => {
expect(a.publicKey).not.toBe(b.publicKey); 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 a = await loadOrCreateSessionKeypair("flexicar", "not-a-uuid");
const b = await loadOrCreateSessionKeypair("flexicar", "not-a-uuid"); const b = await loadOrCreateSessionKeypair("flexicar", "not-a-uuid");
expect(a.publicKey).toMatch(/^[0-9a-f]{64}$/); 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(a.publicKey).not.toBe(b.publicKey);
expect(existsSync(join(dir, "flexicar"))).toBe(false); 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 () => { test("path-traversal slug is rejected (ephemeral, no escape)", async () => {
const a = await loadOrCreateSessionKeypair("../../etc", UUID_A); const a = await loadOrCreateSessionKeypair("../../etc", UUID_A);
expect(a.publicKey).toMatch(/^[0-9a-f]{64}$/); 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();
});
});