fix(anthropic): stop injecting env.CLAUDE_CODE_OAUTH_TOKEN — let CLI auto-refresh

OLP was injecting accessToken from ~/.claude/.credentials.json into the
spawn env as CLAUDE_CODE_OAUTH_TOKEN unconditionally. This defeated
claude CLI's built-in OAuth refresh: when the env var is present, the
CLI reads it directly and never touches credentials.json, so expired
accessTokens were never swapped using the refreshToken sitting right
there in the file. Result: hard 401 cascade every ~8h (access token
TTL) requiring manual `security find-generic-password → scp → PI231`
cycle.

Empirical 2026-05-28 spike on PI231 v2.1.104:
- expiresAt = 1 min ago (simulated expiry)
- spawn claude -p WITHOUT CLAUDE_CODE_OAUTH_TOKEN env
- response succeeded, AND credentials.json.expiresAt advanced to
  ~8h in the future
- token refresh handled internally by claude CLI

Fix: remove the env injection in _spawnAndStream. buildSpawnEnv still
forwards process.env (minus ANTHROPIC_*), so if the operator explicitly
sets CLAUDE_CODE_OAUTH_TOKEN at OLP boot, it's still honored — for
users on ephemeral CI / no-file-creds setups. The readAuthArtifact()
call remains as a preflight check (verify creds exist before spawn)
but the read value is no longer forwarded.

Tests: 793 → 795
- 41h: regression guard — no CLAUDE_CODE_OAUTH_TOKEN in env when only
  file/keychain auth is available
- 41h-2: operator-set process.env passthrough still works

User-facing impact: bot-down-every-8-hours issue resolved. Hermes +
OpenClaw clients no longer require periodic keychain → PI231 copy.

Authority:
- empirical PI231 v2.1.104 spike 2026-05-28 (no public CLI doc cites
  refresh behavior; verified by simulating expiry + observing file
  update post-spawn)
- ADR 0009 Amendment 1 § "Caveats" — implementation must remain robust
  to OAuth flow changes (this fix removes a hard-coded assumption that
  was actively harmful)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 16:52:43 +10:00
co-authored by Claude Opus 4.7
parent dd0c821272
commit dbac5f5521
2 changed files with 98 additions and 3 deletions
+67
View File
@@ -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;
}
});
});