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
76 lines
3.5 KiB
TypeScript
76 lines
3.5 KiB
TypeScript
// 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<DeliveredGrant> {
|
|
if (!path) return {};
|
|
try {
|
|
const raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
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();
|