mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 13:53:02 -07:00
feat(sandbox): add custom sandbox flags and orchestrator refactor
This commit is contained in:
+14
-4
@@ -91,11 +91,21 @@ Built-in profiles (set via `SEATBELT_PROFILE` env var):
|
|||||||
### Custom sandbox flags
|
### Custom sandbox flags
|
||||||
|
|
||||||
For container-based sandboxing, you can inject custom flags into the `docker` or
|
For container-based sandboxing, you can inject custom flags into the `docker` or
|
||||||
`podman` command using the `SANDBOX_FLAGS` environment variable. This is useful
|
`podman` command using the `tools.sandboxFlags` setting in your `settings.json`
|
||||||
for advanced configurations, such as disabling security features for specific
|
or the `SANDBOX_FLAGS` environment variable. This is useful for advanced
|
||||||
use cases.
|
configurations, such as disabling security features for specific use cases.
|
||||||
|
|
||||||
**Example (Podman)**:
|
**Example (`settings.json`)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"sandboxFlags": "--security-opt label=disable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (Environment variable)**:
|
||||||
|
|
||||||
To disable SELinux labeling for volume mounts, you can set the following:
|
To disable SELinux labeling for volume mounts, you can set the following:
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ they appear in the UI.
|
|||||||
|
|
||||||
| UI Label | Setting | Description | Default |
|
| UI Label | Setting | Description | Default |
|
||||||
| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
|
| Sandbox Flags | `tools.sandboxFlags` | Additional flags to pass to the sandbox container engine (Docker or Podman). Environment variables can be used and will be expanded. | `""` |
|
||||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||||
@@ -118,6 +119,7 @@ they appear in the UI.
|
|||||||
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
|
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
|
||||||
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
||||||
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
|
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
|
||||||
|
| Auto-add to Policy | `security.autoAddPolicy` | Automatically add "Proceed always" approvals to your persistent policy. | `true` |
|
||||||
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
|
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
|
||||||
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
|
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
|
||||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
|
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
|
||||||
|
|||||||
@@ -104,5 +104,7 @@ export async function loadSandboxConfig(
|
|||||||
const image =
|
const image =
|
||||||
process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri;
|
process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri;
|
||||||
|
|
||||||
return command && image ? { command, image } : undefined;
|
const flags = settings.tools?.sandboxFlags;
|
||||||
|
|
||||||
|
return command && image ? { command, image, flags } : undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1201,6 +1201,18 @@ const SETTINGS_SCHEMA = {
|
|||||||
`,
|
`,
|
||||||
showInDialog: false,
|
showInDialog: false,
|
||||||
},
|
},
|
||||||
|
sandboxFlags: {
|
||||||
|
type: 'string',
|
||||||
|
label: 'Sandbox Flags',
|
||||||
|
category: 'Tools',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: '',
|
||||||
|
description: oneLine`
|
||||||
|
Additional flags to pass to the sandbox container engine (Docker or Podman).
|
||||||
|
Environment variables can be used and will be expanded.
|
||||||
|
`,
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
shell: {
|
shell: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
label: 'Shell',
|
label: 'Shell',
|
||||||
|
|||||||
@@ -5,17 +5,36 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { spawn, exec, execSync } from 'node:child_process';
|
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { start_sandbox } from './sandbox.js';
|
import { start_sandbox } from './sandbox.js';
|
||||||
import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core';
|
import {
|
||||||
|
FatalSandboxError,
|
||||||
|
type SandboxConfig,
|
||||||
|
SandboxOrchestrator,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
const { mockedHomedir, mockedGetContainerPath } = vi.hoisted(() => ({
|
const { mockedHomedir, mockedGetContainerPath, mockSpawnAsync } = vi.hoisted(
|
||||||
mockedHomedir: vi.fn().mockReturnValue('/home/user'),
|
() => ({
|
||||||
mockedGetContainerPath: vi.fn().mockImplementation((p: string) => p),
|
mockedHomedir: vi.fn().mockReturnValue('/home/user'),
|
||||||
}));
|
mockedGetContainerPath: vi.fn().mockImplementation((p: string) => p),
|
||||||
|
mockSpawnAsync: vi.fn().mockImplementation(async (cmd, args) => {
|
||||||
|
if (cmd === 'id' && args?.[0] === '-u')
|
||||||
|
return { stdout: '1000', stderr: '' };
|
||||||
|
if (cmd === 'id' && args?.[0] === '-g')
|
||||||
|
return { stdout: '1000', stderr: '' };
|
||||||
|
if (cmd === 'getconf') return { stdout: '/tmp/cache', stderr: '' };
|
||||||
|
if (cmd === 'docker' && args?.[0] === 'ps')
|
||||||
|
return { stdout: 'existing-container', stderr: '' };
|
||||||
|
if (cmd === 'docker' && args?.[0] === 'network')
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
if (cmd === 'curl') return { stdout: 'ok', stderr: '' };
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
vi.mock('./sandboxUtils.js', async (importOriginal) => {
|
vi.mock('./sandboxUtils.js', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('./sandboxUtils.js')>();
|
const actual = await importOriginal<typeof import('./sandboxUtils.js')>();
|
||||||
@@ -28,32 +47,6 @@ vi.mock('./sandboxUtils.js', async (importOriginal) => {
|
|||||||
vi.mock('node:child_process');
|
vi.mock('node:child_process');
|
||||||
vi.mock('node:os');
|
vi.mock('node:os');
|
||||||
vi.mock('node:fs');
|
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: '' };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return actual.promisify(fn);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
@@ -68,13 +61,15 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
coreEvents: {
|
coreEvents: {
|
||||||
emitFeedback: vi.fn(),
|
emitFeedback: vi.fn(),
|
||||||
},
|
},
|
||||||
FatalSandboxError: class extends Error {
|
SandboxOrchestrator: {
|
||||||
constructor(message: string) {
|
ensureSandboxImageIsPresent: vi.fn().mockResolvedValue(true),
|
||||||
super(message);
|
getContainerRunArgs: vi
|
||||||
this.name = 'FatalSandboxError';
|
.fn()
|
||||||
}
|
.mockResolvedValue(['run', '-i', '--rm', '--init']),
|
||||||
|
getSeatbeltArgs: vi.fn().mockReturnValue(['-D', 'TARGET_DIR=/tmp']),
|
||||||
},
|
},
|
||||||
GEMINI_DIR: '.gemini',
|
spawnAsync: mockSpawnAsync,
|
||||||
|
LOCAL_DEV_SANDBOX_IMAGE_NAME: 'gemini-cli-sandbox',
|
||||||
homedir: mockedHomedir,
|
homedir: mockedHomedir,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -107,6 +102,21 @@ describe('sandbox', () => {
|
|||||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
|
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
|
||||||
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
|
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
|
||||||
|
|
||||||
|
// Default mockSpawnAsync implementation
|
||||||
|
mockSpawnAsync.mockImplementation(async (cmd, args) => {
|
||||||
|
if (cmd === 'id' && args?.[0] === '-u')
|
||||||
|
return { stdout: '1000', stderr: '' };
|
||||||
|
if (cmd === 'id' && args?.[0] === '-g')
|
||||||
|
return { stdout: '1000', stderr: '' };
|
||||||
|
if (cmd === 'getconf') return { stdout: '/tmp/cache', stderr: '' };
|
||||||
|
if (cmd === 'docker' && args?.[0] === 'ps')
|
||||||
|
return { stdout: 'existing-container', stderr: '' };
|
||||||
|
if (cmd === 'docker' && args?.[0] === 'network')
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
if (cmd === 'curl') return { stdout: 'ok', stderr: '' };
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -122,30 +132,27 @@ describe('sandbox', () => {
|
|||||||
image: 'some-image',
|
image: 'some-image',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MockProcess extends EventEmitter {
|
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||||
stdout: EventEmitter;
|
// @ts-expect-error - mocking readonly property
|
||||||
stderr: EventEmitter;
|
|
||||||
}
|
|
||||||
const mockSpawnProcess = new EventEmitter() as MockProcess;
|
|
||||||
mockSpawnProcess.stdout = new EventEmitter();
|
mockSpawnProcess.stdout = new EventEmitter();
|
||||||
|
// @ts-expect-error - mocking readonly property
|
||||||
mockSpawnProcess.stderr = new EventEmitter();
|
mockSpawnProcess.stderr = new EventEmitter();
|
||||||
|
// @ts-expect-error - mocking readonly property
|
||||||
|
mockSpawnProcess.pid = 123;
|
||||||
vi.mocked(spawn).mockReturnValue(
|
vi.mocked(spawn).mockReturnValue(
|
||||||
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
|
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||||
|
|
||||||
setTimeout(() => {
|
// Use setImmediate to ensure the promise has had a chance to register handlers
|
||||||
mockSpawnProcess.emit('close', 0);
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
}, 10);
|
mockSpawnProcess.emit('close', 0);
|
||||||
|
|
||||||
await expect(promise).resolves.toBe(0);
|
await expect(promise).resolves.toBe(0);
|
||||||
expect(spawn).toHaveBeenCalledWith(
|
expect(spawn).toHaveBeenCalledWith(
|
||||||
'sandbox-exec',
|
'sandbox-exec',
|
||||||
expect.arrayContaining([
|
expect.arrayContaining(['-D', expect.stringContaining('TARGET_DIR=')]),
|
||||||
'-f',
|
|
||||||
expect.stringContaining('sandbox-macos-permissive-open.sb'),
|
|
||||||
]),
|
|
||||||
expect.objectContaining({ stdio: 'inherit' }),
|
expect.objectContaining({ stdio: 'inherit' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -167,152 +174,155 @@ describe('sandbox', () => {
|
|||||||
image: 'gemini-cli-sandbox',
|
image: 'gemini-cli-sandbox',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock image check to return true (image exists)
|
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||||
interface MockProcessWithStdout extends EventEmitter {
|
// @ts-expect-error - mocking readonly property
|
||||||
stdout: EventEmitter;
|
mockSpawnProcess.stdout = new EventEmitter();
|
||||||
}
|
// @ts-expect-error - mocking readonly property
|
||||||
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
|
mockSpawnProcess.stderr = new EventEmitter();
|
||||||
mockImageCheckProcess.stdout = new EventEmitter();
|
// @ts-expect-error - mocking readonly property
|
||||||
vi.mocked(spawn).mockImplementationOnce((_cmd, args) => {
|
mockSpawnProcess.pid = 123;
|
||||||
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) => {
|
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||||
if (event === 'close') {
|
if (event === 'close') {
|
||||||
setTimeout(() => cb(0), 10);
|
setImmediate(() => cb(0));
|
||||||
}
|
}
|
||||||
return mockSpawnProcess;
|
return mockSpawnProcess;
|
||||||
});
|
});
|
||||||
vi.mocked(spawn).mockImplementationOnce((cmd, args) => {
|
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||||
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']);
|
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||||
|
|
||||||
await expect(promise).resolves.toBe(0);
|
await expect(promise).resolves.toBe(0);
|
||||||
|
expect(
|
||||||
|
SandboxOrchestrator.ensureSandboxImageIsPresent,
|
||||||
|
).toHaveBeenCalled();
|
||||||
|
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalled();
|
||||||
expect(spawn).toHaveBeenCalledWith(
|
expect(spawn).toHaveBeenCalledWith(
|
||||||
'docker',
|
'docker',
|
||||||
expect.arrayContaining(['run', '-i', '--rm', '--init']),
|
expect.any(Array),
|
||||||
expect.objectContaining({ stdio: 'inherit' }),
|
expect.objectContaining({ stdio: 'inherit' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pull image if missing', async () => {
|
it('should inject custom flags from SANDBOX_FLAGS env var', async () => {
|
||||||
|
process.env['SANDBOX_FLAGS'] =
|
||||||
|
'--security-opt label=disable --env FOO=bar';
|
||||||
const config: SandboxConfig = {
|
const config: SandboxConfig = {
|
||||||
command: 'docker',
|
command: 'docker',
|
||||||
image: 'missing-image',
|
image: 'gemini-cli-sandbox',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Image check fails
|
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||||
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) => {
|
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||||
if (event === 'close') {
|
if (event === 'close') {
|
||||||
setTimeout(() => cb(0), 10);
|
setImmediate(() => cb(0));
|
||||||
}
|
}
|
||||||
return mockSpawnProcess;
|
return mockSpawnProcess;
|
||||||
});
|
});
|
||||||
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
|
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||||
|
|
||||||
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
await start_sandbox(config);
|
||||||
|
|
||||||
await expect(promise).resolves.toBe(0);
|
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
|
||||||
expect(spawn).toHaveBeenCalledWith(
|
config,
|
||||||
'docker',
|
expect.any(String),
|
||||||
expect.arrayContaining(['pull', 'missing-image']),
|
expect.any(String),
|
||||||
expect.any(Object),
|
'--security-opt label=disable --env FOO=bar',
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw if image pull fails', async () => {
|
it('should inject custom flags from config (settings)', async () => {
|
||||||
|
const config: SandboxConfig = {
|
||||||
|
command: 'docker',
|
||||||
|
image: 'gemini-cli-sandbox',
|
||||||
|
flags: '--privileged',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||||
|
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||||
|
if (event === 'close') {
|
||||||
|
setImmediate(() => cb(0));
|
||||||
|
}
|
||||||
|
return mockSpawnProcess;
|
||||||
|
});
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||||
|
|
||||||
|
await start_sandbox(config);
|
||||||
|
|
||||||
|
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
|
||||||
|
config,
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expand multiple environment variables in sandbox flags', async () => {
|
||||||
|
process.env['VAR1'] = 'val1';
|
||||||
|
process.env['VAR2'] = 'val2';
|
||||||
|
const config: SandboxConfig = {
|
||||||
|
command: 'docker',
|
||||||
|
image: 'gemini-cli-sandbox',
|
||||||
|
flags: '--env V1=$VAR1 --env V2=${VAR2}',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||||
|
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||||
|
if (event === 'close') {
|
||||||
|
setImmediate(() => cb(0));
|
||||||
|
}
|
||||||
|
return mockSpawnProcess;
|
||||||
|
});
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||||
|
|
||||||
|
await start_sandbox(config);
|
||||||
|
|
||||||
|
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
|
||||||
|
config,
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle quoted strings in sandbox flags', async () => {
|
||||||
|
const config: SandboxConfig = {
|
||||||
|
command: 'docker',
|
||||||
|
image: 'gemini-cli-sandbox',
|
||||||
|
flags: '--label "description=multi word label" --env \'FOO=bar baz\'',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||||
|
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||||
|
if (event === 'close') {
|
||||||
|
setImmediate(() => cb(0));
|
||||||
|
}
|
||||||
|
return mockSpawnProcess;
|
||||||
|
});
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||||
|
|
||||||
|
await start_sandbox(config);
|
||||||
|
|
||||||
|
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
|
||||||
|
config,
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if image is missing', async () => {
|
||||||
const config: SandboxConfig = {
|
const config: SandboxConfig = {
|
||||||
command: 'docker',
|
command: 'docker',
|
||||||
image: 'missing-image',
|
image: 'missing-image',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Image check fails
|
vi.mocked(
|
||||||
interface MockProcessWithStdout extends EventEmitter {
|
SandboxOrchestrator.ensureSandboxImageIsPresent,
|
||||||
stdout: EventEmitter;
|
).mockResolvedValueOnce(false);
|
||||||
}
|
|
||||||
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);
|
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
|
||||||
});
|
});
|
||||||
@@ -325,51 +335,20 @@ describe('sandbox', () => {
|
|||||||
process.env['SANDBOX_MOUNTS'] = '/host/path:/container/path:ro';
|
process.env['SANDBOX_MOUNTS'] = '/host/path:/container/path:ro';
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(true); // For mount path check
|
vi.mocked(fs.existsSync).mockReturnValue(true); // For mount path check
|
||||||
|
|
||||||
// Mock image check to return true
|
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||||
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) => {
|
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||||
if (event === 'close') {
|
if (event === 'close') {
|
||||||
setTimeout(() => cb(0), 10);
|
setImmediate(() => cb(0));
|
||||||
}
|
}
|
||||||
return mockSpawnProcess;
|
return mockSpawnProcess;
|
||||||
});
|
});
|
||||||
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
|
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||||
|
|
||||||
await start_sandbox(config);
|
await start_sandbox(config);
|
||||||
|
|
||||||
// The first call is 'docker images -q ...'
|
expect(spawn).toHaveBeenCalledWith(
|
||||||
expect(spawn).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
'docker',
|
'docker',
|
||||||
expect.arrayContaining(['images', '-q']),
|
expect.arrayContaining(['--volume', '/host/path:/container/path:ro']),
|
||||||
);
|
|
||||||
|
|
||||||
// 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),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -382,30 +361,14 @@ describe('sandbox', () => {
|
|||||||
process.env['GOOGLE_GEMINI_BASE_URL'] = 'http://gemini.proxy';
|
process.env['GOOGLE_GEMINI_BASE_URL'] = 'http://gemini.proxy';
|
||||||
process.env['GOOGLE_VERTEX_BASE_URL'] = 'http://vertex.proxy';
|
process.env['GOOGLE_VERTEX_BASE_URL'] = 'http://vertex.proxy';
|
||||||
|
|
||||||
// Mock image check to return true
|
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||||
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) => {
|
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||||
if (event === 'close') {
|
if (event === 'close') {
|
||||||
setTimeout(() => cb(0), 10);
|
setImmediate(() => cb(0));
|
||||||
}
|
}
|
||||||
return mockSpawnProcess;
|
return mockSpawnProcess;
|
||||||
});
|
});
|
||||||
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
|
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||||
|
|
||||||
await start_sandbox(config);
|
await start_sandbox(config);
|
||||||
|
|
||||||
@@ -434,30 +397,14 @@ describe('sandbox', () => {
|
|||||||
return Buffer.from('');
|
return Buffer.from('');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock image check to return true
|
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||||
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) => {
|
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||||
if (event === 'close') {
|
if (event === 'close') {
|
||||||
setTimeout(() => cb(0), 10);
|
setImmediate(() => cb(0));
|
||||||
}
|
}
|
||||||
return mockSpawnProcess;
|
return mockSpawnProcess;
|
||||||
});
|
});
|
||||||
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
|
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||||
|
|
||||||
await start_sandbox(config);
|
await start_sandbox(config);
|
||||||
|
|
||||||
@@ -467,11 +414,63 @@ describe('sandbox', () => {
|
|||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
// Check that the entrypoint command includes useradd/groupadd
|
// Check that the entrypoint command includes useradd/groupadd
|
||||||
const args = vi.mocked(spawn).mock.calls[1][1] as string[];
|
const args = vi.mocked(spawn).mock.calls[0][1] as string[];
|
||||||
const entrypointCmd = args[args.length - 1];
|
const entrypointCmd = args[args.length - 1];
|
||||||
expect(entrypointCmd).toContain('groupadd');
|
expect(entrypointCmd).toContain('groupadd');
|
||||||
expect(entrypointCmd).toContain('useradd');
|
expect(entrypointCmd).toContain('useradd');
|
||||||
expect(entrypointCmd).toContain('su -p gemini');
|
expect(entrypointCmd).toContain('su -p gemini');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('waitForProxy timeout', () => {
|
||||||
|
it('should time out waiting for proxy', async () => {
|
||||||
|
const config: SandboxConfig = {
|
||||||
|
command: 'docker',
|
||||||
|
image: 'gemini-cli-sandbox',
|
||||||
|
};
|
||||||
|
process.env['GEMINI_SANDBOX_PROXY_COMMAND'] = 'my-proxy';
|
||||||
|
|
||||||
|
// Mock spawn to return processes that stay open
|
||||||
|
vi.mocked(spawn).mockImplementation(() => {
|
||||||
|
const p = new EventEmitter() as unknown as ChildProcess;
|
||||||
|
// @ts-expect-error - mocking readonly property
|
||||||
|
p.pid = 123;
|
||||||
|
p.kill = vi.fn();
|
||||||
|
// @ts-expect-error - mocking readonly property
|
||||||
|
p.stderr = new EventEmitter();
|
||||||
|
// @ts-expect-error - mocking readonly property
|
||||||
|
p.stdout = new EventEmitter();
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock spawnAsync to fail for curl (simulating proxy not started)
|
||||||
|
mockSpawnAsync.mockImplementation(async (cmd) => {
|
||||||
|
if (cmd === 'curl') {
|
||||||
|
throw new Error('Connection refused');
|
||||||
|
}
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock Date.now to control time
|
||||||
|
let currentTime = 1000000;
|
||||||
|
const dateSpy = vi.spyOn(Date, 'now').mockImplementation(() => {
|
||||||
|
currentTime += 10000; // Increment time by 10s on each call to hit timeout fast
|
||||||
|
return currentTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
// We also need to mock setTimeout to resolve immediately,
|
||||||
|
// otherwise the loop will still take real time.
|
||||||
|
const originalSetTimeout = global.setTimeout;
|
||||||
|
// @ts-expect-error - mocking global setTimeout
|
||||||
|
global.setTimeout = vi.fn().mockImplementation((cb) => cb());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const promise = start_sandbox(config);
|
||||||
|
await expect(promise).rejects.toThrow(/Timed out waiting for proxy/);
|
||||||
|
} finally {
|
||||||
|
dateSpy.mockRestore();
|
||||||
|
global.setTimeout = originalSetTimeout;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+112
-220
@@ -1,16 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
* Copyright 2025 Google LLC
|
* Copyright 2026 Google LLC
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { exec, execSync, spawn, type ChildProcess } from 'node:child_process';
|
import { execSync, spawn, type ChildProcess } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { quote, parse } from 'shell-quote';
|
import { quote } from 'shell-quote';
|
||||||
import { promisify } from 'node:util';
|
|
||||||
import type { Config, SandboxConfig } from '@google/gemini-cli-core';
|
import type { Config, SandboxConfig } from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
coreEvents,
|
coreEvents,
|
||||||
@@ -18,6 +17,11 @@ import {
|
|||||||
FatalSandboxError,
|
FatalSandboxError,
|
||||||
GEMINI_DIR,
|
GEMINI_DIR,
|
||||||
homedir,
|
homedir,
|
||||||
|
SandboxOrchestrator,
|
||||||
|
LOCAL_DEV_SANDBOX_IMAGE_NAME,
|
||||||
|
SANDBOX_NETWORK_NAME,
|
||||||
|
SANDBOX_PROXY_NAME,
|
||||||
|
spawnAsync,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
|
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
@@ -27,13 +31,30 @@ import {
|
|||||||
parseImageName,
|
parseImageName,
|
||||||
ports,
|
ports,
|
||||||
entrypoint,
|
entrypoint,
|
||||||
LOCAL_DEV_SANDBOX_IMAGE_NAME,
|
|
||||||
SANDBOX_NETWORK_NAME,
|
|
||||||
SANDBOX_PROXY_NAME,
|
|
||||||
BUILTIN_SEATBELT_PROFILES,
|
BUILTIN_SEATBELT_PROFILES,
|
||||||
} from './sandboxUtils.js';
|
} from './sandboxUtils.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
async function waitForProxy(
|
||||||
|
proxyUrl: string,
|
||||||
|
timeoutMs: number = 30000,
|
||||||
|
retryDelayMs: number = 500,
|
||||||
|
now: () => number = Date.now,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = now();
|
||||||
|
while (now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
await spawnAsync('curl', ['-s', proxyUrl], {
|
||||||
|
timeout: 500,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
await new Promise((r) => setTimeout(r, retryDelayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new FatalSandboxError(
|
||||||
|
`Timed out waiting for proxy at ${proxyUrl} to start after ${timeoutMs / 1000} seconds`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function start_sandbox(
|
export async function start_sandbox(
|
||||||
config: SandboxConfig,
|
config: SandboxConfig,
|
||||||
@@ -70,26 +91,16 @@ export async function start_sandbox(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`);
|
debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`);
|
||||||
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
|
const cacheDir = (
|
||||||
const nodeOptions = [
|
await spawnAsync('getconf', ['DARWIN_USER_CACHE_DIR'])
|
||||||
...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
|
).stdout.trim();
|
||||||
...nodeArgs,
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
const args = [
|
const targetDirReal = fs.realpathSync(process.cwd());
|
||||||
'-D',
|
const tmpDirReal = fs.realpathSync(os.tmpdir());
|
||||||
`TARGET_DIR=${fs.realpathSync(process.cwd())}`,
|
const homeDirReal = fs.realpathSync(homedir());
|
||||||
'-D',
|
const cacheDirReal = fs.realpathSync(cacheDir);
|
||||||
`TMP_DIR=${fs.realpathSync(os.tmpdir())}`,
|
|
||||||
'-D',
|
|
||||||
`HOME_DIR=${fs.realpathSync(homedir())}`,
|
|
||||||
'-D',
|
|
||||||
`CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add included directories from the workspace context
|
// Add included directories from the workspace context
|
||||||
// Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them
|
|
||||||
const MAX_INCLUDE_DIRS = 5;
|
|
||||||
const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
|
const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
|
||||||
const includedDirs: string[] = [];
|
const includedDirs: string[] = [];
|
||||||
|
|
||||||
@@ -106,21 +117,24 @@ export async function start_sandbox(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < MAX_INCLUDE_DIRS; i++) {
|
const args = SandboxOrchestrator.getSeatbeltArgs(
|
||||||
let dirPath = '/dev/null'; // Default to a safe path that won't cause issues
|
targetDirReal,
|
||||||
|
tmpDirReal,
|
||||||
|
homeDirReal,
|
||||||
|
cacheDirReal,
|
||||||
|
profileFile,
|
||||||
|
includedDirs,
|
||||||
|
);
|
||||||
|
|
||||||
if (i < includedDirs.length) {
|
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
|
||||||
dirPath = includedDirs[i];
|
const nodeOptions = [
|
||||||
}
|
...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
|
||||||
|
...nodeArgs,
|
||||||
args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
|
].join(' ');
|
||||||
}
|
|
||||||
|
|
||||||
const finalArgv = cliArgs;
|
const finalArgv = cliArgs;
|
||||||
|
|
||||||
args.push(
|
args.push(
|
||||||
'-f',
|
|
||||||
profileFile,
|
|
||||||
'sh',
|
'sh',
|
||||||
'-c',
|
'-c',
|
||||||
[
|
[
|
||||||
@@ -161,6 +175,7 @@ export async function start_sandbox(
|
|||||||
if (proxyProcess?.pid) {
|
if (proxyProcess?.pid) {
|
||||||
process.kill(-proxyProcess.pid, 'SIGTERM');
|
process.kill(-proxyProcess.pid, 'SIGTERM');
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
};
|
};
|
||||||
process.off('exit', stopProxy);
|
process.off('exit', stopProxy);
|
||||||
process.on('exit', stopProxy);
|
process.on('exit', stopProxy);
|
||||||
@@ -185,9 +200,7 @@ export async function start_sandbox(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
debugLogger.log('waiting for proxy to start ...');
|
debugLogger.log('waiting for proxy to start ...');
|
||||||
await execAsync(
|
await waitForProxy('http://localhost:8877', 30000, 500);
|
||||||
`until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// spawn child and let it inherit stdio
|
// spawn child and let it inherit stdio
|
||||||
process.stdin.pause();
|
process.stdin.pause();
|
||||||
@@ -255,7 +268,11 @@ export async function start_sandbox(
|
|||||||
|
|
||||||
// stop if image is missing
|
// stop if image is missing
|
||||||
if (
|
if (
|
||||||
!(await ensureSandboxImageIsPresent(config.command, image, cliConfig))
|
!(await SandboxOrchestrator.ensureSandboxImageIsPresent(
|
||||||
|
config.command,
|
||||||
|
image,
|
||||||
|
cliConfig,
|
||||||
|
))
|
||||||
) {
|
) {
|
||||||
const remedy =
|
const remedy =
|
||||||
image === LOCAL_DEV_SANDBOX_IMAGE_NAME
|
image === LOCAL_DEV_SANDBOX_IMAGE_NAME
|
||||||
@@ -268,26 +285,13 @@ export async function start_sandbox(
|
|||||||
|
|
||||||
// use interactive mode and auto-remove container on exit
|
// use interactive mode and auto-remove container on exit
|
||||||
// run init binary inside container to forward signals & reap zombies
|
// run init binary inside container to forward signals & reap zombies
|
||||||
const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir];
|
const args = await SandboxOrchestrator.getContainerRunArgs(
|
||||||
|
config,
|
||||||
// add custom flags from SANDBOX_FLAGS
|
workdir,
|
||||||
if (process.env['SANDBOX_FLAGS']) {
|
containerWorkdir,
|
||||||
const flags = parse(process.env['SANDBOX_FLAGS'], process.env).filter(
|
process.env['SANDBOX_FLAGS'],
|
||||||
(f): f is string => typeof f === 'string',
|
!process.stdin.isTTY,
|
||||||
);
|
);
|
||||||
args.push(...flags);
|
|
||||||
}
|
|
||||||
|
|
||||||
// add TTY only if stdin is TTY as well, i.e. for piped input don't init TTY in container
|
|
||||||
if (process.stdin.isTTY) {
|
|
||||||
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 (set via --workdir)
|
|
||||||
args.push('--volume', `${workdir}:${containerWorkdir}`);
|
|
||||||
|
|
||||||
// mount user settings directory inside container, after creating if missing
|
// mount user settings directory inside container, after creating if missing
|
||||||
// note user/home changes inside sandbox and we mount at BOTH paths for consistency
|
// note user/home changes inside sandbox and we mount at BOTH paths for consistency
|
||||||
@@ -409,17 +413,38 @@ export async function start_sandbox(
|
|||||||
|
|
||||||
// if using proxy, switch to internal networking through proxy
|
// if using proxy, switch to internal networking through proxy
|
||||||
if (proxy) {
|
if (proxy) {
|
||||||
execSync(
|
try {
|
||||||
`${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`,
|
await spawnAsync(config.command, [
|
||||||
);
|
'network',
|
||||||
|
'inspect',
|
||||||
|
SANDBOX_NETWORK_NAME,
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
await spawnAsync(config.command, [
|
||||||
|
'network',
|
||||||
|
'create',
|
||||||
|
'--internal',
|
||||||
|
SANDBOX_NETWORK_NAME,
|
||||||
|
]);
|
||||||
|
}
|
||||||
args.push('--network', SANDBOX_NETWORK_NAME);
|
args.push('--network', SANDBOX_NETWORK_NAME);
|
||||||
// if proxy command is set, create a separate network w/ host access (i.e. non-internal)
|
// 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
|
// 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
|
// this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation
|
||||||
if (proxyCommand) {
|
if (proxyCommand) {
|
||||||
execSync(
|
try {
|
||||||
`${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`,
|
await spawnAsync(config.command, [
|
||||||
);
|
'network',
|
||||||
|
'inspect',
|
||||||
|
SANDBOX_PROXY_NAME,
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
await spawnAsync(config.command, [
|
||||||
|
'network',
|
||||||
|
'create',
|
||||||
|
SANDBOX_PROXY_NAME,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,9 +461,12 @@ export async function start_sandbox(
|
|||||||
debugLogger.log(`ContainerName: ${containerName}`);
|
debugLogger.log(`ContainerName: ${containerName}`);
|
||||||
} else {
|
} else {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
const containerNameCheck = (
|
const { stdout: containerNameCheck } = await spawnAsync(config.command, [
|
||||||
await execAsync(`${config.command} ps -a --format "{{.Names}}"`)
|
'ps',
|
||||||
).stdout.trim();
|
'-a',
|
||||||
|
'--format',
|
||||||
|
'{{.Names}}',
|
||||||
|
]);
|
||||||
while (containerNameCheck.includes(`${imageName}-${index}`)) {
|
while (containerNameCheck.includes(`${imageName}-${index}`)) {
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
@@ -606,8 +634,8 @@ export async function start_sandbox(
|
|||||||
// The entrypoint script then handles dropping privileges to the correct user.
|
// The entrypoint script then handles dropping privileges to the correct user.
|
||||||
args.push('--user', 'root');
|
args.push('--user', 'root');
|
||||||
|
|
||||||
const uid = (await execAsync('id -u')).stdout.trim();
|
const uid = (await spawnAsync('id', ['-u'])).stdout.trim();
|
||||||
const gid = (await execAsync('id -g')).stdout.trim();
|
const gid = (await spawnAsync('id', ['-g'])).stdout.trim();
|
||||||
|
|
||||||
// Instead of passing --user to the main sandbox container, we let it
|
// Instead of passing --user to the main sandbox container, we let it
|
||||||
// start as root, then create a user with the host's UID/GID, and
|
// start as root, then create a user with the host's UID/GID, and
|
||||||
@@ -660,8 +688,13 @@ export async function start_sandbox(
|
|||||||
// install handlers to stop proxy on exit/signal
|
// install handlers to stop proxy on exit/signal
|
||||||
const stopProxy = () => {
|
const stopProxy = () => {
|
||||||
debugLogger.log('stopping proxy container ...');
|
debugLogger.log('stopping proxy container ...');
|
||||||
execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`);
|
return spawnAsync(config.command, [
|
||||||
|
'rm',
|
||||||
|
'-f',
|
||||||
|
SANDBOX_PROXY_NAME,
|
||||||
|
])?.catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
process.off('exit', stopProxy);
|
process.off('exit', stopProxy);
|
||||||
process.on('exit', stopProxy);
|
process.on('exit', stopProxy);
|
||||||
process.off('SIGINT', stopProxy);
|
process.off('SIGINT', stopProxy);
|
||||||
@@ -681,18 +714,19 @@ export async function start_sandbox(
|
|||||||
process.kill(-sandboxProcess.pid, 'SIGTERM');
|
process.kill(-sandboxProcess.pid, 'SIGTERM');
|
||||||
}
|
}
|
||||||
throw new FatalSandboxError(
|
throw new FatalSandboxError(
|
||||||
`Proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`,
|
`Proxy container command exited with code ${code}, signal ${signal}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
debugLogger.log('waiting for proxy to start ...');
|
debugLogger.log('waiting for proxy to start ...');
|
||||||
await execAsync(
|
await waitForProxy('http://localhost:8877', 30000, 500);
|
||||||
`until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
|
|
||||||
);
|
|
||||||
// connect proxy container to sandbox network
|
// connect proxy container to sandbox network
|
||||||
// (workaround for older versions of docker that don't support multiple --network args)
|
// (workaround for older versions of docker that don't support multiple --network args)
|
||||||
await execAsync(
|
await spawnAsync(config.command, [
|
||||||
`${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`,
|
'network',
|
||||||
);
|
'connect',
|
||||||
|
SANDBOX_NETWORK_NAME,
|
||||||
|
SANDBOX_PROXY_NAME,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// spawn child and let it inherit stdio
|
// spawn child and let it inherit stdio
|
||||||
@@ -721,145 +755,3 @@ export async function start_sandbox(
|
|||||||
patcher.cleanup();
|
patcher.cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions to ensure sandbox image is present
|
|
||||||
async function imageExists(sandbox: string, image: string): Promise<boolean> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const args = ['images', '-q', image];
|
|
||||||
const checkProcess = spawn(sandbox, args);
|
|
||||||
|
|
||||||
let stdoutData = '';
|
|
||||||
if (checkProcess.stdout) {
|
|
||||||
checkProcess.stdout.on('data', (data) => {
|
|
||||||
stdoutData += data.toString();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
checkProcess.on('error', (err) => {
|
|
||||||
debugLogger.warn(
|
|
||||||
`Failed to start '${sandbox}' command for image check: ${err.message}`,
|
|
||||||
);
|
|
||||||
resolve(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
checkProcess.on('close', (code) => {
|
|
||||||
// Non-zero code might indicate docker daemon not running, etc.
|
|
||||||
// The primary success indicator is non-empty stdoutData.
|
|
||||||
if (code !== 0) {
|
|
||||||
// console.warn(`'${sandbox} images -q ${image}' exited with code ${code}.`);
|
|
||||||
}
|
|
||||||
resolve(stdoutData.trim() !== '');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pullImage(
|
|
||||||
sandbox: string,
|
|
||||||
image: string,
|
|
||||||
cliConfig?: Config,
|
|
||||||
): Promise<boolean> {
|
|
||||||
debugLogger.debug(`Attempting to pull image ${image} using ${sandbox}...`);
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const args = ['pull', image];
|
|
||||||
const pullProcess = spawn(sandbox, args, { stdio: 'pipe' });
|
|
||||||
|
|
||||||
let stderrData = '';
|
|
||||||
|
|
||||||
const onStdoutData = (data: Buffer) => {
|
|
||||||
if (cliConfig?.getDebugMode() || process.env['DEBUG']) {
|
|
||||||
debugLogger.log(data.toString().trim()); // Show pull progress
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onStderrData = (data: Buffer) => {
|
|
||||||
stderrData += data.toString();
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(data.toString().trim()); // Show pull errors/info from the command itself
|
|
||||||
};
|
|
||||||
|
|
||||||
const onError = (err: Error) => {
|
|
||||||
debugLogger.warn(
|
|
||||||
`Failed to start '${sandbox} pull ${image}' command: ${err.message}`,
|
|
||||||
);
|
|
||||||
cleanup();
|
|
||||||
resolve(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClose = (code: number | null) => {
|
|
||||||
if (code === 0) {
|
|
||||||
debugLogger.log(`Successfully pulled image ${image}.`);
|
|
||||||
cleanup();
|
|
||||||
resolve(true);
|
|
||||||
} else {
|
|
||||||
debugLogger.warn(
|
|
||||||
`Failed to pull image ${image}. '${sandbox} pull ${image}' exited with code ${code}.`,
|
|
||||||
);
|
|
||||||
if (stderrData.trim()) {
|
|
||||||
// Details already printed by the stderr listener above
|
|
||||||
}
|
|
||||||
cleanup();
|
|
||||||
resolve(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
if (pullProcess.stdout) {
|
|
||||||
pullProcess.stdout.removeListener('data', onStdoutData);
|
|
||||||
}
|
|
||||||
if (pullProcess.stderr) {
|
|
||||||
pullProcess.stderr.removeListener('data', onStderrData);
|
|
||||||
}
|
|
||||||
pullProcess.removeListener('error', onError);
|
|
||||||
pullProcess.removeListener('close', onClose);
|
|
||||||
if (pullProcess.connected) {
|
|
||||||
pullProcess.disconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (pullProcess.stdout) {
|
|
||||||
pullProcess.stdout.on('data', onStdoutData);
|
|
||||||
}
|
|
||||||
if (pullProcess.stderr) {
|
|
||||||
pullProcess.stderr.on('data', onStderrData);
|
|
||||||
}
|
|
||||||
pullProcess.on('error', onError);
|
|
||||||
pullProcess.on('close', onClose);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureSandboxImageIsPresent(
|
|
||||||
sandbox: string,
|
|
||||||
image: string,
|
|
||||||
cliConfig?: Config,
|
|
||||||
): Promise<boolean> {
|
|
||||||
debugLogger.log(`Checking for sandbox image: ${image}`);
|
|
||||||
if (await 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 pullImage(sandbox, image, cliConfig)) {
|
|
||||||
// After attempting to pull, check again to be certain
|
|
||||||
if (await 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ import { readFile } from 'node:fs/promises';
|
|||||||
import { quote } from 'shell-quote';
|
import { quote } from 'shell-quote';
|
||||||
import { debugLogger, GEMINI_DIR } from '@google/gemini-cli-core';
|
import { debugLogger, GEMINI_DIR } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
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';
|
|
||||||
export const BUILTIN_SEATBELT_PROFILES = [
|
export const BUILTIN_SEATBELT_PROFILES = [
|
||||||
'permissive-open',
|
'permissive-open',
|
||||||
'permissive-proxied',
|
'permissive-proxied',
|
||||||
|
|||||||
@@ -428,6 +428,7 @@ export enum AuthProviderType {
|
|||||||
export interface SandboxConfig {
|
export interface SandboxConfig {
|
||||||
command: 'docker' | 'podman' | 'sandbox-exec';
|
command: 'docker' | 'podman' | 'sandbox-exec';
|
||||||
image: string;
|
image: string;
|
||||||
|
flags?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export * from './utils/apiConversionUtils.js';
|
|||||||
export * from './utils/channel.js';
|
export * from './utils/channel.js';
|
||||||
export * from './utils/constants.js';
|
export * from './utils/constants.js';
|
||||||
export * from './utils/sessionUtils.js';
|
export * from './utils/sessionUtils.js';
|
||||||
|
export * from './utils/sandboxOrchestrator.js';
|
||||||
|
|
||||||
// Export services
|
// Export services
|
||||||
export * from './services/fileDiscoveryService.js';
|
export * from './services/fileDiscoveryService.js';
|
||||||
|
|||||||
@@ -6,3 +6,7 @@
|
|||||||
|
|
||||||
export const REFERENCE_CONTENT_START = '--- Content from referenced files ---';
|
export const REFERENCE_CONTENT_START = '--- Content from referenced files ---';
|
||||||
export const REFERENCE_CONTENT_END = '--- End of content ---';
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1255,6 +1255,13 @@
|
|||||||
"markdownDescription": "Sandbox execution environment. Set to a boolean to enable or disable the sandbox, or provide a string path to a sandbox profile.\n\n- Category: `Tools`\n- Requires restart: `yes`",
|
"markdownDescription": "Sandbox execution environment. Set to a boolean to enable or disable the sandbox, or provide a string path to a sandbox profile.\n\n- Category: `Tools`\n- Requires restart: `yes`",
|
||||||
"$ref": "#/$defs/BooleanOrString"
|
"$ref": "#/$defs/BooleanOrString"
|
||||||
},
|
},
|
||||||
|
"sandboxFlags": {
|
||||||
|
"title": "Sandbox Flags",
|
||||||
|
"description": "Additional flags to pass to the sandbox container engine (Docker or Podman). Environment variables can be used and will be expanded.",
|
||||||
|
"markdownDescription": "Additional flags to pass to the sandbox container engine (Docker or Podman). Environment variables can be used and will be expanded.\n\n- Category: `Tools`\n- Requires restart: `yes`\n- Default: ``",
|
||||||
|
"default": "",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"shell": {
|
"shell": {
|
||||||
"title": "Shell",
|
"title": "Shell",
|
||||||
"description": "Settings for shell execution.",
|
"description": "Settings for shell execution.",
|
||||||
@@ -1425,6 +1432,13 @@
|
|||||||
"default": false,
|
"default": false,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"autoAddPolicy": {
|
||||||
|
"title": "Auto-add to Policy",
|
||||||
|
"description": "Automatically add \"Proceed always\" approvals to your persistent policy.",
|
||||||
|
"markdownDescription": "Automatically add \"Proceed always\" approvals to your persistent policy.\n\n- Category: `Security`\n- Requires restart: `no`\n- Default: `true`",
|
||||||
|
"default": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"blockGitExtensions": {
|
"blockGitExtensions": {
|
||||||
"title": "Blocks extensions from Git",
|
"title": "Blocks extensions from Git",
|
||||||
"description": "Blocks installing and loading extensions from Git.",
|
"description": "Blocks installing and loading extensions from Git.",
|
||||||
|
|||||||
Reference in New Issue
Block a user