fix(ai): surface Google OAuth account-verification URL on VALIDATION_REQUIRED login

This commit is contained in:
igasmi
2026-06-16 17:48:58 -04:00
parent dc14689fc1
commit 7497d50d93
6 changed files with 236 additions and 3 deletions
+4
View File
@@ -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
+13 -2
View File
@@ -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;
@@ -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,
@@ -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}.`;
}
@@ -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;
@@ -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);
});
});