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

@@ -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();
}