From 7497d50d93d3caf090002bcd8558c8eec35d697c Mon Sep 17 00:00:00 2001 From: igasmi <77757014+igasmi@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:38:38 -0400 Subject: [PATCH] fix(ai): surface Google OAuth account-verification URL on VALIDATION_REQUIRED login --- packages/ai/CHANGELOG.md | 4 + .../ai/src/providers/google-gemini-cli.ts | 15 +- .../src/registry/oauth/google-oauth-shared.ts | 10 +- packages/ai/src/utils/google-validation.ts | 25 +++ .../test/google-gemini-cli-alignment.test.ts | 37 +++++ .../test/google-oauth-validation-url.test.ts | 148 ++++++++++++++++++ 6 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 packages/ai/src/utils/google-validation.ts create mode 100644 packages/ai/test/google-oauth-validation-url.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index daf0d1c79..169910727 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Antigravity and Gemini CLI model requests failing with an opaque error when Google requires account verification. Cloud Code Assist `403 VALIDATION_REQUIRED` responses now surface the `validation_url` and the signed-in account email when available, so users see an actionable account-verification message instead of the raw API error body. + ## [16.0.2] - 2026-06-16 ### Added diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 190c91a02..72f72933c 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -27,6 +27,7 @@ import type { } from "../types"; import { normalizeSystemPrompts } from "../utils"; import { AssistantMessageEventStream } from "../utils/event-stream"; +import { extractGoogleValidationUrl, formatGoogleValidationRequiredMessage } from "../utils/google-validation"; import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump } from "../utils/http-inspector"; import { getStreamFirstEventTimeoutMs } from "../utils/idle-iterator"; // Refresh is the sole responsibility of AuthStorage (broker-aware, single-flighted); @@ -153,6 +154,7 @@ interface GeminiCliApiKeyPayload { project_id?: unknown; refreshToken?: unknown; expiresAt?: unknown; + email?: unknown; refresh?: unknown; expires?: unknown; } @@ -161,6 +163,7 @@ interface ParsedGeminiCliCredentials { projectId: string; refreshToken?: string; expiresAt?: number; + email?: string; } function normalizeExpiryMs(value: unknown): number | undefined { @@ -200,12 +203,14 @@ export function parseGeminiCliCredentials(apiKeyRaw: string): ParsedGeminiCliCre ? parsed.refresh : undefined; const expiresAt = normalizeExpiryMs(parsed.expiresAt ?? parsed.expires); + const email = typeof parsed.email === "string" && parsed.email.length > 0 ? parsed.email : undefined; return { accessToken: parsed.token, projectId, refreshToken, expiresAt, + email, }; } @@ -400,10 +405,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( ); if (!response.ok) { const errorText = await response.text(); + const validationUrl = extractGoogleValidationUrl(errorText); + const errorMessage = validationUrl + ? formatGoogleValidationRequiredMessage(validationUrl, "retry your request", parsedCredentials.email) + : extractErrorMessage(errorText); throw new GeminiCliApiError( - `Cloud Code Assist API error (${response.status}): ${extractErrorMessage(errorText)}`, + `Cloud Code Assist API error (${response.status}): ${errorMessage}`, response.status, - { headers: response.headers }, + { + headers: response.headers, + }, ); } const requestUrl = response.url; diff --git a/packages/ai/src/registry/oauth/google-oauth-shared.ts b/packages/ai/src/registry/oauth/google-oauth-shared.ts index 9b2683ef2..2fe077a1c 100644 --- a/packages/ai/src/registry/oauth/google-oauth-shared.ts +++ b/packages/ai/src/registry/oauth/google-oauth-shared.ts @@ -4,6 +4,7 @@ * Both providers use the same authorization-code flow shape; only the client * credentials, scopes, endpoint constants, and project-discovery logic differ. */ +import { extractGoogleValidationUrl, formatGoogleValidationRequiredMessage } from "../../utils/google-validation"; import { OAuthCallbackFlow } from "./callback-server"; import type { OAuthController, OAuthCredentials } from "./types"; @@ -89,7 +90,14 @@ export class GoogleOAuthFlow extends OAuthCallbackFlow { this.ctrl.onProgress?.("Getting user info..."); const email = await getUserEmail(tokenData.access_token); - const projectId = await this.config.discoverProject(tokenData.access_token, this.ctrl.onProgress); + let projectId: string; + try { + projectId = await this.config.discoverProject(tokenData.access_token, this.ctrl.onProgress); + } catch (err) { + const validationUrl = extractGoogleValidationUrl(err instanceof Error ? err.message : String(err)); + if (!validationUrl) throw err; + throw new Error(formatGoogleValidationRequiredMessage(validationUrl, "sign in again", email)); + } return { refresh: tokenData.refresh_token, diff --git a/packages/ai/src/utils/google-validation.ts b/packages/ai/src/utils/google-validation.ts new file mode 100644 index 000000000..b56fc93f0 --- /dev/null +++ b/packages/ai/src/utils/google-validation.ts @@ -0,0 +1,25 @@ +export function extractGoogleValidationUrl(errorBody: string): string | undefined { + if (!errorBody.includes("VALIDATION_REQUIRED")) return undefined; + const start = errorBody.indexOf("{"); + if (start === -1) return undefined; + try { + const parsed = JSON.parse(errorBody.slice(start)) as { + error?: { details?: Array<{ reason?: string; metadata?: { validation_url?: string } }> }; + }; + const detail = parsed.error?.details?.find( + d => d.reason === "VALIDATION_REQUIRED" && typeof d.metadata?.validation_url === "string", + ); + return detail?.metadata?.validation_url; + } catch { + return undefined; + } +} + +export function formatGoogleValidationRequiredMessage( + validationUrl: string, + nextAction: string, + email?: string, +): string { + const account = email ? ` for ${email}` : ""; + return `Account verification required${account}. Visit ${validationUrl} to continue, then ${nextAction}.`; +} diff --git a/packages/ai/test/google-gemini-cli-alignment.test.ts b/packages/ai/test/google-gemini-cli-alignment.test.ts index 63a29c237..d3acb7a7a 100644 --- a/packages/ai/test/google-gemini-cli-alignment.test.ts +++ b/packages/ai/test/google-gemini-cli-alignment.test.ts @@ -39,6 +39,22 @@ function createContext(): Context { }; } +const VALIDATION_URL = "https://accounts.google.com/signin/continue?sarp=1&scc=1&plt=AKgnsbtTOKEN"; + +const validationRequiredBody = JSON.stringify({ + error: { + code: 403, + status: "PERMISSION_DENIED", + details: [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + reason: "VALIDATION_REQUIRED", + metadata: { validation_url: VALIDATION_URL, validation_url_link_text: "Verify your account" }, + }, + ], + }, +}); + describe("Google Gemini CLI alignment", () => { it("encodes enriched OAuth JSON while preserving token + projectId", async () => { const expiresAt = Date.now() + 60 * 60 * 1000; @@ -77,6 +93,7 @@ describe("Google Gemini CLI alignment", () => { projectId: "proj-legacy", refreshToken: undefined, expiresAt: undefined, + email: undefined, }); const aliasPayload = parseGeminiCliCredentials( @@ -92,6 +109,7 @@ describe("Google Gemini CLI alignment", () => { projectId: "proj-alias", refreshToken: "refresh-alias", expiresAt: 1_737_000_000_000, + email: undefined, }); const enriched = parseGeminiCliCredentials( @@ -100,6 +118,7 @@ describe("Google Gemini CLI alignment", () => { projectId: "proj-enriched", refreshToken: "refresh-token", expiresAt: 1_737_000_000_000, + email: "dev@example.com", }), ); expect(enriched).toEqual({ @@ -107,6 +126,7 @@ describe("Google Gemini CLI alignment", () => { projectId: "proj-enriched", refreshToken: "refresh-token", expiresAt: 1_737_000_000_000, + email: "dev@example.com", }); }); @@ -379,6 +399,23 @@ describe("Google Gemini CLI alignment", () => { expect(events.filter(e => e.type === "toolcall_start")).toHaveLength(1); }); + it("surfaces account verification failures from model requests", async () => { + const fetchMock: FetchImpl = async () => new Response(validationRequiredBody, { status: 403 }); + const model = createModel("google-antigravity"); + + const stream = streamGoogleGeminiCli(model, createContext(), { + apiKey: JSON.stringify({ token: "token", projectId: "proj-123", email: "dev@example.com" }), + fetch: fetchMock, + }); + + const result = await stream.result(); + expect(result.stopReason).toBe("error"); + expect(result.errorStatus).toBe(403); + expect(result.errorMessage).toBe( + `Cloud Code Assist API error (403): Account verification required for dev@example.com. Visit ${VALIDATION_URL} to continue, then retry your request.`, + ); + }); + describe("retry guardrails", () => { it("does not treat explicit HTTP failures as network retry errors", async () => { let fetchCalls = 0; diff --git a/packages/ai/test/google-oauth-validation-url.test.ts b/packages/ai/test/google-oauth-validation-url.test.ts new file mode 100644 index 000000000..77d5ac09d --- /dev/null +++ b/packages/ai/test/google-oauth-validation-url.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { GoogleOAuthFlow, type GoogleOAuthFlowConfig } from "@oh-my-pi/pi-ai/oauth/google-oauth-shared"; +import type { OAuthController } from "@oh-my-pi/pi-ai/oauth/types"; +import { extractGoogleValidationUrl } from "@oh-my-pi/pi-ai/utils/google-validation"; + +const VALIDATION_URL = "https://accounts.google.com/signin/continue?sarp=1&scc=1&plt=AKgnsbtTOKEN"; + +const validationBody = JSON.stringify({ + error: { + code: 403, + status: "PERMISSION_DENIED", + details: [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + reason: "VALIDATION_REQUIRED", + metadata: { validation_url: VALIDATION_URL, validation_url_link_text: "Verify your account" }, + }, + ], + }, +}); + +describe("extractGoogleValidationUrl", () => { + it("extracts the validation url from a raw 403 VALIDATION_REQUIRED body", () => { + expect(extractGoogleValidationUrl(validationBody)).toBe(VALIDATION_URL); + }); + + it("extracts the url when the body is wrapped in the discovery error prefix", () => { + // exchangeToken receives discoverProject's thrown message, which embeds the raw body. + const wrapped = `Could not discover or provision an Antigravity project. loadCodeAssist failed: 403 Forbidden: ${validationBody}`; + expect(extractGoogleValidationUrl(wrapped)).toBe(VALIDATION_URL); + }); + + it("returns undefined for a 403 that is not VALIDATION_REQUIRED", () => { + const body = JSON.stringify({ + error: { code: 403, status: "PERMISSION_DENIED", details: [{ reason: "ACCESS_TOKEN_SCOPE_INSUFFICIENT" }] }, + }); + expect(extractGoogleValidationUrl(body)).toBeUndefined(); + }); + + it("returns undefined when VALIDATION_REQUIRED carries no validation_url", () => { + const body = JSON.stringify({ + error: { code: 403, details: [{ reason: "VALIDATION_REQUIRED", metadata: {} }] }, + }); + expect(extractGoogleValidationUrl(body)).toBeUndefined(); + }); + + it("returns undefined for non-JSON error text", () => { + expect(extractGoogleValidationUrl("loadCodeAssist failed: 500 Internal Server Error")).toBeUndefined(); + }); + + it("returns undefined for empty input", () => { + expect(extractGoogleValidationUrl("")).toBeUndefined(); + }); +}); + +const TOKEN_URL = "https://oauth2.example.com/token"; + +function urlOf(input: string | URL | Request): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return input.url; +} + +function makeConfig(discoverProject: GoogleOAuthFlowConfig["discoverProject"]): GoogleOAuthFlowConfig { + return { + clientId: "client-id", + clientSecret: "client-secret", + authUrl: "https://accounts.example.com/o/oauth2/auth", + tokenUrl: TOKEN_URL, + scopes: ["scope-a"], + callbackPort: 0, + callbackPath: "/callback", + discoverProject, + }; +} + +/** Stub the token-exchange POST and the optional userinfo GET that exchangeToken issues. */ +function stubTokenAndUserInfo(email?: string): void { + vi.spyOn(globalThis, "fetch").mockImplementation( + Object.assign( + async (input: string | URL | Request) => { + if (urlOf(input).includes("userinfo")) { + if (!email) return new Response("{}", { status: 401 }); + return new Response(JSON.stringify({ email }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response( + JSON.stringify({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600 }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }, + { preconnect: fetch.preconnect }, + ), + ); +} + +describe("GoogleOAuthFlow account verification", () => { + const ctrl: OAuthController = {}; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rewrites a VALIDATION_REQUIRED discovery failure into an actionable message naming the account", async () => { + stubTokenAndUserInfo("user@example.com"); + const flow = new GoogleOAuthFlow( + ctrl, + makeConfig(async () => { + throw new Error( + `Could not discover or provision an Antigravity project. loadCodeAssist failed: 403 Forbidden: ${validationBody}`, + ); + }), + ); + + await expect(flow.exchangeToken("auth-code", "state", "https://localhost/callback")).rejects.toThrow( + `Account verification required for user@example.com. Visit ${VALIDATION_URL} to continue, then sign in again.`, + ); + }); + + it("omits the account clause when the userinfo lookup yields no email", async () => { + stubTokenAndUserInfo(); + const flow = new GoogleOAuthFlow( + ctrl, + makeConfig(async () => { + throw new Error(`loadCodeAssist failed: 403 Forbidden: ${validationBody}`); + }), + ); + + await expect(flow.exchangeToken("auth-code", "state", "https://localhost/callback")).rejects.toThrow( + `Account verification required. Visit ${VALIDATION_URL} to continue, then sign in again.`, + ); + }); + + it("propagates the original discovery error untouched when it is not VALIDATION_REQUIRED", async () => { + stubTokenAndUserInfo("user@example.com"); + const original = "Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT."; + const flow = new GoogleOAuthFlow( + ctrl, + makeConfig(async () => { + throw new Error(original); + }), + ); + + await expect(flow.exchangeToken("auth-code", "state", "https://localhost/callback")).rejects.toThrow(original); + }); +});