mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user