From f289ce8419d776a807ecb31b8f1434049b71bb82 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Thu, 7 May 2026 12:47:00 +0300 Subject: [PATCH] fix(gemini): align headless session/auth flow with async CLI behavior Why: - Gemini does not expose a new session id synchronously. It emits the canonical id in the init stream event. - Creating temporary ids in web mode introduced identity drift, extra mapping logic, and harder resume/debug behavior. - Headless server runs often miss shell-inherited auth vars, while users configure Gemini through user-level env files. - Gemini mirrors session artifacts across folders, which caused duplicate sync events and duplicate session rows. What changed: - Removed temporary Gemini ids for new sessions. - New Gemini sessions are now created only after init provides session_id. - Persisted cliSessionId from the discovered canonical id, keeping one identifier across stream, storage, and resume. - Built Gemini spawn env from process env plus user-level fallback files: ~/.gemini/.env then ~/.env, honoring GEMINI_CLI_HOME. - Added --skip-trust for headless runs, because web flows cannot answer interactive trust prompts. - Improved terminal error mapping and rejection reasons, especially for auth exit code 41 with actionable context. - Limited Gemini synchronization to tmp JSONL chat artifacts, and disabled duplicate watcher/index paths that mirror the same sessions. - Added gemini-2.5-flash-lite to shared model constants. Result: - Gemini session identity is canonical and provider-consistent. - Headless auth now matches practical Gemini CLI configuration patterns. - Duplicate Gemini session indexing is reduced at the source. - Operators get clearer, actionable failure messages. --- server/gemini-cli.js | 231 +++++++++++++++--- .../list/gemini/gemini-auth.provider.ts | 173 ++++++++++++- .../gemini-session-synchronizer.provider.ts | 44 ++-- .../services/sessions-watcher.service.ts | 10 +- shared/modelConstants.js | 1 + 5 files changed, 394 insertions(+), 65 deletions(-) diff --git a/server/gemini-cli.js b/server/gemini-cli.js index 2e68a938..0a35634a 100644 --- a/server/gemini-cli.js +++ b/server/gemini-cli.js @@ -1,19 +1,125 @@ import { spawn } from 'child_process'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; + import crossSpawn from 'cross-spawn'; -// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js) -const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; -import { promises as fs } from 'fs'; -import path from 'path'; -import os from 'os'; import sessionManager from './sessionManager.js'; import GeminiResponseHandler from './gemini-response-handler.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { createNormalizedMessage } from './shared/utils.js'; +// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js) +const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; + let activeGeminiProcesses = new Map(); // Track active processes by session ID +function mapGeminiExitCodeToMessage(exitCode) { + switch (exitCode) { + case 41: + return 'Gemini authentication failed (exit code 41). Run `gemini` in a terminal to choose an auth method, or configure a valid `GEMINI_API_KEY`.'; + case 42: + return 'Gemini rejected the request input (exit code 42).'; + case 44: + return 'Gemini sandbox error (exit code 44). Check local sandbox/container settings.'; + case 52: + return 'Gemini configuration error (exit code 52). Check your Gemini settings files for invalid JSON/config.'; + case 53: + return 'Gemini conversation turn limit reached (exit code 53). Start a new Gemini session.'; + default: + return null; + } +} + +const GEMINI_AUTH_ENV_KEYS = [ + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_CLOUD_PROJECT_ID', + 'GOOGLE_CLOUD_LOCATION', + 'GOOGLE_APPLICATION_CREDENTIALS' +]; + +function parseEnvFileContent(content) { + const parsed = {}; + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + continue; + } + + const exportPrefix = 'export '; + const normalizedLine = line.startsWith(exportPrefix) ? line.slice(exportPrefix.length).trim() : line; + const separatorIndex = normalizedLine.indexOf('='); + + if (separatorIndex <= 0) { + continue; + } + + const key = normalizedLine.slice(0, separatorIndex).trim(); + if (!key) { + continue; + } + + let value = normalizedLine.slice(separatorIndex + 1).trim(); + const hasDoubleQuotes = value.startsWith('"') && value.endsWith('"'); + const hasSingleQuotes = value.startsWith('\'') && value.endsWith('\''); + + if (hasDoubleQuotes || hasSingleQuotes) { + value = value.slice(1, -1); + } else { + // Support inline comments in unquoted values: KEY=value # comment + value = value.replace(/\s+#.*$/, '').trim(); + } + + parsed[key] = value; + } + + return parsed; +} + +async function loadGeminiUserLevelEnv() { + const geminiCliHome = (process.env.GEMINI_CLI_HOME || '').trim() || os.homedir(); + const envCandidates = [ + path.join(geminiCliHome, '.gemini', '.env'), + path.join(geminiCliHome, '.env') + ]; + + for (const envPath of envCandidates) { + try { + await fs.access(envPath); + const content = await fs.readFile(envPath, 'utf8'); + return parseEnvFileContent(content); + } catch { + // Keep scanning for the next candidate. + } + } + + return {}; +} + +async function buildGeminiProcessEnv() { + const processEnv = { ...process.env }; + if (GEMINI_AUTH_ENV_KEYS.every((key) => processEnv[key])) { + return processEnv; + } + + // Gemini CLI docs recommend ~/.gemini/.env for persistent headless auth settings. + // When the server process was launched without shell profile variables, we still + // want the spawned CLI process to inherit those user-level credentials. + const userEnv = await loadGeminiUserLevelEnv(); + for (const key of GEMINI_AUTH_ENV_KEYS) { + if (!processEnv[key] && userEnv[key]) { + processEnv[key] = userEnv[key]; + } + } + + return processEnv; +} + async function spawnGemini(command, options = {}, ws) { const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options; let capturedSessionId = sessionId; // Track session ID throughout the process @@ -100,6 +206,11 @@ async function spawnGemini(command, options = {}, ws) { args.push('--debug'); } + // This integration runs Gemini in headless mode and cannot answer trust prompts. + // Skip folder-trust interactivity so authenticated runs don't fail with + // FatalUntrustedWorkspaceError in previously unseen directories. + args.push('--skip-trust'); + // Add MCP config flag only if MCP servers are configured try { const geminiConfigPath = path.join(os.homedir(), '.gemini.json'); @@ -168,11 +279,13 @@ async function spawnGemini(command, options = {}, ws) { spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args]; } + const spawnEnv = await buildGeminiProcessEnv(); + return new Promise((resolve, reject) => { const geminiProcess = spawnFunction(spawnCmd, spawnArgs, { cwd: workingDir, stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env } // Inherit all environment variables + env: spawnEnv }); let terminalNotificationSent = false; let terminalFailureReason = null; @@ -276,12 +389,43 @@ async function spawnGemini(command, options = {}, ws) { } }, onInit: (event) => { - if (capturedSessionId) { - const sess = sessionManager.getSession(capturedSessionId); - if (sess && !sess.cliSessionId) { - sess.cliSessionId = event.session_id; - sessionManager.saveSession(capturedSessionId); + const discoveredSessionId = event?.session_id; + if (!discoveredSessionId) { + return; + } + + // New Gemini sessions announce their canonical ID asynchronously via the + // initial `init` stream event. Avoid synthetic IDs and only register + // the session once that real ID is known (same model used by Claude/Codex). + if (!capturedSessionId) { + capturedSessionId = discoveredSessionId; + + sessionManager.createSession(capturedSessionId, cwd || process.cwd()); + if (command) { + sessionManager.addMessage(capturedSessionId, 'user', command); } + + if (processKey !== capturedSessionId) { + activeGeminiProcesses.delete(processKey); + activeGeminiProcesses.set(capturedSessionId, geminiProcess); + } + + geminiProcess.sessionId = capturedSessionId; + + if (ws.setSessionId && typeof ws.setSessionId === 'function') { + ws.setSessionId(capturedSessionId); + } + + if (!sessionId && !sessionCreatedSent) { + sessionCreatedSent = true; + ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' })); + } + } + + const sess = sessionManager.getSession(capturedSessionId); + if (sess && !sess.cliSessionId) { + sess.cliSessionId = discoveredSessionId; + sessionManager.saveSession(capturedSessionId); } } }); @@ -292,30 +436,6 @@ async function spawnGemini(command, options = {}, ws) { const rawOutput = data.toString(); startTimeout(); // Re-arm the timeout - // For new sessions, create a session ID FIRST - if (!sessionId && !sessionCreatedSent && !capturedSessionId) { - capturedSessionId = `gemini_${Date.now()}`; - sessionCreatedSent = true; - - // Create session in session manager - sessionManager.createSession(capturedSessionId, cwd || process.cwd()); - - // Save the user message now that we have a session ID - if (command) { - sessionManager.addMessage(capturedSessionId, 'user', command); - } - - // Update process key with captured session ID - if (processKey !== capturedSessionId) { - activeGeminiProcesses.delete(processKey); - activeGeminiProcesses.set(capturedSessionId, geminiProcess); - } - - ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId); - - ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' })); - } - if (responseHandler) { responseHandler.processData(rawOutput); } else if (rawOutput) { @@ -381,12 +501,38 @@ async function spawnGemini(command, options = {}, ws) { notifyTerminalState({ code }); resolve(); } else { - // code 127 = shell "command not found" — check installation + const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; + + // code 127 = shell "command not found" - check installation if (code === 127) { const installed = await providerAuthService.isProviderInstalled('gemini'); if (!installed) { - const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; - ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' })); + terminalFailureReason = 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli'; + ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' })); + } + } else if (code === 41) { + // Gemini CLI documents exit code 41 as FatalAuthenticationError. + // Surface an actionable auth error instead of a generic exit-code message. + let authErrorSuffix = ''; + try { + const authStatus = await providerAuthService.getProviderAuthStatus('gemini'); + if (!authStatus?.authenticated && authStatus?.error) { + authErrorSuffix = ` Details: ${authStatus.error}`; + } + } catch { + // Keep base remediation text when auth status lookup fails. + } + + terminalFailureReason = + 'Gemini authentication failed (exit code 41). ' + + 'Run `gemini` in a terminal to choose an auth method, or configure `GEMINI_API_KEY`.' + + authErrorSuffix; + ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' })); + } else { + const mappedError = mapGeminiExitCodeToMessage(code); + if (mappedError) { + terminalFailureReason = mappedError; + ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' })); } } @@ -394,7 +540,14 @@ async function spawnGemini(command, options = {}, ws) { code, error: code === null ? 'Gemini CLI process was terminated or timed out' : null }); - reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`)); + reject( + new Error( + terminalFailureReason + || (code === null + ? 'Gemini CLI process was terminated or timed out' + : `Gemini CLI exited with code ${code}`) + ) + ); } }); diff --git a/server/modules/providers/list/gemini/gemini-auth.provider.ts b/server/modules/providers/list/gemini/gemini-auth.provider.ts index 60b0749e..9ae43c9e 100644 --- a/server/modules/providers/list/gemini/gemini-auth.provider.ts +++ b/server/modules/providers/list/gemini/gemini-auth.provider.ts @@ -15,7 +15,37 @@ type GeminiCredentialsStatus = { error?: string; }; +type GeminiAuthType = + | 'oauth-personal' + | 'gemini-api-key' + | 'vertex-ai' + | 'compute-default-credentials' + | 'gateway' + | 'cloud-shell' + | null; + +/** + * Env keys Gemini CLI accepts for headless authentication. + * We check these from both process.env and user-level env files. + */ +const GEMINI_AUTH_ENV_KEYS = [ + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_CLOUD_PROJECT_ID', + 'GOOGLE_CLOUD_LOCATION', + 'GOOGLE_APPLICATION_CREDENTIALS', +] as const; + export class GeminiProviderAuth implements IProviderAuth { + /** + * Gemini CLI can override its home root via GEMINI_CLI_HOME. + * Use the same resolution so status checks match runtime behavior. + */ + private getGeminiCliHome(): string { + return process.env.GEMINI_CLI_HOME?.trim() || os.homedir(); + } + /** * Checks whether the Gemini CLI is available on this host. */ @@ -58,6 +88,88 @@ export class GeminiProviderAuth implements IProviderAuth { }; } + /** + * Parses dotenv-style key/value pairs. + */ + private parseEnvFile(content: string): Record { + const parsed: Record = {}; + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + continue; + } + + const normalizedLine = line.startsWith('export ') + ? line.slice('export '.length).trim() + : line; + const separatorIndex = normalizedLine.indexOf('='); + if (separatorIndex <= 0) { + continue; + } + + const key = normalizedLine.slice(0, separatorIndex).trim(); + if (!key) { + continue; + } + + let value = normalizedLine.slice(separatorIndex + 1).trim(); + const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\'')); + if (quoted) { + value = value.slice(1, -1); + } else { + value = value.replace(/\s+#.*$/, '').trim(); + } + + parsed[key] = value; + } + + return parsed; + } + + /** + * Loads user-level auth env in Gemini's "first file found" order. + */ + private async loadUserLevelAuthEnv(): Promise> { + const geminiCliHome = this.getGeminiCliHome(); + const envCandidates = [ + path.join(geminiCliHome, '.gemini', '.env'), + path.join(geminiCliHome, '.env'), + ]; + + for (const envPath of envCandidates) { + try { + const content = await readFile(envPath, 'utf8'); + return this.parseEnvFile(content); + } catch { + // Continue to the next fallback. + } + } + + return {}; + } + + /** + * Reads Gemini's selected auth type from settings.json when available. + */ + private async readSelectedAuthType(): Promise { + try { + const settingsPath = path.join(this.getGeminiCliHome(), '.gemini', 'settings.json'); + const content = await readFile(settingsPath, 'utf8'); + const settings = readObjectRecord(JSON.parse(content)); + const security = readObjectRecord(settings?.security); + const auth = readObjectRecord(security?.auth); + const selectedType = readOptionalString(auth?.selectedType); + if (!selectedType) { + return null; + } + + return selectedType as GeminiAuthType; + } catch { + return null; + } + } + /** * Checks Gemini credentials from API key env vars or local OAuth credential files. */ @@ -66,8 +178,46 @@ export class GeminiProviderAuth implements IProviderAuth { return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; } + const userEnv = await this.loadUserLevelAuthEnv(); + if (readOptionalString(userEnv.GEMINI_API_KEY)) { + return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; + } + + const selectedType = await this.readSelectedAuthType(); + if (selectedType === 'vertex-ai') { + const hasGoogleApiKey = Boolean( + process.env.GOOGLE_API_KEY?.trim() + || readOptionalString(userEnv.GOOGLE_API_KEY) + ); + const hasProject = Boolean( + process.env.GOOGLE_CLOUD_PROJECT?.trim() + || process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() + || readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT) + || readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT_ID) + ); + const hasLocation = Boolean( + process.env.GOOGLE_CLOUD_LOCATION?.trim() + || readOptionalString(userEnv.GOOGLE_CLOUD_LOCATION) + ); + const hasServiceAccount = Boolean( + process.env.GOOGLE_APPLICATION_CREDENTIALS?.trim() + || readOptionalString(userEnv.GOOGLE_APPLICATION_CREDENTIALS) + ); + + if (hasGoogleApiKey || hasServiceAccount || (hasProject && hasLocation)) { + return { authenticated: true, email: 'Vertex AI Auth', method: 'vertex_ai' }; + } + + return { + authenticated: false, + email: null, + method: 'vertex_ai', + error: 'Gemini is set to Vertex AI, but required env vars are missing', + }; + } + try { - const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + const credsPath = path.join(this.getGeminiCliHome(), '.gemini', 'oauth_creds.json'); const content = await readFile(credsPath, 'utf8'); const creds = readObjectRecord(JSON.parse(content)) ?? {}; const accessToken = readOptionalString(creds.access_token); @@ -106,6 +256,25 @@ export class GeminiProviderAuth implements IProviderAuth { method: 'credentials_file', }; } catch { + if (selectedType === 'gemini-api-key') { + return { + authenticated: false, + email: null, + method: 'api_key', + error: 'Gemini is set to "Use Gemini API key", but GEMINI_API_KEY is unavailable', + }; + } + + if (selectedType === 'oauth-personal') { + return { + authenticated: false, + email: null, + method: 'credentials_file', + error: 'Gemini is set to Google sign-in, but no cached OAuth credentials were found', + }; + } + + // If no explicit auth type was selected, surface the generic "not configured" error. return { authenticated: false, email: null, @@ -140,7 +309,7 @@ export class GeminiProviderAuth implements IProviderAuth { */ private async getActiveAccountEmail(): Promise { try { - const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); + const accPath = path.join(this.getGeminiCliHome(), '.gemini', 'google_accounts.json'); const accContent = await readFile(accPath, 'utf8'); const accounts = readObjectRecord(JSON.parse(accContent)); return readOptionalString(accounts?.active) ?? null; diff --git a/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts b/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts index 52c62e9b..7ec3eff9 100644 --- a/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts +++ b/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts @@ -39,33 +39,37 @@ export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer { async synchronize(since?: Date): Promise { const projectHashLookup = this.buildProjectHashLookup(); - const legacySessionFiles = await findFilesRecursivelyCreatedAfter( - path.join(this.geminiHome, 'sessions'), - '.json', - since ?? null - ); - const legacyTempFiles = await findFilesRecursivelyCreatedAfter( - path.join(this.geminiHome, 'tmp'), - '.json', - since ?? null - ); - const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter( - path.join(this.geminiHome, 'sessions'), - '.jsonl', - since ?? null - ); + // const legacySessionFiles = await findFilesRecursivelyCreatedAfter( + // path.join(this.geminiHome, 'sessions'), + // '.json', + // since ?? null + // ); + // Gemini creates overlapping artifacts across `sessions/` and `tmp/`. + // We currently index only `tmp/*/chats/*.jsonl` because those files are the + // live transcript source and avoid duplicate session rows from mirrored files. + // const legacyTempFiles = await findFilesRecursivelyCreatedAfter( + // path.join(this.geminiHome, 'tmp'), + // '.json', + // since ?? null + // ); + // const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter( + // path.join(this.geminiHome, 'sessions'), + // '.jsonl', + // since ?? null + // ); const jsonlTempFiles = await findFilesRecursivelyCreatedAfter( path.join(this.geminiHome, 'tmp'), '.jsonl', since ?? null ); - // Process legacy JSON first, then JSONL. If both exist for a session id, - // the JSONL artifact becomes the canonical jsonl_path via upsert. + // Current strategy: index only temp chat JSONL artifacts. const files = [ - ...legacySessionFiles, - ...legacyTempFiles, - ...jsonlSessionFiles, + // ...legacySessionFiles, + // Intentionally disabled to avoid duplicate indexing from mirrored + // `sessions/*.json` and `sessions/*.jsonl` artifacts. + // ...legacyTempFiles, + // ...jsonlSessionFiles, ...jsonlTempFiles, ]; diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index c6fdcceb..7a36e599 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -24,10 +24,12 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions'), }, - { - provider: 'gemini', - rootPath: path.join(os.homedir(), '.gemini', 'sessions'), - }, + // { + // provider: 'gemini', + // rootPath: path.join(os.homedir(), '.gemini', 'sessions'), + // }, + // Keep `sessions/` watcher disabled: Gemini also mirrors artifacts there, + // which causes duplicate synchronization events. { provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'tmp'), diff --git a/shared/modelConstants.js b/shared/modelConstants.js index 90b973ed..ae42ed27 100644 --- a/shared/modelConstants.js +++ b/shared/modelConstants.js @@ -84,6 +84,7 @@ export const GEMINI_MODELS = { { value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, { value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, { value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" }, + { value: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" }, { value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" }, { value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" }, {