Skip to content

feat(daemon): add stateful headless daemon mode#20700

Open
h30s wants to merge 6 commits intogoogle-gemini:mainfrom
h30s:feat/daemon-mode-15338
Open

feat(daemon): add stateful headless daemon mode#20700
h30s wants to merge 6 commits intogoogle-gemini:mainfrom
h30s:feat/daemon-mode-15338

Conversation

@h30s
Copy link
Copy Markdown
Contributor

@h30s h30s commented Feb 28, 2026

Summary

This PR introduces a stateful headless Daemon Mode for the Gemini CLI, allowing seamless, continuous CLI context and automation without the interactive TUI startup penalty.

Details

Introduces --daemon, --daemon-status, --daemon-stop to manage the background server.
Introduces --client, --session, --close, --verbose to establish communication with the daemon via Unix sockets. Context and CWD are retained per named session.

Related Issues

Fixes #15338

How to Validate

  1. Start daemon in a terminal: npm run start -- --daemon
  2. Run prompt via client: npm run start -- --client --session test "what is 2+2"
  3. Verify persistent memory: npm run start -- --client --session test "multiply that by 4"

Pre-Merge Checklist

  • Updated relevant documentation and README (if needed)
  • Added/updated tests (if needed)
  • Noted breaking changes (if any)
  • Validated on required platforms/methods:
    • Linux
      • npm run

      • npx

      • Docker

      • Podman

      • Seatbelt

    • Windows
      • npm run
      • npx
      • Docker
    • Linux
      • npm run
      • npx
      • Docker

@h30s h30s requested a review from a team as a code owner February 28, 2026 18:58
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the Gemini CLI by introducing a new daemon mode. This mode allows the CLI to operate as a persistent background service, eliminating the overhead of repeated TUI startups and enabling continuous, stateful interactions. Users can now manage the daemon and interact with it via a client, leveraging named sessions to maintain context and current working directory across multiple prompts, which is particularly beneficial for automation workflows.

Highlights

  • New Daemon Mode: Introduced a stateful headless daemon mode for the Gemini CLI, allowing it to run as a persistent background process.
  • Daemon Management CLI Arguments: Added new CLI arguments: --daemon to start the daemon, --daemon-status to check its running state, and --daemon-stop to gracefully shut it down.
  • Client Interaction CLI Arguments: Implemented client-side arguments: --client to connect to the daemon, --session to manage named sessions with persistent context, --close to terminate a specific session, and --verbose for detailed client output.
  • Unix Socket Communication: Enabled communication between the CLI client and the daemon server using Unix sockets for efficient inter-process communication.
  • Session Management: Provided functionality to maintain context and current working directory (CWD) per named session, enhancing automation capabilities.
Changelog
  • packages/cli/src/config/config.ts
    • Added new boolean and string properties to the CliArgs interface to support daemon and client mode options.
    • Extended the parseArguments function to include new CLI options for daemon management (--daemon, --daemon-status, --daemon-stop) and client interaction (--client, --session, --close, --verbose).
  • packages/cli/src/daemon/daemon.test.ts
    • Added a new test file to validate the daemon client functionality.
    • Included tests for getDaemonSocketPath to ensure correct path generation and platform-specific error handling.
    • Implemented tests for checkDaemonStatus to verify accurate reporting of the daemon's running state.
  • packages/cli/src/daemon/daemonClient.ts
    • Created a new module for client-side daemon interaction.
    • Implemented getDaemonSocketPath to determine the Unix socket file location.
    • Developed checkDaemonStatus to ascertain if the daemon is active.
    • Provided connectToDaemon for establishing a connection to the daemon server.
    • Implemented runDaemonClientCommands to handle various client actions such as checking status, stopping the daemon, closing sessions, and sending prompts, communicating via JSON over Unix sockets.
  • packages/cli/src/daemon/daemonServer.ts
    • Created a new module containing the core logic for the daemon server.
    • Implemented startDaemon to initialize and listen for client connections on a Unix socket, including pre-loading base configuration and authentication.
    • Developed shutdownDaemon for graceful termination of the server and cleanup of the socket file.
    • Implemented handleClientRequest to parse incoming client payloads and dispatch actions like stopping the daemon, closing specific sessions, or processing prompts.
    • Provided runDaemonTurn to manage the lifecycle of a prompt within a daemon session, including handling tool calls and streaming responses back to the client.
  • packages/cli/src/gemini.test.tsx
    • Updated the CliArgs mock within gemini.test.tsx to include the newly introduced daemon and client mode properties, ensuring test compatibility.
  • packages/cli/src/gemini.tsx
    • Modified the main function to include early checks for daemon client and server commands.
    • Added conditional logic to import and execute runDaemonClientCommands or startDaemon based on the presence of relevant CLI arguments.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a stateful headless daemon mode for the Gemini CLI. While a significant feature, it presents critical security vulnerabilities, including unrestricted Unix socket permissions that could lead to session hijacking, and blind trust in the client's Current Working Directory (CWD), enabling potential path traversal for unauthorized information disclosure. These path traversal issues are reinforced by existing guidelines on sanitizing user-provided paths and internal validation in utility functions. Additionally, the implementation has robustness issues such as race conditions during daemon shutdown and incorrect handling of streaming data on the client side. Addressing these concerns is crucial for the security and reliability of this new daemon feature.

