Compare commits
2 Commits
main
...
fix/gemini
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6607f865cf | ||
|
|
f0f5bc6cf1 |
@ -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.
|
||||
|
||||
@ -18,7 +18,18 @@ Restart the Gateway after enabling.
|
||||
clawdbot models auth login --provider google-gemini-cli --set-default
|
||||
```
|
||||
|
||||
## Env vars
|
||||
## Requirements
|
||||
|
||||
If the Gemini CLI is installed, Clawdbot extracts the OAuth client ID 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`
|
||||
|
||||
205
extensions/google-gemini-cli-auth/oauth.test.ts
Normal file
205
extensions/google-gemini-cli-auth/oauth.test.ts
Normal file
@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } 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}";
|
||||
`;
|
||||
const FAKE_OAUTH2_ID_ONLY = `
|
||||
const clientId = "${FAKE_CLIENT_ID}";
|
||||
`;
|
||||
|
||||
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 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_CONTENT;
|
||||
return "";
|
||||
});
|
||||
|
||||
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 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 || 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();
|
||||
expect(extractGeminiCliCredentials()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when oauth2.js lacks credentials", 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 "// no credentials here";
|
||||
return "";
|
||||
});
|
||||
|
||||
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 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_CONTENT;
|
||||
return "";
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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, isAbsolute, join, normalize } from "node:path";
|
||||
|
||||
const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
|
||||
const CLIENT_SECRET_KEYS = [
|
||||
@ -47,15 +48,203 @@ 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).",
|
||||
);
|
||||
type GeminiCliClientConfig = { clientId: string; clientSecret?: string };
|
||||
|
||||
let cachedGeminiCliCredentials: GeminiCliClientConfig | null = null;
|
||||
|
||||
/** @internal */
|
||||
export function clearCredentialsCache(): void {
|
||||
cachedGeminiCliCredentials = null;
|
||||
}
|
||||
|
||||
/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */
|
||||
export function extractGeminiCliCredentials(): GeminiCliClientConfig | null {
|
||||
if (cachedGeminiCliCredentials) return cachedGeminiCliCredentials;
|
||||
|
||||
try {
|
||||
const oauthPath = findGeminiCliOAuthPath();
|
||||
if (!oauthPath) return null;
|
||||
|
||||
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) {
|
||||
cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch?.[1] };
|
||||
return cachedGeminiCliCredentials;
|
||||
}
|
||||
const clientSecret = resolveEnv(CLIENT_SECRET_KEYS);
|
||||
return { clientId, clientSecret };
|
||||
} catch {
|
||||
// Gemini CLI not installed or extraction failed
|
||||
}
|
||||
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<string>();
|
||||
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<string>();
|
||||
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)) {
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
} 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?.clientId) {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
// 3. No credentials available
|
||||
throw new Error(
|
||||
"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.",
|
||||
);
|
||||
}
|
||||
|
||||
function isWSL(): boolean {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user