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
83 lines
3.1 KiB
TypeScript
83 lines
3.1 KiB
TypeScript
// 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;
|
|
}
|