diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index 7ec7e53124..64bfc022b1 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -23,6 +23,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { detectIde, IDE_DEFINITIONS } from './detect-ide.js'; import * as os from 'node:os'; import * as path from 'node:path'; +import { getIdeServerHost } from './ide-client.js'; vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); @@ -34,7 +35,7 @@ vi.mock('node:fs', async (importOriginal) => { readdir: vi.fn(), }, realpathSync: (p: string) => p, - existsSync: () => false, + existsSync: vi.fn(() => false), }; }); vi.mock('./process-utils.js'); @@ -110,7 +111,7 @@ describe('IdeClient', () => { await ideClient.connect(); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp/', 'gemini-ide-server-12345.json'), + path.join('/tmp', 'gemini', 'ide', 'gemini-ide-server-12345.json'), 'utf8', ); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( @@ -277,7 +278,7 @@ describe('IdeClient', () => { expect(result).toEqual(config); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'gemini-ide-server-12345.json'), + path.join('/tmp', 'gemini', 'ide', 'gemini-ide-server-12345.json'), 'utf8', ); }); @@ -324,7 +325,7 @@ describe('IdeClient', () => { expect(result).toEqual(config); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'gemini-ide-server-12345-123.json'), + path.join('/tmp', 'gemini', 'ide', 'gemini-ide-server-12345-123.json'), 'utf8', ); }); @@ -517,11 +518,11 @@ describe('IdeClient', () => { expect(result).toEqual(validConfig); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'gemini-ide-server-12345-111.json'), + path.join('/tmp', 'gemini', 'ide', 'gemini-ide-server-12345-111.json'), 'utf8', ); expect(fs.promises.readFile).not.toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'not-a-config-file.txt'), + path.join('/tmp', 'gemini', 'ide', 'not-a-config-file.txt'), 'utf8', ); }); @@ -956,3 +957,178 @@ describe('IdeClient', () => { }); }); }); + +describe('getIdeServerHost', () => { + let existsSyncMock: Mock; + let originalSshConnection: string | undefined; + let originalVscodeRemoteSession: string | undefined; + let originalRemoteContainers: string | undefined; + + beforeEach(() => { + existsSyncMock = vi.mocked(fs.existsSync); + existsSyncMock.mockClear(); + originalSshConnection = process.env['SSH_CONNECTION']; + originalVscodeRemoteSession = + process.env['VSCODE_REMOTE_CONTAINERS_SESSION']; + originalRemoteContainers = process.env['REMOTE_CONTAINERS']; + + delete process.env['SSH_CONNECTION']; + delete process.env['VSCODE_REMOTE_CONTAINERS_SESSION']; + delete process.env['REMOTE_CONTAINERS']; + }); + + afterEach(() => { + vi.clearAllMocks(); + if (originalSshConnection !== undefined) { + process.env['SSH_CONNECTION'] = originalSshConnection; + } else { + delete process.env['SSH_CONNECTION']; + } + if (originalVscodeRemoteSession !== undefined) { + process.env['VSCODE_REMOTE_CONTAINERS_SESSION'] = + originalVscodeRemoteSession; + } else { + delete process.env['VSCODE_REMOTE_CONTAINERS_SESSION']; + } + if (originalRemoteContainers !== undefined) { + process.env['REMOTE_CONTAINERS'] = originalRemoteContainers; + } else { + delete process.env['REMOTE_CONTAINERS']; + } + }); + + // Helper to set existsSync mock behavior + const setupFsMocks = ( + dockerenvExists: boolean, + containerenvExists: boolean, + ) => { + existsSyncMock.mockImplementation((path: string) => { + if (path === '/.dockerenv') { + return dockerenvExists; + } + if (path === '/run/.containerenv') { + return containerenvExists; + } + return false; + }); + }; + + it('should return 127.0.0.1 when not in container and no SSH_CONNECTION or Dev Container env vars', () => { + setupFsMocks(false, false); + delete process.env['SSH_CONNECTION']; + delete process.env['VSCODE_REMOTE_CONTAINERS_SESSION']; + delete process.env['REMOTE_CONTAINERS']; + expect(getIdeServerHost()).toBe('127.0.0.1'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/.dockerenv'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/run/.containerenv'); + }); + + it('should return 127.0.0.1 when not in container but SSH_CONNECTION is set', () => { + setupFsMocks(false, false); + process.env['SSH_CONNECTION'] = 'some_ssh_value'; + delete process.env['VSCODE_REMOTE_CONTAINERS_SESSION']; + delete process.env['REMOTE_CONTAINERS']; + expect(getIdeServerHost()).toBe('127.0.0.1'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/.dockerenv'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/run/.containerenv'); + }); + + it('should return host.docker.internal when in .dockerenv container and no SSH_CONNECTION or Dev Container env vars', () => { + setupFsMocks(true, false); + delete process.env['SSH_CONNECTION']; + delete process.env['VSCODE_REMOTE_CONTAINERS_SESSION']; + delete process.env['REMOTE_CONTAINERS']; + expect(getIdeServerHost()).toBe('host.docker.internal'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/.dockerenv'); + expect(vi.mocked(fs.existsSync)).not.toHaveBeenCalledWith( + '/run/.containerenv', + ); // Short-circuiting + }); + + it('should return 127.0.0.1 when in .dockerenv container and SSH_CONNECTION is set', () => { + setupFsMocks(true, false); + process.env['SSH_CONNECTION'] = 'some_ssh_value'; + delete process.env['VSCODE_REMOTE_CONTAINERS_SESSION']; + delete process.env['REMOTE_CONTAINERS']; + expect(getIdeServerHost()).toBe('127.0.0.1'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/.dockerenv'); + expect(vi.mocked(fs.existsSync)).not.toHaveBeenCalledWith( + '/run/.containerenv', + ); // Short-circuiting + }); + + it('should return 127.0.0.1 when in .dockerenv container and VSCODE_REMOTE_CONTAINERS_SESSION is set', () => { + setupFsMocks(true, false); + delete process.env['SSH_CONNECTION']; + process.env['VSCODE_REMOTE_CONTAINERS_SESSION'] = 'some_session_id'; + expect(getIdeServerHost()).toBe('127.0.0.1'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/.dockerenv'); + expect(vi.mocked(fs.existsSync)).not.toHaveBeenCalledWith( + '/run/.containerenv', + ); // Short-circuiting + }); + + it('should return host.docker.internal when in .containerenv container and no SSH_CONNECTION or Dev Container env vars', () => { + setupFsMocks(false, true); + delete process.env['SSH_CONNECTION']; + delete process.env['VSCODE_REMOTE_CONTAINERS_SESSION']; + delete process.env['REMOTE_CONTAINERS']; + expect(getIdeServerHost()).toBe('host.docker.internal'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/.dockerenv'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/run/.containerenv'); + }); + + it('should return 127.0.0.1 when in .containerenv container and SSH_CONNECTION is set', () => { + setupFsMocks(false, true); + process.env['SSH_CONNECTION'] = 'some_ssh_value'; + delete process.env['VSCODE_REMOTE_CONTAINERS_SESSION']; + delete process.env['REMOTE_CONTAINERS']; + expect(getIdeServerHost()).toBe('127.0.0.1'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/.dockerenv'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/run/.containerenv'); + }); + + it('should return 127.0.0.1 when in .containerenv container and REMOTE_CONTAINERS is set', () => { + setupFsMocks(false, true); + delete process.env['SSH_CONNECTION']; + process.env['REMOTE_CONTAINERS'] = 'true'; + expect(getIdeServerHost()).toBe('127.0.0.1'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/.dockerenv'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/run/.containerenv'); + }); + + it('should return host.docker.internal when in both containers and no SSH_CONNECTION or Dev Container env vars', () => { + setupFsMocks(true, true); + delete process.env['SSH_CONNECTION']; + delete process.env['VSCODE_REMOTE_CONTAINERS_SESSION']; + delete process.env['REMOTE_CONTAINERS']; + expect(getIdeServerHost()).toBe('host.docker.internal'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/.dockerenv'); + expect(vi.mocked(fs.existsSync)).not.toHaveBeenCalledWith( + '/run/.containerenv', + ); // Short-circuiting + }); + + it('should return 127.0.0.1 when in both containers and SSH_CONNECTION is set', () => { + setupFsMocks(true, true); + process.env['SSH_CONNECTION'] = 'some_ssh_value'; + delete process.env['VSCODE_REMOTE_CONTAINERS_SESSION']; + delete process.env['REMOTE_CONTAINERS']; + expect(getIdeServerHost()).toBe('127.0.0.1'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/.dockerenv'); + expect(vi.mocked(fs.existsSync)).not.toHaveBeenCalledWith( + '/run/.containerenv', + ); // Short-circuiting + }); + + it('should return 127.0.0.1 when in both containers and VSCODE_REMOTE_CONTAINERS_SESSION is set', () => { + setupFsMocks(true, true); + delete process.env['SSH_CONNECTION']; + process.env['VSCODE_REMOTE_CONTAINERS_SESSION'] = 'some_session_id'; + expect(getIdeServerHost()).toBe('127.0.0.1'); + expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith('/.dockerenv'); + expect(vi.mocked(fs.existsSync)).not.toHaveBeenCalledWith( + '/run/.containerenv', + ); // Short-circuiting + }); +}); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index d8e0577e28..a4d9234bd0 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -585,6 +585,8 @@ export class IdeClient { try { const portFile = path.join( os.tmpdir(), + 'gemini', + 'ide', `gemini-ide-server-${this.ideProcessInfo.pid}.json`, ); const portFileContents = await fs.promises.readFile(portFile, 'utf8'); @@ -671,11 +673,11 @@ export class IdeClient { return validWorkspaces[0]; } - private createProxyAwareFetch() { - // ignore proxy for '127.0.0.1' by default to allow connecting to the ide mcp server + private async createProxyAwareFetch(ideServerHost: string) { + // ignore proxy for the IDE server host to allow connecting to the ide mcp server const existingNoProxy = process.env['NO_PROXY'] || ''; const agent = new EnvHttpProxyAgent({ - noProxy: [existingNoProxy, '127.0.0.1'].filter(Boolean).join(','), + noProxy: [existingNoProxy, ideServerHost].filter(Boolean).join(','), }); const undiciPromise = import('undici'); // Suppress unhandled rejection if the promise is not awaited immediately. @@ -777,23 +779,28 @@ export class IdeClient { private async establishHttpConnection(port: string): Promise { let transport: StreamableHTTPClientTransport | undefined; try { + const ideServerHost = getIdeServerHost(); + const portNumber = parseInt(port, 10); + // validate port to prevent Server-Side Request Forgery (SSRF) vulnerability + if (isNaN(portNumber) || portNumber <= 0 || portNumber > 65535) { + return false; + } + const serverUrl = `http://${ideServerHost}:${portNumber}/mcp`; logger.debug('Attempting to connect to IDE via HTTP SSE'); + logger.debug(`Server URL: ${serverUrl}`); this.client = new Client({ name: 'streamable-http-client', // TODO(#3487): use the CLI version here. version: '1.0.0', }); - transport = new StreamableHTTPClientTransport( - new URL(`http://${getIdeServerHost()}:${port}/mcp`), - { - fetch: this.createProxyAwareFetch(), - requestInit: { - headers: this.authToken - ? { Authorization: `Bearer ${this.authToken}` } - : {}, - }, + transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: await this.createProxyAwareFetch(ideServerHost), + requestInit: { + headers: this.authToken + ? { Authorization: `Bearer ${this.authToken}` } + : {}, }, - ); + }); await this.client.connect(transport); this.registerClientHandlers(); await this.discoverTools(); @@ -846,8 +853,31 @@ export class IdeClient { } } -function getIdeServerHost() { - const isInContainer = - fs.existsSync('/.dockerenv') || fs.existsSync('/run/.containerenv'); - return isInContainer ? 'host.docker.internal' : '127.0.0.1'; +export function getIdeServerHost() { + let host: string; + host = '127.0.0.1'; + if (isInContainer()) { + // when ssh-connection (e.g. remote-ssh) or devcontainer setup: + // --> host must be '127.0.0.1' to have cli companion working + if (!isSshConnected() && !isDevContainer()) { + host = 'host.docker.internal'; + } + } + logger.debug(`[getIdeServerHost] Mapping IdeServerHost to '${host}'`); + return host; +} + +function isInContainer() { + return fs.existsSync('/.dockerenv') || fs.existsSync('/run/.containerenv'); +} + +function isSshConnected() { + return !!process.env['SSH_CONNECTION']; +} + +function isDevContainer() { + return !!( + process.env['VSCODE_REMOTE_CONTAINERS_SESSION'] || + process.env['REMOTE_CONTAINERS'] + ); }