The licence manager, in Go, and claude-code's half of ADR 0206

claude-licence-manager holds the anthropic-licence-manager seat: it reads
every node's holdings state, adopts a login it does not hold by refreshing
it (newest first, once per account), keeps each grant alive under a lease,
publishes what each consumer should hold as its bindings state with a
generation, and answers current sealed to the consumer's key. Postgres
store prepared by a run-once step; grants encrypted with the vault's key.

claude-code reports what its node holds (fingerprints and account, never a
token), hands its grant over only when the manager asks, watches its
binding and fetches the token on a newer generation, and writes
access-token-only. Its ask now reads the runtime's answer as a value and
addresses seats as seats.
This commit is contained in:
jochen
2026-10-04 12:18:51 +02:00
parent 83a51832d7
commit 15b2e6b86e
19 changed files with 2982 additions and 99 deletions
+64 -29
View File
@@ -3,10 +3,10 @@
// 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 0201). node.ts holds the
// logic.
// At start it renders the agent's managed directory, reports what this node holds as the module's
// `holdings` state and again whenever the credentials file changes, watches the licence manager's
// `bindings` state for this node and fetches the token when it says so (novox/hq ADR 0206), and watches
// the module's `servers` state — every node's MCP server registrations (ADR 0201). node.ts holds the logic.
import { mkdtempSync, readFileSync, rmSync, watchFile, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -14,18 +14,16 @@ 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,
MANAGED_DIR, MANAGER, ServerView, fingerprint, grantFor, holdingsOf, keypair, onBinding, onServerChange, pull,
readJson, registerServer, registered, renderNow,
type Ask, type BindingState, 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;
@@ -58,11 +56,16 @@ const writeManaged: WriteManaged = (name, content) => {
return `${name}: written`;
};
/** A tool on the bus, through the runtime; its MCP answer read back as JSON where it is JSON. */
/**
* A tool on the bus, through the runtime. The runtime answers with the tool's value itself — a refusal is
* the request failing — so this reads an MCP envelope only where something answered with one.
*/
const ask: Ask = async (address, args) => {
const answer = (await broker().request<Record<string, unknown>, { content?: { text?: string }[]; isError?: boolean }>(address, args)) ?? {};
const text = answer.content?.map((c) => c.text ?? "").join("") ?? "";
if (answer.isError) throw new Error(`${address}: ${text}`);
const answer = await broker().request<Record<string, unknown>, unknown>(address, args);
const env = answer as { content?: { text?: string }[]; isError?: boolean } | null;
if (!env || typeof env !== "object" || !Array.isArray(env.content)) return answer;
const text = env.content.map((c) => c.text ?? "").join("");
if (env.isError) throw new Error(`${address}: ${text}`);
try {
return JSON.parse(text);
} catch {
@@ -72,7 +75,7 @@ const ask: Ask = async (address, args) => {
/** 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 out = await ask("seat: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] ?? "";
@@ -94,6 +97,7 @@ function status(p: Paths): Record<string, unknown> {
licence: readJson(join(p.state, "licence.json"), null),
token: grant ? { fingerprint: fingerprint(grant.accessToken), expiresAt: new Date(grant.expiresAt).toISOString(),
loginWaiting: holdsLogin(creds) } : null,
holdings: holdingsOf(p),
managed,
registered: Object.keys(registered(p)),
};
@@ -129,6 +133,17 @@ function tools(p: Paths): ToolDefinition[] {
input: {},
run: async () => pull(p, ask, writeManaged),
},
{
name: "claude_code_grant",
description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.",
input: { public_key: { type: "string", description: "the manager's public key, PEM; the grant opens only with its private half" } },
run: async (a) => {
if (typeof a.public_key !== "string" || !a.public_key.includes("PUBLIC KEY")) {
throw new Error("claude_code_grant seals to a public key, and none was given");
}
return grantFor(p, a.public_key) ?? { waiting: false };
},
},
{
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.<server>` for every node, `<node>.<server>` for one.",
@@ -181,12 +196,6 @@ 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 0201). **Not awaited
// where the module is imported**: the runtime waits on the handshake, and a bucket that is not on the
// bus yet — or a grant the bus has not reloaded — answers late; awaited here, that left the bundle
@@ -211,14 +220,40 @@ if (p) {
};
watchServers();
// 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"));
/** Ask the state again until it answers: its bucket or the bus's grant may arrive after the module. */
const persist = (what: string, attempt: () => Promise<unknown>, done: (n: number) => void, n = 0): void => {
attempt().then(() => done(n), (err) => {
const wait = [2, 5, 10, 30][n] ?? 60;
say(`${what} not yet (${err instanceof Error ? err.message : String(err)}); asking again in ${wait}s`);
setTimeout(() => persist(what, attempt, done, n + 1), wait * 1000);
});
};
// 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"));
});
// What this node holds (ADR 0206): at start — a node already logged in is reported at once — and on
// every change of the credentials file, polled because the file is replaced by rename and a watch on the
// old inode would go quiet. Fingerprints and expiries only; the runtime refuses a token anyway.
const holdings = state<Record<string, unknown>>("holdings");
let reported = "";
const report = (): void => {
const now = holdingsOf(p);
const text = JSON.stringify(now);
if (text === reported) return;
persist("reporting what this node holds", () => holdings.put(p.node, now as unknown as Record<string, unknown>), () => {
reported = text;
say(`reported: ${now.identity?.emailAddress ?? "no account"}, ${now.kind ?? "no token"}` +
`${now.refresh.present ? ", a login waiting" : ""}${now.licence ? `, licence ${now.licence} g${now.generation}` : ""}`);
});
};
report();
watchFile(join(p.home, ".claude", ".credentials.json"), { interval: 5000 }, report);
// What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer generation
// is fetched with the seat's `current`, sealed to this module's key. Absent until the manager exists.
persist("watching this node's licence binding", () => state<BindingState>(`${MANAGER}.bindings`).watch(async (c) => {
if (c.key !== p.node) return;
const done = await onBinding(p, c.op === "put" ? (c.value as BindingState) : null, ask, writeManaged)
.catch((err) => `fetching this node's token failed: ${err instanceof Error ? err.message : String(err)}`);
if (done) say(done);
report();
}, { key: p.node }), (n) => say(`watching this node's licence binding${n ? ` (after ${n} refusal(s))` : ""}`));
}