From 0a4d8746f9df54c0104aea71bb9307fa707e51d5 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 23 Jul 2026 20:07:41 +0000 Subject: [PATCH] fix(oauth): bound and cancel google provisioning poll Gemini CLI / Antigravity OAuth login could hang indefinitely during Cloud Code Assist provisioning: pollOperation was an unbounded while(true) loop with bare fetch calls that never checked OAuthController.signal, passed no signal to fetch, and had no per-request timeout or attempt cap. Add a shared oauthFetch helper that composes the controller signal with a 30s per-request timeout, and a throwIfLoginCancelled guard. Thread the signal through exchangeToken, getUserEmail, discoverProject, onboardUser, pollOperation, and the Antigravity onboardProjectWithRetries/discoverProject. Bound pollOperation to 24 attempts; a stall now surfaces OAuthError (kind:"timeout") and cancellation surfaces LoginCancelledError. Fixes #4085 --- packages/ai/CHANGELOG.md | 1 + .../src/registry/oauth/google-antigravity.ts | 52 ++++--- .../src/registry/oauth/google-gemini-cli.ts | 88 +++++++----- .../src/registry/oauth/google-oauth-shared.ts | 128 +++++++++++++++--- .../ai/test/google-oauth-hostname.test.ts | 1 + .../test/google-oauth-validation-url.test.ts | 1 + packages/ai/test/issue-4085-repro.test.ts | 95 +++++++++++++ 7 files changed, 295 insertions(+), 71 deletions(-) create mode 100644 packages/ai/test/issue-4085-repro.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c05886526..70cad25fc 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,7 @@ - Added interactive Meta Model API key login and `MODEL_API_KEY` / `META_API_KEY` environment authentication ([#4941](https://github.com/can1357/oh-my-pi/issues/4941)). - Fixed SuperGrok (`xai-oauth`) `/usage` showing "no usage data" for unified-billing accounts: when `?format=credits` lacks `creditUsagePercent` (or marks `isUnifiedBillingUser`), fall back to / merge the default monthly `monthlyLimit`/`used` payload. - Fixed sessions wedging onto their fallback model with `400 Invalid \`signature\` in \`thinking\` block` after switching to an Anthropic signing endpoint while the latest assistant turn came from a different Anthropic-compatible provider (e.g. Kimi k3). The cross-model thinking-signature strip skipped the latest surviving assistant turn entirely, replaying the foreign signature verbatim on every attempt; the latest turn now strips signatures whose issuing provider differs from the target (same-provider switches keep their byte-for-byte latest turn), and foreign `redacted_thinking` siblings are dropped alongside instead of riding the wire unverifiable. +- Fixed the Google Gemini CLI / Antigravity OAuth login hanging indefinitely (or ignoring ESC/cancel) during Cloud Code Assist project provisioning: `pollOperation` was an unbounded `while (true)` loop with bare `fetch` calls that never checked `OAuthController.signal`, passed no signal to `fetch`, and imposed no per-request timeout or attempt cap, so a stalled `done: false` operation or a hung fetch left login unrecoverable short of SIGKILL. The post-callback provisioning phase (token exchange, user-info, project discovery/onboarding, LRO polling) now threads the controller signal, applies a 30s per-request timeout via the new `oauthFetch` helper, checks cancellation before each poll, and bounds polling to 24 attempts — surfacing a `LoginCancelledError` on cancel and an `OAuthError` (`kind: "timeout"`) on a stall instead of spinning forever ([#4085](https://github.com/can1357/oh-my-pi/issues/4085)). ## [17.0.9] - 2026-07-23 diff --git a/packages/ai/src/registry/oauth/google-antigravity.ts b/packages/ai/src/registry/oauth/google-antigravity.ts index 6ef78c7f8..958ac5bcc 100644 --- a/packages/ai/src/registry/oauth/google-antigravity.ts +++ b/packages/ai/src/registry/oauth/google-antigravity.ts @@ -4,7 +4,7 @@ */ import { getAntigravityUserAgent } from "@oh-my-pi/pi-catalog/wire/gemini-headers"; import * as AIError from "../../error"; -import { runGoogleOAuthLogin } from "./google-oauth-shared"; +import { oauthFetch, runGoogleOAuthLogin, throwIfLoginCancelled } from "./google-oauth-shared"; import type { OAuthController, OAuthCredentials } from "./types"; const decode = (s: string) => atob(s); @@ -74,25 +74,28 @@ async function onboardProjectWithRetries( endpoint: string, headers: Record, onboardBody: { tierId: string; metadata: typeof ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA }, + signal: AbortSignal | undefined, onProgress?: (message: string) => void, ): Promise { for (let attempt = 1; attempt <= PROJECT_ONBOARD_MAX_ATTEMPTS; attempt += 1) { if (attempt > 1) { onProgress?.(`Waiting for project provisioning (attempt ${attempt}/${PROJECT_ONBOARD_MAX_ATTEMPTS})...`); + throwIfLoginCancelled(signal); await Bun.sleep(PROJECT_ONBOARD_INTERVAL_MS); } - const onboardResponse = await fetch(`${endpoint}/v1internal:onboardUser`, { - method: "POST", - headers, - body: JSON.stringify(onboardBody), - }); + throwIfLoginCancelled(signal); + const onboardResponse = await oauthFetch( + `${endpoint}/v1internal:onboardUser`, + { method: "POST", headers, body: JSON.stringify(onboardBody) }, + { provider: "google-antigravity", signal }, + ); if (!onboardResponse.ok) { const errorText = await onboardResponse.text(); throw new AIError.OAuthError( `onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}: ${errorText}`, - { kind: "provisioning", status: onboardResponse.status }, + { kind: "provisioning", provider: "google-antigravity", status: onboardResponse.status }, ); } @@ -109,11 +112,15 @@ async function onboardProjectWithRetries( throw new AIError.OAuthError( `onboardUser did not return a provisioned project id after ${PROJECT_ONBOARD_MAX_ATTEMPTS} attempts`, - { kind: "provisioning" }, + { kind: "provisioning", provider: "google-antigravity" }, ); } -async function discoverProject(accessToken: string, onProgress?: (message: string) => void): Promise { +async function discoverProject( + accessToken: string, + onProgress?: (message: string) => void, + signal?: AbortSignal, +): Promise { const headers = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", @@ -123,13 +130,18 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin onProgress?.("Checking for existing project..."); const endpoint = CLOUD_CODE_ENDPOINT; try { - const loadResponse = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify({ - metadata: ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA, - }), - }); + throwIfLoginCancelled(signal); + const loadResponse = await oauthFetch( + `${endpoint}/v1internal:loadCodeAssist`, + { + method: "POST", + headers, + body: JSON.stringify({ + metadata: ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA, + }), + }, + { provider: "google-antigravity", signal }, + ); if (!loadResponse.ok) { const errorText = await loadResponse.text(); @@ -151,18 +163,22 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin tierId, metadata: ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA, }; - const provisionedProject = await onboardProjectWithRetries(endpoint, headers, onboardBody, onProgress); + const provisionedProject = await onboardProjectWithRetries(endpoint, headers, onboardBody, signal, onProgress); return provisionedProject; } catch (error) { + if (error instanceof AIError.LoginCancelledError || error instanceof AIError.OAuthError) { + throw error; + } throw new AIError.OAuthError( `Could not discover or provision an Antigravity project. ${error instanceof Error ? error.message : String(error)}`, - { kind: "discovery", cause: error }, + { kind: "discovery", provider: "google-antigravity", cause: error }, ); } } export async function loginAntigravity(ctrl: OAuthController): Promise { return runGoogleOAuthLogin(ctrl, { + provider: "google-antigravity", clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, authUrl: AUTH_URL, diff --git a/packages/ai/src/registry/oauth/google-gemini-cli.ts b/packages/ai/src/registry/oauth/google-gemini-cli.ts index 7833069a7..1f36d6991 100644 --- a/packages/ai/src/registry/oauth/google-gemini-cli.ts +++ b/packages/ai/src/registry/oauth/google-gemini-cli.ts @@ -6,7 +6,7 @@ import { getGeminiCliHeaders } from "@oh-my-pi/pi-catalog/wire/gemini-headers"; import { $env } from "@oh-my-pi/pi-utils"; import * as AIError from "../../error"; -import { runGoogleOAuthLogin } from "./google-oauth-shared"; +import { oauthFetch, runGoogleOAuthLogin, throwIfLoginCancelled } from "./google-oauth-shared"; import type { OAuthController, OAuthCredentials } from "./types"; const decode = (s: string) => atob(s); @@ -63,22 +63,34 @@ function isVpcScAffectedUser(payload: unknown): boolean { return error.details.some(detail => detail.reason === "SECURITY_POLICY_VIOLATED"); } -async function pollOperation( +/** + * LRO poll cadence and bound. Cloud Code Assist project provisioning normally + * completes within a handful of polls; the attempt cap converts a stuck + * `done: false` operation (or a service incident) into a bounded login error + * instead of the previous unbounded loop. + */ +const POLL_INTERVAL_MS = 5000; +export const POLL_MAX_ATTEMPTS = 24; + +export async function pollOperation( operationName: string, headers: Record, + signal: AbortSignal | undefined, onProgress?: (message: string) => void, ): Promise { - let attempt = 0; - while (true) { + for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt += 1) { if (attempt > 0) { - onProgress?.(`Waiting for project provisioning (attempt ${attempt + 1})...`); - await Bun.sleep(5000); + onProgress?.(`Waiting for project provisioning (attempt ${attempt + 1}/${POLL_MAX_ATTEMPTS})...`); + throwIfLoginCancelled(signal); + await Bun.sleep(POLL_INTERVAL_MS); } - const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { - method: "GET", - headers, - }); + throwIfLoginCancelled(signal); + const response = await oauthFetch( + `${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, + { method: "GET", headers }, + { provider: "google-gemini-cli", signal }, + ); if (!response.ok) { throw new AIError.OAuthError(`Failed to poll operation: ${response.status} ${response.statusText}`, { @@ -92,12 +104,19 @@ async function pollOperation( if (data.done) { return data; } - - attempt += 1; } + + throw new AIError.OAuthError(`Project provisioning did not complete after ${POLL_MAX_ATTEMPTS} attempts`, { + kind: "timeout", + provider: "google-gemini-cli", + }); } -async function discoverProject(accessToken: string, onProgress?: (message: string) => void): Promise { +async function discoverProject( + accessToken: string, + onProgress?: (message: string) => void, + signal?: AbortSignal, +): Promise { const envProjectId = $env.GOOGLE_CLOUD_PROJECT || $env.GOOGLE_CLOUD_PROJECT_ID; const headers = { @@ -107,19 +126,23 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin }; onProgress?.("Checking for existing Cloud Code Assist project..."); - const loadResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify({ - cloudaicompanionProject: envProjectId, - metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - duetProject: envProjectId, - }, - }), - }); + const loadResponse = await oauthFetch( + `${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, + { + method: "POST", + headers, + body: JSON.stringify({ + cloudaicompanionProject: envProjectId, + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + duetProject: envProjectId, + }, + }), + }, + { provider: "google-gemini-cli", signal }, + ); let data: LoadCodeAssistPayload; @@ -185,11 +208,11 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin (onboardBody.metadata as Record).duetProject = envProjectId; } - const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, { - method: "POST", - headers, - body: JSON.stringify(onboardBody), - }); + const onboardResponse = await oauthFetch( + `${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, + { method: "POST", headers, body: JSON.stringify(onboardBody) }, + { provider: "google-gemini-cli", signal }, + ); if (!onboardResponse.ok) { const errorText = await onboardResponse.text(); @@ -202,7 +225,7 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin let lroData = (await onboardResponse.json()) as LongRunningOperationResponse; if (!lroData.done && lroData.name) { - lroData = await pollOperation(lroData.name, headers, onProgress); + lroData = await pollOperation(lroData.name, headers, signal, onProgress); } const projectId = lroData.response?.cloudaicompanionProject?.id; @@ -224,6 +247,7 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin export async function loginGeminiCli(ctrl: OAuthController): Promise { return runGoogleOAuthLogin(ctrl, { + provider: "google-gemini-cli", clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, authUrl: AUTH_URL, diff --git a/packages/ai/src/registry/oauth/google-oauth-shared.ts b/packages/ai/src/registry/oauth/google-oauth-shared.ts index 78c8359a3..d8e65dfc5 100644 --- a/packages/ai/src/registry/oauth/google-oauth-shared.ts +++ b/packages/ai/src/registry/oauth/google-oauth-shared.ts @@ -9,7 +9,70 @@ import { extractGoogleValidationUrl, formatGoogleValidationRequiredMessage } fro import { OAuthCallbackFlow } from "./callback-server"; import type { OAuthController, OAuthCredentials } from "./types"; +/** + * Per-request timeout for the post-callback provisioning phase (token exchange, + * user-info, project discovery/onboarding, LRO polling). These Cloud Code + * Assist calls normally settle in well under this window; a longer stall means + * a hung endpoint that must surface a login error instead of hanging forever. + * The callback server's own 300s deadline covers only the browser-callback wait + * ({@link OAuthCallbackFlow}) and does not gate this phase. + */ +export const OAUTH_REQUEST_TIMEOUT_MS = 30_000; + +/** Options for {@link oauthFetch}. */ +export interface OAuthFetchOptions { + /** Provider id recorded on any {@link AIError.OAuthError} raised. */ + provider: string; + /** Controller signal; when it aborts, the in-flight request is cancelled. */ + signal?: AbortSignal; + /** Override the per-request timeout (defaults to {@link OAUTH_REQUEST_TIMEOUT_MS}). */ + timeoutMs?: number; +} + +/** + * Throw {@link AIError.LoginCancelledError} when the controller signal has + * already aborted. Gates each provisioning round-trip, which the callback-wait + * cancellation checks in {@link OAuthCallbackFlow} do not reach. + */ +export function throwIfLoginCancelled(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new AIError.LoginCancelledError(`OAuth login cancelled: ${String(signal.reason)}`); + } +} + +/** + * `fetch` for the provisioning phase: composes the controller signal with a + * per-request timeout so a stalled endpoint aborts instead of hanging login, + * and user cancellation aborts the in-flight request. Cancellation surfaces as + * {@link AIError.LoginCancelledError}; a timeout surfaces as an + * {@link AIError.OAuthError} with `kind: "timeout"`. + */ +export async function oauthFetch( + url: string, + init: RequestInit, + { provider, signal, timeoutMs = OAUTH_REQUEST_TIMEOUT_MS }: OAuthFetchOptions, +): Promise { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; + try { + return await fetch(url, { ...init, signal: requestSignal }); + } catch (err) { + if (signal?.aborted) { + throw new AIError.LoginCancelledError(`OAuth login cancelled: ${String(signal.reason)}`); + } + if (timeoutSignal.aborted) { + throw new AIError.OAuthError(`Timed out after ${timeoutMs}ms waiting for ${url}`, { + kind: "timeout", + provider, + }); + } + throw err; + } +} + export interface GoogleOAuthFlowConfig { + /** Provider id used in progress/error reporting and per-request fetches. */ + provider: string; clientId: string; clientSecret: string; authUrl: string; @@ -17,21 +80,31 @@ export interface GoogleOAuthFlowConfig { scopes: string[]; callbackPort: number; callbackPath: string; - discoverProject: (accessToken: string, onProgress?: (message: string) => void) => Promise; + discoverProject: ( + accessToken: string, + onProgress?: (message: string) => void, + signal?: AbortSignal, + ) => Promise; } -async function getUserEmail(accessToken: string): Promise { +async function getUserEmail( + accessToken: string, + provider: string, + signal: AbortSignal | undefined, +): Promise { try { - const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { - headers: { Authorization: `Bearer ${accessToken}` }, - }); + const response = await oauthFetch( + "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + { headers: { Authorization: `Bearer ${accessToken}` } }, + { provider, signal }, + ); if (response.ok) { const data = (await response.json()) as { email?: string }; return data.email; } } catch { - // Ignore errors, email is optional + // Ignore errors, email is optional; the caller re-checks cancellation. } return undefined; } @@ -64,23 +137,30 @@ export class GoogleOAuthFlow extends OAuthCallbackFlow { } async exchangeToken(code: string, _state: string, redirectUri: string): Promise { + const { provider } = this.config; + const signal = this.ctrl.signal; + throwIfLoginCancelled(signal); this.ctrl.onProgress?.("Exchanging authorization code for tokens..."); - const tokenResponse = await fetch(this.config.tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: this.config.clientId, - client_secret: this.config.clientSecret, - code, - grant_type: "authorization_code", - redirect_uri: redirectUri, - }), - }); + const tokenResponse = await oauthFetch( + this.config.tokenUrl, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + }, + { provider, signal }, + ); if (!tokenResponse.ok) { const error = await tokenResponse.text(); - throw new AIError.OAuthError(`Token exchange failed: ${error}`, { kind: "token-exchange" }); + throw new AIError.OAuthError(`Token exchange failed: ${error}`, { kind: "token-exchange", provider }); } const tokenData = (await tokenResponse.json()) as { @@ -90,19 +170,25 @@ export class GoogleOAuthFlow extends OAuthCallbackFlow { }; if (!tokenData.refresh_token) { - throw new AIError.OAuthError("No refresh token received. Please try again.", { kind: "validation" }); + throw new AIError.OAuthError("No refresh token received. Please try again.", { + kind: "validation", + provider, + }); } + throwIfLoginCancelled(signal); this.ctrl.onProgress?.("Getting user info..."); - const email = await getUserEmail(tokenData.access_token); + const email = await getUserEmail(tokenData.access_token, provider, signal); + throwIfLoginCancelled(signal); let projectId: string; try { - projectId = await this.config.discoverProject(tokenData.access_token, this.ctrl.onProgress); + projectId = await this.config.discoverProject(tokenData.access_token, this.ctrl.onProgress, signal); } catch (err) { const validationUrl = extractGoogleValidationUrl(err instanceof Error ? err.message : String(err)); if (!validationUrl) throw err; throw new AIError.OAuthError(formatGoogleValidationRequiredMessage(validationUrl, "sign in again", email), { kind: "validation", + provider, }); } diff --git a/packages/ai/test/google-oauth-hostname.test.ts b/packages/ai/test/google-oauth-hostname.test.ts index 2e3d356e7..d073757f3 100644 --- a/packages/ai/test/google-oauth-hostname.test.ts +++ b/packages/ai/test/google-oauth-hostname.test.ts @@ -8,6 +8,7 @@ describe("GoogleOAuthFlow callback hostname", () => { onAuth: () => {}, }; const flow = new GoogleOAuthFlow(ctrl, { + provider: "google-gemini-cli", clientId: "test-client", clientSecret: "test-secret", authUrl: "https://example.com/auth", diff --git a/packages/ai/test/google-oauth-validation-url.test.ts b/packages/ai/test/google-oauth-validation-url.test.ts index 77d5ac09d..dfe0d27fb 100644 --- a/packages/ai/test/google-oauth-validation-url.test.ts +++ b/packages/ai/test/google-oauth-validation-url.test.ts @@ -63,6 +63,7 @@ function urlOf(input: string | URL | Request): string { function makeConfig(discoverProject: GoogleOAuthFlowConfig["discoverProject"]): GoogleOAuthFlowConfig { return { + provider: "google-gemini-cli", clientId: "client-id", clientSecret: "client-secret", authUrl: "https://accounts.example.com/o/oauth2/auth", diff --git a/packages/ai/test/issue-4085-repro.test.ts b/packages/ai/test/issue-4085-repro.test.ts new file mode 100644 index 000000000..b48ed61cc --- /dev/null +++ b/packages/ai/test/issue-4085-repro.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import * as AIError from "@oh-my-pi/pi-ai/error"; +import { POLL_MAX_ATTEMPTS, pollOperation } from "@oh-my-pi/pi-ai/registry/oauth/google-gemini-cli"; +import { oauthFetch } from "@oh-my-pi/pi-ai/registry/oauth/google-oauth-shared"; + +/** + * A loopback server whose handler never resolves — models a stalled Cloud Code + * Assist endpoint (network change / proxy stall / API incident). `idleTimeout: 0` + * keeps the request open instead of letting Bun close it, so the only escape is + * the caller's own signal/timeout. + */ +function stallingServer() { + return Bun.serve({ port: 0, idleTimeout: 0, fetch: () => new Promise(() => {}) }); +} + +describe("issue #4085 — Google OAuth provisioning honors cancel/timeout", () => { + afterEach(() => { + // spyOn handles are restored per-test to stay full-suite safe. + }); + + it("oauthFetch surfaces an already-cancelled signal as LoginCancelledError", async () => { + const ctrl = new AbortController(); + ctrl.abort(new Error("user pressed ESC")); + await expect( + oauthFetch("http://127.0.0.1:1/never", {}, { provider: "google-gemini-cli", signal: ctrl.signal }), + ).rejects.toBeInstanceOf(AIError.LoginCancelledError); + }); + + it("oauthFetch aborts an in-flight request when the signal fires mid-fetch", async () => { + const server = stallingServer(); + try { + const ctrl = new AbortController(); + const pending = oauthFetch( + `http://127.0.0.1:${server.port}/`, + {}, + { provider: "google-gemini-cli", signal: ctrl.signal }, + ); + // Yield one microtask so oauthFetch reaches its awaited `fetch`, then + // abort the in-flight request — no wall-clock timer. Without the signal + // wired into fetch this would hang until the 30s default timeout and + // blow the test deadline instead of rejecting fast. + await Promise.resolve(); + ctrl.abort(new Error("cancelled")); + await expect(pending).rejects.toBeInstanceOf(AIError.LoginCancelledError); + } finally { + server.stop(true); + } + }); + + it("oauthFetch surfaces a stalled endpoint as an OAuthError timeout", async () => { + const server = stallingServer(); + try { + const err = await oauthFetch( + `http://127.0.0.1:${server.port}/`, + {}, + { provider: "google-gemini-cli", timeoutMs: 50 }, + ).catch((e: unknown) => e); + expect(err).toBeInstanceOf(AIError.OAuthError); + expect((err as AIError.OAuthError).kind).toBe("timeout"); + } finally { + server.stop(true); + } + }); + + it("pollOperation is bounded: a never-done operation fails after POLL_MAX_ATTEMPTS", async () => { + const sleepSpy = spyOn(Bun, "sleep").mockImplementation((() => Promise.resolve()) as typeof Bun.sleep); + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response(JSON.stringify({ done: false }), { status: 200 })) as unknown as typeof globalThis.fetch, + ); + try { + const err = await pollOperation("operations/abc", {}, undefined).catch((e: unknown) => e); + expect(err).toBeInstanceOf(AIError.OAuthError); + expect((err as AIError.OAuthError).kind).toBe("timeout"); + expect(fetchSpy).toHaveBeenCalledTimes(POLL_MAX_ATTEMPTS); + } finally { + fetchSpy.mockRestore(); + sleepSpy.mockRestore(); + } + }); + + it("pollOperation aborts before polling when the signal is already cancelled", async () => { + const fetchSpy = spyOn(globalThis, "fetch"); + try { + const ctrl = new AbortController(); + ctrl.abort(new Error("cancelled")); + await expect(pollOperation("operations/abc", {}, ctrl.signal)).rejects.toBeInstanceOf( + AIError.LoginCancelledError, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); +});