diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c0e0c3d52d..f828c49dda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/google/oauth.credentials.ts b/extensions/google/oauth.credentials.ts index 670ae4de943..9441506a1c7 100644 --- a/extensions/google/oauth.credentials.ts +++ b/extensions/google/oauth.credentials.ts @@ -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(); 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; } diff --git a/extensions/google/oauth.project.ts b/extensions/google/oauth.project.ts index fa163b12f19..a05cc79728e 100644 --- a/extensions/google/oauth.project.ts +++ b/extensions/google/oauth.project.ts @@ -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 { try { @@ -97,24 +93,18 @@ export async function resolveGoogleOAuthIdentity(accessToken: string): Promise<{ async function discoverProject(accessToken: string): Promise { 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 { const onboardBody: Record = { tierId, metadata: { - ...metadata, + ...LOAD_CODE_ASSIST_METADATA, }, }; if (tierId !== TIER_FREE && envProject) { diff --git a/extensions/google/oauth.test.ts b/extensions/google/oauth.test.ts index e4290a48ac1..de494dbaa3d 100644 --- a/extensions/google/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -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, }); });