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:
@@ -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 `<tmpdir>/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<token,
|
||||
SessionInfo>` 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
|
||||
<hex>` 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=<slug>` (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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
56
apps/cli/src/services/session/resolve.ts
Normal file
56
apps/cli/src/services/session/resolve.ts
Normal 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;
|
||||
}
|
||||
53
apps/cli/src/services/session/token.ts
Normal file
53
apps/cli/src/services/session/token.ts
Normal 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;
|
||||
Reference in New Issue
Block a user