mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-14 08:01:02 -07:00
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
750 lines
25 KiB
TypeScript
750 lines
25 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { spawn, exec, execFile, execSync } from 'node:child_process';
|
|
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(() => ({
|
|
mockedHomedir: vi.fn().mockReturnValue('/home/user'),
|
|
mockedGetContainerPath: vi.fn().mockImplementation((p: string) => p),
|
|
}));
|
|
|
|
vi.mock('./sandboxUtils.js', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('./sandboxUtils.js')>();
|
|
return {
|
|
...actual,
|
|
getContainerPath: mockedGetContainerPath,
|
|
};
|
|
});
|
|
|
|
vi.mock('node:child_process');
|
|
vi.mock('node:os');
|
|
vi.mock('node:fs');
|
|
vi.mock('node:util', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:util')>();
|
|
return {
|
|
...actual,
|
|
promisify: (fn: (...args: unknown[]) => unknown) => {
|
|
if (fn === exec) {
|
|
return async (cmd: string) => {
|
|
if (cmd === 'id -u' || cmd === 'id -g') {
|
|
return { stdout: '1000', stderr: '' };
|
|
}
|
|
if (cmd.includes('curl')) {
|
|
return { stdout: '', stderr: '' };
|
|
}
|
|
if (cmd.includes('getconf DARWIN_USER_CACHE_DIR')) {
|
|
return { stdout: '/tmp/cache', stderr: '' };
|
|
}
|
|
if (cmd.includes('ps -a --format')) {
|
|
return { stdout: 'existing-container', stderr: '' };
|
|
}
|
|
return { stdout: '', stderr: '' };
|
|
};
|
|
}
|
|
if (fn === execFile) {
|
|
return async (file: string, args: string[]) => {
|
|
if (file === 'lxc' && args[0] === 'list') {
|
|
const output = process.env['TEST_LXC_LIST_OUTPUT'];
|
|
if (output === 'throw') {
|
|
throw new Error('lxc command not found');
|
|
}
|
|
return { stdout: output ?? '[]', stderr: '' };
|
|
}
|
|
if (
|
|
file === 'lxc' &&
|
|
args[0] === 'config' &&
|
|
args[1] === 'device' &&
|
|
args[2] === 'add'
|
|
) {
|
|
return { stdout: '', stderr: '' };
|
|
}
|
|
return { stdout: '', stderr: '' };
|
|
};
|
|
}
|
|
return actual.promisify(fn);
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
|
return {
|
|
...actual,
|
|
debugLogger: {
|
|
log: vi.fn(),
|
|
debug: vi.fn(),
|
|
warn: vi.fn(),
|
|
},
|
|
coreEvents: {
|
|
emitFeedback: vi.fn(),
|
|
},
|
|
FatalSandboxError: class extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'FatalSandboxError';
|
|
}
|
|
},
|
|
GEMINI_DIR: '.gemini',
|
|
homedir: mockedHomedir,
|
|
};
|
|
});
|
|
|
|
describe('sandbox', () => {
|
|
const originalEnv = process.env;
|
|
const originalArgv = process.argv;
|
|
let mockProcessIn: {
|
|
pause: ReturnType<typeof vi.fn>;
|
|
resume: ReturnType<typeof vi.fn>;
|
|
isTTY: boolean;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
process.env = { ...originalEnv };
|
|
process.argv = [...originalArgv];
|
|
mockProcessIn = {
|
|
pause: vi.fn(),
|
|
resume: vi.fn(),
|
|
isTTY: true,
|
|
};
|
|
Object.defineProperty(process, 'stdin', {
|
|
value: mockProcessIn,
|
|
writable: true,
|
|
});
|
|
vi.mocked(os.platform).mockReturnValue('linux');
|
|
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
|
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
|
|
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
process.argv = originalArgv;
|
|
});
|
|
|
|
describe('start_sandbox', () => {
|
|
it('should handle macOS seatbelt (sandbox-exec)', async () => {
|
|
vi.mocked(os.platform).mockReturnValue('darwin');
|
|
const config: SandboxConfig = createMockSandboxConfig({
|
|
command: 'sandbox-exec',
|
|
image: 'some-image',
|
|
});
|
|
|
|
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, [], undefined, ['arg1']);
|
|
|
|
setTimeout(() => {
|
|
mockSpawnProcess.emit('close', 0);
|
|
}, 10);
|
|
|
|
await expect(promise).resolves.toBe(0);
|
|
expect(spawn).toHaveBeenCalledWith(
|
|
'sandbox-exec',
|
|
expect.arrayContaining([
|
|
'-f',
|
|
expect.stringContaining('sandbox-macos-permissive-open.sb'),
|
|
]),
|
|
expect.objectContaining({ stdio: 'inherit' }),
|
|
);
|
|
});
|
|
|
|
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 = createMockSandboxConfig({
|
|
command: 'sandbox-exec',
|
|
image: 'some-image',
|
|
});
|
|
|
|
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
|
|
});
|
|
|
|
it('should handle Docker execution', async () => {
|
|
const config: SandboxConfig = createMockSandboxConfig({
|
|
command: 'docker',
|
|
image: 'gemini-cli-sandbox',
|
|
});
|
|
|
|
// Mock image check to return true (image exists)
|
|
interface MockProcessWithStdout extends EventEmitter {
|
|
stdout: EventEmitter;
|
|
}
|
|
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
|
|
mockImageCheckProcess.stdout = new EventEmitter();
|
|
vi.mocked(spawn).mockImplementationOnce((_cmd, args) => {
|
|
if (args && args[0] === 'images') {
|
|
setTimeout(() => {
|
|
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
|
|
mockImageCheckProcess.emit('close', 0);
|
|
}, 1);
|
|
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
|
|
}
|
|
return new EventEmitter() as unknown as ReturnType<typeof spawn>; // fallback
|
|
});
|
|
|
|
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((cmd, args) => {
|
|
if (cmd === 'docker' && args && args[0] === 'run') {
|
|
return mockSpawnProcess;
|
|
}
|
|
return new EventEmitter() as unknown as ReturnType<typeof spawn>;
|
|
});
|
|
|
|
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
|
|
|
await expect(promise).resolves.toBe(0);
|
|
expect(spawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
expect.arrayContaining(['run', '-i', '--rm', '--init']),
|
|
expect.objectContaining({ stdio: 'inherit' }),
|
|
);
|
|
});
|
|
|
|
it('should pull image if missing', async () => {
|
|
const config: SandboxConfig = createMockSandboxConfig({
|
|
command: 'docker',
|
|
image: 'missing-image',
|
|
});
|
|
|
|
// 1. Image check fails
|
|
interface MockProcessWithStdout extends EventEmitter {
|
|
stdout: EventEmitter;
|
|
}
|
|
const mockImageCheckProcess1 =
|
|
new EventEmitter() as MockProcessWithStdout;
|
|
mockImageCheckProcess1.stdout = new EventEmitter();
|
|
vi.mocked(spawn).mockImplementationOnce(() => {
|
|
setTimeout(() => {
|
|
mockImageCheckProcess1.emit('close', 0);
|
|
}, 1);
|
|
return mockImageCheckProcess1 as unknown as ReturnType<typeof spawn>;
|
|
});
|
|
|
|
// 2. Pull image succeeds
|
|
interface MockProcessWithStdoutStderr extends EventEmitter {
|
|
stdout: EventEmitter;
|
|
stderr: EventEmitter;
|
|
}
|
|
const mockPullProcess = new EventEmitter() as MockProcessWithStdoutStderr;
|
|
mockPullProcess.stdout = new EventEmitter();
|
|
mockPullProcess.stderr = new EventEmitter();
|
|
vi.mocked(spawn).mockImplementationOnce(() => {
|
|
setTimeout(() => {
|
|
mockPullProcess.emit('close', 0);
|
|
}, 1);
|
|
return mockPullProcess as unknown as ReturnType<typeof spawn>;
|
|
});
|
|
|
|
// 3. Image check succeeds
|
|
const mockImageCheckProcess2 =
|
|
new EventEmitter() as MockProcessWithStdout;
|
|
mockImageCheckProcess2.stdout = new EventEmitter();
|
|
vi.mocked(spawn).mockImplementationOnce(() => {
|
|
setTimeout(() => {
|
|
mockImageCheckProcess2.stdout.emit('data', Buffer.from('image-id'));
|
|
mockImageCheckProcess2.emit('close', 0);
|
|
}, 1);
|
|
return mockImageCheckProcess2 as unknown as ReturnType<typeof spawn>;
|
|
});
|
|
|
|
// 4. Docker run
|
|
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);
|
|
|
|
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
|
|
|
await expect(promise).resolves.toBe(0);
|
|
expect(spawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
expect.arrayContaining(['pull', 'missing-image']),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should throw if image pull fails', async () => {
|
|
const config: SandboxConfig = createMockSandboxConfig({
|
|
command: 'docker',
|
|
image: 'missing-image',
|
|
});
|
|
|
|
// 1. Image check fails
|
|
interface MockProcessWithStdout extends EventEmitter {
|
|
stdout: EventEmitter;
|
|
}
|
|
const mockImageCheckProcess1 =
|
|
new EventEmitter() as MockProcessWithStdout;
|
|
mockImageCheckProcess1.stdout = new EventEmitter();
|
|
vi.mocked(spawn).mockImplementationOnce(() => {
|
|
setTimeout(() => {
|
|
mockImageCheckProcess1.emit('close', 0);
|
|
}, 1);
|
|
return mockImageCheckProcess1 as unknown as ReturnType<typeof spawn>;
|
|
});
|
|
|
|
// 2. Pull image fails
|
|
interface MockProcessWithStdoutStderr extends EventEmitter {
|
|
stdout: EventEmitter;
|
|
stderr: EventEmitter;
|
|
}
|
|
const mockPullProcess = new EventEmitter() as MockProcessWithStdoutStderr;
|
|
mockPullProcess.stdout = new EventEmitter();
|
|
mockPullProcess.stderr = new EventEmitter();
|
|
vi.mocked(spawn).mockImplementationOnce(() => {
|
|
setTimeout(() => {
|
|
mockPullProcess.emit('close', 1);
|
|
}, 1);
|
|
return mockPullProcess as unknown as ReturnType<typeof spawn>;
|
|
});
|
|
|
|
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
|
|
});
|
|
|
|
it('should mount volumes correctly', async () => {
|
|
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
|
|
|
|
// 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);
|
|
|
|
// The first call is 'docker images -q ...'
|
|
expect(spawn).toHaveBeenNthCalledWith(
|
|
1,
|
|
'docker',
|
|
expect.arrayContaining(['images', '-q']),
|
|
);
|
|
|
|
// The second call is 'docker run ...'
|
|
expect(spawn).toHaveBeenNthCalledWith(
|
|
2,
|
|
'docker',
|
|
expect.arrayContaining([
|
|
'run',
|
|
'--volume',
|
|
'/host/path:/container/path:ro',
|
|
'--volume',
|
|
expect.stringMatching(/[\\/]home[\\/]user[\\/]\.gemini/),
|
|
]),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
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';
|
|
|
|
// 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([
|
|
'--env',
|
|
'GOOGLE_GEMINI_BASE_URL=http://gemini.proxy',
|
|
'--env',
|
|
'GOOGLE_VERTEX_BASE_URL=http://vertex.proxy',
|
|
]),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should handle user creation on Linux if needed', async () => {
|
|
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) => {
|
|
if (cmd === 'id -u') return Buffer.from('1000');
|
|
if (cmd === 'id -g') return Buffer.from('1000');
|
|
return Buffer.from('');
|
|
});
|
|
|
|
// 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(['--user', 'root', '--env', 'HOME=/home/user']),
|
|
expect.any(Object),
|
|
);
|
|
// Check that the entrypoint command includes useradd/groupadd
|
|
const args = vi.mocked(spawn).mock.calls[1][1] as string[];
|
|
const entrypointCmd = args[args.length - 1];
|
|
expect(entrypointCmd).toContain('groupadd');
|
|
expect(entrypointCmd).toContain('useradd');
|
|
expect(entrypointCmd).toContain('su -p gemini');
|
|
});
|
|
|
|
describe('LXC sandbox', () => {
|
|
const LXC_RUNNING = JSON.stringify([
|
|
{ name: 'gemini-sandbox', status: 'Running' },
|
|
]);
|
|
const LXC_STOPPED = JSON.stringify([
|
|
{ name: 'gemini-sandbox', status: 'Stopped' },
|
|
]);
|
|
|
|
beforeEach(() => {
|
|
delete process.env['TEST_LXC_LIST_OUTPUT'];
|
|
});
|
|
|
|
it('should run lxc exec with correct args for a running container', async () => {
|
|
process.env['TEST_LXC_LIST_OUTPUT'] = LXC_RUNNING;
|
|
const config: SandboxConfig = createMockSandboxConfig({
|
|
command: 'lxc',
|
|
image: 'gemini-sandbox',
|
|
});
|
|
|
|
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).mockImplementation((cmd) => {
|
|
if (cmd === 'lxc') {
|
|
return mockSpawnProcess;
|
|
}
|
|
return new EventEmitter() as unknown as ReturnType<typeof spawn>;
|
|
});
|
|
|
|
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
|
await expect(promise).resolves.toBe(0);
|
|
|
|
expect(spawn).toHaveBeenCalledWith(
|
|
'lxc',
|
|
expect.arrayContaining(['exec', 'gemini-sandbox', '--cwd']),
|
|
expect.objectContaining({ stdio: 'inherit' }),
|
|
);
|
|
});
|
|
|
|
it('should throw FatalSandboxError if lxc list fails', async () => {
|
|
process.env['TEST_LXC_LIST_OUTPUT'] = 'throw';
|
|
const config: SandboxConfig = createMockSandboxConfig({
|
|
command: 'lxc',
|
|
image: 'gemini-sandbox',
|
|
});
|
|
|
|
await expect(start_sandbox(config)).rejects.toThrow(
|
|
/Failed to query LXC container/,
|
|
);
|
|
});
|
|
|
|
it('should throw FatalSandboxError if container is not running', async () => {
|
|
process.env['TEST_LXC_LIST_OUTPUT'] = LXC_STOPPED;
|
|
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 = createMockSandboxConfig({
|
|
command: 'lxc',
|
|
image: 'gemini-sandbox',
|
|
});
|
|
|
|
await expect(start_sandbox(config)).rejects.toThrow(/not found/);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('gVisor (runsc)', () => {
|
|
it('should use docker with --runtime=runsc on Linux', async () => {
|
|
vi.mocked(os.platform).mockReturnValue('linux');
|
|
const config: SandboxConfig = createMockSandboxConfig({
|
|
command: 'runsc',
|
|
image: 'gemini-cli-sandbox',
|
|
});
|
|
|
|
// 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>;
|
|
});
|
|
|
|
// Mock docker run
|
|
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, [], undefined, ['arg1']);
|
|
|
|
// Verify docker (not runsc) is called for image check
|
|
expect(spawn).toHaveBeenNthCalledWith(
|
|
1,
|
|
'docker',
|
|
expect.arrayContaining(['images', '-q', 'gemini-cli-sandbox']),
|
|
);
|
|
|
|
// Verify docker run includes --runtime=runsc
|
|
expect(spawn).toHaveBeenNthCalledWith(
|
|
2,
|
|
'docker',
|
|
expect.arrayContaining(['run', '--runtime=runsc']),
|
|
expect.objectContaining({ stdio: 'inherit' }),
|
|
);
|
|
});
|
|
});
|
|
});
|