Files
openclaw/extensions/google/oauth.test.ts
hugh.li 9dd449045a fix(google-gemini-cli-auth): fix Gemini CLI OAuth failures on Windows (#40729)
* fix(google-gemini-cli-auth): fix Gemini CLI OAuth failures on Windows

Two issues prevented Gemini CLI OAuth from working on Windows:

1. resolveGeminiCliDirs: the first candidate `dirname(dirname(resolvedPath))`
   can resolve to an unrelated ancestor directory (e.g. the nvm root
   `C:\Users\<user>\AppData\Local\nvm`) when gemini is installed via nvm.
   The subsequent `findFile` recursive search (depth 10) then picks up an
   `oauth2.js` from a completely different package (e.g.
   `discord-api-types/payloads/v10/oauth2.js`), which naturally does not
   contain Google OAuth credentials, causing silent extraction failure.

   Fix: validate candidate directories before including them — only keep
   candidates that contain a `package.json` or a `node_modules/@google/
   gemini-cli-core` subdirectory.

2. resolvePlatform: returns "WINDOWS" on win32, but Google's loadCodeAssist
   API rejects it as an invalid Platform enum value (400 INVALID_ARGUMENT),
   just like it rejects "LINUX".

   Fix: use "PLATFORM_UNSPECIFIED" for all non-macOS platforms.

* test(google-gemini-cli-auth): keep oauth regressions portable

* chore(changelog): add google gemini cli auth fix note

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-04 23:22:36 +09:00

651 lines
21 KiB
TypeScript

import { join, parse } from "node:path";
import { describe, expect, it, vi, beforeAll, beforeEach, afterEach } from "vitest";
vi.mock("../../src/infra/wsl.js", () => ({
isWSL2Sync: () => false,
}));
vi.mock("../../src/infra/net/fetch-guard.js", () => ({
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 () => {},
};
},
}));
const mockExistsSync = vi.fn();
const mockReadFileSync = vi.fn();
const mockRealpathSync = vi.fn();
const mockReaddirSync = vi.fn();
describe("extractGeminiCliCredentials", () => {
const normalizePath = (value: string) =>
value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
const rootDir = parse(process.cwd()).root || "/";
const FAKE_CLIENT_ID = "123456789-abcdef.apps.googleusercontent.com";
const FAKE_CLIENT_SECRET = "GOCSPX-FakeSecretValue123";
const FAKE_OAUTH2_CONTENT = `
const clientId = "${FAKE_CLIENT_ID}";
const clientSecret = "${FAKE_CLIENT_SECRET}";
`;
let originalPath: string | undefined;
let extractGeminiCliCredentials: typeof import("./oauth.credentials.js").extractGeminiCliCredentials;
let clearCredentialsCache: typeof import("./oauth.credentials.js").clearCredentialsCache;
let setOAuthCredentialsFsForTest: typeof import("./oauth.credentials.js").setOAuthCredentialsFsForTest;
async function installMockFs() {
setOAuthCredentialsFsForTest({
existsSync: (...args) => mockExistsSync(...args),
readFileSync: (...args) => mockReadFileSync(...args),
realpathSync: (...args) => mockRealpathSync(...args),
readdirSync: (...args) => mockReaddirSync(...args),
});
}
function makeFakeLayout() {
const binDir = join(rootDir, "fake", "bin");
const geminiPath = join(binDir, "gemini");
const resolvedPath = join(
rootDir,
"fake",
"lib",
"node_modules",
"@google",
"gemini-cli",
"dist",
"index.js",
);
const oauth2Path = join(
rootDir,
"fake",
"lib",
"node_modules",
"@google",
"gemini-cli",
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"src",
"code_assist",
"oauth2.js",
);
return { binDir, geminiPath, resolvedPath, oauth2Path };
}
function installGeminiLayout(params: {
oauth2Exists?: boolean;
oauth2Content?: string;
readdir?: string[];
}) {
const layout = makeFakeLayout();
process.env.PATH = layout.binDir;
// resolveGeminiCliDirs checks package.json to validate candidate directories
const geminiCliDir = join(rootDir, "fake", "lib", "node_modules", "@google", "gemini-cli");
const packageJsonPath = normalizePath(join(geminiCliDir, "package.json"));
mockExistsSync.mockImplementation((p: string) => {
const normalized = normalizePath(p);
if (normalized === normalizePath(layout.geminiPath)) {
return true;
}
if (normalized === packageJsonPath) {
return true;
}
if (params.oauth2Exists && normalized === normalizePath(layout.oauth2Path)) {
return true;
}
return false;
});
mockRealpathSync.mockReturnValue(layout.resolvedPath);
if (params.oauth2Content !== undefined) {
mockReadFileSync.mockReturnValue(params.oauth2Content);
}
if (params.readdir) {
mockReaddirSync.mockReturnValue(params.readdir);
}
return layout;
}
function installNpmShimLayout(params: { oauth2Exists?: boolean; oauth2Content?: string }) {
const binDir = join(rootDir, "fake", "npm-bin");
const geminiPath = join(binDir, "gemini");
const resolvedPath = geminiPath;
const geminiCliDir = join(binDir, "node_modules", "@google", "gemini-cli");
const oauth2Path = join(
geminiCliDir,
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"src",
"code_assist",
"oauth2.js",
);
const packageJsonPath = normalizePath(join(geminiCliDir, "package.json"));
process.env.PATH = binDir;
mockExistsSync.mockImplementation((p: string) => {
const normalized = normalizePath(p);
if (normalized === normalizePath(geminiPath)) {
return true;
}
if (normalized === packageJsonPath) {
return true;
}
if (params.oauth2Exists && normalized === normalizePath(oauth2Path)) {
return true;
}
return false;
});
mockRealpathSync.mockReturnValue(resolvedPath);
if (params.oauth2Content !== undefined) {
mockReadFileSync.mockReturnValue(params.oauth2Content);
}
}
function installHomebrewLibexecLayout(params: { oauth2Content: string }) {
const brewPrefix = join(rootDir, "opt", "homebrew");
const cellarRoot = join(brewPrefix, "Cellar", "gemini-cli", "1.2.3");
const binDir = join(brewPrefix, "bin");
const geminiPath = join(binDir, "gemini");
const resolvedPath = join(cellarRoot, "libexec", "bin", "gemini");
const geminiCliDir = join(
cellarRoot,
"libexec",
"lib",
"node_modules",
"@google",
"gemini-cli",
);
const packageJsonPath = normalizePath(join(geminiCliDir, "package.json"));
const oauth2Path = join(
geminiCliDir,
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"src",
"code_assist",
"oauth2.js",
);
process.env.PATH = binDir;
mockExistsSync.mockImplementation((p: string) => {
const normalized = normalizePath(p);
return (
normalized === normalizePath(geminiPath) ||
normalized === packageJsonPath ||
normalized === normalizePath(oauth2Path)
);
});
mockRealpathSync.mockReturnValue(resolvedPath);
mockReadFileSync.mockImplementation((p: string) => {
if (normalizePath(p) === normalizePath(oauth2Path)) {
return params.oauth2Content;
}
throw new Error(`Unexpected read for ${p}`);
});
}
function installWindowsNvmLayoutWithUnrelatedOauth(params: {
oauth2Content: string;
unrelatedOauth2Content: string;
}) {
const nvmRoot = join(rootDir, "fake", "Users", "lobster", "AppData", "Local", "nvm");
const versionDir = join(nvmRoot, "v24.1.0");
const geminiPath = join(versionDir, process.platform === "win32" ? "gemini.cmd" : "gemini");
const resolvedPath = geminiPath;
const geminiCliDir = join(versionDir, "node_modules", "@google", "gemini-cli");
const packageJsonPath = normalizePath(join(geminiCliDir, "package.json"));
const oauth2Path = join(
geminiCliDir,
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"src",
"code_assist",
"oauth2.js",
);
const unrelatedOauth2Path = join(
nvmRoot,
"node_modules",
"discord-api-types",
"payloads",
"v10",
"oauth2.js",
);
process.env.PATH = versionDir;
mockExistsSync.mockImplementation((p: string) => {
const normalized = normalizePath(p);
return (
normalized === normalizePath(geminiPath) ||
normalized === packageJsonPath ||
normalized === normalizePath(oauth2Path)
);
});
mockRealpathSync.mockReturnValue(resolvedPath);
mockReadFileSync.mockImplementation((p: string) => {
const normalized = normalizePath(p);
if (normalized === normalizePath(oauth2Path)) {
return params.oauth2Content;
}
if (normalized === normalizePath(unrelatedOauth2Path)) {
return params.unrelatedOauth2Content;
}
throw new Error(`Unexpected read for ${p}`);
});
mockReaddirSync.mockImplementation((p: string) => {
const normalized = normalizePath(p);
if (normalized === normalizePath(nvmRoot)) {
return [dirent("node_modules", true)];
}
if (normalized === normalizePath(join(nvmRoot, "node_modules"))) {
return [dirent("discord-api-types", true)];
}
if (normalized === normalizePath(join(nvmRoot, "node_modules", "discord-api-types"))) {
return [dirent("payloads", true)];
}
if (
normalized === normalizePath(join(nvmRoot, "node_modules", "discord-api-types", "payloads"))
) {
return [dirent("v10", true)];
}
if (
normalized ===
normalizePath(join(nvmRoot, "node_modules", "discord-api-types", "payloads", "v10"))
) {
return [dirent("oauth2.js", false)];
}
return [];
});
return { unrelatedOauth2Path };
}
function dirent(name: string, isDirectory: boolean) {
return {
name,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isDirectory: () => isDirectory,
isFIFO: () => false,
isFile: () => !isDirectory,
isSocket: () => false,
isSymbolicLink: () => false,
};
}
function expectFakeCliCredentials(result: unknown) {
expect(result).toEqual({
clientId: FAKE_CLIENT_ID,
clientSecret: FAKE_CLIENT_SECRET,
});
}
beforeAll(async () => {
({ extractGeminiCliCredentials, clearCredentialsCache, setOAuthCredentialsFsForTest } =
await import("./oauth.credentials.js"));
});
beforeEach(async () => {
vi.clearAllMocks();
originalPath = process.env.PATH;
await installMockFs();
});
afterEach(async () => {
process.env.PATH = originalPath;
setOAuthCredentialsFsForTest();
});
it("returns null when gemini binary is not in PATH", async () => {
process.env.PATH = "/nonexistent";
mockExistsSync.mockReturnValue(false);
clearCredentialsCache();
expect(extractGeminiCliCredentials()).toBeNull();
});
it("extracts credentials from oauth2.js in known path", async () => {
installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT });
clearCredentialsCache();
const result = extractGeminiCliCredentials();
expectFakeCliCredentials(result);
});
it("extracts credentials when PATH entry is an npm global shim", async () => {
installNpmShimLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT });
clearCredentialsCache();
const result = extractGeminiCliCredentials();
expectFakeCliCredentials(result);
});
it("extracts credentials from Homebrew libexec installs", async () => {
installHomebrewLibexecLayout({ oauth2Content: FAKE_OAUTH2_CONTENT });
clearCredentialsCache();
const result = extractGeminiCliCredentials();
expectFakeCliCredentials(result);
});
it("returns null when oauth2.js cannot be found", async () => {
installGeminiLayout({ oauth2Exists: false, readdir: [] });
clearCredentialsCache();
expect(extractGeminiCliCredentials()).toBeNull();
});
it("returns null when oauth2.js lacks credentials", async () => {
installGeminiLayout({ oauth2Exists: true, oauth2Content: "// no credentials here" });
clearCredentialsCache();
expect(extractGeminiCliCredentials()).toBeNull();
});
it("caches credentials after first extraction", async () => {
installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT });
clearCredentialsCache();
// First call
const result1 = extractGeminiCliCredentials();
expect(result1).not.toBeNull();
// Second call should use cache (readFileSync not called again)
const readCount = mockReadFileSync.mock.calls.length;
const result2 = extractGeminiCliCredentials();
expect(result2).toEqual(result1);
expect(mockReadFileSync.mock.calls.length).toBe(readCount);
});
it("skips unrelated oauth2.js files when gemini resolves inside a Windows nvm root", async () => {
const { unrelatedOauth2Path } = installWindowsNvmLayoutWithUnrelatedOauth({
oauth2Content: FAKE_OAUTH2_CONTENT,
unrelatedOauth2Content: "// unrelated oauth file",
});
clearCredentialsCache();
const result = extractGeminiCliCredentials();
expectFakeCliCredentials(result);
expect(
mockReadFileSync.mock.calls.some(
([path]) => normalizePath(String(path)) === normalizePath(unrelatedOauth2Path),
),
).toBe(false);
});
});
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;
const EXPECTED_LOAD_CODE_ASSIST_METADATA = {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
} as const;
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" },
});
}
function getFormField(body: RequestInit["body"], name: string): string | null {
if (!(body instanceof URLSearchParams)) {
throw new Error("Expected URLSearchParams body");
}
return body.get(name);
}
type LoginGeminiCliOAuthFn = (options: {
isRemote: boolean;
openUrl: () => Promise<void>;
log: (msg: string) => void;
note: () => Promise<void>;
prompt: () => Promise<string>;
progress: { update: () => void; stop: () => void };
}) => Promise<{ projectId: string }>;
async function runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth: LoginGeminiCliOAuthFn) {
let authUrl = "";
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: () => {} },
});
return { result, authUrl };
}
async function runRemoteLoginExpectingProjectId(
loginGeminiCliOAuth: LoginGeminiCliOAuthFn,
projectId: string,
) {
const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth);
expect(result.projectId).toBe(projectId);
}
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"; // pragma: allowlist 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);
const { loginGeminiCliOAuth } = await import("./oauth.js");
await runRemoteLoginExpectingProjectId(loginGeminiCliOAuth, "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(EXPECTED_LOAD_CODE_ASSIST_METADATA);
const body = JSON.parse(String(loadRequests[0]?.init?.body));
expect(body).toEqual({
metadata: EXPECTED_LOAD_CODE_ASSIST_METADATA,
});
});
it("keeps OAuth state separate from the PKCE verifier during manual login", 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({
currentTier: { id: "standard-tier" },
cloudaicompanionProject: { id: "prod-project" },
});
}
throw new Error(`Unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const { loginGeminiCliOAuth } = await import("./oauth.js");
const { authUrl } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth);
const authState = new URL(authUrl).searchParams.get("state");
expect(authState).toBeTruthy();
const tokenRequest = requests.find((request) => request.url === TOKEN_URL);
expect(tokenRequest).toBeDefined();
const codeVerifier = getFormField(tokenRequest?.init?.body, "code_verifier");
expect(codeVerifier).toBeTruthy();
expect(codeVerifier).not.toBe(authState);
});
it("rejects manual callback input when the returned state does not match", async () => {
const { loginGeminiCliOAuth } = await import("./oauth.js");
await expect(
loginGeminiCliOAuth({
isRemote: true,
openUrl: async () => {},
log: () => {},
note: async () => {},
prompt: async () =>
"http://localhost:8085/oauth2callback?code=oauth-code&state=wrong-state",
progress: { update: () => {}, stop: () => {} },
}),
).rejects.toThrow("OAuth state mismatch - please try again");
});
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);
const { loginGeminiCliOAuth } = await import("./oauth.js");
await runRemoteLoginExpectingProjectId(loginGeminiCliOAuth, "env-project");
expect(requests.filter((url) => url.includes("v1internal:loadCodeAssist"))).toHaveLength(3);
expect(requests.some((url) => url.includes("v1internal:onboardUser"))).toBe(false);
});
});