@akms/mcp-wsl 0.0.1

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/lib/index.cjs ADDED
@@ -0,0 +1,1916 @@
1
+ 'use strict';
2
+
3
+ var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js');
4
+ var os = require('os');
5
+ var path = require('path');
6
+ var child_process = require('child_process');
7
+ var mcpSsh = require('@akms/mcp-ssh');
8
+ var timers = require('timers');
9
+ var fs = require('fs');
10
+ var zod = require('zod');
11
+
12
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
+
14
+ var os__default = /*#__PURE__*/_interopDefault(os);
15
+ var path__default = /*#__PURE__*/_interopDefault(path);
16
+ var fs__default = /*#__PURE__*/_interopDefault(fs);
17
+
18
+ // src/server.ts
19
+
20
+ // src/_defs/defaults.ts
21
+ var LOG_LEVEL_ENV = "WSL_MCP_LOG_LEVEL";
22
+ var DEFAULT_EXEC_TIMEOUT_MS = 6e4;
23
+ var DEFAULT_MAX_OUTPUT_CHARACTERS = 1e5;
24
+ var DEFAULT_MAX_READ_FILE_BYTES = 2e5;
25
+ var OUTPUT_HARD_CAP_MULTIPLIER = 4;
26
+ var SESSION_IDLE_TIMEOUT_MS = 15 * 60 * 1e3;
27
+ var SESSION_SWEEP_INTERVAL_MS = 6e4;
28
+ var MAX_CONCURRENT_PROCESSES = 32;
29
+ var CWD_MARKER = "__AKMS_WSL_CWD__";
30
+ var MAX_DIRECTORY_ENTRIES = 1e3;
31
+ var WSL_EXECUTABLE = "wsl.exe";
32
+ var WSL_FAILURE_EXIT_CODES = [4294967295, -1];
33
+ var SERVER_NAME = "akms-mcp-wsl";
34
+ var SERVER_VERSION = "0.0.1";
35
+
36
+ // src/_defs/env-vars.ts
37
+ var SINGLE_HOST_ENV = {
38
+ /** Presence of this variable is what registers a distro at all. Name as `wsl -l -q` prints it. */
39
+ DISTRO: "WSL_DISTRO",
40
+ /** Linux user to run as (`wsl -u`); the distro's default user when unset. */
41
+ USER: "WSL_USER",
42
+ /** Alias shown to the model; defaults to the distro name. */
43
+ NAME: "WSL_NAME",
44
+ DESCRIPTION: "WSL_DESCRIPTION",
45
+ /** Directory new sessions start in. */
46
+ CWD: "WSL_CWD",
47
+ READONLY: "WSL_READONLY",
48
+ ALLOW_SUDO: "WSL_ALLOW_SUDO",
49
+ /** Interop `.exe` execution and writes under `/mnt/<drive>/`. Default false. */
50
+ ALLOW_WINDOWS: "WSL_ALLOW_WINDOWS",
51
+ /** Comma-separated binary names. */
52
+ ALLOW_COMMANDS: "WSL_ALLOW_COMMANDS",
53
+ /** Comma-separated regex sources. */
54
+ DENY_PATTERNS: "WSL_DENY_PATTERNS",
55
+ /** Comma-separated absolute path prefixes. */
56
+ ALLOWED_PATHS: "WSL_ALLOWED_PATHS",
57
+ EXEC_TIMEOUT_MS: "WSL_EXEC_TIMEOUT_MS",
58
+ MAX_OUTPUT_CHARACTERS: "WSL_MAX_OUTPUT",
59
+ MAX_READ_FILE_BYTES: "WSL_MAX_READ_BYTES"
60
+ };
61
+ var TRUTHY_ENV_VALUES = ["true", "1", "yes", "on"];
62
+ var FALSY_ENV_VALUES = ["false", "0", "no", "off"];
63
+
64
+ // src/_defs/wsl-rules.ts
65
+ var WSL_CATASTROPHE_RULES = [
66
+ {
67
+ pattern: /^wsl(?:\.exe)?\b.*\s-(?:-shutdown|-terminate|-unregister|t)(?:\s|$)/i,
68
+ reason: "stops or removes the distro this server runs in (wsl --shutdown / --terminate / --unregister)"
69
+ },
70
+ {
71
+ pattern: /^wslconfig(?:\.exe)?\b.*\s\/(?:t|u)\b/i,
72
+ reason: "terminates or unregisters the distro this server runs in (wslconfig /t, /u)"
73
+ },
74
+ {
75
+ pattern: /^(?:poweroff|halt)\b/,
76
+ reason: "powers off the distro this server runs in"
77
+ },
78
+ {
79
+ pattern: /^systemctl\b.*\b(?:poweroff|halt|reboot|kexec|suspend|hibernate)\b/,
80
+ reason: "powers off or restarts the distro this server runs in"
81
+ },
82
+ {
83
+ pattern: /^(?:telinit|init)\s+[06]\b/,
84
+ reason: "halts or reboots the distro this server runs in"
85
+ }
86
+ ];
87
+ var WINDOWS_INTEROP_BINARY_PATTERN = new RegExp(
88
+ "^(?:(?:powershell|pwsh|cmd|explorer|wsl|wslconfig|wslview|reg|regedit|schtasks|sc|net|netsh|taskkill|tasklist|mshta|rundll32|wscript|cscript|msiexec|start|winget|choco|scoop)(?:\\.exe|\\.cmd|\\.bat)?|.*\\.(?:exe|cmd|bat|ps1|msi|com))$",
89
+ "i"
90
+ );
91
+ var MOUNT_PATH_PATTERN = /\/mnt\/([a-z])(\/[^\s'"|;&<>]*)?/gi;
92
+
93
+ // src/_libs/logger.ts
94
+ var LEVEL_WEIGHTS = {
95
+ silent: 0,
96
+ debug: 1,
97
+ info: 2,
98
+ warn: 3,
99
+ error: 4
100
+ };
101
+ var SECRET_META_KEYS = ["password", "passphrase", "secret", "token"];
102
+ function resolveThreshold() {
103
+ const configured = process.env[LOG_LEVEL_ENV];
104
+ if (configured == null) {
105
+ return "info";
106
+ }
107
+ const normalized = configured.toLowerCase();
108
+ if (Object.hasOwn(LEVEL_WEIGHTS, normalized) == false) {
109
+ return "info";
110
+ }
111
+ return normalized;
112
+ }
113
+ function maskSecrets(meta) {
114
+ const masked = {};
115
+ for (const [key, value] of Object.entries(meta)) {
116
+ if (SECRET_META_KEYS.includes(key.toLowerCase()) == true) {
117
+ masked[key] = "***";
118
+ continue;
119
+ }
120
+ masked[key] = value;
121
+ }
122
+ return masked;
123
+ }
124
+ function describeError(error) {
125
+ if (error instanceof Error) {
126
+ if (error.stack != null) {
127
+ return error.stack;
128
+ }
129
+ return error.message;
130
+ }
131
+ return String(error);
132
+ }
133
+ function write(level, message, meta) {
134
+ const threshold = resolveThreshold();
135
+ if (threshold === "silent") {
136
+ return;
137
+ }
138
+ if (LEVEL_WEIGHTS[level] < LEVEL_WEIGHTS[threshold]) {
139
+ return;
140
+ }
141
+ const record = {
142
+ time: (/* @__PURE__ */ new Date()).toISOString(),
143
+ level,
144
+ message,
145
+ ...maskSecrets(meta)
146
+ };
147
+ process.stderr.write(`${JSON.stringify(record)}
148
+ `);
149
+ }
150
+ function createLogger(baseMeta) {
151
+ return {
152
+ child(meta) {
153
+ return createLogger({ ...baseMeta, ...meta });
154
+ },
155
+ debug(message, meta) {
156
+ write("debug", message, { ...baseMeta, ...meta });
157
+ },
158
+ info(message, meta) {
159
+ write("info", message, { ...baseMeta, ...meta });
160
+ },
161
+ warn(message, meta) {
162
+ write("warn", message, { ...baseMeta, ...meta });
163
+ },
164
+ error(error, message, meta) {
165
+ write("error", message, { ...baseMeta, ...meta, error: describeError(error) });
166
+ }
167
+ };
168
+ }
169
+ var $logger = createLogger({});
170
+
171
+ // src/_libs/text.ts
172
+ function truncateOutput(text, maxCharacters) {
173
+ if (text.length <= maxCharacters) {
174
+ return { text, truncated: false };
175
+ }
176
+ let cutAt = maxCharacters;
177
+ const lastKeptCode = text.charCodeAt(cutAt - 1);
178
+ if (lastKeptCode >= 55296 && lastKeptCode <= 56319) {
179
+ cutAt -= 1;
180
+ }
181
+ const droppedCharacters = text.length - cutAt;
182
+ const kept = text.slice(0, cutAt);
183
+ return {
184
+ text: `${kept}
185
+ ... [truncated ${droppedCharacters} characters of ${text.length}]`,
186
+ truncated: true
187
+ };
188
+ }
189
+ function quoteShellArgument(value) {
190
+ const escaped = value.replace(/'/g, `'\\''`);
191
+ return `'${escaped}'`;
192
+ }
193
+ function formatBytes(bytes) {
194
+ if (bytes < 1024) {
195
+ return `${bytes} B`;
196
+ }
197
+ if (bytes < 1024 * 1024) {
198
+ const kilobytes = bytes / 1024;
199
+ return `${kilobytes.toFixed(1)} KB`;
200
+ }
201
+ const megabytes = bytes / (1024 * 1024);
202
+ return `${megabytes.toFixed(1)} MB`;
203
+ }
204
+ function expandHomePath(inputPath) {
205
+ let expanded = inputPath;
206
+ if (inputPath === "~") {
207
+ expanded = os__default.default.homedir();
208
+ } else if (inputPath.startsWith("~/") == true || inputPath.startsWith("~\\") == true) {
209
+ expanded = path__default.default.join(os__default.default.homedir(), inputPath.slice(2));
210
+ }
211
+ return path__default.default.resolve(expanded);
212
+ }
213
+ function mountPathToWindows(distroPath) {
214
+ const match = /^\/mnt\/([a-z])(?:\/(.*))?$/i.exec(distroPath.trim());
215
+ if (match == null) {
216
+ return null;
217
+ }
218
+ const drive = match[1].toUpperCase();
219
+ const rest = match[2] ?? "";
220
+ return `${drive}:/${rest}`;
221
+ }
222
+
223
+ // src/modules/config/host-profile.ts
224
+ function readEnv(name) {
225
+ const value = process.env[name];
226
+ if (value == null) {
227
+ return void 0;
228
+ }
229
+ const trimmed = value.trim();
230
+ if (trimmed === "") {
231
+ return void 0;
232
+ }
233
+ return trimmed;
234
+ }
235
+ function readBooleanEnv(name) {
236
+ const value = readEnv(name);
237
+ if (value == null) {
238
+ return void 0;
239
+ }
240
+ const normalized = value.toLowerCase();
241
+ if (TRUTHY_ENV_VALUES.includes(normalized) == true) {
242
+ return true;
243
+ }
244
+ if (FALSY_ENV_VALUES.includes(normalized) == false) {
245
+ throw new Error(`${name} must be one of ${[...TRUTHY_ENV_VALUES, ...FALSY_ENV_VALUES].join(", ")} (got "${value}")`);
246
+ }
247
+ return false;
248
+ }
249
+ function readIntegerEnv(name) {
250
+ const value = readEnv(name);
251
+ if (value == null) {
252
+ return void 0;
253
+ }
254
+ const parsed = Number(value);
255
+ if (Number.isInteger(parsed) == false || parsed <= 0) {
256
+ throw new Error(`${name} must be a positive integer (got "${value}")`);
257
+ }
258
+ return parsed;
259
+ }
260
+ function readListEnv(name) {
261
+ const value = readEnv(name);
262
+ if (value == null) {
263
+ return void 0;
264
+ }
265
+ const items = value.split(",").map((item) => item.trim()).filter((item) => item !== "");
266
+ if (items.length === 0) {
267
+ return void 0;
268
+ }
269
+ return items;
270
+ }
271
+ function compileDenyPatterns(sources) {
272
+ return sources.map((source) => {
273
+ try {
274
+ return new RegExp(source, "i");
275
+ } catch (ex) {
276
+ let detail = String(ex);
277
+ if (ex instanceof Error) {
278
+ detail = ex.message;
279
+ }
280
+ throw new Error(`${SINGLE_HOST_ENV.DENY_PATTERNS} has an invalid entry /${source}/: ${detail}`);
281
+ }
282
+ });
283
+ }
284
+ function loadHostProfile() {
285
+ const distro = readEnv(SINGLE_HOST_ENV.DISTRO);
286
+ if (distro == null) {
287
+ const _startupLogger = $logger.child({ context: "loadHostProfile" });
288
+ _startupLogger.warn("no distro is registered; set the distro variable and restart", {
289
+ env_vars: [SINGLE_HOST_ENV.DISTRO]
290
+ });
291
+ return null;
292
+ }
293
+ const name = readEnv(SINGLE_HOST_ENV.NAME) ?? distro;
294
+ const _logger = $logger.child({ context: "loadHostProfile", profile: name });
295
+ const allowCommands = readListEnv(SINGLE_HOST_ENV.ALLOW_COMMANDS) ?? [];
296
+ if (allowCommands.length > 0 && allowCommands.includes("pwd") == false) {
297
+ allowCommands.push("pwd");
298
+ }
299
+ const policy = {
300
+ readonly: readBooleanEnv(SINGLE_HOST_ENV.READONLY) ?? false,
301
+ allowSudo: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_SUDO) ?? true,
302
+ execTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.EXEC_TIMEOUT_MS) ?? DEFAULT_EXEC_TIMEOUT_MS,
303
+ // Nothing connects; carried only because the shared policy type declares it.
304
+ connectTimeoutMs: 0,
305
+ maxOutputCharacters: readIntegerEnv(SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS) ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
306
+ maxReadFileBytes: readIntegerEnv(SINGLE_HOST_ENV.MAX_READ_FILE_BYTES) ?? DEFAULT_MAX_READ_FILE_BYTES,
307
+ allowCommands,
308
+ denyPatterns: compileDenyPatterns(readListEnv(SINGLE_HOST_ENV.DENY_PATTERNS) ?? []),
309
+ allowedPaths: readListEnv(SINGLE_HOST_ENV.ALLOWED_PATHS) ?? []
310
+ };
311
+ const profile = {
312
+ name,
313
+ distro,
314
+ username: readEnv(SINGLE_HOST_ENV.USER),
315
+ description: readEnv(SINGLE_HOST_ENV.DESCRIPTION),
316
+ defaultCwd: readEnv(SINGLE_HOST_ENV.CWD),
317
+ allowWindows: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_WINDOWS) ?? false,
318
+ policy
319
+ };
320
+ _logger.info("host profile loaded", {
321
+ distro,
322
+ username: profile.username ?? "(distro default)",
323
+ readonly: policy.readonly,
324
+ allow_windows: profile.allowWindows
325
+ });
326
+ return profile;
327
+ }
328
+ function toHostSummary(profile) {
329
+ return {
330
+ name: profile.name,
331
+ distro: profile.distro,
332
+ username: profile.username ?? "(distro default)",
333
+ description: profile.description,
334
+ readonly: profile.policy.readonly,
335
+ allowSudo: profile.policy.allowSudo,
336
+ allowWindows: profile.allowWindows,
337
+ allowedPaths: profile.policy.allowedPaths,
338
+ allowCommands: profile.policy.allowCommands
339
+ };
340
+ }
341
+ var MAX_INSPECTION_DEPTH = 5;
342
+ function inspectWslCommand(canonicalCommand, profile, depth = 0) {
343
+ if (depth === 0) {
344
+ const baseVerdict = mcpSsh.inspectCommand(canonicalCommand, profile.policy);
345
+ if (baseVerdict.allowed == false) {
346
+ return baseVerdict;
347
+ }
348
+ }
349
+ if (depth > MAX_INSPECTION_DEPTH) {
350
+ return {
351
+ allowed: false,
352
+ reason: `command nests substitutions deeper than ${MAX_INSPECTION_DEPTH} levels`,
353
+ offendingText: canonicalCommand
354
+ };
355
+ }
356
+ for (const substitution of mcpSsh.extractSubstitutions(canonicalCommand)) {
357
+ const nestedVerdict = inspectWslCommand(substitution, profile, depth + 1);
358
+ if (nestedVerdict.allowed == false) {
359
+ return nestedVerdict;
360
+ }
361
+ }
362
+ for (const segment of mcpSsh.splitCommandSegments(canonicalCommand)) {
363
+ const analysis = mcpSsh.analyzeSegment(segment);
364
+ for (const rule of WSL_CATASTROPHE_RULES) {
365
+ if (rule.pattern.test(analysis.normalizedText) == true) {
366
+ return { allowed: false, reason: rule.reason, offendingText: segment.trim() };
367
+ }
368
+ }
369
+ if (profile.allowWindows == false && WINDOWS_INTEROP_BINARY_PATTERN.test(analysis.binary) == true) {
370
+ return {
371
+ allowed: false,
372
+ reason: `'${analysis.binary}' is a Windows executable: interop leaves the distro for the machine running this server (set WSL_ALLOW_WINDOWS=true to permit it)`,
373
+ offendingText: segment.trim()
374
+ };
375
+ }
376
+ const mountPaths = collectMountPaths(segment, analysis);
377
+ if (mountPaths.length === 0) {
378
+ continue;
379
+ }
380
+ const writeShaped = isWriteShaped(segment, profile.policy);
381
+ if (profile.allowWindows == false && writeShaped == true) {
382
+ const mountTarget = findMountWriteTarget(segment, analysis);
383
+ if (mountTarget != null) {
384
+ return {
385
+ allowed: false,
386
+ reason: `writing to ${mountTarget} lands on the Windows filesystem of the machine running this server (set WSL_ALLOW_WINDOWS=true to permit writes under /mnt/<drive>/; reads and copies out of it are already allowed)`,
387
+ offendingText: segment.trim()
388
+ };
389
+ }
390
+ }
391
+ let access = "read";
392
+ if (writeShaped == true) {
393
+ access = "write";
394
+ }
395
+ for (const mountPath of mountPaths) {
396
+ const protectedVerdict = inspectMountPath(mountPath, access);
397
+ if (protectedVerdict.allowed == false) {
398
+ return protectedVerdict;
399
+ }
400
+ }
401
+ }
402
+ return { allowed: true };
403
+ }
404
+ function inspectWslPath(distroPath, profile, access) {
405
+ const baseVerdict = mcpSsh.inspectRemotePath(distroPath, profile.policy, access);
406
+ if (baseVerdict.allowed == false) {
407
+ return baseVerdict;
408
+ }
409
+ if (mountPathToWindows(distroPath) == null) {
410
+ return { allowed: true };
411
+ }
412
+ if (access === "write" && profile.allowWindows == false) {
413
+ return {
414
+ allowed: false,
415
+ reason: "writes under /mnt/<drive>/ land on the Windows filesystem of the machine running this server (set WSL_ALLOW_WINDOWS=true to permit them)",
416
+ offendingText: distroPath.trim()
417
+ };
418
+ }
419
+ return inspectMountPath(distroPath.trim(), access);
420
+ }
421
+ function inspectMountPath(distroPath, access) {
422
+ const windowsPath = mountPathToWindows(distroPath);
423
+ if (windowsPath == null) {
424
+ return { allowed: true };
425
+ }
426
+ const verdict = mcpSsh.inspectLocalPath(windowsPath, access);
427
+ if (verdict.allowed == true) {
428
+ return verdict;
429
+ }
430
+ return {
431
+ allowed: false,
432
+ reason: `${verdict.reason} (reached through ${distroPath})`,
433
+ offendingText: distroPath
434
+ };
435
+ }
436
+ var DESTINATION_LAST_BINARIES = /* @__PURE__ */ new Set(["cp", "rsync", "install", "scp"]);
437
+ var DESTINATION_BY_OPTION = {
438
+ tar: /^(?:-C|--directory)$/,
439
+ unzip: /^-d$/,
440
+ curl: /^(?:-o|--output)$/,
441
+ wget: /^(?:-O|--output-document)$/
442
+ };
443
+ var TAR_CREATE_FLAG_PATTERN = /^(?:-[a-zA-Z]*[cru][a-zA-Z]*|--create|--append|--update|--concatenate|--delete)$/;
444
+ function findMountWriteTarget(segment, analysis) {
445
+ for (const redirectionTarget of collectRedirectionTargets(segment)) {
446
+ if (isMountPath(redirectionTarget) == true) {
447
+ return redirectionTarget;
448
+ }
449
+ }
450
+ const binary = analysis.binary.toLowerCase();
451
+ let candidates = analysis.operands;
452
+ if (DESTINATION_LAST_BINARIES.has(binary) == true) {
453
+ candidates = [...analysis.operands.slice(-1), ...valuesAfterOption(analysis.tokens, TARGET_DIRECTORY_OPTION)];
454
+ } else if (binary === "tar") {
455
+ const creating = analysis.flags.some((flag) => TAR_CREATE_FLAG_PATTERN.test(flag) == true);
456
+ if (creating == false) {
457
+ candidates = valuesAfterOption(analysis.tokens, DESTINATION_BY_OPTION.tar);
458
+ }
459
+ } else if (DESTINATION_BY_OPTION[binary] != null) {
460
+ candidates = valuesAfterOption(analysis.tokens, DESTINATION_BY_OPTION[binary]);
461
+ }
462
+ candidates = [...candidates, ...valuesInFlags(analysis.flags)];
463
+ for (const candidate of candidates) {
464
+ if (isMountPath(candidate) == true) {
465
+ return candidate;
466
+ }
467
+ }
468
+ return null;
469
+ }
470
+ var TARGET_DIRECTORY_OPTION = /^(?:-t|--target-directory)$/;
471
+ function valuesInFlags(flags) {
472
+ const values = [];
473
+ for (const flag of flags) {
474
+ const separator = flag.indexOf("=");
475
+ if (separator > 0) {
476
+ values.push(flag.slice(separator + 1));
477
+ }
478
+ }
479
+ return values;
480
+ }
481
+ function valuesAfterOption(tokens, optionPattern) {
482
+ const values = [];
483
+ for (let index = 0; index < tokens.length - 1; index++) {
484
+ if (optionPattern.test(tokens[index]) == true) {
485
+ values.push(tokens[index + 1]);
486
+ }
487
+ }
488
+ return values;
489
+ }
490
+ function collectRedirectionTargets(segment) {
491
+ const targets = [];
492
+ for (const match of segment.matchAll(/>{1,2}\s*(['"]?)([^\s'"|;&<>]+)/g)) {
493
+ targets.push(match[2]);
494
+ }
495
+ return targets;
496
+ }
497
+ function bareCandidate(candidate) {
498
+ return candidate.replace(/^['"]|['"]$/g, "").replace(/^[A-Za-z_][\w-]*=/, "");
499
+ }
500
+ function isMountPath(candidate) {
501
+ return /^\/mnt\/[a-z](?:\/|$)/i.test(bareCandidate(candidate));
502
+ }
503
+ function collectMountPaths(segment, analysis) {
504
+ const found = /* @__PURE__ */ new Set();
505
+ const candidates = [
506
+ ...analysis.operands,
507
+ ...valuesInFlags(analysis.flags),
508
+ ...collectRedirectionTargets(segment)
509
+ ];
510
+ for (const candidate of candidates) {
511
+ if (isMountPath(candidate) == true) {
512
+ found.add(bareCandidate(candidate));
513
+ }
514
+ }
515
+ for (const match of segment.matchAll(MOUNT_PATH_PATTERN)) {
516
+ found.add(match[0]);
517
+ }
518
+ return Array.from(found);
519
+ }
520
+ function isWriteShaped(segment, policy) {
521
+ const readonlyPolicy = { ...policy, readonly: true };
522
+ const verdict = mcpSsh.inspectCommand(segment, readonlyPolicy);
523
+ return verdict.allowed == false;
524
+ }
525
+
526
+ // src/modules/wsl/wsl-command.ts
527
+ function buildWslArguments(profile, script) {
528
+ const wslArguments = ["-d", profile.distro];
529
+ if (profile.username != null) {
530
+ wslArguments.push("-u", profile.username);
531
+ }
532
+ wslArguments.push("--exec", "bash", "-lc", script);
533
+ return wslArguments;
534
+ }
535
+ function buildDistroScript(command, cwd, trackCwd) {
536
+ const lines = [];
537
+ if (cwd != null && cwd !== "") {
538
+ const quotedCwd = quoteShellArgument(cwd);
539
+ lines.push(`cd ${quotedCwd} 2>/dev/null || { echo 'akms-mcp-wsl: cannot enter the requested working directory' >&2; exit 1; }`);
540
+ }
541
+ lines.push(command);
542
+ if (trackCwd == true) {
543
+ lines.push("__akms_exit=$?");
544
+ lines.push(`printf '\\n${CWD_MARKER}%s' "$PWD"`);
545
+ lines.push("exit $__akms_exit");
546
+ }
547
+ return lines.join("\n");
548
+ }
549
+ function extractCwdMarker(stdout) {
550
+ const markerIndex = stdout.lastIndexOf(CWD_MARKER);
551
+ if (markerIndex < 0) {
552
+ return { output: stdout };
553
+ }
554
+ const markerPayload = stdout.slice(markerIndex + CWD_MARKER.length);
555
+ const cwd = (markerPayload.split("\n")[0] ?? "").trim();
556
+ let output = stdout.slice(0, markerIndex);
557
+ if (output.endsWith("\n") == true) {
558
+ output = output.slice(0, -1);
559
+ }
560
+ if (cwd === "") {
561
+ return { output };
562
+ }
563
+ return { output, cwd };
564
+ }
565
+
566
+ // src/modules/wsl/wsl-host.ts
567
+ var activeProcessCount = 0;
568
+ var WslHost = class {
569
+ constructor(profile) {
570
+ this.profile = profile;
571
+ }
572
+ /**
573
+ * Screens a command against this profile's policy, then runs it.
574
+ *
575
+ * The canonical form is what gets screened *and* what gets sent — validating one
576
+ * string while executing another is a bypass by construction.
577
+ *
578
+ * @throws {GuardRejectionError} If the policy refuses the command or the cwd.
579
+ * @throws {Error} If `wsl.exe` itself fails (unknown distro, service down) or cannot be spawned.
580
+ */
581
+ async exec(options) {
582
+ const canonicalCommand = mcpSsh.canonicalizeCommand(options.command);
583
+ const verdict = inspectWslCommand(canonicalCommand, this.profile);
584
+ if (verdict.allowed == false) {
585
+ throw new mcpSsh.GuardRejectionError(verdict);
586
+ }
587
+ if (options.cwd != null) {
588
+ const cwdVerdict = mcpSsh.inspectWorkingDirectory(options.cwd);
589
+ if (cwdVerdict.allowed == false) {
590
+ throw new mcpSsh.GuardRejectionError(cwdVerdict);
591
+ }
592
+ }
593
+ const _logger = $logger.child({ context: "WslHost.exec", profile: this.profile.name, cwd: options.cwd });
594
+ const script = buildDistroScript(canonicalCommand, options.cwd, options.trackCwd);
595
+ const raw = await this.runProcess({
596
+ script,
597
+ timeoutMs: options.timeoutMs,
598
+ maxCapturedBytes: options.maxOutputCharacters * OUTPUT_HARD_CAP_MULTIPLIER
599
+ });
600
+ let output = raw.stdout.toString("utf8");
601
+ let cwd = void 0;
602
+ if (options.trackCwd == true) {
603
+ const parsed = extractCwdMarker(output);
604
+ output = parsed.output;
605
+ cwd = parsed.cwd;
606
+ }
607
+ const truncatedStdout = truncateOutput(output, options.maxOutputCharacters);
608
+ const truncatedStderr = truncateOutput(raw.stderr.toString("utf8"), options.maxOutputCharacters);
609
+ const result = {
610
+ stdout: truncatedStdout.text,
611
+ stderr: truncatedStderr.text,
612
+ exitCode: raw.exitCode,
613
+ timedOut: raw.timedOut,
614
+ truncated: truncatedStdout.truncated == true || truncatedStderr.truncated == true || raw.capExceeded == true,
615
+ durationMs: raw.durationMs
616
+ };
617
+ if (raw.signal != null) {
618
+ result.signal = raw.signal;
619
+ }
620
+ if (cwd != null) {
621
+ result.cwd = cwd;
622
+ }
623
+ _logger.debug("command finished", {
624
+ exit_code: result.exitCode,
625
+ duration_ms: result.durationMs,
626
+ timed_out: result.timedOut,
627
+ truncated: result.truncated
628
+ });
629
+ return result;
630
+ }
631
+ /**
632
+ * Spawns `wsl.exe` for one script and collects what it produced.
633
+ *
634
+ * Not guarded — callers own that: `exec` screens the command, the file operations
635
+ * screen the path and build the script themselves. stdin is closed (or fed and then
636
+ * closed) so anything that waits for input fails at once instead of hanging until the
637
+ * timeout.
638
+ *
639
+ * @throws {Error} If the process ceiling is reached, `wsl.exe` cannot be started, or
640
+ * `wsl.exe` reports a failure of its own rather than the command's exit
641
+ * status — that message (unknown distro, service down) is the whole diagnosis.
642
+ */
643
+ async runProcess(options) {
644
+ if (activeProcessCount >= MAX_CONCURRENT_PROCESSES) {
645
+ throw new Error(`too many concurrent wsl.exe processes (${MAX_CONCURRENT_PROCESSES}); wait for running calls to finish`);
646
+ }
647
+ const _logger = $logger.child({ context: "WslHost.runProcess", profile: this.profile.name });
648
+ const wslArguments = buildWslArguments(this.profile, options.script);
649
+ const startedAt = Date.now();
650
+ activeProcessCount += 1;
651
+ try {
652
+ const raw = await new Promise((resolve, reject) => {
653
+ let stdinMode = "ignore";
654
+ if (options.stdin != null) {
655
+ stdinMode = "pipe";
656
+ }
657
+ const child = child_process.spawn(WSL_EXECUTABLE, wslArguments, {
658
+ // WSL_UTF8: wsl.exe's *own* messages (unknown distro, service errors) are
659
+ // UTF-16 by default even on a pipe; the Linux side is UTF-8 regardless.
660
+ env: { ...process.env, WSL_UTF8: "1" },
661
+ stdio: [stdinMode, "pipe", "pipe"],
662
+ windowsHide: true
663
+ });
664
+ const stdoutStream = child.stdout;
665
+ const stderrStream = child.stderr;
666
+ if (stdoutStream == null || stderrStream == null) {
667
+ reject(new Error("wsl.exe was spawned without stdio pipes"));
668
+ return;
669
+ }
670
+ const stdoutChunks = [];
671
+ const stderrChunks = [];
672
+ let stdoutBytes = 0;
673
+ let stderrBytes = 0;
674
+ let sinkBytes = 0;
675
+ let timedOut = false;
676
+ let capExceeded = false;
677
+ let settled = false;
678
+ let stdinFailure = null;
679
+ const timeoutTimer = setTimeout(() => {
680
+ timedOut = true;
681
+ _logger.warn("command timed out, killing wsl.exe", { timeout_ms: options.timeoutMs });
682
+ child.kill();
683
+ }, options.timeoutMs);
684
+ timeoutTimer.unref();
685
+ const finish = (exitCode, signal) => {
686
+ if (settled == true) {
687
+ return;
688
+ }
689
+ settled = true;
690
+ clearTimeout(timeoutTimer);
691
+ const output = {
692
+ stdout: Buffer.concat(stdoutChunks),
693
+ stderr: Buffer.concat(stderrChunks),
694
+ sinkBytes,
695
+ exitCode,
696
+ timedOut,
697
+ capExceeded,
698
+ durationMs: Date.now() - startedAt
699
+ };
700
+ if (signal != null) {
701
+ output.signal = signal;
702
+ }
703
+ resolve(output);
704
+ };
705
+ const fail = (error) => {
706
+ if (settled == true) {
707
+ return;
708
+ }
709
+ settled = true;
710
+ clearTimeout(timeoutTimer);
711
+ reject(error);
712
+ };
713
+ const exceedCap = (stream) => {
714
+ if (capExceeded == true) {
715
+ return;
716
+ }
717
+ capExceeded = true;
718
+ _logger.warn(`${stream} exceeded the hard cap, killing wsl.exe`, { hard_cap: options.maxCapturedBytes });
719
+ child.kill();
720
+ };
721
+ if (options.stdoutSink != null) {
722
+ const sink = options.stdoutSink;
723
+ stdoutStream.on("data", (chunk) => {
724
+ sinkBytes += chunk.length;
725
+ });
726
+ stdoutStream.pipe(sink, { end: false });
727
+ sink.on("error", fail);
728
+ } else {
729
+ stdoutStream.on("data", (chunk) => {
730
+ if (capExceeded == true) {
731
+ return;
732
+ }
733
+ stdoutChunks.push(chunk);
734
+ stdoutBytes += chunk.length;
735
+ if (stdoutBytes > options.maxCapturedBytes) {
736
+ exceedCap("stdout");
737
+ }
738
+ });
739
+ }
740
+ stderrStream.on("data", (chunk) => {
741
+ if (capExceeded == true) {
742
+ return;
743
+ }
744
+ stderrChunks.push(chunk);
745
+ stderrBytes += chunk.length;
746
+ if (stderrBytes > options.maxCapturedBytes) {
747
+ exceedCap("stderr");
748
+ }
749
+ });
750
+ child.on("error", fail);
751
+ child.on("close", (exitCode, signal) => {
752
+ if (stdinFailure != null) {
753
+ fail(new Error(`local input failed while feeding the distro: ${stdinFailure.message}`));
754
+ return;
755
+ }
756
+ finish(exitCode, signal);
757
+ });
758
+ if (options.stdin != null && child.stdin != null) {
759
+ child.stdin.on("error", () => {
760
+ });
761
+ if (Buffer.isBuffer(options.stdin) == true) {
762
+ child.stdin.end(options.stdin);
763
+ } else {
764
+ const source = options.stdin;
765
+ source.on("error", (error) => {
766
+ stdinFailure = error;
767
+ child.kill();
768
+ });
769
+ source.pipe(child.stdin);
770
+ }
771
+ }
772
+ });
773
+ if (raw.exitCode != null && WSL_FAILURE_EXIT_CODES.includes(raw.exitCode) == true) {
774
+ let detail = `${raw.stdout.toString("utf8")}
775
+ ${raw.stderr.toString("utf8")}`.trim();
776
+ if (detail === "" && options.stdoutSink != null) {
777
+ detail = "wsl.exe reported a failure of its own (unknown distro, WSL service down) \u2014 run akms-mcp-wsl --check";
778
+ }
779
+ throw new Error(`wsl.exe failed for distro '${this.profile.distro}': ${detail}`);
780
+ }
781
+ return raw;
782
+ } finally {
783
+ activeProcessCount -= 1;
784
+ }
785
+ }
786
+ };
787
+ var MAX_OPEN_SESSIONS = 32;
788
+ var WslSession = class {
789
+ constructor(sessionId, host) {
790
+ this.sessionId = sessionId;
791
+ this.host = host;
792
+ this.execCount = 0;
793
+ this.openedAt = /* @__PURE__ */ new Date();
794
+ /** Tail of the serialized operation chain; see `runExclusive`. */
795
+ this.operationChain = Promise.resolve();
796
+ this.activeOperationCount = 0;
797
+ this.cwd = host.profile.defaultCwd ?? "";
798
+ this.lastUsedAt = /* @__PURE__ */ new Date();
799
+ }
800
+ get profile() {
801
+ return this.host.profile;
802
+ }
803
+ /** True while an operation is running, so the idle sweeper leaves it alone. */
804
+ get isBusy() {
805
+ return this.activeOperationCount > 0;
806
+ }
807
+ touch() {
808
+ this.lastUsedAt = /* @__PURE__ */ new Date();
809
+ }
810
+ /**
811
+ * Runs `operation` with exclusive use of this session.
812
+ *
813
+ * Serialized because `cwd` is read-modify-write across a process run: two concurrent
814
+ * calls both read the old directory, and the one that finishes second writes it back —
815
+ * silently undoing the other's `cd`.
816
+ */
817
+ async runExclusive(operation) {
818
+ const previous = this.operationChain;
819
+ let releaseChain = () => {
820
+ };
821
+ this.operationChain = new Promise((resolve) => {
822
+ releaseChain = resolve;
823
+ });
824
+ await previous.catch(() => {
825
+ });
826
+ this.activeOperationCount += 1;
827
+ this.touch();
828
+ try {
829
+ return await operation(this.host);
830
+ } finally {
831
+ this.activeOperationCount -= 1;
832
+ this.touch();
833
+ releaseChain();
834
+ }
835
+ }
836
+ /**
837
+ * Runs a command in this session's working directory and adopts wherever it ended up,
838
+ * so a `cd` in one call is still in effect on the next.
839
+ *
840
+ * Passing `cwd` runs this command elsewhere *and* moves the session there — same
841
+ * result as prefixing `cd <cwd> &&`, which is what a shell user would expect.
842
+ */
843
+ async exec(command, options) {
844
+ const policy = this.profile.policy;
845
+ const targetCwd = options?.cwd ?? this.cwd;
846
+ return await this.runExclusive(async (host) => {
847
+ const result = await host.exec({
848
+ command,
849
+ cwd: targetCwd,
850
+ timeoutMs: options?.timeoutMs ?? policy.execTimeoutMs,
851
+ maxOutputCharacters: policy.maxOutputCharacters,
852
+ trackCwd: true
853
+ });
854
+ if (result.cwd != null && result.cwd !== "") {
855
+ const verdict = mcpSsh.inspectWorkingDirectory(result.cwd);
856
+ if (verdict.allowed == true) {
857
+ this.cwd = result.cwd;
858
+ } else {
859
+ const _logger = $logger.child({ context: "WslSession.exec", session_id: this.sessionId });
860
+ _logger.warn("reported working directory contains unsafe characters, keeping the previous cwd", {
861
+ reported_cwd: result.cwd
862
+ });
863
+ }
864
+ }
865
+ this.execCount += 1;
866
+ return result;
867
+ });
868
+ }
869
+ toInfo() {
870
+ let displayCwd = this.cwd;
871
+ if (displayCwd === "") {
872
+ displayCwd = "(login default)";
873
+ }
874
+ return {
875
+ sessionId: this.sessionId,
876
+ profileName: this.profile.name,
877
+ distro: this.profile.distro,
878
+ username: this.profile.username ?? "(distro default)",
879
+ cwd: displayCwd,
880
+ openedAt: this.openedAt.toISOString(),
881
+ lastUsedAt: this.lastUsedAt.toISOString(),
882
+ execCount: this.execCount
883
+ };
884
+ }
885
+ };
886
+ var WslSessionManager = class {
887
+ constructor() {
888
+ this.sessions = /* @__PURE__ */ new Map();
889
+ this.sequence = 0;
890
+ this.sweepTimer = null;
891
+ }
892
+ /**
893
+ * Registers a session. Nothing is opened — the caller probes with `pwd` to prove
894
+ * the distro answers and to settle the starting directory.
895
+ *
896
+ * @throws {Error} If the session ceiling is reached.
897
+ */
898
+ open(host) {
899
+ if (this.sessions.size >= MAX_OPEN_SESSIONS) {
900
+ throw new Error(`too many open wsl sessions (${MAX_OPEN_SESSIONS}), close some with wsl_disconnect first`);
901
+ }
902
+ this.sequence += 1;
903
+ const sessionId = `${host.profile.name}#${this.sequence}`;
904
+ const session = new WslSession(sessionId, host);
905
+ this.sessions.set(sessionId, session);
906
+ const _logger = $logger.child({ context: "WslSessionManager.open", session_id: sessionId, profile: host.profile.name });
907
+ _logger.info("wsl session opened", { open_sessions: this.sessions.size });
908
+ return session;
909
+ }
910
+ /**
911
+ * Looks up a session, failing with the list of live ids so the caller can self-correct.
912
+ *
913
+ * @throws {Error} If no session matches `sessionId`.
914
+ */
915
+ require(sessionId) {
916
+ const session = this.sessions.get(sessionId);
917
+ if (session == null) {
918
+ const openIds = Array.from(this.sessions.keys());
919
+ let openSummary = "none";
920
+ if (openIds.length > 0) {
921
+ openSummary = openIds.join(", ");
922
+ }
923
+ throw new Error(`unknown wsl session '${sessionId}' (open sessions: ${openSummary})`);
924
+ }
925
+ return session;
926
+ }
927
+ close(sessionId) {
928
+ const deleted = this.sessions.delete(sessionId);
929
+ if (deleted == false) {
930
+ return false;
931
+ }
932
+ const _logger = $logger.child({ context: "WslSessionManager.close", session_id: sessionId });
933
+ _logger.info("wsl session closed", { open_sessions: this.sessions.size });
934
+ return true;
935
+ }
936
+ closeAll() {
937
+ for (const sessionId of Array.from(this.sessions.keys())) {
938
+ this.close(sessionId);
939
+ }
940
+ }
941
+ list() {
942
+ return Array.from(this.sessions.values()).map((session) => session.toInfo());
943
+ }
944
+ /** Starts the idle sweeper; the timer is unref'd so it never holds the process open. */
945
+ startIdleSweeper() {
946
+ if (this.sweepTimer != null) {
947
+ return;
948
+ }
949
+ this.sweepTimer = timers.setInterval(() => {
950
+ const expiredAt = Date.now() - SESSION_IDLE_TIMEOUT_MS;
951
+ for (const session of Array.from(this.sessions.values())) {
952
+ if (session.isBusy == true) {
953
+ continue;
954
+ }
955
+ if (session.lastUsedAt.getTime() > expiredAt) {
956
+ continue;
957
+ }
958
+ const _logger = $logger.child({
959
+ context: "WslSessionManager.sweep",
960
+ session_id: session.sessionId,
961
+ idle_ms: Date.now() - session.lastUsedAt.getTime()
962
+ });
963
+ _logger.info("forgetting idle wsl session");
964
+ this.close(session.sessionId);
965
+ }
966
+ }, SESSION_SWEEP_INTERVAL_MS);
967
+ this.sweepTimer.unref();
968
+ }
969
+ stopIdleSweeper() {
970
+ if (this.sweepTimer == null) {
971
+ return;
972
+ }
973
+ timers.clearInterval(this.sweepTimer);
974
+ this.sweepTimer = null;
975
+ }
976
+ };
977
+ var METADATA_CAPTURE_BYTES = 4 * 1024 * 1024;
978
+ function requirePermittedPath(host, distroPath, access) {
979
+ const verdict = inspectWslPath(distroPath, host.profile, access);
980
+ if (verdict.allowed == false) {
981
+ throw new mcpSsh.GuardRejectionError(verdict);
982
+ }
983
+ }
984
+ async function runFixedScript(host, script, stdin) {
985
+ const output = await host.runProcess({
986
+ script,
987
+ stdin,
988
+ timeoutMs: host.profile.policy.execTimeoutMs,
989
+ maxCapturedBytes: METADATA_CAPTURE_BYTES
990
+ });
991
+ if (output.timedOut == true) {
992
+ throw new Error(`timed out after ${host.profile.policy.execTimeoutMs}ms`);
993
+ }
994
+ if (output.exitCode !== 0) {
995
+ const detail = output.stderr.toString("utf8").trim();
996
+ throw new Error(detail === "" ? `exit code ${output.exitCode}` : detail);
997
+ }
998
+ return output.stdout;
999
+ }
1000
+ var ENTRY_TYPES = {
1001
+ f: "file",
1002
+ d: "directory",
1003
+ l: "symlink"
1004
+ };
1005
+ async function listDirectory(host, distroPath) {
1006
+ requirePermittedPath(host, distroPath, "read");
1007
+ const quotedPath = quoteShellArgument(distroPath);
1008
+ const findPrefix = `find ${quotedPath} -mindepth 1 -maxdepth 1`;
1009
+ const script = [
1010
+ `test -d ${quotedPath} || { echo 'not a directory' >&2; exit 2; }`,
1011
+ // First line: the true entry count. The listing itself is bounded with `head`, so
1012
+ // a directory of a million files costs the distro a `find`, not this server 4 MB.
1013
+ `${findPrefix} -printf x | wc -c`,
1014
+ // One line per entry: type \t mode \t size \t mtime \t name. `-printf` never quotes,
1015
+ // and a name cannot contain a newline in any directory a tool should be listing.
1016
+ `${findPrefix} -printf '%y\\t%m\\t%s\\t%TY-%Tm-%TdT%TH:%TM:%.2TS\\t%f\\n' | head -n ${MAX_DIRECTORY_ENTRIES + 1}`
1017
+ ].join("\n");
1018
+ const stdout = await runFixedScript(host, script);
1019
+ const [countLine, ...lines] = stdout.toString("utf8").split("\n").filter((line) => line !== "");
1020
+ const reportedCount = Number(countLine) || 0;
1021
+ const entries = [];
1022
+ for (const line of lines) {
1023
+ const [typeLetter, mode, size, modifiedAt, ...nameParts] = line.split(" ");
1024
+ const name = nameParts.join(" ");
1025
+ if (name == null || name === "") {
1026
+ continue;
1027
+ }
1028
+ entries.push({
1029
+ name,
1030
+ type: ENTRY_TYPES[typeLetter] ?? "other",
1031
+ sizeBytes: Number(size) || 0,
1032
+ mode: `0${mode}`,
1033
+ modifiedAt
1034
+ });
1035
+ }
1036
+ entries.sort((left, right) => {
1037
+ if (left.type === "directory" && right.type !== "directory") {
1038
+ return -1;
1039
+ }
1040
+ if (left.type !== "directory" && right.type === "directory") {
1041
+ return 1;
1042
+ }
1043
+ return left.name.localeCompare(right.name);
1044
+ });
1045
+ const totalCount = Math.max(reportedCount, entries.length);
1046
+ const truncated = totalCount > MAX_DIRECTORY_ENTRIES;
1047
+ return {
1048
+ entries: entries.slice(0, MAX_DIRECTORY_ENTRIES),
1049
+ totalCount,
1050
+ truncated
1051
+ };
1052
+ }
1053
+ async function readFile(host, distroPath, maxBytes) {
1054
+ requirePermittedPath(host, distroPath, "read");
1055
+ const quotedPath = quoteShellArgument(distroPath);
1056
+ const statScript = [
1057
+ `test -e ${quotedPath} || { echo 'no such file' >&2; exit 2; }`,
1058
+ `test -d ${quotedPath} && { echo 'is a directory' >&2; exit 2; }`,
1059
+ `stat -c %s ${quotedPath}`
1060
+ ].join("\n");
1061
+ const sizeBytes = Number((await runFixedScript(host, statScript)).toString("utf8").trim()) || 0;
1062
+ const probeBytes = maxBytes + 1;
1063
+ const output = await host.runProcess({
1064
+ script: `head -c ${probeBytes} ${quotedPath}`,
1065
+ timeoutMs: host.profile.policy.execTimeoutMs,
1066
+ maxCapturedBytes: probeBytes
1067
+ });
1068
+ if (output.exitCode !== 0 && output.capExceeded == false) {
1069
+ throw new Error(output.stderr.toString("utf8").trim() || `exit code ${output.exitCode}`);
1070
+ }
1071
+ const truncated = output.stdout.length > maxBytes;
1072
+ const kept = output.stdout.subarray(0, maxBytes);
1073
+ return {
1074
+ content: kept.toString("utf8"),
1075
+ sizeBytes,
1076
+ readBytes: kept.length,
1077
+ truncated
1078
+ };
1079
+ }
1080
+ async function writeFile(host, args) {
1081
+ const { distroPath, content, append } = args;
1082
+ requirePermittedPath(host, distroPath, "write");
1083
+ const payload = Buffer.from(content, "utf8");
1084
+ const quotedPath = quoteShellArgument(distroPath);
1085
+ let redirection = ">";
1086
+ if (append == true) {
1087
+ redirection = ">>";
1088
+ }
1089
+ await runFixedScript(host, `cat ${redirection} ${quotedPath}`, payload);
1090
+ return payload.length;
1091
+ }
1092
+ async function uploadFile(host, localPath, distroPath) {
1093
+ requirePermittedPath(host, distroPath, "write");
1094
+ const localStats = fs__default.default.statSync(localPath);
1095
+ if (localStats.isFile() == false) {
1096
+ throw new Error(`local path is not a regular file: ${localPath}`);
1097
+ }
1098
+ const stream = fs__default.default.createReadStream(localPath);
1099
+ try {
1100
+ await runFixedScript(host, `cat > ${quoteShellArgument(distroPath)}`, stream);
1101
+ } finally {
1102
+ stream.destroy();
1103
+ }
1104
+ return localStats.size;
1105
+ }
1106
+ async function downloadFile(host, distroPath, localPath) {
1107
+ requirePermittedPath(host, distroPath, "read");
1108
+ const parent = path__default.default.dirname(localPath);
1109
+ if (fs__default.default.existsSync(parent) == false) {
1110
+ throw new Error(`local directory does not exist: ${parent}`);
1111
+ }
1112
+ const quotedPath = quoteShellArgument(distroPath);
1113
+ const script = [
1114
+ `test -f ${quotedPath} || { echo 'not a regular file' >&2; exit 2; }`,
1115
+ `cat ${quotedPath}`
1116
+ ].join("\n");
1117
+ const sink = fs__default.default.createWriteStream(localPath);
1118
+ let output;
1119
+ try {
1120
+ output = await host.runProcess({
1121
+ script,
1122
+ stdoutSink: sink,
1123
+ timeoutMs: host.profile.policy.execTimeoutMs,
1124
+ maxCapturedBytes: METADATA_CAPTURE_BYTES
1125
+ });
1126
+ if (output.timedOut == true) {
1127
+ throw new Error(`timed out after ${host.profile.policy.execTimeoutMs}ms; the partial file was removed`);
1128
+ }
1129
+ if (output.exitCode !== 0) {
1130
+ throw new Error(output.stderr.toString("utf8").trim() || `exit code ${output.exitCode}`);
1131
+ }
1132
+ } catch (ex) {
1133
+ await new Promise((resolve) => {
1134
+ sink.end(resolve);
1135
+ });
1136
+ fs__default.default.rmSync(localPath, { force: true });
1137
+ throw ex;
1138
+ }
1139
+ await new Promise((resolve) => {
1140
+ sink.end(resolve);
1141
+ });
1142
+ return output.sinkBytes;
1143
+ }
1144
+ var LISTING_TIMEOUT_MS = 1e4;
1145
+ var PROBE_CAPTURE_BYTES = 64 * 1024;
1146
+ var LISTING_ROW_PATTERN = /^\s*\*?\s*(.+?)\s+(Running|Stopped|Installing)\s+(\d)\s*$/;
1147
+ async function queryDistroListing(distro) {
1148
+ const _logger = $logger.child({ context: "queryDistroListing", distro });
1149
+ const listing = await new Promise((resolve, reject) => {
1150
+ const child = child_process.spawn(WSL_EXECUTABLE, ["-l", "-v"], {
1151
+ env: { ...process.env, WSL_UTF8: "1" },
1152
+ stdio: ["ignore", "pipe", "pipe"],
1153
+ windowsHide: true
1154
+ });
1155
+ const chunks = [];
1156
+ const timer = setTimeout(() => {
1157
+ child.kill();
1158
+ }, LISTING_TIMEOUT_MS);
1159
+ timer.unref();
1160
+ child.stdout.on("data", (chunk) => {
1161
+ chunks.push(chunk);
1162
+ });
1163
+ child.on("error", (error) => {
1164
+ clearTimeout(timer);
1165
+ reject(error);
1166
+ });
1167
+ child.on("close", () => {
1168
+ clearTimeout(timer);
1169
+ resolve(Buffer.concat(chunks).toString("utf8"));
1170
+ });
1171
+ });
1172
+ for (const line of listing.split(/\r?\n/)) {
1173
+ const match = LISTING_ROW_PATTERN.exec(line);
1174
+ if (match == null) {
1175
+ continue;
1176
+ }
1177
+ if (match[1].toLowerCase() !== distro.toLowerCase()) {
1178
+ continue;
1179
+ }
1180
+ return {
1181
+ installed: true,
1182
+ state: match[2],
1183
+ wslVersion: Number(match[3])
1184
+ };
1185
+ }
1186
+ _logger.warn("distro not present in wsl -l -v");
1187
+ return { installed: false, state: "unknown" };
1188
+ }
1189
+ async function probeDistroFacts(host) {
1190
+ const script = [
1191
+ `printf 'mode=%s\\n' "$(wslinfo --networking-mode 2>/dev/null || echo unknown)"`,
1192
+ `printf 'init=%s\\n' "$(ps -p 1 -o comm= 2>/dev/null || echo unknown)"`,
1193
+ `printf 'kernel=%s\\n' "$(uname -r 2>/dev/null || echo unknown)"`
1194
+ ].join("\n");
1195
+ const output = await host.runProcess({
1196
+ script,
1197
+ timeoutMs: LISTING_TIMEOUT_MS,
1198
+ maxCapturedBytes: PROBE_CAPTURE_BYTES
1199
+ });
1200
+ const facts = {};
1201
+ for (const line of output.stdout.toString("utf8").split("\n")) {
1202
+ const [key, ...rest] = line.split("=");
1203
+ const value = rest.join("=").trim();
1204
+ if (key === "mode") {
1205
+ facts.networkingMode = value;
1206
+ } else if (key === "init") {
1207
+ facts.systemd = value === "systemd";
1208
+ } else if (key === "kernel") {
1209
+ facts.kernel = value;
1210
+ }
1211
+ }
1212
+ return facts;
1213
+ }
1214
+
1215
+ // src/modules/tools/format.ts
1216
+ var ENTRY_TYPE_MARKERS = {
1217
+ file: "-",
1218
+ directory: "d",
1219
+ symlink: "l",
1220
+ other: "?"
1221
+ };
1222
+ function formatHostProfile(profile, status) {
1223
+ if (profile == null) {
1224
+ return [
1225
+ "No WSL distro is registered.",
1226
+ "",
1227
+ "Put the distro in this server's MCP entry \u2014 one server entry per distro and user:",
1228
+ "",
1229
+ ` "env": {`,
1230
+ ` "${SINGLE_HOST_ENV.DISTRO}": "ubuntu",`,
1231
+ ` "${SINGLE_HOST_ENV.USER}": "root"`,
1232
+ " }",
1233
+ "",
1234
+ `${SINGLE_HOST_ENV.DISTRO} is the whole minimum (the name as 'wsl -l -q' prints it); ${SINGLE_HOST_ENV.USER} defaults to the distro's default user.`,
1235
+ "Restart this server after changing it \u2014 environment variables are fixed for the life of the process."
1236
+ ].join("\n");
1237
+ }
1238
+ const summary = toHostSummary(profile);
1239
+ let accessLabel = "writable";
1240
+ if (summary.readonly == true) {
1241
+ accessLabel = "READ-ONLY";
1242
+ }
1243
+ let sudoLabel = "sudo=disabled";
1244
+ if (summary.allowSudo == true) {
1245
+ sudoLabel = "sudo=enabled";
1246
+ }
1247
+ let windowsLabel = "windows=locked (no interop .exe, no writes under /mnt/<drive>/)";
1248
+ if (summary.allowWindows == true) {
1249
+ windowsLabel = "windows=open (interop .exe and /mnt/<drive>/ writes allowed; operator credential and MCP config paths still refused)";
1250
+ }
1251
+ const lines = [];
1252
+ lines.push(`${summary.name} \u2192 ${summary.username}@${summary.distro} (WSL, this machine) ${accessLabel} ${sudoLabel}`);
1253
+ lines.push(` ${windowsLabel}`);
1254
+ if (summary.description != null) {
1255
+ lines.push(` ${summary.description}`);
1256
+ }
1257
+ if (status != null) {
1258
+ lines.push(` ${formatDistroStatus(status)}`);
1259
+ }
1260
+ if (summary.allowedPaths.length > 0) {
1261
+ lines.push(` file tools limited to: ${summary.allowedPaths.join(", ")} (does not restrict wsl_exec)`);
1262
+ }
1263
+ if (summary.allowCommands.length > 0) {
1264
+ lines.push(` commands limited to: ${summary.allowCommands.join(", ")}`);
1265
+ }
1266
+ return lines.join("\n");
1267
+ }
1268
+ function formatDistroStatus(status) {
1269
+ if (status.installed == false) {
1270
+ return "distro: NOT FOUND in 'wsl -l -v' \u2014 check the name (wsl -l -q) and the WSL_DISTRO variable";
1271
+ }
1272
+ const parts = [`distro: ${status.state}`];
1273
+ if (status.wslVersion != null) {
1274
+ parts.push(`WSL ${status.wslVersion}`);
1275
+ }
1276
+ if (status.networkingMode != null) {
1277
+ parts.push(`networking ${status.networkingMode}`);
1278
+ }
1279
+ if (status.systemd != null) {
1280
+ if (status.systemd == true) {
1281
+ parts.push("systemd");
1282
+ } else {
1283
+ parts.push("no systemd");
1284
+ }
1285
+ }
1286
+ if (status.kernel != null) {
1287
+ parts.push(`kernel ${status.kernel}`);
1288
+ }
1289
+ if (status.state === "Stopped") {
1290
+ parts.push("(the first command will start it)");
1291
+ }
1292
+ return parts.join(" \xB7 ");
1293
+ }
1294
+ function formatExecResult(result, timeoutMs) {
1295
+ const headerParts = [];
1296
+ if (result.exitCode != null) {
1297
+ headerParts.push(`exit code: ${result.exitCode}`);
1298
+ } else {
1299
+ headerParts.push("exit code: none (killed)");
1300
+ }
1301
+ if (result.signal != null) {
1302
+ headerParts.push(`signal: ${result.signal}`);
1303
+ }
1304
+ headerParts.push(`${result.durationMs}ms`);
1305
+ if (result.cwd != null) {
1306
+ headerParts.push(`cwd: ${result.cwd}`);
1307
+ }
1308
+ const lines = [headerParts.join(" \xB7 ")];
1309
+ if (result.timedOut == true) {
1310
+ lines.push(`TIMED OUT after ${timeoutMs}ms \u2014 wsl.exe was killed, but the process inside the distro may still be running.`);
1311
+ }
1312
+ if (result.truncated == true) {
1313
+ lines.push("Output was truncated; narrow the command (grep / head / tail) or raise WSL_MAX_OUTPUT for this profile.");
1314
+ }
1315
+ lines.push("");
1316
+ lines.push("--- stdout ---");
1317
+ if (result.stdout === "") {
1318
+ lines.push("(empty)");
1319
+ } else {
1320
+ lines.push(result.stdout);
1321
+ }
1322
+ if (result.stderr !== "") {
1323
+ lines.push("");
1324
+ lines.push("--- stderr ---");
1325
+ lines.push(result.stderr);
1326
+ }
1327
+ return lines.join("\n");
1328
+ }
1329
+ function formatGuardRejection(action, verdict) {
1330
+ const lines = [`Blocked by the host profile's guard policy: ${verdict.reason}`];
1331
+ if (verdict.offendingText != null && verdict.offendingText !== action) {
1332
+ lines.push(`Offending part: ${verdict.offendingText}`);
1333
+ }
1334
+ lines.push(`Requested: ${action}`);
1335
+ lines.push("Nothing was run in the distro. Adjust the request, or relax the profile's policy in the MCP entry.");
1336
+ return lines.join("\n");
1337
+ }
1338
+ function formatSessionList(sessions) {
1339
+ if (sessions.length === 0) {
1340
+ return "No open WSL sessions. Open one with wsl_connect, or omit 'session' to run a one-off command.";
1341
+ }
1342
+ const lines = [`${sessions.length} open WSL session(s):`];
1343
+ const now = Date.now();
1344
+ for (const session of sessions) {
1345
+ const idleSeconds = Math.round((now - new Date(session.lastUsedAt).getTime()) / 1e3);
1346
+ lines.push("");
1347
+ lines.push(`${session.sessionId} \u2192 ${session.username}@${session.distro} profile: ${session.profileName}`);
1348
+ lines.push(` cwd: ${session.cwd} \xB7 ${session.execCount} command(s) \xB7 idle ${idleSeconds}s \xB7 opened ${session.openedAt}`);
1349
+ }
1350
+ return lines.join("\n");
1351
+ }
1352
+ function formatDirectoryListing(distroPath, listing) {
1353
+ if (listing.totalCount === 0) {
1354
+ return `${distroPath} is empty.`;
1355
+ }
1356
+ let header = `${distroPath} \u2014 ${listing.totalCount} entrie(s):`;
1357
+ if (listing.truncated == true) {
1358
+ header = `${distroPath} \u2014 ${listing.totalCount} entrie(s), showing the first ${listing.entries.length}:`;
1359
+ }
1360
+ const lines = [header, ""];
1361
+ for (const entry of listing.entries) {
1362
+ const marker = ENTRY_TYPE_MARKERS[entry.type];
1363
+ const size = formatBytes(entry.sizeBytes).padStart(9);
1364
+ let name = entry.name;
1365
+ if (entry.type === "directory") {
1366
+ name = `${entry.name}/`;
1367
+ }
1368
+ lines.push(`${marker} ${entry.mode} ${size} ${entry.modifiedAt} ${name}`);
1369
+ }
1370
+ if (listing.truncated == true) {
1371
+ lines.push("");
1372
+ lines.push("Listing was capped; narrow the path or filter with wsl_exec if you need the rest.");
1373
+ }
1374
+ return lines.join("\n");
1375
+ }
1376
+
1377
+ // src/modules/tools/tool-context.ts
1378
+ function textResult(text) {
1379
+ return { content: [{ type: "text", text }] };
1380
+ }
1381
+ function errorResult(message) {
1382
+ return { content: [{ type: "text", text: message }], isError: true };
1383
+ }
1384
+ function describeError2(error) {
1385
+ if (error instanceof Error) {
1386
+ return error.message;
1387
+ }
1388
+ return String(error);
1389
+ }
1390
+ function toToolError(error, toolName, action) {
1391
+ const _logger = $logger.child({ context: toolName });
1392
+ if (error instanceof mcpSsh.GuardRejectionError) {
1393
+ _logger.warn("rejected by the guard", { reason: error.verdict.reason, action });
1394
+ const rendered = formatGuardRejection(action, error.verdict);
1395
+ return errorResult(rendered);
1396
+ }
1397
+ _logger.error(error, `${toolName} failed`);
1398
+ return errorResult(`${toolName} failed: ${describeError2(error)}`);
1399
+ }
1400
+ function resolveHost(context, args) {
1401
+ const hasSession = args.session != null && args.session.trim() !== "";
1402
+ if (hasSession == true) {
1403
+ const session = context.sessions.require(args.session.trim());
1404
+ return { host: session.host, session };
1405
+ }
1406
+ if (context.host == null) {
1407
+ throw new Error(`no distro is registered \u2014 set ${SINGLE_HOST_ENV.DISTRO} (and optionally ${SINGLE_HOST_ENV.USER}) in this server's env block, then restart it`);
1408
+ }
1409
+ return { host: context.host, session: null };
1410
+ }
1411
+
1412
+ // src/modules/tools/exec-tools.ts
1413
+ function registerExecTools(server, context) {
1414
+ server.registerTool(
1415
+ "wsl_exec",
1416
+ {
1417
+ title: "Run a command in WSL",
1418
+ description: [
1419
+ "Run a shell command in the configured WSL distro (as the configured user, in a login bash) and return stdout, stderr and the exit code.",
1420
+ "Pass 'session' to reuse a session, which remembers its working directory; omit it for a one-off call.",
1421
+ "A non-zero exit code is reported as normal output, not as a tool error \u2014 read the exit code and stderr to judge the outcome.",
1422
+ "Almost everything is permitted: package installs, service restarts, sudo, interpreters. Refused outright: catastrophic operations (wiping the filesystem root, formatting a disk, powering off or shutting down the distro), and \u2014 unless the profile opens them \u2014 Windows interop executables and writes under /mnt/<drive>/.",
1423
+ "stdin is closed. A job that must outlive the call needs all three descriptors detached: nohup cmd > log 2>&1 < /dev/null &"
1424
+ ].join(" "),
1425
+ inputSchema: {
1426
+ command: zod.z.string().min(1).describe("Shell command, exactly as it would be typed in a terminal. Chaining with ';', '&&', '||' and pipes is allowed; each part is screened separately."),
1427
+ session: zod.z.string().optional().describe("Session id from wsl_connect. Keeps the working directory across calls."),
1428
+ cwd: zod.z.string().optional().describe("Absolute directory to run in, with no shell metacharacters. With a session, the session also moves there."),
1429
+ timeoutMs: zod.z.number().int().positive().optional().describe("Lowers the command timeout for this call; it cannot exceed the profile's own limit. wsl.exe is killed when it elapses; the process inside the distro may survive.")
1430
+ },
1431
+ annotations: {
1432
+ readOnlyHint: false,
1433
+ destructiveHint: true,
1434
+ idempotentHint: false,
1435
+ openWorldHint: true
1436
+ }
1437
+ },
1438
+ async ({ command, session, cwd, timeoutMs }) => {
1439
+ let _logger = $logger.child({ context: "wsl_exec" });
1440
+ try {
1441
+ const { host, session: openSession } = resolveHost(context, { session });
1442
+ const profile = host.profile;
1443
+ _logger = _logger.child({ profile: profile.name, session_id: openSession?.sessionId });
1444
+ const requestedTimeoutMs = timeoutMs ?? profile.policy.execTimeoutMs;
1445
+ const effectiveTimeoutMs = Math.min(requestedTimeoutMs, profile.policy.execTimeoutMs);
1446
+ let requestedCwd = void 0;
1447
+ if (cwd != null && cwd.trim() !== "") {
1448
+ requestedCwd = cwd.trim();
1449
+ }
1450
+ _logger.debug("running command", { command, timeout_ms: effectiveTimeoutMs });
1451
+ let result;
1452
+ if (openSession != null) {
1453
+ result = await openSession.exec(command, { cwd: requestedCwd, timeoutMs: effectiveTimeoutMs });
1454
+ } else {
1455
+ result = await host.exec({
1456
+ command,
1457
+ cwd: requestedCwd ?? profile.defaultCwd,
1458
+ timeoutMs: effectiveTimeoutMs,
1459
+ maxOutputCharacters: profile.policy.maxOutputCharacters,
1460
+ trackCwd: false
1461
+ });
1462
+ }
1463
+ _logger.info("command finished", {
1464
+ exit_code: result.exitCode,
1465
+ duration_ms: result.durationMs,
1466
+ timed_out: result.timedOut
1467
+ });
1468
+ return textResult(formatExecResult(result, effectiveTimeoutMs));
1469
+ } catch (ex) {
1470
+ return toToolError(ex, "wsl_exec", command);
1471
+ }
1472
+ }
1473
+ );
1474
+ }
1475
+ var SESSION_DESCRIPTION = "Session id from wsl_connect. Optional \u2014 the file tools do not depend on a session's working directory, paths are absolute.";
1476
+ function requirePermittedLocalPath(localPath, access) {
1477
+ const verdict = mcpSsh.inspectLocalPath(localPath, access);
1478
+ if (verdict.allowed == false) {
1479
+ throw new mcpSsh.GuardRejectionError(verdict);
1480
+ }
1481
+ }
1482
+ function registerFileTools(server, context) {
1483
+ server.registerTool(
1484
+ "wsl_list_dir",
1485
+ {
1486
+ title: "List a directory in WSL",
1487
+ description: [
1488
+ "List a directory in the distro, with type, permissions, size and mtime.",
1489
+ "Cheaper and more structured than running 'ls -la' through wsl_exec, and it works on a read-only profile."
1490
+ ].join(" "),
1491
+ inputSchema: {
1492
+ path: zod.z.string().min(1).describe("Absolute directory path in the distro. When WSL_ALLOWED_PATHS is set, only paths inside it are accepted."),
1493
+ session: zod.z.string().optional().describe(SESSION_DESCRIPTION)
1494
+ },
1495
+ annotations: {
1496
+ readOnlyHint: true,
1497
+ destructiveHint: false,
1498
+ idempotentHint: true,
1499
+ openWorldHint: true
1500
+ }
1501
+ },
1502
+ async ({ path: distroPath, session }) => {
1503
+ const _logger = $logger.child({ context: "wsl_list_dir", distro_path: distroPath });
1504
+ try {
1505
+ const { host } = resolveHost(context, { session });
1506
+ const listing = await listDirectory(host, distroPath);
1507
+ _logger.info("directory listed", { entry_count: listing.totalCount, truncated: listing.truncated });
1508
+ return textResult(formatDirectoryListing(distroPath, listing));
1509
+ } catch (ex) {
1510
+ return toToolError(ex, "wsl_list_dir", `list ${distroPath}`);
1511
+ }
1512
+ }
1513
+ );
1514
+ server.registerTool(
1515
+ "wsl_read_file",
1516
+ {
1517
+ title: "Read a file in WSL",
1518
+ description: [
1519
+ "Read a text file in the distro and return its contents.",
1520
+ "Oversized files come back truncated to their leading bytes rather than failing, so pointing this at a large log is safe.",
1521
+ "Files that report size 0 but hold content (/proc, /sys) are read in full up to the ceiling."
1522
+ ].join(" "),
1523
+ inputSchema: {
1524
+ path: zod.z.string().min(1).describe("Absolute file path in the distro."),
1525
+ session: zod.z.string().optional().describe(SESSION_DESCRIPTION),
1526
+ maxBytes: zod.z.number().int().positive().optional().describe("Lowers the read ceiling for this call; it cannot exceed WSL_MAX_READ_BYTES.")
1527
+ },
1528
+ annotations: {
1529
+ readOnlyHint: true,
1530
+ destructiveHint: false,
1531
+ idempotentHint: true,
1532
+ openWorldHint: true
1533
+ }
1534
+ },
1535
+ async ({ path: distroPath, session, maxBytes }) => {
1536
+ const _logger = $logger.child({ context: "wsl_read_file", distro_path: distroPath });
1537
+ try {
1538
+ const { host } = resolveHost(context, { session });
1539
+ const requestedLimit = maxBytes ?? host.profile.policy.maxReadFileBytes;
1540
+ const readLimit = Math.min(requestedLimit, host.profile.policy.maxReadFileBytes);
1541
+ const file = await readFile(host, distroPath, readLimit);
1542
+ let header = `${distroPath} \u2014 ${formatBytes(file.readBytes)} read`;
1543
+ if (file.sizeBytes > 0) {
1544
+ header = `${distroPath} \u2014 ${formatBytes(file.sizeBytes)}`;
1545
+ }
1546
+ if (file.truncated == true) {
1547
+ header += `, truncated to the first ${formatBytes(file.readBytes)}`;
1548
+ }
1549
+ _logger.info("file read", { size_bytes: file.sizeBytes, read_bytes: file.readBytes, truncated: file.truncated });
1550
+ return textResult(`${header}
1551
+
1552
+ ${file.content}`);
1553
+ } catch (ex) {
1554
+ return toToolError(ex, "wsl_read_file", `read ${distroPath}`);
1555
+ }
1556
+ }
1557
+ );
1558
+ server.registerTool(
1559
+ "wsl_write_file",
1560
+ {
1561
+ title: "Write a file in WSL",
1562
+ description: [
1563
+ "Write or append UTF-8 text to a file in the distro. The content never passes through a shell, so quoting is not a concern.",
1564
+ "Prefer this over heredocs in wsl_exec. Parent directories must exist. Refused on a read-only profile, and under /mnt/<drive>/ unless the profile allows Windows writes."
1565
+ ].join(" "),
1566
+ inputSchema: {
1567
+ path: zod.z.string().min(1).describe("Absolute file path in the distro."),
1568
+ content: zod.z.string().describe("Text to write."),
1569
+ append: zod.z.boolean().optional().describe("Append instead of overwrite. Default false."),
1570
+ session: zod.z.string().optional().describe(SESSION_DESCRIPTION)
1571
+ },
1572
+ annotations: {
1573
+ readOnlyHint: false,
1574
+ destructiveHint: true,
1575
+ idempotentHint: false,
1576
+ openWorldHint: true
1577
+ }
1578
+ },
1579
+ async ({ path: distroPath, content, append, session }) => {
1580
+ const _logger = $logger.child({ context: "wsl_write_file", distro_path: distroPath });
1581
+ try {
1582
+ const { host } = resolveHost(context, { session });
1583
+ const written = await writeFile(host, { distroPath, content, append: append ?? false });
1584
+ let verb = "Wrote";
1585
+ if (append == true) {
1586
+ verb = "Appended";
1587
+ }
1588
+ _logger.info("file written", { bytes: written, append: append ?? false });
1589
+ return textResult(`${verb} ${formatBytes(written)} to ${distroPath}`);
1590
+ } catch (ex) {
1591
+ return toToolError(ex, "wsl_write_file", `write ${distroPath}`);
1592
+ }
1593
+ }
1594
+ );
1595
+ server.registerTool(
1596
+ "wsl_upload",
1597
+ {
1598
+ title: "Upload a file into WSL",
1599
+ description: [
1600
+ "Copy a file from this machine into the distro. The local path is resolved on the Windows side (a POSIX-looking path becomes <current drive>\\\u2026), and the reply prints it \u2014 check it.",
1601
+ "Credential files and MCP configuration on the local side are refused."
1602
+ ].join(" "),
1603
+ inputSchema: {
1604
+ localPath: zod.z.string().min(1).describe("Path on this machine, '~' expanded."),
1605
+ remotePath: zod.z.string().min(1).describe("Absolute destination path in the distro (the file, not its directory)."),
1606
+ session: zod.z.string().optional().describe(SESSION_DESCRIPTION)
1607
+ },
1608
+ annotations: {
1609
+ readOnlyHint: false,
1610
+ destructiveHint: true,
1611
+ idempotentHint: false,
1612
+ openWorldHint: true
1613
+ }
1614
+ },
1615
+ async ({ localPath, remotePath, session }) => {
1616
+ const resolvedLocalPath = expandHomePath(localPath);
1617
+ const _logger = $logger.child({ context: "wsl_upload", local_path: resolvedLocalPath, distro_path: remotePath });
1618
+ try {
1619
+ requirePermittedLocalPath(resolvedLocalPath, "read");
1620
+ const { host } = resolveHost(context, { session });
1621
+ const sent = await uploadFile(host, resolvedLocalPath, remotePath);
1622
+ _logger.info("file uploaded", { bytes: sent });
1623
+ return textResult(`Uploaded ${formatBytes(sent)}: ${resolvedLocalPath} \u2192 ${remotePath}`);
1624
+ } catch (ex) {
1625
+ return toToolError(ex, "wsl_upload", `upload ${resolvedLocalPath} to ${remotePath}`);
1626
+ }
1627
+ }
1628
+ );
1629
+ server.registerTool(
1630
+ "wsl_download",
1631
+ {
1632
+ title: "Download a file from WSL",
1633
+ description: [
1634
+ "Copy a file from the distro to this machine. The local path is resolved on the Windows side and printed in the reply \u2014 check it.",
1635
+ "The destination directory must already exist; credential locations and MCP configuration on the local side are refused."
1636
+ ].join(" "),
1637
+ inputSchema: {
1638
+ remotePath: zod.z.string().min(1).describe("Absolute source path in the distro."),
1639
+ localPath: zod.z.string().min(1).describe("Destination path on this machine, '~' expanded."),
1640
+ session: zod.z.string().optional().describe(SESSION_DESCRIPTION)
1641
+ },
1642
+ annotations: {
1643
+ readOnlyHint: false,
1644
+ destructiveHint: true,
1645
+ idempotentHint: false,
1646
+ openWorldHint: true
1647
+ }
1648
+ },
1649
+ async ({ remotePath, localPath, session }) => {
1650
+ const resolvedLocalPath = expandHomePath(localPath);
1651
+ const _logger = $logger.child({ context: "wsl_download", local_path: resolvedLocalPath, distro_path: remotePath });
1652
+ try {
1653
+ requirePermittedLocalPath(resolvedLocalPath, "write");
1654
+ const { host } = resolveHost(context, { session });
1655
+ const received = await downloadFile(host, remotePath, resolvedLocalPath);
1656
+ _logger.info("file downloaded", { bytes: received });
1657
+ return textResult(`Downloaded ${formatBytes(received)}: ${remotePath} \u2192 ${resolvedLocalPath}`);
1658
+ } catch (ex) {
1659
+ return toToolError(ex, "wsl_download", `download ${remotePath} to ${resolvedLocalPath}`);
1660
+ }
1661
+ }
1662
+ );
1663
+ }
1664
+
1665
+ // src/modules/tools/host-tools.ts
1666
+ function registerHostTools(server, context) {
1667
+ server.registerTool(
1668
+ "wsl_list_hosts",
1669
+ {
1670
+ title: "Show the WSL distro",
1671
+ description: [
1672
+ "Show the WSL distro and user this server runs commands in, with its guard policy and current state (running or stopped, WSL version, networking mode, whether systemd is PID 1).",
1673
+ "Call this first: it is the only reachable distro, and its policy decides what the other tools will accept.",
1674
+ "The networking mode matters when reasoning about ports \u2014 in mirrored mode a port Windows holds cannot be bound inside the distro, and a listing inside the distro shows only its own namespace."
1675
+ ].join(" "),
1676
+ inputSchema: {},
1677
+ annotations: {
1678
+ readOnlyHint: true,
1679
+ destructiveHint: false,
1680
+ idempotentHint: true,
1681
+ openWorldHint: false
1682
+ }
1683
+ },
1684
+ async () => {
1685
+ const _logger = $logger.child({ context: "wsl_list_hosts" });
1686
+ try {
1687
+ const host = context.host;
1688
+ let status = null;
1689
+ if (host != null) {
1690
+ status = await queryDistroListing(host.profile.distro);
1691
+ if (status.installed == true && status.state === "Running") {
1692
+ const facts = await probeDistroFacts(host);
1693
+ status = { ...status, ...facts };
1694
+ }
1695
+ }
1696
+ const listing = formatHostProfile(host?.profile ?? null, status);
1697
+ _logger.debug("host profile returned", { profile: host?.profile.name, state: status?.state });
1698
+ return textResult(listing);
1699
+ } catch (ex) {
1700
+ return toToolError(ex, "wsl_list_hosts", "show the distro");
1701
+ }
1702
+ }
1703
+ );
1704
+ }
1705
+ function registerSessionTools(server, context) {
1706
+ server.registerTool(
1707
+ "wsl_connect",
1708
+ {
1709
+ title: "Open a WSL session",
1710
+ description: [
1711
+ "Open a session in the configured distro and return its session id.",
1712
+ "A session remembers the working directory, so a 'cd' in one wsl_exec still applies to the next. Each command is still its own process \u2014 nothing else (environment variables, background jobs) persists between calls.",
1713
+ "Close it with wsl_disconnect when done; idle sessions are forgotten automatically."
1714
+ ].join(" "),
1715
+ inputSchema: {
1716
+ cwd: zod.z.string().optional().describe("Absolute directory to start in, with no shell metacharacters. Defaults to WSL_CWD, else the login shell's directory.")
1717
+ },
1718
+ annotations: {
1719
+ readOnlyHint: false,
1720
+ destructiveHint: false,
1721
+ idempotentHint: false,
1722
+ openWorldHint: true
1723
+ }
1724
+ },
1725
+ async ({ cwd }) => {
1726
+ let _logger = $logger.child({ context: "wsl_connect" });
1727
+ let openedSessionId = null;
1728
+ try {
1729
+ const { host } = resolveHost(context, {});
1730
+ const session = context.sessions.open(host);
1731
+ openedSessionId = session.sessionId;
1732
+ _logger = _logger.child({ session_id: session.sessionId });
1733
+ if (cwd != null && cwd.trim() !== "") {
1734
+ session.cwd = cwd.trim();
1735
+ }
1736
+ const probe = await session.exec("pwd");
1737
+ const probeSucceeded = probe.exitCode === 0 && probe.timedOut == false;
1738
+ if (probeSucceeded == false) {
1739
+ context.sessions.close(session.sessionId);
1740
+ openedSessionId = null;
1741
+ _logger.warn("session probe failed, closing the session", {
1742
+ exit_code: probe.exitCode,
1743
+ timed_out: probe.timedOut,
1744
+ stderr: probe.stderr
1745
+ });
1746
+ return errorResult(`wsl_connect failed: could not confirm the starting directory.
1747
+ ${probe.stderr.trim()}`);
1748
+ }
1749
+ const profile = host.profile;
1750
+ const lines = [
1751
+ `Opened session ${session.sessionId} \u2192 ${profile.username ?? "(distro default)"}@${profile.distro}`,
1752
+ `cwd: ${session.cwd}`
1753
+ ];
1754
+ if (profile.policy.readonly == true) {
1755
+ lines.push("This distro is READ-ONLY: writes, redirections and uploads will be rejected.");
1756
+ }
1757
+ lines.push(`Run commands with wsl_exec({ session: "${session.sessionId}", command: "..." }).`);
1758
+ _logger.info("session opened via tool", { cwd: session.cwd });
1759
+ return textResult(lines.join("\n"));
1760
+ } catch (ex) {
1761
+ if (openedSessionId != null) {
1762
+ context.sessions.close(openedSessionId);
1763
+ }
1764
+ return toToolError(ex, "wsl_connect", "open a session");
1765
+ }
1766
+ }
1767
+ );
1768
+ server.registerTool(
1769
+ "wsl_disconnect",
1770
+ {
1771
+ title: "Close a WSL session",
1772
+ description: "Close a session opened with wsl_connect. Nothing in the distro is affected \u2014 only this server's memory of the working directory.",
1773
+ inputSchema: {
1774
+ session: zod.z.string().min(1).describe("Session id from wsl_connect.")
1775
+ },
1776
+ annotations: {
1777
+ readOnlyHint: false,
1778
+ destructiveHint: false,
1779
+ idempotentHint: true,
1780
+ openWorldHint: false
1781
+ }
1782
+ },
1783
+ async ({ session }) => {
1784
+ const _logger = $logger.child({ context: "wsl_disconnect", session_id: session });
1785
+ try {
1786
+ const closed = context.sessions.close(session.trim());
1787
+ if (closed == false) {
1788
+ return errorResult(`no open session '${session}'`);
1789
+ }
1790
+ _logger.info("session closed via tool");
1791
+ return textResult(`Closed session ${session}.`);
1792
+ } catch (ex) {
1793
+ return toToolError(ex, "wsl_disconnect", `close session ${session}`);
1794
+ }
1795
+ }
1796
+ );
1797
+ server.registerTool(
1798
+ "wsl_list_sessions",
1799
+ {
1800
+ title: "List open WSL sessions",
1801
+ description: "List the sessions this server holds, with their working directory, command count and idle time.",
1802
+ inputSchema: {},
1803
+ annotations: {
1804
+ readOnlyHint: true,
1805
+ destructiveHint: false,
1806
+ idempotentHint: true,
1807
+ openWorldHint: false
1808
+ }
1809
+ },
1810
+ async () => {
1811
+ try {
1812
+ return textResult(formatSessionList(context.sessions.list()));
1813
+ } catch (ex) {
1814
+ return toToolError(ex, "wsl_list_sessions", "list sessions");
1815
+ }
1816
+ }
1817
+ );
1818
+ }
1819
+
1820
+ // src/modules/tools/index.ts
1821
+ function registerAllTools(server, context) {
1822
+ registerHostTools(server, context);
1823
+ registerSessionTools(server, context);
1824
+ registerExecTools(server, context);
1825
+ registerFileTools(server, context);
1826
+ }
1827
+
1828
+ // src/server.ts
1829
+ var SERVER_INSTRUCTIONS = [
1830
+ "This server runs shell commands and transfers files in one WSL distro on this machine, through wsl.exe \u2014 no SSH, no network.",
1831
+ "The distro and the Linux user are fixed in this server's own environment \u2014 there is no distro or user argument, and no other machine is reachable through it.",
1832
+ "",
1833
+ "wsl_list_hosts shows which distro and user this is, whether the distro is running, its networking mode, and what it permits. Call it before assuming anything about access or ports.",
1834
+ "",
1835
+ "Workflow:",
1836
+ "1. One command: wsl_exec with just 'command'.",
1837
+ "2. Several commands in a row: wsl_connect once, then wsl_exec with 'session' (the working directory persists across calls), then wsl_disconnect. Only the directory persists \u2014 each command is its own process.",
1838
+ "3. Files: wsl_list_dir, wsl_read_file, wsl_write_file, wsl_upload, wsl_download \u2014 prefer these over shell equivalents; no quoting to get wrong.",
1839
+ "",
1840
+ "What is permitted: ordinary administration, without asking \u2014 package installs, service restarts, sudo, interpreters, config edits, deployments.",
1841
+ "Refused always: operations that destroy the distro or cut this channel \u2014 wiping the filesystem root or a system directory, deleting a file the distro cannot boot without, formatting a disk, writing raw bytes to a block device, powering off, wsl --shutdown / --terminate / --unregister, flushing the firewall, removing every cron job.",
1842
+ "Refused unless the profile opens them (WSL_ALLOW_WINDOWS): running Windows executables through interop (powershell.exe, cmd.exe, any .exe) and writing under /mnt/<drive>/. Reading under /mnt/<drive>/ is allowed. The operator's credential files and MCP configuration under /mnt/ are refused regardless \u2014 those define this very guard.",
1843
+ "The profile may add its own limits (read-only, an allow-list, deny patterns, path restrictions). wsl_list_hosts shows which apply.",
1844
+ "",
1845
+ "Commands run in a login bash with no terminal and with stdin closed, so anything that waits for input fails immediately instead of hanging: use non-interactive flags (apt-get -y, sudo -n), 'top -bn1' rather than 'top', and wsl_write_file rather than an editor.",
1846
+ "A job that may outlive its timeout must be detached with all three descriptors redirected \u2014 nohup cmd > log 2>&1 < /dev/null & \u2014 otherwise wsl.exe waits on the open pipe until the timeout. Poll it through its log.",
1847
+ "",
1848
+ "A guard rejection is a policy decision, not a transient failure \u2014 do not retry the same command; report it and ask before working around it.",
1849
+ "A non-zero exit code is returned as normal output; read it and stderr to judge what happened.",
1850
+ "You still carry the judgement the guards do not: this is a real host, and an irreversible action deserves a check with the operator first."
1851
+ ].join("\n");
1852
+ function createWslMcpServer() {
1853
+ const _logger = $logger.child({ context: "createWslMcpServer" });
1854
+ const profile = loadHostProfile();
1855
+ let host = null;
1856
+ if (profile != null) {
1857
+ host = new WslHost(profile);
1858
+ }
1859
+ const sessions = new WslSessionManager();
1860
+ sessions.startIdleSweeper();
1861
+ const server = new mcp_js.McpServer(
1862
+ {
1863
+ name: SERVER_NAME,
1864
+ version: SERVER_VERSION
1865
+ },
1866
+ {
1867
+ instructions: SERVER_INSTRUCTIONS
1868
+ }
1869
+ );
1870
+ const context = {
1871
+ host,
1872
+ sessions
1873
+ };
1874
+ registerAllTools(server, context);
1875
+ _logger.info("mcp server built", { profile: profile?.name ?? null });
1876
+ return {
1877
+ server,
1878
+ sessions,
1879
+ profile,
1880
+ shutdown() {
1881
+ sessions.stopIdleSweeper();
1882
+ sessions.closeAll();
1883
+ }
1884
+ };
1885
+ }
1886
+
1887
+ Object.defineProperty(exports, "GuardRejectionError", {
1888
+ enumerable: true,
1889
+ get: function () { return mcpSsh.GuardRejectionError; }
1890
+ });
1891
+ exports.$logger = $logger;
1892
+ exports.LOG_LEVEL_ENV = LOG_LEVEL_ENV;
1893
+ exports.SERVER_NAME = SERVER_NAME;
1894
+ exports.SERVER_VERSION = SERVER_VERSION;
1895
+ exports.SINGLE_HOST_ENV = SINGLE_HOST_ENV;
1896
+ exports.WslHost = WslHost;
1897
+ exports.WslSession = WslSession;
1898
+ exports.WslSessionManager = WslSessionManager;
1899
+ exports.buildDistroScript = buildDistroScript;
1900
+ exports.buildWslArguments = buildWslArguments;
1901
+ exports.createWslMcpServer = createWslMcpServer;
1902
+ exports.downloadFile = downloadFile;
1903
+ exports.extractCwdMarker = extractCwdMarker;
1904
+ exports.inspectWslCommand = inspectWslCommand;
1905
+ exports.inspectWslPath = inspectWslPath;
1906
+ exports.listDirectory = listDirectory;
1907
+ exports.loadHostProfile = loadHostProfile;
1908
+ exports.probeDistroFacts = probeDistroFacts;
1909
+ exports.queryDistroListing = queryDistroListing;
1910
+ exports.readFile = readFile;
1911
+ exports.registerAllTools = registerAllTools;
1912
+ exports.toHostSummary = toHostSummary;
1913
+ exports.uploadFile = uploadFile;
1914
+ exports.writeFile = writeFile;
1915
+ //# sourceMappingURL=index.cjs.map
1916
+ //# sourceMappingURL=index.cjs.map