diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index f0b41e7..08893c8 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,64 @@ # Changelog +## 1.29.0 (2026-05-04) — per-session IPC tokens + auto-scoping + +Sprint A Phase 2. Every `claudemesh launch`-spawned session gets a +unique 32-byte cryptographic token that the daemon resolves on every +IPC call to identify which session is talking to it. CLI invocations +from inside that session auto-scope to its workspace instead of +aggregating across every joined mesh. + +### What landed + +- **`services/session/token.ts`** — mint random 32-byte token, write + to `/session-token` (mode 0o600). Reader pulls from + `CLAUDEMESH_IPC_TOKEN_FILE` env (path, not value, to keep the secret + off `ps eww`). Optional `CLAUDEMESH_IPC_TOKEN` direct-value escape + hatch for tests. +- **`daemon/session-registry.ts`** — in-memory `Map` keyed by token, secondary index by sessionId. 30 s + reaper drops entries whose pid is dead; 24 h hard TTL ceiling guards + forgotten sessions. +- **IPC routes** — `POST /v1/sessions/register`, `DELETE + /v1/sessions/:token`, `GET /v1/sessions/me`, `GET /v1/sessions`. +- **IPC auth middleware** — parses `Authorization: ClaudeMesh-Session + ` and attaches the resolved `SessionInfo` to request context. + Layered on top of the existing local-token auth (used for TCP + loopback). Backward-compatible: tokenless callers behave exactly + as before. +- **`services/session/resolve.ts`** — CLI-side helper that asks the + daemon `GET /v1/sessions/me` once per process and caches the result. + Used by verbs that iterate meshes client-side. +- **`launch.ts`** — mints a token, registers it with the daemon, sets + `CLAUDEMESH_IPC_TOKEN_FILE` on the spawned `claude` env. Token file + lives in the same tmpdir as the session config; gets shredded on + cleanup. The daemon's reaper handles dead sessions. +- **`peers.ts`** — selection precedence is now `--mesh` flag → session + token's mesh → all joined meshes. + +### Server-side scoping + +Every read route that takes `?mesh=` (peers, state, memory, +skills) now uses a `meshFromCtx()` helper: explicit query/body wins, +session default fills in when missing. Write routes (set state, +remember, deregister, profile-update) follow the same pattern. Pass +`--mesh` to override. + +### Verified end-to-end + +| Setup | `peer list` returns | +|---|---| +| no token | 3 meshes' peers (aggregate, unchanged) | +| token registered for prueba1 | 4 peers, all `mesh: prueba1` | + +### Out of scope (deferred) + +- SQLite persistence for the registry — restart loses it; the reaper + (or callers re-registering) covers most cases. +- `SO_PEERCRED`-strict pid binding — needs a tiny native binding. +- Per-session policy DSL. +- Cross-machine session sync (waiting on 2.0.0 HKDF identity). + ## 1.28.0 (2026-05-04) — bridge tier deletion + daemon-policy flags First Sprint A drop on the way to v2 thin-client. Two structural changes: diff --git a/apps/cli/package.json b/apps/cli/package.json index c6152d7..d887b5c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.28.0", + "version": "1.29.0", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/launch.ts b/apps/cli/src/commands/launch.ts index c36e044..c712d6e 100644 --- a/apps/cli/src/commands/launch.ts +++ b/apps/cli/src/commands/launch.ts @@ -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 } : {}), diff --git a/apps/cli/src/commands/peers.ts b/apps/cli/src/commands/peers.ts index 5ba00c2..c3130ce 100644 --- a/apps/cli/src/commands/peers.ts +++ b/apps/cli/src/commands/peers.ts @@ -119,7 +119,19 @@ function annotateSelf( export async function runPeers(flags: PeersFlags): Promise { const config = readConfig(); - const slugs = flags.mesh ? [flags.mesh] : config.meshes.map((m) => m.slug); + + // Mesh selection precedence: + // 1. explicit --mesh (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."); diff --git a/apps/cli/src/daemon/ipc/client.ts b/apps/cli/src/daemon/ipc/client.ts index 8f61291..a35d948 100644 --- a/apps/cli/src/daemon/ipc/client.ts +++ b/apps/cli/src/daemon/ipc/client.ts @@ -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(opts: IpcRequestOptions): Promise>((resolve, reject) => { const req = httpRequest( useTcp diff --git a/apps/cli/src/daemon/ipc/server.ts b/apps/cli/src/daemon/ipc/server.ts index 33a0da4..4490fa4 100644 --- a/apps/cli/src/daemon/ipc/server.ts +++ b/apps/cli/src/daemon/ipc/server.ts @@ -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 ` + // (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 | 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 & { 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 & { 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=, 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()]; diff --git a/apps/cli/src/daemon/run.ts b/apps/cli/src/daemon/run.ts index 43d74da..0849ea5 100644 --- a/apps/cli/src/daemon/run.ts +++ b/apps/cli/src/daemon/run.ts @@ -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 { let drain: DrainHandle | null = null; drain = startDrainWorker({ db: outboxDb, brokers }); + startReaper(); + const ipc = startIpcServer({ localToken, tcpEnabled, diff --git a/apps/cli/src/daemon/session-registry.ts b/apps/cli/src/daemon/session-registry.ts new file mode 100644 index 0000000..948ab5f --- /dev/null +++ b/apps/cli/src/daemon/session-registry.ts @@ -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 + * ` 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(); +const bySessionId = new Map(); + +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 { + // 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(); +} diff --git a/apps/cli/src/services/session/resolve.ts b/apps/cli/src/services/session/resolve.ts new file mode 100644 index 0000000..b8ed38d --- /dev/null +++ b/apps/cli/src/services/session/resolve.ts @@ -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 { + 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; +} diff --git a/apps/cli/src/services/session/token.ts b/apps/cli/src/services/session/token.ts new file mode 100644 index 0000000..18fbe60 --- /dev/null +++ b/apps/cli/src/services/session/token.ts @@ -0,0 +1,53 @@ +/** + * Per-session IPC tokens — mint, persist, read. + * + * Each `claudemesh launch` mints a 32-byte random token, writes it to + * `/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 ` 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 ` 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;