mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 02:24:09 -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:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user