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
+1
View File
@@ -428,6 +428,7 @@ export enum AuthProviderType {
export interface SandboxConfig {
command: 'docker' | 'podman' | 'sandbox-exec';
image: string;
flags?: string;
}
/**
+1
View File
@@ -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';
+4
View File
@@ -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;
}
}
}