mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-22 02:54:31 -07:00
feat(sandbox): add custom sandbox flags and orchestrator refactor
This commit is contained in:
@@ -428,6 +428,7 @@ export enum AuthProviderType {
|
||||
export interface SandboxConfig {
|
||||
command: 'docker' | 'podman' | 'sandbox-exec';
|
||||
image: string;
|
||||
flags?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -109,6 +109,7 @@ export * from './utils/apiConversionUtils.js';
|
||||
export * from './utils/channel.js';
|
||||
export * from './utils/constants.js';
|
||||
export * from './utils/sessionUtils.js';
|
||||
export * from './utils/sandboxOrchestrator.js';
|
||||
|
||||
// Export services
|
||||
export * from './services/fileDiscoveryService.js';
|
||||
|
||||
@@ -6,3 +6,7 @@
|
||||
|
||||
export const REFERENCE_CONTENT_START = '--- Content from referenced files ---';
|
||||
export const REFERENCE_CONTENT_END = '--- End of content ---';
|
||||
|
||||
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';
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { SandboxOrchestrator } from './sandboxOrchestrator.js';
|
||||
import type { SandboxConfig } from '../config/config.js';
|
||||
import { spawnAsync } from './shell-utils.js';
|
||||
|
||||
vi.mock('./shell-utils.js', () => ({
|
||||
spawnAsync: vi.fn(),
|
||||
}));
|
||||
vi.mock('../index.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../index.js')>();
|
||||
return {
|
||||
...actual,
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
LOCAL_DEV_SANDBOX_IMAGE_NAME: 'gemini-cli-sandbox',
|
||||
};
|
||||
});
|
||||
|
||||
describe('SandboxOrchestrator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('getContainerRunArgs', () => {
|
||||
it('should build basic run args', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'some-image',
|
||||
};
|
||||
const args = await SandboxOrchestrator.getContainerRunArgs(
|
||||
config,
|
||||
'/work',
|
||||
'/sandbox',
|
||||
);
|
||||
expect(args).toEqual([
|
||||
'run',
|
||||
'-i',
|
||||
'--rm',
|
||||
'--init',
|
||||
'--workdir',
|
||||
'/sandbox',
|
||||
'-t',
|
||||
'--add-host',
|
||||
'host.docker.internal:host-gateway',
|
||||
'--volume',
|
||||
'/work:/sandbox',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include flags from config', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'some-image',
|
||||
flags: '--privileged --net=host',
|
||||
};
|
||||
const args = await SandboxOrchestrator.getContainerRunArgs(
|
||||
config,
|
||||
'/work',
|
||||
'/sandbox',
|
||||
);
|
||||
expect(args).toEqual([
|
||||
'run',
|
||||
'-i',
|
||||
'--rm',
|
||||
'--init',
|
||||
'--workdir',
|
||||
'/sandbox',
|
||||
'--privileged',
|
||||
'--net=host',
|
||||
'-t',
|
||||
'--add-host',
|
||||
'host.docker.internal:host-gateway',
|
||||
'--volume',
|
||||
'/work:/sandbox',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include flags from arguments if provided', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'some-image',
|
||||
};
|
||||
const args = await SandboxOrchestrator.getContainerRunArgs(
|
||||
config,
|
||||
'/work',
|
||||
'/sandbox',
|
||||
'--env FOO=bar',
|
||||
);
|
||||
expect(args).toEqual([
|
||||
'run',
|
||||
'-i',
|
||||
'--rm',
|
||||
'--init',
|
||||
'--workdir',
|
||||
'/sandbox',
|
||||
'--env',
|
||||
'FOO=bar',
|
||||
'-t',
|
||||
'--add-host',
|
||||
'host.docker.internal:host-gateway',
|
||||
'--volume',
|
||||
'/work:/sandbox',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should expand environment variables in flags', async () => {
|
||||
vi.stubEnv('TEST_VAR', 'test-value');
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'some-image',
|
||||
flags: '--label user=$TEST_VAR',
|
||||
};
|
||||
const args = await SandboxOrchestrator.getContainerRunArgs(
|
||||
config,
|
||||
'/work',
|
||||
'/sandbox',
|
||||
);
|
||||
expect(args).toEqual([
|
||||
'run',
|
||||
'-i',
|
||||
'--rm',
|
||||
'--init',
|
||||
'--workdir',
|
||||
'/sandbox',
|
||||
'--label',
|
||||
'user=test-value',
|
||||
'-t',
|
||||
'--add-host',
|
||||
'host.docker.internal:host-gateway',
|
||||
'--volume',
|
||||
'/work:/sandbox',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle complex quoted flags', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'some-image',
|
||||
flags: '--env "FOO=bar baz" --label \'key=val with spaces\'',
|
||||
};
|
||||
const args = await SandboxOrchestrator.getContainerRunArgs(
|
||||
config,
|
||||
'/work',
|
||||
'/sandbox',
|
||||
);
|
||||
expect(args).toEqual([
|
||||
'run',
|
||||
'-i',
|
||||
'--rm',
|
||||
'--init',
|
||||
'--workdir',
|
||||
'/sandbox',
|
||||
'--env',
|
||||
'FOO=bar baz',
|
||||
'--label',
|
||||
'key=val with spaces',
|
||||
'-t',
|
||||
'--add-host',
|
||||
'host.docker.internal:host-gateway',
|
||||
'--volume',
|
||||
'/work:/sandbox',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter out non-string shell-quote Op objects', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'some-image',
|
||||
flags: '--flag > /tmp/out', // shell-quote would return { op: '>' }
|
||||
};
|
||||
const args = await SandboxOrchestrator.getContainerRunArgs(
|
||||
config,
|
||||
'/work',
|
||||
'/sandbox',
|
||||
);
|
||||
expect(args).toEqual([
|
||||
'run',
|
||||
'-i',
|
||||
'--rm',
|
||||
'--init',
|
||||
'--workdir',
|
||||
'/sandbox',
|
||||
'--flag',
|
||||
'/tmp/out',
|
||||
'-t',
|
||||
'--add-host',
|
||||
'host.docker.internal:host-gateway',
|
||||
'--volume',
|
||||
'/work:/sandbox',
|
||||
]);
|
||||
// Note: shell-quote filters out the '>' op but keeps the surrounding strings
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureSandboxImageIsPresent', () => {
|
||||
it('should return true if image exists locally', async () => {
|
||||
vi.mocked(spawnAsync).mockResolvedValueOnce({
|
||||
stdout: 'image-id',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
const result = await SandboxOrchestrator.ensureSandboxImageIsPresent(
|
||||
'docker',
|
||||
'some-image',
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(spawnAsync).toHaveBeenCalledWith('docker', [
|
||||
'images',
|
||||
'-q',
|
||||
'some-image',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should pull image if missing and return true on success', async () => {
|
||||
// 1. Image check fails (returns empty stdout)
|
||||
vi.mocked(spawnAsync).mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||
// 2. Pull image succeeds
|
||||
vi.mocked(spawnAsync).mockResolvedValueOnce({
|
||||
stdout: 'Successfully pulled',
|
||||
stderr: '',
|
||||
});
|
||||
// 3. Image check succeeds
|
||||
vi.mocked(spawnAsync).mockResolvedValueOnce({
|
||||
stdout: 'image-id',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
const result = await SandboxOrchestrator.ensureSandboxImageIsPresent(
|
||||
'docker',
|
||||
'some-image',
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(spawnAsync).toHaveBeenCalledWith('docker', ['pull', 'some-image']);
|
||||
});
|
||||
|
||||
it('should return false if image pull fails', async () => {
|
||||
// 1. Image check fails
|
||||
vi.mocked(spawnAsync).mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||
// 2. Pull image fails
|
||||
vi.mocked(spawnAsync).mockRejectedValueOnce(new Error('Pull failed'));
|
||||
|
||||
const result = await SandboxOrchestrator.ensureSandboxImageIsPresent(
|
||||
'docker',
|
||||
'some-image',
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { parse } from 'shell-quote';
|
||||
import type { Config, SandboxConfig } from '../config/config.js';
|
||||
import { coreEvents } from './events.js';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
import { LOCAL_DEV_SANDBOX_IMAGE_NAME } from './constants.js';
|
||||
import { spawnAsync } from './shell-utils.js';
|
||||
|
||||
/**
|
||||
* Orchestrates sandbox image management and command construction.
|
||||
* This class contains non-UI logic for sandboxing.
|
||||
*/
|
||||
export class SandboxOrchestrator {
|
||||
/**
|
||||
* Constructs the arguments for the container engine 'run' command.
|
||||
*/
|
||||
static async getContainerRunArgs(
|
||||
config: SandboxConfig,
|
||||
workdir: string,
|
||||
containerWorkdir: string,
|
||||
sandboxFlags?: string,
|
||||
isPipedInput: boolean = false,
|
||||
): Promise<string[]> {
|
||||
const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir];
|
||||
|
||||
// Priority: env var > settings
|
||||
const flagsToUse = sandboxFlags || config.flags;
|
||||
if (flagsToUse) {
|
||||
const parsedFlags = parse(flagsToUse, process.env).filter(
|
||||
(f): f is string => typeof f === 'string',
|
||||
);
|
||||
args.push(...parsedFlags);
|
||||
}
|
||||
|
||||
if (!isPipedInput) {
|
||||
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
|
||||
args.push('--volume', `${workdir}:${containerWorkdir}`);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs macOS Seatbelt (sandbox-exec) arguments.
|
||||
*/
|
||||
static getSeatbeltArgs(
|
||||
targetDir: string,
|
||||
tmpDir: string,
|
||||
homeDir: string,
|
||||
cacheDir: string,
|
||||
profileFile: string,
|
||||
includedDirs: string[],
|
||||
maxIncludeDirs: number = 5,
|
||||
): string[] {
|
||||
const args = [
|
||||
'-D',
|
||||
`TARGET_DIR=${targetDir}`,
|
||||
'-D',
|
||||
`TMP_DIR=${tmpDir}`,
|
||||
'-D',
|
||||
`HOME_DIR=${homeDir}`,
|
||||
'-D',
|
||||
`CACHE_DIR=${cacheDir}`,
|
||||
];
|
||||
|
||||
for (let i = 0; i < maxIncludeDirs; i++) {
|
||||
const dirPath = i < includedDirs.length ? includedDirs[i] : '/dev/null';
|
||||
args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
|
||||
}
|
||||
|
||||
args.push('-f', profileFile);
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the sandbox image is present locally or pulled from the registry.
|
||||
*/
|
||||
static async ensureSandboxImageIsPresent(
|
||||
sandbox: string,
|
||||
image: string,
|
||||
cliConfig?: Config,
|
||||
): Promise<boolean> {
|
||||
debugLogger.log(`Checking for sandbox image: ${image}`);
|
||||
if (await this.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 this.pullImage(sandbox, image, cliConfig)) {
|
||||
// After attempting to pull, check again to be certain
|
||||
if (await this.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
|
||||
}
|
||||
|
||||
private static async imageExists(
|
||||
sandbox: string,
|
||||
image: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await spawnAsync(sandbox, ['images', '-q', image]);
|
||||
return stdout.trim() !== '';
|
||||
} catch (err) {
|
||||
debugLogger.warn(
|
||||
`Failed to check image existence with '${sandbox}': ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async pullImage(
|
||||
sandbox: string,
|
||||
image: string,
|
||||
cliConfig?: Config,
|
||||
): Promise<boolean> {
|
||||
debugLogger.debug(`Attempting to pull image ${image} using ${sandbox}...`);
|
||||
try {
|
||||
const { stdout } = await spawnAsync(sandbox, ['pull', image]);
|
||||
if (cliConfig?.getDebugMode() || process.env['DEBUG']) {
|
||||
debugLogger.log(stdout.trim());
|
||||
}
|
||||
debugLogger.log(`Successfully pulled image ${image}.`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
debugLogger.warn(
|
||||
`Failed to pull image ${image}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user