mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 08:12:21 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user