@gemini-cli gemini-cli bot added priority/p2 Important but can be addressed in a future release. area/non-interactive Issues related to GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation help wanted We will accept PRs from all issues marked as "help wanted". Thanks for your support! labels Feb 28, 2026
@gsquared94
Copy link
Copy Markdown
Contributor

Can you attach a screencast showing what you've implemented and how it works?

@h30s h30s force-pushed the feat/daemon-mode-15338 branch from 99dc207 to 81ce1b3 Compare March 6, 2026 07:36
@h30s
Copy link
Copy Markdown
Contributor Author

h30s commented Mar 6, 2026

@gsquared94 Here's a screencast of the daemon mode in action:

Screencast.From.2026-03-06.21-09-00.mp4

@gsquared94
Copy link
Copy Markdown
Contributor

Apologies for the delay in reviewing, I ran an AI review and it pointed the following issues with this PR


Summary

This PR adds a persistent background daemon mode for the Gemini CLI, communicating via Unix domain sockets. While the feature concept is solid and addresses a real user need (avoiding TUI startup cost for scripting/automation), the implementation has critical security vulnerabilities, several architectural concerns, and a number of coding pattern issues that must be addressed before merge.


🔴 Critical — Security Issues

1. Unix Socket Has No Authentication — Any Local User Can Execute Arbitrary AI-Powered Actions

Caution

Severity: Critical — Remote Code Execution by any local user

daemonServer.ts applies chmod 0o600 to the socket after server.listen():

server.listen(socketPath, () => {
  fs.chmodSync(socketPath, 0o600);  // TOCTOU: socket is world-accessible between listen() and chmod()
});

Problems:

  • TOCTOU race condition: Between listen() creating the socket and chmod() running, the socket is created with the default umask permissions. An attacker on the same machine can connect during this window.
  • No in-band authentication: Even after chmod, any process running as the same user can connect and send arbitrary prompts. On shared servers or compromised multi-process environments, this means any process can instruct the AI to execute shell commands, read files, and modify the filesystem — with the daemon owner's full permissions.
  • No socket ownership verification on the client side: the client connects blindly to ~/.gemini/daemon.sock without verifying who owns the socket. A malicious user could pre-create the socket path and MITM all requests.

Recommendation:

  • Set umask(0o077) before listen() and restore it after, or use fs.mkdirSync with restrictive permissions on the containing directory.
  • Add a shared secret or token-based authentication (e.g., write a random token to ~/.gemini/daemon.token with 0o600 perms, require clients to present it).
  • On the client side, verify the socket ownership (uid) before connecting.

2. forceInteractive: true Bypasses Headless Safety Guardrails

daemonServer.ts:

const sessionConfig = await loadCliConfig(
  settings.merged,
  sessionName,
  sessionArgv,
  { cwd, forceInteractive: true },
);

