mcp-fs-shell-windows 0.2.4
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/LICENSE +23 -0
- package/README.md +147 -0
- package/dist/append_files/handler.js +32 -0
- package/dist/append_files/schema.js +7 -0
- package/dist/checksum_files/handler.js +33 -0
- package/dist/checksum_files/schema.js +5 -0
- package/dist/checksum_files_verif/handler.js +66 -0
- package/dist/checksum_files_verif/helpers.js +6 -0
- package/dist/checksum_files_verif/schema.js +8 -0
- package/dist/content_diff/handler.js +11 -0
- package/dist/content_diff/schema.js +7 -0
- package/dist/copy_files/handler.js +43 -0
- package/dist/copy_files/helpers.js +24 -0
- package/dist/copy_files/schema.js +7 -0
- package/dist/count_lines/handler.js +144 -0
- package/dist/count_lines/schema.js +9 -0
- package/dist/create_directories/handler.js +29 -0
- package/dist/create_directories/schema.js +4 -0
- package/dist/delete_files/handler.js +42 -0
- package/dist/delete_files/schema.js +5 -0
- package/dist/delete_files_by_pattern/handler.js +46 -0
- package/dist/delete_files_by_pattern/schema.js +6 -0
- package/dist/directory_tree/handler.js +7 -0
- package/dist/directory_tree/helpers.js +47 -0
- package/dist/directory_tree/schema.js +5 -0
- package/dist/edit_files/handler.js +62 -0
- package/dist/edit_files/schema.js +11 -0
- package/dist/file_diff/handler.js +28 -0
- package/dist/file_diff/schema.js +5 -0
- package/dist/file_info/handler.js +30 -0
- package/dist/file_info/schema.js +4 -0
- package/dist/fuzzy_find_files/handler.js +74 -0
- package/dist/fuzzy_find_files/schema.js +8 -0
- package/dist/helpers/checksum.js +10 -0
- package/dist/helpers/diff.js +10 -0
- package/dist/helpers/path.js +87 -0
- package/dist/index.js +36 -0
- package/dist/list_directory/handler.js +42 -0
- package/dist/list_directory/schema.js +9 -0
- package/dist/move_files/handler.js +68 -0
- package/dist/move_files/schema.js +8 -0
- package/dist/patch_files/handler.js +34 -0
- package/dist/patch_files/helpers.js +103 -0
- package/dist/patch_files/schema.js +18 -0
- package/dist/read_files/handler.js +36 -0
- package/dist/read_files/schema.js +6 -0
- package/dist/search_files/handler.js +15 -0
- package/dist/search_files/helpers.js +71 -0
- package/dist/search_files/schema.js +8 -0
- package/dist/search_glob/handler.js +77 -0
- package/dist/search_glob/schema.js +8 -0
- package/dist/search_regex/handler.js +159 -0
- package/dist/search_regex/schema.js +10 -0
- package/dist/server.js +698 -0
- package/dist/shell/handler.js +687 -0
- package/dist/shell/schema.js +69 -0
- package/dist/write_new_files/handler.js +55 -0
- package/dist/write_new_files/schema.js +9 -0
- package/package.json +37 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
// shell/handler.ts — standalone shell tools (MCP filesystem fork).
|
|
2
|
+
// Windows-first (host is win32). Self-contained: node builtins + SHELL_MAX_TIMEOUT_SEC
|
|
3
|
+
// from ./schema.js. Design constraint: every synchronous handler must return in well
|
|
4
|
+
// under ~30 s (the MCP client kills longer requests with -32001); long-running work
|
|
5
|
+
// goes through the in-server background-job registry.
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import fsp from "fs/promises";
|
|
9
|
+
import nodePath from "path";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import crypto from "crypto";
|
|
12
|
+
import { SHELL_MAX_TIMEOUT_SEC } from "./schema.js";
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Shared state & helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
export const SHELL_JOBS_DIR = nodePath.join(os.tmpdir(), "mcp-filesystem-extended", "shell-jobs");
|
|
17
|
+
const jobs = new Map();
|
|
18
|
+
let defaultShellCwd = process.cwd();
|
|
19
|
+
// Best-effort startup cleanup: drop job log files older than 24 h.
|
|
20
|
+
try {
|
|
21
|
+
const entries = fs.readdirSync(SHELL_JOBS_DIR);
|
|
22
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
try {
|
|
25
|
+
const p = nodePath.join(SHELL_JOBS_DIR, entry);
|
|
26
|
+
if (fs.statSync(p).mtimeMs < cutoff) {
|
|
27
|
+
fs.unlinkSync(p);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// ignore individual failures
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Job dir may not exist yet — that's fine.
|
|
37
|
+
}
|
|
38
|
+
/** Keep only the last `max` chars; mark truncation. */
|
|
39
|
+
function cap(text, max) {
|
|
40
|
+
if (text.length <= max)
|
|
41
|
+
return text;
|
|
42
|
+
return `...[truncated] ` + text.slice(-max);
|
|
43
|
+
}
|
|
44
|
+
const CAP_OK = 100000; // cap for stdout/stderr in success payloads
|
|
45
|
+
const CAP_ERR = 20000; // cap for output embedded in error messages
|
|
46
|
+
const TAIL = 262144; // per-stream in-memory job tail (256 KB)
|
|
47
|
+
function newJobId() {
|
|
48
|
+
return `sj-${Date.now().toString(36)}-${crypto.randomBytes(3).toString("hex")}`;
|
|
49
|
+
}
|
|
50
|
+
/** Resolve the effective working directory (param override or module default) and validate it. */
|
|
51
|
+
async function resolveCwd(cwd) {
|
|
52
|
+
const abs = cwd ? nodePath.resolve(cwd) : defaultShellCwd;
|
|
53
|
+
const st = await fsp.stat(abs).catch(() => null);
|
|
54
|
+
if (!st || !st.isDirectory()) {
|
|
55
|
+
throw new Error(`Working directory does not exist or is not a directory: ${abs}`);
|
|
56
|
+
}
|
|
57
|
+
return abs;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Kill a process tree (Windows).
|
|
61
|
+
*
|
|
62
|
+
* PRIMARY: taskkill /PID <pid> /T /F — walks the LIVE process tree and
|
|
63
|
+
* terminates the root plus every descendant (the actual command processes).
|
|
64
|
+
* taskkill is waited for (its 'close'/'error' event) BEFORE any fallback —
|
|
65
|
+
* child.kill() is never concurrent with a running taskkill, so it can never
|
|
66
|
+
* destroy the root mid tree-walk and orphan descendants (the regression
|
|
67
|
+
* fixed 2026-08-29: an immediate child.kill() let "cancelled" ping -n 300
|
|
68
|
+
* jobs run to completion).
|
|
69
|
+
* After taskkill finishes, the root is probed: child.kill() runs ONLY if the
|
|
70
|
+
* root is still alive (covers a taskkill that could not finish the job).
|
|
71
|
+
* Safe when the root already exited: taskkill exits non-zero ("not found"),
|
|
72
|
+
* the probe says dead, no fallback. Every EventEmitter path is handled (no
|
|
73
|
+
* uncaught 'error' events); killTree is fire-and-forget — callers never block.
|
|
74
|
+
*/
|
|
75
|
+
/**
|
|
76
|
+
* Does a Windows process with this PID currently exist? (probe, no signal —
|
|
77
|
+
* process.kill(pid, 0) only opens the process handle on win32.)
|
|
78
|
+
*/
|
|
79
|
+
function rootAlive(pid) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
try {
|
|
82
|
+
process.kill(pid, 0);
|
|
83
|
+
resolve(true);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
const code = err.code;
|
|
87
|
+
resolve(code !== "ESRCH"); // EPERM etc. = exists but not ours
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function killTree(pid, child) {
|
|
92
|
+
if (pid === null || pid === undefined) {
|
|
93
|
+
try {
|
|
94
|
+
child?.kill();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// already dead
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
let taskkillProc;
|
|
102
|
+
try {
|
|
103
|
+
// stdout piped (not "ignore") so the close event reliably signals that
|
|
104
|
+
// taskkill has FINISHED before we consider any fallback.
|
|
105
|
+
taskkillProc = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
106
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
107
|
+
windowsHide: true,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
try {
|
|
112
|
+
child?.kill();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// already dead
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
let done = false;
|
|
120
|
+
const guard = setTimeout(() => {
|
|
121
|
+
if (!done) {
|
|
122
|
+
done = true;
|
|
123
|
+
void rootAlive(pid).then((alive) => {
|
|
124
|
+
if (alive) {
|
|
125
|
+
try {
|
|
126
|
+
child?.kill();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// already dead
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}, 15000);
|
|
135
|
+
guard.unref();
|
|
136
|
+
const finish = () => {
|
|
137
|
+
if (done)
|
|
138
|
+
return;
|
|
139
|
+
done = true;
|
|
140
|
+
clearTimeout(guard);
|
|
141
|
+
// taskkill has fully exited — no race possible here. Fall back only if
|
|
142
|
+
// the root somehow survived it (e.g. taskkill could not reach the tree).
|
|
143
|
+
void rootAlive(pid).then((alive) => {
|
|
144
|
+
if (alive) {
|
|
145
|
+
try {
|
|
146
|
+
child?.kill();
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// already dead
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
// Drain taskkill's output so its pipes never apply backpressure.
|
|
155
|
+
taskkillProc.stdout?.resume();
|
|
156
|
+
taskkillProc.stderr?.resume();
|
|
157
|
+
taskkillProc.on("error", finish);
|
|
158
|
+
taskkillProc.on("close", finish);
|
|
159
|
+
taskkillProc.unref();
|
|
160
|
+
}
|
|
161
|
+
function sanitizeName(name) {
|
|
162
|
+
const cleaned = name.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 40);
|
|
163
|
+
return cleaned || "job";
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Builds a line-oriented log feeder: buffers chunks per stream, emits COMPLETE lines
|
|
167
|
+
* prefixed with [OUT] / [ERR], and carries the partial trailing line forward.
|
|
168
|
+
*/
|
|
169
|
+
function makeLineFeeder(logStream) {
|
|
170
|
+
const state = { outPartial: "", errPartial: "" };
|
|
171
|
+
const feed = (stream, chunk) => {
|
|
172
|
+
const key = stream === "OUT" ? "outPartial" : "errPartial";
|
|
173
|
+
const buffer = state[key] + chunk;
|
|
174
|
+
const lines = buffer.split("\n");
|
|
175
|
+
state[key] = lines.pop() ?? "";
|
|
176
|
+
const complete = lines.map((l) => `[${stream}] ${l}\n`).join("");
|
|
177
|
+
if (complete)
|
|
178
|
+
logStream.write(complete);
|
|
179
|
+
};
|
|
180
|
+
const flush = () => {
|
|
181
|
+
if (state.outPartial) {
|
|
182
|
+
logStream.write(`[OUT] ${state.outPartial}\n`);
|
|
183
|
+
state.outPartial = "";
|
|
184
|
+
}
|
|
185
|
+
if (state.errPartial) {
|
|
186
|
+
logStream.write(`[ERR] ${state.errPartial}\n`);
|
|
187
|
+
state.errPartial = "";
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
return { feed, flush };
|
|
191
|
+
}
|
|
192
|
+
async function ensureJobsDir() {
|
|
193
|
+
await fsp.mkdir(SHELL_JOBS_DIR, { recursive: true }).catch(() => undefined);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Spawn a process, capture stdout/stderr (utf-8), enforce a timeout via killTree.
|
|
197
|
+
* Resolves (never rejects) with the outcome; spawn failures are reported in spawnError.
|
|
198
|
+
*/
|
|
199
|
+
async function runCaptured(exe, args, opts) {
|
|
200
|
+
const result = { stdout: "", stderr: "", code: null, timedOut: false };
|
|
201
|
+
await new Promise((resolve) => {
|
|
202
|
+
let proc;
|
|
203
|
+
try {
|
|
204
|
+
proc = spawn(exe, args, {
|
|
205
|
+
cwd: opts.cwd,
|
|
206
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
207
|
+
env: opts.env,
|
|
208
|
+
// verbatim: deliver the user command to cmd.exe byte-for-byte. Node's
|
|
209
|
+
// default win32 argv quoting backslash-escapes embedded quotes, which
|
|
210
|
+
// breaks ordinary cmd syntax (wmic where "...", tasklist /fi "...",
|
|
211
|
+
// paths with spaces) at the Node -> CreateProcess boundary.
|
|
212
|
+
windowsVerbatimArguments: opts.verbatim ?? false,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
result.spawnError = err instanceof Error ? err.message : String(err);
|
|
217
|
+
resolve();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const p = proc;
|
|
221
|
+
let settled = false;
|
|
222
|
+
let timer;
|
|
223
|
+
const finish = () => {
|
|
224
|
+
if (settled)
|
|
225
|
+
return;
|
|
226
|
+
settled = true;
|
|
227
|
+
if (timer)
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
resolve();
|
|
230
|
+
};
|
|
231
|
+
p.stdout?.setEncoding("utf-8");
|
|
232
|
+
p.stderr?.setEncoding("utf-8");
|
|
233
|
+
p.stdout?.on("data", (d) => {
|
|
234
|
+
result.stdout += d.toString("utf-8");
|
|
235
|
+
});
|
|
236
|
+
p.stderr?.on("data", (d) => {
|
|
237
|
+
result.stderr += d.toString("utf-8");
|
|
238
|
+
});
|
|
239
|
+
p.on("close", (code) => {
|
|
240
|
+
result.code = code;
|
|
241
|
+
finish();
|
|
242
|
+
});
|
|
243
|
+
p.on("error", (err) => {
|
|
244
|
+
result.spawnError = err.message;
|
|
245
|
+
finish();
|
|
246
|
+
});
|
|
247
|
+
if (opts.input) {
|
|
248
|
+
p.stdin?.write(opts.input);
|
|
249
|
+
}
|
|
250
|
+
// Always close stdin so non-interactive commands cannot hang waiting for input.
|
|
251
|
+
p.stdin?.end();
|
|
252
|
+
timer = setTimeout(() => {
|
|
253
|
+
result.timedOut = true;
|
|
254
|
+
killTree(p.pid ?? null, p);
|
|
255
|
+
}, opts.timeoutMs);
|
|
256
|
+
});
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// shell_run — bounded synchronous execution
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
export async function handleShellRun(command, input, timeoutSeconds, cwd) {
|
|
263
|
+
const abs = await resolveCwd(cwd);
|
|
264
|
+
const timeout = Math.min(Math.max(timeoutSeconds ?? 5, 0.1), SHELL_MAX_TIMEOUT_SEC);
|
|
265
|
+
const started = Date.now();
|
|
266
|
+
const r = await runCaptured("cmd.exe", ["/d", "/c", command], {
|
|
267
|
+
cwd: abs,
|
|
268
|
+
input,
|
|
269
|
+
timeoutMs: timeout * 1000,
|
|
270
|
+
verbatim: true,
|
|
271
|
+
});
|
|
272
|
+
const durationMs = Date.now() - started;
|
|
273
|
+
if (r.spawnError) {
|
|
274
|
+
throw new Error(`Failed to launch command: ${r.spawnError}`);
|
|
275
|
+
}
|
|
276
|
+
if (r.timedOut) {
|
|
277
|
+
throw new Error(`Process timed out after ${timeout}s.\nSTDOUT:\n${cap(r.stdout, CAP_ERR)}\nSTDERR:\n${cap(r.stderr, CAP_ERR)}`);
|
|
278
|
+
}
|
|
279
|
+
if (r.code !== 0) {
|
|
280
|
+
throw new Error(`Process exited with code ${r.code ?? "unknown"}.\nSTDOUT:\n${cap(r.stdout, CAP_ERR)}\nSTDERR:\n${cap(r.stderr, CAP_ERR)}`);
|
|
281
|
+
}
|
|
282
|
+
return JSON.stringify({
|
|
283
|
+
command,
|
|
284
|
+
exitCode: 0,
|
|
285
|
+
stdout: cap(r.stdout, CAP_OK),
|
|
286
|
+
stderr: cap(r.stderr, CAP_OK),
|
|
287
|
+
timedOut: false,
|
|
288
|
+
duration_ms: durationMs,
|
|
289
|
+
cwd: abs,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// shell_test — test wrapper (CI=true, fixed max timeout, never errors on outcomes)
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
export async function handleShellTest(command, cwd) {
|
|
296
|
+
const abs = await resolveCwd(cwd);
|
|
297
|
+
const started = Date.now();
|
|
298
|
+
const r = await runCaptured("cmd.exe", ["/d", "/c", command], {
|
|
299
|
+
cwd: abs,
|
|
300
|
+
timeoutMs: SHELL_MAX_TIMEOUT_SEC * 1000,
|
|
301
|
+
env: { ...process.env, CI: "true" },
|
|
302
|
+
verbatim: true,
|
|
303
|
+
});
|
|
304
|
+
const durationMs = Date.now() - started;
|
|
305
|
+
if (r.spawnError) {
|
|
306
|
+
throw new Error(`Failed to launch test command: ${r.spawnError}`);
|
|
307
|
+
}
|
|
308
|
+
const code = r.code;
|
|
309
|
+
return JSON.stringify({
|
|
310
|
+
command,
|
|
311
|
+
exit_code: code,
|
|
312
|
+
stdout: cap(r.stdout, CAP_OK),
|
|
313
|
+
stderr: cap(r.stderr, CAP_OK),
|
|
314
|
+
passed: code === 0 && !r.timedOut,
|
|
315
|
+
timedOut: r.timedOut,
|
|
316
|
+
duration_ms: durationMs,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// shell_start — background job (registry + log file + 256 KB tails + auto-kill)
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
export async function handleShellStart(command, name, timeoutHours, cwd) {
|
|
323
|
+
const abs = await resolveCwd(cwd);
|
|
324
|
+
await ensureJobsDir();
|
|
325
|
+
const id = newJobId();
|
|
326
|
+
const jobName = name && name.trim() ? name : "shelljob";
|
|
327
|
+
const logFile = nodePath.join(SHELL_JOBS_DIR, `${id}-${sanitizeName(jobName)}.log`);
|
|
328
|
+
const job = {
|
|
329
|
+
id,
|
|
330
|
+
name: jobName,
|
|
331
|
+
command,
|
|
332
|
+
cwd: abs,
|
|
333
|
+
pid: null,
|
|
334
|
+
status: "running",
|
|
335
|
+
exitCode: null,
|
|
336
|
+
startedAt: Date.now(),
|
|
337
|
+
endedAt: null,
|
|
338
|
+
stdoutTail: "",
|
|
339
|
+
stderrTail: "",
|
|
340
|
+
logFile,
|
|
341
|
+
proc: null,
|
|
342
|
+
timeoutHandle: null,
|
|
343
|
+
statusPoll: null,
|
|
344
|
+
};
|
|
345
|
+
let logStream = null;
|
|
346
|
+
try {
|
|
347
|
+
logStream = fs.createWriteStream(logFile);
|
|
348
|
+
logStream.write(`# ${new Date().toISOString()} | ${command} | cwd=${abs}\n`);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Log file is best-effort; the job still runs without it.
|
|
352
|
+
job.logFile = null;
|
|
353
|
+
}
|
|
354
|
+
const timeoutHoursEff = Math.min(Math.max(timeoutHours ?? 10, 0.001), 10);
|
|
355
|
+
try {
|
|
356
|
+
const proc = spawn("cmd.exe", ["/d", "/c", command], {
|
|
357
|
+
cwd: abs,
|
|
358
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
359
|
+
windowsVerbatimArguments: true,
|
|
360
|
+
});
|
|
361
|
+
job.proc = proc;
|
|
362
|
+
job.pid = proc.pid ?? null;
|
|
363
|
+
const feeder = logStream ? makeLineFeeder(logStream) : null;
|
|
364
|
+
const stream = logStream;
|
|
365
|
+
proc.stdout?.on("data", (d) => {
|
|
366
|
+
const text = d.toString("utf-8");
|
|
367
|
+
job.stdoutTail += text;
|
|
368
|
+
if (job.stdoutTail.length > TAIL)
|
|
369
|
+
job.stdoutTail = job.stdoutTail.slice(-TAIL);
|
|
370
|
+
feeder?.feed("OUT", text);
|
|
371
|
+
});
|
|
372
|
+
proc.stderr?.on("data", (d) => {
|
|
373
|
+
const text = d.toString("utf-8");
|
|
374
|
+
job.stderrTail += text;
|
|
375
|
+
if (job.stderrTail.length > TAIL)
|
|
376
|
+
job.stderrTail = job.stderrTail.slice(-TAIL);
|
|
377
|
+
feeder?.feed("ERR", text);
|
|
378
|
+
});
|
|
379
|
+
proc.on("close", (code) => {
|
|
380
|
+
job.status = job.status === "cancelled" || job.status === "timeout" ? job.status : "completed";
|
|
381
|
+
job.exitCode = code;
|
|
382
|
+
job.endedAt = Date.now();
|
|
383
|
+
if (job.timeoutHandle)
|
|
384
|
+
clearTimeout(job.timeoutHandle);
|
|
385
|
+
job.timeoutHandle = null;
|
|
386
|
+
try {
|
|
387
|
+
feeder?.flush();
|
|
388
|
+
stream?.end();
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// best-effort
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
proc.on("error", (err) => {
|
|
395
|
+
if (job.status === "running") {
|
|
396
|
+
job.status = "error";
|
|
397
|
+
job.stderrTail += `\nError: ${err.message}`;
|
|
398
|
+
job.endedAt = Date.now();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
const t = setTimeout(() => {
|
|
402
|
+
if (job.status === "running") {
|
|
403
|
+
job.status = "timeout";
|
|
404
|
+
job.endedAt = Date.now();
|
|
405
|
+
killTree(job.pid, job.proc);
|
|
406
|
+
}
|
|
407
|
+
}, timeoutHoursEff * 3600 * 1000);
|
|
408
|
+
t.unref(); // never keep the MCP server alive solely for this timer
|
|
409
|
+
job.timeoutHandle = t;
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
job.status = "error";
|
|
413
|
+
job.stderrTail += `\nError: ${err instanceof Error ? err.message : String(err)}`;
|
|
414
|
+
job.endedAt = Date.now();
|
|
415
|
+
try {
|
|
416
|
+
logStream?.end();
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
// best-effort
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
jobs.set(id, job);
|
|
423
|
+
// Wait briefly to catch immediate failures (parity with Beledarian's run_background_command).
|
|
424
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
425
|
+
return JSON.stringify({
|
|
426
|
+
id,
|
|
427
|
+
name: job.name,
|
|
428
|
+
pid: job.pid,
|
|
429
|
+
status: job.status,
|
|
430
|
+
logFile: job.logFile,
|
|
431
|
+
message: `Job started. Use shell_check with ID ${id} to poll; shell_cancel to kill.`,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// shell_check — job status + output tails
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
export async function handleShellCheck(id) {
|
|
438
|
+
const job = jobs.get(id);
|
|
439
|
+
if (!job) {
|
|
440
|
+
const active = [...jobs.keys()].join(", ") || "(none)";
|
|
441
|
+
throw new Error(`No shell job found with ID ${id}. Active jobs: ${active}. (Jobs live in the MCP server's memory and are lost when it restarts.)`);
|
|
442
|
+
}
|
|
443
|
+
return JSON.stringify({
|
|
444
|
+
id: job.id,
|
|
445
|
+
name: job.name,
|
|
446
|
+
status: job.status,
|
|
447
|
+
exitCode: job.exitCode,
|
|
448
|
+
duration_seconds: Math.floor((Date.now() - job.startedAt) / 1000),
|
|
449
|
+
stdout_tail: job.stdoutTail.slice(-4000),
|
|
450
|
+
stderr_tail: job.stderrTail.slice(-4000),
|
|
451
|
+
logFile: job.logFile,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
// shell_cancel — kill a job (whole process tree)
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
export async function handleShellCancel(id) {
|
|
458
|
+
const job = jobs.get(id);
|
|
459
|
+
if (!job) {
|
|
460
|
+
const active = [...jobs.keys()].join(", ") || "(none)";
|
|
461
|
+
throw new Error(`No shell job found with ID ${id}. Active jobs: ${active}. (Jobs live in the MCP server's memory and are lost when it restarts.)`);
|
|
462
|
+
}
|
|
463
|
+
if (job.status !== "running") {
|
|
464
|
+
return JSON.stringify({ id: job.id, status: job.status, message: `Job is already ${job.status}.` });
|
|
465
|
+
}
|
|
466
|
+
job.status = "cancelled";
|
|
467
|
+
job.endedAt = Date.now();
|
|
468
|
+
if (job.timeoutHandle)
|
|
469
|
+
clearTimeout(job.timeoutHandle);
|
|
470
|
+
job.timeoutHandle = null;
|
|
471
|
+
killTree(job.pid, job.proc);
|
|
472
|
+
return JSON.stringify({ id: job.id, status: "cancelled", message: "Job killed." });
|
|
473
|
+
}
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// shell_terminal — separate, persistent, interactive cmd /k console window
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
/**
|
|
478
|
+
* PID-liveness probe for terminal windows (tracked by PID rather than by
|
|
479
|
+
* process handle: the window is a separate process tree started via
|
|
480
|
+
* Start-Process). Spawned directly (no shell) so the /fi filter string is a
|
|
481
|
+
* single argv element — no quote mangling. NOTE: this build of tasklist does
|
|
482
|
+
* NOT support compound `AND` filters — the value token swallows the rest of
|
|
483
|
+
* the line (verified 2026-08-29: `PID eq <n> AND ImageName eq cmd.exe`
|
|
484
|
+
* matches nothing), so we filter by PID only and verify the matched row's
|
|
485
|
+
* image name is cmd.exe (mitigates PID reuse). tasklist error output =
|
|
486
|
+
* unknown → assume alive (conservative; the next poll retries).
|
|
487
|
+
*/
|
|
488
|
+
function terminalPidAlive(pid) {
|
|
489
|
+
return new Promise((resolve) => {
|
|
490
|
+
let proc;
|
|
491
|
+
try {
|
|
492
|
+
proc = spawn("tasklist", ["/fi", `PID eq ${pid}`], {
|
|
493
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
494
|
+
windowsHide: true,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
resolve(true); // conservative: assume still alive
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
let out = "";
|
|
502
|
+
let finished = false;
|
|
503
|
+
const finish = (alive) => {
|
|
504
|
+
if (finished)
|
|
505
|
+
return;
|
|
506
|
+
finished = true;
|
|
507
|
+
resolve(alive);
|
|
508
|
+
};
|
|
509
|
+
proc.stdout?.on("data", (d) => {
|
|
510
|
+
out += d.toString("utf-8");
|
|
511
|
+
});
|
|
512
|
+
proc.stderr?.resume();
|
|
513
|
+
proc.on("error", () => finish(true));
|
|
514
|
+
proc.on("close", () => {
|
|
515
|
+
const text = out.trim();
|
|
516
|
+
if (text.startsWith("ERROR")) {
|
|
517
|
+
finish(true); // tasklist itself failed; assume alive, retry next poll
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (text.includes("No tasks are running")) {
|
|
521
|
+
finish(false);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
for (const line of text.split("\n").map((l) => l.trim()).filter(Boolean)) {
|
|
525
|
+
if (line.includes(String(pid)) && /^cmd\.exe/i.test(line)) {
|
|
526
|
+
finish(true);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
finish(false);
|
|
531
|
+
});
|
|
532
|
+
const guard = setTimeout(() => finish(true), 10000);
|
|
533
|
+
guard.unref();
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
async function cleanupTerminalFiles(id) {
|
|
537
|
+
for (const f of [
|
|
538
|
+
nodePath.join(SHELL_JOBS_DIR, `${id}.cmd`),
|
|
539
|
+
nodePath.join(SHELL_JOBS_DIR, `${id}.ps1`),
|
|
540
|
+
]) {
|
|
541
|
+
await fsp.unlink(f).catch(() => undefined);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
export async function handleShellTerminal(command, cwd) {
|
|
545
|
+
const abs = await resolveCwd(cwd);
|
|
546
|
+
await ensureJobsDir();
|
|
547
|
+
const id = newJobId();
|
|
548
|
+
const batchFile = nodePath.join(SHELL_JOBS_DIR, `${id}.cmd`);
|
|
549
|
+
const launcherFile = nodePath.join(SHELL_JOBS_DIR, `${id}.ps1`);
|
|
550
|
+
// The user command runs as a batch file inside a NEW, separate console
|
|
551
|
+
// window. Start-Process gives that window a real console (the user can
|
|
552
|
+
// type into it) and the window is NOT attached to this MCP server's
|
|
553
|
+
// stdio, so the JSON-RPC transport is untouched. The launcher prints the
|
|
554
|
+
// window's PID for tracking.
|
|
555
|
+
await fsp.writeFile(batchFile, command, "utf-8");
|
|
556
|
+
const ps = [
|
|
557
|
+
"$ErrorActionPreference = 'Stop'",
|
|
558
|
+
`$p = Start-Process cmd -ArgumentList '/d', '/k', '${batchFile}' -WorkingDirectory '${abs}' -PassThru`,
|
|
559
|
+
"$p.Id",
|
|
560
|
+
].join("\n");
|
|
561
|
+
await fsp.writeFile(launcherFile, ps, "utf-8");
|
|
562
|
+
const r = await runCaptured("powershell", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", launcherFile], { cwd: abs, timeoutMs: 20000 });
|
|
563
|
+
const pidMatch = r.stdout.match(/\d+/);
|
|
564
|
+
if (r.spawnError || r.timedOut || !pidMatch) {
|
|
565
|
+
await cleanupTerminalFiles(id);
|
|
566
|
+
const detail = r.spawnError
|
|
567
|
+
? `: ${r.spawnError}`
|
|
568
|
+
: r.timedOut
|
|
569
|
+
? " (launcher timed out after 20s)"
|
|
570
|
+
: ` (no PID in output: ${cap(r.stdout + r.stderr, 2000)})`;
|
|
571
|
+
throw new Error(`Failed to launch terminal${detail}`);
|
|
572
|
+
}
|
|
573
|
+
const pid = parseInt(pidMatch[0], 10);
|
|
574
|
+
const job = {
|
|
575
|
+
id,
|
|
576
|
+
name: "terminal",
|
|
577
|
+
command,
|
|
578
|
+
cwd: abs,
|
|
579
|
+
pid,
|
|
580
|
+
status: "running",
|
|
581
|
+
exitCode: null,
|
|
582
|
+
startedAt: Date.now(),
|
|
583
|
+
endedAt: null,
|
|
584
|
+
stdoutTail: "",
|
|
585
|
+
stderrTail: "",
|
|
586
|
+
logFile: null,
|
|
587
|
+
proc: null, // the window is a separate process tree; tracked by PID
|
|
588
|
+
timeoutHandle: null,
|
|
589
|
+
statusPoll: null,
|
|
590
|
+
};
|
|
591
|
+
jobs.set(id, job);
|
|
592
|
+
// Lifecycle: no process handle to watch, so poll PID liveness every 5 s.
|
|
593
|
+
// When the window goes away (user closed it, or the tree was cancelled),
|
|
594
|
+
// finalize the job and clean up the helper files.
|
|
595
|
+
const finalize = () => {
|
|
596
|
+
if (job.statusPoll)
|
|
597
|
+
clearTimeout(job.statusPoll);
|
|
598
|
+
job.statusPoll = null;
|
|
599
|
+
if (job.status === "running") {
|
|
600
|
+
job.status = "completed";
|
|
601
|
+
job.exitCode = 0; // window closed normally by the user
|
|
602
|
+
}
|
|
603
|
+
if (job.endedAt === null)
|
|
604
|
+
job.endedAt = Date.now();
|
|
605
|
+
void cleanupTerminalFiles(id);
|
|
606
|
+
};
|
|
607
|
+
const poll = () => {
|
|
608
|
+
if (job.statusPoll)
|
|
609
|
+
clearTimeout(job.statusPoll);
|
|
610
|
+
const active = job.status === "running" || job.status === "cancelled" || job.status === "timeout";
|
|
611
|
+
if (!active) {
|
|
612
|
+
job.statusPoll = null;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
void terminalPidAlive(pid).then((alive) => {
|
|
616
|
+
if (!alive) {
|
|
617
|
+
finalize();
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
job.statusPoll = setTimeout(poll, 5000);
|
|
621
|
+
job.statusPoll.unref();
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
};
|
|
625
|
+
job.statusPoll = setTimeout(poll, 5000);
|
|
626
|
+
job.statusPoll.unref();
|
|
627
|
+
return JSON.stringify({
|
|
628
|
+
id,
|
|
629
|
+
pid,
|
|
630
|
+
status: "running",
|
|
631
|
+
logFile: null,
|
|
632
|
+
message: `Visible cmd.exe window launched (separate interactive console, cmd /k). The window persists after the command finishes; use shell_cancel with ID ${id} to close it.`,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
// shell_python — temp .py + system python
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
export async function handleShellPython(code, timeoutSeconds, cwd) {
|
|
639
|
+
const abs = await resolveCwd(cwd);
|
|
640
|
+
await ensureJobsDir();
|
|
641
|
+
const file = nodePath.join(SHELL_JOBS_DIR, `py-${Date.now().toString(36)}-${crypto.randomBytes(3).toString("hex")}.py`);
|
|
642
|
+
await fsp.writeFile(file, code, "utf-8");
|
|
643
|
+
const timeout = Math.min(Math.max(timeoutSeconds ?? 5, 0.1), SHELL_MAX_TIMEOUT_SEC);
|
|
644
|
+
const started = Date.now();
|
|
645
|
+
try {
|
|
646
|
+
const r = await runCaptured("python", [file], {
|
|
647
|
+
cwd: abs,
|
|
648
|
+
timeoutMs: timeout * 1000,
|
|
649
|
+
});
|
|
650
|
+
const durationMs = Date.now() - started;
|
|
651
|
+
if (r.spawnError) {
|
|
652
|
+
throw new Error(`Failed to launch python: ${r.spawnError}`);
|
|
653
|
+
}
|
|
654
|
+
if (r.timedOut) {
|
|
655
|
+
throw new Error(`Process timed out after ${timeout}s.\nSTDOUT:\n${cap(r.stdout, CAP_ERR)}\nSTDERR:\n${cap(r.stderr, CAP_ERR)}`);
|
|
656
|
+
}
|
|
657
|
+
if (r.code !== 0) {
|
|
658
|
+
throw new Error(`Process exited with code ${r.code ?? "unknown"}.\nSTDOUT:\n${cap(r.stdout, CAP_ERR)}\nSTDERR:\n${cap(r.stderr, CAP_ERR)}`);
|
|
659
|
+
}
|
|
660
|
+
return JSON.stringify({
|
|
661
|
+
exitCode: 0,
|
|
662
|
+
stdout: cap(r.stdout, CAP_OK),
|
|
663
|
+
stderr: cap(r.stderr, CAP_OK),
|
|
664
|
+
timedOut: false,
|
|
665
|
+
duration_ms: durationMs,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
finally {
|
|
669
|
+
await fsp.unlink(file).catch(() => undefined);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
// shell_cwd — get/set the default working directory for shell_* tools
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
export async function handleShellCwd(dir) {
|
|
676
|
+
if (!dir) {
|
|
677
|
+
return JSON.stringify({ cwd: defaultShellCwd });
|
|
678
|
+
}
|
|
679
|
+
const abs = nodePath.resolve(defaultShellCwd, dir);
|
|
680
|
+
const st = await fsp.stat(abs).catch(() => null);
|
|
681
|
+
if (!st || !st.isDirectory()) {
|
|
682
|
+
throw new Error(`Path is not a directory: ${abs}`);
|
|
683
|
+
}
|
|
684
|
+
const previous = defaultShellCwd;
|
|
685
|
+
defaultShellCwd = abs;
|
|
686
|
+
return JSON.stringify({ previous_directory: previous, current_directory: abs });
|
|
687
|
+
}
|