Skip to content

Commit 425baa3

Browse files
committed
Merge branch 'main' into refactor/tidy-up
2 parents 0d8b483 + 7d769ac commit 425baa3

5 files changed

Lines changed: 131 additions & 20 deletions

File tree

src/mcp/mcp-client.ts

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { spawn, type ChildProcess } from "child_process";
22
import { createInterface, type Interface } from "readline";
3-
import * as os from "os";
43
import * as path from "path";
54
import { killProcessTree } from "../common/process-tree";
65

@@ -97,6 +96,13 @@ type ReadResourceResult = {
9796

9897
export type McpNotificationHandler = (method: string, params?: Record<string, unknown>) => void;
9998

99+
export type McpSpawnSpec = {
100+
command: string;
101+
args: string[];
102+
shell: boolean;
103+
windowsHide?: boolean;
104+
};
105+
100106
export class McpClient {
101107
private process: ChildProcess | null = null;
102108
private reader: Interface | null = null;
@@ -130,25 +136,14 @@ export class McpClient {
130136
...this.env,
131137
};
132138
const args = this.withNpxYesArg(this.command, this.args);
139+
const spawnSpec = createMcpSpawnSpec(this.command, args);
133140

134-
const isWindows = os.platform() === "win32";
135-
136-
if (isWindows) {
137-
// On Windows, shell: true lets cmd.exe resolve the command via
138-
// PATHEXT (npx → npx.cmd, etc.) without blindly appending .cmd,
139-
// which would break absolute paths like process.execPath.
140-
this.process = spawn(this.command, args, {
141-
stdio: ["pipe", "pipe", "pipe"],
142-
env: childEnv,
143-
shell: true,
144-
windowsHide: true,
145-
});
146-
} else {
147-
this.process = spawn(this.command, args, {
148-
stdio: ["pipe", "pipe", "pipe"],
149-
env: childEnv,
150-
});
151-
}
141+
this.process = spawn(spawnSpec.command, spawnSpec.args, {
142+
stdio: ["pipe", "pipe", "pipe"],
143+
env: childEnv,
144+
shell: spawnSpec.shell,
145+
windowsHide: spawnSpec.windowsHide,
146+
});
152147

153148
let resolved = false;
154149
const safeReject = (err: Error) => {
@@ -421,3 +416,31 @@ export class McpClient {
421416
return new Error(stderr ? `${message}. stderr: ${stderr}` : message);
422417
}
423418
}
419+
420+
export function createMcpSpawnSpec(
421+
command: string,
422+
args: string[],
423+
platform: NodeJS.Platform = process.platform
424+
): McpSpawnSpec {
425+
if (platform === "win32") {
426+
return {
427+
// On Windows, shell: true lets cmd.exe resolve the command via PATHEXT
428+
// (npx -> npx.cmd, etc.). Pass one quoted command line with no spawn
429+
// args to avoid Node 24 DEP0190.
430+
command: [command, ...args].map(quoteWindowsShellArg).join(" "),
431+
args: [],
432+
shell: true,
433+
windowsHide: true,
434+
};
435+
}
436+
437+
return {
438+
command,
439+
args,
440+
shell: false,
441+
};
442+
}
443+
444+
function quoteWindowsShellArg(arg: string): string {
445+
return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/\\+$/g, "$&$&")}"`;
446+
}

src/session.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2730,7 +2730,8 @@ ${skillMd}
27302730
status === "waiting_for_user" ||
27312731
status === "completed" ||
27322732
status === "interrupted" ||
2733-
status === "ask_permission"
2733+
status === "ask_permission" ||
2734+
status === "permission_denied"
27342735
) {
27352736
return status;
27362737
}