The daemon is a headless, non-interactive process, yet it forces interactive = true in the config. The interactive flag changes the security posture of tool execution:

  • In non-interactive (headless) mode, the CLI has stricter policies and may refuse certain tool operations.
  • By forcing interactive mode, the daemon grants interactive-level trust to what is effectively an automated, unattended process with no human in the loop to confirm dangerous operations.

This is especially dangerous combined with Issue #1 — any local process can submit prompts that execute tools with interactive-level permissions, with no user confirmation whatsoever.

Recommendation: The daemon should run with headless security policy, not interactive. If the intent is MCP server initialization behavior, decouple that from the interactive flag.

3. User-Controlled cwd — Path Traversal and Symlink Attacks

daemonServer.ts:

const resolvedCwd = path.resolve(cwd);
const homeDir = os.homedir();
if (
  resolvedCwd !== homeDir &&
  !resolvedCwd.startsWith(homeDir + path.sep)
) { /* reject */ }

Problems:

  • path.resolve() does NOT resolve symlinks. An attacker can create a symlink inside ~/ that points to /etc, /root, or any sensitive directory. The check passes, but the actual working directory is outside $HOME.
  • The "must be under $HOME" check is well-intentioned but insufficient without fs.realpathSync().
  • On macOS, os.homedir() returns /Users/foo but /var paths can resolve to /private/var, creating inconsistencies.

Recommendation: Use fs.realpathSync(cwd) and fs.realpathSync(os.homedir()) for the comparison. Consider also checking fs.statSync(resolvedCwd).isDirectory() rather than just fs.existsSync().

4. Session Name Injection in Log/Error Messages

While SESSION_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/ is good validation, the session name is interpolated directly into JSON strings sent to the client:

content: `Session ${sessionName} closed.\n`
content: `Session ${sessionName} not found.\n`

This is safe today because the regex prevents special characters, but the pattern is fragile. If the regex is ever relaxed, this becomes an injection vector.

Recommendation: Always use parameterized messages or explicitly escape interpolated user input.


🟠 Major — Architecture Issues

5. Module-Level Mutable Singleton Map for Sessions

const activeSessions = new Map<string, DaemonSession>();

This is a module-level mutable global. Problems:

  • Untestable: Tests can't easily reset state between runs without reaching into module internals.
  • Not thread-safe: While Node.js is single-threaded, the socket.on('data') handler is async. Multiple concurrent connections for the same session could interleave, leading to race conditions on activeSessions.get() / activeSessions.set().
  • No session cleanup on error: If loadCliConfig or initialize() throws after activeSessions.size >= 5 check but before activeSessions.set(), the session isn't added but the slot is still "reserved" by the check passing.

Recommendation: Encapsulate session management in a class with proper locking (e.g., per-session mutex via a Promise chain) and make it injectable for testing.

6. Concurrent Request Handling Is Broken

The socket.on('data') handler does:

for (const line of parts) {
  // ...
  await handleClientRequest(payload, socket, settings, baseArgv);
}

But socket.on('data') can fire again while the await is blocked. Node's event loop will invoke the callback for new data events while the current handler is awaiting, meaning:

  • A second prompt for the same session will replace session.abortController with a fresh one while the first runDaemonTurn is still running.
  • Both turns write to the same socket concurrently, producing interleaved JSON lines.
  • A stop action received during a prompt execution will trigger process.kill(process.pid, 'SIGTERM') while tool calls are in-flight.

Recommendation: Implement per-connection request queuing. Only one request should be active per socket at a time. For per-session concurrency, use a per-session lock.

7. No Session Resource Cleanup / Memory Leak

DaemonSession holds a Config object (which includes a GeminiClient, MessageBus, MCP connections, etc.), but:

  • There is no TTL or idle timeout for sessions. Sessions persist indefinitely until explicitly closed or the daemon is stopped.
  • Config objects may hold open connections (MCP servers, etc.) that are never cleaned up.
  • Aborting a session (abortController.abort()) during close_session doesn't call any cleanup on the Config object.

Recommendation: Add idle timeouts for sessions. Implement a destroy() or close() method on sessions that properly tears down all held resources.

8. Heavy Duplication of the Core Turn Loop

