diff --git a/extensions/google-gemini-cli-auth/README.md b/extensions/google-gemini-cli-auth/README.md index 6e4bdbd2b..99eab057f 100644 --- a/extensions/google-gemini-cli-auth/README.md +++ b/extensions/google-gemini-cli-auth/README.md @@ -18,7 +18,18 @@ Restart the Gateway after enabling. clawdbot models auth login --provider google-gemini-cli --set-default ``` -## Env vars +## Requirements + +Requires the Gemini CLI to be installed (credentials are extracted automatically): + +```bash +brew install gemini-cli +# or: npm install -g @google/gemini-cli +``` + +## Env vars (optional) + +Override auto-detected credentials with: - `CLAWDBOT_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID` - `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET` diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts new file mode 100644 index 000000000..9d186643a --- /dev/null +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { join } from "node:path"; + +// Mock fs module before importing the module under test +const mockExistsSync = vi.fn(); +const mockReadFileSync = vi.fn(); +const mockRealpathSync = vi.fn(); +const mockReaddirSync = vi.fn(); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: (...args: Parameters) => mockExistsSync(...args), + readFileSync: (...args: Parameters) => mockReadFileSync(...args), + realpathSync: (...args: Parameters) => mockRealpathSync(...args), + readdirSync: (...args: Parameters) => mockReaddirSync(...args), + }; +}); + +describe("extractGeminiCliCredentials", () => { + 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; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + originalPath = process.env.PATH; + }); + + afterEach(() => { + process.env.PATH = originalPath; + }); + + it("returns null when gemini binary is not in PATH", async () => { + process.env.PATH = "/nonexistent"; + mockExistsSync.mockReturnValue(false); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + expect(extractGeminiCliCredentials()).toBeNull(); + }); + + 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"; + + process.env.PATH = fakeBinDir; + + mockExistsSync.mockImplementation((p: string) => { + if (p === fakeGeminiPath) return true; + if (p === fakeOauth2Path) return true; + return false; + }); + mockRealpathSync.mockReturnValue(fakeResolvedPath); + mockReadFileSync.mockReturnValue(FAKE_OAUTH2_CONTENT); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + const result = extractGeminiCliCredentials(); + + expect(result).toEqual({ + clientId: FAKE_CLIENT_ID, + clientSecret: FAKE_CLIENT_SECRET, + }); + }); + + 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"; + + process.env.PATH = fakeBinDir; + + mockExistsSync.mockImplementation((p: string) => p === fakeGeminiPath); + mockRealpathSync.mockReturnValue(fakeResolvedPath); + mockReaddirSync.mockReturnValue([]); // Empty directory for recursive search + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + expect(extractGeminiCliCredentials()).toBeNull(); + }); + + 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"; + + process.env.PATH = fakeBinDir; + + mockExistsSync.mockImplementation((p: string) => { + if (p === fakeGeminiPath) return true; + if (p === fakeOauth2Path) return true; + return false; + }); + mockRealpathSync.mockReturnValue(fakeResolvedPath); + mockReadFileSync.mockReturnValue("// no credentials here"); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + expect(extractGeminiCliCredentials()).toBeNull(); + }); + + 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"; + + process.env.PATH = fakeBinDir; + + mockExistsSync.mockImplementation((p: string) => { + if (p === fakeGeminiPath) return true; + if (p === fakeOauth2Path) return true; + return false; + }); + mockRealpathSync.mockReturnValue(fakeResolvedPath); + mockReadFileSync.mockReturnValue(FAKE_OAUTH2_CONTENT); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + 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); + }); +}); diff --git a/extensions/google-gemini-cli-auth/oauth.ts b/extensions/google-gemini-cli-auth/oauth.ts index 0fc68aa5a..405b94641 100644 --- a/extensions/google-gemini-cli-auth/oauth.ts +++ b/extensions/google-gemini-cli-auth/oauth.ts @@ -1,6 +1,7 @@ import { createHash, randomBytes } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; import { createServer } from "node:http"; +import { delimiter, dirname, join } from "node:path"; const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; const CLIENT_SECRET_KEYS = [ @@ -47,15 +48,98 @@ function resolveEnv(keys: string[]): string | undefined { return undefined; } -function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } { - const clientId = resolveEnv(CLIENT_ID_KEYS); - if (!clientId) { - throw new Error( - "Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).", - ); +let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null; + +/** @internal */ +export function clearCredentialsCache(): void { + cachedGeminiCliCredentials = null; +} + +/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */ +export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { + if (cachedGeminiCliCredentials) return cachedGeminiCliCredentials; + + try { + const geminiPath = findInPath("gemini"); + if (!geminiPath) 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 secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); + if (idMatch && secretMatch) { + cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] }; + return cachedGeminiCliCredentials; + } + } catch { + // Gemini CLI not installed or extraction failed } - const clientSecret = resolveEnv(CLIENT_SECRET_KEYS); - return { clientId, clientSecret }; + return null; +} + +function findInPath(name: string): string | null { + const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""]; + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + for (const ext of exts) { + const p = join(dir, name + ext); + if (existsSync(p)) return p; + } + } + return 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(".")) { + const found = findFile(p, name, depth - 1); + if (found) return found; + } + } + } catch {} + return null; +} + +function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } { + // 1. Check env vars first (user override) + const envClientId = resolveEnv(CLIENT_ID_KEYS); + const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS); + if (envClientId) { + return { clientId: envClientId, clientSecret: envClientSecret }; + } + + // 2. Try to extract from installed Gemini CLI + const extracted = extractGeminiCliCredentials(); + if (extracted) { + 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.", + ); } function isWSL(): boolean {