IDE companion discovery: switch to ~/.qwen/ide lock files

This commit is contained in:
tanzhenxin
2025-12-15 20:15:26 +08:00
parent 4cbb57a793
commit 59c3d3d0f9
11 changed files with 297 additions and 294 deletions

View File

@@ -16,15 +16,15 @@ The plugin **MUST** run a local HTTP server that implements the **Model Context
- **Endpoint:** The server should expose a single endpoint (e.g., `/mcp`) for all MCP communication.
- **Port:** The server **MUST** listen on a dynamically assigned port (i.e., listen on port `0`).
### 2. Discovery Mechanism: The Port File
### 2. Discovery Mechanism: The Lock File
For Qwen Code to connect, it needs to discover which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "discovery file."
For Qwen Code to connect, it needs to discover which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "lock file."
- **How the CLI Finds the File:** The CLI determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then looks for a discovery file that contains this PID in its name.
- **File Location:** The file must be created in a specific directory: `os.tmpdir()/qwen/ide/`. Your plugin must create this directory if it doesn't exist.
- **How the CLI Finds the File:** The CLI determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then scans `~/.qwen/ide/` for `${PID}-*.lock` files, parses their JSON content, and selects one whose `workspacePath` matches the CLI's current working directory. If `QWEN_CODE_IDE_SERVER_PORT` is set, the CLI will prefer `${PID}-${PORT}.lock`.
- **File Location:** The file must be created in a specific directory: `~/.qwen/ide/`. Your plugin must create this directory if it doesn't exist.
- **File Naming Convention:** The filename is critical and **MUST** follow the pattern:
`qwen-code-ide-server-${PID}-${PORT}.json`
- `${PID}`: The process ID of the parent IDE process. Your plugin must determine this PID and include it in the filename.
`${PID}-${PORT}.lock`
- `${PID}`: The process ID of the parent IDE process.
- `${PORT}`: The port your MCP server is listening on.
- **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure:
@@ -47,7 +47,7 @@ For Qwen Code to connect, it needs to discover which IDE instance it's running i
- `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
- **Authentication:** To secure the connection, the plugin **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized.
- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your plugin **SHOULD** both create the discovery file and set the `QWEN_CODE_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `QWEN_CODE_IDE_SERVER_PORT` variable to identify and connect to the correct window's server.
- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your plugin **SHOULD** both create the lock file and set the `QWEN_CODE_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `QWEN_CODE_IDE_SERVER_PORT` variable to identify and connect to the correct window's server.
## II. The Context Interface

View File

@@ -46,12 +46,15 @@ vi.mock('node:fs', async (importOriginal) => {
// Mock Storage from core
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
Storage: {
getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'),
getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/.qwen/settings.json'),
getGlobalSettingsPath: vi
.fn()
.mockReturnValue('/mock/.qwen/settings.json'),
},
};
});
@@ -360,7 +363,10 @@ describe('languageCommand', () => {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output Chinese');
const result = await languageCommand.action(
mockContext,
'output Chinese',
);
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalledWith(
@@ -371,7 +377,9 @@ describe('languageCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('LLM output language rule file generated'),
content: expect.stringContaining(
'LLM output language rule file generated',
),
});
});
@@ -380,7 +388,10 @@ describe('languageCommand', () => {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output Japanese');
const result = await languageCommand.action(
mockContext,
'output Japanese',
);
expect(result).toEqual({
type: 'message',
@@ -514,7 +525,9 @@ describe('languageCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('LLM output language rule file generated'),
content: expect.stringContaining(
'LLM output language rule file generated',
),
});
});
});

View File

@@ -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);
}

View File

@@ -338,10 +338,7 @@ describe('OpenAIContentConverter', () => {
});
it('should handle tools without functionDeclarations', async () => {
const emptyTools: Tool[] = [
{} as Tool,
{ functionDeclarations: [] },
];
const emptyTools: Tool[] = [{} as Tool, { functionDeclarations: [] }];
const result = await converter.convertGeminiToolsToOpenAI(emptyTools);
@@ -489,7 +486,10 @@ describe('OpenAIContentConverter', () => {
const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>;
const nested = properties?.['nested'] as Record<string, unknown>;
const nestedProperties = nested?.['properties'] as Record<string, unknown>;
const nestedProperties = nested?.['properties'] as Record<
string,
unknown
>;
expect(nestedProperties?.['deep']).toEqual({
type: 'integer',

View File

@@ -32,6 +32,7 @@ vi.mock('node:fs', async (importOriginal) => {
...actual.promises,
readFile: vi.fn(),
readdir: vi.fn(),
stat: vi.fn(),
},
realpathSync: (p: string) => p,
existsSync: () => false,
@@ -68,6 +69,7 @@ describe('IdeClient', () => {
command: 'test-ide',
});
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
vi.mocked(os.homedir).mockReturnValue('/home/test');
// Mock MCP client and transports
mockClient = {
@@ -97,6 +99,7 @@ describe('IdeClient', () => {
describe('connect', () => {
it('should connect using HTTP when port is provided in config file', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
@@ -109,7 +112,7 @@ describe('IdeClient', () => {
await ideClient.connect();
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
path.join('/home/test', '.qwen', 'ide', '12345-8080.lock'),
'utf8',
);
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
@@ -120,6 +123,7 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should connect using stdio when stdio config is provided in file', async () => {
@@ -263,7 +267,8 @@ describe('IdeClient', () => {
});
describe('getConnectionConfigFromFile', () => {
it('should return config from the specific pid file if it exists', async () => {
it('should return config from the env port lock file if it exists', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234';
const config = { port: '1234', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
@@ -277,9 +282,10 @@ describe('IdeClient', () => {
expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
path.join('/home/test', '.qwen', 'ide', '12345-1234.lock'),
'utf8',
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should return undefined if no config files are found', async () => {
@@ -300,17 +306,23 @@ describe('IdeClient', () => {
expect(result).toBeUndefined();
});
it('should find and parse a single config file with the new naming scheme', async () => {
const config = { port: '5678', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
); // For old path
it('should find and parse a single lock file matching the IDE pid', async () => {
const config = {
port: '5678',
workspacePath: '/test/workspace',
ppid: 12345,
};
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue(['qwen-code-ide-server-12345-123.json']);
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
).mockResolvedValueOnce(['12345-5678.lock']);
vi.mocked(fs.promises.stat).mockResolvedValueOnce({
mtimeMs: 123,
} as unknown as fs.Stats);
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
JSON.stringify(config),
);
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
@@ -324,7 +336,7 @@ describe('IdeClient', () => {
expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'),
path.join('/home/test', '.qwen', 'ide', '12345-5678.lock'),
'utf8',
);
});
@@ -338,25 +350,23 @@ describe('IdeClient', () => {
port: '1111',
workspacePath: '/invalid/workspace',
};
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
).mockResolvedValueOnce(['12345-1111.lock', '12345-5678.lock']); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(invalidConfig))
.mockResolvedValueOnce(JSON.stringify(validConfig));
const validateSpy = vi
.spyOn(IdeClient, 'validateWorkspacePath')
.mockReturnValueOnce({ isValid: false })
.mockReturnValueOnce({ isValid: true });
.mockImplementation((ideWorkspacePath) => ({
isValid: ideWorkspacePath === '/test/workspace',
}));
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -379,17 +389,15 @@ describe('IdeClient', () => {
it('should return the first valid config when multiple workspaces are valid', async () => {
const config1 = { port: '1111', workspacePath: '/test/workspace' };
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
// Make config1 "newer" so it wins when both are valid.
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
@@ -413,15 +421,15 @@ describe('IdeClient', () => {
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
); // For ~/.qwen/ide/<pid>-<port>.lock
(
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',
]);
).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
@@ -442,17 +450,14 @@ describe('IdeClient', () => {
it('should handle invalid JSON in one of the config files', async () => {
const validConfig = { port: '2222', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce('invalid json')
.mockResolvedValueOnce(JSON.stringify(validConfig));
@@ -474,9 +479,11 @@ describe('IdeClient', () => {
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
vi.mocked(fs.promises.readdir).mockRejectedValue(
new Error('readdir failed'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockRejectedValueOnce(new Error('readdir failed')); // ~/.qwen/ide scan
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -490,18 +497,19 @@ describe('IdeClient', () => {
it('should ignore files with invalid names', async () => {
const validConfig = { port: '3333', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json', // valid
).mockResolvedValueOnce([
'12345-3333.lock', // valid
'not-a-config-file.txt', // invalid
'qwen-code-ide-server-asdf.json', // invalid
]);
'asdf.lock', // invalid
'12345-asdf.lock', // invalid
]); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat).mockResolvedValueOnce({
mtimeMs: 123,
} as unknown as fs.Stats);
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
JSON.stringify(validConfig),
);
@@ -518,11 +526,11 @@ describe('IdeClient', () => {
expect(result).toEqual(validConfig);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'),
path.join('/home/test', '.qwen', 'ide', '12345-3333.lock'),
'utf8',
);
expect(fs.promises.readFile).not.toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'not-a-config-file.txt'),
path.join('/home/test', '.qwen', 'ide', 'not-a-config-file.txt'),
'utf8',
);
});
@@ -538,10 +546,10 @@ describe('IdeClient', () => {
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
).mockResolvedValueOnce(['12345-1111.lock', '12345-3333.lock']); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));

View File

@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
import { isSubpath } from '../utils/paths.js';
import { detectIde, type IdeInfo } from '../ide/detect-ide.js';
import { ideContextStore } from './ideContext.js';
import { Storage } from '../config/storage.js';
import {
IdeContextNotificationSchema,
IdeDiffAcceptedNotificationSchema,
@@ -576,68 +577,55 @@ export class IdeClient {
return undefined;
}
// For backwards compatability
// Preferred: lock file(s) in global qwen dir (~/.qwen/ide/<pid>-<port>.lock)
// 1) If QWEN_CODE_IDE_SERVER_PORT is set, prefer ~/.qwen/ide/<pid>-<port>.lock
// 2) Otherwise (or on failure), scan ~/.qwen/ide for <pid>-*.lock and select:
// - valid workspace path (validateWorkspacePath)
const ideDir = Storage.getGlobalIdeDir();
const idePid = this.ideProcessInfo.pid;
const portFromEnv = this.getPortFromEnv();
if (portFromEnv) {
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);
const lockFile = path.join(ideDir, `${idePid}-${portFromEnv}.lock`);
const lockFileContents = await fs.promises.readFile(lockFile, 'utf8');
return JSON.parse(lockFileContents);
} 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.
// Fall through to scanning / legacy discovery.
}
}
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 fileRegex = new RegExp(`^${idePid}-\\d+\\.lock$`);
const lockFiles = (await fs.promises.readdir(ideDir)).filter((file) =>
fileRegex.test(file),
);
const matchingFiles = portFiles
.filter((file) => fileRegex.test(file))
.sort();
if (matchingFiles.length === 0) {
return undefined;
}
let fileContents: string[];
const fileContents = await Promise.all(
lockFiles.map(async (file) => {
const fullPath = path.join(ideDir, file);
try {
fileContents = await Promise.all(
matchingFiles.map((file) =>
fs.promises.readFile(path.join(portFileDir, file), 'utf8'),
),
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 };
}
}),
);
} 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 validWorkspaces = fileContents
.filter(({ parsed }) => parsed !== undefined)
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.map(({ parsed }) => parsed)
.filter((content) => {
const { isValid } = IdeClient.validateWorkspacePath(
content.workspacePath,
process.cwd(),
@@ -645,16 +633,12 @@ export class IdeClient {
return isValid;
});
if (validWorkspaces.length === 0) {
return undefined;
}
if (validWorkspaces.length > 0) {
if (validWorkspaces.length === 1) {
return validWorkspaces[0];
}
const portFromEnv = this.getPortFromEnv();
if (portFromEnv) {
if (validWorkspaces.length > 1 && portFromEnv) {
const matchingPort = validWorkspaces.find(
(content) => String(content.port) === portFromEnv,
);
@@ -663,8 +647,31 @@ export class IdeClient {
}
}
if (validWorkspaces.length > 1) {
return validWorkspaces[0];
}
}
} catch (_) {
// Fall through to legacy discovery mechanisms.
}
// For backwards compatability: single file in system temp dir named by PID.
try {
const portFile = path.join(
os.tmpdir(),
`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.
}
return undefined;
}
private createProxyAwareFetch() {
// ignore proxy for '127.0.0.1' by deafult to allow connecting to the ide mcp server

View File

@@ -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<typeof os>();
return {
...actual,
tmpdir: vi.fn(() => '/tmp'),
homedir: vi.fn(() => '/home/test'),
};
});
@@ -128,13 +129,11 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${process.ppid}-${port}.lock`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
@@ -143,15 +142,10 @@ describe('IDEServer', () => {
authToken: 'test-auth-token',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile,
expectedLockFile,
expectedContent,
);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
});
it('should set a single folder path', async () => {
@@ -166,13 +160,11 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${process.ppid}-${port}.lock`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
@@ -181,15 +173,10 @@ describe('IDEServer', () => {
authToken: 'test-auth-token',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile,
expectedLockFile,
expectedContent,
);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
});
it('should set an empty string if no folders are open', async () => {
@@ -204,13 +191,11 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${process.ppid}-${port}.lock`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
@@ -219,15 +204,10 @@ describe('IDEServer', () => {
authToken: 'test-auth-token',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile,
expectedLockFile,
expectedContent,
);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
});
it('should update the path when workspace folders change', async () => {
@@ -256,13 +236,11 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${process.ppid}-${port}.lock`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
@@ -271,15 +249,10 @@ describe('IDEServer', () => {
authToken: 'test-auth-token',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile,
expectedLockFile,
expectedContent,
);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
// Simulate removing a folder
vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }];
@@ -296,34 +269,28 @@ describe('IDEServer', () => {
authToken: 'test-auth-token',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile,
expectedLockFile,
expectedContent2,
);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent2,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
});
it('should clear env vars and delete port file on stop', async () => {
it('should clear env vars and delete lock file on stop', async () => {
await ideServer.start(mockContext);
const replaceMock = mockContext.environmentVariableCollection.replace;
const port = getPortFromMock(replaceMock);
const portFile = path.join('/tmp', `qwen-code-ide-server-${port}.json`);
const ppidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
const lockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${process.ppid}-${port}.lock`,
);
expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String));
expect(fs.writeFile).toHaveBeenCalledWith(ppidPortFile, expect.any(String));
expect(fs.writeFile).toHaveBeenCalledWith(lockFile, expect.any(String));
await ideServer.stop();
expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled();
expect(fs.unlink).toHaveBeenCalledWith(portFile);
expect(fs.unlink).toHaveBeenCalledWith(ppidPortFile);
expect(fs.unlink).toHaveBeenCalledWith(lockFile);
});
it.skipIf(process.platform !== 'win32')(
@@ -344,13 +311,11 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${process.ppid}-${port}.lock`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
@@ -359,15 +324,10 @@ describe('IDEServer', () => {
authToken: 'test-auth-token',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile,
expectedLockFile,
expectedContent,
);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
},
);
@@ -379,7 +339,7 @@ describe('IDEServer', () => {
port = (ideServer as unknown as { port: number }).port;
});
it('should allow request without auth token for backwards compatibility', async () => {
it('should reject request without auth token', async () => {
const response = await fetch(`http://localhost:${port}/mcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -390,7 +350,9 @@ describe('IDEServer', () => {
id: 1,
}),
});
expect(response.status).not.toBe(401);
expect(response.status).toBe(401);
const body = await response.text();
expect(body).toBe('Unauthorized');
});
it('should allow request with valid auth token', async () => {
@@ -550,6 +512,7 @@ describe('IDEServer HTTP endpoints', () => {
headers: {
Host: `localhost:${port}`,
'Content-Type': 'application/json',
Authorization: 'Bearer test-auth-token',
},
},
JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),

View File

@@ -38,12 +38,24 @@ class CORSError extends Error {
const MCP_SESSION_ID_HEADER = 'mcp-session-id';
const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT';
const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH';
const QWEN_DIR = '.qwen';
const IDE_DIR = 'ide';
async function getGlobalIdeDir(): Promise<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 {
context: vscode.ExtensionContext;
port: number;
portFile: string;
ppidPortFile: string;
lockFile: string;
authToken: string;
log: (message: string) => void;
}
@@ -51,8 +63,7 @@ interface WritePortAndWorkspaceArgs {
async function writePortAndWorkspace({
context,
port,
portFile,
ppidPortFile,
lockFile,
authToken,
log,
}: WritePortAndWorkspaceArgs): Promise<void> {
@@ -78,19 +89,15 @@ async function writePortAndWorkspace({
authToken,
});
log(`Writing port file to: ${portFile}`);
log(`Writing ppid port file to: ${ppidPortFile}`);
log(`Writing IDE lock file to: ${lockFile}`);
try {
await Promise.all([
fs.writeFile(portFile, content).then(() => fs.chmod(portFile, 0o600)),
fs
.writeFile(ppidPortFile, content)
.then(() => fs.chmod(ppidPortFile, 0o600)),
]);
await fs.mkdir(path.dirname(lockFile), { recursive: true });
await fs.writeFile(lockFile, content);
await fs.chmod(lockFile, 0o600);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log(`Failed to write port to file: ${message}`);
log(`Failed to write IDE lock file: ${message}`);
}
}
@@ -121,8 +128,7 @@ export class IDEServer {
private server: HTTPServer | undefined;
private context: vscode.ExtensionContext | undefined;
private log: (message: string) => void;
private portFile: string | undefined;
private ppidPortFile: string | undefined;
private lockFile: string | undefined;
private port: number | undefined;
private authToken: string | undefined;
private transports: { [sessionId: string]: StreamableHTTPServerTransport } =
@@ -174,20 +180,25 @@ export class IDEServer {
app.use((req, res, next) => {
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(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
this.log('Malformed Authorization header. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
const token = parts[1];
if (token !== this.authToken) {
this.log('Invalid auth token provided. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
}
next();
});
@@ -327,22 +338,25 @@ export class IDEServer {
const address = (this.server as HTTPServer).address();
if (address && typeof address !== 'string') {
this.port = address.port;
this.portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${this.port}.json`,
);
this.ppidPortFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${process.ppid}.json`,
try {
const ideDir = await getGlobalIdeDir();
// Name the lock file by port to support multiple server instances
// under the same parent process.
this.lockFile = path.join(
ideDir,
`${process.ppid}-${this.port}.lock`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.log(`Failed to determine IDE lock directory: ${message}`);
}
this.log(`IDE server listening on http://127.0.0.1:${this.port}`);
if (this.authToken) {
if (this.authToken && this.lockFile) {
await writePortAndWorkspace({
context,
port: this.port,
portFile: this.portFile,
ppidPortFile: this.ppidPortFile,
lockFile: this.lockFile,
authToken: this.authToken,
log: this.log,
});
@@ -371,15 +385,13 @@ export class IDEServer {
this.context &&
this.server &&
this.port &&
this.portFile &&
this.ppidPortFile &&
this.lockFile &&
this.authToken
) {
await writePortAndWorkspace({
context: this.context,
port: this.port,
portFile: this.portFile,
ppidPortFile: this.ppidPortFile,
lockFile: this.lockFile,
authToken: this.authToken,
log: this.log,
});
@@ -405,16 +417,9 @@ export class IDEServer {
if (this.context) {
this.context.environmentVariableCollection.clear();
}
if (this.portFile) {
if (this.lockFile) {
try {
await fs.unlink(this.portFile);
} catch (_err) {
// Ignore errors if the file doesn't exist.
}
}
if (this.ppidPortFile) {
try {
await fs.unlink(this.ppidPortFile);
await fs.unlink(this.lockFile);
} catch (_err) {
// Ignore errors if the file doesn't exist.
}

View File

@@ -48,5 +48,5 @@
}
.assistant-message-container.assistant-message-loading::after {
display: none
display: none;
}

View File

@@ -172,7 +172,8 @@
/* Loading animation for toolcall header */
@keyframes toolcallHeaderPulse {
0%, 100% {
0%,
100% {
opacity: 1;
}
50% {

View File

@@ -51,7 +51,8 @@
.composer-form:focus-within {
/* match existing highlight behavior */
border-color: var(--app-input-highlight);
box-shadow: 0 1px 2px color-mix(in srgb, var(--app-input-highlight), transparent 80%);
box-shadow: 0 1px 2px
color-mix(in srgb, var(--app-input-highlight), transparent 80%);
}
/* Composer: input editable area */
@@ -66,7 +67,7 @@
The data attribute is needed because some browsers insert a <br> in
contentEditable, which breaks :empty matching. */
.composer-input:empty:before,
.composer-input[data-empty="true"]::before {
.composer-input[data-empty='true']::before {
content: attr(data-placeholder);
color: var(--app-input-placeholder-foreground);
pointer-events: none;
@@ -80,7 +81,7 @@
outline: none;
}
.composer-input:disabled,
.composer-input[contenteditable="false"] {
.composer-input[contenteditable='false'] {
color: #999;
cursor: not-allowed;
}