src/tests/mcp-client.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { createMcpSpawnSpec } from "../mcp/mcp-client";
4+
5+
test("createMcpSpawnSpec keeps non-Windows MCP launches shell-free", () => {
6+
assert.deepEqual(createMcpSpawnSpec("npx", ["-y", "@playwright/mcp@latest"], "darwin"), {
7+
command: "npx",
8+
args: ["-y", "@playwright/mcp@latest"],
9+
shell: false,
10+
});
11+
});
12+
13+
test("createMcpSpawnSpec avoids Windows shell args for Node 24", () => {
14+
assert.deepEqual(createMcpSpawnSpec("npx", ["-y", "@playwright/mcp@latest"], "win32"), {
15+
command: '"npx" "-y" "@playwright/mcp@latest"',
16+
args: [],
17+
shell: true,
18+
windowsHide: true,
19+
});
20+
});
21+
22+
test("createMcpSpawnSpec quotes Windows command paths and arguments", () => {
23+
const spec = createMcpSpawnSpec(
24+
String.raw`C:\Program Files\nodejs\node.exe`,
25+
[String.raw`C:\tmp\mcp server.cjs`, 'a "quoted" value'],
26+
"win32"
27+
);
28+
29+
assert.equal(
30+
spec.command,
31+
String.raw`"C:\Program Files\nodejs\node.exe" "C:\tmp\mcp server.cjs" "a \"quoted\" value"`
32+
);
33+
assert.deepEqual(spec.args, []);
34+
});

src/tests/session.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,6 +1313,57 @@ test("activateSession pauses for permission when a tool call requires ask", asyn
13131313
);
13141314
});
13151315

1316+
test("SessionManager preserves permission_denied status when sessions are reloaded", async () => {
1317+
const workspace = createTempDir("deepcode-permission-denied-workspace-");
1318+
const home = createTempDir("deepcode-permission-denied-home-");
1319+
setHomeDir(home);
1320+
1321+
const permissions = {
1322+
allow: [],
1323+
deny: [],
1324+
ask: [],
1325+
defaultMode: "askAll" as const,
1326+
};
1327+
const manager = createPermissionSessionManager(
1328+
workspace,
1329+
[
1330+
{
1331+
choices: [
1332+
{
1333+
message: {
1334+
content: "",
1335+
tool_calls: [
1336+
{
1337+
id: "call-bash",
1338+
type: "function",
1339+
function: {
1340+
name: "bash",
1341+
arguments: JSON.stringify({
1342+
command: "rg TODO src",
1343+
description: "Search TODO markers",
1344+
sideEffects: ["read-in-cwd"],
1345+
}),
1346+
},
1347+
},
1348+
],
1349+
},
1350+
},
1351+
],
1352+
},
1353+
],
1354+
permissions
1355+
);
1356+
1357+
const sessionId = await manager.createSession({ text: "search todos" });
1358+
manager.denySessionPermission(sessionId);
1359+
1360+
const reloadedManager = createPermissionSessionManager(workspace, [], permissions);
1361+
const reloadedSession = reloadedManager.getSession(sessionId);
1362+
1363+
assert.equal(reloadedSession?.status, "permission_denied");
1364+
assert.equal(reloadedSession?.failReason, "Permission denied by user");
1365+
});
1366+
13161367
test("replySession applies permission replies, runs pending tools, and stores always allow scopes", async () => {
13171368
const workspace = createTempDir("deepcode-permission-allow-workspace-");
13181369
const home = createTempDir("deepcode-permission-allow-home-");

src/tests/sessionList.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ test("formatSessionStatus maps status values to display labels", () => {
1818
assert.equal(formatSessionStatus("waiting_for_user"), "waiting");
1919
assert.equal(formatSessionStatus("failed"), "failed");
2020
assert.equal(formatSessionStatus("interrupted"), "stopped");
21+
assert.equal(formatSessionStatus("ask_permission"), "waiting");
22+
assert.equal(formatSessionStatus("permission_denied"), "denied");
2123
assert.equal(formatSessionStatus("unknown_status" as any), "unknown_status");
2224
});
2325

0 commit comments

Comments
 (0)