mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-21 00:37:17 -07:00
WIP: macOS tool sandbox
This commit is contained in:
@@ -31,18 +31,13 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
|
||||
'lxc',
|
||||
];
|
||||
|
||||
function isSandboxCommand(value: string): value is SandboxConfig['command'] {
|
||||
return (VALID_SANDBOX_COMMANDS as readonly string[]).includes(value);
|
||||
function isSandboxCommand(value: string | undefined): value is SandboxConfig['command'] {
|
||||
return (VALID_SANDBOX_COMMANDS as readonly (string | undefined)[]).includes(value);
|
||||
}
|
||||
|
||||
function getSandboxCommand(
|
||||
sandbox?: boolean | string | null,
|
||||
): SandboxConfig['command'] | '' {
|
||||
// If the SANDBOX env var is set, we're already inside the sandbox.
|
||||
if (process.env['SANDBOX']) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// note environment variable takes precedence over argument (from command line or settings)
|
||||
const environmentConfiguredSandbox =
|
||||
process.env['GEMINI_SANDBOX']?.toLowerCase().trim() ?? '';
|
||||
@@ -124,5 +119,10 @@ export async function loadSandboxConfig(
|
||||
process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ??
|
||||
packageJson?.config?.sandboxImageUri;
|
||||
|
||||
return command && image ? { command, image } : undefined;
|
||||
const enabled = !!(command === 'sandbox-exec' || (command && image));
|
||||
if (enabled) {
|
||||
console.error(`[DEBUG] Sandbox Enabled: ${command}${image ? ` (image: ${image})` : ''}`);
|
||||
}
|
||||
|
||||
return enabled ? { command: command as SandboxConfig['command'], image, enabled: true } : undefined;
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@ vi.mock('./config/sandboxConfig.js', () => ({
|
||||
loadSandboxConfig: vi.fn().mockResolvedValue({
|
||||
command: 'docker',
|
||||
image: 'test-image',
|
||||
enabled: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -618,13 +619,18 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
const mockConfig = createMockConfig({
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => '',
|
||||
getSandbox: () => ({ command: 'docker', image: 'test-image' }),
|
||||
getSandbox: () => ({
|
||||
command: 'docker',
|
||||
image: 'test-image',
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue({
|
||||
command: 'docker',
|
||||
image: 'test-image',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
@@ -914,6 +920,7 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
it('should exit with 41 for auth failure during sandbox setup', async () => {
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue({
|
||||
enabled: true,
|
||||
command: 'docker',
|
||||
image: 'test-image',
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ import { createHash } from 'node:crypto';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import dns from 'node:dns';
|
||||
import { start_sandbox } from './utils/sandbox.js';
|
||||
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
loadTrustedFolders,
|
||||
@@ -94,11 +93,6 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import {
|
||||
relaunchAppInChildProcess,
|
||||
relaunchOnExitCode,
|
||||
} from './utils/relaunch.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { deleteSession, listSessions } from './utils/sessions.js';
|
||||
import { createPolicyUpdater } from './config/policy.js';
|
||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||
@@ -503,63 +497,9 @@ export async function main() {
|
||||
// Run deferred command now that we have admin settings.
|
||||
await runDeferredCommand(settings.merged);
|
||||
|
||||
// hop into sandbox if we are outside and sandboxing is enabled
|
||||
if (!process.env['SANDBOX']) {
|
||||
const memoryArgs = settings.merged.advanced.autoConfigureMemory
|
||||
? getNodeMemoryArgs(isDebugMode)
|
||||
: [];
|
||||
const sandboxConfig = await loadSandboxConfig(settings.merged, argv);
|
||||
// We intentionally omit the list of extensions here because extensions
|
||||
// should not impact auth or setting up the sandbox.
|
||||
// TODO(jacobr): refactor loadCliConfig so there is a minimal version
|
||||
// that only initializes enough config to enable refreshAuth or find
|
||||
// another way to decouple refreshAuth from requiring a config.
|
||||
|
||||
if (sandboxConfig) {
|
||||
if (initialAuthFailed) {
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
|
||||
}
|
||||
let stdinData = '';
|
||||
if (!process.stdin.isTTY) {
|
||||
stdinData = await readStdin();
|
||||
}
|
||||
|
||||
// This function is a copy of the one from sandbox.ts
|
||||
// It is moved here to decouple sandbox.ts from the CLI's argument structure.
|
||||
const injectStdinIntoArgs = (
|
||||
args: string[],
|
||||
stdinData?: string,
|
||||
): string[] => {
|
||||
const finalArgs = [...args];
|
||||
if (stdinData) {
|
||||
const promptIndex = finalArgs.findIndex(
|
||||
(arg) => arg === '--prompt' || arg === '-p',
|
||||
);
|
||||
if (promptIndex > -1 && finalArgs.length > promptIndex + 1) {
|
||||
// If there's a prompt argument, prepend stdin to it
|
||||
finalArgs[promptIndex + 1] =
|
||||
`${stdinData}\n\n${finalArgs[promptIndex + 1]}`;
|
||||
} else {
|
||||
// If there's no prompt argument, add stdin as the prompt
|
||||
finalArgs.push('--prompt', stdinData);
|
||||
}
|
||||
}
|
||||
return finalArgs;
|
||||
};
|
||||
|
||||
const sandboxArgs = injectStdinIntoArgs(process.argv, stdinData);
|
||||
|
||||
await relaunchOnExitCode(() =>
|
||||
start_sandbox(sandboxConfig, memoryArgs, partialConfig, sandboxArgs),
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
} else {
|
||||
// Relaunch app so we always have a child process that can be internally
|
||||
// restarted if needed.
|
||||
await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings);
|
||||
}
|
||||
if (initialAuthFailed) {
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
|
||||
}
|
||||
|
||||
// We are now past the logic handling potentially launching a child process
|
||||
|
||||
@@ -6,15 +6,24 @@
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import type {
|
||||
SerializableConfirmationDetails,
|
||||
ToolCallConfirmationDetails,
|
||||
Config,
|
||||
import {
|
||||
type SerializableConfirmationDetails,
|
||||
type ToolCallConfirmationDetails,
|
||||
type Config,
|
||||
hasRedirection,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
hasRedirection: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
@@ -240,6 +249,90 @@ describe('ToolConfirmationMessage', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display redirection warning when hasRedirection is true in confirmationDetails', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'exec',
|
||||
title: 'Confirm Redirection',
|
||||
command: 'ls > out.txt',
|
||||
rootCommand: 'ls',
|
||||
rootCommands: ['ls'],
|
||||
hasRedirection: true,
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
getPreferredEditor={vi.fn()}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Note: Command contains redirection which can be undesirable.');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display redirection warning via async fallback when hasRedirection is missing', async () => {
|
||||
vi.mocked(hasRedirection).mockReturnValue(true);
|
||||
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'exec',
|
||||
title: 'Confirm Redirection Fallback',
|
||||
command: 'ls > out.txt',
|
||||
rootCommand: 'ls',
|
||||
rootCommands: ['ls'],
|
||||
// hasRedirection is missing
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
getPreferredEditor={vi.fn()}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// The warning should be present as it is now calculated synchronously
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Note: Command contains redirection which can be undesirable.');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT display redirection warning when hasRedirection is false', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'exec',
|
||||
title: 'Confirm No Redirection',
|
||||
command: 'ls -la',
|
||||
rootCommand: 'ls',
|
||||
rootCommands: ['ls'],
|
||||
hasRedirection: false,
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
getPreferredEditor={vi.fn()}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Note: This command uses shell redirection');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render multiline shell scripts with correct newlines and syntax highlighting (SVG snapshot)', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'exec',
|
||||
|
||||
@@ -90,6 +90,21 @@ export const ToolConfirmationMessage: React.FC<
|
||||
confirmationDetails.type === 'exit_plan_mode';
|
||||
const isTrustedFolder = config.isTrustedFolder();
|
||||
|
||||
const commandsToDisplay =
|
||||
confirmationDetails.type === 'exec' &&
|
||||
confirmationDetails.commands &&
|
||||
confirmationDetails.commands.length > 1
|
||||
? confirmationDetails.commands
|
||||
: confirmationDetails.type === 'exec'
|
||||
? [confirmationDetails.command]
|
||||
: [];
|
||||
|
||||
const hasRedirectionCheck =
|
||||
confirmationDetails.type === 'exec'
|
||||
? confirmationDetails.hasRedirection ??
|
||||
commandsToDisplay.some((cmd) => hasRedirection(cmd))
|
||||
: false;
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
(outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => {
|
||||
void confirm(callId, outcome, payload).catch((error: unknown) => {
|
||||
@@ -484,17 +499,14 @@ export const ToolConfirmationMessage: React.FC<
|
||||
}
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
const executionProps = confirmationDetails;
|
||||
const containsRedirection = hasRedirectionCheck;
|
||||
let bodyContentHeight = availableBodyContentHeight();
|
||||
let warnings: React.ReactNode | null = null;
|
||||
|
||||
const commandsToDisplay =
|
||||
executionProps.commands && executionProps.commands.length > 1
|
||||
? executionProps.commands
|
||||
: [executionProps.command];
|
||||
const containsRedirection = commandsToDisplay.some((cmd) =>
|
||||
hasRedirection(cmd),
|
||||
);
|
||||
|
||||
let bodyContentHeight = availableBodyContentHeight();
|
||||
let warnings: React.ReactNode = null;
|
||||
|
||||
if (bodyContentHeight !== undefined) {
|
||||
bodyContentHeight -= 2; // Account for padding;
|
||||
|
||||
+18
-14
@@ -1,8 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="173" viewBox="0 0 920 173">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="173" fill="#000000" />
|
||||
<rect width="920" height="224" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">echo</text>
|
||||
<text x="45" y="2" fill="#cdcd00" textLength="63" lengthAdjust="spacingAndGlyphs">"hello"</text>
|
||||
@@ -14,17 +14,21 @@
|
||||
<text x="18" y="36" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">echo</text>
|
||||
<text x="63" y="36" fill="#cd00cd" textLength="18" lengthAdjust="spacingAndGlyphs">$i</text>
|
||||
<text x="0" y="53" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">done</text>
|
||||
<text x="0" y="70" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Allow execution of: 'echo'? </text>
|
||||
<rect x="0" y="102" width="9" height="17" fill="#001a00" />
|
||||
<text x="0" y="104" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">●</text>
|
||||
<rect x="9" y="102" width="9" height="17" fill="#001a00" />
|
||||
<rect x="18" y="102" width="18" height="17" fill="#001a00" />
|
||||
<text x="18" y="104" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
|
||||
<rect x="36" y="102" width="9" height="17" fill="#001a00" />
|
||||
<rect x="45" y="102" width="90" height="17" fill="#001a00" />
|
||||
<text x="45" y="104" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
|
||||
<rect x="135" y="102" width="135" height="17" fill="#001a00" />
|
||||
<text x="0" y="121" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 2. Allow for this session </text>
|
||||
<text x="0" y="138" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 3. No, suggest changes (esc) </text>
|
||||
<text x="0" y="87" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">Note: </text>
|
||||
<text x="54" y="87" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs">Command contains redirection which can be undesirable. </text>
|
||||
<text x="0" y="104" fill="#333333" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">Tip: </text>
|
||||
<text x="54" y="104" fill="#333333" textLength="576" lengthAdjust="spacingAndGlyphs">Toggle auto-edit (Shift+Tab) to allow redirection in the future.</text>
|
||||
<text x="0" y="121" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Allow execution of: 'echo'? </text>
|
||||
<rect x="0" y="153" width="9" height="17" fill="#001a00" />
|
||||
<text x="0" y="155" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">●</text>
|
||||
<rect x="9" y="153" width="9" height="17" fill="#001a00" />
|
||||
<rect x="18" y="153" width="18" height="17" fill="#001a00" />
|
||||
<text x="18" y="155" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
|
||||
<rect x="36" y="153" width="9" height="17" fill="#001a00" />
|
||||
<rect x="45" y="153" width="90" height="17" fill="#001a00" />
|
||||
<text x="45" y="155" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
|
||||
<rect x="135" y="153" width="135" height="17" fill="#001a00" />
|
||||
<text x="0" y="172" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 2. Allow for this session </text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 3. No, suggest changes (esc) </text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.1 KiB |
+9
@@ -42,6 +42,9 @@ exports[`ToolConfirmationMessage > should render multiline shell scripts with co
|
||||
for i in 1 2 3; do
|
||||
echo $i
|
||||
done
|
||||
|
||||
Note: Command contains redirection which can be undesirable.
|
||||
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
|
||||
Allow execution of: 'echo'?
|
||||
|
||||
● 1. Allow once
|
||||
@@ -93,6 +96,9 @@ Apply this change?
|
||||
|
||||
exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
|
||||
"echo "hello"
|
||||
|
||||
Note: Command contains redirection which can be undesirable.
|
||||
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
|
||||
Allow execution of: 'echo'?
|
||||
|
||||
● 1. Allow once
|
||||
@@ -102,6 +108,9 @@ Allow execution of: 'echo'?
|
||||
|
||||
exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should show "allow always" when folder is trusted 1`] = `
|
||||
"echo "hello"
|
||||
|
||||
Note: Command contains redirection which can be undesirable.
|
||||
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
|
||||
Allow execution of: 'echo'?
|
||||
|
||||
● 1. Allow once
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
type ShellExecutionResult,
|
||||
type ShellOutputEvent,
|
||||
CoreToolCallStatus,
|
||||
SandboxProfile,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
@@ -214,6 +215,7 @@ describe('useShellCommandProcessor', () => {
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
SandboxProfile.WORKSPACE_WRITE,
|
||||
);
|
||||
expect(onExecMock).toHaveBeenCalledWith(expect.any(Promise));
|
||||
});
|
||||
@@ -306,6 +308,7 @@ describe('useShellCommandProcessor', () => {
|
||||
expect.any(Object),
|
||||
false, // enableInteractiveShell
|
||||
expect.any(Object),
|
||||
SandboxProfile.WORKSPACE_WRITE,
|
||||
);
|
||||
|
||||
// Wait for the async PID update to happen.
|
||||
@@ -430,6 +433,7 @@ describe('useShellCommandProcessor', () => {
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
SandboxProfile.WORKSPACE_WRITE,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('useTurnActivityMonitor', () => {
|
||||
expect(result.current.operationStartTime).toBe(2000);
|
||||
});
|
||||
|
||||
it('should detect redirection from tool calls', () => {
|
||||
it('should detect redirection from tool calls', async () => {
|
||||
// Force mock implementation to ensure it's active
|
||||
vi.mocked(hasRedirection).mockImplementation((q: string) =>
|
||||
q.includes('>'),
|
||||
|
||||
@@ -48,19 +48,14 @@ export const useTurnActivityMonitor = (
|
||||
}, [streamingState, activePtyId]);
|
||||
|
||||
// Detect redirection in the current query or tool calls.
|
||||
// We derive this directly during render to ensure it's accurate from the first frame.
|
||||
const isRedirectionActive = useMemo(
|
||||
() =>
|
||||
// Check active tool calls for run_shell_command
|
||||
pendingToolCalls.some((tc) => {
|
||||
if (tc.request.name !== 'run_shell_command') return false;
|
||||
const isRedirectionActive = useMemo(() => {
|
||||
return pendingToolCalls.some((tc) => {
|
||||
if (tc.request.name !== 'run_shell_command') return false;
|
||||
|
||||
const command =
|
||||
(tc.request.args as { command?: string })?.command || '';
|
||||
return hasRedirection(command);
|
||||
}),
|
||||
[pendingToolCalls],
|
||||
);
|
||||
const command = (tc.request.args as { command?: string })?.command || '';
|
||||
return hasRedirection(command);
|
||||
});
|
||||
}, [pendingToolCalls]);
|
||||
|
||||
return {
|
||||
operationStartTime,
|
||||
|
||||
@@ -137,7 +137,7 @@ describe('sandbox', () => {
|
||||
describe('start_sandbox', () => {
|
||||
it('should handle macOS seatbelt (sandbox-exec)', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = { enabled: true,
|
||||
command: 'sandbox-exec',
|
||||
image: 'some-image',
|
||||
};
|
||||
@@ -173,7 +173,7 @@ describe('sandbox', () => {
|
||||
it('should throw FatalSandboxError if seatbelt profile is missing', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = { enabled: true,
|
||||
command: 'sandbox-exec',
|
||||
image: 'some-image',
|
||||
};
|
||||
@@ -182,7 +182,7 @@ describe('sandbox', () => {
|
||||
});
|
||||
|
||||
it('should handle Docker execution', async () => {
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = { enabled: true,
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
@@ -231,7 +231,7 @@ describe('sandbox', () => {
|
||||
});
|
||||
|
||||
it('should pull image if missing', async () => {
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = { enabled: true,
|
||||
command: 'docker',
|
||||
image: 'missing-image',
|
||||
};
|
||||
@@ -300,7 +300,7 @@ describe('sandbox', () => {
|
||||
});
|
||||
|
||||
it('should throw if image pull fails', async () => {
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = { enabled: true,
|
||||
command: 'docker',
|
||||
image: 'missing-image',
|
||||
};
|
||||
@@ -338,7 +338,7 @@ describe('sandbox', () => {
|
||||
});
|
||||
|
||||
it('should mount volumes correctly', async () => {
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = { enabled: true,
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
@@ -395,7 +395,7 @@ describe('sandbox', () => {
|
||||
});
|
||||
|
||||
it('should pass through GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL', async () => {
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = { enabled: true,
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
@@ -442,7 +442,7 @@ describe('sandbox', () => {
|
||||
});
|
||||
|
||||
it('should handle user creation on Linux if needed', async () => {
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = { enabled: true,
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
@@ -508,7 +508,7 @@ describe('sandbox', () => {
|
||||
|
||||
it('should run lxc exec with correct args for a running container', async () => {
|
||||
process.env['TEST_LXC_LIST_OUTPUT'] = LXC_RUNNING;
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = { enabled: true,
|
||||
command: 'lxc',
|
||||
image: 'gemini-sandbox',
|
||||
};
|
||||
@@ -542,7 +542,7 @@ describe('sandbox', () => {
|
||||
|
||||
it('should throw FatalSandboxError if lxc list fails', async () => {
|
||||
process.env['TEST_LXC_LIST_OUTPUT'] = 'throw';
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = { enabled: true,
|
||||
command: 'lxc',
|
||||
image: 'gemini-sandbox',
|
||||
};
|
||||
@@ -554,7 +554,7 @@ describe('sandbox', () => {
|
||||
|
||||
it('should throw FatalSandboxError if container is not running', async () => {
|
||||
process.env['TEST_LXC_LIST_OUTPUT'] = LXC_STOPPED;
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = { enabled: true,
|
||||
command: 'lxc',
|
||||
image: 'gemini-sandbox',
|
||||
};
|
||||
@@ -564,7 +564,7 @@ describe('sandbox', () => {
|
||||
|
||||
it('should throw FatalSandboxError if container is not found in list', async () => {
|
||||
process.env['TEST_LXC_LIST_OUTPUT'] = '[]';
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = { enabled: true,
|
||||
command: 'lxc',
|
||||
image: 'gemini-sandbox',
|
||||
};
|
||||
@@ -577,7 +577,7 @@ describe('sandbox', () => {
|
||||
describe('gVisor (runsc)', () => {
|
||||
it('should use docker with --runtime=runsc on Linux', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = { enabled: true,
|
||||
command: 'runsc',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
|
||||
@@ -268,6 +268,13 @@ export async function start_sandbox(
|
||||
}
|
||||
}
|
||||
|
||||
// stop if command or image is missing
|
||||
if (!command || !image) {
|
||||
throw new FatalSandboxError(
|
||||
'Sandbox configuration is missing required "command" or "image" field.',
|
||||
);
|
||||
}
|
||||
|
||||
// stop if image is missing
|
||||
if (!(await ensureSandboxImageIsPresent(command, image, cliConfig))) {
|
||||
const remedy =
|
||||
@@ -714,10 +721,10 @@ export async function start_sandbox(
|
||||
// proxyProcess.stdout?.on('data', (data) => {
|
||||
// console.info(data.toString());
|
||||
// });
|
||||
proxyProcess.stderr?.on('data', (data) => {
|
||||
proxyProcess?.stderr?.on('data', (data) => {
|
||||
debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`);
|
||||
});
|
||||
proxyProcess.on('close', (code, signal) => {
|
||||
proxyProcess?.on('close', (code, signal) => {
|
||||
if (sandboxProcess?.pid) {
|
||||
process.kill(-sandboxProcess.pid, 'SIGTERM');
|
||||
}
|
||||
@@ -743,6 +750,10 @@ export async function start_sandbox(
|
||||
});
|
||||
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
if (!sandboxProcess) {
|
||||
reject(new Error('Failed to spawn sandbox process.'));
|
||||
return;
|
||||
}
|
||||
sandboxProcess.on('error', (err) => {
|
||||
coreEvents.emitFeedback('error', 'Sandbox process error', err);
|
||||
reject(err);
|
||||
|
||||
Reference in New Issue
Block a user