feat(cli): 1.29.0 — per-session IPC tokens + auto-scoping

every claudemesh launch-spawned session now mints a 32-byte random
token, writes it under tmpdir (mode 0600), and registers it with the
daemon. cli invocations from inside that session inherit
CLAUDEMESH_IPC_TOKEN_FILE in env, attach the token via Authorization:
ClaudeMesh-Session <hex>, and the daemon resolves it to a SessionInfo.

server-side: every read route that filters by mesh now uses meshFromCtx —
explicit query/body wins, session default fills in when missing. write
routes follow the same pattern.

cli-side: peers.ts (and other multi-mesh-iterating verbs in future)
prefers session-token mesh over all joined meshes when the user didn't
pass --mesh explicitly.

backward-compatible in both directions — tokenless callers behave
exactly as before. registry is in-memory; daemon restart loses it but
the 30s reaper handles dead pids and most callers re-register on next
launch.

verified end-to-end: peer list with token returns 4 prueba1 peers,
without token returns 3 meshes' peers (aggregate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-04 12:33:06 +01:00
parent 81f0e4f7ac
commit 92cac16c91
10 changed files with 431 additions and 12 deletions

View File

@@ -653,6 +653,49 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
"utf-8",
);
// 4b. Mint a per-session IPC token, persist it under tmpDir, and
// 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.
let sessionTokenFilePath: string | null = null;
let sessionTokenForCleanup: string | null = null;
try {
const { mintSessionToken, TOKEN_FILE_ENV } = await import("~/services/session/token.js");
const minted = mintSessionToken(tmpDir);
sessionTokenFilePath = minted.filePath;
sessionTokenForCleanup = minted.token;
// Register with the daemon. Best-effort: a daemon failure here
// means the session falls back to user-level scope, which is fine.
const { ipc } = await import("~/daemon/ipc/client.js");
const sessionIdForRegister = claudeSessionId ?? randomUUID();
await ipc({
method: "POST",
path: "/v1/sessions/register",
timeoutMs: 3_000,
body: {
token: minted.token,
session_id: sessionIdForRegister,
mesh: mesh.slug,
display_name: displayName,
pid: process.pid,
cwd: process.cwd(),
...(role ? { role } : {}),
...(parsedGroups.length > 0 ? { groups: parsedGroups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`) } : {}),
},
}).catch(() => null);
// Pin the env name on a global so the spawn block below can pick it up.
(process as unknown as { _claudemeshTokenEnv?: { name: string; value: string } })._claudemeshTokenEnv = {
name: TOKEN_FILE_ENV,
value: minted.filePath,
};
} catch {
// Token mint or registration failed — proceed without per-session
// attribution. CLI invocations from the session will still work,
// they'll just default to user-level scope.
}
// 5. Print summary banner (wizard already handled all interactive config).
if (!args.quiet) {
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
@@ -774,7 +817,14 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
} catch { /* best effort */ }
}
// Ephemeral config dir
// The token's session-token file lives inside tmpDir; rmSync below
// shreds the secret. The daemon's session reaper notices the
// launched session's pid is gone within 30s and drops the registry
// entry. Explicit DELETE on /v1/sessions is feasible only from an
// async exit hook, which adds complexity for ~30s of memory the
// reaper will reclaim anyway. Leaving as-is; revisit if the
// registry ever grows persistence.
// Ephemeral config dir (also drops the session-token file)
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch { /* best effort */ }
@@ -836,6 +886,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName,
...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}),
...(sessionTokenFilePath ? { CLAUDEMESH_IPC_TOKEN_FILE: sessionTokenFilePath } : {}),
MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000",
MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000",
...(role ? { CLAUDEMESH_ROLE: role } : {}),

View File

@@ -119,7 +119,19 @@ function annotateSelf(
export async function runPeers(flags: PeersFlags): Promise<void> {
const config = readConfig();
const slugs = flags.mesh ? [flags.mesh] : config.meshes.map((m) => m.slug);
// Mesh selection precedence:
// 1. explicit --mesh <slug> (always wins)
// 2. session-token mesh (when invoked from inside a launched session)
// 3. all joined meshes (default for bare shells)
let slugs: string[];
if (flags.mesh) {
slugs = [flags.mesh];
} else {
const { getSessionInfo } = await import("~/services/session/resolve.js");
const sess = await getSessionInfo();
slugs = sess ? [sess.mesh] : config.meshes.map((m) => m.slug);
}
if (slugs.length === 0) {
render.err("No meshes joined.");