@lzm04521/ssh-mcp-server 1.1.5

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.
Files changed (78) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +157 -0
  3. package/admin-web/dist/assets/index-DXbr5DVR.js +494 -0
  4. package/admin-web/dist/index.html +6 -0
  5. package/admin-web/dist/logo.png +0 -0
  6. package/build/cli/command-line-parser.js +715 -0
  7. package/build/cli/command-line-parser.js.map +1 -0
  8. package/build/cli/run-mode.js +44 -0
  9. package/build/cli/run-mode.js.map +1 -0
  10. package/build/cli/stdio-proxy.js +304 -0
  11. package/build/cli/stdio-proxy.js.map +1 -0
  12. package/build/config/index.js +5 -0
  13. package/build/config/index.js.map +1 -0
  14. package/build/config/server.js +16 -0
  15. package/build/config/server.js.map +1 -0
  16. package/build/core/mcp-http-server.js +14 -0
  17. package/build/core/mcp-http-server.js.map +1 -0
  18. package/build/core/mcp-server.js +95 -0
  19. package/build/core/mcp-server.js.map +1 -0
  20. package/build/index.js +98 -0
  21. package/build/index.js.map +1 -0
  22. package/build/models/admin-types.js +135 -0
  23. package/build/models/admin-types.js.map +1 -0
  24. package/build/models/types.js +2 -0
  25. package/build/models/types.js.map +1 -0
  26. package/build/server/index.js +128 -0
  27. package/build/server/index.js.map +1 -0
  28. package/build/server/routes/admin.js +929 -0
  29. package/build/server/routes/admin.js.map +1 -0
  30. package/build/server/routes/audit.js +9 -0
  31. package/build/server/routes/audit.js.map +1 -0
  32. package/build/server/routes/backups.js +11 -0
  33. package/build/server/routes/backups.js.map +1 -0
  34. package/build/server/routes/mcp.js +52 -0
  35. package/build/server/routes/mcp.js.map +1 -0
  36. package/build/server/routes/settings.js +49 -0
  37. package/build/server/routes/settings.js.map +1 -0
  38. package/build/server/routes/system.js +147 -0
  39. package/build/server/routes/system.js.map +1 -0
  40. package/build/services/audit-store.js +123 -0
  41. package/build/services/audit-store.js.map +1 -0
  42. package/build/services/autostart-service.js +55 -0
  43. package/build/services/autostart-service.js.map +1 -0
  44. package/build/services/backup-scheduler.js +90 -0
  45. package/build/services/backup-scheduler.js.map +1 -0
  46. package/build/services/backup-service.js +113 -0
  47. package/build/services/backup-service.js.map +1 -0
  48. package/build/services/config-store.js +163 -0
  49. package/build/services/config-store.js.map +1 -0
  50. package/build/services/defaults.js +35 -0
  51. package/build/services/defaults.js.map +1 -0
  52. package/build/services/restart-helper.js +43 -0
  53. package/build/services/restart-helper.js.map +1 -0
  54. package/build/services/ssh-connection-manager.js +1775 -0
  55. package/build/services/ssh-connection-manager.js.map +1 -0
  56. package/build/services/update-service.js +93 -0
  57. package/build/services/update-service.js.map +1 -0
  58. package/build/tools/download.js +41 -0
  59. package/build/tools/download.js.map +1 -0
  60. package/build/tools/execute-command.js +53 -0
  61. package/build/tools/execute-command.js.map +1 -0
  62. package/build/tools/index.js +17 -0
  63. package/build/tools/index.js.map +1 -0
  64. package/build/tools/list-directory.js +57 -0
  65. package/build/tools/list-directory.js.map +1 -0
  66. package/build/tools/list-servers.js +51 -0
  67. package/build/tools/list-servers.js.map +1 -0
  68. package/build/tools/upload.js +41 -0
  69. package/build/tools/upload.js.map +1 -0
  70. package/build/utils/logger.js +28 -0
  71. package/build/utils/logger.js.map +1 -0
  72. package/build/utils/ssh-config-parser.js +195 -0
  73. package/build/utils/ssh-config-parser.js.map +1 -0
  74. package/build/utils/status-collector.js +226 -0
  75. package/build/utils/status-collector.js.map +1 -0
  76. package/build/utils/tool-error.js +20 -0
  77. package/build/utils/tool-error.js.map +1 -0
  78. package/package.json +69 -0
