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
+180 -13
View File
@@ -90,7 +90,13 @@ describe('loadSandboxConfig', () => {
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'docker', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'docker',
image: 'default/image',
});
expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
});
@@ -113,7 +119,13 @@ describe('loadSandboxConfig', () => {
process.env['GEMINI_SANDBOX'] = 'lxc';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'lxc', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'lxc',
image: 'default/image',
});
expect(mockedCommandExistsSync).toHaveBeenCalledWith('lxc');
});
@@ -134,6 +146,9 @@ describe('loadSandboxConfig', () => {
);
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'sandbox-exec',
image: 'default/image',
});
@@ -144,6 +159,9 @@ describe('loadSandboxConfig', () => {
mockedCommandExistsSync.mockReturnValue(true); // all commands exist
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'sandbox-exec',
image: 'default/image',
});
@@ -153,14 +171,26 @@ describe('loadSandboxConfig', () => {
mockedOsPlatform.mockReturnValue('linux');
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker');
const config = await loadSandboxConfig({ tools: { sandbox: true } }, {});
expect(config).toEqual({ command: 'docker', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'docker',
image: 'default/image',
});
});
it('should use podman if available and docker is not', async () => {
mockedOsPlatform.mockReturnValue('linux');
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman');
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config).toEqual({ command: 'podman', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'podman',
image: 'default/image',
});
});
it('should throw if sandbox: true but no command is found', async () => {
@@ -177,7 +207,13 @@ describe('loadSandboxConfig', () => {
it('should use the specified command if it exists', async () => {
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, { sandbox: 'podman' });
expect(config).toEqual({ command: 'podman', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'podman',
image: 'default/image',
});
expect(mockedCommandExistsSync).toHaveBeenCalledWith('podman');
});
@@ -205,14 +241,26 @@ describe('loadSandboxConfig', () => {
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'docker', image: 'env/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'docker',
image: 'env/image',
});
});
it('should use image from package.json if env var is not set', async () => {
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'docker', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'docker',
image: 'default/image',
});
});
it('should return undefined if command is found but no image is configured', async () => {
@@ -234,20 +282,115 @@ describe('loadSandboxConfig', () => {
'should enable sandbox for value: %s',
async (value) => {
const config = await loadSandboxConfig({}, { sandbox: value });
expect(config).toEqual({ command: 'docker', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'docker',
image: 'default/image',
});
},
);
it.each([false, 'false', '0', undefined, null, ''])(
'should disable sandbox for value: %s',
async (value) => {
// \`null\` is not a valid type for the arg, but good to test falsiness
// `null` is not a valid type for the arg, but good to test falsiness
const config = await loadSandboxConfig({}, { sandbox: value });
expect(config).toBeUndefined();
},
);
});
describe('with SandboxConfig object in settings', () => {
beforeEach(() => {
mockedOsPlatform.mockReturnValue('linux');
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker');
});
it('should support object structure with enabled: true', async () => {
const config = await loadSandboxConfig(
{
tools: {
sandbox: {
enabled: true,
allowedPaths: ['/tmp'],
networkAccess: true,
},
},
},
{},
);
expect(config).toEqual({
enabled: true,
allowedPaths: ['/tmp'],
networkAccess: true,
command: 'docker',
image: 'default/image',
});
});
it('should support object structure with explicit command', async () => {
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman');
const config = await loadSandboxConfig(
{
tools: {
sandbox: {
enabled: true,
command: 'podman',
},
},
},
{},
);
expect(config?.command).toBe('podman');
});
it('should support object structure with custom image', async () => {
const config = await loadSandboxConfig(
{
tools: {
sandbox: {
enabled: true,
image: 'custom/image',
},
},
},
{},
);
expect(config?.image).toBe('custom/image');
});
it('should return undefined if enabled is false in object', async () => {
const config = await loadSandboxConfig(
{
tools: {
sandbox: {
enabled: false,
},
},
},
{},
);
expect(config).toBeUndefined();
});
it('should prioritize CLI flag over settings object', async () => {
const config = await loadSandboxConfig(
{
tools: {
sandbox: {
enabled: true,
allowedPaths: ['/settings-path'],
},
},
},
{ sandbox: false },
);
expect(config).toBeUndefined();
});
});
describe('with sandbox: runsc (gVisor)', () => {
beforeEach(() => {
mockedOsPlatform.mockReturnValue('linux');
@@ -257,7 +400,13 @@ describe('loadSandboxConfig', () => {
it('should use runsc via CLI argument on Linux', async () => {
const config = await loadSandboxConfig({}, { sandbox: 'runsc' });
expect(config).toEqual({ command: 'runsc', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'runsc',
image: 'default/image',
});
expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc');
expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
});
@@ -266,7 +415,13 @@ describe('loadSandboxConfig', () => {
process.env['GEMINI_SANDBOX'] = 'runsc';
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'runsc', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'runsc',
image: 'default/image',
});
expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc');
expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
});
@@ -277,7 +432,13 @@ describe('loadSandboxConfig', () => {
{},
);
expect(config).toEqual({ command: 'runsc', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'runsc',
image: 'default/image',
});
expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc');
expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
});
@@ -289,7 +450,13 @@ describe('loadSandboxConfig', () => {
{ sandbox: 'podman' },
);
expect(config).toEqual({ command: 'runsc', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'runsc',
image: 'default/image',
});
});
it('should reject runsc on macOS (Linux-only)', async () => {
+30 -5
View File
@@ -23,7 +23,7 @@ const __dirname = path.dirname(__filename);
interface SandboxCliArgs {
sandbox?: boolean | string | null;
}
const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
const VALID_SANDBOX_COMMANDS = [
'docker',
'podman',
'sandbox-exec',
@@ -31,8 +31,10 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
'lxc',
];
function isSandboxCommand(value: string): value is SandboxConfig['command'] {
return (VALID_SANDBOX_COMMANDS as readonly string[]).includes(value);
function isSandboxCommand(
value: string,
): value is Exclude<SandboxConfig['command'], undefined> {
return VALID_SANDBOX_COMMANDS.includes(value);
}
function getSandboxCommand(
@@ -116,13 +118,36 @@ export async function loadSandboxConfig(
argv: SandboxCliArgs,
): Promise<SandboxConfig | undefined> {
const sandboxOption = argv.sandbox ?? settings.tools?.sandbox;
const command = getSandboxCommand(sandboxOption);
let sandboxValue: boolean | string | null | undefined;
let allowedPaths: string[] = [];
let networkAccess = false;
let customImage: string | undefined;
if (
typeof sandboxOption === 'object' &&
sandboxOption !== null &&
!Array.isArray(sandboxOption)
) {
const config = sandboxOption;
sandboxValue = config.enabled ? (config.command ?? true) : false;
allowedPaths = config.allowedPaths ?? [];
networkAccess = config.networkAccess ?? false;
customImage = config.image;
} else if (typeof sandboxOption !== 'object' || sandboxOption === null) {
sandboxValue = sandboxOption;
}
const command = getSandboxCommand(sandboxValue);
const packageJson = await getPackageJson(__dirname);
const image =
process.env['GEMINI_SANDBOX_IMAGE'] ??
process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ??
customImage ??
packageJson?.config?.sandboxImageUri;
return command && image ? { command, image } : undefined;
return command && image
? { enabled: true, allowedPaths, networkAccess, command, image }
: undefined;
}
+41 -5
View File
@@ -18,6 +18,7 @@ import {
type AuthType,
type AgentOverride,
type CustomTheme,
type SandboxConfig,
} from '@google/gemini-cli-core';
import type { SessionRetentionSettings } from './settings.js';
import { DEFAULT_MIN_RETENTION } from '../utils/sessionCleanup.js';
@@ -1263,8 +1264,8 @@ const SETTINGS_SCHEMA = {
label: 'Sandbox',
category: 'Tools',
requiresRestart: true,
default: undefined as boolean | string | undefined,
ref: 'BooleanOrString',
default: undefined as boolean | string | SandboxConfig | undefined,
ref: 'BooleanOrStringOrObject',
description: oneLine`
Sandbox execution environment.
Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile,
@@ -2618,9 +2619,44 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
description: 'Accepts either a single string or an array of strings.',
anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
},
BooleanOrString: {
description: 'Accepts either a boolean flag or a string command name.',
anyOf: [{ type: 'boolean' }, { type: 'string' }],
BooleanOrStringOrObject: {
description:
'Accepts either a boolean flag, a string command name, or a configuration object.',
anyOf: [
{ type: 'boolean' },
{ type: 'string' },
{
type: 'object',
description: 'Sandbox configuration object.',
additionalProperties: false,
properties: {
enabled: {
type: 'boolean',
description: 'Enables or disables the sandbox.',
},
command: {
type: 'string',
description:
'The sandbox command to use (docker, podman, sandbox-exec, runsc, lxc).',
enum: ['docker', 'podman', 'sandbox-exec', 'runsc', 'lxc'],
},
image: {
type: 'string',
description: 'The sandbox image to use.',
},
allowedPaths: {
type: 'array',
description:
'A list of absolute host paths that should be accessible within the sandbox.',
items: { type: 'string' },
},
networkAccess: {
type: 'boolean',
description: 'Whether the sandbox should have internet access.',
},
},
},
],
},
HookDefinitionArray: {
type: 'array',
+69 -13
View File
@@ -27,6 +27,7 @@ import {
type CliArgs,
} from './config/config.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
import { terminalCapabilityManager } from './ui/utils/terminalCapabilityManager.js';
import { start_sandbox } from './utils/sandbox.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
@@ -192,12 +193,19 @@ vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({
vi.mock('./config/config.js', () => ({
loadCliConfig: vi.fn().mockImplementation(async () => createMockConfig()),
parseArguments: vi.fn().mockResolvedValue({}),
parseArguments: vi.fn().mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
}),
isDebugMode: vi.fn(() => false),
}));
vi.mock('read-package-up', () => ({
readPackageUp: vi.fn().mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
packageJson: { name: 'test-pkg', version: 'test-version' },
path: '/fake/path/package.json',
}),
@@ -235,6 +243,9 @@ vi.mock('./utils/relaunch.js', () => ({
vi.mock('./config/sandboxConfig.js', () => ({
loadSandboxConfig: vi.fn().mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'docker',
image: 'test-image',
}),
@@ -540,6 +551,9 @@ describe('gemini.tsx main function kitty protocol', () => {
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -603,6 +617,9 @@ describe('gemini.tsx main function kitty protocol', () => {
});
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -622,14 +639,17 @@ describe('gemini.tsx main function kitty protocol', () => {
const mockConfig = createMockConfig({
isInteractive: () => false,
getQuestion: () => '',
getSandbox: () => ({ command: 'docker', image: 'test-image' }),
getSandbox: () =>
createMockSandboxConfig({ command: 'docker', image: 'test-image' }),
});
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
vi.mocked(loadSandboxConfig).mockResolvedValue({
command: 'docker',
image: 'test-image',
});
vi.mocked(loadSandboxConfig).mockResolvedValue(
createMockSandboxConfig({
command: 'docker',
image: 'test-image',
}),
);
process.env['GEMINI_API_KEY'] = 'test-key';
try {
@@ -670,6 +690,9 @@ describe('gemini.tsx main function kitty protocol', () => {
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadCliConfig).mockResolvedValue(
@@ -725,6 +748,9 @@ describe('gemini.tsx main function kitty protocol', () => {
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: false,
resume: 'session-id',
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -781,6 +807,9 @@ describe('gemini.tsx main function kitty protocol', () => {
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: false,
resume: 'latest',
} as unknown as CliArgs);
@@ -831,6 +860,9 @@ describe('gemini.tsx main function kitty protocol', () => {
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadCliConfig).mockResolvedValue(
@@ -881,6 +913,9 @@ describe('gemini.tsx main function kitty protocol', () => {
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadCliConfig).mockResolvedValue(
@@ -955,6 +990,9 @@ describe('gemini.tsx main function exit codes', () => {
}),
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: true,
} as unknown as CliArgs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -971,10 +1009,12 @@ describe('gemini.tsx main function exit codes', () => {
it('should exit with 41 for auth failure during sandbox setup', async () => {
vi.stubEnv('SANDBOX', '');
vi.mocked(loadSandboxConfig).mockResolvedValue({
command: 'docker',
image: 'test-image',
});
vi.mocked(loadSandboxConfig).mockResolvedValue(
createMockSandboxConfig({
command: 'docker',
image: 'test-image',
}),
);
vi.mocked(loadCliConfig).mockResolvedValue(
createMockConfig({
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
@@ -1014,6 +1054,9 @@ describe('gemini.tsx main function exit codes', () => {
}),
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
resume: 'invalid-session',
} as unknown as CliArgs);
@@ -1055,7 +1098,11 @@ describe('gemini.tsx main function exit codes', () => {
merged: { security: { auth: {} }, ui: {} },
}),
);
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
} as unknown as CliArgs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stdin as any).isTTY = true;
@@ -1090,7 +1137,11 @@ describe('gemini.tsx main function exit codes', () => {
merged: { security: { auth: { selectedType: undefined } }, ui: {} },
}),
);
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
} as unknown as CliArgs);
runNonInteractiveSpy.mockImplementation(() => Promise.resolve());
@@ -1160,7 +1211,12 @@ describe('project hooks loading based on trust', () => {
const configModule = await import('./config/config.js');
loadCliConfig = vi.mocked(configModule.loadCliConfig);
parseArguments = vi.mocked(configModule.parseArguments);
parseArguments.mockResolvedValue({ startupMessages: [] });
parseArguments.mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
startupMessages: [],
});
const settingsModule = await import('./config/settings.js');
loadSettings = vi.mocked(settingsModule.loadSettings);
+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