diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 475214d644..b9ab5a0630 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -83,7 +83,7 @@ export { updateAccountQuota, } from "./quota"; import { extractAccountId } from "../oauth/chatgpt"; -import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; +import { getMainAccountPlan, isMainAccountTokenLive, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; import { captureConfigGeneration, registerStateSweepAfterTick } from "../lib/state-store-sweeper"; import { reconcileLiveStateStores } from "../lib/state-store-registrations"; import { @@ -566,12 +566,29 @@ const MAIN_TERMINAL_AUTH_CODES = new Set([ "invalid_refresh_token", ]); -async function isTerminalMainAuthResponse(resp: Response): Promise { - if (resp.status === 401) return true; +/** + * A WHAM 401 is not itself proof the local credential died. Upstream edges can + * transiently reject a still-valid access token (region/anti-abuse/rotation + * races), and fail-closing on every bare 401 makes a healthy main account flip + * needs-reauth on the next GUI quota poll. Only treat the response as terminal + * when the body carries a known terminal code or the local access-token JWT has + * actually expired. + */ +async function isTerminalMainAuthResponse(resp: Response, accessTokenLive: boolean): Promise { + if (resp.status === 401) { + if (!accessTokenLive) return true; + const code = await readMainAuthErrorCode(resp); + return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code); + } if (resp.status !== 403) return false; + const code = await readMainAuthErrorCode(resp); + return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code); +} + +async function readMainAuthErrorCode(resp: Response): Promise { try { const body = await readBoundedResponseBody(resp, { totalTimeoutMs: 1_000, inactivityTimeoutMs: 1_000 }); - if (!body.displaySafe) return false; + if (!body.displaySafe) return undefined; const parsed = JSON.parse(body.text) as { detail?: { code?: unknown } | string; error?: { code?: unknown } | string; @@ -582,9 +599,9 @@ async function isTerminalMainAuthResponse(resp: Response): Promise { : typeof parsed.error === "object" && parsed.error !== null ? parsed.error.code : parsed.code; - return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code); + return code; } catch { - return false; + return undefined; } } @@ -704,7 +721,7 @@ async function fetchMainAccountInfoWhileOwned( signal: AbortSignal.timeout(8000), }); if (!resp.ok) { - const terminalAuthFailure = await isTerminalMainAuthResponse(resp); + const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenLive()); const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease); if (retried) return retried; if (terminalAuthFailure) { diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 9f035ebc10..7d5145edff 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -80,6 +80,11 @@ let previousCodexHome: string | undefined; let previousManualImportEnv: string | undefined; let previousFetch: typeof fetch; +function jwtWithExp(exp: number): string { + const enc = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + return enc({ alg: "RS256", typ: "JWT" }) + "." + enc({ exp }) + ".sig"; +} + function makeConfig(overrides: Partial = {}): OcxConfig { return { port: 10100, @@ -742,9 +747,9 @@ describe("codex-auth API", () => { expect(main?.needsReauth).toBe(true); }); - test("main account 401 marks needsReauth and exposes it in the DTO (#327)", async () => { + test("bare main account 401 with a live token is transient, not reauth", async () => { writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "expired-main", account_id: "acct-main" }, + tokens: { access_token: "live-main", account_id: "acct-main" }, })); globalThis.fetch = (async () => new Response("", { status: 401 })) as typeof fetch; @@ -753,8 +758,23 @@ describe("codex-auth API", () => { const data = await resp!.json() as { accounts: Array<{ id: string; hasCredential: boolean; needsReauth?: boolean }> }; const main = data.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID); - expect(main).toMatchObject({ hasCredential: true, needsReauth: true }); + expect(main).toMatchObject({ hasCredential: true, needsReauth: false }); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + }); + + test("main account 401 with an expired access token is terminal", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: jwtWithExp(1), account_id: "acct-main" }, + })); + globalThis.fetch = (async () => new Response("", { status: 401 })) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/accounts?refresh=1"); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + const data = await resp!.json() as { accounts: Array<{ id: string; needsReauth?: boolean }> }; + + expect(data.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)?.needsReauth).toBe(true); expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); }); test("main account invalid-workspace 403 is terminal but a generic 403 is not (#327)", async () => { @@ -803,7 +823,7 @@ describe("codex-auth API", () => { expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); }); - test("BUG-R327: main account exposes and updates needsReauth from WHAM auth responses", async () => { + test("BUG-R327: main account exposes and updates needsReauth from terminal WHAM auth responses", async () => { writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: "main-access", @@ -828,9 +848,10 @@ describe("codex-auth API", () => { return data.accounts.find(account => account.id === "__main__")!; }; - expect((await listMain()).needsReauth).toBe(true); + // A bare 401 with a still-live token is transient: it must not mark reauth. + expect((await listMain()).needsReauth).toBe(false); status = 403; - expect((await listMain()).needsReauth).toBe(true); + expect((await listMain()).needsReauth).toBe(false); status = 200; expect((await listMain()).needsReauth).toBe(false); });