Merge pull request #1257 from QwenLM/feat/ide-companion-discovery

IDE companion discovery: switch to ~/.qwen/ide lock files
This commit is contained in:
tanzhenxin
2025-12-19 18:29:11 +08:00
committed by GitHub
7 changed files with 280 additions and 512 deletions

View File

@@ -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. - **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`). - **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. - **How the CLI Finds the File:** The CLI reads the port from `QWEN_CODE_IDE_SERVER_PORT`, then reads `~/.qwen/ide/<PORT>.lock`. (Legacy fallbacks exist for older extensions; see note below.)
- **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. - **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: - **File Naming Convention:** The filename is critical and **MUST** follow the pattern:
`qwen-code-ide-server-${PID}-${PORT}.json` `<PORT>.lock`
- `${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.
- `${PORT}`: The port your MCP server is listening on.
- **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure: - **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure:
```json ```json
@@ -33,21 +32,20 @@ For Qwen Code to connect, it needs to discover which IDE instance it's running i
"port": 12345, "port": 12345,
"workspacePath": "/path/to/project1:/path/to/project2", "workspacePath": "/path/to/project1:/path/to/project2",
"authToken": "a-very-secret-token", "authToken": "a-very-secret-token",
"ideInfo": { "ppid": 1234,
"name": "vscode", "ideName": "VS Code"
"displayName": "VS Code"
}
} }
``` ```
- `port` (number, required): The port of the MCP server. - `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). - `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 <token>` header on all requests. - `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer <token>` header on all requests.
- `ideInfo` (object, required): Information about the IDE. - `ppid` (number, required): The parent process ID of the IDE process.
- `name` (string, required): A short, lowercase identifier for the IDE (e.g., `vscode`, `jetbrains`). - `ideName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
- `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. - **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 `<PORT>.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-<PID>.json` or `qwen-code-ide-server-<PORT>.json`. New integrations should not rely on these legacy files.
## II. The Context Interface ## II. The Context Interface

48
package-lock.json generated
View File

@@ -568,7 +568,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -592,7 +591,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -2157,7 +2155,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=8.0.0" "node": ">=8.0.0"
} }
@@ -3671,7 +3668,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.10.4", "@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
@@ -4142,7 +4138,6 @@
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -4153,7 +4148,6 @@
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
@@ -4359,7 +4353,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.35.0",
@@ -5135,7 +5128,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -5530,7 +5522,8 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/array-includes": { "node_modules/array-includes": {
"version": "3.1.9", "version": "3.1.9",
@@ -6865,6 +6858,7 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "5.2.1" "safe-buffer": "5.2.1"
}, },
@@ -7982,7 +7976,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -8518,6 +8511,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@@ -8579,6 +8573,7 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -8588,6 +8583,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ms": "2.0.0" "ms": "2.0.0"
} }
@@ -8597,6 +8593,7 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -8763,6 +8760,7 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"debug": "2.6.9", "debug": "2.6.9",
"encodeurl": "~2.0.0", "encodeurl": "~2.0.0",
@@ -8781,6 +8779,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ms": "2.0.0" "ms": "2.0.0"
} }
@@ -8789,13 +8788,15 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/finalhandler/node_modules/statuses": { "node_modules/finalhandler/node_modules/statuses": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -9909,7 +9910,6 @@
"resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz", "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz",
"integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==", "integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.0", "@alcalzone/ansi-tokenize": "^0.2.0",
"ansi-escapes": "^7.0.0", "ansi-escapes": "^7.0.0",
@@ -11864,6 +11864,7 @@
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -13162,7 +13163,8 @@
"version": "0.1.12", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/path-type": { "node_modules/path-type": {
"version": "3.0.0", "version": "3.0.0",
@@ -13821,7 +13823,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -13832,7 +13833,6 @@
"integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"shell-quote": "^1.6.1", "shell-quote": "^1.6.1",
"ws": "^7" "ws": "^7"
@@ -13866,7 +13866,6 @@
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@@ -15932,7 +15931,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -16112,8 +16110,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD", "license": "0BSD"
"peer": true
}, },
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.20.3", "version": "4.20.3",
@@ -16121,7 +16118,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "~0.25.0", "esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5" "get-tsconfig": "^4.7.5"
@@ -16316,7 +16312,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -16624,6 +16619,7 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
@@ -16679,7 +16675,6 @@
"integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.6", "fdir": "^6.4.6",
@@ -16793,7 +16788,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -16807,7 +16801,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.4",
@@ -17486,7 +17479,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -17757,7 +17749,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -17776,10 +17767,11 @@
}, },
"packages/sdk-typescript": { "packages/sdk-typescript": {
"name": "@qwen-code/sdk", "name": "@qwen-code/sdk",
"version": "0.5.1", "version": "0.1.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4" "@modelcontextprotocol/sdk": "^1.0.4",
"tiktoken": "^1.0.21"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.14.0", "@types/node": "^20.14.0",

View File

@@ -15,6 +15,7 @@ export const OAUTH_FILE = 'oauth_creds.json';
const TMP_DIR_NAME = 'tmp'; const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin'; const BIN_DIR_NAME = 'bin';
const PROJECT_DIR_NAME = 'projects'; const PROJECT_DIR_NAME = 'projects';
const IDE_DIR_NAME = 'ide';
export class Storage { export class Storage {
private readonly targetDir: string; private readonly targetDir: string;
@@ -59,6 +60,10 @@ export class Storage {
return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME); return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME);
} }
static getGlobalIdeDir(): string {
return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME);
}
static getGlobalBinDir(): string { static getGlobalBinDir(): string {
return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME); return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME);
} }

View File

@@ -32,6 +32,7 @@ vi.mock('node:fs', async (importOriginal) => {
...actual.promises, ...actual.promises,
readFile: vi.fn(), readFile: vi.fn(),
readdir: vi.fn(), readdir: vi.fn(),
stat: vi.fn(),
}, },
realpathSync: (p: string) => p, realpathSync: (p: string) => p,
existsSync: () => false, existsSync: () => false,
@@ -68,6 +69,7 @@ describe('IdeClient', () => {
command: 'test-ide', command: 'test-ide',
}); });
vi.mocked(os.tmpdir).mockReturnValue('/tmp'); vi.mocked(os.tmpdir).mockReturnValue('/tmp');
vi.mocked(os.homedir).mockReturnValue('/home/test');
// Mock MCP client and transports // Mock MCP client and transports
mockClient = { mockClient = {
@@ -97,19 +99,15 @@ describe('IdeClient', () => {
describe('connect', () => { describe('connect', () => {
it('should connect using HTTP when port is provided in config file', async () => { 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' }; const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
await ideClient.connect(); await ideClient.connect();
expect(fs.promises.readFile).toHaveBeenCalledWith( expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'), path.join('/home/test', '.qwen', 'ide', '8080.lock'),
'utf8', 'utf8',
); );
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
@@ -120,16 +118,13 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe( expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected, IDEConnectionStatus.Connected,
); );
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
}); });
it('should connect using stdio when stdio config is provided in file', async () => { 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'] } }; const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
await ideClient.connect(); await ideClient.connect();
@@ -142,19 +137,16 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe( expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected, IDEConnectionStatus.Connected,
); );
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
}); });
it('should prioritize port over stdio when both are in config file', async () => { it('should prioritize port over stdio when both are in config file', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = { const config = {
port: '8080', port: '8080',
stdio: { command: 'test-cmd', args: ['--foo'] }, stdio: { command: 'test-cmd', args: ['--foo'] },
}; };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
await ideClient.connect(); await ideClient.connect();
@@ -164,6 +156,7 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe( expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected, IDEConnectionStatus.Connected,
); );
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
}); });
it('should connect using HTTP when port is provided in environment variables', async () => { it('should connect using HTTP when port is provided in environment variables', async () => {
@@ -263,7 +256,8 @@ describe('IdeClient', () => {
}); });
describe('getConnectionConfigFromFile', () => { 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' }; const config = { port: '1234', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
@@ -277,18 +271,14 @@ describe('IdeClient', () => {
expect(result).toEqual(config); expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith( expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'), path.join('/home/test', '.qwen', 'ide', '1234.lock'),
'utf8', 'utf8',
); );
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
}); });
it('should return undefined if no config files are found', async () => { 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.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 ideClient = await IdeClient.getInstance();
const result = await ( const result = await (
@@ -300,20 +290,15 @@ describe('IdeClient', () => {
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
it('should find and parse a single config file with the new naming scheme', async () => { it('should read legacy pid config when available', async () => {
const config = { port: '5678', workspacePath: '/test/workspace' }; const config = {
vi.mocked(fs.promises.readFile).mockRejectedValueOnce( port: '5678',
new Error('not found'), workspacePath: '/test/workspace',
); // For old path ppid: 12345,
( };
vi.mocked(fs.promises.readdir) as Mock< vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
(path: fs.PathLike) => Promise<string[]> JSON.stringify(config),
> );
).mockResolvedValue(['qwen-code-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 ideClient = await IdeClient.getInstance();
const result = await ( const result = await (
@@ -324,110 +309,18 @@ describe('IdeClient', () => {
expect(result).toEqual(config); expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith( 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', 'utf8',
); );
}); });
it('should filter out configs with invalid workspace paths', async () => { it('should fall back to legacy port file when pid file is missing', 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([
'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<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([
'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<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config1);
});
it('should prioritize the config matching the port from the environment variable', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222'; process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222';
const config1 = { port: '1111', workspacePath: '/test/workspace' };
const config2 = { port: '2222', workspacePath: '/test/workspace2' }; 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([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile) vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1)) .mockRejectedValueOnce(new Error('not found')) // ~/.qwen/ide/<port>.lock
.mockRejectedValueOnce(new Error('not found')) // legacy pid file
.mockResolvedValueOnce(JSON.stringify(config2)); .mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
const result = await ( const result = await (
@@ -437,28 +330,23 @@ describe('IdeClient', () => {
).getConnectionConfigFromFile(); ).getConnectionConfigFromFile();
expect(result).toEqual(config2); 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']; delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
}); });
it('should handle invalid JSON in one of the config files', async () => { it('should fall back to legacy config when env lock file has invalid JSON', async () => {
const validConfig = { port: '2222', workspacePath: '/test/workspace' }; process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
vi.mocked(fs.promises.readFile).mockRejectedValueOnce( const config = { port: '1111', workspacePath: '/test/workspace' };
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile) vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce('invalid json') .mockResolvedValueOnce('invalid json')
.mockResolvedValueOnce(JSON.stringify(validConfig)); .mockResolvedValueOnce(JSON.stringify(config));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
const result = await ( const result = await (
@@ -467,96 +355,7 @@ describe('IdeClient', () => {
} }
).getConnectionConfigFromFile(); ).getConnectionConfigFromFile();
expect(result).toEqual(validConfig); expect(result).toEqual(config);
});
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([
'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<unknown>;
}
).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<string[]>
>
).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<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config2);
delete process.env['QWEN_CODE_IDE_SERVER_PORT']; delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
}); });
}); });

View File

@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
import { isSubpath } from '../utils/paths.js'; import { isSubpath } from '../utils/paths.js';
import { detectIde, type IdeInfo } from '../ide/detect-ide.js'; import { detectIde, type IdeInfo } from '../ide/detect-ide.js';
import { ideContextStore } from './ideContext.js'; import { ideContextStore } from './ideContext.js';
import { Storage } from '../config/storage.js';
import { import {
IdeContextNotificationSchema, IdeContextNotificationSchema,
IdeDiffAcceptedNotificationSchema, IdeDiffAcceptedNotificationSchema,
@@ -572,11 +573,30 @@ export class IdeClient {
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined | undefined
> { > {
if (!this.ideProcessInfo) { const portFromEnv = this.getPortFromEnv();
return undefined; if (portFromEnv) {
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.
}
} }
// For backwards compatability // 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 { try {
const portFile = path.join( const portFile = path.join(
os.tmpdir(), os.tmpdir(),
@@ -585,85 +605,71 @@ export class IdeClient {
const portFileContents = await fs.promises.readFile(portFile, 'utf8'); const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents); return JSON.parse(portFileContents);
} catch (_) { } 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 // /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
// windows are open, multiple files matching the pattern are expected to // windows are open, multiple files matching the pattern are expected to
// exist. // exist.
} }
}
const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide'); if (portFromEnv) {
let portFiles;
try { try {
portFiles = await fs.promises.readdir(portFileDir); 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) { } catch (e) {
logger.debug('Failed to read IDE connection directory:', e); logger.debug('Failed to read IDE connection directory:', e);
return undefined; return [];
} }
if (!portFiles) { const fileContents = await Promise.all(
return undefined; lockFiles.map(async (file) => {
} const fullPath = path.join(ideDir, file);
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 { try {
fileContents = await Promise.all( const stat = await fs.promises.stat(fullPath);
matchingFiles.map((file) => const content = await fs.promises.readFile(fullPath, 'utf8');
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 { try {
return JSON.parse(content); const parsed = JSON.parse(content);
return { file, mtimeMs: stat.mtimeMs, parsed };
} catch (e) { } catch (e) {
logger.debug('Failed to parse JSON from config file: ', e); logger.debug('Failed to parse JSON from lock file: ', e);
return undefined; 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
const validWorkspaces = parsedContents.filter((content) => { // win ties, and skip parsing by returning undefined content.
if (!content) { logger.debug('Failed to read/stat IDE lock file:', e);
return false; return { file, mtimeMs: -Infinity, parsed: undefined };
} }
const { isValid } = IdeClient.validateWorkspacePath( }),
content.workspacePath,
process.cwd(),
); );
return isValid;
});
if (validWorkspaces.length === 0) { return fileContents
return undefined; .filter(({ parsed }) => parsed !== undefined)
} .sort((a, b) => b.mtimeMs - a.mtimeMs)
.map(({ parsed }) => parsed);
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 createProxyAwareFetch() { private createProxyAwareFetch() {

View File

@@ -27,13 +27,14 @@ vi.mock('node:fs/promises', () => ({
writeFile: vi.fn(() => Promise.resolve(undefined)), writeFile: vi.fn(() => Promise.resolve(undefined)),
unlink: vi.fn(() => Promise.resolve(undefined)), unlink: vi.fn(() => Promise.resolve(undefined)),
chmod: vi.fn(() => Promise.resolve(undefined)), chmod: vi.fn(() => Promise.resolve(undefined)),
mkdir: vi.fn(() => Promise.resolve(undefined)),
})); }));
vi.mock('node:os', async (importOriginal) => { vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof os>(); const actual = await importOriginal<typeof os>();
return { return {
...actual, ...actual,
tmpdir: vi.fn(() => '/tmp'), homedir: vi.fn(() => '/home/test'),
}; };
}); });
@@ -128,30 +129,24 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths, workspacePath: expectedWorkspacePaths,
ppid: process.ppid, ppid: process.ppid,
authToken: 'test-auth-token', authToken: 'test-auth-token',
ideName: 'VS Code',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
}); });
it('should set a single folder path', async () => { it('should set a single folder path', async () => {
@@ -166,30 +161,24 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
workspacePath: '/foo/bar', workspacePath: '/foo/bar',
ppid: process.ppid, ppid: process.ppid,
authToken: 'test-auth-token', authToken: 'test-auth-token',
ideName: 'VS Code',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
}); });
it('should set an empty string if no folders are open', async () => { it('should set an empty string if no folders are open', async () => {
@@ -204,30 +193,24 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
workspacePath: '', workspacePath: '',
ppid: process.ppid, ppid: process.ppid,
authToken: 'test-auth-token', authToken: 'test-auth-token',
ideName: 'VS Code',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
}); });
it('should update the path when workspace folders change', async () => { it('should update the path when workspace folders change', async () => {
@@ -256,30 +239,24 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths, workspacePath: expectedWorkspacePaths,
ppid: process.ppid, ppid: process.ppid,
authToken: 'test-auth-token', authToken: 'test-auth-token',
ideName: 'VS Code',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
// Simulate removing a folder // Simulate removing a folder
vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }]; vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }];
@@ -294,36 +271,26 @@ describe('IDEServer', () => {
workspacePath: '/baz/qux', workspacePath: '/baz/qux',
ppid: process.ppid, ppid: process.ppid,
authToken: 'test-auth-token', authToken: 'test-auth-token',
ideName: 'VS Code',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent2, expectedContent2,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent2,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 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); await ideServer.start(mockContext);
const replaceMock = mockContext.environmentVariableCollection.replace; const replaceMock = mockContext.environmentVariableCollection.replace;
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const portFile = path.join('/tmp', `qwen-code-ide-server-${port}.json`); const lockFile = path.join('/home/test', '.qwen', 'ide', `${port}.lock`);
const ppidPortFile = path.join( expect(fs.writeFile).toHaveBeenCalledWith(lockFile, expect.any(String));
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String));
expect(fs.writeFile).toHaveBeenCalledWith(ppidPortFile, expect.any(String));
await ideServer.stop(); await ideServer.stop();
expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled(); expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled();
expect(fs.unlink).toHaveBeenCalledWith(portFile); expect(fs.unlink).toHaveBeenCalledWith(lockFile);
expect(fs.unlink).toHaveBeenCalledWith(ppidPortFile);
}); });
it.skipIf(process.platform !== 'win32')( it.skipIf(process.platform !== 'win32')(
@@ -344,30 +311,24 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths, workspacePath: expectedWorkspacePaths,
ppid: process.ppid, ppid: process.ppid,
authToken: 'test-auth-token', authToken: 'test-auth-token',
ideName: 'VS Code',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
}, },
); );
@@ -379,7 +340,7 @@ describe('IDEServer', () => {
port = (ideServer as unknown as { port: number }).port; 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`, { const response = await fetch(`http://localhost:${port}/mcp`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -390,7 +351,9 @@ describe('IDEServer', () => {
id: 1, 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 () => { it('should allow request with valid auth token', async () => {
@@ -550,6 +513,7 @@ describe('IDEServer HTTP endpoints', () => {
headers: { headers: {
Host: `localhost:${port}`, Host: `localhost:${port}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: 'Bearer test-auth-token',
}, },
}, },
JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }), JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),

View File

@@ -10,6 +10,7 @@ import {
IdeContextNotificationSchema, IdeContextNotificationSchema,
OpenDiffRequestSchema, OpenDiffRequestSchema,
} from '@qwen-code/qwen-code-core/src/ide/types.js'; } 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 { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.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 MCP_SESSION_ID_HEADER = 'mcp-session-id';
const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT'; const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT';
const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH'; const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH';
const QWEN_DIR = '.qwen';
const IDE_DIR = 'ide';
async function getGlobalIdeDir(): Promise<string> {
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 { interface WritePortAndWorkspaceArgs {
context: vscode.ExtensionContext; context: vscode.ExtensionContext;
port: number; port: number;
portFile: string; lockFile: string;
ppidPortFile: string;
authToken: string; authToken: string;
log: (message: string) => void; log: (message: string) => void;
} }
@@ -51,8 +64,7 @@ interface WritePortAndWorkspaceArgs {
async function writePortAndWorkspace({ async function writePortAndWorkspace({
context, context,
port, port,
portFile, lockFile,
ppidPortFile,
authToken, authToken,
log, log,
}: WritePortAndWorkspaceArgs): Promise<void> { }: WritePortAndWorkspaceArgs): Promise<void> {
@@ -71,26 +83,24 @@ async function writePortAndWorkspace({
workspacePath, workspacePath,
); );
const ideInfo = detectIdeFromEnv();
const content = JSON.stringify({ const content = JSON.stringify({
port, port,
workspacePath, workspacePath,
ppid: process.ppid, ppid: process.ppid,
authToken, authToken,
ideName: ideInfo.displayName,
}); });
log(`Writing port file to: ${portFile}`); log(`Writing IDE lock file to: ${lockFile}`);
log(`Writing ppid port file to: ${ppidPortFile}`);
try { try {
await Promise.all([ await fs.mkdir(path.dirname(lockFile), { recursive: true });
fs.writeFile(portFile, content).then(() => fs.chmod(portFile, 0o600)), await fs.writeFile(lockFile, content);
fs await fs.chmod(lockFile, 0o600);
.writeFile(ppidPortFile, content)
.then(() => fs.chmod(ppidPortFile, 0o600)),
]);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(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 server: HTTPServer | undefined;
private context: vscode.ExtensionContext | undefined; private context: vscode.ExtensionContext | undefined;
private log: (message: string) => void; private log: (message: string) => void;
private portFile: string | undefined; private lockFile: string | undefined;
private ppidPortFile: string | undefined;
private port: number | undefined; private port: number | undefined;
private authToken: string | undefined; private authToken: string | undefined;
private transports: { [sessionId: string]: StreamableHTTPServerTransport } = private transports: { [sessionId: string]: StreamableHTTPServerTransport } =
@@ -174,20 +183,25 @@ export class IDEServer {
app.use((req, res, next) => { app.use((req, res, next) => {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (authHeader) { if (!authHeader) {
this.log('Missing Authorization header. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
const parts = authHeader.split(' '); const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') { if (parts.length !== 2 || parts[0] !== 'Bearer') {
this.log('Malformed Authorization header. Rejecting request.'); this.log('Malformed Authorization header. Rejecting request.');
res.status(401).send('Unauthorized'); res.status(401).send('Unauthorized');
return; return;
} }
const token = parts[1]; const token = parts[1];
if (token !== this.authToken) { if (token !== this.authToken) {
this.log('Invalid auth token provided. Rejecting request.'); this.log('Invalid auth token provided. Rejecting request.');
res.status(401).send('Unauthorized'); res.status(401).send('Unauthorized');
return; return;
} }
}
next(); next();
}); });
@@ -327,22 +341,21 @@ export class IDEServer {
const address = (this.server as HTTPServer).address(); const address = (this.server as HTTPServer).address();
if (address && typeof address !== 'string') { if (address && typeof address !== 'string') {
this.port = address.port; this.port = address.port;
this.portFile = path.join( try {
os.tmpdir(), const ideDir = await getGlobalIdeDir();
`qwen-code-ide-server-${this.port}.json`, // Name the lock file by port to support multiple server instances.
); this.lockFile = path.join(ideDir, `${this.port}.lock`);
this.ppidPortFile = path.join( } catch (err) {
os.tmpdir(), const message = err instanceof Error ? err.message : String(err);
`qwen-code-ide-server-${process.ppid}.json`, this.log(`Failed to determine IDE lock directory: ${message}`);
); }
this.log(`IDE server listening on http://127.0.0.1:${this.port}`); this.log(`IDE server listening on http://127.0.0.1:${this.port}`);
if (this.authToken) { if (this.authToken && this.lockFile) {
await writePortAndWorkspace({ await writePortAndWorkspace({
context, context,
port: this.port, port: this.port,
portFile: this.portFile, lockFile: this.lockFile,
ppidPortFile: this.ppidPortFile,
authToken: this.authToken, authToken: this.authToken,
log: this.log, log: this.log,
}); });
@@ -371,15 +384,13 @@ export class IDEServer {
this.context && this.context &&
this.server && this.server &&
this.port && this.port &&
this.portFile && this.lockFile &&
this.ppidPortFile &&
this.authToken this.authToken
) { ) {
await writePortAndWorkspace({ await writePortAndWorkspace({
context: this.context, context: this.context,
port: this.port, port: this.port,
portFile: this.portFile, lockFile: this.lockFile,
ppidPortFile: this.ppidPortFile,
authToken: this.authToken, authToken: this.authToken,
log: this.log, log: this.log,
}); });
@@ -405,16 +416,9 @@ export class IDEServer {
if (this.context) { if (this.context) {
this.context.environmentVariableCollection.clear(); this.context.environmentVariableCollection.clear();
} }
if (this.portFile) { if (this.lockFile) {
try { try {
await fs.unlink(this.portFile); await fs.unlink(this.lockFile);
} catch (_err) {
// Ignore errors if the file doesn't exist.
}
}
if (this.ppidPortFile) {
try {
await fs.unlink(this.ppidPortFile);
} catch (_err) { } catch (_err) {
// Ignore errors if the file doesn't exist. // Ignore errors if the file doesn't exist.
} }