Merge PR #6433: fix(oauth): bound and cancel Google Cloud Code Assist provisioning poll (@roboomp)
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
- 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 OAuth callback servers aborting login when an invalid callback arrives before the legitimate browser redirect, and restricted `localhost` callback listeners to the IPv4 loopback interface ([#4106](https://github.com/can1357/oh-my-pi/issues/4106)).
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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<string, string>,
|
||||
onboardBody: { tierId: string; metadata: typeof ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA },
|
||||
signal: AbortSignal | undefined,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
async function discoverProject(
|
||||
accessToken: string,
|
||||
onProgress?: (message: string) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
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<OAuthCredentials> {
|
||||
return runGoogleOAuthLogin(ctrl, {
|
||||
provider: "google-antigravity",
|
||||
clientId: CLIENT_ID,
|
||||
clientSecret: CLIENT_SECRET,
|
||||
authUrl: AUTH_URL,
|
||||
|
||||
@@ -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<string, string>,
|
||||
signal: AbortSignal | undefined,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<LongRunningOperationResponse> {
|
||||
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<string> {
|
||||
async function discoverProject(
|
||||
accessToken: string,
|
||||
onProgress?: (message: string) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
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<string, unknown>).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<OAuthCredentials> {
|
||||
return runGoogleOAuthLogin(ctrl, {
|
||||
provider: "google-gemini-cli",
|
||||
clientId: CLIENT_ID,
|
||||
clientSecret: CLIENT_SECRET,
|
||||
authUrl: AUTH_URL,
|
||||
|
||||
@@ -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<Response> {
|
||||
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<string>;
|
||||
discoverProject: (
|
||||
accessToken: string,
|
||||
onProgress?: (message: string) => void,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<string>;
|
||||
}
|
||||
|
||||
async function getUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
async function getUserEmail(
|
||||
accessToken: string,
|
||||
provider: string,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<string | undefined> {
|
||||
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<OAuthCredentials> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Response>(() => {}) });
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user