diff --git a/docs/ide-integration/ide-companion-spec.md b/docs/ide-integration/ide-companion-spec.md index 3cf35d75..af265814 100644 --- a/docs/ide-integration/ide-companion-spec.md +++ b/docs/ide-integration/ide-companion-spec.md @@ -16,15 +16,15 @@ The plugin **MUST** run a local HTTP server that implements the **Model Context - **Endpoint:** The server should expose a single endpoint (e.g., `/mcp`) for all MCP communication. - **Port:** The server **MUST** listen on a dynamically assigned port (i.e., listen on port `0`). -### 2. Discovery Mechanism: The Port File +### 2. Discovery Mechanism: The Lock File -For Qwen Code to connect, it needs to discover which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "discovery file." +For Qwen Code to connect, it needs to discover which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "lock file." -- **How the CLI Finds the File:** The CLI determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then looks for a discovery file that contains this PID in its name. -- **File Location:** The file must be created in a specific directory: `os.tmpdir()/qwen/ide/`. Your plugin must create this directory if it doesn't exist. +- **How the CLI Finds the File:** The CLI determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then scans `~/.qwen/ide/` for `${PID}-*.lock` files, parses their JSON content, and selects one whose `workspacePath` matches the CLI's current working directory. If `QWEN_CODE_IDE_SERVER_PORT` is set, the CLI will prefer `${PID}-${PORT}.lock`. +- **File Location:** The file must be created in a specific directory: `~/.qwen/ide/`. Your plugin must create this directory if it doesn't exist. - **File Naming Convention:** The filename is critical and **MUST** follow the pattern: - `qwen-code-ide-server-${PID}-${PORT}.json` - - `${PID}`: The process ID of the parent IDE process. Your plugin must determine this PID and include it in the filename. + `${PID}-${PORT}.lock` + - `${PID}`: The process ID of the parent IDE process. - `${PORT}`: The port your MCP server is listening on. - **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure: @@ -47,7 +47,7 @@ For Qwen Code to connect, it needs to discover which IDE instance it's running i - `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`). - **Authentication:** To secure the connection, the plugin **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized. -- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your plugin **SHOULD** both create the discovery file and set the `QWEN_CODE_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `QWEN_CODE_IDE_SERVER_PORT` variable to identify and connect to the correct window's server. +- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your plugin **SHOULD** both create the lock file and set the `QWEN_CODE_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `QWEN_CODE_IDE_SERVER_PORT` variable to identify and connect to the correct window's server. ## II. The Context Interface diff --git a/packages/cli/src/ui/commands/languageCommand.test.ts b/packages/cli/src/ui/commands/languageCommand.test.ts index 001ccd8e..5a4f395b 100644 --- a/packages/cli/src/ui/commands/languageCommand.test.ts +++ b/packages/cli/src/ui/commands/languageCommand.test.ts @@ -46,12 +46,15 @@ vi.mock('node:fs', async (importOriginal) => { // Mock Storage from core vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const actual = await importOriginal(); + const actual = + await importOriginal(); return { ...actual, Storage: { getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'), - getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/.qwen/settings.json'), + getGlobalSettingsPath: vi + .fn() + .mockReturnValue('/mock/.qwen/settings.json'), }, }; }); @@ -360,7 +363,10 @@ describe('languageCommand', () => { throw new Error('The language command must have an action.'); } - const result = await languageCommand.action(mockContext, 'output Chinese'); + const result = await languageCommand.action( + mockContext, + 'output Chinese', + ); expect(fs.mkdirSync).toHaveBeenCalled(); expect(fs.writeFileSync).toHaveBeenCalledWith( @@ -371,7 +377,9 @@ describe('languageCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'info', - content: expect.stringContaining('LLM output language rule file generated'), + content: expect.stringContaining( + 'LLM output language rule file generated', + ), }); }); @@ -380,7 +388,10 @@ describe('languageCommand', () => { throw new Error('The language command must have an action.'); } - const result = await languageCommand.action(mockContext, 'output Japanese'); + const result = await languageCommand.action( + mockContext, + 'output Japanese', + ); expect(result).toEqual({ type: 'message', @@ -514,7 +525,9 @@ describe('languageCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'info', - content: expect.stringContaining('LLM output language rule file generated'), + content: expect.stringContaining( + 'LLM output language rule file generated', + ), }); }); }); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 8e598787..29484db3 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -15,6 +15,7 @@ export const OAUTH_FILE = 'oauth_creds.json'; const TMP_DIR_NAME = 'tmp'; const BIN_DIR_NAME = 'bin'; const PROJECT_DIR_NAME = 'projects'; +const IDE_DIR_NAME = 'ide'; export class Storage { private readonly targetDir: string; @@ -59,6 +60,10 @@ export class Storage { return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME); } + static getGlobalIdeDir(): string { + return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME); + } + static getGlobalBinDir(): string { return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME); } diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index 54420fbb..5cd6af92 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -338,10 +338,7 @@ describe('OpenAIContentConverter', () => { }); it('should handle tools without functionDeclarations', async () => { - const emptyTools: Tool[] = [ - {} as Tool, - { functionDeclarations: [] }, - ]; + const emptyTools: Tool[] = [{} as Tool, { functionDeclarations: [] }]; const result = await converter.convertGeminiToolsToOpenAI(emptyTools); @@ -489,7 +486,10 @@ describe('OpenAIContentConverter', () => { const result = converter.convertGeminiToolParametersToOpenAI(params); const properties = result?.['properties'] as Record; const nested = properties?.['nested'] as Record; - const nestedProperties = nested?.['properties'] as Record; + const nestedProperties = nested?.['properties'] as Record< + string, + unknown + >; expect(nestedProperties?.['deep']).toEqual({ type: 'integer', diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index ca26f78f..99314c9b 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -32,6 +32,7 @@ vi.mock('node:fs', async (importOriginal) => { ...actual.promises, readFile: vi.fn(), readdir: vi.fn(), + stat: vi.fn(), }, realpathSync: (p: string) => p, existsSync: () => false, @@ -68,6 +69,7 @@ describe('IdeClient', () => { command: 'test-ide', }); vi.mocked(os.tmpdir).mockReturnValue('/tmp'); + vi.mocked(os.homedir).mockReturnValue('/home/test'); // Mock MCP client and transports mockClient = { @@ -97,6 +99,7 @@ describe('IdeClient', () => { describe('connect', () => { it('should connect using HTTP when port is provided in config file', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080'; const config = { port: '8080' }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); ( @@ -109,7 +112,7 @@ describe('IdeClient', () => { await ideClient.connect(); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'qwen-code-ide-server-12345.json'), + path.join('/home/test', '.qwen', 'ide', '12345-8080.lock'), 'utf8', ); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( @@ -120,6 +123,7 @@ describe('IdeClient', () => { expect(ideClient.getConnectionStatus().status).toBe( IDEConnectionStatus.Connected, ); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); it('should connect using stdio when stdio config is provided in file', async () => { @@ -263,7 +267,8 @@ describe('IdeClient', () => { }); describe('getConnectionConfigFromFile', () => { - it('should return config from the specific pid file if it exists', async () => { + it('should return config from the env port lock file if it exists', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234'; const config = { port: '1234', workspacePath: '/test/workspace' }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); @@ -277,9 +282,10 @@ describe('IdeClient', () => { expect(result).toEqual(config); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'qwen-code-ide-server-12345.json'), + path.join('/home/test', '.qwen', 'ide', '12345-1234.lock'), 'utf8', ); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); it('should return undefined if no config files are found', async () => { @@ -300,17 +306,23 @@ describe('IdeClient', () => { 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 + it('should find and parse a single lock file matching the IDE pid', async () => { + const config = { + port: '5678', + workspacePath: '/test/workspace', + ppid: 12345, + }; ( vi.mocked(fs.promises.readdir) as Mock< (path: fs.PathLike) => Promise > - ).mockResolvedValue(['qwen-code-ide-server-12345-123.json']); - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + ).mockResolvedValueOnce(['12345-5678.lock']); + vi.mocked(fs.promises.stat).mockResolvedValueOnce({ + mtimeMs: 123, + } as unknown as fs.Stats); + vi.mocked(fs.promises.readFile).mockResolvedValueOnce( + JSON.stringify(config), + ); vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ isValid: true, }); @@ -324,7 +336,7 @@ describe('IdeClient', () => { expect(result).toEqual(config); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'), + path.join('/home/test', '.qwen', 'ide', '12345-5678.lock'), 'utf8', ); }); @@ -338,25 +350,23 @@ describe('IdeClient', () => { 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([ - 'qwen-code-ide-server-12345-111.json', - 'qwen-code-ide-server-12345-222.json', - ]); + ).mockResolvedValueOnce(['12345-1111.lock', '12345-5678.lock']); // ~/.qwen/ide scan + vi.mocked(fs.promises.stat) + .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats) + .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats); 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 }); + .mockImplementation((ideWorkspacePath) => ({ + isValid: ideWorkspacePath === '/test/workspace', + })); const ideClient = await IdeClient.getInstance(); const result = await ( @@ -379,17 +389,15 @@ describe('IdeClient', () => { 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([ - 'qwen-code-ide-server-12345-111.json', - 'qwen-code-ide-server-12345-222.json', - ]); + ).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan + // Make config1 "newer" so it wins when both are valid. + vi.mocked(fs.promises.stat) + .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats) + .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce(JSON.stringify(config1)) .mockResolvedValueOnce(JSON.stringify(config2)); @@ -413,15 +421,15 @@ describe('IdeClient', () => { const config2 = { port: '2222', workspacePath: '/test/workspace2' }; vi.mocked(fs.promises.readFile).mockRejectedValueOnce( new Error('not found'), - ); + ); // For ~/.qwen/ide/-.lock ( vi.mocked(fs.promises.readdir) as Mock< (path: fs.PathLike) => Promise > - ).mockResolvedValue([ - 'qwen-code-ide-server-12345-111.json', - 'qwen-code-ide-server-12345-222.json', - ]); + ).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan + vi.mocked(fs.promises.stat) + .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats) + .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce(JSON.stringify(config1)) .mockResolvedValueOnce(JSON.stringify(config2)); @@ -442,17 +450,14 @@ describe('IdeClient', () => { 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([ - 'qwen-code-ide-server-12345-111.json', - 'qwen-code-ide-server-12345-222.json', - ]); + ).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan + vi.mocked(fs.promises.stat) + .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats) + .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce('invalid json') .mockResolvedValueOnce(JSON.stringify(validConfig)); @@ -474,9 +479,11 @@ describe('IdeClient', () => { vi.mocked(fs.promises.readFile).mockRejectedValueOnce( new Error('not found'), ); - vi.mocked(fs.promises.readdir).mockRejectedValue( - new Error('readdir failed'), - ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockRejectedValueOnce(new Error('readdir failed')); // ~/.qwen/ide scan const ideClient = await IdeClient.getInstance(); const result = await ( @@ -490,18 +497,19 @@ describe('IdeClient', () => { 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([ - 'qwen-code-ide-server-12345-111.json', // valid + ).mockResolvedValueOnce([ + '12345-3333.lock', // valid 'not-a-config-file.txt', // invalid - 'qwen-code-ide-server-asdf.json', // invalid - ]); + 'asdf.lock', // invalid + '12345-asdf.lock', // invalid + ]); // ~/.qwen/ide scan + vi.mocked(fs.promises.stat).mockResolvedValueOnce({ + mtimeMs: 123, + } as unknown as fs.Stats); vi.mocked(fs.promises.readFile).mockResolvedValueOnce( JSON.stringify(validConfig), ); @@ -518,11 +526,11 @@ describe('IdeClient', () => { expect(result).toEqual(validConfig); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'), + path.join('/home/test', '.qwen', 'ide', '12345-3333.lock'), 'utf8', ); expect(fs.promises.readFile).not.toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'not-a-config-file.txt'), + path.join('/home/test', '.qwen', 'ide', 'not-a-config-file.txt'), 'utf8', ); }); @@ -538,10 +546,10 @@ describe('IdeClient', () => { vi.mocked(fs.promises.readdir) as Mock< (path: fs.PathLike) => Promise > - ).mockResolvedValue([ - 'qwen-code-ide-server-12345-111.json', - 'qwen-code-ide-server-12345-222.json', - ]); + ).mockResolvedValueOnce(['12345-1111.lock', '12345-3333.lock']); // ~/.qwen/ide scan + vi.mocked(fs.promises.stat) + .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats) + .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce(JSON.stringify(config1)) .mockResolvedValueOnce(JSON.stringify(config2)); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index b447f46c..2cb419e0 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs'; import { isSubpath } from '../utils/paths.js'; import { detectIde, type IdeInfo } from '../ide/detect-ide.js'; import { ideContextStore } from './ideContext.js'; +import { Storage } from '../config/storage.js'; import { IdeContextNotificationSchema, IdeDiffAcceptedNotificationSchema, @@ -576,7 +577,85 @@ export class IdeClient { return undefined; } - // For backwards compatability + // Preferred: lock file(s) in global qwen dir (~/.qwen/ide/-.lock) + // 1) If QWEN_CODE_IDE_SERVER_PORT is set, prefer ~/.qwen/ide/-.lock + // 2) Otherwise (or on failure), scan ~/.qwen/ide for -*.lock and select: + // - valid workspace path (validateWorkspacePath) + const ideDir = Storage.getGlobalIdeDir(); + const idePid = this.ideProcessInfo.pid; + const portFromEnv = this.getPortFromEnv(); + if (portFromEnv) { + try { + const lockFile = path.join(ideDir, `${idePid}-${portFromEnv}.lock`); + const lockFileContents = await fs.promises.readFile(lockFile, 'utf8'); + return JSON.parse(lockFileContents); + } catch (_) { + // Fall through to scanning / legacy discovery. + } + } + + try { + const fileRegex = new RegExp(`^${idePid}-\\d+\\.lock$`); + const lockFiles = (await fs.promises.readdir(ideDir)).filter((file) => + fileRegex.test(file), + ); + + const fileContents = await Promise.all( + lockFiles.map(async (file) => { + const fullPath = path.join(ideDir, file); + try { + const stat = await fs.promises.stat(fullPath); + const content = await fs.promises.readFile(fullPath, 'utf8'); + try { + const parsed = JSON.parse(content); + return { file, mtimeMs: stat.mtimeMs, parsed }; + } catch (e) { + logger.debug('Failed to parse JSON from lock file: ', e); + return { file, mtimeMs: stat.mtimeMs, parsed: undefined }; + } + } catch (e) { + // If we can't stat/read the file, treat it as very old so it doesn't + // win ties, and skip parsing by returning undefined content. + logger.debug('Failed to read/stat IDE lock file:', e); + return { file, mtimeMs: -Infinity, parsed: undefined }; + } + }), + ); + const validWorkspaces = fileContents + .filter(({ parsed }) => parsed !== undefined) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .map(({ parsed }) => parsed) + .filter((content) => { + const { isValid } = IdeClient.validateWorkspacePath( + content.workspacePath, + process.cwd(), + ); + return isValid; + }); + + if (validWorkspaces.length > 0) { + if (validWorkspaces.length === 1) { + return validWorkspaces[0]; + } + + if (validWorkspaces.length > 1 && portFromEnv) { + const matchingPort = validWorkspaces.find( + (content) => String(content.port) === portFromEnv, + ); + if (matchingPort) { + return matchingPort; + } + } + + if (validWorkspaces.length > 1) { + return validWorkspaces[0]; + } + } + } catch (_) { + // Fall through to legacy discovery mechanisms. + } + + // For backwards compatability: single file in system temp dir named by PID. try { const portFile = path.join( os.tmpdir(), @@ -585,85 +664,13 @@ export class IdeClient { const portFileContents = await fs.promises.readFile(portFile, 'utf8'); return JSON.parse(portFileContents); } catch (_) { - // For newer extension versions, the file name matches the pattern + // For older/newer extension versions, the file name matches the pattern // /^qwen-code-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( - `^qwen-code-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]; + return undefined; } private createProxyAwareFetch() { diff --git a/packages/vscode-ide-companion/src/ide-server.test.ts b/packages/vscode-ide-companion/src/ide-server.test.ts index 1293f487..c2ef4c40 100644 --- a/packages/vscode-ide-companion/src/ide-server.test.ts +++ b/packages/vscode-ide-companion/src/ide-server.test.ts @@ -27,13 +27,14 @@ vi.mock('node:fs/promises', () => ({ writeFile: vi.fn(() => Promise.resolve(undefined)), unlink: vi.fn(() => Promise.resolve(undefined)), chmod: vi.fn(() => Promise.resolve(undefined)), + mkdir: vi.fn(() => Promise.resolve(undefined)), })); vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - tmpdir: vi.fn(() => '/tmp'), + homedir: vi.fn(() => '/home/test'), }; }); @@ -128,13 +129,11 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${process.ppid}-${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), @@ -143,15 +142,10 @@ describe('IDEServer', () => { authToken: 'test-auth-token', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }); it('should set a single folder path', async () => { @@ -166,13 +160,11 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${process.ppid}-${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), @@ -181,15 +173,10 @@ describe('IDEServer', () => { authToken: 'test-auth-token', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }); it('should set an empty string if no folders are open', async () => { @@ -204,13 +191,11 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${process.ppid}-${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), @@ -219,15 +204,10 @@ describe('IDEServer', () => { authToken: 'test-auth-token', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }); it('should update the path when workspace folders change', async () => { @@ -256,13 +236,11 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${process.ppid}-${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), @@ -271,15 +249,10 @@ describe('IDEServer', () => { authToken: 'test-auth-token', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); // Simulate removing a folder vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }]; @@ -296,34 +269,28 @@ describe('IDEServer', () => { authToken: 'test-auth-token', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent2, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent2, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }); - it('should clear env vars and delete port file on stop', async () => { + it('should clear env vars and delete lock file on stop', async () => { await ideServer.start(mockContext); const replaceMock = mockContext.environmentVariableCollection.replace; const port = getPortFromMock(replaceMock); - const portFile = path.join('/tmp', `qwen-code-ide-server-${port}.json`); - const ppidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const lockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${process.ppid}-${port}.lock`, ); - expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String)); - expect(fs.writeFile).toHaveBeenCalledWith(ppidPortFile, expect.any(String)); + expect(fs.writeFile).toHaveBeenCalledWith(lockFile, expect.any(String)); await ideServer.stop(); expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled(); - expect(fs.unlink).toHaveBeenCalledWith(portFile); - expect(fs.unlink).toHaveBeenCalledWith(ppidPortFile); + expect(fs.unlink).toHaveBeenCalledWith(lockFile); }); it.skipIf(process.platform !== 'win32')( @@ -344,13 +311,11 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${process.ppid}-${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), @@ -359,15 +324,10 @@ describe('IDEServer', () => { authToken: 'test-auth-token', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }, ); @@ -379,7 +339,7 @@ describe('IDEServer', () => { port = (ideServer as unknown as { port: number }).port; }); - it('should allow request without auth token for backwards compatibility', async () => { + it('should reject request without auth token', async () => { const response = await fetch(`http://localhost:${port}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -390,7 +350,9 @@ describe('IDEServer', () => { id: 1, }), }); - expect(response.status).not.toBe(401); + expect(response.status).toBe(401); + const body = await response.text(); + expect(body).toBe('Unauthorized'); }); it('should allow request with valid auth token', async () => { @@ -550,6 +512,7 @@ describe('IDEServer HTTP endpoints', () => { headers: { Host: `localhost:${port}`, 'Content-Type': 'application/json', + Authorization: 'Bearer test-auth-token', }, }, JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }), diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index 69fabbc4..f020eba0 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -38,12 +38,24 @@ class CORSError extends Error { const MCP_SESSION_ID_HEADER = 'mcp-session-id'; const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT'; const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH'; +const QWEN_DIR = '.qwen'; +const IDE_DIR = 'ide'; + +async function getGlobalIdeDir(): Promise { + const homeDir = os.homedir(); + // Prefer home dir, but fall back to tmpdir if unavailable (matches core Storage behavior). + const baseDir = homeDir + ? path.join(homeDir, QWEN_DIR) + : path.join(os.tmpdir(), QWEN_DIR); + const ideDir = path.join(baseDir, IDE_DIR); + await fs.mkdir(ideDir, { recursive: true }); + return ideDir; +} interface WritePortAndWorkspaceArgs { context: vscode.ExtensionContext; port: number; - portFile: string; - ppidPortFile: string; + lockFile: string; authToken: string; log: (message: string) => void; } @@ -51,8 +63,7 @@ interface WritePortAndWorkspaceArgs { async function writePortAndWorkspace({ context, port, - portFile, - ppidPortFile, + lockFile, authToken, log, }: WritePortAndWorkspaceArgs): Promise { @@ -78,19 +89,15 @@ async function writePortAndWorkspace({ authToken, }); - log(`Writing port file to: ${portFile}`); - log(`Writing ppid port file to: ${ppidPortFile}`); + log(`Writing IDE lock file to: ${lockFile}`); try { - await Promise.all([ - fs.writeFile(portFile, content).then(() => fs.chmod(portFile, 0o600)), - fs - .writeFile(ppidPortFile, content) - .then(() => fs.chmod(ppidPortFile, 0o600)), - ]); + await fs.mkdir(path.dirname(lockFile), { recursive: true }); + await fs.writeFile(lockFile, content); + await fs.chmod(lockFile, 0o600); } catch (err) { const message = err instanceof Error ? err.message : String(err); - log(`Failed to write port to file: ${message}`); + log(`Failed to write IDE lock file: ${message}`); } } @@ -121,8 +128,7 @@ export class IDEServer { private server: HTTPServer | undefined; private context: vscode.ExtensionContext | undefined; private log: (message: string) => void; - private portFile: string | undefined; - private ppidPortFile: string | undefined; + private lockFile: string | undefined; private port: number | undefined; private authToken: string | undefined; private transports: { [sessionId: string]: StreamableHTTPServerTransport } = @@ -174,19 +180,24 @@ export class IDEServer { app.use((req, res, next) => { const authHeader = req.headers.authorization; - if (authHeader) { - const parts = authHeader.split(' '); - if (parts.length !== 2 || parts[0] !== 'Bearer') { - this.log('Malformed Authorization header. Rejecting request.'); - res.status(401).send('Unauthorized'); - return; - } - const token = parts[1]; - if (token !== this.authToken) { - this.log('Invalid auth token provided. Rejecting request.'); - res.status(401).send('Unauthorized'); - return; - } + if (!authHeader) { + this.log('Missing Authorization header. Rejecting request.'); + res.status(401).send('Unauthorized'); + return; + } + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + this.log('Malformed Authorization header. Rejecting request.'); + res.status(401).send('Unauthorized'); + return; + } + + const token = parts[1]; + if (token !== this.authToken) { + this.log('Invalid auth token provided. Rejecting request.'); + res.status(401).send('Unauthorized'); + return; } next(); }); @@ -327,22 +338,25 @@ export class IDEServer { const address = (this.server as HTTPServer).address(); if (address && typeof address !== 'string') { this.port = address.port; - this.portFile = path.join( - os.tmpdir(), - `qwen-code-ide-server-${this.port}.json`, - ); - this.ppidPortFile = path.join( - os.tmpdir(), - `qwen-code-ide-server-${process.ppid}.json`, - ); + try { + const ideDir = await getGlobalIdeDir(); + // Name the lock file by port to support multiple server instances + // under the same parent process. + this.lockFile = path.join( + ideDir, + `${process.ppid}-${this.port}.lock`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.log(`Failed to determine IDE lock directory: ${message}`); + } this.log(`IDE server listening on http://127.0.0.1:${this.port}`); - if (this.authToken) { + if (this.authToken && this.lockFile) { await writePortAndWorkspace({ context, port: this.port, - portFile: this.portFile, - ppidPortFile: this.ppidPortFile, + lockFile: this.lockFile, authToken: this.authToken, log: this.log, }); @@ -371,15 +385,13 @@ export class IDEServer { this.context && this.server && this.port && - this.portFile && - this.ppidPortFile && + this.lockFile && this.authToken ) { await writePortAndWorkspace({ context: this.context, port: this.port, - portFile: this.portFile, - ppidPortFile: this.ppidPortFile, + lockFile: this.lockFile, authToken: this.authToken, log: this.log, }); @@ -405,16 +417,9 @@ export class IDEServer { if (this.context) { this.context.environmentVariableCollection.clear(); } - if (this.portFile) { + if (this.lockFile) { try { - await fs.unlink(this.portFile); - } catch (_err) { - // Ignore errors if the file doesn't exist. - } - } - if (this.ppidPortFile) { - try { - await fs.unlink(this.ppidPortFile); + await fs.unlink(this.lockFile); } catch (_err) { // Ignore errors if the file doesn't exist. } diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css index 56946662..67675816 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css +++ b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css @@ -48,5 +48,5 @@ } .assistant-message-container.assistant-message-loading::after { - display: none + display: none; } diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css index 39846d77..e5b2cce9 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css @@ -172,7 +172,8 @@ /* Loading animation for toolcall header */ @keyframes toolcallHeaderPulse { - 0%, 100% { + 0%, + 100% { opacity: 1; } 50% { diff --git a/packages/vscode-ide-companion/src/webview/styles/tailwind.css b/packages/vscode-ide-companion/src/webview/styles/tailwind.css index 46d803d5..eec8877b 100644 --- a/packages/vscode-ide-companion/src/webview/styles/tailwind.css +++ b/packages/vscode-ide-companion/src/webview/styles/tailwind.css @@ -51,7 +51,8 @@ .composer-form:focus-within { /* match existing highlight behavior */ border-color: var(--app-input-highlight); - box-shadow: 0 1px 2px color-mix(in srgb, var(--app-input-highlight), transparent 80%); + box-shadow: 0 1px 2px + color-mix(in srgb, var(--app-input-highlight), transparent 80%); } /* Composer: input editable area */ @@ -66,7 +67,7 @@ The data attribute is needed because some browsers insert a
in contentEditable, which breaks :empty matching. */ .composer-input:empty:before, - .composer-input[data-empty="true"]::before { + .composer-input[data-empty='true']::before { content: attr(data-placeholder); color: var(--app-input-placeholder-foreground); pointer-events: none; @@ -80,7 +81,7 @@ outline: none; } .composer-input:disabled, - .composer-input[contenteditable="false"] { + .composer-input[contenteditable='false'] { color: #999; cursor: not-allowed; }