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>
This commit is contained in:
hugh.li
2026-04-04 22:22:36 +08:00
committed by GitHub
parent eddb94555a
commit 9dd449045a
4 changed files with 292 additions and 96 deletions

View File

@@ -108,6 +108,7 @@ Docs: https://docs.openclaw.ai
- Gateway/auth: serialize async shared-secret auth attempts per client so concurrent Tailscale-capable failures cannot overrun the intended auth rate-limit budget. Thanks @Telecaster2147.
- Doctor/config: compare normalized `talk` configs by deep structural equality instead of key-order-sensitive serialization so `openclaw doctor --fix` stops repeatedly reporting/applying no-op `talk.provider/providers` normalization. (#59911) Thanks @ejames-dev.
- Gateway/device auth: reuse cached device-token scopes only for cached-token reconnects, while keeping explicit `deviceToken` scope requests and empty-cache fallbacks intact so reconnects preserve `operator.read` without breaking explicit auth flows. (#46032) Thanks @caicongyang.
- Google Gemini CLI auth: improve OAuth credential discovery across Windows nvm and Homebrew libexec installs, and align Code Assist metadata so Gemini login stops failing on packaged CLI layouts. (#40729) Thanks @hughcube.
- Mattermost/config schema: accept `groups.*.requireMention` again so existing Mattermost configs no longer fail strict validation after upgrade. (#58271) Thanks @MoerAI.
- Providers/OpenRouter failover: classify `403 "Key limit exceeded"` spending-limit responses as billing so model fallback continues instead of stopping on generic auth. (#59892) Thanks @rockcent.
- Device pairing/security: keep non-operator device scope checks bound to the requested role prefix so bootstrap verification cannot redeem `operator.*` scopes through `node` auth. (#57258) Thanks @jlapenna.

View File

@@ -56,53 +56,18 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret:
const resolvedPath = credentialFs.realpathSync(geminiPath);
const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath);
let content: string | null = null;
for (const geminiCliDir of geminiCliDirs) {
const searchPaths = [
join(
geminiCliDir,
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"src",
"code_assist",
"oauth2.js",
),
join(
geminiCliDir,
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"code_assist",
"oauth2.js",
),
];
for (const path of searchPaths) {
if (credentialFs.existsSync(path)) {
content = credentialFs.readFileSync(path, "utf8");
break;
}
const directCredentials = readGeminiCliCredentialsFromKnownPaths(geminiCliDir);
if (directCredentials) {
cachedGeminiCliCredentials = directCredentials;
return directCredentials;
}
if (content) {
break;
}
const found = findFile(geminiCliDir, "oauth2.js", 10);
if (found) {
content = credentialFs.readFileSync(found, "utf8");
break;
}
}
if (!content) {
return null;
}
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (idMatch && secretMatch) {
cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] };
return cachedGeminiCliCredentials;
const discoveredCredentials = findGeminiCliCredentialsInTree(geminiCliDir, 10);
if (discoveredCredentials) {
cachedGeminiCliCredentials = discoveredCredentials;
return discoveredCredentials;
}
}
} catch {
// Gemini CLI not installed or extraction failed
@@ -123,17 +88,35 @@ function resolveGeminiCliDirs(geminiPath: string, resolvedPath: string): string[
const deduped: string[] = [];
const seen = new Set<string>();
for (const candidate of candidates) {
const key =
process.platform === "win32" ? candidate.replace(/\\/g, "/").toLowerCase() : candidate;
if (seen.has(key)) {
continue;
for (const searchDir of resolveGeminiCliSearchDirs(candidate)) {
const key =
process.platform === "win32" ? searchDir.replace(/\\/g, "/").toLowerCase() : searchDir;
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(searchDir);
}
seen.add(key);
deduped.push(candidate);
}
return deduped;
}
function resolveGeminiCliSearchDirs(candidate: string): string[] {
const searchDirs = [
candidate,
join(candidate, "node_modules", "@google", "gemini-cli"),
join(candidate, "lib", "node_modules", "@google", "gemini-cli"),
];
return searchDirs.filter(looksLikeGeminiCliDir);
}
function looksLikeGeminiCliDir(candidate: string): boolean {
return (
credentialFs.existsSync(join(candidate, "package.json")) ||
credentialFs.existsSync(join(candidate, "node_modules", "@google", "gemini-cli-core"))
);
}
function findInPath(name: string): string | null {
const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""];
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
@@ -147,18 +130,84 @@ function findInPath(name: string): string | null {
return null;
}
function findFile(dir: string, name: string, depth: number): string | null {
function readGeminiCliCredentialsFile(
path: string,
): { clientId: string; clientSecret: string } | null {
try {
return parseGeminiCliCredentials(credentialFs.readFileSync(path, "utf8"));
} catch {
return null;
}
}
function parseGeminiCliCredentials(
content: string,
): { clientId: string; clientSecret: string } | null {
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (!idMatch || !secretMatch) {
return null;
}
return { clientId: idMatch[1], clientSecret: secretMatch[1] };
}
function readGeminiCliCredentialsFromKnownPaths(
geminiCliDir: string,
): { clientId: string; clientSecret: string } | null {
const searchPaths = [
join(
geminiCliDir,
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"src",
"code_assist",
"oauth2.js",
),
join(
geminiCliDir,
"node_modules",
"@google",
"gemini-cli-core",
"dist",
"code_assist",
"oauth2.js",
),
];
for (const path of searchPaths) {
if (!credentialFs.existsSync(path)) {
continue;
}
const credentials = readGeminiCliCredentialsFile(path);
if (credentials) {
return credentials;
}
}
return null;
}
function findGeminiCliCredentialsInTree(
dir: string,
depth: number,
): { clientId: string; clientSecret: string } | null {
if (depth <= 0) {
return null;
}
try {
for (const entry of credentialFs.readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isFile() && entry.name === name) {
return path;
if (entry.isFile() && entry.name === "oauth2.js") {
const credentials = readGeminiCliCredentialsFile(path);
if (credentials) {
return credentials;
}
continue;
}
if (entry.isDirectory() && !entry.name.startsWith(".")) {
const found = findFile(path, name, depth - 1);
const found = findGeminiCliCredentialsInTree(path, depth - 1);
if (found) {
return found;
}

View File

@@ -8,15 +8,11 @@ import {
USERINFO_URL,
} from "./oauth.shared.js";
function resolvePlatform(): "WINDOWS" | "MACOS" | "PLATFORM_UNSPECIFIED" {
if (process.platform === "win32") {
return "WINDOWS";
}
if (process.platform === "darwin") {
return "MACOS";
}
return "PLATFORM_UNSPECIFIED";
}
const LOAD_CODE_ASSIST_METADATA = {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
} as const;
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
@@ -97,24 +93,18 @@ export async function resolveGoogleOAuthIdentity(accessToken: string): Promise<{
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/${process.versions.node}`,
"Client-Metadata": JSON.stringify(metadata),
"Client-Metadata": JSON.stringify(LOAD_CODE_ASSIST_METADATA),
};
const loadBody = {
...(envProject ? { cloudaicompanionProject: envProject } : {}),
metadata: {
...metadata,
...LOAD_CODE_ASSIST_METADATA,
...(envProject ? { duetProject: envProject } : {}),
},
};
@@ -193,7 +183,7 @@ async function discoverProject(accessToken: string): Promise<string> {
const onboardBody: Record<string, unknown> = {
tierId,
metadata: {
...metadata,
...LOAD_CODE_ASSIST_METADATA,
},
};
if (tierId !== TIER_FREE && envProject) {

View File

@@ -91,11 +91,18 @@ describe("extractGeminiCliCredentials", () => {
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;
}
@@ -116,11 +123,9 @@ describe("extractGeminiCliCredentials", () => {
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(
binDir,
"node_modules",
"@google",
"gemini-cli",
geminiCliDir,
"node_modules",
"@google",
"gemini-cli-core",
@@ -129,6 +134,7 @@ describe("extractGeminiCliCredentials", () => {
"code_assist",
"oauth2.js",
);
const packageJsonPath = normalizePath(join(geminiCliDir, "package.json"));
process.env.PATH = binDir;
mockExistsSync.mockImplementation((p: string) => {
@@ -136,6 +142,9 @@ describe("extractGeminiCliCredentials", () => {
if (normalized === normalizePath(geminiPath)) {
return true;
}
if (normalized === packageJsonPath) {
return true;
}
if (params.oauth2Exists && normalized === normalizePath(oauth2Path)) {
return true;
}
@@ -147,6 +156,140 @@ describe("extractGeminiCliCredentials", () => {
}
}
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,
@@ -196,6 +339,15 @@ describe("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: [] });
@@ -225,6 +377,23 @@ describe("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", () => {
@@ -244,16 +413,11 @@ describe("loginGeminiCliOAuth", () => {
"GOOGLE_CLOUD_PROJECT_ID",
] as const;
function getExpectedPlatform(): "WINDOWS" | "MACOS" | "PLATFORM_UNSPECIFIED" {
if (process.platform === "win32") {
return "WINDOWS";
}
if (process.platform === "darwin") {
return "MACOS";
}
// Matches updated resolvePlatform() which uses PLATFORM_UNSPECIFIED for Linux
return "PLATFORM_UNSPECIFIED";
}
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;
@@ -390,19 +554,11 @@ describe("loginGeminiCliOAuth", () => {
const clientMetadata = getHeaderValue(firstHeaders, "Client-Metadata");
expect(clientMetadata).toBeDefined();
expect(JSON.parse(clientMetadata as string)).toEqual({
ideType: "ANTIGRAVITY",
platform: getExpectedPlatform(),
pluginType: "GEMINI",
});
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: {
ideType: "ANTIGRAVITY",
platform: getExpectedPlatform(),
pluginType: "GEMINI",
},
metadata: EXPECTED_LOAD_CODE_ASSIST_METADATA,
});
});