diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index e27587abf0..0c3a1dd341 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -227,6 +227,29 @@ export SANDBOX_FLAGS="--flag1 --flag2=value" $env:SANDBOX_FLAGS="--flag1 --flag2=value" ``` +### Route custom environment variables + +Use the `SANDBOX_ENV` environment variable to explicitly route custom +environment variables into the sandbox. This is a comma-separated list of +`KEY=VALUE` pairs. + +**macOS/Linux** + +```bash +export SANDBOX_ENV="MY_VAR=hello,ANOTHER_VAR=world" +gemini -p "echo \$MY_VAR \$ANOTHER_VAR" +``` + +**Windows (PowerShell)** + +```powershell +$env:SANDBOX_ENV="MY_VAR=hello,ANOTHER_VAR=world" +gemini -p "echo %MY_VAR% %ANOTHER_VAR%" +``` + +Environment variables listed in `SANDBOX_ENV` are also available to tools run by +the agent. + ## Linux UID/GID handling The sandbox automatically handles user permissions on Linux. Override these diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 7eec1c61b8..6431d8add4 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -85,6 +85,7 @@ const AUTH_ENV_VAR_WHITELIST = [ 'GOOGLE_API_KEY', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION', + 'SANDBOX_ENV', ]; /** diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index ef972a4a0b..cc1f31421e 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -514,6 +514,41 @@ describe('sandbox', () => { ); }); + it('should handle SANDBOX_ENV in macOS seatbelt', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + process.env['SANDBOX_ENV'] = 'MY_VAR=hello,ANOTHER_VAR=world'; + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + }); + + interface MockProcess extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockSpawnProcess = new EventEmitter() as MockProcess; + mockSpawnProcess.stdout = new EventEmitter(); + mockSpawnProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockReturnValue( + mockSpawnProcess as unknown as ReturnType, + ); + + const promise = start_sandbox(config); + setTimeout(() => mockSpawnProcess.emit('close', 0), 10); + await promise; + + // Check that SANDBOX_ENV variables are passed in the sh -c command + expect(spawn).toHaveBeenCalledWith( + 'sandbox-exec', + expect.arrayContaining([ + 'sh', + '-c', + expect.stringContaining('MY_VAR=hello ANOTHER_VAR=world'), + ]), + expect.any(Object), + ); + }); + it('should pass through GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL', async () => { const config: SandboxConfig = createMockSandboxConfig({ command: 'docker', diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index dbd2ec64e3..4f027e1ba0 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -141,18 +141,48 @@ export async function start_sandbox( } const finalArgv = cliArgs; + const shCommandParts = [ + `SANDBOX=sandbox-exec`, + `NODE_OPTIONS="${nodeOptions}"`, + ]; - args.push( - '-f', - profileFile, - 'sh', - '-c', - [ - `SANDBOX=sandbox-exec`, - `NODE_OPTIONS="${nodeOptions}"`, - ...finalArgv.map((arg) => quote([arg])), - ].join(' '), - ); + // copy additional environment variables from SANDBOX_ENV + if (process.env['SANDBOX_ENV']) { + let currentEnv = ''; + for (let part of process.env['SANDBOX_ENV'].split(',')) { + part = part.trim(); + if (!part) continue; + if (part.includes('=')) { + if (currentEnv) { + debugLogger.log(`SANDBOX_ENV: ${currentEnv}`); + const [k, ...vParts] = currentEnv.split('='); + const v = vParts.join('='); + shCommandParts.push(`${k}=${quote([v])}`); + } + currentEnv = part; + } else { + if (currentEnv) { + currentEnv += ',' + part; + } else { + debugLogger.log(`SANDBOX_ENV: ${part} (forwarded)`); + const val = process.env[part]; + if (val !== undefined) { + shCommandParts.push(`${part}=${quote([val])}`); + } + } + } + } + if (currentEnv) { + debugLogger.log(`SANDBOX_ENV: ${currentEnv}`); + const [k, ...vParts] = currentEnv.split('='); + const v = vParts.join('='); + shCommandParts.push(`${k}=${quote([v])}`); + } + } + + shCommandParts.push(...finalArgv.map((arg) => quote([arg]))); + + args.push('-f', profileFile, 'sh', '-c', shCommandParts.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; @@ -616,18 +646,29 @@ export async function start_sandbox( // 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('=')) { - debugLogger.log(`SANDBOX_ENV: ${env}`); - args.push('--env', env); + let currentEnv = ''; + for (let part of process.env['SANDBOX_ENV'].split(',')) { + part = part.trim(); + if (!part) continue; + if (part.includes('=')) { + if (currentEnv) { + debugLogger.log(`SANDBOX_ENV: ${currentEnv}`); + args.push('--env', currentEnv); + } + currentEnv = part; + } else { + if (currentEnv) { + currentEnv += ',' + part; } else { - throw new FatalSandboxError( - 'SANDBOX_ENV must be a comma-separated list of key=value pairs', - ); + debugLogger.log(`SANDBOX_ENV: ${part} (forwarded)`); + args.push('--env', part); } } } + if (currentEnv) { + debugLogger.log(`SANDBOX_ENV: ${currentEnv}`); + args.push('--env', currentEnv); + } } // copy NODE_OPTIONS @@ -979,19 +1020,31 @@ async function start_lxc_sandbox( // Forward SANDBOX_ENV key=value pairs if (process.env['SANDBOX_ENV']) { - for (let env of process.env['SANDBOX_ENV'].split(',')) { - if ((env = env.trim())) { - if (env.includes('=')) { - envArgs.push('--env', env); + let currentEnv = ''; + for (let part of process.env['SANDBOX_ENV'].split(',')) { + part = part.trim(); + if (!part) continue; + if (part.includes('=')) { + if (currentEnv) { + envArgs.push('--env', currentEnv); + } + currentEnv = part; + } else { + if (currentEnv) { + currentEnv += ',' + part; } else { - throw new FatalSandboxError( - 'SANDBOX_ENV must be a comma-separated list of key=value pairs', - ); + // LXC doesn't automatically forward from host, so we look it up + const val = process.env[part]; + if (val !== undefined) { + envArgs.push('--env', `${part}=${val}`); + } } } } + if (currentEnv) { + envArgs.push('--env', currentEnv); + } } - // Forward NODE_OPTIONS (e.g. from --inspect flags) const existingNodeOptions = process.env['NODE_OPTIONS'] || ''; const allNodeOptions = [ diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 0bd825db17..a9d9b97f18 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -425,6 +425,20 @@ export class ShellExecutionService { GIT_PAGER: shellExecutionConfig.pager ?? 'cat', }; + // Forward SANDBOX_ENV key=value pairs + if (process.env['SANDBOX_ENV']) { + for (let env of process.env['SANDBOX_ENV'].split(',')) { + if ((env = env.trim())) { + const index = env.indexOf('='); + if (index > 0) { + const key = env.substring(0, index); + const value = env.substring(index + 1); + baseEnv[key] = value; + } + } + } + } + if (!isInteractive) { // Ensure all GIT_CONFIG_* variables are preserved even if they were redacted for (const key of gitConfigKeys) {