diff --git a/CHANGELOG.md b/CHANGELOG.md index 460cc3989..39b968f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.clawd.bot - Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags ### Fixes +- Gemini CLI OAuth: auto-detect client id from installed CLI and harden discovery paths. (#1773) Thanks @benostein. - Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47. - Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete. - Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep. diff --git a/extensions/google-gemini-cli-auth/README.md b/extensions/google-gemini-cli-auth/README.md index 99eab057f..088b13560 100644 --- a/extensions/google-gemini-cli-auth/README.md +++ b/extensions/google-gemini-cli-auth/README.md @@ -20,7 +20,7 @@ clawdbot models auth login --provider google-gemini-cli --set-default ## Requirements -Requires the Gemini CLI to be installed (credentials are extracted automatically): +If the Gemini CLI is installed, Clawdbot extracts the OAuth client ID automatically. ```bash brew install gemini-cli diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts index 9d186643a..5236fefb2 100644 --- a/extensions/google-gemini-cli-auth/oauth.test.ts +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { join } from "node:path"; // Mock fs module before importing the module under test @@ -25,6 +25,9 @@ describe("extractGeminiCliCredentials", () => { const clientId = "${FAKE_CLIENT_ID}"; const clientSecret = "${FAKE_CLIENT_SECRET}"; `; + const FAKE_OAUTH2_ID_ONLY = ` + const clientId = "${FAKE_CLIENT_ID}"; + `; let originalPath: string | undefined; @@ -50,19 +53,25 @@ describe("extractGeminiCliCredentials", () => { it("extracts credentials from oauth2.js in known path", async () => { const fakeBinDir = "/fake/bin"; const fakeGeminiPath = join(fakeBinDir, "gemini"); - const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js"; - const fakeOauth2Path = - "/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"; + const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli"; + const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`; + const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`; + const fakeShimContent = `node "${fakeResolvedPath}"`; process.env.PATH = fakeBinDir; mockExistsSync.mockImplementation((p: string) => { if (p === fakeGeminiPath) return true; + if (p === fakeCliRoot) return true; if (p === fakeOauth2Path) return true; return false; }); mockRealpathSync.mockReturnValue(fakeResolvedPath); - mockReadFileSync.mockReturnValue(FAKE_OAUTH2_CONTENT); + mockReadFileSync.mockImplementation((p: string) => { + if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent; + if (p === fakeOauth2Path) return FAKE_OAUTH2_CONTENT; + return ""; + }); const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); clearCredentialsCache(); @@ -77,13 +86,19 @@ describe("extractGeminiCliCredentials", () => { it("returns null when oauth2.js cannot be found", async () => { const fakeBinDir = "/fake/bin"; const fakeGeminiPath = join(fakeBinDir, "gemini"); - const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js"; + const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli"; + const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`; + const fakeShimContent = `node "${fakeResolvedPath}"`; process.env.PATH = fakeBinDir; - mockExistsSync.mockImplementation((p: string) => p === fakeGeminiPath); + mockExistsSync.mockImplementation((p: string) => p === fakeGeminiPath || p === fakeCliRoot); mockRealpathSync.mockReturnValue(fakeResolvedPath); mockReaddirSync.mockReturnValue([]); // Empty directory for recursive search + mockReadFileSync.mockImplementation((p: string) => { + if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent; + return ""; + }); const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); clearCredentialsCache(); @@ -93,19 +108,25 @@ describe("extractGeminiCliCredentials", () => { it("returns null when oauth2.js lacks credentials", async () => { const fakeBinDir = "/fake/bin"; const fakeGeminiPath = join(fakeBinDir, "gemini"); - const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js"; - const fakeOauth2Path = - "/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"; + const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli"; + const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`; + const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`; + const fakeShimContent = `node "${fakeResolvedPath}"`; process.env.PATH = fakeBinDir; mockExistsSync.mockImplementation((p: string) => { if (p === fakeGeminiPath) return true; + if (p === fakeCliRoot) return true; if (p === fakeOauth2Path) return true; return false; }); mockRealpathSync.mockReturnValue(fakeResolvedPath); - mockReadFileSync.mockReturnValue("// no credentials here"); + mockReadFileSync.mockImplementation((p: string) => { + if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent; + if (p === fakeOauth2Path) return "// no credentials here"; + return ""; + }); const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); clearCredentialsCache(); @@ -115,19 +136,25 @@ describe("extractGeminiCliCredentials", () => { it("caches credentials after first extraction", async () => { const fakeBinDir = "/fake/bin"; const fakeGeminiPath = join(fakeBinDir, "gemini"); - const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js"; - const fakeOauth2Path = - "/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"; + const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli"; + const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`; + const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`; + const fakeShimContent = `node "${fakeResolvedPath}"`; process.env.PATH = fakeBinDir; mockExistsSync.mockImplementation((p: string) => { if (p === fakeGeminiPath) return true; + if (p === fakeCliRoot) return true; if (p === fakeOauth2Path) return true; return false; }); mockRealpathSync.mockReturnValue(fakeResolvedPath); - mockReadFileSync.mockReturnValue(FAKE_OAUTH2_CONTENT); + mockReadFileSync.mockImplementation((p: string) => { + if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent; + if (p === fakeOauth2Path) return FAKE_OAUTH2_CONTENT; + return ""; + }); const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); clearCredentialsCache(); @@ -142,4 +169,37 @@ describe("extractGeminiCliCredentials", () => { expect(result2).toEqual(result1); expect(mockReadFileSync.mock.calls.length).toBe(readCount); }); + + it("returns client id when secret is missing", async () => { + const fakeBinDir = "/fake/bin"; + const fakeGeminiPath = join(fakeBinDir, "gemini"); + const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli"; + const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`; + const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`; + const fakeShimContent = `node "${fakeResolvedPath}"`; + + process.env.PATH = fakeBinDir; + + mockExistsSync.mockImplementation((p: string) => { + if (p === fakeGeminiPath) return true; + if (p === fakeCliRoot) return true; + if (p === fakeOauth2Path) return true; + return false; + }); + mockRealpathSync.mockReturnValue(fakeResolvedPath); + mockReadFileSync.mockImplementation((p: string) => { + if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent; + if (p === fakeOauth2Path) return FAKE_OAUTH2_ID_ONLY; + return ""; + }); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + const result = extractGeminiCliCredentials(); + + expect(result).toEqual({ + clientId: FAKE_CLIENT_ID, + clientSecret: undefined, + }); + }); }); diff --git a/extensions/google-gemini-cli-auth/oauth.ts b/extensions/google-gemini-cli-auth/oauth.ts index 405b94641..7bf6f48cd 100644 --- a/extensions/google-gemini-cli-auth/oauth.ts +++ b/extensions/google-gemini-cli-auth/oauth.ts @@ -1,7 +1,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 { delimiter, dirname, isAbsolute, join, normalize } from "node:path"; const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; const CLIENT_SECRET_KEYS = [ @@ -48,7 +48,9 @@ function resolveEnv(keys: string[]): string | undefined { return undefined; } -let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null; +type GeminiCliClientConfig = { clientId: string; clientSecret?: string }; + +let cachedGeminiCliCredentials: GeminiCliClientConfig | null = null; /** @internal */ export function clearCredentialsCache(): void { @@ -56,38 +58,18 @@ export function clearCredentialsCache(): void { } /** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */ -export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { +export function extractGeminiCliCredentials(): GeminiCliClientConfig | null { if (cachedGeminiCliCredentials) return cachedGeminiCliCredentials; try { - const geminiPath = findInPath("gemini"); - if (!geminiPath) return null; + const oauthPath = findGeminiCliOAuthPath(); + if (!oauthPath) return null; - const resolvedPath = realpathSync(geminiPath); - const geminiCliDir = dirname(dirname(resolvedPath)); - - 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"), - ]; - - let content: string | null = null; - for (const p of searchPaths) { - if (existsSync(p)) { - content = readFileSync(p, "utf8"); - break; - } - } - if (!content) { - const found = findFile(geminiCliDir, "oauth2.js", 10); - if (found) content = readFileSync(found, "utf8"); - } - if (!content) return null; - - const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); + const content = readFileSync(oauthPath, "utf8"); + const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/i); const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); - if (idMatch && secretMatch) { - cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] }; + if (idMatch) { + cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch?.[1] }; return cachedGeminiCliCredentials; } } catch { @@ -96,6 +78,129 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret: return null; } +function findGeminiCliOAuthPath(): string | null { + const geminiPath = findInPath("gemini"); + if (!geminiPath) return null; + + for (const root of getGeminiCliRoots(geminiPath)) { + const searchPaths = [ + join(root, "node_modules", "@google", "gemini-cli-core", "dist", "src", "code_assist", "oauth2.js"), + join(root, "node_modules", "@google", "gemini-cli-core", "dist", "code_assist", "oauth2.js"), + ]; + for (const p of searchPaths) { + if (existsSync(p)) return p; + } + if (shouldSearchRoot(root)) { + const found = findFile(root, "oauth2.js", 8); + if (found) return found; + } + } + + return null; +} + +function getGeminiCliRoots(geminiPath: string): string[] { + const roots = new Set(); + const resolved = safeRealpath(geminiPath); + for (const p of [geminiPath, resolved].filter(Boolean) as string[]) { + for (const root of extractGeminiCliRootsFromFile(p)) { + roots.add(root); + } + const fromPath = extractGeminiCliRootFromPath(p); + if (fromPath) roots.add(fromPath); + } + + for (const globalRoot of getGlobalNodeModulesRoots()) { + const candidate = join(globalRoot, "@google", "gemini-cli"); + if (existsSync(candidate)) roots.add(candidate); + } + + return [...roots].filter((root) => existsSync(root)); +} + +function extractGeminiCliRootsFromFile(filePath: string): string[] { + const roots: string[] = []; + try { + const content = readFileSync(filePath, "utf8"); + if (!content.includes("gemini-cli")) return roots; + + const dir = dirname(filePath); + const regex = /["']([^"'\\r\\n]*@google[\\/]+gemini-cli[\\/]+dist[\\/]+index\\.js)["']/gi; + for (const match of content.matchAll(regex)) { + const resolved = resolveShimPath(dir, match[1]); + const root = extractGeminiCliRootFromPath(resolved); + if (root) roots.push(root); + } + } catch { + // ignore non-text files or missing paths + } + return roots; +} + +function resolveShimPath(baseDir: string, rawPath: string): string { + let candidate = rawPath + .replace(/%~?dp0%/gi, baseDir) + .replace(/\$PSScriptRoot/gi, baseDir) + .replace(/__dirname/g, baseDir); + + if (!isAbsolute(candidate)) { + candidate = join(baseDir, candidate); + } + return normalize(candidate); +} + +function extractGeminiCliRootFromPath(pathValue: string): string | null { + const normalized = pathValue.replace(/\\/g, "/"); + const marker = "/@google/gemini-cli/"; + const idx = normalized.indexOf(marker); + if (idx === -1) return null; + return normalize(`${normalized.slice(0, idx)}${marker.slice(0, -1)}`); +} + +function safeRealpath(pathValue: string): string | null { + try { + return realpathSync(pathValue); + } catch { + return null; + } +} + +function getGlobalNodeModulesRoots(): string[] { + const roots = new Set(); + const prefix = process.env.npm_config_prefix ?? process.env.NPM_CONFIG_PREFIX ?? process.env.PREFIX; + + if (prefix) { + if (process.platform === "win32") { + roots.add(join(prefix, "node_modules")); + } else { + roots.add(join(prefix, "lib", "node_modules")); + } + } + + if (process.platform === "win32") { + const appData = process.env.APPDATA; + if (appData) roots.add(join(appData, "npm", "node_modules")); + } else { + roots.add("/usr/local/lib/node_modules"); + roots.add("/opt/homebrew/lib/node_modules"); + } + + const pnpmHome = process.env.PNPM_HOME; + if (pnpmHome) { + roots.add(join(pnpmHome, "global", "5", "node_modules")); + roots.add(join(pnpmHome, "global", "4", "node_modules")); + } + + return [...roots].filter((root) => existsSync(root)); +} + +function shouldSearchRoot(root: string): boolean { + const normalized = root.replace(/\\/g, "/"); + if (normalized === "/" || /^[A-Za-z]:\/?$/.test(normalized)) return false; + const segments = normalized.split("/").filter(Boolean); + return segments.length >= 3; +} + function findInPath(name: string): string | null { const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""]; for (const dir of (process.env.PATH ?? "").split(delimiter)) { @@ -110,10 +215,10 @@ function findInPath(name: string): string | null { function findFile(dir: string, name: string, depth: number): string | null { if (depth <= 0) return null; try { - for (const e of readdirSync(dir, { withFileTypes: true })) { - const p = join(dir, e.name); - if (e.isFile() && e.name === name) return p; - if (e.isDirectory() && !e.name.startsWith(".")) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isFile() && entry.name === name) return p; + if (entry.isDirectory() && !entry.name.startsWith(".")) { const found = findFile(p, name, depth - 1); if (found) return found; } @@ -132,13 +237,13 @@ function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } // 2. Try to extract from installed Gemini CLI const extracted = extractGeminiCliCredentials(); - if (extracted) { + if (extracted?.clientId) { return extracted; } // 3. No credentials available throw new Error( - "Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.", + "Gemini CLI credentials not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.", ); }