Compare commits
6 Commits
f119226b98
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b735a183 | ||
|
|
213a6b37cc | ||
|
|
c747040e0d | ||
|
|
71401b1d50 | ||
|
|
2b88784005 | ||
|
|
589d050f81 |
4
SPEC.md
4
SPEC.md
@@ -28,7 +28,7 @@ A peer is a Claude Code session connected to a mesh. Ephemeral — comes and goe
|
|||||||
Two-layer identity:
|
Two-layer identity:
|
||||||
|
|
||||||
- **Member identity** — permanent, created by `claudemesh join`. Keypair stored in `~/.claudemesh/config.json`. Proves authorization to connect.
|
- **Member identity** — permanent, created by `claudemesh join`. Keypair stored in `~/.claudemesh/config.json`. Proves authorization to connect.
|
||||||
- **Session identity** — ephemeral, generated on every `claudemesh launch`. Fresh ed25519 keypair per session. Provides routing and E2E encryption. Two sessions from the same member have distinct session keys — they can message each other.
|
- **Session identity** — anchored on Claude Code's session UUID (the same identity `--resume` is built on). An ed25519 keypair is generated once per `(mesh, session UUID)` and persisted under `~/.claudemesh/sessions/<mesh>/<uuid>.json`, so relaunching or resuming the same session reuses the same `sessionPubkey`. Provides routing and E2E encryption. Two distinct sessions from the same member have distinct session keys — they can message each other. Because a DM is sealed to the recipient's `sessionPubkey`, a stable key is what lets queued messages both route to and decrypt on the returning session; the broker enforces one live presence per session pubkey.
|
||||||
|
|
||||||
### Peer attributes
|
### Peer attributes
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ Two-layer identity:
|
|||||||
| groups | `--groups` flag, wizard, or `join_group` | No | Routing labels with optional per-group role |
|
| groups | `--groups` flag, wizard, or `join_group` | No | Routing labels with optional per-group role |
|
||||||
| status | Hook-driven | No | idle / working / dnd |
|
| status | Hook-driven | No | idle / working / dnd |
|
||||||
| summary | `set_summary` tool call | No | 1-2 sentence description of current work |
|
| summary | `set_summary` tool call | No | 1-2 sentence description of current work |
|
||||||
| sessionPubkey | Generated on connect | No | Ephemeral ed25519 pubkey for routing + crypto |
|
| sessionPubkey | Persisted per `(mesh, session UUID)` | Yes (per session UUID) | ed25519 pubkey for routing + crypto; stable across relaunch/`--resume` |
|
||||||
| memberId | From `claudemesh join` | Yes | Permanent mesh membership identity |
|
| memberId | From `claudemesh join` | Yes | Permanent mesh membership identity |
|
||||||
|
|
||||||
### Launch
|
### Launch
|
||||||
|
|||||||
@@ -1635,6 +1635,30 @@ function decMeshCount(meshId: string): void {
|
|||||||
metrics.connectionsActive.set(connections.size);
|
metrics.connectionsActive.set(connections.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a connection from the in-memory map AND keep the per-mesh
|
||||||
|
* connection counter balanced.
|
||||||
|
*
|
||||||
|
* THE counter (`connectionsPerMesh`) is incremented once per successful
|
||||||
|
* hello (incMeshCount). Before this helper, it was decremented ONLY in
|
||||||
|
* evictPresenceFully — so every other removal path (session-id dedup on
|
||||||
|
* reconnect, kick, ban) deleted the entry from `connections` without
|
||||||
|
* decrementing, leaking +1 each time. Over many reconnects the counter
|
||||||
|
* crept up to MAX_CONNECTIONS_PER_MESH and the mesh got bricked with
|
||||||
|
* `capacity` rejections until the broker process restarted (the counter
|
||||||
|
* is in-memory). Routing every non-evict removal through here makes the
|
||||||
|
* counter track `connections` membership exactly. The replaced socket's
|
||||||
|
* own close handler then no-ops (entry already gone), so the decrement
|
||||||
|
* happens exactly once. Returns true if an entry was actually removed.
|
||||||
|
*/
|
||||||
|
function dropConnection(presenceId: string): boolean {
|
||||||
|
const c = connections.get(presenceId);
|
||||||
|
if (!c) return false; // already removed (e.g. evicted) — don't double-decrement
|
||||||
|
connections.delete(presenceId);
|
||||||
|
decMeshCount(c.meshId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function sendError(
|
function sendError(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
code: string,
|
code: string,
|
||||||
@@ -1954,7 +1978,7 @@ async function handleHello(
|
|||||||
if (oldConn.meshId === hello.meshId && oldConn.sessionId === hello.sessionId) {
|
if (oldConn.meshId === hello.meshId && oldConn.sessionId === hello.sessionId) {
|
||||||
log.info("hello dedup", { old_presence: oldPid, session_id: hello.sessionId });
|
log.info("hello dedup", { old_presence: oldPid, session_id: hello.sessionId });
|
||||||
try { oldConn.ws.close(1000, "session_replaced"); } catch { /* already dead */ }
|
try { oldConn.ws.close(1000, "session_replaced"); } catch { /* already dead */ }
|
||||||
connections.delete(oldPid);
|
dropConnection(oldPid);
|
||||||
void disconnectPresence(oldPid);
|
void disconnectPresence(oldPid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2191,23 +2215,38 @@ async function handleSessionHello(
|
|||||||
// session leave.
|
// session leave.
|
||||||
for (const [pid, oldConn] of connections) {
|
for (const [pid, oldConn] of connections) {
|
||||||
if (oldConn.meshId !== hello.meshId) continue;
|
if (oldConn.meshId !== hello.meshId) continue;
|
||||||
if (oldConn.leaseState !== "offline") continue;
|
|
||||||
if (oldConn.sessionPubkey !== hello.sessionPubkey) continue;
|
if (oldConn.sessionPubkey !== hello.sessionPubkey) continue;
|
||||||
|
|
||||||
|
// Same sessionPubkey = same logical session. The CLI now anchors the
|
||||||
|
// session keypair on Claude Code's session UUID and persists it, so a
|
||||||
|
// matching pubkey is always the same peer relaunching/resuming — never
|
||||||
|
// a coincidental collision. Reattach whether the old lease is in its
|
||||||
|
// 90s grace window OR still nominally "online" (a duplicate/relaunch
|
||||||
|
// that raced ahead of the old socket's close). The new WS is
|
||||||
|
// authoritative: cancel any eviction timer, close the stale socket if
|
||||||
|
// it differs, swap in the new WS, restore online. This is the "one
|
||||||
|
// presence per session pubkey" invariant — it kills the same-name
|
||||||
|
// ghost that used to win queued-DM claim races.
|
||||||
|
const wasState = oldConn.leaseState;
|
||||||
if (oldConn.evictionTimer) {
|
if (oldConn.evictionTimer) {
|
||||||
clearTimeout(oldConn.evictionTimer);
|
clearTimeout(oldConn.evictionTimer);
|
||||||
oldConn.evictionTimer = null;
|
oldConn.evictionTimer = null;
|
||||||
}
|
}
|
||||||
|
if (oldConn.ws !== ws) {
|
||||||
|
try { oldConn.ws.close(1000, "session_replaced"); } catch { /* already dead */ }
|
||||||
|
}
|
||||||
oldConn.ws = ws;
|
oldConn.ws = ws;
|
||||||
oldConn.leaseState = "online";
|
oldConn.leaseState = "online";
|
||||||
oldConn.leaseUntil = 0;
|
oldConn.leaseUntil = 0;
|
||||||
oldConn.lastPongAt = Date.now();
|
oldConn.lastPongAt = Date.now();
|
||||||
// Refresh mutable fields from the new hello.
|
// Refresh mutable fields from the new hello.
|
||||||
|
oldConn.sessionId = hello.sessionId;
|
||||||
oldConn.cwd = hello.cwd;
|
oldConn.cwd = hello.cwd;
|
||||||
if (hello.displayName) oldConn.displayName = hello.displayName;
|
if (hello.displayName) oldConn.displayName = hello.displayName;
|
||||||
log.info("session_hello reattach (lease)", {
|
log.info("session_hello reattach", {
|
||||||
presence_id: pid,
|
presence_id: pid,
|
||||||
session_pubkey: hello.sessionPubkey.slice(0, 12),
|
session_pubkey: hello.sessionPubkey.slice(0, 12),
|
||||||
|
was: wasState,
|
||||||
});
|
});
|
||||||
void restorePresence(pid);
|
void restorePresence(pid);
|
||||||
void maybePushQueuedMessages(pid);
|
void maybePushQueuedMessages(pid);
|
||||||
@@ -2226,7 +2265,7 @@ async function handleSessionHello(
|
|||||||
if (oldConn.meshId === hello.meshId && oldConn.sessionId === hello.sessionId) {
|
if (oldConn.meshId === hello.meshId && oldConn.sessionId === hello.sessionId) {
|
||||||
log.info("session_hello dedup", { old_presence: oldPid, session_id: hello.sessionId });
|
log.info("session_hello dedup", { old_presence: oldPid, session_id: hello.sessionId });
|
||||||
try { oldConn.ws.close(1000, "session_replaced"); } catch { /* already dead */ }
|
try { oldConn.ws.close(1000, "session_replaced"); } catch { /* already dead */ }
|
||||||
connections.delete(oldPid);
|
dropConnection(oldPid);
|
||||||
void disconnectPresence(oldPid);
|
void disconnectPresence(oldPid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4917,7 +4956,7 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try { peer.ws.close(closeCode, closeReason); } catch {}
|
try { peer.ws.close(closeCode, closeReason); } catch {}
|
||||||
connections.delete(pid);
|
dropConnection(pid);
|
||||||
void disconnectPresence(pid);
|
void disconnectPresence(pid);
|
||||||
affected.push(peer.displayName || pid);
|
affected.push(peer.displayName || pid);
|
||||||
}
|
}
|
||||||
@@ -4932,7 +4971,7 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try { peer.ws.close(closeCode, `${closeReason}_stale`); } catch {}
|
try { peer.ws.close(closeCode, `${closeReason}_stale`); } catch {}
|
||||||
connections.delete(pid);
|
dropConnection(pid);
|
||||||
void disconnectPresence(pid);
|
void disconnectPresence(pid);
|
||||||
affected.push(peer.displayName || pid);
|
affected.push(peer.displayName || pid);
|
||||||
}
|
}
|
||||||
@@ -4946,7 +4985,7 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try { peer.ws.close(closeCode, closeReason); } catch {}
|
try { peer.ws.close(closeCode, closeReason); } catch {}
|
||||||
connections.delete(pid);
|
dropConnection(pid);
|
||||||
void disconnectPresence(pid);
|
void disconnectPresence(pid);
|
||||||
affected.push(peer.displayName || pid);
|
affected.push(peer.displayName || pid);
|
||||||
}
|
}
|
||||||
@@ -5002,7 +5041,7 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
for (const [pid, peer] of connections) {
|
for (const [pid, peer] of connections) {
|
||||||
if (peer.meshId === conn.meshId && peer.memberPubkey === targetMember.peerPubkey) {
|
if (peer.meshId === conn.meshId && peer.memberPubkey === targetMember.peerPubkey) {
|
||||||
try { peer.ws.close(4002, "banned"); } catch {}
|
try { peer.ws.close(4002, "banned"); } catch {}
|
||||||
connections.delete(pid);
|
dropConnection(pid);
|
||||||
void disconnectPresence(pid);
|
void disconnectPresence(pid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "1.34.17",
|
"version": "1.37.0",
|
||||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -42,6 +42,37 @@ export interface LaunchFlags {
|
|||||||
quiet?: boolean;
|
quiet?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the most-recently-active Claude Code session UUID for a cwd by
|
||||||
|
* inspecting `~/.claude/projects/<encoded-cwd>/<uuid>.jsonl`. Claude Code
|
||||||
|
* encodes the project dir as the absolute path with every `/` → `-`.
|
||||||
|
*
|
||||||
|
* Used by `--continue` (which otherwise gives us no UUID to anchor on) so
|
||||||
|
* a continued session re-attaches to the same claudemesh peer it last
|
||||||
|
* represented. Returns undefined when the project dir is absent/empty —
|
||||||
|
* the caller then falls back to an ephemeral identity.
|
||||||
|
*/
|
||||||
|
function resolveLatestSessionUuid(cwd: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const slug = cwd.replace(/\//g, "-");
|
||||||
|
const dir = join(homedir(), ".claude", "projects", slug);
|
||||||
|
if (!existsSync(dir)) return undefined;
|
||||||
|
const uuidRe = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
||||||
|
let newest: { id: string; mtime: number } | null = null;
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const m = uuidRe.exec(entry);
|
||||||
|
if (!m) continue;
|
||||||
|
try {
|
||||||
|
const mtime = statSync(join(dir, entry)).mtimeMs;
|
||||||
|
if (!newest || mtime > newest.mtime) newest = { id: m[1]!, mtime };
|
||||||
|
} catch { /* file vanished mid-scan — skip */ }
|
||||||
|
}
|
||||||
|
return newest?.id;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Interactive mesh picker ---
|
// --- Interactive mesh picker ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -732,10 +763,16 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
|||||||
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
|
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
|
||||||
messageMode,
|
messageMode,
|
||||||
};
|
};
|
||||||
|
// mode 0600: this config embeds the mesh keypair (secret key). Written
|
||||||
|
// without a mode it lands at 0644 (world/group-readable) — which
|
||||||
|
// `claudemesh status` flags as "perms 0644 — expected 0600". The
|
||||||
|
// enclosing tmpDir is already 0700, but lock the file down too so the
|
||||||
|
// secret is never world-readable even for a moment. The file is freshly
|
||||||
|
// created in a new mkdtemp dir, so the mode applies on create.
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(tmpDir, "config.json"),
|
join(tmpDir, "config.json"),
|
||||||
JSON.stringify(sessionConfig, null, 2) + "\n",
|
JSON.stringify(sessionConfig, null, 2) + "\n",
|
||||||
"utf-8",
|
{ encoding: "utf-8", mode: 0o600 },
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4b. Mint a per-session IPC token, persist it under tmpDir, and
|
// 4b. Mint a per-session IPC token, persist it under tmpDir, and
|
||||||
@@ -754,8 +791,28 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
|||||||
// the TDZ → ReferenceError swallowed by the surrounding catch.
|
// the TDZ → ReferenceError swallowed by the surrounding catch.
|
||||||
// The IPC registration has been silently failing every launch
|
// The IPC registration has been silently failing every launch
|
||||||
// since 1.29.0. Hoist the declaration up so it actually runs.
|
// since 1.29.0. Hoist the declaration up so it actually runs.
|
||||||
|
// Session identity is anchored on Claude Code's session UUID — the
|
||||||
|
// stable thing `--resume` is built on — so the same logical peer keeps
|
||||||
|
// one identity (and one persisted keypair) across relaunches:
|
||||||
|
// - fresh launch: mint a UUID and force it on claude via --session-id.
|
||||||
|
// - --resume V: register V (the returning peer), let claude resume it.
|
||||||
|
// - --continue: resolve the most-recent session UUID in this cwd so
|
||||||
|
// we re-attach to the same peer instead of minting a
|
||||||
|
// throwaway id (the bug that orphaned queued DMs and
|
||||||
|
// spawned same-name ghosts on every relaunch).
|
||||||
const isResume = args.resume !== null || args.continueSession;
|
const isResume = args.resume !== null || args.continueSession;
|
||||||
const claudeSessionId = isResume ? undefined : randomUUID();
|
let claudeSessionId: string | undefined;
|
||||||
|
if (args.resume) {
|
||||||
|
claudeSessionId = args.resume;
|
||||||
|
} else if (args.continueSession) {
|
||||||
|
claudeSessionId = resolveLatestSessionUuid(process.cwd());
|
||||||
|
} else {
|
||||||
|
claudeSessionId = randomUUID();
|
||||||
|
}
|
||||||
|
// Only fresh launches may dictate the UUID via --session-id; --resume
|
||||||
|
// and --continue carry their own session selection and claude rejects
|
||||||
|
// --session-id alongside them.
|
||||||
|
const passSessionIdFlag = !isResume;
|
||||||
let sessionTokenFilePath: string | null = null;
|
let sessionTokenFilePath: string | null = null;
|
||||||
let sessionTokenForCleanup: string | null = null;
|
let sessionTokenForCleanup: string | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -780,7 +837,13 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
|||||||
try {
|
try {
|
||||||
const { generateKeypair } = await import("~/services/crypto/facade.js");
|
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");
|
||||||
const sessionKp = await generateKeypair();
|
// 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();
|
||||||
const att = await signParentAttestation({
|
const att = await signParentAttestation({
|
||||||
parentMemberPubkey: mesh.pubkey,
|
parentMemberPubkey: mesh.pubkey,
|
||||||
parentSecretKey: mesh.secretKey,
|
parentSecretKey: mesh.secretKey,
|
||||||
@@ -917,7 +980,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
|||||||
const claudeArgs = [
|
const claudeArgs = [
|
||||||
"--dangerously-load-development-channels",
|
"--dangerously-load-development-channels",
|
||||||
"server:claudemesh",
|
"server:claudemesh",
|
||||||
...(claudeSessionId ? ["--session-id", claudeSessionId] : []),
|
...(passSessionIdFlag && claudeSessionId ? ["--session-id", claudeSessionId] : []),
|
||||||
...(args.resume ? ["--resume", args.resume] : []),
|
...(args.resume ? ["--resume", args.resume] : []),
|
||||||
...(args.continueSession ? ["--continue"] : []),
|
...(args.continueSession ? ["--continue"] : []),
|
||||||
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
|
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
|
||||||
|
|||||||
@@ -241,14 +241,6 @@ export async function runPeers(flags: PeersFlags): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const peers = await listPeersForMesh(slug);
|
const peers = await listPeersForMesh(slug);
|
||||||
|
|
||||||
if (wantsJson) {
|
|
||||||
const projected = fieldList
|
|
||||||
? peers.map((p) => projectFields(p, fieldList))
|
|
||||||
: peers;
|
|
||||||
allJson.push({ mesh: slug, peers: projected });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide control-plane rows by default — they're infrastructure
|
// Hide control-plane rows by default — they're infrastructure
|
||||||
// (daemon-WS member-keyed presence), not interactive peers, and
|
// (daemon-WS member-keyed presence), not interactive peers, and
|
||||||
// they confused users into thinking the daemon counted as a
|
// they confused users into thinking the daemon counted as a
|
||||||
@@ -258,10 +250,22 @@ export async function runPeers(flags: PeersFlags): Promise<void> {
|
|||||||
// 2026-05-04). annotateSelf() filled in 'session' for older
|
// 2026-05-04). annotateSelf() filled in 'session' for older
|
||||||
// brokers that don't emit peerRole yet, so this filter is
|
// brokers that don't emit peerRole yet, so this filter is
|
||||||
// backwards-compatible by construction — legacy rows show up.
|
// backwards-compatible by construction — legacy rows show up.
|
||||||
|
//
|
||||||
|
// Applied to JSON too (was human-output-only): `peer list --json`
|
||||||
|
// leaking the daemon's control-plane row is what made the daemon
|
||||||
|
// look like an addressable peer and sent DMs into a black hole.
|
||||||
const visible = flags.all
|
const visible = flags.all
|
||||||
? peers
|
? peers
|
||||||
: peers.filter((p) => p.peerRole !== "control-plane");
|
: peers.filter((p) => p.peerRole !== "control-plane");
|
||||||
|
|
||||||
|
if (wantsJson) {
|
||||||
|
const projected = fieldList
|
||||||
|
? visible.map((p) => projectFields(p, fieldList))
|
||||||
|
: visible;
|
||||||
|
allJson.push({ mesh: slug, peers: projected });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Sort: this-session first, then your-other-sessions, then real
|
// Sort: this-session first, then your-other-sessions, then real
|
||||||
// peers. Within each group, idle/working ahead of dnd. Inside the
|
// peers. Within each group, idle/working ahead of dnd. Inside the
|
||||||
// groups, leave broker order. The point is: when you run peer
|
// groups, leave broker order. The point is: when you run peer
|
||||||
|
|||||||
@@ -40,63 +40,98 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
|
|||||||
: flags.priority === "low" ? "low"
|
: flags.priority === "low" ? "low"
|
||||||
: "next";
|
: "next";
|
||||||
|
|
||||||
// Resolve which mesh to use. With --mesh, target it directly.
|
// Resolve which mesh to use. With --mesh, target it directly. Without,
|
||||||
// Without, use first joined mesh — same default as withMesh.
|
// use the only joined mesh, else leave null and let target resolution
|
||||||
|
// below discover the right mesh from where the peer actually lives.
|
||||||
const config = readConfig();
|
const config = readConfig();
|
||||||
const meshSlug =
|
let meshSlug =
|
||||||
flags.mesh ??
|
flags.mesh ??
|
||||||
(config.meshes.length === 1 ? config.meshes[0]!.slug : null);
|
(config.meshes.length === 1 ? config.meshes[0]!.slug : null);
|
||||||
|
|
||||||
// 1.31.6: hex-prefix resolution. If `to` looks like hex but isn't a
|
// Cross-mesh target resolution (1.36.0). A direct send to a hex prefix
|
||||||
// full 64-char pubkey, resolve it against the peer list and replace
|
// or display name is resolved against the peer rosters so the CLI:
|
||||||
// it with the matching full pubkey. The broker stores `targetSpec`
|
// - expands a prefix/name to the full session pubkey (the broker's
|
||||||
// verbatim and the drain query at apps/broker/src/broker.ts:2408
|
// drain matches only full pubkeys — a bare prefix would queue but
|
||||||
// matches only on full pubkeys, so a 16-hex prefix would queue
|
// never fetch: sender saw "sent", recipient saw nothing);
|
||||||
// successfully but never fetch — sender saw "sent", recipient saw
|
// - DISCOVERS which joined mesh the target is on when no --mesh was
|
||||||
// nothing. Resolving here makes the CLI's prefix UX work end-to-end
|
// given and several meshes are joined. Previously this returned
|
||||||
// and surfaces ambiguous / unmatched prefixes with a clear error
|
// `mesh_required` and a live peer on a non-default mesh looked
|
||||||
// instead of a silent drop.
|
// "disconnected". We now scan every joined mesh's roster and, if
|
||||||
if (
|
// the target resolves in exactly one, auto-select that mesh.
|
||||||
!to.startsWith("@") &&
|
// With --mesh (or a single joined mesh) the scan is scoped to that one
|
||||||
!to.startsWith("#") &&
|
// mesh, so `send --mesh X <prefix>` resolves against X's roster — not
|
||||||
to !== "*" &&
|
// the default mesh (the bug where only the full 64-char pubkey worked).
|
||||||
/^[0-9a-f]{4,63}$/i.test(to)
|
const isDirect = !to.startsWith("@") && !to.startsWith("#") && to !== "*";
|
||||||
) {
|
const isFullPubkey = /^[0-9a-f]{64}$/i.test(to);
|
||||||
try {
|
const isPrefix = /^[0-9a-f]{4,63}$/i.test(to);
|
||||||
|
const isName = isDirect && !isFullPubkey && !isPrefix;
|
||||||
|
|
||||||
|
if (isDirect && (isPrefix || isName || (isFullPubkey && !meshSlug))) {
|
||||||
const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js");
|
const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js");
|
||||||
const peers = (await tryListPeersViaDaemon()) ?? [];
|
const searchSlugs = meshSlug ? [meshSlug] : config.meshes.map((m) => m.slug);
|
||||||
const lower = to.toLowerCase();
|
const lower = to.toLowerCase();
|
||||||
const matches = peers.filter((p) => {
|
let daemonReachable = false;
|
||||||
const pk = (p as { pubkey?: string }).pubkey ?? "";
|
type Hit = { slug: string; pubkey: string; displayName: string };
|
||||||
const mpk = (p as { memberPubkey?: string }).memberPubkey ?? "";
|
const matches: Hit[] = [];
|
||||||
return pk.toLowerCase().startsWith(lower) || mpk.toLowerCase().startsWith(lower);
|
for (const slug of searchSlugs) {
|
||||||
});
|
const peers = await tryListPeersViaDaemon(slug);
|
||||||
if (matches.length === 0) {
|
if (peers === null) continue; // daemon unreachable for this query
|
||||||
render.err(`No peer matches hex prefix "${to}".`);
|
daemonReachable = true;
|
||||||
const names = peers
|
for (const p of peers) {
|
||||||
.map((p) => (p as { displayName?: string }).displayName)
|
// Never resolve a name/prefix to a control-plane daemon row — it's
|
||||||
.filter(Boolean)
|
// infrastructure, not an addressable peer, and matching it sends a
|
||||||
.join(", ");
|
// DM that the daemon swallows. (peerRole is the reliable marker;
|
||||||
if (names) render.hint(`online: ${names}`);
|
// the daemon's own row is control-plane.)
|
||||||
|
if ((p as { peerRole?: string }).peerRole === "control-plane") continue;
|
||||||
|
const pk = ((p as { pubkey?: string }).pubkey ?? "").toLowerCase();
|
||||||
|
const mpk = ((p as { memberPubkey?: string }).memberPubkey ?? "").toLowerCase();
|
||||||
|
const dn = (p as { displayName?: string }).displayName ?? "?";
|
||||||
|
const hit = isName
|
||||||
|
? dn.toLowerCase() === lower
|
||||||
|
: pk.startsWith(lower) || mpk.startsWith(lower);
|
||||||
|
if (hit) matches.push({ slug, pubkey: (p as { pubkey?: string }).pubkey ?? "", displayName: dn });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only act on a reachable daemon. If it was down for every query, fall
|
||||||
|
// through to the cold path, which opens its own WS and resolves names.
|
||||||
|
if (daemonReachable) {
|
||||||
|
const byPubkey = new Map<string, Hit>();
|
||||||
|
for (const m of matches) if (!byPubkey.has(m.pubkey)) byPubkey.set(m.pubkey, m);
|
||||||
|
const uniq = [...byPubkey.values()];
|
||||||
|
const meshesHit = [...new Set(uniq.map((m) => m.slug))];
|
||||||
|
|
||||||
|
if (uniq.length === 0) {
|
||||||
|
// For a full pubkey we couldn't locate, keep going — the user gave
|
||||||
|
// a complete key and the daemon send will surface a clear error.
|
||||||
|
if (!isFullPubkey) {
|
||||||
|
render.err(`No peer matches "${to}"${flags.mesh ? ` on mesh "${flags.mesh}"` : " on any joined mesh"}.`);
|
||||||
|
render.hint("Check `claudemesh peer list` (add --mesh <slug> to scope).");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (matches.length > 1) {
|
} else if (uniq.length > 1) {
|
||||||
const candidates = matches
|
if (meshesHit.length > 1 && !meshSlug) {
|
||||||
.map((p) => {
|
// Target lives on several meshes — disambiguate by mesh, not prefix.
|
||||||
const pk = (p as { pubkey?: string }).pubkey ?? "";
|
const where = uniq
|
||||||
const dn = (p as { displayName?: string }).displayName ?? "?";
|
.map((m) => `${m.displayName} ${m.pubkey.slice(0, 12)}… @${m.slug}`)
|
||||||
return `${dn} ${pk.slice(0, 16)}…`;
|
|
||||||
})
|
|
||||||
.join(", ");
|
.join(", ");
|
||||||
render.err(`Ambiguous hex prefix "${to}" — matches ${matches.length} peers.`);
|
render.err(`"${to}" matches peers on ${meshesHit.length} meshes — pick one with --mesh <slug>.`);
|
||||||
|
render.hint(`candidates: ${where}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const candidates = uniq
|
||||||
|
.map((m) => `${m.displayName} ${m.pubkey.slice(0, 16)}…`)
|
||||||
|
.join(", ");
|
||||||
|
render.err(`Ambiguous ${isName ? "name" : "prefix"} "${to}" — matches ${uniq.length} peers.`);
|
||||||
render.hint(`candidates: ${candidates}`);
|
render.hint(`candidates: ${candidates}`);
|
||||||
render.hint("Use a longer prefix or paste the full 64-char pubkey.");
|
render.hint("Use a longer prefix or paste the full 64-char pubkey.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
// Exactly one match — adopt its mesh (P1: kills mesh_required for
|
||||||
|
// peers on a non-default mesh) and its full pubkey (prefix/name).
|
||||||
|
meshSlug = uniq[0]!.slug;
|
||||||
|
if (!isFullPubkey) to = uniq[0]!.pubkey;
|
||||||
}
|
}
|
||||||
to = (matches[0] as { pubkey?: string }).pubkey ?? to;
|
|
||||||
} catch {
|
|
||||||
// Daemon unreachable — fall through; cold path will try a name
|
|
||||||
// lookup and surface its own error if that also fails.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,10 +170,14 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
|
|||||||
const session = await getSessionInfo();
|
const session = await getSessionInfo();
|
||||||
const ownSessionPk = session?.presence?.sessionPubkey?.toLowerCase();
|
const ownSessionPk = session?.presence?.sessionPubkey?.toLowerCase();
|
||||||
const siblings = peers.filter((p) => {
|
const siblings = peers.filter((p) => {
|
||||||
const r = p as { memberPubkey?: string; pubkey?: string; channel?: string };
|
const r = p as { memberPubkey?: string; pubkey?: string; channel?: string; peerRole?: string };
|
||||||
if (!r.pubkey) return false;
|
if (!r.pubkey) return false;
|
||||||
if (ownSessionPk && r.pubkey.toLowerCase() === ownSessionPk) return false;
|
if (ownSessionPk && r.pubkey.toLowerCase() === ownSessionPk) return false;
|
||||||
if (r.channel === "claudemesh-daemon") return false;
|
// Exclude the daemon's own control-plane presence row. peerRole is
|
||||||
|
// the reliable marker (the live daemon row is control-plane even
|
||||||
|
// when its channel reads "claudemesh-session"); keep the channel
|
||||||
|
// check too for older brokers that don't emit peerRole.
|
||||||
|
if (r.peerRole === "control-plane" || r.channel === "claudemesh-daemon") return false;
|
||||||
return r.memberPubkey?.toLowerCase() === to.toLowerCase();
|
return r.memberPubkey?.toLowerCase() === to.toLowerCase();
|
||||||
});
|
});
|
||||||
if (siblings.length === 0) {
|
if (siblings.length === 0) {
|
||||||
@@ -184,6 +223,45 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --self only governs the own-member-key fan-out above, which returns
|
||||||
|
// early. Reaching here with --self still set means the target was NOT
|
||||||
|
// your own member pubkey, so the flag did nothing. Say so rather than
|
||||||
|
// ignoring it silently — the old behavior made `send --self <session-
|
||||||
|
// pubkey>` look like it controlled routing when it was inert. Messaging
|
||||||
|
// a specific session pubkey (including one of your own sibling sessions)
|
||||||
|
// needs no flag and just works.
|
||||||
|
if (flags.self) {
|
||||||
|
render.warn("--self had no effect: it only applies when the target is your own member pubkey (fan-out to your sibling sessions). Sending to this specific pubkey directly.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Honest-delivery pre-check (direct sends only). The daemon path below
|
||||||
|
// queues into the local outbox and returns `queued` optimistically; the
|
||||||
|
// drain then delivers async and retries failures (incl. "no connected
|
||||||
|
// peer") forever. So a bare "sent" line was misleading — a DM to an
|
||||||
|
// offline or stale-session-key target looked delivered but never was.
|
||||||
|
// Resolve the live roster once to learn whether `to` is addressable
|
||||||
|
// right now; this only shapes the confirmation wording (the send still
|
||||||
|
// queues regardless, preserving store-and-forward for genuinely-offline
|
||||||
|
// peers). null = unknown (not a direct DM, or daemon unreachable).
|
||||||
|
let recipientOnline: boolean | null = null;
|
||||||
|
let recipientName: string | undefined;
|
||||||
|
if (isDirect && meshSlug) {
|
||||||
|
const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js");
|
||||||
|
const peers = await tryListPeersViaDaemon(meshSlug);
|
||||||
|
if (peers !== null) {
|
||||||
|
const lower = to.toLowerCase();
|
||||||
|
const match = peers.find((p) => {
|
||||||
|
const r = p as { pubkey?: string; memberPubkey?: string; peerRole?: string };
|
||||||
|
if (r.peerRole === "control-plane") return false;
|
||||||
|
return r.pubkey?.toLowerCase() === lower || r.memberPubkey?.toLowerCase() === lower;
|
||||||
|
});
|
||||||
|
recipientOnline = !!match;
|
||||||
|
recipientName = match ? (match as { displayName?: string }).displayName : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const offlineHint =
|
||||||
|
"Session pubkeys are ephemeral — a key from an ended session never reconnects, so the message can't be delivered. Re-fetch a live target with `claudemesh peer list --json`.";
|
||||||
|
|
||||||
// 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
|
||||||
@@ -192,8 +270,18 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
|
|||||||
const dr = await trySendViaDaemon({ to, message, priority, expectedMesh: meshSlug ?? undefined });
|
const dr = await trySendViaDaemon({ to, message, priority, expectedMesh: meshSlug ?? undefined });
|
||||||
if (dr !== null) {
|
if (dr !== null) {
|
||||||
if (dr.ok) {
|
if (dr.ok) {
|
||||||
if (flags.json) console.log(JSON.stringify({ ok: true, messageId: dr.messageId, target: to, via: "daemon", duplicate: !!dr.duplicate }));
|
if (flags.json) {
|
||||||
else render.ok(`sent to ${to} (daemon)`, dr.messageId ? dim(dr.messageId.slice(0, 8)) : undefined);
|
console.log(JSON.stringify({ ok: true, messageId: dr.messageId, target: to, via: "daemon", duplicate: !!dr.duplicate, status: dr.status, recipientOnline }));
|
||||||
|
} else if (recipientOnline === false) {
|
||||||
|
render.warn(`queued for ${recipientName ?? to.slice(0, 16) + "…"} — no connected peer matches this key on "${meshSlug}".`);
|
||||||
|
render.hint(offlineHint);
|
||||||
|
} else {
|
||||||
|
const who = recipientName ? `${recipientName} (${to.slice(0, 16)}…)` : to;
|
||||||
|
// recipientOnline === true → peer is present, delivery imminent.
|
||||||
|
// null → daemon couldn't tell (e.g. roster query failed); keep
|
||||||
|
// the neutral "(daemon)" transport tag rather than overclaiming.
|
||||||
|
render.ok(`sent to ${who}${recipientOnline === true ? " (online)" : " (daemon)"}`, dr.messageId ? dim(dr.messageId.slice(0, 8)) : undefined);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Daemon answered but rejected (409 idempotency, 400 schema). Surface; do not fall through.
|
// Daemon answered but rejected (409 idempotency, 400 schema). Surface; do not fall through.
|
||||||
@@ -206,8 +294,10 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
|
|||||||
// was removed in 1.28.0.
|
// was removed in 1.28.0.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cold path — open our own WS, encrypt locally, fire envelope.
|
// Cold path — open our own WS, encrypt locally, fire envelope. Use the
|
||||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
// resolved meshSlug (may have been discovered above) so a name/prefix
|
||||||
|
// that lives on a non-default mesh still targets the right one.
|
||||||
|
await withMesh({ meshSlug: meshSlug ?? flags.mesh ?? null }, async (client) => {
|
||||||
let targetSpec = to;
|
let targetSpec = to;
|
||||||
if (to.startsWith("#") && !/^#[0-9a-z_-]{20,}$/i.test(to)) {
|
if (to.startsWith("#") && !/^#[0-9a-z_-]{20,}$/i.test(to)) {
|
||||||
// Topic by name → resolve to "#<topicId>" via topicList. The broker
|
// Topic by name → resolve to "#<topicId>" via topicList. The broker
|
||||||
@@ -237,9 +327,13 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
|
|||||||
const result = await client.send(targetSpec, message, priority);
|
const result = await client.send(targetSpec, message, priority);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
if (flags.json) {
|
if (flags.json) {
|
||||||
console.log(JSON.stringify({ ok: true, messageId: result.messageId, target: to }));
|
console.log(JSON.stringify({ ok: true, messageId: result.messageId, target: to, recipientOnline }));
|
||||||
|
} else if (recipientOnline === false) {
|
||||||
|
render.warn(`queued for ${recipientName ?? to} — no connected peer matches this key on "${meshSlug ?? flags.mesh ?? "default"}".`);
|
||||||
|
render.hint(offlineHint);
|
||||||
} else {
|
} else {
|
||||||
render.ok(`sent to ${to}`, result.messageId ? dim(result.messageId.slice(0, 8)) : undefined);
|
const who = recipientName ? `${recipientName} (${to.slice(0, 16)}…)` : to;
|
||||||
|
render.ok(`sent to ${who}${recipientOnline === true ? " (online)" : ""}`, result.messageId ? dim(result.messageId.slice(0, 8)) : undefined);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (flags.json) {
|
if (flags.json) {
|
||||||
|
|||||||
@@ -83,6 +83,19 @@ export function checkFingerprint(): FingerprintCheck {
|
|||||||
if (stored.schema_version === 2) {
|
if (stored.schema_version === 2) {
|
||||||
if (stored.fingerprint === current.fingerprint)
|
if (stored.fingerprint === current.fingerprint)
|
||||||
return { result: "match", current, stored };
|
return { result: "match", current, stored };
|
||||||
|
|
||||||
|
// host_id wins: when stored and current both carry a non-empty
|
||||||
|
// host_id and they agree, the machine is the same — only the NIC
|
||||||
|
// topology shifted (dock unplugged, Wi-Fi privacy rotation, VPN
|
||||||
|
// adapter came online). host_id is hardware-rooted (IOPlatformUUID
|
||||||
|
// on macOS, /etc/machine-id on Linux) and is the load-bearing
|
||||||
|
// clone signal; stable_mac is best-effort defense-in-depth that
|
||||||
|
// legitimately drifts across boots. Silently rotate the stored
|
||||||
|
// record to the current MAC and proceed.
|
||||||
|
if (stored.host_id && stored.host_id === current.host_id) {
|
||||||
|
writeFileSync(path(), JSON.stringify(current, null, 2), { mode: 0o600 });
|
||||||
|
return { result: "match", current, stored };
|
||||||
|
}
|
||||||
return { result: "mismatch", current, stored };
|
return { result: "mismatch", current, stored };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export const DAEMON_PATHS = {
|
|||||||
get OUTBOX_DB() { return join(this.DAEMON_DIR, "outbox.db"); },
|
get OUTBOX_DB() { return join(this.DAEMON_DIR, "outbox.db"); },
|
||||||
get INBOX_DB() { return join(this.DAEMON_DIR, "inbox.db"); },
|
get INBOX_DB() { return join(this.DAEMON_DIR, "inbox.db"); },
|
||||||
get LOG_FILE() { return join(this.DAEMON_DIR, "daemon.log"); },
|
get LOG_FILE() { return join(this.DAEMON_DIR, "daemon.log"); },
|
||||||
|
/** Persisted session→mesh bindings. Rehydrated on daemon restart so a
|
||||||
|
* restart never orphans a live session's mesh context (the bug where
|
||||||
|
* a peer looked "disconnected" after the daemon bounced). Holds no
|
||||||
|
* secrets — keypairs are reloaded from the per-session keypair store. */
|
||||||
|
get SESSIONS_FILE() { return join(this.DAEMON_DIR, "sessions.json"); },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const DAEMON_TCP_HOST = "127.0.0.1";
|
export const DAEMON_TCP_HOST = "127.0.0.1";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { DAEMON_PATHS } from "./paths.js";
|
|||||||
import { acquireSingletonLock, releaseSingletonLock } from "./lock.js";
|
import { acquireSingletonLock, releaseSingletonLock } from "./lock.js";
|
||||||
import { ensureLocalToken } from "./local-token.js";
|
import { ensureLocalToken } from "./local-token.js";
|
||||||
import { startIpcServer } from "./ipc/server.js";
|
import { startIpcServer } from "./ipc/server.js";
|
||||||
import { setRegistryHooks, startReaper, type SessionInfo } from "./session-registry.js";
|
import { setRegistryHooks, startReaper, registerSession, readPersistedSessions, setRegistryPersistence, type SessionInfo } from "./session-registry.js";
|
||||||
import { openSqlite, type SqliteDb } from "./db/sqlite.js";
|
import { openSqlite, type SqliteDb } from "./db/sqlite.js";
|
||||||
import { migrateOutbox } from "./db/outbox.js";
|
import { migrateOutbox } from "./db/outbox.js";
|
||||||
import { migrateInbox } from "./db/inbox.js";
|
import { migrateInbox } from "./db/inbox.js";
|
||||||
@@ -230,6 +230,20 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
|
|||||||
}
|
}
|
||||||
prior.close().catch(() => { /* ignore */ });
|
prior.close().catch(() => { /* ignore */ });
|
||||||
}
|
}
|
||||||
|
// Also drop any stale WS holding this session pubkey under a
|
||||||
|
// DIFFERENT token. With UUID-anchored persistent keypairs a relaunch
|
||||||
|
// reuses the pubkey, so without this the old SessionBrokerClient
|
||||||
|
// would linger connected (the broker then sees two presences for one
|
||||||
|
// pubkey — the same-name ghost that stole queued DMs). Dedup by
|
||||||
|
// pubkey closes it before the new WS opens.
|
||||||
|
const priorByPubkey = sessionBrokersByPubkey.get(info.presence.sessionPubkey);
|
||||||
|
if (priorByPubkey && priorByPubkey !== prior) {
|
||||||
|
for (const [tok, c] of sessionBrokers) {
|
||||||
|
if (c === priorByPubkey) { sessionBrokers.delete(tok); break; }
|
||||||
|
}
|
||||||
|
sessionBrokersByPubkey.delete(info.presence.sessionPubkey);
|
||||||
|
priorByPubkey.close().catch(() => { /* ignore */ });
|
||||||
|
}
|
||||||
// 1.32.1 — wire push delivery. Messages targeted at the launched
|
// 1.32.1 — wire push delivery. Messages targeted at the launched
|
||||||
// session's pubkey land on THIS WS, not on the member-keyed one,
|
// session's pubkey land on THIS WS, not on the member-keyed one,
|
||||||
// so without this forward they'd silently disappear (the bug that
|
// so without this forward they'd silently disappear (the bug that
|
||||||
@@ -294,6 +308,81 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
|
|||||||
|
|
||||||
startReaper();
|
startReaper();
|
||||||
|
|
||||||
|
// Rehydrate persisted session bindings (1.36.0). A daemon restart used
|
||||||
|
// to wipe the in-memory registry, so every live session lost its mesh
|
||||||
|
// context and CLI commands fell back to an arbitrary default mesh — a
|
||||||
|
// live peer then looked "disconnected" though nothing had moved. We now
|
||||||
|
// reload each persisted binding, validate the pid is still alive (with
|
||||||
|
// a start-time PID-reuse guard), reload its keypair from the per-session
|
||||||
|
// store, re-sign a fresh parent attestation, and re-register it — which
|
||||||
|
// fires onRegister and reconnects its SessionBrokerClient on the broker.
|
||||||
|
try {
|
||||||
|
const persisted = readPersistedSessions(DAEMON_PATHS.SESSIONS_FILE);
|
||||||
|
if (persisted.length > 0) {
|
||||||
|
const { loadOrCreateSessionKeypair } = 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>());
|
||||||
|
let revived = 0;
|
||||||
|
for (const s of persisted) {
|
||||||
|
if (!isPidAlive(s.pid)) continue;
|
||||||
|
if (s.startTime !== undefined) {
|
||||||
|
const live = liveStartTimes.get(s.pid);
|
||||||
|
if (live !== undefined && live !== s.startTime) continue; // PID reused
|
||||||
|
}
|
||||||
|
const meshConfig = meshConfigs.get(s.mesh);
|
||||||
|
if (!meshConfig) continue; // mesh no longer joined
|
||||||
|
try {
|
||||||
|
const kp = await loadOrCreateSessionKeypair(meshConfig.slug, s.sessionId);
|
||||||
|
const att = await signParentAttestation({
|
||||||
|
parentMemberPubkey: meshConfig.pubkey,
|
||||||
|
parentSecretKey: meshConfig.secretKey,
|
||||||
|
sessionPubkey: kp.publicKey,
|
||||||
|
});
|
||||||
|
registerSession({
|
||||||
|
token: s.token,
|
||||||
|
sessionId: s.sessionId,
|
||||||
|
mesh: s.mesh,
|
||||||
|
displayName: s.displayName,
|
||||||
|
pid: s.pid,
|
||||||
|
...(s.cwd ? { cwd: s.cwd } : {}),
|
||||||
|
...(s.role ? { role: s.role } : {}),
|
||||||
|
...(s.groups ? { groups: s.groups } : {}),
|
||||||
|
...(s.startTime ? { startTime: s.startTime } : {}),
|
||||||
|
presence: {
|
||||||
|
sessionPubkey: kp.publicKey,
|
||||||
|
sessionSecretKey: kp.secretKey,
|
||||||
|
parentAttestation: {
|
||||||
|
sessionPubkey: att.sessionPubkey,
|
||||||
|
parentMemberPubkey: att.parentMemberPubkey,
|
||||||
|
expiresAt: att.expiresAt,
|
||||||
|
signature: att.signature,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revived++;
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(JSON.stringify({
|
||||||
|
level: "warn", msg: "session_rehydrate_failed",
|
||||||
|
token: s.token.slice(0, 8), mesh: s.mesh, err: String(err),
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
}) + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.stderr.write(JSON.stringify({
|
||||||
|
level: "info", msg: "sessions_rehydrated",
|
||||||
|
revived, persisted: persisted.length, ts: new Date().toISOString(),
|
||||||
|
}) + "\n");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(JSON.stringify({
|
||||||
|
level: "warn", msg: "session_rehydrate_scan_failed", err: String(err),
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
}) + "\n");
|
||||||
|
}
|
||||||
|
// Enable ongoing persistence now that rehydration has read the old file.
|
||||||
|
setRegistryPersistence(DAEMON_PATHS.SESSIONS_FILE);
|
||||||
|
|
||||||
const ipc = startIpcServer({
|
const ipc = startIpcServer({
|
||||||
localToken,
|
localToken,
|
||||||
tcpEnabled,
|
tcpEnabled,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
import { hostname as osHostname } from "node:os";
|
import { hostname as osHostname } from "node:os";
|
||||||
|
|
||||||
import type { JoinedMesh } from "~/services/config/facade.js";
|
import type { JoinedMesh } from "~/services/config/facade.js";
|
||||||
import { signSessionHello } from "~/services/broker/session-hello-sig.js";
|
import { signSessionHello, signParentAttestation } from "~/services/broker/session-hello-sig.js";
|
||||||
import { connectWsWithBackoff, type WsLifecycle, type WsStatus } from "./ws-lifecycle.js";
|
import { connectWsWithBackoff, type WsLifecycle, type WsStatus } from "./ws-lifecycle.js";
|
||||||
import type { BrokerSendArgs, BrokerSendResult } from "./broker.js";
|
import type { BrokerSendArgs, BrokerSendResult } from "./broker.js";
|
||||||
|
|
||||||
@@ -149,13 +149,35 @@ export class SessionBrokerClient {
|
|||||||
sessionPubkey: this.opts.sessionPubkey,
|
sessionPubkey: this.opts.sessionPubkey,
|
||||||
sessionSecretKey: this.opts.sessionSecretKey,
|
sessionSecretKey: this.opts.sessionSecretKey,
|
||||||
});
|
});
|
||||||
|
// Re-mint the parent attestation fresh on every (re)connect rather
|
||||||
|
// than reusing the one signed at `claudemesh launch`. The minted
|
||||||
|
// attestation has a 12h TTL; reusing the stored instance meant any
|
||||||
|
// reconnect past launch+12h — a network blip, a sleep/wake, or
|
||||||
|
// (most commonly) a broker redeploy that drops every WS at once —
|
||||||
|
// was rejected by the broker with `expired`, after which the daemon
|
||||||
|
// reconnect-looped forever with the same dead token and the session
|
||||||
|
// silently fell off the mesh (its ephemeral pubkey lingering in
|
||||||
|
// peer rosters, undeliverable). The member secret key is in memory
|
||||||
|
// (`mesh.secretKey`, already used at daemon rehydration), so the
|
||||||
|
// daemon can self-renew: fresh-minting keeps live attestations
|
||||||
|
// short-lived AND makes presence self-healing across reconnects.
|
||||||
|
let parentAttestation = this.opts.parentAttestation;
|
||||||
|
try {
|
||||||
|
parentAttestation = await signParentAttestation({
|
||||||
|
parentMemberPubkey: this.opts.mesh.pubkey,
|
||||||
|
parentSecretKey: this.opts.mesh.secretKey,
|
||||||
|
sessionPubkey: this.opts.sessionPubkey,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.log("warn", "parent attestation re-mint failed; reusing stored token (may be expired)", { err: String(e) });
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: "session_hello",
|
type: "session_hello",
|
||||||
meshId: this.opts.mesh.meshId,
|
meshId: this.opts.mesh.meshId,
|
||||||
parentMemberId: this.opts.mesh.memberId,
|
parentMemberId: this.opts.mesh.memberId,
|
||||||
parentMemberPubkey: this.opts.mesh.pubkey,
|
parentMemberPubkey: this.opts.mesh.pubkey,
|
||||||
sessionPubkey: this.opts.sessionPubkey,
|
sessionPubkey: this.opts.sessionPubkey,
|
||||||
parentAttestation: this.opts.parentAttestation,
|
parentAttestation,
|
||||||
displayName: this.opts.displayName,
|
displayName: this.opts.displayName,
|
||||||
sessionId: this.opts.sessionId,
|
sessionId: this.opts.sessionId,
|
||||||
pid: this.opts.pid,
|
pid: this.opts.pid,
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
* session have no token to begin with.
|
* session have no token to begin with.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
import { getProcessStartTime, getProcessStartTimes, isPidAlive } from "./process-info.js";
|
import { getProcessStartTime, getProcessStartTimes, isPidAlive } from "./process-info.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,6 +87,65 @@ const hooks: RegistryHooks = {};
|
|||||||
|
|
||||||
let reaperHandle: NodeJS.Timeout | null = null;
|
let reaperHandle: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
/** When set, registry mutations are mirrored to this file so a daemon
|
||||||
|
* restart can rehydrate live sessions. Holds NO secret material — the
|
||||||
|
* session keypair is reloaded from the per-session keypair store on
|
||||||
|
* rehydrate. null (default) disables persistence, which keeps unit
|
||||||
|
* tests from touching disk unless they opt in. */
|
||||||
|
let persistPath: string | null = null;
|
||||||
|
|
||||||
|
/** Slim, secret-free projection persisted to disk. */
|
||||||
|
export interface PersistedSession {
|
||||||
|
token: string;
|
||||||
|
sessionId: string;
|
||||||
|
mesh: string;
|
||||||
|
displayName: string;
|
||||||
|
pid: number;
|
||||||
|
cwd?: string;
|
||||||
|
role?: string;
|
||||||
|
groups?: string[];
|
||||||
|
startTime?: string;
|
||||||
|
registeredAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPersisted(info: SessionInfo): PersistedSession {
|
||||||
|
// Drop `presence` (carries the session secret key) — never to disk here.
|
||||||
|
const { presence: _presence, ...rest } = info;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enable on-disk persistence of session bindings (called at daemon boot
|
||||||
|
* with DAEMON_PATHS.SESSIONS_FILE). Pass null to disable. */
|
||||||
|
export function setRegistryPersistence(path: string | null): void {
|
||||||
|
persistPath = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(): void {
|
||||||
|
if (!persistPath) return;
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(persistPath), { recursive: true, mode: 0o700 });
|
||||||
|
const rows = [...byToken.values()].map(toPersisted);
|
||||||
|
const tmp = `${persistPath}.${randomBytes(6).toString("hex")}.tmp`;
|
||||||
|
writeFileSync(tmp, JSON.stringify({ version: 1, sessions: rows }), { mode: 0o600 });
|
||||||
|
renameSync(tmp, persistPath);
|
||||||
|
} catch {
|
||||||
|
// Best-effort: a persistence failure must never throttle the registry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read persisted session bindings from disk (pure — no registration, no
|
||||||
|
* liveness check). Returns [] when the file is absent or unreadable.
|
||||||
|
* The daemon's boot rehydration validates liveness and re-registers. */
|
||||||
|
export function readPersistedSessions(path: string): PersistedSession[] {
|
||||||
|
try {
|
||||||
|
if (!existsSync(path)) return [];
|
||||||
|
const parsed = JSON.parse(readFileSync(path, "utf8")) as { sessions?: PersistedSession[] };
|
||||||
|
return Array.isArray(parsed.sessions) ? parsed.sessions : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function startReaper(): void {
|
export function startReaper(): void {
|
||||||
if (reaperHandle) return;
|
if (reaperHandle) return;
|
||||||
// The sweep is async (batched ps) — wrap in `void` so setInterval
|
// The sweep is async (batched ps) — wrap in `void` so setInterval
|
||||||
@@ -125,6 +188,7 @@ export function registerSession(info: Omit<SessionInfo, "registeredAt">): Sessio
|
|||||||
const stored: SessionInfo = { ...info, registeredAt: Date.now() };
|
const stored: SessionInfo = { ...info, registeredAt: Date.now() };
|
||||||
byToken.set(info.token, stored);
|
byToken.set(info.token, stored);
|
||||||
bySessionId.set(info.sessionId, info.token);
|
bySessionId.set(info.sessionId, info.token);
|
||||||
|
persist();
|
||||||
try { hooks.onRegister?.(stored); } catch { /* see above */ }
|
try { hooks.onRegister?.(stored); } catch { /* see above */ }
|
||||||
if (stored.startTime === undefined) {
|
if (stored.startTime === undefined) {
|
||||||
void captureStartTimeAsync(info.token, info.pid);
|
void captureStartTimeAsync(info.token, info.pid);
|
||||||
@@ -138,6 +202,7 @@ async function captureStartTimeAsync(token: string, pid: number): Promise<void>
|
|||||||
const entry = byToken.get(token);
|
const entry = byToken.get(token);
|
||||||
if (!entry || entry.pid !== pid) return; // entry was replaced; skip
|
if (!entry || entry.pid !== pid) return; // entry was replaced; skip
|
||||||
entry.startTime = lstart;
|
entry.startTime = lstart;
|
||||||
|
persist(); // capture start-time on disk so restart can PID-reuse-guard
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deregisterByToken(token: string): boolean {
|
export function deregisterByToken(token: string): boolean {
|
||||||
@@ -145,6 +210,7 @@ export function deregisterByToken(token: string): boolean {
|
|||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
byToken.delete(token);
|
byToken.delete(token);
|
||||||
if (bySessionId.get(entry.sessionId) === token) bySessionId.delete(entry.sessionId);
|
if (bySessionId.get(entry.sessionId) === token) bySessionId.delete(entry.sessionId);
|
||||||
|
persist();
|
||||||
try { hooks.onDeregister?.(entry); } catch { /* see above */ }
|
try { hooks.onDeregister?.(entry); } catch { /* see above */ }
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -217,4 +283,5 @@ export function _resetRegistry(): void {
|
|||||||
bySessionId.clear();
|
bySessionId.clear();
|
||||||
hooks.onRegister = undefined;
|
hooks.onRegister = undefined;
|
||||||
hooks.onDeregister = undefined;
|
hooks.onDeregister = undefined;
|
||||||
|
persistPath = null;
|
||||||
}
|
}
|
||||||
|
|||||||
147
apps/cli/src/services/session/keypair-store.ts
Normal file
147
apps/cli/src/services/session/keypair-store.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Persistent per-session ed25519 keypairs, keyed by Claude Code's
|
||||||
|
* session UUID.
|
||||||
|
*
|
||||||
|
* Background. Until this module landed, `claudemesh launch` minted a
|
||||||
|
* FRESH ephemeral session keypair on every invocation (see
|
||||||
|
* SPEC.md §"Session identity"). That made a peer's routing/crypto
|
||||||
|
* identity unstable across relaunch and `--resume`: a DM is sealed to
|
||||||
|
* the recipient's `sessionPubkey` (crypto_box; see services/crypto/box.ts),
|
||||||
|
* so when the key rotated, any message queued for the old pubkey became
|
||||||
|
* undecryptable AND the old presence lingered as a ghost on the broker.
|
||||||
|
*
|
||||||
|
* The fix anchors session identity on the stable thing Claude Code
|
||||||
|
* itself uses for resume: the session UUID (scoped to the project/cwd).
|
||||||
|
* The keypair for a given (mesh, sessionUuid) is generated once and
|
||||||
|
* persisted, so:
|
||||||
|
* - relaunching / `--resume`-ing the same session reuses the SAME
|
||||||
|
* pubkey → the broker reattaches the existing presence and queued
|
||||||
|
* DMs both route AND decrypt;
|
||||||
|
* - a genuinely new session (fresh UUID) gets a fresh keypair → it is
|
||||||
|
* correctly a distinct peer.
|
||||||
|
*
|
||||||
|
* Storage. `~/.claudemesh/sessions/<meshSlug>/<sessionUuid>.json`, the
|
||||||
|
* file mode 0o600 inside a 0o700 dir — same secret-hygiene as the IPC
|
||||||
|
* token store. The secret key lives on disk (like the member key
|
||||||
|
* already does in the mesh config); the threat-model delta over the old
|
||||||
|
* ephemeral scheme is small and was an accepted trade for reliable
|
||||||
|
* 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 { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { generateKeypair, type Ed25519Keypair } from "~/services/crypto/facade.js";
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
const SLUG_RE = /^[a-z0-9._-]+$/i;
|
||||||
|
|
||||||
|
interface StoredKeypair {
|
||||||
|
version: 1;
|
||||||
|
meshSlug: string;
|
||||||
|
sessionId: string;
|
||||||
|
publicKey: string;
|
||||||
|
secretKey: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Root dir for persisted session keypairs. Stable per-machine; does
|
||||||
|
* NOT honor the per-launch `CLAUDEMESH_CONFIG_DIR` tmpdir (those are
|
||||||
|
* ephemeral and would defeat persistence). */
|
||||||
|
export function sessionsDir(): string {
|
||||||
|
return (
|
||||||
|
process.env.CLAUDEMESH_SESSIONS_DIR ||
|
||||||
|
join(homedir(), ".claudemesh", "sessions")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyFilePath(meshSlug: string, sessionId: string): string {
|
||||||
|
return join(sessionsDir(), meshSlug, `${sessionId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a persisted keypair, returning null (never throwing) when the
|
||||||
|
* file is missing, unreadable, malformed, or carries an invalid key. */
|
||||||
|
function readValidKeypair(file: string): Ed25519Keypair | null {
|
||||||
|
try {
|
||||||
|
if (!existsSync(file)) return null;
|
||||||
|
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<StoredKeypair>;
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed.publicKey === "string" &&
|
||||||
|
/^[0-9a-f]{64}$/.test(parsed.publicKey) &&
|
||||||
|
typeof parsed.secretKey === "string" &&
|
||||||
|
/^[0-9a-f]{128}$/.test(parsed.secretKey)
|
||||||
|
) {
|
||||||
|
return { publicKey: parsed.publicKey, secretKey: parsed.secretKey };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Unreadable / corrupt — caller treats as absent and rewrites.
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the persisted keypair for (meshSlug, sessionId), creating and
|
||||||
|
* writing one on first use. Re-reads from disk every call so concurrent
|
||||||
|
* launches of the same session converge on one identity rather than
|
||||||
|
* racing to mint divergent keys.
|
||||||
|
*
|
||||||
|
* Falls back to an in-memory ephemeral keypair (the legacy behaviour)
|
||||||
|
* when the identifiers are unusable or disk I/O fails — a launch must
|
||||||
|
* never be blocked by a keystore problem.
|
||||||
|
*/
|
||||||
|
export async function loadOrCreateSessionKeypair(
|
||||||
|
meshSlug: string,
|
||||||
|
sessionId: string,
|
||||||
|
): 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)) {
|
||||||
|
return generateKeypair();
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = keyFilePath(meshSlug, sessionId);
|
||||||
|
const existing = readValidKeypair(file);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const kp = await generateKeypair();
|
||||||
|
try {
|
||||||
|
mkdirSync(join(sessionsDir(), meshSlug), { recursive: true, mode: 0o700 });
|
||||||
|
const stored: StoredKeypair = {
|
||||||
|
version: 1,
|
||||||
|
meshSlug,
|
||||||
|
sessionId,
|
||||||
|
publicKey: kp.publicKey,
|
||||||
|
secretKey: kp.secretKey,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
// Write to a temp sibling then rename for atomicity, so a concurrent
|
||||||
|
// reader never sees a half-written file.
|
||||||
|
const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
||||||
|
writeFileSync(tmp, JSON.stringify(stored), { mode: 0o600 });
|
||||||
|
try {
|
||||||
|
// Re-check: another launch may have won the race and created the
|
||||||
|
// canonical file with a VALID keypair while we were generating —
|
||||||
|
// prefer it. A corrupt/invalid existing file is not a winner; fall
|
||||||
|
// through and overwrite it via the atomic rename below.
|
||||||
|
if (existsSync(file)) {
|
||||||
|
const won = readValidKeypair(file);
|
||||||
|
if (won) {
|
||||||
|
try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
|
||||||
|
return won;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// renameSync is atomic on the same filesystem.
|
||||||
|
renameSync(tmp, file);
|
||||||
|
} catch {
|
||||||
|
// rename failed — best effort, the in-memory keypair is still valid
|
||||||
|
// for this launch.
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// mkdir/write failed — return the freshly generated keypair anyway so
|
||||||
|
// the launch proceeds (degrades to ephemeral, same as legacy).
|
||||||
|
}
|
||||||
|
return kp;
|
||||||
|
}
|
||||||
@@ -234,7 +234,7 @@ describe("checkFingerprint (file-based)", () => {
|
|||||||
expect(result.stored?.schema_version).toBe(1);
|
expect(result.stored?.schema_version).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("v2 stored with a different fingerprint reports mismatch", async () => {
|
it("v2 stored with a different fingerprint AND different host_id reports mismatch (genuine clone)", async () => {
|
||||||
const { checkFingerprint, acceptCurrentHost } = await import(
|
const { checkFingerprint, acceptCurrentHost } = await import(
|
||||||
"~/daemon/identity.js"
|
"~/daemon/identity.js"
|
||||||
);
|
);
|
||||||
@@ -245,6 +245,7 @@ describe("checkFingerprint (file-based)", () => {
|
|||||||
{
|
{
|
||||||
...real,
|
...real,
|
||||||
fingerprint: "f".repeat(64),
|
fingerprint: "f".repeat(64),
|
||||||
|
host_id: real.host_id ? `${real.host_id}-cloned` : "linux:spoofed",
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
@@ -256,6 +257,66 @@ describe("checkFingerprint (file-based)", () => {
|
|||||||
expect(result.stored?.schema_version).toBe(2);
|
expect(result.stored?.schema_version).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("v2 stored with matching host_id but different stable_mac silently rotates to match (dock unplugged / Wi-Fi privacy rotation)", async () => {
|
||||||
|
const { checkFingerprint, acceptCurrentHost, fingerprintV2 } = await import(
|
||||||
|
"~/daemon/identity.js"
|
||||||
|
);
|
||||||
|
const real = acceptCurrentHost();
|
||||||
|
// Same host_id, different stable_mac → stale fingerprint on disk.
|
||||||
|
const staleMac = "00:e0:4c:99:99:99";
|
||||||
|
const staleFingerprint = fingerprintV2(real.host_id, staleMac);
|
||||||
|
expect(staleFingerprint).not.toBe(real.fingerprint);
|
||||||
|
writeFileSync(
|
||||||
|
join(testDir, "host_fingerprint.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
...real,
|
||||||
|
stable_mac: staleMac,
|
||||||
|
fingerprint: staleFingerprint,
|
||||||
|
written_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = checkFingerprint();
|
||||||
|
expect(result.result).toBe("match");
|
||||||
|
expect(result.stored?.schema_version).toBe(2);
|
||||||
|
|
||||||
|
// Stored record is silently rewritten with the current MAC/fingerprint.
|
||||||
|
const onDisk = JSON.parse(
|
||||||
|
readFileSync(join(testDir, "host_fingerprint.json"), "utf8"),
|
||||||
|
);
|
||||||
|
expect(onDisk.fingerprint).toBe(real.fingerprint);
|
||||||
|
expect(onDisk.stable_mac).toBe(real.stable_mac);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("v2 stored with EMPTY host_id falls back to strict fingerprint compare (broken v1.34.16 record)", async () => {
|
||||||
|
// Records written by v1.34.16 had empty host_id on macOS — once
|
||||||
|
// current host_id starts populating correctly, we cannot use the
|
||||||
|
// host_id-wins branch (would silently rotate any clone). Strict
|
||||||
|
// fingerprint compare → mismatch → user runs accept-host.
|
||||||
|
const { checkFingerprint } = await import("~/daemon/identity.js");
|
||||||
|
writeFileSync(
|
||||||
|
join(testDir, "host_fingerprint.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
schema_version: 2,
|
||||||
|
fingerprint: "0".repeat(64),
|
||||||
|
host_id: "",
|
||||||
|
stable_mac: "00:e0:4c:11:22:33",
|
||||||
|
written_at: "2026-05-19T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = checkFingerprint();
|
||||||
|
expect(result.result).toBe("mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
it("unknown future schema is treated as 'unavailable', not overwritten", async () => {
|
it("unknown future schema is treated as 'unavailable', not overwritten", async () => {
|
||||||
const { checkFingerprint } = await import("~/daemon/identity.js");
|
const { checkFingerprint } = await import("~/daemon/identity.js");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
|||||||
96
apps/cli/tests/unit/keypair-store.test.ts
Normal file
96
apps/cli/tests/unit/keypair-store.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Persisted, UUID-anchored session keypairs (delivery-reliability fix).
|
||||||
|
*
|
||||||
|
* The keystore is what makes a peer's sessionPubkey stable across
|
||||||
|
* relaunch/--resume, so queued DMs (sealed to that pubkey) both route to
|
||||||
|
* and decrypt on the returning session. Verifies:
|
||||||
|
* - the same (mesh, uuid) returns the SAME keypair across calls and
|
||||||
|
* across a fresh module read (persisted to disk);
|
||||||
|
* - distinct uuids / meshes get distinct keypairs;
|
||||||
|
* - malformed identifiers fall back to an ephemeral keypair and never
|
||||||
|
* escape the sessions dir;
|
||||||
|
* - a corrupt on-disk file is transparently rewritten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadOrCreateSessionKeypair,
|
||||||
|
sessionsDir,
|
||||||
|
} from "../../src/services/session/keypair-store.js";
|
||||||
|
|
||||||
|
const UUID_A = "11111111-2222-3333-4444-555555555555";
|
||||||
|
const UUID_B = "66666666-7777-8888-9999-aaaaaaaaaaaa";
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "cm-keystore-"));
|
||||||
|
process.env.CLAUDEMESH_SESSIONS_DIR = dir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.CLAUDEMESH_SESSIONS_DIR;
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadOrCreateSessionKeypair", () => {
|
||||||
|
test("same (mesh, uuid) is stable across calls", async () => {
|
||||||
|
const a = await loadOrCreateSessionKeypair("flexicar", UUID_A);
|
||||||
|
const b = await loadOrCreateSessionKeypair("flexicar", UUID_A);
|
||||||
|
expect(a.publicKey).toBe(b.publicKey);
|
||||||
|
expect(a.secretKey).toBe(b.secretKey);
|
||||||
|
expect(a.publicKey).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(a.secretKey).toMatch(/^[0-9a-f]{128}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("persists to disk under sessionsDir/<mesh>/<uuid>.json", async () => {
|
||||||
|
await loadOrCreateSessionKeypair("flexicar", UUID_A);
|
||||||
|
const file = join(sessionsDir(), "flexicar", `${UUID_A}.json`);
|
||||||
|
expect(existsSync(file)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("distinct uuids get distinct keys", async () => {
|
||||||
|
const a = await loadOrCreateSessionKeypair("flexicar", UUID_A);
|
||||||
|
const b = await loadOrCreateSessionKeypair("flexicar", UUID_B);
|
||||||
|
expect(a.publicKey).not.toBe(b.publicKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("distinct meshes get distinct keys for the same uuid", async () => {
|
||||||
|
const a = await loadOrCreateSessionKeypair("flexicar", UUID_A);
|
||||||
|
const b = await loadOrCreateSessionKeypair("other-mesh", UUID_A);
|
||||||
|
expect(a.publicKey).not.toBe(b.publicKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed uuid falls back to ephemeral, writes nothing", 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).not.toBe(b.publicKey);
|
||||||
|
expect(existsSync(join(dir, "flexicar"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
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}$/);
|
||||||
|
// Nothing written under the sessions dir for a rejected slug.
|
||||||
|
expect(readdirSync(dir)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("corrupt on-disk file is rewritten and yields a valid key", async () => {
|
||||||
|
const a = await loadOrCreateSessionKeypair("flexicar", UUID_A);
|
||||||
|
const file = join(sessionsDir(), "flexicar", `${UUID_A}.json`);
|
||||||
|
writeFileSync(file, "{ this is not valid json", "utf8");
|
||||||
|
const b = await loadOrCreateSessionKeypair("flexicar", UUID_A);
|
||||||
|
expect(b.publicKey).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
// Rewritten to a fresh, internally-consistent keypair (distinct from
|
||||||
|
// the now-clobbered original).
|
||||||
|
expect(b.publicKey).not.toBe(a.publicKey);
|
||||||
|
const c = await loadOrCreateSessionKeypair("flexicar", UUID_A);
|
||||||
|
expect(c.publicKey).toBe(b.publicKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
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