From 04626be62d164b8b71ad9db8010c0247eae8590e Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 3 Oct 2026 16:13:01 +0200 Subject: [PATCH] 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"] +}