From ff2aa8bf7c01b656c729189d646dcebc0fd7d680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Mon, 4 May 2026 13:08:15 +0100 Subject: [PATCH] feat(cli): launch mints session keypair + parent attestation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claudemesh launch now also generates a per-launch ed25519 keypair and a parent-vouched attestation (12h TTL), included in the body of POST /v1/sessions/register under body.presence. The daemon stores it on SessionInfo and, with CLAUDEMESH_SESSION_PRESENCE=1, opens a long-lived broker WS so the session has its own presence row. Also fixes a latent 1.29.0 bug: claudeSessionId was referenced before its const declaration, hitting the TDZ → ReferenceError silently swallowed by the surrounding catch. Net: the IPC session-token registration has been failing every launch since 1.29.0, falling back to user-level scope for every session. Hoisted the declaration up so the registration actually runs. The presence payload is forward-compat: older daemons ignore unknown body fields, so 1.30.0 CLIs work fine against unupgraded daemons. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cli/src/commands/launch.ts | 60 ++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/commands/launch.ts b/apps/cli/src/commands/launch.ts index c712d6e..1d7c3be 100644 --- a/apps/cli/src/commands/launch.ts +++ b/apps/cli/src/commands/launch.ts @@ -657,6 +657,20 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise< // register it with the daemon. The token's path is exposed to // the spawned claude (and all its descendants) via env so // CLI invocations from inside the session auto-attribute to it. + // + // 1.30.0: also mint an ephemeral ed25519 session keypair and a + // parent-vouched attestation. The daemon uses these to open a + // long-lived broker WebSocket per session (presence row keyed on + // the session pubkey, member_id from the parent), so sibling + // sessions in the same mesh see each other in `peer list`. + // + // Session-id resolution: 1.29.0 referenced `claudeSessionId` + // before its `const` declaration further down the file, hitting + // the TDZ → ReferenceError swallowed by the surrounding catch. + // The IPC registration has been silently failing every launch + // since 1.29.0. Hoist the declaration up so it actually runs. + const isResume = args.resume !== null || args.continueSession; + const claudeSessionId = isResume ? undefined : randomUUID(); let sessionTokenFilePath: string | null = null; let sessionTokenForCleanup: string | null = null; try { @@ -665,6 +679,45 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise< sessionTokenFilePath = minted.filePath; sessionTokenForCleanup = minted.token; + // Per-session ephemeral keypair + parent attestation (1.30.0+). + // Behind CLAUDEMESH_SESSION_PRESENCE: the daemon ignores the + // presence material when the flag is off, so sending it always is + // forward-compatible. + let presencePayload: { + session_pubkey: string; + session_secret_key: string; + parent_attestation: { + session_pubkey: string; + parent_member_pubkey: string; + expires_at: number; + signature: string; + }; + } | undefined; + try { + const { generateKeypair } = await import("~/services/crypto/facade.js"); + const { signParentAttestation } = await import("~/services/broker/session-hello-sig.js"); + const sessionKp = await generateKeypair(); + const att = await signParentAttestation({ + parentMemberPubkey: mesh.pubkey, + parentSecretKey: mesh.secretKey, + sessionPubkey: sessionKp.publicKey, + }); + presencePayload = { + session_pubkey: sessionKp.publicKey, + session_secret_key: sessionKp.secretKey, + parent_attestation: { + session_pubkey: att.sessionPubkey, + parent_member_pubkey: att.parentMemberPubkey, + expires_at: att.expiresAt, + signature: att.signature, + }, + }; + } catch { + // Keypair / attestation failure — proceed without per-session + // presence. The session still registers; only the broker-side + // presence row is skipped. + } + // 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"); @@ -682,6 +735,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise< cwd: process.cwd(), ...(role ? { role } : {}), ...(parsedGroups.length > 0 ? { groups: parsedGroups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`) } : {}), + ...(presencePayload ? { presence: presencePayload } : {}), }, }).catch(() => null); @@ -769,10 +823,8 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise< // passes -y / --yes. Without it, claudemesh tools still work because // `claudemesh install` pre-approves them via allowedTools in settings.json. // This keeps permissions tight for multi-person meshes. - // Session identity: --resume reuses existing session, otherwise generate new. - // When resuming, Claude Code reuses the session ID so the mesh peer identity persists. - const isResume = args.resume !== null || args.continueSession; - const claudeSessionId = isResume ? undefined : randomUUID(); + // Session identity: claudeSessionId was generated above (4b) so the + // session-token registration could include it. Reuse here. const claudeArgs = [ "--dangerously-load-development-channels",