diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index ef972a4a0b..1dda7b355a 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -8,8 +8,13 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { spawn, exec, execFile, execSync } from 'node:child_process'; import os from 'node:os'; import fs from 'node:fs'; +import path from 'node:path'; import { start_sandbox } from './sandbox.js'; -import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core'; +import { + FatalSandboxError, + homedir, + type SandboxConfig, +} from '@google/gemini-cli-core'; import { createMockSandboxConfig } from '@google/gemini-cli-test-utils'; import { EventEmitter } from 'node:events'; @@ -133,6 +138,7 @@ describe('sandbox', () => { afterEach(() => { process.env = originalEnv; process.argv = originalArgv; + vi.unstubAllEnvs(); }); describe('start_sandbox', () => { @@ -171,6 +177,105 @@ describe('sandbox', () => { ); }); + it('should resolve custom seatbelt profile from user home directory', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.stubEnv('SEATBELT_PROFILE', 'custom-test'); + vi.mocked(fs.existsSync).mockImplementation((p) => + String(p).includes( + path.join(homedir(), '.gemini', 'sandbox-macos-custom-test.sb'), + ), + ); + 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, [], undefined, ['arg1']); + + setTimeout(() => { + mockSpawnProcess.emit('close', 0); + }, 10); + + await expect(promise).resolves.toBe(0); + expect(spawn).toHaveBeenCalledWith( + 'sandbox-exec', + expect.any(Array), + expect.objectContaining({ stdio: 'inherit' }), + ); + const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1]; + expect(spawnArgs).toEqual( + expect.arrayContaining(['-f', expect.any(String)]), + ); + const profileArg = spawnArgs?.[spawnArgs.indexOf('-f') + 1]; + expect(profileArg).toEqual( + expect.stringContaining( + path.join(homedir(), '.gemini', 'sandbox-macos-custom-test.sb'), + ), + ); + }); + + it('should fall back to project .gemini directory when user profile is missing', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.stubEnv('SEATBELT_PROFILE', 'custom-test'); + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p); + return ( + s.includes(path.join('.gemini', 'sandbox-macos-custom-test.sb')) && + !s.includes(path.join(homedir(), '.gemini')) + ); + }); + 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, [], undefined, ['arg1']); + + setTimeout(() => { + mockSpawnProcess.emit('close', 0); + }, 10); + + await expect(promise).resolves.toBe(0); + expect(spawn).toHaveBeenCalledWith( + 'sandbox-exec', + expect.any(Array), + expect.objectContaining({ stdio: 'inherit' }), + ); + const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1]; + expect(spawnArgs).toEqual( + expect.arrayContaining(['-f', expect.any(String)]), + ); + const profileArg = spawnArgs?.[spawnArgs.indexOf('-f') + 1]; + expect(profileArg).toEqual( + expect.stringContaining( + path.join('.gemini', 'sandbox-macos-custom-test.sb'), + ), + ); + expect(profileArg).not.toContain(homedir()); + }); + it('should throw FatalSandboxError if seatbelt profile is missing', async () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.mocked(fs.existsSync).mockReturnValue(false); diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index dbd2ec64e3..6001725cdd 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -68,9 +68,17 @@ export async function start_sandbox( let profileFile = fileURLToPath( new URL(`sandbox-macos-${profile}.sb`, import.meta.url), ); - // if profile name is not recognized, then look for file under project settings directory + // if profile name is not recognized, look in user-level ~/.gemini first, + // then fall back to project-level .gemini. path.basename() strips any + // directory separators to prevent path traversal via SEATBELT_PROFILE. if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) { - profileFile = path.join(GEMINI_DIR, `sandbox-macos-${profile}.sb`); + const safeProfile = path.basename(profile); + const fileName = `sandbox-macos-${safeProfile}.sb`; + const userProfileFile = path.join(homedir(), GEMINI_DIR, fileName); + const projectProfileFile = path.join(GEMINI_DIR, fileName); + profileFile = fs.existsSync(userProfileFile) + ? userProfileFile + : projectProfileFile; } if (!fs.existsSync(profileFile)) { throw new FatalSandboxError(