anthropic model-access modules: manager (refreshable-grant) and consumer

Phase C of vendor-agnostic model-access (ADR 0050/0054). Two TypeScript
runtime modules:

- anthropic-manager: the refresh token is sealed at rest to the manager
  node's own key (atrest.ts, envelope encryption over X25519) and opened
  ONLY on the manager node. adopt seals the first envelope; refresh opens
  it, calls the Anthropic OAuth token endpoint, re-seals a rotated refresh
  token, and hands the control plane only the access token plus the opaque
  envelope. Also polls licence-grain usage (ADR 0054).
- anthropic-consumer: writes the delivered access token to
  ~/.claude/.credentials.json, access-token-only, atomically (the refresh
  token is never delivered); reports session-grain usage from the CLI
  transcripts; a fail-closed identity guard (expected-uuid plumbing is a
  flagged TODO).

Both run as scheduled containers (ADR 0053). Pure logic covered by
node --test fixtures (at-rest round-trip, credential strip, transcript
sum, refresh merge).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-07 01:00:12 +02:00
parent cd46d53464
commit c206e2e11e
19 changed files with 1293 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
// 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 opaque refresh-token envelope the control plane forwarded (it cannot open it);
// 2. open it HERE with the node's own sealing key — the one moment a refresh token is in the clear,
// on the one node the ADR permits it;
// 3. call the vendor's OAuth token endpoint to mint a fresh access token (and maybe a rotated
// refresh token);
// 4. re-seal the rotated refresh token at rest (still openable by this node alone);
// 5. hand the control plane back ONLY the access token in the clear + the opaque re-sealed
// envelope — never the refresh token — which it seals per holder and stores;
// 6. poll usage with the fresh access token and record the licence-grain reading.
//
// mesh-control receives the products of steps 5–6 through `licence submit-refresh` (access token +
// opaque envelope). The refresh token never leaves this process except as ciphertext.
//
// 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-control) is done by the caller invoking
// `mesh-control 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 { openAtRest, sealAtRest, type Envelope } from "../atrest.js";
import { refreshGrant, grantFromRefresh, readUsage, flattenUsage } from "../client.js";
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 readTrimmed(path: string): string {
return readFileSync(path, "utf8").trim();
}
/** Accept an envelope in either the wire (snake_case) or internal (camelCase) shape. */
function readEnvelope(path: string): Envelope {
const raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, string>;
const token = raw.token ?? "";
const wrappedKey = raw.wrappedKey ?? raw.wrapped_key ?? "";
const managerKey = raw.managerKey ?? raw.manager_key ?? "";
if (!token || !wrappedKey || !managerKey) {
throw new Error("the refresh-token envelope is missing one of token/wrapped_key/manager_key");
}
return { token, wrappedKey, managerKey };
}
/** Write the envelope in the wire (snake_case) shape mesh-control's `submit-refresh` reads. */
function writeEnvelope(path: string, env: Envelope): void {
atomicWrite(
path,
JSON.stringify({ token: env.token, wrapped_key: env.wrappedKey, manager_key: env.managerKey }),
);
}
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<void> {
const licence = process.env.MESH_ANTHROPIC_LICENCE ?? "unknown";
const envelope = readEnvelope(required("MESH_ANTHROPIC_GRANT_FILE"));
const nodePub = readTrimmed(required("MESH_NODE_SEALING_PUBLIC_FILE"));
const nodePriv = readTrimmed(required("MESH_NODE_SEALING_PRIVATE_FILE"));
// Step 2: the one open, on the manager node.
const refreshToken = openAtRest(envelope, nodePub, nodePriv);
// Step 3: 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 4: re-seal the rotated refresh token, if the vendor rotated it. Nothing to store otherwise.
if (grant.rotatedRefresh) {
const rotated = sealAtRest(grant.rotatedRefresh, nodePub);
if (process.env.MESH_ANTHROPIC_GRANT_OUT) {
writeEnvelope(process.env.MESH_ANTHROPIC_GRANT_OUT, rotated);
}
}
// Step 5: the access token in the clear, for the control plane to seal per 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 6: 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 }),
);
}
await emitUsage({ licence, grain: "licence", ...reading });
}
} 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<string, unknown>): Promise<void> {
const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js";
const { spawn } = await import("node:child_process");
await new Promise<void>((resolve) => {
const child = spawn(process.execPath, [main, "emit", "module.anthropic-manager.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();