diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 3f40362c515..82e285e8661 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -225,6 +225,10 @@ them anyway: it routes to the account-pool login, so `ocx login codex --reauth` `ocx account reauth codex`. The dashboard Codex account pool (Reauthenticate) does it too. That route runs inside the proxy, so it needs a running one. +For the ChatGPT OAuth provider, an expired or revoked refresh grant requires signing in +again. Temporary token-service failures, timeouts and cancellation leave the account +eligible for a later refresh. + ```bash ocx login xai ocx login anthropic diff --git a/src/oauth/chatgpt.ts b/src/oauth/chatgpt.ts index 9f1ea858ff2..c07dc6dcb0c 100644 --- a/src/oauth/chatgpt.ts +++ b/src/oauth/chatgpt.ts @@ -1,6 +1,50 @@ import { OAuthCallbackFlow } from "./callback-server"; import type { OAuthController, OAuthCredentials } from "./types"; import { generatePKCE } from "./pkce"; +import { classifyChatgptRefreshFailure } from "../codex/chatgpt-refresh-failure"; + +/** + * Per-fetch deadline for ChatGPT auth endpoint calls. The deviceauth grant got the + * same bound in #3898 after one stuck TCP connection held the login slot for the + * whole grant; a hung refresh would pin the refresh intent lock indefinitely. + */ +export const CHATGPT_FETCH_TIMEOUT_MS = 30_000; + +function chatGptFetchSignal(signal?: AbortSignal, timeoutMs = CHATGPT_FETCH_TIMEOUT_MS): AbortSignal { + const timeout = AbortSignal.timeout(timeoutMs); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +/** + * Token-endpoint failure carrying the HTTP status, the allowlisted OAuth error code, + * and a terminal verdict (revoked/expired grant) taken from the same classifier the + * Codex pool's refresh path uses — a dead grant must mark needsReauth, not retry. + */ +export class ChatGptTokenError extends Error { + constructor( + public readonly httpStatus: number, + public readonly oauthError: string | undefined, + public readonly terminal: boolean, + message: string, + ) { + super(message); + this.name = "ChatGptTokenError"; + } +} + +async function chatGptTokenError(response: Response, label: string): Promise { + const body = await response.text().catch(() => ""); + const failure = classifyChatgptRefreshFailure(response.status, body); + // The message carries the status and the allowlisted OAuth code only — never the + // free-text `error_description`, which can echo OAuth material into log surfaces + // (the same closed vocabulary the pool's noteChatgptRefreshFailure logs with). + return new ChatGptTokenError( + response.status, + failure.code, + failure.reason !== "unknown", + `${label} failed: ${response.status} code=${failure.code ?? "none"}`, + ); +} const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const AUTH_URL = "https://auth.openai.com/oauth/authorize"; @@ -212,24 +256,13 @@ export class ChatGPTOAuthFlow extends OAuthCallbackFlow { redirect_uri: redirectUri, code_verifier: this.#verifier, }).toString(), + signal: chatGptFetchSignal(this.ctrl.signal), }); - if (!resp.ok) { - const errDesc = await safeErrorDescription(resp); - throw new Error(`ChatGPT token exchange failed: ${resp.status} ${errDesc}`); - } + if (!resp.ok) throw await chatGptTokenError(resp, "ChatGPT token exchange"); return credsFromToken((await resp.json()) as Record); } } -function safeErrorDescription(resp: Response): Promise { - return resp.text().catch(() => "").then(text => { - try { - const parsed = JSON.parse(text) as { error?: string; error_description?: string }; - return [parsed.error, parsed.error_description].filter(Boolean).join(": ") || `HTTP ${resp.status}`; - } catch { return `HTTP ${resp.status}`; } - }); -} - /** * How the user proves identity. `browser` runs the localhost:1455 callback flow; * `device` runs the deviceauth grant, which needs no local browser or listener @@ -255,7 +288,7 @@ export async function loginChatGPT( // Codex-rs uses JSON for refresh — intentional divergence; both accepted by auth.openai.com. export async function refreshChatGPTToken( refreshToken: string, - options: { signal?: AbortSignal } = {}, + options: { signal?: AbortSignal; timeoutMs?: number } = {}, ): Promise { const resp = await fetch(TOKEN_URL, { method: "POST", @@ -265,11 +298,8 @@ export async function refreshChatGPTToken( client_id: CLIENT_ID, refresh_token: refreshToken, }).toString(), - signal: options.signal, + signal: chatGptFetchSignal(options.signal, options.timeoutMs), }); - if (!resp.ok) { - const errDesc = await safeErrorDescription(resp); - throw new Error(`ChatGPT refresh failed: ${resp.status} ${errDesc}`); - } + if (!resp.ok) throw await chatGptTokenError(resp, "ChatGPT refresh"); return credsFromToken((await resp.json()) as Record); } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 63e165ad897..5a338c2c864 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -36,7 +36,7 @@ import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenReques import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic"; import { loginKimi, refreshKimiToken } from "./kimi"; import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous"; -import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt"; +import { ChatGptTokenError, loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; import { loginZed, refreshZedToken } from "./zed"; @@ -354,7 +354,7 @@ export const OAUTH_PROVIDERS: Record = { }, chatgpt: { login: (ctrl, opts) => loginChatGPT(ctrl, { forceLogin: opts?.forceLogin, flow: opts?.flow }), - refresh: (rt) => refreshChatGPTToken(rt), + refresh: (rt, signal) => refreshChatGPTToken(rt, { signal }), providerConfig: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, defaultModel: "gpt-5.6-luna", }, @@ -715,6 +715,9 @@ function terminal(error:unknown):boolean{ if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??""); if(error instanceof KiroTokenRefreshError)return (error.httpStatus===400||error.httpStatus===401)&&error.oauthError!==undefined; if(error instanceof NousTokenError)return error.terminal===true||["invalid_grant","refresh_token_reused","revoked","revoked_token","expired_token"].includes(error.oauthError??""); + // ChatGPT carries the pool classifier's verdict on the error itself: revoked and + // expired grants are terminal; unknown/transient failures stay retryable. + if(error instanceof ChatGptTokenError)return error.terminal; // Local durable-write/read/cleanup failures are operational, not credential // death: the provider credential was never rejected or consumed. Never mark // the account needsReauth for broken local persistence infrastructure. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index ed54a375627..9f1b71e9956 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -194,7 +194,7 @@ rewrite rules and the routed-id settlement. | `src/providers/model-rename-fields.ts`, `src/providers/model-rename-migration.ts` | Classifies every provider config field for a declared model rename. Exact-model records, lists and nested request-pacing keys follow the replacement; an already saved replacement entry wins. Provider-wide settings, including response-tier authority and project-context consent, and credential fields are not model identities. | | `src/providers/resolved-model-policy.ts`, `src/providers/resolved-model-policy-merge.ts` | Static provider/model policy resolution for the final upstream wire model, plus its pure clone/merge/URL/family helpers. The resolver detaches and freezes registry defaults, operator overrides, exact explicit input-modality declarations, provider-scoped hard wire pins (including Command Code's `claude-` prefix), aliases, and explicit false/empty values with field-level provenance. Provider derivation, routing, catalog hints, gather admission, and adapter selection consume its detached frozen result. Callers supply transport match, the exact capability row, and a credential-free effective auth decision; credential bytes, usability evidence, account/quota/health state, and observed limits remain outside the result. | -| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Command Code manual callback JSON remains opaque to the shared `code#state` parser and is state-validated by its provider parser. A raw Command Code paste with an explicit `#state` suffix must match the flow state on the direct prompt as well. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. | +| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Command Code manual callback JSON remains opaque to the shared `code#state` parser and is state-validated by its provider parser. A raw Command Code paste with an explicit `#state` suffix must match the flow state on the direct prompt as well. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. ChatGPT token exchange and refresh in `src/oauth/chatgpt.ts` compose caller cancellation with a fresh 30-second fetch deadline. The registration in `src/oauth/index.ts` preserves that signal and uses the shared Codex classifier through `ChatGptTokenError`: terminal grant refusals mark only the matching generation `needsReauth`; transient failures remain retryable. Errors expose only status and allowlisted code. `tests/oauth/chatgpt-token-expiry.test.ts` and `tests/oauth/oauth-refresh-generic-lock.test.ts` cover these boundaries with synthetic responses. | | `src/combos/failover.ts` | Recognizes the exact bounded ChatGPT plan/model HTTP400 refusal in a bare message or a single `detail`/`error.message` envelope. Competing envelopes stop before code-based hops; finite evidence survives display truncation and SSE error reconstruction without retaining raw bodies. This permits another declared target before output commitment, never credential revocation or provider-wide quota evidence. | | `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | | `src/adapters/openai-responses.ts`, `src/adapters/openai-responses/` | Native OpenAI/ChatGPT Responses passthrough. The canonical ChatGPT adapter forces its upstream-only `stream: true` requirement without changing caller `store`; downstream JSON negotiation remains owned by the [Responses HTTP/SSE contract](transports/responses.md#responses-httpsse). | diff --git a/tests/oauth/chatgpt-token-expiry.test.ts b/tests/oauth/chatgpt-token-expiry.test.ts index 9565ebce02d..5686cbeff6a 100644 --- a/tests/oauth/chatgpt-token-expiry.test.ts +++ b/tests/oauth/chatgpt-token-expiry.test.ts @@ -1,8 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { refreshChatGPTToken } from "../../src/oauth/chatgpt"; +import { CHATGPT_FETCH_TIMEOUT_MS, ChatGPTOAuthFlow, ChatGptTokenError, refreshChatGPTToken } from "../../src/oauth/chatgpt"; const originalFetch = globalThis.fetch; -afterEach(() => { globalThis.fetch = originalFetch; }); +const originalTimeout = AbortSignal.timeout; +afterEach(() => { + globalThis.fetch = originalFetch; + AbortSignal.timeout = originalTimeout; +}); const FALLBACK_MS = 3600 * 1000; const TOLERANCE_MS = 30_000; @@ -64,3 +68,174 @@ describe("ChatGPT OAuth token response parsing", () => { expect(Math.abs(cred.expires - (before + FALLBACK_MS))).toBeLessThan(TOLERANCE_MS); }); }); + +async function refreshError(status: number, body: unknown): Promise { + globalThis.fetch = (async () => new Response( + typeof body === "string" ? body : JSON.stringify(body), { status }, + )) as typeof fetch; + const error = await refreshChatGPTToken("synthetic-refresh").catch((e: unknown) => e); + expect(error).toBeInstanceOf(ChatGptTokenError); + return error as ChatGptTokenError; +} + +// No real fetch: reject when the supplied signal fires, including pre-aborted signals. +function stallFetch(): void { + globalThis.fetch = ((_url: unknown, init?: RequestInit) => + new Promise((_, reject) => { + const signal = init?.signal; + if (signal?.aborted) reject(signal.reason); + else signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + })) as typeof fetch; +} + +async function browserFlow(signal?: AbortSignal): Promise { + const flow = new ChatGPTOAuthFlow({ signal }); + // Initialize PKCE locally; never invoke login(), a browser, or the callback listener. + await flow.generateAuthUrl("synthetic-state", "http://localhost:1455/auth/callback"); + return flow; +} + +describe("ChatGPT OAuth refresh failure classification", () => { + const deadCodes = ["invalid_grant", "refresh_token_reused", "refresh_token_invalidated", + "token_invalidated", "refresh_token_expired"]; + + for (const status of [400, 401, 403]) { + for (const code of deadCodes) { + test(`${status} ${code} is terminal in flat and nested responses`, async () => { + for (const error of [code, { code, message: "synthetic private description" }]) { + const err = await refreshError(status, { error }); + expect(err.httpStatus).toBe(status); + expect(err.oauthError).toBe(code); + expect(err.terminal).toBe(true); + } + }); + } + } + + test("availability failures stay retryable even with a named dead grant or revoked prose", async () => { + for (const status of [429, 500, 502, 503]) { + for (const code of [...deadCodes, "server_error", "temporarily_unavailable"]) { + const err = await refreshError(status, { error: code, error_description: "session revoked or expired" }); + expect(err.terminal).toBe(false); + expect(err.oauthError).toBe(code); + } + } + }); + + test("named transient or unknown codes override misleading descriptions", async () => { + for (const code of ["temporarily_unavailable", "server_error", "invalid_client", "synthetic-unknown"]) { + const err = await refreshError(400, { error: code, error_description: "session revoked or expired" }); + expect(err.terminal).toBe(false); + expect(err.oauthError).toBe(code === "synthetic-unknown" ? undefined : code); + } + }); + + test("preserves the shared classifier's description-only OAuth 400 compatibility", async () => { + expect((await refreshError(400, { error_description: "refresh token expired" })).terminal).toBe(true); + expect((await refreshError(401, { error_description: "refresh token expired" })).terminal).toBe(false); + }); + + test("malformed and oversized classifier input remains retryable", async () => { + for (const body of ["upstream exploded", "null", "[]", { error: { code: 42 } }, + { error: "invalid_grant", padding: "x".repeat(16_384) }]) { + const err = await refreshError(400, body); + expect(err.terminal).toBe(false); + expect(err.oauthError).toBeUndefined(); + expect(err.message).toBe("ChatGPT refresh failed: 400 code=none"); + } + }); + + test("only status and allowlisted code escape in messages, properties, and stacks", async () => { + const privateText = "synthetic-oauth-material-must-stay-private"; + for (const error of ["invalid_grant", privateText, + { code: "refresh_token_invalidated", message: privateText }, { code: privateText, message: privateText }]) { + const err = await refreshError(401, { error, error_description: privateText }); + expect(err.message).toBe(`ChatGPT refresh failed: 401 code=${err.oauthError ?? "none"}`); + expect(JSON.stringify(err)).not.toContain(privateText); + expect(String(err)).not.toContain(privateText); + expect(err.stack).not.toContain(privateText); + } + }); +}); + +describe("ChatGPT token fetch cancellation and deadlines", () => { + test("refresh keeps the form grant and composes the caller with a fresh 30s deadline", async () => { + const observed: AbortSignal[] = []; + const deadlines: number[] = []; + AbortSignal.timeout = (ms) => { deadlines.push(ms); return originalTimeout(ms); }; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + expect(String(url)).toBe("https://auth.openai.com/oauth/token"); + expect(init?.method).toBe("POST"); + expect(init?.headers).toEqual({ "Content-Type": "application/x-www-form-urlencoded" }); + const body = new URLSearchParams(String(init?.body)); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("synthetic-refresh"); + observed.push(init!.signal!); + return Response.json({ access_token: "at", refresh_token: "rt", expires_in: 3600 }); + }) as typeof fetch; + + const caller = new AbortController(); + await refreshChatGPTToken("synthetic-refresh", { signal: caller.signal }); + await refreshChatGPTToken("synthetic-refresh", { signal: caller.signal }); + expect(deadlines).toEqual([30_000, 30_000]); + expect(CHATGPT_FETCH_TIMEOUT_MS).toBe(30_000); + expect(observed[0]).not.toBe(observed[1]); + expect(observed.every(signal => !signal.aborted)).toBe(true); + const reason = new DOMException("synthetic cancellation", "AbortError"); + caller.abort(reason); + expect(observed.every(signal => signal.aborted && signal.reason === reason)).toBe(true); + }); + + test("a pre-aborted refresh preserves the caller's reason", async () => { + stallFetch(); + const reason = new DOMException("synthetic cancellation", "AbortError"); + await expect(refreshChatGPTToken("synthetic-refresh", { signal: AbortSignal.abort(reason) })) + .rejects.toBe(reason); + }); + + test("the fetch deadline aborts a stalled refresh with TimeoutError", async () => { + stallFetch(); + const start = Date.now(); + await expect(refreshChatGPTToken("synthetic-refresh", { timeoutMs: 20 })) + .rejects.toHaveProperty("name", "TimeoutError"); + expect(Date.now() - start).toBeLessThan(5_000); + }); + + test("browser exchange preserves login cancellation during a stalled fetch", async () => { + const caller = new AbortController(); + const flow = await browserFlow(caller.signal); + let started!: () => void; + const began = new Promise(resolve => { started = resolve; }); + stallFetch(); + const stalled = globalThis.fetch; + globalThis.fetch = ((...args: Parameters) => { + started(); + return stalled(...args); + }) as typeof fetch; + const pending = flow.exchangeToken("synthetic-code", "synthetic-state", "http://localhost:1455/auth/callback"); + const reason = new DOMException("synthetic cancellation", "AbortError"); + const rejected = pending.catch((error: unknown) => error); + await began; + caller.abort(reason); + expect(await rejected).toBe(reason); + }); + + test("browser exchange uses the 30s deadline and redacts endpoint failures", async () => { + const deadlines: number[] = []; + AbortSignal.timeout = (ms) => { deadlines.push(ms); return originalTimeout(20); }; + const flow = await browserFlow(); + const privateText = "synthetic-code-verifier-must-stay-private"; + globalThis.fetch = (async () => Response.json({ + error: "invalid_grant", error_description: privateText, + }, { status: 400 })) as typeof fetch; + const err = await flow.exchangeToken("synthetic-code", "synthetic-state", "http://localhost:1455/auth/callback") + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(ChatGptTokenError); + expect((err as Error).message).toBe("ChatGPT token exchange failed: 400 code=invalid_grant"); + expect(String(err)).not.toContain(privateText); + stallFetch(); + await expect(flow.exchangeToken("synthetic-code", "synthetic-state", "http://localhost:1455/auth/callback")) + .rejects.toHaveProperty("name", "TimeoutError"); + expect(deadlines).toEqual([30_000, 30_000]); + }); +}); diff --git a/tests/oauth/oauth-refresh-generic-lock.test.ts b/tests/oauth/oauth-refresh-generic-lock.test.ts index 020473358a6..f47c510030f 100644 --- a/tests/oauth/oauth-refresh-generic-lock.test.ts +++ b/tests/oauth/oauth-refresh-generic-lock.test.ts @@ -4,10 +4,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getValidAccessTokenForAccount, + refreshGenericAccountWithLock, OAuthLoginRequiredError, OAUTH_PROVIDERS, } from "../../src/oauth"; import type { OAuthCredentials } from "../../src/oauth/types"; +import { ChatGptTokenError } from "../../src/oauth/chatgpt"; import { getAccountCredential, getAccountSet, saveCredential } from "../../src/oauth/store"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -205,3 +207,80 @@ describe("generic OAuth refresh lock + CAS", () => { expect(getAccountSet("kimi")!.accounts.find(a => a.id === accountId)!.needsReauth).toBeUndefined(); }); }); + +describe("ChatGPT refresh through the registered provider", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + async function seedExpiredChatgpt(): Promise { + await saveCredential("chatgpt", { + access: "chatgpt-old", refresh: "rt-old", expires: Date.now() - 1, accountId: "chatgpt-acct", + }); + return getAccountSet("chatgpt")!.activeAccountId; + } + + test("a nested terminal endpoint response marks the account needsReauth", async () => { + const accountId = await seedExpiredChatgpt(); + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return Response.json({ error: { code: "refresh_token_expired", message: "synthetic private text" } }, { status: 401 }); + }) as typeof fetch; + await expect(getValidAccessTokenForAccount("chatgpt", accountId)).rejects.toBeInstanceOf(OAuthLoginRequiredError); + expect(getAccountSet("chatgpt")!.accounts.find(a => a.id === accountId)!.needsReauth).toBe(true); + expect(calls).toBe(1); + }); + + test("a transient endpoint failure does not mark needsReauth and a later refresh can recover", async () => { + const accountId = await seedExpiredChatgpt(); + let calls = 0; + globalThis.fetch = (async () => { + calls++; + if (calls === 1) return Response.json({ error: "invalid_grant", error_description: "revoked" }, { status: 503 }); + return Response.json({ access_token: "chatgpt-fresh", refresh_token: "rt-new", expires_in: 3600 }); + }) as typeof fetch; + await expect(getValidAccessTokenForAccount("chatgpt", accountId)).rejects.toBeInstanceOf(ChatGptTokenError); + expect(getAccountSet("chatgpt")!.accounts.find(a => a.id === accountId)!.needsReauth).toBeUndefined(); + await expect(getValidAccessTokenForAccount("chatgpt", accountId)).resolves.toBe("chatgpt-fresh"); + expect(getAccountCredential("chatgpt", accountId)?.refresh).toBe("rt-new"); + expect(calls).toBe(2); + }); + + test("the registry forwards caller cancellation and releases the refresh lock", async () => { + const accountId = await seedExpiredChatgpt(); + const caller = new AbortController(); + let started!: () => void; + const began = new Promise(resolve => { started = resolve; }); + globalThis.fetch = ((_url: unknown, init?: RequestInit) => new Promise((_, reject) => { + started(); + init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + })) as typeof fetch; + const pending = refreshGenericAccountWithLock("chatgpt", accountId, OAUTH_PROVIDERS.chatgpt!, + getAccountCredential("chatgpt", accountId)!, { signal: caller.signal }); + const reason = new DOMException("synthetic cancellation", "AbortError"); + const rejected = pending.catch((error: unknown) => error); + await began; + caller.abort(reason); + expect(await rejected).toBe(reason); + expect(getAccountSet("chatgpt")!.accounts.find(a => a.id === accountId)!.needsReauth).toBeUndefined(); + globalThis.fetch = (async () => Response.json({ access_token: "recovered", refresh_token: "rt-new", expires_in: 3600 })) as typeof fetch; + await expect(getValidAccessTokenForAccount("chatgpt", accountId)).resolves.toBe("recovered"); + }); + + test("a late terminal response cannot mark a replacement credential needsReauth", async () => { + const accountId = await seedExpiredChatgpt(); + let release!: (response: Response) => void; + let started!: () => void; + const began = new Promise(resolve => { started = resolve; }); + globalThis.fetch = (() => new Promise(resolve => { release = resolve; started(); })) as typeof fetch; + const pending = getValidAccessTokenForAccount("chatgpt", accountId); + await began; + await saveCredential("chatgpt", { + access: "replacement", refresh: "replacement-rt", expires: Date.now() + 3_600_000, accountId: "chatgpt-acct", + }); + release(Response.json({ error: "refresh_token_invalidated" }, { status: 401 })); + await expect(pending).rejects.toBeInstanceOf(OAuthLoginRequiredError); + expect(getAccountCredential("chatgpt", accountId)?.refresh).toBe("replacement-rt"); + expect(getAccountSet("chatgpt")!.accounts.find(a => a.id === accountId)!.needsReauth).toBeUndefined(); + }); +});