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

@@ -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,19 +180,24 @@ export class IDEServer {
app.use((req, res, next) => {
const authHeader = req.headers.authorization;
if (authHeader) {
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
this.log('Malformed Authorization header. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
const token = parts[1];
if (token !== this.authToken) {
this.log('Invalid auth token provided. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
if (!authHeader) {
this.log('Missing Authorization header. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
this.log('Malformed Authorization header. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
const token = parts[1];
if (token !== this.authToken) {
this.log('Invalid auth token provided. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
next();
});
@@ -327,22 +338,25 @@ export class IDEServer {
const address = (this.server as HTTPServer).address();
if (address && typeof address !== 'string') {
this.port = address.port;
this.portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${this.port}.json`,
);
this.ppidPortFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${process.ppid}.json`,
);
try {
const ideDir = await getGlobalIdeDir();
// Name the lock file by port to support multiple server instances
// under the same parent process.
this.lockFile = path.join(
ideDir,
`${process.ppid}-${this.port}.lock`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.log(`Failed to determine IDE lock directory: ${message}`);
}
this.log(`IDE server listening on http://127.0.0.1:${this.port}`);
if (this.authToken) {
if (this.authToken && this.lockFile) {
await writePortAndWorkspace({
context,
port: this.port,
portFile: this.portFile,
ppidPortFile: this.ppidPortFile,
lockFile: this.lockFile,
authToken: this.authToken,
log: this.log,
});
@@ -371,15 +385,13 @@ export class IDEServer {
this.context &&
this.server &&
this.port &&
this.portFile &&
this.ppidPortFile &&
this.lockFile &&
this.authToken
) {
await writePortAndWorkspace({
context: this.context,
port: this.port,
portFile: this.portFile,
ppidPortFile: this.ppidPortFile,
lockFile: this.lockFile,
authToken: this.authToken,
log: this.log,
});
@@ -405,16 +417,9 @@ export class IDEServer {
if (this.context) {
this.context.environmentVariableCollection.clear();
}
if (this.portFile) {
if (this.lockFile) {
try {
await fs.unlink(this.portFile);
} catch (_err) {
// Ignore errors if the file doesn't exist.
}
}
if (this.ppidPortFile) {
try {
await fs.unlink(this.ppidPortFile);
await fs.unlink(this.lockFile);
} catch (_err) {
// Ignore errors if the file doesn't exist.
}

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