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:
@@ -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
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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,
|
||||
|
||||
98
apps/cli/src/daemon/session-registry.ts
Normal file
98
apps/cli/src/daemon/session-registry.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user