From 6a7e4ebd5e9b37b9abfe44e989f54bc0f1132a2e Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 02:23:23 +0200 Subject: [PATCH] claude-code over NATS: licence events, a token by request, a login pushed to the manager, MCP servers registered per node or mesh-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Events carry what happened and no secret; tokens travel on requests (design 32 §10). The manager's licence.rotated/switched events make the module ask anthropic-licence-manager.current; at start it asks once to catch up. A refresh token appearing in the credentials file is a login: it is pushed to the manager's adopt at once, sealed to the manager's key — the one time a refresh token travels. A switch replaces the old licence's grant whole, removes the API key and its helper, and rewrites oauthAccount in ~/.claude.json. New tools register and unregister MCP servers on this node, or with nodes: all / a list via an mcp.registered event every node consumes; called for one node, the answer names the other nodes running claude-code. 26 tests. --- modules/claude-code/README.md | 22 ++- modules/claude-code/grant.ts | 12 ++ modules/claude-code/identity.ts | 29 ++- modules/claude-code/module.json | 20 +- modules/claude-code/node.ts | 208 +++++++++++++++++++ modules/claude-code/package.json | 2 +- modules/claude-code/render.ts | 27 ++- modules/claude-code/test/node.test.ts | 119 +++++++++++ modules/claude-code/tools/index.ts | 274 +++++++++++--------------- modules/claude-code/tsconfig.json | 2 +- 10 files changed, 548 insertions(+), 167 deletions(-) create mode 100644 modules/claude-code/node.ts create mode 100644 modules/claude-code/test/node.test.ts diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index f4af228..55de6ed 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -28,12 +28,32 @@ whenever the node's tool runtime collects the module's tools: Under the operator's home, only `~/.claude/.credentials.json`, and only when the licence manager hands this node a subscription token. Nothing else under the home is read or written. +## Over NATS + +Everything between this module and the rest of the mesh is NATS, in two kinds: an **event** says that +something happened and carries no secret, because a stream keeps it; a **request** carries a token, +because nothing keeps it (hq design 32 §10). + +| what | how | +|---|---| +| the licence manager rotated a licence, or switched this node | its `licence.rotated` / `licence.switched` event; this module then asks `anthropic-licence-manager.current` for its token, sealed to the key it sends | +| this node starts | it asks `current` once, so a node that was off catches up | +| a person ran `/login` here | the credentials file gains a refresh token this module never writes; it asks `anthropic-licence-manager.adopt` at once with the grant sealed to the manager's key — the one time a refresh token travels, because the login made the manager's stale | +| an MCP server registered for more nodes than this one | an `mcp.registered` / `mcp.unregistered` event every node's claude-code consumes; a node that was off takes it when it is back | + +## Tools + +`claude_code_status`, `claude_code_render`, `claude_code_pull`, `claude_code_mcp_list`, +`claude_code_mcp_register` (this node by default; `nodes: "all"` or a list for more — called for this +node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`. + ## Settings Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`: - `role` — what this node is, in a few words; shown to every session. -- `mcp_servers` — extra tool servers, keyed by name, in the vendor's `.mcp.json` entry shape +- `mcp_servers` — extra tool servers, set by the operator for the mesh or a node, beside the ones + registered through the tools; keyed by name, in the vendor's `.mcp.json` entry shape (`{"type":"http","url":…}` or `{"type":"stdio","command":…,"args":[…]}`). The name `mesh` is the module's own and cannot be set. Put a person's own servers here, or they stop loading. diff --git a/modules/claude-code/grant.ts b/modules/claude-code/grant.ts index d9c060e..3b425d8 100644 --- a/modules/claude-code/grant.ts +++ b/modules/claude-code/grant.ts @@ -76,6 +76,18 @@ export function holdsLogin(creds: Credentials | null): boolean { return typeof creds?.claudeAiOauth?.refreshToken === "string" && creds.claudeAiOauth.refreshToken.length > 0; } +/** + * The handed grant laid over what is there — a rotation of the licence the node already holds — or, + * for a switch, in place of it: the old licence's grant goes whole, scopes and subscription included, + * and only keys outside the grant (another kind of credential the vendor keeps in the file) stay. + * Either way, no refresh token survives. + */ +export function replacedBy(local: Credentials | null, grant: Grant): Credentials { + const next: Credentials = { ...(local ?? {}) }; + delete next.claudeAiOauth; + return withGrant(next, grant); +} + /** Overlay the handed grant on what is there, and delete any refresh token. */ export function withGrant(local: Credentials | null, grant: Grant): Credentials { const next: Credentials = { ...(local ?? {}) }; diff --git a/modules/claude-code/identity.ts b/modules/claude-code/identity.ts index d9405b0..aa526a9 100644 --- a/modules/claude-code/identity.ts +++ b/modules/claude-code/identity.ts @@ -1,7 +1,10 @@ // Which account the agent is logged in as (novox/hq ADR 0183): not in the token, but in the agent's -// own state file beside the home, `~/.claude.json` → `oauthAccount`. Read, never written. +// own state file beside the home, `~/.claude.json` → `oauthAccount`. Read to attribute a login; written, +// three keys and nothing else, when a licence is switched, so the file Claude Code shows the account from +// names the account whose token it now holds (as the predecessor learned: two files that disagree make +// a later login look like the wrong account). -import { readFileSync } from "node:fs"; +import { readFileSync, renameSync, writeFileSync } from "node:fs"; export interface Identity { readonly accountUuid: string; @@ -23,3 +26,25 @@ export function readIdentity(stateFile: string): Identity | null { return null; } } + +/** + * Point the state file's account at `id`, keeping every other key as found. Returns whether the file + * changed; a file that cannot be read as an object is left alone rather than replaced. + */ +export function writeIdentity(stateFile: string, id: Identity): boolean { + let raw: Record; + try { + raw = JSON.parse(readFileSync(stateFile, "utf8")) as Record; + if (!raw || typeof raw !== "object") return false; + } catch { + raw = {}; + } + const current = (raw.oauthAccount ?? {}) as Record; + if (current.accountUuid === id.accountUuid && current.emailAddress === id.emailAddress + && current.organizationUuid === id.organizationUuid) return false; + raw.oauthAccount = { ...current, accountUuid: id.accountUuid, emailAddress: id.emailAddress, organizationUuid: id.organizationUuid }; + const tmp = `${stateFile}.mesh-tmp`; + writeFileSync(tmp, JSON.stringify(raw, null, 2), { mode: 0o600 }); + renameSync(tmp, stateFile); + return true; +} diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 74eec69..dbea321 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -11,12 +11,26 @@ "binds": { "mcp-endpoint": "${dir:state}/mcp-endpoint.json" }, + "emits": [ + "mcp.registered", + "mcp.unregistered" + ], + "consumes": [ + "claude-code.mcp.registered", + "claude-code.mcp.unregistered", + "claude-licence-manager.licence.rotated", + "claude-licence-manager.licence.switched" + ], + "uses": [ + "anthropic-licence-manager" + ], "tools": [ "claude_code_status", "claude_code_render", - "claude_code_public_key", - "claude_code_apply", - "claude_code_pending_login" + "claude_code_pull", + "claude_code_mcp_list", + "claude_code_mcp_register", + "claude_code_mcp_unregister" ], "resources": [ { diff --git a/modules/claude-code/node.ts b/modules/claude-code/node.ts new file mode 100644 index 0000000..6b1be5b --- /dev/null +++ b/modules/claude-code/node.ts @@ -0,0 +1,208 @@ +// What claude-code does on a node, written against two things it is handed — a way to ask a tool on the +// bus and a way to emit an event — so every path is tested without a bus (novox/hq design 36 §4–§5, +// ADR 0183, ADR 0198). +// +// **Over NATS, in two kinds** (design 32 §10): an event says that something happened and carries no +// secret, because a stream keeps it; a token travels on a request, which nothing keeps. So: +// - the licence manager's `licence.rotated` and `licence.switched` events tell this module to ask the +// seat for its current token, sealed to the key it sends with the request; +// - a login a person made here — a refresh token this module never writes — is offered to the seat at +// once, sealed to the seat's key: the one moment a refresh token travels, because the login made the +// manager's stale; +// - an MCP server registered for more nodes than this one is an `mcp.registered` event every node's +// claude-code consumes, so a node that was off takes it when it is back. + +import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { render, entryProblem, MANAGED_DIR, type Binding, type Facts, type Settings, type Servers } from "./render.js"; +import { generateKeyPair, open, seal, type SealedBox } from "./seal.js"; +import { decideApply, grantOf, holdsLogin, readCredentials, replacedBy, withGrant, writeCredentials, type Grant } from "./grant.js"; +import { readIdentity, writeIdentity, type Identity } from "./identity.js"; + +export const SEAT = "anthropic-licence-manager"; + +export interface Paths { + state: string; + facts: string; + settings: string; + home: string; + node: string; +} + +/** A tool on the bus: its address and arguments in, its JSON answer out. */ +export type Ask = (address: string, args: Record) => Promise; +/** An event of this module's, by its local name. */ +export type Emit = (type: string, body: unknown) => Promise; +/** Write one managed file; answers what happened. */ +export type WriteManaged = (name: string, content: string) => string; + +export const readJson = (p: string, fallback: T): T => { + try { + return JSON.parse(readFileSync(p, "utf8")) as T; + } catch { + return fallback; + } +}; + +const credentialsPath = (p: Paths) => join(p.home, ".claude", ".credentials.json"); +const accountPath = (p: Paths) => join(p.home, ".claude.json"); +const bindingPath = (p: Paths) => join(p.state, "licence.json"); +const apiKeyPath = (p: Paths) => join(p.state, "api-key"); +export const helperPath = (p: Paths) => join(p.state, "api-key-helper"); +const keyPath = (p: Paths) => join(p.state, "key.pem"); +const pubPath = (p: Paths) => join(p.state, "key.pub.pem"); +const registryPath = (p: Paths) => join(p.state, "mcp-servers.json"); + +export function keypair(p: Paths): { publicKey: string; privateKey: string } { + if (!existsSync(keyPath(p))) { + const k = generateKeyPair(); + writeFileSync(keyPath(p), k.privateKey, { mode: 0o600 }); + writeFileSync(pubPath(p), k.publicKey, { mode: 0o644 }); + } + return { privateKey: readFileSync(keyPath(p), "utf8"), publicKey: readFileSync(pubPath(p), "utf8") }; +} + +export function registered(p: Paths): Servers { + return readJson(registryPath(p), {}); +} + +export function renderNow(p: Paths, write: WriteManaged): string[] { + const facts = readJson(p.facts, null); + if (!facts?.console) throw new Error(`the mesh has not rendered ${p.facts} yet; nothing to write`); + const files = render(facts, readJson(p.settings, {}), readJson(bindingPath(p), null), + helperPath(p), registered(p)); + return Object.entries(files).map(([name, content]) => write(name, content)); +} + +// ---- the licence ---------------------------------------------------------------------------------- + +/** What the seat answers to `current`: the licence this node is bound to and its token, sealed. */ +export interface Current { + licence: string; + kind: "subscription" | "api-key"; + sealed: SealedBox; + identity?: Identity | null; +} + +/** Ask the seat for this node's current token and apply it. */ +export async function pull(p: Paths, ask: Ask, write: WriteManaged): Promise> { + const answer = (await ask(`${SEAT}.current`, { node: p.node, public_key: keypair(p).publicKey })) as Current | null; + if (!answer?.sealed) return { applied: false, reason: "the seat holds no licence for this node" }; + return apply(p, answer, write); +} + +/** Apply what the seat handed over. A switch replaces the grant whole and cleans up after the old licence. */ +export function apply(p: Paths, handed: Current, write: WriteManaged): Record { + const plain = open(handed.sealed, keypair(p).privateKey); + const previous = readJson(bindingPath(p), null); + const switched = previous?.licence !== handed.licence; + let outcome: Record = { applied: true, licence: handed.licence, kind: handed.kind, switched }; + if (handed.kind === "api-key") { + writeFileSync(apiKeyPath(p), plain.trim() + "\n", { mode: 0o600 }); + writeFileSync(helperPath(p), `#!/bin/sh\nexec cat '${apiKeyPath(p)}'\n`, { mode: 0o700 }); + chmodSync(helperPath(p), 0o700); + } else { + const grant = JSON.parse(plain) as Grant; + const local = readCredentials(credentialsPath(p)); + const d = decideApply(grantOf(local), grant, switched ? "switch" : "rotation"); + if (d.apply) writeCredentials(credentialsPath(p), switched ? replacedBy(local, grant) : withGrant(local, grant)); + else outcome = { applied: false, licence: handed.licence, reason: d.reason }; + // Away from the API key: it goes, with its helper. + rmSync(apiKeyPath(p), { force: true }); + rmSync(helperPath(p), { force: true }); + } + if (switched && handed.identity?.accountUuid) { + outcome.account = writeIdentity(accountPath(p), handed.identity) ? "updated" : "unchanged"; + } + writeFileSync(bindingPath(p), JSON.stringify({ licence: handed.licence, kind: handed.kind }) + "\n", { mode: 0o600 }); + try { + outcome.rendered = renderNow(p, write); // the key-helper comes or goes with the licence's kind + } catch (err) { + outcome.rendered = { failed: err instanceof Error ? err.message : String(err) }; + } + return outcome; +} + +/** A licence event from the manager: is it for this node? */ +export function concerns(p: Paths, type: string, body: { licence?: string; node?: string }): boolean { + if (type.endsWith("licence.switched")) return body.node === p.node; + if (type.endsWith("licence.rotated")) return body.licence === readJson(bindingPath(p), null)?.licence; + return false; +} + +/** A refresh token in the credentials file is a login: this module never writes one. Offer it to the seat. */ +export async function offerLogin(p: Paths, ask: Ask): Promise | null> { + const creds = readCredentials(credentialsPath(p)); + if (!holdsLogin(creds)) return null; + const key = (await ask(`${SEAT}.public_key`, {})) as { public_key?: string } | null; + if (!key?.public_key) throw new Error("the licence manager did not say what key to seal a login to"); + return (await ask(`${SEAT}.adopt`, { + node: p.node, + identity: readIdentity(accountPath(p)), + sealed: seal(JSON.stringify(creds!.claudeAiOauth), key.public_key), + })) as Record; +} + +// ---- MCP servers ---------------------------------------------------------------------------------- + +export interface Registration { + name: string; + entry?: Record; + /** Which nodes: this one (absent), every node running the module ("all"), or a list. */ + nodes?: "all" | string[]; +} + +function setRegistered(p: Paths, name: string, entry: Record | null): boolean { + const list = { ...registered(p) } as Record>; + const before = JSON.stringify(list[name] ?? null); + if (entry) list[name] = entry; + else delete list[name]; + if (JSON.stringify(list[name] ?? null) === before) return false; + writeFileSync(registryPath(p), JSON.stringify(list, null, 2) + "\n", { mode: 0o600 }); + return true; +} + +const targets = (p: Paths, nodes: Registration["nodes"]) => + nodes === "all" ? true : Array.isArray(nodes) ? nodes.includes(p.node) : false; + +/** Register (or with no entry, unregister) here, and announce it for the other nodes asked for. */ +export async function registerServer(p: Paths, r: Registration, emit: Emit, write: WriteManaged, + others: () => Promise): Promise> { + if (r.entry) { + const problem = entryProblem(r.name, r.entry); + if (problem) return { registered: false, reason: problem }; + } + const here = r.nodes === undefined || targets(p, r.nodes); + const changed = here ? setRegistered(p, r.name, r.entry ?? null) : false; + const rendered = here && changed ? renderNow(p, write) : []; + if (r.nodes !== undefined) { + await emit(r.entry ? "mcp.registered" : "mcp.unregistered", { name: r.name, entry: r.entry ?? null, nodes: r.nodes }); + } + const answer: Record = { + [r.entry ? "registered" : "unregistered"]: r.name, + on: r.nodes === undefined ? [p.node] : r.nodes, + here: here ? (changed ? "changed" : "already so") : "not this node", + rendered, + }; + if (r.nodes === undefined) { + // The question the operator wanted asked: here only, or more? + const elsewhere = (await others().catch(() => [] as string[])).filter((n) => n !== p.node); + answer.also = elsewhere.length + ? `claude-code also runs on ${elsewhere.join(", ")}. To ${r.entry ? "register" : "unregister"} it there too, call again with nodes: "all" or a list of those nodes.` + : `To do the same on every node running claude-code, call again with nodes: "all".`; + } + return answer; +} + +/** An `mcp.registered`/`mcp.unregistered` event from any node's claude-code: apply it if it names this node. */ +export function onServerEvent(p: Paths, type: string, body: Registration, write: WriteManaged): string | null { + if (!body?.name || !targets(p, body.nodes)) return null; + const entry = type.endsWith("mcp.registered") ? body.entry ?? null : null; + if (entry && entryProblem(body.name, entry)) return null; + if (!setRegistered(p, body.name, entry)) return null; + renderNow(p, write); + return `${entry ? "registered" : "unregistered"} ${body.name} from an event`; +} + +export { MANAGED_DIR }; diff --git a/modules/claude-code/package.json b/modules/claude-code/package.json index a903c2b..fca3599 100644 --- a/modules/claude-code/package.json +++ b/modules/claude-code/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "scripts": { - "build": "tsc seal.ts grant.ts identity.ts render.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist", + "build": "tsc seal.ts grant.ts identity.ts render.ts node.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist", "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'" }, "dependencies": { diff --git a/modules/claude-code/render.ts b/modules/claude-code/render.ts index f33aed0..bdc361b 100644 --- a/modules/claude-code/render.ts +++ b/modules/claude-code/render.ts @@ -36,11 +36,30 @@ export interface Rendered { const MESH_ENTRY = "mesh"; -export function render(facts: Facts, settings: Settings, binding: Binding | null, helperPath: string): Rendered { +export type Servers = Readonly>>; + +/** Whether an entry is one the vendor's managed file takes: a name of letters, digits, `-` and `_`, and + * an http/sse server with a url or a stdio server with a command. Returns why not, or null. */ +export function entryProblem(name: string, entry: Record): string | null { + if (!/^[A-Za-z0-9_-]+$/.test(name)) return `"${name}" is not a name the agent takes: letters, digits, - and _`; + if (name === MESH_ENTRY) return `"${MESH_ENTRY}" is the mesh's own entry`; + const type = entry?.type ?? "stdio"; + if (type === "http" || type === "sse" || type === "streamable-http") { + return typeof entry.url === "string" && entry.url ? null : `an ${type} server needs a url`; + } + if (type === "stdio") return typeof entry.command === "string" && entry.command ? null : "a stdio server needs a command"; + return `"${String(type)}" is not a server type the agent knows (http, sse, stdio)`; +} + +/** + * Compose the three files. `registered` is the module's own list on this node — what was registered + * through its tools — laid over the servers the operator set in its settings. + */ +export function render(facts: Facts, settings: Settings, binding: Binding | null, helperPath: string, + registered: Servers = {}): Rendered { const servers: Record = {}; - for (const [name, entry] of Object.entries(settings.mcp_servers ?? {})) { - if (name === MESH_ENTRY) continue; // the mesh's own entry is the mesh's; a setting cannot replace it - if (!/^[A-Za-z0-9_-]+$/.test(name)) continue; + for (const [name, entry] of Object.entries({ ...(settings.mcp_servers ?? {}), ...registered })) { + if (entryProblem(name, entry) !== null) continue; // the mesh's own entry, or one the agent would refuse servers[name] = entry; } servers[MESH_ENTRY] = { type: "http", url: facts.console }; diff --git a/modules/claude-code/test/node.test.ts b/modules/claude-code/test/node.test.ts new file mode 100644 index 0000000..50d57dd --- /dev/null +++ b/modules/claude-code/test/node.test.ts @@ -0,0 +1,119 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + apply, concerns, keypair, offerLogin, onServerEvent, pull, registerServer, registered, type Paths, +} from "../dist/node.js"; +import { generateKeyPair, open, seal } from "../dist/seal.js"; + +const NOW = Date.now(); +function node(name = "laptop"): { p: Paths; written: Record } { + const root = mkdtempSync(join(tmpdir(), "cc-node-")); + const p = { state: join(root, "state"), facts: join(root, "state", "facts.json"), settings: join(root, "state", "settings.json"), home: join(root, "home"), node: name }; + mkdirSync(p.state, { recursive: true }); + mkdirSync(join(p.home, ".claude"), { recursive: true }); + writeFileSync(p.facts, JSON.stringify({ node: name, console: "http://127.0.0.1:4270/mcp" })); + writeFileSync(p.settings, JSON.stringify({ role: "", mcp_servers: {} })); + return { p, written: {} }; +} +const writer = (w: Record) => (name: string, content: string) => { w[name] = content; return `${name}: written`; }; +const creds = (p: Paths) => JSON.parse(readFileSync(join(p.home, ".claude", ".credentials.json"), "utf8")); +const grantFor = (p: Paths, licence: string, token: string, kind: "subscription" | "api-key" = "subscription", identity?: object) => ({ + licence, kind, identity, + sealed: seal(kind === "api-key" ? token : JSON.stringify({ accessToken: token, expiresAt: NOW + 3_600_000, refreshTokenExpiresAt: NOW + 86_400_000, subscriptionType: licence }), keypair(p).publicKey), +}); + +test("a pull asks the seat with this node's key and applies what it answers", async () => { + const { p, written } = node(); + let asked: [string, Record] | null = null; + const r = await pull(p, async (address, args) => { asked = [address, args]; return grantFor(p, "personal", "at-1"); }, writer(written)); + assert.equal(asked![0], "anthropic-licence-manager.current"); + assert.equal(asked![1].node, "laptop"); + assert.match(String(asked![1].public_key), /BEGIN PUBLIC KEY/); + assert.equal(r.applied, true); + assert.equal(creds(p).claudeAiOauth.accessToken, "at-1"); + assert.ok(written["managed-mcp.json"]); +}); + +test("a switch replaces the old licence's grant whole and points the account at the new one", () => { + const { p, written } = node(); + writeFileSync(join(p.home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "old" }, projects: { keep: 1 } })); + apply(p, grantFor(p, "personal", "at-1"), writer(written)); + const r = apply(p, grantFor(p, "work", "at-2", "subscription", { accountUuid: "new", emailAddress: "w@example.org" }), writer(written)); + assert.equal(r.switched, true); + assert.equal(creds(p).claudeAiOauth.accessToken, "at-2"); + assert.equal(creds(p).claudeAiOauth.subscriptionType, "work", "the old licence's subscription type survived the switch"); + const account = JSON.parse(readFileSync(join(p.home, ".claude.json"), "utf8")); + assert.equal(account.oauthAccount.accountUuid, "new"); + assert.deepEqual(account.projects, { keep: 1 }); +}); + +test("switching to the API key adds the key-helper; switching away removes the key and the helper", () => { + const { p, written } = node(); + apply(p, grantFor(p, "api", "sk-key", "api-key"), writer(written)); + assert.ok(JSON.parse(written["managed-settings.json"]).apiKeyHelper); + assert.ok(existsSync(join(p.state, "api-key"))); + apply(p, grantFor(p, "personal", "at-1"), writer(written)); + assert.ok(!("apiKeyHelper" in JSON.parse(written["managed-settings.json"]))); + assert.ok(!existsSync(join(p.state, "api-key")) && !existsSync(join(p.state, "api-key-helper"))); +}); + +test("a rotation event concerns the node bound to that licence; a switch event the node it names", () => { + const { p, written } = node(); + apply(p, grantFor(p, "personal", "at-1"), writer(written)); + assert.equal(concerns(p, "claude-licence-manager.licence.rotated", { licence: "personal" }), true); + assert.equal(concerns(p, "claude-licence-manager.licence.rotated", { licence: "work" }), false); + assert.equal(concerns(p, "claude-licence-manager.licence.switched", { node: "laptop", licence: "work" }), true); + assert.equal(concerns(p, "claude-licence-manager.licence.switched", { node: "server" }), false); +}); + +test("a login is offered to the seat sealed to the seat's key, with the account it belongs to", async () => { + const { p } = node(); + const manager = generateKeyPair(); + writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at-login", refreshToken: "rt-login", expiresAt: NOW } })); + writeFileSync(join(p.home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "u-9" } })); + const calls: [string, Record][] = []; + await offerLogin(p, async (address, args) => { calls.push([address, args]); return address.endsWith("public_key") ? { public_key: manager.publicKey } : { adopted: true }; }); + assert.deepEqual(calls.map((c) => c[0]), ["anthropic-licence-manager.public_key", "anthropic-licence-manager.adopt"]); + const adopt = calls[1][1] as { identity: { accountUuid: string }; sealed: never }; + assert.equal(adopt.identity.accountUuid, "u-9"); + assert.equal(JSON.parse(open(adopt.sealed, manager.privateKey)).refreshToken, "rt-login"); + assert.ok(!JSON.stringify(adopt).includes("rt-login"), "the refresh token crossed in the clear"); +}); + +test("no refresh token in the file is no login, and nothing is asked", async () => { + const { p } = node(); + writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at", expiresAt: NOW } })); + assert.equal(await offerLogin(p, async () => { throw new Error("asked"); }), null); +}); + +test("registering a server here renders it and asks whether to register it on the other nodes", async () => { + const { p, written } = node(); + const emitted: unknown[] = []; + const r = await registerServer(p, { name: "search", entry: { type: "http", url: "https://s.example/mcp" } }, + async (t, b) => { emitted.push([t, b]); }, writer(written), async () => ["laptop", "server", "desktop"]); + assert.equal(r.here, "changed"); + assert.match(String(r.also), /server, desktop/); + assert.equal(emitted.length, 0, "a registration for this node alone is announced to nobody"); + assert.ok(JSON.parse(written["managed-mcp.json"]).mcpServers.search); +}); + +test("registering for every node emits the event, and another node applies it from the event", async () => { + const a = node("laptop"), b = node("server"); + let event: [string, unknown] | null = null; + await registerServer(a.p, { name: "docs", entry: { type: "stdio", command: "docs-mcp" }, nodes: "all" }, + async (t, body) => { event = [t, body]; }, writer(a.written), async () => []); + assert.equal(event![0], "mcp.registered"); + assert.equal(onServerEvent(b.p, "claude-code.mcp.registered", event![1] as never, writer(b.written)), "registered docs from an event"); + assert.deepEqual(registered(b.p).docs, { type: "stdio", command: "docs-mcp" }); + assert.equal(onServerEvent(b.p, "claude-code.mcp.registered", event![1] as never, writer(b.written)), null, "a repeated event changed something"); +}); + +test("an event naming other nodes leaves this one alone; a bad entry is refused before anything is written", async () => { + const { p, written } = node(); + assert.equal(onServerEvent(p, "claude-code.mcp.registered", { name: "x", entry: { type: "http", url: "https://x" }, nodes: ["server"] }, writer(written)), null); + const r = await registerServer(p, { name: "mesh", entry: { type: "http", url: "https://x" } }, async () => {}, writer(written), async () => []); + assert.equal(r.registered, false); +}); diff --git a/modules/claude-code/tools/index.ts b/modules/claude-code/tools/index.ts index daf93e2..7a04abe 100644 --- a/modules/claude-code/tools/index.ts +++ b/modules/claude-code/tools/index.ts @@ -1,142 +1,77 @@ -// claude-code's tools (novox/hq design 36, ADR 0183). A bundle the node's runtime launches and speaks MCP -// to over stdio (ADR 0193), as the operator account; it is given its state directory and two files the -// mesh renders into it (ADR 0192), and the runtime's own words — the operator's account and home among -// them. **stdout is the MCP channel**: everything this module says, it says on stderr. +// 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. // -// Every time the runtime collects these tools, the managed directory is rendered: written only when its -// content changed, through the account's escalation, because /etc is root's. The credentials file under -// the home is written only when the licence manager hands this node a token (`claude_code_apply`); this -// module calls nothing, the manager starts every exchange (ADR 0183's dated note). +// 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, and takes the module's events: the manager's +// licence events and every node's MCP server registrations. node.ts holds the logic. -import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; -import { createHash } from "node:crypto"; +import { readFileSync, watchFile } from "node:fs"; 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 { emit, on } from "@novox/mesh-sdk/events"; -import { MANAGED_DIR, render, type Binding, type Facts, type Settings } from "../render.js"; -import { generateKeyPair, open, seal, type SealedBox } from "../seal.js"; -import { decideApply, grantOf, holdsLogin, readCredentials, withGrant, writeCredentials, type Grant } from "../grant.js"; -import { readIdentity } from "../identity.js"; +import { + MANAGED_DIR, SEAT, concerns, keypair, offerLogin, onServerEvent, pull, readJson, registerServer, + registered, renderNow, type Ask, type Paths, type Registration, type WriteManaged, +} from "../node.js"; +import { grantOf, holdsLogin, readCredentials } from "../grant.js"; +import { createHash } from "node:crypto"; -interface Paths { - state: string; - facts: string; - settings: string; - home: string; - account: string; -} - -function pathsFrom(env: NodeJS.ProcessEnv): Paths | null { - const state = env.MESH_CLAUDE_CODE_STATE; - const facts = env.MESH_CLAUDE_CODE_FACTS; - const settings = env.MESH_CLAUDE_CODE_SETTINGS; - const home = env.MESH_OPERATOR_HOME; - if (!state || !facts || !settings || !home) return null; - return { state, facts, settings, home, account: env.MESH_OPERATOR_ACCOUNT ?? "" }; -} - -const readJson = (p: string, fallback: T): T => { - try { - return JSON.parse(readFileSync(p, "utf8")) as T; - } catch { - return fallback; - } -}; - -const credentialsPath = (p: Paths) => join(p.home, ".claude", ".credentials.json"); -const identityPath = (p: Paths) => join(p.home, ".claude.json"); -const bindingPath = (p: Paths) => join(p.state, "binding.json"); -const apiKeyPath = (p: Paths) => join(p.state, "api-key"); -const helperPath = (p: Paths) => join(p.state, "api-key-helper"); -const keyPath = (p: Paths) => join(p.state, "key.pem"); -const pubPath = (p: Paths) => join(p.state, "key.pub.pem"); +const say = (line: string) => console.error(`[claude-code] ${line}`); const fingerprint = (s: string) => "sha256:" + createHash("sha256").update(s).digest("hex").slice(0, 16); -function keypair(p: Paths): { publicKey: string; privateKey: string } { - if (!existsSync(keyPath(p))) { - const k = generateKeyPair(); - writeFileSync(keyPath(p), k.privateKey, { mode: 0o600 }); - writeFileSync(pubPath(p), k.publicKey, { mode: 0o644 }); - } - return { privateKey: readFileSync(keyPath(p), "utf8"), publicKey: readFileSync(pubPath(p), "utf8") }; +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 when its content changed. Returns what happened, in words. */ -function writeManaged(name: string, content: string, asRoot: boolean): string { +/** Write one managed file as root, only when its content changed. */ +const writeManaged: WriteManaged = (name, content) => { const path = join(MANAGED_DIR, name); - let current: string | null = null; try { - current = readFileSync(path, "utf8"); + if (readFileSync(path, "utf8") === content) return `${name}: unchanged`; } catch { /* absent */ } - if (current === content) return `${name}: unchanged`; + const asRoot = process.getuid?.() === 0; const cmd = asRoot ? ["install", "-D", "-m", "0644", "/dev/stdin", path] : ["sudo", "-n", "install", "-D", "-m", "0644", "/dev/stdin", path]; const r = spawnSync(cmd[0], cmd.slice(1), { input: content, encoding: "utf8" }); 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; this machine does not give it.`, - ); + 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`; -} +}; -function renderNow(p: Paths): string[] { - const facts = readJson(p.facts, null); - if (!facts?.console) throw new Error(`the mesh has not rendered ${p.facts} yet; nothing to write`); - const settings = readJson(p.settings, {}); - const binding = readJson(bindingPath(p), null); - const files = render(facts, settings, binding, helperPath(p)); - const asRoot = process.getuid?.() === 0; - return Object.entries(files).map(([name, content]) => writeManaged(name, content, asRoot)); -} - -interface Handed { - licence: string; - kind: "subscription" | "api-key"; - source: "rotation" | "switch"; - sealed: SealedBox; -} - -function apply(p: Paths, args: Record): Record { - const handed = args as unknown as Handed; - if (!handed?.licence || !handed.sealed || (handed.kind !== "subscription" && handed.kind !== "api-key")) { - return { applied: false, reason: "a hand-over names a licence, its kind and a sealed token" }; - } - const plain = open(handed.sealed, keypair(p).privateKey); - const previous = readJson(bindingPath(p), null); - const source = previous?.licence === handed.licence ? (handed.source ?? "rotation") : "switch"; - if (handed.kind === "api-key") { - writeFileSync(apiKeyPath(p), plain.trim() + "\n", { mode: 0o600 }); - writeFileSync(helperPath(p), `#!/bin/sh\nexec cat '${apiKeyPath(p)}'\n`, { mode: 0o700 }); - chmodSync(helperPath(p), 0o700); - } else { - const grant = JSON.parse(plain) as Grant; - const local = readCredentials(credentialsPath(p)); - const d = decideApply(grantOf(local), grant, source); - if (!d.apply) { - writeFileSync(bindingPath(p), JSON.stringify({ licence: handed.licence, kind: handed.kind }) + "\n", { mode: 0o600 }); - return { applied: false, licence: handed.licence, reason: d.reason }; - } - writeCredentials(credentialsPath(p), withGrant(local, grant)); - } - writeFileSync(bindingPath(p), JSON.stringify({ licence: handed.licence, kind: handed.kind }) + "\n", { mode: 0o600 }); - // An API-key binding adds the key-helper to the managed settings; a subscription takes it away. The - // licence is applied whatever the render says; a render that fails is reported beside it, not instead. - let rendered: string[] | { failed: string }; +/** 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 { - rendered = renderNow(p); - } catch (err) { - rendered = { failed: err instanceof Error ? err.message : String(err) }; + return JSON.parse(text); + } catch { + return text; } - return { applied: true, licence: handed.licence, kind: handed.kind, source, rendered }; +}; + +/** 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 binding = readJson(bindingPath(p), null); - const creds = readCredentials(credentialsPath(p)); + 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 { @@ -146,67 +81,68 @@ function status(p: Paths): Record { } }); return { - node: readJson(p.facts, null)?.node ?? null, - licence: binding, - token: grant - ? { fingerprint: fingerprint(grant.accessToken), expiresAt: new Date(grant.expiresAt).toISOString(), refreshTokenOnDisk: holdsLogin(creds) } - : null, + 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, - publicKey: existsSync(pubPath(p)) ? fingerprint(readFileSync(pubPath(p), "utf8")) : null, + registered: Object.keys(registered(p)), }; } -function pendingLogin(p: Paths, args: Record): Record { - const managerKey = typeof args.public_key === "string" ? args.public_key : ""; - if (!managerKey) return { waiting: false, reason: "the caller names the public key to seal a login to" }; - const creds = readCredentials(credentialsPath(p)); - if (!holdsLogin(creds)) return { waiting: false }; - const identity = readIdentity(identityPath(p)); - return { waiting: true, identity, sealed: seal(JSON.stringify(creds!.claudeAiOauth), managerKey) }; -} - -export function getClaudeCodeTools(p: Paths): ToolDefinition[] { +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: - "This machine's agent as the mesh configured it: the node, the licence it holds and when its token expires, " + - "and the managed files it rendered. Fingerprints only — never a token.", + 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 the agent's managed directory now from the mesh's facts and this module's settings; says which files changed.", + 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) }), + run: async () => ({ rendered: renderNow(p, writeManaged) }), }, { - name: "claude_code_public_key", - description: "The public half of this node's key, which the licence manager seals a token to.", + 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 () => ({ public_key: keypair(p).publicKey }), + run: async () => pull(p, ask, writeManaged), }, { - name: "claude_code_apply", - description: - "The licence manager's hand-over: a token sealed to this node's key, with its licence and kind. Applied by the " + - "lineage rule; the answer says applied or refused and why, never the token.", + name: "claude_code_mcp_list", + description: "The MCP servers registered on this node through this module, beside the console (`mesh`) and those set in the module's settings.", + input: {}, + run: async () => ({ registered: registered(p) }), + }, + { + name: "claude_code_mcp_register", + description: "Register an MCP server with Claude Code on this node — an http/sse server by url, or a stdio server by command — and say which other nodes run claude-code, so it can be registered there too.", input: { - licence: { type: "string", description: "the licence's name" }, - kind: { type: "string", description: "subscription or api-key" }, - source: { type: "string", description: "rotation or switch" }, - sealed: { type: "object", description: "the sealed box" }, + 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) }, emit, writeManaged, nodesRunningMe); }, - run: async (args) => apply(p, args), }, { - name: "claude_code_pending_login", - description: - "A login a person made on this machine, waiting to be adopted: the grant sealed to the key the caller gives, and " + - "the account it belongs to. Nothing when no login is waiting.", - input: { public_key: { type: "string", description: "the caller's public key, PEM" } }, - run: async (args) => pendingLogin(p, args), + 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) }, emit, writeManaged, nodesRunningMe), }, ]; } @@ -216,10 +152,38 @@ registerModuleTools("claude-code", (env) => { if (!p) return []; try { keypair(p); - for (const line of renderNow(p)) if (!line.endsWith("unchanged")) console.error(`[claude-code] ${line}`); + for (const line of renderNow(p, writeManaged)) if (!line.endsWith("unchanged")) say(line); } catch (err) { - // Said, and the tools still served: claude_code_status and claude_code_render say what is wrong. - console.error(`[claude-code] ${err instanceof Error ? err.message : String(err)}`); + say(err instanceof Error ? err.message : String(err)); } - return getClaudeCodeTools(p); + 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")); + + void on("claude-code.mcp.*", async (event) => { + const done = onServerEvent(p, event.type, event.body, writeManaged); + if (done) say(done); + }).catch(loud("the MCP server events")); + + // 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")); + }); +} diff --git a/modules/claude-code/tsconfig.json b/modules/claude-code/tsconfig.json index ac24fee..8e1f1bb 100644 --- a/modules/claude-code/tsconfig.json +++ b/modules/claude-code/tsconfig.json @@ -8,5 +8,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["seal.ts", "grant.ts", "identity.ts", "render.ts", "tools/index.ts"] + "include": ["seal.ts", "grant.ts", "identity.ts", "render.ts", "node.ts", "tools/index.ts"] }