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.
260 lines
14 KiB
TypeScript
260 lines
14 KiB
TypeScript
// 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, 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";
|
|
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 { state } from "@novox/mesh-sdk/state";
|
|
|
|
import {
|
|
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";
|
|
|
|
const say = (line: string) => console.error(`[claude-code] ${line}`);
|
|
|
|
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 */
|
|
}
|
|
// From a file, never /dev/stdin: Node hands a child its input over a socket, which /dev/stdin cannot
|
|
// open (ENXIO) — found on the first assignment, where nothing under /etc/claude-code was ever written.
|
|
const staged = mkdtempSync(join(tmpdir(), "claude-code-"));
|
|
const source = join(staged, name);
|
|
writeFileSync(source, content, { mode: 0o644 });
|
|
const asRoot = process.getuid?.() === 0;
|
|
const cmd = asRoot ? ["install", "-D", "-m", "0644", source, path] : ["sudo", "-n", "install", "-D", "-m", "0644", source, path];
|
|
const r = spawnSync(cmd[0], cmd.slice(1), { encoding: "utf8" });
|
|
rmSync(staged, { recursive: true, force: true });
|
|
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. 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>, 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 {
|
|
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<string[]> {
|
|
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] ?? "";
|
|
return on.trim() === "nothing" ? [] : on.split(",").map((s) => s.trim()).filter(Boolean);
|
|
}
|
|
|
|
function status(p: Paths): Record<string, unknown> {
|
|
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,
|
|
holdings: holdingsOf(p),
|
|
managed,
|
|
registered: Object.keys(registered(p)),
|
|
};
|
|
}
|
|
|
|
/** The module's MCP servers on the bus (ADR 0201): its own state, which every node of it watches. */
|
|
const servers = () => state<Record<string, unknown>>("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_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.",
|
|
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<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) }, 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)}`);
|
|
|
|
// 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
|
|
// unable to answer `initialize` in time and the module unserved (found on its first assignment). So it
|
|
// watches beside the handshake and asks again until the state answers; until then the managed
|
|
// directory holds what the file kept from the last run.
|
|
const watchServers = (attempt = 0): void => {
|
|
state<Record<string, unknown>>("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
|
|
}
|
|
}).then(
|
|
() => say(`watching the MCP servers${attempt ? ` (after ${attempt} refusal(s))` : ""}`),
|
|
(err) => {
|
|
const wait = [2, 5, 10, 30][attempt] ?? 60;
|
|
say(`the MCP servers cannot be watched yet (${err instanceof Error ? err.message : String(err)}); asking again in ${wait}s`);
|
|
setTimeout(() => watchServers(attempt + 1), wait * 1000);
|
|
});
|
|
};
|
|
watchServers();
|
|
|
|
/** 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);
|
|
});
|
|
};
|
|
|
|
// 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))` : ""}`));
|
|
}
|