From bccaf4e8b58493fecc7a8b36a2035fbe0fa0108e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 19:42:40 -0800 Subject: [PATCH 1/4] chore(quest): claim quest/m0/review-gateway.md Quest-Claim: ca7b91faf0d78a1c From a97fbac471c5733d90610e13b53cd222f0cc2ebb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 19:50:35 -0800 Subject: [PATCH 2/4] feat(review): keep the reviewer's gateway and cloud-provider routing quest review now passes the variables that connect the reviewer CLI to its model: ANTHROPIC_BASE_URL, ANTHROPIC_CUSTOM_HEADERS, and Claude Code's cloud provider switches and settings, and Codex's CODEX_CA_CERTIFICATE. While a gateway or provider is set, the credential meant for it passes too, so a review goes where the user's own CLI sessions do. CODEX_ACCESS_TOKEN joins CODEX_API_KEY as a Codex credential. A stored login the API rejects names a credential quest review stripped beside it. Co-Authored-By: Claude Opus 5.5 --- README.md | 17 ++-- quest/m0/README.md | 1 - quest/m0/agent-stalls.md | 5 -- quest/m0/review-gateway.md | 33 -------- src/cli/main.ts | 12 +-- src/cli/review.ts | 103 +++++++++++++++-------- test/review.test.ts | 163 +++++++++++++++++++++++++++++++------ 7 files changed, 225 insertions(+), 109 deletions(-) delete mode 100644 quest/m0/review-gateway.md diff --git a/README.md b/README.md index 31c9c53c..ab9e9541 100644 --- a/README.md +++ b/README.md @@ -931,13 +931,18 @@ branch before it is pushed for review: Codex when run from Claude Code, and Claude Code when run from Codex. `--harness` names the reviewer instead; an unknown harness, or a missing or logged-out CLI, is an error rather than a same-model review, as is a login the API rejects mid-round, such as an expired -or revoked token. The reviewer uses its CLI's own stored login; a CLI with -none keeps the credential it reads from the environment (`ANTHROPIC_API_KEY`, -`ANTHROPIC_AUTH_TOKEN`, or `CLAUDE_CODE_OAUTH_TOKEN` for Claude Code, -`CODEX_API_KEY` for Codex), unless a stripped `ANTHROPIC_BASE_URL` or -`CLAUDE_CODE_USE_BEDROCK`, `_VERTEX`, or `_FOUNDRY` would send it elsewhere. +or revoked token. The reviewer reaches its model as the user's own CLI does: +Claude Code keeps `ANTHROPIC_BASE_URL`, `ANTHROPIC_CUSTOM_HEADERS`, and its +cloud providers' `CLAUDE_CODE_USE_*` switches and settings, so a gateway such +as CLIProxyAPI, Bedrock, Vertex, or Foundry works, and Codex keeps +`CODEX_CA_CERTIFICATE`. It uses the CLI's own stored login; the credential the +CLI reads from the environment (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or +`CLAUDE_CODE_OAUTH_TOKEN` for Claude Code, `CODEX_API_KEY` or +`CODEX_ACCESS_TOKEN` for Codex) passes only when the CLI has no stored login or +a gateway or provider is set. Variables that would change the reviewer's plugins, +permissions, model, or configuration stay stripped. A logged-out error names the profile the CLI checked, `CLAUDE_CONFIG_DIR` or -`CODEX_HOME`, or the default when unset. +`CODEX_HOME`, or the default when unset, and any gateway or provider. Every review runs with a pinned model and effort that `quest review --help` lists; `--model` and `--effort` override them. diff --git a/quest/m0/README.md b/quest/m0/README.md index eaf5ced7..4bbb46e7 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -104,7 +104,6 @@ ownership-manifest design: - [Worktree hooks here](/quest/m0/worktree-hooks.md) - `quest` reaches Claude Code's hooks and the merge driver in this repository, then `quest init` installs the hooks - [Other worktree routes](/quest/m0/worktree-routes.md) - live-check PR worktrees, agent view, remote-control, and workflow agents against the hooks - [Spotlight off in macOS CI](/quest/m0/ci-spotlight.md) - this repository's macOS jobs turn off Spotlight indexing first -- [Review through a gateway](/quest/m0/review-gateway.md) - `quest review` keeps gateway, cloud-provider, and Codex connection variables - [opencode 2.x](/quest/m0/opencode-sdk.md) - the runner drives the opencode 2.x binary through `@opencode/client`, removing the interim 1.19.0 pin - [Waiting sections](/quest/m0/waiting-sections.md) - this repository's waiting quests move from prose to `## Waiting` - [Review harness check](/quest/m0/review-harness.md) - recurring each milestone: newest pinned review models, and the live sandbox test passes diff --git a/quest/m0/agent-stalls.md b/quest/m0/agent-stalls.md index a41b1511..4c4c5ff8 100644 --- a/quest/m0/agent-stalls.md +++ b/quest/m0/agent-stalls.md @@ -55,8 +55,3 @@ Files: the guide's Workspace and Reviews sections in `assets/AGENTS.md`, so `quest waiting` and `/quest-unblock` list it and `/quest-afk` re-dispatches `/quest-review` after the reset. Spawn reports the outage once, not per agent. - -## Related - -- [Review through a gateway](/quest/m0/review-gateway.md) - keeps the - reviewer's gateway credentials, the infrastructure side of quota outages diff --git a/quest/m0/review-gateway.md b/quest/m0/review-gateway.md deleted file mode 100644 index 3a58a836..00000000 --- a/quest/m0/review-gateway.md +++ /dev/null @@ -1,33 +0,0 @@ -# [S] quest review routes through a gateway or cloud provider - -## Goal - -`quest review` works when the reviewer CLI reaches its model through a -gateway such as CLIProxyAPI, or through a cloud provider. The reviewer -keeps `ANTHROPIC_BASE_URL` with its token, `ANTHROPIC_CUSTOM_HEADERS`, -Claude Code's Bedrock, Vertex, and Foundry switches, and Codex's -`CODEX_ACCESS_TOKEN` and `CODEX_CA_CERTIFICATE`, instead of having them -stripped and being reported as logged out. Variables that would change the -reviewer's plugins, permissions, or configuration are still stripped. - -## Plan - -Decided 2026-11-01 (user, /quest-plan of the follow-ups from the -2026-11-01 `/quest-complete` sessions): ✅ plan it, [S], m0: pass these through instead of blocking them. - -- [moq-dev/quest#339](https://github.com/moq-dev/quest/pull/339) lets a - reviewer keep the credential its own CLI reads from the environment, and - passes `CODEX_API_KEY` when `codex login status` fails. It still strips the - variables that send requests to a gateway or another provider, and blocks - credentials while one is set, as the review-login quest's 2026-10-30 - decision asked. That decision kept stripping variables that redirect - billing. This one supersedes it for the gateway and provider variables in - the Goal, because the maintainer routes reviews through them on purpose. -- Reuse the gateway and cloud-routing tests from closed - [moq-dev/quest#316](https://github.com/moq-dev/quest/pull/316), whose - branch `fix/review-provider-environment` - ([79fb7c4](https://github.com/moq-dev/quest/commit/79fb7c4cd45976184c5adc7adc286283b206231c)) - is still on GitHub. -- Keep the managed agents' (`quest agent`) account isolation as it is. If a - credential is still stripped on purpose, the error names the variable - instead of saying "not logged in". diff --git a/src/cli/main.ts b/src/cli/main.ts index e616d616..46d7f980 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -928,11 +928,13 @@ export function main(argv: string[], out: Output): number | Promise { .description( "Review the checkout's committed branch against its base with the other harness: Codex when run from " + "Claude Code, Claude Code when run from Codex, detected from the variables each sets. --harness names " + - "the reviewer instead; there is no other fallback. The reviewer uses its CLI's own stored login or, " + - "without one, the credential it reads from the environment (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or " + - "CLAUDE_CODE_OAUTH_TOKEN for Claude Code, CODEX_API_KEY for Codex) unless a stripped ANTHROPIC_BASE_URL or " + - "CLAUDE_CODE_USE_BEDROCK, _VERTEX, or _FOUNDRY would send it elsewhere. A logged-out error names the " + - "profile it checked, CLAUDE_CONFIG_DIR or CODEX_HOME, or the default when unset. It runs " + + "the reviewer instead; there is no other fallback. The reviewer reaches its model as the user's own CLI " + + "does: Claude Code keeps ANTHROPIC_BASE_URL, ANTHROPIC_CUSTOM_HEADERS, and its cloud providers' " + + "CLAUDE_CODE_USE_* switches and settings, and Codex keeps CODEX_CA_CERTIFICATE. It uses the CLI's own " + + "stored login; the credential the CLI reads from the environment (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, " + + "or CLAUDE_CODE_OAUTH_TOKEN for Claude Code, CODEX_API_KEY or CODEX_ACCESS_TOKEN for Codex) passes only " + + "when the CLI has no stored login or a gateway or provider is set. A logged-out error names the profile it checked, " + + "CLAUDE_CONFIG_DIR or CODEX_HOME, or the default when unset, and any gateway or provider. It runs " + `with ${models}. --model and --effort override them.\n\n` + "The first round runs the reviewer's own review (`codex exec review --base`, or Claude Code's " + "`/code-review ...HEAD`), then resumes that session for one turn that applies the base's " + diff --git a/src/cli/review.ts b/src/cli/review.ts index 772dcca9..eb727bb1 100644 --- a/src/cli/review.ts +++ b/src/cli/review.ts @@ -175,14 +175,19 @@ interface Reviewer { login: string; /** The variable naming the configuration directory, and so the stored login, the status check reads. */ profile: string; - /** Credentials the CLI reads from the environment, passed only when it has no stored login. */ + /** + * Credentials the CLI reads from the environment, passed when it has no + * stored login or a route is set, since a route's credential is meant for it. + */ credentials: string[]; /** - * The variables in `env` that send the CLI's requests, and their credentials, - * to another endpoint or provider. They stay stripped, and while one is set - * no credential passes either, since it is meant for that endpoint. + * Variables that set how the CLI reaches its model: a gateway, a cloud + * provider and its settings, headers, or a CA certificate. They always pass, + * so the review goes where the user's own CLI sessions do. */ - reroutes(env: NodeJS.ProcessEnv): string[]; + connection: string[]; + /** The variables in `env` that send the CLI's requests to a gateway or another provider. */ + routes(env: NodeJS.ProcessEnv): string[]; /** Matches the CLI's error for a login or credential the API rejected: expired, revoked, or invalid. */ unauthorized: RegExp; review(context: Context, session: string): Turn; @@ -204,6 +209,9 @@ const record = (value: unknown) => const count = (value: unknown) => (typeof value === "number" ? value : 0); const text = (value: unknown) => (typeof value === "string" ? value : undefined); +/** The cloud providers Claude Code reaches its model through, each with a `CLAUDE_CODE_USE_` switch. */ +const CLAUDE_PROVIDERS = ["BEDROCK", "VERTEX", "FOUNDRY", "MANTLE", "ANTHROPIC_AWS", "ANTHROPIC_GOOGLE_CLOUD"]; + /** * Claude Code reads no settings files: the checkout's would let a change * instruct or hook its reviewer, and an allow rule in any of them, such as the @@ -217,10 +225,34 @@ const claude: Reviewer = { login: "claude auth login", profile: "CLAUDE_CONFIG_DIR", credentials: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"], - // Claude Code turns a provider on only for `1`, `true`, `yes`, or `on`, so `0` or `false` reroutes nothing. - reroutes: (env) => [ + // A provider's own keys and settings take effect only while its switch is on. Model choices, + // such as ANTHROPIC_MODEL, stay stripped: the review pins its own. + connection: [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_CUSTOM_HEADERS", + ...CLAUDE_PROVIDERS.flatMap((provider) => [`CLAUDE_CODE_USE_${provider}`, `CLAUDE_CODE_SKIP_${provider}_AUTH`]), + "ANTHROPIC_AWS_API_KEY", + "ANTHROPIC_AWS_BASE_URL", + "ANTHROPIC_AWS_WORKSPACE_ID", + "ANTHROPIC_BEDROCK_BASE_URL", + "ANTHROPIC_BEDROCK_MANTLE_BASE_URL", + "ANTHROPIC_BEDROCK_REGION_PREFIX", + "ANTHROPIC_BEDROCK_SERVICE_TIER", + "ANTHROPIC_FOUNDRY_API_KEY", + "ANTHROPIC_FOUNDRY_AUTH_TOKEN", + "ANTHROPIC_FOUNDRY_BASE_URL", + "ANTHROPIC_FOUNDRY_RESOURCE", + "ANTHROPIC_GOOGLE_CLOUD_BASE_URL", + "ANTHROPIC_GOOGLE_CLOUD_PROJECT", + "ANTHROPIC_GOOGLE_CLOUD_WORKSPACE_ID", + "ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION", + "ANTHROPIC_VERTEX_BASE_URL", + "ANTHROPIC_VERTEX_PROJECT_ID", + ], + // Claude Code turns a provider on only for `1`, `true`, `yes`, or `on`, so `0` or `false` routes nothing. + routes: (env) => [ ...(env.ANTHROPIC_BASE_URL ? ["ANTHROPIC_BASE_URL"] : []), - ...["CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"].filter((name) => + ...CLAUDE_PROVIDERS.map((provider) => `CLAUDE_CODE_USE_${provider}`).filter((name) => /^\s*(1|true|yes|on)\s*$/i.test(env[name] ?? ""), ), ], @@ -314,9 +346,11 @@ const codex: Reviewer = { login: "codex login", profile: "CODEX_HOME", // `codex exec` reads CODEX_API_KEY, though `codex login status` does not count it; it ignores OPENAI_API_KEY. - credentials: ["CODEX_API_KEY"], - // OPENAI_BASE_URL is never stripped, so a passed key goes where the CLI already sends its requests. - reroutes: () => [], + // CODEX_ACCESS_TOKEN is an agent identity token, which both read. + credentials: ["CODEX_API_KEY", "CODEX_ACCESS_TOKEN"], + // A gateway that intercepts TLS needs its CA. OPENAI_BASE_URL is never stripped. + connection: ["CODEX_CA_CERTIFICATE"], + routes: () => [], // Such as "unexpected status 401 Unauthorized: ..." or "Your access token could not be refreshed // because your refresh token was revoked. Please log out and sign in again." unauthorized: /\b401 Unauthorized\b|sign in again/, @@ -459,36 +493,39 @@ function profile(reviewer: Reviewer, env: NodeJS.ProcessEnv): string { return dir ? `the profile ${reviewer.profile}=${dir}` : `its default profile (${reviewer.profile} is unset)`; } +/** The variables among `names` that `env` sets, with their values. */ +const pick = (env: NodeJS.ProcessEnv, names: string[]) => + Object.fromEntries(names.filter((name) => env[name] !== undefined).map((name) => [name, env[name]])); + /** * Prepare the reviewer: the user's environment minus `STRIPPED`, so the CLI - * uses its stored login, as any launched agent does. A CLI with no stored - * login keeps the credentials it would read from the environment instead, - * unless a stripped variable would have sent them elsewhere. A shell's API key - * never overrides a stored subscription login, so a review is billed as the - * user's own CLI sessions are. + * uses its stored login, as any launched agent does, plus the variables that + * connect it to its model, so it reaches it the way the user's own CLI does. + * A CLI with no stored login, or one sent to a gateway or another provider, + * also keeps the credentials it would read from the environment. Otherwise a + * shell's API key never overrides a stored subscription login, so a review is + * billed as the user's own CLI sessions are. */ async function prepare(harness: HarnessName, cwd: string, env: NodeJS.ProcessEnv): Promise { const reviewer = REVIEWERS[harness]; - const stripped = strippedEnvironment(env); - const notLoggedIn = `${harness} is not logged in to ${profile(reviewer, env)}`; - const loggedOut = `${notLoggedIn}; run \`${reviewer.login}\``; - if ((await run(reviewer.status, cwd, stripped, "")).code === 0) { - return { reviewer, env: stripped, unauthorized: loggedOut }; + const connected = { ...strippedEnvironment(env), ...pick(env, reviewer.connection) }; + const routes = reviewer.routes(env); + const through = routes.length > 0 ? ` through ${names(routes)}` : ""; + const loggedOut = `${harness} is not logged in to ${profile(reviewer, env)}${through}; run \`${reviewer.login}\``; + const credentials = reviewer.credentials.filter((name) => env[name]); + const withCredentials = { ...connected, ...pick(env, credentials) }; + const rejected = `${harness} rejected ${names(credentials)}${through}; replace it, or run \`${reviewer.login}\``; + if (routes.length > 0) { + if ((await run(reviewer.status, cwd, withCredentials, "")).code !== 0) throw new ReviewInputError(loggedOut); + return { reviewer, env: withCredentials, unauthorized: credentials.length > 0 ? rejected : loggedOut }; } - const reroutes = reviewer.reroutes(env); - if (reroutes.length > 0) { - throw new ReviewInputError( - `${notLoggedIn}, and quest review strips ${names(reroutes)}, which would reroute it; ` + - `run \`${reviewer.login}\``, - ); + if ((await run(reviewer.status, cwd, connected, "")).code === 0) { + const ignored = + credentials.length > 0 ? `; quest review strips ${names(credentials)} while a stored login exists` : ""; + return { reviewer, env: connected, unauthorized: `${loggedOut}${ignored}` }; } - const credentials = reviewer.credentials.filter((name) => env[name]); if (credentials.length === 0) throw new ReviewInputError(loggedOut); - return { - reviewer, - env: { ...stripped, ...Object.fromEntries(credentials.map((name) => [name, env[name]])) }, - unauthorized: `${harness} rejected ${names(credentials)}; replace it, or run \`${reviewer.login}\``, - }; + return { reviewer, env: withCredentials, unauthorized: rejected }; } /** Run one reviewer turn, failing on any error it reports, and as logged out on an authentication error. */ diff --git a/test/review.test.ts b/test/review.test.ts index c37037e6..84a387e7 100644 --- a/test/review.test.ts +++ b/test/review.test.ts @@ -19,9 +19,10 @@ const FINDING = { file: "src/lib.ts", line: 2, severity: "high", title: "Off by /** * Fake reviewer CLIs. Each logs its argv, stdin, and the variables it was * given, then answers as the FAKE_* variables say. FAKE_LOGGED_OUT removes the - * stored login, so only a credential variable the real CLI reads - * authenticates; like the real ones, `claude auth status` counts such a - * variable and `codex login status` does not. FAKE_REJECTED has the API + * stored login, so only a credential variable the real CLI reads, or Claude + * Code's switch to a cloud provider, authenticates; like the real ones, + * `claude auth status` counts such a variable, and `codex login status` counts + * CODEX_ACCESS_TOKEN but not CODEX_API_KEY. FAKE_REJECTED has the API * reject the login in each turn with the real CLI's error, in the style it * names. FAKE_REPLY replaces the findings reply. */ @@ -33,7 +34,8 @@ const stdin = fs.readFileSync(0, "utf8"); const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => /^(CLAUDE|CODEX|ANTHROPIC|FAKE_HOST|PATH$)/.test(k))); fs.appendFileSync(process.env.FAKE_LOG, JSON.stringify({ argv, stdin, env, cwd: process.cwd() }) + "\\n"); const credential = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"].some((k) => process.env[k]); -const loggedIn = !process.env.FAKE_LOGGED_OUT || credential; +const provider = Object.entries(process.env).some(([k, v]) => k.startsWith("CLAUDE_CODE_USE_") && v === "1"); +const loggedIn = !process.env.FAKE_LOGGED_OUT || credential || provider; if (argv[0] === "auth") process.exit(loggedIn ? 0 : 1); const at = (flag) => argv[argv.indexOf(flag) + 1]; const result = (fields) => console.log(JSON.stringify({ type: "result", is_error: false, ...fields })); @@ -77,9 +79,9 @@ const argv = process.argv.slice(2); const stdin = fs.readFileSync(0, "utf8"); const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => /^(CLAUDE|CODEX|ANTHROPIC|FAKE_HOST|PATH$)/.test(k))); fs.appendFileSync(process.env.FAKE_LOG, JSON.stringify({ argv, stdin, env, cwd: process.cwd() }) + "\\n"); -if (argv[0] === "login") process.exit(process.env.FAKE_LOGGED_OUT ? 1 : 0); +if (argv[0] === "login") process.exit(process.env.FAKE_LOGGED_OUT && !process.env.CODEX_ACCESS_TOKEN ? 1 : 0); const event = (value) => console.log(JSON.stringify(value)); -const loggedIn = !process.env.FAKE_LOGGED_OUT || process.env.CODEX_API_KEY; +const loggedIn = !process.env.FAKE_LOGGED_OUT || process.env.CODEX_API_KEY || process.env.CODEX_ACCESS_TOKEN; const rejected = process.env.FAKE_REJECTED ?? (loggedIn ? undefined : "turn"); if (rejected === "turn") { const message = "unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: https://api.openai.com/v1/responses"; @@ -124,16 +126,19 @@ interface Setup { calls(): { argv: string[]; stdin: string; env: Record; cwd: string }[]; } -/** Credentials and reroutes the running session may hold, cleared so each test sets its own. */ +/** Credentials and routes the running session may hold, cleared so each test sets its own. */ const HOST_CREDENTIALS = [ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", + "ANTHROPIC_CUSTOM_HEADERS", "CLAUDE_CODE_OAUTH_TOKEN", - "CLAUDE_CODE_USE_BEDROCK", - "CLAUDE_CODE_USE_VERTEX", - "CLAUDE_CODE_USE_FOUNDRY", + ...["BEDROCK", "VERTEX", "FOUNDRY", "MANTLE", "ANTHROPIC_AWS", "ANTHROPIC_GOOGLE_CLOUD"].map( + (provider) => `CLAUDE_CODE_USE_${provider}`, + ), "CODEX_API_KEY", + "CODEX_ACCESS_TOKEN", + "CODEX_CA_CERTIFICATE", ]; /** @@ -346,44 +351,150 @@ fakes("an environment credential the API rejects is named in the error", async ( expect(result.stderr).toContain("codex rejected CODEX_API_KEY; replace it, or run `codex login`"); }); -fakes("a credential meant for another endpoint stays stripped, and the error names what reroutes it", async () => { +fakes("a stored login the API rejects names the credential quest review strips beside it", async () => { const s = setup("CODEX_THREAD_ID"); - vi.stubEnv("FAKE_LOGGED_OUT", "1"); + vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-key"); + vi.stubEnv("FAKE_REJECTED", "status"); + const result = await quest("--root", s.repo, "review"); + expect(result.code).toBe(2); + expect(result.stderr).toContain( + "claude is not logged in to its default profile (CLAUDE_CONFIG_DIR is unset); run `claude auth login`; " + + "quest review strips ANTHROPIC_API_KEY while a stored login exists", + ); +}); + +fakes.each([ + ["without a stored login", "1"], + ["over a stored login", undefined], +])("Claude keeps a gateway's URL, token, and headers %s, in every round", async (_, loggedOut) => { + const s = setup("CODEX_THREAD_ID"); + vi.stubEnv("FAKE_LOGGED_OUT", loggedOut); vi.stubEnv("ANTHROPIC_AUTH_TOKEN", "gateway-token"); + vi.stubEnv("ANTHROPIC_BASE_URL", "http://127.0.0.1:8317"); + vi.stubEnv("ANTHROPIC_CUSTOM_HEADERS", "X-Gateway-Tenant: review"); + // Neither routes anything, nor changes the reviewer's plugins, model, or account. + vi.stubEnv("CLAUDE_CODE_USE_BEDROCK", ""); + vi.stubEnv("CLAUDE_CODE_PLUGIN_DIRS", "/untrusted/plugins"); + vi.stubEnv("CLAUDE_CODE_EFFORT_LEVEL", "max"); + vi.stubEnv("ANTHROPIC_MODEL", "parent-model"); + vi.stubEnv("CODEX_API_KEY", "other-provider-key"); + vi.stubEnv("OPENAI_API_KEY", "other-provider-key"); + vi.stubEnv("QUEST_API_KEYS", "other-agent-key"); + vi.stubEnv("OP_SERVICE_ACCOUNT_TOKEN", "secret-manager-token"); + vi.stubEnv("FAKE_REPLY", JSON.stringify({ findings: [] })); + const first = await quest("--root", s.repo, "review", "--json"); + expect(first.code, first.stderr).toBe(0); + const resumed = await quest("--root", s.repo, "review", "--json"); + expect(resumed.code, resumed.stderr).toBe(0); + expect(JSON.parse(resumed.stdout)).toMatchObject({ round: 2, session: JSON.parse(first.stdout).session }); + const calls = s.calls(); + expect(calls.map((c) => c.argv[0])).toEqual(["auth", "-p", "-p", "auth", "-p"]); + for (const call of calls) { + expect(call.env).toMatchObject({ + ANTHROPIC_AUTH_TOKEN: "gateway-token", + ANTHROPIC_BASE_URL: "http://127.0.0.1:8317", + ANTHROPIC_CUSTOM_HEADERS: "X-Gateway-Tenant: review", + CLAUDE_CODE_USE_BEDROCK: "", + }); + for (const name of [ + "CODEX_THREAD_ID", + "CODEX_API_KEY", + "CLAUDE_CODE_PLUGIN_DIRS", + "CLAUDE_CODE_EFFORT_LEVEL", + "ANTHROPIC_MODEL", + ]) { + expect(call.env[name], name).toBeUndefined(); + } + } +}); + +fakes("a gateway with no credential and no stored login names the gateway", async () => { + const s = setup("CODEX_THREAD_ID"); + vi.stubEnv("FAKE_LOGGED_OUT", "1"); vi.stubEnv("ANTHROPIC_BASE_URL", "https://gateway.example"); const result = await quest("--root", s.repo, "review"); expect(result.code).toBe(2); expect(result.stderr).toContain( - "claude is not logged in to its default profile (CLAUDE_CONFIG_DIR is unset), and quest review strips ANTHROPIC_BASE_URL, which would reroute it; run `claude auth login`", + "claude is not logged in to its default profile (CLAUDE_CONFIG_DIR is unset) through ANTHROPIC_BASE_URL; run `claude auth login`", ); expect(s.calls().map((c) => c.argv)).toEqual([["auth", "status"]]); }); -fakes.each(["0", "false", ""])("a provider switch set to %j reroutes nothing", async (value) => { +fakes("a gateway credential the API rejects is named with the gateway", async () => { + const s = setup("CODEX_THREAD_ID"); + vi.stubEnv("ANTHROPIC_AUTH_TOKEN", "gateway-token"); + vi.stubEnv("ANTHROPIC_BASE_URL", "https://gateway.example"); + vi.stubEnv("FAKE_REJECTED", "status"); + const result = await quest("--root", s.repo, "review"); + expect(result.code).toBe(2); + expect(result.stderr).toContain( + "claude rejected ANTHROPIC_AUTH_TOKEN through ANTHROPIC_BASE_URL; replace it, or run `claude auth login`", + ); +}); + +fakes.each(["0", "false", ""])("a provider switch set to %j routes nothing", async (value) => { const s = setup("CODEX_THREAD_ID"); - vi.stubEnv("FAKE_LOGGED_OUT", "1"); vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-key"); vi.stubEnv("CLAUDE_CODE_USE_BEDROCK", value); vi.stubEnv("CLAUDE_CODE_USE_VERTEX", value); const result = await quest("--root", s.repo, "review"); expect(result.code, result.stderr).toBe(0); - const turns = s.calls().filter((c) => c.argv[0] !== "auth"); - expect(turns.length).toBeGreaterThan(0); - for (const call of turns) { - expect(call.env.ANTHROPIC_API_KEY).toBe("anthropic-key"); - expect(call.env.CLAUDE_CODE_USE_BEDROCK).toBeUndefined(); + expect(s.calls().length).toBeGreaterThan(1); + for (const call of s.calls()) { + // The stored login still wins over the key, and the switch reaches the CLI as set. + expect(call.env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(call.env.CLAUDE_CODE_USE_BEDROCK).toBe(value); } }); -fakes("an enabled provider switch withholds the credential, and the error names it", async () => { +fakes.each([ + ["BEDROCK", "ANTHROPIC_BEDROCK_BASE_URL"], + ["VERTEX", "ANTHROPIC_VERTEX_BASE_URL"], + ["FOUNDRY", "ANTHROPIC_FOUNDRY_BASE_URL"], + ["MANTLE", "ANTHROPIC_BEDROCK_MANTLE_BASE_URL"], + ["ANTHROPIC_AWS", "ANTHROPIC_AWS_BASE_URL"], + ["ANTHROPIC_GOOGLE_CLOUD", "ANTHROPIC_GOOGLE_CLOUD_BASE_URL"], +])("Claude keeps the %s provider switch with its endpoint and skip-auth setting", async (provider, endpoint) => { const s = setup("CODEX_THREAD_ID"); vi.stubEnv("FAKE_LOGGED_OUT", "1"); - vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-key"); - vi.stubEnv("CLAUDE_CODE_USE_VERTEX", "True"); + vi.stubEnv(`CLAUDE_CODE_USE_${provider}`, "1"); + vi.stubEnv(`CLAUDE_CODE_SKIP_${provider}_AUTH`, "1"); + vi.stubEnv(endpoint, "https://provider.example"); + vi.stubEnv("ANTHROPIC_MODEL", "parent-model"); const result = await quest("--root", s.repo, "review"); - expect(result.code).toBe(2); - expect(result.stderr).toContain("quest review strips CLAUDE_CODE_USE_VERTEX, which would reroute it"); - expect(s.calls().map((c) => c.argv)).toEqual([["auth", "status"]]); + expect(result.code, result.stderr).toBe(0); + expect(s.calls().length).toBeGreaterThan(1); + for (const call of s.calls()) { + expect(call.env[`CLAUDE_CODE_USE_${provider}`]).toBe("1"); + expect(call.env[`CLAUDE_CODE_SKIP_${provider}_AUTH`]).toBe("1"); + expect(call.env[endpoint]).toBe("https://provider.example"); + expect(call.env.ANTHROPIC_MODEL).toBeUndefined(); + } +}); + +fakes("Codex keeps CODEX_CA_CERTIFICATE always, and CODEX_ACCESS_TOKEN without a stored login", async () => { + const s = setup(); + vi.stubEnv("CODEX_CA_CERTIFICATE", "/etc/gateway-ca.pem"); + vi.stubEnv("CODEX_ACCESS_TOKEN", "agent-identity"); + expect((await quest("--root", s.repo, "review")).code).toBe(0); + for (const call of s.calls()) { + expect(call.env.CODEX_CA_CERTIFICATE).toBe("/etc/gateway-ca.pem"); + expect(call.env.CODEX_ACCESS_TOKEN).toBeUndefined(); + } + + const t = setup(); + vi.stubEnv("FAKE_LOGGED_OUT", "1"); + vi.stubEnv("CODEX_CA_CERTIFICATE", "/etc/gateway-ca.pem"); + vi.stubEnv("CODEX_ACCESS_TOKEN", "agent-identity"); + vi.stubEnv("ANTHROPIC_BASE_URL", "https://gateway.example"); + const result = await quest("--root", t.repo, "review"); + expect(result.code, result.stderr).toBe(0); + expect(t.calls().length).toBeGreaterThan(1); + for (const call of t.calls().slice(1)) { + expect(call.env.CODEX_CA_CERTIFICATE).toBe("/etc/gateway-ca.pem"); + expect(call.env.CODEX_ACCESS_TOKEN).toBe("agent-identity"); + expect(call.env.ANTHROPIC_BASE_URL).toBeUndefined(); + } }); fakes("the reviewer CLI is found on PATH, never in the checkout it reviews", async () => { From 1b2dc7b12e6306b98c860ec32312303f2f81d0b2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 19:53:10 -0800 Subject: [PATCH 3/4] fix(review): keep Claude Code's Google Cloud location Co-Authored-By: Claude Opus 5.5 --- src/cli/review.ts | 1 + test/review.test.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/review.ts b/src/cli/review.ts index eb727bb1..52898215 100644 --- a/src/cli/review.ts +++ b/src/cli/review.ts @@ -243,6 +243,7 @@ const claude: Reviewer = { "ANTHROPIC_FOUNDRY_BASE_URL", "ANTHROPIC_FOUNDRY_RESOURCE", "ANTHROPIC_GOOGLE_CLOUD_BASE_URL", + "ANTHROPIC_GOOGLE_CLOUD_LOCATION", "ANTHROPIC_GOOGLE_CLOUD_PROJECT", "ANTHROPIC_GOOGLE_CLOUD_WORKSPACE_ID", "ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION", diff --git a/test/review.test.ts b/test/review.test.ts index 84a387e7..8c78ab67 100644 --- a/test/review.test.ts +++ b/test/review.test.ts @@ -454,7 +454,8 @@ fakes.each([ ["MANTLE", "ANTHROPIC_BEDROCK_MANTLE_BASE_URL"], ["ANTHROPIC_AWS", "ANTHROPIC_AWS_BASE_URL"], ["ANTHROPIC_GOOGLE_CLOUD", "ANTHROPIC_GOOGLE_CLOUD_BASE_URL"], -])("Claude keeps the %s provider switch with its endpoint and skip-auth setting", async (provider, endpoint) => { + ["ANTHROPIC_GOOGLE_CLOUD", "ANTHROPIC_GOOGLE_CLOUD_LOCATION"], +])("Claude keeps the %s provider switch with its %s and skip-auth setting", async (provider, endpoint) => { const s = setup("CODEX_THREAD_ID"); vi.stubEnv("FAKE_LOGGED_OUT", "1"); vi.stubEnv(`CLAUDE_CODE_USE_${provider}`, "1"); From e7cb662490990b9c04fcf2d5334a3eba4090d78f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 07:02:32 -0800 Subject: [PATCH 4/4] fix(review): pass a provider's keys only while a route is set ANTHROPIC_AWS_API_KEY, ANTHROPIC_FOUNDRY_API_KEY, and ANTHROPIC_FOUNDRY_AUTH_TOKEN passed on every turn as connection settings, so an unused provider secret reached the reviewer and its subprocesses. They are now route credentials: passed only while a gateway or provider is set. A shell holding only such a key, with no stored login and no route, still fails fast as logged out. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++- src/cli/main.ts | 3 ++- src/cli/review.ts | 17 ++++++++++++----- test/review.test.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ab9e9541..5cd25859 100644 --- a/README.md +++ b/README.md @@ -939,7 +939,9 @@ as CLIProxyAPI, Bedrock, Vertex, or Foundry works, and Codex keeps CLI reads from the environment (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or `CLAUDE_CODE_OAUTH_TOKEN` for Claude Code, `CODEX_API_KEY` or `CODEX_ACCESS_TOKEN` for Codex) passes only when the CLI has no stored login or -a gateway or provider is set. Variables that would change the reviewer's plugins, +a gateway or provider is set. A provider's own keys (`ANTHROPIC_AWS_API_KEY`, +`ANTHROPIC_FOUNDRY_API_KEY`, `ANTHROPIC_FOUNDRY_AUTH_TOKEN`) pass only while a +gateway or provider is set. Variables that would change the reviewer's plugins, permissions, model, or configuration stay stripped. A logged-out error names the profile the CLI checked, `CLAUDE_CONFIG_DIR` or `CODEX_HOME`, or the default when unset, and any gateway or provider. diff --git a/src/cli/main.ts b/src/cli/main.ts index 46d7f980..d2080bd6 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -933,7 +933,8 @@ export function main(argv: string[], out: Output): number | Promise { "CLAUDE_CODE_USE_* switches and settings, and Codex keeps CODEX_CA_CERTIFICATE. It uses the CLI's own " + "stored login; the credential the CLI reads from the environment (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, " + "or CLAUDE_CODE_OAUTH_TOKEN for Claude Code, CODEX_API_KEY or CODEX_ACCESS_TOKEN for Codex) passes only " + - "when the CLI has no stored login or a gateway or provider is set. A logged-out error names the profile it checked, " + + "when the CLI has no stored login or a gateway or provider is set, and a provider's own keys " + + "(ANTHROPIC_AWS_API_KEY, ANTHROPIC_FOUNDRY_API_KEY, ANTHROPIC_FOUNDRY_AUTH_TOKEN) only while one is set. A logged-out error names the profile it checked, " + "CLAUDE_CONFIG_DIR or CODEX_HOME, or the default when unset, and any gateway or provider. It runs " + `with ${models}. --model and --effort override them.\n\n` + "The first round runs the reviewer's own review (`codex exec review --base`, or Claude Code's " + diff --git a/src/cli/review.ts b/src/cli/review.ts index 52898215..bbad6642 100644 --- a/src/cli/review.ts +++ b/src/cli/review.ts @@ -180,6 +180,11 @@ interface Reviewer { * stored login or a route is set, since a route's credential is meant for it. */ credentials: string[]; + /** + * Credentials only a gateway or provider reads, passed only while a route is + * set, so an unused provider key never reaches the reviewer. + */ + routeCredentials: string[]; /** * Variables that set how the CLI reaches its model: a gateway, a cloud * provider and its settings, headers, or a CA certificate. They always pass, @@ -225,21 +230,20 @@ const claude: Reviewer = { login: "claude auth login", profile: "CLAUDE_CONFIG_DIR", credentials: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"], - // A provider's own keys and settings take effect only while its switch is on. Model choices, + // Claude Code lists these with its credentials, and reads them only while a provider's switch is on. + routeCredentials: ["ANTHROPIC_AWS_API_KEY", "ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_FOUNDRY_AUTH_TOKEN"], + // A provider's own settings take effect only while its switch is on. Model choices, // such as ANTHROPIC_MODEL, stay stripped: the review pins its own. connection: [ "ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", ...CLAUDE_PROVIDERS.flatMap((provider) => [`CLAUDE_CODE_USE_${provider}`, `CLAUDE_CODE_SKIP_${provider}_AUTH`]), - "ANTHROPIC_AWS_API_KEY", "ANTHROPIC_AWS_BASE_URL", "ANTHROPIC_AWS_WORKSPACE_ID", "ANTHROPIC_BEDROCK_BASE_URL", "ANTHROPIC_BEDROCK_MANTLE_BASE_URL", "ANTHROPIC_BEDROCK_REGION_PREFIX", "ANTHROPIC_BEDROCK_SERVICE_TIER", - "ANTHROPIC_FOUNDRY_API_KEY", - "ANTHROPIC_FOUNDRY_AUTH_TOKEN", "ANTHROPIC_FOUNDRY_BASE_URL", "ANTHROPIC_FOUNDRY_RESOURCE", "ANTHROPIC_GOOGLE_CLOUD_BASE_URL", @@ -349,6 +353,7 @@ const codex: Reviewer = { // `codex exec` reads CODEX_API_KEY, though `codex login status` does not count it; it ignores OPENAI_API_KEY. // CODEX_ACCESS_TOKEN is an agent identity token, which both read. credentials: ["CODEX_API_KEY", "CODEX_ACCESS_TOKEN"], + routeCredentials: [], // A gateway that intercepts TLS needs its CA. OPENAI_BASE_URL is never stripped. connection: ["CODEX_CA_CERTIFICATE"], routes: () => [], @@ -513,7 +518,9 @@ async function prepare(harness: HarnessName, cwd: string, env: NodeJS.ProcessEnv const routes = reviewer.routes(env); const through = routes.length > 0 ? ` through ${names(routes)}` : ""; const loggedOut = `${harness} is not logged in to ${profile(reviewer, env)}${through}; run \`${reviewer.login}\``; - const credentials = reviewer.credentials.filter((name) => env[name]); + const credentials = [...reviewer.credentials, ...(routes.length > 0 ? reviewer.routeCredentials : [])].filter( + (name) => env[name], + ); const withCredentials = { ...connected, ...pick(env, credentials) }; const rejected = `${harness} rejected ${names(credentials)}${through}; replace it, or run \`${reviewer.login}\``; if (routes.length > 0) { diff --git a/test/review.test.ts b/test/review.test.ts index 8c78ab67..20b23405 100644 --- a/test/review.test.ts +++ b/test/review.test.ts @@ -130,8 +130,11 @@ interface Setup { const HOST_CREDENTIALS = [ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_AWS_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", + "ANTHROPIC_FOUNDRY_API_KEY", + "ANTHROPIC_FOUNDRY_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", ...["BEDROCK", "VERTEX", "FOUNDRY", "MANTLE", "ANTHROPIC_AWS", "ANTHROPIC_GOOGLE_CLOUD"].map( (provider) => `CLAUDE_CODE_USE_${provider}`, @@ -473,6 +476,31 @@ fakes.each([ } }); +fakes("Claude gets a provider's keys only while a gateway or provider is set", async () => { + const keys = ["ANTHROPIC_AWS_API_KEY", "ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_FOUNDRY_AUTH_TOKEN"]; + const s = setup("CODEX_THREAD_ID"); + for (const key of keys) vi.stubEnv(key, "provider-key"); + expect((await quest("--root", s.repo, "review")).code).toBe(0); + expect(s.calls().length).toBeGreaterThan(1); + for (const call of s.calls()) for (const key of keys) expect(call.env[key], key).toBeUndefined(); + + // With no stored login and no route, they are no login: the review stops before it starts. + const t = setup("CODEX_THREAD_ID"); + for (const key of keys) vi.stubEnv(key, "provider-key"); + vi.stubEnv("FAKE_LOGGED_OUT", "1"); + const loggedOut = await quest("--root", t.repo, "review"); + expect(loggedOut.code).toBe(2); + expect(loggedOut.stderr).toContain("claude is not logged in"); + expect(t.calls().map((c) => c.argv)).toEqual([["auth", "status"]]); + + const u = setup("CODEX_THREAD_ID"); + for (const key of keys) vi.stubEnv(key, "provider-key"); + vi.stubEnv("CLAUDE_CODE_USE_FOUNDRY", "1"); + const routed = await quest("--root", u.repo, "review"); + expect(routed.code, routed.stderr).toBe(0); + for (const call of u.calls()) for (const key of keys) expect(call.env[key], key).toBe("provider-key"); +}); + fakes("Codex keeps CODEX_CA_CERTIFICATE always, and CODEX_ACCESS_TOKEN without a stored login", async () => { const s = setup(); vi.stubEnv("CODEX_CA_CERTIFICATE", "/etc/gateway-ca.pem");