mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
feat(sandbox): add experimental LXC container sandbox support (#20735)
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
|
||||
'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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<typeof spawn>;
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number> {
|
||||
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<string, unknown> =>
|
||||
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<string, string | undefined> = {
|
||||
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<number>((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<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
Reference in New Issue
Block a user