mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
IDE companion discovery: switch to ~/.qwen/ide lock files
This commit is contained in:
@@ -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.
|
- **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 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.
|
- **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: `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`
|
`${PID}-${PORT}.lock`
|
||||||
- `${PID}`: The process ID of the parent IDE process. Your plugin must determine this PID and include it in the filename.
|
- `${PID}`: The process ID of the parent IDE process.
|
||||||
- `${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:
|
||||||
|
|
||||||
@@ -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`).
|
- `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.
|
- **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
|
## II. The Context Interface
|
||||||
|
|
||||||
|
|||||||
@@ -46,12 +46,15 @@ vi.mock('node:fs', async (importOriginal) => {
|
|||||||
|
|
||||||
// Mock Storage from core
|
// Mock Storage from core
|
||||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
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 {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
Storage: {
|
Storage: {
|
||||||
getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'),
|
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.');
|
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.mkdirSync).toHaveBeenCalled();
|
||||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||||
@@ -371,7 +377,9 @@ describe('languageCommand', () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
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.');
|
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({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
@@ -514,7 +525,9 @@ describe('languageCommand', () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
content: expect.stringContaining('LLM output language rule file generated'),
|
content: expect.stringContaining(
|
||||||
|
'LLM output language rule file generated',
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,10 +338,7 @@ describe('OpenAIContentConverter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle tools without functionDeclarations', async () => {
|
it('should handle tools without functionDeclarations', async () => {
|
||||||
const emptyTools: Tool[] = [
|
const emptyTools: Tool[] = [{} as Tool, { functionDeclarations: [] }];
|
||||||
{} as Tool,
|
|
||||||
{ functionDeclarations: [] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await converter.convertGeminiToolsToOpenAI(emptyTools);
|
const result = await converter.convertGeminiToolsToOpenAI(emptyTools);
|
||||||
|
|
||||||
@@ -489,7 +486,10 @@ describe('OpenAIContentConverter', () => {
|
|||||||
const result = converter.convertGeminiToolParametersToOpenAI(params);
|
const result = converter.convertGeminiToolParametersToOpenAI(params);
|
||||||
const properties = result?.['properties'] as Record<string, unknown>;
|
const properties = result?.['properties'] as Record<string, unknown>;
|
||||||
const nested = properties?.['nested'] 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({
|
expect(nestedProperties?.['deep']).toEqual({
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
|
|||||||
@@ -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,6 +99,7 @@ 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));
|
||||||
(
|
(
|
||||||
@@ -109,7 +112,7 @@ describe('IdeClient', () => {
|
|||||||
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', '12345-8080.lock'),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||||
@@ -120,6 +123,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 stdio when stdio config is provided in file', async () => {
|
it('should connect using stdio when stdio config is provided in file', async () => {
|
||||||
@@ -263,7 +267,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,9 +282,10 @@ 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', '12345-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 () => {
|
||||||
@@ -300,17 +306,23 @@ 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 find and parse a single lock file matching the IDE pid', 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.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue(['qwen-code-ide-server-12345-123.json']);
|
).mockResolvedValueOnce(['12345-5678.lock']);
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
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({
|
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||||
isValid: true,
|
isValid: true,
|
||||||
});
|
});
|
||||||
@@ -324,7 +336,7 @@ 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('/home/test', '.qwen', 'ide', '12345-5678.lock'),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -338,25 +350,23 @@ describe('IdeClient', () => {
|
|||||||
port: '1111',
|
port: '1111',
|
||||||
workspacePath: '/invalid/workspace',
|
workspacePath: '/invalid/workspace',
|
||||||
};
|
};
|
||||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
|
||||||
new Error('not found'),
|
|
||||||
);
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([
|
).mockResolvedValueOnce(['12345-1111.lock', '12345-5678.lock']); // ~/.qwen/ide scan
|
||||||
'qwen-code-ide-server-12345-111.json',
|
vi.mocked(fs.promises.stat)
|
||||||
'qwen-code-ide-server-12345-222.json',
|
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
|
||||||
]);
|
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
|
||||||
vi.mocked(fs.promises.readFile)
|
vi.mocked(fs.promises.readFile)
|
||||||
.mockResolvedValueOnce(JSON.stringify(invalidConfig))
|
.mockResolvedValueOnce(JSON.stringify(invalidConfig))
|
||||||
.mockResolvedValueOnce(JSON.stringify(validConfig));
|
.mockResolvedValueOnce(JSON.stringify(validConfig));
|
||||||
|
|
||||||
const validateSpy = vi
|
const validateSpy = vi
|
||||||
.spyOn(IdeClient, 'validateWorkspacePath')
|
.spyOn(IdeClient, 'validateWorkspacePath')
|
||||||
.mockReturnValueOnce({ isValid: false })
|
.mockImplementation((ideWorkspacePath) => ({
|
||||||
.mockReturnValueOnce({ isValid: true });
|
isValid: ideWorkspacePath === '/test/workspace',
|
||||||
|
}));
|
||||||
|
|
||||||
const ideClient = await IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
const result = await (
|
const result = await (
|
||||||
@@ -379,17 +389,15 @@ describe('IdeClient', () => {
|
|||||||
it('should return the first valid config when multiple workspaces are valid', async () => {
|
it('should return the first valid config when multiple workspaces are valid', async () => {
|
||||||
const config1 = { port: '1111', workspacePath: '/test/workspace' };
|
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<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([
|
).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
|
||||||
'qwen-code-ide-server-12345-111.json',
|
// Make config1 "newer" so it wins when both are valid.
|
||||||
'qwen-code-ide-server-12345-222.json',
|
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)
|
vi.mocked(fs.promises.readFile)
|
||||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||||
.mockResolvedValueOnce(JSON.stringify(config2));
|
.mockResolvedValueOnce(JSON.stringify(config2));
|
||||||
@@ -413,15 +421,15 @@ describe('IdeClient', () => {
|
|||||||
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
|
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
|
||||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||||
new Error('not found'),
|
new Error('not found'),
|
||||||
);
|
); // For ~/.qwen/ide/<pid>-<port>.lock
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([
|
).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
|
||||||
'qwen-code-ide-server-12345-111.json',
|
vi.mocked(fs.promises.stat)
|
||||||
'qwen-code-ide-server-12345-222.json',
|
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
|
||||||
]);
|
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
|
||||||
vi.mocked(fs.promises.readFile)
|
vi.mocked(fs.promises.readFile)
|
||||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||||
.mockResolvedValueOnce(JSON.stringify(config2));
|
.mockResolvedValueOnce(JSON.stringify(config2));
|
||||||
@@ -442,17 +450,14 @@ describe('IdeClient', () => {
|
|||||||
|
|
||||||
it('should handle invalid JSON in one of the config files', async () => {
|
it('should handle invalid JSON in one of the config files', async () => {
|
||||||
const validConfig = { port: '2222', workspacePath: '/test/workspace' };
|
const validConfig = { port: '2222', workspacePath: '/test/workspace' };
|
||||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
|
||||||
new Error('not found'),
|
|
||||||
);
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([
|
).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
|
||||||
'qwen-code-ide-server-12345-111.json',
|
vi.mocked(fs.promises.stat)
|
||||||
'qwen-code-ide-server-12345-222.json',
|
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats)
|
||||||
]);
|
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats);
|
||||||
vi.mocked(fs.promises.readFile)
|
vi.mocked(fs.promises.readFile)
|
||||||
.mockResolvedValueOnce('invalid json')
|
.mockResolvedValueOnce('invalid json')
|
||||||
.mockResolvedValueOnce(JSON.stringify(validConfig));
|
.mockResolvedValueOnce(JSON.stringify(validConfig));
|
||||||
@@ -474,9 +479,11 @@ describe('IdeClient', () => {
|
|||||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||||
new Error('not found'),
|
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 ideClient = await IdeClient.getInstance();
|
||||||
const result = await (
|
const result = await (
|
||||||
@@ -490,18 +497,19 @@ describe('IdeClient', () => {
|
|||||||
|
|
||||||
it('should ignore files with invalid names', async () => {
|
it('should ignore files with invalid names', async () => {
|
||||||
const validConfig = { port: '3333', workspacePath: '/test/workspace' };
|
const validConfig = { port: '3333', workspacePath: '/test/workspace' };
|
||||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
|
||||||
new Error('not found'),
|
|
||||||
);
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([
|
).mockResolvedValueOnce([
|
||||||
'qwen-code-ide-server-12345-111.json', // valid
|
'12345-3333.lock', // valid
|
||||||
'not-a-config-file.txt', // invalid
|
'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(
|
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
|
||||||
JSON.stringify(validConfig),
|
JSON.stringify(validConfig),
|
||||||
);
|
);
|
||||||
@@ -518,11 +526,11 @@ describe('IdeClient', () => {
|
|||||||
|
|
||||||
expect(result).toEqual(validConfig);
|
expect(result).toEqual(validConfig);
|
||||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
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',
|
'utf8',
|
||||||
);
|
);
|
||||||
expect(fs.promises.readFile).not.toHaveBeenCalledWith(
|
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',
|
'utf8',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -538,10 +546,10 @@ describe('IdeClient', () => {
|
|||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([
|
).mockResolvedValueOnce(['12345-1111.lock', '12345-3333.lock']); // ~/.qwen/ide scan
|
||||||
'qwen-code-ide-server-12345-111.json',
|
vi.mocked(fs.promises.stat)
|
||||||
'qwen-code-ide-server-12345-222.json',
|
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
|
||||||
]);
|
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
|
||||||
vi.mocked(fs.promises.readFile)
|
vi.mocked(fs.promises.readFile)
|
||||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||||
.mockResolvedValueOnce(JSON.stringify(config2));
|
.mockResolvedValueOnce(JSON.stringify(config2));
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -576,7 +577,85 @@ export class IdeClient {
|
|||||||
return undefined;
|
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 lockFile = path.join(ideDir, `${idePid}-${portFromEnv}.lock`);
|
||||||
|
const lockFileContents = await fs.promises.readFile(lockFile, 'utf8');
|
||||||
|
return JSON.parse(lockFileContents);
|
||||||
|
} catch (_) {
|
||||||
|
// Fall through to scanning / legacy discovery.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileRegex = new RegExp(`^${idePid}-\\d+\\.lock$`);
|
||||||
|
const lockFiles = (await fs.promises.readdir(ideDir)).filter((file) =>
|
||||||
|
fileRegex.test(file),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileContents = await Promise.all(
|
||||||
|
lockFiles.map(async (file) => {
|
||||||
|
const fullPath = path.join(ideDir, file);
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(fullPath);
|
||||||
|
const content = await fs.promises.readFile(fullPath, 'utf8');
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
return { file, mtimeMs: stat.mtimeMs, parsed };
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Failed to parse JSON from lock file: ', e);
|
||||||
|
return { file, mtimeMs: stat.mtimeMs, parsed: undefined };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If we can't stat/read the file, treat it as very old so it doesn't
|
||||||
|
// win ties, and skip parsing by returning undefined content.
|
||||||
|
logger.debug('Failed to read/stat IDE lock file:', e);
|
||||||
|
return { file, mtimeMs: -Infinity, parsed: undefined };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const validWorkspaces = fileContents
|
||||||
|
.filter(({ parsed }) => parsed !== undefined)
|
||||||
|
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||||
|
.map(({ parsed }) => parsed)
|
||||||
|
.filter((content) => {
|
||||||
|
const { isValid } = IdeClient.validateWorkspacePath(
|
||||||
|
content.workspacePath,
|
||||||
|
process.cwd(),
|
||||||
|
);
|
||||||
|
return isValid;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validWorkspaces.length > 0) {
|
||||||
|
if (validWorkspaces.length === 1) {
|
||||||
|
return validWorkspaces[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validWorkspaces.length > 1 && portFromEnv) {
|
||||||
|
const matchingPort = validWorkspaces.find(
|
||||||
|
(content) => String(content.port) === portFromEnv,
|
||||||
|
);
|
||||||
|
if (matchingPort) {
|
||||||
|
return matchingPort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validWorkspaces.length > 1) {
|
||||||
|
return validWorkspaces[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Fall through to legacy discovery mechanisms.
|
||||||
|
}
|
||||||
|
|
||||||
|
// For backwards compatability: single file in system temp dir named by PID.
|
||||||
try {
|
try {
|
||||||
const portFile = path.join(
|
const portFile = path.join(
|
||||||
os.tmpdir(),
|
os.tmpdir(),
|
||||||
@@ -585,85 +664,13 @@ 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');
|
return undefined;
|
||||||
let portFiles;
|
|
||||||
try {
|
|
||||||
portFiles = await fs.promises.readdir(portFileDir);
|
|
||||||
} catch (e) {
|
|
||||||
logger.debug('Failed to read IDE connection directory:', e);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!portFiles) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileRegex = new RegExp(
|
|
||||||
`^qwen-code-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
|
|
||||||
);
|
|
||||||
const matchingFiles = portFiles
|
|
||||||
.filter((file) => fileRegex.test(file))
|
|
||||||
.sort();
|
|
||||||
if (matchingFiles.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fileContents: string[];
|
|
||||||
try {
|
|
||||||
fileContents = await Promise.all(
|
|
||||||
matchingFiles.map((file) =>
|
|
||||||
fs.promises.readFile(path.join(portFileDir, file), 'utf8'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
logger.debug('Failed to read IDE connection config file(s):', e);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const parsedContents = fileContents.map((content) => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(content);
|
|
||||||
} catch (e) {
|
|
||||||
logger.debug('Failed to parse JSON from config file: ', e);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const validWorkspaces = parsedContents.filter((content) => {
|
|
||||||
if (!content) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const { isValid } = IdeClient.validateWorkspacePath(
|
|
||||||
content.workspacePath,
|
|
||||||
process.cwd(),
|
|
||||||
);
|
|
||||||
return isValid;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (validWorkspaces.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validWorkspaces.length === 1) {
|
|
||||||
return validWorkspaces[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const portFromEnv = this.getPortFromEnv();
|
|
||||||
if (portFromEnv) {
|
|
||||||
const matchingPort = validWorkspaces.find(
|
|
||||||
(content) => String(content.port) === portFromEnv,
|
|
||||||
);
|
|
||||||
if (matchingPort) {
|
|
||||||
return matchingPort;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return validWorkspaces[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private createProxyAwareFetch() {
|
private createProxyAwareFetch() {
|
||||||
|
|||||||
@@ -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,13 +129,11 @@ 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(
|
`${process.ppid}-${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),
|
||||||
@@ -143,15 +142,10 @@ describe('IDEServer', () => {
|
|||||||
authToken: 'test-auth-token',
|
authToken: 'test-auth-token',
|
||||||
});
|
});
|
||||||
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,13 +160,11 @@ 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(
|
`${process.ppid}-${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),
|
||||||
@@ -181,15 +173,10 @@ describe('IDEServer', () => {
|
|||||||
authToken: 'test-auth-token',
|
authToken: 'test-auth-token',
|
||||||
});
|
});
|
||||||
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,13 +191,11 @@ 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(
|
`${process.ppid}-${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),
|
||||||
@@ -219,15 +204,10 @@ describe('IDEServer', () => {
|
|||||||
authToken: 'test-auth-token',
|
authToken: 'test-auth-token',
|
||||||
});
|
});
|
||||||
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,13 +236,11 @@ 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(
|
`${process.ppid}-${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),
|
||||||
@@ -271,15 +249,10 @@ describe('IDEServer', () => {
|
|||||||
authToken: 'test-auth-token',
|
authToken: 'test-auth-token',
|
||||||
});
|
});
|
||||||
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' } }];
|
||||||
@@ -296,34 +269,28 @@ describe('IDEServer', () => {
|
|||||||
authToken: 'test-auth-token',
|
authToken: 'test-auth-token',
|
||||||
});
|
});
|
||||||
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(
|
||||||
const ppidPortFile = path.join(
|
'/home/test',
|
||||||
'/tmp',
|
'.qwen',
|
||||||
`qwen-code-ide-server-${process.ppid}.json`,
|
'ide',
|
||||||
|
`${process.ppid}-${port}.lock`,
|
||||||
);
|
);
|
||||||
expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String));
|
expect(fs.writeFile).toHaveBeenCalledWith(lockFile, 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,13 +311,11 @@ 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(
|
`${process.ppid}-${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),
|
||||||
@@ -359,15 +324,10 @@ describe('IDEServer', () => {
|
|||||||
authToken: 'test-auth-token',
|
authToken: 'test-auth-token',
|
||||||
});
|
});
|
||||||
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 +339,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 +350,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 +512,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' }),
|
||||||
|
|||||||
@@ -38,12 +38,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 +63,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> {
|
||||||
@@ -78,19 +89,15 @@ async function writePortAndWorkspace({
|
|||||||
authToken,
|
authToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
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 +128,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,19 +180,24 @@ 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) {
|
||||||
const parts = authHeader.split(' ');
|
this.log('Missing Authorization header. Rejecting request.');
|
||||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
res.status(401).send('Unauthorized');
|
||||||
this.log('Malformed Authorization header. Rejecting request.');
|
return;
|
||||||
res.status(401).send('Unauthorized');
|
}
|
||||||
return;
|
|
||||||
}
|
const parts = authHeader.split(' ');
|
||||||
const token = parts[1];
|
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||||
if (token !== this.authToken) {
|
this.log('Malformed Authorization header. Rejecting request.');
|
||||||
this.log('Invalid auth token provided. Rejecting request.');
|
res.status(401).send('Unauthorized');
|
||||||
res.status(401).send('Unauthorized');
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
const token = parts[1];
|
||||||
|
if (token !== this.authToken) {
|
||||||
|
this.log('Invalid auth token provided. Rejecting request.');
|
||||||
|
res.status(401).send('Unauthorized');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
@@ -327,22 +338,25 @@ 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
|
||||||
);
|
// under the same parent process.
|
||||||
this.ppidPortFile = path.join(
|
this.lockFile = path.join(
|
||||||
os.tmpdir(),
|
ideDir,
|
||||||
`qwen-code-ide-server-${process.ppid}.json`,
|
`${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}`);
|
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 +385,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 +417,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.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,5 +48,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assistant-message-container.assistant-message-loading::after {
|
.assistant-message-container.assistant-message-loading::after {
|
||||||
display: none
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,7 +172,8 @@
|
|||||||
|
|
||||||
/* Loading animation for toolcall header */
|
/* Loading animation for toolcall header */
|
||||||
@keyframes toolcallHeaderPulse {
|
@keyframes toolcallHeaderPulse {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
|
|||||||
@@ -51,7 +51,8 @@
|
|||||||
.composer-form:focus-within {
|
.composer-form:focus-within {
|
||||||
/* match existing highlight behavior */
|
/* match existing highlight behavior */
|
||||||
border-color: var(--app-input-highlight);
|
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 */
|
/* Composer: input editable area */
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
The data attribute is needed because some browsers insert a <br> in
|
The data attribute is needed because some browsers insert a <br> in
|
||||||
contentEditable, which breaks :empty matching. */
|
contentEditable, which breaks :empty matching. */
|
||||||
.composer-input:empty:before,
|
.composer-input:empty:before,
|
||||||
.composer-input[data-empty="true"]::before {
|
.composer-input[data-empty='true']::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
color: var(--app-input-placeholder-foreground);
|
color: var(--app-input-placeholder-foreground);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -80,7 +81,7 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.composer-input:disabled,
|
.composer-input:disabled,
|
||||||
.composer-input[contenteditable="false"] {
|
.composer-input[contenteditable='false'] {
|
||||||
color: #999;
|
color: #999;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user