Events carry what happened and no secret; tokens travel on requests (design 32 §10). The manager's licence.rotated/switched events make the module ask anthropic-licence-manager.current; at start it asks once to catch up. A refresh token appearing in the credentials file is a login: it is pushed to the manager's adopt at once, sealed to the manager's key — the one time a refresh token travels. A switch replaces the old licence's grant whole, removes the API key and its helper, and rewrites oauthAccount in ~/.claude.json. New tools register and unregister MCP servers on this node, or with nodes: all / a list via an mcp.registered event every node consumes; called for one node, the answer names the other nodes running claude-code. 26 tests.
120 lines
7.7 KiB
TypeScript
120 lines
7.7 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
apply, concerns, keypair, offerLogin, onServerEvent, pull, registerServer, registered, type Paths,
|
|
} from "../dist/node.js";
|
|
import { generateKeyPair, open, seal } from "../dist/seal.js";
|
|
|
|
const NOW = Date.now();
|
|
function node(name = "laptop"): { p: Paths; written: Record<string, string> } {
|
|
const root = mkdtempSync(join(tmpdir(), "cc-node-"));
|
|
const p = { state: join(root, "state"), facts: join(root, "state", "facts.json"), settings: join(root, "state", "settings.json"), home: join(root, "home"), node: name };
|
|
mkdirSync(p.state, { recursive: true });
|
|
mkdirSync(join(p.home, ".claude"), { recursive: true });
|
|
writeFileSync(p.facts, JSON.stringify({ node: name, console: "http://127.0.0.1:4270/mcp" }));
|
|
writeFileSync(p.settings, JSON.stringify({ role: "", mcp_servers: {} }));
|
|
return { p, written: {} };
|
|
}
|
|
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) => ({
|
|
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),
|
|
});
|
|
|
|
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");
|
|
assert.match(String(asked![1].public_key), /BEGIN PUBLIC KEY/);
|
|
assert.equal(r.applied, true);
|
|
assert.equal(creds(p).claudeAiOauth.accessToken, "at-1");
|
|
assert.ok(written["managed-mcp.json"]);
|
|
});
|
|
|
|
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));
|
|
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");
|
|
const account = JSON.parse(readFileSync(join(p.home, ".claude.json"), "utf8"));
|
|
assert.equal(account.oauthAccount.accountUuid, "new");
|
|
assert.deepEqual(account.projects, { keep: 1 });
|
|
});
|
|
|
|
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));
|
|
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));
|
|
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("a login is offered to the seat sealed to the seat's key, with the account it belongs to", async () => {
|
|
const { p } = node();
|
|
const manager = generateKeyPair();
|
|
writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at-login", 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");
|
|
});
|
|
|
|
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("registering a server here renders it and asks whether to register it on the other nodes", async () => {
|
|
const { p, written } = node();
|
|
const emitted: unknown[] = [];
|
|
const r = await registerServer(p, { name: "search", entry: { type: "http", url: "https://s.example/mcp" } },
|
|
async (t, b) => { emitted.push([t, b]); }, writer(written), async () => ["laptop", "server", "desktop"]);
|
|
assert.equal(r.here, "changed");
|
|
assert.match(String(r.also), /server, desktop/);
|
|
assert.equal(emitted.length, 0, "a registration for this node alone is announced to nobody");
|
|
assert.ok(JSON.parse(written["managed-mcp.json"]).mcpServers.search);
|
|
});
|
|
|
|
test("registering for every node emits the event, and another node applies it from the event", async () => {
|
|
const a = node("laptop"), b = node("server");
|
|
let event: [string, unknown] | null = null;
|
|
await registerServer(a.p, { name: "docs", entry: { type: "stdio", command: "docs-mcp" }, nodes: "all" },
|
|
async (t, body) => { event = [t, body]; }, writer(a.written), async () => []);
|
|
assert.equal(event![0], "mcp.registered");
|
|
assert.equal(onServerEvent(b.p, "claude-code.mcp.registered", event![1] as never, writer(b.written)), "registered docs from an event");
|
|
assert.deepEqual(registered(b.p).docs, { type: "stdio", command: "docs-mcp" });
|
|
assert.equal(onServerEvent(b.p, "claude-code.mcp.registered", event![1] as never, writer(b.written)), null, "a repeated event changed something");
|
|
});
|
|
|
|
test("an event naming other nodes leaves this one alone; a bad entry is refused before anything is written", async () => {
|
|
const { p, written } = node();
|
|
assert.equal(onServerEvent(p, "claude-code.mcp.registered", { name: "x", entry: { type: "http", url: "https://x" }, nodes: ["server"] }, writer(written)), null);
|
|
const r = await registerServer(p, { name: "mesh", entry: { type: "http", url: "https://x" } }, async () => {}, writer(written), async () => []);
|
|
assert.equal(r.registered, false);
|
|
});
|