fix: harden gemini cli oauth discovery (#1773) (thanks @benostein)

This commit is contained in:
Peter Steinberger 2026-01-25 13:07:03 +00:00
parent f0f5bc6cf1
commit 6607f865cf
4 changed files with 217 additions and 51 deletions

View File

@ -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.

View File

@ -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

View File

@ -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,
});
});
});

View File

@ -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<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)) {
@ -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.",
);
}