diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 27953c60a9c..c93d4f09c11 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -105,6 +105,15 @@ export interface CliArgs { rawOutput: boolean | undefined; acceptRawOutputRisk: boolean | undefined; isCommand: boolean | undefined; + + // Daemon and Client mode options + daemon: boolean | undefined; + daemonStatus: boolean | undefined; + daemonStop: boolean | undefined; + client: boolean | undefined; + session: string | undefined; + close: boolean | undefined; + verbose: boolean | undefined; } /** @@ -442,6 +451,37 @@ export async function parseArguments( .option('accept-raw-output-risk', { type: 'boolean', description: 'Suppress the security warning when using --raw-output.', + }) + .option('daemon', { + type: 'boolean', + description: + 'Run the Gemini CLI engine as a persistent background process.', + }) + .option('daemon-status', { + type: 'boolean', + description: 'Check if the daemon is running.', + }) + .option('daemon-stop', { + type: 'boolean', + description: 'Stop the running daemon gracefully.', + }) + .option('client', { + type: 'boolean', + description: 'Connect to the running daemon to send prompts.', + }) + .option('session', { + type: 'string', + description: + 'Named session for client connections to maintain context.', + }) + .option('close', { + type: 'boolean', + description: 'Close the specified session with the daemon.', + }) + .option('verbose', { + type: 'boolean', + description: + 'Verbose intermediate tool call and logging output for the client.', }), ) .version(await getVersion()) // This will enable the --version flag based on package.json @@ -513,6 +553,22 @@ export interface LoadCliConfigOptions { projectHooks?: { [K in HookEventName]?: HookDefinition[] } & { disabled?: string[]; }; + /** + * When true, forces the config to treat this as an interactive session, + * which means MCP server initialization happens in the background rather + * than blocking startup. Used by daemon mode to avoid blocking on slow + * or unavailable MCP servers. + */ + forceInteractive?: boolean; + + /** + * When true, MCP initialization will run in the background even when the + * config is treated as non-interactive. Used by daemon/headless mode to + * avoid waiting on MCP startup latency, without relaxing interactive + * safety posture. + */ + mcpInitializationInBackground?: boolean; + worktreeSettings?: WorktreeSettings; } @@ -522,7 +578,12 @@ export async function loadCliConfig( argv: CliArgs, options: LoadCliConfigOptions = {}, ): Promise { - const { cwd = process.cwd(), projectHooks } = options; + const { + cwd = process.cwd(), + projectHooks, + forceInteractive, + mcpInitializationInBackground, + } = options; const debugMode = isDebugMode(argv); const worktreeSettings = @@ -739,6 +800,7 @@ export async function loadCliConfig( // -p/--prompt forces non-interactive (headless) mode // -i/--prompt-interactive forces interactive mode with an initial prompt const interactive = + !!forceInteractive || !!argv.promptInteractive || !!argv.acp || !!argv.experimentalAcp || @@ -990,6 +1052,7 @@ export async function loadCliConfig( compressionThreshold: settings.model?.compressionThreshold, folderTrust, interactive, + mcpInitializationInBackground, trustedFolder, useBackgroundColor: settings.ui?.useBackgroundColor, useAlternateBuffer: settings.ui?.useAlternateBuffer, diff --git a/packages/cli/src/daemon/daemon.test.ts b/packages/cli/src/daemon/daemon.test.ts new file mode 100644 index 00000000000..c676cca034f --- /dev/null +++ b/packages/cli/src/daemon/daemon.test.ts @@ -0,0 +1,278 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import net from 'node:net'; +import fs from 'node:fs'; +import os from 'node:os'; +import { getDaemonSocketPath, checkDaemonStatus } from './daemonClient.js'; + +import { loadCliConfig } from '../config/config.js'; +import type { CliArgs } from '../config/config.js'; +import { + createTestMergedSettings, + type LoadedSettings, +} from '../config/settings.js'; + +vi.mock('../config/config.js', () => ({ + loadCliConfig: vi.fn(), +})); + +vi.mock('../utils/cleanup.js', () => ({ + runExitCleanup: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../validateNonInterActiveAuth.js', () => ({ + validateNonInteractiveAuth: vi.fn(), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + type CoreModule = typeof import('@google/gemini-cli-core'); + const actual = await importOriginal(); + return { + ...actual, + debugLogger: { + error: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, + writeToStdout: vi.fn(), + writeToStderr: vi.fn(), + }; +}); + +describe('Daemon Mode', () => { + const testHome = '/tmp/gemini-test-home'; + const socketPath = `${testHome}/.gemini/daemon.sock`; + const tokenPath = `${testHome}/.gemini/daemon.token`; + let mockServer: net.Server; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(process, 'exit').mockImplementation( + (() => {}) as unknown as typeof process.exit, + ); + if (typeof process.umask === 'function') { + vi.spyOn(process, 'umask').mockImplementation(() => 0o22); + } + + // Mock os.homedir to avoid polluting real user dirs + vi.spyOn(os, 'homedir').mockReturnValue(testHome); + if (!fs.existsSync(`${testHome}/.gemini`)) { + fs.mkdirSync(`${testHome}/.gemini`, { recursive: true }); + } + + // Mock loadCliConfig used by daemon startup pre-auth and session init. + // For our unit tests we only need the base config to initialize/dispose. + (loadCliConfig as unknown as ReturnType).mockResolvedValue({ + initialize: vi.fn().mockResolvedValue(undefined), + refreshAuth: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn().mockResolvedValue(undefined), + }); + }); + + afterEach(() => { + if (mockServer) { + mockServer.close(); + } + if (fs.existsSync(socketPath)) { + fs.unlinkSync(socketPath); + } + if (fs.existsSync(tokenPath)) { + fs.unlinkSync(tokenPath); + } + }); + + describe('daemonClient', () => { + it('should throw an error on Windows', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + expect(() => getDaemonSocketPath()).toThrow( + 'Daemon mode is currently not supported on Windows.', + ); + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should return false if daemon is not running', async () => { + if (process.platform === 'win32') { + // checkDaemonStatus uses Unix sockets; unsupported on Windows. + return; + } + const isRunning = await checkDaemonStatus(); + expect(isRunning).toBe(false); + }); + + it('should return true if daemon is running', async () => { + if (process.platform === 'win32') { + return; + } + mockServer = net.createServer().listen(socketPath); + await new Promise((resolve) => setTimeout(resolve, 100)); // wait for listen + + const isRunning = await checkDaemonStatus(); + expect(isRunning).toBe(true); + }); + + // We can add more comprehensive e2e test if necessary. + }); + + describe('daemonServer', () => { + function daemonTestSettings(): LoadedSettings { + return { + merged: createTestMergedSettings({ + security: { + auth: { selectedType: undefined, useExternal: false }, + }, + }), + } as LoadedSettings; + } + + const baseArgv = {} as CliArgs; + + it('rejects unauthenticated prompt requests', async () => { + if (process.platform === 'win32') { + // Daemon mode is not supported on Windows. + return; + } + + const { startDaemon } = await import('./daemonServer.js'); + const settings = daemonTestSettings(); + + await startDaemon(settings, baseArgv); + // Wait for daemon to be reachable. + await new Promise((resolve, reject) => { + const deadline = Date.now() + 2000; + const tick = async () => { + try { + const ok = await checkDaemonStatus(); + if (ok) return resolve(); + } catch { + // ignore + } + if (Date.now() > deadline) return reject(new Error('timeout')); + setTimeout(tick, 50); + }; + void tick(); + }); + + const clientResponse: string = await new Promise((resolve) => { + let buffer = ''; + const client = net.createConnection(socketPath); + client.on('connect', () => { + client.write( + JSON.stringify({ + action: 'prompt', + session: 'test', + cwd: testHome, + input: 'hello', + token: 'wrong-token', + }) + '\n', + ); + }); + client.on('data', (d: Buffer) => { + buffer += d.toString('utf8'); + }); + client.on('end', () => resolve(buffer)); + }); + + const messages = clientResponse + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .map((l) => JSON.parse(l)); + + expect( + messages.some( + (m) => + m.type === 'error' && m.content === 'Unauthorized daemon request.', + ), + ).toBe(true); + expect(messages.some((m) => m.type === 'end')).toBe(true); + + const token = fs.readFileSync(tokenPath, 'utf8').trim(); + // Graceful shutdown (process.exit is mocked in beforeEach). + await new Promise((resolve) => { + const stopClient = net.createConnection(socketPath); + stopClient.on('connect', () => { + stopClient.write(JSON.stringify({ action: 'stop', token }) + '\n'); + }); + stopClient.on('data', () => {}); + stopClient.on('end', () => resolve(undefined)); + }); + }); + + it('rejects prompt cwd outside $HOME', async () => { + if (process.platform === 'win32') { + // Daemon mode is not supported on Windows. + return; + } + + const { startDaemon } = await import('./daemonServer.js'); + const settings = daemonTestSettings(); + + await startDaemon(settings, baseArgv); + await new Promise((resolve, reject) => { + const deadline = Date.now() + 2000; + const tick = async () => { + try { + const ok = await checkDaemonStatus(); + if (ok) return resolve(); + } catch { + // ignore + } + if (Date.now() > deadline) return reject(new Error('timeout')); + setTimeout(tick, 50); + }; + void tick(); + }); + + const token = fs.readFileSync(tokenPath, 'utf8').trim(); + + const clientResponse: string = await new Promise((resolve) => { + let buffer = ''; + const client = net.createConnection(socketPath); + client.on('connect', () => { + client.write( + JSON.stringify({ + action: 'prompt', + session: 'test', + cwd: '/', // outside mocked $HOME + input: 'hello', + token, + }) + '\n', + ); + }); + client.on('data', (d: Buffer) => { + buffer += d.toString('utf8'); + }); + client.on('end', () => resolve(buffer)); + }); + + const messages = clientResponse + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .map((l) => JSON.parse(l)); + + const errorMessage = messages.find((m) => m.type === 'error')?.content; + expect(errorMessage).toContain( + 'Security restriction - session cwd must be within the user home directory.', + ); + expect(messages.some((m) => m.type === 'end')).toBe(true); + + // Graceful shutdown (process.exit is mocked in beforeEach). + await new Promise((resolve) => { + const stopClient = net.createConnection(socketPath); + stopClient.on('connect', () => { + stopClient.write(JSON.stringify({ action: 'stop', token }) + '\n'); + }); + stopClient.on('data', () => {}); + stopClient.on('end', () => resolve(undefined)); + }); + }); + }); +}); diff --git a/packages/cli/src/daemon/daemonClient.ts b/packages/cli/src/daemon/daemonClient.ts new file mode 100644 index 00000000000..6d60e6a43d7 --- /dev/null +++ b/packages/cli/src/daemon/daemonClient.ts @@ -0,0 +1,276 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import net from 'node:net'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + ExitCodes, + writeToStderr, + writeToStdout, +} from '@google/gemini-cli-core'; +import type { CliArgs } from '../config/config.js'; +import { isRecord } from '../utils/settingsUtils.js'; + +interface DaemonMessage { + type: string; + content?: string; +} + +function isDaemonMessage(value: unknown): value is DaemonMessage { + if (!isRecord(value)) return false; + const messageType = value['type']; + return typeof messageType === 'string'; +} + +export function getDaemonSocketPath(): string { + if (process.platform === 'win32') { + throw new Error('Daemon mode is currently not supported on Windows.'); + } + return path.join(os.homedir(), '.gemini', 'daemon.sock'); +} + +export function getDaemonTokenPath(): string { + if (process.platform === 'win32') { + throw new Error('Daemon mode is currently not supported on Windows.'); + } + return path.join(os.homedir(), '.gemini', 'daemon.token'); +} + +function readDaemonAuthToken(): string { + const tokenPath = getDaemonTokenPath(); + if (!fs.existsSync(tokenPath)) { + throw new Error( + 'Daemon auth token missing. Start the daemon first with `gemini --daemon`.', + ); + } + const token = fs.readFileSync(tokenPath, 'utf8').trim(); + if (!token) { + throw new Error('Daemon auth token is empty.'); + } + // Basic sanity: token should be printable and stable. + if (token.length < 16 || token.length > 1024) { + throw new Error('Daemon auth token has unexpected length.'); + } + return token; +} + +export async function checkDaemonStatus(): Promise { + const socketPath = getDaemonSocketPath(); + return new Promise((resolve) => { + const client = net.createConnection(socketPath, () => { + client.end(); + resolve(true); + }); + client.on('error', () => { + resolve(false); + }); + }); +} + +function connectToDaemon(socketPath: string): Promise { + return new Promise((resolve, reject) => { + const client = net.createConnection(socketPath, () => { + resolve(client); + }); + client.on('error', (err: Error) => { + reject(err); + }); + }); +} + +export async function runDaemonClientCommands( + argv: CliArgs, + input: string | undefined, +): Promise { + const socketPath = getDaemonSocketPath(); + // Token is required for all state-changing operations. + const daemonToken = (() => { + try { + return readDaemonAuthToken(); + } catch (_e) { + return undefined; + } + })(); + + if (argv.daemonStatus) { + const isRunning = await checkDaemonStatus(); + if (isRunning) { + writeToStdout('Daemon is running.\n'); + process.exit(ExitCodes.SUCCESS); + } else { + writeToStderr('Daemon is not running.\n'); + process.exit(1); + } + } + + if (argv.daemonStop) { + try { + if (!daemonToken) { + writeToStderr('Error: Daemon not running or unauthorized.\n'); + process.exit(1); + } + const client = await connectToDaemon(socketPath); + client.write( + JSON.stringify({ action: 'stop', token: daemonToken }) + '\n', + ); + client.end(); + writeToStdout('Daemon stop signal sent.\n'); + process.exit(ExitCodes.SUCCESS); + } catch (_err) { + writeToStderr('Error: Daemon not running.\n'); + process.exit(1); + } + } + + if (argv.close) { + if (!argv.session) { + writeToStderr( + 'Error: Please provide a session name with --session when using --close.\n', + ); + process.exit(1); + } + try { + if (!daemonToken) { + writeToStderr('Error: Daemon not running or unauthorized.\n'); + process.exit(1); + } + const client = await connectToDaemon(socketPath); + client.write( + JSON.stringify({ + action: 'close_session', + session: argv.session, + token: daemonToken, + }) + '\n', + ); + await new Promise((resolve) => { + let buffer = ''; + client.on('data', (d: Buffer) => { + buffer += d.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line) continue; + try { + const raw: unknown = JSON.parse(line); + if (isDaemonMessage(raw) && raw.type === 'error') { + writeToStderr((raw.content ?? '') + '\n'); + client.end(); + process.exit(1); + return; + } + } catch { + // ignore unparseable lines + } + } + client.end(); + resolve(); + }); + client.on('end', resolve); + }).then(() => { + writeToStdout(`Session '${argv.session}' closed.\n`); + process.exit(ExitCodes.SUCCESS); + }); + return; + } catch (_err) { + writeToStderr('Error: Daemon not running.\n'); + process.exit(1); + } + } + + if (argv.client) { + if (!input) { + writeToStderr('Error: No prompt provided.\n'); + process.exit(1); + } + + try { + if (!daemonToken) { + writeToStderr('Error: Daemon not running or unauthorized.\n'); + process.exit(1); + } + const client = await connectToDaemon(socketPath); + + const payload = { + action: 'prompt', + session: argv.session || 'default', + cwd: process.cwd(), + input, + verbose: argv.verbose || false, + token: daemonToken, + }; + + client.write(JSON.stringify(payload) + '\n'); + + // Wait for the daemon response to complete before returning. + // Without this, main() returns immediately and the process may exit + // before the socket event listeners fire. + await new Promise((resolve) => { + let resolved = false; + let hasOutput = false; + const exit = (code: number) => { + if (resolved) return; + resolved = true; + // Ensure output ends with a newline so the shell prompt + // doesn't overwrite the last line of output. + if (hasOutput) { + writeToStdout('\n'); + } + resolve(); + // Flush stdout fully before exiting to avoid losing buffered output + // on TTY streams where process.exit() can discard pending writes. + process.stdout.write('', () => process.exit(code)); + }; + + let buffer = ''; + client.on('data', (data: Buffer) => { + buffer += data.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep incomplete line for next data event + + for (const line of lines) { + if (!line) continue; + try { + const raw: unknown = JSON.parse(line); + if (!isDaemonMessage(raw)) continue; + const msg = raw; + if (msg.type === 'output') { + writeToStdout(msg.content ?? ''); + hasOutput = true; + } else if (msg.type === 'error') { + writeToStderr((msg.content ?? '') + '\n'); + exit(1); + } else if (msg.type === 'verbose' && argv.verbose) { + writeToStderr((msg.content ?? '') + '\n'); + } else if (msg.type === 'end') { + client.end(); + exit(ExitCodes.SUCCESS); + } + } catch (_e) { + // Unparseable, just print generic + writeToStdout(line + '\n'); + } + } + }); + + client.on('end', () => { + exit(ExitCodes.SUCCESS); + }); + + client.on('error', (err: Error) => { + writeToStderr(`Stream error: ${err.message}\n`); + exit(1); + }); + }); + } catch (_err) { + writeToStderr( + 'Error: Daemon not running. Start with `gemini --daemon`\n', + ); + process.exit(1); + } + } +} diff --git a/packages/cli/src/daemon/daemonServer.ts b/packages/cli/src/daemon/daemonServer.ts new file mode 100644 index 00000000000..7beec02472c --- /dev/null +++ b/packages/cli/src/daemon/daemonServer.ts @@ -0,0 +1,666 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import net from 'node:net'; +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import { + getDaemonSocketPath, + getDaemonTokenPath, + checkDaemonStatus, +} from './daemonClient.js'; +import { + type Config, + debugLogger, + writeToStdout, + promptIdContext, + recordToolCallInteractions, + ToolErrorType, + GeminiEventType, + ExitCodes, + Scheduler, + ROOT_SCHEDULER_ID, + writeToStderr, + type ToolCallRequestInfo, + type CompletedToolCall, +} from '@google/gemini-cli-core'; +import type { Part, Content } from '@google/genai'; +import { loadCliConfig, type CliArgs } from '../config/config.js'; +import type { LoadedSettings } from '../config/settings.js'; +import { isRecord } from '../utils/settingsUtils.js'; +import { runExitCleanup } from '../utils/cleanup.js'; +import { validateNonInteractiveAuth } from '../validateNonInterActiveAuth.js'; + +interface DaemonSession { + id: string; + config: Config; + abortController: AbortController; + lastActivityMs: number; + activeTurn?: Promise; + closing?: boolean; +} + +const activeSessions = new Map(); +const sessionLocks = new Map>(); + +const SESSION_IDLE_MS = 30 * 60 * 1000; // 30 minutes +const SESSION_LIMIT = 5; +const MAX_IPC_MESSAGE_BYTES = 256 * 1024; // 256 KiB + +export async function startDaemon( + settings: LoadedSettings, + baseArgv: CliArgs, +): Promise { + const isRunning = await checkDaemonStatus(); + if (isRunning) { + writeToStderr('Error: Daemon already running.\n'); + process.exit(1); + } + + // Pre-load a base config to ensure authentication is valid before listening + const baseConfig = await loadCliConfig( + settings.merged, + 'daemon-base', + baseArgv, + { cwd: process.cwd() }, + ); + await baseConfig.initialize(); + if (settings.merged.security.auth.selectedType) { + const authType = await validateNonInteractiveAuth( + settings.merged.security.auth.selectedType, + settings.merged.security.auth.useExternal, + baseConfig, + settings, + ); + await baseConfig.refreshAuth(authType); + } + + // Base config is only used for pre-validating auth; dispose it to avoid + // leaking resources (MCP clients, etc.). + await baseConfig.dispose(); + + const socketPath = getDaemonSocketPath(); + const tokenPath = getDaemonTokenPath(); + const daemonDir = path.dirname(socketPath); // ~/.gemini + + // Ensure daemon directory is not group/world accessible. + if (!fs.existsSync(daemonDir)) { + fs.mkdirSync(daemonDir, { recursive: true, mode: 0o700 }); + } else { + try { + fs.chmodSync(daemonDir, 0o700); + } catch { + // Best-effort; ignore if chmod is not permitted. + } + } + + // Generate a fresh per-daemon token and persist it for the client. + const daemonToken = crypto.randomBytes(32).toString('hex'); + fs.writeFileSync(tokenPath, daemonToken, { mode: 0o600 }); + + if (fs.existsSync(socketPath)) { + fs.unlinkSync(socketPath); + } + + const server = net.createServer((socket: net.Socket) => { + let buffer = ''; + let requestChain = Promise.resolve(); + + socket.on('data', (data: Buffer) => { + buffer += data.toString('utf8'); + if (Buffer.byteLength(buffer, 'utf8') > MAX_IPC_MESSAGE_BYTES) { + socket.destroy(new Error('IPC message too large')); + return; + } + + const parts = buffer.split('\n'); + buffer = parts.pop() || ''; // Keep the incomplete line + + for (const line of parts) { + if (!line.trim()) continue; + requestChain = requestChain + .then(async () => { + if (socket.destroyed) return; + const raw: unknown = JSON.parse(line); + if (!isValidDaemonPayload(raw)) { + safeWrite(socket, { + type: 'error', + content: 'Invalid request payload.', + }); + safeWrite(socket, { type: 'end' }); + socket.end(); + return; + } + await handleClientRequest( + raw, + socket, + settings, + baseArgv, + daemonToken, + () => shutdownDaemon(server, socketPath, tokenPath), + ); + }) + .catch((err) => { + debugLogger.error('Daemon request handling error:', err); + safeWrite(socket, { + type: 'error', + content: 'Error handling daemon request.', + }); + safeWrite(socket, { type: 'end' }); + socket.end(); + }); + } + }); + + socket.on('error', (err: unknown) => { + debugLogger.error('Daemon client connection error:', err); + }); + }); + + // Set restrictive permissions during socket creation to avoid a TOCTOU + // window before permissions are corrected. + const previousUmask = process.umask(0o077); + server.listen(socketPath, () => { + process.umask(previousUmask); + fs.chmodSync(socketPath, 0o600); + writeToStdout(`Daemon started, listening on ${socketPath}\n`); + }); + + // Session TTL cleanup: sessions are closed when idle to avoid keeping + // background MCP connections open forever. + const ttlTimer = setInterval(() => { + const now = Date.now(); + for (const [sessionName, session] of activeSessions.entries()) { + if (now - session.lastActivityMs <= SESSION_IDLE_MS) continue; + + session.abortController.abort(); + activeSessions.delete(sessionName); + + void (async () => { + try { + await session.activeTurn; + } finally { + try { + await session.config.dispose(); + } catch { + // best-effort + } + } + })(); + } + }, 60 * 1000); + + server.on('close', () => { + clearInterval(ttlTimer); + }); + + process.on('SIGINT', async () => { + await shutdownDaemon(server, socketPath, tokenPath); + }); + + process.on('SIGTERM', async () => { + await shutdownDaemon(server, socketPath, tokenPath); + }); +} + +async function shutdownDaemon( + server: net.Server, + socketPath: string, + tokenPath: string, +) { + writeToStdout('\nShutting down daemon...\n'); + await new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); + + // Dispose any active sessions to close MCP connections, etc. + const sessions = Array.from(activeSessions.values()); + await Promise.allSettled( + sessions.map(async (s) => { + try { + s.abortController.abort(); + await s.config.dispose(); + } catch { + // Best-effort cleanup. + } + }), + ); + activeSessions.clear(); + + if (fs.existsSync(socketPath)) { + fs.unlinkSync(socketPath); + } + + if (fs.existsSync(tokenPath)) { + fs.unlinkSync(tokenPath); + } + await runExitCleanup(); + process.exit(ExitCodes.SUCCESS); +} + +interface DaemonPayload { + action?: string; + session?: string; + cwd?: string; + input?: string; + verbose?: boolean; + token?: string; +} + +const SESSION_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/; + +function isValidSessionName(name: string): boolean { + return SESSION_NAME_RE.test(name); +} + +type DaemonResponse = + | { type: 'error'; content: string } + | { type: 'output'; content: string } + | { type: 'verbose'; content: string } + | { type: 'end' }; + +function safeWrite(socket: net.Socket, response: DaemonResponse): void { + if (socket.destroyed) return; + try { + socket.write(JSON.stringify(response) + '\n'); + } catch { + // Ignore write errors (e.g. socket already closed). + } +} + +function isValidDaemonPayload(value: unknown): value is DaemonPayload { + if (!isRecord(value)) return false; + const obj = value; + + const action = obj['action']; + if (typeof action !== 'string') return false; + if (!['prompt', 'close_session', 'stop'].includes(action)) return false; + + const token = obj['token']; + if (token !== undefined && typeof token !== 'string') return false; + + const session = obj['session']; + if (session !== undefined && typeof session !== 'string') return false; + + const cwd = obj['cwd']; + if (cwd !== undefined && typeof cwd !== 'string') return false; + + const input = obj['input']; + if (input !== undefined && typeof input !== 'string') return false; + + const verbose = obj['verbose']; + if (verbose !== undefined && typeof verbose !== 'boolean') return false; + + return true; +} + +async function withSessionLock( + sessionName: string, + fn: () => Promise, +): Promise { + const previous = sessionLocks.get(sessionName) ?? Promise.resolve(); + const next = previous.then(fn, fn); + + const completion = next.then( + () => undefined, + () => undefined, + ); + sessionLocks.set(sessionName, completion); + + try { + return await next; + } finally { + if (sessionLocks.get(sessionName) === completion) { + sessionLocks.delete(sessionName); + } + } +} + +async function handleClientRequest( + payload: DaemonPayload, + socket: net.Socket, + settings: LoadedSettings, + baseArgv: CliArgs, + daemonToken: string, + shutdownFn: () => Promise, +): Promise { + if (socket.destroyed) return; + + // Authenticate every IPC message (prevents local session hijacking). + if (payload.token !== daemonToken) { + safeWrite(socket, { + type: 'error', + content: 'Unauthorized daemon request.', + }); + safeWrite(socket, { type: 'end' }); + socket.end(); + return; + } + + const action = payload.action; + if (action === 'stop') { + safeWrite(socket, { type: 'end' }); + socket.end(); + // Trigger graceful shutdown to run all cleanup handlers. + void shutdownFn(); + return; + } + + if (action === 'close_session') { + const sessionName = payload.session; + if (!sessionName || !isValidSessionName(sessionName)) { + safeWrite(socket, { + type: 'error', + content: + 'Missing or invalid session name. Use 1-64 alphanumeric, dash, or underscore characters.', + }); + safeWrite(socket, { type: 'end' }); + socket.end(); + return; + } + + const session = activeSessions.get(sessionName); + if (!session) { + safeWrite(socket, { + type: 'error', + content: `Session ${sessionName} not found.`, + }); + safeWrite(socket, { type: 'end' }); + socket.end(); + return; + } + + session.closing = true; + session.abortController.abort(); + activeSessions.delete(sessionName); + + safeWrite(socket, { + type: 'output', + content: `Session ${sessionName} closed.\n`, + }); + safeWrite(socket, { type: 'end' }); + socket.end(); + + // Dispose resources after the in-flight turn (if any). + void (async () => { + try { + await session.activeTurn; + } catch { + // Ignore; we're closing. + } finally { + try { + await session.config.dispose(); + } catch { + // Best-effort cleanup. + } + } + })(); + + return; + } + + if (action === 'prompt') { + const { session: sessionName, cwd, input, verbose } = payload; + if (!sessionName || !input || !cwd) { + safeWrite(socket, { + type: 'error', + content: 'Missing required prompt parameters.', + }); + safeWrite(socket, { type: 'end' }); + socket.end(); + return; + } + + if (!isValidSessionName(sessionName)) { + safeWrite(socket, { + type: 'error', + content: + 'Invalid session name. Use 1-64 alphanumeric, dash, or underscore characters.', + }); + safeWrite(socket, { type: 'end' }); + socket.end(); + return; + } + + // Hardening: resolve symlinks to prevent escaping `$HOME` via symlinks. + let resolvedCwd: string; + let resolvedHome: string; + try { + resolvedCwd = fs.realpathSync(cwd); + resolvedHome = fs.realpathSync(os.homedir()); + const stat = fs.statSync(resolvedCwd); + if (!stat.isDirectory()) { + throw new Error('Working directory is not a directory.'); + } + } catch { + safeWrite(socket, { + type: 'error', + content: 'Error: Invalid working directory.', + }); + safeWrite(socket, { type: 'end' }); + socket.end(); + return; + } + + if (resolvedCwd !== resolvedHome) { + const homePrefix = resolvedHome.endsWith(path.sep) + ? resolvedHome + : resolvedHome + path.sep; + if (!resolvedCwd.startsWith(homePrefix)) { + safeWrite(socket, { + type: 'error', + content: + 'Error: Security restriction - session cwd must be within the user home directory.', + }); + safeWrite(socket, { type: 'end' }); + socket.end(); + return; + } + } + + try { + await withSessionLock(sessionName, async () => { + let session = activeSessions.get(sessionName); + if (!session) { + // Enforce session limit. + if (activeSessions.size >= SESSION_LIMIT) { + safeWrite(socket, { + type: 'error', + content: `Error: Session limit reached (${SESSION_LIMIT}/${SESSION_LIMIT}). Close an existing session.`, + }); + return; + } + + // Initialize a new config for this session tied to its cwd. + const sessionArgv = { ...baseArgv, prompt: input }; + const sessionConfig = await loadCliConfig( + settings.merged, + sessionName, + sessionArgv, + { + cwd: resolvedCwd, + mcpInitializationInBackground: true, + }, + ); + await sessionConfig.initialize(); + if (settings.merged.security.auth.selectedType) { + const authType = await validateNonInteractiveAuth( + settings.merged.security.auth.selectedType, + settings.merged.security.auth.useExternal, + sessionConfig, + settings, + ); + await sessionConfig.refreshAuth(authType); + } + + session = { + id: sessionName, + config: sessionConfig, + abortController: new AbortController(), + lastActivityMs: Date.now(), + }; + activeSessions.set(sessionName, session); + } + + session.lastActivityMs = Date.now(); + // Fresh abort controller for this turn. + session.abortController = new AbortController(); + session.closing = false; + + const turnPromise = runDaemonTurn( + session, + input, + socket, + verbose ?? false, + ); + session.activeTurn = turnPromise; + await turnPromise; + }); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + debugLogger.error(`Daemon session error: ${errorMessage}`, err); + safeWrite(socket, { + type: 'error', + content: 'Error executing prompt.', + }); + } + + safeWrite(socket, { type: 'end' }); + socket.end(); + return; + } + + safeWrite(socket, { type: 'error', content: 'Invalid daemon action.' }); + safeWrite(socket, { type: 'end' }); + socket.end(); +} + +async function runDaemonTurn( + session: DaemonSession, + input: string, + socket: net.Socket, + verbose: boolean, +) { + const { config, abortController } = session; + const prompt_id = crypto.randomUUID(); + + return promptIdContext.run(prompt_id, async () => { + try { + const geminiClient = config.getGeminiClient(); + const scheduler = new Scheduler({ + context: config, + messageBus: config.getMessageBus(), + getPreferredEditor: () => undefined, + schedulerId: ROOT_SCHEDULER_ID, + }); + + const query: Part[] = [{ text: input }]; + let currentMessages: Content[] = [{ role: 'user', parts: query }]; + let turnCount = 0; + + while (true) { + turnCount++; + if ( + config.getMaxSessionTurns() >= 0 && + turnCount > config.getMaxSessionTurns() + ) { + safeWrite(socket, { + type: 'error', + content: 'Maximum session turns exceeded', + }); + return; + } + + const responseStream = geminiClient.sendMessageStream( + currentMessages[0]?.parts || [], + abortController.signal, + prompt_id, + undefined, + false, + turnCount === 1 ? input : undefined, + ); + + const toolCallRequests: ToolCallRequestInfo[] = []; + + for await (const event of responseStream) { + if (abortController.signal.aborted) { + safeWrite(socket, { type: 'error', content: 'Cancelled' }); + return; + } + + if (event.type === GeminiEventType.Content) { + if (event.value) { + safeWrite(socket, { type: 'output', content: event.value }); + } + } else if (event.type === GeminiEventType.ToolCallRequest) { + if (verbose) { + safeWrite(socket, { + type: 'verbose', + content: `[Daemon] Calling tool: ${event.value.name}`, + }); + } + toolCallRequests.push(event.value); + } else if (event.type === GeminiEventType.Error) { + throw event.value.error; + } else if ( + event.type === GeminiEventType.AgentExecutionStopped || + event.type === GeminiEventType.AgentExecutionBlocked + ) { + return; + } + } + + if (toolCallRequests.length > 0) { + const completedToolCalls = await scheduler.schedule( + toolCallRequests, + abortController.signal, + ); + const toolResponseParts: Part[] = []; + + for (const completedToolCall of completedToolCalls) { + const toolResponse = completedToolCall.response; + if (toolResponse.responseParts) { + toolResponseParts.push(...toolResponse.responseParts); + } + } + + try { + const currentModel = + geminiClient.getCurrentSequenceModel() ?? config.getModel(); + geminiClient + .getChat() + .recordCompletedToolCalls(currentModel, completedToolCalls); + await recordToolCallInteractions(config, completedToolCalls); + } catch (e) { + debugLogger.error('Error recording tool outputs', e); + } + + const stopExecutionTool = completedToolCalls.find( + (tc: CompletedToolCall) => + tc.response.errorType === ToolErrorType.STOP_EXECUTION, + ); + if (stopExecutionTool) { + return; + } + + currentMessages = [{ role: 'user', parts: toolResponseParts }]; + } else { + return; // Done + } + } + } catch (error: unknown) { + debugLogger.error('Error executing daemon prompt:', error); + safeWrite(socket, { + type: 'error', + content: 'Error executing prompt.', + }); + } + }); +} diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index fd19ffa79ca..d7ba404e3ad 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -516,6 +516,13 @@ describe('gemini.tsx main function kitty protocol', () => { rawOutput: undefined, acceptRawOutputRisk: undefined, isCommand: undefined, + daemon: undefined, + daemonStatus: undefined, + daemonStop: undefined, + client: undefined, + session: undefined, + close: undefined, + verbose: undefined, }); await act(async () => { @@ -574,6 +581,13 @@ describe('gemini.tsx main function kitty protocol', () => { rawOutput: undefined, acceptRawOutputRisk: undefined, isCommand: undefined, + daemon: undefined, + daemonStatus: undefined, + daemonStop: undefined, + client: undefined, + session: undefined, + close: undefined, + verbose: undefined, }); await act(async () => { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index fa22f592672..8f9b79d7769 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -285,6 +285,39 @@ export async function main() { }); } + // Check for daemon client/server commands — these need raw stdio (not + // patched) because they write directly to stdout/stderr and must not be + // intercepted by the interactive UI renderer. + if ( + argv.daemonStatus || + argv.daemonStop || + argv.client || + argv.close || + argv.daemon + ) { + cleanupStdio(); + + if (argv.daemon) { + const { startDaemon } = await import('./daemon/daemonServer.js'); + await startDaemon(settings, argv); + return; + } + + let input = argv.promptInteractive || argv.prompt || argv.query || ''; + if (argv.client && !process.stdin.isTTY) { + const { readStdin } = await import('./utils/readStdin.js'); + const stdinData = await readStdin(); + if (stdinData) { + input = input ? `${stdinData}\n\n${input}` : stdinData; + } + } + const { runDaemonClientCommands } = await import( + './daemon/daemonClient.js' + ); + await runDaemonClientCommands(argv, input); + return; + } + // Check for invalid input combinations early to prevent crashes if (argv.promptInteractive && !process.stdin.isTTY) { writeToStderr( diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 34a19f01d53..6f8fa52120f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -662,6 +662,13 @@ export interface ConfigParameters { discoveryMaxDirs?: number; compressionThreshold?: number; interactive?: boolean; + /** + * When true, MCP initialization will run in the background even when + * `interactive` is false. This is useful for daemon/headless mode where we + * want to avoid waiting on MCP server startup, without changing the + * interactive safety posture. + */ + mcpInitializationInBackground?: boolean; trustedFolder?: boolean; useBackgroundColor?: boolean; useAlternateBuffer?: boolean; @@ -871,6 +878,7 @@ export class Config implements McpContext, AgentLoopContext { private readonly compressionThreshold: number | undefined; /** Public for testing only */ readonly interactive: boolean; + private readonly mcpInitializationInBackground: boolean; private readonly ptyInfo: string; private readonly trustedFolder: boolean | undefined; private readonly directWebFetch: boolean; @@ -1218,6 +1226,8 @@ export class Config implements McpContext, AgentLoopContext { this.discoveryMaxDirs = params.discoveryMaxDirs ?? 200; this.compressionThreshold = params.compressionThreshold; this.interactive = params.interactive ?? false; + this.mcpInitializationInBackground = + params.mcpInitializationInBackground ?? false; this.ptyInfo = params.ptyInfo ?? 'child_process'; this.trustedFolder = params.trustedFolder; this.directWebFetch = params.directWebFetch ?? false; @@ -1458,7 +1468,10 @@ export class Config implements McpContext, AgentLoopContext { } }); - if (!this.interactive || this.acpMode) { + if ( + this.acpMode || + (!this.interactive && !this.mcpInitializationInBackground) + ) { await this.mcpInitializationPromise; }