fix(ai): surface Google OAuth account-verification URL on VALIDATION_REQUIRED login
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user