diff --git a/docs/users/ide-integration/ide-companion-spec.md b/docs/users/ide-integration/ide-companion-spec.md index 3cf35d75..37b0b833 100644 --- a/docs/users/ide-integration/ide-companion-spec.md +++ b/docs/users/ide-integration/ide-companion-spec.md @@ -16,16 +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 what port your server is using. The plugin **MUST** facilitate this by creating a "lock file" and setting the port environment variable. -- **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 reads the port from `QWEN_CODE_IDE_SERVER_PORT`, then reads `~/.qwen/ide/.lock`. (Legacy fallbacks exist for older extensions; see note below.) +- **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. - - `${PORT}`: The port your MCP server is listening on. + `.lock` + - ``: The port your MCP server is listening on. - **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure: ```json @@ -33,21 +32,20 @@ For Qwen Code to connect, it needs to discover which IDE instance it's running i "port": 12345, "workspacePath": "/path/to/project1:/path/to/project2", "authToken": "a-very-secret-token", - "ideInfo": { - "name": "vscode", - "displayName": "VS Code" - } + "ppid": 1234, + "ideName": "VS Code" } ``` - `port` (number, required): The port of the MCP server. - `workspacePath` (string, required): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your plugin **MUST** provide the correct, absolute path(s) to the root of the open workspace(s). - `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer ` header on all requests. - - `ideInfo` (object, required): Information about the IDE. - - `name` (string, required): A short, lowercase identifier for the IDE (e.g., `vscode`, `jetbrains`). - - `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`). + - `ppid` (number, required): The parent process ID of the IDE process. + - `ideName` (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. +- **Environment Variables (Required):** Your plugin **MUST** set `QWEN_CODE_IDE_SERVER_PORT` in the integrated terminal so the CLI can locate the correct `.lock` file. + +**Legacy note:** For extensions older than v0.5.1, Qwen Code may fall back to reading JSON files in the system temp directory named `qwen-code-ide-server-.json` or `qwen-code-ide-server-.json`. New integrations should not rely on these legacy files. ## II. The Context Interface diff --git a/package-lock.json b/package-lock.json index 85a56a7d..7911e73d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -568,7 +568,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -592,7 +591,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2157,7 +2155,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -3671,7 +3668,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4142,7 +4138,6 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4153,7 +4148,6 @@ "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -4359,7 +4353,6 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5135,7 +5128,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5530,7 +5522,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/array-includes": { "version": "3.1.9", @@ -6865,6 +6858,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -7982,7 +7976,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8518,6 +8511,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8579,6 +8573,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -8588,6 +8583,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8597,6 +8593,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -8763,6 +8760,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -8781,6 +8779,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8789,13 +8788,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -9909,7 +9910,6 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz", "integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -11864,6 +11864,7 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -13162,7 +13163,8 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/path-type": { "version": "3.0.0", @@ -13821,7 +13823,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13832,7 +13833,6 @@ "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -13866,7 +13866,6 @@ "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -15932,7 +15931,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16112,8 +16110,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -16121,7 +16118,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16316,7 +16312,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16624,6 +16619,7 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -16679,7 +16675,6 @@ "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -16793,7 +16788,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16807,7 +16801,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17486,7 +17479,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17757,7 +17749,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17776,10 +17767,11 @@ }, "packages/sdk-typescript": { "name": "@qwen-code/sdk", - "version": "0.5.1", + "version": "0.1.1", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.4" + "@modelcontextprotocol/sdk": "^1.0.4", + "tiktoken": "^1.0.21" }, "devDependencies": { "@types/node": "^20.14.0", 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/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index ca26f78f..72f78089 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,19 +99,15 @@ 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)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'qwen-code-ide-server-12345.json'), + path.join('/home/test', '.qwen', 'ide', '8080.lock'), 'utf8', ); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( @@ -120,16 +118,13 @@ 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 () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080'; const config = { stdio: { command: 'test-cmd', args: ['--foo'] } }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -142,19 +137,16 @@ describe('IdeClient', () => { expect(ideClient.getConnectionStatus().status).toBe( IDEConnectionStatus.Connected, ); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); it('should prioritize port over stdio when both are in config file', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080'; const config = { port: '8080', stdio: { command: 'test-cmd', args: ['--foo'] }, }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -164,6 +156,7 @@ describe('IdeClient', () => { expect(ideClient.getConnectionStatus().status).toBe( IDEConnectionStatus.Connected, ); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); it('should connect using HTTP when port is provided in environment variables', async () => { @@ -263,7 +256,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,18 +271,14 @@ 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', '1234.lock'), 'utf8', ); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); it('should return undefined if no config files are found', async () => { vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found')); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); const result = await ( @@ -300,20 +290,15 @@ 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 - ( - 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)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); + it('should read legacy pid config when available', async () => { + const config = { + port: '5678', + workspacePath: '/test/workspace', + ppid: 12345, + }; + vi.mocked(fs.promises.readFile).mockResolvedValueOnce( + JSON.stringify(config), + ); const ideClient = await IdeClient.getInstance(); const result = await ( @@ -324,110 +309,18 @@ 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('/tmp', 'qwen-code-ide-server-12345.json'), 'utf8', ); }); - it('should filter out configs with invalid workspace paths', async () => { - const validConfig = { - port: '5678', - workspacePath: '/test/workspace', - }; - const invalidConfig = { - port: '1111', - workspacePath: '/invalid/workspace', - }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'qwen-code-ide-server-12345-111.json', - 'qwen-code-ide-server-12345-222.json', - ]); - vi.mocked(fs.promises.readFile) - .mockResolvedValueOnce(JSON.stringify(invalidConfig)) - .mockResolvedValueOnce(JSON.stringify(validConfig)); - - const validateSpy = vi - .spyOn(IdeClient, 'validateWorkspacePath') - .mockReturnValueOnce({ isValid: false }) - .mockReturnValueOnce({ isValid: true }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(validConfig); - expect(validateSpy).toHaveBeenCalledWith( - '/invalid/workspace', - '/test/workspace/sub-dir', - ); - expect(validateSpy).toHaveBeenCalledWith( - '/test/workspace', - '/test/workspace/sub-dir', - ); - }); - - it('should return the first valid config when multiple workspaces are valid', async () => { - const config1 = { port: '1111', workspacePath: '/test/workspace' }; - const config2 = { port: '2222', workspacePath: '/test/workspace2' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'qwen-code-ide-server-12345-111.json', - 'qwen-code-ide-server-12345-222.json', - ]); - vi.mocked(fs.promises.readFile) - .mockResolvedValueOnce(JSON.stringify(config1)) - .mockResolvedValueOnce(JSON.stringify(config2)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(config1); - }); - - it('should prioritize the config matching the port from the environment variable', async () => { + it('should fall back to legacy port file when pid file is missing', async () => { process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222'; - const config1 = { port: '1111', workspacePath: '/test/workspace' }; const config2 = { port: '2222', workspacePath: '/test/workspace2' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'qwen-code-ide-server-12345-111.json', - 'qwen-code-ide-server-12345-222.json', - ]); vi.mocked(fs.promises.readFile) - .mockResolvedValueOnce(JSON.stringify(config1)) + .mockRejectedValueOnce(new Error('not found')) // ~/.qwen/ide/.lock + .mockRejectedValueOnce(new Error('not found')) // legacy pid file .mockResolvedValueOnce(JSON.stringify(config2)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); const ideClient = await IdeClient.getInstance(); const result = await ( @@ -437,28 +330,23 @@ describe('IdeClient', () => { ).getConnectionConfigFromFile(); expect(result).toEqual(config2); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join('/tmp', 'qwen-code-ide-server-12345.json'), + 'utf8', + ); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join('/tmp', 'qwen-code-ide-server-2222.json'), + 'utf8', + ); delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); - 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', - ]); + it('should fall back to legacy config when env lock file has invalid JSON', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333'; + const config = { port: '1111', workspacePath: '/test/workspace' }; vi.mocked(fs.promises.readFile) .mockResolvedValueOnce('invalid json') - .mockResolvedValueOnce(JSON.stringify(validConfig)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); + .mockResolvedValueOnce(JSON.stringify(config)); const ideClient = await IdeClient.getInstance(); const result = await ( @@ -467,96 +355,7 @@ describe('IdeClient', () => { } ).getConnectionConfigFromFile(); - expect(result).toEqual(validConfig); - }); - - it('should return undefined if readdir throws an error', async () => { - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - vi.mocked(fs.promises.readdir).mockRejectedValue( - new Error('readdir failed'), - ); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toBeUndefined(); - }); - - it('should ignore files with invalid names', async () => { - const validConfig = { port: '3333', workspacePath: '/test/workspace' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'qwen-code-ide-server-12345-111.json', // valid - 'not-a-config-file.txt', // invalid - 'qwen-code-ide-server-asdf.json', // invalid - ]); - vi.mocked(fs.promises.readFile).mockResolvedValueOnce( - JSON.stringify(validConfig), - ); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(validConfig); - expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'qwen-code-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['QWEN_CODE_IDE_SERVER_PORT'] = '3333'; - const config1 = { port: 1111, workspacePath: '/test/workspace' }; - const config2 = { port: 3333, workspacePath: '/test/workspace2' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'qwen-code-ide-server-12345-111.json', - 'qwen-code-ide-server-12345-222.json', - ]); - vi.mocked(fs.promises.readFile) - .mockResolvedValueOnce(JSON.stringify(config1)) - .mockResolvedValueOnce(JSON.stringify(config2)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(config2); + expect(result).toEqual(config); delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); }); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index b447f46c..b216506f 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, @@ -572,98 +573,103 @@ export class IdeClient { | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) | undefined > { - if (!this.ideProcessInfo) { - return undefined; - } - - // For backwards compatability - try { - const portFile = path.join( - os.tmpdir(), - `qwen-code-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 - // /^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; + try { + const ideDir = Storage.getGlobalIdeDir(); + const lockFile = path.join(ideDir, `${portFromEnv}.lock`); + const lockFileContents = await fs.promises.readFile(lockFile, 'utf8'); + return JSON.parse(lockFileContents); + } catch (_) { + // Fall through to legacy discovery. } } - return validWorkspaces[0]; + // Legacy discovery for VSCode extension < v0.5.1. + return this.getLegacyConnectionConfig(portFromEnv); + } + + // Legacy connection files were written in the global temp directory. + private async getLegacyConnectionConfig( + portFromEnv?: string, + ): Promise< + | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) + | undefined + > { + if (this.ideProcessInfo) { + try { + const portFile = path.join( + os.tmpdir(), + `qwen-code-ide-server-${this.ideProcessInfo.pid}.json`, + ); + const portFileContents = await fs.promises.readFile(portFile, 'utf8'); + return JSON.parse(portFileContents); + } catch (_) { + // 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. + } + } + + if (portFromEnv) { + try { + const portFile = path.join( + os.tmpdir(), + `qwen-code-ide-server-${portFromEnv}.json`, + ); + const portFileContents = await fs.promises.readFile(portFile, 'utf8'); + return JSON.parse(portFileContents); + } catch (_) { + // Ignore and fall through. + } + } + + return undefined; + } + + protected async getAllConnectionConfigs( + ideDir: string, + ): Promise< + ConnectionConfig & Array<{ workspacePath?: string; ideInfo?: IdeInfo }> + > { + const fileRegex = new RegExp('^\\d+\\.lock$'); + let lockFiles: string[]; + try { + lockFiles = (await fs.promises.readdir(ideDir)).filter((file) => + fileRegex.test(file), + ); + } catch (e) { + logger.debug('Failed to read IDE connection directory:', e); + return []; + } + + 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 }; + } + }), + ); + + return fileContents + .filter(({ parsed }) => parsed !== undefined) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .map(({ parsed }) => parsed); } 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..8268efe6 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,30 +129,24 @@ 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', + `${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), workspacePath: expectedWorkspacePaths, ppid: process.ppid, authToken: 'test-auth-token', + ideName: 'VS Code', }); 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,30 +161,24 @@ 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', + `${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), workspacePath: '/foo/bar', ppid: process.ppid, authToken: 'test-auth-token', + ideName: 'VS Code', }); 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,30 +193,24 @@ 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', + `${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), workspacePath: '', ppid: process.ppid, authToken: 'test-auth-token', + ideName: 'VS Code', }); 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,30 +239,24 @@ 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', + `${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), workspacePath: expectedWorkspacePaths, ppid: process.ppid, authToken: 'test-auth-token', + ideName: 'VS Code', }); 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' } }]; @@ -294,36 +271,26 @@ describe('IDEServer', () => { workspacePath: '/baz/qux', ppid: process.ppid, authToken: 'test-auth-token', + ideName: 'VS Code', }); 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`, - ); - expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String)); - expect(fs.writeFile).toHaveBeenCalledWith(ppidPortFile, expect.any(String)); + const lockFile = path.join('/home/test', '.qwen', 'ide', `${port}.lock`); + 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,30 +311,24 @@ 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', + `${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), workspacePath: expectedWorkspacePaths, ppid: process.ppid, authToken: 'test-auth-token', + ideName: 'VS Code', }); 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 +340,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 +351,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 +513,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..f7712399 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -10,6 +10,7 @@ import { IdeContextNotificationSchema, OpenDiffRequestSchema, } from '@qwen-code/qwen-code-core/src/ide/types.js'; +import { detectIdeFromEnv } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; @@ -38,12 +39,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 +64,7 @@ interface WritePortAndWorkspaceArgs { async function writePortAndWorkspace({ context, port, - portFile, - ppidPortFile, + lockFile, authToken, log, }: WritePortAndWorkspaceArgs): Promise { @@ -71,26 +83,24 @@ async function writePortAndWorkspace({ workspacePath, ); + const ideInfo = detectIdeFromEnv(); const content = JSON.stringify({ port, workspacePath, ppid: process.ppid, authToken, + ideName: ideInfo.displayName, }); - 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 +131,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 +183,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 +341,21 @@ 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. + this.lockFile = path.join(ideDir, `${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 +384,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 +416,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. }