Repository navigation
Description
Client or integration
Codex App via OpenCodex; account status/reconnect in the OpenCodex dashboard.
Area
Authentication and account pool
Summary
On Windows, my selected Anthropic OAuth account repeatedly needs manual reauthentication the next morning. Another Anthropic account and other providers do not show the same recurring symptom.
A local reproduction on OpenCodex 2.100.2 / packaged Bun 1.4.4 confirms one failure path: an actual initial connection refusal to a closed loopback HTTP proxy leaves a durable refresh intent. A subsequent attempt, with a successful fake token response available, marks the account needsReauth and throws OAuthLoginRequiredError without making another token fetch.
Expected: a demonstrably unsent initial connection attempt should remain retryable when the proxy becomes available. This is a related case to #6570, which was closed after #6586 fixed direct token-host DNS failure; the installed release still only classifies the narrow direct-DNS case as requestNotSent.
Version
@bitkyc08/opencodex2.100.2- Running package's bundled Bun: 1.4.4 (measured with
--version) - Release source: v2.100.2 / 61147ec6
Operating system
Windows 11 Pro, x64, OS version 10.0.26300.
Provider and model
Anthropic / Claude subscription OAuth, credential source oauth, two accounts in the pool. No model inference is needed for the reproduction.
Observed startup timeline and attribution limits
On 2026-11-12, local time UTC+08:00:
| Time | Observation |
|---|---|
| 08:24:11 | OpenCodex service process started after sign-in. |
| 08:24:18 | Service logged outbound proxy http://127.0.0.1:7892/. |
| 08:24:19 | A refresh-intent file for the selected Anthropic account was created. |
| 08:24 onward | Other provider discovery failed; Google Antigravity refresh attempts repeated. |
| 08:55:50 / 08:55:54 | FlClash / FlClashCore processes started. |
| 08:56:09 | Google Antigravity logged credentials rotated and persisted. |
Read-only inspection later found a leftover Anthropic intent for an older credential generation; the current credentials were usable and not marked needsReauth at inspection time.
The live logs do not retain the exact error for that Anthropic refresh. Startup ordering strongly suggests an unavailable local proxy, but does not conclusively establish the cause of every historical reconnect prompt. The independent reproduction below establishes the connection-refusal state transition, rather than proving what happened to the real grant. I have not tested whether both real accounts would behave identically under an induced outage.
Reproduction
Save the script below and run:
<packaged-bun> opencodex-anthropic-proxy-repro.mjs <installed-opencodex-package-directory>It uses the installed production refresh/store functions, fake credentials, and an isolated temporary OPENCODEX_HOME. For the proxy-refusal case, the fetch wrapper uses real Bun fetch through a closed loopback proxy, with a loopback destination as well. Other token responses are stubbed. It never calls Anthropic or reads/replays real account tokens.
Observed results across two attempts per scenario:
| First result | Token fetch calls | Second result | needsReauth | Pending intent | Recovered |
|---|---|---|---|---|---|
Actual proxy ECONNREFUSED |
1 | OAuthLoginRequiredError | true | true | false |
| Explicit HTTP 503 | 2 | Success | false | false | true |
| Ambiguous timeout (control) | 1 | OAuthLoginRequiredError | true | true | false |
Both controls passed. The final recovery assertion fails as intended, exit 1:
BUG: proven initial proxy connection refusal should allow recovery.
The successful/recovered endpoint is simulated; the connection refusal itself is produced by the packaged Bun transport. This is not an end-to-end test of a running FlClash proxy or the real Anthropic token endpoint.
Runnable isolated reproduction
// Run: <packaged-bun> opencodex-anthropic-proxy-repro.mjs <installed-package-dir>
// Fake credentials, private temporary store, and loopback-only network traffic.
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve, dirname } from 'node:path';
import { pathToFileURL } from 'node:url';
import assert from 'node:assert/strict';
const root = resolve(process.argv[2]);
const scratch = mkdtempSync(join(tmpdir(), 'ocx-proxy-refresh-repro-'));
process.env.OPENCODEX_HOME = scratch;
const listener = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('unused') });
const deadProxy = `http://127.0.0.1:${listener.port}`;
await listener.stop(true);
process.env.HTTP_PROXY = deadProxy;
process.env.HTTPS_PROXY = deadProxy;
process.env.NO_PROXY = '';
process.env.no_proxy = '';
const scenarios = ['proxy-refused', 'http503', 'ambiguous-timeout'];
const credential = { access: 'fake-access', refresh: 'fake-refresh', expires: Date.now() - 60_000, source: 'oauth' };
writeFileSync(join(scratch, 'auth.json'), JSON.stringify({ anthropic: {
activeAccountId: scenarios[0], accounts: scenarios.map(id => ({ id, credential })),
} }));
const realFetch = globalThis.fetch;
try {
const { refreshAnthropicAccountWithLock } = await import(pathToFileURL(join(root, 'src/oauth/index.ts')).href);
const { refreshAnthropicToken } = await import(pathToFileURL(join(root, 'src/oauth/anthropic.ts')).href);
const { getAccountCredentialWithStatus, peekOAuthRefreshIntent } = await import(pathToFileURL(join(root, 'src/oauth/store.ts')).href);
const results = [];
for (const scenario of scenarios) {
let tokenFetchCalls = 0;
globalThis.fetch = async (url, init) => {
if (String(url) === 'https://api.anthropic.com/api/oauth/profile') return new Response('{}', { status: 503 });
assert.equal(String(url), 'https://api.anthropic.com/v1/oauth/token');
tokenFetchCalls++;
if (tokenFetchCalls === 1) {
if (scenario === 'proxy-refused') {
// Exercise actual packaged Bun transport against a closed loopback HTTP proxy.
// The destination is also loopback, so even unexpected bypass cannot reach Anthropic.
return realFetch('http://127.0.0.1:1/token', { ...init, proxy: deadProxy });
}
if (scenario === 'http503') return new Response('{"error":"temporarily_unavailable"}', { status: 503 });
throw new DOMException('Simulated ambiguous timeout', 'TimeoutError');
}
return Response.json({ access_token: 'fake-new-access', refresh_token: 'fake-new-refresh', expires_in: 3600 });
};
const errors = [];
for (let attempt = 0; attempt < 2; attempt++) {
try {
await refreshAnthropicAccountWithLock('anthropic', scenario, { refresh: refreshAnthropicToken }, credential);
errors.push(null);
} catch (error) {
errors.push({ name: error.name, code: error.code ?? null });
}
}
const row = getAccountCredentialWithStatus('anthropic', scenario);
results.push({ scenario, tokenFetchCalls, errors, needsReauth: row.needsReauth === true,
pendingIntent: !!peekOAuthRefreshIntent('anthropic', scenario), recovered: row.credential.access === 'fake-new-access' });
}
console.log(JSON.stringify({ packageVersion: JSON.parse(await Bun.file(join(root, 'package.json')).text()).version,
bunVersion: Bun.version, results }, null, 2));
assert.equal(results[1].recovered, true, 'HTTP 503 control should recover');
assert.equal(results[2].pendingIntent, true, 'Ambiguous timeout must retain its replay guard');
assert.equal(results[0].errors[0]?.code, 'ECONNREFUSED', 'First failure must be an actual connection refusal');
assert.equal(results[0].recovered, true, 'BUG: proven initial proxy connection refusal should allow recovery');
} finally {
globalThis.fetch = realFetch;
assert.equal(dirname(scratch), resolve(tmpdir()));
assert.ok(scratch.startsWith(join(tmpdir(), 'ocx-proxy-refresh-repro-')));
rmSync(scratch, { recursive: true, force: true });
}Relevant code
- anthropic.ts:65–89: only structured direct token-host
ENOTFOUNDis converted toAnthropicTokenError(..., requestNotSent=true); a proxy connection refusal propagates as a raw error. - index.ts:908–910: an outstanding same-generation intent marks the account as needing reauthentication before retrying the transport.
- index.ts:926–966: dispatch is considered possible before calling the adapter; the nonterminal cleanup depends on
requestNotSentor an explicit HTTP rejection.
Redacted configuration
{
"proxy": "http://127.0.0.1:7892",
"anthropicAccountPool": {
"enabled": true,
"nativeMessages": true,
"strategy": "quota",
"stickyLimit": 1,
"autoSwitchThreshold": 99,
"quotaWindow": "five-hour"
}
}Expected handling
Please consider a narrow, transport-proven classification for an initial failure to connect to the configured local proxy, or avoiding the refresh dispatch until that proxy is reachable. Keep the replay guard for genuinely ambiguous outcomes, including post-dispatch disconnects/timeouts. A generic ECONNREFUSED string or all network failures should not automatically authorize token replay.
A sanitized refresh failure category in diagnostics would also help distinguish an unknown refresh outcome from a rejected grant. No live credential, replay guard, startup task, or proxy setting was changed while preparing this report.
Checks
- Searched existing issues, including [Bug]: Anthropic OAuth DNS failure leaves a durable intent and permanently requires re-login #6570 and its fix fix(oauth): release Anthropic refresh intent after a proven-unsent DNS failure #6586.
- Removed real credentials, account identifiers, email addresses, private user paths, and original credential files.
Activity
fix(oauth): keep an Anthropic refresh retryable when its first connec…