@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/LICENSE.md +23 -0
- package/README.md +152 -0
- package/lib/cli.cjs +2059 -0
- package/lib/cli.cjs.map +1 -0
- package/lib/cli.d.cts +1 -0
- package/lib/cli.d.ts +1 -0
- package/lib/cli.js +2051 -0
- package/lib/cli.js.map +1 -0
- package/lib/index.cjs +1916 -0
- package/lib/index.cjs.map +1 -0
- package/lib/index.d.cts +456 -0
- package/lib/index.d.ts +456 -0
- package/lib/index.js +1882 -0
- package/lib/index.js.map +1 -0
- package/package.json +64 -0
package/lib/cli.cjs
ADDED
|
@@ -0,0 +1,2059 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var stdio_js = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
5
|
+
var os = require('os');
|
|
6
|
+
var path = require('path');
|
|
7
|
+
var child_process = require('child_process');
|
|
8
|
+
var mcpSsh = require('@akms/mcp-ssh');
|
|
9
|
+
var timers = require('timers');
|
|
10
|
+
var fs = require('fs');
|
|
11
|
+
var zod = require('zod');
|
|
12
|
+
var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
13
|
+
|
|
14
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
15
|
+
|
|
16
|
+
var os__default = /*#__PURE__*/_interopDefault(os);
|
|
17
|
+
var path__default = /*#__PURE__*/_interopDefault(path);
|
|
18
|
+
var fs__default = /*#__PURE__*/_interopDefault(fs);
|
|
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
|
+
var MAX_INSPECTION_DEPTH = 5;
|
|
223
|
+
function inspectWslCommand(canonicalCommand, profile, depth = 0) {
|
|
224
|
+
if (depth === 0) {
|
|
225
|
+
const baseVerdict = mcpSsh.inspectCommand(canonicalCommand, profile.policy);
|
|
226
|
+
if (baseVerdict.allowed == false) {
|
|
227
|
+
return baseVerdict;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (depth > MAX_INSPECTION_DEPTH) {
|
|
231
|
+
return {
|
|
232
|
+
allowed: false,
|
|
233
|
+
reason: `command nests substitutions deeper than ${MAX_INSPECTION_DEPTH} levels`,
|
|
234
|
+
offendingText: canonicalCommand
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
for (const substitution of mcpSsh.extractSubstitutions(canonicalCommand)) {
|
|
238
|
+
const nestedVerdict = inspectWslCommand(substitution, profile, depth + 1);
|
|
239
|
+
if (nestedVerdict.allowed == false) {
|
|
240
|
+
return nestedVerdict;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const segment of mcpSsh.splitCommandSegments(canonicalCommand)) {
|
|
244
|
+
const analysis = mcpSsh.analyzeSegment(segment);
|
|
245
|
+
for (const rule of WSL_CATASTROPHE_RULES) {
|
|
246
|
+
if (rule.pattern.test(analysis.normalizedText) == true) {
|
|
247
|
+
return { allowed: false, reason: rule.reason, offendingText: segment.trim() };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (profile.allowWindows == false && WINDOWS_INTEROP_BINARY_PATTERN.test(analysis.binary) == true) {
|
|
251
|
+
return {
|
|
252
|
+
allowed: false,
|
|
253
|
+
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)`,
|
|
254
|
+
offendingText: segment.trim()
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const mountPaths = collectMountPaths(segment, analysis);
|
|
258
|
+
if (mountPaths.length === 0) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const writeShaped = isWriteShaped(segment, profile.policy);
|
|
262
|
+
if (profile.allowWindows == false && writeShaped == true) {
|
|
263
|
+
const mountTarget = findMountWriteTarget(segment, analysis);
|
|
264
|
+
if (mountTarget != null) {
|
|
265
|
+
return {
|
|
266
|
+
allowed: false,
|
|
267
|
+
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)`,
|
|
268
|
+
offendingText: segment.trim()
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
let access = "read";
|
|
273
|
+
if (writeShaped == true) {
|
|
274
|
+
access = "write";
|
|
275
|
+
}
|
|
276
|
+
for (const mountPath of mountPaths) {
|
|
277
|
+
const protectedVerdict = inspectMountPath(mountPath, access);
|
|
278
|
+
if (protectedVerdict.allowed == false) {
|
|
279
|
+
return protectedVerdict;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { allowed: true };
|
|
284
|
+
}
|
|
285
|
+
function inspectWslPath(distroPath, profile, access) {
|
|
286
|
+
const baseVerdict = mcpSsh.inspectRemotePath(distroPath, profile.policy, access);
|
|
287
|
+
if (baseVerdict.allowed == false) {
|
|
288
|
+
return baseVerdict;
|
|
289
|
+
}
|
|
290
|
+
if (mountPathToWindows(distroPath) == null) {
|
|
291
|
+
return { allowed: true };
|
|
292
|
+
}
|
|
293
|
+
if (access === "write" && profile.allowWindows == false) {
|
|
294
|
+
return {
|
|
295
|
+
allowed: false,
|
|
296
|
+
reason: "writes under /mnt/<drive>/ land on the Windows filesystem of the machine running this server (set WSL_ALLOW_WINDOWS=true to permit them)",
|
|
297
|
+
offendingText: distroPath.trim()
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return inspectMountPath(distroPath.trim(), access);
|
|
301
|
+
}
|
|
302
|
+
function inspectMountPath(distroPath, access) {
|
|
303
|
+
const windowsPath = mountPathToWindows(distroPath);
|
|
304
|
+
if (windowsPath == null) {
|
|
305
|
+
return { allowed: true };
|
|
306
|
+
}
|
|
307
|
+
const verdict = mcpSsh.inspectLocalPath(windowsPath, access);
|
|
308
|
+
if (verdict.allowed == true) {
|
|
309
|
+
return verdict;
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
allowed: false,
|
|
313
|
+
reason: `${verdict.reason} (reached through ${distroPath})`,
|
|
314
|
+
offendingText: distroPath
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
var DESTINATION_LAST_BINARIES = /* @__PURE__ */ new Set(["cp", "rsync", "install", "scp"]);
|
|
318
|
+
var DESTINATION_BY_OPTION = {
|
|
319
|
+
tar: /^(?:-C|--directory)$/,
|
|
320
|
+
unzip: /^-d$/,
|
|
321
|
+
curl: /^(?:-o|--output)$/,
|
|
322
|
+
wget: /^(?:-O|--output-document)$/
|
|
323
|
+
};
|
|
324
|
+
var TAR_CREATE_FLAG_PATTERN = /^(?:-[a-zA-Z]*[cru][a-zA-Z]*|--create|--append|--update|--concatenate|--delete)$/;
|
|
325
|
+
function findMountWriteTarget(segment, analysis) {
|
|
326
|
+
for (const redirectionTarget of collectRedirectionTargets(segment)) {
|
|
327
|
+
if (isMountPath(redirectionTarget) == true) {
|
|
328
|
+
return redirectionTarget;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const binary = analysis.binary.toLowerCase();
|
|
332
|
+
let candidates = analysis.operands;
|
|
333
|
+
if (DESTINATION_LAST_BINARIES.has(binary) == true) {
|
|
334
|
+
candidates = [...analysis.operands.slice(-1), ...valuesAfterOption(analysis.tokens, TARGET_DIRECTORY_OPTION)];
|
|
335
|
+
} else if (binary === "tar") {
|
|
336
|
+
const creating = analysis.flags.some((flag) => TAR_CREATE_FLAG_PATTERN.test(flag) == true);
|
|
337
|
+
if (creating == false) {
|
|
338
|
+
candidates = valuesAfterOption(analysis.tokens, DESTINATION_BY_OPTION.tar);
|
|
339
|
+
}
|
|
340
|
+
} else if (DESTINATION_BY_OPTION[binary] != null) {
|
|
341
|
+
candidates = valuesAfterOption(analysis.tokens, DESTINATION_BY_OPTION[binary]);
|
|
342
|
+
}
|
|
343
|
+
candidates = [...candidates, ...valuesInFlags(analysis.flags)];
|
|
344
|
+
for (const candidate of candidates) {
|
|
345
|
+
if (isMountPath(candidate) == true) {
|
|
346
|
+
return candidate;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
var TARGET_DIRECTORY_OPTION = /^(?:-t|--target-directory)$/;
|
|
352
|
+
function valuesInFlags(flags) {
|
|
353
|
+
const values = [];
|
|
354
|
+
for (const flag of flags) {
|
|
355
|
+
const separator = flag.indexOf("=");
|
|
356
|
+
if (separator > 0) {
|
|
357
|
+
values.push(flag.slice(separator + 1));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return values;
|
|
361
|
+
}
|
|
362
|
+
function valuesAfterOption(tokens, optionPattern) {
|
|
363
|
+
const values = [];
|
|
364
|
+
for (let index = 0; index < tokens.length - 1; index++) {
|
|
365
|
+
if (optionPattern.test(tokens[index]) == true) {
|
|
366
|
+
values.push(tokens[index + 1]);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return values;
|
|
370
|
+
}
|
|
371
|
+
function collectRedirectionTargets(segment) {
|
|
372
|
+
const targets = [];
|
|
373
|
+
for (const match of segment.matchAll(/>{1,2}\s*(['"]?)([^\s'"|;&<>]+)/g)) {
|
|
374
|
+
targets.push(match[2]);
|
|
375
|
+
}
|
|
376
|
+
return targets;
|
|
377
|
+
}
|
|
378
|
+
function bareCandidate(candidate) {
|
|
379
|
+
return candidate.replace(/^['"]|['"]$/g, "").replace(/^[A-Za-z_][\w-]*=/, "");
|
|
380
|
+
}
|
|
381
|
+
function isMountPath(candidate) {
|
|
382
|
+
return /^\/mnt\/[a-z](?:\/|$)/i.test(bareCandidate(candidate));
|
|
383
|
+
}
|
|
384
|
+
function collectMountPaths(segment, analysis) {
|
|
385
|
+
const found = /* @__PURE__ */ new Set();
|
|
386
|
+
const candidates = [
|
|
387
|
+
...analysis.operands,
|
|
388
|
+
...valuesInFlags(analysis.flags),
|
|
389
|
+
...collectRedirectionTargets(segment)
|
|
390
|
+
];
|
|
391
|
+
for (const candidate of candidates) {
|
|
392
|
+
if (isMountPath(candidate) == true) {
|
|
393
|
+
found.add(bareCandidate(candidate));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
for (const match of segment.matchAll(MOUNT_PATH_PATTERN)) {
|
|
397
|
+
found.add(match[0]);
|
|
398
|
+
}
|
|
399
|
+
return Array.from(found);
|
|
400
|
+
}
|
|
401
|
+
function isWriteShaped(segment, policy) {
|
|
402
|
+
const readonlyPolicy = { ...policy, readonly: true };
|
|
403
|
+
const verdict = mcpSsh.inspectCommand(segment, readonlyPolicy);
|
|
404
|
+
return verdict.allowed == false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// src/modules/wsl/wsl-command.ts
|
|
408
|
+
function buildWslArguments(profile, script) {
|
|
409
|
+
const wslArguments = ["-d", profile.distro];
|
|
410
|
+
if (profile.username != null) {
|
|
411
|
+
wslArguments.push("-u", profile.username);
|
|
412
|
+
}
|
|
413
|
+
wslArguments.push("--exec", "bash", "-lc", script);
|
|
414
|
+
return wslArguments;
|
|
415
|
+
}
|
|
416
|
+
function buildDistroScript(command, cwd, trackCwd) {
|
|
417
|
+
const lines = [];
|
|
418
|
+
if (cwd != null && cwd !== "") {
|
|
419
|
+
const quotedCwd = quoteShellArgument(cwd);
|
|
420
|
+
lines.push(`cd ${quotedCwd} 2>/dev/null || { echo 'akms-mcp-wsl: cannot enter the requested working directory' >&2; exit 1; }`);
|
|
421
|
+
}
|
|
422
|
+
lines.push(command);
|
|
423
|
+
if (trackCwd == true) {
|
|
424
|
+
lines.push("__akms_exit=$?");
|
|
425
|
+
lines.push(`printf '\\n${CWD_MARKER}%s' "$PWD"`);
|
|
426
|
+
lines.push("exit $__akms_exit");
|
|
427
|
+
}
|
|
428
|
+
return lines.join("\n");
|
|
429
|
+
}
|
|
430
|
+
function extractCwdMarker(stdout) {
|
|
431
|
+
const markerIndex = stdout.lastIndexOf(CWD_MARKER);
|
|
432
|
+
if (markerIndex < 0) {
|
|
433
|
+
return { output: stdout };
|
|
434
|
+
}
|
|
435
|
+
const markerPayload = stdout.slice(markerIndex + CWD_MARKER.length);
|
|
436
|
+
const cwd = (markerPayload.split("\n")[0] ?? "").trim();
|
|
437
|
+
let output = stdout.slice(0, markerIndex);
|
|
438
|
+
if (output.endsWith("\n") == true) {
|
|
439
|
+
output = output.slice(0, -1);
|
|
440
|
+
}
|
|
441
|
+
if (cwd === "") {
|
|
442
|
+
return { output };
|
|
443
|
+
}
|
|
444
|
+
return { output, cwd };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/modules/wsl/wsl-host.ts
|
|
448
|
+
var activeProcessCount = 0;
|
|
449
|
+
var WslHost = class {
|
|
450
|
+
constructor(profile) {
|
|
451
|
+
this.profile = profile;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Screens a command against this profile's policy, then runs it.
|
|
455
|
+
*
|
|
456
|
+
* The canonical form is what gets screened *and* what gets sent — validating one
|
|
457
|
+
* string while executing another is a bypass by construction.
|
|
458
|
+
*
|
|
459
|
+
* @throws {GuardRejectionError} If the policy refuses the command or the cwd.
|
|
460
|
+
* @throws {Error} If `wsl.exe` itself fails (unknown distro, service down) or cannot be spawned.
|
|
461
|
+
*/
|
|
462
|
+
async exec(options) {
|
|
463
|
+
const canonicalCommand = mcpSsh.canonicalizeCommand(options.command);
|
|
464
|
+
const verdict = inspectWslCommand(canonicalCommand, this.profile);
|
|
465
|
+
if (verdict.allowed == false) {
|
|
466
|
+
throw new mcpSsh.GuardRejectionError(verdict);
|
|
467
|
+
}
|
|
468
|
+
if (options.cwd != null) {
|
|
469
|
+
const cwdVerdict = mcpSsh.inspectWorkingDirectory(options.cwd);
|
|
470
|
+
if (cwdVerdict.allowed == false) {
|
|
471
|
+
throw new mcpSsh.GuardRejectionError(cwdVerdict);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const _logger = $logger.child({ context: "WslHost.exec", profile: this.profile.name, cwd: options.cwd });
|
|
475
|
+
const script = buildDistroScript(canonicalCommand, options.cwd, options.trackCwd);
|
|
476
|
+
const raw = await this.runProcess({
|
|
477
|
+
script,
|
|
478
|
+
timeoutMs: options.timeoutMs,
|
|
479
|
+
maxCapturedBytes: options.maxOutputCharacters * OUTPUT_HARD_CAP_MULTIPLIER
|
|
480
|
+
});
|
|
481
|
+
let output = raw.stdout.toString("utf8");
|
|
482
|
+
let cwd = void 0;
|
|
483
|
+
if (options.trackCwd == true) {
|
|
484
|
+
const parsed = extractCwdMarker(output);
|
|
485
|
+
output = parsed.output;
|
|
486
|
+
cwd = parsed.cwd;
|
|
487
|
+
}
|
|
488
|
+
const truncatedStdout = truncateOutput(output, options.maxOutputCharacters);
|
|
489
|
+
const truncatedStderr = truncateOutput(raw.stderr.toString("utf8"), options.maxOutputCharacters);
|
|
490
|
+
const result = {
|
|
491
|
+
stdout: truncatedStdout.text,
|
|
492
|
+
stderr: truncatedStderr.text,
|
|
493
|
+
exitCode: raw.exitCode,
|
|
494
|
+
timedOut: raw.timedOut,
|
|
495
|
+
truncated: truncatedStdout.truncated == true || truncatedStderr.truncated == true || raw.capExceeded == true,
|
|
496
|
+
durationMs: raw.durationMs
|
|
497
|
+
};
|
|
498
|
+
if (raw.signal != null) {
|
|
499
|
+
result.signal = raw.signal;
|
|
500
|
+
}
|
|
501
|
+
if (cwd != null) {
|
|
502
|
+
result.cwd = cwd;
|
|
503
|
+
}
|
|
504
|
+
_logger.debug("command finished", {
|
|
505
|
+
exit_code: result.exitCode,
|
|
506
|
+
duration_ms: result.durationMs,
|
|
507
|
+
timed_out: result.timedOut,
|
|
508
|
+
truncated: result.truncated
|
|
509
|
+
});
|
|
510
|
+
return result;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Spawns `wsl.exe` for one script and collects what it produced.
|
|
514
|
+
*
|
|
515
|
+
* Not guarded — callers own that: `exec` screens the command, the file operations
|
|
516
|
+
* screen the path and build the script themselves. stdin is closed (or fed and then
|
|
517
|
+
* closed) so anything that waits for input fails at once instead of hanging until the
|
|
518
|
+
* timeout.
|
|
519
|
+
*
|
|
520
|
+
* @throws {Error} If the process ceiling is reached, `wsl.exe` cannot be started, or
|
|
521
|
+
* `wsl.exe` reports a failure of its own rather than the command's exit
|
|
522
|
+
* status — that message (unknown distro, service down) is the whole diagnosis.
|
|
523
|
+
*/
|
|
524
|
+
async runProcess(options) {
|
|
525
|
+
if (activeProcessCount >= MAX_CONCURRENT_PROCESSES) {
|
|
526
|
+
throw new Error(`too many concurrent wsl.exe processes (${MAX_CONCURRENT_PROCESSES}); wait for running calls to finish`);
|
|
527
|
+
}
|
|
528
|
+
const _logger = $logger.child({ context: "WslHost.runProcess", profile: this.profile.name });
|
|
529
|
+
const wslArguments = buildWslArguments(this.profile, options.script);
|
|
530
|
+
const startedAt = Date.now();
|
|
531
|
+
activeProcessCount += 1;
|
|
532
|
+
try {
|
|
533
|
+
const raw = await new Promise((resolve, reject) => {
|
|
534
|
+
let stdinMode = "ignore";
|
|
535
|
+
if (options.stdin != null) {
|
|
536
|
+
stdinMode = "pipe";
|
|
537
|
+
}
|
|
538
|
+
const child = child_process.spawn(WSL_EXECUTABLE, wslArguments, {
|
|
539
|
+
// WSL_UTF8: wsl.exe's *own* messages (unknown distro, service errors) are
|
|
540
|
+
// UTF-16 by default even on a pipe; the Linux side is UTF-8 regardless.
|
|
541
|
+
env: { ...process.env, WSL_UTF8: "1" },
|
|
542
|
+
stdio: [stdinMode, "pipe", "pipe"],
|
|
543
|
+
windowsHide: true
|
|
544
|
+
});
|
|
545
|
+
const stdoutStream = child.stdout;
|
|
546
|
+
const stderrStream = child.stderr;
|
|
547
|
+
if (stdoutStream == null || stderrStream == null) {
|
|
548
|
+
reject(new Error("wsl.exe was spawned without stdio pipes"));
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const stdoutChunks = [];
|
|
552
|
+
const stderrChunks = [];
|
|
553
|
+
let stdoutBytes = 0;
|
|
554
|
+
let stderrBytes = 0;
|
|
555
|
+
let sinkBytes = 0;
|
|
556
|
+
let timedOut = false;
|
|
557
|
+
let capExceeded = false;
|
|
558
|
+
let settled = false;
|
|
559
|
+
let stdinFailure = null;
|
|
560
|
+
const timeoutTimer = setTimeout(() => {
|
|
561
|
+
timedOut = true;
|
|
562
|
+
_logger.warn("command timed out, killing wsl.exe", { timeout_ms: options.timeoutMs });
|
|
563
|
+
child.kill();
|
|
564
|
+
}, options.timeoutMs);
|
|
565
|
+
timeoutTimer.unref();
|
|
566
|
+
const finish = (exitCode, signal) => {
|
|
567
|
+
if (settled == true) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
settled = true;
|
|
571
|
+
clearTimeout(timeoutTimer);
|
|
572
|
+
const output = {
|
|
573
|
+
stdout: Buffer.concat(stdoutChunks),
|
|
574
|
+
stderr: Buffer.concat(stderrChunks),
|
|
575
|
+
sinkBytes,
|
|
576
|
+
exitCode,
|
|
577
|
+
timedOut,
|
|
578
|
+
capExceeded,
|
|
579
|
+
durationMs: Date.now() - startedAt
|
|
580
|
+
};
|
|
581
|
+
if (signal != null) {
|
|
582
|
+
output.signal = signal;
|
|
583
|
+
}
|
|
584
|
+
resolve(output);
|
|
585
|
+
};
|
|
586
|
+
const fail = (error) => {
|
|
587
|
+
if (settled == true) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
settled = true;
|
|
591
|
+
clearTimeout(timeoutTimer);
|
|
592
|
+
reject(error);
|
|
593
|
+
};
|
|
594
|
+
const exceedCap = (stream) => {
|
|
595
|
+
if (capExceeded == true) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
capExceeded = true;
|
|
599
|
+
_logger.warn(`${stream} exceeded the hard cap, killing wsl.exe`, { hard_cap: options.maxCapturedBytes });
|
|
600
|
+
child.kill();
|
|
601
|
+
};
|
|
602
|
+
if (options.stdoutSink != null) {
|
|
603
|
+
const sink = options.stdoutSink;
|
|
604
|
+
stdoutStream.on("data", (chunk) => {
|
|
605
|
+
sinkBytes += chunk.length;
|
|
606
|
+
});
|
|
607
|
+
stdoutStream.pipe(sink, { end: false });
|
|
608
|
+
sink.on("error", fail);
|
|
609
|
+
} else {
|
|
610
|
+
stdoutStream.on("data", (chunk) => {
|
|
611
|
+
if (capExceeded == true) {
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
stdoutChunks.push(chunk);
|
|
615
|
+
stdoutBytes += chunk.length;
|
|
616
|
+
if (stdoutBytes > options.maxCapturedBytes) {
|
|
617
|
+
exceedCap("stdout");
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
stderrStream.on("data", (chunk) => {
|
|
622
|
+
if (capExceeded == true) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
stderrChunks.push(chunk);
|
|
626
|
+
stderrBytes += chunk.length;
|
|
627
|
+
if (stderrBytes > options.maxCapturedBytes) {
|
|
628
|
+
exceedCap("stderr");
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
child.on("error", fail);
|
|
632
|
+
child.on("close", (exitCode, signal) => {
|
|
633
|
+
if (stdinFailure != null) {
|
|
634
|
+
fail(new Error(`local input failed while feeding the distro: ${stdinFailure.message}`));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
finish(exitCode, signal);
|
|
638
|
+
});
|
|
639
|
+
if (options.stdin != null && child.stdin != null) {
|
|
640
|
+
child.stdin.on("error", () => {
|
|
641
|
+
});
|
|
642
|
+
if (Buffer.isBuffer(options.stdin) == true) {
|
|
643
|
+
child.stdin.end(options.stdin);
|
|
644
|
+
} else {
|
|
645
|
+
const source = options.stdin;
|
|
646
|
+
source.on("error", (error) => {
|
|
647
|
+
stdinFailure = error;
|
|
648
|
+
child.kill();
|
|
649
|
+
});
|
|
650
|
+
source.pipe(child.stdin);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
if (raw.exitCode != null && WSL_FAILURE_EXIT_CODES.includes(raw.exitCode) == true) {
|
|
655
|
+
let detail = `${raw.stdout.toString("utf8")}
|
|
656
|
+
${raw.stderr.toString("utf8")}`.trim();
|
|
657
|
+
if (detail === "" && options.stdoutSink != null) {
|
|
658
|
+
detail = "wsl.exe reported a failure of its own (unknown distro, WSL service down) \u2014 run akms-mcp-wsl --check";
|
|
659
|
+
}
|
|
660
|
+
throw new Error(`wsl.exe failed for distro '${this.profile.distro}': ${detail}`);
|
|
661
|
+
}
|
|
662
|
+
return raw;
|
|
663
|
+
} finally {
|
|
664
|
+
activeProcessCount -= 1;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
var MAX_OPEN_SESSIONS = 32;
|
|
669
|
+
var WslSession = class {
|
|
670
|
+
constructor(sessionId, host) {
|
|
671
|
+
this.sessionId = sessionId;
|
|
672
|
+
this.host = host;
|
|
673
|
+
this.execCount = 0;
|
|
674
|
+
this.openedAt = /* @__PURE__ */ new Date();
|
|
675
|
+
/** Tail of the serialized operation chain; see `runExclusive`. */
|
|
676
|
+
this.operationChain = Promise.resolve();
|
|
677
|
+
this.activeOperationCount = 0;
|
|
678
|
+
this.cwd = host.profile.defaultCwd ?? "";
|
|
679
|
+
this.lastUsedAt = /* @__PURE__ */ new Date();
|
|
680
|
+
}
|
|
681
|
+
get profile() {
|
|
682
|
+
return this.host.profile;
|
|
683
|
+
}
|
|
684
|
+
/** True while an operation is running, so the idle sweeper leaves it alone. */
|
|
685
|
+
get isBusy() {
|
|
686
|
+
return this.activeOperationCount > 0;
|
|
687
|
+
}
|
|
688
|
+
touch() {
|
|
689
|
+
this.lastUsedAt = /* @__PURE__ */ new Date();
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Runs `operation` with exclusive use of this session.
|
|
693
|
+
*
|
|
694
|
+
* Serialized because `cwd` is read-modify-write across a process run: two concurrent
|
|
695
|
+
* calls both read the old directory, and the one that finishes second writes it back —
|
|
696
|
+
* silently undoing the other's `cd`.
|
|
697
|
+
*/
|
|
698
|
+
async runExclusive(operation) {
|
|
699
|
+
const previous = this.operationChain;
|
|
700
|
+
let releaseChain = () => {
|
|
701
|
+
};
|
|
702
|
+
this.operationChain = new Promise((resolve) => {
|
|
703
|
+
releaseChain = resolve;
|
|
704
|
+
});
|
|
705
|
+
await previous.catch(() => {
|
|
706
|
+
});
|
|
707
|
+
this.activeOperationCount += 1;
|
|
708
|
+
this.touch();
|
|
709
|
+
try {
|
|
710
|
+
return await operation(this.host);
|
|
711
|
+
} finally {
|
|
712
|
+
this.activeOperationCount -= 1;
|
|
713
|
+
this.touch();
|
|
714
|
+
releaseChain();
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Runs a command in this session's working directory and adopts wherever it ended up,
|
|
719
|
+
* so a `cd` in one call is still in effect on the next.
|
|
720
|
+
*
|
|
721
|
+
* Passing `cwd` runs this command elsewhere *and* moves the session there — same
|
|
722
|
+
* result as prefixing `cd <cwd> &&`, which is what a shell user would expect.
|
|
723
|
+
*/
|
|
724
|
+
async exec(command, options) {
|
|
725
|
+
const policy = this.profile.policy;
|
|
726
|
+
const targetCwd = options?.cwd ?? this.cwd;
|
|
727
|
+
return await this.runExclusive(async (host) => {
|
|
728
|
+
const result = await host.exec({
|
|
729
|
+
command,
|
|
730
|
+
cwd: targetCwd,
|
|
731
|
+
timeoutMs: options?.timeoutMs ?? policy.execTimeoutMs,
|
|
732
|
+
maxOutputCharacters: policy.maxOutputCharacters,
|
|
733
|
+
trackCwd: true
|
|
734
|
+
});
|
|
735
|
+
if (result.cwd != null && result.cwd !== "") {
|
|
736
|
+
const verdict = mcpSsh.inspectWorkingDirectory(result.cwd);
|
|
737
|
+
if (verdict.allowed == true) {
|
|
738
|
+
this.cwd = result.cwd;
|
|
739
|
+
} else {
|
|
740
|
+
const _logger = $logger.child({ context: "WslSession.exec", session_id: this.sessionId });
|
|
741
|
+
_logger.warn("reported working directory contains unsafe characters, keeping the previous cwd", {
|
|
742
|
+
reported_cwd: result.cwd
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
this.execCount += 1;
|
|
747
|
+
return result;
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
toInfo() {
|
|
751
|
+
let displayCwd = this.cwd;
|
|
752
|
+
if (displayCwd === "") {
|
|
753
|
+
displayCwd = "(login default)";
|
|
754
|
+
}
|
|
755
|
+
return {
|
|
756
|
+
sessionId: this.sessionId,
|
|
757
|
+
profileName: this.profile.name,
|
|
758
|
+
distro: this.profile.distro,
|
|
759
|
+
username: this.profile.username ?? "(distro default)",
|
|
760
|
+
cwd: displayCwd,
|
|
761
|
+
openedAt: this.openedAt.toISOString(),
|
|
762
|
+
lastUsedAt: this.lastUsedAt.toISOString(),
|
|
763
|
+
execCount: this.execCount
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
var WslSessionManager = class {
|
|
768
|
+
constructor() {
|
|
769
|
+
this.sessions = /* @__PURE__ */ new Map();
|
|
770
|
+
this.sequence = 0;
|
|
771
|
+
this.sweepTimer = null;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Registers a session. Nothing is opened — the caller probes with `pwd` to prove
|
|
775
|
+
* the distro answers and to settle the starting directory.
|
|
776
|
+
*
|
|
777
|
+
* @throws {Error} If the session ceiling is reached.
|
|
778
|
+
*/
|
|
779
|
+
open(host) {
|
|
780
|
+
if (this.sessions.size >= MAX_OPEN_SESSIONS) {
|
|
781
|
+
throw new Error(`too many open wsl sessions (${MAX_OPEN_SESSIONS}), close some with wsl_disconnect first`);
|
|
782
|
+
}
|
|
783
|
+
this.sequence += 1;
|
|
784
|
+
const sessionId = `${host.profile.name}#${this.sequence}`;
|
|
785
|
+
const session = new WslSession(sessionId, host);
|
|
786
|
+
this.sessions.set(sessionId, session);
|
|
787
|
+
const _logger = $logger.child({ context: "WslSessionManager.open", session_id: sessionId, profile: host.profile.name });
|
|
788
|
+
_logger.info("wsl session opened", { open_sessions: this.sessions.size });
|
|
789
|
+
return session;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Looks up a session, failing with the list of live ids so the caller can self-correct.
|
|
793
|
+
*
|
|
794
|
+
* @throws {Error} If no session matches `sessionId`.
|
|
795
|
+
*/
|
|
796
|
+
require(sessionId) {
|
|
797
|
+
const session = this.sessions.get(sessionId);
|
|
798
|
+
if (session == null) {
|
|
799
|
+
const openIds = Array.from(this.sessions.keys());
|
|
800
|
+
let openSummary = "none";
|
|
801
|
+
if (openIds.length > 0) {
|
|
802
|
+
openSummary = openIds.join(", ");
|
|
803
|
+
}
|
|
804
|
+
throw new Error(`unknown wsl session '${sessionId}' (open sessions: ${openSummary})`);
|
|
805
|
+
}
|
|
806
|
+
return session;
|
|
807
|
+
}
|
|
808
|
+
close(sessionId) {
|
|
809
|
+
const deleted = this.sessions.delete(sessionId);
|
|
810
|
+
if (deleted == false) {
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
const _logger = $logger.child({ context: "WslSessionManager.close", session_id: sessionId });
|
|
814
|
+
_logger.info("wsl session closed", { open_sessions: this.sessions.size });
|
|
815
|
+
return true;
|
|
816
|
+
}
|
|
817
|
+
closeAll() {
|
|
818
|
+
for (const sessionId of Array.from(this.sessions.keys())) {
|
|
819
|
+
this.close(sessionId);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
list() {
|
|
823
|
+
return Array.from(this.sessions.values()).map((session) => session.toInfo());
|
|
824
|
+
}
|
|
825
|
+
/** Starts the idle sweeper; the timer is unref'd so it never holds the process open. */
|
|
826
|
+
startIdleSweeper() {
|
|
827
|
+
if (this.sweepTimer != null) {
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
this.sweepTimer = timers.setInterval(() => {
|
|
831
|
+
const expiredAt = Date.now() - SESSION_IDLE_TIMEOUT_MS;
|
|
832
|
+
for (const session of Array.from(this.sessions.values())) {
|
|
833
|
+
if (session.isBusy == true) {
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
if (session.lastUsedAt.getTime() > expiredAt) {
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
const _logger = $logger.child({
|
|
840
|
+
context: "WslSessionManager.sweep",
|
|
841
|
+
session_id: session.sessionId,
|
|
842
|
+
idle_ms: Date.now() - session.lastUsedAt.getTime()
|
|
843
|
+
});
|
|
844
|
+
_logger.info("forgetting idle wsl session");
|
|
845
|
+
this.close(session.sessionId);
|
|
846
|
+
}
|
|
847
|
+
}, SESSION_SWEEP_INTERVAL_MS);
|
|
848
|
+
this.sweepTimer.unref();
|
|
849
|
+
}
|
|
850
|
+
stopIdleSweeper() {
|
|
851
|
+
if (this.sweepTimer == null) {
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
timers.clearInterval(this.sweepTimer);
|
|
855
|
+
this.sweepTimer = null;
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
var METADATA_CAPTURE_BYTES = 4 * 1024 * 1024;
|
|
859
|
+
function requirePermittedPath(host, distroPath, access) {
|
|
860
|
+
const verdict = inspectWslPath(distroPath, host.profile, access);
|
|
861
|
+
if (verdict.allowed == false) {
|
|
862
|
+
throw new mcpSsh.GuardRejectionError(verdict);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
async function runFixedScript(host, script, stdin) {
|
|
866
|
+
const output = await host.runProcess({
|
|
867
|
+
script,
|
|
868
|
+
stdin,
|
|
869
|
+
timeoutMs: host.profile.policy.execTimeoutMs,
|
|
870
|
+
maxCapturedBytes: METADATA_CAPTURE_BYTES
|
|
871
|
+
});
|
|
872
|
+
if (output.timedOut == true) {
|
|
873
|
+
throw new Error(`timed out after ${host.profile.policy.execTimeoutMs}ms`);
|
|
874
|
+
}
|
|
875
|
+
if (output.exitCode !== 0) {
|
|
876
|
+
const detail = output.stderr.toString("utf8").trim();
|
|
877
|
+
throw new Error(detail === "" ? `exit code ${output.exitCode}` : detail);
|
|
878
|
+
}
|
|
879
|
+
return output.stdout;
|
|
880
|
+
}
|
|
881
|
+
var ENTRY_TYPES = {
|
|
882
|
+
f: "file",
|
|
883
|
+
d: "directory",
|
|
884
|
+
l: "symlink"
|
|
885
|
+
};
|
|
886
|
+
async function listDirectory(host, distroPath) {
|
|
887
|
+
requirePermittedPath(host, distroPath, "read");
|
|
888
|
+
const quotedPath = quoteShellArgument(distroPath);
|
|
889
|
+
const findPrefix = `find ${quotedPath} -mindepth 1 -maxdepth 1`;
|
|
890
|
+
const script = [
|
|
891
|
+
`test -d ${quotedPath} || { echo 'not a directory' >&2; exit 2; }`,
|
|
892
|
+
// First line: the true entry count. The listing itself is bounded with `head`, so
|
|
893
|
+
// a directory of a million files costs the distro a `find`, not this server 4 MB.
|
|
894
|
+
`${findPrefix} -printf x | wc -c`,
|
|
895
|
+
// One line per entry: type \t mode \t size \t mtime \t name. `-printf` never quotes,
|
|
896
|
+
// and a name cannot contain a newline in any directory a tool should be listing.
|
|
897
|
+
`${findPrefix} -printf '%y\\t%m\\t%s\\t%TY-%Tm-%TdT%TH:%TM:%.2TS\\t%f\\n' | head -n ${MAX_DIRECTORY_ENTRIES + 1}`
|
|
898
|
+
].join("\n");
|
|
899
|
+
const stdout = await runFixedScript(host, script);
|
|
900
|
+
const [countLine, ...lines] = stdout.toString("utf8").split("\n").filter((line) => line !== "");
|
|
901
|
+
const reportedCount = Number(countLine) || 0;
|
|
902
|
+
const entries = [];
|
|
903
|
+
for (const line of lines) {
|
|
904
|
+
const [typeLetter, mode, size, modifiedAt, ...nameParts] = line.split(" ");
|
|
905
|
+
const name = nameParts.join(" ");
|
|
906
|
+
if (name == null || name === "") {
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
entries.push({
|
|
910
|
+
name,
|
|
911
|
+
type: ENTRY_TYPES[typeLetter] ?? "other",
|
|
912
|
+
sizeBytes: Number(size) || 0,
|
|
913
|
+
mode: `0${mode}`,
|
|
914
|
+
modifiedAt
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
entries.sort((left, right) => {
|
|
918
|
+
if (left.type === "directory" && right.type !== "directory") {
|
|
919
|
+
return -1;
|
|
920
|
+
}
|
|
921
|
+
if (left.type !== "directory" && right.type === "directory") {
|
|
922
|
+
return 1;
|
|
923
|
+
}
|
|
924
|
+
return left.name.localeCompare(right.name);
|
|
925
|
+
});
|
|
926
|
+
const totalCount = Math.max(reportedCount, entries.length);
|
|
927
|
+
const truncated = totalCount > MAX_DIRECTORY_ENTRIES;
|
|
928
|
+
return {
|
|
929
|
+
entries: entries.slice(0, MAX_DIRECTORY_ENTRIES),
|
|
930
|
+
totalCount,
|
|
931
|
+
truncated
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
async function readFile(host, distroPath, maxBytes) {
|
|
935
|
+
requirePermittedPath(host, distroPath, "read");
|
|
936
|
+
const quotedPath = quoteShellArgument(distroPath);
|
|
937
|
+
const statScript = [
|
|
938
|
+
`test -e ${quotedPath} || { echo 'no such file' >&2; exit 2; }`,
|
|
939
|
+
`test -d ${quotedPath} && { echo 'is a directory' >&2; exit 2; }`,
|
|
940
|
+
`stat -c %s ${quotedPath}`
|
|
941
|
+
].join("\n");
|
|
942
|
+
const sizeBytes = Number((await runFixedScript(host, statScript)).toString("utf8").trim()) || 0;
|
|
943
|
+
const probeBytes = maxBytes + 1;
|
|
944
|
+
const output = await host.runProcess({
|
|
945
|
+
script: `head -c ${probeBytes} ${quotedPath}`,
|
|
946
|
+
timeoutMs: host.profile.policy.execTimeoutMs,
|
|
947
|
+
maxCapturedBytes: probeBytes
|
|
948
|
+
});
|
|
949
|
+
if (output.exitCode !== 0 && output.capExceeded == false) {
|
|
950
|
+
throw new Error(output.stderr.toString("utf8").trim() || `exit code ${output.exitCode}`);
|
|
951
|
+
}
|
|
952
|
+
const truncated = output.stdout.length > maxBytes;
|
|
953
|
+
const kept = output.stdout.subarray(0, maxBytes);
|
|
954
|
+
return {
|
|
955
|
+
content: kept.toString("utf8"),
|
|
956
|
+
sizeBytes,
|
|
957
|
+
readBytes: kept.length,
|
|
958
|
+
truncated
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
async function writeFile(host, args) {
|
|
962
|
+
const { distroPath, content, append } = args;
|
|
963
|
+
requirePermittedPath(host, distroPath, "write");
|
|
964
|
+
const payload = Buffer.from(content, "utf8");
|
|
965
|
+
const quotedPath = quoteShellArgument(distroPath);
|
|
966
|
+
let redirection = ">";
|
|
967
|
+
if (append == true) {
|
|
968
|
+
redirection = ">>";
|
|
969
|
+
}
|
|
970
|
+
await runFixedScript(host, `cat ${redirection} ${quotedPath}`, payload);
|
|
971
|
+
return payload.length;
|
|
972
|
+
}
|
|
973
|
+
async function uploadFile(host, localPath, distroPath) {
|
|
974
|
+
requirePermittedPath(host, distroPath, "write");
|
|
975
|
+
const localStats = fs__default.default.statSync(localPath);
|
|
976
|
+
if (localStats.isFile() == false) {
|
|
977
|
+
throw new Error(`local path is not a regular file: ${localPath}`);
|
|
978
|
+
}
|
|
979
|
+
const stream = fs__default.default.createReadStream(localPath);
|
|
980
|
+
try {
|
|
981
|
+
await runFixedScript(host, `cat > ${quoteShellArgument(distroPath)}`, stream);
|
|
982
|
+
} finally {
|
|
983
|
+
stream.destroy();
|
|
984
|
+
}
|
|
985
|
+
return localStats.size;
|
|
986
|
+
}
|
|
987
|
+
async function downloadFile(host, distroPath, localPath) {
|
|
988
|
+
requirePermittedPath(host, distroPath, "read");
|
|
989
|
+
const parent = path__default.default.dirname(localPath);
|
|
990
|
+
if (fs__default.default.existsSync(parent) == false) {
|
|
991
|
+
throw new Error(`local directory does not exist: ${parent}`);
|
|
992
|
+
}
|
|
993
|
+
const quotedPath = quoteShellArgument(distroPath);
|
|
994
|
+
const script = [
|
|
995
|
+
`test -f ${quotedPath} || { echo 'not a regular file' >&2; exit 2; }`,
|
|
996
|
+
`cat ${quotedPath}`
|
|
997
|
+
].join("\n");
|
|
998
|
+
const sink = fs__default.default.createWriteStream(localPath);
|
|
999
|
+
let output;
|
|
1000
|
+
try {
|
|
1001
|
+
output = await host.runProcess({
|
|
1002
|
+
script,
|
|
1003
|
+
stdoutSink: sink,
|
|
1004
|
+
timeoutMs: host.profile.policy.execTimeoutMs,
|
|
1005
|
+
maxCapturedBytes: METADATA_CAPTURE_BYTES
|
|
1006
|
+
});
|
|
1007
|
+
if (output.timedOut == true) {
|
|
1008
|
+
throw new Error(`timed out after ${host.profile.policy.execTimeoutMs}ms; the partial file was removed`);
|
|
1009
|
+
}
|
|
1010
|
+
if (output.exitCode !== 0) {
|
|
1011
|
+
throw new Error(output.stderr.toString("utf8").trim() || `exit code ${output.exitCode}`);
|
|
1012
|
+
}
|
|
1013
|
+
} catch (ex) {
|
|
1014
|
+
await new Promise((resolve) => {
|
|
1015
|
+
sink.end(resolve);
|
|
1016
|
+
});
|
|
1017
|
+
fs__default.default.rmSync(localPath, { force: true });
|
|
1018
|
+
throw ex;
|
|
1019
|
+
}
|
|
1020
|
+
await new Promise((resolve) => {
|
|
1021
|
+
sink.end(resolve);
|
|
1022
|
+
});
|
|
1023
|
+
return output.sinkBytes;
|
|
1024
|
+
}
|
|
1025
|
+
var LISTING_TIMEOUT_MS = 1e4;
|
|
1026
|
+
var PROBE_CAPTURE_BYTES = 64 * 1024;
|
|
1027
|
+
var LISTING_ROW_PATTERN = /^\s*\*?\s*(.+?)\s+(Running|Stopped|Installing)\s+(\d)\s*$/;
|
|
1028
|
+
async function queryDistroListing(distro) {
|
|
1029
|
+
const _logger = $logger.child({ context: "queryDistroListing", distro });
|
|
1030
|
+
const listing = await new Promise((resolve, reject) => {
|
|
1031
|
+
const child = child_process.spawn(WSL_EXECUTABLE, ["-l", "-v"], {
|
|
1032
|
+
env: { ...process.env, WSL_UTF8: "1" },
|
|
1033
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1034
|
+
windowsHide: true
|
|
1035
|
+
});
|
|
1036
|
+
const chunks = [];
|
|
1037
|
+
const timer = setTimeout(() => {
|
|
1038
|
+
child.kill();
|
|
1039
|
+
}, LISTING_TIMEOUT_MS);
|
|
1040
|
+
timer.unref();
|
|
1041
|
+
child.stdout.on("data", (chunk) => {
|
|
1042
|
+
chunks.push(chunk);
|
|
1043
|
+
});
|
|
1044
|
+
child.on("error", (error) => {
|
|
1045
|
+
clearTimeout(timer);
|
|
1046
|
+
reject(error);
|
|
1047
|
+
});
|
|
1048
|
+
child.on("close", () => {
|
|
1049
|
+
clearTimeout(timer);
|
|
1050
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
for (const line of listing.split(/\r?\n/)) {
|
|
1054
|
+
const match = LISTING_ROW_PATTERN.exec(line);
|
|
1055
|
+
if (match == null) {
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
if (match[1].toLowerCase() !== distro.toLowerCase()) {
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
return {
|
|
1062
|
+
installed: true,
|
|
1063
|
+
state: match[2],
|
|
1064
|
+
wslVersion: Number(match[3])
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
_logger.warn("distro not present in wsl -l -v");
|
|
1068
|
+
return { installed: false, state: "unknown" };
|
|
1069
|
+
}
|
|
1070
|
+
async function probeDistroFacts(host) {
|
|
1071
|
+
const script = [
|
|
1072
|
+
`printf 'mode=%s\\n' "$(wslinfo --networking-mode 2>/dev/null || echo unknown)"`,
|
|
1073
|
+
`printf 'init=%s\\n' "$(ps -p 1 -o comm= 2>/dev/null || echo unknown)"`,
|
|
1074
|
+
`printf 'kernel=%s\\n' "$(uname -r 2>/dev/null || echo unknown)"`
|
|
1075
|
+
].join("\n");
|
|
1076
|
+
const output = await host.runProcess({
|
|
1077
|
+
script,
|
|
1078
|
+
timeoutMs: LISTING_TIMEOUT_MS,
|
|
1079
|
+
maxCapturedBytes: PROBE_CAPTURE_BYTES
|
|
1080
|
+
});
|
|
1081
|
+
const facts = {};
|
|
1082
|
+
for (const line of output.stdout.toString("utf8").split("\n")) {
|
|
1083
|
+
const [key, ...rest] = line.split("=");
|
|
1084
|
+
const value = rest.join("=").trim();
|
|
1085
|
+
if (key === "mode") {
|
|
1086
|
+
facts.networkingMode = value;
|
|
1087
|
+
} else if (key === "init") {
|
|
1088
|
+
facts.systemd = value === "systemd";
|
|
1089
|
+
} else if (key === "kernel") {
|
|
1090
|
+
facts.kernel = value;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
return facts;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// src/modules/config/host-profile.ts
|
|
1097
|
+
function readEnv(name) {
|
|
1098
|
+
const value = process.env[name];
|
|
1099
|
+
if (value == null) {
|
|
1100
|
+
return void 0;
|
|
1101
|
+
}
|
|
1102
|
+
const trimmed = value.trim();
|
|
1103
|
+
if (trimmed === "") {
|
|
1104
|
+
return void 0;
|
|
1105
|
+
}
|
|
1106
|
+
return trimmed;
|
|
1107
|
+
}
|
|
1108
|
+
function readBooleanEnv(name) {
|
|
1109
|
+
const value = readEnv(name);
|
|
1110
|
+
if (value == null) {
|
|
1111
|
+
return void 0;
|
|
1112
|
+
}
|
|
1113
|
+
const normalized = value.toLowerCase();
|
|
1114
|
+
if (TRUTHY_ENV_VALUES.includes(normalized) == true) {
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
if (FALSY_ENV_VALUES.includes(normalized) == false) {
|
|
1118
|
+
throw new Error(`${name} must be one of ${[...TRUTHY_ENV_VALUES, ...FALSY_ENV_VALUES].join(", ")} (got "${value}")`);
|
|
1119
|
+
}
|
|
1120
|
+
return false;
|
|
1121
|
+
}
|
|
1122
|
+
function readIntegerEnv(name) {
|
|
1123
|
+
const value = readEnv(name);
|
|
1124
|
+
if (value == null) {
|
|
1125
|
+
return void 0;
|
|
1126
|
+
}
|
|
1127
|
+
const parsed = Number(value);
|
|
1128
|
+
if (Number.isInteger(parsed) == false || parsed <= 0) {
|
|
1129
|
+
throw new Error(`${name} must be a positive integer (got "${value}")`);
|
|
1130
|
+
}
|
|
1131
|
+
return parsed;
|
|
1132
|
+
}
|
|
1133
|
+
function readListEnv(name) {
|
|
1134
|
+
const value = readEnv(name);
|
|
1135
|
+
if (value == null) {
|
|
1136
|
+
return void 0;
|
|
1137
|
+
}
|
|
1138
|
+
const items = value.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
1139
|
+
if (items.length === 0) {
|
|
1140
|
+
return void 0;
|
|
1141
|
+
}
|
|
1142
|
+
return items;
|
|
1143
|
+
}
|
|
1144
|
+
function compileDenyPatterns(sources) {
|
|
1145
|
+
return sources.map((source) => {
|
|
1146
|
+
try {
|
|
1147
|
+
return new RegExp(source, "i");
|
|
1148
|
+
} catch (ex) {
|
|
1149
|
+
let detail = String(ex);
|
|
1150
|
+
if (ex instanceof Error) {
|
|
1151
|
+
detail = ex.message;
|
|
1152
|
+
}
|
|
1153
|
+
throw new Error(`${SINGLE_HOST_ENV.DENY_PATTERNS} has an invalid entry /${source}/: ${detail}`);
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
function loadHostProfile() {
|
|
1158
|
+
const distro = readEnv(SINGLE_HOST_ENV.DISTRO);
|
|
1159
|
+
if (distro == null) {
|
|
1160
|
+
const _startupLogger = $logger.child({ context: "loadHostProfile" });
|
|
1161
|
+
_startupLogger.warn("no distro is registered; set the distro variable and restart", {
|
|
1162
|
+
env_vars: [SINGLE_HOST_ENV.DISTRO]
|
|
1163
|
+
});
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
const name = readEnv(SINGLE_HOST_ENV.NAME) ?? distro;
|
|
1167
|
+
const _logger = $logger.child({ context: "loadHostProfile", profile: name });
|
|
1168
|
+
const allowCommands = readListEnv(SINGLE_HOST_ENV.ALLOW_COMMANDS) ?? [];
|
|
1169
|
+
if (allowCommands.length > 0 && allowCommands.includes("pwd") == false) {
|
|
1170
|
+
allowCommands.push("pwd");
|
|
1171
|
+
}
|
|
1172
|
+
const policy = {
|
|
1173
|
+
readonly: readBooleanEnv(SINGLE_HOST_ENV.READONLY) ?? false,
|
|
1174
|
+
allowSudo: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_SUDO) ?? true,
|
|
1175
|
+
execTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.EXEC_TIMEOUT_MS) ?? DEFAULT_EXEC_TIMEOUT_MS,
|
|
1176
|
+
// Nothing connects; carried only because the shared policy type declares it.
|
|
1177
|
+
connectTimeoutMs: 0,
|
|
1178
|
+
maxOutputCharacters: readIntegerEnv(SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS) ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
|
|
1179
|
+
maxReadFileBytes: readIntegerEnv(SINGLE_HOST_ENV.MAX_READ_FILE_BYTES) ?? DEFAULT_MAX_READ_FILE_BYTES,
|
|
1180
|
+
allowCommands,
|
|
1181
|
+
denyPatterns: compileDenyPatterns(readListEnv(SINGLE_HOST_ENV.DENY_PATTERNS) ?? []),
|
|
1182
|
+
allowedPaths: readListEnv(SINGLE_HOST_ENV.ALLOWED_PATHS) ?? []
|
|
1183
|
+
};
|
|
1184
|
+
const profile = {
|
|
1185
|
+
name,
|
|
1186
|
+
distro,
|
|
1187
|
+
username: readEnv(SINGLE_HOST_ENV.USER),
|
|
1188
|
+
description: readEnv(SINGLE_HOST_ENV.DESCRIPTION),
|
|
1189
|
+
defaultCwd: readEnv(SINGLE_HOST_ENV.CWD),
|
|
1190
|
+
allowWindows: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_WINDOWS) ?? false,
|
|
1191
|
+
policy
|
|
1192
|
+
};
|
|
1193
|
+
_logger.info("host profile loaded", {
|
|
1194
|
+
distro,
|
|
1195
|
+
username: profile.username ?? "(distro default)",
|
|
1196
|
+
readonly: policy.readonly,
|
|
1197
|
+
allow_windows: profile.allowWindows
|
|
1198
|
+
});
|
|
1199
|
+
return profile;
|
|
1200
|
+
}
|
|
1201
|
+
function toHostSummary(profile) {
|
|
1202
|
+
return {
|
|
1203
|
+
name: profile.name,
|
|
1204
|
+
distro: profile.distro,
|
|
1205
|
+
username: profile.username ?? "(distro default)",
|
|
1206
|
+
description: profile.description,
|
|
1207
|
+
readonly: profile.policy.readonly,
|
|
1208
|
+
allowSudo: profile.policy.allowSudo,
|
|
1209
|
+
allowWindows: profile.allowWindows,
|
|
1210
|
+
allowedPaths: profile.policy.allowedPaths,
|
|
1211
|
+
allowCommands: profile.policy.allowCommands
|
|
1212
|
+
};
|
|
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
|
+
var SERVER_INSTRUCTIONS = [
|
|
1828
|
+
"This server runs shell commands and transfers files in one WSL distro on this machine, through wsl.exe \u2014 no SSH, no network.",
|
|
1829
|
+
"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.",
|
|
1830
|
+
"",
|
|
1831
|
+
"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.",
|
|
1832
|
+
"",
|
|
1833
|
+
"Workflow:",
|
|
1834
|
+
"1. One command: wsl_exec with just 'command'.",
|
|
1835
|
+
"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.",
|
|
1836
|
+
"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.",
|
|
1837
|
+
"",
|
|
1838
|
+
"What is permitted: ordinary administration, without asking \u2014 package installs, service restarts, sudo, interpreters, config edits, deployments.",
|
|
1839
|
+
"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.",
|
|
1840
|
+
"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.",
|
|
1841
|
+
"The profile may add its own limits (read-only, an allow-list, deny patterns, path restrictions). wsl_list_hosts shows which apply.",
|
|
1842
|
+
"",
|
|
1843
|
+
"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.",
|
|
1844
|
+
"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.",
|
|
1845
|
+
"",
|
|
1846
|
+
"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.",
|
|
1847
|
+
"A non-zero exit code is returned as normal output; read it and stderr to judge what happened.",
|
|
1848
|
+
"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."
|
|
1849
|
+
].join("\n");
|
|
1850
|
+
function createWslMcpServer() {
|
|
1851
|
+
const _logger = $logger.child({ context: "createWslMcpServer" });
|
|
1852
|
+
const profile = loadHostProfile();
|
|
1853
|
+
let host = null;
|
|
1854
|
+
if (profile != null) {
|
|
1855
|
+
host = new WslHost(profile);
|
|
1856
|
+
}
|
|
1857
|
+
const sessions = new WslSessionManager();
|
|
1858
|
+
sessions.startIdleSweeper();
|
|
1859
|
+
const server = new mcp_js.McpServer(
|
|
1860
|
+
{
|
|
1861
|
+
name: SERVER_NAME,
|
|
1862
|
+
version: SERVER_VERSION
|
|
1863
|
+
},
|
|
1864
|
+
{
|
|
1865
|
+
instructions: SERVER_INSTRUCTIONS
|
|
1866
|
+
}
|
|
1867
|
+
);
|
|
1868
|
+
const context = {
|
|
1869
|
+
host,
|
|
1870
|
+
sessions
|
|
1871
|
+
};
|
|
1872
|
+
registerAllTools(server, context);
|
|
1873
|
+
_logger.info("mcp server built", { profile: profile?.name ?? null });
|
|
1874
|
+
return {
|
|
1875
|
+
server,
|
|
1876
|
+
sessions,
|
|
1877
|
+
profile,
|
|
1878
|
+
shutdown() {
|
|
1879
|
+
sessions.stopIdleSweeper();
|
|
1880
|
+
sessions.closeAll();
|
|
1881
|
+
}
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// src/cli.ts
|
|
1886
|
+
var SSH_LOG_LEVEL_ENV = "SSH_MCP_LOG_LEVEL";
|
|
1887
|
+
function parseArguments(argv) {
|
|
1888
|
+
const parsed = { showHelp: false, showVersion: false, checkOnly: false };
|
|
1889
|
+
for (const argument of argv) {
|
|
1890
|
+
if (argument === "--help" || argument === "-h") {
|
|
1891
|
+
parsed.showHelp = true;
|
|
1892
|
+
continue;
|
|
1893
|
+
}
|
|
1894
|
+
if (argument === "--version" || argument === "-v") {
|
|
1895
|
+
parsed.showVersion = true;
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
if (argument === "--check") {
|
|
1899
|
+
parsed.checkOnly = true;
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
return parsed;
|
|
1904
|
+
}
|
|
1905
|
+
var HELP_TEXT = [
|
|
1906
|
+
`${SERVER_NAME} ${SERVER_VERSION} \u2014 MCP server for command execution and file transfer in a WSL distro (via wsl.exe)`,
|
|
1907
|
+
"",
|
|
1908
|
+
"Usage:",
|
|
1909
|
+
" akms-mcp-wsl [--check]",
|
|
1910
|
+
"",
|
|
1911
|
+
"Options:",
|
|
1912
|
+
" --check Look up the configured distro, run 'id' in it, report the result, and exit.",
|
|
1913
|
+
" -v, --version Print the version and exit",
|
|
1914
|
+
" -h, --help Print this help and exit",
|
|
1915
|
+
"",
|
|
1916
|
+
"Environment \u2014 one server entry per distro and user, set in the MCP client's env block:",
|
|
1917
|
+
` ${SINGLE_HOST_ENV.DISTRO} Distro name as 'wsl -l -q' prints it (required)`,
|
|
1918
|
+
` ${SINGLE_HOST_ENV.USER} Linux user to run as; the distro default when unset`,
|
|
1919
|
+
` ${SINGLE_HOST_ENV.NAME} Alias shown to the model; defaults to the distro name`,
|
|
1920
|
+
` ${SINGLE_HOST_ENV.DESCRIPTION} Shown to the model \u2014 say what the distro is for`,
|
|
1921
|
+
` ${SINGLE_HOST_ENV.CWD} Directory new sessions start in`,
|
|
1922
|
+
` ${SINGLE_HOST_ENV.READONLY} Reject write commands, redirections, uploads (default false)`,
|
|
1923
|
+
` ${SINGLE_HOST_ENV.ALLOW_SUDO} Allow sudo / su / doas (default true)`,
|
|
1924
|
+
` ${SINGLE_HOST_ENV.ALLOW_WINDOWS} Allow interop .exe and writes under /mnt/<drive>/ (default false)`,
|
|
1925
|
+
` ${SINGLE_HOST_ENV.ALLOW_COMMANDS} Comma-separated allow-list of binaries`,
|
|
1926
|
+
` ${SINGLE_HOST_ENV.DENY_PATTERNS} Comma-separated extra deny regexes`,
|
|
1927
|
+
` ${SINGLE_HOST_ENV.ALLOWED_PATHS} Comma-separated path prefixes the file tools may touch`,
|
|
1928
|
+
` ${SINGLE_HOST_ENV.EXEC_TIMEOUT_MS} / ${SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS} / ${SINGLE_HOST_ENV.MAX_READ_FILE_BYTES}`,
|
|
1929
|
+
` ${LOG_LEVEL_ENV} silent | debug | info | warn | error (default: info, written to stderr)`,
|
|
1930
|
+
"",
|
|
1931
|
+
"Windows only \u2014 wsl.exe is the transport. The server speaks MCP over stdio; run it from an MCP client, not interactively."
|
|
1932
|
+
].join("\n");
|
|
1933
|
+
async function runDistroCheck(profile) {
|
|
1934
|
+
if (profile == null) {
|
|
1935
|
+
process.stdout.write(`No WSL distro is registered \u2014 set ${SINGLE_HOST_ENV.DISTRO} (and optionally ${SINGLE_HOST_ENV.USER}).
|
|
1936
|
+
`);
|
|
1937
|
+
return 1;
|
|
1938
|
+
}
|
|
1939
|
+
const endpoint = `${profile.username ?? "(distro default)"}@${profile.distro}`;
|
|
1940
|
+
process.stdout.write(`Checking ${profile.name} \u2192 ${endpoint}
|
|
1941
|
+
|
|
1942
|
+
`);
|
|
1943
|
+
let listing;
|
|
1944
|
+
try {
|
|
1945
|
+
listing = await queryDistroListing(profile.distro);
|
|
1946
|
+
} catch (ex) {
|
|
1947
|
+
let detail = String(ex);
|
|
1948
|
+
if (ex instanceof Error) {
|
|
1949
|
+
detail = ex.message;
|
|
1950
|
+
}
|
|
1951
|
+
process.stdout.write(` FAILED wsl.exe could not be started: ${detail}
|
|
1952
|
+
`);
|
|
1953
|
+
return 1;
|
|
1954
|
+
}
|
|
1955
|
+
if (listing.installed == false) {
|
|
1956
|
+
process.stdout.write(` FAILED ${formatDistroStatus(listing)}
|
|
1957
|
+
`);
|
|
1958
|
+
return 1;
|
|
1959
|
+
}
|
|
1960
|
+
const host = new WslHost(profile);
|
|
1961
|
+
const startedAt = Date.now();
|
|
1962
|
+
try {
|
|
1963
|
+
const probe = await host.exec({
|
|
1964
|
+
command: "id",
|
|
1965
|
+
timeoutMs: profile.policy.execTimeoutMs,
|
|
1966
|
+
maxOutputCharacters: profile.policy.maxOutputCharacters,
|
|
1967
|
+
trackCwd: false
|
|
1968
|
+
});
|
|
1969
|
+
const durationMs = Date.now() - startedAt;
|
|
1970
|
+
if (probe.exitCode !== 0) {
|
|
1971
|
+
process.stdout.write(` FAILED ${endpoint} exit code ${probe.exitCode}
|
|
1972
|
+
${probe.stderr.trim()}
|
|
1973
|
+
`);
|
|
1974
|
+
return 1;
|
|
1975
|
+
}
|
|
1976
|
+
const facts = await probeDistroFacts(host);
|
|
1977
|
+
process.stdout.write(` OK ${probe.stdout.trim()} (${durationMs}ms)
|
|
1978
|
+
`);
|
|
1979
|
+
process.stdout.write(` ${formatDistroStatus({ ...listing, ...facts })}
|
|
1980
|
+
`);
|
|
1981
|
+
return 0;
|
|
1982
|
+
} catch (ex) {
|
|
1983
|
+
let detail = String(ex);
|
|
1984
|
+
if (ex instanceof Error) {
|
|
1985
|
+
detail = ex.message;
|
|
1986
|
+
}
|
|
1987
|
+
process.stdout.write(` FAILED ${endpoint}
|
|
1988
|
+
${detail}
|
|
1989
|
+
`);
|
|
1990
|
+
return 1;
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
async function main() {
|
|
1994
|
+
const _logger = $logger.child({ context: "cli" });
|
|
1995
|
+
const cliArguments = parseArguments(process.argv.slice(2));
|
|
1996
|
+
if (cliArguments.showHelp == true) {
|
|
1997
|
+
process.stderr.write(`${HELP_TEXT}
|
|
1998
|
+
`);
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
if (cliArguments.showVersion == true) {
|
|
2002
|
+
process.stderr.write(`${SERVER_VERSION}
|
|
2003
|
+
`);
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
if (cliArguments.checkOnly == true && process.env[LOG_LEVEL_ENV] == null) {
|
|
2007
|
+
process.env[LOG_LEVEL_ENV] = "silent";
|
|
2008
|
+
}
|
|
2009
|
+
if (process.env[SSH_LOG_LEVEL_ENV] == null && process.env[LOG_LEVEL_ENV] != null) {
|
|
2010
|
+
process.env[SSH_LOG_LEVEL_ENV] = process.env[LOG_LEVEL_ENV];
|
|
2011
|
+
}
|
|
2012
|
+
const instance = createWslMcpServer();
|
|
2013
|
+
if (cliArguments.checkOnly == true) {
|
|
2014
|
+
const exitCode = await runDistroCheck(instance.profile);
|
|
2015
|
+
instance.shutdown();
|
|
2016
|
+
process.exit(exitCode);
|
|
2017
|
+
}
|
|
2018
|
+
let shuttingDown = false;
|
|
2019
|
+
const shutdown = (signal) => {
|
|
2020
|
+
if (shuttingDown == true) {
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
shuttingDown = true;
|
|
2024
|
+
_logger.info("shutting down", { signal });
|
|
2025
|
+
instance.shutdown();
|
|
2026
|
+
void instance.server.close().finally(() => {
|
|
2027
|
+
process.exit(0);
|
|
2028
|
+
});
|
|
2029
|
+
};
|
|
2030
|
+
process.on("SIGINT", () => {
|
|
2031
|
+
shutdown("SIGINT");
|
|
2032
|
+
});
|
|
2033
|
+
process.on("SIGTERM", () => {
|
|
2034
|
+
shutdown("SIGTERM");
|
|
2035
|
+
});
|
|
2036
|
+
process.stdin.on("close", () => {
|
|
2037
|
+
shutdown("stdin-close");
|
|
2038
|
+
});
|
|
2039
|
+
const transport = new stdio_js.StdioServerTransport();
|
|
2040
|
+
await instance.server.connect(transport);
|
|
2041
|
+
_logger.info("mcp server ready on stdio", {
|
|
2042
|
+
server: SERVER_NAME,
|
|
2043
|
+
version: SERVER_VERSION,
|
|
2044
|
+
profile: instance.profile?.name ?? null
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
main().catch((error) => {
|
|
2048
|
+
const _logger = $logger.child({ context: "cli" });
|
|
2049
|
+
_logger.error(error, "mcp server failed to start");
|
|
2050
|
+
let detail = String(error);
|
|
2051
|
+
if (error instanceof Error) {
|
|
2052
|
+
detail = error.message;
|
|
2053
|
+
}
|
|
2054
|
+
process.stderr.write(`akms-mcp-wsl: ${detail}
|
|
2055
|
+
`);
|
|
2056
|
+
process.exit(1);
|
|
2057
|
+
});
|
|
2058
|
+
//# sourceMappingURL=cli.cjs.map
|
|
2059
|
+
//# sourceMappingURL=cli.cjs.map
|