diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 5beb243aa15..42d2a36989b 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 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) 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..0cf225279e3 100644 --- a/docs/ide-integration/index.md +++ b/docs/ide-integration/index.md @@ -220,6 +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:** 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 @@ -240,6 +244,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 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` - **Cause:** The connection to the IDE companion was lost. diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index 1d0c511f93c..809ef1b41a2 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -1748,14 +1748,10 @@ describe('sandbox', () => { }); describe('gVisor (runsc)', () => { - it('should use docker with --runtime=runsc on Linux', async () => { - vi.mocked(os.platform).mockReturnValue('linux'); - 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; } @@ -1769,7 +1765,6 @@ describe('sandbox', () => { return mockImageCheckProcess as unknown as ReturnType; }); - // Mock docker run const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< typeof spawn >; @@ -1783,19 +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[]; + } - // Verify docker run includes --runtime=runsc - expect(spawn).toHaveBeenNthCalledWith( - 2, - 'docker', + /** 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', + }), + ); + + expect(dockerRunArgs).toEqual( expect.arrayContaining(['run', '--runtime=runsc']), - expect.objectContaining({ stdio: 'inherit' }), + ); + expect(envEntries(dockerRunArgs)).toEqual( + expect.arrayContaining([ + 'GEMINI_SANDBOX=runsc', + 'GEMINI_CLI_IDE_SERVER_PORT=54321', + 'GEMINI_CLI_IDE_WORKSPACE_PATH=/workspace/project', + 'TERM_PROGRAM=vscode', + ]), + ); + }); + + 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 007a4645080..97b0e33f866 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -801,6 +801,13 @@ export async function start_sandbox( } } + // 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 // also mount-replace VIRTUAL_ENV directory with /sandbox.venv // sandbox can then set up this new VIRTUAL_ENV directory using sandbox.bashrc (see below) 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 bfd5cae6b8a..b6e7c46075b 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,13 +146,24 @@ export class IdeClient { connectionConfig?.workspacePath ?? process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; + const isGvisor = isGvisorSandbox(); + const ideName = this.currentIde.displayName; + 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, process.cwd(), ); if (!isValid) { - this.setState(IDEConnectionStatus.Disconnected, error, logError); + // 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, + logError, + ); return; } @@ -194,11 +206,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-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 1df20ff9baf..f55d3f81c75 100644 --- a/packages/core/src/ide/ide-connection-utils.ts +++ b/packages/core/src/ide/ide-connection-utils.ts @@ -395,6 +395,18 @@ 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 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 new file mode 100644 index 00000000000..7d1ed3724fb --- /dev/null +++ b/packages/core/src/ide/ide-gvisor-sandbox.test.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * 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 { getIdeProcessInfo } from './process-utils.js'; + +// 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(), +})); + +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(); +} + +describe('IdeClient inside a gVisor (runsc) sandbox', () => { + let sandboxTmpDir: string; + + beforeEach(() => { + ( + IdeClient as unknown as { instancePromise: Promise | null } + ).instancePromise = null; + + vi.mocked(getIdeProcessInfo).mockResolvedValue({ + pid: 1, + command: '/sbin/docker-init -- bash', + }); + + // 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(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + fs.rmSync(sandboxTmpDir, { recursive: true, force: true }); + }); + + it('explains the gVisor network isolation when the companion is unreachable', async () => { + vi.stubEnv( + 'GEMINI_CLI_IDE_SERVER_PORT', + String(await getUnreachablePort()), + ); + + const { status, details } = await connectAndGetStatus(); + + expect(status).toBe(IDEConnectionStatus.Disconnected); + expect(details).toContain(GVISOR_MESSAGE); + expect(details).not.toContain('/ide install'); + }); + + it('explains the gVisor network isolation when no IDE connection details reach the sandbox', async () => { + vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', undefined); + + const { status, details } = await connectAndGetStatus(); + + expect(status).toBe(IDEConnectionStatus.Disconnected); + expect(details).toContain(GVISOR_MESSAGE); + expect(details).not.toContain('/ide install'); + }); + + 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(await getUnreachablePort()), + ); + + const { status, details } = await connectAndGetStatus(); + + expect(status).toBe(IDEConnectionStatus.Disconnected); + expect(details).toContain(GENERIC_MESSAGE); + expect(details).not.toContain('gVisor'); + }); +});