diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md new file mode 100644 index 0000000..2575ad9 --- /dev/null +++ b/modules/claude-code/README.md @@ -0,0 +1,74 @@ +# 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 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 +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. + +## Over NATS + +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); 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 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 + +`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, 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. + +## 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/grant.ts b/modules/claude-code/grant.ts new file mode 100644 index 0000000..3b425d8 --- /dev/null +++ b/modules/claude-code/grant.ts @@ -0,0 +1,112 @@ +// 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; +} + +/** + * 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 ?? {}) }; + 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..aa526a9 --- /dev/null +++ b/modules/claude-code/identity.ts @@ -0,0 +1,50 @@ +// 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 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, renameSync, writeFileSync } 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; + } +} + +/** + * 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 new file mode 100644 index 0000000..841e009 --- /dev/null +++ b/modules/claude-code/module.json @@ -0,0 +1,90 @@ +{ + "module": "claude-code", + "version": "1", + "slug": "agent", + "capabilities": [ + "package-manager" + ], + "requires": [ + "mcp-endpoint" + ], + "binds": { + "mcp-endpoint": "${dir:state}/mcp-endpoint.json" + }, + "consumes": [ + "claude-licence-manager.licence.rotated", + "claude-licence-manager.licence.switched" + ], + "state": [ + "servers" + ], + "tools": [ + "claude_code_status", + "claude_code_render", + "claude_code_pull", + "claude_code_mcp_list", + "claude_code_mcp_register", + "claude_code_mcp_unregister" + ], + "resources": [ + { + "id": "package", + "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", + "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/node.ts b/modules/claude-code/node.ts new file mode 100644 index 0000000..af22d7f --- /dev/null +++ b/modules/claude-code/node.ts @@ -0,0 +1,271 @@ +// 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 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"; + +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: "reason" in d ? d.reason : undefined }; // narrowed by hand: the build compiles without strict + // 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[]; +} + +/** 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; +} + +/** One change to the `servers` state, as a watch hands it over. */ +export interface ServerChange { + key: string; + op: "put" | "delete"; + value?: Record; +} + +/** 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 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 ? (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); + 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; +} + +export { MANAGED_DIR }; diff --git a/modules/claude-code/package.json b/modules/claude-code/package.json new file mode 100644 index 0000000..fc05060 --- /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 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": { + "@novox/mesh-sdk": "^0.1.7" + }, + "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..bdc361b --- /dev/null +++ b/modules/claude-code/render.ts @@ -0,0 +1,135 @@ +// 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 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 ?? {}), ...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 }; + + 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\`. 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: 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. + +## 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/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/node.test.ts b/modules/claude-code/test/node.test.ts new file mode 100644 index 0000000..d7cd92d --- /dev/null +++ b/modules/claude-code/test/node.test.ts @@ -0,0 +1,173 @@ +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, onServerChange, pull, registerServer, registered, ServerView, type Paths, + type ServerChange, type ServerState, +} 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); +}); + +/** 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.deepEqual([...b.kept.keys()], ["laptop.search"]); + assert.ok(JSON.parse(n.written["managed-mcp.json"]).mcpServers.search); +}); + +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" }, + 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("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/test/render.test.ts b/modules/claude-code/test/render.test.ts new file mode 100644 index 0000000..2efeb59 --- /dev/null +++ b/modules/claude-code/test/render.test.ts @@ -0,0 +1,40 @@ +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, /mesh_call/); + assert.match(md, /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/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")); +}); diff --git a/modules/claude-code/tools/index.ts b/modules/claude-code/tools/index.ts new file mode 100644 index 0000000..af4bffb --- /dev/null +++ b/modules/claude-code/tools/index.ts @@ -0,0 +1,208 @@ +// claude-code's bundle (novox/hq design 36, ADR 0183). The node's runtime launches it over stdio, as the +// operator account (ADR 0193), and is its bus (ADR 0198): it asks tools, emits and consumes through the +// runtime. It is given its state directory and two files the mesh renders into it (ADR 0192), beside the +// runtime's own words. **stdout is the MCP channel**: everything this module says, it says on stderr. +// +// At start it renders the agent's managed directory, asks the licence manager for this node's token, +// begins watching the credentials file for a login, takes the manager's licence events, and watches the +// module's `servers` state — every node's MCP server registrations (novox/hq ADR 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 { on } from "@novox/mesh-sdk/events"; +import { state } from "@novox/mesh-sdk/state"; + +import { + MANAGED_DIR, SEAT, ServerView, concerns, keypair, offerLogin, onServerChange, pull, readJson, registerServer, + registered, renderNow, type Ask, type Paths, type Registration, type ServerChange, type ServerState, type WriteManaged, +} from "../node.js"; +import { grantOf, holdsLogin, readCredentials } from "../grant.js"; +import { createHash } from "node:crypto"; + +const say = (line: string) => console.error(`[claude-code] ${line}`); +const fingerprint = (s: string) => "sha256:" + createHash("sha256").update(s).digest("hex").slice(0, 16); + +function pathsFrom(env: NodeJS.ProcessEnv): Paths | null { + const state = env.MESH_CLAUDE_CODE_STATE, facts = env.MESH_CLAUDE_CODE_FACTS; + const settings = env.MESH_CLAUDE_CODE_SETTINGS, home = env.MESH_OPERATOR_HOME, node = env.MESH_NODE; + if (!state || !facts || !settings || !home || !node) return null; + return { state, facts, settings, home, node }; +} + +/** Write one managed file as root, only when its content changed. */ +const writeManaged: WriteManaged = (name, content) => { + const path = join(MANAGED_DIR, name); + try { + if (readFileSync(path, "utf8") === content) return `${name}: unchanged`; + } catch { + /* absent */ + } + 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`); + } + return `${name}: written`; +}; + +/** A tool on the bus, through the runtime; its MCP answer read back as JSON where it is JSON. */ +const ask: Ask = async (address, args) => { + const answer = (await broker().request, { content?: { text?: string }[]; isError?: boolean }>(address, args)) ?? {}; + const text = answer.content?.map((c) => c.text ?? "").join("") ?? ""; + if (answer.isError) throw new Error(`${address}: ${text}`); + try { + return JSON.parse(text); + } catch { + return text; + } +}; + +/** The nodes claude-code runs on, from the controller's list of modules — for the register tool's question. */ +async function nodesRunningMe(): Promise { + const out = await ask("mesh-controller.modules", {}); + const text = typeof out === "string" ? out : String((out as { output?: string })?.output ?? ""); + const line = text.split("\n").find((l) => /^claude-code\s/.test(l)) ?? ""; + const on = line.split(" on ")[1] ?? ""; + return on.trim() === "nothing" ? [] : on.split(",").map((s) => s.trim()).filter(Boolean); +} + +function status(p: Paths): Record { + const creds = readCredentials(join(p.home, ".claude", ".credentials.json")); + const grant = grantOf(creds); + const managed = ["managed-mcp.json", "managed-settings.json", "CLAUDE.md"].map((f) => { + try { + return { file: join(MANAGED_DIR, f), fingerprint: fingerprint(readFileSync(join(MANAGED_DIR, f), "utf8")) }; + } catch { + return { file: join(MANAGED_DIR, f), fingerprint: null }; + } + }); + return { + node: p.node, + licence: readJson(join(p.state, "licence.json"), null), + token: grant ? { fingerprint: fingerprint(grant.accessToken), expiresAt: new Date(grant.expiresAt).toISOString(), + loginWaiting: holdsLogin(creds) } : null, + managed, + registered: Object.keys(registered(p)), + }; +} + +/** The module's MCP servers on the bus (ADR 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"] => + v === undefined || v === "" ? undefined : v === "all" ? "all" : String(v).split(",").map((s) => s.trim()).filter(Boolean); + return [ + { + name: "claude_code_status", + description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, the managed files, the MCP servers registered here. Fingerprints only, never a token.", + input: {}, + run: async () => status(p), + }, + { + name: "claude_code_render", + description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here.", + input: {}, + run: async () => ({ rendered: renderNow(p, writeManaged) }), + }, + { + name: "claude_code_pull", + description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its next event.", + input: {}, + run: async () => pull(p, ask, writeManaged), + }, + { + name: "claude_code_mcp_list", + description: "The MCP servers registered through this module: those that apply on this node (beside the console, `mesh`, and those set in the module's settings), and every registration on the mesh, by key — `all.` for every node, `.` for one.", + input: {}, + run: async () => ({ here: registered(p), everywhere: await servers().keys() }), + }, + { + name: "claude_code_mcp_register", + description: "Register an MCP server with Claude Code on this node, every node, or a list — an http/sse server by url, or a stdio server by command. Kept on the bus, so a node that joins later takes it too. Never put a secret in env or headers: the mesh refuses one.", + input: { + name: { type: "string", description: "the server's name: letters, digits, - and _" }, + type: { type: "string", description: "http, sse or stdio (default stdio when a command is given, http when a url is)" }, + url: { type: "string", description: "an http or sse server's url" }, + command: { type: "string", description: "a stdio server's program" }, + args: { type: "array", description: "a stdio server's arguments" }, + env: { type: "object", description: "a stdio server's environment" }, + headers: { type: "object", description: "an http server's headers" }, + nodes: nodesArg, + }, + run: async (a) => { + const entry: Record = { type: a.type ?? (a.url ? "http" : "stdio") }; + for (const k of ["url", "command", "args", "env", "headers"]) if (a[k] !== undefined) entry[k] = a[k]; + return registerServer(p, { name: String(a.name ?? ""), entry, nodes: nodesOf(a.nodes) }, servers(), viewOf(p), writeManaged, nodesRunningMe); + }, + }, + { + name: "claude_code_mcp_unregister", + description: "Remove an MCP server registered through this module, on this node or more.", + input: { name: { type: "string", description: "the server's name" }, nodes: nodesArg }, + run: async (a) => registerServer(p, { name: String(a.name ?? ""), nodes: nodesOf(a.nodes) }, servers(), viewOf(p), writeManaged, nodesRunningMe), + }, + ]; +} + +registerModuleTools("claude-code", (env) => { + const p = pathsFrom(env); + if (!p) return []; + try { + keypair(p); + for (const line of renderNow(p, writeManaged)) if (!line.endsWith("unchanged")) say(line); + } catch (err) { + say(err instanceof Error ? err.message : String(err)); + } + return tools(p); +}); + +// Launched by the runtime: the bus is there from the first line (ADR 0198). Outside it — a test, a +// build — nothing below runs. +const p = process.env.MESH_SERVED_MODULE ? pathsFrom(process.env) : null; +if (p) { + const loud = (what: string) => (err: unknown) => say(`${what}: ${err instanceof Error ? err.message : String(err)}`); + + void on<{ licence?: string; node?: string }>("claude-licence-manager.licence.*", async (event) => { + if (!concerns(p, event.type, event.body ?? {})) return; + say(`${event.type} — asking ${SEAT} for this node's token`); + say(JSON.stringify(await pull(p, ask, writeManaged).catch((e) => ({ failed: String(e) })))); + }).catch(loud("the licence events")); + + // Every node's MCP servers: the whole current set first, then each change (ADR 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")); + + // 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 new file mode 100644 index 0000000..8e1f1bb --- /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", "node.ts", "tools/index.ts"] +}