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
+6 -5
View File
@@ -11,17 +11,18 @@
"binds": {
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
},
"consumes": [
"claude-licence-manager.licence.rotated",
"claude-licence-manager.licence.switched"
],
"state": [
"servers"
"servers",
"holdings"
],
"reads": [
"claude-licence-manager.bindings"
],
"tools": [
"claude_code_status",
"claude_code_render",
"claude_code_pull",
"claude_code_grant",
"claude_code_mcp_list",
"claude_code_mcp_register",
"claude_code_mcp_unregister"
+116 -36
View File
@@ -2,19 +2,21 @@
// 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 0201): one
// key per server in the module's `servers` bucket — `all.<server>` for every node, `<node>.<server>`
// 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.
// **Over NATS** (design 32 §10, ADR 0201, ADR 0206): what is *current* is state, a secret only ever
// travels on a request, sealed to its one recipient, and nothing is an event any more:
// - **what this node holds** is the module's `holdings` state, one key per node: the account, the kind,
// fingerprints and expiries — never a token. Written at start and on every change of the credentials
// file, so the licence manager learns a login, or a node already logged in, from the state alone;
// - **the grant itself** leaves only when the manager asks `claude_code_grant`, sealed to the key it
// gives — the manager adopts a licence by refreshing it, and from then on is its only refresher;
// - **what this node should hold** is the manager's `bindings` state; a newer generation for this node
// is fetched with the seat's `current` verb, sealed to this module's key, and written access-token-only,
// so the agent here never refreshes and a refresh token appearing later is a person's login;
// - an MCP server registered through this module is a key in its `servers` state — `all.<server>` for
// every node, `<node>.<server>` for one — which every node watches.
import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { render, entryProblem, MANAGED_DIR, type Binding, type Facts, type Settings, type Servers } from "./render.js";
@@ -23,6 +25,13 @@ import { decideApply, grantOf, holdsLogin, readCredentials, replacedBy, withGran
import { readIdentity, writeIdentity, type Identity } from "./identity.js";
export const SEAT = "anthropic-licence-manager";
/** The manager module whose `bindings` state this module reads (ADR 0206). */
export const MANAGER = "claude-licence-manager";
/** A seat's verb as the runtime addresses it: a role, not a module. */
export const seatVerb = (verb: string) => `seat:${SEAT}.${verb}`;
/** A token named without being one: the first 16 hex of its SHA-256. */
export const fingerprint = (s: string) => "sha256:" + createHash("sha256").update(s).digest("hex").slice(0, 16);
export interface Paths {
state: string;
@@ -79,25 +88,112 @@ export function renderNow(p: Paths, write: WriteManaged): string[] {
// ---- the licence ----------------------------------------------------------------------------------
/** What the licence this node holds was, as it was last applied: its name, kind and generation. */
export interface Applied extends Binding {
readonly generation?: number;
}
/** What the manager's `bindings` state says one consumer should hold (ADR 0206). */
export interface BindingState {
readonly licence: string;
readonly kind: "subscription" | "api-key";
readonly generation: number;
}
/** 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";
generation?: number;
sealed: SealedBox;
identity?: Identity | null;
}
/**
* What this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager to tell a
* login it has not adopted from one it has, and never a token — fingerprints and expiries only.
*/
export interface Holdings {
readonly node: string;
readonly identity: Identity | null;
readonly kind: "subscription" | "api-key" | null;
/** The refresh token's fingerprint, and whether the file holds one at all: a login waiting. */
readonly refresh: { readonly present: boolean; readonly fingerprint: string | null; readonly expiresAt: number | null };
readonly access: { readonly fingerprint: string; readonly expiresAt: number } | null;
/** The licence and generation this module last applied, or null before any. */
readonly licence: string | null;
readonly generation: number;
/** When the credentials file last changed: the newest login of several is tried first. */
readonly changedAt: string | null;
}
export function holdingsOf(p: Paths): Holdings {
const creds = readCredentials(credentialsPath(p));
const o = creds?.claudeAiOauth as Record<string, unknown> | undefined;
const applied = readJson<Applied | null>(bindingPath(p), null);
let changedAt: string | null = null;
try {
changedAt = statSync(credentialsPath(p)).mtime.toISOString();
} catch {
/* no file */
}
const refreshValue = typeof o?.refreshToken === "string" && o.refreshToken ? o.refreshToken : null;
const accessValue = typeof o?.accessToken === "string" && o.accessToken ? o.accessToken : null;
const apiKey = existsSync(apiKeyPath(p));
return {
node: p.node,
identity: readIdentity(accountPath(p)),
kind: apiKey ? "api-key" : accessValue ? "subscription" : null,
refresh: {
present: refreshValue !== null,
fingerprint: refreshValue ? fingerprint(refreshValue) : null,
expiresAt: o?.refreshTokenExpiresAt == null ? null : Number(o.refreshTokenExpiresAt),
},
access: accessValue ? { fingerprint: fingerprint(accessValue), expiresAt: Number(o?.expiresAt ?? 0) } : null,
licence: applied?.licence ?? null,
generation: applied?.generation ?? 0,
changedAt,
};
}
/**
* The full grant in the credentials file, sealed to the key the manager gives — the one time a refresh
* token leaves this node, for the manager to adopt by refreshing it (ADR 0206). Null when the file holds
* no refresh token: there is nothing to adopt.
*/
export function grantFor(p: Paths, managerPublicKey: string): { sealed: SealedBox; identity: Identity | null; fingerprint: string } | null {
const creds = readCredentials(credentialsPath(p));
if (!holdsLogin(creds)) return null;
const oauth = creds!.claudeAiOauth!;
return {
sealed: seal(JSON.stringify(oauth), managerPublicKey),
identity: readIdentity(accountPath(p)),
fingerprint: fingerprint(String(oauth.refreshToken)),
};
}
/** 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;
const answer = (await ask(seatVerb("current"), { consumer: 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);
}
/**
* The manager's `bindings` key for this node changed (ADR 0206): fetch the token when the generation is
* newer than the one applied. A released binding keeps the last token, which lives hours, and says so.
*/
export async function onBinding(p: Paths, b: BindingState | null, ask: Ask, write: WriteManaged): Promise<string | null> {
if (!b) return "this node's binding was released; it keeps its last token until it expires";
const applied = readJson<Applied | null>(bindingPath(p), null);
if (applied && (applied.generation ?? 0) >= b.generation) return null;
return JSON.stringify(await pull(p, ask, 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 previous = readJson<Applied | 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") {
@@ -107,7 +203,10 @@ export function apply(p: Paths, handed: Current, write: WriteManaged): Record<st
} else {
const grant = JSON.parse(plain) as Grant;
const local = readCredentials(credentialsPath(p));
const d = decideApply(grantOf(local), grant, switched ? "switch" : "rotation");
// A login waiting here was handed to the manager first (ADR 0206): what comes back is its successor,
// and the refresh token in the file is the one the manager just spent. Written access-token-only
// either way, so the agent here never refreshes.
const d = decideApply(grantOf(local), grant, switched || holdsLogin(local) ? "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.
@@ -117,7 +216,8 @@ export function apply(p: Paths, handed: Current, write: WriteManaged): Record<st
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 });
const applied: Applied = { licence: handed.licence, kind: handed.kind, generation: handed.generation ?? previous?.generation ?? 0 };
writeFileSync(bindingPath(p), JSON.stringify(applied) + "\n", { mode: 0o600 });
try {
outcome.rendered = renderNow(p, write); // the key-helper comes or goes with the licence's kind
} catch (err) {
@@ -126,26 +226,6 @@ export function apply(p: Paths, handed: Current, write: WriteManaged): Record<st
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 {
+55 -29
View File
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
apply, concerns, keypair, offerLogin, onServerChange, pull, registerServer, registered, ServerView, type Paths,
apply, grantFor, holdingsOf, keypair, onBinding, onServerChange, pull, registerServer, registered, ServerView, type Paths,
type ServerChange, type ServerState,
} from "../dist/node.js";
import { generateKeyPair, open, seal } from "../dist/seal.js";
@@ -21,7 +21,7 @@ function node(name = "laptop"): { p: Paths; written: Record<string, string> } {
}
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) => ({
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),
});
@@ -29,9 +29,9 @@ const grantFor = (p: Paths, licence: string, token: string, kind: "subscription"
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");
const r = await pull(p, async (address, args) => { asked = [address, args]; return grantFor_(p, "personal", "at-1"); }, writer(written));
assert.equal(asked![0], "seat:anthropic-licence-manager.current");
assert.equal(asked![1].consumer, "laptop");
assert.match(String(asked![1].public_key), /BEGIN PUBLIC KEY/);
assert.equal(r.applied, true);
assert.equal(creds(p).claudeAiOauth.accessToken, "at-1");
@@ -41,8 +41,8 @@ test("a pull asks the seat with this node's key and applies what it answers", as
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));
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");
@@ -53,41 +53,67 @@ test("a switch replaces the old licence's grant whole and points the account at
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));
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));
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("what a node holds is reported with fingerprints and its account, never a token", () => {
const { p } = node();
writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at-secret", refreshToken: "rt-secret", expiresAt: NOW + 1000, refreshTokenExpiresAt: NOW + 9000 } }));
writeFileSync(join(p.home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "u-1", emailAddress: "a@example.org" } }));
const h = holdingsOf(p);
assert.equal(h.node, "laptop");
assert.equal(h.identity?.accountUuid, "u-1");
assert.equal(h.kind, "subscription");
assert.equal(h.refresh.present, true);
assert.match(String(h.refresh.fingerprint), /^sha256:[0-9a-f]{16}$/);
assert.equal(h.access?.expiresAt, NOW + 1000);
assert.ok(h.changedAt);
const text = JSON.stringify(h);
assert.ok(!text.includes("at-secret") && !text.includes("rt-secret"), "a token is in the report");
assert.ok(!/token|secret|password/i.test(Object.keys(h).join(" ") + " " + Object.keys(h.refresh).join(" ")),
"a field the runtime would refuse is in the report");
});
test("a login is offered to the seat sealed to the seat's key, with the account it belongs to", async () => {
test("a node with nothing reports nothing held, and the grant answers only a waiting login", () => {
const { p } = node();
const h = holdingsOf(p);
assert.equal(h.kind, null);
assert.equal(h.refresh.present, false);
const manager = generateKeyPair();
writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at-login", refreshToken: "rt-login", expiresAt: NOW } }));
assert.equal(grantFor(p, manager.publicKey), null);
writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at", 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");
const g = grantFor(p, manager.publicKey)!;
assert.equal(g.identity?.accountUuid, "u-9");
assert.equal(JSON.parse(open(g.sealed, manager.privateKey)).refreshToken, "rt-login");
assert.ok(!JSON.stringify(g).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("a newer generation in the bindings fetches the token once, by the seat's verb; an equal one asks nothing", async () => {
const { p, written } = node();
const asked: string[] = [];
const seatAsk = async (address: string) => { asked.push(address); return { ...grantFor_(p, "personal", "at-1"), generation: 3 }; };
await onBinding(p, { licence: "personal", kind: "subscription", generation: 3 }, seatAsk, writer(written));
assert.deepEqual(asked, ["seat:anthropic-licence-manager.current"]);
assert.equal(creds(p).claudeAiOauth.accessToken, "at-1");
assert.equal(await onBinding(p, { licence: "personal", kind: "subscription", generation: 3 }, seatAsk, writer(written)), null);
assert.equal(asked.length, 1, "an equal generation asked again");
assert.equal(holdingsOf(p).generation, 3);
});
test("the token a node is handed replaces a login's grant and leaves no refresh token", () => {
const { p, written } = node();
writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at-old", refreshToken: "rt-spent", expiresAt: NOW + 7_200_000 } }));
const r = apply(p, grantFor_(p, "personal", "at-new"), writer(written));
assert.equal(r.applied, true);
assert.equal(creds(p).claudeAiOauth.accessToken, "at-new");
assert.equal(creds(p).claudeAiOauth.refreshToken, undefined, "a refresh token survived the hand-over");
assert.equal(holdingsOf(p).refresh.present, false);
});
/** The `servers` state as the bus holds it, shared by every node in a test, with each node's watch. */
+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))` : ""}`));
}