From 573775274422e825dc2f98c95e00c03fc82dee3d Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 3 Oct 2026 16:05:58 +0200 Subject: [PATCH 1/7] claude-code: the sealed hand-over, the credentials write with the lineage rule, the identity read (hq to-be 40 WP2, in progress) The parts of the agent module that hold whichever way the console is registered: X25519 + HKDF + AES-GCM from Node's own library so the bundle carries no dependency; the predecessor's lineage rule (rotation only if newer, a re-issue adopted, a switch regardless) with its incidents as tests; an atomic 0600 write that strips any refresh token and keeps keys it does not know; the account read from the agent's own state file. Manifest and renderer follow. --- modules/claude-code/grant.ts | 100 ++++++++++++++++++++++ modules/claude-code/identity.ts | 25 ++++++ modules/claude-code/seal.ts | 77 +++++++++++++++++ modules/claude-code/test/grant.test.ts | 54 ++++++++++++ modules/claude-code/test/identity.test.ts | 19 ++++ modules/claude-code/test/seal.test.ts | 31 +++++++ 6 files changed, 306 insertions(+) create mode 100644 modules/claude-code/grant.ts create mode 100644 modules/claude-code/identity.ts create mode 100644 modules/claude-code/seal.ts create mode 100644 modules/claude-code/test/grant.test.ts create mode 100644 modules/claude-code/test/identity.test.ts create mode 100644 modules/claude-code/test/seal.test.ts diff --git a/modules/claude-code/grant.ts b/modules/claude-code/grant.ts new file mode 100644 index 0000000..d9c060e --- /dev/null +++ b/modules/claude-code/grant.ts @@ -0,0 +1,100 @@ +// The agent's credentials file, and whether an offered grant may replace what it holds (novox/hq +// ADR 0183, design 36 §5). Pure where it decides, so the rules are tested without a file. +// +// The file is the vendor's: `{ claudeAiOauth: { accessToken, expiresAt, refreshTokenExpiresAt?, +// scopes?, subscriptionType?, rateLimitTier? }, ... }`. A node never holds a refresh token, so the +// one this module writes never carries one, and a full grant a login left behind is stripped the +// moment the manager hands the node its own. +// +// The lineage rule is the predecessor's, with the incidents that earned it: a rotation of the same +// licence is applied only if newer; a grant re-issued by a login is adopted whatever its expiry; a +// switch to another licence is applied regardless, because across licences the expiries are +// unrelated numbers. + +import { readFileSync, renameSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +export interface Grant { + readonly accessToken: string; + readonly expiresAt: number; + readonly refreshTokenExpiresAt?: number | null; + readonly scopes?: readonly string[] | null; + readonly subscriptionType?: string | null; + readonly rateLimitTier?: string | null; +} + +export type ApplySource = "rotation" | "switch"; + +export type ApplyDecision = + | { apply: true; reissued?: boolean } + | { apply: false; reason: "already-current" } + | { apply: false; reason: "not-newer"; localExpiresAt: number }; + +/** Two refresh-token expiries within a day are one lineage; a login starts a fresh window weeks away. */ +export const GENERATION_TOLERANCE_MS = 24 * 60 * 60 * 1000; + +export function sameGeneration(a?: number | null, b?: number | null): boolean { + if (a == null || b == null) return true; + return Math.abs(Number(a) - Number(b)) <= GENERATION_TOLERANCE_MS; +} + +export function decideApply(local: Grant | null | undefined, offered: Grant, source: ApplySource): ApplyDecision { + if (!local?.accessToken) return { apply: true }; + if (local.accessToken === offered.accessToken) return { apply: false, reason: "already-current" }; + const reissued = !sameGeneration(local.refreshTokenExpiresAt, offered.refreshTokenExpiresAt); + if (source === "rotation" && !reissued && Number(local.expiresAt) >= Number(offered.expiresAt)) { + return { apply: false, reason: "not-newer", localExpiresAt: Number(local.expiresAt) }; + } + return reissued ? { apply: true, reissued: true } : { apply: true }; +} + +type Oauth = Record & { accessToken?: string; refreshToken?: string; expiresAt?: number }; +type Credentials = Record & { claudeAiOauth?: Oauth }; + +export function readCredentials(path: string): Credentials | null { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Credentials; + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +/** The grant the file holds, or null. */ +export function grantOf(creds: Credentials | null): Grant | null { + const o = creds?.claudeAiOauth; + if (!o?.accessToken) return null; + return { + accessToken: o.accessToken, + expiresAt: Number(o.expiresAt ?? 0), + refreshTokenExpiresAt: o.refreshTokenExpiresAt == null ? null : Number(o.refreshTokenExpiresAt), + }; +} + +/** Does the file hold a full grant — a refresh token this module never writes, so a person's login? */ +export function holdsLogin(creds: Credentials | null): boolean { + return typeof creds?.claudeAiOauth?.refreshToken === "string" && creds.claudeAiOauth.refreshToken.length > 0; +} + +/** 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 ?? {}) }; + const oauth: Oauth = { ...(local?.claudeAiOauth ?? {}) }; + oauth.accessToken = grant.accessToken; + oauth.expiresAt = grant.expiresAt; + for (const k of ["refreshTokenExpiresAt", "scopes", "subscriptionType", "rateLimitTier"] as const) { + const v = grant[k]; + if (v != null) oauth[k] = v as unknown; + } + delete oauth.refreshToken; + next.claudeAiOauth = oauth; + return next; +} + +/** Write atomically at 0600: a partial credentials file must never be read as a whole one. */ +export function writeCredentials(path: string, creds: Credentials): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const tmp = `${path}.mesh-tmp`; + writeFileSync(tmp, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 }); + renameSync(tmp, path); +} diff --git a/modules/claude-code/identity.ts b/modules/claude-code/identity.ts new file mode 100644 index 0000000..d9405b0 --- /dev/null +++ b/modules/claude-code/identity.ts @@ -0,0 +1,25 @@ +// 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. + +import { readFileSync } from "node:fs"; + +export interface Identity { + readonly accountUuid: string; + readonly emailAddress?: string; + readonly organizationUuid?: string; +} + +export function readIdentity(stateFile: string): Identity | null { + try { + const raw = JSON.parse(readFileSync(stateFile, "utf8")) as { oauthAccount?: Record }; + const a = raw.oauthAccount; + if (!a || typeof a.accountUuid !== "string") return null; + return { + accountUuid: a.accountUuid, + emailAddress: typeof a.emailAddress === "string" ? a.emailAddress : undefined, + organizationUuid: typeof a.organizationUuid === "string" ? a.organizationUuid : undefined, + }; + } catch { + return null; + } +} diff --git a/modules/claude-code/seal.ts b/modules/claude-code/seal.ts new file mode 100644 index 0000000..8c467c0 --- /dev/null +++ b/modules/claude-code/seal.ts @@ -0,0 +1,77 @@ +// Sealing a token to one recipient (novox/hq ADR 0183): the manager seals what it hands a node to that +// node's agent module key, and a node seals a waiting login to the key the manager names. X25519 for +// the agreement, HKDF-SHA256 for the key, AES-256-GCM for the box — all from Node's own library, so a +// bundle carries no dependency and no secret ever crosses the bus in the clear. +// +// A sealed box is `{ v: 1, eph, iv, tag, ct }`, every field base64. `eph` is a one-time public key, so +// two boxes of one value to one recipient share nothing, and only the recipient's private key opens it. + +import { + createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman, + generateKeyPairSync, hkdfSync, randomBytes, type KeyObject, +} from "node:crypto"; + +export interface SealedBox { + readonly v: 1; + readonly eph: string; + readonly iv: string; + readonly tag: string; + readonly ct: string; +} + +/** A recipient's keypair, as the two PEM strings it is kept and published as. */ +export interface KeyPairPem { + readonly publicKey: string; + readonly privateKey: string; +} + +const INFO = Buffer.from("novox-mesh sealed box v1"); + +export function generateKeyPair(): KeyPairPem { + const { publicKey, privateKey } = generateKeyPairSync("x25519"); + return { + publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(), + privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; +} + +function keyFor(secret: Buffer, eph: Buffer, recipient: Buffer): Buffer { + // The ephemeral and the recipient's public halves are bound into the key, so a box cannot be + // re-addressed to another recipient by swapping its `eph`. + return Buffer.from(hkdfSync("sha256", secret, Buffer.concat([eph, recipient]), INFO, 32)); +} + +function rawPublic(key: KeyObject): Buffer { + return key.export({ type: "spki", format: "der" }).subarray(-32); +} + +export function seal(plaintext: string, recipientPublicPem: string): SealedBox { + const recipient = createPublicKey(recipientPublicPem); + const eph = generateKeyPairSync("x25519"); + const secret = diffieHellman({ privateKey: eph.privateKey, publicKey: recipient }); + const ephRaw = eph.publicKey.export({ type: "spki", format: "der" }); + const key = keyFor(secret, ephRaw, rawPublic(recipient)); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return { + v: 1, + eph: ephRaw.toString("base64"), + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + ct: ct.toString("base64"), + }; +} + +/** Open a box with the recipient's private key. Throws on a box for another key or one tampered with. */ +export function open(box: SealedBox, privateKeyPem: string): string { + if (!box || box.v !== 1) throw new Error("not a sealed box this module can open"); + const priv = createPrivateKey(privateKeyPem); + const ephRaw = Buffer.from(box.eph, "base64"); + const eph = createPublicKey({ key: ephRaw, format: "der", type: "spki" }); + const secret = diffieHellman({ privateKey: priv, publicKey: eph }); + const key = keyFor(secret, ephRaw, rawPublic(createPublicKey(priv))); + const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(box.iv, "base64")); + decipher.setAuthTag(Buffer.from(box.tag, "base64")); + return Buffer.concat([decipher.update(Buffer.from(box.ct, "base64")), decipher.final()]).toString("utf8"); +} diff --git a/modules/claude-code/test/grant.test.ts b/modules/claude-code/test/grant.test.ts new file mode 100644 index 0000000..1b10895 --- /dev/null +++ b/modules/claude-code/test/grant.test.ts @@ -0,0 +1,54 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + decideApply, grantOf, holdsLogin, readCredentials, withGrant, writeCredentials, type Grant, +} from "../dist/grant.js"; + +const NOW = 1_700_000_000_000; +const HOUR = 3_600_000; +const g = (over: Partial = {}): Grant => ({ + accessToken: "tok-A", expiresAt: NOW + HOUR, refreshTokenExpiresAt: NOW + 30 * 24 * HOUR, ...over, +}); + +test("a rotation applies a newer grant of the same licence", () => { + assert.deepEqual(decideApply(g(), g({ accessToken: "tok-B", expiresAt: NOW + 2 * HOUR }), "rotation"), { apply: true }); +}); + +test("a rotation refuses a grant that arrived late and is older", () => { + const d = decideApply(g({ accessToken: "new", expiresAt: NOW + 2 * HOUR }), g({ accessToken: "old" }), "rotation"); + assert.equal(d.apply === false && d.reason, "not-newer"); +}); + +test("a grant re-issued by a login is adopted even though it expires sooner (2026-09-05)", () => { + const local = g({ expiresAt: NOW + 8 * HOUR, refreshTokenExpiresAt: NOW + 30 * 24 * HOUR }); + const offered = g({ accessToken: "reissued", expiresAt: NOW + HOUR, refreshTokenExpiresAt: NOW + 5 * 24 * HOUR }); + assert.deepEqual(decideApply(local, offered, "rotation"), { apply: true, reissued: true }); +}); + +test("a switch to another licence applies whatever the expiries say", () => { + const local = g({ expiresAt: NOW + 8 * HOUR }); + assert.equal(decideApply(local, g({ accessToken: "other", expiresAt: NOW + HOUR }), "switch").apply, true); +}); + +test("the same token is not rewritten", () => { + assert.deepEqual(decideApply(g(), g(), "switch"), { apply: false, reason: "already-current" }); +}); + +test("a full grant left by a login is seen as a login, and stripped when the node's own is written", () => { + const dir = mkdtempSync(join(tmpdir(), "claude-code-")); + const path = join(dir, ".claude", ".credentials.json"); + writeFileSync(join(dir, "x"), ""); + const login = { claudeAiOauth: { accessToken: "at-login", refreshToken: "rt-login", expiresAt: NOW }, other: 1 }; + assert.equal(holdsLogin(login), true); + writeCredentials(path, withGrant(login, g({ accessToken: "at-mesh", scopes: ["user:inference"] }))); + const back = readCredentials(path)!; + assert.equal(holdsLogin(back), false); + assert.equal(grantOf(back)!.accessToken, "at-mesh"); + assert.deepEqual(back.claudeAiOauth!.scopes, ["user:inference"]); + assert.equal(back.other, 1, "a key the module does not know was lost"); + assert.ok(!readFileSync(path, "utf8").includes("rt-login")); + assert.equal(statSync(path).mode & 0o777, 0o600); +}); diff --git a/modules/claude-code/test/identity.test.ts b/modules/claude-code/test/identity.test.ts new file mode 100644 index 0000000..31f8c6b --- /dev/null +++ b/modules/claude-code/test/identity.test.ts @@ -0,0 +1,19 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readIdentity } from "../dist/identity.js"; + +test("the account is read from the agent's state file", () => { + const p = join(mkdtempSync(join(tmpdir(), "cc-id-")), ".claude.json"); + writeFileSync(p, JSON.stringify({ oauthAccount: { accountUuid: "u-1", emailAddress: "a@example.org" }, other: 2 })); + assert.deepEqual(readIdentity(p), { accountUuid: "u-1", emailAddress: "a@example.org", organizationUuid: undefined }); +}); + +test("no state file, or no account in it, is no identity rather than a guess", () => { + assert.equal(readIdentity("/nonexistent/.claude.json"), null); + const p = join(mkdtempSync(join(tmpdir(), "cc-id-")), ".claude.json"); + writeFileSync(p, "{}"); + assert.equal(readIdentity(p), null); +}); diff --git a/modules/claude-code/test/seal.test.ts b/modules/claude-code/test/seal.test.ts new file mode 100644 index 0000000..9685d4e --- /dev/null +++ b/modules/claude-code/test/seal.test.ts @@ -0,0 +1,31 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { generateKeyPair, open, seal } from "../dist/seal.js"; + +test("a box opens with its recipient's key and yields the value", () => { + const k = generateKeyPair(); + assert.equal(open(seal("at-secret", k.publicKey), k.privateKey), "at-secret"); +}); + +test("a box sealed for one node does not open with another node's key", () => { + const a = generateKeyPair(); + const b = generateKeyPair(); + assert.throws(() => open(seal("at-secret", a.publicKey), b.privateKey)); +}); + +test("a tampered box is refused, not opened to garbage", () => { + const k = generateKeyPair(); + const box = seal("at-secret", k.publicKey); + const ct = Buffer.from(box.ct, "base64"); + ct[0] ^= 0xff; + assert.throws(() => open({ ...box, ct: ct.toString("base64") }, k.privateKey)); +}); + +test("two boxes of one value share nothing a reader could compare", () => { + const k = generateKeyPair(); + const x = seal("at-secret", k.publicKey); + const y = seal("at-secret", k.publicKey); + assert.notEqual(x.ct, y.ct); + assert.notEqual(x.eph, y.eph); + assert.ok(!JSON.stringify(x).includes("at-secret")); +}); From f42b58f7898155d6373db0dc925a6606e9a52444 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 3 Oct 2026 16:13:01 +0200 Subject: [PATCH 2/7] claude-code: the manifest, the managed directory and the tools (hq design 36, to-be 40 WP2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module owns /etc/claude-code: managed-mcp.json lists the console as `mesh` over HTTP on loopback plus the servers in its mcp_servers setting (exclusive, by the operator's choice — the https rule of managedMcpServers refuses a loopback console); managed-settings.json carries the attribution convention, keeps claude.ai connectors, and adds the key-helper only for an API-key licence; CLAUDE.md says how a session here works. Rendered whenever the runtime collects the tools, written only on change, through the operator account's sudo. Under the home, only the credentials file, only on a hand-over. Nothing declared under a home or /etc; the console's port comes from node-tools' mcp-endpoint (mesh-tools #34). --- modules/claude-code/README.md | 42 +++++ modules/claude-code/module.json | 69 ++++++++ modules/claude-code/package.json | 18 ++ modules/claude-code/render.ts | 111 ++++++++++++ modules/claude-code/test/render.test.ts | 39 +++++ modules/claude-code/tools/index.ts | 218 ++++++++++++++++++++++++ modules/claude-code/tsconfig.json | 12 ++ 7 files changed, 509 insertions(+) create mode 100644 modules/claude-code/README.md create mode 100644 modules/claude-code/module.json create mode 100644 modules/claude-code/package.json create mode 100644 modules/claude-code/render.ts create mode 100644 modules/claude-code/test/render.test.ts create mode 100644 modules/claude-code/tools/index.ts create mode 100644 modules/claude-code/tsconfig.json diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md new file mode 100644 index 0000000..697d7a6 --- /dev/null +++ b/modules/claude-code/README.md @@ -0,0 +1,42 @@ +# claude-code + +The operator's agent on a machine (novox/hq design 36): its package, its machine-wide managed +configuration, and the consumer side of the Anthropic licence manager (design 39, ADR 0183). + +## What it writes + +Under the agent's managed directory, `/etc/claude-code`, owned whole by this module and rewritten +whenever the node's tool runtime collects the module's tools: + +| file | holds | +|---|---| +| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's | +| `managed-settings.json` | the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence | +| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions | + +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. + +## 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 + (`{"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. + +## On a machine that carried the predecessor + +Remove these by hand, once; the mesh removes nothing it did not make (ADR 0182): + +- `~/.claude/CLAUDE.md` +- `~/.claude/rules/00-hal-mesh.md`, `~/.claude/rules/conventions.md` +- `~/.claude/skills/cleanup/`, `~/.claude/skills/hal-switch-license/` +- the hand-made console entry in `~/.claude.json` under `mcpServers` — it is ignored now anyway + +## Escalation + +Writing `/etc/claude-code` needs root. The runtime runs as the operator account, and the module uses +that account's passwordless `sudo`; on a machine without it, `claude_code_render` says so and nothing +is written. diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json new file mode 100644 index 0000000..1d470a0 --- /dev/null +++ b/modules/claude-code/module.json @@ -0,0 +1,69 @@ +{ + "module": "claude-code", + "version": "1", + "slug": "agent", + "capabilities": [ + "package-manager" + ], + "requires": [ + "mcp-endpoint" + ], + "binds": { + "mcp-endpoint": "${dir:state}/mcp-endpoint.json" + }, + "tools": [ + "claude_code_status", + "claude_code_render", + "claude_code_public_key", + "claude_code_apply", + "claude_code_pending_login" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "claude-code" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "${machine:account}", + "place": "." + }, + { + "id": "facts", + "type": "file", + "path": "${dir:state}/facts.json", + "mode": "0600", + "owner": "${machine:account}", + "content": "{\n \"node\": \"${machine:name}\",\n \"console\": \"http://127.0.0.1:${bound:mcp-endpoint:port}/mcp\"\n}\n" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "${machine:account}", + "merge": "json", + "content": "{\n \"role\": \"\",\n \"mcp_servers\": {}\n}\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "env": { + "MESH_CLAUDE_CODE_STATE": "${dir:state}", + "MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json", + "MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json" + } + } + ] + } +} diff --git a/modules/claude-code/package.json b/modules/claude-code/package.json new file mode 100644 index 0000000..a903c2b --- /dev/null +++ b/modules/claude-code/package.json @@ -0,0 +1,18 @@ +{ + "name": "@novox/module-claude-code", + "version": "0.1.0", + "description": "claude-code — the operator's agent on a machine: its managed configuration, and the consumer side of the Anthropic licence manager (novox/hq design 36).", + "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", + "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'" + }, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/claude-code/render.ts b/modules/claude-code/render.ts new file mode 100644 index 0000000..4545c88 --- /dev/null +++ b/modules/claude-code/render.ts @@ -0,0 +1,111 @@ +// What the module writes into the agent's machine-wide managed directory (novox/hq design 36 §1–§4). +// Pure: composed from the facts the mesh rendered, the settings the operator set and the licence the +// node holds, so what lands under /etc is tested without a machine. +// +// Three files, owned whole by this module: +// managed-mcp.json the tool servers every session loads: the mesh's console as `mesh`, and the +// servers the operator declared for the mesh or this node. Exclusive by the +// vendor's rule — a server not listed here does not load — which is why the +// list is the module's settings and nothing else (operator's choice, 2026-10-03). +// managed-settings.json the mesh's keys only: the repositories' attribution convention, the +// claude.ai connectors kept beside the managed servers, and — for an API-key +// licence only — the key-helper. A person's preferences are theirs. +// CLAUDE.md how a session on this mesh works, who this node is, the conventions. + +export const MANAGED_DIR = "/etc/claude-code"; + +export interface Facts { + readonly node: string; + readonly console: string; +} + +export interface Settings { + readonly role?: string; + /** Extra tool servers, in the vendor's `.mcp.json` entry shape, keyed by name. */ + readonly mcp_servers?: Readonly>>; +} + +export interface Binding { + readonly licence: string; + readonly kind: "subscription" | "api-key"; +} + +export interface Rendered { + readonly [file: string]: string; +} + +const MESH_ENTRY = "mesh"; + +export function render(facts: Facts, settings: Settings, binding: Binding | null, helperPath: string): 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; + servers[name] = entry; + } + servers[MESH_ENTRY] = { type: "http", url: facts.console }; + + const managed: Record = { + attribution: { commit: "", pr: "" }, + allowAllClaudeAiMcps: true, + }; + if (binding?.kind === "api-key") managed.apiKeyHelper = helperPath; + + return { + "managed-mcp.json": json({ mcpServers: sortKeys(servers) }), + "managed-settings.json": json(managed), + "CLAUDE.md": instructions(facts, settings), + }; +} + +function json(v: unknown): string { + return JSON.stringify(v, null, 2) + "\n"; +} + +function sortKeys(o: Record): Record { + return Object.fromEntries(Object.keys(o).sort().map((k) => [k, o[k]])); +} + +export function instructions(facts: Facts, settings: Settings): string { + const role = settings.role?.trim() ? settings.role.trim() : "not stated — set it in this module's settings for the node"; + return `# This machine is a node of a Novox mesh + +Written by the mesh's \`claude-code\` module. Edit the module's settings or the catalogue, never this file: +it is rewritten whenever the module renders. + +## Who this node is + +- **Node:** \`${facts.node}\` +- **Role:** ${role} +- The other nodes, their roles and what runs where: ask the controller (\`mesh-controller.nodes\`, + \`mesh-controller.node\`). Nothing here lists them, because a copy drifts. + +## How a session on this mesh works + +The console is the only way to the mesh: the MCP server named \`mesh\`. Its tools are the vocabulary. + +- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the + literal text before forming a hypothesis: \`records.records_search\`. Read a document with + \`records.records_read\`. +- **Ask the mesh before changing it.** \`mesh-controller.status\`, \`.plan\`, \`.node\`, \`.modules\`. + Change it through the controller's verbs (\`assign\`, \`push\`, \`settings\`) or the catalogue. +- **The forge** through the forge module's tools. +- **A licence** through the \`anthropic-licence-manager\` seat's verbs. Never edit the agent's credentials + file by hand, never print or ask for a token. + +## Hard rules + +- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If + unsure, \`mesh-controller.plan\` for the node says what the mesh writes there. +- Never write to a store's database by hand; schema changes are numbered migrations. +- Never push to a main branch: a branch, a pull request, and a human approval for every merge. +- The mesh creates no symlinks, and nobody else does either. +- A package is declared in a module, never installed by hand. + +## Conventions + +- Commit messages are concise, in the imperative, about why. +- Test before pushing: nodes update unattended. +- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done. +`; +} diff --git a/modules/claude-code/test/render.test.ts b/modules/claude-code/test/render.test.ts new file mode 100644 index 0000000..e8d90c8 --- /dev/null +++ b/modules/claude-code/test/render.test.ts @@ -0,0 +1,39 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { render } from "../dist/render.js"; + +const facts = { node: "workstation", console: "http://127.0.0.1:4270/mcp" }; + +test("the console is the `mesh` server, and an operator's servers are listed beside it", () => { + const out = render(facts, { mcp_servers: { search: { type: "http", url: "https://s.example/mcp" } } }, null, "/h"); + const mcp = JSON.parse(out["managed-mcp.json"]); + assert.deepEqual(Object.keys(mcp.mcpServers), ["mesh", "search"]); + assert.deepEqual(mcp.mcpServers.mesh, { type: "http", url: facts.console }); +}); + +test("a setting cannot replace the mesh's own entry, and a name the vendor refuses is left out", () => { + const out = render(facts, { mcp_servers: { mesh: { type: "http", url: "http://evil" }, "bad name": {} } }, null, "/h"); + const mcp = JSON.parse(out["managed-mcp.json"]); + assert.equal(mcp.mcpServers.mesh.url, facts.console); + assert.ok(!("bad name" in mcp.mcpServers)); +}); + +test("managed settings carry the mesh's keys only, and the key-helper only for an API-key licence", () => { + const sub = JSON.parse(render(facts, {}, { licence: "personal", kind: "subscription" }, "/h")["managed-settings.json"]); + assert.deepEqual(sub, { attribution: { commit: "", pr: "" }, allowAllClaudeAiMcps: true }); + const key = JSON.parse(render(facts, {}, { licence: "api", kind: "api-key" }, "/state/api-key-helper")["managed-settings.json"]); + assert.equal(key.apiKeyHelper, "/state/api-key-helper"); + assert.ok(!("model" in key), "a preference is the person's"); +}); + +test("the instruction file names the node and its role, and no other node", () => { + const md = render(facts, { role: "the laptop" }, null, "/h")["CLAUDE.md"]; + assert.match(md, /\*\*Node:\*\* `workstation`/); + assert.match(md, /\*\*Role:\*\* the laptop/); + assert.match(md, /records\.records_search/); +}); + +test("rendering is deterministic, so an unchanged input writes nothing", () => { + const s = { mcp_servers: { b: { type: "http", url: "https://b" }, a: { type: "http", url: "https://a" } } }; + assert.deepEqual(render(facts, s, null, "/h"), render(facts, s, null, "/h")); +}); diff --git a/modules/claude-code/tools/index.ts b/modules/claude-code/tools/index.ts new file mode 100644 index 0000000..a3f312e --- /dev/null +++ b/modules/claude-code/tools/index.ts @@ -0,0 +1,218 @@ +// claude-code's tools (novox/hq design 36, ADR 0183). Served by the node's tool runtime, which runs as +// the operator account; this bundle 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. +// +// 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). + +import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; + +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"; + +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 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") }; +} + +/** Write one managed file as root when its content changed. Returns what happened, in words. */ +function writeManaged(name: string, content: string, asRoot: boolean): string { + const path = join(MANAGED_DIR, name); + let current: string | null = null; + try { + current = readFileSync(path, "utf8"); + } catch { + /* absent */ + } + if (current === content) return `${name}: unchanged`; + 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.`, + ); + } + 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. + const rendered = renderNow(p); + return { applied: true, licence: handed.licence, kind: handed.kind, source, rendered }; +} + +function status(p: Paths): Record { + const binding = readJson(bindingPath(p), null); + const creds = readCredentials(credentialsPath(p)); + 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: readJson(p.facts, null)?.node ?? null, + licence: binding, + token: grant + ? { fingerprint: fingerprint(grant.accessToken), expiresAt: new Date(grant.expiresAt).toISOString(), refreshTokenOnDisk: holdsLogin(creds) } + : null, + managed, + publicKey: existsSync(pubPath(p)) ? fingerprint(readFileSync(pubPath(p), "utf8")) : null, + }; +} + +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[] { + 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.", + 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.", + input: {}, + run: async () => ({ rendered: renderNow(p) }), + }, + { + name: "claude_code_public_key", + description: "The public half of this node's key, which the licence manager seals a token to.", + input: {}, + run: async () => ({ public_key: keypair(p).publicKey }), + }, + { + 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.", + 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" }, + }, + 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), + }, + ]; +} + +registerModuleTools("claude-code", (env) => { + const p = pathsFrom(env); + if (!p) return []; + try { + keypair(p); + for (const line of renderNow(p)) if (!line.endsWith("unchanged")) console.log(`[claude-code] ${line}`); + } catch (err) { + // Said, and the tools still served: claude_code_status and claude_code_render say what is wrong. + console.log(`[claude-code] ${err instanceof Error ? err.message : String(err)}`); + } + return getClaudeCodeTools(p); +}); diff --git a/modules/claude-code/tsconfig.json b/modules/claude-code/tsconfig.json new file mode 100644 index 0000000..ac24fee --- /dev/null +++ b/modules/claude-code/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["seal.ts", "grant.ts", "identity.ts", "render.ts", "tools/index.ts"] +} From eab335b755949f245ee0ac65feb883e30b5e9571 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 3 Oct 2026 23:41:01 +0200 Subject: [PATCH 3/7] claude-code: launched over stdio (ADR 0193), the console's five tools in its instructions (ADR 0195) Every bundle is now a child speaking MCP over stdio, so stdout is the channel: the module logs on stderr. The managed CLAUDE.md teaches mesh_search, mesh_describe, mesh_call, mesh_overview and mesh_machine with addresses (., /.) instead of flat tool names. A hand-over is applied whatever the trailing render says; a failed render is reported beside it. Proven over stdio as the runtime drives it: five tools listed, a key made on first use, a sealed switch writing an access-token-only 0600 credentials file that keeps unknown keys. --- modules/claude-code/render.ts | 17 +++++++++++------ modules/claude-code/test/render.test.ts | 3 ++- modules/claude-code/tools/index.ts | 21 ++++++++++++++------- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/modules/claude-code/render.ts b/modules/claude-code/render.ts index 4545c88..f33aed0 100644 --- a/modules/claude-code/render.ts +++ b/modules/claude-code/render.ts @@ -82,14 +82,19 @@ it is rewritten whenever the module renders. ## How a session on this mesh works -The console is the only way to the mesh: the MCP server named \`mesh\`. Its tools are the vocabulary. +The console is the only way to the mesh: the MCP server named \`mesh\`. It offers five tools, and +everything else is an address you find and call through them: + +- \`mesh_search\` — words in, matching addresses out. \`mesh_describe\` — one address's arguments. +- \`mesh_call\` — call an address. A seat the mesh holds once is \`.\` (the mesh's own verbs + are \`mesh-controller.\`: \`status\`, \`plan\`, \`node\`, \`assign\`, \`push\`, \`settings\`); + a module on a machine is \`/.\`. +- \`mesh_overview\` and \`mesh_machine\` — the mesh's seats and machines, and what one machine runs. - **Symptom first.** For an error, a failing service or anything unexpected, search the record with the - literal text before forming a hypothesis: \`records.records_search\`. Read a document with - \`records.records_read\`. -- **Ask the mesh before changing it.** \`mesh-controller.status\`, \`.plan\`, \`.node\`, \`.modules\`. - Change it through the controller's verbs (\`assign\`, \`push\`, \`settings\`) or the catalogue. -- **The forge** through the forge module's tools. + literal text before forming a hypothesis: the records module's \`records_search\`, then + \`records_read\`. +- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue. - **A licence** through the \`anthropic-licence-manager\` seat's verbs. Never edit the agent's credentials file by hand, never print or ask for a token. diff --git a/modules/claude-code/test/render.test.ts b/modules/claude-code/test/render.test.ts index e8d90c8..2efeb59 100644 --- a/modules/claude-code/test/render.test.ts +++ b/modules/claude-code/test/render.test.ts @@ -30,7 +30,8 @@ test("the instruction file names the node and its role, and no other node", () = const md = render(facts, { role: "the laptop" }, null, "/h")["CLAUDE.md"]; assert.match(md, /\*\*Node:\*\* `workstation`/); assert.match(md, /\*\*Role:\*\* the laptop/); - assert.match(md, /records\.records_search/); + assert.match(md, /mesh_call/); + assert.match(md, /records_search/); }); test("rendering is deterministic, so an unchanged input writes nothing", () => { diff --git a/modules/claude-code/tools/index.ts b/modules/claude-code/tools/index.ts index a3f312e..daf93e2 100644 --- a/modules/claude-code/tools/index.ts +++ b/modules/claude-code/tools/index.ts @@ -1,6 +1,7 @@ -// claude-code's tools (novox/hq design 36, ADR 0183). Served by the node's tool runtime, which runs as -// the operator account; this bundle 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. +// 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. // // 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 @@ -122,8 +123,14 @@ function apply(p: Paths, args: Record): Record 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. - const rendered = renderNow(p); + // 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 }; + try { + rendered = renderNow(p); + } catch (err) { + rendered = { failed: err instanceof Error ? err.message : String(err) }; + } return { applied: true, licence: handed.licence, kind: handed.kind, source, rendered }; } @@ -209,10 +216,10 @@ registerModuleTools("claude-code", (env) => { if (!p) return []; try { keypair(p); - for (const line of renderNow(p)) if (!line.endsWith("unchanged")) console.log(`[claude-code] ${line}`); + for (const line of renderNow(p)) if (!line.endsWith("unchanged")) console.error(`[claude-code] ${line}`); } catch (err) { // Said, and the tools still served: claude_code_status and claude_code_render say what is wrong. - console.log(`[claude-code] ${err instanceof Error ? err.message : String(err)}`); + console.error(`[claude-code] ${err instanceof Error ? err.message : String(err)}`); } return getClaudeCodeTools(p); }); From 03e729d1036631b8884f40d8bfd113e2a484ccd6 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 3 Oct 2026 23:49:16 +0200 Subject: [PATCH 4/7] claude-code owns /etc/claude-code and ~/.claude as declared directories So the controller's ownership check refuses a second module owning either. ~/.claude is the operator's at 0700 (it was 0755 on the workstations); of what is inside, the module owns only what it writes, and the host keeps a directory that is not empty when the module goes (hq ADR 0182). --- modules/claude-code/README.md | 11 +++++++++++ modules/claude-code/module.json | 13 +++++++++++++ 2 files changed, 24 insertions(+) diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index 697d7a6..f4af228 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -3,6 +3,17 @@ The operator's agent on a machine (novox/hq design 36): its package, its machine-wide managed configuration, and the consumer side of the Anthropic licence manager (design 39, ADR 0183). +## What it owns + +Two directories, declared, so the mesh refuses a second module owning either: + +- `/etc/claude-code`, the agent's machine-wide managed directory, root's, `0755`. +- `~/.claude` under the operator account's home, the operator's, `0700`. The module owns the directory — + that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else + in it (memory, history, projects, local settings, a person's own rules and skills) is the person's + and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host + removes a directory only when it is empty. + ## What it writes Under the agent's managed directory, `/etc/claude-code`, owned whole by this module and rewritten diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 1d470a0..74eec69 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -24,6 +24,19 @@ "type": "package", "package": "claude-code" }, + { + "id": "managed", + "type": "directory", + "path": "/etc/claude-code", + "mode": "0755" + }, + { + "id": "agent-home", + "type": "directory", + "path": "${machine:account-home}/.claude", + "mode": "0700", + "owner": "${machine:account}" + }, { "id": "state", "type": "directory", From 6a7e4ebd5e9b37b9abfe44e989f54bc0f1132a2e Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 02:23:23 +0200 Subject: [PATCH 5/7] 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"] } From 295cc59e1e55729083081d87af7c930ba880fd38 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 02:23:34 +0200 Subject: [PATCH 6/7] claude-code: declare using the licence manager's seat when that module exists; until then the mesh refuses a seat no module declares --- modules/claude-code/module.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index dbea321..3fa6085 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -21,9 +21,6 @@ "claude-licence-manager.licence.rotated", "claude-licence-manager.licence.switched" ], - "uses": [ - "anthropic-licence-manager" - ], "tools": [ "claude_code_status", "claude_code_render", From 3668b02b94b6f8c704a67a5110821c6c7bf21933 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 03:48:41 +0200 Subject: [PATCH 7/7] claude-code keeps its MCP servers in state, not events (novox/hq ADR 0202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One key per registration in the module's servers bucket — all. or . — watched by every node, so a node assigned after a registration takes it at start, which the mcp.registered event could not do. Also narrows apply()'s refusal by hand: the builder compiles without strict, where the discriminated union does not narrow and the build failed. --- modules/claude-code/README.md | 7 +- modules/claude-code/module.json | 9 +- modules/claude-code/node.ts | 129 +++++++++++++++++++------- modules/claude-code/package.json | 2 +- modules/claude-code/test/node.test.ts | 94 +++++++++++++++---- modules/claude-code/tools/index.ts | 47 +++++++--- 6 files changed, 211 insertions(+), 77 deletions(-) diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index 55de6ed..2575ad9 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -30,16 +30,17 @@ 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 +Everything between this module and the rest of the mesh is NATS, in three 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). +because nothing keeps it (hq design 32 §10); and **state** is the current value of something every node +must see, a node that joins later included — kept, so it carries no secret either (hq ADR 0202). | 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 | +| an MCP server registered through this module | a key in the module's `servers` state — `all.` for every node, `.` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. An entry with a secret in its `env` or `headers` is refused by the runtime | ## Tools diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 3fa6085..841e009 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -11,16 +11,13 @@ "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" ], + "state": [ + "servers" + ], "tools": [ "claude_code_status", "claude_code_render", diff --git a/modules/claude-code/node.ts b/modules/claude-code/node.ts index 6b1be5b..af22d7f 100644 --- a/modules/claude-code/node.ts +++ b/modules/claude-code/node.ts @@ -9,8 +9,10 @@ // - 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. +// - an MCP server registered through this module is **state, not an event** (novox/hq ADR 0202): one +// key per server in the module's `servers` bucket — `all.` for every node, `.` +// for one — which every node watches. A node that joins later, or was off, reads the whole current set +// at start; unregistering is a delete. A secret never goes in an entry: the runtime refuses one. import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -107,7 +109,7 @@ export function apply(p: Paths, handed: Current, write: WriteManaged): 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; +/** The `servers` state, as this module reaches it through the runtime (`state("servers")` in the SDK). */ +export interface ServerState { + put(key: string, value: Record): Promise; + delete(key: string): Promise; + keys(): Promise; } -const targets = (p: Paths, nodes: Registration["nodes"]) => - nodes === "all" ? true : Array.isArray(nodes) ? nodes.includes(p.node) : false; +/** One change to the `servers` state, as a watch hands it over. */ +export interface ServerChange { + key: string; + op: "put" | "delete"; + value?: Record; +} -/** 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> { +/** The key a registration lives at: `all.` for every node, `.` for one. */ +export const keyOf = (scope: string, name: string) => `${scope}.${name}`; + +/** + * What this node takes from the `servers` state: the entries for every node and for this one, by key — + * kept in memory from the watch, and written through to the module's own file whenever what applies here + * changes, so the managed directory can be rendered without the bus. + */ +export class ServerView { + private readonly entries = new Map>(); + constructor(private readonly p: Paths) {} + + /** Take one change; answers whether what applies to this node changed. */ + take(c: ServerChange): boolean { + const dot = c.key.indexOf("."); + const scope = c.key.slice(0, dot), name = c.key.slice(dot + 1); + if (dot <= 0 || (scope !== "all" && scope !== this.p.node)) return false; + if (c.op === "put" && c.value && entryProblem(name, c.value) === null) this.entries.set(c.key, c.value); + else this.entries.delete(c.key); + return this.writeThrough(); + } + + /** What applies here: every node's entries, with this node's own laid over them by server name. */ + effective(): Servers { + const out: Record> = {}; + for (const scope of ["all", this.p.node]) { + for (const [key, entry] of [...this.entries].sort(([a], [b]) => a.localeCompare(b))) { + if (key.startsWith(scope + ".")) out[key.slice(scope.length + 1)] = entry; + } + } + return out; + } + + private writeThrough(): boolean { + const now = JSON.stringify(this.effective(), null, 2) + "\n"; + let before = ""; + try { + before = readFileSync(registryPath(this.p), "utf8"); + } catch { + /* none yet */ + } + if (now === before) return false; + writeFileSync(registryPath(this.p), now, { mode: 0o600 }); + return true; + } +} + +/** A change from the watch: take it, and render when what applies here changed. */ +export function onServerChange(view: ServerView, c: ServerChange, p: Paths, write: WriteManaged): string | null { + if (!view.take(c)) return null; + renderNow(p, write); + return `${c.op === "put" ? "registered" : "unregistered"} ${c.key}`; +} + +const scopesOf = (p: Paths, nodes: Registration["nodes"]): string[] => + nodes === undefined ? [p.node] : nodes === "all" ? ["all"] : nodes; + +/** + * Register (or with no entry, unregister) a server: a put (or delete) per scope in the `servers` state. + * Taken into this node's view at once, so the answer says what it did here; every other node takes it + * from its watch, and a node that joins later from the current state. + */ +export async function registerServer(p: Paths, r: Registration, servers: ServerState, view: ServerView, + 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 scopes = scopesOf(p, r.nodes); + // Compared before and after rather than read from take(): this node's own watch may hand the view the + // same change first, and then take() here finds nothing new although this call made it. + const before = JSON.stringify(view.effective()); + for (const scope of scopes) { + const key = keyOf(scope, r.name); + if (r.entry) await servers.put(key, r.entry); + else await servers.delete(key); + view.take({ key, op: r.entry ? "put" : "delete", value: r.entry }); } + const changedHere = JSON.stringify(view.effective()) !== before; + const here = scopes.includes("all") || scopes.includes(p.node); 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, + here: here ? (changedHere ? "changed" : "already so") : "not this node", + rendered: changedHere ? renderNow(p, write) : [], }; + if (!r.entry && view.effective()[r.name]) { + answer.still = `${r.name} still applies here from another registration (for every node, or for this one); unregister that too`; + } 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); @@ -195,14 +268,4 @@ export async function registerServer(p: Paths, r: Registration, emit: Emit, writ 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 fca3599..fc05060 100644 --- a/modules/claude-code/package.json +++ b/modules/claude-code/package.json @@ -9,7 +9,7 @@ "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'" }, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.7" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/claude-code/test/node.test.ts b/modules/claude-code/test/node.test.ts index 50d57dd..d7cd92d 100644 --- a/modules/claude-code/test/node.test.ts +++ b/modules/claude-code/test/node.test.ts @@ -4,7 +4,8 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from "node:os"; import { join } from "node:path"; import { - apply, concerns, keypair, offerLogin, onServerEvent, pull, registerServer, registered, type Paths, + apply, concerns, keypair, offerLogin, onServerChange, pull, registerServer, registered, ServerView, type Paths, + type ServerChange, type ServerState, } from "../dist/node.js"; import { generateKeyPair, open, seal } from "../dist/seal.js"; @@ -89,31 +90,84 @@ test("no refresh token in the file is no login, and nothing is asked", async () 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"]); +/** The `servers` state as the bus holds it, shared by every node in a test, with each node's watch. */ +function bus() { + const kept = new Map>(); + const watchers: ((c: ServerChange) => void)[] = []; + const state: ServerState = { + put: async (key, value) => { kept.set(key, value); watchers.forEach((w) => w({ key, op: "put", value })); return kept.size; }, + delete: async (key) => { kept.delete(key); watchers.forEach((w) => w({ key, op: "delete" })); }, + keys: async () => [...kept.keys()].sort(), + }; + /** A node joining: its view takes the current state, then every change. */ + const join = (n: { p: Paths; written: Record }) => { + const view = new ServerView(n.p); + for (const [key, value] of kept) onServerChange(view, { key, op: "put", value }, n.p, writer(n.written)); + watchers.push((c) => onServerChange(view, c, n.p, writer(n.written))); + return view; + }; + return { state, join, kept }; +} + +test("registering a server here puts it under this node's key, renders it, and asks about the other nodes", async () => { + const n = node(); + const b = bus(); + const view = b.join(n); + const r = await registerServer(n.p, { name: "search", entry: { type: "http", url: "https://s.example/mcp" } }, + b.state, view, writer(n.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); + assert.deepEqual([...b.kept.keys()], ["laptop.search"]); + assert.ok(JSON.parse(n.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; +test("registering for every node reaches the others through their watch, and a node joining later reads it", async () => { + const a = node("laptop"), s = node("server"); + const b = bus(); + const va = b.join(a); + b.join(s); 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"); + b.state, va, writer(a.written), async () => []); + assert.deepEqual([...b.kept.keys()], ["all.docs"]); + assert.deepEqual(registered(s.p).docs, { type: "stdio", command: "docs-mcp" }); + assert.ok(JSON.parse(s.written["managed-mcp.json"]).mcpServers.docs); + // The gap events left: a node assigned after the registration takes the whole current set at start. + const late = node("desktop"); + b.join(late); + assert.deepEqual(registered(late.p).docs, { type: "stdio", command: "docs-mcp" }); + // Unregistering is a delete, and every node's view drops it. + await registerServer(a.p, { name: "docs", nodes: "all" }, b.state, va, writer(a.written), async () => []); + assert.equal(registered(s.p).docs, undefined); + assert.equal(registered(late.p).docs, undefined); }); -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 () => []); +test("a node's own registration overrides the one for every node; other nodes' keys leave this one alone", async () => { + const a = node("laptop"), s = node("server"); + const b = bus(); + const va = b.join(a); + const vs = b.join(s); + await registerServer(a.p, { name: "x", entry: { type: "http", url: "https://all" }, nodes: "all" }, b.state, va, writer(a.written), async () => []); + await registerServer(a.p, { name: "x", entry: { type: "http", url: "https://laptop" } }, b.state, va, writer(a.written), async () => []); + assert.equal(registered(a.p).x.url, "https://laptop"); + assert.equal(registered(s.p).x.url, "https://all"); + await registerServer(a.p, { name: "only", entry: { type: "http", url: "https://o" }, nodes: ["server"] }, b.state, va, writer(a.written), async () => []); + assert.equal(registered(a.p).only, undefined); + assert.equal(registered(s.p).only.url, "https://o"); + // Unregistering here leaves the every-node one applying, and says so. + const r = await registerServer(a.p, { name: "x" }, b.state, va, writer(a.written), async () => []); + assert.match(String(r.still), /still applies here/); + assert.equal(registered(a.p).x.url, "https://all"); + assert.equal(vs.effective().x.url, "https://all"); +}); + +test("a bad entry is refused before anything is put; a repeated change changes nothing", async () => { + const n = node(); + const b = bus(); + const view = b.join(n); + const r = await registerServer(n.p, { name: "mesh", entry: { type: "http", url: "https://x" } }, b.state, view, writer(n.written), async () => []); assert.equal(r.registered, false); + assert.equal(b.kept.size, 0); + assert.equal(onServerChange(view, { key: "all.a", op: "put", value: { type: "http", url: "https://a" } }, n.p, writer(n.written)), "registered all.a"); + assert.equal(onServerChange(view, { key: "all.a", op: "put", value: { type: "http", url: "https://a" } }, n.p, writer(n.written)), null); + assert.equal(onServerChange(view, { key: "server.b", op: "put", value: { type: "http", url: "https://b" } }, n.p, writer(n.written)), null); }); diff --git a/modules/claude-code/tools/index.ts b/modules/claude-code/tools/index.ts index 7a04abe..af4bffb 100644 --- a/modules/claude-code/tools/index.ts +++ b/modules/claude-code/tools/index.ts @@ -4,19 +4,21 @@ // 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, and takes the module's events: the manager's -// licence events and every node's MCP server registrations. node.ts holds the logic. +// 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 0202). node.ts holds the +// logic. 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 { on } from "@novox/mesh-sdk/events"; +import { state } from "@novox/mesh-sdk/state"; import { - MANAGED_DIR, SEAT, concerns, keypair, offerLogin, onServerEvent, pull, readJson, registerServer, - registered, renderNow, type Ask, type Paths, type Registration, type WriteManaged, + 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"; @@ -90,6 +92,13 @@ function status(p: Paths): Record { }; } +/** The module's MCP servers on the bus (ADR 0202): 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"] => @@ -115,13 +124,13 @@ function tools(p: Paths): ToolDefinition[] { }, { 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.", + 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 () => ({ registered: registered(p) }), + 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 — 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.", + 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)" }, @@ -135,14 +144,14 @@ function tools(p: Paths): ToolDefinition[] { 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); + 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) }, emit, writeManaged, nodesRunningMe), + run: async (a) => registerServer(p, { name: String(a.name ?? ""), nodes: nodesOf(a.nodes) }, servers(), viewOf(p), writeManaged, nodesRunningMe), }, ]; } @@ -171,10 +180,20 @@ if (p) { 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")); + // Every node's MCP servers: the whole current set first, then each change (ADR 0202). Awaited, so the + // managed directory holds every server that applies here before the bundle says what it serves. + try { + await 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 + } + }); + } catch (err) { + loud("watching the MCP servers")(err); + } // 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"));