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
143 lines
6.2 KiB
TypeScript
143 lines
6.2 KiB
TypeScript
// 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();
|