runDaemonTurn() reimplements the core Gemini turn loop (stream → tool calls → record → loop). This is essentially a copy-paste from the existing gemini.tsx / App.tsx rendering loop, adapted for sockets.

This creates a significant maintenance burden:

  • Any bug fix or feature addition to the core turn loop must be duplicated here.
  • The daemon version may drift out of sync (e.g., missing handling for new GeminiEventType values, new safety checks, etc.).

Recommendation: Extract the core turn loop into a shared abstraction (e.g., a "headless runner" or "prompt executor") that both the TUI and daemon mode use. The SDK (packages/sdk) might already have something similar to build on.


🟡 Moderate — Coding Pattern Issues

9. process.exit() Called from Library Code

Both daemonClient.ts and daemonServer.ts call process.exit() liberally. This is an anti-pattern:

  • It makes the code impossible to test properly (the test mocks process.exit but this is fragile).
  • It prevents proper cleanup (finally blocks, event handlers, etc.).
  • It bypasses Node.js's graceful shutdown.

Recommendation: Throw typed errors or return exit codes. Let main() be the only place that calls process.exit().

10. Unsafe Type Casting of Unvalidated Input

const raw: unknown = JSON.parse(line);
const payload: DaemonPayload =
  raw !== null && typeof raw === 'object'
    ? (raw as DaemonPayload)
    : {};

This checks that the parsed JSON is an object, then blindly casts it to DaemonPayload. The action, session, cwd, input, verbose fields are assumed to be the right types but never validated. A client sending { "action": "prompt", "input": 123 } would pass all checks but input would be a number, not a string.

Recommendation: Use a proper validation function (like isDaemonMessage on the client side, but more thorough). Validate each field's type.

11. Non-Cryptographic Randomness for prompt_id

const prompt_id = Math.random().toString(16).slice(2);

Math.random() is not cryptographically secure and can produce duplicates under high load. If prompt_id is used for any correlation or security purpose, this is a vulnerability.

Recommendation: Use crypto.randomUUID() which is available in Node.js 19+ and is both unique and cryptographically random.

12. Error Messages Leak Internal Details

content: errorMessage || 'Unknown handler error',
content: `Error executing prompt: ${errorMessage}`,

