summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
authorBrandon Keiji <[email protected]>2025-06-18 10:01:00 -0700
committerGitHub <[email protected]>2025-06-18 17:01:00 +0000
commit332512853e40e6c9b826b60057a389e9d34453fd (patch)
tree7ee45ec5d7f27cb5664c353379580133b223da77 /packages/cli/src
parent30d1662128e688bf94653a0144ef96c311fae40b (diff)
feat: consolidate sandbox configurations into a single object (#1154)
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/config/config.ts10
-rw-r--r--packages/cli/src/config/sandboxConfig.ts102
-rw-r--r--packages/cli/src/gemini.tsx8
-rw-r--r--packages/cli/src/ui/App.test.tsx13
-rw-r--r--packages/cli/src/utils/sandbox.ts91
5 files changed, 139 insertions, 85 deletions
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index c790db0b..26878646 100644
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -28,6 +28,7 @@ import * as dotenv from 'dotenv';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
+import { loadSandboxConfig } from './sandboxConfig.js';
// Simple console logger for now - replace with actual logger if available
const logger = {
@@ -42,6 +43,7 @@ const logger = {
interface CliArgs {
model: string | undefined;
sandbox: boolean | string | undefined;
+ 'sandbox-image': string | undefined;
debug: boolean | undefined;
prompt: string | undefined;
all_files: boolean | undefined;
@@ -72,6 +74,10 @@ async function parseArguments(): Promise<CliArgs> {
type: 'boolean',
description: 'Run in sandbox?',
})
+ .option('sandbox-image', {
+ type: 'string',
+ description: 'Sandbox image URI.',
+ })
.option('debug', {
alias: 'd',
type: 'boolean',
@@ -192,11 +198,13 @@ export async function loadCliConfig(
const mcpServers = mergeMcpServers(settings, extensions);
+ const sandboxConfig = await loadSandboxConfig(settings, argv);
+
return new Config({
sessionId,
contentGeneratorConfig,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
- sandbox: argv.sandbox ?? settings.sandbox,
+ sandbox: sandboxConfig,
targetDir: process.cwd(),
debugMode,
question: argv.prompt || '',
diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts
new file mode 100644
index 00000000..69a54900
--- /dev/null
+++ b/packages/cli/src/config/sandboxConfig.ts
@@ -0,0 +1,102 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { SandboxConfig } from '@gemini-cli/core';
+import commandExists from 'command-exists';
+import * as os from 'node:os';
+import { getPackageJson } from '../utils/package.js';
+import { Settings } from './settings.js';
+
+// This is a stripped-down version of the CliArgs interface from config.ts
+// to avoid circular dependencies.
+interface SandboxCliArgs {
+ sandbox?: boolean | string;
+ 'sandbox-image'?: string;
+}
+
+const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
+ 'docker',
+ 'podman',
+ 'sandbox-exec',
+];
+
+function isSandboxCommand(value: string): value is SandboxConfig['command'] {
+ return (VALID_SANDBOX_COMMANDS as readonly string[]).includes(value);
+}
+
+function getSandboxCommand(
+ sandbox?: boolean | string,
+): SandboxConfig['command'] | '' {
+ // note environment variable takes precedence over argument (from command line or settings)
+ sandbox = process.env.GEMINI_SANDBOX?.toLowerCase().trim() ?? sandbox;
+ if (sandbox === '1' || sandbox === 'true') sandbox = true;
+ else if (sandbox === '0' || sandbox === 'false') sandbox = false;
+
+ if (sandbox === false) {
+ return '';
+ }
+
+ if (typeof sandbox === 'string' && sandbox !== '') {
+ if (!isSandboxCommand(sandbox)) {
+ console.error(
+ `ERROR: invalid sandbox command '${sandbox}'. Must be one of ${VALID_SANDBOX_COMMANDS.join(
+ ', ',
+ )}`,
+ );
+ process.exit(1);
+ }
+ // confirm that specfied command exists
+ if (commandExists.sync(sandbox)) {
+ return sandbox;
+ }
+ console.error(
+ `ERROR: missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`,
+ );
+ process.exit(1);
+ }
+
+ // look for seatbelt, docker, or podman, in that order
+ // for container-based sandboxing, require sandbox to be enabled explicitly
+ if (os.platform() === 'darwin' && commandExists.sync('sandbox-exec')) {
+ return 'sandbox-exec';
+ } else if (commandExists.sync('docker') && sandbox === true) {
+ return 'docker';
+ } else if (commandExists.sync('podman') && sandbox === true) {
+ return 'podman';
+ }
+
+ // throw an error if user requested sandbox but no command was found
+ if (sandbox === true) {
+ console.error(
+ 'ERROR: GEMINI_SANDBOX is true but failed to determine command for sandbox; ' +
+ 'install docker or podman or specify command in GEMINI_SANDBOX',
+ );
+ process.exit(1);
+ }
+
+ return '';
+}
+
+export async function loadSandboxConfig(
+ settings: Settings,
+ argv: SandboxCliArgs,
+): Promise<SandboxConfig | undefined> {
+ const sandboxOption = argv.sandbox ?? settings.sandbox;
+ const sandboxCommand = getSandboxCommand(sandboxOption);
+ if (!sandboxCommand) {
+ return undefined;
+ }
+
+ const packageJson = await getPackageJson();
+ return {
+ command: sandboxCommand,
+ image:
+ argv['sandbox-image'] ??
+ process.env.GEMINI_SANDBOX_IMAGE ??
+ packageJson?.config?.sandboxImageUri ??
+ 'gemini-cli-sandbox',
+ };
+}
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index 16dc7d83..148f18bf 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -10,7 +10,7 @@ import { AppWrapper } from './ui/App.js';
import { loadCliConfig } from './config/config.js';
import { readStdin } from './utils/readStdin.js';
import { basename } from 'node:path';
-import { sandbox_command, start_sandbox } from './utils/sandbox.js';
+import { start_sandbox } from './utils/sandbox.js';
import { LoadedSettings, loadSettings } from './config/settings.js';
import { themeManager } from './ui/themes/theme-manager.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
@@ -72,9 +72,9 @@ export async function main() {
// hop into sandbox if we are outside and sandboxing is enabled
if (!process.env.SANDBOX) {
- const sandbox = sandbox_command(config.getSandbox());
- if (sandbox) {
- await start_sandbox(sandbox);
+ const sandboxConfig = config.getSandbox();
+ if (sandboxConfig) {
+ await start_sandbox(sandboxConfig);
process.exit(0);
}
}
diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx
index 201d0698..0ebaa34d 100644
--- a/packages/cli/src/ui/App.test.tsx
+++ b/packages/cli/src/ui/App.test.tsx
@@ -13,6 +13,7 @@ import {
ApprovalMode,
ToolRegistry,
AccessibilitySettings,
+ SandboxConfig,
} from '@gemini-cli/core';
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
import process from 'node:process';
@@ -21,7 +22,7 @@ import process from 'node:process';
interface MockServerConfig {
apiKey: string;
model: string;
- sandbox: boolean | string;
+ sandbox?: SandboxConfig;
targetDir: string;
debugMode: boolean;
question?: string;
@@ -42,7 +43,7 @@ interface MockServerConfig {
getApiKey: Mock<() => string>;
getModel: Mock<() => string>;
- getSandbox: Mock<() => boolean | string>;
+ getSandbox: Mock<() => SandboxConfig | undefined>;
getTargetDir: Mock<() => string>;
getToolRegistry: Mock<() => ToolRegistry>; // Use imported ToolRegistry type
getDebugMode: Mock<() => boolean>;
@@ -78,7 +79,7 @@ vi.mock('@gemini-cli/core', async (importOriginal) => {
return {
apiKey: opts.apiKey || 'test-key',
model: opts.model || 'test-model-in-mock-factory',
- sandbox: typeof opts.sandbox === 'boolean' ? opts.sandbox : false,
+ sandbox: opts.sandbox,
targetDir: opts.targetDir || '/test/dir',
debugMode: opts.debugMode || false,
question: opts.question,
@@ -99,9 +100,7 @@ vi.mock('@gemini-cli/core', async (importOriginal) => {
getApiKey: vi.fn(() => opts.apiKey || 'test-key'),
getModel: vi.fn(() => opts.model || 'test-model-in-mock-factory'),
- getSandbox: vi.fn(() =>
- typeof opts.sandbox === 'boolean' ? opts.sandbox : false,
- ),
+ getSandbox: vi.fn(() => opts.sandbox),
getTargetDir: vi.fn(() => opts.targetDir || '/test/dir'),
getToolRegistry: vi.fn(() => ({}) as ToolRegistry), // Simple mock
getDebugMode: vi.fn(() => opts.debugMode || false),
@@ -190,7 +189,7 @@ describe('App UI', () => {
model: 'test-model',
},
embeddingModel: 'test-embedding-model',
- sandbox: false,
+ sandbox: undefined,
targetDir: '/test/dir',
debugMode: false,
userMemory: '',
diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts
index 36dec7f0..9e9ab1a7 100644
--- a/packages/cli/src/utils/sandbox.ts
+++ b/packages/cli/src/utils/sandbox.ts
@@ -10,13 +10,12 @@ import path from 'node:path';
import fs from 'node:fs';
import { readFile } from 'node:fs/promises';
import { quote } from 'shell-quote';
-import { getPackageJson } from './package.js';
-import commandExists from 'command-exists';
import {
USER_SETTINGS_DIR,
SETTINGS_DIRECTORY_NAME,
} from '../config/settings.js';
import { promisify } from 'util';
+import { SandboxConfig } from '@gemini-cli/core';
const execAsync = promisify(exec);
@@ -99,62 +98,6 @@ async function shouldUseCurrentUserInSandbox(): Promise<boolean> {
return false; // Default to false if no other condition is met
}
-async function getSandboxImageName(
- isCustomProjectSandbox: boolean,
-): Promise<string> {
- const packageJson = await getPackageJson();
- return (
- process.env.GEMINI_SANDBOX_IMAGE ??
- packageJson?.config?.sandboxImageUri ??
- (isCustomProjectSandbox
- ? LOCAL_DEV_SANDBOX_IMAGE_NAME + '-' + path.basename(path.resolve())
- : LOCAL_DEV_SANDBOX_IMAGE_NAME)
- );
-}
-
-export function sandbox_command(sandbox?: string | boolean): string {
- // note environment variable takes precedence over argument (from command line or settings)
- sandbox = process.env.GEMINI_SANDBOX?.toLowerCase().trim() ?? sandbox;
- if (sandbox === '1' || sandbox === 'true') sandbox = true;
- else if (sandbox === '0' || sandbox === 'false') sandbox = false;
-
- if (sandbox === false) {
- return '';
- }
-
- if (typeof sandbox === 'string' && sandbox !== '') {
- // confirm that specfied command exists
- if (commandExists.sync(sandbox)) {
- return sandbox;
- }
- console.error(
- `ERROR: missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`,
- );
- process.exit(1);
- }
-
- // look for seatbelt, docker, or podman, in that order
- // for container-based sandboxing, require sandbox to be enabled explicitly
- if (os.platform() === 'darwin' && commandExists.sync('sandbox-exec')) {
- return 'sandbox-exec';
- } else if (commandExists.sync('docker') && sandbox === true) {
- return 'docker';
- } else if (commandExists.sync('podman') && sandbox === true) {
- return 'podman';
- }
-
- // throw an error if user requested sandbox but no command was found
- if (sandbox === true) {
- console.error(
- 'ERROR: GEMINI_SANDBOX is true but failed to determine command for sandbox; ' +
- 'install docker or podman or specify command in GEMINI_SANDBOX',
- );
- process.exit(1);
- }
-
- return '';
-}
-
// docker does not allow container names to contain ':' or '/', so we
// parse those out and make the name a little shorter
function parseImageName(image: string): string {
@@ -237,8 +180,8 @@ function entrypoint(workdir: string): string[] {
return ['bash', '-c', args.join(' ')];
}
-export async function start_sandbox(sandbox: string) {
- if (sandbox === 'sandbox-exec') {
+export async function start_sandbox(config: SandboxConfig) {
+ if (config.command === 'sandbox-exec') {
// disallow BUILD_SANDBOX
if (process.env.BUILD_SANDBOX) {
console.error('ERROR: cannot BUILD_SANDBOX when using MacOS Seatbelt');
@@ -340,14 +283,14 @@ export async function start_sandbox(sandbox: string) {
);
}
// spawn child and let it inherit stdio
- sandboxProcess = spawn(sandbox, args, {
+ sandboxProcess = spawn(config.command, args, {
stdio: 'inherit',
});
await new Promise((resolve) => sandboxProcess?.on('close', resolve));
return;
}
- console.error(`hopping into sandbox (command: ${sandbox}) ...`);
+ console.error(`hopping into sandbox (command: ${config.command}) ...`);
// determine full path for gemini-cli to distinguish linked vs installed setting
const gcPath = fs.realpathSync(process.argv[1]);
@@ -358,7 +301,7 @@ export async function start_sandbox(sandbox: string) {
);
const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile);
- const image = await getSandboxImageName(isCustomProjectSandbox);
+ const image = config.image;
const workdir = path.resolve(process.cwd());
const containerWorkdir = getContainerPath(workdir);
@@ -391,7 +334,7 @@ export async function start_sandbox(sandbox: string) {
stdio: 'inherit',
env: {
...process.env,
- GEMINI_SANDBOX: sandbox, // in case sandbox is enabled via flags (see config.ts under cli package)
+ GEMINI_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package)
},
},
);
@@ -399,7 +342,7 @@ export async function start_sandbox(sandbox: string) {
}
// stop if image is missing
- if (!(await ensureSandboxImageIsPresent(sandbox, image))) {
+ if (!(await ensureSandboxImageIsPresent(config.command, image))) {
const remedy =
image === LOCAL_DEV_SANDBOX_IMAGE_NAME
? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.'
@@ -529,7 +472,7 @@ export async function start_sandbox(sandbox: string) {
// if using proxy, switch to internal networking through proxy
if (proxy) {
execSync(
- `${sandbox} network inspect ${SANDBOX_NETWORK_NAME} || ${sandbox} network create --internal ${SANDBOX_NETWORK_NAME}`,
+ `${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${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)
@@ -537,7 +480,7 @@ export async function start_sandbox(sandbox: string) {
// this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation
if (proxyCommand) {
execSync(
- `${sandbox} network inspect ${SANDBOX_PROXY_NAME} || ${sandbox} network create ${SANDBOX_PROXY_NAME}`,
+ `${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`,
);
}
}
@@ -546,7 +489,9 @@ export async function start_sandbox(sandbox: string) {
// name container after image, plus numeric suffix to avoid conflicts
const imageName = parseImageName(image);
let index = 0;
- const containerNameCheck = execSync(`${sandbox} ps -a --format "{{.Names}}"`)
+ const containerNameCheck = execSync(
+ `${config.command} ps -a --format "{{.Names}}"`,
+ )
.toString()
.trim();
while (containerNameCheck.includes(`${imageName}-${index}`)) {
@@ -650,7 +595,7 @@ export async function start_sandbox(sandbox: string) {
args.push('--env', `SANDBOX=${containerName}`);
// for podman only, use empty --authfile to skip unnecessary auth refresh overhead
- if (sandbox === 'podman') {
+ if (config.command === 'podman') {
const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json');
fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8');
args.push('--authfile', emptyAuthFilePath);
@@ -683,7 +628,7 @@ export async function start_sandbox(sandbox: string) {
if (proxyCommand) {
// run proxyCommand in its own container
- const proxyContainerCommand = `${sandbox} run --rm --init ${userFlag} --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`;
+ const proxyContainerCommand = `${config.command} run --rm --init ${userFlag} --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`;
proxyProcess = spawn(proxyContainerCommand, {
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
@@ -692,7 +637,7 @@ export async function start_sandbox(sandbox: string) {
// install handlers to stop proxy on exit/signal
const stopProxy = () => {
console.log('stopping proxy container ...');
- execSync(`${sandbox} rm -f ${SANDBOX_PROXY_NAME}`);
+ execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`);
};
process.on('exit', stopProxy);
process.on('SIGINT', stopProxy);
@@ -721,12 +666,12 @@ export async function start_sandbox(sandbox: string) {
// connect proxy container to sandbox network
// (workaround for older versions of docker that don't support multiple --network args)
await execAsync(
- `${sandbox} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`,
+ `${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`,
);
}
// spawn child and let it inherit stdio
- sandboxProcess = spawn(sandbox, args, {
+ sandboxProcess = spawn(config.command, args, {
stdio: 'inherit',
});