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>
93 lines
3.7 KiB
TypeScript
93 lines
3.7 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|