mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
fix(ide): no-op refactoring that moves the connection logic to helper functions (#18118)
This commit is contained in:
@@ -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<typeof fs>();
|
||||
@@ -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<Client>;
|
||||
@@ -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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<unknown>;
|
||||
}
|
||||
).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<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).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<string[]>
|
||||
>
|
||||
).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<unknown>;
|
||||
}
|
||||
).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<string[]>
|
||||
>
|
||||
).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<unknown>;
|
||||
}
|
||||
).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<string[]>
|
||||
>
|
||||
).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<unknown>;
|
||||
}
|
||||
).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<string[]>
|
||||
>
|
||||
).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<unknown>;
|
||||
}
|
||||
).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<string[]>
|
||||
>
|
||||
).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<unknown>;
|
||||
}
|
||||
).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<unknown>;
|
||||
}
|
||||
).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<string[]>
|
||||
>
|
||||
).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<unknown>;
|
||||
}
|
||||
).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<string[]>
|
||||
>
|
||||
).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<unknown>;
|
||||
}
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, (result: DiffUpdateResult) => 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<Response> => {
|
||||
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<unknown> | 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<boolean> {
|
||||
private async establishHttpConnection(
|
||||
port: string,
|
||||
authToken: string | undefined,
|
||||
): Promise<boolean> {
|
||||
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']
|
||||
);
|
||||
}
|
||||
|
||||
544
packages/core/src/ide/ide-connection-utils.test.ts
Normal file
544
packages/core/src/ide/ide-connection-utils.test.ts
Normal file
@@ -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<typeof fs>();
|
||||
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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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<string[]>
|
||||
>
|
||||
).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
|
||||
});
|
||||
});
|
||||
});
|
||||
253
packages/core/src/ide/ide-connection-utils.ts
Normal file
253
packages/core/src/ide/ide-connection-utils.ts
Normal file
@@ -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<Response> => {
|
||||
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<unknown> | 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']
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user