@oh-my-pi/pi-coding-agent 17.0.1 → 17.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +162 -40
- package/dist/cli.js +4559 -4543
- package/dist/types/advisor/config.d.ts +14 -6
- package/dist/types/advisor/runtime.d.ts +20 -11
- package/dist/types/autolearn/controller.d.ts +1 -0
- package/dist/types/config/model-discovery.d.ts +17 -0
- package/dist/types/config/model-resolver.d.ts +6 -2
- package/dist/types/config/settings-schema.d.ts +39 -10
- package/dist/types/config/settings.d.ts +50 -0
- package/dist/types/cursor.d.ts +11 -0
- package/dist/types/discovery/helpers.d.ts +5 -2
- package/dist/types/eval/agent-bridge.d.ts +7 -19
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
- package/dist/types/extensibility/extensions/runner.d.ts +7 -0
- package/dist/types/extensibility/extensions/types.d.ts +18 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +55 -1
- package/dist/types/extensibility/shared-events.d.ts +6 -0
- package/dist/types/extensibility/utils.d.ts +2 -2
- package/dist/types/lsp/client.d.ts +2 -0
- package/dist/types/lsp/types.d.ts +3 -0
- package/dist/types/mcp/transports/stdio.d.ts +42 -1
- package/dist/types/modes/components/advisor-config.d.ts +8 -1
- package/dist/types/modes/components/agent-hub.d.ts +15 -0
- package/dist/types/modes/components/hook-editor.d.ts +7 -0
- package/dist/types/modes/components/model-hub.d.ts +5 -2
- package/dist/types/modes/components/session-selector.d.ts +2 -0
- package/dist/types/modes/controllers/command-controller.d.ts +8 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
- package/dist/types/modes/print-mode.d.ts +1 -1
- package/dist/types/modes/warp-events.d.ts +24 -0
- package/dist/types/modes/warp-events.test.d.ts +1 -0
- package/dist/types/plan-mode/model-transition.d.ts +47 -0
- package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
- package/dist/types/registry/agent-lifecycle.d.ts +26 -1
- package/dist/types/registry/persisted-agents.d.ts +3 -0
- package/dist/types/sdk.d.ts +27 -5
- package/dist/types/session/agent-session.d.ts +34 -10
- package/dist/types/session/session-entries.d.ts +6 -1
- package/dist/types/session/session-history-format.d.ts +10 -0
- package/dist/types/session/session-manager.d.ts +19 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
- package/dist/types/task/executor.d.ts +26 -1
- package/dist/types/task/index.d.ts +1 -1
- package/dist/types/task/label.d.ts +1 -1
- package/dist/types/task/parallel.d.ts +14 -0
- package/dist/types/task/structured-subagent.d.ts +111 -0
- package/dist/types/task/types.d.ts +51 -0
- package/dist/types/telemetry-export.d.ts +34 -9
- package/dist/types/tools/approval.d.ts +8 -0
- package/dist/types/tools/bash.d.ts +2 -0
- package/dist/types/tools/essential-tools.d.ts +29 -0
- package/dist/types/tools/fetch.d.ts +4 -5
- package/dist/types/tools/hub/messaging.d.ts +5 -1
- package/dist/types/tools/image-gen.d.ts +2 -1
- package/dist/types/tools/index.d.ts +17 -1
- package/dist/types/tools/todo.d.ts +31 -0
- package/dist/types/tools/xdev.d.ts +11 -2
- package/dist/types/tui/tree-list.d.ts +7 -0
- package/dist/types/utils/markit.d.ts +11 -0
- package/dist/types/utils/title-generator.d.ts +2 -1
- package/dist/types/web/search/providers/kimi.d.ts +4 -1
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +21 -16
- package/src/advisor/__tests__/advisor.test.ts +1304 -42
- package/src/advisor/__tests__/config.test.ts +58 -2
- package/src/advisor/config.ts +76 -24
- package/src/advisor/runtime.ts +445 -92
- package/src/autolearn/controller.ts +23 -28
- package/src/cli/file-processor.ts +1 -2
- package/src/cli.ts +5 -1
- package/src/config/model-discovery.ts +81 -21
- package/src/config/model-registry.ts +25 -6
- package/src/config/model-resolver.ts +14 -7
- package/src/config/settings-schema.ts +46 -9
- package/src/config/settings.ts +405 -25
- package/src/cursor.ts +20 -3
- package/src/debug/report-bundle.ts +40 -4
- package/src/discovery/helpers.ts +28 -5
- package/src/eval/__tests__/agent-bridge.test.ts +133 -47
- package/src/eval/__tests__/prelude-agent.test.ts +29 -0
- package/src/eval/agent-bridge.ts +104 -477
- package/src/eval/jl/prelude.jl +7 -6
- package/src/eval/js/shared/prelude.txt +5 -4
- package/src/eval/py/prelude.py +11 -39
- package/src/eval/rb/prelude.rb +5 -6
- package/src/exec/bash-executor.ts +14 -5
- package/src/extensibility/custom-tools/loader.ts +3 -3
- package/src/extensibility/custom-tools/wrapper.ts +2 -1
- package/src/extensibility/extensions/loader.ts +3 -3
- package/src/extensibility/extensions/managed-timers.ts +83 -0
- package/src/extensibility/extensions/runner.ts +26 -0
- package/src/extensibility/extensions/types.ts +18 -0
- package/src/extensibility/extensions/wrapper.ts +2 -1
- package/src/extensibility/hooks/loader.ts +3 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +149 -8
- package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
- package/src/extensibility/plugins/manager.ts +2 -2
- package/src/extensibility/shared-events.ts +6 -0
- package/src/extensibility/utils.ts +91 -25
- package/src/irc/bus.ts +22 -3
- package/src/launch/broker.ts +3 -2
- package/src/launch/client.ts +2 -2
- package/src/launch/presence.ts +2 -2
- package/src/lsp/client.ts +58 -1
- package/src/lsp/index.ts +62 -6
- package/src/lsp/types.ts +3 -0
- package/src/main.ts +11 -8
- package/src/mcp/manager.ts +9 -3
- package/src/mcp/oauth-flow.ts +20 -0
- package/src/mcp/transports/stdio.test.ts +269 -1
- package/src/mcp/transports/stdio.ts +255 -24
- package/src/modes/components/advisor-config.ts +65 -3
- package/src/modes/components/agent-hub.ts +1 -72
- package/src/modes/components/ask-dialog.ts +1 -1
- package/src/modes/components/bash-execution.ts +7 -3
- package/src/modes/components/eval-execution.ts +3 -1
- package/src/modes/components/hook-editor.ts +18 -3
- package/src/modes/components/model-hub.ts +138 -42
- package/src/modes/components/session-selector.ts +4 -0
- package/src/modes/components/status-line/component.test.ts +1 -0
- package/src/modes/components/status-line/segments.ts +21 -6
- package/src/modes/controllers/command-controller.ts +167 -47
- package/src/modes/controllers/event-controller.ts +5 -0
- package/src/modes/controllers/extension-ui-controller.ts +4 -22
- package/src/modes/controllers/input-controller.ts +12 -12
- package/src/modes/controllers/selector-controller.ts +191 -31
- package/src/modes/interactive-mode.ts +169 -62
- package/src/modes/print-mode.ts +3 -3
- package/src/modes/rpc/host-tools.ts +2 -1
- package/src/modes/rpc/rpc-mode.ts +19 -4
- package/src/modes/warp-events.test.ts +794 -0
- package/src/modes/warp-events.ts +232 -0
- package/src/plan-mode/model-transition.test.ts +60 -0
- package/src/plan-mode/model-transition.ts +51 -0
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/tools/eval.md +5 -2
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/task.md +12 -8
- package/src/prompts/tools/write.md +1 -1
- package/src/registry/agent-lifecycle.ts +133 -18
- package/src/registry/persisted-agents.ts +74 -0
- package/src/sdk.ts +343 -122
- package/src/session/agent-session.ts +1359 -368
- package/src/session/session-entries.ts +6 -1
- package/src/session/session-history-format.ts +20 -5
- package/src/session/session-manager.ts +57 -0
- package/src/session/streaming-output.ts +41 -1
- package/src/slash-commands/builtin-registry.ts +7 -0
- package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
- package/src/system-prompt.ts +7 -2
- package/src/task/executor.ts +100 -22
- package/src/task/index.ts +258 -429
- package/src/task/label.ts +2 -0
- package/src/task/parallel.ts +43 -0
- package/src/task/persisted-revive.ts +19 -7
- package/src/task/structured-subagent.ts +642 -0
- package/src/task/types.ts +58 -0
- package/src/telemetry-export.ts +453 -97
- package/src/tools/__tests__/eval-description.test.ts +1 -1
- package/src/tools/approval.ts +11 -0
- package/src/tools/bash.ts +71 -38
- package/src/tools/essential-tools.ts +45 -0
- package/src/tools/fetch.ts +28 -105
- package/src/tools/gh.ts +169 -2
- package/src/tools/hub/messaging.ts +16 -3
- package/src/tools/image-gen.ts +69 -7
- package/src/tools/index.ts +50 -15
- package/src/tools/path-utils.ts +1 -0
- package/src/tools/read.ts +62 -29
- package/src/tools/todo.ts +126 -13
- package/src/tools/write.ts +22 -4
- package/src/tools/xdev.ts +14 -3
- package/src/tui/tree-list.ts +39 -0
- package/src/utils/markit.ts +12 -0
- package/src/utils/title-generator.ts +15 -4
- package/src/utils/zip.ts +16 -1
- package/src/web/search/providers/kimi.ts +18 -12
- package/src/web/search/types.ts +6 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it, spyOn } from "bun:test";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
2
5
|
|
|
3
|
-
import { resolveStdioSpawnCommand, StdioTransport } from "./stdio";
|
|
6
|
+
import { resolveStdioSpawnCommand, StdioTransport, terminateStdioProcess } from "./stdio";
|
|
4
7
|
|
|
5
8
|
describe("resolveStdioSpawnCommand", () => {
|
|
6
9
|
it("hides Windows executable MCP servers when the host has no console", async () => {
|
|
@@ -149,3 +152,268 @@ describe.skipIf(process.platform === "win32")("StdioTransport request write stal
|
|
|
149
152
|
}
|
|
150
153
|
}, 8000);
|
|
151
154
|
});
|
|
155
|
+
|
|
156
|
+
function processExists(pid: number): boolean {
|
|
157
|
+
try {
|
|
158
|
+
process.kill(pid, 0);
|
|
159
|
+
return true;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Regression for #5578: `close()` used a bare `this.#process.kill()` (direct
|
|
166
|
+
// SIGTERM, no wait, no escalate, no process-group signal), so a detached
|
|
167
|
+
// session-leader child (or a grandchild it spawned) that ignores/traps
|
|
168
|
+
// SIGTERM survived host exit and became an orphan pinned to PID 1 on Linux.
|
|
169
|
+
// `sleep`/`bun`/POSIX signal semantics are exercised directly here rather
|
|
170
|
+
// than through `StdioTransport.connect()`, because `connect()` derives
|
|
171
|
+
// `detached` from `resolveStdioSpawnCommand()`, which is tied to the host's
|
|
172
|
+
// real `process.platform` — a POSIX detached session cannot be reproduced
|
|
173
|
+
// end-to-end through `connect()` on a non-Linux dev/CI host, but a real
|
|
174
|
+
// detached process group can still be spawned directly on any POSIX host.
|
|
175
|
+
describe.skipIf(process.platform === "win32")("terminateStdioProcess", () => {
|
|
176
|
+
it("escalates a detached child that traps SIGTERM to SIGKILL", async () => {
|
|
177
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-kill-solo-"));
|
|
178
|
+
const scriptPath = path.join(tempDir, "child.mjs");
|
|
179
|
+
const readyPath = path.join(tempDir, "ready");
|
|
180
|
+
await fs.writeFile(
|
|
181
|
+
scriptPath,
|
|
182
|
+
[
|
|
183
|
+
"import { writeFileSync } from 'node:fs';",
|
|
184
|
+
"process.on('SIGTERM', () => {});",
|
|
185
|
+
`writeFileSync(${JSON.stringify(readyPath)}, '1');`,
|
|
186
|
+
"setInterval(() => {}, 60_000);",
|
|
187
|
+
].join("\n"),
|
|
188
|
+
);
|
|
189
|
+
const proc = Bun.spawn(["bun", "run", scriptPath], {
|
|
190
|
+
stdin: "ignore",
|
|
191
|
+
stdout: "ignore",
|
|
192
|
+
stderr: "ignore",
|
|
193
|
+
detached: true,
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
// Wait for the child to actually register its SIGTERM handler before
|
|
197
|
+
// signaling it: signaling too early races the child's startup and
|
|
198
|
+
// hits the default (terminate) action instead of exercising the trap.
|
|
199
|
+
for (let i = 0; i < 100; i++) {
|
|
200
|
+
try {
|
|
201
|
+
await fs.access(readyPath);
|
|
202
|
+
break;
|
|
203
|
+
} catch {
|
|
204
|
+
await Bun.sleep(20);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const started = performance.now();
|
|
209
|
+
await terminateStdioProcess(proc, true);
|
|
210
|
+
await proc.exited;
|
|
211
|
+
const elapsedMs = performance.now() - started;
|
|
212
|
+
|
|
213
|
+
expect(proc.signalCode).toBe("SIGKILL");
|
|
214
|
+
// Escalation only fires after the ~1s SIGTERM grace window elapses —
|
|
215
|
+
// a too-fast exit would mean SIGKILL fired without waiting.
|
|
216
|
+
expect(elapsedMs).toBeGreaterThanOrEqual(900);
|
|
217
|
+
} finally {
|
|
218
|
+
try {
|
|
219
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
220
|
+
} catch {
|
|
221
|
+
// Already gone.
|
|
222
|
+
}
|
|
223
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
}, 5000);
|
|
226
|
+
|
|
227
|
+
it("reaches a SIGTERM-trapping grandchild through the group SIGKILL, not just the direct child", async () => {
|
|
228
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-group-kill-"));
|
|
229
|
+
const grandchildScriptPath = path.join(tempDir, "grandchild.mjs");
|
|
230
|
+
const parentScriptPath = path.join(tempDir, "parent.mjs");
|
|
231
|
+
const grandchildPidPath = path.join(tempDir, "grandchild.pid");
|
|
232
|
+
|
|
233
|
+
// Grandchild also traps SIGTERM, so only an unrestricted SIGKILL to the
|
|
234
|
+
// whole group — not a signal to the direct (parent) child alone — can
|
|
235
|
+
// reach and stop it.
|
|
236
|
+
await fs.writeFile(
|
|
237
|
+
grandchildScriptPath,
|
|
238
|
+
[
|
|
239
|
+
"import { writeFileSync } from 'node:fs';",
|
|
240
|
+
"process.on('SIGTERM', () => {});",
|
|
241
|
+
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(process.pid));`,
|
|
242
|
+
"setInterval(() => {}, 60_000);",
|
|
243
|
+
].join("\n"),
|
|
244
|
+
);
|
|
245
|
+
await fs.writeFile(
|
|
246
|
+
parentScriptPath,
|
|
247
|
+
[
|
|
248
|
+
"process.on('SIGTERM', () => {});",
|
|
249
|
+
`Bun.spawn(["bun", "run", ${JSON.stringify(grandchildScriptPath)}], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });`,
|
|
250
|
+
"setInterval(() => {}, 60_000);",
|
|
251
|
+
].join("\n"),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const proc = Bun.spawn(["bun", "run", parentScriptPath], {
|
|
255
|
+
stdin: "ignore",
|
|
256
|
+
stdout: "ignore",
|
|
257
|
+
stderr: "ignore",
|
|
258
|
+
detached: true,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
// Polls real wall-clock time rather than an event/promise: the
|
|
263
|
+
// grandchild process is a real external OS process writing to a real
|
|
264
|
+
// file, with no in-process signal this test can `await` directly.
|
|
265
|
+
let grandchildPid: number | undefined;
|
|
266
|
+
for (let i = 0; i < 100 && grandchildPid === undefined; i++) {
|
|
267
|
+
try {
|
|
268
|
+
grandchildPid = Number.parseInt(await fs.readFile(grandchildPidPath, "utf8"), 10);
|
|
269
|
+
} catch {
|
|
270
|
+
await Bun.sleep(20);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (grandchildPid === undefined) throw new Error("grandchild never reported its pid");
|
|
274
|
+
expect(processExists(grandchildPid)).toBe(true);
|
|
275
|
+
|
|
276
|
+
await terminateStdioProcess(proc, true);
|
|
277
|
+
await proc.exited;
|
|
278
|
+
expect(proc.signalCode).toBe("SIGKILL");
|
|
279
|
+
|
|
280
|
+
// The group SIGKILL is delivered to every member simultaneously, but
|
|
281
|
+
// give the kernel a brief window to finish reaping before asserting.
|
|
282
|
+
let grandchildAlive = processExists(grandchildPid);
|
|
283
|
+
for (let i = 0; i < 25 && grandchildAlive; i++) {
|
|
284
|
+
await Bun.sleep(20);
|
|
285
|
+
grandchildAlive = processExists(grandchildPid);
|
|
286
|
+
}
|
|
287
|
+
expect(grandchildAlive).toBe(false);
|
|
288
|
+
} finally {
|
|
289
|
+
try {
|
|
290
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
291
|
+
} catch {
|
|
292
|
+
// Already gone.
|
|
293
|
+
}
|
|
294
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
295
|
+
}
|
|
296
|
+
}, 8000);
|
|
297
|
+
|
|
298
|
+
it("still reaps a SIGTERM-trapping grandchild after the detached leader exits cooperatively", async () => {
|
|
299
|
+
// Regression: the leader exiting within the SIGTERM grace window used to
|
|
300
|
+
// be treated as proof the whole process group was gone, so `close()`
|
|
301
|
+
// returned early and never delivered a group SIGKILL — leaving exactly
|
|
302
|
+
// the orphaned grandchild this change is meant to reap. Unlike the
|
|
303
|
+
// group-SIGKILL test above, the leader here does NOT trap SIGTERM, so
|
|
304
|
+
// it exits promptly on its own; only the grandchild ignores signals.
|
|
305
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-leader-exit-group-kill-"));
|
|
306
|
+
const grandchildScriptPath = path.join(tempDir, "grandchild.mjs");
|
|
307
|
+
const parentScriptPath = path.join(tempDir, "parent.mjs");
|
|
308
|
+
const grandchildPidPath = path.join(tempDir, "grandchild.pid");
|
|
309
|
+
|
|
310
|
+
await fs.writeFile(
|
|
311
|
+
grandchildScriptPath,
|
|
312
|
+
[
|
|
313
|
+
"import { writeFileSync } from 'node:fs';",
|
|
314
|
+
"process.on('SIGTERM', () => {});",
|
|
315
|
+
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(process.pid));`,
|
|
316
|
+
"setInterval(() => {}, 60_000);",
|
|
317
|
+
].join("\n"),
|
|
318
|
+
);
|
|
319
|
+
// No SIGTERM handler here: the default action (terminate) fires as soon
|
|
320
|
+
// as the group SIGTERM lands, well inside TERM_GRACE_MS.
|
|
321
|
+
await fs.writeFile(
|
|
322
|
+
parentScriptPath,
|
|
323
|
+
[
|
|
324
|
+
`Bun.spawn(["bun", "run", ${JSON.stringify(grandchildScriptPath)}], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });`,
|
|
325
|
+
"setInterval(() => {}, 60_000);",
|
|
326
|
+
].join("\n"),
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const proc = Bun.spawn(["bun", "run", parentScriptPath], {
|
|
330
|
+
stdin: "ignore",
|
|
331
|
+
stdout: "ignore",
|
|
332
|
+
stderr: "ignore",
|
|
333
|
+
detached: true,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
let grandchildPid: number | undefined;
|
|
338
|
+
for (let i = 0; i < 100 && grandchildPid === undefined; i++) {
|
|
339
|
+
try {
|
|
340
|
+
grandchildPid = Number.parseInt(await fs.readFile(grandchildPidPath, "utf8"), 10);
|
|
341
|
+
} catch {
|
|
342
|
+
await Bun.sleep(20);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (grandchildPid === undefined) throw new Error("grandchild never reported its pid");
|
|
346
|
+
expect(processExists(grandchildPid)).toBe(true);
|
|
347
|
+
|
|
348
|
+
const started = performance.now();
|
|
349
|
+
await terminateStdioProcess(proc, true);
|
|
350
|
+
await proc.exited;
|
|
351
|
+
const elapsedMs = performance.now() - started;
|
|
352
|
+
|
|
353
|
+
// The leader exits on the initial SIGTERM (no trap), so this must not
|
|
354
|
+
// block for the ~1s TERM grace window before sweeping the group.
|
|
355
|
+
expect(elapsedMs).toBeLessThan(700);
|
|
356
|
+
|
|
357
|
+
let grandchildAlive = processExists(grandchildPid);
|
|
358
|
+
for (let i = 0; i < 25 && grandchildAlive; i++) {
|
|
359
|
+
await Bun.sleep(20);
|
|
360
|
+
grandchildAlive = processExists(grandchildPid);
|
|
361
|
+
}
|
|
362
|
+
expect(grandchildAlive).toBe(false);
|
|
363
|
+
} finally {
|
|
364
|
+
try {
|
|
365
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
366
|
+
} catch {
|
|
367
|
+
// Already gone.
|
|
368
|
+
}
|
|
369
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
370
|
+
}
|
|
371
|
+
}, 8000);
|
|
372
|
+
|
|
373
|
+
it("never attempts a process-group signal when the transport did not spawn detached", async () => {
|
|
374
|
+
const proc = Bun.spawn(["bun", "-e", "await Bun.sleep(60_000)"], {
|
|
375
|
+
stdin: "ignore",
|
|
376
|
+
stdout: "ignore",
|
|
377
|
+
stderr: "ignore",
|
|
378
|
+
detached: false,
|
|
379
|
+
});
|
|
380
|
+
const killSpy = spyOn(process, "kill");
|
|
381
|
+
try {
|
|
382
|
+
await terminateStdioProcess(proc, false);
|
|
383
|
+
await proc.exited;
|
|
384
|
+
|
|
385
|
+
// Only the direct-child `Subprocess.kill()` path may run; the global
|
|
386
|
+
// `process.kill()` (used exclusively for the negative-pid group
|
|
387
|
+
// signal) must never be reached.
|
|
388
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
389
|
+
} finally {
|
|
390
|
+
killSpy.mockRestore();
|
|
391
|
+
try {
|
|
392
|
+
proc.kill("SIGKILL");
|
|
393
|
+
} catch {
|
|
394
|
+
// Already gone.
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}, 5000);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe.skipIf(process.platform === "win32")("StdioTransport.close teardown", () => {
|
|
401
|
+
it("closes a well-behaved child promptly without escalating to SIGKILL", async () => {
|
|
402
|
+
const transport = new StdioTransport({
|
|
403
|
+
command: "bun",
|
|
404
|
+
args: ["-e", "await Bun.sleep(60_000)"],
|
|
405
|
+
});
|
|
406
|
+
try {
|
|
407
|
+
await transport.connect();
|
|
408
|
+
const started = performance.now();
|
|
409
|
+
await transport.close();
|
|
410
|
+
const elapsedMs = performance.now() - started;
|
|
411
|
+
|
|
412
|
+
// No SIGTERM trap => the child dies almost immediately; close() must
|
|
413
|
+
// not block for the full ~1s TERM grace window before returning.
|
|
414
|
+
expect(elapsedMs).toBeLessThan(700);
|
|
415
|
+
} finally {
|
|
416
|
+
await transport.close();
|
|
417
|
+
}
|
|
418
|
+
}, 5000);
|
|
419
|
+
});
|
|
@@ -54,6 +54,18 @@ export interface StdioSpawnCommand {
|
|
|
54
54
|
* grandchildren keep stdout routed through our pipe (#3544).
|
|
55
55
|
*/
|
|
56
56
|
detached: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Pass argv to `Bun.spawn` verbatim (Windows only), suppressing the
|
|
59
|
+
* default libuv backslash-quoting.
|
|
60
|
+
*
|
|
61
|
+
* Set when `cmd` already holds a `cmd.exe /d /e:ON /v:OFF /c "<line>"`
|
|
62
|
+
* command line escaped for `cmd.exe`'s parser (see `buildCmdExeArgv`).
|
|
63
|
+
* libuv's quoting targets `CommandLineToArgvW`, not `cmd.exe`, so letting
|
|
64
|
+
* it re-quote a batch launch would corrupt arguments and re-open the
|
|
65
|
+
* `%VAR%` / quote-injection holes the escaping closes (BatBadBut,
|
|
66
|
+
* CVE-2024-24576).
|
|
67
|
+
*/
|
|
68
|
+
windowsVerbatimArguments?: boolean;
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
/** Inputs used to resolve platform-specific stdio spawn behavior. */
|
|
@@ -203,23 +215,6 @@ async function resolveWindowsNpmShimCommand(
|
|
|
203
215
|
};
|
|
204
216
|
}
|
|
205
217
|
|
|
206
|
-
function quoteCmdArg(value: string): string {
|
|
207
|
-
if (value.length === 0) return '""';
|
|
208
|
-
let result = '"';
|
|
209
|
-
for (const char of value) {
|
|
210
|
-
if (char === '"') {
|
|
211
|
-
result += '^"';
|
|
212
|
-
} else if (char === "^") {
|
|
213
|
-
result += "^^";
|
|
214
|
-
} else if (char === "%") {
|
|
215
|
-
result += "^%";
|
|
216
|
-
} else {
|
|
217
|
-
result += char;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return `${result}"`;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
218
|
function isWindowsBatchCommand(command: string): boolean {
|
|
224
219
|
return WINDOWS_BATCH_EXTENSIONS.has(path.extname(command).toLowerCase());
|
|
225
220
|
}
|
|
@@ -229,10 +224,93 @@ function resolveComSpec(env: Record<string, string | undefined>): string {
|
|
|
229
224
|
return comspec && comspec.length > 0 ? comspec : "cmd.exe";
|
|
230
225
|
}
|
|
231
226
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
227
|
+
// Argument bytes cmd.exe delivers unchanged without quoting. Anything outside
|
|
228
|
+
// this set (spaces, quotes, `%`, shell metacharacters, non-ASCII) forces the
|
|
229
|
+
// quoted+escaped path below. Mirrors the fuzz-tested allow-list from Zig's
|
|
230
|
+
// BatBadBut mitigation.
|
|
231
|
+
const CMD_SAFE_ARG = /^[A-Za-z0-9#$*+\-./:?@\\_]+$/;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Escape the interior of a `cmd.exe`-quoted token: neutralize `%VAR%` expansion
|
|
235
|
+
* and double any backslash run that precedes a quote (including the caller's
|
|
236
|
+
* closing quote) so `CommandLineToArgvW` delivers the backslashes literally.
|
|
237
|
+
*
|
|
238
|
+
* `cmd.exe` re-parses the whole `/c` string and expands `%…%` *before* the
|
|
239
|
+
* batch shim's own argv split runs, so both the command path and every argument
|
|
240
|
+
* must pass through this. Percent → `%%cd:~,%` (which expands to nothing,
|
|
241
|
+
* leaving a literal `%`) and `"` → `""` are the documented BatBadBut mitigation
|
|
242
|
+
* (CVE-2024-24576). The caller supplies the surrounding double quotes.
|
|
243
|
+
*
|
|
244
|
+
* @see https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/
|
|
245
|
+
*/
|
|
246
|
+
function escapeCmdQuotedInterior(value: string): string {
|
|
247
|
+
let out = "";
|
|
248
|
+
let backslashes = 0;
|
|
249
|
+
for (const ch of value) {
|
|
250
|
+
if (ch === "\\") {
|
|
251
|
+
backslashes += 1;
|
|
252
|
+
out += ch;
|
|
253
|
+
} else if (ch === '"') {
|
|
254
|
+
out += "\\".repeat(backslashes);
|
|
255
|
+
out += '""';
|
|
256
|
+
backslashes = 0;
|
|
257
|
+
} else if (ch === "%") {
|
|
258
|
+
out += "%%cd:~,%";
|
|
259
|
+
backslashes = 0;
|
|
260
|
+
} else {
|
|
261
|
+
backslashes = 0;
|
|
262
|
+
out += ch;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Double the trailing backslash run so it stays literal before the closing
|
|
266
|
+
// quote the caller appends.
|
|
267
|
+
out += "\\".repeat(backslashes);
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Reject bytes that cannot round-trip through `cmd.exe`'s `/c` command line. */
|
|
272
|
+
function assertCmdBatchToken(value: string, kind: "command" | "argument"): void {
|
|
273
|
+
// NUL/LF act as an end-of-command marker and CR is stripped, so any of them
|
|
274
|
+
// would silently truncate or corrupt the launch.
|
|
275
|
+
if (/[\0\r\n]/.test(value)) {
|
|
276
|
+
throw new Error(`Windows batch MCP ${kind} cannot contain NUL, CR, or LF characters`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Escape one argument for `cmd.exe`'s command-line pre-parse so a `.cmd`/`.bat`
|
|
282
|
+
* shim receives it verbatim. Quotes only when the argument is empty, ends in a
|
|
283
|
+
* backslash, or holds a byte outside {@link CMD_SAFE_ARG}; the quoted body is
|
|
284
|
+
* escaped by {@link escapeCmdQuotedInterior}.
|
|
285
|
+
*
|
|
286
|
+
* @throws when the argument contains NUL, CR, or LF (see {@link assertCmdBatchToken}).
|
|
287
|
+
*/
|
|
288
|
+
function escapeCmdBatchArg(arg: string): string {
|
|
289
|
+
assertCmdBatchToken(arg, "argument");
|
|
290
|
+
const needsQuotes = arg.length === 0 || arg.endsWith("\\") || !CMD_SAFE_ARG.test(arg);
|
|
291
|
+
// An unquoted arg is pure allow-list bytes (no `%`, `"`, or trailing `\`), so
|
|
292
|
+
// it needs no interior escaping.
|
|
293
|
+
return needsQuotes ? `"${escapeCmdQuotedInterior(arg)}"` : arg;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Build the `cmd.exe` argv for a Windows `.cmd`/`.bat` (or unresolved bare)
|
|
298
|
+
* MCP command.
|
|
299
|
+
*
|
|
300
|
+
* The trailing element is a single `/c` string wrapped in an outer quote pair
|
|
301
|
+
* that `cmd.exe` strips (its opening-quote rule). The command token is always
|
|
302
|
+
* quoted and, like every argument, escaped so a `%` in the resolved path (e.g.
|
|
303
|
+
* `C:\work\%TOKEN%\server.cmd`) is not expanded before the shim launches.
|
|
304
|
+
* `/e:ON` keeps command extensions on (required for the `%%cd:~,%` trick) and
|
|
305
|
+
* `/v:OFF` disables delayed expansion. The result MUST be spawned with
|
|
306
|
+
* `windowsVerbatimArguments` so libuv passes it through unmodified.
|
|
307
|
+
*/
|
|
308
|
+
function buildCmdExeArgv(comspec: string, command: string, args: readonly string[]): string[] {
|
|
309
|
+
assertCmdBatchToken(command, "command");
|
|
310
|
+
let line = `""${escapeCmdQuotedInterior(command)}"`;
|
|
311
|
+
for (const arg of args) line += ` ${escapeCmdBatchArg(arg)}`;
|
|
312
|
+
line += '"';
|
|
313
|
+
return [comspec, "/d", "/e:ON", "/v:OFF", "/c", line];
|
|
236
314
|
}
|
|
237
315
|
|
|
238
316
|
/**
|
|
@@ -245,7 +323,7 @@ function buildCmdExeCommand(command: string, args: readonly string[]): string {
|
|
|
245
323
|
* only appends `.exe` for extensionless names — `.cmd`/`.bat` are never
|
|
246
324
|
* tried, so `npx` (which exists only as `npx.cmd` on Windows) crashes the
|
|
247
325
|
* subprocess immediately. When the resolver can't pin the command down,
|
|
248
|
-
* route through `cmd.exe
|
|
326
|
+
* route through `cmd.exe` so Windows's own PATHEXT lookup runs.
|
|
249
327
|
*/
|
|
250
328
|
export async function resolveStdioSpawnCommand(
|
|
251
329
|
config: MCPStdioServerConfig,
|
|
@@ -271,9 +349,10 @@ export async function resolveStdioSpawnCommand(
|
|
|
271
349
|
if (!needsCmdExe) return { cmd: [resolvedCommand, ...args], windowsHide, detached };
|
|
272
350
|
|
|
273
351
|
return {
|
|
274
|
-
cmd:
|
|
352
|
+
cmd: buildCmdExeArgv(resolveComSpec(options.env), resolvedCommand, args),
|
|
275
353
|
windowsHide,
|
|
276
354
|
detached,
|
|
355
|
+
windowsVerbatimArguments: true,
|
|
277
356
|
};
|
|
278
357
|
}
|
|
279
358
|
|
|
@@ -325,6 +404,132 @@ export function writeFrame(stdin: FrameSink, frame: string): boolean {
|
|
|
325
404
|
}
|
|
326
405
|
}
|
|
327
406
|
|
|
407
|
+
/** Grace window to observe a cooperative exit after SIGTERM before escalating to SIGKILL. */
|
|
408
|
+
const TERM_GRACE_MS = 1000;
|
|
409
|
+
/** Grace window to observe SIGKILL taking effect before `close()` gives up and returns. */
|
|
410
|
+
const KILL_GRACE_MS = 500;
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The subset of `Subprocess` that termination needs. Decoupled from the
|
|
414
|
+
* `Subprocess<In, Out, Err>` stdio generics — `#process`'s pipes are
|
|
415
|
+
* irrelevant to signaling — so tests can exercise it against a plain
|
|
416
|
+
* `Bun.spawn(cmd, { stdio: "ignore" })` child without fighting the generics.
|
|
417
|
+
*/
|
|
418
|
+
interface KillableSubprocess {
|
|
419
|
+
readonly pid: number;
|
|
420
|
+
readonly exited: Promise<number>;
|
|
421
|
+
kill(signal?: number | NodeJS.Signals): void;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Race `exited` against a timer. Resolves `true` once the process has exited
|
|
426
|
+
* within `timeoutMs`, `false` if the timer wins first. `exited` resolving OR
|
|
427
|
+
* rejecting both count as "exited" — mirrors `waitForExit()` in
|
|
428
|
+
* `lsp/client.ts`, which treats the same ambiguity (Bun documents
|
|
429
|
+
* `Subprocess.exited` as resolve-only, but a settle either way means there is
|
|
430
|
+
* nothing left to wait on).
|
|
431
|
+
*
|
|
432
|
+
* The timer is always cleared before returning — win or lose — so a process
|
|
433
|
+
* that exits promptly never leaves a dangling `timeoutMs` timer holding the
|
|
434
|
+
* event loop open behind it.
|
|
435
|
+
*/
|
|
436
|
+
async function waitForProcessExit(exited: Promise<number>, timeoutMs: number): Promise<boolean> {
|
|
437
|
+
const { promise: timedOut, resolve: resolveTimedOut } = Promise.withResolvers<false>();
|
|
438
|
+
const timer = setTimeout(() => resolveTimedOut(false), timeoutMs);
|
|
439
|
+
try {
|
|
440
|
+
return await Promise.race([
|
|
441
|
+
exited.then(
|
|
442
|
+
() => true,
|
|
443
|
+
() => true,
|
|
444
|
+
),
|
|
445
|
+
timedOut,
|
|
446
|
+
]);
|
|
447
|
+
} finally {
|
|
448
|
+
clearTimeout(timer);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** `true` when `error` is a Node errno exception carrying the given `code`. */
|
|
453
|
+
function isErrnoCode(error: unknown, code: string): boolean {
|
|
454
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
|
455
|
+
return error.code === code;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Signal `signal` to `proc`. When `detached` is true on a POSIX platform,
|
|
460
|
+
* targets the whole process group via the negative-pid convention
|
|
461
|
+
* (`process.kill(-pid, signal)`) so a detached session leader's descendants —
|
|
462
|
+
* not just the direct child — receive it too; a bare direct-child signal
|
|
463
|
+
* never reaches grandchildren the child itself spawned.
|
|
464
|
+
*
|
|
465
|
+
* `ESRCH` from the group signal means the group is already gone — that is a
|
|
466
|
+
* success (nothing left to signal), not a failure — so it does not fall
|
|
467
|
+
* through. Any other group-signal failure (e.g. `EPERM`) falls back to
|
|
468
|
+
* signaling the direct child as a last resort. Non-detached transports
|
|
469
|
+
* (macOS, Windows, or POSIX where detach did not apply) always signal the
|
|
470
|
+
* direct child only: a negative-pid signal outside a detached session could
|
|
471
|
+
* hit an unrelated process group.
|
|
472
|
+
*/
|
|
473
|
+
function signalStdioProcess(
|
|
474
|
+
proc: KillableSubprocess,
|
|
475
|
+
detached: boolean,
|
|
476
|
+
signal: NodeJS.Signals,
|
|
477
|
+
platform: NodeJS.Platform,
|
|
478
|
+
): void {
|
|
479
|
+
if (detached && platform !== "win32") {
|
|
480
|
+
try {
|
|
481
|
+
process.kill(-proc.pid, signal);
|
|
482
|
+
return;
|
|
483
|
+
} catch (error) {
|
|
484
|
+
if (isErrnoCode(error, "ESRCH")) return;
|
|
485
|
+
// Fall through to the direct-child signal below.
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
proc.kill(signal);
|
|
490
|
+
} catch {
|
|
491
|
+
// Already gone.
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Terminate an MCP stdio subprocess: SIGTERM (process-group when `detached`
|
|
497
|
+
* on POSIX, direct child otherwise), wait up to `TERM_GRACE_MS` for a
|
|
498
|
+
* cooperative exit, then escalate to SIGKILL — waiting up to `KILL_GRACE_MS`
|
|
499
|
+
* more only when the leader itself hadn't already exited. A detached
|
|
500
|
+
* leader's cooperative exit does not prove the whole process group is gone
|
|
501
|
+
* (a grandchild can outlive it and ignore SIGTERM), so detached transports
|
|
502
|
+
* always fire the group SIGKILL sweep, even after a clean SIGTERM exit.
|
|
503
|
+
* Every step is a no-op-safe signal against an already-exited target, so
|
|
504
|
+
* repeat calls (idempotent `close()`) never throw.
|
|
505
|
+
*
|
|
506
|
+
* Exported so tests can exercise group-signal escalation with an explicit
|
|
507
|
+
* `detached`/`platform` pair: `StdioTransport.connect()` derives `detached`
|
|
508
|
+
* from `resolveStdioSpawnCommand()`, which is tied to the host's real
|
|
509
|
+
* `process.platform`, so a POSIX detached session cannot be reproduced
|
|
510
|
+
* end-to-end through `connect()` on a non-Linux dev/CI host.
|
|
511
|
+
*/
|
|
512
|
+
export async function terminateStdioProcess(
|
|
513
|
+
proc: KillableSubprocess,
|
|
514
|
+
detached: boolean,
|
|
515
|
+
platform: NodeJS.Platform = process.platform,
|
|
516
|
+
): Promise<void> {
|
|
517
|
+
signalStdioProcess(proc, detached, "SIGTERM", platform);
|
|
518
|
+
const exitedOnTerm = await waitForProcessExit(proc.exited, TERM_GRACE_MS);
|
|
519
|
+
// A non-detached transport has no process group beyond the leader itself:
|
|
520
|
+
// once it exits, there is nothing left to signal. A detached transport's
|
|
521
|
+
// leader exiting is NOT proof the group is empty — a grandchild it spawned
|
|
522
|
+
// can still be alive and ignoring SIGTERM — so detached transports always
|
|
523
|
+
// fall through to the group SIGKILL, even on a cooperative leader exit.
|
|
524
|
+
if (exitedOnTerm && !detached) return;
|
|
525
|
+
signalStdioProcess(proc, detached, "SIGKILL", platform);
|
|
526
|
+
// Once the leader has already exited there is no further `exited` signal
|
|
527
|
+
// to wait on for this call — the SIGKILL above is a fire-and-forget sweep
|
|
528
|
+
// for any surviving group members — so only block on the grace window
|
|
529
|
+
// when the leader itself is still the thing being escalated against.
|
|
530
|
+
if (!exitedOnTerm) await waitForProcessExit(proc.exited, KILL_GRACE_MS);
|
|
531
|
+
}
|
|
532
|
+
|
|
328
533
|
/**
|
|
329
534
|
* Stdio transport for MCP servers.
|
|
330
535
|
* Spawns a subprocess and communicates via stdin/stdout.
|
|
@@ -340,6 +545,12 @@ export class StdioTransport implements MCPTransport {
|
|
|
340
545
|
>();
|
|
341
546
|
#connected = false;
|
|
342
547
|
#readLoop: Promise<void> | null = null;
|
|
548
|
+
/**
|
|
549
|
+
* Set from `resolveStdioSpawnCommand()`'s `detached` flag in `connect()`.
|
|
550
|
+
* Gates process-group signaling in `close()` — only a transport that
|
|
551
|
+
* actually spawned into its own session may target it.
|
|
552
|
+
*/
|
|
553
|
+
#detached = false;
|
|
343
554
|
|
|
344
555
|
onClose?: () => void;
|
|
345
556
|
onError?: (error: Error) => void;
|
|
@@ -388,7 +599,9 @@ export class StdioTransport implements MCPTransport {
|
|
|
388
599
|
stderr: "pipe",
|
|
389
600
|
windowsHide: spawnCommand.windowsHide,
|
|
390
601
|
detached: spawnCommand.detached,
|
|
602
|
+
windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments,
|
|
391
603
|
});
|
|
604
|
+
this.#detached = spawnCommand.detached;
|
|
392
605
|
|
|
393
606
|
this.#connected = true;
|
|
394
607
|
|
|
@@ -650,8 +863,26 @@ export class StdioTransport implements MCPTransport {
|
|
|
650
863
|
}
|
|
651
864
|
|
|
652
865
|
if (this.#process) {
|
|
653
|
-
|
|
866
|
+
// Grab the handle and null the field immediately (before any
|
|
867
|
+
// `await`) so a concurrent/repeat `close()` sees `#process` already
|
|
868
|
+
// cleared and skips straight past this block — no double-signal.
|
|
869
|
+
const proc = this.#process;
|
|
654
870
|
this.#process = null;
|
|
871
|
+
|
|
872
|
+
// 1. Cooperative EOF first: a well-behaved server sees stdin close
|
|
873
|
+
// and can exit on its own before any signal is sent. Guarded — the
|
|
874
|
+
// sink can throw if the pipe is already closed/dead (e.g. the child
|
|
875
|
+
// already exited and the read loop got there first).
|
|
876
|
+
try {
|
|
877
|
+
proc.stdin.end();
|
|
878
|
+
} catch {
|
|
879
|
+
// Already closed/dead.
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// 2-3. Group-aware SIGTERM (when this transport actually spawned
|
|
883
|
+
// detached), bounded wait, then escalate to SIGKILL. See
|
|
884
|
+
// `terminateStdioProcess` for the exact signaling/escalation rules.
|
|
885
|
+
await terminateStdioProcess(proc, this.#detached);
|
|
655
886
|
}
|
|
656
887
|
|
|
657
888
|
if (this.#readLoop) {
|