// claude-code's bundle (novox/hq design 36, ADR 0183). The node's runtime launches it over stdio, as the // operator account (ADR 0193), and is its bus (ADR 0198): it asks tools, emits and consumes through the // runtime. It is given its state directory and two files the mesh renders into it (ADR 0192), beside the // runtime's own words. **stdout is the MCP channel**: everything this module says, it says on stderr. // // At start it renders the agent's managed directory, asks the licence manager for this node's token, // begins watching the credentials file for a login, takes the manager's licence events, and watches the // module's `servers` state — every node's MCP server registrations (novox/hq ADR 0201). node.ts holds the // logic. import { mkdtempSync, readFileSync, rmSync, watchFile, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { broker } from "@novox/mesh-sdk/messaging"; import { on } from "@novox/mesh-sdk/events"; import { state } from "@novox/mesh-sdk/state"; import { MANAGED_DIR, SEAT, ServerView, concerns, keypair, offerLogin, onServerChange, pull, readJson, registerServer, registered, renderNow, type Ask, type Paths, type Registration, type ServerChange, type ServerState, type WriteManaged, } from "../node.js"; import { grantOf, holdsLogin, readCredentials } from "../grant.js"; import { createHash } from "node:crypto"; const say = (line: string) => console.error(`[claude-code] ${line}`); const fingerprint = (s: string) => "sha256:" + createHash("sha256").update(s).digest("hex").slice(0, 16); function pathsFrom(env: NodeJS.ProcessEnv): Paths | null { const state = env.MESH_CLAUDE_CODE_STATE, facts = env.MESH_CLAUDE_CODE_FACTS; const settings = env.MESH_CLAUDE_CODE_SETTINGS, home = env.MESH_OPERATOR_HOME, node = env.MESH_NODE; if (!state || !facts || !settings || !home || !node) return null; return { state, facts, settings, home, node }; } /** Write one managed file as root, only when its content changed. */ const writeManaged: WriteManaged = (name, content) => { const path = join(MANAGED_DIR, name); try { if (readFileSync(path, "utf8") === content) return `${name}: unchanged`; } catch { /* absent */ } // From a file, never /dev/stdin: Node hands a child its input over a socket, which /dev/stdin cannot // open (ENXIO) — found on the first assignment, where nothing under /etc/claude-code was ever written. const staged = mkdtempSync(join(tmpdir(), "claude-code-")); const source = join(staged, name); writeFileSync(source, content, { mode: 0o644 }); const asRoot = process.getuid?.() === 0; const cmd = asRoot ? ["install", "-D", "-m", "0644", source, path] : ["sudo", "-n", "install", "-D", "-m", "0644", source, path]; const r = spawnSync(cmd[0], cmd.slice(1), { encoding: "utf8" }); rmSync(staged, { recursive: true, force: true }); if (r.status !== 0) { throw new Error(`${name}: could not be written to ${MANAGED_DIR} (${(r.stderr || r.error?.message || "").trim()}); ` + `the module writes there through the operator account's passwordless sudo`); } return `${name}: written`; }; /** A tool on the bus, through the runtime; its MCP answer read back as JSON where it is JSON. */ const ask: Ask = async (address, args) => { const answer = (await broker().request, { content?: { text?: string }[]; isError?: boolean }>(address, args)) ?? {}; const text = answer.content?.map((c) => c.text ?? "").join("") ?? ""; if (answer.isError) throw new Error(`${address}: ${text}`); try { return JSON.parse(text); } catch { return text; } }; /** The nodes claude-code runs on, from the controller's list of modules — for the register tool's question. */ async function nodesRunningMe(): Promise { const out = await ask("mesh-controller.modules", {}); const text = typeof out === "string" ? out : String((out as { output?: string })?.output ?? ""); const line = text.split("\n").find((l) => /^claude-code\s/.test(l)) ?? ""; const on = line.split(" on ")[1] ?? ""; return on.trim() === "nothing" ? [] : on.split(",").map((s) => s.trim()).filter(Boolean); } function status(p: Paths): Record { const creds = readCredentials(join(p.home, ".claude", ".credentials.json")); const grant = grantOf(creds); const managed = ["managed-mcp.json", "managed-settings.json", "CLAUDE.md"].map((f) => { try { return { file: join(MANAGED_DIR, f), fingerprint: fingerprint(readFileSync(join(MANAGED_DIR, f), "utf8")) }; } catch { return { file: join(MANAGED_DIR, f), fingerprint: null }; } }); return { node: p.node, licence: readJson(join(p.state, "licence.json"), null), token: grant ? { fingerprint: fingerprint(grant.accessToken), expiresAt: new Date(grant.expiresAt).toISOString(), loginWaiting: holdsLogin(creds) } : null, managed, registered: Object.keys(registered(p)), }; } /** The module's MCP servers on the bus (ADR 0201): its own state, which every node of it watches. */ const servers = () => state>("servers") as unknown as ServerState; /** What this node takes from that state, kept from the watch. One per process. */ let view: ServerView | null = null; const viewOf = (p: Paths) => (view ??= new ServerView(p)); function tools(p: Paths): ToolDefinition[] { const nodesArg = { type: "string", description: 'more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only' }; const nodesOf = (v: unknown): Registration["nodes"] => v === undefined || v === "" ? undefined : v === "all" ? "all" : String(v).split(",").map((s) => s.trim()).filter(Boolean); return [ { name: "claude_code_status", description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, the managed files, the MCP servers registered here. Fingerprints only, never a token.", input: {}, run: async () => status(p), }, { name: "claude_code_render", description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here.", input: {}, run: async () => ({ rendered: renderNow(p, writeManaged) }), }, { name: "claude_code_pull", description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its next event.", input: {}, run: async () => pull(p, ask, writeManaged), }, { name: "claude_code_mcp_list", description: "The MCP servers registered through this module: those that apply on this node (beside the console, `mesh`, and those set in the module's settings), and every registration on the mesh, by key — `all.` for every node, `.` for one.", input: {}, run: async () => ({ here: registered(p), everywhere: await servers().keys() }), }, { name: "claude_code_mcp_register", description: "Register an MCP server with Claude Code on this node, every node, or a list — an http/sse server by url, or a stdio server by command. Kept on the bus, so a node that joins later takes it too. Never put a secret in env or headers: the mesh refuses one.", input: { name: { type: "string", description: "the server's name: letters, digits, - and _" }, type: { type: "string", description: "http, sse or stdio (default stdio when a command is given, http when a url is)" }, url: { type: "string", description: "an http or sse server's url" }, command: { type: "string", description: "a stdio server's program" }, args: { type: "array", description: "a stdio server's arguments" }, env: { type: "object", description: "a stdio server's environment" }, headers: { type: "object", description: "an http server's headers" }, nodes: nodesArg, }, run: async (a) => { const entry: Record = { type: a.type ?? (a.url ? "http" : "stdio") }; for (const k of ["url", "command", "args", "env", "headers"]) if (a[k] !== undefined) entry[k] = a[k]; return registerServer(p, { name: String(a.name ?? ""), entry, nodes: nodesOf(a.nodes) }, servers(), viewOf(p), writeManaged, nodesRunningMe); }, }, { name: "claude_code_mcp_unregister", description: "Remove an MCP server registered through this module, on this node or more.", input: { name: { type: "string", description: "the server's name" }, nodes: nodesArg }, run: async (a) => registerServer(p, { name: String(a.name ?? ""), nodes: nodesOf(a.nodes) }, servers(), viewOf(p), writeManaged, nodesRunningMe), }, ]; } registerModuleTools("claude-code", (env) => { const p = pathsFrom(env); if (!p) return []; try { keypair(p); for (const line of renderNow(p, writeManaged)) if (!line.endsWith("unchanged")) say(line); } catch (err) { say(err instanceof Error ? err.message : String(err)); } return tools(p); }); // Launched by the runtime: the bus is there from the first line (ADR 0198). Outside it — a test, a // build — nothing below runs. const p = process.env.MESH_SERVED_MODULE ? pathsFrom(process.env) : null; if (p) { const loud = (what: string) => (err: unknown) => say(`${what}: ${err instanceof Error ? err.message : String(err)}`); void on<{ licence?: string; node?: string }>("claude-licence-manager.licence.*", async (event) => { if (!concerns(p, event.type, event.body ?? {})) return; say(`${event.type} — asking ${SEAT} for this node's token`); say(JSON.stringify(await pull(p, ask, writeManaged).catch((e) => ({ failed: String(e) })))); }).catch(loud("the licence events")); // Every node's MCP servers: the whole current set first, then each change (ADR 0201). **Not awaited // where the module is imported**: the runtime waits on the handshake, and a bucket that is not on the // bus yet — or a grant the bus has not reloaded — answers late; awaited here, that left the bundle // unable to answer `initialize` in time and the module unserved (found on its first assignment). So it // watches beside the handshake and asks again until the state answers; until then the managed // directory holds what the file kept from the last run. const watchServers = (attempt = 0): void => { state>("servers").watch((c) => { try { const done = onServerChange(viewOf(p), c as ServerChange, p, writeManaged); if (done) say(done); } catch (err) { loud(`taking ${c.op} ${c.key}`)(err); // the view took it; the next render writes it } }).then( () => say(`watching the MCP servers${attempt ? ` (after ${attempt} refusal(s))` : ""}`), (err) => { const wait = [2, 5, 10, 30][attempt] ?? 60; say(`the MCP servers cannot be watched yet (${err instanceof Error ? err.message : String(err)}); asking again in ${wait}s`); setTimeout(() => watchServers(attempt + 1), wait * 1000); }); }; watchServers(); // Catch up once at start: a node that was off takes its current token now. void pull(p, ask, writeManaged).then((r) => say(`at start: ${JSON.stringify(r)}`), loud("asking for this node's token at start")); // A login: a refresh token appears in the credentials file. Polled, because the file is replaced by // rename and a watch on the old inode would go quiet. const credentials = join(p.home, ".claude", ".credentials.json"); watchFile(credentials, { interval: 5000 }, () => { void offerLogin(p, ask).then((r) => { if (r) say(`a login here was offered to ${SEAT}: ${JSON.stringify(r)}`); }, loud("offering a login to the licence manager")); }); }