feat(cli): durable session→mesh binding + cross-mesh send (1.36.0)
Fixes the 'live peer looks disconnected' class of bugs. Two layers: ROOT CAUSE — involuntary mesh context loss: The session→mesh binding lived only in the daemon's in-memory registry, so a daemon restart (e.g. `daemon down && up`) wiped it. Every live session then lost its mesh, and CLI commands fell back to an arbitrary default mesh — a peer that never moved looked offline. Fix: persist session bindings to ~/.claudemesh/daemon/sessions.json (secret-free — keypairs reload from the per-session keypair store). On boot the daemon rehydrates each binding whose pid is still alive (with a start-time PID-reuse guard), reloads its keypair, re-signs a parent attestation, and re-registers it — which reconnects its SessionBroker WS. Restarts are now transparent; sessions keep their mesh. DEFENSIVE LAYER — cross-mesh send resolution: `send` without --mesh and several joined meshes returned mesh_required; a prefix under --mesh X resolved against the default mesh's roster, not X's (only the full 64-char pubkey worked). Now a name/prefix is resolved across all joined meshes (or scoped to --mesh): unique match auto-selects its mesh, multi-mesh match asks for --mesh, none gives a clear error. Kills mesh_required for peers on a non-default mesh and fixes P3. Maps to field-report P1/P2/P3. P4 (shared member) left as-is (by design). New: 5 persistence unit tests. Full suite 119/119. Daemon boot verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
100
apps/cli/tests/unit/session-registry-persist.test.ts
Normal file
100
apps/cli/tests/unit/session-registry-persist.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Session-registry persistence (1.36.0) — durable session→mesh bindings.
|
||||
*
|
||||
* A daemon restart used to wipe the in-memory registry, orphaning every
|
||||
* live session's mesh context. Persistence lets the daemon rehydrate on
|
||||
* boot. Verifies:
|
||||
* - register writes a slim record to disk; readPersistedSessions reads it;
|
||||
* - the session SECRET KEY is never written to disk;
|
||||
* - deregister removes the record;
|
||||
* - persistence is off by default (no disk writes until enabled).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
_resetRegistry,
|
||||
deregisterByToken,
|
||||
readPersistedSessions,
|
||||
registerSession,
|
||||
setRegistryPersistence,
|
||||
} from "../../src/daemon/session-registry.js";
|
||||
|
||||
const SECRET = "b".repeat(128);
|
||||
const PRESENCE = {
|
||||
sessionPubkey: "a".repeat(64),
|
||||
sessionSecretKey: SECRET,
|
||||
parentAttestation: {
|
||||
sessionPubkey: "a".repeat(64),
|
||||
parentMemberPubkey: "c".repeat(64),
|
||||
expiresAt: 9_999_999_999,
|
||||
signature: "d".repeat(128),
|
||||
},
|
||||
};
|
||||
|
||||
let dir: string;
|
||||
let file: string;
|
||||
|
||||
beforeEach(() => {
|
||||
_resetRegistry();
|
||||
dir = mkdtempSync(join(tmpdir(), "cm-reg-"));
|
||||
file = join(dir, "sessions.json");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetRegistry();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("registry persistence", () => {
|
||||
test("off by default — no disk writes until enabled", () => {
|
||||
registerSession({ token: "t1", sessionId: "s1", mesh: "flexicar", displayName: "a", pid: process.pid, startTime: "x" });
|
||||
expect(existsSync(file)).toBe(false);
|
||||
});
|
||||
|
||||
test("register persists a slim record; readPersistedSessions round-trips", () => {
|
||||
setRegistryPersistence(file);
|
||||
registerSession({
|
||||
token: "t1", sessionId: "11111111-2222-3333-4444-555555555555",
|
||||
mesh: "flexicar", displayName: "intra-back", pid: process.pid,
|
||||
cwd: "/tmp/x", role: "dev", startTime: "x", presence: PRESENCE,
|
||||
});
|
||||
const rows = readPersistedSessions(file);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
token: "t1", mesh: "flexicar", displayName: "intra-back", cwd: "/tmp/x", role: "dev",
|
||||
});
|
||||
});
|
||||
|
||||
test("session secret key is NEVER written to disk", () => {
|
||||
setRegistryPersistence(file);
|
||||
registerSession({ token: "t1", sessionId: "s1", mesh: "flexicar", displayName: "a", pid: process.pid, startTime: "x", presence: PRESENCE });
|
||||
const raw = readFileSync(file, "utf8");
|
||||
expect(raw).not.toContain(SECRET);
|
||||
expect(raw).not.toContain("sessionSecretKey");
|
||||
expect(raw).not.toContain("parentAttestation");
|
||||
// And the parsed record carries no presence material.
|
||||
expect(readPersistedSessions(file)[0]).not.toHaveProperty("presence");
|
||||
});
|
||||
|
||||
test("deregister removes the record from disk", () => {
|
||||
setRegistryPersistence(file);
|
||||
registerSession({ token: "t1", sessionId: "s1", mesh: "flexicar", displayName: "a", pid: process.pid, startTime: "x" });
|
||||
registerSession({ token: "t2", sessionId: "s2", mesh: "nedas", displayName: "b", pid: process.pid, startTime: "x" });
|
||||
expect(readPersistedSessions(file)).toHaveLength(2);
|
||||
deregisterByToken("t1");
|
||||
const rows = readPersistedSessions(file);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.token).toBe("t2");
|
||||
});
|
||||
|
||||
test("readPersistedSessions tolerates a missing/corrupt file", () => {
|
||||
expect(readPersistedSessions(join(dir, "nope.json"))).toEqual([]);
|
||||
const bad = join(dir, "bad.json");
|
||||
writeFileSync(bad, "{not json");
|
||||
expect(readPersistedSessions(bad)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user