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, onServerChange, pull, registerServer, registered, ServerView, type Paths, type ServerChange, type ServerState, } from "../dist/node.js"; import { generateKeyPair, open, seal } from "../dist/seal.js"; const NOW = Date.now(); function node(name = "laptop"): { p: Paths; written: Record } { 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) => (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] | 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][] = []; 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); }); /** The `servers` state as the bus holds it, shared by every node in a test, with each node's watch. */ function bus() { const kept = new Map>(); const watchers: ((c: ServerChange) => void)[] = []; const state: ServerState = { put: async (key, value) => { kept.set(key, value); watchers.forEach((w) => w({ key, op: "put", value })); return kept.size; }, delete: async (key) => { kept.delete(key); watchers.forEach((w) => w({ key, op: "delete" })); }, keys: async () => [...kept.keys()].sort(), }; /** A node joining: its view takes the current state, then every change. */ const join = (n: { p: Paths; written: Record }) => { const view = new ServerView(n.p); for (const [key, value] of kept) onServerChange(view, { key, op: "put", value }, n.p, writer(n.written)); watchers.push((c) => onServerChange(view, c, n.p, writer(n.written))); return view; }; return { state, join, kept }; } test("registering a server here puts it under this node's key, renders it, and asks about the other nodes", async () => { const n = node(); const b = bus(); const view = b.join(n); const r = await registerServer(n.p, { name: "search", entry: { type: "http", url: "https://s.example/mcp" } }, b.state, view, writer(n.written), async () => ["laptop", "server", "desktop"]); assert.equal(r.here, "changed"); assert.match(String(r.also), /server, desktop/); assert.deepEqual([...b.kept.keys()], ["laptop.search"]); assert.ok(JSON.parse(n.written["managed-mcp.json"]).mcpServers.search); }); test("registering for every node reaches the others through their watch, and a node joining later reads it", async () => { const a = node("laptop"), s = node("server"); const b = bus(); const va = b.join(a); b.join(s); await registerServer(a.p, { name: "docs", entry: { type: "stdio", command: "docs-mcp" }, nodes: "all" }, b.state, va, writer(a.written), async () => []); assert.deepEqual([...b.kept.keys()], ["all.docs"]); assert.deepEqual(registered(s.p).docs, { type: "stdio", command: "docs-mcp" }); assert.ok(JSON.parse(s.written["managed-mcp.json"]).mcpServers.docs); // The gap events left: a node assigned after the registration takes the whole current set at start. const late = node("desktop"); b.join(late); assert.deepEqual(registered(late.p).docs, { type: "stdio", command: "docs-mcp" }); // Unregistering is a delete, and every node's view drops it. await registerServer(a.p, { name: "docs", nodes: "all" }, b.state, va, writer(a.written), async () => []); assert.equal(registered(s.p).docs, undefined); assert.equal(registered(late.p).docs, undefined); }); test("a node's own registration overrides the one for every node; other nodes' keys leave this one alone", async () => { const a = node("laptop"), s = node("server"); const b = bus(); const va = b.join(a); const vs = b.join(s); await registerServer(a.p, { name: "x", entry: { type: "http", url: "https://all" }, nodes: "all" }, b.state, va, writer(a.written), async () => []); await registerServer(a.p, { name: "x", entry: { type: "http", url: "https://laptop" } }, b.state, va, writer(a.written), async () => []); assert.equal(registered(a.p).x.url, "https://laptop"); assert.equal(registered(s.p).x.url, "https://all"); await registerServer(a.p, { name: "only", entry: { type: "http", url: "https://o" }, nodes: ["server"] }, b.state, va, writer(a.written), async () => []); assert.equal(registered(a.p).only, undefined); assert.equal(registered(s.p).only.url, "https://o"); // Unregistering here leaves the every-node one applying, and says so. const r = await registerServer(a.p, { name: "x" }, b.state, va, writer(a.written), async () => []); assert.match(String(r.still), /still applies here/); assert.equal(registered(a.p).x.url, "https://all"); assert.equal(vs.effective().x.url, "https://all"); }); test("a bad entry is refused before anything is put; a repeated change changes nothing", async () => { const n = node(); const b = bus(); const view = b.join(n); const r = await registerServer(n.p, { name: "mesh", entry: { type: "http", url: "https://x" } }, b.state, view, writer(n.written), async () => []); assert.equal(r.registered, false); assert.equal(b.kept.size, 0); assert.equal(onServerChange(view, { key: "all.a", op: "put", value: { type: "http", url: "https://a" } }, n.p, writer(n.written)), "registered all.a"); assert.equal(onServerChange(view, { key: "all.a", op: "put", value: { type: "http", url: "https://a" } }, n.p, writer(n.written)), null); assert.equal(onServerChange(view, { key: "server.b", op: "put", value: { type: "http", url: "https://b" } }, n.p, writer(n.written)), null); });