Compare commits

...

13 Commits

40 changed files with 1016 additions and 569 deletions
+5 -14
View File
@@ -19,24 +19,15 @@ Use the following command in Gemini CLI:
Running this command will open a dialog with your options:
| Option | Description | Models |
| ----------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview (if enabled), gemini-3-flash-preview (if enabled) |
| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash |
| Manual | Select a specific model. | Any available model. |
| Option | Description | Models |
| ----------------- | -------------------------------------------------------------- | -------------------------------------------- |
| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview, gemini-3-flash-preview |
| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash |
| Manual | Select a specific model. | Any available model. |
We recommend selecting one of the above **Auto** options. However, you can
select **Manual** to select a specific model from those available.
### Gemini 3 and preview features
> **Note:** Gemini 3 is not currently available on all account types. To learn
> more about Gemini 3 access, refer to
> [Gemini 3 on Gemini CLI](../get-started/gemini-3.md).
To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable
[**Preview Features** by using the `settings` command](../cli/settings.md).
You can also use the `--model` flag to specify a particular Gemini model on
startup. For more details, refer to the
[configuration documentation](../reference/configuration.md).
+14 -4
View File
@@ -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:
+1
View File
@@ -113,6 +113,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` |
+6
View File
@@ -756,6 +756,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.sandboxFlags`** (string):
- **Description:** Additional flags to pass to the sandbox container engine
(Docker or Podman). Environment variables can be used and will be expanded.
- **Default:** `""`
- **Requires restart:** Yes
- **`tools.shell.enableInteractiveShell`** (boolean):
- **Description:** Use node-pty for an interactive shell experience. Fallback
to child_process still applies.
+7 -20
View File
@@ -55,26 +55,8 @@ export default tseslint.config(
},
},
{
// Import specific config
files: ['packages/*/src/**/*.{ts,tsx}'], // Target all TS/TSX in the packages
plugins: {
import: importPlugin,
},
settings: {
'import/resolver': {
node: true,
},
},
rules: {
...importPlugin.configs.recommended.rules,
...importPlugin.configs.typescript.rules,
'import/no-default-export': 'warn',
'import/no-unresolved': 'off', // Disable for now, can be noisy with monorepos/paths
},
},
{
// General overrides and rules for the project (TS/TSX files)
files: ['packages/*/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package
// Rules for packages/*/src (TS/TSX)
files: ['packages/*/src/**/*.{ts,tsx}'],
plugins: {
import: importPlugin,
},
@@ -95,6 +77,11 @@ export default tseslint.config(
},
},
rules: {
...importPlugin.configs.recommended.rules,
...importPlugin.configs.typescript.rules,
'import/no-default-export': 'warn',
'import/no-unresolved': 'off',
'import/no-duplicates': 'error',
// General Best Practice Rules (subset adapted for flat config)
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
'arrow-body-style': ['error', 'as-needed'],
+1 -1
View File
@@ -88,7 +88,7 @@ describe('Answer vs. ask eval', () => {
* Ensures that when the user asks a general question, the agent does NOT
* automatically modify the file.
*/
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should not edit files when asked a general question',
prompt: 'How does app.ts work?',
files: FILES,
+1 -1
View File
@@ -25,7 +25,7 @@ describe('git repo eval', () => {
* The phrasing is intentionally chosen to evoke 'complete' to help the test
* be more consistent.
*/
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should not git add commit changes unprompted',
prompt:
'Finish this up for me by just making a targeted fix for the bug in index.ts. Do not build, install anything, or add tests',
+1 -1
View File
@@ -86,7 +86,7 @@ Provide the answer as an XML block like this:
});
const extensionVsGlobalTest = 'Extension memory wins over Global memory';
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: extensionVsGlobalTest,
params: {
settings: {
+2 -2
View File
@@ -18,7 +18,7 @@ describe('plan_mode', () => {
experimental: { plan: true },
};
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should refuse file modification when in plan mode',
approvalMode: ApprovalMode.PLAN,
params: {
@@ -57,7 +57,7 @@ describe('plan_mode', () => {
},
});
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should refuse saving new documentation to the repo when in plan mode',
approvalMode: ApprovalMode.PLAN,
params: {
+3 -3
View File
@@ -125,7 +125,7 @@ describe('save_memory', () => {
});
const rememberingCommandAlias = 'Agent remembers custom command aliases';
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: rememberingCommandAlias,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -178,7 +178,7 @@ describe('save_memory', () => {
const rememberingCodingStyle =
"Agent remembers user's coding style preference";
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: rememberingCodingStyle,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -260,7 +260,7 @@ describe('save_memory', () => {
});
const rememberingBirthday = "Agent remembers user's birthday";
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: rememberingBirthday,
params: {
settings: { tools: { core: ['save_memory'] } },
+1 -1
View File
@@ -72,7 +72,7 @@ describe('Shell Efficiency', () => {
},
});
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled',
params: {
settings: {
+5 -6
View File
@@ -12,23 +12,22 @@ import type {
RequestContext,
ExecutionEventBus,
} from '@a2a-js/sdk/server';
import type { ToolCallRequestInfo, Config } from '@google/gemini-cli-core';
import {
GeminiEventType,
SimpleExtensionLoader,
type ToolCallRequestInfo,
type Config,
} from '@google/gemini-cli-core';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
import type {
StateChange,
AgentSettings,
PersistedStateMetadata,
} from '../types.js';
import {
CoderAgentEvent,
getPersistedState,
setPersistedState,
type StateChange,
type AgentSettings,
type PersistedStateMetadata,
getContextIdFromMetadata,
getAgentSettingsFromMetadata,
} from '../types.js';
+5 -7
View File
@@ -14,17 +14,15 @@ import {
type Mock,
} from 'vitest';
import { Task } from './task.js';
import type {
ToolCall,
Config,
ToolCallRequestInfo,
GitService,
CompletedToolCall,
} from '@google/gemini-cli-core';
import {
GeminiEventType,
ApprovalMode,
ToolConfirmationOutcome,
type Config,
type ToolCallRequestInfo,
type GitService,
type CompletedToolCall,
type ToolCall,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
+14 -11
View File
@@ -31,7 +31,10 @@ import {
EDIT_TOOL_NAMES,
processRestorableToolCalls,
} from '@google/gemini-cli-core';
import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
import {
type ExecutionEventBus,
type RequestContext,
} from '@a2a-js/sdk/server';
import type {
TaskStatusUpdateEvent,
TaskArtifactUpdateEvent,
@@ -44,16 +47,16 @@ import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { CoderAgentEvent } from '../types.js';
import type {
CoderAgentMessage,
StateChange,
ToolCallUpdate,
TextContent,
TaskMetadata,
Thought,
ThoughtSummary,
Citation,
import {
CoderAgentEvent,
type CoderAgentMessage,
type StateChange,
type ToolCallUpdate,
type TextContent,
type TaskMetadata,
type Thought,
type ThoughtSummary,
type Citation,
} from '../types.js';
import type { PartUnion, Part as genAiPart } from '@google/genai';
@@ -6,7 +6,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { InitCommand } from './init.js';
import { performInit } from '@google/gemini-cli-core';
import {
performInit,
type CommandActionReturn,
type Config,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { CoderAgentExecutor } from '../agent/executor.js';
@@ -14,7 +18,6 @@ import { CoderAgentEvent } from '../types.js';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
import { createMockConfig } from '../utils/testing_utils.js';
import type { CommandContext } from './types.js';
import type { CommandActionReturn, Config } from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -9,6 +9,9 @@ import {
listMemoryFiles,
refreshMemory,
showMemory,
type AnyDeclarativeTool,
type Config,
type ToolRegistry,
} from '@google/gemini-cli-core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
@@ -19,11 +22,6 @@ import {
ShowMemoryCommand,
} from './memory.js';
import type { CommandContext } from './types.js';
import type {
AnyDeclarativeTool,
Config,
ToolRegistry,
} from '@google/gemini-cli-core';
// Mock the core functions
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+3 -5
View File
@@ -8,11 +8,6 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as dotenv from 'dotenv';
import type {
TelemetryTarget,
ConfigParameters,
ExtensionLoader,
} from '@google/gemini-cli-core';
import {
AuthType,
Config,
@@ -28,6 +23,9 @@ import {
fetchAdminControlsOnce,
getCodeAssistServer,
ExperimentFlags,
type TelemetryTarget,
type ConfigParameters,
type ExtensionLoader,
} from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
+5 -4
View File
@@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config,
ToolCallConfirmationDetails,
import {
GeminiEventType,
ApprovalMode,
type Config,
type ToolCallConfirmationDetails,
} from '@google/gemini-cli-core';
import { GeminiEventType, ApprovalMode } from '@google/gemini-cli-core';
import type {
TaskStatusUpdateEvent,
SendStreamingMessageSuccessResponse,
@@ -11,8 +11,16 @@ import { gzipSync, gunzipSync } from 'node:zlib';
import { v4 as uuidv4 } from 'uuid';
import type { Task as SDKTask } from '@a2a-js/sdk';
import type { TaskStore } from '@a2a-js/sdk/server';
import type { Mocked, MockedClass, Mock } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
describe,
it,
expect,
beforeEach,
vi,
type Mocked,
type MockedClass,
type Mock,
} from 'vitest';
import { GCSTaskStore, NoOpTaskStore } from './gcs.js';
import { logger } from '../utils/logger.js';
@@ -8,8 +8,7 @@ import type { Message } from '@a2a-js/sdk';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
import { v4 as uuidv4 } from 'uuid';
import { CoderAgentEvent } from '../types.js';
import type { StateChange } from '../types.js';
import { CoderAgentEvent, type StateChange } from '../types.js';
export async function pushTaskStateFailed(
error: unknown,
@@ -18,9 +18,10 @@ import {
HookSystem,
PolicyDecision,
tmpdir,
type Config,
type Storage,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import type { Config, Storage } from '@google/gemini-cli-core';
import { expect, vi } from 'vitest';
export function createMockConfig(
+3 -1
View File
@@ -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;
}
+12
View File
@@ -1245,6 +1245,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',
+241 -237
View File
@@ -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<typeof import('./sandboxUtils.js')>();
@@ -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<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) => {
const actual =
@@ -68,13 +61,17 @@ 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',
SANDBOX_NETWORK_NAME: 'gemini-cli-sandbox',
SANDBOX_PROXY_NAME: 'gemini-cli-sandbox-proxy',
homedir: mockedHomedir,
};
});
@@ -107,9 +104,27 @@ 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(() => {
// Note: We intentionally avoid vi.restoreAllMocks() here because it clears the top-level
// vi.mock('@google/gemini-cli-core') entirely, making its un-exported constants (like GEMINI_DIR)
// undefined in subsequent tests. Call counts are still reset by clearAllMocks() in beforeEach.
process.env = originalEnv;
process.argv = originalArgv;
});
@@ -122,30 +137,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<typeof spawn>,
);
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 +179,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<typeof spawn>;
}
return new EventEmitter() as unknown as ReturnType<typeof spawn>; // 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<typeof spawn>;
});
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<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
>;
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<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>;
});
vi.mocked(
SandboxOrchestrator.ensureSandboxImageIsPresent,
).mockResolvedValueOnce(false);
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
});
@@ -325,51 +340,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<typeof spawn>;
});
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 +366,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<typeof spawn>;
});
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 +402,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<typeof spawn>;
});
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 +419,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;
}
});
});
});
});
+117 -220
View File
@@ -1,16 +1,20 @@
/**
* @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,
spawnSync,
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 +22,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 +36,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<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(
config: SandboxConfig,
@@ -70,26 +96,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 +122,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 +180,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 +205,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 +273,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 +290,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 +418,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 +466,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 +639,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 +693,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}`);
try {
spawnSync(config.command, ['rm', '-f', SANDBOX_PROXY_NAME]);
} catch {
// ignore
}
};
process.off('exit', stopProxy);
process.on('exit', stopProxy);
process.off('SIGINT', stopProxy);
@@ -681,18 +719,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 +760,3 @@ export async function start_sandbox(
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
}
-3
View File
@@ -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',
+1
View File
@@ -431,6 +431,7 @@ export enum AuthProviderType {
export interface SandboxConfig {
command: 'docker' | 'podman' | 'sandbox-exec';
image: string;
flags?: string;
}
/**
+1
View File
@@ -112,6 +112,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';
+9 -3
View File
@@ -29,6 +29,7 @@ import { PolicyDecision } from '../policy/types.js';
import {
ToolConfirmationOutcome,
type AnyDeclarativeTool,
Kind,
} from '../tools/tools.js';
import { getToolSuggestion } from '../utils/tool-utils.js';
import { runInDevTraceSpan } from '../telemetry/trace.js';
@@ -427,11 +428,11 @@ export class Scheduler {
return true;
}
// If the first tool is read-only, batch all contiguous read-only tools.
if (next.tool?.isReadOnly) {
// If the first tool is parallelizable, batch all contiguous parallelizable tools.
if (this._isParallelizable(next.tool)) {
while (this.state.queueLength > 0) {
const peeked = this.state.peekQueue();
if (peeked && peeked.tool?.isReadOnly) {
if (peeked && this._isParallelizable(peeked.tool)) {
this.state.dequeue();
} else {
break;
@@ -516,6 +517,11 @@ export class Scheduler {
return false;
}
private _isParallelizable(tool?: AnyDeclarativeTool): boolean {
if (!tool) return false;
return tool.isReadOnly || tool.kind === Kind.Agent;
}
private async _processValidatingCall(
active: ValidatingToolCall,
signal: AbortSignal,
@@ -70,6 +70,7 @@ import { ApprovalMode, PolicyDecision } from '../policy/types.js';
import {
type AnyDeclarativeTool,
type AnyToolInvocation,
Kind,
} from '../tools/tools.js';
import type {
ToolCallRequestInfo,
@@ -124,18 +125,51 @@ describe('Scheduler Parallel Execution', () => {
schedulerId: ROOT_SCHEDULER_ID,
};
const agentReq1: ToolCallRequestInfo = {
callId: 'agent-1',
name: 'agent-tool-1',
args: { query: 'do thing 1' },
isClientInitiated: false,
prompt_id: 'p1',
schedulerId: ROOT_SCHEDULER_ID,
};
const agentReq2: ToolCallRequestInfo = {
callId: 'agent-2',
name: 'agent-tool-2',
args: { query: 'do thing 2' },
isClientInitiated: false,
prompt_id: 'p1',
schedulerId: ROOT_SCHEDULER_ID,
};
const readTool1 = {
name: 'read-tool-1',
kind: Kind.Read,
isReadOnly: true,
build: vi.fn(),
} as unknown as AnyDeclarativeTool;
const readTool2 = {
name: 'read-tool-2',
kind: Kind.Read,
isReadOnly: true,
build: vi.fn(),
} as unknown as AnyDeclarativeTool;
const writeTool = {
name: 'write-tool',
kind: Kind.Execute,
isReadOnly: false,
build: vi.fn(),
} as unknown as AnyDeclarativeTool;
const agentTool1 = {
name: 'agent-tool-1',
kind: Kind.Agent,
isReadOnly: false,
build: vi.fn(),
} as unknown as AnyDeclarativeTool;
const agentTool2 = {
name: 'agent-tool-2',
kind: Kind.Agent,
isReadOnly: false,
build: vi.fn(),
} as unknown as AnyDeclarativeTool;
@@ -160,11 +194,19 @@ describe('Scheduler Parallel Execution', () => {
if (name === 'read-tool-1') return readTool1;
if (name === 'read-tool-2') return readTool2;
if (name === 'write-tool') return writeTool;
if (name === 'agent-tool-1') return agentTool1;
if (name === 'agent-tool-2') return agentTool2;
return undefined;
}),
getAllToolNames: vi
.fn()
.mockReturnValue(['read-tool-1', 'read-tool-2', 'write-tool']),
.mockReturnValue([
'read-tool-1',
'read-tool-2',
'write-tool',
'agent-tool-1',
'agent-tool-2',
]),
} as unknown as Mocked<ToolRegistry>;
mockConfig = {
@@ -279,6 +321,12 @@ describe('Scheduler Parallel Execution', () => {
vi.mocked(writeTool.build).mockReturnValue(
mockInvocation as unknown as AnyToolInvocation,
);
vi.mocked(agentTool1.build).mockReturnValue(
mockInvocation as unknown as AnyToolInvocation,
);
vi.mocked(agentTool2.build).mockReturnValue(
mockInvocation as unknown as AnyToolInvocation,
);
});
afterEach(() => {
@@ -418,4 +466,41 @@ describe('Scheduler Parallel Execution', () => {
expect(executionLog.indexOf('start-call-4')).toBeGreaterThan(end3);
expect(executionLog.indexOf('start-call-5')).toBeGreaterThan(end3);
});
it('should execute [Agent, Agent, Sequential, Parallelizable] in three waves', async () => {
const executionLog: string[] = [];
mockExecutor.execute.mockImplementation(async ({ call }) => {
const id = call.request.callId;
executionLog.push(`start-${id}`);
await new Promise<void>((resolve) => setTimeout(resolve, 10));
executionLog.push(`end-${id}`);
return {
status: 'success',
response: { callId: id, responseParts: [] },
} as unknown as SuccessfulToolCall;
});
// Schedule: agentReq1 (Parallel), agentReq2 (Parallel), req3 (Sequential/Write), req1 (Parallel/Read)
await scheduler.schedule([agentReq1, agentReq2, req3, req1], signal);
// Wave 1: agent-1, agent-2 (parallel)
expect(executionLog.slice(0, 2)).toContain('start-agent-1');
expect(executionLog.slice(0, 2)).toContain('start-agent-2');
// Both agents must end before anything else starts
const endAgent1 = executionLog.indexOf('end-agent-1');
const endAgent2 = executionLog.indexOf('end-agent-2');
const wave1End = Math.max(endAgent1, endAgent2);
// Wave 2: call-3 (sequential/write)
const start3 = executionLog.indexOf('start-call-3');
const end3 = executionLog.indexOf('end-call-3');
expect(start3).toBeGreaterThan(wave1End);
expect(end3).toBeGreaterThan(start3);
// Wave 3: call-1 (parallelizable/read)
const start1 = executionLog.indexOf('start-call-1');
expect(start1).toBeGreaterThan(end3);
});
});
+5
View File
@@ -6,3 +6,8 @@
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';
export const GEMINI_DIR = '.gemini';
+2 -2
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { expand } from 'dotenv-expand';
import { expand, type DotenvExpandOutput } from 'dotenv-expand';
/**
* Expands environment variables in a string using the provided environment record.
@@ -45,7 +45,7 @@ export function expandEnvVars(
}
}
const result = expand({
const result: DotenvExpandOutput = expand({
parsed: { [dummyKey]: processedStr },
processEnv,
});
@@ -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;
}
}
}
+1 -2
View File
@@ -8,10 +8,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GeminiCliAgent } from './agent.js';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __dirname = path.dirname(__filename);
// Set this to true locally when you need to update snapshots
const RECORD_MODE = process.env['RECORD_NEW_RESPONSES'] === 'true';
+5 -2
View File
@@ -4,8 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config as CoreConfig } from '@google/gemini-cli-core';
import { ShellExecutionService, ShellTool } from '@google/gemini-cli-core';
import {
ShellExecutionService,
ShellTool,
type Config as CoreConfig,
} from '@google/gemini-cli-core';
import type {
AgentShell,
AgentShellResult,
+1 -2
View File
@@ -9,10 +9,9 @@ import { GeminiCliAgent } from './agent.js';
import { skillDir } from './skills.js';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __dirname = path.dirname(__filename);
// Set this to true locally when you need to update snapshots
const RECORD_MODE = process.env['RECORD_NEW_RESPONSES'] === 'true';
+1 -2
View File
@@ -10,10 +10,9 @@ import * as path from 'node:path';
import { z } from 'zod';
import { tool, ModelVisibleError } from './tool.js';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __dirname = path.dirname(__filename);
// Set this to true locally when you need to update snapshots
const RECORD_MODE = process.env['RECORD_NEW_RESPONSES'] === 'true';
+1 -2
View File
@@ -6,13 +6,12 @@
import { expect } from 'vitest';
import { execSync, spawn, type ChildProcess } from 'node:child_process';
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import fs, { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { env } from 'node:process';
import { setTimeout as sleep } from 'node:timers/promises';
import { DEFAULT_GEMINI_MODEL, GEMINI_DIR } from '@google/gemini-cli-core';
import fs from 'node:fs';
import * as pty from '@lydell/node-pty';
import stripAnsi from 'strip-ansi';
import * as os from 'node:os';
+7
View File
@@ -1281,6 +1281,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.",