claude-code over NATS: licence events, a token by request, a login pushed to the manager, MCP servers registered per node or mesh-wide

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