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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
34
apps/cli/src/commands/session-reattach.ts
Normal file
34
apps/cli/src/commands/session-reattach.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
127
apps/cli/src/services/session/reattach.ts
Normal file
127
apps/cli/src/services/session/reattach.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}$/);
|
||||
|
||||
92
apps/cli/tests/unit/session-reattach.test.ts
Normal file
92
apps/cli/tests/unit/session-reattach.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user