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..88f997b2e87 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,14 @@ 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 explicit terminal OAuth code confirming an expired +or revoked refresh grant requires signing in again. Free-text descriptions alone never +require reauthentication. Temporary token-service failures, timeouts and cancellation leave the account +eligible for a later refresh. Browser token exchange and refresh each have a 30-second +per-fetch deadline covering headers and body. HTTP-derived errors show only the status +and an allowlisted OAuth code. Cancellation, timeout and transport failures use fixed +messages without the original reason or cause. + ```bash ocx login xai ocx login anthropic diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 107e07ff6ff..bc28473c790 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -329,6 +329,7 @@ "chatgpt-device-auth.test.ts": "oauth", "chatgpt-oauth.test.ts": "oauth", "chatgpt-token-expiry.test.ts": "oauth", + "chatgpt-token-boundaries.test.ts": "oauth", "chutes-provider.test.ts": "providers", "ci-bun-crash-classifier.test.ts": "ci-workflows", "ci-crash-disposition.test.ts": "ci-workflows", diff --git a/src/oauth/chatgpt.ts b/src/oauth/chatgpt.ts index 9f1ea858ff2..2b745f25ae5 100644 --- a/src/oauth/chatgpt.ts +++ b/src/oauth/chatgpt.ts @@ -1,6 +1,106 @@ import { OAuthCallbackFlow } from "./callback-server"; import type { OAuthController, OAuthCredentials } from "./types"; import { generatePKCE } from "./pkce"; +import { classifyChatgptRefreshFailure } from "../codex/chatgpt-refresh-failure"; +import { readBoundedResponseBytes } from "../lib/bounded-body"; + +/** + * 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; + +/** Non-HTTP failures retain no caller reason, upstream error, or cause. */ +export class ChatGptTokenRequestError extends Error { + readonly terminal = false; + + constructor(kind: "abort" | "timeout" | "transport") { + super(kind === "abort" ? "ChatGPT token request cancelled" + : kind === "timeout" ? "ChatGPT token request timed out" : "ChatGPT token request failed"); + this.name = kind === "abort" ? "AbortError" : kind === "timeout" ? "TimeoutError" : "ChatGptTokenRequestError"; + } +} + +const CHATGPT_ERROR_BODY_MAX_BYTES = 16 * 1024; +const CHATGPT_SUCCESS_BODY_MAX_BYTES = 64 * 1024; + +/** One deadline covers headers and body; every settled call detaches its caller. */ +async function chatGptTokenRequest( + body: URLSearchParams, + label: "ChatGPT refresh" | "ChatGPT token exchange", + options: { signal?: AbortSignal; timeoutMs?: number }, +): Promise { + const controller = new AbortController(); + const onAbort = () => controller.abort(new ChatGptTokenRequestError("abort")); + options.signal?.addEventListener("abort", onAbort, { once: true }); + if (options.signal?.aborted) onAbort(); + const timer = setTimeout(() => controller.abort(new ChatGptTokenRequestError("timeout")), + options.timeoutMs ?? CHATGPT_FETCH_TIMEOUT_MS); + let endpointError: ChatGptTokenError | undefined; + try { + controller.signal.throwIfAborted(); + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + signal: controller.signal, + }); + const result = await readBoundedResponseBytes(response, { + signal: controller.signal, + maxBytes: response.ok ? CHATGPT_SUCCESS_BODY_MAX_BYTES : CHATGPT_ERROR_BODY_MAX_BYTES, + }); + controller.signal.throwIfAborted(); + const text = new TextDecoder().decode(result.bytes); + if (!response.ok) { + endpointError = chatGptTokenError(response.status, result.oversized ? "" : text, label); + throw endpointError; + } + if (result.oversized) throw new ChatGptTokenRequestError("transport"); + return credsFromToken(JSON.parse(text) as Record); + } catch (error) { + if (controller.signal.aborted) throw controller.signal.reason; + if (endpointError && error === endpointError) throw endpointError; + throw new ChatGptTokenRequestError("transport"); + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + } +} + +/** + * Token-endpoint failure carrying the HTTP status, the allowlisted OAuth error code, + * and a terminal verdict requiring an explicit grant-refusal code. The shared + * Codex classifier supplies the code vocabulary; free text is never grant evidence. + */ +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"; + } +} + +function chatGptTokenError(status: number, body: string, label: string): ChatGptTokenError { + const failure = classifyChatgptRefreshFailure(status, body); + // Reclassify the allowlisted code alone to reuse the shared terminal vocabulary + // without its description-only compatibility rule for other Codex callers. + const terminal = failure.reason !== "unknown" && failure.code !== undefined + && classifyChatgptRefreshFailure(status, JSON.stringify({ error: failure.code })).reason !== "unknown"; + // 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( + status, + failure.code, + terminal, + `${label} failed: ${status} code=${failure.code ?? "none"}`, + ); +} const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const AUTH_URL = "https://auth.openai.com/oauth/authorize"; @@ -202,34 +302,16 @@ export class ChatGPTOAuthFlow extends OAuthCallbackFlow { async exchangeToken(code: string, _state: string, redirectUri: string): Promise { if (!this.#verifier) throw new Error("ChatGPT PKCE verifier not initialized"); - const resp = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: CLIENT_ID, - code, - redirect_uri: redirectUri, - code_verifier: this.#verifier, - }).toString(), - }); - if (!resp.ok) { - const errDesc = await safeErrorDescription(resp); - throw new Error(`ChatGPT token exchange failed: ${resp.status} ${errDesc}`); - } - return credsFromToken((await resp.json()) as Record); + return chatGptTokenRequest(new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + code, + redirect_uri: redirectUri, + code_verifier: this.#verifier, + }), "ChatGPT token exchange", { signal: this.ctrl.signal }); } } -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,21 +337,11 @@ 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", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - client_id: CLIENT_ID, - refresh_token: refreshToken, - }).toString(), - signal: options.signal, - }); - if (!resp.ok) { - const errDesc = await safeErrorDescription(resp); - throw new Error(`ChatGPT refresh failed: ${resp.status} ${errDesc}`); - } - return credsFromToken((await resp.json()) as Record); + return chatGptTokenRequest(new URLSearchParams({ + grant_type: "refresh_token", + client_id: CLIENT_ID, + refresh_token: refreshToken, + }), "ChatGPT refresh", options); } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 97fd7788194..e893a09af4b 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -38,7 +38,7 @@ import { anthropicOAuthDefinition, assertAnthropicInstanceLoginConfig, Anthropic resumeAnthropicRefreshIntentCleanup, clearObservedAnthropicRefreshIntent } from "./anthropic-oauth-definitions"; import { loginKimi, refreshKimiToken } from "./kimi"; import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous"; -import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt"; +import { ChatGptTokenRequestError, ChatGptTokenError, loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; import { loginZed, refreshZedToken } from "./zed"; @@ -350,7 +350,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", }, @@ -712,10 +712,14 @@ function isTerminalRefreshError(err: unknown): boolean { || msg.includes("expired_token"); } function terminal(error:unknown):boolean{ + if (error instanceof ChatGptTokenRequestError) return false; if(error instanceof XaiTokenRequestError)return ["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??""); 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 760104a812c..8dc40b61338 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 per-fetch deadline covering headers and body. Error bodies are capped at 16 KiB bytes and success JSON at 64 KiB; each settled call detaches its caller listener and clears its timer. The registration in `src/oauth/index.ts` preserves that signal and uses the shared Codex classifier through `ChatGptTokenError`: terminal grant refusals require an explicit allowlisted terminal OAuth code and mark only the matching generation `needsReauth`; free-text descriptions never establish terminality; transient failures remain retryable. HTTP-derived errors expose only status and allowlisted code. Cancellation and deadline errors use fixed AbortError/TimeoutError messages; transport and parsing failures also use fixed messages, without raw reasons or causes, and never mark reauthentication. The registry preserves its own stale-flight error. `tests/oauth/chatgpt-token-expiry.test.ts`, `tests/oauth/chatgpt-token-boundaries.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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 96cf8a8e464..2e5672419ea 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -234,7 +234,7 @@ "chat-refusal-scope.test.ts": "responses", "chat-refusal.test.ts": "responses", "chat-responses-control-integration.test.ts": "responses", "chat-responses-control-scope.test.ts": "responses", "chat-tool-choice-allowed-tools.test.ts": "responses", "chatgpt-device-auth.test.ts": "oauth", - "chatgpt-oauth.test.ts": "oauth", "chatgpt-token-expiry.test.ts": "oauth", + "chatgpt-oauth.test.ts": "oauth", "chatgpt-token-expiry.test.ts": "oauth", "chatgpt-token-boundaries.test.ts": "oauth", "chutes-provider.test.ts": "providers", "ci-bun-crash-classifier.test.ts": "ci-workflows", "ci-crash-disposition.test.ts": "ci-workflows", "ci-privacy-gate.test.ts": "ci-workflows", "ci-review-lanes.test.ts": "ci-workflows", "ci-scope-reduction.test.ts": "ci-workflows", diff --git a/tests/oauth/chatgpt-token-boundaries.test.ts b/tests/oauth/chatgpt-token-boundaries.test.ts new file mode 100644 index 00000000000..895d44dbdf9 --- /dev/null +++ b/tests/oauth/chatgpt-token-boundaries.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { ChatGptTokenError, ChatGptTokenRequestError, refreshChatGPTToken } from "../../src/oauth/chatgpt"; + +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); + +function stalledBody(status: number) { + let started!: () => void; + const began = new Promise(resolve => { started = resolve; }); + let cancelled = false; + globalThis.fetch = (async () => new Response(new ReadableStream({ + pull() { started(); }, + cancel() { cancelled = true; }, + }), { status })) as typeof fetch; + return { began, cancelled: () => cancelled }; +} + +function instrumentCaller() { + const caller = new AbortController(); + const listeners = new Set(); + const add = caller.signal.addEventListener.bind(caller.signal); + const remove = caller.signal.removeEventListener.bind(caller.signal); + spyOn(caller.signal, "addEventListener").mockImplementation((type, listener, options) => { + if (type === "abort" && listener) listeners.add(listener); + add(type, listener, options); + }); + spyOn(caller.signal, "removeEventListener").mockImplementation((type, listener, options) => { + if (type === "abort" && listener) listeners.delete(listener); + remove(type, listener, options); + }); + return { caller, outstanding: () => listeners.size }; +} + +function expectPrivateAbort(error: unknown, name: string, message: string) { + expect(error).toBeInstanceOf(ChatGptTokenRequestError); + expect(error).toMatchObject({ name, message, terminal: false }); + expect(error).not.toHaveProperty("cause"); + expect(error).not.toHaveProperty("reason"); + const surfaced = JSON.stringify(Object.getOwnPropertyDescriptors(error)) + String(error); + expect(surfaced).not.toContain("invalid_grant"); + expect(surfaced).not.toContain("synthetic-private-token"); +} + +describe("ChatGPT token body bounds and cancellation", () => { + for (const status of [200, 400]) { + test(`headers arrive but ${status} body stalls past the per-fetch deadline`, async () => { + const body = stalledBody(status); + const pending = refreshChatGPTToken("synthetic-refresh", { timeoutMs: 20 }); + await body.began; + expectPrivateAbort(await pending.catch(error => error), "TimeoutError", "ChatGPT token request timed out"); + expect(body.cancelled()).toBe(true); + }); + + test(`caller abort during ${status} body read is sanitized and propagated`, async () => { + const body = stalledBody(status); + const caller = new AbortController(); + const pending = refreshChatGPTToken("synthetic-refresh", { signal: caller.signal }); + const rejected = pending.catch(error => error); + await body.began; + caller.abort(new Error("invalid_grant synthetic-private-token")); + expectPrivateAbort(await rejected, "AbortError", "ChatGPT token request cancelled"); + expect(body.cancelled()).toBe(true); + }); + } + + test("oversized error body stops at 16 KiB bytes and discards a terminal prefix", async () => { + let pulls = 0; + let cancelled = false; + const prefix = new TextEncoder().encode('{"error":"invalid_grant","padding":"'); + globalThis.fetch = (async () => new Response(new ReadableStream({ + pull(controller) { + pulls++; + const chunk = new TextEncoder().encode("é".repeat(2048)); + if (pulls === 1) chunk.set(prefix); + controller.enqueue(chunk); + // Advertise a much larger body without allocating or consuming it. + if (pulls === 1000) controller.close(); + }, + cancel() { cancelled = true; }, + }, { highWaterMark: 0 }), { status: 400 })) as typeof fetch; + const error = await refreshChatGPTToken("synthetic-refresh").catch(error => error); + expect(error).toBeInstanceOf(ChatGptTokenError); + expect(error).toMatchObject({ httpStatus: 400, terminal: false, oauthError: undefined }); + expect(error.message).toBe("ChatGPT refresh failed: 400 code=none"); + expect(pulls).toBe(5); + expect(cancelled).toBe(true); + }); + + test("success JSON exceeding 64 KiB is cancelled and sanitized before parsing", async () => { + let cancelled = false; + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(65_537)); }, + cancel() { cancelled = true; }, + }))) as typeof fetch; + const error = await refreshChatGPTToken("synthetic-refresh").catch(error => error); + expect(error).toBeInstanceOf(ChatGptTokenRequestError); + expect(error.message).toBe("ChatGPT token request failed"); + expect(cancelled).toBe(true); + }); + + test("malformed success JSON never exposes the parser's input", async () => { + globalThis.fetch = (async () => new Response("invalid_grant synthetic-private-token")) as typeof fetch; + const error = await refreshChatGPTToken("synthetic-refresh").catch(error => error); + expectPrivateAbort(error, "ChatGptTokenRequestError", "ChatGPT token request failed"); + }); +}); + +describe("ChatGPT caller signal cleanup", () => { + for (const outcome of ["transport failure", "JSON parse failure"] as const) { + test(`${outcome} clears the deadline timer and removes the caller listener`, async () => { + const { caller, outstanding } = instrumentCaller(); + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + let deadline: ReturnType | undefined; + const timers = spyOn(globalThis, "setTimeout").mockImplementation((callback, ms, ...args) => { + deadline = originalSetTimeout(callback, ms, ...args); + return deadline; + }); + const cleared = spyOn(globalThis, "clearTimeout"); + let completedSignal!: AbortSignal; + try { + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + completedSignal = init!.signal!; + if (outcome === "transport failure") throw new Error("invalid_grant synthetic-private-token"); + return new Response("invalid_grant synthetic-private-token"); + }) as typeof fetch; + const error = await refreshChatGPTToken("synthetic-refresh", { signal: caller.signal }).catch(error => error); + expectPrivateAbort(error, "ChatGptTokenRequestError", "ChatGPT token request failed"); + expect(timers).toHaveBeenCalledTimes(1); + expect(timers.mock.calls[0]![1]).toBe(30_000); + expect(deadline).toBeDefined(); + expect(cleared).toHaveBeenCalledWith(deadline!); + expect(outstanding()).toBe(0); + caller.abort(new Error("synthetic-private-token")); + expect(completedSignal.aborted).toBe(false); + } finally { + timers.mockRestore(); + cleared.mockRestore(); + if (deadline !== undefined) originalClearTimeout(deadline); + } + }); + } + + for (const outcome of ["success", "HTTP failure", "timeout"] as const) { + test(`${outcome} removes the caller listener; later abort cancels only a separate flight`, async () => { + const { caller, outstanding } = instrumentCaller(); + let completedSignal!: AbortSignal; + if (outcome === "timeout") { + globalThis.fetch = ((_url: unknown, init?: RequestInit) => { + completedSignal = init!.signal!; + return new Promise((_, reject) => { + completedSignal.addEventListener("abort", () => reject(completedSignal.reason), { once: true }); + }); + }) as typeof fetch; + } else { + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + completedSignal = init!.signal!; + return outcome === "success" ? Response.json({ access_token: "synthetic-access" }) + : Response.json({ error: "invalid_grant" }, { status: 400 }); + }) as typeof fetch; + } + const result = await refreshChatGPTToken("synthetic-refresh", { signal: caller.signal, timeoutMs: 20 }) + .then(() => "success", error => error); + if (outcome === "success") expect(result).toBe("success"); + else if (outcome === "HTTP failure") expect(result).toBeInstanceOf(ChatGptTokenError); + else expect(result).toHaveProperty("name", "TimeoutError"); + expect(outstanding()).toBe(0); + const wasAborted = completedSignal.aborted; + const completedReason = completedSignal.reason; + const body = stalledBody(200); + const pending = refreshChatGPTToken("synthetic-refresh", { signal: caller.signal }); + const rejected = pending.catch(error => error); + await body.began; + expect(outstanding()).toBe(1); + caller.abort(new Error("invalid_grant synthetic-private-token")); + expectPrivateAbort(await rejected, "AbortError", "ChatGPT token request cancelled"); + expect(outstanding()).toBe(0); + expect(completedSignal.aborted).toBe(wasAborted); + expect(completedSignal.reason).toBe(completedReason); + }); + } +}); diff --git a/tests/oauth/chatgpt-token-expiry.test.ts b/tests/oauth/chatgpt-token-expiry.test.ts index 9565ebce02d..ce0f63e00af 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, ChatGptTokenRequestError, refreshChatGPTToken } from "../../src/oauth/chatgpt"; const originalFetch = globalThis.fetch; -afterEach(() => { globalThis.fetch = originalFetch; }); +const originalSetTimeout = globalThis.setTimeout; +afterEach(() => { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; +}); const FALLBACK_MS = 3600 * 1000; const TOLERANCE_MS = 30_000; @@ -64,3 +68,198 @@ 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("description-only or blank-code OAuth failures never establish terminality", async () => { + for (const body of [ + { error_description: "refresh token expired" }, + { error: " ", error_description: "refresh token revoked" }, + { error: { code: " ", message: "refresh token invalidated" } }, + { error: { message: "upstream route revoked temporarily" } }, + ]) { + for (const status of [400, 401, 403]) { + const error = await refreshError(status, body); + expect(error.terminal).toBe(false); + expect(error.oauthError).toBeUndefined(); + } + } + }); + + 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[] = []; + globalThis.setTimeout = ((fn, ms, ...args) => { + deadlines.push(ms!); return originalSetTimeout(fn, ms, ...args); + }) as typeof setTimeout; + 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("invalid_grant synthetic-private-token", "AbortError"); + caller.abort(reason); + expect(observed.every(signal => !signal.aborted)).toBe(true); + }); + + test("a pre-aborted refresh sanitizes the caller's reason", async () => { + stallFetch(); + const reason = new DOMException("invalid_grant synthetic-private-token", "AbortError"); + const err = await refreshChatGPTToken("synthetic-refresh", { signal: AbortSignal.abort(reason) }) + .catch((error: unknown) => error); + expect(err).toBeInstanceOf(ChatGptTokenRequestError); + expect(err).toMatchObject({ name: "AbortError", message: "ChatGPT token request cancelled" }); + expect(err).not.toBe(reason); + expect(err).not.toHaveProperty("cause"); + expect(JSON.stringify(err) + String(err) + (err as Error).stack).not.toContain(reason.message); + }); + + 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 sanitizes 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("invalid_grant synthetic-private-token", "AbortError"); + const rejected = pending.catch((error: unknown) => error); + await began; + caller.abort(reason); + const err = await rejected; + expect(err).toBeInstanceOf(ChatGptTokenRequestError); + expect(err).toMatchObject({ name: "AbortError", message: "ChatGPT token request cancelled" }); + expect(err).not.toBe(reason); + expect(err).not.toHaveProperty("cause"); + expect(JSON.stringify(err) + String(err) + (err as Error).stack).not.toContain(reason.message); + }); + + test("browser exchange uses the 30s deadline and redacts endpoint failures", async () => { + const deadlines: number[] = []; + globalThis.setTimeout = ((fn, ms, ...args) => { + deadlines.push(ms!); return originalSetTimeout(fn, 20, ...args); + }) as typeof setTimeout; + 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..63cd499e03a 100644 --- a/tests/oauth/oauth-refresh-generic-lock.test.ts +++ b/tests/oauth/oauth-refresh-generic-lock.test.ts @@ -1,13 +1,16 @@ -import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; import { mkdirSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getValidAccessTokenForAccount, + refreshGenericAccountWithLock, OAuthLoginRequiredError, + OAuthTokenRefreshStaleError, OAUTH_PROVIDERS, } from "../../src/oauth"; import type { OAuthCredentials } from "../../src/oauth/types"; +import { ChatGptTokenError, ChatGptTokenRequestError, refreshChatGPTToken } from "../../src/oauth/chatgpt"; import { getAccountCredential, getAccountSet, saveCredential } from "../../src/oauth/store"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -205,3 +208,198 @@ 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; + const originalChatgptRefresh = OAUTH_PROVIDERS.chatgpt!.refresh; + afterEach(() => { + globalThis.fetch = originalFetch; + OAUTH_PROVIDERS.chatgpt!.refresh = originalChatgptRefresh; + }); + + 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("400 invalid_grant marks the current credential generation needsReauth", async () => { + const accountId = await seedExpiredChatgpt(); + globalThis.fetch = (async () => Response.json({ error: "invalid_grant" }, { status: 400 })) as typeof fetch; + await expect(refreshChatGPTToken("synthetic-refresh")).rejects.toMatchObject({ terminal: true }); + await expect(getValidAccessTokenForAccount("chatgpt", accountId)).rejects.toBeInstanceOf(OAuthLoginRequiredError); + expect(getAccountSet("chatgpt")!.accounts.find(a => a.id === accountId)!.needsReauth).toBe(true); + }); + + 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); + }); + + for (const [label, body] of [ + ["missing code", { error_description: "refresh token expired" }], + ["blank code", { error: " ", error_description: "refresh token revoked" }], + ["nested blank code", { error: { code: " ", message: "refresh token invalidated" } }], + ["nested message-only envelope", { error: { message: "upstream route revoked temporarily" } }], + ] as const) { + test(`${label} prose stays nonterminal and registry recovers`, async () => { + const accountId = await seedExpiredChatgpt(); + const credential = getAccountCredential("chatgpt", accountId)!; + globalThis.fetch = (async () => Response.json(body, { status: 400 })) as typeof fetch; + const error = await refreshGenericAccountWithLock("chatgpt", accountId, + OAUTH_PROVIDERS.chatgpt!, credential).catch(error => error); + expect(error).toBeInstanceOf(ChatGptTokenError); + expect(error).toMatchObject({ httpStatus: 400, terminal: false, oauthError: undefined }); + expect(getAccountSet("chatgpt")!.accounts.find(a => a.id === accountId)!.needsReauth).toBeUndefined(); + globalThis.fetch = (async () => Response.json({ access_token: "recovered", refresh_token: "rt-new" })) as typeof fetch; + await expect(refreshGenericAccountWithLock("chatgpt", accountId, + OAUTH_PROVIDERS.chatgpt!, credential)).resolves.toBe("recovered"); + expect(getAccountCredential("chatgpt", accountId)?.refresh).toBe("rt-new"); + expect(getAccountSet("chatgpt")!.accounts.find(a => a.id === accountId)!.needsReauth).toBeUndefined(); + }); + } + + 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("invalid_grant synthetic-private-token", "AbortError"); + const rejected = pending.catch((error: unknown) => error); + await began; + caller.abort(reason); + const err = await rejected; + expect(err).toBeInstanceOf(ChatGptTokenRequestError); + expect(err).toMatchObject({ name: "AbortError", message: "ChatGPT token request cancelled" }); + expect(err).not.toBe(reason); + expect(err).not.toHaveProperty("cause"); + for (const text of ["invalid_grant", "synthetic-private-token"]) { + expect(JSON.stringify(err) + String(err) + (err as Error).stack).not.toContain(text); + } + 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(); + }); + test("non-HTTP registry failures sanitize misleading grant text, private properties, stack and OAuth logs", async () => { + const privateValues = ["synthetic-private-access", "synthetic-private-refresh", "synthetic-private@example.test", + "synthetic-private-account-id"]; + await saveCredential("chatgpt", { + access: privateValues[0]!, refresh: privateValues[1]!, email: privateValues[2]!, + accountId: privateValues[3]!, expires: Date.now() - 1, + }); + const accountId = getAccountSet("chatgpt")!.activeAccountId; + const logs: string[] = []; + const logger = spyOn(console, "info").mockImplementation((...args) => { logs.push(args.join(" ")); }); + try { + for (const marker of ["invalid_grant", "revoked", "refresh_token_reused"]) { + globalThis.fetch = (async () => { + throw Object.assign(new Error(`${marker} ${privateValues.join(" ")}`), { + access: privateValues[0], refresh: privateValues[1], email: privateValues[2], accountId, + cause: new Error(privateValues.join(" ")), + }); + }) as typeof fetch; + const error = await getValidAccessTokenForAccount("chatgpt", accountId).catch(error => error); + expect(error).toBeInstanceOf(ChatGptTokenRequestError); + expect(error.message).toBe("ChatGPT token request failed"); + expect(error).not.toHaveProperty("cause"); + const surfaced = JSON.stringify(Object.getOwnPropertyDescriptors(error)) + String(error) + logs.join(" "); + for (const text of [...privateValues, accountId, marker]) expect(surfaced).not.toContain(text); + expect(getAccountSet("chatgpt")!.accounts.find(a => a.id === accountId)!.needsReauth).toBeUndefined(); + } + expect(logs.length).toBeGreaterThan(0); + expect(logs.some(line => line.includes("account="))).toBe(true); + globalThis.fetch = (async () => Response.json({ access_token: "recovered", refresh_token: "rt-new" })) as typeof fetch; + await expect(getValidAccessTokenForAccount("chatgpt", accountId)).resolves.toBe("recovered"); + } finally { logger.mockRestore(); } + }); + + test("registry deadline is nonterminal and a later refresh recovers", async () => { + const accountId = await seedExpiredChatgpt(); + // Exercise the real registered refresh path with a bounded test deadline. + OAUTH_PROVIDERS.chatgpt!.refresh = (rt, signal) => refreshChatGPTToken(rt, { signal, timeoutMs: 20 }); + let cancelled = false; + globalThis.fetch = (async () => new Response(new ReadableStream({ + cancel() { cancelled = true; }, + }))) as typeof fetch; + const error = await getValidAccessTokenForAccount("chatgpt", accountId).catch(error => error); + expect(error).toBeInstanceOf(ChatGptTokenRequestError); + expect(error).toMatchObject({ name: "TimeoutError", message: "ChatGPT token request timed out" }); + expect(cancelled).toBe(true); + expect(getAccountSet("chatgpt")!.accounts.find(a => a.id === accountId)!.needsReauth).toBeUndefined(); + globalThis.fetch = (async () => Response.json({ access_token: "recovered", refresh_token: "rt-new" })) as typeof fetch; + await expect(getValidAccessTokenForAccount("chatgpt", accountId)).resolves.toBe("recovered"); + expect(getAccountSet("chatgpt")!.accounts.find(a => a.id === accountId)!.needsReauth).toBeUndefined(); + }); + + test("ChatGPT stale-flight abort still reaches the registry as OAuthTokenRefreshStaleError", async () => { + const accountId = await seedExpiredChatgpt(); + let started!: () => void; + const began = new Promise(resolve => { started = resolve; }); + let calls = 0; + globalThis.fetch = ((_url: unknown, init?: RequestInit) => { + calls++; + if (calls > 1) return Promise.resolve(Response.json({ access_token: "replacement", refresh_token: "rt-new" })); + return new Promise((_, reject) => { + init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true }); + started(); + }); + }) as typeof fetch; + const old = getValidAccessTokenForAccount("chatgpt", accountId).catch(error => error); + await began; + const clock = spyOn(Date, "now").mockReturnValue(Date.now() + 120_001); + try { + const replacement = getValidAccessTokenForAccount("chatgpt", accountId); + expect(await old).toBeInstanceOf(OAuthTokenRefreshStaleError); + await expect(replacement).resolves.toBe("replacement"); + expect(calls).toBe(2); + expect(getAccountSet("chatgpt")!.accounts.find(a => a.id === accountId)!.needsReauth).toBeUndefined(); + } finally { clock.mockRestore(); } + }); + +});