From 0dd0b836123f6ddb9fba888fdfeee69398702d44 Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Mon, 2 Feb 2026 19:42:29 -0500 Subject: [PATCH] fix(ide): no-op refactoring that moves the connection logic to helper functions (#18118) --- packages/core/src/ide/ide-client.test.ts | 710 ++---------------- packages/core/src/ide/ide-client.ts | 307 +------- .../core/src/ide/ide-connection-utils.test.ts | 544 ++++++++++++++ packages/core/src/ide/ide-connection-utils.ts | 253 +++++++ 4 files changed, 882 insertions(+), 932 deletions(-) create mode 100644 packages/core/src/ide/ide-connection-utils.test.ts create mode 100644 packages/core/src/ide/ide-connection-utils.ts diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index 25822a4161..7cf2f101e6 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -12,19 +12,23 @@ import { beforeEach, afterEach, type Mocked, - type Mock, } from 'vitest'; import { IdeClient, IDEConnectionStatus } from './ide-client.js'; -import * as fs from 'node:fs'; +import type * as fs from 'node:fs'; import { getIdeProcessInfo } from './process-utils.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; 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'; -import { pathToFileURL } from 'node:url'; + +import { + getConnectionConfigFromFile, + getStdioConfigFromEnv, + getPortFromEnv, + validateWorkspacePath, + getIdeServerHost, +} from './ide-connection-utils.js'; vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); @@ -45,6 +49,7 @@ vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js'); vi.mock('@modelcontextprotocol/sdk/client/stdio.js'); vi.mock('./detect-ide.js'); vi.mock('node:os'); +vi.mock('./ide-connection-utils.js'); describe('IdeClient', () => { let mockClient: Mocked; @@ -71,6 +76,7 @@ describe('IdeClient', () => { command: 'test-ide', }); vi.mocked(os.tmpdir).mockReturnValue('/tmp'); + vi.mocked(getIdeServerHost).mockReturnValue('127.0.0.1'); // Mock MCP client and transports mockClient = { @@ -101,20 +107,13 @@ describe('IdeClient', () => { describe('connect', () => { it('should connect using HTTP when port is provided in config file', async () => { const config = { port: '8080' }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); + vi.mocked(getConnectionConfigFromFile).mockResolvedValue(config); + vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true }); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); - expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'gemini', 'ide', 'gemini-ide-server-12345.json'), - 'utf8', - ); + expect(getConnectionConfigFromFile).toHaveBeenCalledWith(12345); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( new URL('http://127.0.0.1:8080/mcp'), expect.any(Object), @@ -126,13 +125,10 @@ describe('IdeClient', () => { }); it('should connect using stdio when stdio config is provided in file', async () => { + // Update the mock to use the new utility const config = { stdio: { command: 'test-cmd', args: ['--foo'] } }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); + vi.mocked(getConnectionConfigFromFile).mockResolvedValue(config); + vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true }); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -152,12 +148,8 @@ describe('IdeClient', () => { port: '8080', stdio: { command: 'test-cmd', args: ['--foo'] }, }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); + vi.mocked(getConnectionConfigFromFile).mockResolvedValue(config); + vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true }); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -170,15 +162,9 @@ describe('IdeClient', () => { }); it('should connect using HTTP when port is provided in environment variables', async () => { - vi.mocked(fs.promises.readFile).mockRejectedValue( - new Error('File not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); - process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090'; + vi.mocked(getConnectionConfigFromFile).mockResolvedValue(undefined); + vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true }); + vi.mocked(getPortFromEnv).mockReturnValue('9090'); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -194,16 +180,12 @@ describe('IdeClient', () => { }); it('should connect using stdio when stdio config is in environment variables', async () => { - vi.mocked(fs.promises.readFile).mockRejectedValue( - new Error('File not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); - process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'] = 'env-cmd'; - process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'] = '["--bar"]'; + vi.mocked(getConnectionConfigFromFile).mockResolvedValue(undefined); + vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true }); + vi.mocked(getStdioConfigFromEnv).mockReturnValue({ + command: 'env-cmd', + args: ['--bar'], + }); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -220,13 +202,9 @@ describe('IdeClient', () => { it('should prioritize file config over environment variables', async () => { const config = { port: '8080' }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); - process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090'; + vi.mocked(getConnectionConfigFromFile).mockResolvedValue(config); + vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true }); + vi.mocked(getPortFromEnv).mockReturnValue('9090'); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -241,14 +219,8 @@ describe('IdeClient', () => { }); it('should be disconnected if no config is found', async () => { - vi.mocked(fs.promises.readFile).mockRejectedValue( - new Error('File not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); + vi.mocked(getConnectionConfigFromFile).mockResolvedValue(undefined); + vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true }); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -264,303 +236,6 @@ describe('IdeClient', () => { }); }); - describe('getConnectionConfigFromFile', () => { - it('should return config from the specific pid file if it exists', async () => { - const config = { port: '1234', workspacePath: '/test/workspace' }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - - const ideClient = await IdeClient.getInstance(); - // In tests, the private method can be accessed like this. - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(config); - expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'gemini', 'ide', 'gemini-ide-server-12345.json'), - 'utf8', - ); - }); - - it('should return undefined if no config files are found', async () => { - vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found')); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toBeUndefined(); - }); - - it('should find and parse a single config file with the new naming scheme', async () => { - const config = { port: '5678', workspacePath: '/test/workspace' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); // For old path - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue(['gemini-ide-server-12345-123.json']); - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(config); - expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'gemini', 'ide', 'gemini-ide-server-12345-123.json'), - 'utf8', - ); - }); - - it('should filter out configs with invalid workspace paths', async () => { - const validConfig = { - port: '5678', - workspacePath: '/test/workspace', - }; - const invalidConfig = { - port: '1111', - workspacePath: '/invalid/workspace', - }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'gemini-ide-server-12345-111.json', - 'gemini-ide-server-12345-222.json', - ]); - vi.mocked(fs.promises.readFile) - .mockResolvedValueOnce(JSON.stringify(invalidConfig)) - .mockResolvedValueOnce(JSON.stringify(validConfig)); - - const validateSpy = vi - .spyOn(IdeClient, 'validateWorkspacePath') - .mockReturnValueOnce({ isValid: false }) - .mockReturnValueOnce({ isValid: true }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(validConfig); - expect(validateSpy).toHaveBeenCalledWith( - '/invalid/workspace', - '/test/workspace/sub-dir', - ); - expect(validateSpy).toHaveBeenCalledWith( - '/test/workspace', - '/test/workspace/sub-dir', - ); - }); - - it('should return the first valid config when multiple workspaces are valid', async () => { - const config1 = { port: '1111', workspacePath: '/test/workspace' }; - const config2 = { port: '2222', workspacePath: '/test/workspace2' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'gemini-ide-server-12345-111.json', - 'gemini-ide-server-12345-222.json', - ]); - vi.mocked(fs.promises.readFile) - .mockResolvedValueOnce(JSON.stringify(config1)) - .mockResolvedValueOnce(JSON.stringify(config2)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(config1); - }); - - it('should prioritize the config matching the port from the environment variable', async () => { - process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '2222'; - const config1 = { port: '1111', workspacePath: '/test/workspace' }; - const config2 = { port: '2222', workspacePath: '/test/workspace2' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'gemini-ide-server-12345-111.json', - 'gemini-ide-server-12345-222.json', - ]); - vi.mocked(fs.promises.readFile) - .mockResolvedValueOnce(JSON.stringify(config1)) - .mockResolvedValueOnce(JSON.stringify(config2)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(config2); - }); - - it('should handle invalid JSON in one of the config files', async () => { - const validConfig = { port: '2222', workspacePath: '/test/workspace' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'gemini-ide-server-12345-111.json', - 'gemini-ide-server-12345-222.json', - ]); - vi.mocked(fs.promises.readFile) - .mockResolvedValueOnce('invalid json') - .mockResolvedValueOnce(JSON.stringify(validConfig)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(validConfig); - }); - - it('should return undefined if readdir throws an error', async () => { - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - vi.mocked(fs.promises.readdir).mockRejectedValue( - new Error('readdir failed'), - ); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toBeUndefined(); - }); - - it('should ignore files with invalid names', async () => { - const validConfig = { port: '3333', workspacePath: '/test/workspace' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'gemini-ide-server-12345-111.json', // valid - 'not-a-config-file.txt', // invalid - 'gemini-ide-server-asdf.json', // invalid - ]); - vi.mocked(fs.promises.readFile).mockResolvedValueOnce( - JSON.stringify(validConfig), - ); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(validConfig); - expect(fs.promises.readFile).toHaveBeenCalledWith( - 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'), - 'utf8', - ); - }); - - it('should match env port string to a number port in the config', async () => { - process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '3333'; - const config1 = { port: 1111, workspacePath: '/test/workspace' }; - const config2 = { port: 3333, workspacePath: '/test/workspace2' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'gemini-ide-server-12345-111.json', - 'gemini-ide-server-12345-222.json', - ]); - vi.mocked(fs.promises.readFile) - .mockResolvedValueOnce(JSON.stringify(config1)) - .mockResolvedValueOnce(JSON.stringify(config2)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(config2); - }); - }); - describe('isDiffingEnabled', () => { it('should return false if not connected', async () => { const ideClient = await IdeClient.getInstance(); @@ -569,12 +244,8 @@ describe('IdeClient', () => { it('should return false if tool discovery fails', async () => { const config = { port: '8080' }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); + vi.mocked(getConnectionConfigFromFile).mockResolvedValue(config); + vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true }); mockClient.request.mockRejectedValue(new Error('Method not found')); const ideClient = await IdeClient.getInstance(); @@ -588,12 +259,8 @@ describe('IdeClient', () => { it('should return false if diffing tools are not available', async () => { const config = { port: '8080' }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); + vi.mocked(getConnectionConfigFromFile).mockResolvedValue(config); + vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true }); mockClient.request.mockResolvedValue({ tools: [{ name: 'someOtherTool' }], }); @@ -609,12 +276,8 @@ describe('IdeClient', () => { it('should return false if only openDiff tool is available', async () => { const config = { port: '8080' }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); + vi.mocked(getConnectionConfigFromFile).mockResolvedValue(config); + vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true }); mockClient.request.mockResolvedValue({ tools: [{ name: 'openDiff' }], }); @@ -630,12 +293,8 @@ describe('IdeClient', () => { it('should return true if connected and diffing tools are available', async () => { const config = { port: '8080' }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); + vi.mocked(getConnectionConfigFromFile).mockResolvedValue(config); + vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true }); mockClient.request.mockResolvedValue({ tools: [{ name: 'openDiff' }, { name: 'closeDiff' }], }); @@ -902,12 +561,8 @@ describe('IdeClient', () => { it('should connect with an auth token if provided in the discovery file', async () => { const authToken = 'test-auth-token'; const config = { port: '8080', authToken }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); + vi.mocked(getConnectionConfigFromFile).mockResolvedValue(config); + vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true }); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -928,15 +583,9 @@ describe('IdeClient', () => { }); it('should connect with an auth token from environment variable if config file is missing', async () => { - vi.mocked(fs.promises.readFile).mockRejectedValue( - new Error('File not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); - process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090'; + vi.mocked(getConnectionConfigFromFile).mockResolvedValue(undefined); + vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true }); + vi.mocked(getPortFromEnv).mockReturnValue('9090'); process.env['GEMINI_CLI_IDE_AUTH_TOKEN'] = 'env-auth-token'; const ideClient = await IdeClient.getInstance(); @@ -958,270 +607,3 @@ 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 - }); - - describe('validateWorkspacePath', () => { - describe('with special characters and encoding', () => { - it('should return true for a URI-encoded path with spaces', () => { - const workspaceDir = path.resolve('/test/my workspace'); - const workspacePath = '/test/my%20workspace'; - const cwd = path.join(workspaceDir, 'sub-dir'); - const result = IdeClient.validateWorkspacePath(workspacePath, cwd); - expect(result.isValid).toBe(true); - }); - - it('should return true for a URI-encoded path with Korean characters', () => { - const workspaceDir = path.resolve('/test/테스트'); - const workspacePath = '/test/%ED%85%8C%EC%8A%A4%ED%8A%B8'; // "테스트" - const cwd = path.join(workspaceDir, 'sub-dir'); - const result = IdeClient.validateWorkspacePath(workspacePath, cwd); - expect(result.isValid).toBe(true); - }); - - it('should return true for a plain decoded path with Korean characters', () => { - const workspacePath = path.resolve('/test/테스트'); - const cwd = path.join(workspacePath, 'sub-dir'); - const result = IdeClient.validateWorkspacePath(workspacePath, cwd); - expect(result.isValid).toBe(true); - }); - - it('should return true when one of multi-root paths is a valid URI-encoded path', () => { - const workspaceDir1 = path.resolve('/another/workspace'); - const workspaceDir2 = path.resolve('/test/테스트'); - const workspacePath = [ - workspaceDir1, - '/test/%ED%85%8C%EC%8A%A4%ED%8A%B8', // "테스트" - ].join(path.delimiter); - const cwd = path.join(workspaceDir2, 'sub-dir'); - const result = IdeClient.validateWorkspacePath(workspacePath, cwd); - expect(result.isValid).toBe(true); - }); - - it('should return true for paths containing a literal % sign', () => { - const workspacePath = path.resolve('/test/a%path'); - const cwd = path.join(workspacePath, 'sub-dir'); - const result = IdeClient.validateWorkspacePath(workspacePath, cwd); - expect(result.isValid).toBe(true); - }); - - it.skipIf(process.platform !== 'win32')( - 'should correctly convert a Windows file URI', - () => { - const workspacePath = 'file:///C:\\Users\\test'; - const cwd = 'C:\\Users\\test\\sub-dir'; - - const result = IdeClient.validateWorkspacePath(workspacePath, cwd); - - expect(result.isValid).toBe(true); - }, - ); - }); - }); - - describe('validateWorkspacePath (sanitization)', () => { - it.each([ - { - description: 'should return true for identical paths', - workspacePath: path.resolve('test', 'ws'), - cwd: path.resolve('test', 'ws'), - expectedValid: true, - }, - { - description: 'should return true when workspace has file:// protocol', - workspacePath: pathToFileURL(path.resolve('test', 'ws')).toString(), - cwd: path.resolve('test', 'ws'), - expectedValid: true, - }, - { - description: 'should return true when workspace has encoded spaces', - workspacePath: path.resolve('test', 'my ws').replace(/ /g, '%20'), - cwd: path.resolve('test', 'my ws'), - expectedValid: true, - }, - { - description: - 'should return true when cwd needs normalization matching workspace', - workspacePath: path.resolve('test', 'my ws'), - cwd: path.resolve('test', 'my ws').replace(/ /g, '%20'), - expectedValid: true, - }, - ])('$description', ({ workspacePath, cwd, expectedValid }) => { - expect(IdeClient.validateWorkspacePath(workspacePath, cwd)).toMatchObject( - { isValid: expectedValid }, - ); - }); - }); -}); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 928c411395..58067d70ab 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as fs from 'node:fs'; -import { isSubpath, resolveToRealPath } from '../utils/paths.js'; import { detectIde, type IdeInfo } from '../ide/detect-ide.js'; import { ideContextStore } from './ideContext.js'; import { @@ -19,12 +17,18 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { EnvHttpProxyAgent } from 'undici'; import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { IDE_REQUEST_TIMEOUT_MS } from './constants.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { + getConnectionConfigFromFile, + getIdeServerHost, + getPortFromEnv, + getStdioConfigFromEnv, + validateWorkspacePath, + createProxyAwareFetch, + type StdioConfig, +} from './ide-connection-utils.js'; const logger = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -54,17 +58,6 @@ export enum IDEConnectionStatus { Connecting = 'connecting', } -type StdioConfig = { - command: string; - args: string[]; -}; - -type ConnectionConfig = { - port?: string; - authToken?: string; - stdio?: StdioConfig; -}; - /** * Manages the connection to and interaction with the IDE server. */ @@ -78,10 +71,7 @@ export class IdeClient { }; private currentIde: IdeInfo | undefined; private ideProcessInfo: { pid: number; command: string } | undefined; - private connectionConfig: - | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) - | undefined; - private authToken: string | undefined; + private diffResponses = new Map void>(); private statusListeners = new Set<(state: IDEConnectionState) => void>(); private trustChangeListeners = new Set<(isTrusted: boolean) => void>(); @@ -100,10 +90,12 @@ export class IdeClient { IdeClient.instancePromise = (async () => { const client = new IdeClient(); client.ideProcessInfo = await getIdeProcessInfo(); - client.connectionConfig = await client.getConnectionConfigFromFile(); + const connectionConfig = client.ideProcessInfo + ? await getConnectionConfigFromFile(client.ideProcessInfo.pid) + : undefined; client.currentIde = detectIde( client.ideProcessInfo, - client.connectionConfig?.ideInfo, + connectionConfig?.ideInfo, ); return client; })(); @@ -140,16 +132,17 @@ export class IdeClient { this.setState(IDEConnectionStatus.Connecting); - this.connectionConfig = await this.getConnectionConfigFromFile(); - this.authToken = - this.connectionConfig?.authToken ?? - process.env['GEMINI_CLI_IDE_AUTH_TOKEN']; + const connectionConfig = this.ideProcessInfo + ? await getConnectionConfigFromFile(this.ideProcessInfo.pid) + : undefined; + const authToken = + connectionConfig?.authToken ?? process.env['GEMINI_CLI_IDE_AUTH_TOKEN']; const workspacePath = - this.connectionConfig?.workspacePath ?? + connectionConfig?.workspacePath ?? process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; - const { isValid, error } = IdeClient.validateWorkspacePath( + const { isValid, error } = validateWorkspacePath( workspacePath, process.cwd(), ); @@ -159,18 +152,19 @@ export class IdeClient { return; } - if (this.connectionConfig) { - if (this.connectionConfig.port) { + if (connectionConfig) { + if (connectionConfig.port) { const connected = await this.establishHttpConnection( - this.connectionConfig.port, + connectionConfig.port, + authToken, ); if (connected) { return; } } - if (this.connectionConfig.stdio) { + if (connectionConfig.stdio) { const connected = await this.establishStdioConnection( - this.connectionConfig.stdio, + connectionConfig.stdio, ); if (connected) { return; @@ -178,15 +172,18 @@ export class IdeClient { } } - const portFromEnv = this.getPortFromEnv(); + const portFromEnv = getPortFromEnv(); if (portFromEnv) { - const connected = await this.establishHttpConnection(portFromEnv); + const connected = await this.establishHttpConnection( + portFromEnv, + authToken, + ); if (connected) { return; } } - const stdioConfigFromEnv = this.getStdioConfigFromEnv(); + const stdioConfigFromEnv = getStdioConfigFromEnv(); if (stdioConfigFromEnv) { const connected = await this.establishStdioConnection(stdioConfigFromEnv); if (connected) { @@ -493,204 +490,6 @@ export class IdeClient { } } - static validateWorkspacePath( - ideWorkspacePath: string | undefined, - cwd: string, - ): { isValid: boolean; error?: string } { - if (ideWorkspacePath === undefined) { - return { - isValid: false, - error: `Failed to connect to IDE companion extension. Please ensure the extension is running. To install the extension, run /ide install.`, - }; - } - - if (ideWorkspacePath === '') { - return { - isValid: false, - error: `To use this feature, please open a workspace folder in your IDE and try again.`, - }; - } - - const ideWorkspacePaths = ideWorkspacePath - .split(path.delimiter) - .map((p) => resolveToRealPath(p)) - .filter((e) => !!e); - const realCwd = resolveToRealPath(cwd); - const isWithinWorkspace = ideWorkspacePaths.some((workspacePath) => - isSubpath(workspacePath, realCwd), - ); - - if (!isWithinWorkspace) { - return { - isValid: false, - error: `Directory mismatch. Gemini CLI is running in a different location than the open workspace in the IDE. Please run the CLI from one of the following directories: ${ideWorkspacePaths.join( - ', ', - )}`, - }; - } - return { isValid: true }; - } - - private getPortFromEnv(): string | undefined { - const port = process.env['GEMINI_CLI_IDE_SERVER_PORT']; - if (!port) { - return undefined; - } - return port; - } - - private getStdioConfigFromEnv(): StdioConfig | undefined { - const command = process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND']; - if (!command) { - return undefined; - } - - const argsStr = process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS']; - let args: string[] = []; - if (argsStr) { - try { - const parsedArgs = JSON.parse(argsStr); - if (Array.isArray(parsedArgs)) { - args = parsedArgs; - } else { - logger.error( - 'GEMINI_CLI_IDE_SERVER_STDIO_ARGS must be a JSON array string.', - ); - } - } catch (e) { - logger.error('Failed to parse GEMINI_CLI_IDE_SERVER_STDIO_ARGS:', e); - } - } - - return { command, args }; - } - - private async getConnectionConfigFromFile(): Promise< - | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) - | undefined - > { - if (!this.ideProcessInfo) { - return undefined; - } - - // For backwards compatibility - try { - const portFile = path.join( - os.tmpdir(), - 'gemini', - 'ide', - `gemini-ide-server-${this.ideProcessInfo.pid}.json`, - ); - const portFileContents = await fs.promises.readFile(portFile, 'utf8'); - return JSON.parse(portFileContents); - } catch (_) { - // For newer extension versions, the file name matches the pattern - // /^gemini-ide-server-${pid}-\d+\.json$/. If multiple IDE - // windows are open, multiple files matching the pattern are expected to - // exist. - } - - const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide'); - let portFiles; - try { - portFiles = await fs.promises.readdir(portFileDir); - } catch (e) { - logger.debug('Failed to read IDE connection directory:', e); - return undefined; - } - - if (!portFiles) { - return undefined; - } - - const fileRegex = new RegExp( - `^gemini-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`, - ); - const matchingFiles = portFiles - .filter((file) => fileRegex.test(file)) - .sort(); - if (matchingFiles.length === 0) { - return undefined; - } - - let fileContents: string[]; - try { - fileContents = await Promise.all( - matchingFiles.map((file) => - fs.promises.readFile(path.join(portFileDir, file), 'utf8'), - ), - ); - } catch (e) { - logger.debug('Failed to read IDE connection config file(s):', e); - return undefined; - } - const parsedContents = fileContents.map((content) => { - try { - return JSON.parse(content); - } catch (e) { - logger.debug('Failed to parse JSON from config file: ', e); - return undefined; - } - }); - - const validWorkspaces = parsedContents.filter((content) => { - if (!content) { - return false; - } - const { isValid } = IdeClient.validateWorkspacePath( - content.workspacePath, - process.cwd(), - ); - return isValid; - }); - - if (validWorkspaces.length === 0) { - return undefined; - } - - if (validWorkspaces.length === 1) { - return validWorkspaces[0]; - } - - const portFromEnv = this.getPortFromEnv(); - if (portFromEnv) { - const matchingPort = validWorkspaces.find( - (content) => String(content.port) === portFromEnv, - ); - if (matchingPort) { - return matchingPort; - } - } - - return validWorkspaces[0]; - } - - 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, ideServerHost].filter(Boolean).join(','), - }); - const undiciPromise = import('undici'); - // Suppress unhandled rejection if the promise is not awaited immediately. - // If the import fails, the error will be thrown when awaiting undiciPromise below. - undiciPromise.catch(() => {}); - return async (url: string | URL, init?: RequestInit): Promise => { - const { fetch: fetchFn } = await undiciPromise; - const fetchOptions: RequestInit & { dispatcher?: unknown } = { - ...init, - dispatcher: agent, - }; - const options = fetchOptions as unknown as import('undici').RequestInit; - const response = await fetchFn(url, options); - return new Response(response.body as ReadableStream | null, { - status: response.status, - statusText: response.statusText, - headers: [...response.headers.entries()], - }); - }; - } - private registerClientHandlers() { if (!this.client) { return; @@ -768,7 +567,10 @@ export class IdeClient { ); } - private async establishHttpConnection(port: string): Promise { + private async establishHttpConnection( + port: string, + authToken: string | undefined, + ): Promise { let transport: StreamableHTTPClientTransport | undefined; try { const ideServerHost = getIdeServerHost(); @@ -786,11 +588,9 @@ export class IdeClient { version: '1.0.0', }); transport = new StreamableHTTPClientTransport(new URL(serverUrl), { - fetch: await this.createProxyAwareFetch(ideServerHost), + fetch: await createProxyAwareFetch(ideServerHost), requestInit: { - headers: this.authToken - ? { Authorization: `Bearer ${this.authToken}` } - : {}, + headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, }, }); await this.client.connect(transport); @@ -844,32 +644,3 @@ export class IdeClient { } } } - -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'] - ); -} diff --git a/packages/core/src/ide/ide-connection-utils.test.ts b/packages/core/src/ide/ide-connection-utils.test.ts new file mode 100644 index 0000000000..19d955ca36 --- /dev/null +++ b/packages/core/src/ide/ide-connection-utils.test.ts @@ -0,0 +1,544 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + getConnectionConfigFromFile, + validateWorkspacePath, + getIdeServerHost, +} from './ide-connection-utils.js'; +import { pathToFileURL } from 'node:url'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + promises: { + ...actual.promises, + readFile: vi.fn(), + readdir: vi.fn(), + }, + realpathSync: (p: string) => p, + existsSync: vi.fn(() => false), + }; +}); +vi.mock('node:os'); + +describe('ide-connection-utils', () => { + beforeEach(() => { + // Mock environment variables + vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/test/workspace'); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', ''); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_STDIO_COMMAND', ''); + vi.stubEnv('GEMINI_CLI_IDE_SERVER_STDIO_ARGS', ''); + vi.stubEnv('GEMINI_CLI_IDE_AUTH_TOKEN', ''); + + vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir'); + vi.mocked(os.tmpdir).mockReturnValue('/tmp'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + describe('getConnectionConfigFromFile', () => { + it('should return config from the specific pid file if it exists', async () => { + const config = { port: '1234', workspacePath: '/test/workspace' }; + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + + const result = await getConnectionConfigFromFile(12345); + + expect(result).toEqual(config); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join('/tmp', 'gemini', 'ide', 'gemini-ide-server-12345.json'), + 'utf8', + ); + }); + + it('should return undefined if no config files are found', async () => { + vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found')); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([]); + + const result = await getConnectionConfigFromFile(12345); + + expect(result).toBeUndefined(); + }); + + it('should find and parse a single config file with the new naming scheme', async () => { + const config = { port: '5678', workspacePath: '/test/workspace' }; + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); // For old path + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue(['gemini-ide-server-12345-123.json']); + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + + const result = await getConnectionConfigFromFile(12345); + + expect(result).toEqual(config); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join('/tmp', 'gemini', 'ide', 'gemini-ide-server-12345-123.json'), + 'utf8', + ); + }); + + it('should filter out configs with invalid workspace paths', async () => { + const validConfig = { + port: '5678', + workspacePath: '/test/workspace', + }; + const invalidConfig = { + port: '1111', + workspacePath: '/invalid/workspace', + }; + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([ + 'gemini-ide-server-12345-111.json', + 'gemini-ide-server-12345-222.json', + ]); + vi.mocked(fs.promises.readFile) + .mockResolvedValueOnce(JSON.stringify(invalidConfig)) + .mockResolvedValueOnce(JSON.stringify(validConfig)); + + const result = await getConnectionConfigFromFile(12345); + + expect(result).toEqual(validConfig); + }); + + it('should return the first valid config when multiple workspaces are valid', async () => { + const config1 = { port: '1111', workspacePath: '/test/workspace' }; + const config2 = { port: '2222', workspacePath: '/test/workspace2' }; + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([ + 'gemini-ide-server-12345-111.json', + 'gemini-ide-server-12345-222.json', + ]); + vi.mocked(fs.promises.readFile) + .mockResolvedValueOnce(JSON.stringify(config1)) + .mockResolvedValueOnce(JSON.stringify(config2)); + + const result = await getConnectionConfigFromFile(12345); + + expect(result).toEqual(config1); + }); + + it('should prioritize the config matching the port from the environment variable', async () => { + vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '2222'); + const config1 = { port: '1111', workspacePath: '/test/workspace' }; + const config2 = { port: '2222', workspacePath: '/test/workspace' }; + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([ + 'gemini-ide-server-12345-111.json', + 'gemini-ide-server-12345-222.json', + ]); + vi.mocked(fs.promises.readFile) + .mockResolvedValueOnce(JSON.stringify(config1)) + .mockResolvedValueOnce(JSON.stringify(config2)); + + const result = await getConnectionConfigFromFile(12345); + + expect(result).toEqual(config2); + }); + + it('should handle invalid JSON in one of the config files', async () => { + const validConfig = { port: '2222', workspacePath: '/test/workspace' }; + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([ + 'gemini-ide-server-12345-111.json', + 'gemini-ide-server-12345-222.json', + ]); + vi.mocked(fs.promises.readFile) + .mockResolvedValueOnce('invalid json') + .mockResolvedValueOnce(JSON.stringify(validConfig)); + + const result = await getConnectionConfigFromFile(12345); + + expect(result).toEqual(validConfig); + }); + + it('should return undefined if readdir throws an error', async () => { + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + vi.mocked(fs.promises.readdir).mockRejectedValue( + new Error('readdir failed'), + ); + + const result = await getConnectionConfigFromFile(12345); + + expect(result).toBeUndefined(); + }); + + it('should ignore files with invalid names', async () => { + const validConfig = { port: '3333', workspacePath: '/test/workspace' }; + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([ + 'gemini-ide-server-12345-111.json', // valid + 'not-a-config-file.txt', // invalid + 'gemini-ide-server-asdf.json', // invalid + ]); + vi.mocked(fs.promises.readFile).mockResolvedValueOnce( + JSON.stringify(validConfig), + ); + + const result = await getConnectionConfigFromFile(12345); + + expect(result).toEqual(validConfig); + expect(fs.promises.readFile).toHaveBeenCalledWith( + 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'), + 'utf8', + ); + }); + + it('should match env port string to a number port in the config', async () => { + vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '3333'); + const config1 = { port: 1111, workspacePath: '/test/workspace' }; + const config2 = { port: 3333, workspacePath: '/test/workspace' }; + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([ + 'gemini-ide-server-12345-111.json', + 'gemini-ide-server-12345-222.json', + ]); + vi.mocked(fs.promises.readFile) + .mockResolvedValueOnce(JSON.stringify(config1)) + .mockResolvedValueOnce(JSON.stringify(config2)); + + const result = await getConnectionConfigFromFile(12345); + + expect(result).toEqual(config2); + }); + }); + + describe('validateWorkspacePath', () => { + it('should return valid if path is within cwd', () => { + const result = validateWorkspacePath( + '/test/workspace', + '/test/workspace/sub-dir', + ); + expect(result.isValid).toBe(true); + }); + + it('should return invalid if path is undefined', () => { + const result = validateWorkspacePath( + undefined, + '/test/workspace/sub-dir', + ); + expect(result.isValid).toBe(false); + expect(result.error).toContain('Failed to connect'); + }); + + it('should return invalid if path is empty', () => { + const result = validateWorkspacePath('', '/test/workspace/sub-dir'); + expect(result.isValid).toBe(false); + expect(result.error).toContain('please open a workspace folder'); + }); + + it('should return invalid if cwd is not within workspace path', () => { + const result = validateWorkspacePath( + '/other/workspace', + '/test/workspace/sub-dir', + ); + expect(result.isValid).toBe(false); + expect(result.error).toContain('Directory mismatch'); + }); + }); + describe('with special characters and encoding', () => { + it('should return true for a URI-encoded path with spaces', () => { + const workspaceDir = path.resolve('/test/my workspace'); + const workspacePath = '/test/my%20workspace'; + const cwd = path.join(workspaceDir, 'sub-dir'); + const result = validateWorkspacePath(workspacePath, cwd); + expect(result.isValid).toBe(true); + }); + + it('should return true for a URI-encoded path with Korean characters', () => { + const workspaceDir = path.resolve('/test/테스트'); + const workspacePath = '/test/%ED%85%8C%EC%8A%A4%ED%8A%B8'; // "테스트" + const cwd = path.join(workspaceDir, 'sub-dir'); + const result = validateWorkspacePath(workspacePath, cwd); + expect(result.isValid).toBe(true); + }); + + it('should return true for a plain decoded path with Korean characters', () => { + const workspacePath = path.resolve('/test/테스트'); + const cwd = path.join(workspacePath, 'sub-dir'); + const result = validateWorkspacePath(workspacePath, cwd); + expect(result.isValid).toBe(true); + }); + + it('should return true when one of multi-root paths is a valid URI-encoded path', () => { + const workspaceDir1 = path.resolve('/another/workspace'); + const workspaceDir2 = path.resolve('/test/테스트'); + const workspacePath = [ + workspaceDir1, + '/test/%ED%85%8C%EC%8A%A4%ED%8A%B8', // "테스트" + ].join(path.delimiter); + const cwd = path.join(workspaceDir2, 'sub-dir'); + const result = validateWorkspacePath(workspacePath, cwd); + expect(result.isValid).toBe(true); + }); + + it('should return true for paths containing a literal % sign', () => { + const workspacePath = path.resolve('/test/a%path'); + const cwd = path.join(workspacePath, 'sub-dir'); + const result = validateWorkspacePath(workspacePath, cwd); + expect(result.isValid).toBe(true); + }); + + it.skipIf(process.platform !== 'win32')( + 'should correctly convert a Windows file URI', + () => { + const workspacePath = 'file:///C:\\Users\\test'; + const cwd = 'C:\\Users\\test\\sub-dir'; + + const result = validateWorkspacePath(workspacePath, cwd); + + expect(result.isValid).toBe(true); + }, + ); + }); + + describe('validateWorkspacePath (sanitization)', () => { + it.each([ + { + description: 'should return true for identical paths', + workspacePath: path.resolve('test', 'ws'), + cwd: path.resolve('test', 'ws'), + expectedValid: true, + }, + { + description: 'should return true when workspace has file:// protocol', + workspacePath: pathToFileURL(path.resolve('test', 'ws')).toString(), + cwd: path.resolve('test', 'ws'), + expectedValid: true, + }, + { + description: 'should return true when workspace has encoded spaces', + workspacePath: path.resolve('test', 'my ws').replace(/ /g, '%20'), + cwd: path.resolve('test', 'my ws'), + expectedValid: true, + }, + { + description: + 'should return true when cwd needs normalization matching workspace', + workspacePath: path.resolve('test', 'my ws'), + cwd: path.resolve('test', 'my ws').replace(/ /g, '%20'), + expectedValid: true, + }, + ])('$description', ({ workspacePath, cwd, expectedValid }) => { + expect(validateWorkspacePath(workspacePath, cwd)).toMatchObject({ + isValid: expectedValid, + }); + }); + }); + + describe('getIdeServerHost', () => { + // Helper to set existsSync mock behavior + const existsSyncMock = vi.mocked(fs.existsSync); + const setupFsMocks = ( + dockerenvExists: boolean, + containerenvExists: boolean, + ) => { + existsSyncMock.mockImplementation((path: fs.PathLike) => { + 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); + vi.stubEnv('SSH_CONNECTION', ''); + vi.stubEnv('VSCODE_REMOTE_CONTAINERS_SESSION', ''); + vi.stubEnv('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); + vi.stubEnv('SSH_CONNECTION', 'some_ssh_value'); + vi.stubEnv('VSCODE_REMOTE_CONTAINERS_SESSION', ''); + vi.stubEnv('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); + vi.stubEnv('SSH_CONNECTION', ''); + vi.stubEnv('VSCODE_REMOTE_CONTAINERS_SESSION', ''); + vi.stubEnv('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); + vi.stubEnv('SSH_CONNECTION', 'some_ssh_value'); + vi.stubEnv('VSCODE_REMOTE_CONTAINERS_SESSION', ''); + vi.stubEnv('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); + vi.stubEnv('SSH_CONNECTION', ''); + vi.stubEnv('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); + vi.stubEnv('SSH_CONNECTION', ''); + vi.stubEnv('VSCODE_REMOTE_CONTAINERS_SESSION', ''); + vi.stubEnv('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); + vi.stubEnv('SSH_CONNECTION', 'some_ssh_value'); + vi.stubEnv('VSCODE_REMOTE_CONTAINERS_SESSION', ''); + vi.stubEnv('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); + vi.stubEnv('SSH_CONNECTION', ''); + vi.stubEnv('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); + vi.stubEnv('SSH_CONNECTION', ''); + vi.stubEnv('VSCODE_REMOTE_CONTAINERS_SESSION', ''); + vi.stubEnv('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); + vi.stubEnv('SSH_CONNECTION', 'some_ssh_value'); + vi.stubEnv('VSCODE_REMOTE_CONTAINERS_SESSION', ''); + vi.stubEnv('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); + vi.stubEnv('SSH_CONNECTION', ''); + vi.stubEnv('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-connection-utils.ts b/packages/core/src/ide/ide-connection-utils.ts new file mode 100644 index 0000000000..2b00f593c0 --- /dev/null +++ b/packages/core/src/ide/ide-connection-utils.ts @@ -0,0 +1,253 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { EnvHttpProxyAgent } from 'undici'; +import { debugLogger } from '../utils/debugLogger.js'; +import { isSubpath, resolveToRealPath } from '../utils/paths.js'; +import { type IdeInfo } from './detect-ide.js'; + +const logger = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + debug: (...args: any[]) => + debugLogger.debug('[DEBUG] [IDEConnectionUtils]', ...args), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: (...args: any[]) => + debugLogger.error('[ERROR] [IDEConnectionUtils]', ...args), +}; + +export type StdioConfig = { + command: string; + args: string[]; +}; + +export type ConnectionConfig = { + port?: string; + authToken?: string; + stdio?: StdioConfig; +}; + +export function validateWorkspacePath( + ideWorkspacePath: string | undefined, + cwd: string, +): { isValid: boolean; error?: string } { + if (ideWorkspacePath === undefined) { + return { + isValid: false, + error: `Failed to connect to IDE companion extension. Please ensure the extension is running. To install the extension, run /ide install.`, + }; + } + + if (ideWorkspacePath === '') { + return { + isValid: false, + error: `To use this feature, please open a workspace folder in your IDE and try again.`, + }; + } + + const ideWorkspacePaths = ideWorkspacePath + .split(path.delimiter) + .map((p) => resolveToRealPath(p)) + .filter((e) => !!e); + const realCwd = resolveToRealPath(cwd); + const isWithinWorkspace = ideWorkspacePaths.some((workspacePath) => + isSubpath(workspacePath, realCwd), + ); + + if (!isWithinWorkspace) { + return { + isValid: false, + error: `Directory mismatch. Gemini CLI is running in a different location than the open workspace in the IDE. Please run the CLI from one of the following directories: ${ideWorkspacePaths.join( + ', ', + )}`, + }; + } + return { isValid: true }; +} + +export function getPortFromEnv(): string | undefined { + const port = process.env['GEMINI_CLI_IDE_SERVER_PORT']; + if (!port) { + return undefined; + } + return port; +} + +export function getStdioConfigFromEnv(): StdioConfig | undefined { + const command = process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND']; + if (!command) { + return undefined; + } + + const argsStr = process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS']; + let args: string[] = []; + if (argsStr) { + try { + const parsedArgs = JSON.parse(argsStr); + if (Array.isArray(parsedArgs)) { + args = parsedArgs; + } else { + logger.error( + 'GEMINI_CLI_IDE_SERVER_STDIO_ARGS must be a JSON array string.', + ); + } + } catch (e) { + logger.error('Failed to parse GEMINI_CLI_IDE_SERVER_STDIO_ARGS:', e); + } + } + + return { command, args }; +} + +export async function getConnectionConfigFromFile( + pid: number, +): Promise< + (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) | undefined +> { + // For backwards compatibility + try { + const portFile = path.join( + os.tmpdir(), + 'gemini', + 'ide', + `gemini-ide-server-${pid}.json`, + ); + const portFileContents = await fs.promises.readFile(portFile, 'utf8'); + return JSON.parse(portFileContents); + } catch (_) { + // For newer extension versions, the file name matches the pattern + // /^gemini-ide-server-${pid}-\d+\.json$/. If multiple IDE + // windows are open, multiple files matching the pattern are expected to + // exist. + } + + const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide'); + let portFiles; + try { + portFiles = await fs.promises.readdir(portFileDir); + } catch (e) { + logger.debug('Failed to read IDE connection directory:', e); + return undefined; + } + + if (!portFiles) { + return undefined; + } + + const fileRegex = new RegExp(`^gemini-ide-server-${pid}-\\d+\\.json$`); + const matchingFiles = portFiles.filter((file) => fileRegex.test(file)).sort(); + if (matchingFiles.length === 0) { + return undefined; + } + + let fileContents: string[]; + try { + fileContents = await Promise.all( + matchingFiles.map((file) => + fs.promises.readFile(path.join(portFileDir, file), 'utf8'), + ), + ); + } catch (e) { + logger.debug('Failed to read IDE connection config file(s):', e); + return undefined; + } + const parsedContents = fileContents.map((content) => { + try { + return JSON.parse(content); + } catch (e) { + logger.debug('Failed to parse JSON from config file: ', e); + return undefined; + } + }); + + const validWorkspaces = parsedContents.filter((content) => { + if (!content) { + return false; + } + const { isValid } = validateWorkspacePath( + content.workspacePath, + process.cwd(), + ); + return isValid; + }); + + if (validWorkspaces.length === 0) { + return undefined; + } + + if (validWorkspaces.length === 1) { + return validWorkspaces[0]; + } + + const portFromEnv = getPortFromEnv(); + if (portFromEnv) { + const matchingPort = validWorkspaces.find( + (content) => String(content.port) === portFromEnv, + ); + if (matchingPort) { + return matchingPort; + } + } + + return validWorkspaces[0]; +} + +export async function 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, ideServerHost].filter(Boolean).join(','), + }); + const undiciPromise = import('undici'); + // Suppress unhandled rejection if the promise is not awaited immediately. + // If the import fails, the error will be thrown when awaiting undiciPromise below. + undiciPromise.catch(() => {}); + return async (url: string | URL, init?: RequestInit): Promise => { + const { fetch: fetchFn } = await undiciPromise; + const fetchOptions: RequestInit & { dispatcher?: unknown } = { + ...init, + dispatcher: agent, + }; + const options = fetchOptions as unknown as import('undici').RequestInit; + const response = await fetchFn(url, options); + return new Response(response.body as ReadableStream | null, { + status: response.status, + statusText: response.statusText, + headers: [...response.headers.entries()], + }); + }; +} + +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'] + ); +}