feat(sandbox): add custom sandbox flags and orchestrator refactor

This commit is contained in:
Spencer
2026-02-27 20:27:03 +00:00
parent d7320f5425
commit 9a6a049ed8
13 changed files with 827 additions and 465 deletions

View File

@@ -104,5 +104,7 @@ export async function loadSandboxConfig(
const image =
process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri;
return command && image ? { command, image } : undefined;
const flags = settings.tools?.sandboxFlags;
return command && image ? { command, image, flags } : undefined;
}

View File

@@ -1201,6 +1201,18 @@ const SETTINGS_SCHEMA = {
`,
showInDialog: false,
},
sandboxFlags: {
type: 'string',
label: 'Sandbox Flags',
category: 'Tools',
requiresRestart: true,
default: '',
description: oneLine`
Additional flags to pass to the sandbox container engine (Docker or Podman).
Environment variables can be used and will be expanded.
`,
showInDialog: true,
},
shell: {
type: 'object',
label: 'Shell',

View File

@@ -5,17 +5,36 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn, exec, execSync } from 'node:child_process';
import { spawn, execSync, type ChildProcess } from 'node:child_process';
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 {
FatalSandboxError,
type SandboxConfig,
SandboxOrchestrator,
} from '@google/gemini-cli-core';
import { EventEmitter } from 'node:events';
const { mockedHomedir, mockedGetContainerPath } = vi.hoisted(() => ({
mockedHomedir: vi.fn().mockReturnValue('/home/user'),
mockedGetContainerPath: vi.fn().mockImplementation((p: string) => p),
}));
const { mockedHomedir, mockedGetContainerPath, mockSpawnAsync } = vi.hoisted(
() => ({
mockedHomedir: vi.fn().mockReturnValue('/home/user'),
mockedGetContainerPath: vi.fn().mockImplementation((p: string) => p),
mockSpawnAsync: vi.fn().mockImplementation(async (cmd, args) => {
if (cmd === 'id' && args?.[0] === '-u')
return { stdout: '1000', stderr: '' };
if (cmd === 'id' && args?.[0] === '-g')
return { stdout: '1000', stderr: '' };
if (cmd === 'getconf') return { stdout: '/tmp/cache', stderr: '' };
if (cmd === 'docker' && args?.[0] === 'ps')
return { stdout: 'existing-container', stderr: '' };
if (cmd === 'docker' && args?.[0] === 'network')
return { stdout: '', stderr: '' };
if (cmd === 'curl') return { stdout: 'ok', stderr: '' };
return { stdout: '', stderr: '' };
}),
}),
);
vi.mock('./sandboxUtils.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./sandboxUtils.js')>();
@@ -28,32 +47,6 @@ vi.mock('./sandboxUtils.js', async (importOriginal) => {
vi.mock('node:child_process');
vi.mock('node:os');
vi.mock('node:fs');
vi.mock('node:util', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:util')>();
return {
...actual,
promisify: (fn: (...args: unknown[]) => unknown) => {
if (fn === exec) {
return async (cmd: string) => {
if (cmd === 'id -u' || cmd === 'id -g') {
return { stdout: '1000', stderr: '' };
}
if (cmd.includes('curl')) {
return { stdout: '', stderr: '' };
}
if (cmd.includes('getconf DARWIN_USER_CACHE_DIR')) {
return { stdout: '/tmp/cache', stderr: '' };
}
if (cmd.includes('ps -a --format')) {
return { stdout: 'existing-container', stderr: '' };
}
return { stdout: '', stderr: '' };
};
}
return actual.promisify(fn);
},
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -68,13 +61,15 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
coreEvents: {
emitFeedback: vi.fn(),
},
FatalSandboxError: class extends Error {
constructor(message: string) {
super(message);
this.name = 'FatalSandboxError';
}
SandboxOrchestrator: {
ensureSandboxImageIsPresent: vi.fn().mockResolvedValue(true),
getContainerRunArgs: vi
.fn()
.mockResolvedValue(['run', '-i', '--rm', '--init']),
getSeatbeltArgs: vi.fn().mockReturnValue(['-D', 'TARGET_DIR=/tmp']),
},
GEMINI_DIR: '.gemini',
spawnAsync: mockSpawnAsync,
LOCAL_DEV_SANDBOX_IMAGE_NAME: 'gemini-cli-sandbox',
homedir: mockedHomedir,
};
});
@@ -107,6 +102,21 @@ describe('sandbox', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
// Default mockSpawnAsync implementation
mockSpawnAsync.mockImplementation(async (cmd, args) => {
if (cmd === 'id' && args?.[0] === '-u')
return { stdout: '1000', stderr: '' };
if (cmd === 'id' && args?.[0] === '-g')
return { stdout: '1000', stderr: '' };
if (cmd === 'getconf') return { stdout: '/tmp/cache', stderr: '' };
if (cmd === 'docker' && args?.[0] === 'ps')
return { stdout: 'existing-container', stderr: '' };
if (cmd === 'docker' && args?.[0] === 'network')
return { stdout: '', stderr: '' };
if (cmd === 'curl') return { stdout: 'ok', stderr: '' };
return { stdout: '', stderr: '' };
});
});
afterEach(() => {
@@ -122,30 +132,27 @@ describe('sandbox', () => {
image: 'some-image',
};
interface MockProcess extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockSpawnProcess = new EventEmitter() as MockProcess;
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
// @ts-expect-error - mocking readonly property
mockSpawnProcess.stdout = new EventEmitter();
// @ts-expect-error - mocking readonly property
mockSpawnProcess.stderr = new EventEmitter();
// @ts-expect-error - mocking readonly property
mockSpawnProcess.pid = 123;
vi.mocked(spawn).mockReturnValue(
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
);
const promise = start_sandbox(config, [], undefined, ['arg1']);
setTimeout(() => {
mockSpawnProcess.emit('close', 0);
}, 10);
// Use setImmediate to ensure the promise has had a chance to register handlers
await new Promise((resolve) => setImmediate(resolve));
mockSpawnProcess.emit('close', 0);
await expect(promise).resolves.toBe(0);
expect(spawn).toHaveBeenCalledWith(
'sandbox-exec',
expect.arrayContaining([
'-f',
expect.stringContaining('sandbox-macos-permissive-open.sb'),
]),
expect.arrayContaining(['-D', expect.stringContaining('TARGET_DIR=')]),
expect.objectContaining({ stdio: 'inherit' }),
);
});
@@ -167,152 +174,155 @@ describe('sandbox', () => {
image: 'gemini-cli-sandbox',
};
// Mock image check to return true (image exists)
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce((_cmd, args) => {
if (args && args[0] === 'images') {
setTimeout(() => {
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess.emit('close', 0);
}, 1);
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
}
return new EventEmitter() as unknown as ReturnType<typeof spawn>; // fallback
});
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
>;
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
// @ts-expect-error - mocking readonly property
mockSpawnProcess.stdout = new EventEmitter();
// @ts-expect-error - mocking readonly property
mockSpawnProcess.stderr = new EventEmitter();
// @ts-expect-error - mocking readonly property
mockSpawnProcess.pid = 123;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setTimeout(() => cb(0), 10);
setImmediate(() => cb(0));
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockImplementationOnce((cmd, args) => {
if (cmd === 'docker' && args && args[0] === 'run') {
return mockSpawnProcess;
}
return new EventEmitter() as unknown as ReturnType<typeof spawn>;
});
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
const promise = start_sandbox(config, [], undefined, ['arg1']);
await expect(promise).resolves.toBe(0);
expect(
SandboxOrchestrator.ensureSandboxImageIsPresent,
).toHaveBeenCalled();
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
'docker',
expect.arrayContaining(['run', '-i', '--rm', '--init']),
expect.any(Array),
expect.objectContaining({ stdio: 'inherit' }),
);
});
it('should pull image if missing', async () => {
it('should inject custom flags from SANDBOX_FLAGS env var', async () => {
process.env['SANDBOX_FLAGS'] =
'--security-opt label=disable --env FOO=bar';
const config: SandboxConfig = {
command: 'docker',
image: 'missing-image',
image: 'gemini-cli-sandbox',
};
// 1. Image check fails
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess1 =
new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess1.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess1.emit('close', 0);
}, 1);
return mockImageCheckProcess1 as unknown as ReturnType<typeof spawn>;
});
// 2. Pull image succeeds
interface MockProcessWithStdoutStderr extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockPullProcess = new EventEmitter() as MockProcessWithStdoutStderr;
mockPullProcess.stdout = new EventEmitter();
mockPullProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockPullProcess.emit('close', 0);
}, 1);
return mockPullProcess as unknown as ReturnType<typeof spawn>;
});
// 3. Image check succeeds
const mockImageCheckProcess2 =
new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess2.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess2.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess2.emit('close', 0);
}, 1);
return mockImageCheckProcess2 as unknown as ReturnType<typeof spawn>;
});
// 4. Docker run
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
>;
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setTimeout(() => cb(0), 10);
setImmediate(() => cb(0));
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
const promise = start_sandbox(config, [], undefined, ['arg1']);
await start_sandbox(config);
await expect(promise).resolves.toBe(0);
expect(spawn).toHaveBeenCalledWith(
'docker',
expect.arrayContaining(['pull', 'missing-image']),
expect.any(Object),
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
config,
expect.any(String),
expect.any(String),
'--security-opt label=disable --env FOO=bar',
false,
);
});
it('should throw if image pull fails', async () => {
it('should inject custom flags from config (settings)', async () => {
const config: SandboxConfig = {
command: 'docker',
image: 'gemini-cli-sandbox',
flags: '--privileged',
};
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setImmediate(() => cb(0));
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
await start_sandbox(config);
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
config,
expect.any(String),
expect.any(String),
undefined,
false,
);
});
it('should expand multiple environment variables in sandbox flags', async () => {
process.env['VAR1'] = 'val1';
process.env['VAR2'] = 'val2';
const config: SandboxConfig = {
command: 'docker',
image: 'gemini-cli-sandbox',
flags: '--env V1=$VAR1 --env V2=${VAR2}',
};
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setImmediate(() => cb(0));
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
await start_sandbox(config);
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
config,
expect.any(String),
expect.any(String),
undefined,
false,
);
});
it('should handle quoted strings in sandbox flags', async () => {
const config: SandboxConfig = {
command: 'docker',
image: 'gemini-cli-sandbox',
flags: '--label "description=multi word label" --env \'FOO=bar baz\'',
};
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setImmediate(() => cb(0));
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
await start_sandbox(config);
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
config,
expect.any(String),
expect.any(String),
undefined,
false,
);
});
it('should throw if image is missing', async () => {
const config: SandboxConfig = {
command: 'docker',
image: 'missing-image',
};
// 1. Image check fails
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess1 =
new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess1.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess1.emit('close', 0);
}, 1);
return mockImageCheckProcess1 as unknown as ReturnType<typeof spawn>;
});
// 2. Pull image fails
interface MockProcessWithStdoutStderr extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockPullProcess = new EventEmitter() as MockProcessWithStdoutStderr;
mockPullProcess.stdout = new EventEmitter();
mockPullProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockPullProcess.emit('close', 1);
}, 1);
return mockPullProcess as unknown as ReturnType<typeof spawn>;
});
vi.mocked(
SandboxOrchestrator.ensureSandboxImageIsPresent,
).mockResolvedValueOnce(false);
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
});
@@ -325,51 +335,20 @@ describe('sandbox', () => {
process.env['SANDBOX_MOUNTS'] = '/host/path:/container/path:ro';
vi.mocked(fs.existsSync).mockReturnValue(true); // For mount path check
// 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
>;
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setTimeout(() => cb(0), 10);
setImmediate(() => cb(0));
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
await start_sandbox(config);
// The first call is 'docker images -q ...'
expect(spawn).toHaveBeenNthCalledWith(
1,
expect(spawn).toHaveBeenCalledWith(
'docker',
expect.arrayContaining(['images', '-q']),
);
// The second call is 'docker run ...'
expect(spawn).toHaveBeenNthCalledWith(
2,
'docker',
expect.arrayContaining([
'run',
'--volume',
'/host/path:/container/path:ro',
'--volume',
expect.stringMatching(/[\\/]home[\\/]user[\\/]\.gemini/),
]),
expect.arrayContaining(['--volume', '/host/path:/container/path:ro']),
expect.any(Object),
);
});
@@ -382,30 +361,14 @@ describe('sandbox', () => {
process.env['GOOGLE_GEMINI_BASE_URL'] = 'http://gemini.proxy';
process.env['GOOGLE_VERTEX_BASE_URL'] = 'http://vertex.proxy';
// 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
>;
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setTimeout(() => cb(0), 10);
setImmediate(() => cb(0));
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
await start_sandbox(config);
@@ -434,30 +397,14 @@ describe('sandbox', () => {
return Buffer.from('');
});
// 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
>;
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setTimeout(() => cb(0), 10);
setImmediate(() => cb(0));
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
await start_sandbox(config);
@@ -467,11 +414,63 @@ describe('sandbox', () => {
expect.any(Object),
);
// Check that the entrypoint command includes useradd/groupadd
const args = vi.mocked(spawn).mock.calls[1][1] as string[];
const args = vi.mocked(spawn).mock.calls[0][1] as string[];
const entrypointCmd = args[args.length - 1];
expect(entrypointCmd).toContain('groupadd');
expect(entrypointCmd).toContain('useradd');
expect(entrypointCmd).toContain('su -p gemini');
});
describe('waitForProxy timeout', () => {
it('should time out waiting for proxy', async () => {
const config: SandboxConfig = {
command: 'docker',
image: 'gemini-cli-sandbox',
};
process.env['GEMINI_SANDBOX_PROXY_COMMAND'] = 'my-proxy';
// Mock spawn to return processes that stay open
vi.mocked(spawn).mockImplementation(() => {
const p = new EventEmitter() as unknown as ChildProcess;
// @ts-expect-error - mocking readonly property
p.pid = 123;
p.kill = vi.fn();
// @ts-expect-error - mocking readonly property
p.stderr = new EventEmitter();
// @ts-expect-error - mocking readonly property
p.stdout = new EventEmitter();
return p;
});
// Mock spawnAsync to fail for curl (simulating proxy not started)
mockSpawnAsync.mockImplementation(async (cmd) => {
if (cmd === 'curl') {
throw new Error('Connection refused');
}
return { stdout: '', stderr: '' };
});
// Mock Date.now to control time
let currentTime = 1000000;
const dateSpy = vi.spyOn(Date, 'now').mockImplementation(() => {
currentTime += 10000; // Increment time by 10s on each call to hit timeout fast
return currentTime;
});
// We also need to mock setTimeout to resolve immediately,
// otherwise the loop will still take real time.
const originalSetTimeout = global.setTimeout;
// @ts-expect-error - mocking global setTimeout
global.setTimeout = vi.fn().mockImplementation((cb) => cb());
try {
const promise = start_sandbox(config);
await expect(promise).rejects.toThrow(/Timed out waiting for proxy/);
} finally {
dateSpy.mockRestore();
global.setTimeout = originalSetTimeout;
}
});
});
});
});

View File

@@ -1,16 +1,15 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { exec, execSync, spawn, type ChildProcess } from 'node:child_process';
import { execSync, spawn, type ChildProcess } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { quote, parse } from 'shell-quote';
import { promisify } from 'node:util';
import { quote } from 'shell-quote';
import type { Config, SandboxConfig } from '@google/gemini-cli-core';
import {
coreEvents,
@@ -18,6 +17,11 @@ import {
FatalSandboxError,
GEMINI_DIR,
homedir,
SandboxOrchestrator,
LOCAL_DEV_SANDBOX_IMAGE_NAME,
SANDBOX_NETWORK_NAME,
SANDBOX_PROXY_NAME,
spawnAsync,
} from '@google/gemini-cli-core';
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
import { randomBytes } from 'node:crypto';
@@ -27,13 +31,30 @@ import {
parseImageName,
ports,
entrypoint,
LOCAL_DEV_SANDBOX_IMAGE_NAME,
SANDBOX_NETWORK_NAME,
SANDBOX_PROXY_NAME,
BUILTIN_SEATBELT_PROFILES,
} from './sandboxUtils.js';
const execAsync = promisify(exec);
async function waitForProxy(
proxyUrl: string,
timeoutMs: number = 30000,
retryDelayMs: number = 500,
now: () => number = Date.now,
): Promise<void> {
const start = now();
while (now() - start < timeoutMs) {
try {
await spawnAsync('curl', ['-s', proxyUrl], {
timeout: 500,
});
return;
} catch {
await new Promise((r) => setTimeout(r, retryDelayMs));
}
}
throw new FatalSandboxError(
`Timed out waiting for proxy at ${proxyUrl} to start after ${timeoutMs / 1000} seconds`,
);
}
export async function start_sandbox(
config: SandboxConfig,
@@ -70,26 +91,16 @@ export async function start_sandbox(
);
}
debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`);
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
const nodeOptions = [
...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
...nodeArgs,
].join(' ');
const cacheDir = (
await spawnAsync('getconf', ['DARWIN_USER_CACHE_DIR'])
).stdout.trim();
const args = [
'-D',
`TARGET_DIR=${fs.realpathSync(process.cwd())}`,
'-D',
`TMP_DIR=${fs.realpathSync(os.tmpdir())}`,
'-D',
`HOME_DIR=${fs.realpathSync(homedir())}`,
'-D',
`CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`,
];
const targetDirReal = fs.realpathSync(process.cwd());
const tmpDirReal = fs.realpathSync(os.tmpdir());
const homeDirReal = fs.realpathSync(homedir());
const cacheDirReal = fs.realpathSync(cacheDir);
// Add included directories from the workspace context
// Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them
const MAX_INCLUDE_DIRS = 5;
const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
const includedDirs: string[] = [];
@@ -106,21 +117,24 @@ export async function start_sandbox(
}
}
for (let i = 0; i < MAX_INCLUDE_DIRS; i++) {
let dirPath = '/dev/null'; // Default to a safe path that won't cause issues
const args = SandboxOrchestrator.getSeatbeltArgs(
targetDirReal,
tmpDirReal,
homeDirReal,
cacheDirReal,
profileFile,
includedDirs,
);
if (i < includedDirs.length) {
dirPath = includedDirs[i];
}
args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
}
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
const nodeOptions = [
...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
...nodeArgs,
].join(' ');
const finalArgv = cliArgs;
args.push(
'-f',
profileFile,
'sh',
'-c',
[
@@ -161,6 +175,7 @@ export async function start_sandbox(
if (proxyProcess?.pid) {
process.kill(-proxyProcess.pid, 'SIGTERM');
}
return;
};
process.off('exit', stopProxy);
process.on('exit', stopProxy);
@@ -185,9 +200,7 @@ export async function start_sandbox(
);
});
debugLogger.log('waiting for proxy to start ...');
await execAsync(
`until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
);
await waitForProxy('http://localhost:8877', 30000, 500);
}
// spawn child and let it inherit stdio
process.stdin.pause();
@@ -255,7 +268,11 @@ export async function start_sandbox(
// stop if image is missing
if (
!(await ensureSandboxImageIsPresent(config.command, image, cliConfig))
!(await SandboxOrchestrator.ensureSandboxImageIsPresent(
config.command,
image,
cliConfig,
))
) {
const remedy =
image === LOCAL_DEV_SANDBOX_IMAGE_NAME
@@ -268,26 +285,13 @@ export async function start_sandbox(
// use interactive mode and auto-remove container on exit
// run init binary inside container to forward signals & reap zombies
const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir];
// add custom flags from SANDBOX_FLAGS
if (process.env['SANDBOX_FLAGS']) {
const flags = parse(process.env['SANDBOX_FLAGS'], process.env).filter(
(f): f is string => typeof f === 'string',
);
args.push(...flags);
}
// add TTY only if stdin is TTY as well, i.e. for piped input don't init TTY in container
if (process.stdin.isTTY) {
args.push('-t');
}
// allow access to host.docker.internal
args.push('--add-host', 'host.docker.internal:host-gateway');
// mount current directory as working directory in sandbox (set via --workdir)
args.push('--volume', `${workdir}:${containerWorkdir}`);
const args = await SandboxOrchestrator.getContainerRunArgs(
config,
workdir,
containerWorkdir,
process.env['SANDBOX_FLAGS'],
!process.stdin.isTTY,
);
// mount user settings directory inside container, after creating if missing
// note user/home changes inside sandbox and we mount at BOTH paths for consistency
@@ -409,17 +413,38 @@ export async function start_sandbox(
// if using proxy, switch to internal networking through proxy
if (proxy) {
execSync(
`${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`,
);
try {
await spawnAsync(config.command, [
'network',
'inspect',
SANDBOX_NETWORK_NAME,
]);
} catch {
await spawnAsync(config.command, [
'network',
'create',
'--internal',
SANDBOX_NETWORK_NAME,
]);
}
args.push('--network', SANDBOX_NETWORK_NAME);
// 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(
`${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`,
);
try {
await spawnAsync(config.command, [
'network',
'inspect',
SANDBOX_PROXY_NAME,
]);
} catch {
await spawnAsync(config.command, [
'network',
'create',
SANDBOX_PROXY_NAME,
]);
}
}
}
}
@@ -436,9 +461,12 @@ export async function start_sandbox(
debugLogger.log(`ContainerName: ${containerName}`);
} else {
let index = 0;
const containerNameCheck = (
await execAsync(`${config.command} ps -a --format "{{.Names}}"`)
).stdout.trim();
const { stdout: containerNameCheck } = await spawnAsync(config.command, [
'ps',
'-a',
'--format',
'{{.Names}}',
]);
while (containerNameCheck.includes(`${imageName}-${index}`)) {
index++;
}
@@ -606,8 +634,8 @@ export async function start_sandbox(
// The entrypoint script then handles dropping privileges to the correct user.
args.push('--user', 'root');
const uid = (await execAsync('id -u')).stdout.trim();
const gid = (await execAsync('id -g')).stdout.trim();
const uid = (await spawnAsync('id', ['-u'])).stdout.trim();
const gid = (await spawnAsync('id', ['-g'])).stdout.trim();
// Instead of passing --user to the main sandbox container, we let it
// start as root, then create a user with the host's UID/GID, and
@@ -660,8 +688,13 @@ export async function start_sandbox(
// install handlers to stop proxy on exit/signal
const stopProxy = () => {
debugLogger.log('stopping proxy container ...');
execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`);
return spawnAsync(config.command, [
'rm',
'-f',
SANDBOX_PROXY_NAME,
])?.catch(() => {});
};
process.off('exit', stopProxy);
process.on('exit', stopProxy);
process.off('SIGINT', stopProxy);
@@ -681,18 +714,19 @@ export async function start_sandbox(
process.kill(-sandboxProcess.pid, 'SIGTERM');
}
throw new FatalSandboxError(
`Proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`,
`Proxy container command exited with code ${code}, signal ${signal}`,
);
});
debugLogger.log('waiting for proxy to start ...');
await execAsync(
`until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
);
await waitForProxy('http://localhost:8877', 30000, 500);
// connect proxy container to sandbox network
// (workaround for older versions of docker that don't support multiple --network args)
await execAsync(
`${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`,
);
await spawnAsync(config.command, [
'network',
'connect',
SANDBOX_NETWORK_NAME,
SANDBOX_PROXY_NAME,
]);
}
// spawn child and let it inherit stdio
@@ -721,145 +755,3 @@ export async function start_sandbox(
patcher.cleanup();
}
}
// Helper functions to ensure sandbox image is present
async function imageExists(sandbox: string, image: string): Promise<boolean> {
return new Promise((resolve) => {
const args = ['images', '-q', image];
const checkProcess = spawn(sandbox, args);
let stdoutData = '';
if (checkProcess.stdout) {
checkProcess.stdout.on('data', (data) => {
stdoutData += data.toString();
});
}
checkProcess.on('error', (err) => {
debugLogger.warn(
`Failed to start '${sandbox}' command for image check: ${err.message}`,
);
resolve(false);
});
checkProcess.on('close', (code) => {
// Non-zero code might indicate docker daemon not running, etc.
// The primary success indicator is non-empty stdoutData.
if (code !== 0) {
// console.warn(`'${sandbox} images -q ${image}' exited with code ${code}.`);
}
resolve(stdoutData.trim() !== '');
});
});
}
async function pullImage(
sandbox: string,
image: string,
cliConfig?: Config,
): Promise<boolean> {
debugLogger.debug(`Attempting to pull image ${image} using ${sandbox}...`);
return new Promise((resolve) => {
const args = ['pull', image];
const pullProcess = spawn(sandbox, args, { stdio: 'pipe' });
let stderrData = '';
const onStdoutData = (data: Buffer) => {
if (cliConfig?.getDebugMode() || process.env['DEBUG']) {
debugLogger.log(data.toString().trim()); // Show pull progress
}
};
const onStderrData = (data: Buffer) => {
stderrData += data.toString();
// eslint-disable-next-line no-console
console.error(data.toString().trim()); // Show pull errors/info from the command itself
};
const onError = (err: Error) => {
debugLogger.warn(
`Failed to start '${sandbox} pull ${image}' command: ${err.message}`,
);
cleanup();
resolve(false);
};
const onClose = (code: number | null) => {
if (code === 0) {
debugLogger.log(`Successfully pulled image ${image}.`);
cleanup();
resolve(true);
} else {
debugLogger.warn(
`Failed to pull image ${image}. '${sandbox} pull ${image}' exited with code ${code}.`,
);
if (stderrData.trim()) {
// Details already printed by the stderr listener above
}
cleanup();
resolve(false);
}
};
const cleanup = () => {
if (pullProcess.stdout) {
pullProcess.stdout.removeListener('data', onStdoutData);
}
if (pullProcess.stderr) {
pullProcess.stderr.removeListener('data', onStderrData);
}
pullProcess.removeListener('error', onError);
pullProcess.removeListener('close', onClose);
if (pullProcess.connected) {
pullProcess.disconnect();
}
};
if (pullProcess.stdout) {
pullProcess.stdout.on('data', onStdoutData);
}
if (pullProcess.stderr) {
pullProcess.stderr.on('data', onStderrData);
}
pullProcess.on('error', onError);
pullProcess.on('close', onClose);
});
}
async function ensureSandboxImageIsPresent(
sandbox: string,
image: string,
cliConfig?: Config,
): Promise<boolean> {
debugLogger.log(`Checking for sandbox image: ${image}`);
if (await imageExists(sandbox, image)) {
debugLogger.log(`Sandbox image ${image} found locally.`);
return true;
}
debugLogger.log(`Sandbox image ${image} not found locally.`);
if (image === LOCAL_DEV_SANDBOX_IMAGE_NAME) {
// user needs to build the image themselves
return false;
}
if (await pullImage(sandbox, image, cliConfig)) {
// After attempting to pull, check again to be certain
if (await imageExists(sandbox, image)) {
debugLogger.log(`Sandbox image ${image} is now available after pulling.`);
return true;
} else {
debugLogger.warn(
`Sandbox image ${image} still not found after a pull attempt. This might indicate an issue with the image name or registry, or the pull command reported success but failed to make the image available.`,
);
return false;
}
}
coreEvents.emitFeedback(
'error',
`Failed to obtain sandbox image ${image} after check and pull attempt.`,
);
return false; // Pull command failed or image still not present
}

View File

@@ -10,9 +10,6 @@ import { readFile } from 'node:fs/promises';
import { quote } from 'shell-quote';
import { debugLogger, GEMINI_DIR } from '@google/gemini-cli-core';
export const LOCAL_DEV_SANDBOX_IMAGE_NAME = 'gemini-cli-sandbox';
export const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox';
export const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy';
export const BUILTIN_SEATBELT_PROFILES = [
'permissive-open',
'permissive-proxied',