mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 08:31:14 -07:00
feat(cli): add native gVisor (runsc) sandboxing support (#21062)
Co-authored-by: Zheyuan <zlin252@emory.edu> Co-authored-by: Kartik Angiras <angiraskartik@gmail.com>
This commit is contained in:
@@ -573,4 +573,57 @@ describe('sandbox', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('gVisor (runsc)', () => {
|
||||
it('should use docker with --runtime=runsc on Linux', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
const config: SandboxConfig = {
|
||||
command: 'runsc',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
|
||||
// Mock image check
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
|
||||
mockImageCheckProcess.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
// Mock docker run
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
|
||||
typeof spawn
|
||||
>;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setTimeout(() => cb(0), 10);
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
|
||||
|
||||
await start_sandbox(config, [], undefined, ['arg1']);
|
||||
|
||||
// Verify docker (not runsc) is called for image check
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'docker',
|
||||
expect.arrayContaining(['images', '-q', 'gemini-cli-sandbox']),
|
||||
);
|
||||
|
||||
// Verify docker run includes --runtime=runsc
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'docker',
|
||||
expect.arrayContaining(['run', '--runtime=runsc']),
|
||||
expect.objectContaining({ stdio: 'inherit' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,7 +215,10 @@ export async function start_sandbox(
|
||||
return await start_lxc_sandbox(config, nodeArgs, cliArgs);
|
||||
}
|
||||
|
||||
debugLogger.log(`hopping into sandbox (command: ${config.command}) ...`);
|
||||
// runsc uses docker with --runtime=runsc
|
||||
const command = config.command === 'runsc' ? 'docker' : config.command;
|
||||
|
||||
debugLogger.log(`hopping into sandbox (command: ${command}) ...`);
|
||||
|
||||
// determine full path for gemini-cli to distinguish linked vs installed setting
|
||||
const gcPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';
|
||||
@@ -258,7 +261,7 @@ export async function start_sandbox(
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package)
|
||||
GEMINI_SANDBOX: command, // in case sandbox is enabled via flags (see config.ts under cli package)
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -266,9 +269,7 @@ export async function start_sandbox(
|
||||
}
|
||||
|
||||
// stop if image is missing
|
||||
if (
|
||||
!(await ensureSandboxImageIsPresent(config.command, image, cliConfig))
|
||||
) {
|
||||
if (!(await ensureSandboxImageIsPresent(command, image, cliConfig))) {
|
||||
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.'
|
||||
@@ -282,11 +283,17 @@ export async function start_sandbox(
|
||||
// run init binary inside container to forward signals & reap zombies
|
||||
const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir];
|
||||
|
||||
// add runsc runtime if using runsc
|
||||
if (config.command === 'runsc') {
|
||||
args.push('--runtime=runsc');
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -422,7 +429,7 @@ export async function start_sandbox(
|
||||
// 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}`,
|
||||
`${command} network inspect ${SANDBOX_NETWORK_NAME} || ${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)
|
||||
@@ -430,7 +437,7 @@ export async function start_sandbox(
|
||||
// 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}`,
|
||||
`${command} network inspect ${SANDBOX_PROXY_NAME} || ${command} network create ${SANDBOX_PROXY_NAME}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -449,7 +456,7 @@ export async function start_sandbox(
|
||||
} else {
|
||||
let index = 0;
|
||||
const containerNameCheck = (
|
||||
await execAsync(`${config.command} ps -a --format "{{.Names}}"`)
|
||||
await execAsync(`${command} ps -a --format "{{.Names}}"`)
|
||||
).stdout.trim();
|
||||
while (containerNameCheck.includes(`${imageName}-${index}`)) {
|
||||
index++;
|
||||
@@ -599,7 +606,7 @@ export async function start_sandbox(
|
||||
args.push('--env', `SANDBOX=${containerName}`);
|
||||
|
||||
// for podman only, use empty --authfile to skip unnecessary auth refresh overhead
|
||||
if (config.command === 'podman') {
|
||||
if (command === 'podman') {
|
||||
const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json');
|
||||
fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8');
|
||||
args.push('--authfile', emptyAuthFilePath);
|
||||
@@ -663,16 +670,38 @@ export async function start_sandbox(
|
||||
|
||||
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, {
|
||||
// build args array to prevent command injection
|
||||
const proxyContainerArgs = [
|
||||
'run',
|
||||
'--rm',
|
||||
'--init',
|
||||
...(userFlag ? userFlag.split(' ') : []),
|
||||
'--name',
|
||||
SANDBOX_PROXY_NAME,
|
||||
'--network',
|
||||
SANDBOX_PROXY_NAME,
|
||||
'-p',
|
||||
'8877:8877',
|
||||
'-v',
|
||||
`${process.cwd()}:${workdir}`,
|
||||
'--workdir',
|
||||
workdir,
|
||||
image,
|
||||
// proxyCommand may be a shell string, so parse it into tokens safely
|
||||
...parse(proxyCommand, process.env).filter(
|
||||
(f): f is string => typeof f === 'string',
|
||||
),
|
||||
];
|
||||
|
||||
proxyProcess = spawn(command, proxyContainerArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: true,
|
||||
shell: false, // <-- no shell; args are passed directly
|
||||
detached: true,
|
||||
});
|
||||
// install handlers to stop proxy on exit/signal
|
||||
const stopProxy = () => {
|
||||
debugLogger.log('stopping proxy container ...');
|
||||
execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`);
|
||||
execSync(`${command} rm -f ${SANDBOX_PROXY_NAME}`);
|
||||
};
|
||||
process.off('exit', stopProxy);
|
||||
process.on('exit', stopProxy);
|
||||
@@ -693,7 +722,7 @@ export async function start_sandbox(
|
||||
process.kill(-sandboxProcess.pid, 'SIGTERM');
|
||||
}
|
||||
throw new FatalSandboxError(
|
||||
`Proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`,
|
||||
`Proxy container command '${command} ${proxyContainerArgs.join(' ')}' exited with code ${code}, signal ${signal}`,
|
||||
);
|
||||
});
|
||||
debugLogger.log('waiting for proxy to start ...');
|
||||
@@ -703,13 +732,13 @@ export async function start_sandbox(
|
||||
// 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}`,
|
||||
`${command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`,
|
||||
);
|
||||
}
|
||||
|
||||
// spawn child and let it inherit stdio
|
||||
process.stdin.pause();
|
||||
sandboxProcess = spawn(config.command, args, {
|
||||
sandboxProcess = spawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user