mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-29 11:10:59 -07:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edd77168f5 | |||
| 08ee136132 | |||
| 8b133b9c0d | |||
| 5ec55fc718 | |||
| f4e7d4159a | |||
| 5b08f0644d | |||
| 9a6a049ed8 |
+14
-4
@@ -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:
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -11,50 +11,6 @@ Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses default placeholder when not provided 2`] = `
|
||||
"
|
||||
ERROR Cannot read properties of undefined (reading '$$typeof')
|
||||
|
||||
/Users/spencertang/Workspace/gemini-cli/node_modules/react/cjs/react.developme
|
||||
nt.js:1208:15
|
||||
|
||||
1205: };
|
||||
1206: exports.useContext = function (Context) {
|
||||
1207: var dispatcher = resolveDispatcher();
|
||||
1208: Context.$$typeof === REACT_CONSUMER_TYPE &&
|
||||
1209: console.error(
|
||||
1210 "Calling useContext(Context.Consumer) is not supported and will
|
||||
: cause bugs. Did you mean to call useContext(Context) instead?"
|
||||
1211: );
|
||||
|
||||
-process.env.NODE_ENV.expo
|
||||
ts.useContext (/Users/spencertang/Workspace/gemini-cli/node_module
|
||||
s/react/cjs/react.development.js:1208:15)
|
||||
- useTerminalCapabilities (src/ui/hooks/useTerminalCapabilities.ts:15:19)
|
||||
- useAlternateBuffer (src/ui/hooks/useAlternateBuffer.ts:15:24)
|
||||
- ChoiceQuestionView (src/ui/components/AskUserDialog.tsx:484:29)
|
||||
-Object.react-stack-bot
|
||||
om-frame (/Users/spencertang/Workspace/gemini-cli/node_modules/r
|
||||
eact-reconciler/cjs/react-reconciler.development.js:158
|
||||
59:20)
|
||||
-renderWithHo
|
||||
ks (/Users/spencertang/Workspace/gemini-cli/node_modules/react-recon
|
||||
ciler/cjs/react-reconciler.development.js:3221:22)
|
||||
-updateFunctionCom
|
||||
onent (/Users/spencertang/Workspace/gemini-cli/node_modules/react-
|
||||
reconciler/cjs/react-reconciler.development.js:6475:19)
|
||||
-beginWor
|
||||
(/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconcile
|
||||
r/cjs/react-reconciler.development.js:8009:18)
|
||||
-runWithFiberIn
|
||||
EV (/Users/spencertang/Workspace/gemini-cli/node_modules/react-rec
|
||||
onciler/cjs/react-reconciler.development.js:1738:13)
|
||||
-performUnitOfW
|
||||
rk (/Users/spencertang/Workspace/gemini-cli/node_modules/react-rec
|
||||
onciler/cjs/react-reconciler.development.js:12834:22)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = `
|
||||
"Select your preferred language:
|
||||
|
||||
@@ -66,50 +22,6 @@ Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 2`] = `
|
||||
"
|
||||
ERROR Cannot read properties of undefined (reading '$$typeof')
|
||||
|
||||
/Users/spencertang/Workspace/gemini-cli/node_modules/react/cjs/react.developme
|
||||
nt.js:1208:15
|
||||
|
||||
1205: };
|
||||
1206: exports.useContext = function (Context) {
|
||||
1207: var dispatcher = resolveDispatcher();
|
||||
1208: Context.$$typeof === REACT_CONSUMER_TYPE &&
|
||||
1209: console.error(
|
||||
1210 "Calling useContext(Context.Consumer) is not supported and will
|
||||
: cause bugs. Did you mean to call useContext(Context) instead?"
|
||||
1211: );
|
||||
|
||||
-process.env.NODE_ENV.expo
|
||||
ts.useContext (/Users/spencertang/Workspace/gemini-cli/node_module
|
||||
s/react/cjs/react.development.js:1208:15)
|
||||
- useTerminalCapabilities (src/ui/hooks/useTerminalCapabilities.ts:15:19)
|
||||
- useAlternateBuffer (src/ui/hooks/useAlternateBuffer.ts:15:24)
|
||||
- ChoiceQuestionView (src/ui/components/AskUserDialog.tsx:484:29)
|
||||
-Object.react-stack-bot
|
||||
om-frame (/Users/spencertang/Workspace/gemini-cli/node_modules/r
|
||||
eact-reconciler/cjs/react-reconciler.development.js:158
|
||||
59:20)
|
||||
-renderWithHo
|
||||
ks (/Users/spencertang/Workspace/gemini-cli/node_modules/react-recon
|
||||
ciler/cjs/react-reconciler.development.js:3221:22)
|
||||
-updateFunctionCom
|
||||
onent (/Users/spencertang/Workspace/gemini-cli/node_modules/react-
|
||||
reconciler/cjs/react-reconciler.development.js:6475:19)
|
||||
-beginWor
|
||||
(/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconcile
|
||||
r/cjs/react-reconciler.development.js:8009:18)
|
||||
-runWithFiberIn
|
||||
EV (/Users/spencertang/Workspace/gemini-cli/node_modules/react-rec
|
||||
onciler/cjs/react-reconciler.development.js:1738:13)
|
||||
-performUnitOfW
|
||||
rk (/Users/spencertang/Workspace/gemini-cli/node_modules/react-rec
|
||||
onciler/cjs/react-reconciler.development.js:12834:22)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
|
||||
"Choose an option
|
||||
|
||||
@@ -163,48 +75,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 2`] = `
|
||||
"
|
||||
ERROR Cannot read properties of undefined (reading '$$typeof')
|
||||
|
||||
/Users/spencertang/Workspace/gemini-cli/node_modules/react/cjs/react.development.js:1208:15
|
||||
|
||||
1205: };
|
||||
1206: exports.useContext = function (Context) {
|
||||
1207: var dispatcher = resolveDispatcher();
|
||||
1208: Context.$$typeof === REACT_CONSUMER_TYPE &&
|
||||
1209: console.error(
|
||||
1210 "Calling useContext(Context.Consumer) is not supported and will cause bugs. Did you
|
||||
: mean to call useContext(Context) instead?"
|
||||
1211: );
|
||||
|
||||
-process.env.NODE_ENV.exports
|
||||
useContext (/Users/spencertang/Workspace/gemini-cli/node_modules/react/cjs/react
|
||||
.development.js:1208:15)
|
||||
- useTerminalCapabilities (src/ui/hooks/useTerminalCapabilities.ts:15:19)
|
||||
- useAlternateBuffer (src/ui/hooks/useAlternateBuffer.ts:15:24)
|
||||
- ChoiceQuestionView (src/ui/components/AskUserDialog.tsx:484:29)
|
||||
-Object.react-stack-bott
|
||||
m-frame (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs
|
||||
/react-reconciler.development.js:15859:20)
|
||||
-renderWithHoo
|
||||
s (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-rec
|
||||
onciler.development.js:3221:22)
|
||||
-updateFunctionComp
|
||||
nent (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/reac
|
||||
t-reconciler.development.js:6475:19)
|
||||
-beginWor
|
||||
(/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil
|
||||
er.development.js:8009:18)
|
||||
-runWithFiberIn
|
||||
EV (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-re
|
||||
conciler.development.js:1738:13)
|
||||
-performUnitOfW
|
||||
rk (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-re
|
||||
conciler.development.js:12834:22)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
|
||||
"What should we name this component?
|
||||
|
||||
@@ -331,45 +201,3 @@ README → (not answered)
|
||||
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 2`] = `
|
||||
"
|
||||
ERROR Cannot read properties of undefined (reading '$$typeof')
|
||||
|
||||
/Users/spencertang/Workspace/gemini-cli/node_modules/react/cjs/react.development.js:1208:15
|
||||
|
||||
1205: };
|
||||
1206: exports.useContext = function (Context) {
|
||||
1207: var dispatcher = resolveDispatcher();
|
||||
1208: Context.$$typeof === REACT_CONSUMER_TYPE &&
|
||||
1209: console.error(
|
||||
1210: "Calling useContext(Context.Consumer) is not supported and will cause bugs. Did you mean to call
|
||||
useContext(Context) instead?"
|
||||
1211: );
|
||||
|
||||
-process.env.NODE_ENV.exports.useCo
|
||||
text (/Users/spencertang/Workspace/gemini-cli/node_modules/react/cjs/react.development.j
|
||||
s:1208:15)
|
||||
- useTerminalCapabilities (src/ui/hooks/useTerminalCapabilities.ts:15:19)
|
||||
- useAlternateBuffer (src/ui/hooks/useAlternateBuffer.ts:15:24)
|
||||
- ChoiceQuestionView (src/ui/components/AskUserDialog.tsx:484:29)
|
||||
-Object.react-stack-botto
|
||||
-frame (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.d
|
||||
evelopment.js:15859:20)
|
||||
-renderWithHook
|
||||
(/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development
|
||||
.js:3221:22)
|
||||
-updateFunctionCompo
|
||||
ent (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develo
|
||||
pment.js:6475:19)
|
||||
-beginWork
|
||||
(/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:8
|
||||
009:18)
|
||||
-runWithFiberInD
|
||||
V (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developmen
|
||||
t.js:1738:13)
|
||||
-performUnitOfWo
|
||||
k (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developmen
|
||||
t.js:12834:22)
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -6,51 +6,6 @@ Spinner Initializing...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > handles empty clients map 2`] = `
|
||||
"
|
||||
ERROR Cannot read properties of undefined (reading 'isKittyProtocolEnabled')
|
||||
|
||||
src/ui/contexts/KeypressContext.tsx:797:36
|
||||
|
||||
794: process.stdin.setEncoding('utf8'); // Make data events emit strings
|
||||
795:
|
||||
796: let processor = nonKeyboardEventFilter(broadcast);
|
||||
797: if (!terminalCapabilityManager.isKittyProtocolEnabled()) {
|
||||
798: processor = bufferFastReturn(processor);
|
||||
799: }
|
||||
800: processor = bufferBackslashEnter(processor);
|
||||
|
||||
- (src/ui/contexts/KeypressContext.tsx:797:36)
|
||||
-Object.react-stack-bott
|
||||
m-frame (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs
|
||||
/react-reconciler.development.js:15945:20)
|
||||
-runWithFiberIn
|
||||
EV (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-re
|
||||
conciler.development.js:1738:13)
|
||||
-commitHookEffectList
|
||||
ount (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
|
||||
act-reconciler.development.js:9516:29)
|
||||
-commitHookPassiveMount
|
||||
ffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/
|
||||
react-reconciler.development.js:9639:11)
|
||||
-commitPassiveMountOn
|
||||
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
|
||||
act-reconciler.development.js:11364:13)
|
||||
-recursivelyTraversePassiveM
|
||||
untEffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler
|
||||
/cjs/react-reconciler.development.js:11338:11)
|
||||
-commitPassiveMountOn
|
||||
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
|
||||
act-reconciler.development.js:11479:11)
|
||||
-recursivelyTraversePassiveM
|
||||
untEffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler
|
||||
/cjs/react-reconciler.development.js:11338:11)
|
||||
-commitPassiveMountOn
|
||||
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
|
||||
act-reconciler.development.js:11357:11)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > renders initial state 1`] = `
|
||||
"
|
||||
Spinner Initializing...
|
||||
@@ -63,98 +18,8 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
|
||||
"
|
||||
ERROR Cannot read properties of undefined (reading 'isKittyProtocolEnabled')
|
||||
|
||||
src/ui/contexts/KeypressContext.tsx:797:36
|
||||
|
||||
794: process.stdin.setEncoding('utf8'); // Make data events emit strings
|
||||
795:
|
||||
796: let processor = nonKeyboardEventFilter(broadcast);
|
||||
797: if (!terminalCapabilityManager.isKittyProtocolEnabled()) {
|
||||
798: processor = bufferFastReturn(processor);
|
||||
799: }
|
||||
800: processor = bufferBackslashEnter(processor);
|
||||
|
||||
- (src/ui/contexts/KeypressContext.tsx:797:36)
|
||||
-Object.react-stack-bott
|
||||
m-frame (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs
|
||||
/react-reconciler.development.js:15945:20)
|
||||
-runWithFiberIn
|
||||
EV (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-re
|
||||
conciler.development.js:1738:13)
|
||||
-commitHookEffectList
|
||||
ount (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
|
||||
act-reconciler.development.js:9516:29)
|
||||
-commitHookPassiveMount
|
||||
ffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/
|
||||
react-reconciler.development.js:9639:11)
|
||||
-commitPassiveMountOn
|
||||
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
|
||||
act-reconciler.development.js:11364:13)
|
||||
-recursivelyTraversePassiveM
|
||||
untEffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler
|
||||
/cjs/react-reconciler.development.js:11338:11)
|
||||
-commitPassiveMountOn
|
||||
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
|
||||
act-reconciler.development.js:11479:11)
|
||||
-recursivelyTraversePassiveM
|
||||
untEffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler
|
||||
/cjs/react-reconciler.development.js:11338:11)
|
||||
-commitPassiveMountOn
|
||||
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
|
||||
act-reconciler.development.js:11357:11)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
|
||||
"
|
||||
ERROR Cannot read properties of undefined (reading 'isKittyProtocolEnabled')
|
||||
|
||||
src/ui/contexts/KeypressContext.tsx:797:36
|
||||
|
||||
794: process.stdin.setEncoding('utf8'); // Make data events emit strings
|
||||
795:
|
||||
796: let processor = nonKeyboardEventFilter(broadcast);
|
||||
797: if (!terminalCapabilityManager.isKittyProtocolEnabled()) {
|
||||
798: processor = bufferFastReturn(processor);
|
||||
799: }
|
||||
800: processor = bufferBackslashEnter(processor);
|
||||
|
||||
- (src/ui/contexts/KeypressContext.tsx:797:36)
|
||||
-Object.react-stack-bott
|
||||
m-frame (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs
|
||||
/react-reconciler.development.js:15945:20)
|
||||
-runWithFiberIn
|
||||
EV (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-re
|
||||
conciler.development.js:1738:13)
|
||||
-commitHookEffectList
|
||||
ount (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
|
||||
act-reconciler.development.js:9516:29)
|
||||
-commitHookPassiveMount
|
||||
ffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/
|
||||
react-reconciler.development.js:9639:11)
|
||||
-commitPassiveMountOn
|
||||
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
|
||||
act-reconciler.development.js:11364:13)
|
||||
-recursivelyTraversePassiveM
|
||||
untEffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler
|
||||
/cjs/react-reconciler.development.js:11338:11)
|
||||
-commitPassiveMountOn
|
||||
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
|
||||
act-reconciler.development.js:11479:11)
|
||||
-recursivelyTraversePassiveM
|
||||
untEffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler
|
||||
/cjs/react-reconciler.development.js:11338:11)
|
||||
-commitPassiveMountOn
|
||||
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
|
||||
act-reconciler.development.js:11357:11)
|
||||
"
|
||||
`;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { TerminalCapabilityManager } from './terminalCapabilityManager.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import {
|
||||
enableKittyKeyboardProtocol,
|
||||
enableModifyOtherKeys,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
// Mock fs
|
||||
vi.mock('node:fs', () => ({
|
||||
writeSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock core
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
enableKittyKeyboardProtocol: vi.fn(),
|
||||
disableKittyKeyboardProtocol: vi.fn(),
|
||||
enableModifyOtherKeys: vi.fn(),
|
||||
disableModifyOtherKeys: vi.fn(),
|
||||
enableBracketedPasteMode: vi.fn(),
|
||||
disableBracketedPasteMode: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('TerminalCapabilityManager', () => {
|
||||
let stdin: EventEmitter & {
|
||||
isTTY?: boolean;
|
||||
isRaw?: boolean;
|
||||
setRawMode?: (mode: boolean) => void;
|
||||
removeListener?: (
|
||||
event: string,
|
||||
listener: (...args: unknown[]) => void,
|
||||
) => void;
|
||||
};
|
||||
let stdout: { isTTY?: boolean; fd?: number };
|
||||
// Save original process properties
|
||||
const originalStdin = process.stdin;
|
||||
const originalStdout = process.stdout;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Reset singleton
|
||||
TerminalCapabilityManager.resetInstanceForTesting();
|
||||
|
||||
// Setup process mocks
|
||||
stdin = new EventEmitter();
|
||||
stdin.isTTY = true;
|
||||
stdin.isRaw = false;
|
||||
stdin.setRawMode = vi.fn();
|
||||
stdin.removeListener = vi.fn();
|
||||
|
||||
stdout = { isTTY: true, fd: 1 };
|
||||
|
||||
// Use defineProperty to mock process.stdin/stdout
|
||||
Object.defineProperty(process, 'stdin', {
|
||||
value: stdin,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process, 'stdout', {
|
||||
value: stdout,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
// Restore original process properties
|
||||
Object.defineProperty(process, 'stdin', {
|
||||
value: originalStdin,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process, 'stdout', {
|
||||
value: originalStdout,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect Kitty support when u response is received', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
const promise = manager.detectCapabilities();
|
||||
|
||||
// Simulate Kitty response: \x1b[?1u
|
||||
stdin.emit('data', Buffer.from('\x1b[?1u'));
|
||||
// Complete detection with DA1
|
||||
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||
|
||||
await promise;
|
||||
expect(manager.isKittyProtocolEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Background Color', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
const promise = manager.detectCapabilities();
|
||||
|
||||
// Simulate OSC 11 response
|
||||
// \x1b]11;rgb:0000/ff00/0000\x1b\
|
||||
// RGB: 0, 255, 0 -> #00ff00
|
||||
stdin.emit('data', Buffer.from('\x1b]11;rgb:0000/ffff/0000\x1b\\'));
|
||||
// Complete detection with DA1
|
||||
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||
|
||||
await promise;
|
||||
expect(manager.getTerminalBackgroundColor()).toBe('#00ff00');
|
||||
});
|
||||
|
||||
it('should detect Terminal Name', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
const promise = manager.detectCapabilities();
|
||||
|
||||
// Simulate Terminal Name response
|
||||
stdin.emit('data', Buffer.from('\x1bP>|WezTerm 20240203\x1b\\'));
|
||||
// Complete detection with DA1
|
||||
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||
|
||||
await promise;
|
||||
expect(manager.getTerminalName()).toBe('WezTerm 20240203');
|
||||
});
|
||||
|
||||
it('should complete early if sentinel (DA1) is found', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
const promise = manager.detectCapabilities();
|
||||
|
||||
stdin.emit('data', Buffer.from('\x1b[?1u'));
|
||||
stdin.emit('data', Buffer.from('\x1b]11;rgb:0000/0000/0000\x1b\\'));
|
||||
// Sentinel
|
||||
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||
|
||||
// Should resolve without waiting for timeout
|
||||
await promise;
|
||||
|
||||
expect(manager.isKittyProtocolEnabled()).toBe(true);
|
||||
expect(manager.getTerminalBackgroundColor()).toBe('#000000');
|
||||
});
|
||||
|
||||
it('should timeout if no DA1 (c) is received', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
const promise = manager.detectCapabilities();
|
||||
|
||||
// Simulate only Kitty response
|
||||
stdin.emit('data', Buffer.from('\x1b[?1u'));
|
||||
|
||||
// Advance to timeout
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
await promise;
|
||||
expect(manager.isKittyProtocolEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect Kitty if only DA1 (c) is received', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
const promise = manager.detectCapabilities();
|
||||
|
||||
// Simulate DA1 response only: \x1b[?62;c
|
||||
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||
|
||||
await promise;
|
||||
expect(manager.isKittyProtocolEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle split chunks', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
const promise = manager.detectCapabilities();
|
||||
|
||||
// Split response: \x1b[? 1u
|
||||
stdin.emit('data', Buffer.from('\x1b[?'));
|
||||
stdin.emit('data', Buffer.from('1u'));
|
||||
// Complete with DA1
|
||||
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||
|
||||
await promise;
|
||||
expect(manager.isKittyProtocolEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
describe('modifyOtherKeys detection', () => {
|
||||
it('should detect modifyOtherKeys support (level 2)', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
const promise = manager.detectCapabilities();
|
||||
|
||||
// Simulate modifyOtherKeys level 2 response: \x1b[>4;2m
|
||||
stdin.emit('data', Buffer.from('\x1b[>4;2m'));
|
||||
// Complete detection with DA1
|
||||
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||
|
||||
await promise;
|
||||
|
||||
expect(enableModifyOtherKeys).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not enable modifyOtherKeys for level 0', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
const promise = manager.detectCapabilities();
|
||||
|
||||
// Simulate modifyOtherKeys level 0 response: \x1b[>4;0m
|
||||
stdin.emit('data', Buffer.from('\x1b[>4;0m'));
|
||||
// Complete detection with DA1
|
||||
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||
|
||||
await promise;
|
||||
|
||||
expect(enableModifyOtherKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer Kitty over modifyOtherKeys', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
const promise = manager.detectCapabilities();
|
||||
|
||||
// Simulate both Kitty and modifyOtherKeys responses
|
||||
stdin.emit('data', Buffer.from('\x1b[?1u'));
|
||||
stdin.emit('data', Buffer.from('\x1b[>4;2m'));
|
||||
// Complete detection with DA1
|
||||
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||
|
||||
await promise;
|
||||
expect(manager.isKittyProtocolEnabled()).toBe(true);
|
||||
|
||||
expect(enableKittyKeyboardProtocol).toHaveBeenCalled();
|
||||
expect(enableModifyOtherKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should enable modifyOtherKeys when Kitty not supported', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
const promise = manager.detectCapabilities();
|
||||
|
||||
// Simulate only modifyOtherKeys response (no Kitty)
|
||||
stdin.emit('data', Buffer.from('\x1b[>4;2m'));
|
||||
// Complete detection with DA1
|
||||
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||
|
||||
await promise;
|
||||
|
||||
expect(manager.isKittyProtocolEnabled()).toBe(false);
|
||||
expect(enableModifyOtherKeys).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle split modifyOtherKeys response chunks', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
const promise = manager.detectCapabilities();
|
||||
|
||||
// Split response: \x1b[>4;2m
|
||||
stdin.emit('data', Buffer.from('\x1b[>4;'));
|
||||
stdin.emit('data', Buffer.from('2m'));
|
||||
// Complete detection with DA1
|
||||
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||
|
||||
await promise;
|
||||
|
||||
expect(enableModifyOtherKeys).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should detect modifyOtherKeys with other capabilities', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
const promise = manager.detectCapabilities();
|
||||
|
||||
stdin.emit('data', Buffer.from('\x1b]11;rgb:1a1a/1a1a/1a1a\x1b\\')); // background color
|
||||
stdin.emit('data', Buffer.from('\x1bP>|tmux\x1b\\')); // Terminal name
|
||||
stdin.emit('data', Buffer.from('\x1b[>4;2m')); // modifyOtherKeys
|
||||
// Complete detection with DA1
|
||||
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||
|
||||
await promise;
|
||||
|
||||
expect(manager.getTerminalBackgroundColor()).toBe('#1a1a1a');
|
||||
expect(manager.getTerminalName()).toBe('tmux');
|
||||
|
||||
expect(enableModifyOtherKeys).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not enable modifyOtherKeys without explicit response', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
const promise = manager.detectCapabilities();
|
||||
|
||||
// Simulate only DA1 response (no specific MOK or Kitty response)
|
||||
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||
|
||||
await promise;
|
||||
|
||||
expect(manager.isKittyProtocolEnabled()).toBe(false);
|
||||
expect(enableModifyOtherKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should wrap queries in hidden/clear sequence', async () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
void manager.detectCapabilities();
|
||||
|
||||
expect(fs.writeSync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
// eslint-disable-next-line no-control-regex
|
||||
expect.stringMatching(/^\x1b\[8m.*\x1b\[2K\r\x1b\[0m$/s),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsOsc9Notifications', () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'WezTerm (terminal name)',
|
||||
terminalName: 'WezTerm',
|
||||
env: {},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'iTerm.app (terminal name)',
|
||||
terminalName: 'iTerm.app',
|
||||
env: {},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'ghostty (terminal name)',
|
||||
terminalName: 'ghostty',
|
||||
env: {},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'kitty (terminal name)',
|
||||
terminalName: 'kitty',
|
||||
env: {},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'some-other-term (terminal name)',
|
||||
terminalName: 'some-other-term',
|
||||
env: {},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'iTerm.app (TERM_PROGRAM)',
|
||||
terminalName: undefined,
|
||||
env: { TERM_PROGRAM: 'iTerm.app' },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'vscode (TERM_PROGRAM)',
|
||||
terminalName: undefined,
|
||||
env: { TERM_PROGRAM: 'vscode' },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'xterm-kitty (TERM)',
|
||||
terminalName: undefined,
|
||||
env: { TERM: 'xterm-kitty' },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'xterm-256color (TERM)',
|
||||
terminalName: undefined,
|
||||
env: { TERM: 'xterm-256color' },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'Windows Terminal (WT_SESSION)',
|
||||
terminalName: 'iTerm.app',
|
||||
env: { WT_SESSION: 'some-guid' },
|
||||
expected: false,
|
||||
},
|
||||
])(
|
||||
'should return $expected for $name',
|
||||
({ terminalName, env, expected }) => {
|
||||
vi.spyOn(manager, 'getTerminalName').mockReturnValue(terminalName);
|
||||
expect(manager.supportsOsc9Notifications(env)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -84,7 +84,6 @@ describe('SubAgentInvocation', () => {
|
||||
params: {},
|
||||
getDescription: vi.fn(),
|
||||
toolLocations: vi.fn(),
|
||||
isSensitive: false,
|
||||
};
|
||||
|
||||
MockSubagentToolWrapper.prototype.build = vi
|
||||
|
||||
@@ -431,6 +431,7 @@ export enum AuthProviderType {
|
||||
export interface SandboxConfig {
|
||||
command: 'docker' | 'podman' | 'sandbox-exec';
|
||||
image: string;
|
||||
flags?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -121,7 +121,6 @@ export interface UpdatePolicy {
|
||||
argsPattern?: string;
|
||||
commandPrefix?: string | string[];
|
||||
mcpName?: string;
|
||||
isSensitive?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolPolicyRejection {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -43,15 +43,7 @@ class ActivateSkillToolInvocation extends BaseToolInvocation<
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
) {
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
undefined,
|
||||
undefined,
|
||||
true, // ActivateSkill is always sensitive
|
||||
);
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
@@ -193,14 +185,13 @@ export class ActivateSkillTool extends BaseDeclarativeTool<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
_isSensitive?: boolean,
|
||||
): ToolInvocation<ActivateSkillToolParams, ToolResult> {
|
||||
return new ActivateSkillToolInvocation(
|
||||
this.config,
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
_toolDisplayName ?? 'Activate Skill',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -293,7 +293,6 @@ describe('AskUserTool', () => {
|
||||
getDescription: vi.fn().mockReturnValue(''),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(false),
|
||||
isSensitive: false,
|
||||
};
|
||||
|
||||
const buildSpy = vi.spyOn(tool, 'build').mockReturnValue(mockInvocation);
|
||||
|
||||
@@ -202,21 +202,6 @@ describe('EditTool', () => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should be marked as sensitive and pass the flag to its invocations', () => {
|
||||
// Check the tool definition itself
|
||||
expect(tool.isSensitive).toBe(true);
|
||||
|
||||
// Build an invocation and check the instance
|
||||
const params: EditToolParams = {
|
||||
file_path: path.join(rootDir, 'test.txt'),
|
||||
instruction: 'An instruction',
|
||||
old_string: 'old',
|
||||
new_string: 'new',
|
||||
};
|
||||
const invocation = tool.build(params);
|
||||
expect(invocation.isSensitive).toBe(true);
|
||||
});
|
||||
|
||||
describe('applyReplacement', () => {
|
||||
it('should return newString if isNewFile is true', () => {
|
||||
expect(applyReplacement(null, 'old', 'new', true)).toBe('new');
|
||||
|
||||
@@ -434,17 +434,8 @@ class EditToolInvocation
|
||||
messageBus: MessageBus,
|
||||
toolName?: string,
|
||||
displayName?: string,
|
||||
isSensitive?: boolean,
|
||||
) {
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
toolName,
|
||||
displayName,
|
||||
undefined,
|
||||
undefined,
|
||||
isSensitive,
|
||||
);
|
||||
super(params, messageBus, toolName, displayName);
|
||||
}
|
||||
|
||||
override toolLocations(): ToolLocation[] {
|
||||
@@ -965,9 +956,6 @@ export class EditTool
|
||||
messageBus,
|
||||
true, // isOutputMarkdown
|
||||
false, // canUpdateOutput
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1013,9 +1001,6 @@ export class EditTool
|
||||
protected createInvocation(
|
||||
params: EditToolParams,
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
): ToolInvocation<EditToolParams, ToolResult> {
|
||||
return new EditToolInvocation(
|
||||
this.config,
|
||||
@@ -1023,7 +1008,6 @@ export class EditTool
|
||||
messageBus,
|
||||
this.name,
|
||||
this.displayName,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,17 +79,8 @@ class GetInternalDocsInvocation extends BaseToolInvocation<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
) {
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
undefined,
|
||||
undefined,
|
||||
isSensitive,
|
||||
);
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
}
|
||||
|
||||
override async shouldConfirmExecute(
|
||||
@@ -174,9 +165,6 @@ export class GetInternalDocsTool extends BaseDeclarativeTool<
|
||||
messageBus,
|
||||
/* isOutputMarkdown */ true,
|
||||
/* canUpdateOutput */ false,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,14 +173,12 @@ export class GetInternalDocsTool extends BaseDeclarativeTool<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
): ToolInvocation<GetInternalDocsParams, ToolResult> {
|
||||
return new GetInternalDocsInvocation(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName ?? GetInternalDocsTool.Name,
|
||||
_toolDisplayName,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,17 +96,8 @@ class GlobToolInvocation extends BaseToolInvocation<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
) {
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
undefined,
|
||||
undefined,
|
||||
isSensitive,
|
||||
);
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
@@ -287,9 +278,6 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
|
||||
messageBus,
|
||||
true,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -340,7 +328,6 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
): ToolInvocation<GlobToolParams, ToolResult> {
|
||||
return new GlobToolInvocation(
|
||||
this.config,
|
||||
@@ -348,7 +335,6 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,17 +83,8 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
) {
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
undefined,
|
||||
undefined,
|
||||
isSensitive,
|
||||
);
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
this.fileExclusions = config.getFileExclusions();
|
||||
}
|
||||
|
||||
@@ -610,9 +601,6 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
messageBus,
|
||||
true,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -688,7 +676,6 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
): ToolInvocation<GrepToolParams, ToolResult> {
|
||||
return new GrepToolInvocation(
|
||||
this.config,
|
||||
@@ -696,7 +683,6 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -78,17 +78,8 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
) {
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
undefined,
|
||||
undefined,
|
||||
isSensitive,
|
||||
);
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,9 +293,6 @@ export class LSTool extends BaseDeclarativeTool<LSToolParams, ToolResult> {
|
||||
messageBus,
|
||||
true,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -328,15 +316,13 @@ export class LSTool extends BaseDeclarativeTool<LSToolParams, ToolResult> {
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
): ToolInvocation<LSToolParams, ToolResult> {
|
||||
return new LSToolInvocation(
|
||||
this.config,
|
||||
params,
|
||||
messageBus,
|
||||
messageBus ?? this.messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
|
||||
private readonly toolDescription?: string,
|
||||
private readonly toolParameterSchema?: unknown,
|
||||
toolAnnotationsData?: Record<string, unknown>,
|
||||
isSensitive: boolean = false,
|
||||
) {
|
||||
// Use composite format for policy checks: serverName__toolName
|
||||
// This enables server wildcards (e.g., "google-workspace__*")
|
||||
@@ -106,7 +105,6 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
|
||||
displayName,
|
||||
serverName,
|
||||
toolAnnotationsData,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -285,7 +283,6 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
|
||||
false, // canUpdateOutput,
|
||||
extensionName,
|
||||
extensionId,
|
||||
true, // isSensitive
|
||||
);
|
||||
this._isReadOnly = isReadOnly;
|
||||
}
|
||||
@@ -334,7 +331,6 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_displayName?: string,
|
||||
isSensitive?: boolean,
|
||||
): ToolInvocation<ToolParams, ToolResult> {
|
||||
return new DiscoveredMCPToolInvocation(
|
||||
this.mcpTool,
|
||||
@@ -348,7 +344,6 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
|
||||
this.description,
|
||||
this.parameterSchema,
|
||||
this._toolAnnotations,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,9 +285,6 @@ export class MemoryTool
|
||||
messageBus,
|
||||
true,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,17 +114,8 @@ class ReadManyFilesToolInvocation extends BaseToolInvocation<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
) {
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
undefined,
|
||||
undefined,
|
||||
isSensitive,
|
||||
);
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
@@ -483,9 +474,6 @@ export class ReadManyFilesTool extends BaseDeclarativeTool<
|
||||
messageBus,
|
||||
true, // isOutputMarkdown
|
||||
false, // canUpdateOutput
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -494,7 +482,6 @@ export class ReadManyFilesTool extends BaseDeclarativeTool<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
): ToolInvocation<ReadManyFilesParams, ToolResult> {
|
||||
return new ReadManyFilesToolInvocation(
|
||||
this.config,
|
||||
@@ -502,7 +489,6 @@ export class ReadManyFilesTool extends BaseDeclarativeTool<
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -167,17 +167,8 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
) {
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
undefined,
|
||||
undefined,
|
||||
isSensitive,
|
||||
);
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
@@ -593,9 +584,6 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
messageBus,
|
||||
true, // isOutputMarkdown
|
||||
false, // canUpdateOutput
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
this.fileDiscoveryService = new FileDiscoveryService(
|
||||
config.getTargetDir(),
|
||||
@@ -677,16 +665,14 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
): ToolInvocation<RipGrepToolParams, ToolResult> {
|
||||
return new GrepToolInvocation(
|
||||
this.config,
|
||||
this.fileDiscoveryService,
|
||||
params,
|
||||
messageBus,
|
||||
messageBus ?? this.messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,17 +68,8 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
) {
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
undefined,
|
||||
undefined,
|
||||
isSensitive,
|
||||
);
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
@@ -488,9 +479,6 @@ export class ShellTool extends BaseDeclarativeTool<
|
||||
messageBus,
|
||||
false, // output is not markdown
|
||||
true, // output can be updated
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -516,7 +504,6 @@ export class ShellTool extends BaseDeclarativeTool<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
): ToolInvocation<ShellToolParams, ToolResult> {
|
||||
return new ShellToolInvocation(
|
||||
this.config,
|
||||
@@ -524,7 +511,6 @@ export class ShellTool extends BaseDeclarativeTool<
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ class TestToolInvocation implements ToolInvocation<object, ToolResult> {
|
||||
constructor(
|
||||
readonly params: object,
|
||||
private readonly executeFn: () => Promise<ToolResult>,
|
||||
readonly isSensitive: boolean = false,
|
||||
) {}
|
||||
|
||||
getDescription(): string {
|
||||
|
||||
@@ -33,11 +33,6 @@ export interface ToolInvocation<
|
||||
*/
|
||||
params: TParams;
|
||||
|
||||
/**
|
||||
* Whether the tool is sensitive and requires specific policy approvals.
|
||||
*/
|
||||
isSensitive: boolean;
|
||||
|
||||
/**
|
||||
* Gets a pre-execution description of the tool operation.
|
||||
*
|
||||
@@ -80,7 +75,6 @@ export interface ToolInvocation<
|
||||
export interface PolicyUpdateOptions {
|
||||
commandPrefix?: string | string[];
|
||||
mcpName?: string;
|
||||
argsPattern?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,7 +92,6 @@ export abstract class BaseToolInvocation<
|
||||
readonly _toolDisplayName?: string,
|
||||
readonly _serverName?: string,
|
||||
readonly _toolAnnotations?: Record<string, unknown>,
|
||||
readonly isSensitive: boolean = false,
|
||||
) {}
|
||||
|
||||
abstract getDescription(): string;
|
||||
@@ -159,7 +152,6 @@ export abstract class BaseToolInvocation<
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: this._toolName,
|
||||
persist: outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
isSensitive: this.isSensitive,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -348,11 +340,6 @@ export interface ToolBuilder<
|
||||
*/
|
||||
isReadOnly: boolean;
|
||||
|
||||
/**
|
||||
* Whether the tool is sensitive and requires specific policy approvals.
|
||||
*/
|
||||
isSensitive: boolean;
|
||||
|
||||
/**
|
||||
* Validates raw parameters and builds a ready-to-execute invocation.
|
||||
* @param params The raw, untrusted parameters from the model.
|
||||
@@ -381,7 +368,6 @@ export abstract class DeclarativeTool<
|
||||
readonly canUpdateOutput: boolean = false,
|
||||
readonly extensionName?: string,
|
||||
readonly extensionId?: string,
|
||||
readonly isSensitive: boolean = false,
|
||||
) {}
|
||||
|
||||
get isReadOnly(): boolean {
|
||||
@@ -512,34 +498,6 @@ export abstract class BaseDeclarativeTool<
|
||||
TParams extends object,
|
||||
TResult extends ToolResult,
|
||||
> extends DeclarativeTool<TParams, TResult> {
|
||||
constructor(
|
||||
name: string,
|
||||
displayName: string,
|
||||
description: string,
|
||||
kind: Kind,
|
||||
parameterSchema: unknown,
|
||||
messageBus: MessageBus,
|
||||
isOutputMarkdown: boolean = true,
|
||||
canUpdateOutput: boolean = false,
|
||||
extensionName?: string,
|
||||
extensionId?: string,
|
||||
isSensitive: boolean = false,
|
||||
) {
|
||||
super(
|
||||
name,
|
||||
displayName,
|
||||
description,
|
||||
kind,
|
||||
parameterSchema,
|
||||
messageBus,
|
||||
isOutputMarkdown,
|
||||
canUpdateOutput,
|
||||
extensionName,
|
||||
extensionId,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
|
||||
build(params: TParams): ToolInvocation<TParams, TResult> {
|
||||
const validationError = this.validateToolParams(params);
|
||||
if (validationError) {
|
||||
@@ -550,7 +508,6 @@ export abstract class BaseDeclarativeTool<
|
||||
this.messageBus,
|
||||
this.name,
|
||||
this.displayName,
|
||||
this.isSensitive,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -576,7 +533,6 @@ export abstract class BaseDeclarativeTool<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
): ToolInvocation<TParams, TResult>;
|
||||
}
|
||||
|
||||
@@ -870,7 +826,6 @@ export enum ToolConfirmationOutcome {
|
||||
|
||||
export enum Kind {
|
||||
Read = 'read',
|
||||
Write = 'write',
|
||||
Edit = 'edit',
|
||||
Delete = 'delete',
|
||||
Move = 'move',
|
||||
@@ -887,7 +842,6 @@ export enum Kind {
|
||||
|
||||
// Function kinds that have side effects
|
||||
export const MUTATOR_KINDS: Kind[] = [
|
||||
Kind.Write,
|
||||
Kind.Edit,
|
||||
Kind.Delete,
|
||||
Kind.Move,
|
||||
|
||||
@@ -178,17 +178,8 @@ class WebFetchToolInvocation extends BaseToolInvocation<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
) {
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
undefined,
|
||||
undefined,
|
||||
isSensitive,
|
||||
);
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
}
|
||||
|
||||
private async executeFallback(signal: AbortSignal): Promise<ToolResult> {
|
||||
@@ -698,9 +689,6 @@ export class WebFetchTool extends BaseDeclarativeTool<
|
||||
messageBus,
|
||||
true, // isOutputMarkdown
|
||||
false, // canUpdateOutput
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -741,7 +729,6 @@ export class WebFetchTool extends BaseDeclarativeTool<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
): ToolInvocation<WebFetchToolParams, ToolResult> {
|
||||
return new WebFetchToolInvocation(
|
||||
this.config,
|
||||
@@ -749,7 +736,6 @@ export class WebFetchTool extends BaseDeclarativeTool<
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -71,17 +71,8 @@ class WebSearchToolInvocation extends BaseToolInvocation<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
) {
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
undefined,
|
||||
undefined,
|
||||
isSensitive,
|
||||
);
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
}
|
||||
|
||||
override getDescription(): string {
|
||||
@@ -217,9 +208,6 @@ export class WebSearchTool extends BaseDeclarativeTool<
|
||||
messageBus,
|
||||
true, // isOutputMarkdown
|
||||
false, // canUpdateOutput
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -242,7 +230,6 @@ export class WebSearchTool extends BaseDeclarativeTool<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
): ToolInvocation<WebSearchToolParams, WebSearchToolResult> {
|
||||
return new WebSearchToolInvocation(
|
||||
this.config,
|
||||
@@ -250,7 +237,6 @@ export class WebSearchTool extends BaseDeclarativeTool<
|
||||
messageBus ?? this.messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -136,17 +136,8 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
messageBus: MessageBus,
|
||||
toolName?: string,
|
||||
displayName?: string,
|
||||
isSensitive?: boolean,
|
||||
) {
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
toolName,
|
||||
displayName,
|
||||
undefined,
|
||||
undefined,
|
||||
isSensitive,
|
||||
);
|
||||
super(params, messageBus, toolName, displayName);
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
this.params.file_path,
|
||||
@@ -438,14 +429,11 @@ export class WriteFileTool
|
||||
WriteFileTool.Name,
|
||||
WRITE_FILE_DISPLAY_NAME,
|
||||
WRITE_FILE_DEFINITION.base.description!,
|
||||
Kind.Write,
|
||||
Kind.Edit,
|
||||
WRITE_FILE_DEFINITION.base.parametersJsonSchema,
|
||||
messageBus,
|
||||
true,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -489,9 +477,6 @@ export class WriteFileTool
|
||||
protected createInvocation(
|
||||
params: WriteFileToolParams,
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
): ToolInvocation<WriteFileToolParams, ToolResult> {
|
||||
return new WriteFileToolInvocation(
|
||||
this.config,
|
||||
@@ -499,7 +484,6 @@ export class WriteFileTool
|
||||
messageBus ?? this.messageBus,
|
||||
this.name,
|
||||
this.displayName,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,17 +34,8 @@ class WriteTodosToolInvocation extends BaseToolInvocation<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
isSensitive?: boolean,
|
||||
) {
|
||||
super(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
undefined,
|
||||
undefined,
|
||||
isSensitive,
|
||||
);
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
@@ -94,9 +85,6 @@ export class WriteTodosTool extends BaseDeclarativeTool<
|
||||
messageBus,
|
||||
true, // isOutputMarkdown
|
||||
false, // canUpdateOutput
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,14 +128,12 @@ export class WriteTodosTool extends BaseDeclarativeTool<
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_displayName?: string,
|
||||
isSensitive?: boolean,
|
||||
): ToolInvocation<WriteTodosToolParams, ToolResult> {
|
||||
return new WriteTodosToolInvocation(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_displayName,
|
||||
isSensitive,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user