From 2ba74b52af093d62f4c2c1d07cea5c69ba27ce4a Mon Sep 17 00:00:00 2001 From: Elberth Date: Tue, 6 Oct 2026 23:14:43 +0000 Subject: [PATCH 1/4] fix(ide): support container sandbox IDE auth and surface gVisor isolation error --- packages/cli/src/utils/sandbox.test.ts | 83 ++++++- packages/cli/src/utils/sandbox.ts | 14 ++ packages/core/src/ide/ide-client.ts | 22 +- .../core/src/ide/ide-gvisor-sandbox.test.ts | 212 ++++++++++++++++++ .../src/ide-server.test.ts | 41 ++++ .../vscode-ide-companion/src/ide-server.ts | 4 +- 6 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/ide/ide-gvisor-sandbox.test.ts diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index 1d0c511f93c..5e3247cedbf 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -1713,6 +1713,59 @@ describe('sandbox', () => { ); }); + it('should pass through IDE mode environment variables to lxc exec', async () => { + process.env['TEST_LXC_LIST_OUTPUT'] = LXC_RUNNING; + vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '12345'); + vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/workspace'); + vi.stubEnv('GEMINI_CLI_IDE_AUTH_TOKEN', 'secret-token'); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_STDIO_COMMAND', 'node'); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_STDIO_ARGS', '["server.js"]'); + vi.stubEnv('TERM_PROGRAM', 'vscode'); + + const config: SandboxConfig = createMockSandboxConfig({ + command: 'lxc', + image: 'gemini-sandbox', + }); + + 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).mockImplementation((cmd) => { + if (cmd === 'lxc') { + return mockSpawnProcess; + } + return new EventEmitter() as unknown as ReturnType; + }); + + await expect(start_sandbox(config)).resolves.toBe(0); + + expect(spawn).toHaveBeenCalledWith( + 'lxc', + expect.arrayContaining([ + '--env', + 'GEMINI_CLI_IDE_SERVER_PORT=12345', + '--env', + 'GEMINI_CLI_IDE_WORKSPACE_PATH=/workspace', + '--env', + 'GEMINI_CLI_IDE_AUTH_TOKEN=secret-token', + '--env', + 'GEMINI_CLI_IDE_SERVER_STDIO_COMMAND=node', + '--env', + 'GEMINI_CLI_IDE_SERVER_STDIO_ARGS=["server.js"]', + '--env', + 'TERM_PROGRAM=vscode', + ]), + expect.objectContaining({ stdio: 'inherit' }), + ); + }); + it('should throw FatalSandboxError if lxc list fails', async () => { process.env['TEST_LXC_LIST_OUTPUT'] = 'throw'; const config: SandboxConfig = createMockSandboxConfig({ @@ -1748,8 +1801,15 @@ describe('sandbox', () => { }); describe('gVisor (runsc)', () => { - it('should use docker with --runtime=runsc on Linux', async () => { + it('should use docker with --runtime=runsc on Linux and forward GEMINI_SANDBOX=runsc and IDE env vars', async () => { vi.mocked(os.platform).mockReturnValue('linux'); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '54321'); + vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/workspace/project'); + vi.stubEnv('GEMINI_CLI_IDE_AUTH_TOKEN', 'ide-auth-token-123'); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_STDIO_COMMAND', 'ide-mcp-cmd'); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_STDIO_ARGS', '["--stdio"]'); + vi.stubEnv('TERM_PROGRAM', 'vscode'); + const config: SandboxConfig = createMockSandboxConfig({ command: 'runsc', image: 'gemini-cli-sandbox', @@ -1790,11 +1850,28 @@ describe('sandbox', () => { expect.arrayContaining(['images', '-q', 'gemini-cli-sandbox']), ); - // Verify docker run includes --runtime=runsc + // Verify docker run includes --runtime=runsc, GEMINI_SANDBOX=runsc, and IDE env vars expect(spawn).toHaveBeenNthCalledWith( 2, 'docker', - expect.arrayContaining(['run', '--runtime=runsc']), + expect.arrayContaining([ + 'run', + '--runtime=runsc', + '--env', + 'GEMINI_SANDBOX=runsc', + '--env', + 'GEMINI_CLI_IDE_SERVER_PORT=54321', + '--env', + 'GEMINI_CLI_IDE_WORKSPACE_PATH=/workspace/project', + '--env', + 'GEMINI_CLI_IDE_AUTH_TOKEN=ide-auth-token-123', + '--env', + 'GEMINI_CLI_IDE_SERVER_STDIO_COMMAND=ide-mcp-cmd', + '--env', + 'GEMINI_CLI_IDE_SERVER_STDIO_ARGS=["--stdio"]', + '--env', + 'TERM_PROGRAM=vscode', + ]), expect.objectContaining({ stdio: 'inherit' }), ); }); diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 007a4645080..ac6f7d1563d 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -794,6 +794,9 @@ export async function start_sandbox( for (const envVar of [ 'GEMINI_CLI_IDE_SERVER_PORT', 'GEMINI_CLI_IDE_WORKSPACE_PATH', + 'GEMINI_CLI_IDE_AUTH_TOKEN', + 'GEMINI_CLI_IDE_SERVER_STDIO_COMMAND', + 'GEMINI_CLI_IDE_SERVER_STDIO_ARGS', 'TERM_PROGRAM', ]) { if (process.env[envVar]) { @@ -801,6 +804,12 @@ export async function start_sandbox( } } + const geminiSandboxEnv = + config.command === 'runsc' ? 'runsc' : process.env['GEMINI_SANDBOX']; + if (geminiSandboxEnv) { + args.push('--env', `GEMINI_SANDBOX=${geminiSandboxEnv}`); + } + // 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) @@ -1209,6 +1218,11 @@ async function start_lxc_sandbox( GEMINI_CLI_IDE_SERVER_PORT: process.env['GEMINI_CLI_IDE_SERVER_PORT'], GEMINI_CLI_IDE_WORKSPACE_PATH: process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'], + GEMINI_CLI_IDE_AUTH_TOKEN: process.env['GEMINI_CLI_IDE_AUTH_TOKEN'], + GEMINI_CLI_IDE_SERVER_STDIO_COMMAND: + process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'], + GEMINI_CLI_IDE_SERVER_STDIO_ARGS: + process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'], TERM_PROGRAM: process.env['TERM_PROGRAM'], }; for (const [key, value] of Object.entries(envVarsToForward)) { diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index bfd5cae6b8a..1c2107b76a3 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -145,13 +145,23 @@ export class IdeClient { connectionConfig?.workspacePath ?? process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; + const isGvisor = + Boolean(process.env['SANDBOX']?.toLowerCase().includes('runsc')) || + process.env['GEMINI_SANDBOX']?.toLowerCase().trim() === 'runsc'; + const ideName = this.currentIde.displayName; + const gvisorFailureDetails = `Failed to connect to IDE companion extension in ${ideName}: gVisor (runsc) sandboxing enforces strict network isolation which prevents host loopback communication.`; + const { isValid, error } = validateWorkspacePath( workspacePath, process.cwd(), ); if (!isValid) { - this.setState(IDEConnectionStatus.Disconnected, error, logError); + this.setState( + IDEConnectionStatus.Disconnected, + workspacePath === undefined && isGvisor ? gvisorFailureDetails : error, + logError, + ); return; } @@ -194,11 +204,11 @@ export class IdeClient { } } - this.setState( - IDEConnectionStatus.Disconnected, - `Failed to connect to IDE companion extension in ${this.currentIde.displayName}. Please ensure the extension is running. To install the extension, run /ide install.`, - logError, - ); + const failureDetails = isGvisor + ? gvisorFailureDetails + : `Failed to connect to IDE companion extension in ${ideName}. Please ensure the extension is running. To install the extension, run /ide install.`; + + this.setState(IDEConnectionStatus.Disconnected, failureDetails, logError); } /** diff --git a/packages/core/src/ide/ide-gvisor-sandbox.test.ts b/packages/core/src/ide/ide-gvisor-sandbox.test.ts new file mode 100644 index 00000000000..eca779872f8 --- /dev/null +++ b/packages/core/src/ide/ide-gvisor-sandbox.test.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as http from 'node:http'; +import type * as fs from 'node:fs'; +import { IdeClient, IDEConnectionStatus } from './ide-client.js'; +import { getIdeServerHost } from './ide-connection-utils.js'; +import { getIdeProcessInfo } from './process-utils.js'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn((targetPath: fs.PathLike) => { + if (targetPath === '/.dockerenv') { + return true; + } + return actual.existsSync(targetPath); + }), + promises: { + ...actual.promises, + // Inside the sandbox container, the host's /tmp/gemini/ide discovery directory is not mounted + open: vi + .fn() + .mockRejectedValue(new Error('ENOENT: no such file or directory')), + readdir: vi + .fn() + .mockRejectedValue(new Error('ENOENT: no such file or directory')), + readFile: vi + .fn() + .mockRejectedValue(new Error('ENOENT: no such file or directory')), + }, + }; +}); + +vi.mock('./process-utils.js', () => ({ + getIdeProcessInfo: vi.fn(), +})); + +describe('Issue #21331: IDE Companion connection under gVisor (runsc) sandbox', () => { + let mockCompanionServer: http.Server; + let serverPort: number; + + beforeEach(async () => { + // Reset IdeClient singleton instancePromise between tests + ( + IdeClient as unknown as { instancePromise: Promise | null } + ).instancePromise = null; + + vi.mocked(getIdeProcessInfo).mockResolvedValue({ + pid: 1, + command: '/sbin/docker-init -- bash', + }); + + // Start a local HTTP server mirroring IDEServer's exact binding (127.0.0.1), + // Host header validation, and Bearer token authentication. + await new Promise((resolve, reject) => { + mockCompanionServer = http.createServer((req, res) => { + const host = (req.headers.host || '').toLowerCase(); + const allowedHosts = [ + `localhost:${serverPort}`, + `127.0.0.1:${serverPort}`, + `host.docker.internal:${serverPort}`, + `host.containers.internal:${serverPort}`, + ]; + if (!allowedHosts.includes(host)) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid Host header' })); + return; + } + + const authHeader = req.headers.authorization; + if (!authHeader || authHeader !== 'Bearer valid-auth-token') { + res.writeHead(401, { 'Content-Type': 'text/plain' }); + res.end('Unauthorized'); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', result: { tools: [] } })); + }); + + mockCompanionServer.listen(0, '127.0.0.1', () => { + const address = mockCompanionServer.address(); + if (address && typeof address !== 'string') { + serverPort = address.port; + resolve(); + } else { + reject(new Error('Failed to bind mock companion server')); + } + }); + mockCompanionServer.on('error', reject); + }); + + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', process.cwd()); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + await new Promise((resolve) => { + mockCompanionServer.close(() => resolve()); + }); + }); + + it('reports explicit gVisor network isolation error when GEMINI_SANDBOX=runsc', async () => { + vi.stubEnv('GEMINI_SANDBOX', 'runsc'); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', String(serverPort)); + vi.stubEnv('GEMINI_CLI_IDE_AUTH_TOKEN', 'valid-auth-token'); + + // Inside the container, getIdeServerHost() maps to host.docker.internal + expect(getIdeServerHost()).toBe('host.docker.internal'); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect({ logToConsole: false }); + + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Disconnected, + ); + expect(ideClient.getConnectionStatus().details).toContain( + 'gVisor (runsc) sandboxing enforces strict network isolation which prevents host loopback communication.', + ); + expect(ideClient.getConnectionStatus().details).not.toContain( + '/ide install', + ); + }); + + it('reports explicit gVisor network isolation error when SANDBOX env var contains runsc', async () => { + vi.stubEnv('SANDBOX', 'gemini-cli-sandbox-runsc-a1b2c3'); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', String(serverPort)); + vi.stubEnv('GEMINI_CLI_IDE_AUTH_TOKEN', 'valid-auth-token'); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect({ logToConsole: false }); + + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Disconnected, + ); + expect(ideClient.getConnectionStatus().details).toContain( + 'gVisor (runsc) sandboxing enforces strict network isolation which prevents host loopback communication.', + ); + }); + + it('reports explicit gVisor network isolation error when GEMINI_CLI_IDE_WORKSPACE_PATH is unset (e.g. file-based discovery on host)', async () => { + vi.stubEnv('GEMINI_SANDBOX', 'runsc'); + vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', undefined); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect({ logToConsole: false }); + + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Disconnected, + ); + expect(ideClient.getConnectionStatus().details).toContain( + 'gVisor (runsc) sandboxing enforces strict network isolation which prevents host loopback communication.', + ); + expect(ideClient.getConnectionStatus().details).not.toContain( + '/ide install', + ); + }); + + it('preserves directory mismatch error under gVisor when GEMINI_CLI_IDE_WORKSPACE_PATH points to another directory', async () => { + vi.stubEnv('GEMINI_SANDBOX', 'runsc'); + vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/non-matching/workspace/path'); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect({ logToConsole: false }); + + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Disconnected, + ); + expect(ideClient.getConnectionStatus().details).toContain( + 'Directory mismatch.', + ); + }); + + it('preserves open workspace folder error under gVisor when GEMINI_CLI_IDE_WORKSPACE_PATH is empty string', async () => { + vi.stubEnv('GEMINI_SANDBOX', 'runsc'); + vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', ''); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect({ logToConsole: false }); + + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Disconnected, + ); + expect(ideClient.getConnectionStatus().details).toContain( + 'please open a workspace folder in your IDE', + ); + }); + + it('preserves standard /ide install error message when not running under gVisor', async () => { + vi.stubEnv('GEMINI_SANDBOX', 'docker'); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', String(serverPort)); + vi.stubEnv('GEMINI_CLI_IDE_AUTH_TOKEN', 'valid-auth-token'); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect({ logToConsole: false }); + + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Disconnected, + ); + expect(ideClient.getConnectionStatus().details).toContain( + 'Please ensure the extension is running. To install the extension, run /ide install.', + ); + }); +}); diff --git a/packages/vscode-ide-companion/src/ide-server.test.ts b/packages/vscode-ide-companion/src/ide-server.test.ts index 630640574ad..a705d65dd86 100644 --- a/packages/vscode-ide-companion/src/ide-server.test.ts +++ b/packages/vscode-ide-companion/src/ide-server.test.ts @@ -541,4 +541,45 @@ describe('IDEServer HTTP endpoints', () => { // but it's not a host error, which is what we are testing. expect(response.statusCode).toBe(400); }); + + it.each([ + 'host.docker.internal', + 'host.containers.internal', + 'Host.Docker.Internal', + ])( + 'should allow requests from container host header %s', + async (containerHost) => { + const response = await request( + port, + { + path: '/mcp', + method: 'POST', + headers: { + Host: `${containerHost}:${port}`, + 'Content-Type': 'application/json', + Authorization: 'Bearer test-auth-token', + }, + }, + JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }), + ); + expect(response.statusCode).toBe(400); + }, + ); + + it('should deny requests with a container host header on a mismatched port', async () => { + const response = await request( + port, + { + path: '/mcp', + method: 'POST', + headers: { + Host: `host.docker.internal:${Number(port) + 1}`, + 'Content-Type': 'application/json', + Authorization: 'Bearer test-auth-token', + }, + }, + JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }), + ); + expect(response.statusCode).toBe(403); + }); }); diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index 39ef770079d..08749aeb623 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -164,8 +164,10 @@ export class IDEServer { const allowedHosts = [ `localhost:${this.port}`, `127.0.0.1:${this.port}`, + `host.docker.internal:${this.port}`, + `host.containers.internal:${this.port}`, ]; - if (!allowedHosts.includes(host)) { + if (!allowedHosts.includes(host.toLowerCase())) { return res.status(403).json({ error: 'Invalid Host header' }); } next(); From 2f5ec1f5fc3e4fb201c2af62b1b388bfd836eed9 Mon Sep 17 00:00:00 2001 From: Elberth Date: Tue, 6 Oct 2026 23:28:07 +0000 Subject: [PATCH 2/4] fix(ide): address review feedback for sandbox env forwarding and isGvisorSandbox --- packages/cli/src/utils/sandbox.test.ts | 16 ++++++---------- packages/cli/src/utils/sandbox.ts | 2 -- packages/core/src/ide/ide-client.ts | 5 ++--- packages/core/src/ide/ide-connection-utils.ts | 7 +++++++ packages/core/src/ide/ide-gvisor-sandbox.test.ts | 7 ++++++- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index 5e3247cedbf..d5eea930fe7 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -1717,7 +1717,6 @@ describe('sandbox', () => { process.env['TEST_LXC_LIST_OUTPUT'] = LXC_RUNNING; vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '12345'); vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/workspace'); - vi.stubEnv('GEMINI_CLI_IDE_AUTH_TOKEN', 'secret-token'); vi.stubEnv('GEMINI_CLI_IDE_SERVER_STDIO_COMMAND', 'node'); vi.stubEnv('GEMINI_CLI_IDE_SERVER_STDIO_ARGS', '["server.js"]'); vi.stubEnv('TERM_PROGRAM', 'vscode'); @@ -1754,8 +1753,6 @@ describe('sandbox', () => { '--env', 'GEMINI_CLI_IDE_WORKSPACE_PATH=/workspace', '--env', - 'GEMINI_CLI_IDE_AUTH_TOKEN=secret-token', - '--env', 'GEMINI_CLI_IDE_SERVER_STDIO_COMMAND=node', '--env', 'GEMINI_CLI_IDE_SERVER_STDIO_ARGS=["server.js"]', @@ -1850,10 +1847,9 @@ describe('sandbox', () => { expect.arrayContaining(['images', '-q', 'gemini-cli-sandbox']), ); - // Verify docker run includes --runtime=runsc, GEMINI_SANDBOX=runsc, and IDE env vars - expect(spawn).toHaveBeenNthCalledWith( - 2, - 'docker', + // Verify docker run includes --runtime=runsc, GEMINI_SANDBOX=runsc, and safe IDE env vars (excluding GEMINI_CLI_IDE_AUTH_TOKEN) + const dockerRunArgs = vi.mocked(spawn).mock.calls[1][1] as string[]; + expect(dockerRunArgs).toEqual( expect.arrayContaining([ 'run', '--runtime=runsc', @@ -1864,15 +1860,15 @@ describe('sandbox', () => { '--env', 'GEMINI_CLI_IDE_WORKSPACE_PATH=/workspace/project', '--env', - 'GEMINI_CLI_IDE_AUTH_TOKEN=ide-auth-token-123', - '--env', 'GEMINI_CLI_IDE_SERVER_STDIO_COMMAND=ide-mcp-cmd', '--env', 'GEMINI_CLI_IDE_SERVER_STDIO_ARGS=["--stdio"]', '--env', 'TERM_PROGRAM=vscode', ]), - expect.objectContaining({ stdio: 'inherit' }), + ); + expect(dockerRunArgs).not.toContain( + 'GEMINI_CLI_IDE_AUTH_TOKEN=ide-auth-token-123', ); }); }); diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index ac6f7d1563d..c00109aa5fd 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -794,7 +794,6 @@ export async function start_sandbox( for (const envVar of [ 'GEMINI_CLI_IDE_SERVER_PORT', 'GEMINI_CLI_IDE_WORKSPACE_PATH', - 'GEMINI_CLI_IDE_AUTH_TOKEN', 'GEMINI_CLI_IDE_SERVER_STDIO_COMMAND', 'GEMINI_CLI_IDE_SERVER_STDIO_ARGS', 'TERM_PROGRAM', @@ -1218,7 +1217,6 @@ async function start_lxc_sandbox( GEMINI_CLI_IDE_SERVER_PORT: process.env['GEMINI_CLI_IDE_SERVER_PORT'], GEMINI_CLI_IDE_WORKSPACE_PATH: process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'], - GEMINI_CLI_IDE_AUTH_TOKEN: process.env['GEMINI_CLI_IDE_AUTH_TOKEN'], GEMINI_CLI_IDE_SERVER_STDIO_COMMAND: process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'], GEMINI_CLI_IDE_SERVER_STDIO_ARGS: diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 1c2107b76a3..78ae83eebf1 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -27,6 +27,7 @@ import { getIdeServerHost, getPortFromEnv, getStdioConfigFromEnv, + isGvisorSandbox, validateWorkspacePath, createProxyAwareFetch, type StdioConfig, @@ -145,9 +146,7 @@ export class IdeClient { connectionConfig?.workspacePath ?? process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; - const isGvisor = - Boolean(process.env['SANDBOX']?.toLowerCase().includes('runsc')) || - process.env['GEMINI_SANDBOX']?.toLowerCase().trim() === 'runsc'; + const isGvisor = isGvisorSandbox(); const ideName = this.currentIde.displayName; const gvisorFailureDetails = `Failed to connect to IDE companion extension in ${ideName}: gVisor (runsc) sandboxing enforces strict network isolation which prevents host loopback communication.`; diff --git a/packages/core/src/ide/ide-connection-utils.ts b/packages/core/src/ide/ide-connection-utils.ts index 1df20ff9baf..89fd54e1b0c 100644 --- a/packages/core/src/ide/ide-connection-utils.ts +++ b/packages/core/src/ide/ide-connection-utils.ts @@ -395,6 +395,13 @@ export function getIdeServerHost() { return host; } +export function isGvisorSandbox(): boolean { + return ( + Boolean(process.env['SANDBOX']?.toLowerCase().includes('runsc')) || + process.env['GEMINI_SANDBOX']?.toLowerCase().trim() === 'runsc' + ); +} + function isInContainer() { return fs.existsSync('/.dockerenv') || fs.existsSync('/run/.containerenv'); } diff --git a/packages/core/src/ide/ide-gvisor-sandbox.test.ts b/packages/core/src/ide/ide-gvisor-sandbox.test.ts index eca779872f8..bb5af113540 100644 --- a/packages/core/src/ide/ide-gvisor-sandbox.test.ts +++ b/packages/core/src/ide/ide-gvisor-sandbox.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as http from 'node:http'; import type * as fs from 'node:fs'; import { IdeClient, IDEConnectionStatus } from './ide-client.js'; -import { getIdeServerHost } from './ide-connection-utils.js'; +import { getIdeServerHost, isGvisorSandbox } from './ide-connection-utils.js'; import { getIdeProcessInfo } from './process-utils.js'; vi.mock('node:fs', async (importOriginal) => { @@ -115,6 +115,7 @@ describe('Issue #21331: IDE Companion connection under gVisor (runsc) sandbox', // Inside the container, getIdeServerHost() maps to host.docker.internal expect(getIdeServerHost()).toBe('host.docker.internal'); + expect(isGvisorSandbox()).toBe(true); const ideClient = await IdeClient.getInstance(); await ideClient.connect({ logToConsole: false }); @@ -135,6 +136,8 @@ describe('Issue #21331: IDE Companion connection under gVisor (runsc) sandbox', vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', String(serverPort)); vi.stubEnv('GEMINI_CLI_IDE_AUTH_TOKEN', 'valid-auth-token'); + expect(isGvisorSandbox()).toBe(true); + const ideClient = await IdeClient.getInstance(); await ideClient.connect({ logToConsole: false }); @@ -199,6 +202,8 @@ describe('Issue #21331: IDE Companion connection under gVisor (runsc) sandbox', vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', String(serverPort)); vi.stubEnv('GEMINI_CLI_IDE_AUTH_TOKEN', 'valid-auth-token'); + expect(isGvisorSandbox()).toBe(false); + const ideClient = await IdeClient.getInstance(); await ideClient.connect({ logToConsole: false }); From b060c88de5c337a4a8bf6935b9a4825d0fcfecd6 Mon Sep 17 00:00:00 2001 From: Elberth Date: Tue, 6 Oct 2026 23:58:02 +0000 Subject: [PATCH 3/4] test(cli): use vi.stubEnv for TEST_LXC_LIST_OUTPUT in sandbox test --- packages/cli/src/utils/sandbox.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index d5eea930fe7..589577ce8d7 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -1714,7 +1714,7 @@ describe('sandbox', () => { }); it('should pass through IDE mode environment variables to lxc exec', async () => { - process.env['TEST_LXC_LIST_OUTPUT'] = LXC_RUNNING; + vi.stubEnv('TEST_LXC_LIST_OUTPUT', LXC_RUNNING); vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '12345'); vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/workspace'); vi.stubEnv('GEMINI_CLI_IDE_SERVER_STDIO_COMMAND', 'node'); From a99e5b94bfd1367b9d9099a1099649e8e4b91f9a Mon Sep 17 00:00:00 2001 From: Elberth Date: Wed, 7 Oct 2026 00:28:01 +0000 Subject: [PATCH 4/4] docs(sandbox): document gVisor (runsc) IDE companion limitation --- docs/cli/sandbox.md | 8 ++++++++ docs/ide-integration/index.md | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 5beb243aa15..e1482aacf6d 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -220,6 +220,14 @@ To set up runsc: 2. Configure the Docker daemon to use the runsc runtime. 3. Verify the installation. +**Limitations**: + +- Linux only (gVisor is not available on macOS or Windows). +- [IDE integration](../ide-integration/index.md) is not supported when using + `runsc` because gVisor's isolated network stack blocks communication with the + IDE companion server on the host loopback interface. Use `docker` sandboxing + if you need IDE companion integration inside a container. + ### 5. LXC/LXD (Linux only, experimental) Full-system container sandboxing using LXC/LXD. Unlike Docker/Podman, LXC diff --git a/docs/ide-integration/index.md b/docs/ide-integration/index.md index 428fe558082..71227aeaad8 100644 --- a/docs/ide-integration/index.md +++ b/docs/ide-integration/index.md @@ -220,6 +220,11 @@ If you are using Gemini CLI within a sandbox, be aware of the following: IDE server on `host.docker.internal`. No special configuration is usually required, but you may need to ensure your Docker networking setup allows connections from the container to the host. +- **In a gVisor (`runsc`) sandbox:** The IDE companion integration is not + supported when running with `GEMINI_SANDBOX=runsc` because gVisor's isolated + user-space network stack blocks host loopback communication. Use + `GEMINI_SANDBOX=docker` if you require IDE companion integration with + container sandboxing. ## Troubleshooting @@ -240,6 +245,16 @@ If you are using Gemini CLI within a sandbox, be aware of the following: 2. Open a new terminal window in your IDE to ensure it picks up the correct environment. +- **Message:** + `🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]: gVisor (runsc) sandboxing enforces strict network isolation which prevents host loopback communication.` + + - **Cause:** You are running Gemini CLI with gVisor (`runsc`) sandboxing + enabled, which isolates container network traffic from the host loopback + interface used by the IDE companion server. + - **Solution:** Switch to standard Docker sandboxing (`GEMINI_SANDBOX=docker`) + or run Gemini CLI outside the `runsc` container when using IDE companion + features. + - **Message:** `🔴 Disconnected: IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable` - **Cause:** The connection to the IDE companion was lost.