From d6a7334279366762787bed6a5bd08a125c7c3ba8 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Wed, 6 Aug 2025 17:19:10 -0700 Subject: [PATCH] fix(logging): Ensure sandbox startup messages are routed to stderr (#5725) --- packages/cli/src/ui/types.ts | 2 +- packages/cli/src/ui/utils/ConsolePatcher.ts | 5 +- packages/cli/src/utils/sandbox.ts | 1078 ++++++++++--------- 3 files changed, 551 insertions(+), 534 deletions(-) diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 6d078b22..b52bf64d 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -224,7 +224,7 @@ export type Message = }; export interface ConsoleMessageItem { - type: 'log' | 'warn' | 'error' | 'debug'; + type: 'log' | 'warn' | 'error' | 'debug' | 'info'; content: string; count: number; } diff --git a/packages/cli/src/ui/utils/ConsolePatcher.ts b/packages/cli/src/ui/utils/ConsolePatcher.ts index a429698d..8e95adc1 100644 --- a/packages/cli/src/ui/utils/ConsolePatcher.ts +++ b/packages/cli/src/ui/utils/ConsolePatcher.ts @@ -18,6 +18,7 @@ export class ConsolePatcher { private originalConsoleWarn = console.warn; private originalConsoleError = console.error; private originalConsoleDebug = console.debug; + private originalConsoleInfo = console.info; private params: ConsolePatcherParams; @@ -30,6 +31,7 @@ export class ConsolePatcher { console.warn = this.patchConsoleMethod('warn', this.originalConsoleWarn); console.error = this.patchConsoleMethod('error', this.originalConsoleError); console.debug = this.patchConsoleMethod('debug', this.originalConsoleDebug); + console.info = this.patchConsoleMethod('info', this.originalConsoleInfo); } cleanup = () => { @@ -37,13 +39,14 @@ export class ConsolePatcher { console.warn = this.originalConsoleWarn; console.error = this.originalConsoleError; console.debug = this.originalConsoleDebug; + console.info = this.originalConsoleInfo; }; private formatArgs = (args: unknown[]): string => util.format(...args); private patchConsoleMethod = ( - type: 'log' | 'warn' | 'error' | 'debug', + type: 'log' | 'warn' | 'error' | 'debug' | 'info', originalMethod: (...args: unknown[]) => void, ) => (...args: unknown[]) => { diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index d53608d1..3550f45b 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -16,6 +16,7 @@ import { } from '../config/settings.js'; import { promisify } from 'util'; import { Config, SandboxConfig } from '@google/gemini-cli-core'; +import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; const execAsync = promisify(exec); @@ -185,119 +186,564 @@ export async function start_sandbox( nodeArgs: string[] = [], cliConfig?: Config, ) { - if (config.command === 'sandbox-exec') { - // disallow BUILD_SANDBOX + const patcher = new ConsolePatcher({ + debugMode: cliConfig?.getDebugMode() || !!process.env.DEBUG, + stderr: true, + }); + patcher.patch(); + + try { + if (config.command === 'sandbox-exec') { + // disallow BUILD_SANDBOX + if (process.env.BUILD_SANDBOX) { + console.error('ERROR: cannot BUILD_SANDBOX when using macOS Seatbelt'); + process.exit(1); + } + const profile = (process.env.SEATBELT_PROFILE ??= 'permissive-open'); + let profileFile = new URL(`sandbox-macos-${profile}.sb`, import.meta.url) + .pathname; + // if profile name is not recognized, then look for file under project settings directory + if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) { + profileFile = path.join( + SETTINGS_DIRECTORY_NAME, + `sandbox-macos-${profile}.sb`, + ); + } + if (!fs.existsSync(profileFile)) { + console.error( + `ERROR: missing macos seatbelt profile file '${profileFile}'`, + ); + process.exit(1); + } + // Log on STDERR so it doesn't clutter the output on STDOUT + console.error(`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 args = [ + '-D', + `TARGET_DIR=${fs.realpathSync(process.cwd())}`, + '-D', + `TMP_DIR=${fs.realpathSync(os.tmpdir())}`, + '-D', + `HOME_DIR=${fs.realpathSync(os.homedir())}`, + '-D', + `CACHE_DIR=${fs.realpathSync(execSync(`getconf DARWIN_USER_CACHE_DIR`).toString().trim())}`, + ]; + + // 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[] = []; + + if (cliConfig) { + const workspaceContext = cliConfig.getWorkspaceContext(); + const directories = workspaceContext.getDirectories(); + + // Filter out TARGET_DIR + for (const dir of directories) { + const realDir = fs.realpathSync(dir); + if (realDir !== targetDir) { + includedDirs.push(realDir); + } + } + } + + for (let i = 0; i < MAX_INCLUDE_DIRS; i++) { + let dirPath = '/dev/null'; // Default to a safe path that won't cause issues + + if (i < includedDirs.length) { + dirPath = includedDirs[i]; + } + + args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`); + } + + args.push( + '-f', + profileFile, + 'sh', + '-c', + [ + `SANDBOX=sandbox-exec`, + `NODE_OPTIONS="${nodeOptions}"`, + ...process.argv.map((arg) => quote([arg])), + ].join(' '), + ); + // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set + const proxyCommand = process.env.GEMINI_SANDBOX_PROXY_COMMAND; + let proxyProcess: ChildProcess | undefined = undefined; + let sandboxProcess: ChildProcess | undefined = undefined; + const sandboxEnv = { ...process.env }; + if (proxyCommand) { + const proxy = + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy || + 'http://localhost:8877'; + sandboxEnv['HTTPS_PROXY'] = proxy; + sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl + sandboxEnv['HTTP_PROXY'] = proxy; + sandboxEnv['http_proxy'] = proxy; + const noProxy = process.env.NO_PROXY || process.env.no_proxy; + if (noProxy) { + sandboxEnv['NO_PROXY'] = noProxy; + sandboxEnv['no_proxy'] = noProxy; + } + proxyProcess = spawn(proxyCommand, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + detached: true, + }); + // install handlers to stop proxy on exit/signal + const stopProxy = () => { + console.log('stopping proxy ...'); + if (proxyProcess?.pid) { + process.kill(-proxyProcess.pid, 'SIGTERM'); + } + }; + process.on('exit', stopProxy); + process.on('SIGINT', stopProxy); + process.on('SIGTERM', stopProxy); + + // commented out as it disrupts ink rendering + // proxyProcess.stdout?.on('data', (data) => { + // console.info(data.toString()); + // }); + proxyProcess.stderr?.on('data', (data) => { + console.error(data.toString()); + }); + proxyProcess.on('close', (code, signal) => { + console.error( + `ERROR: proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`, + ); + if (sandboxProcess?.pid) { + process.kill(-sandboxProcess.pid, 'SIGTERM'); + } + process.exit(1); + }); + console.log('waiting for proxy to start ...'); + await execAsync( + `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, + ); + } + // spawn child and let it inherit stdio + sandboxProcess = spawn(config.command, args, { + stdio: 'inherit', + }); + await new Promise((resolve) => sandboxProcess?.on('close', resolve)); + return; + } + + 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]); + + const projectSandboxDockerfile = path.join( + SETTINGS_DIRECTORY_NAME, + 'sandbox.Dockerfile', + ); + const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile); + + const image = config.image; + const workdir = path.resolve(process.cwd()); + const containerWorkdir = getContainerPath(workdir); + + // if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under gemini-cli repo + // + // note this can only be done with binary linked from gemini-cli repo if (process.env.BUILD_SANDBOX) { - console.error('ERROR: cannot BUILD_SANDBOX when using macOS Seatbelt'); - process.exit(1); + if (!gcPath.includes('gemini-cli/packages/')) { + console.error( + 'ERROR: cannot build sandbox using installed gemini binary; ' + + 'run `npm link ./packages/cli` under gemini-cli repo to switch to linked binary.', + ); + process.exit(1); + } else { + console.error('building sandbox ...'); + const gcRoot = gcPath.split('/packages/')[0]; + // if project folder has sandbox.Dockerfile under project settings folder, use that + let buildArgs = ''; + const projectSandboxDockerfile = path.join( + SETTINGS_DIRECTORY_NAME, + 'sandbox.Dockerfile', + ); + if (isCustomProjectSandbox) { + console.error(`using ${projectSandboxDockerfile} for sandbox`); + buildArgs += `-f ${path.resolve(projectSandboxDockerfile)} -i ${image}`; + } + execSync( + `cd ${gcRoot} && node scripts/build_sandbox.js -s ${buildArgs}`, + { + stdio: 'inherit', + env: { + ...process.env, + GEMINI_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package) + }, + }, + ); + } } - const profile = (process.env.SEATBELT_PROFILE ??= 'permissive-open'); - let profileFile = new URL(`sandbox-macos-${profile}.sb`, import.meta.url) - .pathname; - // if profile name is not recognized, then look for file under project settings directory - if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) { - profileFile = path.join( - SETTINGS_DIRECTORY_NAME, - `sandbox-macos-${profile}.sb`, - ); - } - if (!fs.existsSync(profileFile)) { + + // stop if image is missing + 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.' + : 'Please check the image name, your network connection, or notify gemini-cli-dev@google.com if the issue persists.'; console.error( - `ERROR: missing macos seatbelt profile file '${profileFile}'`, + `ERROR: Sandbox image '${image}' is missing or could not be pulled. ${remedy}`, ); process.exit(1); } - // Log on STDERR so it doesn't clutter the output on STDOUT - console.error(`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 args = [ - '-D', - `TARGET_DIR=${fs.realpathSync(process.cwd())}`, - '-D', - `TMP_DIR=${fs.realpathSync(os.tmpdir())}`, - '-D', - `HOME_DIR=${fs.realpathSync(os.homedir())}`, - '-D', - `CACHE_DIR=${fs.realpathSync(execSync(`getconf DARWIN_USER_CACHE_DIR`).toString().trim())}`, - ]; + // 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 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[] = []; + // 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); + } - if (cliConfig) { - const workspaceContext = cliConfig.getWorkspaceContext(); - const directories = workspaceContext.getDirectories(); + // 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'); + } - // Filter out TARGET_DIR - for (const dir of directories) { - const realDir = fs.realpathSync(dir); - if (realDir !== targetDir) { - includedDirs.push(realDir); + // mount current directory as working directory in sandbox (set via --workdir) + args.push('--volume', `${workdir}:${containerWorkdir}`); + + // mount user settings directory inside container, after creating if missing + // note user/home changes inside sandbox and we mount at BOTH paths for consistency + const userSettingsDirOnHost = USER_SETTINGS_DIR; + const userSettingsDirInSandbox = getContainerPath( + `/home/node/${SETTINGS_DIRECTORY_NAME}`, + ); + if (!fs.existsSync(userSettingsDirOnHost)) { + fs.mkdirSync(userSettingsDirOnHost); + } + args.push( + '--volume', + `${userSettingsDirOnHost}:${userSettingsDirInSandbox}`, + ); + if (userSettingsDirInSandbox !== userSettingsDirOnHost) { + args.push( + '--volume', + `${userSettingsDirOnHost}:${getContainerPath(userSettingsDirOnHost)}`, + ); + } + + // mount os.tmpdir() as os.tmpdir() inside container + args.push('--volume', `${os.tmpdir()}:${getContainerPath(os.tmpdir())}`); + + // mount gcloud config directory if it exists + const gcloudConfigDir = path.join(os.homedir(), '.config', 'gcloud'); + if (fs.existsSync(gcloudConfigDir)) { + args.push( + '--volume', + `${gcloudConfigDir}:${getContainerPath(gcloudConfigDir)}:ro`, + ); + } + + // mount ADC file if GOOGLE_APPLICATION_CREDENTIALS is set + if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { + const adcFile = process.env.GOOGLE_APPLICATION_CREDENTIALS; + if (fs.existsSync(adcFile)) { + args.push('--volume', `${adcFile}:${getContainerPath(adcFile)}:ro`); + args.push( + '--env', + `GOOGLE_APPLICATION_CREDENTIALS=${getContainerPath(adcFile)}`, + ); + } + } + + // mount paths listed in SANDBOX_MOUNTS + if (process.env.SANDBOX_MOUNTS) { + for (let mount of process.env.SANDBOX_MOUNTS.split(',')) { + if (mount.trim()) { + // parse mount as from:to:opts + let [from, to, opts] = mount.trim().split(':'); + to = to || from; // default to mount at same path inside container + opts = opts || 'ro'; // default to read-only + mount = `${from}:${to}:${opts}`; + // check that from path is absolute + if (!path.isAbsolute(from)) { + console.error( + `ERROR: path '${from}' listed in SANDBOX_MOUNTS must be absolute`, + ); + process.exit(1); + } + // check that from path exists on host + if (!fs.existsSync(from)) { + console.error( + `ERROR: missing mount path '${from}' listed in SANDBOX_MOUNTS`, + ); + process.exit(1); + } + console.error(`SANDBOX_MOUNTS: ${from} -> ${to} (${opts})`); + args.push('--volume', mount); } } } - for (let i = 0; i < MAX_INCLUDE_DIRS; i++) { - let dirPath = '/dev/null'; // Default to a safe path that won't cause issues + // expose env-specified ports on the sandbox + ports().forEach((p) => args.push('--publish', `${p}:${p}`)); - if (i < includedDirs.length) { - dirPath = includedDirs[i]; - } - - args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`); + // if DEBUG is set, expose debugging port + if (process.env.DEBUG) { + const debugPort = process.env.DEBUG_PORT || '9229'; + args.push(`--publish`, `${debugPort}:${debugPort}`); } - args.push( - '-f', - profileFile, - 'sh', - '-c', - [ - `SANDBOX=sandbox-exec`, - `NODE_OPTIONS="${nodeOptions}"`, - ...process.argv.map((arg) => quote([arg])), - ].join(' '), - ); - // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set + // copy proxy environment variables, replacing localhost with SANDBOX_PROXY_NAME + // copy as both upper-case and lower-case as is required by some utilities + // GEMINI_SANDBOX_PROXY_COMMAND implies HTTPS_PROXY unless HTTP_PROXY is set const proxyCommand = process.env.GEMINI_SANDBOX_PROXY_COMMAND; - let proxyProcess: ChildProcess | undefined = undefined; - let sandboxProcess: ChildProcess | undefined = undefined; - const sandboxEnv = { ...process.env }; + if (proxyCommand) { - const proxy = + let proxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || 'http://localhost:8877'; - sandboxEnv['HTTPS_PROXY'] = proxy; - sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl - sandboxEnv['HTTP_PROXY'] = proxy; - sandboxEnv['http_proxy'] = proxy; + proxy = proxy.replace('localhost', SANDBOX_PROXY_NAME); + if (proxy) { + args.push('--env', `HTTPS_PROXY=${proxy}`); + args.push('--env', `https_proxy=${proxy}`); // lower-case can be required, e.g. for curl + args.push('--env', `HTTP_PROXY=${proxy}`); + args.push('--env', `http_proxy=${proxy}`); + } const noProxy = process.env.NO_PROXY || process.env.no_proxy; if (noProxy) { - sandboxEnv['NO_PROXY'] = noProxy; - sandboxEnv['no_proxy'] = noProxy; + args.push('--env', `NO_PROXY=${noProxy}`); + args.push('--env', `no_proxy=${noProxy}`); } - proxyProcess = spawn(proxyCommand, { + + // 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}`, + ); + 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}`, + ); + } + } + } + + // name container after image, plus numeric suffix to avoid conflicts + const imageName = parseImageName(image); + let index = 0; + const containerNameCheck = execSync( + `${config.command} ps -a --format "{{.Names}}"`, + ) + .toString() + .trim(); + while (containerNameCheck.includes(`${imageName}-${index}`)) { + index++; + } + const containerName = `${imageName}-${index}`; + args.push('--name', containerName, '--hostname', containerName); + + // copy GEMINI_API_KEY(s) + if (process.env.GEMINI_API_KEY) { + args.push('--env', `GEMINI_API_KEY=${process.env.GEMINI_API_KEY}`); + } + if (process.env.GOOGLE_API_KEY) { + args.push('--env', `GOOGLE_API_KEY=${process.env.GOOGLE_API_KEY}`); + } + + // copy GOOGLE_GENAI_USE_VERTEXAI + if (process.env.GOOGLE_GENAI_USE_VERTEXAI) { + args.push( + '--env', + `GOOGLE_GENAI_USE_VERTEXAI=${process.env.GOOGLE_GENAI_USE_VERTEXAI}`, + ); + } + + // copy GOOGLE_GENAI_USE_GCA + if (process.env.GOOGLE_GENAI_USE_GCA) { + args.push( + '--env', + `GOOGLE_GENAI_USE_GCA=${process.env.GOOGLE_GENAI_USE_GCA}`, + ); + } + + // copy GOOGLE_CLOUD_PROJECT + if (process.env.GOOGLE_CLOUD_PROJECT) { + args.push( + '--env', + `GOOGLE_CLOUD_PROJECT=${process.env.GOOGLE_CLOUD_PROJECT}`, + ); + } + + // copy GOOGLE_CLOUD_LOCATION + if (process.env.GOOGLE_CLOUD_LOCATION) { + args.push( + '--env', + `GOOGLE_CLOUD_LOCATION=${process.env.GOOGLE_CLOUD_LOCATION}`, + ); + } + + // copy GEMINI_MODEL + if (process.env.GEMINI_MODEL) { + args.push('--env', `GEMINI_MODEL=${process.env.GEMINI_MODEL}`); + } + + // copy TERM and COLORTERM to try to maintain terminal setup + if (process.env.TERM) { + args.push('--env', `TERM=${process.env.TERM}`); + } + if (process.env.COLORTERM) { + args.push('--env', `COLORTERM=${process.env.COLORTERM}`); + } + + // copy VIRTUAL_ENV if under working directory + // also mount-replace VIRTUAL_ENV directory with /sandbox.venv + // sandbox can then set up this new VIRTUAL_ENV directory using sandbox.bashrc (see below) + // directory will be empty if not set up, which is still preferable to having host binaries + if ( + process.env.VIRTUAL_ENV?.toLowerCase().startsWith(workdir.toLowerCase()) + ) { + const sandboxVenvPath = path.resolve( + SETTINGS_DIRECTORY_NAME, + 'sandbox.venv', + ); + if (!fs.existsSync(sandboxVenvPath)) { + fs.mkdirSync(sandboxVenvPath, { recursive: true }); + } + args.push( + '--volume', + `${sandboxVenvPath}:${getContainerPath(process.env.VIRTUAL_ENV)}`, + ); + args.push( + '--env', + `VIRTUAL_ENV=${getContainerPath(process.env.VIRTUAL_ENV)}`, + ); + } + + // copy additional environment variables from SANDBOX_ENV + if (process.env.SANDBOX_ENV) { + for (let env of process.env.SANDBOX_ENV.split(',')) { + if ((env = env.trim())) { + if (env.includes('=')) { + console.error(`SANDBOX_ENV: ${env}`); + args.push('--env', env); + } else { + console.error( + 'ERROR: SANDBOX_ENV must be a comma-separated list of key=value pairs', + ); + process.exit(1); + } + } + } + } + + // copy NODE_OPTIONS + const existingNodeOptions = process.env.NODE_OPTIONS || ''; + const allNodeOptions = [ + ...(existingNodeOptions ? [existingNodeOptions] : []), + ...nodeArgs, + ].join(' '); + + if (allNodeOptions.length > 0) { + args.push('--env', `NODE_OPTIONS="${allNodeOptions}"`); + } + + // set SANDBOX as container name + args.push('--env', `SANDBOX=${containerName}`); + + // for podman only, use empty --authfile to skip unnecessary auth refresh overhead + if (config.command === 'podman') { + const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json'); + fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8'); + args.push('--authfile', emptyAuthFilePath); + } + + // Determine if the current user's UID/GID should be passed to the sandbox. + // See shouldUseCurrentUserInSandbox for more details. + let userFlag = ''; + const finalEntrypoint = entrypoint(workdir); + + if (process.env.GEMINI_CLI_INTEGRATION_TEST === 'true') { + args.push('--user', 'root'); + userFlag = '--user root'; + } else if (await shouldUseCurrentUserInSandbox()) { + // For the user-creation logic to work, the container must start as root. + // The entrypoint script then handles dropping privileges to the correct user. + args.push('--user', 'root'); + + const uid = execSync('id -u').toString().trim(); + const gid = execSync('id -g').toString().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 + // finally switch to that user to run the gemini process. This is + // necessary on Linux to ensure the user exists within the + // container's /etc/passwd file, which is required by os.userInfo(). + const username = 'gemini'; + const homeDir = getContainerPath(os.homedir()); + + const setupUserCommands = [ + // Use -f with groupadd to avoid errors if the group already exists. + `groupadd -f -g ${gid} ${username}`, + // Create user only if it doesn't exist. Use -o for non-unique UID. + `id -u ${username} &>/dev/null || useradd -o -u ${uid} -g ${gid} -d ${homeDir} -s /bin/bash ${username}`, + ].join(' && '); + + const originalCommand = finalEntrypoint[2]; + const escapedOriginalCommand = originalCommand.replace(/'/g, "'\\''"); + + // Use `su -p` to preserve the environment. + const suCommand = `su -p ${username} -c '${escapedOriginalCommand}'`; + + // The entrypoint is always `['bash', '-c', '']`, so we modify the command part. + finalEntrypoint[2] = `${setupUserCommands} && ${suCommand}`; + + // We still need userFlag for the simpler proxy container, which does not have this issue. + userFlag = `--user ${uid}:${gid}`; + // When forcing a UID in the sandbox, $HOME can be reset to '/', so we copy $HOME as well. + args.push('--env', `HOME=${os.homedir()}`); + } + + // push container image name + args.push(image); + + // push container entrypoint (including args) + args.push(...finalEntrypoint); + + // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set + let proxyProcess: ChildProcess | undefined = undefined; + let sandboxProcess: ChildProcess | undefined = undefined; + + if (proxyCommand) { + // run proxyCommand in its own container + 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, detached: true, }); // install handlers to stop proxy on exit/signal const stopProxy = () => { - console.log('stopping proxy ...'); - if (proxyProcess?.pid) { - process.kill(-proxyProcess.pid, 'SIGTERM'); - } + console.log('stopping proxy container ...'); + execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`); }; process.on('exit', stopProxy); process.on('SIGINT', stopProxy); @@ -308,11 +754,11 @@ export async function start_sandbox( // console.info(data.toString()); // }); proxyProcess.stderr?.on('data', (data) => { - console.error(data.toString()); + console.error(data.toString().trim()); }); proxyProcess.on('close', (code, signal) => { console.error( - `ERROR: proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`, + `ERROR: proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`, ); if (sandboxProcess?.pid) { process.kill(-sandboxProcess.pid, 'SIGTERM'); @@ -323,467 +769,35 @@ export async function start_sandbox( await execAsync( `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, ); + // 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}`, + ); } + // spawn child and let it inherit stdio sandboxProcess = spawn(config.command, args, { stdio: 'inherit', }); - await new Promise((resolve) => sandboxProcess?.on('close', resolve)); - return; - } - console.error(`hopping into sandbox (command: ${config.command}) ...`); + sandboxProcess.on('error', (err) => { + console.error('Sandbox process error:', err); + }); - // determine full path for gemini-cli to distinguish linked vs installed setting - const gcPath = fs.realpathSync(process.argv[1]); - - const projectSandboxDockerfile = path.join( - SETTINGS_DIRECTORY_NAME, - 'sandbox.Dockerfile', - ); - const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile); - - const image = config.image; - const workdir = path.resolve(process.cwd()); - const containerWorkdir = getContainerPath(workdir); - - // if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under gemini-cli repo - // - // note this can only be done with binary linked from gemini-cli repo - if (process.env.BUILD_SANDBOX) { - if (!gcPath.includes('gemini-cli/packages/')) { - console.error( - 'ERROR: cannot build sandbox using installed gemini binary; ' + - 'run `npm link ./packages/cli` under gemini-cli repo to switch to linked binary.', - ); - process.exit(1); - } else { - console.error('building sandbox ...'); - const gcRoot = gcPath.split('/packages/')[0]; - // if project folder has sandbox.Dockerfile under project settings folder, use that - let buildArgs = ''; - const projectSandboxDockerfile = path.join( - SETTINGS_DIRECTORY_NAME, - 'sandbox.Dockerfile', - ); - if (isCustomProjectSandbox) { - console.error(`using ${projectSandboxDockerfile} for sandbox`); - buildArgs += `-f ${path.resolve(projectSandboxDockerfile)} -i ${image}`; - } - execSync( - `cd ${gcRoot} && node scripts/build_sandbox.js -s ${buildArgs}`, - { - stdio: 'inherit', - env: { - ...process.env, - GEMINI_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package) - }, - }, - ); - } - } - - // stop if image is missing - 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.' - : 'Please check the image name, your network connection, or notify gemini-cli-dev@google.com if the issue persists.'; - console.error( - `ERROR: Sandbox image '${image}' is missing or could not be pulled. ${remedy}`, - ); - process.exit(1); - } - - // 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'); - } - - // mount current directory as working directory in sandbox (set via --workdir) - args.push('--volume', `${workdir}:${containerWorkdir}`); - - // mount user settings directory inside container, after creating if missing - // note user/home changes inside sandbox and we mount at BOTH paths for consistency - const userSettingsDirOnHost = USER_SETTINGS_DIR; - const userSettingsDirInSandbox = getContainerPath( - `/home/node/${SETTINGS_DIRECTORY_NAME}`, - ); - if (!fs.existsSync(userSettingsDirOnHost)) { - fs.mkdirSync(userSettingsDirOnHost); - } - args.push('--volume', `${userSettingsDirOnHost}:${userSettingsDirInSandbox}`); - if (userSettingsDirInSandbox !== userSettingsDirOnHost) { - args.push( - '--volume', - `${userSettingsDirOnHost}:${getContainerPath(userSettingsDirOnHost)}`, - ); - } - - // mount os.tmpdir() as os.tmpdir() inside container - args.push('--volume', `${os.tmpdir()}:${getContainerPath(os.tmpdir())}`); - - // mount gcloud config directory if it exists - const gcloudConfigDir = path.join(os.homedir(), '.config', 'gcloud'); - if (fs.existsSync(gcloudConfigDir)) { - args.push( - '--volume', - `${gcloudConfigDir}:${getContainerPath(gcloudConfigDir)}:ro`, - ); - } - - // mount ADC file if GOOGLE_APPLICATION_CREDENTIALS is set - if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { - const adcFile = process.env.GOOGLE_APPLICATION_CREDENTIALS; - if (fs.existsSync(adcFile)) { - args.push('--volume', `${adcFile}:${getContainerPath(adcFile)}:ro`); - args.push( - '--env', - `GOOGLE_APPLICATION_CREDENTIALS=${getContainerPath(adcFile)}`, - ); - } - } - - // mount paths listed in SANDBOX_MOUNTS - if (process.env.SANDBOX_MOUNTS) { - for (let mount of process.env.SANDBOX_MOUNTS.split(',')) { - if (mount.trim()) { - // parse mount as from:to:opts - let [from, to, opts] = mount.trim().split(':'); - to = to || from; // default to mount at same path inside container - opts = opts || 'ro'; // default to read-only - mount = `${from}:${to}:${opts}`; - // check that from path is absolute - if (!path.isAbsolute(from)) { - console.error( - `ERROR: path '${from}' listed in SANDBOX_MOUNTS must be absolute`, + await new Promise((resolve) => { + sandboxProcess?.on('close', (code, signal) => { + if (code !== 0) { + console.log( + `Sandbox process exited with code: ${code}, signal: ${signal}`, ); - process.exit(1); } - // check that from path exists on host - if (!fs.existsSync(from)) { - console.error( - `ERROR: missing mount path '${from}' listed in SANDBOX_MOUNTS`, - ); - process.exit(1); - } - console.error(`SANDBOX_MOUNTS: ${from} -> ${to} (${opts})`); - args.push('--volume', mount); - } - } - } - - // expose env-specified ports on the sandbox - ports().forEach((p) => args.push('--publish', `${p}:${p}`)); - - // if DEBUG is set, expose debugging port - if (process.env.DEBUG) { - const debugPort = process.env.DEBUG_PORT || '9229'; - args.push(`--publish`, `${debugPort}:${debugPort}`); - } - - // copy proxy environment variables, replacing localhost with SANDBOX_PROXY_NAME - // copy as both upper-case and lower-case as is required by some utilities - // GEMINI_SANDBOX_PROXY_COMMAND implies HTTPS_PROXY unless HTTP_PROXY is set - const proxyCommand = process.env.GEMINI_SANDBOX_PROXY_COMMAND; - - if (proxyCommand) { - let proxy = - process.env.HTTPS_PROXY || - process.env.https_proxy || - process.env.HTTP_PROXY || - process.env.http_proxy || - 'http://localhost:8877'; - proxy = proxy.replace('localhost', SANDBOX_PROXY_NAME); - if (proxy) { - args.push('--env', `HTTPS_PROXY=${proxy}`); - args.push('--env', `https_proxy=${proxy}`); // lower-case can be required, e.g. for curl - args.push('--env', `HTTP_PROXY=${proxy}`); - args.push('--env', `http_proxy=${proxy}`); - } - const noProxy = process.env.NO_PROXY || process.env.no_proxy; - if (noProxy) { - args.push('--env', `NO_PROXY=${noProxy}`); - args.push('--env', `no_proxy=${noProxy}`); - } - - // 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}`, - ); - 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}`, - ); - } - } - } - - // name container after image, plus numeric suffix to avoid conflicts - const imageName = parseImageName(image); - let index = 0; - const containerNameCheck = execSync( - `${config.command} ps -a --format "{{.Names}}"`, - ) - .toString() - .trim(); - while (containerNameCheck.includes(`${imageName}-${index}`)) { - index++; - } - const containerName = `${imageName}-${index}`; - args.push('--name', containerName, '--hostname', containerName); - - // copy GEMINI_API_KEY(s) - if (process.env.GEMINI_API_KEY) { - args.push('--env', `GEMINI_API_KEY=${process.env.GEMINI_API_KEY}`); - } - if (process.env.GOOGLE_API_KEY) { - args.push('--env', `GOOGLE_API_KEY=${process.env.GOOGLE_API_KEY}`); - } - - // copy GOOGLE_GENAI_USE_VERTEXAI - if (process.env.GOOGLE_GENAI_USE_VERTEXAI) { - args.push( - '--env', - `GOOGLE_GENAI_USE_VERTEXAI=${process.env.GOOGLE_GENAI_USE_VERTEXAI}`, - ); - } - - // copy GOOGLE_GENAI_USE_GCA - if (process.env.GOOGLE_GENAI_USE_GCA) { - args.push( - '--env', - `GOOGLE_GENAI_USE_GCA=${process.env.GOOGLE_GENAI_USE_GCA}`, - ); - } - - // copy GOOGLE_CLOUD_PROJECT - if (process.env.GOOGLE_CLOUD_PROJECT) { - args.push( - '--env', - `GOOGLE_CLOUD_PROJECT=${process.env.GOOGLE_CLOUD_PROJECT}`, - ); - } - - // copy GOOGLE_CLOUD_LOCATION - if (process.env.GOOGLE_CLOUD_LOCATION) { - args.push( - '--env', - `GOOGLE_CLOUD_LOCATION=${process.env.GOOGLE_CLOUD_LOCATION}`, - ); - } - - // copy GEMINI_MODEL - if (process.env.GEMINI_MODEL) { - args.push('--env', `GEMINI_MODEL=${process.env.GEMINI_MODEL}`); - } - - // copy TERM and COLORTERM to try to maintain terminal setup - if (process.env.TERM) { - args.push('--env', `TERM=${process.env.TERM}`); - } - if (process.env.COLORTERM) { - args.push('--env', `COLORTERM=${process.env.COLORTERM}`); - } - - // copy VIRTUAL_ENV if under working directory - // also mount-replace VIRTUAL_ENV directory with /sandbox.venv - // sandbox can then set up this new VIRTUAL_ENV directory using sandbox.bashrc (see below) - // directory will be empty if not set up, which is still preferable to having host binaries - if ( - process.env.VIRTUAL_ENV?.toLowerCase().startsWith(workdir.toLowerCase()) - ) { - const sandboxVenvPath = path.resolve( - SETTINGS_DIRECTORY_NAME, - 'sandbox.venv', - ); - if (!fs.existsSync(sandboxVenvPath)) { - fs.mkdirSync(sandboxVenvPath, { recursive: true }); - } - args.push( - '--volume', - `${sandboxVenvPath}:${getContainerPath(process.env.VIRTUAL_ENV)}`, - ); - args.push( - '--env', - `VIRTUAL_ENV=${getContainerPath(process.env.VIRTUAL_ENV)}`, - ); - } - - // copy additional environment variables from SANDBOX_ENV - if (process.env.SANDBOX_ENV) { - for (let env of process.env.SANDBOX_ENV.split(',')) { - if ((env = env.trim())) { - if (env.includes('=')) { - console.error(`SANDBOX_ENV: ${env}`); - args.push('--env', env); - } else { - console.error( - 'ERROR: SANDBOX_ENV must be a comma-separated list of key=value pairs', - ); - process.exit(1); - } - } - } - } - - // copy NODE_OPTIONS - const existingNodeOptions = process.env.NODE_OPTIONS || ''; - const allNodeOptions = [ - ...(existingNodeOptions ? [existingNodeOptions] : []), - ...nodeArgs, - ].join(' '); - - if (allNodeOptions.length > 0) { - args.push('--env', `NODE_OPTIONS="${allNodeOptions}"`); - } - - // set SANDBOX as container name - args.push('--env', `SANDBOX=${containerName}`); - - // for podman only, use empty --authfile to skip unnecessary auth refresh overhead - if (config.command === 'podman') { - const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json'); - fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8'); - args.push('--authfile', emptyAuthFilePath); - } - - // Determine if the current user's UID/GID should be passed to the sandbox. - // See shouldUseCurrentUserInSandbox for more details. - let userFlag = ''; - const finalEntrypoint = entrypoint(workdir); - - if (process.env.GEMINI_CLI_INTEGRATION_TEST === 'true') { - args.push('--user', 'root'); - userFlag = '--user root'; - } else if (await shouldUseCurrentUserInSandbox()) { - // For the user-creation logic to work, the container must start as root. - // The entrypoint script then handles dropping privileges to the correct user. - args.push('--user', 'root'); - - const uid = execSync('id -u').toString().trim(); - const gid = execSync('id -g').toString().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 - // finally switch to that user to run the gemini process. This is - // necessary on Linux to ensure the user exists within the - // container's /etc/passwd file, which is required by os.userInfo(). - const username = 'gemini'; - const homeDir = getContainerPath(os.homedir()); - - const setupUserCommands = [ - // Use -f with groupadd to avoid errors if the group already exists. - `groupadd -f -g ${gid} ${username}`, - // Create user only if it doesn't exist. Use -o for non-unique UID. - `id -u ${username} &>/dev/null || useradd -o -u ${uid} -g ${gid} -d ${homeDir} -s /bin/bash ${username}`, - ].join(' && '); - - const originalCommand = finalEntrypoint[2]; - const escapedOriginalCommand = originalCommand.replace(/'/g, "'\\''"); - - // Use `su -p` to preserve the environment. - const suCommand = `su -p ${username} -c '${escapedOriginalCommand}'`; - - // The entrypoint is always `['bash', '-c', '']`, so we modify the command part. - finalEntrypoint[2] = `${setupUserCommands} && ${suCommand}`; - - // We still need userFlag for the simpler proxy container, which does not have this issue. - userFlag = `--user ${uid}:${gid}`; - // When forcing a UID in the sandbox, $HOME can be reset to '/', so we copy $HOME as well. - args.push('--env', `HOME=${os.homedir()}`); - } - - // push container image name - args.push(image); - - // push container entrypoint (including args) - args.push(...finalEntrypoint); - - // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set - let proxyProcess: ChildProcess | undefined = undefined; - let sandboxProcess: ChildProcess | undefined = undefined; - - if (proxyCommand) { - // run proxyCommand in its own container - 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, - detached: true, + resolve(); + }); }); - // install handlers to stop proxy on exit/signal - const stopProxy = () => { - console.log('stopping proxy container ...'); - execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`); - }; - process.on('exit', stopProxy); - process.on('SIGINT', stopProxy); - process.on('SIGTERM', stopProxy); - - // commented out as it disrupts ink rendering - // proxyProcess.stdout?.on('data', (data) => { - // console.info(data.toString()); - // }); - proxyProcess.stderr?.on('data', (data) => { - console.error(data.toString().trim()); - }); - proxyProcess.on('close', (code, signal) => { - console.error( - `ERROR: proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`, - ); - if (sandboxProcess?.pid) { - process.kill(-sandboxProcess.pid, 'SIGTERM'); - } - process.exit(1); - }); - console.log('waiting for proxy to start ...'); - await execAsync( - `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, - ); - // 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}`, - ); + } finally { + patcher.cleanup(); } - - // spawn child and let it inherit stdio - sandboxProcess = spawn(config.command, args, { - stdio: 'inherit', - }); - - sandboxProcess.on('error', (err) => { - console.error('Sandbox process error:', err); - }); - - await new Promise((resolve) => { - sandboxProcess?.on('close', (code, signal) => { - if (code !== 0) { - console.log( - `Sandbox process exited with code: ${code}, signal: ${signal}`, - ); - } - resolve(); - }); - }); } // Helper functions to ensure sandbox image is present