import { execFile, execFileSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'; import { createServer } from 'node:http'; import { delimiter, dirname, join } from 'node:path'; import { getClawXConfigDir } from './paths'; const CLIENT_ID_KEYS = ['OPENCLAW_GEMINI_OAUTH_CLIENT_ID', 'GEMINI_CLI_OAUTH_CLIENT_ID']; const CLIENT_SECRET_KEYS = [ 'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET', 'GEMINI_CLI_OAUTH_CLIENT_SECRET', ]; const REDIRECT_URI = 'http://127.0.0.1:8085/oauth2callback'; const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; const TOKEN_URL = 'https://oauth2.googleapis.com/token'; const USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json'; const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'; const SCOPES = [ 'https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', ]; const TIER_FREE = 'free-tier'; const TIER_LEGACY = 'legacy-tier'; const TIER_STANDARD = 'standard-tier'; const LOCAL_GEMINI_DIR = join(getClawXConfigDir(), 'gemini-cli'); export type GeminiCliOAuthCredentials = { access: string; refresh: string; expires: number; email?: string; projectId?: string; }; export type GeminiCliOAuthContext = { isRemote: boolean; openUrl: (url: string) => Promise; log: (msg: string) => void; note: (message: string, title?: string) => Promise; prompt: (message: string) => Promise; progress: { update: (msg: string) => void; stop: (msg?: string) => void }; }; export class DetailedError extends Error { detail: string; constructor(message: string, detail: string) { super(message); this.name = 'DetailedError'; this.detail = detail; } } let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null; function resolveEnv(keys: string[]): string | undefined { for (const key of keys) { const value = process.env[key]?.trim(); if (value) { return value; } } return undefined; } function findInPath(name: string): string | null { const exts = process.platform === 'win32' ? ['.cmd', '.bat', '.exe', ''] : ['']; for (const dir of (process.env.PATH ?? '').split(delimiter)) { if (!dir) continue; 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 next = join(dir, entry.name); if (entry.isFile() && entry.name === name) { return next; } if (entry.isDirectory() && !entry.name.startsWith('.')) { const found = findFile(next, name, depth - 1); if (found) { return found; } } } } catch { return null; } return null; } 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 { return null; } return null; } function extractFromLocalInstall(): { clientId: string; clientSecret: string } | null { const coreDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core'); if (!existsSync(coreDir)) { return null; } const searchPaths = [ join(coreDir, 'dist', 'src', 'code_assist', 'oauth2.js'), join(coreDir, '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(coreDir, '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) { return { clientId: idMatch[1], clientSecret: secretMatch[1] }; } return null; } async function installViaNpm(onProgress?: (msg: string) => void): Promise { const npmBin = findInPath('npm'); if (!npmBin) { return false; } onProgress?.('Installing Gemini OAuth helper...'); return await new Promise((resolve) => { const useShell = process.platform === 'win32'; const child = execFile( npmBin, ['install', '--prefix', LOCAL_GEMINI_DIR, '@google/gemini-cli'], { timeout: 120_000, shell: useShell, env: { ...process.env, NODE_ENV: '' } }, (err) => { if (err) { onProgress?.(`Gemini helper install failed, falling back to direct download...`); resolve(false); } else { cachedGeminiCliCredentials = null; onProgress?.('Gemini OAuth helper installed'); resolve(true); } }, ); child.stderr?.on('data', () => { // Suppress npm noise. }); }); } async function installViaDirectDownload(onProgress?: (msg: string) => void): Promise { try { onProgress?.('Downloading Gemini OAuth helper...'); const metaRes = await fetch('https://registry.npmjs.org/@google/gemini-cli-core/latest'); if (!metaRes.ok) { onProgress?.(`Failed to fetch Gemini package metadata: ${metaRes.status}`); return false; } const meta = (await metaRes.json()) as { dist?: { tarball?: string } }; const tarballUrl = meta.dist?.tarball; if (!tarballUrl) { onProgress?.('Gemini package tarball URL missing'); return false; } const tarRes = await fetch(tarballUrl); if (!tarRes.ok) { onProgress?.(`Failed to download Gemini package: ${tarRes.status}`); return false; } const buffer = Buffer.from(await tarRes.arrayBuffer()); const targetDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core'); mkdirSync(targetDir, { recursive: true }); const tmpFile = join(LOCAL_GEMINI_DIR, '_tmp_gemini-cli-core.tgz'); writeFileSync(tmpFile, buffer); try { execFileSync('tar', ['xzf', tmpFile, '-C', targetDir, '--strip-components=1'], { timeout: 30_000, }); } finally { try { unlinkSync(tmpFile); } catch { // ignore } } cachedGeminiCliCredentials = null; onProgress?.('Gemini OAuth helper ready'); return true; } catch (err) { onProgress?.(`Direct Gemini helper download failed: ${err instanceof Error ? err.message : String(err)}`); return false; } } async function ensureOAuthClientConfig( onProgress?: (msg: string) => void, ): Promise<{ clientId: string; clientSecret?: string }> { const envClientId = resolveEnv(CLIENT_ID_KEYS); const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS); if (envClientId) { return { clientId: envClientId, clientSecret: envClientSecret }; } const extracted = extractGeminiCliCredentials(); if (extracted) { return extracted; } const localExtracted = extractFromLocalInstall(); if (localExtracted) { return localExtracted; } mkdirSync(LOCAL_GEMINI_DIR, { recursive: true }); const installed = await installViaNpm(onProgress) || await installViaDirectDownload(onProgress); if (installed) { const installedExtracted = extractFromLocalInstall(); if (installedExtracted) { return installedExtracted; } } throw new Error( 'Unable to prepare Gemini OAuth credentials automatically. Set GEMINI_CLI_OAUTH_CLIENT_ID or try again later.', ); } function generatePkce(): { verifier: string; challenge: string } { const verifier = randomBytes(32).toString('hex'); const challenge = createHash('sha256').update(verifier).digest('base64url'); return { verifier, challenge }; } function buildAuthUrl(clientId: string, challenge: string, verifier: string): string { const params = new URLSearchParams({ client_id: clientId, response_type: 'code', redirect_uri: REDIRECT_URI, scope: SCOPES.join(' '), code_challenge: challenge, code_challenge_method: 'S256', state: verifier, access_type: 'offline', prompt: 'consent', }); return `${AUTH_URL}?${params.toString()}`; } async function waitForLocalCallback(params: { expectedState: string; timeoutMs: number; onProgress?: (message: string) => void; }): Promise<{ code: string; state: string }> { const port = 8085; const hostname = '127.0.0.1'; const expectedPath = '/oauth2callback'; return new Promise((resolve, reject) => { let timeout: NodeJS.Timeout | null = null; const server = createServer((req, res) => { try { const requestUrl = new URL(req.url ?? '/', `http://${hostname}:${port}`); if (requestUrl.pathname !== expectedPath) { res.statusCode = 404; res.setHeader('Content-Type', 'text/plain'); res.end('Not found'); return; } const error = requestUrl.searchParams.get('error'); const code = requestUrl.searchParams.get('code')?.trim(); const state = requestUrl.searchParams.get('state')?.trim(); if (error) { res.statusCode = 400; res.setHeader('Content-Type', 'text/plain'); res.end(`Authentication failed: ${error}`); finish(new Error(`OAuth error: ${error}`)); return; } if (!code || !state) { res.statusCode = 400; res.setHeader('Content-Type', 'text/plain'); res.end('Missing code or state'); finish(new Error('Missing OAuth code or state')); return; } if (state !== params.expectedState) { res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end( "

Session expired

This authorization link is from a previous attempt. Please go back to ClawX and try again.

", ); return; } res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end( "

Gemini CLI OAuth complete

You can close this window and return to ClawX.

", ); finish(undefined, { code, state }); } catch (err) { finish(err instanceof Error ? err : new Error('OAuth callback failed')); } }); const finish = (err?: Error, result?: { code: string; state: string }) => { if (timeout) { clearTimeout(timeout); } try { server.close(); } catch { // ignore } if (err) { reject(err); } else if (result) { resolve(result); } }; server.once('error', (err) => { finish(err instanceof Error ? err : new Error('OAuth callback server error')); }); server.listen(port, hostname, () => { params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}...`); }); timeout = setTimeout(() => { finish(new DetailedError( 'OAuth login timed out. The browser did not redirect back. Check if localhost:8085 is blocked.', `Waited ${params.timeoutMs / 1000}s for callback on ${hostname}:${port}`, )); }, params.timeoutMs); }); } async function getUserEmail(accessToken: string): Promise { try { const response = await fetch(USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (response.ok) { const data = (await response.json()) as { email?: string }; return data.email; } } catch { // ignore } return undefined; } function getDefaultTier( allowedTiers?: Array<{ id?: string; isDefault?: boolean }>, ): { id?: string } | undefined { if (!allowedTiers?.length) { return { id: TIER_LEGACY }; } return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY }; } function isVpcScAffected(payload: unknown): boolean { if (!payload || typeof payload !== 'object') { return false; } const error = (payload as { error?: unknown }).error; if (!error || typeof error !== 'object') { return false; } const details = (error as { details?: unknown[] }).details; if (!Array.isArray(details)) { return false; } return details.some( (item) => typeof item === 'object' && item && (item as { reason?: string }).reason === 'SECURITY_POLICY_VIOLATED', ); } async function pollOperation( operationName: string, headers: Record, ): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> { for (let attempt = 0; attempt < 24; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 5000)); const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { headers }); if (!response.ok) { continue; } const data = (await response.json()) as { done?: boolean; response?: { cloudaicompanionProject?: { id?: string } }; }; if (data.done) { return data; } } throw new Error('Operation polling timeout'); } async function discoverProject(accessToken: string): Promise { const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; const headers = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'User-Agent': 'google-api-nodejs-client/9.15.1', 'X-Goog-Api-Client': 'gl-node/clawx', }; const loadBody = { cloudaicompanionProject: envProject, metadata: { ideType: 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI', duetProject: envProject, }, }; let data: { currentTier?: { id?: string }; cloudaicompanionProject?: string | { id?: string }; allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; } = {}; const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, { method: 'POST', headers, body: JSON.stringify(loadBody), }); if (!response.ok) { const errorPayload = await response.json().catch(() => null); if (isVpcScAffected(errorPayload)) { data = { currentTier: { id: TIER_STANDARD } }; } else { throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`); } } else { data = (await response.json()) as typeof data; } if (data.currentTier) { const project = data.cloudaicompanionProject; if (typeof project === 'string' && project) { return project; } if (typeof project === 'object' && project?.id) { return project.id; } if (envProject) { return envProject; } } const hasExistingTierButNoProject = !!data.currentTier; const tier = hasExistingTierButNoProject ? { id: TIER_FREE } : getDefaultTier(data.allowedTiers); const tierId = tier?.id || TIER_FREE; if (tierId !== TIER_FREE && !envProject) { throw new DetailedError( 'Your Google account requires a Cloud project. Please create one and set GOOGLE_CLOUD_PROJECT.', `tierId=${tierId}, currentTier=${JSON.stringify(data.currentTier ?? null)}, allowedTiers=${JSON.stringify(data.allowedTiers)}`, ); } const onboardBody: Record = { tierId, metadata: { ideType: 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI', }, }; if (tierId !== TIER_FREE && envProject) { onboardBody.cloudaicompanionProject = envProject; (onboardBody.metadata as Record).duetProject = envProject; } const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, { method: 'POST', headers, body: JSON.stringify(onboardBody), }); if (!onboardResponse.ok) { const respText = await onboardResponse.text().catch(() => ''); throw new DetailedError( 'Google project provisioning failed. Please try again later.', `onboardUser ${onboardResponse.status} ${onboardResponse.statusText}: ${respText}`, ); } let lro = (await onboardResponse.json()) as { done?: boolean; name?: string; response?: { cloudaicompanionProject?: { id?: string } }; }; if (!lro.done && lro.name) { lro = await pollOperation(lro.name, headers); } const projectId = lro.response?.cloudaicompanionProject?.id; if (projectId) { return projectId; } if (envProject) { return envProject; } throw new DetailedError( 'Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.', `tierId=${tierId}, onboardResponse=${JSON.stringify(lro)}, currentTier=${JSON.stringify(data.currentTier ?? null)}`, ); } async function exchangeCodeForTokens( code: string, verifier: string, clientConfig: { clientId: string; clientSecret?: string }, ): Promise { const { clientId, clientSecret } = clientConfig; const body = new URLSearchParams({ client_id: clientId, code, grant_type: 'authorization_code', redirect_uri: REDIRECT_URI, code_verifier: verifier, }); if (clientSecret) { body.set('client_secret', clientSecret); } const response = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token exchange failed: ${errorText}`); } const data = (await response.json()) as { access_token: string; refresh_token: string; expires_in: number; }; if (!data.refresh_token) { throw new Error('No refresh token received. Please try again.'); } const email = await getUserEmail(data.access_token); const projectId = await discoverProject(data.access_token); const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; return { refresh: data.refresh_token, access: data.access_token, expires: expiresAt, projectId, email, }; } export async function loginGeminiCliOAuth( ctx: GeminiCliOAuthContext, ): Promise { if (ctx.isRemote) { throw new Error('Remote/manual Gemini OAuth is not implemented in ClawX yet.'); } await ctx.note( [ 'Browser will open for Google authentication.', 'Sign in with your Google account for Gemini CLI access.', 'The callback will be captured automatically on 127.0.0.1:8085.', ].join('\n'), 'Gemini CLI OAuth', ); ctx.progress.update('Preparing Google OAuth...'); const clientConfig = await ensureOAuthClientConfig((msg) => ctx.progress.update(msg)); const { verifier, challenge } = generatePkce(); const authUrl = buildAuthUrl(clientConfig.clientId, challenge, verifier); ctx.progress.update('Complete sign-in in browser...'); try { await ctx.openUrl(authUrl); } catch { ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`); } try { const { code } = await waitForLocalCallback({ expectedState: verifier, timeoutMs: 5 * 60 * 1000, onProgress: (msg) => ctx.progress.update(msg), }); ctx.progress.update('Exchanging authorization code for tokens...'); return await exchangeCodeForTokens(code, verifier, clientConfig); } catch (err) { if ( err instanceof Error && (err.message.includes('EADDRINUSE') || err.message.includes('port') || err.message.includes('listen')) ) { throw new Error( 'Port 8085 is in use by another process. Close the other application using port 8085 and try again.', { cause: err }, ); } throw err; } } // Best-effort check to help with diagnostics if the user claims gemini is installed but PATH is stale. export function detectGeminiCliVersion(): string | null { try { const geminiPath = findInPath('gemini'); if (!geminiPath) { return null; } return execFileSync(geminiPath, ['--version'], { encoding: 'utf8' }).trim(); } catch { return null; } }