From 2ba74b52af093d62f4c2c1d07cea5c69ba27ce4a Mon Sep 17 00:00:00 2001 From: Elberth Date: Tue, 6 Oct 2026 23:14:43 +0000 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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. From 26ef5158255049beeccb5c15dc11856b6354d2dc Mon Sep 17 00:00:00 2001 From: Elberth Date: Wed, 7 Oct 2026 18:12:01 +0000 Subject: [PATCH 5/5] fix(ide): scope gVisor IDE diagnostic to a single runsc marker Trim PR #29665 to the minimal change needed for #21331 and harden its tests. - sandbox.ts: inject GEMINI_SANDBOX=runsc only when the sandbox command is runsc; stop forwarding GEMINI_CLI_IDE_SERVER_STDIO_COMMAND/ARGS (host paths are meaningless inside the container) - revert the companion Host allowlist change (unrelated to gVisor; tracked as a follow-up with the Linux Docker IDE issues) - isGvisorSandbox(): single documented signal (GEMINI_SANDBOX), no container-name heuristic - IdeClient.connect(): message now states both cause and remedy - tests: mocked connect() matrix in ide-client.test.ts (incl. "still connects when reachable"), isGvisorSandbox() table, hermetic real-socket regression test (refused loopback port + isolated TMPDIR instead of DNS and fs mocks), launcher tests assert real --env pairs and that the auth token and stdio command are never forwarded - docs: drop the "use docker instead" recommendation, which is not reliable on Linux either --- docs/cli/sandbox.md | 8 +- docs/ide-integration/index.md | 25 +- packages/cli/src/utils/sandbox.test.ts | 156 +++++------ packages/cli/src/utils/sandbox.ts | 15 +- packages/core/src/ide/ide-client.test.ts | 129 +++++++++ packages/core/src/ide/ide-client.ts | 5 +- .../core/src/ide/ide-connection-utils.test.ts | 28 ++ packages/core/src/ide/ide-connection-utils.ts | 13 +- .../core/src/ide/ide-gvisor-sandbox.test.ts | 265 +++++++----------- .../src/ide-server.test.ts | 41 --- .../vscode-ide-companion/src/ide-server.ts | 4 +- 11 files changed, 361 insertions(+), 328 deletions(-) diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index e1482aacf6d..42d2a36989b 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -223,10 +223,10 @@ To set up runsc: **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. +- [IDE integration](../ide-integration/index.md) is not available inside a + `runsc` sandbox: gVisor's isolated network stack can't reach the IDE companion + server on the host loopback interface. To use IDE integration, run Gemini CLI + without the `runsc` sandbox. ### 5. LXC/LXD (Linux only, experimental) diff --git a/docs/ide-integration/index.md b/docs/ide-integration/index.md index 71227aeaad8..0cf225279e3 100644 --- a/docs/ide-integration/index.md +++ b/docs/ide-integration/index.md @@ -220,11 +220,10 @@ 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. +- **In a gVisor (`runsc`) sandbox:** IDE integration is not available when you + run Gemini CLI with `GEMINI_SANDBOX=runsc`. gVisor's isolated user-space + network stack can't reach the IDE companion server on the host loopback + interface. To use IDE integration, run Gemini CLI without the `runsc` sandbox. ## Troubleshooting @@ -246,14 +245,14 @@ If you are using Gemini CLI within a sandbox, be aware of the following: 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. + `🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]: gVisor (runsc) sandboxing isolates the container network stack, so the IDE companion server on the host is unreachable. To use IDE integration, run Gemini CLI without the runsc sandbox.` + + - **Cause:** You are running Gemini CLI with gVisor (`runsc`) sandboxing. + gVisor isolates the container's network traffic from the host loopback + interface that the IDE companion server listens on, so the connection can't + succeed. + - **Solution:** Run Gemini CLI without the `runsc` sandbox when you need IDE + integration. - **Message:** `🔴 Disconnected: IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable` diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index 589577ce8d7..809ef1b41a2 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -1713,56 +1713,6 @@ describe('sandbox', () => { ); }); - it('should pass through IDE mode environment variables to lxc exec', async () => { - 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'); - 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_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({ @@ -1798,21 +1748,10 @@ describe('sandbox', () => { }); describe('gVisor (runsc)', () => { - 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', - }); - - // Mock image check + /** Mocks the image check and `docker run`, then returns the run args. */ + async function captureDockerRunArgs( + config: SandboxConfig, + ): Promise { interface MockProcessWithStdout extends EventEmitter { stdout: EventEmitter; } @@ -1826,7 +1765,6 @@ describe('sandbox', () => { return mockImageCheckProcess as unknown as ReturnType; }); - // Mock docker run const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< typeof spawn >; @@ -1840,35 +1778,89 @@ describe('sandbox', () => { 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']), + expect.arrayContaining(['images', '-q', config.image]), + ); + expect(vi.mocked(spawn).mock.calls[1][0]).toBe('docker'); + return vi.mocked(spawn).mock.calls[1][1] as string[]; + } + + /** Extracts the `KEY=VALUE` entries passed via `--env`. */ + const envEntries = (args: string[]): string[] => + args.flatMap((arg, i) => (args[i - 1] === '--env' ? [arg] : [])); + + beforeEach(() => { + 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('TERM_PROGRAM', 'vscode'); + }); + + it('should use docker with --runtime=runsc on Linux and mark the container with GEMINI_SANDBOX=runsc', async () => { + const dockerRunArgs = await captureDockerRunArgs( + createMockSandboxConfig({ + command: 'runsc', + image: 'gemini-cli-sandbox', + }), ); - // 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']), + ); + expect(envEntries(dockerRunArgs)).toEqual( 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_SERVER_STDIO_COMMAND=ide-mcp-cmd', - '--env', - 'GEMINI_CLI_IDE_SERVER_STDIO_ARGS=["--stdio"]', - '--env', 'TERM_PROGRAM=vscode', ]), ); - expect(dockerRunArgs).not.toContain( - 'GEMINI_CLI_IDE_AUTH_TOKEN=ide-auth-token-123', + }); + + it('should never forward the IDE auth token or stdio command into the runsc container', async () => { + const dockerRunArgs = await captureDockerRunArgs( + createMockSandboxConfig({ + command: 'runsc', + image: 'gemini-cli-sandbox', + }), + ); + + const forwardedKeys = envEntries(dockerRunArgs).map( + (entry) => entry.split('=')[0], + ); + expect(forwardedKeys).not.toContain('GEMINI_CLI_IDE_AUTH_TOKEN'); + expect(forwardedKeys).not.toContain( + 'GEMINI_CLI_IDE_SERVER_STDIO_COMMAND', + ); + expect(forwardedKeys).not.toContain('GEMINI_CLI_IDE_SERVER_STDIO_ARGS'); + }); + + it('should not set GEMINI_SANDBOX for a plain docker sandbox', async () => { + vi.stubEnv('GEMINI_SANDBOX', 'docker'); + + const dockerRunArgs = await captureDockerRunArgs( + createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }), + ); + + expect(dockerRunArgs).not.toContain('--runtime=runsc'); + expect( + envEntries(dockerRunArgs).some((entry) => + entry.startsWith('GEMINI_SANDBOX='), + ), + ).toBe(false); + expect(envEntries(dockerRunArgs)).toEqual( + expect.arrayContaining([ + 'GEMINI_CLI_IDE_SERVER_PORT=54321', + 'GEMINI_CLI_IDE_WORKSPACE_PATH=/workspace/project', + 'TERM_PROGRAM=vscode', + ]), ); }); }); diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index c00109aa5fd..97b0e33f866 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -794,8 +794,6 @@ export async function start_sandbox( for (const envVar of [ 'GEMINI_CLI_IDE_SERVER_PORT', 'GEMINI_CLI_IDE_WORKSPACE_PATH', - 'GEMINI_CLI_IDE_SERVER_STDIO_COMMAND', - 'GEMINI_CLI_IDE_SERVER_STDIO_ARGS', 'TERM_PROGRAM', ]) { if (process.env[envVar]) { @@ -803,10 +801,11 @@ export async function start_sandbox( } } - const geminiSandboxEnv = - config.command === 'runsc' ? 'runsc' : process.env['GEMINI_SANDBOX']; - if (geminiSandboxEnv) { - args.push('--env', `GEMINI_SANDBOX=${geminiSandboxEnv}`); + // gVisor's isolated network stack cannot reach the IDE companion server on + // the host loopback interface. Tell the CLI inside the container which + // runtime launched it so it can explain IDE connection failures. + if (config.command === 'runsc') { + args.push('--env', 'GEMINI_SANDBOX=runsc'); } // copy VIRTUAL_ENV if under working directory @@ -1217,10 +1216,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_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.test.ts b/packages/core/src/ide/ide-client.test.ts index 7cf2f101e67..257a0080d78 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -26,6 +26,7 @@ import { getConnectionConfigFromFile, getStdioConfigFromEnv, getPortFromEnv, + isGvisorSandbox, validateWorkspacePath, getIdeServerHost, } from './ide-connection-utils.js'; @@ -234,6 +235,134 @@ describe('IdeClient', () => { 'Failed to connect', ); }); + + describe('inside a gVisor (runsc) sandbox', () => { + const GVISOR_MESSAGE = + 'Failed to connect to IDE companion extension in VS Code: gVisor (runsc) sandboxing isolates the container network stack, so the IDE companion server on the host is unreachable. To use IDE integration, run Gemini CLI without the runsc sandbox.'; + const GENERIC_MESSAGE = + 'Failed to connect to IDE companion extension in VS Code. Please ensure the extension is running. To install the extension, run /ide install.'; + + beforeEach(() => { + vi.mocked(isGvisorSandbox).mockReturnValue(true); + vi.mocked(getConnectionConfigFromFile).mockResolvedValue(undefined); + vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true }); + }); + + afterEach(() => { + vi.mocked(isGvisorSandbox).mockReset(); + }); + + it('should explain the gVisor network isolation when the HTTP connection fails', async () => { + vi.mocked(getPortFromEnv).mockReturnValue('9090'); + mockClient.connect.mockRejectedValue(new Error('ECONNREFUSED')); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + + // The connection is still attempted; only the diagnostic changes. + expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( + new URL('http://127.0.0.1:9090/mcp'), + expect.any(Object), + ); + expect(ideClient.getConnectionStatus()).toEqual({ + status: IDEConnectionStatus.Disconnected, + details: GVISOR_MESSAGE, + }); + }); + + it('should explain the gVisor network isolation when the stdio connection fails', async () => { + vi.mocked(getStdioConfigFromEnv).mockReturnValue({ + command: 'env-cmd', + args: ['--bar'], + }); + mockClient.connect.mockRejectedValue(new Error('ENOENT')); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + + expect(StdioClientTransport).toHaveBeenCalled(); + expect(ideClient.getConnectionStatus()).toEqual({ + status: IDEConnectionStatus.Disconnected, + details: GVISOR_MESSAGE, + }); + }); + + it('should explain the gVisor network isolation when no connection config is found', async () => { + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + + expect(StreamableHTTPClientTransport).not.toHaveBeenCalled(); + expect(StdioClientTransport).not.toHaveBeenCalled(); + expect(ideClient.getConnectionStatus()).toEqual({ + status: IDEConnectionStatus.Disconnected, + details: GVISOR_MESSAGE, + }); + }); + + it('should explain the gVisor network isolation instead of suggesting /ide install when the workspace path is unknown', async () => { + vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', undefined); + vi.mocked(validateWorkspacePath).mockReturnValue({ + isValid: false, + error: GENERIC_MESSAGE, + }); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + + expect(validateWorkspacePath).toHaveBeenCalledWith( + undefined, + '/test/workspace/sub-dir', + ); + expect(StreamableHTTPClientTransport).not.toHaveBeenCalled(); + expect(ideClient.getConnectionStatus()).toEqual({ + status: IDEConnectionStatus.Disconnected, + details: GVISOR_MESSAGE, + }); + }); + + it('should preserve workspace validation errors when the workspace path is known', async () => { + const mismatchError = + 'Directory mismatch. Gemini CLI is running in a different location than the open workspace in the IDE.'; + vi.mocked(validateWorkspacePath).mockReturnValue({ + isValid: false, + error: mismatchError, + }); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + + expect(ideClient.getConnectionStatus()).toEqual({ + status: IDEConnectionStatus.Disconnected, + details: mismatchError, + }); + }); + + it('should still connect when the companion is reachable', async () => { + vi.mocked(getPortFromEnv).mockReturnValue('9090'); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + + expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport); + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Connected, + ); + }); + + it('should keep the generic message when not running under gVisor', async () => { + vi.mocked(isGvisorSandbox).mockReturnValue(false); + vi.mocked(getPortFromEnv).mockReturnValue('9090'); + mockClient.connect.mockRejectedValue(new Error('ECONNREFUSED')); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + + expect(ideClient.getConnectionStatus()).toEqual({ + status: IDEConnectionStatus.Disconnected, + details: GENERIC_MESSAGE, + }); + }); + }); }); describe('isDiffingEnabled', () => { diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 78ae83eebf1..b6e7c46075b 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -148,7 +148,7 @@ export class IdeClient { 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.`; + const gvisorFailureDetails = `Failed to connect to IDE companion extension in ${ideName}: gVisor (runsc) sandboxing isolates the container network stack, so the IDE companion server on the host is unreachable. To use IDE integration, run Gemini CLI without the runsc sandbox.`; const { isValid, error } = validateWorkspacePath( workspacePath, @@ -156,6 +156,9 @@ export class IdeClient { ); if (!isValid) { + // An unknown workspace path normally means the extension is not + // installed, so the generic error suggests `/ide install`. Under gVisor + // that advice cannot help: the companion is unreachable either way. this.setState( IDEConnectionStatus.Disconnected, workspacePath === undefined && isGvisor ? gvisorFailureDetails : error, diff --git a/packages/core/src/ide/ide-connection-utils.test.ts b/packages/core/src/ide/ide-connection-utils.test.ts index 16315689429..02ff9661b33 100644 --- a/packages/core/src/ide/ide-connection-utils.test.ts +++ b/packages/core/src/ide/ide-connection-utils.test.ts @@ -18,6 +18,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { getConnectionConfigFromFile, + isGvisorSandbox, validateWorkspacePath, getIdeServerHost, } from './ide-connection-utils.js'; @@ -886,4 +887,31 @@ describe('ide-connection-utils', () => { }); }); }); + + describe('isGvisorSandbox', () => { + it.each([ + ['runsc', true], + ['RUNSC', true], + [' runsc\n', true], + ['docker', false], + ['podman', false], + ['sandbox-exec', false], + ['true', false], + ['', false], + ])('returns %s for GEMINI_SANDBOX=%j', (value, expected) => { + vi.stubEnv('GEMINI_SANDBOX', value); + expect(isGvisorSandbox()).toBe(expected); + }); + + it('returns false when GEMINI_SANDBOX is not set', () => { + vi.stubEnv('GEMINI_SANDBOX', undefined); + expect(isGvisorSandbox()).toBe(false); + }); + + it('ignores the SANDBOX container name', () => { + vi.stubEnv('GEMINI_SANDBOX', undefined); + vi.stubEnv('SANDBOX', 'gemini-cli-sandbox-runsc-0123456789ab'); + expect(isGvisorSandbox()).toBe(false); + }); + }); }); diff --git a/packages/core/src/ide/ide-connection-utils.ts b/packages/core/src/ide/ide-connection-utils.ts index 89fd54e1b0c..f55d3f81c75 100644 --- a/packages/core/src/ide/ide-connection-utils.ts +++ b/packages/core/src/ide/ide-connection-utils.ts @@ -395,11 +395,16 @@ export function getIdeServerHost() { return host; } +/** + * Returns true when the CLI runs inside a gVisor (runsc) sandbox container. + * + * The sandbox launcher forwards `GEMINI_SANDBOX=runsc` into the container + * (see `start_sandbox` in `packages/cli/src/utils/sandbox.ts`). gVisor's + * isolated network stack cannot reach the IDE companion server on the host + * loopback interface, so IDE connection attempts always fail there. + */ export function isGvisorSandbox(): boolean { - return ( - Boolean(process.env['SANDBOX']?.toLowerCase().includes('runsc')) || - process.env['GEMINI_SANDBOX']?.toLowerCase().trim() === 'runsc' - ); + return process.env['GEMINI_SANDBOX']?.toLowerCase().trim() === 'runsc'; } function isInContainer() { diff --git a/packages/core/src/ide/ide-gvisor-sandbox.test.ts b/packages/core/src/ide/ide-gvisor-sandbox.test.ts index bb5af113540..7d1ed3724fb 100644 --- a/packages/core/src/ide/ide-gvisor-sandbox.test.ts +++ b/packages/core/src/ide/ide-gvisor-sandbox.test.ts @@ -4,49 +4,73 @@ * 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'; +/** + * Regression tests for + * https://github.com/google-gemini/gemini-cli/issues/21331 + * + * Exercises the real HTTP connection path of `IdeClient.connect()` in the + * environment the sandbox launcher creates inside a gVisor (runsc) container: + * `TERM_PROGRAM`, `GEMINI_CLI_IDE_SERVER_PORT`, `GEMINI_CLI_IDE_WORKSPACE_PATH` + * and `GEMINI_SANDBOX=runsc` are forwarded, but gVisor's isolated network stack + * cannot reach the IDE companion server on the host, so every connection + * attempt fails. + * + * The tests are hermetic: the unreachable companion is a refused loopback + * port (no DNS, no Docker, no runsc needed) and `os.tmpdir()` points at an + * empty directory so host discovery files cannot leak in. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { IdeClient, IDEConnectionStatus } from './ide-client.js'; -import { getIdeServerHost, isGvisorSandbox } 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')), - }, - }; -}); - +// Inside the sandbox the IDE process is not an ancestor of the CLI; the CLI +// only learns about the IDE through the environment variables forwarded by +// the sandbox launcher. 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; +const GVISOR_MESSAGE = + 'gVisor (runsc) sandboxing isolates the container network stack, so the IDE companion server on the host is unreachable. To use IDE integration, run Gemini CLI without the runsc sandbox.'; +const GENERIC_MESSAGE = + 'Please ensure the extension is running. To install the extension, run /ide install.'; + +/** + * Returns a loopback port nothing is listening on. Connecting to it is refused + * immediately, which is the same observable outcome the CLI gets under gVisor + * when it tries to reach the companion on the host. + */ +async function getUnreachablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + reject(new Error('Could not allocate a loopback port')); + return; + } + server.close(() => resolve(address.port)); + }); + }); +} + +async function connectAndGetStatus() { + const ideClient = await IdeClient.getInstance(); + await ideClient.connect({ logToConsole: false }); + return ideClient.getConnectionStatus(); +} - beforeEach(async () => { - // Reset IdeClient singleton instancePromise between tests +describe('IdeClient inside a gVisor (runsc) sandbox', () => { + let sandboxTmpDir: string; + + beforeEach(() => { ( IdeClient as unknown as { instancePromise: Promise | null } ).instancePromise = null; @@ -56,162 +80,63 @@ describe('Issue #21331: IDE Companion connection under gVisor (runsc) sandbox', 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); - }); + // The host's $TMPDIR/gemini/ide discovery directory is not mounted into + // the sandbox. + sandboxTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-runsc-')); + vi.stubEnv('TMPDIR', sandboxTmpDir); + vi.stubEnv('TMP', sandboxTmpDir); + vi.stubEnv('TEMP', sandboxTmpDir); + // Environment forwarded by the sandbox launcher. + vi.stubEnv('GEMINI_SANDBOX', 'runsc'); vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', process.cwd()); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', undefined); + vi.stubEnv('GEMINI_CLI_IDE_AUTH_TOKEN', undefined); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_STDIO_COMMAND', undefined); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_STDIO_ARGS', undefined); }); - afterEach(async () => { + afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); - await new Promise((resolve) => { - mockCompanionServer.close(() => resolve()); - }); + fs.rmSync(sandboxTmpDir, { recursive: true, force: true }); }); - 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'); - expect(isGvisorSandbox()).toBe(true); - - 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('explains the gVisor network isolation when the companion is unreachable', async () => { + vi.stubEnv( + 'GEMINI_CLI_IDE_SERVER_PORT', + String(await getUnreachablePort()), ); - 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'); - expect(isGvisorSandbox()).toBe(true); + const { status, details } = await connectAndGetStatus(); - 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(status).toBe(IDEConnectionStatus.Disconnected); + expect(details).toContain(GVISOR_MESSAGE); + expect(details).not.toContain('/ide install'); }); - 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'); + it('explains the gVisor network isolation when no IDE connection details reach the sandbox', async () => { vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', undefined); - const ideClient = await IdeClient.getInstance(); - await ideClient.connect({ logToConsole: false }); + const { status, details } = await connectAndGetStatus(); - 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', - ); + expect(status).toBe(IDEConnectionStatus.Disconnected); + expect(details).toContain(GVISOR_MESSAGE); + expect(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 () => { + it('keeps the generic message outside gVisor when the companion is unreachable', 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'); - - expect(isGvisorSandbox()).toBe(false); + vi.stubEnv( + 'GEMINI_CLI_IDE_SERVER_PORT', + String(await getUnreachablePort()), + ); - const ideClient = await IdeClient.getInstance(); - await ideClient.connect({ logToConsole: false }); + const { status, details } = await connectAndGetStatus(); - 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.', - ); + expect(status).toBe(IDEConnectionStatus.Disconnected); + expect(details).toContain(GENERIC_MESSAGE); + expect(details).not.toContain('gVisor'); }); }); diff --git a/packages/vscode-ide-companion/src/ide-server.test.ts b/packages/vscode-ide-companion/src/ide-server.test.ts index a705d65dd86..630640574ad 100644 --- a/packages/vscode-ide-companion/src/ide-server.test.ts +++ b/packages/vscode-ide-companion/src/ide-server.test.ts @@ -541,45 +541,4 @@ 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 08749aeb623..39ef770079d 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -164,10 +164,8 @@ 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.toLowerCase())) { + if (!allowedHosts.includes(host)) { return res.status(403).json({ error: 'Invalid Host header' }); } next();