// 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; }