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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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))` : ""}`));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user