diff options
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/config/config.ts | 10 | ||||
| -rw-r--r-- | packages/cli/src/config/sandboxConfig.ts | 102 | ||||
| -rw-r--r-- | packages/cli/src/gemini.tsx | 8 | ||||
| -rw-r--r-- | packages/cli/src/ui/App.test.tsx | 13 | ||||
| -rw-r--r-- | packages/cli/src/utils/sandbox.ts | 91 |
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', }); |
