Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/cli/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/ide-integration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
97 changes: 81 additions & 16 deletions packages/cli/src/utils/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
Expand All @@ -1769,7 +1765,6 @@ describe('sandbox', () => {
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
});

// Mock docker run
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
>;
Expand All @@ -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',
]),
);
});
});
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/utils/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project_settings>/sandbox.venv
// sandbox can then set up this new VIRTUAL_ENV directory using sandbox.bashrc (see below)
Expand Down
129 changes: 129 additions & 0 deletions packages/core/src/ide/ide-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
getConnectionConfigFromFile,
getStdioConfigFromEnv,
getPortFromEnv,
isGvisorSandbox,
validateWorkspacePath,
getIdeServerHost,
} from './ide-connection-utils.js';
Expand Down Expand Up @@ -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', () => {
Expand Down
24 changes: 18 additions & 6 deletions packages/core/src/ide/ide-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
getIdeServerHost,
getPortFromEnv,
getStdioConfigFromEnv,
isGvisorSandbox,
validateWorkspacePath,
createProxyAwareFetch,
type StdioConfig,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}

/**
Expand Down
Loading
Loading