From ef5f47bd3925851cacf7257a47a669cc3dc6b2cb Mon Sep 17 00:00:00 2001 From: wzfmini01 Date: Sun, 5 Apr 2026 09:41:43 +0200 Subject: [PATCH] fix(google-gemini-cli-auth): detect bundled npm installs (#60486) (#60486) Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + extensions/google/oauth.credentials.ts | 43 ++++++++++++++++++++--- extensions/google/oauth.test.ts | 47 ++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9d11cccea7..326c7992318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,7 @@ Docs: https://docs.openclaw.ai - 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. - Agents/scheduling: steer background-now work toward automatic completion wake and treat `process` polling as on-demand inspection or intervention instead of default completion handling. (#60877) Thanks @vincentkoc. - 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. +- Google Gemini CLI auth: detect bundled npm installs by scanning packaged bundle files for the Gemini OAuth client config, so `npm install -g @google/gemini-cli` layouts work again. (#60486) Thanks @wzfmini01. - Mattermost/config schema: accept `groups.*.requireMention` again so existing Mattermost configs no longer fail strict validation after upgrade. (#58271) Thanks @MoerAI. - Agents/failover: scope Anthropic `An unknown error occurred` failover matching by provider so generic internal unknown-error text no longer triggers retryable timeout fallback. (#59325) Thanks @aaron-he-zhu. - 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. diff --git a/extensions/google/oauth.credentials.ts b/extensions/google/oauth.credentials.ts index 9441506a1c7..c9c26266e57 100644 --- a/extensions/google/oauth.credentials.ts +++ b/extensions/google/oauth.credentials.ts @@ -63,6 +63,12 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret: return directCredentials; } + const bundledCredentials = readGeminiCliCredentialsFromBundle(geminiCliDir); + if (bundledCredentials) { + cachedGeminiCliCredentials = bundledCredentials; + return bundledCredentials; + } + const discoveredCredentials = findGeminiCliCredentialsInTree(geminiCliDir, 10); if (discoveredCredentials) { cachedGeminiCliCredentials = discoveredCredentials; @@ -143,12 +149,16 @@ function readGeminiCliCredentialsFile( 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) { + const clientId = + content.match(/OAUTH_CLIENT_ID\s*=\s*["']([^"']+)["']/)?.[1] ?? + content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/)?.[1]; + const clientSecret = + content.match(/OAUTH_CLIENT_SECRET\s*=\s*["']([^"']+)["']/)?.[1] ?? + content.match(/(GOCSPX-[A-Za-z0-9_-]+)/)?.[1]; + if (!clientId || !clientSecret) { return null; } - return { clientId: idMatch[1], clientSecret: secretMatch[1] }; + return { clientId, clientSecret }; } function readGeminiCliCredentialsFromKnownPaths( @@ -189,6 +199,31 @@ function readGeminiCliCredentialsFromKnownPaths( return null; } +function readGeminiCliCredentialsFromBundle( + geminiCliDir: string, +): { clientId: string; clientSecret: string } | null { + const bundleDir = join(geminiCliDir, "bundle"); + if (!credentialFs.existsSync(bundleDir)) { + return null; + } + + try { + for (const entry of credentialFs.readdirSync(bundleDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".js")) { + continue; + } + const credentials = readGeminiCliCredentialsFile(join(bundleDir, entry.name)); + if (credentials) { + return credentials; + } + } + } catch { + // Ignore bundle traversal failures and fall back to the recursive search. + } + + return null; +} + function findGeminiCliCredentialsInTree( dir: string, depth: number, diff --git a/extensions/google/oauth.test.ts b/extensions/google/oauth.test.ts index de494dbaa3d..2066cbdeb0b 100644 --- a/extensions/google/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -156,6 +156,39 @@ describe("extractGeminiCliCredentials", () => { } } + function installBundledNpmLayout(params: { bundleContent: string }) { + const binDir = join(rootDir, "fake", "npm-bundle-bin"); + const geminiPath = join(binDir, "gemini"); + const resolvedPath = geminiPath; + const geminiCliDir = join(binDir, "node_modules", "@google", "gemini-cli"); + const packageJsonPath = normalizePath(join(geminiCliDir, "package.json")); + const bundleDir = join(geminiCliDir, "bundle"); + const chunkPath = join(bundleDir, "chunk-ABC123.js"); + + process.env.PATH = binDir; + mockExistsSync.mockImplementation((p: string) => { + const normalized = normalizePath(p); + return ( + normalized === normalizePath(geminiPath) || + normalized === packageJsonPath || + normalized === normalizePath(bundleDir) + ); + }); + mockRealpathSync.mockReturnValue(resolvedPath); + mockReaddirSync.mockImplementation((p: string) => { + if (normalizePath(String(p)) === normalizePath(bundleDir)) { + return [dirent("chunk-ABC123.js", false)]; + } + return []; + }); + mockReadFileSync.mockImplementation((p: string) => { + if (normalizePath(String(p)) === normalizePath(chunkPath)) { + return params.bundleContent; + } + throw new Error(`Unexpected read for ${p}`); + }); + } + function installHomebrewLibexecLayout(params: { oauth2Content: string }) { const brewPrefix = join(rootDir, "opt", "homebrew"); const cellarRoot = join(brewPrefix, "Cellar", "gemini-cli", "1.2.3"); @@ -339,6 +372,20 @@ describe("extractGeminiCliCredentials", () => { expectFakeCliCredentials(result); }); + it("extracts credentials from bundled npm installs", async () => { + installBundledNpmLayout({ + bundleContent: ` + const OAUTH_CLIENT_ID = "${FAKE_CLIENT_ID}"; + const OAUTH_CLIENT_SECRET = "${FAKE_CLIENT_SECRET}"; + `, + }); + + clearCredentialsCache(); + const result = extractGeminiCliCredentials(); + + expectFakeCliCredentials(result); + }); + it("extracts credentials from Homebrew libexec installs", async () => { installHomebrewLibexecLayout({ oauth2Content: FAKE_OAUTH2_CONTENT });