Files
claudecodeui/server/gemini-cli.js
2026-05-08 22:51:03 +03:00

618 lines
25 KiB
JavaScript

import { spawn } from 'child_process';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import crossSpawn from 'cross-spawn';
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 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 (processEnv.GEMINI_API_KEY || processEnv.GOOGLE_API_KEY || processEnv.GOOGLE_APPLICATION_CREDENTIALS) {
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
let sessionCreatedSent = false; // Track if we've already sent session-created event
let assistantBlocks = []; // Accumulate the full response blocks including tools
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
allowedTools: [],
disallowedTools: [],
skipPermissions: false
};
// Build Gemini CLI command - start with print/resume flags first
const args = [];
// Add prompt flag with command if we have a command
if (command && command.trim()) {
args.push('--prompt', command);
}
// If we have a sessionId, we want to resume
if (sessionId) {
const session = sessionManager.getSession(sessionId);
if (session && session.cliSessionId) {
args.push('--resume', session.cliSessionId);
}
}
// Use cwd (actual project directory) instead of projectPath (Gemini's metadata directory)
// Clean the path by removing any non-printable characters
const cleanPath = (cwd || projectPath || process.cwd()).replace(/[^\x20-\x7E]/g, '').trim();
const workingDir = cleanPath;
// Handle images by saving them to temporary files and passing paths to Gemini
const tempImagePaths = [];
let tempDir = null;
if (images && images.length > 0) {
try {
// Create temp directory in the project directory so Gemini can access it
tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
await fs.mkdir(tempDir, { recursive: true });
// Save each image to a temp file
for (const [index, image] of images.entries()) {
// Extract base64 data and mime type
const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
if (!matches) {
continue;
}
const [, mimeType, base64Data] = matches;
const extension = mimeType.split('/')[1] || 'png';
const filename = `image_${index}.${extension}`;
const filepath = path.join(tempDir, filename);
// Write base64 data to file
await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
tempImagePaths.push(filepath);
}
// Include the full image paths in the prompt for Gemini to reference
// Gemini CLI can read images from file paths in the prompt
if (tempImagePaths.length > 0 && command && command.trim()) {
const imageNote = `\n\n[Images given: ${tempImagePaths.length} images are located at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
const modifiedCommand = command + imageNote;
// Update the command in args
const promptIndex = args.indexOf('--prompt');
if (promptIndex !== -1 && args[promptIndex + 1] === command) {
args[promptIndex + 1] = modifiedCommand;
} else if (promptIndex !== -1) {
// If we're using context, update the full prompt
args[promptIndex + 1] = args[promptIndex + 1] + imageNote;
}
}
} catch (error) {
console.error('Error processing images for Gemini:', error);
}
}
// Add basic flags for Gemini
if (options.debug) {
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');
let hasMcpServers = false;
try {
await fs.access(geminiConfigPath);
const geminiConfigRaw = await fs.readFile(geminiConfigPath, 'utf8');
const geminiConfig = JSON.parse(geminiConfigRaw);
// Check global MCP servers
if (geminiConfig.mcpServers && Object.keys(geminiConfig.mcpServers).length > 0) {
hasMcpServers = true;
}
// Check project-specific MCP servers
if (!hasMcpServers && geminiConfig.geminiProjects) {
const currentProjectPath = process.cwd();
const projectConfig = geminiConfig.geminiProjects[currentProjectPath];
if (projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0) {
hasMcpServers = true;
}
}
} catch (e) {
// Ignore if file doesn't exist or isn't parsable
}
if (hasMcpServers) {
args.push('--mcp-config', geminiConfigPath);
}
} catch (error) {
// Ignore outer errors
}
// Add model for all sessions (both new and resumed)
let modelToUse = options.model || 'gemini-2.5-flash';
args.push('--model', modelToUse);
args.push('--output-format', 'stream-json');
// Handle approval modes and allowed tools
if (settings.skipPermissions || options.skipPermissions || permissionMode === 'yolo') {
args.push('--yolo');
} else if (permissionMode === 'auto_edit') {
args.push('--approval-mode', 'auto_edit');
} else if (permissionMode === 'plan') {
args.push('--approval-mode', 'plan');
}
if (settings.allowedTools && settings.allowedTools.length > 0) {
args.push('--allowed-tools', settings.allowedTools.join(','));
}
// Try to find gemini in PATH first, then fall back to environment variable
const geminiPath = process.env.GEMINI_PATH || 'gemini';
let spawnCmd = geminiPath;
let spawnArgs = args;
// On non-Windows platforms, wrap the execution in a shell to avoid ENOEXEC
// which happens when the target is a script lacking a shebang.
if (os.platform() !== 'win32') {
spawnCmd = 'sh';
// Use exec to replace the shell process, ensuring signals hit gemini directly
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: spawnEnv
});
let terminalNotificationSent = false;
let terminalFailureReason = null;
const notifyTerminalState = ({ code = null, error = null } = {}) => {
if (terminalNotificationSent) {
return;
}
terminalNotificationSent = true;
const finalSessionId = capturedSessionId || sessionId || processKey;
if (code === 0 && !error) {
notifyRunStopped({
userId: ws?.userId || null,
provider: 'gemini',
sessionId: finalSessionId,
sessionName: sessionSummary,
stopReason: 'completed'
});
return;
}
notifyRunFailed({
userId: ws?.userId || null,
provider: 'gemini',
sessionId: finalSessionId,
sessionName: sessionSummary,
error: error || terminalFailureReason || `Gemini CLI exited with code ${code}`
});
};
// Attach temp file info to process for cleanup later
geminiProcess.tempImagePaths = tempImagePaths;
geminiProcess.tempDir = tempDir;
// Store process reference for potential abort
const processKey = capturedSessionId || sessionId || Date.now().toString();
activeGeminiProcesses.set(processKey, geminiProcess);
// Store sessionId on the process object for debugging
geminiProcess.sessionId = processKey;
// Close stdin to signal we're done sending input
geminiProcess.stdin.end();
// Add timeout handler
const timeoutMs = 120000; // 120 seconds for slower models
let timeout;
const startTimeout = () => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey);
terminalFailureReason = `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`;
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
try {
geminiProcess.kill('SIGTERM');
} catch (e) { }
}, timeoutMs);
};
startTimeout();
// Save user message to session when starting
if (command && capturedSessionId) {
sessionManager.addMessage(capturedSessionId, 'user', command);
}
// Create response handler for NDJSON buffering
let responseHandler;
if (ws) {
responseHandler = new GeminiResponseHandler(ws, {
onContentFragment: (content) => {
if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') {
assistantBlocks[assistantBlocks.length - 1].text += content;
} else {
assistantBlocks.push({ type: 'text', text: content });
}
},
onToolUse: (event) => {
assistantBlocks.push({
type: 'tool_use',
id: event.tool_id,
name: event.tool_name,
input: event.parameters
});
},
onToolResult: (event) => {
if (capturedSessionId) {
if (assistantBlocks.length > 0) {
sessionManager.addMessage(capturedSessionId, 'assistant', [...assistantBlocks]);
assistantBlocks = [];
}
sessionManager.addMessage(capturedSessionId, 'user', [{
type: 'tool_result',
tool_use_id: event.tool_id,
content: event.output === undefined ? null : event.output,
is_error: event.status === 'error'
}]);
}
},
onInit: (event) => {
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);
}
}
});
}
// Handle stdout
geminiProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
startTimeout(); // Re-arm the timeout
if (responseHandler) {
responseHandler.processData(rawOutput);
} else if (rawOutput) {
// Fallback to direct sending for raw CLI mode without WS
if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') {
assistantBlocks[assistantBlocks.length - 1].text += rawOutput;
} else {
assistantBlocks.push({ type: 'text', text: rawOutput });
}
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);
ws.send(createNormalizedMessage({ kind: 'stream_delta', content: rawOutput, sessionId: socketSessionId, provider: 'gemini' }));
}
});
// Handle stderr
geminiProcess.stderr.on('data', (data) => {
const errorMsg = data.toString();
// Filter out deprecation warnings and "Loaded cached credentials" message
if (errorMsg.includes('[DEP0040]') ||
errorMsg.includes('DeprecationWarning') ||
errorMsg.includes('--trace-deprecation') ||
errorMsg.includes('Loaded cached credentials')) {
return;
}
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);
ws.send(createNormalizedMessage({ kind: 'error', content: errorMsg, sessionId: socketSessionId, provider: 'gemini' }));
});
// Handle process completion
geminiProcess.on('close', async (code) => {
clearTimeout(timeout);
// Flush any remaining buffered content
if (responseHandler) {
responseHandler.forceFlush();
responseHandler.destroy();
}
// Clean up process reference
const finalSessionId = capturedSessionId || sessionId || processKey;
activeGeminiProcesses.delete(finalSessionId);
// Save assistant response to session if we have one
if (finalSessionId && assistantBlocks.length > 0) {
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
}
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' }));
// Clean up temporary image files if any
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
for (const imagePath of geminiProcess.tempImagePaths) {
await fs.unlink(imagePath).catch(err => { });
}
if (geminiProcess.tempDir) {
await fs.rm(geminiProcess.tempDir, { recursive: true, force: true }).catch(err => { });
}
}
if (code === 0) {
notifyTerminalState({ code });
resolve();
} else {
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) {
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 a valid `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' }));
}
}
notifyTerminalState({
code,
error: code === null ? 'Gemini CLI process was terminated or timed out' : null
});
reject(
new Error(
terminalFailureReason
|| (code === null
? 'Gemini CLI process was terminated or timed out'
: `Gemini CLI exited with code ${code}`)
)
);
}
});
// Handle process errors
geminiProcess.on('error', async (error) => {
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeGeminiProcesses.delete(finalSessionId);
// Check if Gemini CLI is installed for a clearer error message
const installed = await providerAuthService.isProviderInstalled('gemini');
const errorContent = !installed
? 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli'
: error.message;
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
notifyTerminalState({ error });
reject(error);
});
});
}
function abortGeminiSession(sessionId) {
let geminiProc = activeGeminiProcesses.get(sessionId);
let processKey = sessionId;
if (!geminiProc) {
for (const [key, proc] of activeGeminiProcesses.entries()) {
if (proc.sessionId === sessionId) {
geminiProc = proc;
processKey = key;
break;
}
}
}
if (geminiProc) {
try {
geminiProc.kill('SIGTERM');
setTimeout(() => {
if (activeGeminiProcesses.has(processKey)) {
try {
geminiProc.kill('SIGKILL');
} catch (e) { }
}
}, 2000); // Wait 2 seconds before force kill
return true;
} catch (error) {
return false;
}
}
return false;
}
function isGeminiSessionActive(sessionId) {
return activeGeminiProcesses.has(sessionId);
}
function getActiveGeminiSessions() {
return Array.from(activeGeminiProcesses.keys());
}
export {
spawnGemini,
abortGeminiSession,
isGeminiSessionActive,
getActiveGeminiSessions
};