fix(gemini-cli-auth): auto-extract OAuth credentials from installed Gemini CLI (#1773)
Fixes #1765 - Extract client ID and secret from Gemini CLI's bundled oauth2.js - Cross-platform binary lookup (no shell commands) - Fallback to env vars for user override - Add tests for credential extraction
This commit is contained in:
@@ -18,7 +18,18 @@ Restart the Gateway after enabling.
|
|||||||
clawdbot models auth login --provider google-gemini-cli --set-default
|
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_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
|
||||||
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
|
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
|
||||||
|
|||||||
145
extensions/google-gemini-cli-auth/oauth.test.ts
Normal file
145
extensions/google-gemini-cli-auth/oauth.test.ts
Normal file
@@ -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<typeof import("node:fs")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
existsSync: (...args: Parameters<typeof actual.existsSync>) => mockExistsSync(...args),
|
||||||
|
readFileSync: (...args: Parameters<typeof actual.readFileSync>) => mockReadFileSync(...args),
|
||||||
|
realpathSync: (...args: Parameters<typeof actual.realpathSync>) => mockRealpathSync(...args),
|
||||||
|
readdirSync: (...args: Parameters<typeof actual.readdirSync>) => 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
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 { 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_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
|
||||||
const CLIENT_SECRET_KEYS = [
|
const CLIENT_SECRET_KEYS = [
|
||||||
@@ -47,15 +48,98 @@ function resolveEnv(keys: string[]): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
|
let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null;
|
||||||
const clientId = resolveEnv(CLIENT_ID_KEYS);
|
|
||||||
if (!clientId) {
|
/** @internal */
|
||||||
throw new Error(
|
export function clearCredentialsCache(): void {
|
||||||
"Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).",
|
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 null;
|
||||||
return { clientId, clientSecret };
|
}
|
||||||
|
|
||||||
|
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 {
|
function isWSL(): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user