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
+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. */