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.");

View File

@@ -2,6 +2,7 @@ import { request as httpRequest } from "node:http";
import { DAEMON_PATHS, DAEMON_TCP_HOST, DAEMON_TCP_DEFAULT_PORT } from "../paths.js";
import { readLocalToken } from "../local-token.js";
import { readSessionTokenFromEnv } from "~/services/session/token.js";
export interface IpcRequestOptions {
method?: "GET" | "POST" | "PATCH" | "DELETE";
@@ -44,6 +45,19 @@ export async function ipc<T = unknown>(opts: IpcRequestOptions): Promise<IpcResp
headers.authorization = `Bearer ${tok}`;
}
// Per-session token attribution. When the calling process has
// CLAUDEMESH_IPC_TOKEN_FILE set (a launched session and its
// descendants), attach the session token. The daemon's auth
// middleware resolves it to a SessionInfo and uses it for default-
// mesh scoping. Sent as a second Authorization header is not
// possible per HTTP semantics, so we layer: when both UDS and a
// session token exist, send the session token; the bearer remains
// only for TCP loopback callers.
if (!useTcp) {
const sessionTok = readSessionTokenFromEnv();
if (sessionTok) headers.authorization = `ClaudeMesh-Session ${sessionTok}`;
}
return new Promise<IpcResponse<T>>((resolve, reject) => {
const req = httpRequest(
useTcp

View File

@@ -10,6 +10,10 @@ import { listOutbox, requeueDeadOrPending, type OutboxStatus } from "../db/outbo
import { randomUUID } from "node:crypto";
import { bindSseStream, type EventBus } from "../events.js";
import type { DaemonBrokerClient } from "../broker.js";
import {
registerSession, deregisterByToken, resolveToken, listSessions, startReaper,
type SessionInfo,
} from "../session-registry.js";
import { VERSION } from "~/constants/urls.js";
/**
@@ -172,12 +176,28 @@ function makeHandler(opts: {
}
}
// Per-session token resolution. Layers on top of the machine-level
// local-token auth above: callers from inside a `claudemesh launch`-
// spawned session pass `Authorization: ClaudeMesh-Session <hex>`
// (instead of, or in addition to, Bearer over TCP) and we resolve
// it to a SessionInfo that downstream routes use for default-mesh
// scoping and attribution.
let session: SessionInfo | null = null;
{
const authz = req.headers.authorization ?? "";
const sm = /^ClaudeMesh-Session\s+([0-9a-f]{64})$/i.exec(authz.trim());
if (sm && sm[1]) session = resolveToken(sm[1].toLowerCase());
}
/** Pick mesh from explicit body/query first, then session default. */
const meshFromCtx = (explicit?: string | null): string | null =>
(explicit && explicit.trim()) ? explicit : (session?.mesh ?? null);
// Routing.
if (req.method === "GET" && url.pathname === "/v1/version") {
respond(res, 200, {
daemon_version: VERSION,
ipc_api: "v1",
ipc_features: ["version", "health", "send", "inbox", "events", "peers", "profile", "skills", "state", "memory"],
ipc_features: ["version", "health", "send", "inbox", "events", "peers", "profile", "skills", "state", "memory", "sessions"],
schema_version: 1,
});
return;
@@ -188,6 +208,59 @@ function makeHandler(opts: {
return;
}
// Session registry routes (1.29.0)
if (req.method === "POST" && url.pathname === "/v1/sessions/register") {
try {
const body = await readJsonBody(req, 64 * 1024) as Record<string, unknown> | null;
if (!body) { respond(res, 400, { error: "missing body" }); return; }
const token = typeof body.token === "string" ? body.token : "";
if (!/^[0-9a-f]{64}$/i.test(token)) { respond(res, 400, { error: "token must be 64 hex chars" }); return; }
const sessionId = typeof body.session_id === "string" ? body.session_id : "";
const mesh = typeof body.mesh === "string" ? body.mesh : "";
const displayName = typeof body.display_name === "string" ? body.display_name : "";
const pid = typeof body.pid === "number" ? body.pid : 0;
if (!sessionId || !mesh || !displayName || !pid) {
respond(res, 400, { error: "session_id, mesh, display_name, pid all required" });
return;
}
const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
const role = typeof body.role === "string" ? body.role : undefined;
const groups = Array.isArray(body.groups)
? body.groups.filter((g): g is string => typeof g === "string")
: undefined;
const stored = registerSession({
token: token.toLowerCase(),
sessionId, mesh, displayName, pid, cwd, role, groups,
});
opts.log("info", "session_registered", { sessionId, mesh, pid });
respond(res, 200, { ok: true, registered_at: stored.registeredAt });
} catch (e) {
respond(res, 400, { error: String(e) });
}
return;
}
if (req.method === "DELETE" && url.pathname.startsWith("/v1/sessions/")) {
const tail = url.pathname.slice("/v1/sessions/".length);
if (!/^[0-9a-f]{64}$/i.test(tail)) { respond(res, 400, { error: "invalid token" }); return; }
const ok = deregisterByToken(tail.toLowerCase());
respond(res, ok ? 200 : 404, { ok, token_prefix: tail.slice(0, 8) });
return;
}
if (req.method === "GET" && url.pathname === "/v1/sessions/me") {
if (!session) { respond(res, 401, { error: "no session token" }); return; }
const { token, ...redacted } = session;
respond(res, 200, { session: { ...redacted, token_prefix: token.slice(0, 8) } });
return;
}
if (req.method === "GET" && url.pathname === "/v1/sessions") {
const all = listSessions().map(({ token, ...rest }) => ({ ...rest, token_prefix: token.slice(0, 8) }));
respond(res, 200, { sessions: all });
return;
}
if (req.method === "GET" && url.pathname === "/v1/events") {
if (!opts.bus) {
respond(res, 503, { error: "event bus not initialised" });
@@ -202,7 +275,7 @@ function makeHandler(opts: {
respond(res, 503, { error: "broker not initialised" });
return;
}
const filterMesh = url.searchParams.get("mesh") ?? undefined;
const filterMesh = meshFromCtx(url.searchParams.get("mesh")) ?? undefined;
try {
// Aggregate across all attached meshes; each peer record gets a
// `mesh` field so the caller can scope client-side. A single
@@ -229,7 +302,7 @@ function makeHandler(opts: {
respond(res, 503, { error: "broker not initialised" });
return;
}
const filterMesh = url.searchParams.get("mesh") ?? undefined;
const filterMesh = meshFromCtx(url.searchParams.get("mesh")) ?? undefined;
const key = url.searchParams.get("key");
try {
if (key) {
@@ -268,7 +341,7 @@ function makeHandler(opts: {
respond(res, 400, { error: "missing 'key' (string)" });
return;
}
const requested = (typeof body.mesh === "string" ? body.mesh : null) || null;
const requested = meshFromCtx(typeof body.mesh === "string" ? body.mesh : null);
let chosen = requested;
if (!chosen && opts.brokers.size === 1) chosen = opts.brokers.keys().next().value as string;
if (!chosen) {
@@ -291,7 +364,7 @@ function makeHandler(opts: {
return;
}
const query = url.searchParams.get("q") ?? "";
const filterMesh = url.searchParams.get("mesh") ?? undefined;
const filterMesh = meshFromCtx(url.searchParams.get("mesh")) ?? undefined;
try {
const all: Array<Record<string, unknown> & { mesh: string }> = [];
for (const [slug, b] of opts.brokers.entries()) {
@@ -317,7 +390,7 @@ function makeHandler(opts: {
respond(res, 400, { error: "missing 'content' (string)" });
return;
}
const requested = (typeof body.mesh === "string" ? body.mesh : null) || null;
const requested = meshFromCtx(typeof body.mesh === "string" ? body.mesh : null);
let chosen = requested;
if (!chosen && opts.brokers.size === 1) chosen = opts.brokers.keys().next().value as string;
if (!chosen) {
@@ -363,7 +436,7 @@ function makeHandler(opts: {
return;
}
const query = url.searchParams.get("query") ?? undefined;
const filterMesh = url.searchParams.get("mesh") ?? undefined;
const filterMesh = meshFromCtx(url.searchParams.get("mesh")) ?? undefined;
try {
const all: Array<Record<string, unknown> & { mesh: string }> = [];
for (const [slug, b] of opts.brokers.entries()) {
@@ -389,7 +462,7 @@ function makeHandler(opts: {
}
const name = decodeURIComponent(url.pathname.slice("/v1/skills/".length));
if (!name) { respond(res, 400, { error: "missing skill name" }); return; }
const filterMesh = url.searchParams.get("mesh") ?? undefined;
const filterMesh = meshFromCtx(url.searchParams.get("mesh")) ?? undefined;
try {
// First mesh that has the skill wins. With ?mesh=<slug>, only that
// mesh is queried.
@@ -417,7 +490,7 @@ function makeHandler(opts: {
// present in the body or query, otherwise broadcast to all attached
// meshes (presence is per-mesh, but most users want consistent
// presence across all of theirs).
const requested = (typeof body.mesh === "string" ? body.mesh : url.searchParams.get("mesh")) || null;
const requested = meshFromCtx(typeof body.mesh === "string" ? body.mesh : url.searchParams.get("mesh"));
const targets = requested
? [opts.brokers.get(requested)].filter(Boolean) as DaemonBrokerClient[]
: [...opts.brokers.values()];

View File

@@ -4,6 +4,7 @@ import { DAEMON_PATHS } from "./paths.js";
import { acquireSingletonLock, releaseSingletonLock } from "./lock.js";
import { ensureLocalToken } from "./local-token.js";
import { startIpcServer } from "./ipc/server.js";
import { startReaper } from "./session-registry.js";
import { openSqlite, type SqliteDb } from "./db/sqlite.js";
import { migrateOutbox } from "./db/outbox.js";
import { migrateInbox } from "./db/inbox.js";
@@ -153,6 +154,8 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<number> {
let drain: DrainHandle | null = null;
drain = startDrainWorker({ db: outboxDb, brokers });
startReaper();
const ipc = startIpcServer({
localToken,
tcpEnabled,

View File

@@ -0,0 +1,98 @@
/**
* In-memory per-token session registry kept by the daemon.
*
* `claudemesh launch` POSTs `/v1/sessions/register` with the token it
* minted plus session metadata (sessionId, mesh, displayName, pid,
* cwd, role, groups). Subsequent CLI invocations from inside that
* session present the token via `Authorization: ClaudeMesh-Session
* <hex>` and the daemon's IPC auth middleware resolves it here in O(1).
*
* Lifecycle:
* - register replaces any prior entry under the same `sessionId`
* (handles re-launch and `--resume` flows cleanly).
* - reaper polls every 30 s and drops entries whose pid is dead.
* - hard ttl ceiling of 24 h is a leak guard for forgotten sessions.
*
* Persistence: in-memory only for v1. A daemon restart clears the
* registry — every launched session needs to re-register. That's fine
* for now because launch.ts re-registers on `ensureDaemonRunning`'s
* success path, and most ad-hoc CLI invocations from outside a launched
* session have no token to begin with.
*/
export interface SessionInfo {
token: string;
sessionId: string;
mesh: string;
displayName: string;
pid: number;
cwd?: string;
role?: string;
groups?: string[];
registeredAt: number;
}
const TTL_MS = 24 * 60 * 60 * 1000;
const REAPER_INTERVAL_MS = 30 * 1000;
const byToken = new Map<string, SessionInfo>();
const bySessionId = new Map<string, string>();
let reaperHandle: NodeJS.Timeout | null = null;
export function startReaper(): void {
if (reaperHandle) return;
reaperHandle = setInterval(reapDead, REAPER_INTERVAL_MS).unref?.() ?? reaperHandle;
}
export function stopReaper(): void {
if (reaperHandle) { clearInterval(reaperHandle); reaperHandle = null; }
}
export function registerSession(info: Omit<SessionInfo, "registeredAt">): SessionInfo {
// Replace any prior entry under the same sessionId.
const priorToken = bySessionId.get(info.sessionId);
if (priorToken && priorToken !== info.token) byToken.delete(priorToken);
const stored: SessionInfo = { ...info, registeredAt: Date.now() };
byToken.set(info.token, stored);
bySessionId.set(info.sessionId, info.token);
return stored;
}
export function deregisterByToken(token: string): boolean {
const entry = byToken.get(token);
if (!entry) return false;
byToken.delete(token);
if (bySessionId.get(entry.sessionId) === token) bySessionId.delete(entry.sessionId);
return true;
}
export function resolveToken(token: string): SessionInfo | null {
const entry = byToken.get(token);
if (!entry) return null;
if (Date.now() - entry.registeredAt > TTL_MS) {
deregisterByToken(token);
return null;
}
return entry;
}
export function listSessions(): SessionInfo[] {
return [...byToken.values()];
}
function reapDead(): void {
const dead: string[] = [];
for (const [token, info] of byToken.entries()) {
if (Date.now() - info.registeredAt > TTL_MS) { dead.push(token); continue; }
try { process.kill(info.pid, 0); } catch { dead.push(token); }
}
for (const t of dead) deregisterByToken(t);
}
/** Test helper. */
export function _resetRegistry(): void {
byToken.clear();
bySessionId.clear();
}

View File

@@ -0,0 +1,56 @@
/**
* CLI-side session resolver. Reads the session token from env, asks
* the daemon `GET /v1/sessions/me`, and caches the result for the
* lifetime of this CLI invocation.
*
* Used by verbs that iterate multiple meshes client-side (peer list,
* me, member list) so that, when invoked from inside a launched
* session, they auto-scope to that session's workspace instead of
* aggregating across every joined mesh.
*
* Returns null when:
* - no token in env (caller is outside a launched session, or
* bare `claudemesh` with no installed daemon).
* - token present but daemon doesn't recognize it (registry was
* reset by a daemon restart).
* - any IPC error (treat as "no scoping info, fall back to default
* behavior").
*/
import { ipc } from "~/daemon/ipc/client.js";
import { readSessionTokenFromEnv } from "./token.js";
export interface ResolvedSession {
sessionId: string;
mesh: string;
displayName: string;
pid: number;
cwd?: string;
role?: string;
groups?: string[];
}
let cached: ResolvedSession | null | undefined = undefined;
export async function getSessionInfo(): Promise<ResolvedSession | null> {
if (cached !== undefined) return cached;
const tok = readSessionTokenFromEnv();
if (!tok) { cached = null; return null; }
try {
const res = await ipc<{ session?: ResolvedSession }>({
path: "/v1/sessions/me",
timeoutMs: 1_500,
});
if (res.status !== 200 || !res.body.session) { cached = null; return null; }
cached = res.body.session;
return cached;
} catch {
cached = null;
return null;
}
}
/** Test helper. */
export function _resetSessionCache(): void {
cached = undefined;
}

View File

@@ -0,0 +1,53 @@
/**
* Per-session IPC tokens — mint, persist, read.
*
* Each `claudemesh launch` mints a 32-byte random token, writes it to
* `<tmpdir>/session-token` (mode 0o600), and exposes the path to the
* spawned `claude` via `CLAUDEMESH_IPC_TOKEN_FILE`. Subprocesses
* inheriting this env auto-attach the token to every IPC request via
* the `Authorization: ClaudeMesh-Session <hex>` header. The daemon's
* registry resolves the token to `{sessionId, mesh, displayName, pid,
* cwd, ...}` in O(1) and uses it for auto-scoping + attribution.
*
* Why a file path env var, not the value directly:
* `ps eww -p <pid>` shows env values to other processes of the same
* uid. The path leaks; the secret in mode-0600 files inside a
* mode-0700 tmpdir does not. Same trick OpenSSH uses for SSH_AUTH_SOCK.
*/
import { randomBytes } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
const ENV_TOKEN_FILE = "CLAUDEMESH_IPC_TOKEN_FILE";
export interface MintedToken {
token: string;
/** Filesystem path the token was written to. Pass via env to children. */
filePath: string;
}
/** Generate a fresh 64-hex token and write it under `dir`. */
export function mintSessionToken(dir: string, fileName = "session-token"): MintedToken {
const token = randomBytes(32).toString("hex");
const filePath = `${dir}/${fileName}`;
writeFileSync(filePath, token, { mode: 0o600 });
return { token, filePath };
}
/** Read a token from the path in CLAUDEMESH_IPC_TOKEN_FILE, if present.
* Falls back to a literal CLAUDEMESH_IPC_TOKEN env value (for testing).
* Returns null when neither is set or the file is unreadable. */
export function readSessionTokenFromEnv(env: NodeJS.ProcessEnv = process.env): string | null {
const direct = env.CLAUDEMESH_IPC_TOKEN;
if (direct && /^[0-9a-f]{64}$/i.test(direct)) return direct.toLowerCase();
const path = env[ENV_TOKEN_FILE];
if (!path) return null;
try {
if (!existsSync(path)) return null;
const raw = readFileSync(path, "utf8").trim();
if (/^[0-9a-f]{64}$/i.test(raw)) return raw.toLowerCase();
return null;
} catch { return null; }
}
export const TOKEN_FILE_ENV = ENV_TOKEN_FILE;