diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts index 3d5df634ff6..46a12a0a5ee 100644 --- a/extensions/google-gemini-cli-auth/oauth.test.ts +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -3,6 +3,19 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; vi.mock("openclaw/plugin-sdk", () => ({ isWSL2Sync: () => false, + fetchWithSsrFGuard: async (params: { + url: string; + init?: RequestInit; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + }) => { + const fetchImpl = params.fetchImpl ?? globalThis.fetch; + const response = await fetchImpl(params.url, params.init); + return { + response, + finalUrl: params.url, + release: async () => {}, + }; + }, })); // Mock fs module before importing the module under test @@ -208,3 +221,204 @@ describe("extractGeminiCliCredentials", () => { expect(mockReadFileSync.mock.calls.length).toBe(readCount); }); }); + +describe("loginGeminiCliOAuth", () => { + const TOKEN_URL = "https://oauth2.googleapis.com/token"; + const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"; + const LOAD_PROD = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; + const LOAD_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist"; + const LOAD_AUTOPUSH = + "https://autopush-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist"; + + const ENV_KEYS = [ + "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", + "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", + "GEMINI_CLI_OAUTH_CLIENT_ID", + "GEMINI_CLI_OAUTH_CLIENT_SECRET", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_PROJECT_ID", + ] as const; + + function getExpectedPlatform(): "WINDOWS" | "MACOS" | "LINUX" { + if (process.platform === "win32") { + return "WINDOWS"; + } + if (process.platform === "linux") { + return "LINUX"; + } + return "MACOS"; + } + + function getRequestUrl(input: string | URL | Request): string { + return typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + } + + function getHeaderValue(headers: HeadersInit | undefined, name: string): string | undefined { + if (!headers) { + return undefined; + } + if (headers instanceof Headers) { + return headers.get(name) ?? undefined; + } + if (Array.isArray(headers)) { + return headers.find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1]; + } + return (headers as Record)[name]; + } + + function responseJson(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + } + + let envSnapshot: Partial>; + beforeEach(() => { + envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); + process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_ID = "test-client-id.apps.googleusercontent.com"; + process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET = "GOCSPX-test-client-secret"; + delete process.env.GEMINI_CLI_OAUTH_CLIENT_ID; + delete process.env.GEMINI_CLI_OAUTH_CLIENT_SECRET; + delete process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.GOOGLE_CLOUD_PROJECT_ID; + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + const value = envSnapshot[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + vi.unstubAllGlobals(); + }); + + it("falls back across loadCodeAssist endpoints with aligned headers and metadata", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = getRequestUrl(input); + requests.push({ url, init }); + + if (url === TOKEN_URL) { + return responseJson({ + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + }); + } + if (url === USERINFO_URL) { + return responseJson({ email: "lobster@openclaw.ai" }); + } + if (url === LOAD_PROD) { + return responseJson({ error: { message: "temporary failure" } }, 503); + } + if (url === LOAD_DAILY) { + return responseJson({ + currentTier: { id: "standard-tier" }, + cloudaicompanionProject: { id: "daily-project" }, + }); + } + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + let authUrl = ""; + const { loginGeminiCliOAuth } = await import("./oauth.js"); + const result = await loginGeminiCliOAuth({ + isRemote: true, + openUrl: async () => {}, + log: (msg) => { + const found = msg.match(/https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?[^\s]+/); + if (found?.[0]) { + authUrl = found[0]; + } + }, + note: async () => {}, + prompt: async () => { + const state = new URL(authUrl).searchParams.get("state"); + return `${"http://localhost:8085/oauth2callback"}?code=oauth-code&state=${state}`; + }, + progress: { update: () => {}, stop: () => {} }, + }); + + expect(result.projectId).toBe("daily-project"); + const loadRequests = requests.filter((request) => + request.url.includes("v1internal:loadCodeAssist"), + ); + expect(loadRequests.map((request) => request.url)).toEqual([LOAD_PROD, LOAD_DAILY]); + + const firstHeaders = loadRequests[0]?.init?.headers; + expect(getHeaderValue(firstHeaders, "X-Goog-Api-Client")).toBe( + `gl-node/${process.versions.node}`, + ); + + const clientMetadata = getHeaderValue(firstHeaders, "Client-Metadata"); + expect(clientMetadata).toBeDefined(); + expect(JSON.parse(clientMetadata as string)).toEqual({ + ideType: "ANTIGRAVITY", + platform: getExpectedPlatform(), + pluginType: "GEMINI", + }); + + const body = JSON.parse(String(loadRequests[0]?.init?.body)); + expect(body).toEqual({ + metadata: { + ideType: "ANTIGRAVITY", + platform: getExpectedPlatform(), + pluginType: "GEMINI", + }, + }); + }); + + it("falls back to GOOGLE_CLOUD_PROJECT when all loadCodeAssist endpoints fail", async () => { + process.env.GOOGLE_CLOUD_PROJECT = "env-project"; + + const requests: string[] = []; + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = getRequestUrl(input); + requests.push(url); + + if (url === TOKEN_URL) { + return responseJson({ + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + }); + } + if (url === USERINFO_URL) { + return responseJson({ email: "lobster@openclaw.ai" }); + } + if ([LOAD_PROD, LOAD_DAILY, LOAD_AUTOPUSH].includes(url)) { + return responseJson({ error: { message: "unavailable" } }, 503); + } + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + let authUrl = ""; + const { loginGeminiCliOAuth } = await import("./oauth.js"); + const result = await loginGeminiCliOAuth({ + isRemote: true, + openUrl: async () => {}, + log: (msg) => { + const found = msg.match(/https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?[^\s]+/); + if (found?.[0]) { + authUrl = found[0]; + } + }, + note: async () => {}, + prompt: async () => { + const state = new URL(authUrl).searchParams.get("state"); + return `${"http://localhost:8085/oauth2callback"}?code=oauth-code&state=${state}`; + }, + progress: { update: () => {}, stop: () => {} }, + }); + + expect(result.projectId).toBe("env-project"); + expect(requests.filter((url) => url.includes("v1internal:loadCodeAssist"))).toHaveLength(3); + expect(requests.some((url) => url.includes("v1internal:onboardUser"))).toBe(false); + }); +}); diff --git a/extensions/google-gemini-cli-auth/oauth.ts b/extensions/google-gemini-cli-auth/oauth.ts index bba4c6b1f39..7e2280b9c9f 100644 --- a/extensions/google-gemini-cli-auth/oauth.ts +++ b/extensions/google-gemini-cli-auth/oauth.ts @@ -2,7 +2,7 @@ import { createHash, randomBytes } from "node:crypto"; import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; import { createServer } from "node:http"; import { delimiter, dirname, join } from "node:path"; -import { isWSL2Sync } from "openclaw/plugin-sdk"; +import { fetchWithSsrFGuard, isWSL2Sync } from "openclaw/plugin-sdk"; const CLIENT_ID_KEYS = ["OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; const CLIENT_SECRET_KEYS = [ @@ -13,7 +13,15 @@ const REDIRECT_URI = "http://localhost:8085/oauth2callback"; const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; const TOKEN_URL = "https://oauth2.googleapis.com/token"; const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"; -const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"; +const CODE_ASSIST_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com"; +const CODE_ASSIST_ENDPOINT_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com"; +const CODE_ASSIST_ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com"; +const LOAD_CODE_ASSIST_ENDPOINTS = [ + CODE_ASSIST_ENDPOINT_PROD, + CODE_ASSIST_ENDPOINT_DAILY, + CODE_ASSIST_ENDPOINT_AUTOPUSH, +]; +const DEFAULT_FETCH_TIMEOUT_MS = 10_000; const SCOPES = [ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", @@ -216,6 +224,38 @@ function generatePkce(): { verifier: string; challenge: string } { return { verifier, challenge }; } +function resolvePlatform(): "WINDOWS" | "MACOS" | "LINUX" { + if (process.platform === "win32") { + return "WINDOWS"; + } + if (process.platform === "linux") { + return "LINUX"; + } + return "MACOS"; +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, +): Promise { + const { response, release } = await fetchWithSsrFGuard({ + url, + init, + timeoutMs, + }); + try { + const body = await response.arrayBuffer(); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } finally { + await release(); + } +} + function buildAuthUrl(challenge: string, verifier: string): string { const { clientId } = resolveOAuthClientConfig(); const params = new URLSearchParams({ @@ -369,9 +409,13 @@ async function exchangeCodeForTokens( body.set("client_secret", clientSecret); } - const response = await fetch(TOKEN_URL, { + const response = await fetchWithTimeout(TOKEN_URL, { method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + Accept: "*/*", + "User-Agent": "google-api-nodejs-client/9.15.1", + }, body, }); @@ -405,7 +449,7 @@ async function exchangeCodeForTokens( async function getUserEmail(accessToken: string): Promise { try { - const response = await fetch(USERINFO_URL, { + const response = await fetchWithTimeout(USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (response.ok) { @@ -420,20 +464,25 @@ async function getUserEmail(accessToken: string): Promise { async function discoverProject(accessToken: string): Promise { const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; + const platform = resolvePlatform(); + const metadata = { + ideType: "ANTIGRAVITY", + platform, + pluginType: "GEMINI", + }; const headers = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": "google-api-nodejs-client/9.15.1", - "X-Goog-Api-Client": "gl-node/openclaw", + "X-Goog-Api-Client": `gl-node/${process.versions.node}`, + "Client-Metadata": JSON.stringify(metadata), }; const loadBody = { - cloudaicompanionProject: envProject, + ...(envProject ? { cloudaicompanionProject: envProject } : {}), metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - duetProject: envProject, + ...metadata, + ...(envProject ? { duetProject: envProject } : {}), }, }; @@ -442,29 +491,46 @@ async function discoverProject(accessToken: string): Promise { cloudaicompanionProject?: string | { id?: string }; allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; } = {}; + let activeEndpoint = CODE_ASSIST_ENDPOINT_PROD; + let loadError: Error | undefined; + for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) { + try { + const response = await fetchWithTimeout(`${endpoint}/v1internal:loadCodeAssist`, { + method: "POST", + headers, + body: JSON.stringify(loadBody), + }); - try { - const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify(loadBody), - }); - - if (!response.ok) { - const errorPayload = await response.json().catch(() => null); - if (isVpcScAffected(errorPayload)) { - data = { currentTier: { id: TIER_STANDARD } }; - } else { - throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`); + if (!response.ok) { + const errorPayload = await response.json().catch(() => null); + if (isVpcScAffected(errorPayload)) { + data = { currentTier: { id: TIER_STANDARD } }; + activeEndpoint = endpoint; + loadError = undefined; + break; + } + loadError = new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`); + continue; } - } else { + data = (await response.json()) as typeof data; + activeEndpoint = endpoint; + loadError = undefined; + break; + } catch (err) { + loadError = err instanceof Error ? err : new Error("loadCodeAssist failed", { cause: err }); } - } catch (err) { - if (err instanceof Error) { - throw err; + } + + const hasLoadCodeAssistData = + Boolean(data.currentTier) || + Boolean(data.cloudaicompanionProject) || + Boolean(data.allowedTiers?.length); + if (!hasLoadCodeAssistData && loadError) { + if (envProject) { + return envProject; } - throw new Error("loadCodeAssist failed", { cause: err }); + throw loadError; } if (data.currentTier) { @@ -494,9 +560,7 @@ async function discoverProject(accessToken: string): Promise { const onboardBody: Record = { tierId, metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", + ...metadata, }, }; if (tierId !== TIER_FREE && envProject) { @@ -504,7 +568,7 @@ async function discoverProject(accessToken: string): Promise { (onboardBody.metadata as Record).duetProject = envProject; } - const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, { + const onboardResponse = await fetchWithTimeout(`${activeEndpoint}/v1internal:onboardUser`, { method: "POST", headers, body: JSON.stringify(onboardBody), @@ -521,7 +585,7 @@ async function discoverProject(accessToken: string): Promise { }; if (!lro.done && lro.name) { - lro = await pollOperation(lro.name, headers); + lro = await pollOperation(activeEndpoint, lro.name, headers); } const projectId = lro.response?.cloudaicompanionProject?.id; @@ -567,12 +631,13 @@ function getDefaultTier( } async function pollOperation( + endpoint: string, operationName: string, headers: Record, ): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> { for (let attempt = 0; attempt < 24; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 5000)); - const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { + const response = await fetchWithTimeout(`${endpoint}/v1internal/${operationName}`, { headers, }); if (!response.ok) {