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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user