diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 1e8885ae11..4bf1d02703 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -1137,9 +1137,20 @@ async function resolveCodexToken( let errDesc: string; let errCodeExact: string | undefined; try { - const parsed = JSON.parse(errText) as { error?: string; error_description?: string }; - errCodeExact = typeof parsed.error === "string" ? parsed.error.trim() : undefined; - errDesc = [parsed.error, parsed.error_description].filter(Boolean).join(": ") || `HTTP ${res.status}`; + const parsed = JSON.parse(errText) as { + error?: string | { code?: string; message?: string }; + error_description?: string; + }; + if (typeof parsed.error === "string") { + errCodeExact = parsed.error.trim(); + errDesc = [parsed.error, parsed.error_description].filter(Boolean).join(": "); + } else if (parsed.error && typeof parsed.error === "object") { + errCodeExact = typeof parsed.error.code === "string" ? parsed.error.code.trim() : undefined; + errDesc = [parsed.error.code, parsed.error.message, parsed.error_description].filter(Boolean).join(": "); + } else { + errDesc = parsed.error_description || `HTTP ${res.status}`; + } + if (!errDesc) errDesc = `HTTP ${res.status}`; } catch { errDesc = `HTTP ${res.status}`; } // `invalid_grant` is the standard OAuth code for a refresh token that is no longer // usable, and upstream sends it bare with no description. Without it here the dead @@ -1150,8 +1161,10 @@ async function resolveCodexToken( // `server_error` whose description happens to mention invalid_grant would otherwise // retire a healthy account, which is the failure this whole change exists to remove. const reason = errCodeExact === "invalid_grant" + || errCodeExact === "refresh_token_invalidated" || errDesc.includes("invalidated") || errDesc.includes("revoked") ? "revoked" as const - : errDesc.includes("expired") ? "expired" as const + : errCodeExact === "refresh_token_expired" + || errDesc.includes("expired") ? "expired" as const : "unknown" as const; throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`); } diff --git a/tests/codex-integration/codex-account-store.test.ts b/tests/codex-integration/codex-account-store.test.ts index e44ce355e0..6539349337 100644 --- a/tests/codex-integration/codex-account-store.test.ts +++ b/tests/codex-integration/codex-account-store.test.ts @@ -1155,6 +1155,82 @@ describe("codex-account-store CRUD", () => { } }); + test("nested error object with refresh_token_invalidated classifies as revoked", async () => { + const { forceRefreshCodexPoolToken, readCodexAccountRecord, saveCodexAccountCredential, TokenRefreshError } = + await import("../../src/codex/account-store"); + saveCodexAccountCredential("invalidated-grant", { + accessToken: "rejected", + refreshToken: "grant", + expiresAt: Date.now() + 3600_000, + chatgptAccountId: "acc", + }); + const generation = readCodexAccountRecord("invalidated-grant")!.generation; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + Response.json( + { + error: { + message: "Your session has ended. Please log in again.", + type: "invalid_request_error", + param: null, + code: "refresh_token_invalidated", + }, + }, + { status: 401 }, + )) as typeof fetch; + + try { + await forceRefreshCodexPoolToken("invalidated-grant", { + rejectedGeneration: generation, + rejectedAccessToken: "rejected", + }); + throw new Error("expected a TokenRefreshError"); + } catch (error) { + expect(error).toBeInstanceOf(TokenRefreshError); + expect((error as InstanceType).reason).toBe("revoked"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("nested error object with refresh_token_expired classifies as expired", async () => { + const { forceRefreshCodexPoolToken, readCodexAccountRecord, saveCodexAccountCredential, TokenRefreshError } = + await import("../../src/codex/account-store"); + saveCodexAccountCredential("expired-grant", { + accessToken: "rejected", + refreshToken: "grant", + expiresAt: Date.now() + 3600_000, + chatgptAccountId: "acc", + }); + const generation = readCodexAccountRecord("expired-grant")!.generation; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + Response.json( + { + error: { + message: "The refresh token has expired.", + type: "invalid_request_error", + param: null, + code: "refresh_token_expired", + }, + }, + { status: 401 }, + )) as typeof fetch; + + try { + await forceRefreshCodexPoolToken("expired-grant", { + rejectedGeneration: generation, + rejectedAccessToken: "rejected", + }); + throw new Error("expected a TokenRefreshError"); + } catch (error) { + expect(error).toBeInstanceOf(TokenRefreshError); + expect((error as InstanceType).reason).toBe("expired"); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("a replacement landing mid-refresh is not reported as this call's own lineage (#2887 review)", async () => { // `selfRefreshed` is what gates the affinity handoff. An external replacement must not // set it: that credential may be a different upstream identity, so inheriting the