From a8113da12ef0f309b8d95987e68642e786673edb Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 16:35:12 +0200 Subject: [PATCH] Retire anthropic-manager and anthropic-consumer (novox/hq ADR 0183) ADR 0183: retired once the licence manager runs. claude-licence-manager has held the anthropic-licence-manager seat since 2026-10-04; neither old module was assigned anywhere. --- modules/anthropic-consumer/apply/index.ts | 75 --------- modules/anthropic-consumer/credentials.ts | 82 --------- modules/anthropic-consumer/identity.ts | 49 ------ modules/anthropic-consumer/module.json | 76 --------- modules/anthropic-consumer/package.json | 14 -- .../test/credentials.test.ts | 32 ---- .../test/transcript.test.ts | 48 ------ modules/anthropic-consumer/transcript.ts | 113 ------------- modules/anthropic-consumer/tsconfig.json | 18 -- modules/anthropic-consumer/usage/index.ts | 138 --------------- modules/anthropic-manager/adopt/index.ts | 35 ---- modules/anthropic-manager/client.ts | 135 --------------- modules/anthropic-manager/grantfile.ts | 32 ---- modules/anthropic-manager/module.json | 63 ------- modules/anthropic-manager/package.json | 16 -- modules/anthropic-manager/refresh/index.ts | 157 ------------------ modules/anthropic-manager/sealedbox.ts | 57 ------- modules/anthropic-manager/test/client.test.ts | 43 ----- .../anthropic-manager/test/sealedbox.test.ts | 36 ---- modules/anthropic-manager/tsconfig.json | 19 --- .../tweetnacl-sealedbox-js.d.ts | 13 -- 21 files changed, 1251 deletions(-) delete mode 100644 modules/anthropic-consumer/apply/index.ts delete mode 100644 modules/anthropic-consumer/credentials.ts delete mode 100644 modules/anthropic-consumer/identity.ts delete mode 100644 modules/anthropic-consumer/module.json delete mode 100644 modules/anthropic-consumer/package.json delete mode 100644 modules/anthropic-consumer/test/credentials.test.ts delete mode 100644 modules/anthropic-consumer/test/transcript.test.ts delete mode 100644 modules/anthropic-consumer/transcript.ts delete mode 100644 modules/anthropic-consumer/tsconfig.json delete mode 100644 modules/anthropic-consumer/usage/index.ts delete mode 100644 modules/anthropic-manager/adopt/index.ts delete mode 100644 modules/anthropic-manager/client.ts delete mode 100644 modules/anthropic-manager/grantfile.ts delete mode 100644 modules/anthropic-manager/module.json delete mode 100644 modules/anthropic-manager/package.json delete mode 100644 modules/anthropic-manager/refresh/index.ts delete mode 100644 modules/anthropic-manager/sealedbox.ts delete mode 100644 modules/anthropic-manager/test/client.test.ts delete mode 100644 modules/anthropic-manager/test/sealedbox.test.ts delete mode 100644 modules/anthropic-manager/tsconfig.json delete mode 100644 modules/anthropic-manager/tweetnacl-sealedbox-js.d.ts diff --git a/modules/anthropic-consumer/apply/index.ts b/modules/anthropic-consumer/apply/index.ts deleted file mode 100644 index c0ab37a..0000000 --- a/modules/anthropic-consumer/apply/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -// The consumer's scheduled run: take the ACCESS token the mesh delivered and write it where the -// Claude CLI reads it, access-token-only (novox/hq ADR 0050). The refresh token is never here to -// strip — the manager holds it, and a holder's delivery has only ever been the access token. -// -// What the host delivers, per the manifest: -// secrets.model-access -> a file holding the sealed-then-unsealed ACCESS token (the host opened it -// with this node's private key; this process reads plaintext). -// binds.model-access -> a JSON file of the non-secret facts the licence serves (which licence, -// model, and — when the control plane carries them — grant expiry/scopes). -// -// Runs as `mesh-tools run` (no broker) on a schedule, so it is idempotent: same token in, same file -// out. - -import { readFileSync } from "node:fs"; - -import { deliver, type DeliveredGrant } from "../credentials.js"; -import { readAccountUuid, check } from "../identity.js"; - -function required(name: string): string { - const v = process.env[name]; - if (!v) throw new Error(`${name} is not set — the consumer runtime was deployed without it`); - return v; -} - -/** Read optional non-secret grant metadata (expiry, scopes, subscription) from the bound facts file. */ -function readBoundMeta(path: string | undefined): Partial { - if (!path) return {}; - try { - const raw = JSON.parse(readFileSync(path, "utf8")) as Record; - return { - expiresAt: typeof raw.expiresAt === "number" ? raw.expiresAt : null, - refreshTokenExpiresAt: typeof raw.refreshTokenExpiresAt === "number" ? raw.refreshTokenExpiresAt : null, - scopes: Array.isArray(raw.scopes) ? (raw.scopes as string[]) : null, - subscriptionType: typeof raw.subscriptionType === "string" ? raw.subscriptionType : null, - }; - } catch { - return {}; - } -} - -function main(): void { - const accessToken = readFileSync(required("MESH_MODEL_ACCESS_SECRET_FILE"), "utf8").trim(); - if (!accessToken) { - // Nothing was delivered — which reads exactly like a credential that never arrived, so it is - // said rather than written as an empty file the CLI would take for a login it should not do. - throw new Error("[anthropic-consumer] the delivered access token is empty; nothing was written"); - } - - const meta = readBoundMeta(process.env.MESH_MODEL_ACCESS_BIND_FILE); - const grant: DeliveredGrant = { accessToken, ...meta }; - - const target = process.env.MESH_CLAUDE_CREDENTIALS_FILE ?? `${homedir()}/.claude/.credentials.json`; - deliver(target, grant); - console.error(`[anthropic-consumer] wrote an access-token-only credential to ${target}`); - - // The mis-binding guard, best-effort and fail-closed. The expected account uuid is not yet plumbed - // (identity.ts TODO), so this reports what it can see rather than acting on it — it never delivers - // to a wrong account because it never learns one to deliver to. - const identityFile = process.env.MESH_CLAUDE_IDENTITY_FILE ?? `${homedir()}/.claude.json`; - const found = readAccountUuid(identityFile); - const expected = process.env.MESH_MODEL_ACCESS_ACCOUNT_UUID ?? null; - const verdict = check(found, expected); - if (verdict.state === "wrong-account") { - throw new Error( - `[anthropic-consumer] the CLI is logged in as ${verdict.found}, not the licensed ${verdict.expected}; refusing`, - ); - } - console.error(`[anthropic-consumer] identity check: ${verdict.state}`); -} - -function homedir(): string { - return process.env.HOME ?? "/root"; -} - -main(); diff --git a/modules/anthropic-consumer/credentials.ts b/modules/anthropic-consumer/credentials.ts deleted file mode 100644 index 574c9f5..0000000 --- a/modules/anthropic-consumer/credentials.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Writing the access token where the Claude CLI reads it — the consumer half of model-access -// (novox/hq ADR 0050). A node holds an ACCESS token and nothing else: it cannot rotate, so it is -// never given a refresh token, and this enforces that on every write. -// -// The file shape and the strip are ported byte-exact from the mature implementation (see the port -// map): `~/.claude/.credentials.json` → `{ claudeAiOauth: { accessToken, expiresAt, -// refreshTokenExpiresAt?, scopes?, subscriptionType? } }`, and the refresh token is deleted, not -// merely omitted, so a full grant left by an interactive login is stripped back to access-only. - -import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; -import { dirname } from "node:path"; - -/** The access-token-only grant the mesh delivered — what the manager submitted, minus the refresh. */ -export interface DeliveredGrant { - readonly accessToken: string; - readonly expiresAt?: number | null; - readonly refreshTokenExpiresAt?: number | null; - readonly scopes?: string[] | null; - readonly subscriptionType?: string | null; -} - -interface ClaudeOauth { - accessToken?: string; - expiresAt?: number; - refreshTokenExpiresAt?: number; - scopes?: string[]; - subscriptionType?: string; - refreshToken?: string; -} - -interface Credentials { - claudeAiOauth?: ClaudeOauth; - [key: string]: unknown; -} - -/** Read the existing credentials file, or an empty object if there is none or it is unreadable. */ -function readLocal(path: string): Credentials { - try { - return JSON.parse(readFileSync(path, "utf8")) as Credentials; - } catch { - return {}; - } -} - -/** - * Overlay the delivered grant onto whatever is on disk, then STRIP the refresh token — the node - * carve-out. Returns the object to write, so the strip is testable without touching a file. - */ -export function applyGrant(local: Credentials, grant: DeliveredGrant): Credentials { - const oauth = local.claudeAiOauth ?? {}; - const next: Credentials = { - ...local, - claudeAiOauth: { - ...oauth, - accessToken: grant.accessToken, - ...(grant.expiresAt != null ? { expiresAt: grant.expiresAt } : {}), - ...(grant.refreshTokenExpiresAt != null - ? { refreshTokenExpiresAt: grant.refreshTokenExpiresAt } - : {}), - ...(grant.scopes ? { scopes: grant.scopes } : {}), - ...(grant.subscriptionType ? { subscriptionType: grant.subscriptionType } : {}), - }, - }; - // A node NEVER holds a refresh token: delete it, so a full grant on disk is reduced to access-only. - delete next.claudeAiOauth!.refreshToken; - return next; -} - -/** Atomic write-then-rename at 0600 — a partial credentials file must never be read as a whole one. */ -export function writeCredentials(path: string, creds: Credentials): void { - mkdirSync(dirname(path), { recursive: true }); - const tmp = `${path}.tmp`; - writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 }); - renameSync(tmp, path); -} - -/** Read, overlay, strip, write — the whole consumer credential update, in one call. */ -export function deliver(path: string, grant: DeliveredGrant): Credentials { - const next = applyGrant(readLocal(path), grant); - writeCredentials(path, next); - return next; -} diff --git a/modules/anthropic-consumer/identity.ts b/modules/anthropic-consumer/identity.ts deleted file mode 100644 index e3af1c0..0000000 --- a/modules/anthropic-consumer/identity.ts +++ /dev/null @@ -1,49 +0,0 @@ -// The mis-binding guard (novox/hq ADR 0050, port map §identity). Account identity is NOT in the -// token or any API — it lives in a sibling CLI state file, `~/.claude.json` → -// `oauthAccount.accountUuid`. The guard compares the account the CLI is actually logged in as to the -// account the licence was recorded against, and FAILS CLOSED: an absent file or an unrecorded licence -// account refuses rather than guesses, because delivering an access token to the wrong account is the -// exact fault this exists to catch. -// -// **Partial first cut, FLAGGED.** Reading the sibling file is implemented; the licence's recorded -// account uuid is not yet plumbed from the control plane to the consumer (the bound `model.json` does -// not carry it today). So `check` returns `licence-not-adopted` when no expected uuid is supplied, -// which is the fail-closed answer, and the wiring of the expected uuid is a TODO below. - -import { readFileSync } from "node:fs"; - -export type IdentityVerdict = - | { state: "verified"; accountUuid: string } - | { state: "no-identity-file" } - | { state: "licence-not-adopted" } - | { state: "wrong-account"; found: string; expected: string }; - -interface ClaudeJson { - oauthAccount?: { accountUuid?: string; emailAddress?: string; organizationUuid?: string }; -} - -/** Read `oauthAccount.accountUuid` from `~/.claude.json`, or null if the file or field is absent. */ -export function readAccountUuid(path: string): string | null { - try { - const raw = JSON.parse(readFileSync(path, "utf8")) as ClaudeJson; - return raw.oauthAccount?.accountUuid ?? null; - } catch { - return null; - } -} - -/** - * Compare the CLI's logged-in account to the one the licence was recorded against. Pure over its - * inputs so the fail-closed logic is tested without a filesystem. - * - * TODO(novox/hq ADR 0050, Phase C): plumb `expected` — the licence's recorded account uuid — from the - * control plane into the consumer's bound `model.json`, then adopt-on-first-sight or refuse per the - * port map's five states. Until then only the two safe verdicts are reachable: verified when an - * expected uuid is provided and matches, refuse otherwise. - */ -export function check(found: string | null, expected: string | null): IdentityVerdict { - if (found === null) return { state: "no-identity-file" }; - if (!expected) return { state: "licence-not-adopted" }; - if (found === expected) return { state: "verified", accountUuid: found }; - return { state: "wrong-account", found, expected }; -} diff --git a/modules/anthropic-consumer/module.json b/modules/anthropic-consumer/module.json deleted file mode 100644 index b2379cc..0000000 --- a/modules/anthropic-consumer/module.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "module": "anthropic-consumer", - "version": "1", - "slug": "claude", - "capabilities": [ - "container-runtime" - ], - "requires": [ - "model-access" - ], - "binds": { - "model-access": "${dir:state}/model.json" - }, - "secrets": { - "model-access": "${dir:state}/access-token" - }, - "emits": [ - "usage.session" - ], - "resources": [ - { - "id": "state", - "type": "directory", - "mode": "0700", - "place": "." - }, - { - "id": "claude-home", - "type": "directory", - "path": "/var/lib/anthropic-consumer/claude", - "mode": "0700" - }, - { - "id": "out", - "type": "directory", - "mode": "0700" - }, - { - "id": "apply", - "type": "process", - "name": "anthropic-consumer-apply", - "artifact": "code", - "run": [ - "node", - "apply/index.js" - ], - "schedule": "*/5 * * * *", - "env": { - "MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/access-token", - "MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json", - "MESH_CLAUDE_CREDENTIALS_FILE": "${dir:state}/claude/.credentials.json", - "MESH_CLAUDE_IDENTITY_FILE": "${dir:state}/claude/.claude.json" - } - } - ], - "build": { - "artifacts": [ - { - "name": "code", - "kind": "bundle", - "language": "typescript", - "entrypoints": [ - "apply/index.js", - "usage/index.js" - ], - "loads": [ - "usage/index.js" - ], - "env": { - "MESH_CLAUDE_PROJECTS_DIR": "${dir:state}/claude/projects", - "MESH_ANTHROPIC_USAGE_OUT": "${dir:state}/out/session-usage.json" - } - } - ] - } -} diff --git a/modules/anthropic-consumer/package.json b/modules/anthropic-consumer/package.json deleted file mode 100644 index ef3f418..0000000 --- a/modules/anthropic-consumer/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@novox/module-anthropic-consumer", - "version": "0.1.0", - "description": "anthropic-consumer — the consumer side of model-access (ADR 0050): writes the delivered access token to ~/.claude/.credentials.json (access-token-only) and reports session-grain usage from the CLI transcripts (ADR 0054).", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/anthropic-consumer/test/credentials.test.ts b/modules/anthropic-consumer/test/credentials.test.ts deleted file mode 100644 index 72ddcd7..0000000 --- a/modules/anthropic-consumer/test/credentials.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { applyGrant, deliver } from "../credentials.ts"; - -test("applyGrant strips the refresh token a full grant on disk left behind", () => { - const local = { claudeAiOauth: { accessToken: "at-old", refreshToken: "rt-must-not-survive" } }; - const next = applyGrant(local, { accessToken: "at-new", expiresAt: 123 }); - assert.equal(next.claudeAiOauth!.accessToken, "at-new"); - assert.equal(next.claudeAiOauth!.expiresAt, 123); - assert.ok(!("refreshToken" in next.claudeAiOauth!), "a node held onto a refresh token"); -}); - -test("deliver writes the port-map shape, access-token-only, and never a refresh token", () => { - const dir = mkdtempSync(join(tmpdir(), "anthropic-consumer-")); - const path = join(dir, ".credentials.json"); - // A prior interactive login left a full grant on disk. - writeFileSync(path, JSON.stringify({ claudeAiOauth: { accessToken: "at-old", refreshToken: "rt-login" } })); - - deliver(path, { accessToken: "at-delivered", expiresAt: 999, subscriptionType: "max" }); - - const raw = readFileSync(path, "utf8"); - const creds = JSON.parse(raw); - assert.equal(creds.claudeAiOauth.accessToken, "at-delivered"); - assert.equal(creds.claudeAiOauth.expiresAt, 999); - assert.equal(creds.claudeAiOauth.subscriptionType, "max"); - assert.doesNotMatch(raw, /rt-login/, "the refresh token is still on disk"); - assert.ok(!("refreshToken" in creds.claudeAiOauth)); -}); diff --git a/modules/anthropic-consumer/test/transcript.test.ts b/modules/anthropic-consumer/test/transcript.test.ts deleted file mode 100644 index 5048b5a..0000000 --- a/modules/anthropic-consumer/test/transcript.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; - -import { foldTranscript } from "../transcript.ts"; - -// A captured-shape transcript: two assistant turns and a user line, exactly the fields the port map -// names. Not imagined — the field names match the mature implementation's parse. -const TRANSCRIPT = [ - JSON.stringify({ type: "user", timestamp: "2026-01-01T00:00:00Z", cwd: "/work/app", gitBranch: "main" }), - JSON.stringify({ - type: "assistant", - timestamp: "2026-01-01T00:00:01Z", - costUSD: 0.01, - message: { - model: "claude-opus-4-8", - usage: { input_tokens: 100, cache_creation_input_tokens: 20, cache_read_input_tokens: 5, output_tokens: 40 }, - }, - }), - JSON.stringify({ - type: "assistant", - timestamp: "2026-01-01T00:00:02Z", - costUSD: 0.02, - message: { model: "claude-opus-4-8", usage: { input_tokens: 200, output_tokens: 60 } }, - }), - "", // a half-written trailing line is ordinary and must not be fatal. -].join("\n"); - -test("a transcript sums per-session token counts, cost, and metadata", () => { - const s = foldTranscript("session-abc", TRANSCRIPT); - assert.equal(s.sessionId, "session-abc"); - assert.equal(s.turns, 2); - assert.equal(s.inputTokens, 300); - assert.equal(s.cacheCreationTokens, 20); - assert.equal(s.cacheReadTokens, 5); - assert.equal(s.outputTokens, 100); - assert.equal(Math.round(s.costUSD * 100) / 100, 0.03); - assert.equal(s.model, "claude-opus-4-8"); - assert.equal(s.gitBranch, "main"); - assert.equal(s.cwd, "/work/app"); - assert.equal(s.startedAt, "2026-01-01T00:00:00Z"); - assert.equal(s.lastActive, "2026-01-01T00:00:02Z"); -}); - -test("a malformed line is skipped, not fatal", () => { - const s = foldTranscript("s", 'not json\n{"type":"assistant","message":{"usage":{"output_tokens":7}}}'); - assert.equal(s.outputTokens, 7); - assert.equal(s.turns, 1); -}); diff --git a/modules/anthropic-consumer/transcript.ts b/modules/anthropic-consumer/transcript.ts deleted file mode 100644 index bb6149b..0000000 --- a/modules/anthropic-consumer/transcript.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Session-grain usage from the CLI's own transcripts (novox/hq ADR 0054). The mature implementation -// reads `~/.claude/projects//.jsonl` and sums the token counts each assistant -// message reports; this ports the token extraction and DROPS the per-message account-attribution -// timeline — the nox (node,module) session has a fixed licence binding (port map "don't-map" #3), so -// there is nothing to attribute per message. -// -// The fields are ported from the port map: assistant lines carry -// `message.usage.{input_tokens,cache_creation_input_tokens,cache_read_input_tokens,output_tokens}`, -// `costUSD`, `message.model`, `timestamp`; user lines carry `cwd`, `gitBranch`. - -import { createInterface } from "node:readline"; -import { createReadStream } from "node:fs"; - -/** One session's totals — the session-grain usage row ADR 0054 fixes. */ -export interface SessionUsage { - sessionId: string; - model: string | null; - gitBranch: string | null; - cwd: string | null; - turns: number; - inputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - outputTokens: number; - costUSD: number; - startedAt: string | null; - lastActive: string | null; -} - -interface Line { - type?: string; - timestamp?: string; - cwd?: string; - gitBranch?: string; - costUSD?: number; - message?: { - model?: string; - usage?: { - input_tokens?: number; - cache_creation_input_tokens?: number; - cache_read_input_tokens?: number; - output_tokens?: number; - }; - }; -} - -function empty(sessionId: string): SessionUsage { - return { - sessionId, - model: null, - gitBranch: null, - cwd: null, - turns: 0, - inputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - outputTokens: 0, - costUSD: 0, - startedAt: null, - lastActive: null, - }; -} - -/** Fold one transcript line into a session's running totals. Pure, so it is tested on fixtures. */ -export function foldLine(acc: SessionUsage, raw: string): SessionUsage { - const line = parse(raw); - if (!line) return acc; - - if (line.timestamp) { - if (!acc.startedAt || line.timestamp < acc.startedAt) acc.startedAt = line.timestamp; - if (!acc.lastActive || line.timestamp > acc.lastActive) acc.lastActive = line.timestamp; - } - if (line.type === "user") { - if (line.cwd) acc.cwd = line.cwd; - if (line.gitBranch) acc.gitBranch = line.gitBranch; - } - if (line.type === "assistant") { - acc.turns += 1; - const u = line.message?.usage ?? {}; - acc.inputTokens += u.input_tokens ?? 0; - acc.cacheCreationTokens += u.cache_creation_input_tokens ?? 0; - acc.cacheReadTokens += u.cache_read_input_tokens ?? 0; - acc.outputTokens += u.output_tokens ?? 0; - acc.costUSD += line.costUSD ?? 0; - if (!acc.model && line.message?.model) acc.model = line.message.model; - } - return acc; -} - -function parse(raw: string): Line | null { - const trimmed = raw.trim(); - if (!trimmed) return null; - try { - return JSON.parse(trimmed) as Line; - } catch { - // A malformed line is skipped, never fatal: a transcript is an append-only log the CLI owns, and - // a half-written last line is ordinary. - return null; - } -} - -/** Sum a whole transcript string into one session's usage — the tested core of the streaming read. */ -export function foldTranscript(sessionId: string, text: string): SessionUsage { - return text.split("\n").reduce(foldLine, empty(sessionId)); -} - -/** Stream one `.jsonl` file line by line, so a large transcript never loads whole. */ -export async function readSessionFile(path: string, sessionId: string): Promise { - const acc = empty(sessionId); - const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity }); - for await (const line of rl) foldLine(acc, line); - return acc; -} diff --git a/modules/anthropic-consumer/tsconfig.json b/modules/anthropic-consumer/tsconfig.json deleted file mode 100644 index ab08067..0000000 --- a/modules/anthropic-consumer/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": [ - "credentials.ts", - "transcript.ts", - "identity.ts", - "apply/index.ts", - "usage/index.ts" - ] -} diff --git a/modules/anthropic-consumer/usage/index.ts b/modules/anthropic-consumer/usage/index.ts deleted file mode 100644 index 71a9662..0000000 --- a/modules/anthropic-consumer/usage/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -// Session-grain usage emission (novox/hq ADR 0054). On a schedule, read every transcript under -// `~/.claude/projects/*/.jsonl`, sum its tokens, and emit one session-grain usage event -// per session. The consumer IS the (node,module) session's fixed binding, so no per-message account -// attribution is done — just the totals (port map "don't-map" #3). -// -// Runs in the node's runtime (novox/hq ADR 0198), every five minutes, so events are emitted through -// the runtime as this module; the totals are also written to a file so the reading is observable -// without one. - -import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; -import { join, dirname } from "node:path"; - -import { emit } from "@novox/mesh-sdk/events"; - -import { readSessionFile, type SessionUsage } from "../transcript.js"; - -/** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local - * to the producer (the normalisation lives in the adapter), so nothing here couples to the SDK. */ -interface UsageRow { - licence: string; - consumer: string; - period: string; - metric: string; - value: number; -} - -/** The licence this session's usage is charged to. The model-access binding names it; failing that, - * the deployed env; failing that, "unknown" — a reading is never dropped for want of a licence. */ -function boundLicence(): string { - const bindFile = process.env.MESH_MODEL_ACCESS_BIND_FILE; - if (bindFile) { - try { - const raw = JSON.parse(readFileSync(bindFile, "utf8")) as { licence?: unknown }; - if (typeof raw.licence === "string" && raw.licence) return raw.licence; - } catch { - // A missing or unreadable bind file is not fatal — fall through to the env, then to "unknown". - } - } - return process.env.MESH_ANTHROPIC_LICENCE || "unknown"; -} - -/** Normalise one session reading into ADR 0054 rows: one row per metric, a row omitted when its - * number is not finite. `consumer` is node/module/session — the session grain. */ -function sessionRows(licence: string, node: string, module: string, r: SessionUsage): UsageRow[] { - const consumer = `${node}/${module}/${r.sessionId}`; - const rows: UsageRow[] = []; - const add = (metric: string, value: number): void => { - if (Number.isFinite(value)) rows.push({ licence, consumer, period: "session", metric, value }); - }; - add("input_tokens", r.inputTokens); - add("output_tokens", r.outputTokens); - add("cache_creation_tokens", r.cacheCreationTokens); - add("cache_read_tokens", r.cacheReadTokens); - add("cost_usd", r.costUSD); - return rows; -} - -function projectsDir(): string { - return process.env.MESH_CLAUDE_PROJECTS_DIR ?? `${process.env.HOME ?? "/root"}/.claude/projects`; -} - -/** Every `.jsonl` under the projects tree, with the project directory it sits in. */ -function transcripts(root: string): { path: string; sessionId: string }[] { - const found: { path: string; sessionId: string }[] = []; - let projects: string[]; - try { - projects = readdirSync(root); - } catch { - return found; // no projects yet is not a failure — there is simply nothing to report. - } - for (const proj of projects) { - const dir = join(root, proj); - let entries: string[]; - try { - if (!statSync(dir).isDirectory()) continue; - entries = readdirSync(dir); - } catch { - continue; - } - for (const file of entries) { - if (!file.endsWith(".jsonl")) continue; - found.push({ path: join(dir, file), sessionId: file.replace(/\.jsonl$/, "") }); - } - } - return found; -} - -async function main(): Promise { - const module = process.env.MESH_MODULE ?? "anthropic-consumer"; - const node = process.env.MESH_NODE ?? "unknown"; - - const readings: SessionUsage[] = []; - for (const t of transcripts(projectsDir())) { - try { - readings.push(await readSessionFile(t.path, t.sessionId)); - } catch (err) { - console.error(`[anthropic-consumer] could not read ${t.path}: ${err}`); - } - } - - // Emit ADR-0054 rows, not a vendor-shaped body: the consumer of module.*.usage.* is the - // vendor-neutral model-usage store, so the session→row normalisation is done HERE. The full - // SessionUsage rides as `raw`, so model, branch, cwd and timestamps are not lost. - const licence = boundLicence(); - for (const r of readings) { - await emitUsage({ rows: sessionRows(licence, node, module, r), raw: r }); - } - - if (process.env.MESH_ANTHROPIC_USAGE_OUT) { - atomicWrite(process.env.MESH_ANTHROPIC_USAGE_OUT, JSON.stringify(readings, null, 2)); - } - console.error(`[anthropic-consumer] reported ${readings.length} session(s)`); -} - -function atomicWrite(path: string, content: string): void { - mkdirSync(dirname(path), { recursive: true }); - const tmp = `${path}.tmp`; - writeFileSync(tmp, content, { mode: 0o600 }); - renameSync(tmp, path); -} - -/** Emit best-effort through the runtime: a reading that could not be announced is still in the file. */ -async function emitUsage(body: Record): Promise { - try { - await emit("usage.session", body); - } catch (err) { - console.error(`[anthropic-consumer] could not emit usage: ${err}`); - } -} - -// The cadence the scheduled container had: once at start, then every five minutes. Not awaited, so the -// runtime's handshake is answered while a long first reading is still under way. -const EVERY_MS = 5 * 60 * 1000; -const tick = (): void => { - void main().catch((err) => console.error(`[anthropic-consumer] usage reading failed: ${err}`)); -}; -tick(); -setInterval(tick, EVERY_MS); diff --git a/modules/anthropic-manager/adopt/index.ts b/modules/anthropic-manager/adopt/index.ts deleted file mode 100644 index 792ef1a..0000000 --- a/modules/anthropic-manager/adopt/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Adoption: the ONE time an operator's refresh token enters the mesh, and it enters already sealed. -// -// The refresh token is read here, on the MANAGER NODE, sealed to that node's PUBLIC sealing key, and -// only the sealed box leaves this process (novox/hq ADR 0050). The control plane stores that box via -// `licence set-grant` without ever seeing the refresh token in the clear — the same bound every -// delivery keeps. This is the counterpart to `refresh/index.js`: adoption seals the first box, refresh -// re-seals a rotated one; both use the very anonymous box (`crypto_box_seal`) the mesh seals every -// credential with, so the HOST unseals the stored box to mount the cleartext back — this module is -// never given a private key and opens nothing. -// -// MESH_ANTHROPIC_ADOPT_TOKEN_FILE the operator's refresh token, read once and never written out -// MESH_MODEL_ACCESS_BIND_FILE the manager holder's bound facts, carrying manager_public_key -// MESH_ANTHROPIC_GRANT_OUT where the sealed box is written, for `licence set-grant` - -import { readFileSync } from "node:fs"; - -import { seal } from "../sealedbox.js"; -import { managerPublicKey, writeSealedGrant } from "../grantfile.js"; - -function required(name: string): string { - const v = process.env[name]; - if (!v) throw new Error(`${name} is not set — adoption needs it`); - return v; -} - -const refreshToken = readFileSync(required("MESH_ANTHROPIC_ADOPT_TOKEN_FILE"), "utf8").trim(); -if (!refreshToken) throw new Error("[anthropic-manager] there is no refresh token to adopt"); - -// The node's PUBLIC sealing key, delivered by the mesh in the manager holder's bound facts. Public, -// so it is safe to hand a module; the private half stays with the host, which is what opens the box. -const nodePub = managerPublicKey(required("MESH_MODEL_ACCESS_BIND_FILE")); - -const sealed = seal(new Uint8Array(Buffer.from(refreshToken, "utf8")), nodePub); -writeSealedGrant(required("MESH_ANTHROPIC_GRANT_OUT"), sealed, nodePub); -console.error("[anthropic-manager] sealed the refresh token to this node's key; only the host opens it"); diff --git a/modules/anthropic-manager/client.ts b/modules/anthropic-manager/client.ts deleted file mode 100644 index 5b37387..0000000 --- a/modules/anthropic-manager/client.ts +++ /dev/null @@ -1,135 +0,0 @@ -// The only file that talks to Anthropic — the vendor half of the refreshable-grant adapter -// (novox/hq ADR 0050). Isolated exactly as cloudflare-dns isolates its registrar call, so the -// vendor is swappable and the one place a token endpoint is reached is auditable. -// -// Two endpoints, and they are different hosts (port-map "don't-map" #1): the TOKEN host mints a new -// access token from the refresh token; the USAGE host reports utilisation against an access token. - -/** The token endpoint, overridable so the lab can point the whole flow at a stub without a vendor. */ -export function tokenEndpoint(env = process.env): string { - return env.MESH_ANTHROPIC_TOKEN_ENDPOINT ?? "https://platform.claude.com/v1/oauth/token"; -} - -/** The usage endpoint, likewise overridable for the lab. */ -export function usageEndpoint(env = process.env): string { - return env.MESH_ANTHROPIC_USAGE_ENDPOINT ?? "https://api.anthropic.com/api/oauth/usage"; -} - -// The OAuth client id is a hard-won constant, ported byte-exact from the mature implementation: a -// metadata URL in its place yields 400. It is not a secret (it identifies the public Claude Code -// client), so it lives in code. -const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; - -/** The vendor's token response, snake_case as the wire has it. */ -export interface RefreshedGrant { - readonly access_token?: string; - readonly refresh_token?: string; - readonly expires_in?: number; - readonly refresh_token_expires_in?: number; - readonly scopes?: string[]; - readonly subscription_type?: string; -} - -/** - * Exchange a refresh token for a fresh grant. Returns null on any non-ok response, surfacing the - * OAuth error body (invalid_grant/invalid_client/…) — the difference between "the token is dead" and - * "the endpoint was unreachable", which a bare status hides. - */ -export async function refreshGrant( - refreshToken: string, - env = process.env, -): Promise { - const resp = await fetch(tokenEndpoint(env), { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: CLIENT_ID, - }), - }); - if (!resp.ok) { - const body = await resp.text().catch(() => ""); - console.error( - `[anthropic-manager] token refresh failed: ${resp.status} ${resp.statusText} — ${body.slice(0, 400)}`, - ); - return null; - } - return (await resp.json()) as RefreshedGrant; -} - -/** The vendor's usage response — utilisation percentages against several windows. */ -export interface UsageLimits { - readonly five_hour?: { utilization: number; resets_at?: string }; - readonly seven_day?: { utilization: number; resets_at?: string }; - readonly seven_day_sonnet?: { utilization: number; resets_at?: string }; - readonly seven_day_opus?: { utilization: number; resets_at?: string }; - readonly extra_usage?: { utilization: number }; - readonly [key: string]: unknown; -} - -/** - * Read utilisation for an access token. Never refreshes here (a 401 is just reported): a second - * refresh source racing the first is the fault the mature implementation warns against. - */ -export async function readUsage(accessToken: string, env = process.env): Promise { - const resp = await fetch(usageEndpoint(env), { - headers: { authorization: `Bearer ${accessToken}` }, - }); - if (!resp.ok) { - const body = await resp.text().catch(() => ""); - console.error(`[anthropic-manager] usage endpoint returned ${resp.status}: ${body.slice(0, 200)}`); - return null; - } - return (await resp.json()) as UsageLimits; -} - -/** The licence-grain reading ADR 0054 fixes, flattened from the vendor's windows. */ -export interface UsageReading { - readonly sessionPct: number | null; - readonly sessionResetsAt: string | null; - readonly weeklyPct: number | null; - readonly sonnetPct: number | null; - readonly extraPct: number | null; - readonly raw: UsageLimits; -} - -export function flattenUsage(u: UsageLimits): UsageReading { - return { - sessionPct: u.five_hour?.utilization ?? null, - sessionResetsAt: u.five_hour?.resets_at ?? null, - weeklyPct: u.seven_day?.utilization ?? null, - sonnetPct: u.seven_day_sonnet?.utilization ?? null, - extraPct: u.extra_usage?.utilization ?? null, - raw: u, - }; -} - -/** The access-token-only grant a holder is delivered — the port-map credential-file shape's fields. */ -export interface AccessGrant { - readonly accessToken: string; - readonly expiresAt: number | null; - readonly refreshTokenExpiresAt: number | null; - readonly scopes: string[] | null; - readonly subscriptionType: string | null; -} - -/** - * Turn a vendor refresh into what the manager submits: the access-token-only grant for holders, and - * the rotated refresh token if the vendor sent one. Never clobbers a good grant from an empty - * response — no access_token means the caller keeps what it had. - */ -export function grantFromRefresh(r: RefreshedGrant, nowMs: number): { access: AccessGrant; rotatedRefresh: string | null } | null { - if (!r.access_token) return null; - return { - access: { - accessToken: r.access_token, - expiresAt: typeof r.expires_in === "number" ? nowMs + r.expires_in * 1000 : null, - refreshTokenExpiresAt: - typeof r.refresh_token_expires_in === "number" ? nowMs + r.refresh_token_expires_in * 1000 : null, - scopes: r.scopes ?? null, - subscriptionType: r.subscription_type ?? null, - }, - rotatedRefresh: r.refresh_token ?? null, - }; -} diff --git a/modules/anthropic-manager/grantfile.ts b/modules/anthropic-manager/grantfile.ts deleted file mode 100644 index 1c5915d..0000000 --- a/modules/anthropic-manager/grantfile.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Reading the manager node's PUBLIC sealing key out of the bound facts the mesh delivers, and -// writing a sealed refresh token in the wire shape mesh-controller reads. -// -// **The public key is delivered, not derived.** The manager module holds no node key of its own -// (novox/hq ADR 0050) — it is deliberately never given one. To seal a refresh token to this node it -// needs the node's PUBLIC sealing key, and mesh-controller puts that in the manager holder's bound facts -// (`serves.manager_public_key`), safe to disclose because it is public. Both adoption and every -// rotation read it from there. - -import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; -import { dirname } from "node:path"; - -/** The manager node's public sealing key, from the bound facts file the mesh delivers. */ -export function managerPublicKey(boundFile: string): string { - const raw = JSON.parse(readFileSync(boundFile, "utf8")) as { serves?: Record }; - const key = raw.serves?.["manager_public_key"]; - if (typeof key !== "string" || key === "") { - throw new Error( - "the bound facts carry no manager_public_key — this node is not the licence's manager, or " + - "the manager holder has not been delivered yet", - ); - } - return key; -} - -/** Write a sealed refresh token in the {sealed, manager_key} wire shape mesh-controller reads. */ -export function writeSealedGrant(path: string, sealed: string, managerKey: string): void { - mkdirSync(dirname(path), { recursive: true }); - const tmp = `${path}.tmp`; - writeFileSync(tmp, JSON.stringify({ sealed, manager_key: managerKey }), { mode: 0o600 }); - renameSync(tmp, path); -} diff --git a/modules/anthropic-manager/module.json b/modules/anthropic-manager/module.json deleted file mode 100644 index 51b3ec8..0000000 --- a/modules/anthropic-manager/module.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "module": "anthropic-manager", - "version": "1", - "slug": "anthmgr", - "capabilities": [ - "container-runtime" - ], - "requires": [ - "model-access" - ], - "binds": { - "model-access": "${dir:mesh-state}/model.json" - }, - "secrets": { - "model-access": "${dir:mesh-state}/refresh-token" - }, - "own-secrets": { - "broker": "${dir:mesh-state}/broker" - }, - "emits": [ - "usage.read" - ], - "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, - { - "id": "out", - "type": "directory", - "path": "${dir:mesh-state}/out", - "mode": "0700" - }, - { - "id": "refresh", - "type": "container", - "name": "mesh-anthropic-manager-refresh", - "image": "mesh-runtime-anthropic-manager@sha256:0000000000000000000000000000000000000000000000000000000000000000", - "network": "host", - "schedule": "*/5 * * * *", - "args": [ - "run", - "/app/modules/anthropic-manager/dist/refresh/index.js" - ], - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "${dir:mesh-state}:/run/state" - ], - "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_ANTHROPIC_LICENCE": "personal", - "MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/refresh-token", - "MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json", - "MESH_ANTHROPIC_ACCESS_OUT": "/run/state/out/access-token", - "MESH_ANTHROPIC_GRANT_OUT": "/run/state/out/grant.json", - "MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/usage.json", - "MESH_TOOLS_MAIN": "/app/dist/main.js" - } - } - ] -} diff --git a/modules/anthropic-manager/package.json b/modules/anthropic-manager/package.json deleted file mode 100644 index df1bea1..0000000 --- a/modules/anthropic-manager/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@novox/module-anthropic-manager", - "version": "0.1.0", - "description": "anthropic-manager — the manager side of the model-access refreshable-grant (ADR 0050): opens the refresh token on the manager node alone, refreshes it against Anthropic's OAuth endpoint, and submits back only the access token and the re-sealed refresh envelope.", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.0", - "tweetnacl": "^1.0.3", - "tweetnacl-sealedbox-js": "^1.2.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/modules/anthropic-manager/refresh/index.ts b/modules/anthropic-manager/refresh/index.ts deleted file mode 100644 index 3749233..0000000 --- a/modules/anthropic-manager/refresh/index.ts +++ /dev/null @@ -1,157 +0,0 @@ -// The manager's scheduled run (novox/hq ADR 0050/0053). It is the whole of the carve-out in one -// place, and it runs on the MANAGER NODE, never in the control plane: -// -// 1. read the refresh token as CLEARTEXT — the host unsealed the stored box with THIS node's private -// key and mounted it at the module's bound secret path, exactly as it delivers any credential. -// This module holds no node key and opens nothing itself; -// 2. call the vendor's OAuth token endpoint to mint a fresh access token (and maybe a rotated -// refresh token); -// 3. if the vendor rotated the refresh token, SEAL the new one to this node's PUBLIC sealing key -// (delivered in the bound facts) with the same anonymous box the mesh seals every credential with; -// 4. hand the control plane back ONLY the access token in the clear + the opaque re-sealed box — -// never the refresh token — which it seals per consumer holder and stores; -// 5. poll usage with the fresh access token and record the licence-grain reading. -// -// mesh-controller receives the products of steps 3–4 through `licence submit-refresh` (access token + -// sealed box). The refresh token never leaves this process except as ciphertext, and it never had to -// be opened here at all — the host did that. -// -// This runs as `mesh-tools run`, which connects no broker, so the outputs are written to files the -// host mounts; the submit itself (the transport to mesh-controller) is done by the caller invoking -// `mesh-controller licence submit-refresh`. In the lab that caller is the scenario; in production it is -// an authenticated call the manager node makes. The transport is the one part stubbed here — FLAGGED -// — because a cross-node authenticated command surface is out of this module's scope. - -import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; -import { dirname } from "node:path"; - -import { readEnv } from "@novox/mesh-sdk/primitives"; - -import { seal } from "../sealedbox.js"; -import { managerPublicKey, writeSealedGrant } from "../grantfile.js"; -import { refreshGrant, grantFromRefresh, readUsage, flattenUsage, type UsageReading } from "../client.js"; - -/** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local - * to the producer (the normalisation lives in the adapter), so nothing here couples to the SDK. */ -interface UsageRow { - licence: string; - consumer: string; - period: string; - metric: string; - value: number; -} - -/** Normalise a licence-grain reading into ADR 0054 rows: one utilization row per window, a row - * omitted when its percentage is absent. `consumer` is the holding module — the licence grain. */ -function licenceRows(licence: string, consumer: string, reading: UsageReading): UsageRow[] { - const rows: UsageRow[] = []; - const add = (period: string, pct: number | null): void => { - if (pct !== null && pct !== undefined && Number.isFinite(pct)) { - rows.push({ licence, consumer, period, metric: "utilization", value: pct }); - } - }; - add("5h", reading.sessionPct); - add("7d", reading.weeklyPct); - add("extra", reading.extraPct); - return rows; -} - -function required(name: string): string { - const v = process.env[name]; - if (!v) throw new Error(`${name} is not set — the manager runtime was deployed without it`); - return v; -} - -function atomicWrite(path: string, content: string): void { - mkdirSync(dirname(path), { recursive: true }); - const tmp = `${path}.tmp`; - writeFileSync(tmp, content, { mode: 0o600 }); - renameSync(tmp, path); -} - -async function main(): Promise { - const licence = process.env.MESH_ANTHROPIC_LICENCE ?? "unknown"; - - // Step 1: the refresh token as cleartext, unsealed and mounted by the HOST. No open here. - const refreshToken = readFileSync(required("MESH_MODEL_ACCESS_SECRET_FILE"), "utf8").trim(); - if (!refreshToken) { - // Nothing was delivered — the manager has not adopted a refresh token yet, or the push has not - // landed. Said rather than treated as an empty token the vendor would reject obscurely. - throw new Error("[anthropic-manager] no refresh token was delivered; adopt one first"); - } - - // The node's PUBLIC sealing key, to re-seal a rotated refresh token. Public, delivered in the facts. - const nodePub = managerPublicKey(required("MESH_MODEL_ACCESS_BIND_FILE")); - - // Step 2: the vendor call. - const refreshed = await refreshGrant(refreshToken); - if (!refreshed) { - // A dead endpoint or a rejected token: nothing to publish, and we do not clobber a good grant. - throw new Error(`[anthropic-manager] the refresh of ${licence} produced no grant`); - } - const grant = grantFromRefresh(refreshed, Date.now()); - if (!grant) { - throw new Error(`[anthropic-manager] the refresh of ${licence} returned no access token`); - } - - // Step 3: re-seal the rotated refresh token, if the vendor rotated it. Nothing to store otherwise. - if (grant.rotatedRefresh) { - const sealed = seal(new Uint8Array(Buffer.from(grant.rotatedRefresh, "utf8")), nodePub); - if (process.env.MESH_ANTHROPIC_GRANT_OUT) { - writeSealedGrant(process.env.MESH_ANTHROPIC_GRANT_OUT, sealed, nodePub); - } - } - - // Step 4: the access token in the clear, for the control plane to seal per consumer holder. This is - // all it ever receives that is not ciphertext. - atomicWrite(required("MESH_ANTHROPIC_ACCESS_OUT"), grant.access.accessToken); - - console.error( - `[anthropic-manager] refreshed ${licence}: access token minted` + - (grant.rotatedRefresh ? ", refresh token rotated and re-sealed" : ", refresh token unchanged"), - ); - - // Step 5: licence-grain usage, best-effort — a usage read failing must not fail the refresh. - try { - const usage = await readUsage(grant.access.accessToken); - if (usage) { - const reading = flattenUsage(usage); - if (process.env.MESH_ANTHROPIC_USAGE_OUT) { - atomicWrite( - process.env.MESH_ANTHROPIC_USAGE_OUT, - JSON.stringify({ licence, grain: "licence", ...reading }), - ); - } - // Emit ADR-0054 rows, not a vendor-shaped body: the consumer of module.*.usage.* is the - // vendor-neutral model-usage store, so the normalisation is done HERE. The node names the - // holding module; with MESH_NODE unset the consumer is the module alone. - const node = readEnv("MESH_NODE", ""); - const consumer = node ? `${node}/anthropic-manager` : "anthropic-manager"; - await emitUsage({ rows: licenceRows(licence, consumer, reading), raw: usage }); - } - } catch (err) { - console.error(`[anthropic-manager] usage poll for ${licence} failed: ${err}`); - } -} - -/** - * Emit a usage event best-effort by shelling out to the sibling mesh-tools `emit` primitive, which - * is the one path that wires a broker from a run-once/scheduled step (which itself connects none). - * A broker hiccup must never fail a refresh that already happened. - */ -async function emitUsage(body: Record): Promise { - const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js"; - const { spawn } = await import("node:child_process"); - await new Promise((resolve) => { - const child = spawn(process.execPath, [main, "emit", "usage.read", JSON.stringify(body)], { - stdio: "inherit", - }); - child.on("exit", () => resolve()); - child.on("error", (err) => { - console.error(`[anthropic-manager] could not emit usage: ${err}`); - resolve(); - }); - }); -} - -await main(); diff --git a/modules/anthropic-manager/sealedbox.ts b/modules/anthropic-manager/sealedbox.ts deleted file mode 100644 index ae4a19f..0000000 --- a/modules/anthropic-manager/sealedbox.ts +++ /dev/null @@ -1,57 +0,0 @@ -// A NaCl `crypto_box_seal`, byte-compatible with Go's `box.SealAnonymous`, over the audited -// `tweetnacl-sealedbox-js`. -// -// **Why this file exists, and why it is exactly this.** novox/hq ADR 0050's refreshable-grant -// carve-out delivers the refresh token to the manager module the way the mesh delivers every other -// credential: sealed to the node's key, and unsealed by the *host* — never by the module. The host -// unseals with Go's `golang.org/x/crypto/nacl/box.OpenAnonymous` (mesh-host -// internal/identity/sealing.go), and mesh-controller seals with `box.SealAnonymous` -// (mesh-controller internal/secrets/seal.go). Both are NaCl `crypto_box_seal`: -// -// sealed = ephemeralPub(32) ‖ crypto_box(msg, nonce, recipientPub, ephemeralSecret) -// nonce = blake2b( ephemeralPub ‖ recipientPub , 24 bytes, unkeyed ) -// -// When the vendor rotates the refresh token, the manager module must store the new one back the -// same way — sealed to the manager node's own sealing key — so mesh-controller keeps it without ever -// reading it and the host can later unseal it to deliver the cleartext again. That reseal happens -// here, on the manager node, in TypeScript. It therefore has to produce the *identical* byte format -// Go's `Open` accepts, or the host would refuse the delivery. -// -// **The crypto is not ours.** `tweetnacl-sealedbox-js` is `crypto_box_seal` built on the audited -// TweetNaCl (`tweetnacl`) and blakejs — the same construction, and the same libraries, the mesh used -// to validate this seal during Phase C. It generates the ephemeral X25519 key pair, derives the -// nonce as `blake2b(ephemeralPub ‖ recipientPub, 24)`, and produces `ephemeralPub ‖ box`. That is -// exactly what Go's `box.OpenAnonymous` opens: the wire format is unchanged from the hand-transcribed -// version this replaces — only the implementation is now a maintained, reviewed dependency rather -// than a copy of TweetNaCl and blakejs carried inline. The module runtime image bundles it -// (package.json dependencies; novox/hq ADR 0052). -// -// **How it is kept honest.** A cross-language test seals a fixture here and opens it in Go -// (mesh-controller internal/secrets/sealedbox_xcheck_test.go); the fixture is regenerated from this -// `seal()`. A drift between this seal and Go's box surfaces there as a seal Go cannot open, not as a -// refresh token silently mangled in production. -// -// This module SEALS only. It never opens — opening is the host's job, with the node private key the -// module is deliberately never given. - -// A default import, not `{ seal }`: the library is a CommonJS UMD bundle, and Node's ESM loader -// cannot statically see its named exports — only its default, which is the whole module object. -import sealedbox from "tweetnacl-sealedbox-js"; - -const RAW_KEY_LEN = 32; - -/** - * Seal a value to a node's public sealing key, producing what Go's `box.OpenAnonymous` opens. - * - * @param value the plaintext (e.g. a rotated refresh token) - * @param recipientPublicB64 the node's raw 32-byte X25519 public key, standard base64 - * @returns standard-base64( ephemeralPub ‖ box ) - */ -export function seal(value: Uint8Array, recipientPublicB64: string): string { - const recipientPub = Buffer.from(recipientPublicB64, "base64"); - if (recipientPub.length !== RAW_KEY_LEN) { - throw new Error(`a sealing public key is 32 bytes, not ${recipientPub.length}`); - } - const sealed = sealedbox.seal(value, new Uint8Array(recipientPub)); - return Buffer.from(sealed).toString("base64"); -} diff --git a/modules/anthropic-manager/test/client.test.ts b/modules/anthropic-manager/test/client.test.ts deleted file mode 100644 index a28cd5f..0000000 --- a/modules/anthropic-manager/test/client.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; - -import { grantFromRefresh, flattenUsage } from "../client.ts"; - -test("an empty refresh response never clobbers a good grant", () => { - assert.equal(grantFromRefresh({}, 1000), null); -}); - -test("a refresh with an access token yields an access-token-only grant and epoch expiry", () => { - const out = grantFromRefresh( - { access_token: "at-new", expires_in: 3600, refresh_token: "rt-rotated", subscription_type: "pro" }, - 1_000_000, - ); - assert.ok(out); - assert.equal(out!.access.accessToken, "at-new"); - assert.equal(out!.access.expiresAt, 1_000_000 + 3600 * 1000); - assert.equal(out!.access.subscriptionType, "pro"); - // The rotated refresh token is reported separately, for the manager to re-seal — never put in the - // holder grant. - assert.equal(out!.rotatedRefresh, "rt-rotated"); - assert.ok(!("refreshToken" in (out!.access as object))); -}); - -test("a refresh that did not rotate the refresh token reports none to re-seal", () => { - const out = grantFromRefresh({ access_token: "at-new" }, 0); - assert.ok(out); - assert.equal(out!.rotatedRefresh, null); -}); - -test("usage flattens the vendor windows to the ADR 0054 grain", () => { - const r = flattenUsage({ - five_hour: { utilization: 42, resets_at: "2026-01-01T00:00:00Z" }, - seven_day: { utilization: 10 }, - seven_day_sonnet: { utilization: 5 }, - extra_usage: { utilization: 1 }, - }); - assert.equal(r.sessionPct, 42); - assert.equal(r.sessionResetsAt, "2026-01-01T00:00:00Z"); - assert.equal(r.weeklyPct, 10); - assert.equal(r.sonnetPct, 5); - assert.equal(r.extraPct, 1); -}); diff --git a/modules/anthropic-manager/test/sealedbox.test.ts b/modules/anthropic-manager/test/sealedbox.test.ts deleted file mode 100644 index f7c11f8..0000000 --- a/modules/anthropic-manager/test/sealedbox.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { generateKeyPairSync } from "node:crypto"; - -import { seal } from "../sealedbox.ts"; - -// The definitive proof that this seal interoperates with Go's box.OpenAnonymous (the host's Unseal -// and mesh-controller's secrets.Seal/Open) is a cross-language test in mesh-controller -// (internal/secrets/sealedbox_xcheck_test.go), which opens a fixture this module's seal() produced. -// These tests hold the TypeScript side: the output has the crypto_box_seal shape, and it is -// randomised so a rotation that changed nothing looks nothing like one that changed everything. - -/** A node public key as the mesh records it: raw 32-byte X25519, standard base64. */ -function aNodePublicKey(): string { - const kp = generateKeyPairSync("x25519"); - const x = (kp.publicKey.export({ format: "jwk" }) as { x: string }).x; - return Buffer.from(x, "base64url").toString("base64"); -} - -test("a seal has the crypto_box_seal shape: ephemeralPub(32) + tag(16) + ciphertext(len)", () => { - const pub = aNodePublicKey(); - const msg = Buffer.from("rt-a-refresh-token", "utf8"); - const blob = Buffer.from(seal(new Uint8Array(msg), pub), "base64"); - // 32 (ephemeral public key) + 16 (Poly1305 tag) + message length. - assert.equal(blob.length, 32 + 16 + msg.length); -}); - -test("two seals of the same value differ — a fresh ephemeral key each time", () => { - const pub = aNodePublicKey(); - const msg = new Uint8Array(Buffer.from("rt-a-refresh-token", "utf8")); - assert.notEqual(seal(msg, pub), seal(msg, pub)); -}); - -test("a public key that is not 32 bytes is refused before anything is sealed", () => { - assert.throws(() => seal(new Uint8Array([1, 2, 3]), Buffer.from("short").toString("base64"))); -}); diff --git a/modules/anthropic-manager/tsconfig.json b/modules/anthropic-manager/tsconfig.json deleted file mode 100644 index acd0018..0000000 --- a/modules/anthropic-manager/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": [ - "tweetnacl-sealedbox-js.d.ts", - "sealedbox.ts", - "grantfile.ts", - "client.ts", - "adopt/index.ts", - "refresh/index.ts" - ] -} diff --git a/modules/anthropic-manager/tweetnacl-sealedbox-js.d.ts b/modules/anthropic-manager/tweetnacl-sealedbox-js.d.ts deleted file mode 100644 index 756dfba..0000000 --- a/modules/anthropic-manager/tweetnacl-sealedbox-js.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Ambient types for `tweetnacl-sealedbox-js` (crypto_box_seal), which ships without its own. -// The library is a small UMD bundle over `tweetnacl` and `blakejs`; only `seal` is used here. -declare module "tweetnacl-sealedbox-js" { - /** crypto_box_seal: returns ephemeralPub(32) ‖ box, sealed to `recipientPublicKey`. */ - export function seal(message: Uint8Array, recipientPublicKey: Uint8Array): Uint8Array; - /** crypto_box_seal_open: returns the plaintext, or null if it does not open. */ - export function open( - sealed: Uint8Array, - recipientPublicKey: Uint8Array, - recipientSecretKey: Uint8Array, - ): Uint8Array | null; - export const overheadLength: number; -}