From 3288f92dbec474587682fba62e4a44434d0b7658 Mon Sep 17 00:00:00 2001 From: Luca Giordano Date: Fri, 3 Jul 2026 10:36:59 +0200 Subject: [PATCH] fix: AI reviewer authenticated via low-balance API key instead of subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both PR #92's and PR #96's review runs failed with "Credit balance is too low" (the exit-causing error; an unrelated "workspace has not been trusted" permissions warning prints alongside it and had been masking the real cause). Root cause, confirmed live: reviewer-adapter.ts runs via noSandbox() + sandcastle's top-level run(), which inherits the devcontainer's own process env — ANTHROPIC_API_KEY (ADR-0018 cockpit passthrough; also needed by headroom's own compress() calls, ADR-0023) sits alongside CLAUDE_CODE_OAUTH_TOKEN (resolved separately from .sandcastle/.env by sandcastle for agent auth). Claude Code prefers an explicit API key over the subscription token whenever both are present, so every review silently authenticated against the API key — and failed outright once that key's balance ran out. Verified precisely: `ANTHROPIC_API_KEY="" CLAUDE_CODE_OAUTH_TOKEN= claude --print ...` succeeds where the unmodified env fails. Confirmed this specific fix mechanism (claudeCode(model, { env }) on sandcastle's top-level run()) actually threads through — unlike SandboxRunner's createSandbox() + .run() path (see PR #97), where AgentProvider.env is silently discarded. Fix: force ANTHROPIC_API_KEY to an empty string (not omitted — Claude Code only falls back to OAuth when the var is unset/empty) on all 4 claudeCode() calls in reviewer-adapter.ts, via a shared FORCE_OAUTH_ENV constant. --- .sandcastle/reviewer-adapter.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/.sandcastle/reviewer-adapter.ts b/.sandcastle/reviewer-adapter.ts index d7bd446..7feebb1 100644 --- a/.sandcastle/reviewer-adapter.ts +++ b/.sandcastle/reviewer-adapter.ts @@ -26,6 +26,26 @@ import { getCompressionCallback } from "./context-compressor.js"; const _dir = dirname(fileURLToPath(import.meta.url)); +/** + * Force Claude Code to authenticate via CLAUDE_CODE_OAUTH_TOKEN (subscription, + * ADR-0003) instead of ANTHROPIC_API_KEY (pay-as-you-go). The reviewer runs via + * noSandbox() + sandcastle's top-level run(), which inherits the devcontainer's + * own process env — that env carries ANTHROPIC_API_KEY (ADR-0018 cockpit + * passthrough; also needed by the headroom proxy's own compress() calls, see + * ADR-0023) alongside CLAUDE_CODE_OAUTH_TOKEN (from .sandcastle/.env, resolved + * separately by sandcastle for agent auth). Claude Code prefers an explicit API + * key over the subscription token whenever both are present, so every review + * silently authenticated against the API key instead — and failed outright + * ("Credit balance is too low") once that key's balance ran out, masked by an + * unrelated "workspace not trusted" warning that always prints alongside it. + * Live-verified: clearing the key here (empty string, not omitting it — Claude + * Code only falls back to OAuth when the var is unset/empty) restores the + * working OAuth path. Confirmed this works on the top-level run() path (unlike + * SandboxRunner's createSandbox()+.run() path, where AgentProvider.env is + * silently ignored entirely — see sandbox-runner.ts's `sandboxEnv` field). + */ +const FORCE_OAUTH_ENV = { ANTHROPIC_API_KEY: "" }; + /** Standard Schema-compatible validator for the reviewer's structured output. */ const reviewOutputSchema = { "~standard": { @@ -200,7 +220,7 @@ export class ReviewerAdapter { const compressed = await compression(text); reviewResult = await run({ - agent: claudeCode(model, { permissionMode: "auto" }), + agent: claudeCode(model, { permissionMode: "auto", env: FORCE_OAUTH_ENV }), sandbox: noSandbox(), cwd, prompt: compressed, @@ -209,7 +229,7 @@ export class ReviewerAdapter { }); } else { reviewResult = await run({ - agent: claudeCode(model, { permissionMode: "auto" }), + agent: claudeCode(model, { permissionMode: "auto", env: FORCE_OAUTH_ENV }), sandbox: noSandbox(), cwd, ...passOneConfig, @@ -239,7 +259,7 @@ export class ReviewerAdapter { cwd, }) : await run({ - agent: claudeCode(model, { permissionMode: "auto" }), + agent: claudeCode(model, { permissionMode: "auto", env: FORCE_OAUTH_ENV }), sandbox: noSandbox(), cwd, prompt: compressed, @@ -256,7 +276,7 @@ export class ReviewerAdapter { }, ) : await run({ - agent: claudeCode(model, { permissionMode: "auto" }), + agent: claudeCode(model, { permissionMode: "auto", env: FORCE_OAUTH_ENV }), sandbox: noSandbox(), cwd, promptFile: passTwoConfig.promptFile,