diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 1d075989af..1d1b18351d 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -50,6 +50,50 @@ Cross-platform sandboxing with complete process isolation. **Note**: Requires building the sandbox image locally or using a published image from your organization's registry. +### 3. LXC/LXD (Linux only, experimental) + +Full-system container sandboxing using LXC/LXD. Unlike Docker/Podman, LXC +containers run a complete Linux system with `systemd`, `snapd`, and other system +services. This is ideal for tools that don't work in standard Docker containers, +such as Snapcraft and Rockcraft. + +**Prerequisites**: + +- Linux only. +- LXC/LXD must be installed (`snap install lxd` or `apt install lxd`). +- A container must be created and running before starting Gemini CLI. Gemini + does **not** create the container automatically. + +**Quick setup**: + +```bash +# Initialize LXD (first time only) +lxd init --auto + +# Create and start an Ubuntu container +lxc launch ubuntu:24.04 gemini-sandbox + +# Enable LXC sandboxing +export GEMINI_SANDBOX=lxc +gemini -p "build the project" +``` + +**Custom container name**: + +```bash +export GEMINI_SANDBOX=lxc +export GEMINI_SANDBOX_IMAGE=my-snapcraft-container +gemini -p "build the snap" +``` + +**Limitations**: + +- Linux only (LXC is not available on macOS or Windows). +- The container must already exist and be running. +- The workspace directory is bind-mounted into the container at the same + absolute path — the path must be writable inside the container. +- Used with tools like Snapcraft or Rockcraft that require a full system. + ## Quickstart ```bash @@ -88,7 +132,8 @@ gemini -p "run the test suite" ### Enable sandboxing (in order of precedence) 1. **Command flag**: `-s` or `--sandbox` -2. **Environment variable**: `GEMINI_SANDBOX=true|docker|podman|sandbox-exec` +2. **Environment variable**: + `GEMINI_SANDBOX=true|docker|podman|sandbox-exec|lxc` 3. **Settings file**: `"sandbox": true` in the `tools` object of your `settings.json` file (e.g., `{"tools": {"sandbox": true}}`). diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 82ee987eb2..9da687a3df 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -747,7 +747,8 @@ their corresponding top-level category object in your `settings.json` file. - **`tools.sandbox`** (boolean | string): - **Description:** Sandbox execution environment. Set to a boolean to enable - or disable the sandbox, or provide a string path to a sandbox profile. + or disable the sandbox, provide a string path to a sandbox profile, or + specify an explicit sandbox command (e.g., "docker", "podman", "lxc"). - **Default:** `undefined` - **Requires restart:** Yes diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts index 14080dc30b..8083b0ddf1 100644 --- a/packages/cli/src/config/sandboxConfig.test.ts +++ b/packages/cli/src/config/sandboxConfig.test.ts @@ -97,7 +97,7 @@ describe('loadSandboxConfig', () => { it('should throw if GEMINI_SANDBOX is an invalid command', async () => { process.env['GEMINI_SANDBOX'] = 'invalid-command'; await expect(loadSandboxConfig({}, {})).rejects.toThrow( - "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec", + "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, lxc", ); }); @@ -108,6 +108,22 @@ describe('loadSandboxConfig', () => { "Missing sandbox command 'docker' (from GEMINI_SANDBOX)", ); }); + + it('should use lxc if GEMINI_SANDBOX=lxc and it exists', async () => { + process.env['GEMINI_SANDBOX'] = 'lxc'; + mockedCommandExistsSync.mockReturnValue(true); + const config = await loadSandboxConfig({}, {}); + expect(config).toEqual({ command: 'lxc', image: 'default/image' }); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('lxc'); + }); + + it('should throw if GEMINI_SANDBOX=lxc but lxc command does not exist', async () => { + process.env['GEMINI_SANDBOX'] = 'lxc'; + mockedCommandExistsSync.mockReturnValue(false); + await expect(loadSandboxConfig({}, {})).rejects.toThrow( + "Missing sandbox command 'lxc' (from GEMINI_SANDBOX)", + ); + }); }); describe('with sandbox: true', () => { diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index 57430becae..bb812cd317 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -27,6 +27,7 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray = [ 'docker', 'podman', 'sandbox-exec', + 'lxc', ]; function isSandboxCommand(value: string): value is SandboxConfig['command'] { @@ -91,6 +92,9 @@ function getSandboxCommand( } return ''; + // Note: 'lxc' is intentionally not auto-detected because it requires a + // pre-existing, running container managed by the user. Use + // GEMINI_SANDBOX=lxc or sandbox: "lxc" in settings to enable it. } export async function loadSandboxConfig( diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fb0520d334..8c0d13e2dd 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1236,7 +1236,8 @@ const SETTINGS_SCHEMA = { ref: 'BooleanOrString', description: oneLine` Sandbox execution environment. - Set to a boolean to enable or disable the sandbox, or provide a string path to a sandbox profile. + Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, + or specify an explicit sandbox command (e.g., "docker", "podman", "lxc"). `, showInDialog: false, }, diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index 50b1699644..3b66d1a6de 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -5,7 +5,7 @@ */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { spawn, exec, execSync } from 'node:child_process'; +import { spawn, exec, execFile, execSync } from 'node:child_process'; import os from 'node:os'; import fs from 'node:fs'; import { start_sandbox } from './sandbox.js'; @@ -50,6 +50,26 @@ vi.mock('node:util', async (importOriginal) => { return { stdout: '', stderr: '' }; }; } + if (fn === execFile) { + return async (file: string, args: string[]) => { + if (file === 'lxc' && args[0] === 'list') { + const output = process.env['TEST_LXC_LIST_OUTPUT']; + if (output === 'throw') { + throw new Error('lxc command not found'); + } + return { stdout: output ?? '[]', stderr: '' }; + } + if ( + file === 'lxc' && + args[0] === 'config' && + args[1] === 'device' && + args[2] === 'add' + ) { + return { stdout: '', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }; + } return actual.promisify(fn); }, }; @@ -473,5 +493,84 @@ describe('sandbox', () => { expect(entrypointCmd).toContain('useradd'); expect(entrypointCmd).toContain('su -p gemini'); }); + + describe('LXC sandbox', () => { + const LXC_RUNNING = JSON.stringify([ + { name: 'gemini-sandbox', status: 'Running' }, + ]); + const LXC_STOPPED = JSON.stringify([ + { name: 'gemini-sandbox', status: 'Stopped' }, + ]); + + beforeEach(() => { + delete process.env['TEST_LXC_LIST_OUTPUT']; + }); + + it('should run lxc exec with correct args for a running container', async () => { + process.env['TEST_LXC_LIST_OUTPUT'] = LXC_RUNNING; + const config: SandboxConfig = { + command: 'lxc', + image: 'gemini-sandbox', + }; + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + + vi.mocked(spawn).mockImplementation((cmd) => { + if (cmd === 'lxc') { + return mockSpawnProcess; + } + return new EventEmitter() as unknown as ReturnType; + }); + + const promise = start_sandbox(config, [], undefined, ['arg1']); + await expect(promise).resolves.toBe(0); + + expect(spawn).toHaveBeenCalledWith( + 'lxc', + expect.arrayContaining(['exec', 'gemini-sandbox', '--cwd']), + expect.objectContaining({ stdio: 'inherit' }), + ); + }); + + it('should throw FatalSandboxError if lxc list fails', async () => { + process.env['TEST_LXC_LIST_OUTPUT'] = 'throw'; + const config: SandboxConfig = { + command: 'lxc', + image: 'gemini-sandbox', + }; + + await expect(start_sandbox(config)).rejects.toThrow( + /Failed to query LXC container/, + ); + }); + + it('should throw FatalSandboxError if container is not running', async () => { + process.env['TEST_LXC_LIST_OUTPUT'] = LXC_STOPPED; + const config: SandboxConfig = { + command: 'lxc', + image: 'gemini-sandbox', + }; + + await expect(start_sandbox(config)).rejects.toThrow(/is not running/); + }); + + it('should throw FatalSandboxError if container is not found in list', async () => { + process.env['TEST_LXC_LIST_OUTPUT'] = '[]'; + const config: SandboxConfig = { + command: 'lxc', + image: 'gemini-sandbox', + }; + + await expect(start_sandbox(config)).rejects.toThrow(/not found/); + }); + }); }); }); diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index ffd77fb119..94811107fc 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { exec, execSync, spawn, type ChildProcess } from 'node:child_process'; +import { + exec, + execFile, + execFileSync, + execSync, + spawn, + type ChildProcess, +} from 'node:child_process'; import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; @@ -34,6 +41,7 @@ import { } from './sandboxUtils.js'; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); export async function start_sandbox( config: SandboxConfig, @@ -203,6 +211,10 @@ export async function start_sandbox( }); } + if (config.command === 'lxc') { + return await start_lxc_sandbox(config, nodeArgs, cliArgs); + } + debugLogger.log(`hopping into sandbox (command: ${config.command}) ...`); // determine full path for gemini-cli to distinguish linked vs installed setting @@ -722,6 +734,208 @@ export async function start_sandbox( } } +// Helper function to start a sandbox using LXC/LXD. +// Unlike Docker/Podman, LXC does not launch a transient container from an +// image. The user creates and manages their own LXC container; Gemini runs +// inside it via `lxc exec`. The container name is stored in config.image +// (default: "gemini-sandbox"). The workspace is bind-mounted into the +// container at the same absolute path. +async function start_lxc_sandbox( + config: SandboxConfig, + nodeArgs: string[] = [], + cliArgs: string[] = [], +): Promise { + const containerName = config.image || 'gemini-sandbox'; + const workdir = path.resolve(process.cwd()); + + debugLogger.log( + `starting lxc sandbox (container: ${containerName}, workdir: ${workdir}) ...`, + ); + + // Verify the container exists and is running. + let listOutput: string; + try { + const { stdout } = await execFileAsync('lxc', [ + 'list', + containerName, + '--format=json', + ]); + listOutput = stdout.trim(); + } catch (err) { + throw new FatalSandboxError( + `Failed to query LXC container '${containerName}': ${err instanceof Error ? err.message : String(err)}. ` + + `Make sure LXC/LXD is installed and '${containerName}' container exists. ` + + `Create one with: lxc launch ubuntu:24.04 ${containerName}`, + ); + } + + let containers: Array<{ name: string; status: string }> = []; + try { + const parsed: unknown = JSON.parse(listOutput); + if (Array.isArray(parsed)) { + containers = parsed + .filter( + (item): item is Record => + item !== null && + typeof item === 'object' && + 'name' in item && + 'status' in item, + ) + .map((item) => ({ + name: String(item['name']), + status: String(item['status']), + })); + } + } catch { + containers = []; + } + + const container = containers.find((c) => c.name === containerName); + if (!container) { + throw new FatalSandboxError( + `LXC container '${containerName}' not found. ` + + `Create one with: lxc launch ubuntu:24.04 ${containerName}`, + ); + } + if (container.status.toLowerCase() !== 'running') { + throw new FatalSandboxError( + `LXC container '${containerName}' is not running (current status: ${container.status}). ` + + `Start it with: lxc start ${containerName}`, + ); + } + + // Bind-mount the working directory into the container at the same path. + // Using "lxc config device add" is idempotent when the device name matches. + const deviceName = `gemini-workspace-${randomBytes(4).toString('hex')}`; + try { + await execFileAsync('lxc', [ + 'config', + 'device', + 'add', + containerName, + deviceName, + 'disk', + `source=${workdir}`, + `path=${workdir}`, + ]); + debugLogger.log( + `mounted workspace '${workdir}' into container as device '${deviceName}'`, + ); + } catch (err) { + throw new FatalSandboxError( + `Failed to mount workspace into LXC container '${containerName}': ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Remove the workspace device from the container when the process exits. + // Only the 'exit' event is needed — the CLI's cleanup.ts already handles + // SIGINT and SIGTERM by calling process.exit(), which fires 'exit'. + const removeDevice = () => { + try { + execFileSync( + 'lxc', + ['config', 'device', 'remove', containerName, deviceName], + { timeout: 2000 }, + ); + } catch { + // Best-effort cleanup; ignore errors on exit. + } + }; + process.on('exit', removeDevice); + + // Build the environment variable arguments for `lxc exec`. + const envArgs: string[] = []; + const envVarsToForward: Record = { + GEMINI_API_KEY: process.env['GEMINI_API_KEY'], + GOOGLE_API_KEY: process.env['GOOGLE_API_KEY'], + GOOGLE_GEMINI_BASE_URL: process.env['GOOGLE_GEMINI_BASE_URL'], + GOOGLE_VERTEX_BASE_URL: process.env['GOOGLE_VERTEX_BASE_URL'], + GOOGLE_GENAI_USE_VERTEXAI: process.env['GOOGLE_GENAI_USE_VERTEXAI'], + GOOGLE_GENAI_USE_GCA: process.env['GOOGLE_GENAI_USE_GCA'], + GOOGLE_CLOUD_PROJECT: process.env['GOOGLE_CLOUD_PROJECT'], + GOOGLE_CLOUD_LOCATION: process.env['GOOGLE_CLOUD_LOCATION'], + GEMINI_MODEL: process.env['GEMINI_MODEL'], + TERM: process.env['TERM'], + COLORTERM: process.env['COLORTERM'], + GEMINI_CLI_IDE_SERVER_PORT: process.env['GEMINI_CLI_IDE_SERVER_PORT'], + GEMINI_CLI_IDE_WORKSPACE_PATH: process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'], + TERM_PROGRAM: process.env['TERM_PROGRAM'], + }; + for (const [key, value] of Object.entries(envVarsToForward)) { + if (value) { + envArgs.push('--env', `${key}=${value}`); + } + } + + // Forward SANDBOX_ENV key=value pairs + if (process.env['SANDBOX_ENV']) { + for (let env of process.env['SANDBOX_ENV'].split(',')) { + if ((env = env.trim())) { + if (env.includes('=')) { + envArgs.push('--env', env); + } else { + throw new FatalSandboxError( + 'SANDBOX_ENV must be a comma-separated list of key=value pairs', + ); + } + } + } + } + + // Forward NODE_OPTIONS (e.g. from --inspect flags) + const existingNodeOptions = process.env['NODE_OPTIONS'] || ''; + const allNodeOptions = [ + ...(existingNodeOptions ? [existingNodeOptions] : []), + ...nodeArgs, + ].join(' '); + if (allNodeOptions.length > 0) { + envArgs.push('--env', `NODE_OPTIONS=${allNodeOptions}`); + } + + // Mark that we're running inside an LXC sandbox. + envArgs.push('--env', `SANDBOX=${containerName}`); + + // Build the command entrypoint (same logic as Docker path). + const finalEntrypoint = entrypoint(workdir, cliArgs); + + // Build the full lxc exec command args. + const args = [ + 'exec', + containerName, + '--cwd', + workdir, + ...envArgs, + '--', + ...finalEntrypoint, + ]; + + debugLogger.log(`lxc exec args: ${args.join(' ')}`); + + process.stdin.pause(); + const sandboxProcess = spawn('lxc', args, { + stdio: 'inherit', + }); + + return new Promise((resolve, reject) => { + sandboxProcess.on('error', (err) => { + coreEvents.emitFeedback('error', 'LXC sandbox process error', err); + reject(err); + }); + + sandboxProcess.on('close', (code, signal) => { + process.stdin.resume(); + process.off('exit', removeDevice); + removeDevice(); + if (code !== 0 && code !== null) { + debugLogger.log( + `LXC sandbox process exited with code: ${code}, signal: ${signal}`, + ); + } + resolve(code ?? 1); + }); + }); +} + // Helper functions to ensure sandbox image is present async function imageExists(sandbox: string, image: string): Promise { return new Promise((resolve) => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ce07271139..8c341073eb 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -446,7 +446,7 @@ export enum AuthProviderType { } export interface SandboxConfig { - command: 'docker' | 'podman' | 'sandbox-exec'; + command: 'docker' | 'podman' | 'sandbox-exec' | 'lxc'; image: string; } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index a0ef69eab5..185a4cd1ce 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1271,8 +1271,8 @@ "properties": { "sandbox": { "title": "Sandbox", - "description": "Sandbox execution environment. Set to a boolean to enable or disable the sandbox, or provide a string path to a sandbox profile.", - "markdownDescription": "Sandbox execution environment. Set to a boolean to enable or disable the sandbox, or provide a string path to a sandbox profile.\n\n- Category: `Tools`\n- Requires restart: `yes`", + "description": "Sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\").", + "markdownDescription": "Sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\").\n\n- Category: `Tools`\n- Requires restart: `yes`", "$ref": "#/$defs/BooleanOrString" }, "shell": {