mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-03 00:14:28 -07:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user