fix(gemini-oauth): align OAuth project discovery metadata and endpoint fallbacks (#16684)

* fix(gemini-oauth): align loadCodeAssist metadata and endpoint fallback

* test(gemini-oauth): cover endpoint fallback and env project fallback

* fix(gemini-oauth): route timed fetches through ssrf guard

* test(gemini-oauth): mock guarded fetch in oauth tests
This commit is contained in:
Vincent Koc
2026-02-26 15:24:35 -05:00
committed by GitHub
parent a1628d89ec
commit 764cd5a310
2 changed files with 314 additions and 35 deletions

View File

@@ -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<Response>;
}) => {
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<string, string>)[name];
}
function responseJson(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
let envSnapshot: Partial<Record<(typeof ENV_KEYS)[number], string>>;
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);
});
});

View File

@@ -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<Response> {
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<string | undefined> {
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<string | undefined> {
async function discoverProject(accessToken: string): Promise<string> {
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<string> {
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<string> {
const onboardBody: Record<string, unknown> = {
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<string> {
(onboardBody.metadata as Record<string, unknown>).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<string> {
};
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<string, string>,
): 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) {