feat(core): implement SandboxManager interface and config schema (#21774)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Gal Zahavi
2026-03-11 14:42:50 -07:00
committed by GitHub
parent 926dddf0bf
commit e3b3b71c14
15 changed files with 1074 additions and 214 deletions
+147 -27
View File
@@ -10,6 +10,7 @@ import os from 'node:os';
import fs from 'node:fs';
import { start_sandbox } from './sandbox.js';
import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core';
import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
import { EventEmitter } from 'node:events';
const { mockedHomedir, mockedGetContainerPath } = vi.hoisted(() => ({
@@ -137,10 +138,10 @@ describe('sandbox', () => {
describe('start_sandbox', () => {
it('should handle macOS seatbelt (sandbox-exec)', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'sandbox-exec',
image: 'some-image',
};
});
interface MockProcess extends EventEmitter {
stdout: EventEmitter;
@@ -173,19 +174,19 @@ describe('sandbox', () => {
it('should throw FatalSandboxError if seatbelt profile is missing', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.mocked(fs.existsSync).mockReturnValue(false);
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'sandbox-exec',
image: 'some-image',
};
});
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
});
it('should handle Docker execution', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
};
});
// Mock image check to return true (image exists)
interface MockProcessWithStdout extends EventEmitter {
@@ -231,10 +232,10 @@ describe('sandbox', () => {
});
it('should pull image if missing', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'missing-image',
};
});
// 1. Image check fails
interface MockProcessWithStdout extends EventEmitter {
@@ -300,10 +301,10 @@ describe('sandbox', () => {
});
it('should throw if image pull fails', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'missing-image',
};
});
// 1. Image check fails
interface MockProcessWithStdout extends EventEmitter {
@@ -338,10 +339,10 @@ describe('sandbox', () => {
});
it('should mount volumes correctly', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
};
});
process.env['SANDBOX_MOUNTS'] = '/host/path:/container/path:ro';
vi.mocked(fs.existsSync).mockReturnValue(true); // For mount path check
@@ -394,11 +395,130 @@ describe('sandbox', () => {
);
});
it('should pass through GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL', async () => {
const config: SandboxConfig = {
it('should handle allowedPaths in Docker', async () => {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
};
allowedPaths: ['/extra/path'],
});
vi.mocked(fs.existsSync).mockReturnValue(true);
// Mock image check to return true
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess.emit('close', 0);
}, 1);
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
});
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).mockImplementationOnce(() => mockSpawnProcess);
await start_sandbox(config);
expect(spawn).toHaveBeenCalledWith(
'docker',
expect.arrayContaining(['--volume', '/extra/path:/extra/path:ro']),
expect.any(Object),
);
});
it('should handle networkAccess: false in Docker', async () => {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
networkAccess: false,
});
// Mock image check
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess.emit('close', 0);
}, 1);
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
});
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).mockImplementationOnce(() => mockSpawnProcess);
await start_sandbox(config);
expect(execSync).toHaveBeenCalledWith(
expect.stringContaining('network create --internal gemini-cli-sandbox'),
expect.any(Object),
);
expect(spawn).toHaveBeenCalledWith(
'docker',
expect.arrayContaining(['--network', 'gemini-cli-sandbox']),
expect.any(Object),
);
});
it('should handle allowedPaths in macOS seatbelt', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
const config: SandboxConfig = createMockSandboxConfig({
command: 'sandbox-exec',
image: 'some-image',
allowedPaths: ['/Users/user/extra'],
});
vi.mocked(fs.existsSync).mockReturnValue(true);
interface MockProcess extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockSpawnProcess = new EventEmitter() as MockProcess;
mockSpawnProcess.stdout = new EventEmitter();
mockSpawnProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockReturnValue(
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
);
const promise = start_sandbox(config);
setTimeout(() => mockSpawnProcess.emit('close', 0), 10);
await promise;
// Check that the extra path is passed as an INCLUDE_DIR_X argument
expect(spawn).toHaveBeenCalledWith(
'sandbox-exec',
expect.arrayContaining(['INCLUDE_DIR_0=/Users/user/extra']),
expect.any(Object),
);
});
it('should pass through GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL', async () => {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
});
process.env['GOOGLE_GEMINI_BASE_URL'] = 'http://gemini.proxy';
process.env['GOOGLE_VERTEX_BASE_URL'] = 'http://vertex.proxy';
@@ -442,10 +562,10 @@ describe('sandbox', () => {
});
it('should handle user creation on Linux if needed', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
};
});
process.env['SANDBOX_SET_UID_GID'] = 'true';
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(execSync).mockImplementation((cmd) => {
@@ -508,10 +628,10 @@ describe('sandbox', () => {
it('should run lxc exec with correct args for a running container', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = LXC_RUNNING;
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'lxc',
image: 'gemini-sandbox',
};
});
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
@@ -542,10 +662,10 @@ describe('sandbox', () => {
it('should throw FatalSandboxError if lxc list fails', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = 'throw';
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'lxc',
image: 'gemini-sandbox',
};
});
await expect(start_sandbox(config)).rejects.toThrow(
/Failed to query LXC container/,
@@ -554,20 +674,20 @@ describe('sandbox', () => {
it('should throw FatalSandboxError if container is not running', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = LXC_STOPPED;
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
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 = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'lxc',
image: 'gemini-sandbox',
};
});
await expect(start_sandbox(config)).rejects.toThrow(/not found/);
});
@@ -577,10 +697,10 @@ describe('sandbox', () => {
describe('gVisor (runsc)', () => {
it('should use docker with --runtime=runsc on Linux', async () => {
vi.mocked(os.platform).mockReturnValue('linux');
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'runsc',
image: 'gemini-cli-sandbox',
};
});
// Mock image check
interface MockProcessWithStdout extends EventEmitter {
+212 -129
View File
@@ -7,9 +7,9 @@
import {
exec,
execFile,
execFileSync,
execSync,
spawn,
spawnSync,
type ChildProcess,
} from 'node:child_process';
import path from 'node:path';
@@ -114,6 +114,22 @@ export async function start_sandbox(
}
}
// Add custom allowed paths from config
if (config.allowedPaths) {
for (const hostPath of config.allowedPaths) {
if (
hostPath &&
path.isAbsolute(hostPath) &&
fs.existsSync(hostPath)
) {
const realDir = fs.realpathSync(hostPath);
if (!includedDirs.includes(realDir) && realDir !== targetDir) {
includedDirs.push(realDir);
}
}
}
}
for (let i = 0; i < MAX_INCLUDE_DIRS; i++) {
let dirPath = '/dev/null'; // Default to a safe path that won't cause issues
@@ -217,6 +233,7 @@ export async function start_sandbox(
// runsc uses docker with --runtime=runsc
const command = config.command === 'runsc' ? 'docker' : config.command;
if (!command) throw new FatalSandboxError('Sandbox command is required');
debugLogger.log(`hopping into sandbox (command: ${command}) ...`);
@@ -230,6 +247,9 @@ export async function start_sandbox(
const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile);
const image = config.image;
if (!image) throw new FatalSandboxError('Sandbox image is required');
if (!/^[a-zA-Z0-9_.:/-]+$/.test(image))
throw new FatalSandboxError('Invalid sandbox image name');
const workdir = path.resolve(process.cwd());
const containerWorkdir = getContainerPath(workdir);
@@ -392,6 +412,19 @@ export async function start_sandbox(
}
}
// mount paths listed in config.allowedPaths
if (config.allowedPaths) {
for (const hostPath of config.allowedPaths) {
if (hostPath && path.isAbsolute(hostPath) && fs.existsSync(hostPath)) {
const containerPath = getContainerPath(hostPath);
debugLogger.log(
`Config allowedPath: ${hostPath} -> ${containerPath} (ro)`,
);
args.push('--volume', `${hostPath}:${containerPath}:ro`);
}
}
}
// expose env-specified ports on the sandbox
ports().forEach((p) => args.push('--publish', `${p}:${p}`));
@@ -425,21 +458,27 @@ export async function start_sandbox(
args.push('--env', `NO_PROXY=${noProxy}`);
args.push('--env', `no_proxy=${noProxy}`);
}
}
// if using proxy, switch to internal networking through proxy
if (proxy) {
execSync(
`${command} network inspect ${SANDBOX_NETWORK_NAME} || ${command} network create --internal ${SANDBOX_NETWORK_NAME}`,
);
args.push('--network', SANDBOX_NETWORK_NAME);
// handle network access and proxy configuration
if (!config.networkAccess || proxyCommand) {
const isInternal = !config.networkAccess || !!proxyCommand;
const networkFlags = isInternal ? '--internal' : '';
execSync(
`${command} network inspect ${SANDBOX_NETWORK_NAME} || ${command} network create ${networkFlags} ${SANDBOX_NETWORK_NAME}`,
{ stdio: 'ignore' },
);
args.push('--network', SANDBOX_NETWORK_NAME);
if (proxyCommand) {
// if proxy command is set, create a separate network w/ host access (i.e. non-internal)
// we will run proxy in its own container connected to both host network and internal network
// this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation
if (proxyCommand) {
execSync(
`${command} network inspect ${SANDBOX_PROXY_NAME} || ${command} network create ${SANDBOX_PROXY_NAME}`,
);
}
execSync(
`${command} network inspect ${SANDBOX_PROXY_NAME} || ${command} network create ${SANDBOX_PROXY_NAME}`,
{ stdio: 'ignore' },
);
}
}
@@ -833,136 +872,180 @@ async function start_lxc_sandbox(
);
}
// 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')}`;
const devicesToRemove: string[] = [];
const removeDevices = () => {
for (const deviceName of devicesToRemove) {
try {
spawnSync(
'lxc',
['config', 'device', 'remove', containerName, deviceName],
{ timeout: 1000, killSignal: 'SIGKILL', stdio: 'ignore' },
);
} catch {
// Best-effort cleanup; ignore errors on exit.
}
}
};
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)}`,
);
}
// 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 workspaceDeviceName = `gemini-workspace-${randomBytes(4).toString(
'hex',
)}`;
devicesToRemove.push(workspaceDeviceName);
// 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 },
await execFileAsync('lxc', [
'config',
'device',
'add',
containerName,
workspaceDeviceName,
'disk',
`source=${workdir}`,
`path=${workdir}`,
]);
debugLogger.log(
`mounted workspace '${workdir}' into container as device '${workspaceDeviceName}'`,
);
} catch (err) {
throw new FatalSandboxError(
`Failed to mount workspace into LXC container '${containerName}': ${err instanceof Error ? err.message : String(err)}`,
);
} 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',
);
// Add custom allowed paths from config
if (config.allowedPaths) {
for (const hostPath of config.allowedPaths) {
if (hostPath && path.isAbsolute(hostPath) && fs.existsSync(hostPath)) {
const allowedDeviceName = `gemini-allowed-${randomBytes(4).toString(
'hex',
)}`;
devicesToRemove.push(allowedDeviceName);
try {
await execFileAsync('lxc', [
'config',
'device',
'add',
containerName,
allowedDeviceName,
'disk',
`source=${hostPath}`,
`path=${hostPath}`,
'readonly=true',
]);
debugLogger.log(
`mounted allowed path '${hostPath}' into container as device '${allowedDeviceName}' (ro)`,
);
} catch (err) {
debugLogger.warn(
`Failed to mount allowed path '${hostPath}' into LXC container: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
}
}
// 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}`);
}
// Remove the devices 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'.
process.on('exit', removeDevices);
// 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}`,
);
// 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}`);
}
resolve(code ?? 1);
}
// 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 await 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();
if (code !== 0 && code !== null) {
debugLogger.log(
`LXC sandbox process exited with code: ${code}, signal: ${signal}`,
);
}
resolve(code ?? 1);
});
});
} finally {
process.off('exit', removeDevices);
removeDevices();
}
}
// Helper functions to ensure sandbox image is present