2025-04-30 00:38:25 +00:00
/ * *
* @license
* Copyright 2025 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
2025-06-11 10:50:31 -07:00
import { exec , execSync , spawn , type ChildProcess } from 'node:child_process' ;
2025-04-30 00:38:25 +00:00
import os from 'node:os' ;
import path from 'node:path' ;
import fs from 'node:fs' ;
2025-05-13 21:13:54 +00:00
import { readFile } from 'node:fs/promises' ;
2025-04-30 00:38:25 +00:00
import { quote } from 'shell-quote' ;
2025-05-01 12:08:24 -07:00
import {
USER_SETTINGS_DIR ,
SETTINGS_DIRECTORY_NAME ,
} from '../config/settings.js' ;
2025-06-11 10:50:31 -07:00
import { promisify } from 'util' ;
2025-06-25 05:41:11 -07:00
import { SandboxConfig } from '@google/gemini-cli-core' ;
2025-06-11 10:50:31 -07:00
const execAsync = promisify ( exec ) ;
2025-04-30 00:38:25 +00:00
2025-06-09 12:19:42 -07:00
function getContainerPath ( hostPath : string ) : string {
if ( os . platform ( ) !== 'win32' ) {
return hostPath ;
}
const withForwardSlashes = hostPath . replace ( /\\/g , '/' ) ;
const match = withForwardSlashes . match ( /^([A-Z]):\/(.*)/i ) ;
if ( match ) {
return ` / ${ match [ 1 ] . toLowerCase ( ) } / ${ match [ 2 ] } ` ;
}
return hostPath ;
}
2025-06-03 19:32:17 +00:00
const LOCAL_DEV_SANDBOX_IMAGE_NAME = 'gemini-cli-sandbox' ;
2025-06-10 08:58:37 -07:00
const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox' ;
const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy' ;
const BUILTIN_SEATBELT_PROFILES = [
'permissive-open' ,
'permissive-closed' ,
'permissive-proxied' ,
'restrictive-open' ,
'restrictive-closed' ,
'restrictive-proxied' ,
] ;
2025-06-03 19:32:17 +00:00
2025-05-13 21:13:54 +00:00
/ * *
* Determines whether the sandbox container should be run with the current user ' s UID and GID .
* This is often necessary on Linux systems ( especially Debian / Ubuntu based ) when using
* rootful Docker without userns - remap configured , to avoid permission issues with
* mounted volumes .
*
* The behavior is controlled by the ` SANDBOX_SET_UID_GID ` environment variable :
* - If ` SANDBOX_SET_UID_GID ` is "1" or "true" , this function returns ` true ` .
* - If ` SANDBOX_SET_UID_GID ` is "0" or "false" , this function returns ` false ` .
* - If ` SANDBOX_SET_UID_GID ` is not set :
* - On Debian / Ubuntu Linux , it defaults to ` true ` .
* - On other OSes , or if OS detection fails , it defaults to ` false ` .
*
* For more context on running Docker containers as non - root , see :
* https : //medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15
*
* @returns { Promise < boolean > } A promise that resolves to true if the current user ' s UID / GID should be used , false otherwise .
* /
async function shouldUseCurrentUserInSandbox ( ) : Promise < boolean > {
const envVar = process . env . SANDBOX_SET_UID_GID ? . toLowerCase ( ) . trim ( ) ;
if ( envVar === '1' || envVar === 'true' ) {
return true ;
}
if ( envVar === '0' || envVar === 'false' ) {
return false ;
}
// If environment variable is not explicitly set, check for Debian/Ubuntu Linux
if ( os . platform ( ) === 'linux' ) {
try {
const osReleaseContent = await readFile ( '/etc/os-release' , 'utf8' ) ;
if (
osReleaseContent . includes ( 'ID=debian' ) ||
osReleaseContent . includes ( 'ID=ubuntu' ) ||
osReleaseContent . match ( /^ID_LIKE=.*debian.*/m ) || // Covers derivatives
osReleaseContent . match ( /^ID_LIKE=.*ubuntu.*/m ) // Covers derivatives
) {
2025-05-15 10:54:30 -07:00
// note here and below we use console.error for informational messages on stderr
console . error (
2025-05-13 21:13:54 +00:00
'INFO: Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.' ,
) ;
return true ;
}
} catch ( _err ) {
// Silently ignore if /etc/os-release is not found or unreadable.
// The default (false) will be applied in this case.
console . warn (
'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.' ,
) ;
}
}
return false ; // Default to false if no other condition is met
}
2025-04-30 17:16:29 +00:00
// docker does not allow container names to contain ':' or '/', so we
2025-07-21 17:54:44 -04:00
// parse those out to shorten the name
2025-04-30 07:39:00 +00:00
function parseImageName ( image : string ) : string {
2025-04-30 17:16:29 +00:00
const [ fullName , tag ] = image . split ( ':' ) ;
const name = fullName . split ( '/' ) . at ( - 1 ) ? ? 'unknown-image' ;
return tag ? ` ${ name } - ${ tag } ` : name ;
2025-04-30 07:39:00 +00:00
}
2025-05-07 14:23:13 +00:00
function ports ( ) : string [ ] {
return ( process . env . SANDBOX_PORTS ? ? '' )
. split ( ',' )
. filter ( ( p ) = > p . trim ( ) )
. map ( ( p ) = > p . trim ( ) ) ;
}
function entrypoint ( workdir : string ) : string [ ] {
2025-06-09 12:19:42 -07:00
const isWindows = os . platform ( ) === 'win32' ;
const containerWorkdir = getContainerPath ( workdir ) ;
const shellCmds = [ ] ;
const pathSeparator = isWindows ? ';' : ':' ;
2025-05-07 14:23:13 +00:00
let pathSuffix = '' ;
if ( process . env . PATH ) {
2025-06-09 12:19:42 -07:00
const paths = process . env . PATH . split ( pathSeparator ) ;
for ( const p of paths ) {
const containerPath = getContainerPath ( p ) ;
if (
containerPath . toLowerCase ( ) . startsWith ( containerWorkdir . toLowerCase ( ) )
) {
pathSuffix += ` : ${ containerPath } ` ;
2025-05-07 14:23:13 +00:00
}
}
}
if ( pathSuffix ) {
2025-06-09 12:19:42 -07:00
shellCmds . push ( ` export PATH=" $ PATH ${ pathSuffix } "; ` ) ;
2025-05-07 14:23:13 +00:00
}
let pythonPathSuffix = '' ;
if ( process . env . PYTHONPATH ) {
2025-06-09 12:19:42 -07:00
const paths = process . env . PYTHONPATH . split ( pathSeparator ) ;
for ( const p of paths ) {
const containerPath = getContainerPath ( p ) ;
if (
containerPath . toLowerCase ( ) . startsWith ( containerWorkdir . toLowerCase ( ) )
) {
pythonPathSuffix += ` : ${ containerPath } ` ;
2025-05-07 14:23:13 +00:00
}
}
}
if ( pythonPathSuffix ) {
2025-06-09 12:19:42 -07:00
shellCmds . push ( ` export PYTHONPATH=" $ PYTHONPATH ${ pythonPathSuffix } "; ` ) ;
2025-05-07 14:23:13 +00:00
}
const projectSandboxBashrc = path . join (
SETTINGS_DIRECTORY_NAME ,
'sandbox.bashrc' ,
) ;
if ( fs . existsSync ( projectSandboxBashrc ) ) {
2025-06-09 12:19:42 -07:00
shellCmds . push ( ` source ${ getContainerPath ( projectSandboxBashrc ) } ; ` ) ;
2025-05-07 14:23:13 +00:00
}
ports ( ) . forEach ( ( p ) = >
2025-06-09 12:19:42 -07:00
shellCmds . push (
2025-05-07 14:23:13 +00:00
` socat TCP4-LISTEN: ${ p } ,bind= $ (hostname -i),fork,reuseaddr TCP4:127.0.0.1: ${ p } 2> /dev/null & ` ,
) ,
) ;
const cliArgs = process . argv . slice ( 2 ) . map ( ( arg ) = > quote ( [ arg ] ) ) ;
const cliCmd =
process . env . NODE_ENV === 'development'
? process . env . DEBUG
? 'npm run debug --'
2025-06-08 01:04:20 -04:00
: 'npm rebuild && npm run start --'
2025-06-09 12:19:42 -07:00
: process . env . DEBUG
2025-05-13 17:49:45 +00:00
? ` node --inspect-brk=0.0.0.0: ${ process . env . DEBUG_PORT || '9229' } $ (which gemini) `
: 'gemini' ;
2025-05-07 14:23:13 +00:00
2025-06-09 12:19:42 -07:00
const args = [ . . . shellCmds , cliCmd , . . . cliArgs ] ;
2025-05-07 14:23:13 +00:00
return [ 'bash' , '-c' , args . join ( ' ' ) ] ;
}
2025-06-24 21:18:55 +00:00
export async function start_sandbox (
config : SandboxConfig ,
nodeArgs : string [ ] = [ ] ,
) {
2025-06-18 10:01:00 -07:00
if ( config . command === 'sandbox-exec' ) {
2025-05-09 08:44:40 -07:00
// disallow BUILD_SANDBOX
if ( process . env . BUILD_SANDBOX ) {
2025-07-21 17:54:44 -04:00
console . error ( 'ERROR: cannot BUILD_SANDBOX when using macOS Seatbelt' ) ;
2025-05-09 08:44:40 -07:00
process . exit ( 1 ) ;
}
2025-06-10 08:58:37 -07:00
const profile = ( process . env . SEATBELT_PROFILE ? ? = 'permissive-open' ) ;
2025-05-09 11:33:05 -07:00
let profileFile = new URL ( ` sandbox-macos- ${ profile } .sb ` , import . meta . url )
. pathname ;
2025-06-10 08:58:37 -07:00
// if profile name is not recognized, then look for file under project settings directory
if ( ! BUILTIN_SEATBELT_PROFILES . includes ( profile ) ) {
2025-05-09 11:33:05 -07:00
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 ) ;
}
2025-06-18 11:40:15 -07:00
// Log on STDERR so it doesn't clutter the output on STDOUT
2025-05-15 10:54:30 -07:00
console . error ( ` using macos seatbelt (profile: ${ profile } ) ... ` ) ;
2025-05-09 08:44:40 -07:00
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
2025-06-24 21:18:55 +00:00
const nodeOptions = [
. . . ( process . env . DEBUG ? [ '--inspect-brk' ] : [ ] ) ,
. . . nodeArgs ,
] . join ( ' ' ) ;
2025-05-07 20:03:29 -07:00
const args = [
'-D' ,
2025-05-08 11:28:45 -07:00
` TARGET_DIR= ${ fs . realpathSync ( process . cwd ( ) ) } ` ,
2025-05-07 20:03:29 -07:00
'-D' ,
` TMP_DIR= ${ fs . realpathSync ( os . tmpdir ( ) ) } ` ,
2025-05-08 11:28:45 -07:00
'-D' ,
` HOME_DIR= ${ fs . realpathSync ( os . homedir ( ) ) } ` ,
2025-05-29 15:06:09 -07:00
'-D' ,
` CACHE_DIR= ${ fs . realpathSync ( execSync ( ` getconf DARWIN_USER_CACHE_DIR ` ) . toString ( ) . trim ( ) ) } ` ,
2025-05-07 20:03:29 -07:00
'-f' ,
2025-05-09 11:33:05 -07:00
profileFile ,
2025-06-09 12:19:42 -07:00
'sh' ,
2025-05-07 20:03:29 -07:00
'-c' ,
2025-05-09 08:44:40 -07:00
[
` SANDBOX=sandbox-exec ` ,
2025-06-24 21:18:55 +00:00
` NODE_OPTIONS=" ${ nodeOptions } " ` ,
2025-05-09 08:44:40 -07:00
. . . process . argv . map ( ( arg ) = > quote ( [ arg ] ) ) ,
] . join ( ' ' ) ,
2025-05-07 20:03:29 -07:00
] ;
2025-06-10 08:58:37 -07:00
// start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set
const proxyCommand = process . env . GEMINI_SANDBOX_PROXY_COMMAND ;
2025-06-11 10:50:31 -07:00
let proxyProcess : ChildProcess | undefined = undefined ;
let sandboxProcess : ChildProcess | undefined = undefined ;
2025-06-10 08:58:37 -07:00
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 ,
} ) ;
2025-06-11 10:50:31 -07:00
// 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 ) ;
2025-06-10 08:58:37 -07:00
// 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 ( ) ) ;
} ) ;
2025-06-11 10:50:31 -07:00
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 ) ;
2025-06-10 08:58:37 -07:00
} ) ;
2025-06-11 10:50:31 -07:00
console . log ( 'waiting for proxy to start ...' ) ;
await execAsync (
2025-06-11 11:31:38 -07:00
` until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done ` ,
2025-06-11 10:50:31 -07:00
) ;
2025-06-10 08:58:37 -07:00
}
2025-06-11 10:50:31 -07:00
// spawn child and let it inherit stdio
2025-06-18 10:01:00 -07:00
sandboxProcess = spawn ( config . command , args , {
2025-06-11 10:50:31 -07:00
stdio : 'inherit' ,
} ) ;
await new Promise ( ( resolve ) = > sandboxProcess ? . on ( 'close' , resolve ) ) ;
2025-05-07 20:03:29 -07:00
return ;
}
2025-06-18 10:01:00 -07:00
console . error ( ` hopping into sandbox (command: ${ config . command } ) ... ` ) ;
2025-05-08 14:50:35 -07:00
2025-06-03 14:02:00 -07:00
// determine full path for gemini-cli to distinguish linked vs installed setting
2025-06-09 12:19:42 -07:00
const gcPath = fs . realpathSync ( process . argv [ 1 ] ) ;
2025-04-30 00:38:25 +00:00
2025-06-05 17:46:54 +02:00
const projectSandboxDockerfile = path . join (
SETTINGS_DIRECTORY_NAME ,
'sandbox.Dockerfile' ,
) ;
const isCustomProjectSandbox = fs . existsSync ( projectSandboxDockerfile ) ;
2025-06-18 10:01:00 -07:00
const image = config . image ;
2025-06-09 12:19:42 -07:00
const workdir = path . resolve ( process . cwd ( ) ) ;
const containerWorkdir = getContainerPath ( workdir ) ;
2025-04-30 00:38:25 +00:00
2025-06-12 19:38:10 +02:00
// if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under gemini-cli repo
2025-06-05 17:46:54 +02:00
//
2025-06-03 14:02:00 -07:00
// note this can only be done with binary linked from gemini-cli repo
2025-06-05 18:47:39 +02:00
if ( process . env . BUILD_SANDBOX ) {
2025-06-03 14:02:00 -07:00
if ( ! gcPath . includes ( 'gemini-cli/packages/' ) ) {
2025-04-30 00:38:25 +00:00
console . error (
2025-06-05 17:46:54 +02:00
'ERROR: cannot build sandbox using installed gemini binary; ' +
2025-06-03 14:02:00 -07:00
'run `npm link ./packages/cli` under gemini-cli repo to switch to linked binary.' ,
2025-04-30 00:38:25 +00:00
) ;
process . exit ( 1 ) ;
} else {
2025-05-15 10:54:30 -07:00
console . error ( 'building sandbox ...' ) ;
2025-04-30 00:38:25 +00:00
const gcRoot = gcPath . split ( '/packages/' ) [ 0 ] ;
2025-05-02 14:07:40 -07:00
// if project folder has sandbox.Dockerfile under project settings folder, use that
let buildArgs = '' ;
const projectSandboxDockerfile = path . join (
SETTINGS_DIRECTORY_NAME ,
'sandbox.Dockerfile' ,
) ;
2025-06-05 17:46:54 +02:00
if ( isCustomProjectSandbox ) {
2025-05-15 10:54:30 -07:00
console . error ( ` using ${ projectSandboxDockerfile } for sandbox ` ) ;
2025-06-08 16:43:04 -07:00
buildArgs += ` -f ${ path . resolve ( projectSandboxDockerfile ) } -i ${ image } ` ;
2025-05-02 14:07:40 -07:00
}
2025-06-12 19:38:10 +02:00
execSync (
` cd ${ gcRoot } && node scripts/build_sandbox.js -s ${ buildArgs } ` ,
{
stdio : 'inherit' ,
env : {
. . . process . env ,
2025-06-18 10:01:00 -07:00
GEMINI_SANDBOX : config.command , // in case sandbox is enabled via flags (see config.ts under cli package)
2025-06-12 19:38:10 +02:00
} ,
2025-06-03 14:02:00 -07:00
} ,
2025-06-12 19:38:10 +02:00
) ;
2025-04-30 00:38:25 +00:00
}
}
// stop if image is missing
2025-06-18 10:01:00 -07:00
if ( ! ( await ensureSandboxImageIsPresent ( config . command , image ) ) ) {
2025-06-03 19:32:17 +00:00
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.' ;
2025-05-30 20:49:47 +00:00
console . error (
` ERROR: Sandbox image ' ${ image } ' is missing or could not be pulled. ${ remedy } ` ,
) ;
2025-04-30 00:38:25 +00:00
process . exit ( 1 ) ;
}
2025-06-04 08:24:33 +02:00
// use interactive mode and auto-remove container on exit
2025-04-30 00:38:25 +00:00
// run init binary inside container to forward signals & reap zombies
2025-06-09 12:19:42 -07:00
const args = [ 'run' , '-i' , '--rm' , '--init' , '--workdir' , containerWorkdir ] ;
2025-06-04 08:24:33 +02:00
// 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' ) ;
}
2025-04-30 00:38:25 +00:00
2025-05-02 12:04:22 -07:00
// mount current directory as working directory in sandbox (set via --workdir)
2025-06-09 12:19:42 -07:00
args . push ( '--volume' , ` ${ workdir } : ${ containerWorkdir } ` ) ;
2025-04-30 00:38:25 +00:00
2025-05-01 12:08:24 -07:00
// 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 ;
2025-06-10 00:27:40 -07:00
const userSettingsDirInSandbox = getContainerPath (
` /home/node/ ${ SETTINGS_DIRECTORY_NAME } ` ,
) ;
2025-05-01 12:08:24 -07:00
if ( ! fs . existsSync ( userSettingsDirOnHost ) ) {
fs . mkdirSync ( userSettingsDirOnHost ) ;
}
2025-06-09 12:19:42 -07:00
args . push ( '--volume' , ` ${ userSettingsDirOnHost } : ${ userSettingsDirInSandbox } ` ) ;
2025-05-01 12:08:24 -07:00
if ( userSettingsDirInSandbox !== userSettingsDirOnHost ) {
args . push (
'--volume' ,
2025-06-09 12:19:42 -07:00
` ${ userSettingsDirOnHost } : ${ getContainerPath ( userSettingsDirOnHost ) } ` ,
2025-05-01 12:08:24 -07:00
) ;
}
2025-06-09 12:19:42 -07:00
// mount os.tmpdir() as os.tmpdir() inside container
args . push ( '--volume' , ` ${ os . tmpdir ( ) } : ${ getContainerPath ( os . tmpdir ( ) ) } ` ) ;
2025-04-30 00:38:25 +00:00
2025-06-16 10:55:13 +08:00
// 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 ) } ` ,
) ;
}
}
2025-04-30 00:38:25 +00:00
// 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 ) ;
}
2025-05-15 10:54:30 -07:00
console . error ( ` SANDBOX_MOUNTS: ${ from } -> ${ to } ( ${ opts } ) ` ) ;
2025-04-30 00:38:25 +00:00
args . push ( '--volume' , mount ) ;
}
}
}
2025-05-07 14:23:13 +00:00
// expose env-specified ports on the sandbox
ports ( ) . forEach ( ( p ) = > args . push ( '--publish' , ` ${ p } : ${ p } ` ) ) ;
2025-05-09 08:44:40 -07:00
// if DEBUG is set, expose debugging port
2025-05-07 14:23:13 +00:00
if ( process . env . DEBUG ) {
const debugPort = process . env . DEBUG_PORT || '9229' ;
args . push ( ` --publish ` , ` ${ debugPort } : ${ debugPort } ` ) ;
}
2025-06-10 08:58:37 -07:00
// 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 ;
2025-06-16 08:27:29 -07:00
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 ) {
2025-06-10 08:58:37 -07:00
execSync (
2025-06-18 10:01:00 -07:00
` ${ config . command } network inspect ${ SANDBOX_NETWORK_NAME } || ${ config . command } network create --internal ${ SANDBOX_NETWORK_NAME } ` ,
2025-06-10 08:58:37 -07:00
) ;
2025-06-16 08:27:29 -07:00
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 (
2025-06-18 10:01:00 -07:00
` ${ config . command } network inspect ${ SANDBOX_PROXY_NAME } || ${ config . command } network create ${ SANDBOX_PROXY_NAME } ` ,
2025-06-16 08:27:29 -07:00
) ;
}
2025-06-10 08:58:37 -07:00
}
}
2025-04-30 00:38:25 +00:00
// name container after image, plus numeric suffix to avoid conflicts
2025-05-30 19:28:46 +00:00
const imageName = parseImageName ( image ) ;
2025-04-30 00:38:25 +00:00
let index = 0 ;
2025-06-18 10:01:00 -07:00
const containerNameCheck = execSync (
` ${ config . command } ps -a --format "{{.Names}}" ` ,
)
2025-06-09 12:19:42 -07:00
. toString ( )
. trim ( ) ;
while ( containerNameCheck . includes ( ` ${ imageName } - ${ index } ` ) ) {
2025-04-30 00:38:25 +00:00
index ++ ;
}
2025-05-30 19:28:46 +00:00
const containerName = ` ${ imageName } - ${ index } ` ;
args . push ( '--name' , containerName , '--hostname' , containerName ) ;
2025-04-30 00:38:25 +00:00
2025-06-13 16:32:15 +08:00
// copy GEMINI_API_KEY(s)
2025-04-30 00:38:25 +00:00
if ( process . env . GEMINI_API_KEY ) {
args . push ( '--env' , ` GEMINI_API_KEY= ${ process . env . GEMINI_API_KEY } ` ) ;
}
2025-06-13 16:32:15 +08:00
if ( process . env . GOOGLE_API_KEY ) {
args . push ( '--env' , ` GOOGLE_API_KEY= ${ process . env . GOOGLE_API_KEY } ` ) ;
}
2025-04-30 00:38:25 +00:00
2025-06-16 10:55:13 +08:00
// 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_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 } ` ,
) ;
}
2025-05-17 17:28:44 -07:00
// copy GEMINI_MODEL
if ( process . env . GEMINI_MODEL ) {
args . push ( '--env' , ` GEMINI_MODEL= ${ process . env . GEMINI_MODEL } ` ) ;
2025-04-30 00:38:25 +00:00
}
// 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 } ` ) ;
}
2025-05-03 09:12:44 -07:00
// copy VIRTUAL_ENV if under working directory
// also mount-replace VIRTUAL_ENV directory with <project_settings>/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
2025-06-09 12:19:42 -07:00
if (
process . env . VIRTUAL_ENV ? . toLowerCase ( ) . startsWith ( workdir . toLowerCase ( ) )
) {
2025-05-03 00:39:31 -07:00
const sandboxVenvPath = path . resolve (
SETTINGS_DIRECTORY_NAME ,
'sandbox.venv' ,
) ;
if ( ! fs . existsSync ( sandboxVenvPath ) ) {
fs . mkdirSync ( sandboxVenvPath , { recursive : true } ) ;
}
2025-06-09 12:19:42 -07:00
args . push (
'--volume' ,
` ${ sandboxVenvPath } : ${ getContainerPath ( process . env . VIRTUAL_ENV ) } ` ,
) ;
args . push (
'--env' ,
` VIRTUAL_ENV= ${ getContainerPath ( process . env . VIRTUAL_ENV ) } ` ,
) ;
2025-05-03 00:39:31 -07:00
}
2025-04-30 00:38:25 +00:00
// 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 ( '=' ) ) {
2025-05-15 10:54:30 -07:00
console . error ( ` SANDBOX_ENV: ${ env } ` ) ;
2025-04-30 00:38:25 +00:00
args . push ( '--env' , env ) ;
} else {
console . error (
'ERROR: SANDBOX_ENV must be a comma-separated list of key=value pairs' ,
) ;
process . exit ( 1 ) ;
}
}
}
}
2025-05-08 14:50:35 -07:00
// copy NODE_OPTIONS
2025-06-24 21:18:55 +00:00
const existingNodeOptions = process . env . NODE_OPTIONS || '' ;
const allNodeOptions = [
. . . ( existingNodeOptions ? [ existingNodeOptions ] : [ ] ) ,
. . . nodeArgs ,
] . join ( ' ' ) ;
if ( allNodeOptions . length > 0 ) {
args . push ( '--env' , ` NODE_OPTIONS=" ${ allNodeOptions } " ` ) ;
2025-05-08 14:50:35 -07:00
}
2025-04-30 00:38:25 +00:00
// set SANDBOX as container name
2025-05-30 19:28:46 +00:00
args . push ( '--env' , ` SANDBOX= ${ containerName } ` ) ;
2025-04-30 00:38:25 +00:00
// for podman only, use empty --authfile to skip unnecessary auth refresh overhead
2025-06-18 10:01:00 -07:00
if ( config . command === 'podman' ) {
2025-04-30 00:38:25 +00:00
const emptyAuthFilePath = path . join ( os . tmpdir ( ) , 'empty_auth.json' ) ;
fs . writeFileSync ( emptyAuthFilePath , '{}' , 'utf-8' ) ;
args . push ( '--authfile' , emptyAuthFilePath ) ;
}
2025-05-13 21:13:54 +00:00
// Determine if the current user's UID/GID should be passed to the sandbox.
// See shouldUseCurrentUserInSandbox for more details.
2025-06-11 10:50:31 -07:00
let userFlag = '' ;
2025-06-19 14:40:10 -07:00
const finalEntrypoint = entrypoint ( workdir ) ;
2025-06-17 08:24:07 -07:00
if ( process . env . GEMINI_CLI_INTEGRATION_TEST === 'true' ) {
args . push ( '--user' , 'root' ) ;
userFlag = '--user root' ;
} else if ( await shouldUseCurrentUserInSandbox ( ) ) {
2025-06-19 14:40:10 -07:00
// 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' ) ;
2025-04-30 00:38:25 +00:00
const uid = execSync ( 'id -u' ) . toString ( ) . trim ( ) ;
const gid = execSync ( 'id -g' ) . toString ( ) . trim ( ) ;
2025-06-19 14:40:10 -07:00
// 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', '<command>']`, 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.
2025-06-11 10:50:31 -07:00
userFlag = ` --user ${ uid } : ${ gid } ` ;
2025-06-19 14:40:10 -07:00
// When forcing a UID in the sandbox, $HOME can be reset to '/', so we copy $HOME as well.
2025-05-20 15:30:49 -07:00
args . push ( '--env' , ` HOME= ${ os . homedir ( ) } ` ) ;
2025-04-30 00:38:25 +00:00
}
2025-05-07 14:23:13 +00:00
// push container image name
args . push ( image ) ;
// push container entrypoint (including args)
2025-06-19 14:40:10 -07:00
args . push ( . . . finalEntrypoint ) ;
2025-04-30 00:38:25 +00:00
2025-06-10 08:58:37 -07:00
// start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set
2025-06-11 10:50:31 -07:00
let proxyProcess : ChildProcess | undefined = undefined ;
let sandboxProcess : ChildProcess | undefined = undefined ;
2025-06-16 08:27:29 -07:00
2025-06-10 08:58:37 -07:00
if ( proxyCommand ) {
// run proxyCommand in its own container
2025-06-18 10:01:00 -07:00
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 } ` ;
2025-06-10 08:58:37 -07:00
proxyProcess = spawn ( proxyContainerCommand , {
stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
shell : true ,
detached : true ,
} ) ;
2025-06-11 10:50:31 -07:00
// install handlers to stop proxy on exit/signal
const stopProxy = ( ) = > {
console . log ( 'stopping proxy container ...' ) ;
2025-06-18 10:01:00 -07:00
execSync ( ` ${ config . command } rm -f ${ SANDBOX_PROXY_NAME } ` ) ;
2025-06-11 10:50:31 -07:00
} ;
process . on ( 'exit' , stopProxy ) ;
process . on ( 'SIGINT' , stopProxy ) ;
process . on ( 'SIGTERM' , stopProxy ) ;
2025-06-10 08:58:37 -07:00
// 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 ( ) ) ;
} ) ;
2025-06-11 10:50:31 -07:00
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 ) ;
} ) ;
2025-06-10 08:58:37 -07:00
console . log ( 'waiting for proxy to start ...' ) ;
2025-06-11 11:31:38 -07:00
await execAsync (
` until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done ` ,
) ;
2025-06-11 10:50:31 -07:00
// connect proxy container to sandbox network
// (workaround for older versions of docker that don't support multiple --network args)
await execAsync (
2025-06-18 10:01:00 -07:00
` ${ config . command } network connect ${ SANDBOX_NETWORK_NAME } ${ SANDBOX_PROXY_NAME } ` ,
2025-06-11 10:50:31 -07:00
) ;
2025-06-10 08:58:37 -07:00
}
2025-06-09 12:19:42 -07:00
2025-06-11 10:50:31 -07:00
// spawn child and let it inherit stdio
2025-06-18 10:01:00 -07:00
sandboxProcess = spawn ( config . command , args , {
2025-06-11 10:50:31 -07:00
stdio : 'inherit' ,
} ) ;
2025-04-30 00:38:25 +00:00
2025-06-11 10:50:31 -07:00
sandboxProcess . on ( 'error' , ( err ) = > {
console . error ( 'Sandbox process error:' , err ) ;
} ) ;
2025-06-10 08:58:37 -07:00
2025-06-11 10:50:31 -07:00
await new Promise < void > ( ( resolve ) = > {
sandboxProcess ? . on ( 'close' , ( code , signal ) = > {
if ( code !== 0 ) {
console . log (
` Sandbox process exited with code: ${ code } , signal: ${ signal } ` ,
) ;
}
resolve ( ) ;
2025-06-10 08:58:37 -07:00
} ) ;
2025-06-11 10:50:31 -07:00
} ) ;
2025-04-30 00:38:25 +00:00
}
2025-05-30 20:49:47 +00:00
// Helper functions to ensure sandbox image is present
async function imageExists ( sandbox : string , image : string ) : Promise < boolean > {
return new Promise ( ( resolve ) = > {
const args = [ 'images' , '-q' , image ] ;
const checkProcess = spawn ( sandbox , args ) ;
let stdoutData = '' ;
if ( checkProcess . stdout ) {
checkProcess . stdout . on ( 'data' , ( data ) = > {
stdoutData += data . toString ( ) ;
} ) ;
}
checkProcess . on ( 'error' , ( err ) = > {
console . warn (
` Failed to start ' ${ sandbox } ' command for image check: ${ err . message } ` ,
) ;
resolve ( false ) ;
} ) ;
checkProcess . on ( 'close' , ( code ) = > {
// Non-zero code might indicate docker daemon not running, etc.
// The primary success indicator is non-empty stdoutData.
if ( code !== 0 ) {
// console.warn(`'${sandbox} images -q ${image}' exited with code ${code}.`);
}
resolve ( stdoutData . trim ( ) !== '' ) ;
} ) ;
} ) ;
}
async function pullImage ( sandbox : string , image : string ) : Promise < boolean > {
console . info ( ` Attempting to pull image ${ image } using ${ sandbox } ... ` ) ;
return new Promise ( ( resolve ) = > {
const args = [ 'pull' , image ] ;
const pullProcess = spawn ( sandbox , args , { stdio : 'pipe' } ) ;
let stderrData = '' ;
2025-06-05 06:40:33 -07:00
const onStdoutData = ( data : Buffer ) = > {
console . info ( data . toString ( ) . trim ( ) ) ; // Show pull progress
} ;
const onStderrData = ( data : Buffer ) = > {
stderrData += data . toString ( ) ;
console . error ( data . toString ( ) . trim ( ) ) ; // Show pull errors/info from the command itself
} ;
const onError = ( err : Error ) = > {
2025-05-30 20:49:47 +00:00
console . warn (
` Failed to start ' ${ sandbox } pull ${ image } ' command: ${ err . message } ` ,
) ;
2025-06-05 06:40:33 -07:00
cleanup ( ) ;
2025-05-30 20:49:47 +00:00
resolve ( false ) ;
2025-06-05 06:40:33 -07:00
} ;
2025-05-30 20:49:47 +00:00
2025-06-05 06:40:33 -07:00
const onClose = ( code : number | null ) = > {
2025-05-30 20:49:47 +00:00
if ( code === 0 ) {
console . info ( ` Successfully pulled image ${ image } . ` ) ;
2025-06-05 06:40:33 -07:00
cleanup ( ) ;
2025-05-30 20:49:47 +00:00
resolve ( true ) ;
} else {
console . warn (
` Failed to pull image ${ image } . ' ${ sandbox } pull ${ image } ' exited with code ${ code } . ` ,
) ;
if ( stderrData . trim ( ) ) {
// Details already printed by the stderr listener above
}
2025-06-05 06:40:33 -07:00
cleanup ( ) ;
2025-05-30 20:49:47 +00:00
resolve ( false ) ;
}
2025-06-05 06:40:33 -07:00
} ;
const cleanup = ( ) = > {
if ( pullProcess . stdout ) {
pullProcess . stdout . removeListener ( 'data' , onStdoutData ) ;
}
if ( pullProcess . stderr ) {
pullProcess . stderr . removeListener ( 'data' , onStderrData ) ;
}
pullProcess . removeListener ( 'error' , onError ) ;
pullProcess . removeListener ( 'close' , onClose ) ;
if ( pullProcess . connected ) {
pullProcess . disconnect ( ) ;
}
} ;
if ( pullProcess . stdout ) {
pullProcess . stdout . on ( 'data' , onStdoutData ) ;
}
if ( pullProcess . stderr ) {
pullProcess . stderr . on ( 'data' , onStderrData ) ;
}
pullProcess . on ( 'error' , onError ) ;
pullProcess . on ( 'close' , onClose ) ;
2025-05-30 20:49:47 +00:00
} ) ;
}
async function ensureSandboxImageIsPresent (
sandbox : string ,
image : string ,
) : Promise < boolean > {
console . info ( ` Checking for sandbox image: ${ image } ` ) ;
if ( await imageExists ( sandbox , image ) ) {
console . info ( ` Sandbox image ${ image } found locally. ` ) ;
return true ;
}
console . info ( ` Sandbox image ${ image } not found locally. ` ) ;
2025-06-03 19:32:17 +00:00
if ( image === LOCAL_DEV_SANDBOX_IMAGE_NAME ) {
2025-07-21 17:54:44 -04:00
// user needs to build the image themselves
2025-06-03 19:32:17 +00:00
return false ;
}
2025-05-30 20:49:47 +00:00
if ( await pullImage ( sandbox , image ) ) {
// After attempting to pull, check again to be certain
if ( await imageExists ( sandbox , image ) ) {
console . info ( ` Sandbox image ${ image } is now available after pulling. ` ) ;
return true ;
} else {
console . warn (
` Sandbox image ${ image } still not found after a pull attempt. This might indicate an issue with the image name or registry, or the pull command reported success but failed to make the image available. ` ,
) ;
return false ;
}
}
console . error (
` Failed to obtain sandbox image ${ image } after check and pull attempt. ` ,
) ;
return false ; // Pull command failed or image still not present
}