Files
mesh-catalog/modules/anthropic-manager/refresh/index.ts
T
jschoubben c206e2e11e 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
2026-09-07 01:00:12 +02:00

143 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();