Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand All @@ -522,7 +578,12 @@ export async function loadCliConfig(
argv: CliArgs,
options: LoadCliConfigOptions = {},
): Promise<Config> {
const { cwd = process.cwd(), projectHooks } = options;
const {
cwd = process.cwd(),
projectHooks,
forceInteractive,
mcpInitializationInBackground,
} = options;
const debugMode = isDebugMode(argv);

const worktreeSettings =
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -990,6 +1052,7 @@ export async function loadCliConfig(
compressionThreshold: settings.model?.compressionThreshold,
folderTrust,
interactive,
mcpInitializationInBackground,
trustedFolder,
useBackgroundColor: settings.ui?.useBackgroundColor,
useAlternateBuffer: settings.ui?.useAlternateBuffer,
Expand Down
278 changes: 278 additions & 0 deletions packages/cli/src/daemon/daemon.test.ts
Original file line number Diff line number Diff line change
@@ -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<CoreModule>();
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<typeof vi.fn>).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<void>((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<void>((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));
});
});
});
});
Loading