@@ -0,0 +1,1775 @@
1
+ import { createRequire } from "node:module";
2
+ import { Logger } from "../utils/logger.js";
3
+ import { collectSystemStatus } from "../utils/status-collector.js";
4
+ import { ToolError } from "../utils/tool-error.js";
5
+ import { getFlatHosts, getFlatHostByName } from "./config-store.js";
6
+ import { globalAuditStore } from "./audit-store.js";
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import { pipeline } from "node:stream/promises";
10
+ import { StringDecoder } from "node:string_decoder";
11
+ const require = createRequire(import.meta.url);
12
+ const ANSI_OSC_PATTERN = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
13
+ const ANSI_CSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
14
+ // Matches the exit code that `buildShellCommandScript` prints right after the
15
+ // end marker prefix, anchored so it can only be read at the expected offset.
16
+ const SHELL_EXIT_CODE_PATTERN = /^(-?\d+)__(?:\r)?\n/;
17
+ const SHELL_EXIT_CODE_MAX_LENGTH = 32;
18
+ const COMMAND_TEMPLATE_PLACEHOLDER = "<command>";
19
+ const QUOTED_COMMAND_TEMPLATE_PLACEHOLDER = "<quotedCommand>";
20
+ const DEFAULT_CONNECTION_TIMEOUT_MS = 30000;
21
+ const DEFAULT_COMMAND_TIMEOUT_MS = 30000;
22
+ const DEFAULT_KEEPALIVE_INTERVAL_MS = 10000;
23
+ const DEFAULT_KEEPALIVE_COUNT_MAX = 3;
24
+ const DEFAULT_SFTP_TIMEOUT_MS = 300000;
25
+ const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
26
+ // ssh2's SFTP ReadStream/WriteStream keep a single request in flight, so
27
+ // transfer throughput is capped at one chunk per round trip regardless of the
28
+ // available bandwidth. fastGet/fastPut pipeline `concurrency` chunks instead,
29
+ // which is what makes large transfers usable on high latency links.
30
+ const SFTP_FAST_TRANSFER_OPTIONS = {
31
+ concurrency: 64,
32
+ chunkSize: 32 * 1024,
33
+ };
34
+ // Under this size the streaming path already completes in a couple of round
35
+ // trips, so the extra stat fastGet needs would cost more than the concurrency
36
+ // saves. Staying on the streaming path below the threshold also keeps the
37
+ // existing behaviour for files whose reported size is unusable: fastGet/fastPut
38
+ // plan their chunks from that size and treat `size <= 0` as "nothing to
39
+ // transfer", which would silently produce an empty file for pseudo files such
40
+ // as /proc/cpuinfo.
41
+ const SFTP_FAST_TRANSFER_MIN_BYTES = 256 * 1024;
42
+ function applyCommandTemplate(template, command) {
43
+ const quotedCommand = shellQuote(command);
44
+ return template
45
+ .split(QUOTED_COMMAND_TEMPLATE_PLACEHOLDER)
46
+ .join(quotedCommand)
47
+ .split(`'${COMMAND_TEMPLATE_PLACEHOLDER}'`)
48
+ .join(quotedCommand)
49
+ .split(`"${COMMAND_TEMPLATE_PLACEHOLDER}"`)
50
+ .join(quotedCommand)
51
+ .split(COMMAND_TEMPLATE_PLACEHOLDER)
52
+ .join(command);
53
+ }
54
+ function shellQuote(value) {
55
+ return `'${value.replace(/'/g, `'\\''`)}'`;
56
+ }
57
+ /**
58
+ * The caller cannot see the process working directory or the server's path
59
+ * configuration, so a rejection that only says "must be within an allowed path"
60
+ * leaves it nothing to correct against. Name the roots instead.
61
+ */
62
+ function describeAllowedRoots(kind, allowedRoots) {
63
+ return `Allowed ${kind} paths for this connection: ${allowedRoots.join(", ")}.`;
64
+ }
65
+ function isPathWithinRoot(candidate, root) {
66
+ const relativePath = path.relative(root, candidate);
67
+ return (relativePath === "" ||
68
+ (relativePath !== "" &&
69
+ !relativePath.startsWith("..") &&
70
+ !path.isAbsolute(relativePath)));
71
+ }
72
+ function redactProxyUrl(proxyUrl) {
73
+ const redactedUrl = new URL(proxyUrl.toString());
74
+ if (redactedUrl.username) {
75
+ redactedUrl.username = "***";
76
+ }
77
+ if (redactedUrl.password) {
78
+ redactedUrl.password = "***";
79
+ }
80
+ return redactedUrl.toString();
81
+ }
82
+ function normalizeUrlHostname(hostname) {
83
+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
84
+ return hostname.slice(1, -1);
85
+ }
86
+ return hostname;
87
+ }
88
+ function formatHostPort(host, port) {
89
+ const formattedHost = host.includes(":") && !host.startsWith("[")
90
+ ? `[${host}]`
91
+ : host;
92
+ return `${formattedHost}:${port}`;
93
+ }
94
+ function parseProxyPort(proxyUrl, defaultPort) {
95
+ const port = proxyUrl.port
96
+ ? Number.parseInt(proxyUrl.port, 10)
97
+ : defaultPort;
98
+ if (!port || !Number.isInteger(port) || port <= 0 || port > 65535) {
99
+ throw new Error("Proxy URL must include a valid port");
100
+ }
101
+ return port;
102
+ }
103
+ function isPasswordPrompt(prompt) {
104
+ const promptText = prompt.toLowerCase();
105
+ return promptText.includes("password") || promptText.includes("密码");
106
+ }
107
+ function isAuthMethodAllowedByServer(method, methodsLeft) {
108
+ if (methodsLeft === null) {
109
+ return true;
110
+ }
111
+ // ssh-agent uses the SSH publickey protocol method.
112
+ if (method === "agent") {
113
+ return methodsLeft.includes("publickey");
114
+ }
115
+ return methodsLeft.includes(method);
116
+ }
117
+ /**
118
+ * SSH Connection Manager class
119
+ */
120
+ export class SSHConnectionManager {
121
+ static instance;
122
+ clients = new Map();
123
+ configs = {};
124
+ connected = new Map();
125
+ statusCache = new Map();
126
+ pendingConnections = new Map();
127
+ pendingStatusCollections = new Map();
128
+ commandWhitelistRegexes = new Map();
129
+ commandBlacklistRegexes = new Map();
130
+ shellStreams = new Map();
131
+ shellReady = new Map();
132
+ shellQueues = new Map();
133
+ shellBuffers = new Map();
134
+ // A multi-byte character can be split across two TCP chunks, so the channel
135
+ // needs one decoder for its whole lifetime rather than a decode per chunk.
136
+ shellDecoders = new Map();
137
+ defaultName = "default";
138
+ currentConfig = null;
139
+ constructor() { }
140
+ /**
141
+ * Get singleton instance
142
+ */
143
+ static getInstance() {
144
+ if (!SSHConnectionManager.instance) {
145
+ SSHConnectionManager.instance = new SSHConnectionManager();
146
+ }
147
+ return SSHConnectionManager.instance;
148
+ }
149
+ /**
150
+ * Batch set SSH configurations
151
+ */
152
+ setConfig(cfg, defaultName) {
153
+ this.disconnect();
154
+ this.commandWhitelistRegexes.clear();
155
+ this.commandBlacklistRegexes.clear();
156
+ let configs;
157
+ if (cfg && typeof cfg === "object" && "projects" in cfg) {
158
+ const global = cfg;
159
+ this.currentConfig = global;
160
+ const flat = getFlatHosts(global);
161
+ const sec = global.security || {};
162
+ configs = {};
163
+ for (const [flatName, entry] of flat) {
164
+ // 全局安全兜底:白名单/路径在连接级留空时跟随全局,配置了则以连接级为准;
165
+ // 黑名单取并集——全局高危拦截不能被连接级配置清空
166
+ const c = { ...entry.config };
167
+ if ((!c.commandWhitelist || c.commandWhitelist.length === 0) && Array.isArray(sec.commandWhitelist) && sec.commandWhitelist.length > 0) {
168
+ c.commandWhitelist = sec.commandWhitelist;
169
+ }
170
+ if (Array.isArray(sec.commandBlacklist) && sec.commandBlacklist.length > 0) {
171
+ c.commandBlacklist = [...sec.commandBlacklist, ...(c.commandBlacklist || [])];
172
+ }
173
+ if ((!c.allowedLocalPaths || c.allowedLocalPaths.length === 0) && Array.isArray(sec.allowedLocalPaths) && sec.allowedLocalPaths.length > 0) {
174
+ c.allowedLocalPaths = sec.allowedLocalPaths;
175
+ }
176
+ if ((!c.allowedRemotePaths || c.allowedRemotePaths.length === 0) && Array.isArray(sec.allowedRemotePaths) && sec.allowedRemotePaths.length > 0) {
177
+ c.allowedRemotePaths = sec.allowedRemotePaths;
178
+ }
179
+ configs[flatName] = c;
180
+ }
181
+ globalAuditStore.retentionDays = global.audit?.retentionDays;
182
+ }
183
+ else {
184
+ this.currentConfig = null;
185
+ configs = cfg;
186
+ }
187
+ for (const [name, config] of Object.entries(configs)) {
188
+ this.commandWhitelistRegexes.set(name, this.compilePatterns(config.commandWhitelist, name, "whitelist"));
189
+ this.commandBlacklistRegexes.set(name, this.compilePatterns(config.commandBlacklist, name, "blacklist"));
190
+ }
191
+ this.configs = configs;
192
+ if (defaultName && configs[defaultName]) {
193
+ this.defaultName = defaultName;
194
+ }
195
+ else if (Object.keys(configs).length > 0) {
196
+ this.defaultName = Object.keys(configs)[0];
197
+ }
198
+ }
199
+ /** 审计埋点:enabled=false 全关;logResults=false 只记失败。存储异常静默,不影响命令执行 */
200
+ auditLog(connection, tool, ok, detail) {
201
+ const audit = this.currentConfig?.audit;
202
+ if (audit?.enabled === false)
203
+ return;
204
+ if (ok && audit?.logResults === false)
205
+ return;
206
+ try {
207
+ globalAuditStore.log({ connection, tool, status: ok ? "ok" : "fail", sql: detail });
208
+ }
209
+ catch { }
210
+ }
211
+ auditName(name) {
212
+ return name || this.defaultName;
213
+ }
214
+ resolveFlatName(name) {
215
+ if (!name) {
216
+ if (Object.keys(this.configs).length === 0) {
217
+ throw new ToolError("HOST_NOT_FOUND", "SSH configuration not set: No hosts configured", false);
218
+ }
219
+ if (this.configs[this.defaultName]) {
220
+ return this.defaultName;
221
+ }
222
+ return Object.keys(this.configs)[0];
223
+ }
224
+ if (this.configs[name]) {
225
+ return name;
226
+ }
227
+ if (this.currentConfig) {
228
+ const result = getFlatHostByName(name, this.currentConfig);
229
+ if (result) {
230
+ if (result.ambiguous) {
231
+ throw new ToolError("AMBIGUOUS_HOST", `Ambiguous host '${name}': candidates ${result.candidates.join(", ")}`, false);
232
+ }
233
+ return result.flatName;
234
+ }
235
+ throw new ToolError("HOST_NOT_FOUND", `SSH configuration for '${name}' not set`, false);
236
+ }
237
+ throw new ToolError("HOST_NOT_FOUND", `SSH configuration for '${name}' not set`, false);
238
+ }
239
+ /**
240
+ * Get specified connection configuration
241
+ */
242
+ getConfig(name) {
243
+ const key = this.resolveFlatName(name);
244
+ const cfg = this.configs[key];
245
+ if (!cfg) {
246
+ throw new ToolError("HOST_NOT_FOUND", `SSH configuration for '${key}' not set`, false);
247
+ }
248
+ return cfg;
249
+ }
250
+ /**
251
+ * Batch connect all configured SSH connections
252
+ */
253
+ async connectAll() {
254
+ const names = Object.keys(this.configs);
255
+ const results = await Promise.allSettled(names.map((name) => this.connect(name)));
256
+ const failures = results
257
+ .map((result, index) => ({ result, name: names[index] }))
258
+ .filter((entry) => entry.result.status === "rejected");
259
+ if (failures.length > 0) {
260
+ throw new ToolError("SSH_CONNECTION_FAILED", failures
261
+ .map(({ name, result }) => `[${name}] ${result.reason instanceof Error
262
+ ? result.reason.message
263
+ : String(result.reason)}`)
264
+ .join("; "), true);
265
+ }
266
+ }
267
+ /**
268
+ * Connect to SSH with specified name
269
+ */
270
+ async connect(name) {
271
+ const key = this.resolveFlatName(name);
272
+ if (this.hasUsableConnection(key)) {
273
+ return;
274
+ }
275
+ const existingConnection = this.pendingConnections.get(key);
276
+ if (existingConnection) {
277
+ await existingConnection;
278
+ return;
279
+ }
280
+ const config = this.getConfig(key);
281
+ const client = this.createClient();
282
+ const connectionPromise = new Promise(async (resolve, reject) => {
283
+ let settled = false;
284
+ const timeoutMs = this.getConnectionTimeoutMs(config);
285
+ const timeoutId = setTimeout(() => {
286
+ rejectOnce(new ToolError("SSH_CONNECTION_TIMEOUT", `SSH connection [${key}] timed out after ${timeoutMs}ms`, true));
287
+ this.invalidateConnection(key);
288
+ try {
289
+ client.destroy();
290
+ }
291
+ catch {
292
+ // Ignore cleanup errors during connection timeout.
293
+ }
294
+ }, timeoutMs);
295
+ const clearConnectionTimeout = () => clearTimeout(timeoutId);
296
+ const resolveOnce = () => {
297
+ if (settled) {
298
+ return;
299
+ }
300
+ settled = true;
301
+ clearConnectionTimeout();
302
+ resolve();
303
+ };
304
+ const rejectOnce = (error) => {
305
+ if (settled) {
306
+ return;
307
+ }
308
+ settled = true;
309
+ clearConnectionTimeout();
310
+ reject(error);
311
+ };
312
+ client.on("ready", async () => {
313
+ Logger.log(`Successfully connected to SSH server [${key}] ${config.host}:${config.port}`);
314
+ try {
315
+ if (this.getTransportMode(config) === "shell") {
316
+ await this.initializeShellSession(client, key, config);
317
+ }
318
+ this.clients.set(key, client);
319
+ this.connected.set(key, true);
320
+ this.scheduleStatusCollection(key);
321
+ resolveOnce();
322
+ }
323
+ catch (error) {
324
+ this.connected.set(key, false);
325
+ this.cleanupShellState(key, true);
326
+ try {
327
+ client.end();
328
+ }
329
+ catch {
330
+ // Ignore cleanup errors during failed initialization.
331
+ }
332
+ rejectOnce(error instanceof ToolError
333
+ ? error
334
+ : new ToolError("SSH_CONNECTION_FAILED", `SSH connection [${key}] failed: ${error.message}`, true));
335
+ }
336
+ });
337
+ client.on("error", (err) => {
338
+ this.connected.set(key, false);
339
+ if (this.clients.get(key) === client || this.shellStreams.has(key)) {
340
+ this.invalidateConnection(key);
341
+ }
342
+ rejectOnce(new ToolError("SSH_CONNECTION_FAILED", `SSH connection [${key}] failed: ${err.message}`, true));
343
+ });
344
+ client.on("close", () => {
345
+ this.clearConnectionState(key);
346
+ Logger.log(`SSH connection [${key}] closed`, "info");
347
+ });
348
+ try {
349
+ const sshConfig = await this.buildClientConfig(key, config);
350
+ client.connect(sshConfig);
351
+ }
352
+ catch (error) {
353
+ rejectOnce(error);
354
+ }
355
+ });
356
+ this.pendingConnections.set(key, connectionPromise);
357
+ try {
358
+ await connectionPromise;
359
+ }
360
+ finally {
361
+ this.pendingConnections.delete(key);
362
+ }
363
+ }
364
+ /**
365
+ * Get SSH Client with specified name
366
+ */
367
+ getClient(name) {
368
+ const key = this.resolveFlatName(name);
369
+ const client = this.clients.get(key);
370
+ if (!client) {
371
+ throw new Error(`SSH client for '${key}' not connected`);
372
+ }
373
+ return client;
374
+ }
375
+ /**
376
+ * Execute SSH command
377
+ */
378
+ async executeCommand(cmdString, directory, name, options = {}) {
379
+ try {
380
+ const result = await this.runCommandInternal(cmdString, directory, name, options);
381
+ this.auditLog(this.auditName(name), "execute-command", true, directory ? `cd ${directory} && ${cmdString}` : cmdString);
382
+ return result;
383
+ }
384
+ catch (e) {
385
+ this.auditLog(this.auditName(name), "execute-command", false, directory ? `cd ${directory} && ${cmdString}` : cmdString);
386
+ throw e;
387
+ }
388
+ }
389
+ /**
390
+ * Upload file
391
+ */
392
+ validateLocalPath(localPath, name, purpose = "read") {
393
+ if (typeof localPath !== "string" || localPath.length === 0) {
394
+ throw new ToolError("LOCAL_PATH_NOT_ALLOWED", "Local path must be a non-empty string.", false);
395
+ }
396
+ if (localPath.includes("\0")) {
397
+ throw new ToolError("LOCAL_PATH_NOT_ALLOWED", "Local path must not contain null bytes.", false);
398
+ }
399
+ const resolvedPath = path.resolve(localPath);
400
+ const allowedRoots = this.getAllowedLocalRoots(name);
401
+ const parentPath = path.dirname(resolvedPath);
402
+ const existingPath = this.tryRealpath(resolvedPath);
403
+ const parentRealPath = this.tryRealpath(parentPath);
404
+ let pathToCheck = existingPath;
405
+ if (!pathToCheck && parentRealPath) {
406
+ pathToCheck = path.join(parentRealPath, path.basename(resolvedPath));
407
+ }
408
+ if (!pathToCheck) {
409
+ pathToCheck = resolvedPath;
410
+ }
411
+ if (purpose === "write" && !parentRealPath) {
412
+ throw new ToolError("LOCAL_PATH_NOT_ALLOWED", `Local path parent directory must exist and be within an allowed local path. Resolved to: ${resolvedPath}. ${describeAllowedRoots("local", allowedRoots)}`, false);
413
+ }
414
+ const isAllowed = allowedRoots.some((allowedRoot) => isPathWithinRoot(pathToCheck, allowedRoot));
415
+ if (!isAllowed) {
416
+ throw new ToolError("LOCAL_PATH_NOT_ALLOWED", `Path traversal detected. Local path resolved to: ${pathToCheck}. ${describeAllowedRoots("local", allowedRoots)}`, false);
417
+ }
418
+ return resolvedPath;
419
+ }
420
+ getAllowedLocalRoots(name) {
421
+ const config = this.getConfig(name);
422
+ return [process.cwd(), ...(config.allowedLocalPaths || [])]
423
+ .filter((allowedPath) => allowedPath.trim().length > 0)
424
+ .map((allowedPath) => {
425
+ const resolvedRoot = path.resolve(allowedPath);
426
+ return this.tryRealpath(resolvedRoot) || resolvedRoot;
427
+ });
428
+ }
429
+ tryRealpath(localPath) {
430
+ try {
431
+ return fs.realpathSync.native(localPath);
432
+ }
433
+ catch {
434
+ return undefined;
435
+ }
436
+ }
437
+ validateRemotePath(remotePath, name) {
438
+ if (typeof remotePath !== "string" || remotePath.length === 0) {
439
+ throw new ToolError("REMOTE_PATH_NOT_ALLOWED", "Remote path must be a non-empty string.", false);
440
+ }
441
+ if (remotePath.includes("\0")) {
442
+ throw new ToolError("REMOTE_PATH_NOT_ALLOWED", "Remote path must not contain null bytes.", false);
443
+ }
444
+ if (!path.posix.isAbsolute(remotePath)) {
445
+ throw new ToolError("REMOTE_PATH_NOT_ALLOWED", `Remote path must be an absolute POSIX path, got: ${remotePath}`, false);
446
+ }
447
+ const resolvedPath = path.posix.normalize(remotePath);
448
+ const config = this.getConfig(name);
449
+ const allowedRoots = config.allowedRemotePaths || [];
450
+ if (allowedRoots.length === 0) {
451
+ return resolvedPath;
452
+ }
453
+ const isAllowed = allowedRoots.some((allowedRoot) => resolvedPath === allowedRoot ||
454
+ resolvedPath.startsWith(allowedRoot.endsWith("/") ? allowedRoot : `${allowedRoot}/`));
455
+ if (!isAllowed) {
456
+ throw new ToolError("REMOTE_PATH_NOT_ALLOWED", `Remote path is not within the configured allowedRemotePaths. Resolved to: ${resolvedPath}. ${describeAllowedRoots("remote", allowedRoots)}`, false);
457
+ }
458
+ return resolvedPath;
459
+ }
460
+ /**
461
+ * Upload file
462
+ */
463
+ async upload(localPath, remotePath, name) {
464
+ try {
465
+ const result = await this.uploadInternal(localPath, remotePath, name);
466
+ this.auditLog(this.auditName(name), "upload", true, `${localPath} → ${remotePath}`);
467
+ return result;
468
+ }
469
+ catch (e) {
470
+ this.auditLog(this.auditName(name), "upload", false, `${localPath} → ${remotePath}`);
471
+ throw e;
472
+ }
473
+ }
474
+ async uploadInternal(localPath, remotePath, name) {
475
+ const config = this.getConfig(name);
476
+ const key = this.resolveFlatName(name);
477
+ if (this.getTransportMode(config) === "shell") {
478
+ throw new ToolError("UNSUPPORTED_IN_SHELL_MODE", "Current bastion shell mode does not support SFTP upload/download.", false);
479
+ }
480
+ const validatedLocalPath = this.validateLocalPath(localPath, name, "read");
481
+ const validatedRemotePath = this.validateRemotePath(remotePath, name);
482
+ const client = await this.ensureConnected(name);
483
+ const sftpTimeoutMs = this.getSftpTimeoutMs(config);
484
+ const sftp = await this.withTimeout(this.openSftp(client), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP open timed out after ${sftpTimeoutMs}ms`);
485
+ try {
486
+ const localSize = await this.getLocalSizeForFastTransfer(validatedLocalPath);
487
+ if (localSize === undefined) {
488
+ await this.withTimeout(pipeline(fs.createReadStream(validatedLocalPath), sftp.createWriteStream(validatedRemotePath)), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP upload timed out after ${sftpTimeoutMs}ms`);
489
+ }
490
+ else {
491
+ await this.withTimeout(new Promise((resolve, reject) => {
492
+ sftp.fastPut(validatedLocalPath, validatedRemotePath, SFTP_FAST_TRANSFER_OPTIONS, (err) => (err ? reject(err) : resolve()));
493
+ }), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP upload timed out after ${sftpTimeoutMs}ms`);
494
+ await this.appendUploadTail(sftp, validatedLocalPath, validatedRemotePath, localSize, sftpTimeoutMs, key);
495
+ }
496
+ return "File uploaded successfully";
497
+ }
498
+ catch (error) {
499
+ if (error instanceof ToolError && error.code === "OPERATION_TIMEOUT") {
500
+ throw error;
501
+ }
502
+ if (this.errorPathMatches(error, validatedLocalPath)) {
503
+ throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read local file: ${error.message}`, false);
504
+ }
505
+ throw new ToolError("SFTP_ERROR", `File upload failed: ${error.message}`, true);
506
+ }
507
+ finally {
508
+ this.closeSftp(sftp);
509
+ }
510
+ }
511
+ /**
512
+ * List remote directory
513
+ */
514
+ async listDirectory(remotePath, name) {
515
+ try {
516
+ const result = await this.listDirectoryInternal(remotePath, name);
517
+ this.auditLog(this.auditName(name), "list-directory", true, remotePath);
518
+ return result;
519
+ }
520
+ catch (e) {
521
+ this.auditLog(this.auditName(name), "list-directory", false, remotePath);
522
+ throw e;
523
+ }
524
+ }
525
+ async listDirectoryInternal(remotePath, name) {
526
+ const config = this.getConfig(name);
527
+ const key = this.resolveFlatName(name);
528
+ if (this.getTransportMode(config) === "shell") {
529
+ throw new ToolError("UNSUPPORTED_IN_SHELL_MODE", "Current bastion shell mode does not support SFTP directory listing. Use execute-command with ls instead.", false);
530
+ }
531
+ const validatedRemotePath = this.validateRemotePath(remotePath, name);
532
+ const client = await this.ensureConnected(name);
533
+ const sftpTimeoutMs = this.getSftpTimeoutMs(config);
534
+ const sftp = await this.withTimeout(this.openSftp(client), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP open timed out after ${sftpTimeoutMs}ms`);
535
+ let entries;
536
+ try {
537
+ entries = await this.withTimeout(new Promise((resolve, reject) => {
538
+ sftp.readdir(validatedRemotePath, (err, list) => err ? reject(err) : resolve(list));
539
+ }), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP readdir timed out after ${sftpTimeoutMs}ms`);
540
+ }
541
+ catch (error) {
542
+ const err = error;
543
+ if (err?.code === "ENOENT" || /no such file/i.test(String(err?.message))) {
544
+ throw new ToolError("DIR_NOT_FOUND", `Remote directory not found: ${validatedRemotePath}`, false);
545
+ }
546
+ if (err instanceof ToolError)
547
+ throw err;
548
+ if (/not a directory/i.test(String(err?.message))) {
549
+ throw new ToolError("NOT_A_DIRECTORY", `Remote path is not a directory: ${validatedRemotePath}`, false);
550
+ }
551
+ throw new ToolError("SFTP_ERROR", `Directory listing failed: ${String(err?.message || err)}`, true);
552
+ }
553
+ finally {
554
+ this.closeSftp(sftp);
555
+ }
556
+ const mapped = entries.map((e) => ({
557
+ name: String(e.filename),
558
+ type: e.longname?.[0] === "d" ? "dir" : e.longname?.[0] === "l" ? "symlink" : "file",
559
+ size: typeof e.attrs?.size === "number" ? e.attrs.size : undefined,
560
+ mtimeMs: typeof e.attrs?.mtime === "number" ? e.attrs.mtime * 1000 : undefined,
561
+ }));
562
+ // 目录在前、其余按名称排序,方便模型快速定位
563
+ mapped.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1);
564
+ return mapped;
565
+ }
566
+ /**
567
+ * Download file
568
+ */
569
+ async download(remotePath, localPath, name) {
570
+ try {
571
+ const result = await this.downloadInternal(remotePath, localPath, name);
572
+ this.auditLog(this.auditName(name), "download", true, `${remotePath} → ${localPath}`);
573
+ return result;
574
+ }
575
+ catch (e) {
576
+ this.auditLog(this.auditName(name), "download", false, `${remotePath} → ${localPath}`);
577
+ throw e;
578
+ }
579
+ }
580
+ async downloadInternal(remotePath, localPath, name) {
581
+ const config = this.getConfig(name);
582
+ const key = this.resolveFlatName(name);
583
+ if (this.getTransportMode(config) === "shell") {
584
+ throw new ToolError("UNSUPPORTED_IN_SHELL_MODE", "Current bastion shell mode does not support SFTP upload/download.", false);
585
+ }
586
+ const validatedLocalPath = this.validateLocalPath(localPath, name, "write");
587
+ const validatedRemotePath = this.validateRemotePath(remotePath, name);
588
+ const client = await this.ensureConnected(name);
589
+ const sftpTimeoutMs = this.getSftpTimeoutMs(config);
590
+ const sftp = await this.withTimeout(this.openSftp(client), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP open timed out after ${sftpTimeoutMs}ms`);
591
+ const tempLocalPath = `${validatedLocalPath}.tmp-${process.pid}-${Date.now()}-${Math.random()
592
+ .toString(16)
593
+ .slice(2)}`;
594
+ try {
595
+ const remoteSize = await this.getRemoteSizeForFastTransfer(sftp, validatedRemotePath, sftpTimeoutMs, key);
596
+ if (remoteSize === undefined) {
597
+ await this.withTimeout(pipeline(sftp.createReadStream(validatedRemotePath), fs.createWriteStream(tempLocalPath, { flags: "wx" })), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP download timed out after ${sftpTimeoutMs}ms`);
598
+ }
599
+ else {
600
+ // fastGet opens the destination with "w", which would drop the
601
+ // exclusive-create guarantee the streaming path gets from "wx".
602
+ // Claiming the temp name first keeps it.
603
+ await fs.promises.writeFile(tempLocalPath, "", { flag: "wx" });
604
+ await this.withTimeout(new Promise((resolve, reject) => {
605
+ sftp.fastGet(validatedRemotePath, tempLocalPath, SFTP_FAST_TRANSFER_OPTIONS, (err) => (err ? reject(err) : resolve()));
606
+ }), sftpTimeoutMs, () => this.invalidateConnection(key), `SFTP download timed out after ${sftpTimeoutMs}ms`);
607
+ await this.appendDownloadTail(sftp, validatedRemotePath, tempLocalPath, sftpTimeoutMs, key);
608
+ }
609
+ await fs.promises.rename(tempLocalPath, validatedLocalPath);
610
+ return "File downloaded successfully";
611
+ }
612
+ catch (error) {
613
+ await this.unlinkIfExists(tempLocalPath);
614
+ if (error instanceof ToolError && error.code === "OPERATION_TIMEOUT") {
615
+ throw error;
616
+ }
617
+ if (this.errorPathMatches(error, tempLocalPath) ||
618
+ this.errorPathMatches(error, validatedLocalPath)) {
619
+ throw new ToolError("LOCAL_FILE_WRITE_FAILED", `Failed to save file: ${error.message}`, false);
620
+ }
621
+ throw new ToolError("SFTP_ERROR", `File download failed: ${error.message}`, true);
622
+ }
623
+ finally {
624
+ this.closeSftp(sftp);
625
+ }
626
+ }
627
+ openSftp(client) {
628
+ return new Promise((resolve, reject) => {
629
+ client.sftp((err, sftp) => {
630
+ if (err) {
631
+ reject(new ToolError("SFTP_ERROR", `SFTP connection failed: ${err.message}`, true));
632
+ return;
633
+ }
634
+ resolve(sftp);
635
+ });
636
+ });
637
+ }
638
+ statRemote(sftp, remotePath) {
639
+ return new Promise((resolve, reject) => {
640
+ sftp.stat(remotePath, (err, stats) => err ? reject(err) : resolve(stats));
641
+ });
642
+ }
643
+ /**
644
+ * Size to hand to the concurrent transfer path, or undefined to stay on the
645
+ * streaming path. Anything unexpected — a missing file, a directory, a
646
+ * server without stat support — falls back so the streaming path reports the
647
+ * same error it reports today.
648
+ */
649
+ async getRemoteSizeForFastTransfer(sftp, remotePath, timeoutMs, key) {
650
+ let stats;
651
+ try {
652
+ stats = await this.withTimeout(this.statRemote(sftp, remotePath), timeoutMs, () => this.invalidateConnection(key), `SFTP stat timed out after ${timeoutMs}ms`);
653
+ }
654
+ catch (error) {
655
+ // A timeout already tore the connection down, so it has to surface.
656
+ if (error instanceof ToolError) {
657
+ throw error;
658
+ }
659
+ return undefined;
660
+ }
661
+ return stats.isFile() && stats.size >= SFTP_FAST_TRANSFER_MIN_BYTES
662
+ ? stats.size
663
+ : undefined;
664
+ }
665
+ async getLocalSizeForFastTransfer(localPath) {
666
+ try {
667
+ const stats = await fs.promises.stat(localPath);
668
+ return stats.isFile() && stats.size >= SFTP_FAST_TRANSFER_MIN_BYTES
669
+ ? stats.size
670
+ : undefined;
671
+ }
672
+ catch {
673
+ return undefined;
674
+ }
675
+ }
676
+ /**
677
+ * fastGet transfers exactly the byte count the remote file had when it
678
+ * started, so data appended while it ran would be dropped. The streaming path
679
+ * reads until EOF and would have picked that tail up, so fetch it here to
680
+ * keep both paths equivalent for files that are still being written.
681
+ */
682
+ async appendDownloadTail(sftp, remotePath, tempLocalPath, timeoutMs, key) {
683
+ const downloadedBytes = (await fs.promises.stat(tempLocalPath)).size;
684
+ const stats = await this.withTimeout(this.statRemote(sftp, remotePath), timeoutMs, () => this.invalidateConnection(key), `SFTP stat timed out after ${timeoutMs}ms`);
685
+ if (stats.size <= downloadedBytes) {
686
+ return;
687
+ }
688
+ await this.withTimeout(pipeline(sftp.createReadStream(remotePath, { start: downloadedBytes }), fs.createWriteStream(tempLocalPath, { flags: "a" })), timeoutMs, () => this.invalidateConnection(key), `SFTP download timed out after ${timeoutMs}ms`);
689
+ }
690
+ /**
691
+ * Upload counterpart of appendDownloadTail. The local stat is free, so the
692
+ * remote round trip only happens when the local file actually grew.
693
+ */
694
+ async appendUploadTail(sftp, localPath, remotePath, sizeBeforeTransfer, timeoutMs, key) {
695
+ const currentLocalSize = (await fs.promises.stat(localPath)).size;
696
+ if (currentLocalSize <= sizeBeforeTransfer) {
697
+ return;
698
+ }
699
+ const stats = await this.withTimeout(this.statRemote(sftp, remotePath), timeoutMs, () => this.invalidateConnection(key), `SFTP stat timed out after ${timeoutMs}ms`);
700
+ if (stats.size >= currentLocalSize) {
701
+ return;
702
+ }
703
+ await this.withTimeout(pipeline(fs.createReadStream(localPath, { start: stats.size }), sftp.createWriteStream(remotePath, { flags: "a" })), timeoutMs, () => this.invalidateConnection(key), `SFTP upload timed out after ${timeoutMs}ms`);
704
+ }
705
+ closeSftp(sftp) {
706
+ try {
707
+ sftp.end();
708
+ }
709
+ catch {
710
+ // Ignore cleanup errors after transfer completion.
711
+ }
712
+ }
713
+ withTimeout(promise, timeoutMs, onTimeout, message) {
714
+ let timeoutId;
715
+ return new Promise((resolve, reject) => {
716
+ timeoutId = setTimeout(() => {
717
+ try {
718
+ onTimeout();
719
+ }
720
+ catch {
721
+ // Ignore cleanup errors while rejecting a timed out operation.
722
+ }
723
+ reject(new ToolError("OPERATION_TIMEOUT", message, true));
724
+ }, timeoutMs);
725
+ promise.then((value) => {
726
+ clearTimeout(timeoutId);
727
+ resolve(value);
728
+ }, (error) => {
729
+ clearTimeout(timeoutId);
730
+ reject(error);
731
+ });
732
+ });
733
+ }
734
+ errorPathMatches(error, localPath) {
735
+ const errorPath = error.path;
736
+ return typeof errorPath === "string" && path.resolve(errorPath) === localPath;
737
+ }
738
+ async unlinkIfExists(localPath) {
739
+ try {
740
+ await fs.promises.unlink(localPath);
741
+ }
742
+ catch (error) {
743
+ if (error.code !== "ENOENT") {
744
+ Logger.log(`Failed to remove partial local file ${localPath}: ${error.message}`, "error");
745
+ }
746
+ }
747
+ }
748
+ /**
749
+ * Disconnect SSH connection
750
+ */
751
+ disconnect() {
752
+ for (const timeoutId of this.pendingStatusCollections.values()) {
753
+ clearTimeout(timeoutId);
754
+ }
755
+ this.pendingStatusCollections.clear();
756
+ for (const [key] of this.clients) {
757
+ this.cleanupShellState(key, true);
758
+ }
759
+ if (this.clients.size > 0) {
760
+ for (const client of this.clients.values()) {
761
+ client.end();
762
+ }
763
+ this.clients.clear();
764
+ }
765
+ this.connected.clear();
766
+ this.statusCache.clear();
767
+ this.pendingConnections.clear();
768
+ this.commandWhitelistRegexes.clear();
769
+ this.commandBlacklistRegexes.clear();
770
+ this.shellStreams.clear();
771
+ this.shellReady.clear();
772
+ this.shellQueues.clear();
773
+ this.shellBuffers.clear();
774
+ this.shellDecoders.clear();
775
+ }
776
+ /**
777
+ * Get basic information of all configured servers
778
+ */
779
+ getAllServerInfos() {
780
+ return Object.keys(this.configs).map((key) => {
781
+ const config = this.configs[key];
782
+ const status = this.statusCache.get(key);
783
+ let project;
784
+ let environment;
785
+ let hostName;
786
+ if (this.currentConfig) {
787
+ const flat = getFlatHosts(this.currentConfig);
788
+ const entry = flat.get(key);
789
+ if (entry) {
790
+ project = entry.project;
791
+ environment = entry.environment;
792
+ hostName = entry.host;
793
+ }
794
+ }
795
+ return {
796
+ name: key,
797
+ host: config.host,
798
+ port: config.port,
799
+ username: config.username,
800
+ connected: this.connected.get(key) === true,
801
+ status: status,
802
+ project,
803
+ environment,
804
+ hostName,
805
+ };
806
+ });
807
+ }
808
+ createClient() {
809
+ const { Client } = require("ssh2");
810
+ return new Client();
811
+ }
812
+ async ensureConnected(name) {
813
+ const key = this.resolveFlatName(name);
814
+ if (!this.hasUsableConnection(key)) {
815
+ await this.connect(key);
816
+ }
817
+ const client = this.clients.get(key);
818
+ if (!client) {
819
+ throw new Error(`SSH client for '${key}' not initialized`);
820
+ }
821
+ return client;
822
+ }
823
+ hasUsableConnection(key) {
824
+ const client = this.clients.get(key);
825
+ if (!client || this.connected.get(key) !== true) {
826
+ return false;
827
+ }
828
+ const config = this.getConfig(key);
829
+ if (this.getTransportMode(config) === "shell") {
830
+ return (this.shellReady.get(key) === true && this.shellStreams.has(key));
831
+ }
832
+ return true;
833
+ }
834
+ getTransportMode(config) {
835
+ return config.transportMode || "exec";
836
+ }
837
+ getShellReadyTimeoutMs(config) {
838
+ return config.shellReadyTimeoutMs || 10000;
839
+ }
840
+ getShellCommandTimeoutMs(config) {
841
+ return config.shellCommandTimeoutMs || DEFAULT_COMMAND_TIMEOUT_MS;
842
+ }
843
+ getCommandTimeoutMs(config) {
844
+ return config.commandTimeoutMs || DEFAULT_COMMAND_TIMEOUT_MS;
845
+ }
846
+ getConnectionTimeoutMs(config) {
847
+ return config.connectionTimeoutMs || DEFAULT_CONNECTION_TIMEOUT_MS;
848
+ }
849
+ getSftpTimeoutMs(config) {
850
+ return config.sftpTimeoutMs || DEFAULT_SFTP_TIMEOUT_MS;
851
+ }
852
+ getMaxOutputBytes(config) {
853
+ const configured = config.maxOutputBytes;
854
+ if (configured === undefined) {
855
+ return DEFAULT_MAX_OUTPUT_BYTES;
856
+ }
857
+ if (!Number.isSafeInteger(configured) || configured < 0) {
858
+ throw new ToolError("COMMAND_VALIDATION_FAILED", `maxOutputBytes must be a non-negative integer, got: ${String(configured)}`, false);
859
+ }
860
+ return configured;
861
+ }
862
+ async createSocksProxySocket(proxyUrl, config) {
863
+ const { SocksClient } = require("socks");
864
+ const proxyHost = normalizeUrlHostname(proxyUrl.hostname);
865
+ const proxyPort = parseProxyPort(proxyUrl);
866
+ if (!proxyHost) {
867
+ throw new Error("Proxy URL must include a host");
868
+ }
869
+ const proxy = {
870
+ host: proxyHost,
871
+ port: proxyPort,
872
+ type: 5,
873
+ };
874
+ if (proxyUrl.username) {
875
+ proxy.userId = decodeURIComponent(proxyUrl.username);
876
+ }
877
+ if (proxyUrl.password) {
878
+ proxy.password = decodeURIComponent(proxyUrl.password);
879
+ }
880
+ const { socket } = await SocksClient.createConnection({
881
+ proxy,
882
+ command: "connect",
883
+ destination: {
884
+ host: config.host,
885
+ port: config.port,
886
+ },
887
+ timeout: this.getConnectionTimeoutMs(config),
888
+ });
889
+ return socket;
890
+ }
891
+ createHttpProxySocket(proxyUrl, config) {
892
+ const isTlsProxy = proxyUrl.protocol === "https:";
893
+ const proxyHost = normalizeUrlHostname(proxyUrl.hostname);
894
+ const proxyPort = parseProxyPort(proxyUrl, isTlsProxy ? 443 : 80);
895
+ if (!proxyHost) {
896
+ throw new Error("Proxy URL must include a host");
897
+ }
898
+ const destination = formatHostPort(config.host, config.port);
899
+ const headers = { Host: destination };
900
+ if (proxyUrl.username || proxyUrl.password) {
901
+ const username = decodeURIComponent(proxyUrl.username);
902
+ const password = decodeURIComponent(proxyUrl.password);
903
+ headers["Proxy-Authorization"] = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
904
+ }
905
+ return new Promise((resolve, reject) => {
906
+ const options = {
907
+ method: "CONNECT",
908
+ hostname: proxyHost,
909
+ port: proxyPort,
910
+ path: destination,
911
+ headers,
912
+ };
913
+ const proxyRequest = isTlsProxy
914
+ ? require("node:https").request(options)
915
+ : require("node:http").request(options);
916
+ proxyRequest.once("connect", (response, socket, head) => {
917
+ proxyRequest.setTimeout(0);
918
+ if (response.statusCode !== 200) {
919
+ socket.destroy();
920
+ reject(new Error(`HTTP proxy CONNECT failed with status ${response.statusCode ?? "unknown"}`));
921
+ return;
922
+ }
923
+ if (head.length > 0) {
924
+ socket.unshift(head);
925
+ }
926
+ resolve(socket);
927
+ });
928
+ proxyRequest.once("error", reject);
929
+ proxyRequest.setTimeout(this.getConnectionTimeoutMs(config), () => {
930
+ proxyRequest.destroy(new Error("HTTP proxy CONNECT timed out"));
931
+ });
932
+ proxyRequest.end();
933
+ });
934
+ }
935
+ async createProxySocket(proxyUrl, config) {
936
+ switch (proxyUrl.protocol) {
937
+ case "socks:":
938
+ case "socks5:":
939
+ return this.createSocksProxySocket(proxyUrl, config);
940
+ case "http:":
941
+ case "https:":
942
+ return this.createHttpProxySocket(proxyUrl, config);
943
+ default:
944
+ throw new Error(`Unsupported proxy protocol '${proxyUrl.protocol}'. Use socks://, socks5://, http://, or https://`);
945
+ }
946
+ }
947
+ async buildClientConfig(key, config) {
948
+ const sshConfig = {
949
+ host: config.host,
950
+ port: config.port,
951
+ username: config.username,
952
+ readyTimeout: this.getConnectionTimeoutMs(config),
953
+ timeout: this.getConnectionTimeoutMs(config),
954
+ keepaliveInterval: config.keepaliveIntervalMs || DEFAULT_KEEPALIVE_INTERVAL_MS,
955
+ keepaliveCountMax: config.keepaliveCountMax || DEFAULT_KEEPALIVE_COUNT_MAX,
956
+ };
957
+ if (config.algorithms) {
958
+ sshConfig.algorithms = config.algorithms;
959
+ }
960
+ if (config.proxy && config.socksProxy) {
961
+ throw new ToolError("SSH_CONNECTION_FAILED", `Proxy configuration for [${key}] cannot use both 'proxy' and 'socksProxy'`, false);
962
+ }
963
+ const proxyValue = config.proxy || config.socksProxy;
964
+ if (proxyValue) {
965
+ try {
966
+ const proxyUrl = new URL(proxyValue);
967
+ if (config.socksProxy &&
968
+ proxyUrl.protocol !== "socks:" &&
969
+ proxyUrl.protocol !== "socks5:") {
970
+ throw new Error("The legacy 'socksProxy' option only supports socks:// or socks5:// URLs; use 'proxy' for HTTP or HTTPS proxies");
971
+ }
972
+ Logger.log(`Using proxy for [${key}]: ${redactProxyUrl(proxyUrl)}`, "info");
973
+ sshConfig.sock = await this.createProxySocket(proxyUrl, config);
974
+ Logger.log(`SSH config object with proxy: ${JSON.stringify(sshConfig, (field, value) => (field === "sock" ? "[Socket object]" : value))}`, "info");
975
+ }
976
+ catch (error) {
977
+ throw new ToolError("SSH_CONNECTION_FAILED", `Failed to create proxy connection for [${key}]: ${error.message}`, true);
978
+ }
979
+ }
980
+ // Enable keyboard-interactive authentication for 2FA/MFA
981
+ if (config.tryKeyboard) {
982
+ sshConfig.tryKeyboard = true;
983
+ // Build ordered preference of methods this connection supports.
984
+ const authMethods = [];
985
+ if (config.privateKey) {
986
+ authMethods.push("publickey");
987
+ }
988
+ if (config.agent) {
989
+ authMethods.push("agent");
990
+ }
991
+ if (config.password) {
992
+ authMethods.push("password");
993
+ }
994
+ authMethods.push("keyboard-interactive");
995
+ const triedMethods = [];
996
+ const maxAuthAttempts = authMethods.length;
997
+ sshConfig.authHandler = (methodsLeft, partialSuccess, callback) => {
998
+ // Prevent infinite retry loops.
999
+ if (triedMethods.length >= maxAuthAttempts) {
1000
+ Logger.log(`[${key}] Authentication failed after trying [${triedMethods.join(", ")}]`, "error");
1001
+ return callback(false);
1002
+ }
1003
+ // Pick the next preferred method that hasn't been attempted yet
1004
+ // (and is still allowed by the server if methodsLeft is provided).
1005
+ const candidates = methodsLeft !== null
1006
+ ? authMethods.filter((m) => isAuthMethodAllowedByServer(m, methodsLeft))
1007
+ : authMethods;
1008
+ const nextMethod = candidates.find((m) => !triedMethods.includes(m));
1009
+ if (!nextMethod) {
1010
+ Logger.log(`[${key}] All supported auth methods exhausted`, "error");
1011
+ return callback(false);
1012
+ }
1013
+ triedMethods.push(nextMethod);
1014
+ Logger.log(`[${key}] Trying auth method: ${nextMethod} (${triedMethods.length}/${maxAuthAttempts})`, "info");
1015
+ return callback(nextMethod);
1016
+ };
1017
+ // Handle keyboard-interactive prompts (for 2FA codes)
1018
+ sshConfig.keyboard = (name, instructions, instructionsLang, prompts, finish) => {
1019
+ Logger.log(`[${key}] Keyboard-interactive authentication requested`, "info");
1020
+ Logger.log(`[${key}] Name: ${name}`, "debug");
1021
+ Logger.log(`[${key}] Instructions: ${instructions}`, "debug");
1022
+ Logger.log(`[${key}] Prompts: ${JSON.stringify(prompts)}`, "debug");
1023
+ const otpCode = process.env.SSH_MCP_2FA_CODE;
1024
+ const responses = [];
1025
+ for (const prompt of prompts) {
1026
+ if (config.password && isPasswordPrompt(prompt.prompt)) {
1027
+ // For password prompts, use the configured password
1028
+ responses.push(config.password);
1029
+ Logger.log(`[${key}] Responding to password prompt: ${prompt.prompt}`, "debug");
1030
+ }
1031
+ else if (otpCode) {
1032
+ // For 2FA/verification code prompts, use SSH_MCP_2FA_CODE if provided
1033
+ responses.push(otpCode);
1034
+ Logger.log(`[${key}] Responding to non-password prompt with SSH_MCP_2FA_CODE: ${prompt.prompt}`, "info");
1035
+ }
1036
+ else if (config.password && prompts.length === 1 && !prompt.echo) {
1037
+ // Single non-echoing prompt without "password" label:
1038
+ // treat as password prompt (common on embedded devices)
1039
+ responses.push(config.password);
1040
+ Logger.log(`[${key}] Responding to single non-echo prompt (assumed password): ${prompt.prompt}`, "debug");
1041
+ }
1042
+ else {
1043
+ // No code available — empty response will fail the auth attempt;
1044
+ // set SSH_MCP_2FA_CODE before connecting to enable 2FA/MFA.
1045
+ responses.push("");
1046
+ Logger.log(`[${key}] Empty response for prompt (set SSH_MCP_2FA_CODE to satisfy 2FA): ${prompt.prompt}`, "info");
1047
+ }
1048
+ }
1049
+ finish(responses);
1050
+ };
1051
+ }
1052
+ if (config.agent) {
1053
+ sshConfig.agent = config.agent;
1054
+ Logger.log(`Using SSH agent authentication for [${key}]: ${config.agent}`, "info");
1055
+ if (!config.tryKeyboard) {
1056
+ return sshConfig;
1057
+ }
1058
+ }
1059
+ if (config.privateKey) {
1060
+ try {
1061
+ sshConfig.privateKey = fs.readFileSync(config.privateKey, "utf8");
1062
+ if (config.passphrase) {
1063
+ sshConfig.passphrase = config.passphrase;
1064
+ }
1065
+ Logger.log(`Using SSH private key authentication for [${key}]`, "info");
1066
+ if (!config.tryKeyboard) {
1067
+ return sshConfig;
1068
+ }
1069
+ }
1070
+ catch (error) {
1071
+ throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read private key file for [${key}]: ${error.message}`, false);
1072
+ }
1073
+ }
1074
+ if (config.password) {
1075
+ sshConfig.password = config.password;
1076
+ Logger.log(`Using password authentication for [${key}]`, "info");
1077
+ if (!config.tryKeyboard) {
1078
+ return sshConfig;
1079
+ }
1080
+ }
1081
+ if (!config.agent && !config.privateKey && !config.password && !config.tryKeyboard) {
1082
+ throw new ToolError("SSH_AUTHENTICATION_MISSING", `No valid authentication method provided for [${key}] (agent, password, private key, or tryKeyboard)`, false);
1083
+ }
1084
+ return sshConfig;
1085
+ }
1086
+ scheduleStatusCollection(key) {
1087
+ const existingStatusCollection = this.pendingStatusCollections.get(key);
1088
+ if (existingStatusCollection) {
1089
+ clearTimeout(existingStatusCollection);
1090
+ }
1091
+ const timeoutId = setTimeout(() => {
1092
+ this.pendingStatusCollections.delete(key);
1093
+ void this.collectStatusForConnection(key);
1094
+ }, 1000);
1095
+ this.pendingStatusCollections.set(key, timeoutId);
1096
+ }
1097
+ async collectStatusForConnection(key) {
1098
+ try {
1099
+ const status = await collectSystemStatus((command, connectionName) => this.runCommandInternal(command, undefined, connectionName, {
1100
+ prevalidatedInternalCommand: true,
1101
+ }), key, (command, connectionName) => this.validateCommand(command, connectionName).isAllowed);
1102
+ this.statusCache.set(key, status);
1103
+ Logger.log(`System status collected for [${key}]`, "info");
1104
+ }
1105
+ catch (error) {
1106
+ Logger.log(`Failed to collect system status for [${key}]: ${error.message}`, "error");
1107
+ this.statusCache.set(key, {
1108
+ reachable: true,
1109
+ lastUpdated: new Date().toISOString(),
1110
+ });
1111
+ }
1112
+ }
1113
+ compilePatterns(patterns, connectionName, kind) {
1114
+ if (!patterns || patterns.length === 0) {
1115
+ return [];
1116
+ }
1117
+ return patterns.map((pattern) => {
1118
+ try {
1119
+ return new RegExp(pattern);
1120
+ }
1121
+ catch (error) {
1122
+ throw new Error(`Invalid ${kind} pattern for '${connectionName}': ${pattern} (${error.message})`);
1123
+ }
1124
+ });
1125
+ }
1126
+ validateCommand(command, name) {
1127
+ const key = this.resolveFlatName(name);
1128
+ const whitelistRegexes = this.commandWhitelistRegexes.get(key) || [];
1129
+ if (whitelistRegexes.length > 0) {
1130
+ const matchesWhitelist = whitelistRegexes.some((regex) => regex.test(command));
1131
+ if (!matchesWhitelist) {
1132
+ return {
1133
+ isAllowed: false,
1134
+ reason: "Command not in whitelist, execution forbidden",
1135
+ };
1136
+ }
1137
+ }
1138
+ const blacklistRegexes = this.commandBlacklistRegexes.get(key) || [];
1139
+ if (blacklistRegexes.length > 0) {
1140
+ const matchesBlacklist = blacklistRegexes.some((regex) => regex.test(command));
1141
+ if (matchesBlacklist) {
1142
+ return {
1143
+ isAllowed: false,
1144
+ reason: "Command matches blacklist, execution forbidden",
1145
+ };
1146
+ }
1147
+ }
1148
+ return {
1149
+ isAllowed: true,
1150
+ };
1151
+ }
1152
+ formatCommandFailure(stdout, stderr, exitCode, exitSignal) {
1153
+ const outputSections = [];
1154
+ if (stdout) {
1155
+ outputSections.push(stdout);
1156
+ }
1157
+ if (stderr) {
1158
+ outputSections.push(`[stderr]\n${stderr}`);
1159
+ }
1160
+ if (exitCode !== undefined) {
1161
+ outputSections.push(`[exit code] ${exitCode}`);
1162
+ }
1163
+ if (exitSignal) {
1164
+ outputSections.push(`[signal] ${exitSignal}`);
1165
+ }
1166
+ return outputSections.join("\n");
1167
+ }
1168
+ /**
1169
+ * Format the output of a command that finished successfully.
1170
+ *
1171
+ * stderr is kept instead of being dropped: with `pty: false` a successful
1172
+ * command's stderr is delivered on a separate channel, and discarding it
1173
+ * silently loses warnings and progress output written there by tools such as
1174
+ * git, docker and npm. With the default `pty: true` the remote end merges
1175
+ * stderr into stdout, so `stderr` is empty here and the output is unchanged.
1176
+ */
1177
+ formatCommandSuccess(stdout, stderr) {
1178
+ if (!stderr) {
1179
+ return stdout;
1180
+ }
1181
+ return [stdout, `[stderr]\n${stderr}`].filter(Boolean).join("\n");
1182
+ }
1183
+ async runCommandInternal(cmdString, directory, name, options = {}) {
1184
+ if (!options.prevalidatedInternalCommand) {
1185
+ const validationResult = this.validateCommand(cmdString, name);
1186
+ if (!validationResult.isAllowed) {
1187
+ throw new ToolError("COMMAND_VALIDATION_FAILED", `Command validation failed: ${validationResult.reason}`, false);
1188
+ }
1189
+ }
1190
+ const key = this.resolveFlatName(name);
1191
+ const config = this.getConfig(name);
1192
+ const transportMode = this.getTransportMode(config);
1193
+ const timeout = options.timeout ??
1194
+ (transportMode === "shell"
1195
+ ? this.getShellCommandTimeoutMs(config)
1196
+ : this.getCommandTimeoutMs(config));
1197
+ const connectionTimeoutMs = this.getConnectionTimeoutMs(config);
1198
+ const client = await this.withTimeout(this.ensureConnected(name), connectionTimeoutMs, () => this.invalidateConnection(key), `SSH connection [${key}] timed out after ${connectionTimeoutMs}ms`);
1199
+ if (transportMode === "shell") {
1200
+ return this.runShellCommand(cmdString, directory, name, timeout);
1201
+ }
1202
+ return this.runExecCommand(client, config, cmdString, directory, timeout, key);
1203
+ }
1204
+ runExecCommand(client, config, cmdString, directory, timeout, key) {
1205
+ let commandToRun = directory
1206
+ ? `cd -- ${shellQuote(directory)} && ${cmdString}`
1207
+ : cmdString;
1208
+ if (config.commandTemplate) {
1209
+ commandToRun = applyCommandTemplate(config.commandTemplate, commandToRun);
1210
+ }
1211
+ const maxOutputBytes = this.getMaxOutputBytes(config);
1212
+ return new Promise((resolve, reject) => {
1213
+ let openTimeoutId;
1214
+ let commandTimeoutId;
1215
+ let settled = false;
1216
+ const cleanup = () => {
1217
+ if (openTimeoutId) {
1218
+ clearTimeout(openTimeoutId);
1219
+ }
1220
+ if (commandTimeoutId) {
1221
+ clearTimeout(commandTimeoutId);
1222
+ }
1223
+ };
1224
+ client.exec(commandToRun, { pty: config.pty !== undefined ? config.pty : true }, (err, stream) => {
1225
+ if (openTimeoutId) {
1226
+ clearTimeout(openTimeoutId);
1227
+ openTimeoutId = undefined;
1228
+ }
1229
+ if (settled) {
1230
+ try {
1231
+ stream?.close();
1232
+ }
1233
+ catch {
1234
+ // Ignore late stream cleanup errors after timeout.
1235
+ }
1236
+ return;
1237
+ }
1238
+ if (err) {
1239
+ cleanup();
1240
+ settled = true;
1241
+ reject(new ToolError("COMMAND_EXECUTION_ERROR", `Command execution error: ${err.message}`, true));
1242
+ return;
1243
+ }
1244
+ let data = "";
1245
+ let errorData = "";
1246
+ let exitCode;
1247
+ let exitSignal;
1248
+ let capturedBytes = 0;
1249
+ // Decoding each chunk on its own corrupts any multi-byte character
1250
+ // that happens to be split across a chunk boundary.
1251
+ const stdoutDecoder = new StringDecoder("utf8");
1252
+ const stderrDecoder = new StringDecoder("utf8");
1253
+ // Without a cap a single command (`cat` on a huge file, an unbounded
1254
+ // `journalctl`, ...) can buffer unbounded output in memory until the
1255
+ // command timeout fires. Stop capturing and close the channel instead.
1256
+ const appendChunk = (chunk, isStderr) => {
1257
+ if (settled) {
1258
+ return;
1259
+ }
1260
+ if (maxOutputBytes > 0 &&
1261
+ capturedBytes + chunk.length > maxOutputBytes) {
1262
+ const remaining = maxOutputBytes - capturedBytes;
1263
+ if (remaining > 0) {
1264
+ const partial = chunk.subarray(0, remaining);
1265
+ if (isStderr) {
1266
+ errorData += stderrDecoder.write(partial);
1267
+ }
1268
+ else {
1269
+ data += stdoutDecoder.write(partial);
1270
+ }
1271
+ }
1272
+ capturedBytes = maxOutputBytes;
1273
+ cleanup();
1274
+ settled = true;
1275
+ try {
1276
+ stream.close();
1277
+ }
1278
+ catch {
1279
+ // Ignore close errors while aborting an oversized command.
1280
+ }
1281
+ const stdout = data.trimEnd();
1282
+ const stderr = errorData.trimEnd();
1283
+ reject(new ToolError("OUTPUT_LIMIT_EXCEEDED", [
1284
+ this.formatCommandSuccess(stdout, stderr),
1285
+ `[truncated] Output exceeded maxOutputBytes=${maxOutputBytes}; the command was aborted.`,
1286
+ ]
1287
+ .filter(Boolean)
1288
+ .join("\n"), false));
1289
+ return;
1290
+ }
1291
+ capturedBytes += chunk.length;
1292
+ if (isStderr) {
1293
+ errorData += stderrDecoder.write(chunk);
1294
+ }
1295
+ else {
1296
+ data += stdoutDecoder.write(chunk);
1297
+ }
1298
+ };
1299
+ stream.on("data", (chunk) => appendChunk(chunk, false));
1300
+ stream.stderr.on("data", (chunk) => appendChunk(chunk, true));
1301
+ stream.on("exit", (code, signal) => {
1302
+ exitCode = code;
1303
+ exitSignal = signal;
1304
+ });
1305
+ stream.on("close", (code, signal) => {
1306
+ cleanup();
1307
+ if (settled) {
1308
+ return;
1309
+ }
1310
+ settled = true;
1311
+ if (exitCode === undefined) {
1312
+ exitCode = code;
1313
+ }
1314
+ if (!exitSignal && signal) {
1315
+ exitSignal = signal;
1316
+ }
1317
+ // Flush any trailing incomplete multi-byte sequence.
1318
+ const stdout = (data + stdoutDecoder.end()).trimEnd();
1319
+ const stderr = (errorData + stderrDecoder.end()).trimEnd();
1320
+ const hasNonZeroExitCode = exitCode !== undefined && exitCode !== 0;
1321
+ const hasExitSignal = exitSignal !== undefined && exitSignal !== "";
1322
+ if (hasNonZeroExitCode || hasExitSignal) {
1323
+ reject(new ToolError("COMMAND_EXECUTION_ERROR", this.formatCommandFailure(stdout, stderr, exitCode, exitSignal) ||
1324
+ (hasExitSignal
1325
+ ? `Command terminated by signal ${exitSignal}${exitCode !== undefined ? ` (exit code ${exitCode})` : ""}`
1326
+ : `Command failed with exit code ${exitCode}`), false));
1327
+ return;
1328
+ }
1329
+ resolve(this.formatCommandSuccess(stdout, stderr));
1330
+ });
1331
+ stream.on("error", (streamError) => {
1332
+ cleanup();
1333
+ settled = true;
1334
+ reject(new ToolError("COMMAND_EXECUTION_ERROR", `Stream error: ${streamError.message}`, true));
1335
+ });
1336
+ commandTimeoutId = setTimeout(() => {
1337
+ try {
1338
+ stream.close();
1339
+ }
1340
+ catch {
1341
+ // Ignore stream close errors during timeout handling.
1342
+ }
1343
+ if (!settled) {
1344
+ settled = true;
1345
+ const stdout = data.trimEnd();
1346
+ const stderr = errorData.trimEnd();
1347
+ reject(new ToolError("COMMAND_TIMEOUT", [
1348
+ this.formatCommandFailure(stdout, stderr),
1349
+ `[timeout] Command timed out after ${timeout}ms`,
1350
+ ]
1351
+ .filter(Boolean)
1352
+ .join("\n"), true));
1353
+ }
1354
+ }, timeout);
1355
+ });
1356
+ openTimeoutId = setTimeout(() => {
1357
+ if (!settled) {
1358
+ settled = true;
1359
+ this.invalidateConnection(key);
1360
+ reject(new ToolError("COMMAND_TIMEOUT", `[timeout] Command channel did not open within ${timeout}ms`, true));
1361
+ }
1362
+ }, timeout);
1363
+ });
1364
+ }
1365
+ async initializeShellSession(client, key, config) {
1366
+ const stream = await new Promise((resolve, reject) => {
1367
+ client.shell({ term: "xterm" }, (err, channel) => {
1368
+ if (err) {
1369
+ reject(new ToolError("SSH_CONNECTION_FAILED", `Failed to initialize shell transport for [${key}]: ${err.message}`, true));
1370
+ return;
1371
+ }
1372
+ resolve(channel);
1373
+ });
1374
+ });
1375
+ this.shellStreams.set(key, stream);
1376
+ this.shellReady.set(key, false);
1377
+ this.shellQueues.set(key, Promise.resolve());
1378
+ this.shellBuffers.set(key, "");
1379
+ this.shellDecoders.set(key, new StringDecoder("utf8"));
1380
+ const readyId = this.generateMarkerId("ready");
1381
+ const readyMarker = `__MCP_READY__${readyId}__`;
1382
+ try {
1383
+ await this.waitForShellReady(key, stream, readyMarker, this.getShellReadyTimeoutMs(config));
1384
+ await this.configureShellSession(key, stream, config);
1385
+ this.shellReady.set(key, true);
1386
+ this.attachShellLifecycleListeners(key, stream);
1387
+ }
1388
+ catch (error) {
1389
+ this.cleanupShellState(key, true);
1390
+ throw new ToolError("SSH_CONNECTION_FAILED", `Shell transport initialization failed for [${key}]: ${error.message}`, true);
1391
+ }
1392
+ }
1393
+ waitForShellReady(key, stream, readyMarker, timeout) {
1394
+ return new Promise((resolve, reject) => {
1395
+ let settled = false;
1396
+ let timeoutId;
1397
+ let probeIntervalId;
1398
+ // Escape the marker in the probe payload so the command text does not
1399
+ // contain the literal marker string. When the PTY echoes the probe
1400
+ // command back (echo is still on during the first probe), the echo no
1401
+ // longer matches readyMarker, so resolveIfReady only fires once the
1402
+ // printf actually executes and emits the literal marker. Without this,
1403
+ // waitForShellReady resolves on the echoed command line, before the
1404
+ // probed command has run — which is fatal when the probed command is
1405
+ // `stty -echo` in configureShellSession: the next real command would be
1406
+ // sent before echo is disabled and would itself be echoed, leaving
1407
+ // extractShellCommandResult matching markers inside the echoed script.
1408
+ const escapedMarker = readyMarker
1409
+ .replace(/\\/g, "\\\\")
1410
+ .replace(/_/g, "\\137");
1411
+ const payload = `printf '${escapedMarker}\\n'\n`;
1412
+ const cleanup = () => {
1413
+ if (timeoutId) {
1414
+ clearTimeout(timeoutId);
1415
+ }
1416
+ if (probeIntervalId) {
1417
+ clearInterval(probeIntervalId);
1418
+ }
1419
+ stream.off("data", onData);
1420
+ stream.off("close", onClose);
1421
+ stream.off("error", onError);
1422
+ };
1423
+ const resolveIfReady = () => {
1424
+ const buffer = this.shellBuffers.get(key) || "";
1425
+ const markerIndex = buffer.indexOf(readyMarker);
1426
+ if (markerIndex === -1) {
1427
+ return;
1428
+ }
1429
+ const lineEndIndex = buffer.indexOf("\n", markerIndex);
1430
+ if (lineEndIndex === -1) {
1431
+ return;
1432
+ }
1433
+ if (!settled) {
1434
+ settled = true;
1435
+ this.shellBuffers.set(key, buffer.slice(lineEndIndex + 1));
1436
+ cleanup();
1437
+ resolve();
1438
+ }
1439
+ };
1440
+ const onData = (chunk) => {
1441
+ this.appendShellBuffer(key, chunk);
1442
+ resolveIfReady();
1443
+ };
1444
+ const onClose = () => {
1445
+ if (settled) {
1446
+ return;
1447
+ }
1448
+ settled = true;
1449
+ cleanup();
1450
+ reject(new Error("Shell channel closed before ready probe completed"));
1451
+ };
1452
+ const onError = (error) => {
1453
+ if (settled) {
1454
+ return;
1455
+ }
1456
+ settled = true;
1457
+ cleanup();
1458
+ reject(error);
1459
+ };
1460
+ stream.on("data", onData);
1461
+ stream.on("close", onClose);
1462
+ stream.on("error", onError);
1463
+ timeoutId = setTimeout(() => {
1464
+ if (settled) {
1465
+ return;
1466
+ }
1467
+ settled = true;
1468
+ cleanup();
1469
+ reject(new Error(`Timed out waiting for shell ready marker after ${timeout}ms`));
1470
+ }, timeout);
1471
+ stream.write(payload);
1472
+ probeIntervalId = setInterval(() => {
1473
+ if (!settled) {
1474
+ stream.write(payload);
1475
+ }
1476
+ }, 1000);
1477
+ resolveIfReady();
1478
+ });
1479
+ }
1480
+ attachShellLifecycleListeners(key, stream) {
1481
+ const handleUnavailable = (reason) => {
1482
+ if (this.shellStreams.get(key) !== stream) {
1483
+ return;
1484
+ }
1485
+ Logger.log(`Shell channel [${key}] unavailable: ${reason}`, "error");
1486
+ this.invalidateConnection(key);
1487
+ };
1488
+ stream.on("close", () => handleUnavailable("closed"));
1489
+ stream.on("error", (error) => handleUnavailable(`error: ${error.message}`));
1490
+ }
1491
+ async configureShellSession(key, stream, config) {
1492
+ // Send `export PS1=''` and `stty -echo` first. The verification probe
1493
+ // (printf, emitted by waitForShellReady below with an escaped marker)
1494
+ // follows them in the same shell input stream, so by the time the
1495
+ // probe's literal marker is emitted, stty has executed and terminal echo
1496
+ // is off. We must NOT write our own `printf '${marker}'` here: that
1497
+ // payload contains the literal marker, so when echo is still on it would
1498
+ // be echoed back and waitForShellReady would resolve prematurely, before
1499
+ // stty runs — leaving the next real command echoed.
1500
+ stream.write("export PS1=''\n");
1501
+ stream.write("stty -echo >/dev/null 2>&1 || true\n");
1502
+ const verifyId = this.generateMarkerId("stty");
1503
+ const verifyMarker = `__MCP_CONFIGURE__${verifyId}__`;
1504
+ await this.waitForShellReady(key, stream, verifyMarker, this.getShellReadyTimeoutMs(config));
1505
+ }
1506
+ runShellCommand(cmdString, directory, name, timeout) {
1507
+ const key = this.resolveFlatName(name);
1508
+ return this.enqueueShellCommand(key, () => this.executeShellCommand(key, cmdString, directory, timeout));
1509
+ }
1510
+ enqueueShellCommand(key, task) {
1511
+ const previous = this.shellQueues.get(key) || Promise.resolve();
1512
+ const next = previous.catch(() => undefined).then(task);
1513
+ this.shellQueues.set(key, next.then(() => undefined, () => undefined));
1514
+ return next;
1515
+ }
1516
+ executeShellCommand(key, cmdString, directory, timeout) {
1517
+ const stream = this.shellStreams.get(key);
1518
+ if (!stream || this.shellReady.get(key) !== true) {
1519
+ throw new ToolError("SSH_CONNECTION_FAILED", `Shell transport for [${key}] is not ready`, true);
1520
+ }
1521
+ const commandId = this.generateMarkerId("command");
1522
+ const config = this.getConfig(key);
1523
+ const script = this.buildShellCommandScript(commandId, cmdString, directory, config.commandTemplate);
1524
+ const maxOutputBytes = this.getMaxOutputBytes(config);
1525
+ return new Promise((resolve, reject) => {
1526
+ let settled = false;
1527
+ let timeoutId;
1528
+ const scanState = {
1529
+ outputStartIndex: -1,
1530
+ tail: "",
1531
+ // Whatever the previous command left behind cannot hold this command's
1532
+ // markers, so the scan starts past it.
1533
+ tailStart: (this.shellBuffers.get(key) || "").length,
1534
+ countedOutputEndIndex: -1,
1535
+ capturedOutputBytes: 0,
1536
+ };
1537
+ const cleanup = () => {
1538
+ if (timeoutId) {
1539
+ clearTimeout(timeoutId);
1540
+ }
1541
+ stream.off("data", onData);
1542
+ stream.off("close", onClose);
1543
+ stream.off("error", onError);
1544
+ };
1545
+ const finish = (error, output) => {
1546
+ if (settled) {
1547
+ return;
1548
+ }
1549
+ settled = true;
1550
+ cleanup();
1551
+ if (error) {
1552
+ reject(error);
1553
+ return;
1554
+ }
1555
+ resolve(output || "");
1556
+ };
1557
+ const resolveIfComplete = () => {
1558
+ const matched = this.extractShellCommandResult(key, commandId, scanState);
1559
+ if (maxOutputBytes > 0 &&
1560
+ scanState.capturedOutputBytes > maxOutputBytes) {
1561
+ // The shell channel is shared by every command on this connection,
1562
+ // so it cannot simply be closed like an exec channel: the command
1563
+ // would keep writing into the buffer. Drop the connection instead.
1564
+ this.invalidateConnection(key);
1565
+ finish(new ToolError("OUTPUT_LIMIT_EXCEEDED", `[truncated] Output exceeded maxOutputBytes=${maxOutputBytes}; the command was aborted.`, false));
1566
+ return;
1567
+ }
1568
+ if (!matched) {
1569
+ return;
1570
+ }
1571
+ this.shellBuffers.set(key, matched.remainder);
1572
+ const output = this.stripLeadingBeginMarker(this.cleanShellOutput(matched.output), commandId).trimEnd();
1573
+ if (matched.exitCode !== 0) {
1574
+ finish(new ToolError("COMMAND_EXECUTION_ERROR", this.formatCommandFailure(output, "", matched.exitCode) ||
1575
+ `Command failed with exit code ${matched.exitCode}`, false));
1576
+ return;
1577
+ }
1578
+ finish(undefined, output);
1579
+ };
1580
+ const onData = (chunk) => {
1581
+ scanState.tail += this.appendShellBuffer(key, chunk);
1582
+ resolveIfComplete();
1583
+ };
1584
+ const onClose = () => {
1585
+ finish(new ToolError("COMMAND_EXECUTION_ERROR", "Shell channel closed during command execution", true));
1586
+ };
1587
+ const onError = (error) => {
1588
+ finish(new ToolError("COMMAND_EXECUTION_ERROR", `Shell channel error during command execution: ${error.message}`, true));
1589
+ };
1590
+ stream.on("data", onData);
1591
+ stream.on("close", onClose);
1592
+ stream.on("error", onError);
1593
+ timeoutId = setTimeout(() => {
1594
+ this.invalidateConnection(key);
1595
+ finish(new ToolError("COMMAND_TIMEOUT", `[timeout] Command timed out after ${timeout}ms`, true));
1596
+ }, timeout);
1597
+ stream.write(script);
1598
+ resolveIfComplete();
1599
+ });
1600
+ }
1601
+ buildShellCommandScript(commandId, cmdString, directory, commandTemplate) {
1602
+ const beginMarker = `__MCP_BEGIN__${commandId}__`;
1603
+ const endMarker = `__MCP_END__${commandId}__RC__`;
1604
+ let commandBody = directory
1605
+ ? `cd -- ${shellQuote(directory)} && { ${cmdString}; }`
1606
+ : `{ ${cmdString}; }`;
1607
+ if (commandTemplate) {
1608
+ commandBody = applyCommandTemplate(commandTemplate, commandBody);
1609
+ }
1610
+ return [
1611
+ `printf '${beginMarker}\\n'`,
1612
+ commandBody,
1613
+ "__mcp_rc=$?",
1614
+ `printf '\\n${endMarker}%s__\\n' "$__mcp_rc"`,
1615
+ "",
1616
+ ].join("\n");
1617
+ }
1618
+ /**
1619
+ * Drop the part of the tail that can no longer start a marker: a marker only
1620
+ * straddles the boundary of the chunk that just arrived, so keeping the last
1621
+ * `markerLength - 1` characters is enough.
1622
+ */
1623
+ trimShellScanTail(scanState, markerLength) {
1624
+ this.advanceShellScanTail(scanState, scanState.tail.length - Math.min(scanState.tail.length, markerLength - 1));
1625
+ }
1626
+ advanceShellScanTail(scanState, offset) {
1627
+ if (offset <= 0) {
1628
+ return;
1629
+ }
1630
+ scanState.tailStart += offset;
1631
+ scanState.tail = scanState.tail.slice(offset);
1632
+ }
1633
+ /**
1634
+ * Locate a finished command, scanning only the freshly arrived tail. The
1635
+ * accumulated buffer is read once, after the whole end marker is in hand.
1636
+ */
1637
+ extractShellCommandResult(key, commandId, scanState) {
1638
+ if (scanState.outputStartIndex === -1) {
1639
+ const beginMarker = `__MCP_BEGIN__${commandId}__`;
1640
+ const beginIndex = scanState.tail.indexOf(beginMarker);
1641
+ if (beginIndex === -1) {
1642
+ this.trimShellScanTail(scanState, beginMarker.length);
1643
+ return null;
1644
+ }
1645
+ const beginLineEndIndex = scanState.tail.indexOf("\n", beginIndex);
1646
+ if (beginLineEndIndex === -1) {
1647
+ this.advanceShellScanTail(scanState, beginIndex);
1648
+ return null;
1649
+ }
1650
+ this.advanceShellScanTail(scanState, beginLineEndIndex + 1);
1651
+ scanState.outputStartIndex = scanState.tailStart;
1652
+ scanState.countedOutputEndIndex = scanState.tailStart;
1653
+ }
1654
+ // Search the fixed prefix rather than the whole pattern: its length is
1655
+ // known, which is what makes the retained overlap provably sufficient. The
1656
+ // exit code is then parsed from the short slice that follows it.
1657
+ const endPrefix = `__MCP_END__${commandId}__RC__`;
1658
+ const endIndex = scanState.tail.indexOf(endPrefix);
1659
+ if (endIndex === -1) {
1660
+ // Keep the marker overlap plus CRLF immediately before it. The command
1661
+ // wrapper emits that newline as framing, so it must not count against the
1662
+ // user's output limit.
1663
+ this.trimShellScanTail(scanState, endPrefix.length + 2);
1664
+ this.countShellOutputThrough(key, scanState, scanState.tailStart);
1665
+ return null;
1666
+ }
1667
+ const absoluteEndIndex = scanState.tailStart + endIndex;
1668
+ const buffer = this.shellBuffers.get(key) || "";
1669
+ let outputEndIndex = absoluteEndIndex;
1670
+ if (buffer[outputEndIndex - 1] === "\n") {
1671
+ outputEndIndex -= 1;
1672
+ if (buffer[outputEndIndex - 1] === "\r") {
1673
+ outputEndIndex -= 1;
1674
+ }
1675
+ }
1676
+ this.countShellOutputThrough(key, scanState, outputEndIndex);
1677
+ const exitCodeStart = endIndex + endPrefix.length;
1678
+ const matched = SHELL_EXIT_CODE_PATTERN.exec(scanState.tail.slice(exitCodeStart, exitCodeStart + SHELL_EXIT_CODE_MAX_LENGTH));
1679
+ if (!matched) {
1680
+ // The prefix arrived but the exit code has not; resume from here.
1681
+ this.advanceShellScanTail(scanState, endIndex);
1682
+ return null;
1683
+ }
1684
+ const consumedEndIndex = absoluteEndIndex + endPrefix.length + matched[0].length;
1685
+ return {
1686
+ output: buffer.slice(scanState.outputStartIndex, absoluteEndIndex),
1687
+ exitCode: Number.parseInt(matched[1], 10),
1688
+ remainder: buffer.slice(consumedEndIndex),
1689
+ };
1690
+ }
1691
+ countShellOutputThrough(key, scanState, endIndex) {
1692
+ if (scanState.outputStartIndex === -1 ||
1693
+ endIndex <= scanState.countedOutputEndIndex) {
1694
+ return;
1695
+ }
1696
+ const buffer = this.shellBuffers.get(key) || "";
1697
+ scanState.capturedOutputBytes += Buffer.byteLength(buffer.slice(scanState.countedOutputEndIndex, endIndex), "utf8");
1698
+ scanState.countedOutputEndIndex = endIndex;
1699
+ }
1700
+ /** Appends the decoded chunk and returns just that text. */
1701
+ appendShellBuffer(key, chunk) {
1702
+ let decoder = this.shellDecoders.get(key);
1703
+ if (!decoder) {
1704
+ decoder = new StringDecoder("utf8");
1705
+ this.shellDecoders.set(key, decoder);
1706
+ }
1707
+ // Concatenation alone stays cheap; it is reading the result that forces the
1708
+ // rope to flatten, so nothing here may inspect the accumulated buffer.
1709
+ const text = decoder.write(chunk);
1710
+ const current = this.shellBuffers.get(key) || "";
1711
+ this.shellBuffers.set(key, current + text);
1712
+ return text;
1713
+ }
1714
+ cleanShellOutput(output) {
1715
+ return output
1716
+ .replace(ANSI_OSC_PATTERN, "")
1717
+ .replace(ANSI_CSI_PATTERN, "")
1718
+ .replace(/\r\n/g, "\n")
1719
+ .replace(/\r/g, "\n");
1720
+ }
1721
+ stripLeadingBeginMarker(output, commandId) {
1722
+ const beginPrefix = `__MCP_BEGIN__${commandId}__`;
1723
+ if (!output.startsWith(beginPrefix)) {
1724
+ return output;
1725
+ }
1726
+ const newlineIndex = output.indexOf("\n");
1727
+ if (newlineIndex === -1) {
1728
+ return "";
1729
+ }
1730
+ return output.slice(newlineIndex + 1);
1731
+ }
1732
+ generateMarkerId(prefix) {
1733
+ return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
1734
+ }
1735
+ cleanupShellState(key, closeStream = false) {
1736
+ const stream = this.shellStreams.get(key);
1737
+ if (closeStream && stream) {
1738
+ try {
1739
+ stream.close();
1740
+ }
1741
+ catch {
1742
+ // Ignore shell close errors during cleanup.
1743
+ }
1744
+ }
1745
+ this.shellStreams.delete(key);
1746
+ this.shellReady.delete(key);
1747
+ this.shellQueues.delete(key);
1748
+ this.shellBuffers.delete(key);
1749
+ this.shellDecoders.delete(key);
1750
+ }
1751
+ clearConnectionState(key) {
1752
+ const pendingStatusCollection = this.pendingStatusCollections.get(key);
1753
+ if (pendingStatusCollection) {
1754
+ clearTimeout(pendingStatusCollection);
1755
+ this.pendingStatusCollections.delete(key);
1756
+ }
1757
+ this.cleanupShellState(key);
1758
+ this.connected.set(key, false);
1759
+ this.clients.delete(key);
1760
+ this.pendingConnections.delete(key);
1761
+ }
1762
+ invalidateConnection(key) {
1763
+ const client = this.clients.get(key);
1764
+ this.clearConnectionState(key);
1765
+ if (client) {
1766
+ try {
1767
+ client.end();
1768
+ }
1769
+ catch {
1770
+ // Ignore client close errors during invalidation.
1771
+ }
1772
+ }
1773
+ }
1774
+ }
1775
+ //# sourceMappingURL=ssh-connection-manager.js.map