diff --git a/codev/projects/bugfix-985-consult-m-claude-bills-metered/status.yaml b/codev/projects/bugfix-985-consult-m-claude-bills-metered/status.yaml new file mode 100644 index 000000000..fe90bfe5a --- /dev/null +++ b/codev/projects/bugfix-985-consult-m-claude-bills-metered/status.yaml @@ -0,0 +1,17 @@ +id: bugfix-985 +title: consult-m-claude-bills-metered +protocol: bugfix +phase: pr +plan_phases: [] +current_plan_phase: null +gates: + pr: + status: approved + requested_at: '2026-06-04T04:22:57.960Z' + approved_at: '2026-06-04T05:13:18.384Z' +iteration: 1 +build_complete: false +history: [] +started_at: '2026-06-04T04:10:42.581Z' +updated_at: '2026-06-04T05:13:18.385Z' +pr_ready_for_human: false diff --git a/codev/resources/commands/consult.md b/codev/resources/commands/consult.md index 4ed8a8004..76da6fb97 100644 --- a/codev/resources/commands/consult.md +++ b/codev/resources/commands/consult.md @@ -154,6 +154,19 @@ Configure API keys: - Codex: `OPENAI_API_KEY` - Gemini: `GOOGLE_API_KEY` or `GEMINI_API_KEY` +### Claude auth: subscription vs. metered API + +`consult -m claude` runs on the Claude Agent SDK. When `CLAUDE_CODE_OAUTH_TOKEN` +(a Claude subscription/OAuth token) is present, consult strips `ANTHROPIC_API_KEY` +and `ANTHROPIC_AUTH_TOKEN` from the SDK subprocess env so the consultation +authenticates against the **subscription** rather than the **metered Opus API**. +The Agent SDK otherwise prioritizes `ANTHROPIC_API_KEY`, which silently routes +CMAP/review traffic to the metered API (issue #985). When no OAuth token is set, +the API key is used as before so CI / key-only environments keep working. + +> **Caveat:** dedicated Agent-SDK subscription credit starts **2026-06-15**. +> Before that date, subscription auth draws from the interactive Max quota. + ## The Consultant Role The consultant role (`codev/roles/consultant.md`) defines behavior: diff --git a/codev/state/bugfix-985_thread.md b/codev/state/bugfix-985_thread.md new file mode 100644 index 000000000..5ecea9384 --- /dev/null +++ b/codev/state/bugfix-985_thread.md @@ -0,0 +1,52 @@ +# bugfix-985 thread + +## Issue +`consult -m claude` bills the metered Opus API instead of the Claude subscription because it +forwards `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` into the Agent SDK env, which shadows +`CLAUDE_CODE_OAUTH_TOKEN` in the SDK's auth priority. Reported by an external adopter +(~$150/day on a heavy dev day). + +## Investigate (done) +Root cause confirmed in source on this branch: +- `packages/codev/src/commands/consult/index.ts`, `runClaudeConsultation()` lines 504-509 + copy ALL of `process.env` into the `env` object passed to `claudeQuery`. That includes + `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` when present, which the Agent SDK prioritizes + over `CLAUDE_CODE_OAUTH_TOKEN`. +- No existing test exercises this env construction. Function is not exported. +- Established testability pattern in this file: internal helpers re-exported with `_` prefix + (see export block ~line 1627). Test dir: `src/commands/consult/__tests__/` (vitest). +- Adjacent (noted, not in scope): line 519 hardcodes `model: 'claude-opus-4-6'`. + +## Fix plan +- Extract env-building into a pure exported helper `buildClaudeConsultEnv(processEnv)` so it's + unit-testable. When `CLAUDE_CODE_OAUTH_TOKEN` is set, delete `ANTHROPIC_API_KEY` and + `ANTHROPIC_AUTH_TOKEN` from the LOCAL env object (not global process.env). When OAuth token + is absent, preserve the API key (CI/key-only envs keep working). +- Regression test in `__tests__/claude-auth-env.test.ts`. +- Doc note in consult docs re: subscription-credit caveat (2026-06-15). + +## Fix (done) +- `consult/index.ts`: extracted env-building into exported pure helper + `buildClaudeConsultEnv(processEnv)`. Strips `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` + from the local copy only when `CLAUDE_CODE_OAUTH_TOKEN` is set; preserves the key + otherwise. `runClaudeConsultation` now calls the helper. +- Regression test: `__tests__/claude-auth-env.test.ts` (4 cases: strip-on-oauth, + preserve-without-oauth, no-mutation-of-source, drops-undefined). All pass. +- Doc note added to `codev/resources/commands/consult.md`. +- Verified: consult suite 79/79 pass; `tsc --noEmit` clean (after building codev-core + in the fresh worktree). Net diff well under 300 LOC. + +BUGFIX protocol phases: Investigate → Fix → Create PR. + +## Create PR (done) +- PR #986 opened against main. "Fixes #985". +- CMAP (3-way) on the PR — all APPROVE, HIGH confidence, zero blocking issues: + - Codex: APPROVE/HIGH — correct, tightly scoped, solid regression coverage. + - Gemini: APPROVE/HIGH — correct root-cause fix, thorough branch coverage. + - Claude: APPROVE/HIGH — clean minimal fix; flagged (non-blocking) external-adopter + scrubbing convention + the `_`-prefix export style note (kept direct export — it's a + legitimate public utility, not a test-only helper). +- Applied scrub: removed external-adopter workspace name from PR body + this thread + (kept the ~$150/day signal). Issue body still names the adopter — upstream of the + builder; flagged to architect. +- Awaiting architect approval to merge (BUGFIX pr gate). diff --git a/packages/codev/src/commands/consult/__tests__/claude-auth-env.test.ts b/packages/codev/src/commands/consult/__tests__/claude-auth-env.test.ts new file mode 100644 index 000000000..d697638f3 --- /dev/null +++ b/packages/codev/src/commands/consult/__tests__/claude-auth-env.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { buildClaudeConsultEnv } from '../index.js'; + +/** + * Regression test for issue #985. + * + * `consult -m claude` must authenticate the Agent SDK with the Claude + * subscription (`CLAUDE_CODE_OAUTH_TOKEN`) rather than the metered Opus API. + * The SDK prioritizes `ANTHROPIC_API_KEY` over `CLAUDE_CODE_OAUTH_TOKEN`, so + * when an OAuth token is present the API/auth tokens must be stripped from the + * subprocess env. When no OAuth token is set, the API key must be preserved so + * CI / key-only environments still authenticate. + */ +describe('buildClaudeConsultEnv (issue #985)', () => { + it('strips ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN when OAuth token is set', () => { + const env = buildClaudeConsultEnv({ + CLAUDE_CODE_OAUTH_TOKEN: 'oauth-sub-token', + ANTHROPIC_API_KEY: 'sk-metered-key', + ANTHROPIC_AUTH_TOKEN: 'auth-token', + PATH: '/usr/bin', + }); + + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + // OAuth token and unrelated vars are preserved. + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-sub-token'); + expect(env.PATH).toBe('/usr/bin'); + }); + + it('preserves ANTHROPIC_API_KEY when no OAuth token is set (CI / key-only)', () => { + const env = buildClaudeConsultEnv({ + ANTHROPIC_API_KEY: 'sk-metered-key', + ANTHROPIC_AUTH_TOKEN: 'auth-token', + PATH: '/usr/bin', + }); + + expect(env.ANTHROPIC_API_KEY).toBe('sk-metered-key'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('auth-token'); + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined(); + }); + + it('does not mutate the source process.env object', () => { + const source: NodeJS.ProcessEnv = { + CLAUDE_CODE_OAUTH_TOKEN: 'oauth-sub-token', + ANTHROPIC_API_KEY: 'sk-metered-key', + ANTHROPIC_AUTH_TOKEN: 'auth-token', + }; + + buildClaudeConsultEnv(source); + + // The deletion must be scoped to the returned copy, never the global env. + expect(source.ANTHROPIC_API_KEY).toBe('sk-metered-key'); + expect(source.ANTHROPIC_AUTH_TOKEN).toBe('auth-token'); + }); + + it('drops undefined values while copying', () => { + const env = buildClaudeConsultEnv({ + DEFINED: 'yes', + UNDEFINED: undefined, + }); + + expect(env.DEFINED).toBe('yes'); + expect('UNDEFINED' in env).toBe(false); + }); +}); diff --git a/packages/codev/src/commands/consult/index.ts b/packages/codev/src/commands/consult/index.ts index 8e0c5208e..c481b184d 100644 --- a/packages/codev/src/commands/consult/index.ts +++ b/packages/codev/src/commands/consult/index.ts @@ -477,6 +477,40 @@ export async function runCodexConsultation( } } +/** + * Build the env passed to the Claude Agent SDK subprocess for a consultation. + * + * Copies the given environment, but when a Claude subscription/OAuth token + * (`CLAUDE_CODE_OAUTH_TOKEN`) is present, strips `ANTHROPIC_API_KEY` and + * `ANTHROPIC_AUTH_TOKEN` from the *copy* (never the global `process.env`). + * The Agent SDK prioritizes the API key over the OAuth token, so leaving the + * key in would silently route CMAP/review traffic to the metered Opus API + * instead of the Claude subscription (issue #985). + * + * When no OAuth token is set, the API key is preserved so CI / key-only + * environments continue to authenticate. + * + * The deletion is scoped to this subprocess env only — other callers that need + * the API key (persona, dev:local) are unaffected. + */ +export function buildClaudeConsultEnv( + processEnv: NodeJS.ProcessEnv, +): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(processEnv)) { + if (value !== undefined) { + env[key] = value; + } + } + + if (env.CLAUDE_CODE_OAUTH_TOKEN) { + delete env.ANTHROPIC_API_KEY; + delete env.ANTHROPIC_AUTH_TOKEN; + } + + return env; +} + /** * Run Claude consultation via Agent SDK. * Uses the SDK's query() function instead of CLI subprocess. @@ -501,12 +535,7 @@ async function runClaudeConsultation( const savedClaudeCode = process.env.CLAUDECODE; delete process.env.CLAUDECODE; - const env: Record = {}; - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { - env[key] = value; - } - } + const env = buildClaudeConsultEnv(process.env); try { const session = claudeQuery({