Error messages from internal exceptions (stack traces, file paths, module names) are sent directly to the client over the socket. In a daemon scenario where something other than the immediate user could potentially be the client (see Issue #1), this constitutes information disclosure.

Recommendation: Send sanitized, user-facing error messages. Log detailed errors to the debug logger only.

13. --close Without --client Has Ambiguous Behavior

The --close flag works independent of --client in gemini.tsx:

if (argv.daemonStatus || argv.daemonStop || argv.client || argv.close || argv.daemon)

But conceptually --close is a client operation. The yargs configuration doesn't enforce --close requiring --session, nor does it make --close imply --client. Edge cases like --close --daemon are not handled.

Recommendation: Use yargs .implies() or .conflicts() to enforce valid flag combinations. Add mutual exclusion between --daemon and client-side flags.

14. Tests Are Minimal and Fragile

The test file (daemon.test.ts) has only 3 tests:

  1. Windows platform rejection (via Object.defineProperty — fragile)
  2. Status check when not running
  3. Status check when running (using setTimeout for synchronization — flaky)

Missing test coverage:

  • Zero tests for daemonServer.ts (the most complex and security-critical file)
  • No tests for concurrent request handling
  • No tests for session lifecycle (create, reuse, close, limit)
  • No tests for malicious input handling
  • No tests for the CWD validation logic
  • No integration test verifying end-to-end daemon↔client flow

Recommendation: Add comprehensive unit tests for handleClientRequest, runDaemonTurn, and the CWD validation. Add integration tests for the socket protocol.


🔵 Minor / Nits

15. Magic Number: Session Limit

if (activeSessions.size >= 5) {

The limit of 5 is hardcoded. Should be a named constant and/or configurable.

16. Inconsistent Newline in Error Messages

Some error content values end with \n, some don't. This creates inconsistent client output:

content: 'Missing or invalid session name. Use 1-64 alphanumeric...\n'  // has \n
content: 'Missing required prompt parameters.'  // no \n
content: 'Error: Session limit reached (5/5). Close an existing session.'  // no \n

17. Socket Path Not Configurable

getDaemonSocketPath() hardcodes ~/.gemini/daemon.sock. For multi-instance usage or containerized environments, this should be configurable (e.g., via an environment variable or --socket-path flag).

18. Missing socket.end() After Error in handleClientRequest

When action === 'prompt' and an early validation error occurs, the code writes an error + end message but does NOT end the socket. This leaves the connection open, potentially leaking file descriptors:

socket.write(JSON.stringify({ type: 'error', content: '...' }) + '\n');
socket.write(JSON.stringify({ type: 'end' }) + '\n');
// missing: socket.end();
return;

19. No PID File for Daemon Management

The daemon doesn't write a PID file. This means:

  • There's no way to detect a zombie daemon (socket file exists but process is dead)
  • --daemon-status relies on a connection test, which is slower and less reliable

Verification Questions

  1. Does the daemon inherit the sandbox policy from the starting terminal session? — Based on the code, it uses forceInteractive: true which may bypass sandbox restrictions entirely. This needs explicit verification.

  2. What happens if two users on the same machine both run --daemon? — They'd both try to create ~/.gemini/daemon.sock in their own home dirs, which should be fine, but the socket permissions under default umask could be globally readable.

  3. What happens if the daemon process crashes without cleanup? — The stale socket file at ~/.gemini/daemon.sock would prevent a new daemon from starting cleanly. The "delete if exists" logic handles this via if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath), but this fires before the checkDaemonStatus() call, meaning it could delete a live daemon's socket.

  4. Is the prompt action's finally block safe if the socket is already closed?socket.write() on a destroyed socket will throw/emit error. The finally block always writes { type: 'end' } but doesn't check if the socket is still writable.

  5. What happens with extremely large payloads over the socket? — There is no message size limit. A client could send a multi-gigabyte JSON line, causing the daemon to OOM.


Summary Table

# Severity Category Issue
1 🔴 Critical Security No socket authentication — local process RCE
2 🔴 Critical Security forceInteractive bypasses headless safety
3 🔴 Critical Security CWD symlink traversal bypass
4 🟠 Major Security Fragile session name sanitization
5 🟠 Major Architecture Module-level mutable global state
6 🟠 Major Architecture Broken concurrent request handling
7 🟠 Major Architecture No session TTL / resource cleanup
8 🟠 Major Architecture Duplicated turn loop
9 🟡 Moderate Pattern process.exit() from library code
10 🟡 Moderate Pattern Unsafe type casting of IPC messages
11 🟡 Moderate Security Non-cryptographic prompt_id
12 🟡 Moderate Security Error message information disclosure
13 🟡 Moderate UX Ambiguous flag combinations
14 🟡 Moderate Quality Minimal test coverage
15 🔵 Minor Pattern Hardcoded session limit
16 🔵 Minor Pattern Inconsistent newlines
17 🔵 Minor UX Non-configurable socket path
18 🔵 Minor Bug Missing socket.end() after errors
19 🔵 Minor Ops No PID file

Overall Recommendation: This PR should not be merged in its current state. The socket authentication gap (Issue No. 1) combined with forceInteractive (Issue No. 2) creates a scenario where any local process can instruct the AI to execute arbitrary shell commands with the daemon owner's privileges and without any user confirmation gate. These are the minimum blockers. Issues 3, 5, 6, 7, and 8 should also be addressed before merge.

@h30s
Copy link
Copy Markdown
Contributor Author

h30s commented Apr 2, 2026

@gsquared94 PTAL

@gsquared94
Copy link
Copy Markdown
Contributor

@gsquared94 PTAL

Please fix the CI failures.

@h30s
Copy link
Copy Markdown
Contributor Author

h30s commented Apr 5, 2026

Please fix the CI failures.

Done!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/non-interactive Issues related to GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation help wanted We will accept PRs from all issues marked as "help wanted". Thanks for your support! priority/p2 Important but can be addressed in a future release.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: add a stateful headless mode (daemon/server mode)

3 participants