diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 392c71a176..3600c81c9f 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -91,11 +91,21 @@ Built-in profiles (set via `SEATBELT_PROFILE` env var): ### Custom sandbox flags For container-based sandboxing, you can inject custom flags into the `docker` or -`podman` command using the `SANDBOX_FLAGS` environment variable. This is useful -for advanced configurations, such as disabling security features for specific -use cases. +`podman` command using the `tools.sandboxFlags` setting in your `settings.json` +or the `SANDBOX_FLAGS` environment variable. This is useful for advanced +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: diff --git a/docs/cli/settings.md b/docs/cli/settings.md index b0c12116d6..bfccc8af65 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -106,6 +106,7 @@ they appear in the UI. | 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` | | 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` | @@ -118,6 +119,7 @@ they appear in the UI. | ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | | 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` | +| 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` | | 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` | diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index e1b7305772..66274a2cd2 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -104,5 +104,7 @@ export async function loadSandboxConfig( const image = 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; } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 48a7641766..b1bc0a7b14 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1201,6 +1201,18 @@ const SETTINGS_SCHEMA = { `, 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: { type: 'object', label: 'Shell', diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index 50b1699644..4bdfda62ab 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -5,17 +5,36 @@ */ 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 fs from 'node:fs'; 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'; -const { mockedHomedir, mockedGetContainerPath } = vi.hoisted(() => ({ - mockedHomedir: vi.fn().mockReturnValue('/home/user'), - mockedGetContainerPath: vi.fn().mockImplementation((p: string) => p), -})); +const { mockedHomedir, mockedGetContainerPath, mockSpawnAsync } = vi.hoisted( + () => ({ + 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) => { const actual = await importOriginal(); @@ -28,32 +47,6 @@ vi.mock('./sandboxUtils.js', async (importOriginal) => { vi.mock('node:child_process'); vi.mock('node:os'); vi.mock('node:fs'); -vi.mock('node:util', async (importOriginal) => { - const actual = await importOriginal(); - 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) => { const actual = @@ -68,13 +61,15 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { coreEvents: { emitFeedback: vi.fn(), }, - FatalSandboxError: class extends Error { - constructor(message: string) { - super(message); - this.name = 'FatalSandboxError'; - } + SandboxOrchestrator: { + ensureSandboxImageIsPresent: vi.fn().mockResolvedValue(true), + getContainerRunArgs: vi + .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, }; }); @@ -107,6 +102,21 @@ describe('sandbox', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.realpathSync).mockImplementation((p) => p as string); 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(() => { @@ -122,30 +132,27 @@ describe('sandbox', () => { image: 'some-image', }; - interface MockProcess extends EventEmitter { - stdout: EventEmitter; - stderr: EventEmitter; - } - const mockSpawnProcess = new EventEmitter() as MockProcess; + const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess; + // @ts-expect-error - mocking readonly property mockSpawnProcess.stdout = new EventEmitter(); + // @ts-expect-error - mocking readonly property mockSpawnProcess.stderr = new EventEmitter(); + // @ts-expect-error - mocking readonly property + mockSpawnProcess.pid = 123; vi.mocked(spawn).mockReturnValue( mockSpawnProcess as unknown as ReturnType, ); const promise = start_sandbox(config, [], undefined, ['arg1']); - setTimeout(() => { - mockSpawnProcess.emit('close', 0); - }, 10); + // Use setImmediate to ensure the promise has had a chance to register handlers + await new Promise((resolve) => setImmediate(resolve)); + mockSpawnProcess.emit('close', 0); await expect(promise).resolves.toBe(0); expect(spawn).toHaveBeenCalledWith( 'sandbox-exec', - expect.arrayContaining([ - '-f', - expect.stringContaining('sandbox-macos-permissive-open.sb'), - ]), + expect.arrayContaining(['-D', expect.stringContaining('TARGET_DIR=')]), expect.objectContaining({ stdio: 'inherit' }), ); }); @@ -167,152 +174,155 @@ describe('sandbox', () => { 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; - } - return new EventEmitter() as unknown as ReturnType; // fallback - }); - - const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< - typeof spawn - >; + const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess; + // @ts-expect-error - mocking readonly property + mockSpawnProcess.stdout = new EventEmitter(); + // @ts-expect-error - mocking readonly property + mockSpawnProcess.stderr = new EventEmitter(); + // @ts-expect-error - mocking readonly property + mockSpawnProcess.pid = 123; mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { if (event === 'close') { - setTimeout(() => cb(0), 10); + setImmediate(() => cb(0)); } return mockSpawnProcess; }); - vi.mocked(spawn).mockImplementationOnce((cmd, args) => { - if (cmd === 'docker' && args && args[0] === 'run') { - return mockSpawnProcess; - } - return new EventEmitter() as unknown as ReturnType; - }); + vi.mocked(spawn).mockReturnValue(mockSpawnProcess); const promise = start_sandbox(config, [], undefined, ['arg1']); await expect(promise).resolves.toBe(0); + expect( + SandboxOrchestrator.ensureSandboxImageIsPresent, + ).toHaveBeenCalled(); + expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalled(); expect(spawn).toHaveBeenCalledWith( 'docker', - expect.arrayContaining(['run', '-i', '--rm', '--init']), + expect.any(Array), 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 = { command: 'docker', - image: 'missing-image', + image: 'gemini-cli-sandbox', }; - // 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; - }); - - // 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; - }); - - // 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; - }); - - // 4. Docker run - const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< - typeof spawn - >; + const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess; mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { if (event === 'close') { - setTimeout(() => cb(0), 10); + setImmediate(() => cb(0)); } 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(spawn).toHaveBeenCalledWith( - 'docker', - expect.arrayContaining(['pull', 'missing-image']), - expect.any(Object), + expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith( + config, + expect.any(String), + expect.any(String), + '--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 = { 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; - }); - - // 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; - }); + vi.mocked( + SandboxOrchestrator.ensureSandboxImageIsPresent, + ).mockResolvedValueOnce(false); await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError); }); @@ -325,51 +335,20 @@ describe('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; - }); - - const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< - typeof spawn - >; + const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess; mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { if (event === 'close') { - setTimeout(() => cb(0), 10); + setImmediate(() => cb(0)); } return mockSpawnProcess; }); - vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + vi.mocked(spawn).mockReturnValue(mockSpawnProcess); await start_sandbox(config); - // The first call is 'docker images -q ...' - expect(spawn).toHaveBeenNthCalledWith( - 1, + expect(spawn).toHaveBeenCalledWith( '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.arrayContaining(['--volume', '/host/path:/container/path:ro']), expect.any(Object), ); }); @@ -382,30 +361,14 @@ describe('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; - }); - - const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< - typeof spawn - >; + const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess; mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { if (event === 'close') { - setTimeout(() => cb(0), 10); + setImmediate(() => cb(0)); } return mockSpawnProcess; }); - vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + vi.mocked(spawn).mockReturnValue(mockSpawnProcess); await start_sandbox(config); @@ -434,30 +397,14 @@ describe('sandbox', () => { 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; - }); - - const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< - typeof spawn - >; + const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess; mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { if (event === 'close') { - setTimeout(() => cb(0), 10); + setImmediate(() => cb(0)); } return mockSpawnProcess; }); - vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + vi.mocked(spawn).mockReturnValue(mockSpawnProcess); await start_sandbox(config); @@ -467,11 +414,63 @@ describe('sandbox', () => { expect.any(Object), ); // 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]; expect(entrypointCmd).toContain('groupadd'); expect(entrypointCmd).toContain('useradd'); 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; + } + }); + }); }); }); diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index ffd77fb119..8efafabfcf 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -1,16 +1,15 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * 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 fs from 'node:fs'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; -import { quote, parse } from 'shell-quote'; -import { promisify } from 'node:util'; +import { quote } from 'shell-quote'; import type { Config, SandboxConfig } from '@google/gemini-cli-core'; import { coreEvents, @@ -18,6 +17,11 @@ import { FatalSandboxError, GEMINI_DIR, homedir, + SandboxOrchestrator, + LOCAL_DEV_SANDBOX_IMAGE_NAME, + SANDBOX_NETWORK_NAME, + SANDBOX_PROXY_NAME, + spawnAsync, } from '@google/gemini-cli-core'; import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; import { randomBytes } from 'node:crypto'; @@ -27,13 +31,30 @@ import { parseImageName, ports, entrypoint, - LOCAL_DEV_SANDBOX_IMAGE_NAME, - SANDBOX_NETWORK_NAME, - SANDBOX_PROXY_NAME, BUILTIN_SEATBELT_PROFILES, } from './sandboxUtils.js'; -const execAsync = promisify(exec); +async function waitForProxy( + proxyUrl: string, + timeoutMs: number = 30000, + retryDelayMs: number = 500, + now: () => number = Date.now, +): Promise { + 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( config: SandboxConfig, @@ -70,26 +91,16 @@ export async function start_sandbox( ); } debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`); - // if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS - const nodeOptions = [ - ...(process.env['DEBUG'] ? ['--inspect-brk'] : []), - ...nodeArgs, - ].join(' '); + const cacheDir = ( + await spawnAsync('getconf', ['DARWIN_USER_CACHE_DIR']) + ).stdout.trim(); - const args = [ - '-D', - `TARGET_DIR=${fs.realpathSync(process.cwd())}`, - '-D', - `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())}`, - ]; + const targetDirReal = fs.realpathSync(process.cwd()); + const tmpDirReal = fs.realpathSync(os.tmpdir()); + const homeDirReal = fs.realpathSync(homedir()); + const cacheDirReal = fs.realpathSync(cacheDir); // 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 includedDirs: string[] = []; @@ -106,21 +117,24 @@ export async function start_sandbox( } } - for (let i = 0; i < MAX_INCLUDE_DIRS; i++) { - let dirPath = '/dev/null'; // Default to a safe path that won't cause issues + const args = SandboxOrchestrator.getSeatbeltArgs( + targetDirReal, + tmpDirReal, + homeDirReal, + cacheDirReal, + profileFile, + includedDirs, + ); - if (i < includedDirs.length) { - dirPath = includedDirs[i]; - } - - args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`); - } + // if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS + const nodeOptions = [ + ...(process.env['DEBUG'] ? ['--inspect-brk'] : []), + ...nodeArgs, + ].join(' '); const finalArgv = cliArgs; args.push( - '-f', - profileFile, 'sh', '-c', [ @@ -161,6 +175,7 @@ export async function start_sandbox( if (proxyProcess?.pid) { process.kill(-proxyProcess.pid, 'SIGTERM'); } + return; }; process.off('exit', stopProxy); process.on('exit', stopProxy); @@ -185,9 +200,7 @@ export async function start_sandbox( ); }); debugLogger.log('waiting for proxy to start ...'); - await execAsync( - `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, - ); + await waitForProxy('http://localhost:8877', 30000, 500); } // spawn child and let it inherit stdio process.stdin.pause(); @@ -255,7 +268,11 @@ export async function start_sandbox( // stop if image is missing if ( - !(await ensureSandboxImageIsPresent(config.command, image, cliConfig)) + !(await SandboxOrchestrator.ensureSandboxImageIsPresent( + config.command, + image, + cliConfig, + )) ) { const remedy = image === LOCAL_DEV_SANDBOX_IMAGE_NAME @@ -268,26 +285,13 @@ export async function start_sandbox( // use interactive mode and auto-remove container on exit // run init binary inside container to forward signals & reap zombies - const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir]; - - // add custom flags from SANDBOX_FLAGS - if (process.env['SANDBOX_FLAGS']) { - const flags = parse(process.env['SANDBOX_FLAGS'], process.env).filter( - (f): f is string => typeof f === 'string', - ); - 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}`); + const args = await SandboxOrchestrator.getContainerRunArgs( + config, + workdir, + containerWorkdir, + process.env['SANDBOX_FLAGS'], + !process.stdin.isTTY, + ); // mount user settings directory inside container, after creating if missing // 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 (proxy) { - execSync( - `${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`, - ); + try { + 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); // if proxy command is set, create a separate network w/ host access (i.e. non-internal) // we will run proxy in its own container connected to both host network and internal network // this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation if (proxyCommand) { - execSync( - `${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`, - ); + try { + 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}`); } else { let index = 0; - const containerNameCheck = ( - await execAsync(`${config.command} ps -a --format "{{.Names}}"`) - ).stdout.trim(); + const { stdout: containerNameCheck } = await spawnAsync(config.command, [ + 'ps', + '-a', + '--format', + '{{.Names}}', + ]); while (containerNameCheck.includes(`${imageName}-${index}`)) { index++; } @@ -606,8 +634,8 @@ export async function start_sandbox( // The entrypoint script then handles dropping privileges to the correct user. args.push('--user', 'root'); - const uid = (await execAsync('id -u')).stdout.trim(); - const gid = (await execAsync('id -g')).stdout.trim(); + const uid = (await spawnAsync('id', ['-u'])).stdout.trim(); + const gid = (await spawnAsync('id', ['-g'])).stdout.trim(); // 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 @@ -660,8 +688,13 @@ export async function start_sandbox( // install handlers to stop proxy on exit/signal const stopProxy = () => { 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.on('exit', stopProxy); process.off('SIGINT', stopProxy); @@ -681,18 +714,19 @@ export async function start_sandbox( process.kill(-sandboxProcess.pid, 'SIGTERM'); } 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 ...'); - await execAsync( - `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, - ); + await waitForProxy('http://localhost:8877', 30000, 500); // connect proxy container to sandbox network // (workaround for older versions of docker that don't support multiple --network args) - await execAsync( - `${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`, - ); + await spawnAsync(config.command, [ + 'network', + 'connect', + SANDBOX_NETWORK_NAME, + SANDBOX_PROXY_NAME, + ]); } // spawn child and let it inherit stdio @@ -721,145 +755,3 @@ export async function start_sandbox( patcher.cleanup(); } } - -// Helper functions to ensure sandbox image is present -async function imageExists(sandbox: string, image: string): Promise { - 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 { - 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 { - 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 -} diff --git a/packages/cli/src/utils/sandboxUtils.ts b/packages/cli/src/utils/sandboxUtils.ts index b33a1af3a3..b64ad22df7 100644 --- a/packages/cli/src/utils/sandboxUtils.ts +++ b/packages/cli/src/utils/sandboxUtils.ts @@ -10,9 +10,6 @@ import { readFile } from 'node:fs/promises'; import { quote } from 'shell-quote'; 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 = [ 'permissive-open', 'permissive-proxied', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 256e079fde..a8c7cb28f8 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -428,6 +428,7 @@ export enum AuthProviderType { export interface SandboxConfig { command: 'docker' | 'podman' | 'sandbox-exec'; image: string; + flags?: string; } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b0f2c1d8cd..76083c2d74 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -109,6 +109,7 @@ export * from './utils/apiConversionUtils.js'; export * from './utils/channel.js'; export * from './utils/constants.js'; export * from './utils/sessionUtils.js'; +export * from './utils/sandboxOrchestrator.js'; // Export services export * from './services/fileDiscoveryService.js'; diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index e11cbb67c1..5806dc8443 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -6,3 +6,7 @@ export const REFERENCE_CONTENT_START = '--- Content from referenced files ---'; export const REFERENCE_CONTENT_END = '--- End of content ---'; + +export const LOCAL_DEV_SANDBOX_IMAGE_NAME = 'gemini-cli-sandbox'; +export const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox'; +export const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy'; diff --git a/packages/core/src/utils/sandboxOrchestrator.test.ts b/packages/core/src/utils/sandboxOrchestrator.test.ts new file mode 100644 index 0000000000..69b8374c07 --- /dev/null +++ b/packages/core/src/utils/sandboxOrchestrator.test.ts @@ -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(); + 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); + }); + }); +}); diff --git a/packages/core/src/utils/sandboxOrchestrator.ts b/packages/core/src/utils/sandboxOrchestrator.ts new file mode 100644 index 0000000000..7fe1fe4f30 --- /dev/null +++ b/packages/core/src/utils/sandboxOrchestrator.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 51bf9c84e2..426c52c88e 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -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`", "$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": { "title": "Shell", "description": "Settings for shell execution.", @@ -1425,6 +1432,13 @@ "default": false, "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": { "title": "Blocks extensions from Git", "description": "Blocks installing and loading extensions from Git.",