diff --git a/lib/providers/anthropic.mjs b/lib/providers/anthropic.mjs index d46de06..8ef45a7 100644 --- a/lib/providers/anthropic.mjs +++ b/lib/providers/anthropic.mjs @@ -864,9 +864,37 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) { const bin = resolveClaudeBin(); const env = buildSpawnEnv(); - // Inject OAuth token so the CLI can authenticate. - // We set it explicitly here so the plugin is self-contained. - env.CLAUDE_CODE_OAUTH_TOKEN = auth.accessToken; + // OAuth credential strategy: + // + // Previously this plugin set env.CLAUDE_CODE_OAUTH_TOKEN to the accessToken + // value read from ~/.claude/.credentials.json (or keychain). That worked at + // call time but defeated the CLI's built-in auto-refresh: when env var is + // present, claude reads it directly and never touches credentials.json, so + // expired access tokens were never swapped using the refreshToken sitting + // right there in the file. The result was a hard 401 cascade every few + // hours (access token TTL ≈ 8h) requiring manual keychain → PI231 copy. + // + // 2026-05-28 spike: with credentials.json present and CLAUDE_CODE_OAUTH_TOKEN + // unset, the CLI detects expired accessToken, calls the OAuth token endpoint + // with refreshToken, persists the new accessToken/expiresAt back to + // credentials.json, and proceeds. Tested on PI231 v2.1.104 with simulated + // expiry; the file's expiresAt advanced from "1 min ago" to "8 hours from + // now" in a single spawn cycle. + // + // So: DO NOT inject env.CLAUDE_CODE_OAUTH_TOKEN here. buildSpawnEnv already + // forwards process.env (minus ANTHROPIC_*), so if the operator explicitly + // set CLAUDE_CODE_OAUTH_TOKEN at OLP boot time, it's still honored — that + // path is for users who can't rely on file/keychain (e.g. ephemeral CI). + // + // The readAuthArtifact() call above is now a preflight check only — verify + // creds exist before spawn — but the value is NOT forwarded to the CLI. + // + // Authority: + // - claude CLI v2.1.104 reads ~/.claude/.credentials.json + refreshes via + // refreshToken (empirically verified PI231 2026-05-28; no public doc cite) + // - OCP server.mjs:864-888 was the historical source of the env-injection + // pattern, kept here for OAuth artifact discovery but no longer for env + // forwarding // ADR 0009 Amendment 1: system prompt extracted from IR messages, // prepended with OLP_SYSTEM_PROMPT_WRAPPER, passed via --system-prompt. diff --git a/test-features.mjs b/test-features.mjs index d805d2e..a377fe2 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -17703,4 +17703,71 @@ describe('Suite 41 — ADR 0009 Amendment 1: stream-json transport (Phase 6)', ( // model value must follow immediately after --model assert.equal(args[modelIdx + 1], 'claude-opus-4-7', '--model value must follow the flag'); }); + + // ── 41h: OAuth env-injection regression guard (2026-05-28) ─────────── + // Previously _spawnAndStream injected env.CLAUDE_CODE_OAUTH_TOKEN from + // readAuthArtifact() unconditionally. That defeated the CLI's built-in + // refresh: with env var present, claude never reads credentials.json, + // so expired accessTokens are never swapped via refreshToken. Result: + // hard 401 cascade every ~8h requiring manual keychain copy. + // Fix: don't inject. Spawned process inherits process.env via + // buildSpawnEnv; the CLI handles file + refresh natively. + + it('41h: _spawnAndStream does NOT set env.CLAUDE_CODE_OAUTH_TOKEN when only file/keychain auth available', async () => { + // Capture the env passed to spawn + let capturedEnv = null; + const fakeSpawn = (_bin, _args, opts) => { + capturedEnv = opts.env; + // Minimal valid NDJSON: result/success → stop + return makeMockSpawnNDJSON([], { skipEarlySpawn: false })(_bin, _args, opts); + }; + // Ensure the process.env CLAUDE_CODE_OAUTH_TOKEN is unset for this test + const sentinel = process.env.CLAUDE_CODE_OAUTH_TOKEN; + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + __setSpawnImpl(fakeSpawn); + try { + const ir = makeIR({ + model: 'claude-sonnet-4-6', + messages: [{ role: 'user', content: 'hi' }], + }); + // Provide auth context directly so readAuthArtifact path doesn't matter + const it = anthropic.spawn(ir, { accessToken: 'sentinel-should-not-leak-to-env' }); + try { for await (const _ of it) { /* drain */ } } catch { /* ignored */ } + assert.ok(capturedEnv, 'spawn must have been called'); + assert.equal( + capturedEnv.CLAUDE_CODE_OAUTH_TOKEN, undefined, + 'env.CLAUDE_CODE_OAUTH_TOKEN must NOT be injected — CLI reads credentials.json natively and auto-refreshes', + ); + } finally { + __resetSpawnImpl(); + if (sentinel !== undefined) process.env.CLAUDE_CODE_OAUTH_TOKEN = sentinel; + } + }); + + it('41h-2: _spawnAndStream forwards process.env.CLAUDE_CODE_OAUTH_TOKEN when explicitly set at OLP boot', async () => { + let capturedEnv = null; + const fakeSpawn = (_bin, _args, opts) => { + capturedEnv = opts.env; + return makeMockSpawnNDJSON([], { skipEarlySpawn: false })(_bin, _args, opts); + }; + const sentinel = process.env.CLAUDE_CODE_OAUTH_TOKEN; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'operator-explicit-token'; + __setSpawnImpl(fakeSpawn); + try { + const ir = makeIR({ + model: 'claude-sonnet-4-6', + messages: [{ role: 'user', content: 'hi' }], + }); + const it = anthropic.spawn(ir, { accessToken: 'from-file' }); + try { for await (const _ of it) { } } catch { } + assert.equal( + capturedEnv.CLAUDE_CODE_OAUTH_TOKEN, 'operator-explicit-token', + 'process.env override must propagate through buildSpawnEnv unchanged', + ); + } finally { + __resetSpawnImpl(); + if (sentinel === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + else process.env.CLAUDE_CODE_OAUTH_TOKEN = sentinel; + } + }); });