Skip to content

[Bug][Windows] Anthropic OAuth refresh requires re-login after local proxy connection refusal at startup #8577

Closed

Description

@2378855025

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/opencodex 2.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 ENOTFOUND is converted to AnthropicTokenError(..., 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 requestNotSent or 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't workingplatformOS/service/tray/ACL (Windows-heavy, not Windows-only)

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions