fix(google-gemini-cli-auth): detect bundled npm installs (#60486) (#60486)

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
wzfmini01 2026-04-05 09:41:43 +02:00 committed by GitHub
parent acd8966ff0
commit ef5f47bd39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 87 additions and 4 deletions

View File

@ -103,6 +103,7 @@ Docs: https://docs.openclaw.ai
- Gateway/device auth: reuse cached device-token scopes only for cached-token reconnects, while keeping explicit `deviceToken` scope requests and empty-cache fallbacks intact so reconnects preserve `operator.read` without breaking explicit auth flows. (#46032) Thanks @caicongyang.
- Agents/scheduling: steer background-now work toward automatic completion wake and treat `process` polling as on-demand inspection or intervention instead of default completion handling. (#60877) Thanks @vincentkoc.
- Google Gemini CLI auth: improve OAuth credential discovery across Windows nvm and Homebrew libexec installs, and align Code Assist metadata so Gemini login stops failing on packaged CLI layouts. (#40729) Thanks @hughcube.
- Google Gemini CLI auth: detect bundled npm installs by scanning packaged bundle files for the Gemini OAuth client config, so `npm install -g @google/gemini-cli` layouts work again. (#60486) Thanks @wzfmini01.
- Mattermost/config schema: accept `groups.*.requireMention` again so existing Mattermost configs no longer fail strict validation after upgrade. (#58271) Thanks @MoerAI.
- Agents/failover: scope Anthropic `An unknown error occurred` failover matching by provider so generic internal unknown-error text no longer triggers retryable timeout fallback. (#59325) Thanks @aaron-he-zhu.
- Providers/OpenRouter failover: classify `403 "Key limit exceeded"` spending-limit responses as billing so model fallback continues instead of stopping on generic auth. (#59892) Thanks @rockcent.

View File

@ -63,6 +63,12 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret:
return directCredentials;
}
const bundledCredentials = readGeminiCliCredentialsFromBundle(geminiCliDir);
if (bundledCredentials) {
cachedGeminiCliCredentials = bundledCredentials;
return bundledCredentials;
}
const discoveredCredentials = findGeminiCliCredentialsInTree(geminiCliDir, 10);
if (discoveredCredentials) {
cachedGeminiCliCredentials = discoveredCredentials;
@ -143,12 +149,16 @@ function readGeminiCliCredentialsFile(
function parseGeminiCliCredentials(
content: string,
): { clientId: string; clientSecret: string } | null {
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (!idMatch || !secretMatch) {
const clientId =
content.match(/OAUTH_CLIENT_ID\s*=\s*["']([^"']+)["']/)?.[1] ??
content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/)?.[1];
const clientSecret =
content.match(/OAUTH_CLIENT_SECRET\s*=\s*["']([^"']+)["']/)?.[1] ??
content.match(/(GOCSPX-[A-Za-z0-9_-]+)/)?.[1];
if (!clientId || !clientSecret) {
return null;
}
return { clientId: idMatch[1], clientSecret: secretMatch[1] };
return { clientId, clientSecret };
}
function readGeminiCliCredentialsFromKnownPaths(
@ -189,6 +199,31 @@ function readGeminiCliCredentialsFromKnownPaths(
return null;
}
function readGeminiCliCredentialsFromBundle(
geminiCliDir: string,
): { clientId: string; clientSecret: string } | null {
const bundleDir = join(geminiCliDir, "bundle");
if (!credentialFs.existsSync(bundleDir)) {
return null;
}
try {
for (const entry of credentialFs.readdirSync(bundleDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".js")) {
continue;
}
const credentials = readGeminiCliCredentialsFile(join(bundleDir, entry.name));
if (credentials) {
return credentials;
}
}
} catch {
// Ignore bundle traversal failures and fall back to the recursive search.
}
return null;
}
function findGeminiCliCredentialsInTree(
dir: string,
depth: number,

View File

@ -156,6 +156,39 @@ describe("extractGeminiCliCredentials", () => {
}
}
function installBundledNpmLayout(params: { bundleContent: string }) {
const binDir = join(rootDir, "fake", "npm-bundle-bin");
const geminiPath = join(binDir, "gemini");
const resolvedPath = geminiPath;
const geminiCliDir = join(binDir, "node_modules", "@google", "gemini-cli");
const packageJsonPath = normalizePath(join(geminiCliDir, "package.json"));
const bundleDir = join(geminiCliDir, "bundle");
const chunkPath = join(bundleDir, "chunk-ABC123.js");
process.env.PATH = binDir;
mockExistsSync.mockImplementation((p: string) => {
const normalized = normalizePath(p);
return (
normalized === normalizePath(geminiPath) ||
normalized === packageJsonPath ||
normalized === normalizePath(bundleDir)
);
});
mockRealpathSync.mockReturnValue(resolvedPath);
mockReaddirSync.mockImplementation((p: string) => {
if (normalizePath(String(p)) === normalizePath(bundleDir)) {
return [dirent("chunk-ABC123.js", false)];
}
return [];
});
mockReadFileSync.mockImplementation((p: string) => {
if (normalizePath(String(p)) === normalizePath(chunkPath)) {
return params.bundleContent;
}
throw new Error(`Unexpected read for ${p}`);
});
}
function installHomebrewLibexecLayout(params: { oauth2Content: string }) {
const brewPrefix = join(rootDir, "opt", "homebrew");
const cellarRoot = join(brewPrefix, "Cellar", "gemini-cli", "1.2.3");
@ -339,6 +372,20 @@ describe("extractGeminiCliCredentials", () => {
expectFakeCliCredentials(result);
});
it("extracts credentials from bundled npm installs", async () => {
installBundledNpmLayout({
bundleContent: `
const OAUTH_CLIENT_ID = "${FAKE_CLIENT_ID}";
const OAUTH_CLIENT_SECRET = "${FAKE_CLIENT_SECRET}";
`,
});
clearCredentialsCache();
const result = extractGeminiCliCredentials();
expectFakeCliCredentials(result);
});
it("extracts credentials from Homebrew libexec installs", async () => {
installHomebrewLibexecLayout({ oauth2Content: FAKE_OAUTH2_CONTENT });