@aaarc/handfree-ssh-mcp 1.0.0

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.
@@ -0,0 +1,1726 @@
1
+ import { Client } from "ssh2";
2
+ import { SocksClient } from "socks";
3
+ import { Logger } from "../utils/logger.js";
4
+ import { collectSystemStatus } from "../utils/status-collector.js";
5
+ import { ToolError } from "../utils/tool-error.js";
6
+ import { OutputCollector } from "../utils/output-collector.js";
7
+ import { OutputLogWriter } from "../utils/output-log-writer.js";
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import crypto from "crypto";
11
+ const CONNECTION_RESET_FIELDS = [
12
+ "host",
13
+ "port",
14
+ "username",
15
+ "password",
16
+ "privateKey",
17
+ "passphrase",
18
+ "agent",
19
+ "identitiesOnly",
20
+ "authOptional",
21
+ "socksProxy",
22
+ ];
23
+ /**
24
+ * Default command whitelist - only allow safe Linux commands
25
+ * These patterns are regex strings that match allowed commands
26
+ */
27
+ /**
28
+ * Default command whitelist - only allow safe Linux commands
29
+ * These patterns are regex strings that match allowed commands
30
+ *
31
+ * SECURITY: Commands not matching any pattern will be BLOCKED with an error message
32
+ */
33
+ const DEFAULT_COMMAND_WHITELIST = [
34
+ // Basic file operations (READ-ONLY)
35
+ "^ls( .*)?$",
36
+ "^cat .*$",
37
+ "^head .*$",
38
+ "^tail .*$",
39
+ "^less .*$",
40
+ "^more .*$",
41
+ "^wc .*$",
42
+ "^file .*$",
43
+ "^stat .*$",
44
+ "^find .*$",
45
+ "^grep .*$",
46
+ "^awk .*$",
47
+ "^sed .*$",
48
+ // Directory navigation
49
+ "^pwd$",
50
+ "^cd .*$",
51
+ // System info (read-only)
52
+ "^echo .*$",
53
+ "^hostname$",
54
+ "^whoami$",
55
+ "^id$",
56
+ "^uname .*$",
57
+ "^df .*$",
58
+ "^du .*$",
59
+ "^free .*$",
60
+ "^top -bn1.*$",
61
+ "^ps .*$",
62
+ "^uptime$",
63
+ "^date$",
64
+ "^env$",
65
+ "^printenv.*$",
66
+ // Network diagnostics (read-only)
67
+ "^ping -c \\d+ .*$",
68
+ "^curl .*$",
69
+ "^wget .*$",
70
+ "^netstat .*$",
71
+ "^ss .*$",
72
+ "^ip .*$",
73
+ // Git operations
74
+ "^git .*$",
75
+ // Systemctl (read-only)
76
+ "^systemctl status .*$",
77
+ "^systemctl list-.*$",
78
+ "^journalctl .*$",
79
+ // Docker (read-only)
80
+ "^docker ps.*$",
81
+ "^docker logs.*$",
82
+ "^docker images.*$",
83
+ "^docker inspect.*$",
84
+ // ============================================
85
+ // DANGEROUS COMMANDS - EXPLICITLY BANNED:
86
+ // - rm, rmdir (delete files/dirs)
87
+ // - mv (can overwrite/move files)
88
+ // - cp (can overwrite files)
89
+ // - chmod, chown (change permissions)
90
+ // - kill, pkill (terminate processes)
91
+ // - mkdir, touch (create files/dirs)
92
+ // - >, >> (redirect/overwrite files)
93
+ // - wget -O, curl -o (download and overwrite)
94
+ // ============================================
95
+ ];
96
+ /**
97
+ * SSH Connection Manager class
98
+ */
99
+ export class SSHConnectionManager {
100
+ static instance;
101
+ clients = new Map();
102
+ configs = {};
103
+ connected = new Map();
104
+ statusCache = new Map();
105
+ connectionGenerations = new Map();
106
+ pendingClients = new Map();
107
+ // In-flight connect() promises, keyed by server name. Used to dedupe
108
+ // concurrent connect attempts so we never create two SSH clients for the
109
+ // same server and leak the loser.
110
+ connecting = new Map();
111
+ defaultName = "default";
112
+ enabledServers = null; // null = all servers enabled
113
+ outputLogRoot = null; // null = use <cwd>/.handfree-output at write time
114
+ constructor() { }
115
+ /**
116
+ * Get singleton instance
117
+ */
118
+ static getInstance() {
119
+ if (!SSHConnectionManager.instance) {
120
+ SSHConnectionManager.instance = new SSHConnectionManager();
121
+ }
122
+ return SSHConnectionManager.instance;
123
+ }
124
+ /**
125
+ * Batch set SSH configurations
126
+ * @param configs - Server configurations
127
+ * @param enabledServers - List of enabled server names (null = all enabled)
128
+ */
129
+ setConfig(configs, enabledServers) {
130
+ this.configs = configs;
131
+ this.enabledServers = enabledServers || null;
132
+ // Only single-server deployments have a meaningful "default". When >1
133
+ // server is enabled, every tool call must specify connectionName
134
+ // (enforced by resolveServer), so a default would just hide bugs.
135
+ const effectiveNames = enabledServers && enabledServers.length > 0
136
+ ? enabledServers
137
+ : Object.keys(configs);
138
+ if (effectiveNames.length === 1) {
139
+ this.defaultName = effectiveNames[0];
140
+ }
141
+ else {
142
+ this.defaultName = "";
143
+ }
144
+ if (this.enabledServers) {
145
+ Logger.log(`Enabled servers: ${this.enabledServers.join(", ")}`, "info");
146
+ if (this.defaultName) {
147
+ Logger.log(`Default server: ${this.defaultName}`, "info");
148
+ }
149
+ }
150
+ }
151
+ /**
152
+ * Replace the full config map during hot-reload. Connections whose host,
153
+ * port, username, authentication, or proxy settings changed are closed so
154
+ * the next tool call reconnects with the fresh OpenSSH/YAML values.
155
+ */
156
+ replaceConfig(configs, enabledServers) {
157
+ const previous = this.configs;
158
+ const changedConnections = [];
159
+ for (const [name, oldConfig] of Object.entries(previous)) {
160
+ const nextConfig = configs[name];
161
+ if (!nextConfig || this.connectionFieldsChanged(oldConfig, nextConfig)) {
162
+ this.closeClient(name, true);
163
+ this.statusCache.delete(name);
164
+ changedConnections.push(name);
165
+ }
166
+ }
167
+ this.setConfig(configs, enabledServers);
168
+ Logger.log(`Hot-reloaded SSH config: ${Object.keys(configs).length} server(s), ` +
169
+ `${changedConnections.length} connection(s) reset`, "info");
170
+ }
171
+ /**
172
+ * Set the root directory under which execute-command full-output logs are
173
+ * persisted. If null/unset, defaults to <cwd>/.handfree-output resolved at
174
+ * each write. Per-call logs land under <root>/<server>/<user>/<file>.log.
175
+ */
176
+ setOutputLogRoot(rootDir) {
177
+ this.outputLogRoot = rootDir && rootDir.length > 0 ? rootDir : null;
178
+ }
179
+ /**
180
+ * Resolve the configured output log root, applying the default
181
+ * (<cwd>/.handfree-output) when nothing was explicitly set.
182
+ */
183
+ getOutputLogRoot() {
184
+ return this.outputLogRoot ?? path.join(process.cwd(), ".handfree-output");
185
+ }
186
+ /**
187
+ * Hot-reload mutable policy fields from a fresh config map.
188
+ * Only updates whitelist, blacklist, safeDirectory, allowedRemoteDirectories,
189
+ * and allowedLocalDirectories for servers that already exist. Does NOT touch
190
+ * SSH connections or credentials.
191
+ */
192
+ updatePolicies(freshConfigs) {
193
+ let changed = 0;
194
+ for (const [name, existing] of Object.entries(this.configs)) {
195
+ const fresh = freshConfigs[name];
196
+ if (!fresh)
197
+ continue;
198
+ const wlChanged = JSON.stringify(existing.commandWhitelist) !==
199
+ JSON.stringify(fresh.commandWhitelist);
200
+ const blChanged = JSON.stringify(existing.commandBlacklist) !==
201
+ JSON.stringify(fresh.commandBlacklist);
202
+ const sdChanged = existing.safeDirectory !== fresh.safeDirectory;
203
+ const ardChanged = JSON.stringify(existing.allowedRemoteDirectories) !==
204
+ JSON.stringify(fresh.allowedRemoteDirectories);
205
+ const aldChanged = JSON.stringify(existing.allowedLocalDirectories) !==
206
+ JSON.stringify(fresh.allowedLocalDirectories);
207
+ if (wlChanged || blChanged || sdChanged || ardChanged || aldChanged) {
208
+ existing.commandWhitelist = fresh.commandWhitelist;
209
+ existing.commandBlacklist = fresh.commandBlacklist;
210
+ existing.safeDirectory = fresh.safeDirectory;
211
+ existing.allowedRemoteDirectories = fresh.allowedRemoteDirectories;
212
+ existing.allowedLocalDirectories = fresh.allowedLocalDirectories;
213
+ changed++;
214
+ const parts = [];
215
+ if (wlChanged)
216
+ parts.push(`whitelist(${(fresh.commandWhitelist ?? []).length})`);
217
+ if (blChanged)
218
+ parts.push(`blacklist(${(fresh.commandBlacklist ?? []).length})`);
219
+ if (sdChanged)
220
+ parts.push(`safeDirectory(${fresh.safeDirectory ?? "none"})`);
221
+ if (ardChanged)
222
+ parts.push(`allowedRemoteDirectories(${(fresh.allowedRemoteDirectories ?? []).length})`);
223
+ if (aldChanged)
224
+ parts.push(`allowedLocalDirectories(${(fresh.allowedLocalDirectories ?? []).length})`);
225
+ Logger.log(`Hot-reloaded policies for [${name}]: ${parts.join(", ")}`, "info");
226
+ }
227
+ }
228
+ if (changed === 0) {
229
+ Logger.log("Config file changed but no policy updates detected", "info");
230
+ }
231
+ }
232
+ connectionFieldsChanged(oldConfig, nextConfig) {
233
+ return CONNECTION_RESET_FIELDS.some((key) => oldConfig[key] !== nextConfig[key]);
234
+ }
235
+ closeClient(name, bumpGeneration = false) {
236
+ if (bumpGeneration) {
237
+ this.bumpConnectionGeneration(name);
238
+ }
239
+ const pendingClient = this.pendingClients.get(name);
240
+ if (pendingClient) {
241
+ try {
242
+ pendingClient.end();
243
+ }
244
+ catch {
245
+ // Ignore close errors for dead pending clients.
246
+ }
247
+ this.pendingClients.delete(name);
248
+ }
249
+ const client = this.clients.get(name);
250
+ if (client) {
251
+ try {
252
+ client.end();
253
+ }
254
+ catch {
255
+ // Ignore close errors for dead clients.
256
+ }
257
+ this.clients.delete(name);
258
+ }
259
+ this.connected.set(name, false);
260
+ this.connecting.delete(name);
261
+ }
262
+ bumpConnectionGeneration(name) {
263
+ this.connectionGenerations.set(name, (this.connectionGenerations.get(name) ?? 0) + 1);
264
+ }
265
+ /**
266
+ * Check if a server is enabled for use
267
+ */
268
+ isServerEnabled(name) {
269
+ if (!this.enabledServers) {
270
+ return true; // All servers enabled
271
+ }
272
+ return this.enabledServers.includes(name);
273
+ }
274
+ /**
275
+ * Returns true when more than one server is enabled,
276
+ * meaning callers MUST specify connectionName explicitly.
277
+ */
278
+ isMultiServer() {
279
+ const count = this.enabledServers
280
+ ? this.enabledServers.length
281
+ : Object.keys(this.configs).length;
282
+ return count > 1;
283
+ }
284
+ /**
285
+ * Resolve the target server name.
286
+ * When multiple servers are enabled, connectionName is mandatory.
287
+ */
288
+ resolveServer(connectionName) {
289
+ if (this.isMultiServer() && !connectionName) {
290
+ const names = this.enabledServers ?? Object.keys(this.configs);
291
+ throw new ToolError("INVALID_CONFIGURATION", `Multiple servers are enabled (${names.join(", ")}). You must specify connectionName explicitly. Call list-servers to see available names.`, false);
292
+ }
293
+ return connectionName || this.defaultName;
294
+ }
295
+ /**
296
+ * Get specified connection configuration
297
+ * Throws error if server is not enabled
298
+ */
299
+ getConfig(name) {
300
+ const key = name || this.defaultName;
301
+ // Check if server exists
302
+ if (!this.configs[key]) {
303
+ throw new ToolError("INVALID_CONFIGURATION", `SSH configuration for '${key}' not set`, false);
304
+ }
305
+ // Check if server is enabled
306
+ if (!this.isServerEnabled(key)) {
307
+ throw new ToolError("INVALID_CONFIGURATION", `SSH server '${key}' is not enabled. Enabled servers: ${this.enabledServers?.join(", ") || "none"}`, false);
308
+ }
309
+ return this.configs[key];
310
+ }
311
+ /**
312
+ * Get server config without throwing (returns null if not found/enabled)
313
+ * Useful for tools that want to inspect config without failing
314
+ */
315
+ getServerConfig(name) {
316
+ const key = name || this.defaultName;
317
+ if (!this.configs[key]) {
318
+ return null;
319
+ }
320
+ if (!this.isServerEnabled(key)) {
321
+ return null;
322
+ }
323
+ return this.configs[key];
324
+ }
325
+ /**
326
+ * Batch connect all configured SSH connections
327
+ */
328
+ async connectAll() {
329
+ const names = this.enabledServers ?? Object.keys(this.configs);
330
+ const results = await Promise.allSettled(names.map((name) => this.connect(name)));
331
+ const failures = results
332
+ .map((r, i) => (r.status === "rejected" ? `${names[i]}: ${r.reason.message}` : null))
333
+ .filter(Boolean);
334
+ if (failures.length > 0) {
335
+ Logger.log(`Pre-connect failures: ${failures.join("; ")}`, "error");
336
+ }
337
+ }
338
+ /**
339
+ * Connect to SSH with specified name.
340
+ *
341
+ * Concurrent callers for the same server share a single in-flight promise,
342
+ * so we never create two SSH clients and leak the loser.
343
+ */
344
+ async connect(name) {
345
+ const key = name || this.defaultName;
346
+ if (this.connected.get(key) && this.clients.get(key)) {
347
+ return;
348
+ }
349
+ const inFlight = this.connecting.get(key);
350
+ if (inFlight) {
351
+ return inFlight;
352
+ }
353
+ const promise = this.doConnect(key).finally(() => {
354
+ this.connecting.delete(key);
355
+ });
356
+ this.connecting.set(key, promise);
357
+ return promise;
358
+ }
359
+ /**
360
+ * Actual SSH connect implementation. Callers must go through `connect()`
361
+ * so concurrent requests are deduped.
362
+ * @private
363
+ */
364
+ async doConnect(key) {
365
+ const config = this.getConfig(key);
366
+ const client = new Client();
367
+ const generation = this.connectionGenerations.get(key) ?? 0;
368
+ this.pendingClients.set(key, client);
369
+ try {
370
+ await new Promise(async (resolve, reject) => {
371
+ client.on("ready", () => {
372
+ if ((this.connectionGenerations.get(key) ?? 0) !== generation) {
373
+ try {
374
+ client.end();
375
+ }
376
+ catch {
377
+ // Ignore stale-client close errors.
378
+ }
379
+ reject(new ToolError("SSH_CONNECTION_FAILED", `SSH connection [${key}] was superseded by a config reload`, true));
380
+ return;
381
+ }
382
+ this.connected.set(key, true);
383
+ Logger.log(`Successfully connected to SSH server [${key}] ${config.host}:${config.port}`);
384
+ // 先 resolve,让用户命令可以立即执行
385
+ resolve();
386
+ // 延迟执行系统状态收集,避免与用户的第一个命令竞争 SSH 通道
387
+ // 这修复了首次连接后第一个命令失败的竞态条件问题
388
+ // See: https://github.com/classfang/ssh-mcp-server/issues/XX
389
+ setTimeout(() => {
390
+ collectSystemStatus(client, key)
391
+ .then((status) => {
392
+ this.statusCache.set(key, status);
393
+ Logger.log(`System status collected for [${key}]`, "info");
394
+ })
395
+ .catch((error) => {
396
+ Logger.log(`Failed to collect system status for [${key}]: ${error.message}`, "error");
397
+ // Set basic status even if collection fails
398
+ this.statusCache.set(key, {
399
+ reachable: true,
400
+ lastUpdated: new Date().toISOString(),
401
+ });
402
+ });
403
+ }, 1000); // 延迟 1 秒,确保用户命令有足够的时间窗口
404
+ });
405
+ client.on("error", (err) => {
406
+ if ((this.connectionGenerations.get(key) ?? 0) === generation) {
407
+ this.connected.set(key, false);
408
+ }
409
+ reject(new ToolError("SSH_CONNECTION_FAILED", `SSH connection [${key}] failed: ${err.message}`, true));
410
+ });
411
+ client.on("close", () => {
412
+ if ((this.connectionGenerations.get(key) ?? 0) === generation &&
413
+ this.clients.get(key) === client) {
414
+ this.connected.set(key, false);
415
+ }
416
+ Logger.log(`SSH connection [${key}] closed`, "info");
417
+ });
418
+ const sshConfig = {
419
+ host: config.host,
420
+ port: config.port,
421
+ username: config.username,
422
+ };
423
+ const agent = config.agent === false
424
+ ? undefined
425
+ : config.agent || (config.identitiesOnly ? undefined : process.env.SSH_AUTH_SOCK);
426
+ if (agent) {
427
+ sshConfig.agent = agent;
428
+ }
429
+ // Add SOCKS proxy configuration if provided
430
+ if (config.socksProxy) {
431
+ try {
432
+ // Parse SOCKS proxy URL
433
+ const proxyUrl = new URL(config.socksProxy);
434
+ const proxyHost = proxyUrl.hostname;
435
+ const proxyPort = parseInt(proxyUrl.port, 10);
436
+ Logger.log(`Using SOCKS proxy for [${key}]: ${config.socksProxy}`, "info");
437
+ // Create SOCKS connection
438
+ const { socket } = await SocksClient.createConnection({
439
+ proxy: {
440
+ host: proxyHost,
441
+ port: proxyPort,
442
+ type: 5,
443
+ },
444
+ command: "connect",
445
+ destination: {
446
+ host: config.host,
447
+ port: config.port,
448
+ },
449
+ });
450
+ // Set the socket as the sock for SSH connection
451
+ sshConfig.sock = socket;
452
+ Logger.log(`SSH config object with SOCKS proxy: ${JSON.stringify(sshConfig, (k, v) => (k === "sock" ? "[Socket object]" : v))}`, "info");
453
+ }
454
+ catch (err) {
455
+ return reject(new ToolError("SSH_CONNECTION_FAILED", `Failed to create SOCKS proxy connection for [${key}]: ${err.message}`, true));
456
+ }
457
+ }
458
+ if (config.privateKey) {
459
+ try {
460
+ sshConfig.privateKey = fs.readFileSync(config.privateKey, "utf8");
461
+ if (config.passphrase) {
462
+ sshConfig.passphrase = config.passphrase;
463
+ }
464
+ Logger.log(`Using SSH private key authentication for [${key}]`, "info");
465
+ }
466
+ catch (err) {
467
+ return reject(new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read private key file for [${key}]: ${err.message}`, false));
468
+ }
469
+ }
470
+ else if (config.password) {
471
+ sshConfig.password = config.password;
472
+ Logger.log(`Using password authentication for [${key}]`, "info");
473
+ }
474
+ else if (agent || config.authOptional) {
475
+ Logger.log(`Using SSH agent/default authentication for [${key}]`, "info");
476
+ }
477
+ else {
478
+ return reject(new ToolError("SSH_AUTHENTICATION_MISSING", `No valid authentication method provided for [${key}] (password or private key)`, false));
479
+ }
480
+ client.connect(sshConfig);
481
+ });
482
+ }
483
+ finally {
484
+ if (this.pendingClients.get(key) === client) {
485
+ this.pendingClients.delete(key);
486
+ }
487
+ }
488
+ if ((this.connectionGenerations.get(key) ?? 0) !== generation) {
489
+ try {
490
+ client.end();
491
+ }
492
+ catch {
493
+ // Ignore stale-client close errors.
494
+ }
495
+ throw new ToolError("SSH_CONNECTION_FAILED", `SSH connection [${key}] was superseded by a config reload`, true);
496
+ }
497
+ this.clients.set(key, client);
498
+ }
499
+ /**
500
+ * Get SSH Client with specified name
501
+ */
502
+ getClient(name) {
503
+ const key = name || this.defaultName;
504
+ const client = this.clients.get(key);
505
+ if (!client) {
506
+ throw new ToolError("SSH_CONNECTION_FAILED", `SSH client for '${key}' not connected`, true);
507
+ }
508
+ return client;
509
+ }
510
+ /**
511
+ * Ensure SSH client is connected
512
+ * @private
513
+ */
514
+ async ensureConnected(name) {
515
+ const key = name || this.defaultName;
516
+ if (!this.connected.get(key) || !this.clients.get(key)) {
517
+ await this.connect(key);
518
+ }
519
+ const client = this.clients.get(key);
520
+ if (!client) {
521
+ throw new ToolError("SSH_CONNECTION_FAILED", `SSH client for '${key}' not initialized`, true);
522
+ }
523
+ return client;
524
+ }
525
+ /**
526
+ * Check if an error is a connection-related error that can be retried
527
+ * @private
528
+ */
529
+ isConnectionError(error) {
530
+ if (error instanceof ToolError) {
531
+ return error.code === "SSH_CONNECTION_FAILED";
532
+ }
533
+ const msg = error.message.toLowerCase();
534
+ return (msg.includes("not connected") ||
535
+ msg.includes("connection") ||
536
+ msg.includes("socket") ||
537
+ msg.includes("econnreset") ||
538
+ msg.includes("econnrefused") ||
539
+ msg.includes("epipe") ||
540
+ msg.includes("closed") ||
541
+ msg.includes("end of stream") ||
542
+ msg.includes("channel"));
543
+ }
544
+ /**
545
+ * Force reconnect to SSH server
546
+ * @private
547
+ */
548
+ async reconnect(name) {
549
+ const key = name || this.defaultName;
550
+ Logger.log(`Attempting to reconnect SSH [${key}]...`, "info");
551
+ // Close existing connection if any
552
+ const existingClient = this.clients.get(key);
553
+ if (existingClient) {
554
+ try {
555
+ existingClient.end();
556
+ }
557
+ catch (e) {
558
+ // Ignore errors when closing dead connection
559
+ }
560
+ this.clients.delete(key);
561
+ }
562
+ const pendingClient = this.pendingClients.get(key);
563
+ if (pendingClient) {
564
+ try {
565
+ pendingClient.end();
566
+ }
567
+ catch (e) {
568
+ // Ignore errors when closing dead connection
569
+ }
570
+ this.pendingClients.delete(key);
571
+ }
572
+ this.bumpConnectionGeneration(key);
573
+ this.connected.set(key, false);
574
+ // Reconnect
575
+ await this.connect(key);
576
+ }
577
+ /**
578
+ * Sleep helper for retry backoff
579
+ * @private
580
+ */
581
+ sleep(ms) {
582
+ return new Promise(resolve => setTimeout(resolve, ms));
583
+ }
584
+ /**
585
+ * SECURITY: Patterns that indicate command chaining (used to detect hidden rm)
586
+ */
587
+ static COMMAND_CHAIN_PATTERNS = [
588
+ /;/, // Command separator: cmd1; cmd2
589
+ /&&/, // AND operator: cmd1 && cmd2
590
+ /\|\|/, // OR operator: cmd1 || cmd2
591
+ /\|/, // Pipe: cmd1 | cmd2
592
+ /\$\(/, // Command substitution: $(cmd)
593
+ /`/, // Backtick substitution: `cmd`
594
+ ];
595
+ /**
596
+ * SECURITY: Safe directory for destructive operations (rm, mv, etc.)
597
+ */
598
+ // Default safe directory fallback (used only if username missing for some reason)
599
+ static DEFAULT_SAFE_DIRECTORY = "/home";
600
+ /**
601
+ * SECURITY: Find the first destructive pattern match and return a reason
602
+ * Returns null when no destructive pattern is found
603
+ * @private
604
+ */
605
+ getDestructiveMatch(command) {
606
+ // This catches: "cd /; rm -rf *", "echo test && rm file", "$(rm file)", and risky writes like "> /etc/..."
607
+ const destructivePatterns = [
608
+ { regex: /\brm\b/, reason: "rm in command chain" },
609
+ { regex: /\brmdir\b/, reason: "rmdir in command chain" },
610
+ { regex: /\bunlink\b/, reason: "unlink in command chain" },
611
+ { regex: /\bshred\b/, reason: "shred in command chain" },
612
+ { regex: /\btruncate\b/, reason: "truncate in command chain" },
613
+ { regex: /-delete\b/, reason: "find -delete detected" },
614
+ { regex: /-exec\s+rm\b/, reason: "find -exec rm detected" },
615
+ { regex: /\bdd\b.*\bof=/, reason: "dd with of= (can overwrite files)" },
616
+ { regex: /\bmv\b/, reason: "mv detected (can overwrite)" },
617
+ { regex: /\bcp\b.*-f/, reason: "cp -f detected (force overwrite)" },
618
+ // Allow stderr redirection to /dev/null (2>/dev/null, 2>&1>/dev/null, etc.)
619
+ // Block dangerous writes like: echo x > /etc/passwd, cat > /bin/bash
620
+ { regex: /(?<![0-9])>\s*\/(?!dev\/null)/, reason: "output redirection to absolute path" },
621
+ { regex: />\s*~/, reason: "output redirection to home path" },
622
+ ];
623
+ for (const { regex, reason } of destructivePatterns) {
624
+ if (regex.test(command)) {
625
+ return reason;
626
+ }
627
+ }
628
+ return null;
629
+ }
630
+ /**
631
+ * SECURITY: Validate that a path is within the safe directory
632
+ * Handles path traversal attacks like ../../etc/passwd
633
+ * @private
634
+ */
635
+ isPathInSafeDirectory(filePath, safeDir) {
636
+ // Normalize: remove redundant slashes, handle . and ..
637
+ const parts = filePath.split('/').filter(p => p !== '' && p !== '.');
638
+ const normalized = [];
639
+ for (const part of parts) {
640
+ if (part === '..') {
641
+ normalized.pop(); // Go up one directory
642
+ }
643
+ else {
644
+ normalized.push(part);
645
+ }
646
+ }
647
+ const normalizedPath = '/' + normalized.join('/');
648
+ // After normalization, must be inside safe directory with a `/` boundary
649
+ // so `/home/alice-evil` is rejected when safeDir is `/home/alice`.
650
+ if (safeDir === "/")
651
+ return true;
652
+ return normalizedPath === safeDir || normalizedPath.startsWith(safeDir + "/");
653
+ }
654
+ /**
655
+ * SECURITY: Validate rm command specifically
656
+ * Only allow rm on files/dirs inside the safe directory
657
+ * @private
658
+ */
659
+ validateRmCommand(command, safeDir) {
660
+ // Check for command chaining - rm must be a standalone command, not hidden in a chain
661
+ for (const pattern of SSHConnectionManager.COMMAND_CHAIN_PATTERNS) {
662
+ if (pattern.test(command)) {
663
+ return {
664
+ valid: false,
665
+ reason: `rm command cannot be chained with other commands. Found: ${pattern.toString()}`
666
+ };
667
+ }
668
+ }
669
+ // Extract the rm command and its arguments
670
+ const rmMatch = command.match(/^rm\s+(.+)$/);
671
+ if (!rmMatch) {
672
+ return { valid: false, reason: "Invalid rm command format" };
673
+ }
674
+ const argsString = rmMatch[1];
675
+ // Parse arguments - split by space but handle quoted strings
676
+ // For simplicity, we'll split by space and filter out flags
677
+ const parts = argsString.split(/\s+/);
678
+ const paths = [];
679
+ for (const part of parts) {
680
+ // Skip flags like -f, -r, -rf, --force, etc.
681
+ if (part.startsWith('-')) {
682
+ continue;
683
+ }
684
+ paths.push(part);
685
+ }
686
+ if (paths.length === 0) {
687
+ return { valid: false, reason: "No paths specified for rm" };
688
+ }
689
+ // Validate each path
690
+ for (const p of paths) {
691
+ // Must be absolute path
692
+ if (!p.startsWith('/')) {
693
+ return { valid: false, reason: `rm path must be absolute: ${p}` };
694
+ }
695
+ // Must be inside safe directory
696
+ if (!this.isPathInSafeDirectory(p, safeDir)) {
697
+ return {
698
+ valid: false,
699
+ reason: `rm blocked: path "${p}" is outside safe directory "${safeDir}"`
700
+ };
701
+ }
702
+ }
703
+ return { valid: true };
704
+ }
705
+ /**
706
+ * SECURITY: Main command validation with multiple layers of defense
707
+ * @private
708
+ */
709
+ validateCommand(command, name) {
710
+ // ========================================
711
+ // LAYER 1: Check for hidden destructive commands in chains
712
+ // Block things like: "cd /; rm -rf *", "echo | rm file", "$(rm file)"
713
+ // ========================================
714
+ const config = this.getConfig(name);
715
+ const safeDir = config.safeDirectory
716
+ || (config.username
717
+ ? (config.username === 'root' ? '/root' : `/home/${config.username}`)
718
+ : SSHConnectionManager.DEFAULT_SAFE_DIRECTORY);
719
+ const destructiveReason = this.getDestructiveMatch(command);
720
+ if (destructiveReason) {
721
+ // Command contains rm/rmdir - check if it's a simple rm command or hidden in a chain
722
+ const trimmed = command.trim();
723
+ // If it starts with rm, validate it properly but continue through whitelist/blacklist policy checks
724
+ if (trimmed.startsWith('rm ') || trimmed === 'rm') {
725
+ const rmValidation = this.validateRmCommand(trimmed, safeDir);
726
+ if (!rmValidation.valid) {
727
+ Logger.log(`SECURITY: rm command blocked: ${rmValidation.reason}`, "error");
728
+ return {
729
+ isAllowed: false,
730
+ reason: rmValidation.reason,
731
+ };
732
+ }
733
+ Logger.log(`SECURITY: rm command passed safe directory validation (${safeDir}): ${command}`, "info");
734
+ }
735
+ else {
736
+ // Destructive pattern detected somewhere in the command (chained, subshell, redirection, etc.)
737
+ Logger.log(`SECURITY: Destructive pattern detected (${destructiveReason}): ${command}`, "error");
738
+ return {
739
+ isAllowed: false,
740
+ reason: `Blocked destructive pattern: ${destructiveReason}. Command: "${command}"`,
741
+ };
742
+ }
743
+ }
744
+ // ========================================
745
+ // LAYER 2: Whitelist check for non-destructive commands
746
+ // ========================================
747
+ // Use config whitelist if provided, otherwise use default whitelist
748
+ const whitelist = (config.commandWhitelist && config.commandWhitelist.length > 0)
749
+ ? config.commandWhitelist
750
+ : DEFAULT_COMMAND_WHITELIST;
751
+ // Check whitelist - command must match one of the patterns to be allowed
752
+ const matchesWhitelist = whitelist.some((pattern) => {
753
+ try {
754
+ const regex = new RegExp(pattern);
755
+ return regex.test(command);
756
+ }
757
+ catch (e) {
758
+ Logger.log(`Invalid whitelist regex pattern: ${pattern}`, "error");
759
+ return false;
760
+ }
761
+ });
762
+ if (!matchesWhitelist) {
763
+ Logger.log(`Command blocked by whitelist: ${command}`, "info");
764
+ return {
765
+ isAllowed: false,
766
+ reason: `Command not in whitelist, execution forbidden. Command: "${command}"`,
767
+ };
768
+ }
769
+ // ========================================
770
+ // LAYER 3: Blacklist check
771
+ // ========================================
772
+ if (config.commandBlacklist && config.commandBlacklist.length > 0) {
773
+ const matchesBlacklist = config.commandBlacklist.some((pattern) => {
774
+ try {
775
+ const regex = new RegExp(pattern);
776
+ return regex.test(command);
777
+ }
778
+ catch (e) {
779
+ Logger.log(`Invalid blacklist regex pattern: ${pattern}`, "error");
780
+ return false;
781
+ }
782
+ });
783
+ if (matchesBlacklist) {
784
+ Logger.log(`Command blocked by blacklist: ${command}`, "info");
785
+ return {
786
+ isAllowed: false,
787
+ reason: "Command matches blacklist, execution forbidden",
788
+ };
789
+ }
790
+ }
791
+ // Validation passed
792
+ return {
793
+ isAllowed: true,
794
+ };
795
+ }
796
+ /**
797
+ * Low-level streaming runner shared by both the buffered (`executeCommand`)
798
+ * and progress (`executeCommandWithProgress`) paths.
799
+ *
800
+ * Streams raw stdout/stderr bytes into:
801
+ * - `stdoutCollector` / `stderrCollector` (tail-only, for the returned text)
802
+ * - `logWriter` (full output persisted to disk)
803
+ * - `onProgress` (live forwarding, never truncated)
804
+ *
805
+ * Resolves with the exit code (null if the remote process was signaled).
806
+ * @private
807
+ */
808
+ async runCommandStream(cmdString, client, timeout, sinks) {
809
+ return new Promise((resolve, reject) => {
810
+ let timeoutId;
811
+ const cleanup = () => {
812
+ if (timeoutId)
813
+ clearTimeout(timeoutId);
814
+ };
815
+ client.exec(cmdString, (err, stream) => {
816
+ if (err) {
817
+ cleanup();
818
+ reject(new ToolError("COMMAND_EXECUTION_ERROR", `Command execution error: ${err.message}`, false));
819
+ return;
820
+ }
821
+ stream.on("data", (chunk) => {
822
+ sinks.stdoutCollector?.push(chunk);
823
+ sinks.logWriter?.appendStdout(chunk);
824
+ if (sinks.onProgress)
825
+ sinks.onProgress(chunk.toString());
826
+ });
827
+ stream.stderr.on("data", (chunk) => {
828
+ sinks.stderrCollector?.push(chunk);
829
+ sinks.logWriter?.appendStderr(chunk);
830
+ if (sinks.onProgress)
831
+ sinks.onProgress(`[STDERR] ${chunk.toString()}`);
832
+ });
833
+ stream.on("close", (code) => {
834
+ cleanup();
835
+ resolve(code ?? null);
836
+ });
837
+ stream.on("error", (err) => {
838
+ cleanup();
839
+ reject(new ToolError("COMMAND_EXECUTION_ERROR", `Stream error: ${err.message}`, false));
840
+ });
841
+ timeoutId = setTimeout(() => {
842
+ cleanup();
843
+ try {
844
+ stream.signal("KILL");
845
+ }
846
+ catch (e) {
847
+ // Ignore errors when sending signal
848
+ }
849
+ try {
850
+ stream.close();
851
+ }
852
+ catch (e) {
853
+ // Ignore errors when closing streams during timeout
854
+ }
855
+ reject(new ToolError("COMMAND_TIMEOUT", `Command timeout: execution exceeded ${timeout}ms limit. Remote process killed.`, false));
856
+ }, timeout);
857
+ });
858
+ });
859
+ }
860
+ /**
861
+ * Default cap on bytes returned to the caller from `execute-command`.
862
+ * Combined stdout + stderr; tail-only truncation past this limit. The
863
+ * full output is always persisted to disk regardless of this cap.
864
+ */
865
+ static DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024;
866
+ /**
867
+ * Assemble the final user-visible result string from collected tails.
868
+ * Adds a truncation header (with the on-disk log path) and the legacy
869
+ * [STDERR]/[EXIT CODE] markers so the LLM still sees the same shape.
870
+ */
871
+ buildExecuteResult(args) {
872
+ const { stdoutCollector, stderrCollector, exitCode, logWriter, maxOutputBytes } = args;
873
+ const stdoutSnap = stdoutCollector.getSnapshot();
874
+ const stderrSnap = stderrCollector.getSnapshot();
875
+ const totalBytes = stdoutSnap.totalBytes + stderrSnap.totalBytes;
876
+ const totalDropped = stdoutSnap.droppedBytes + stderrSnap.droppedBytes;
877
+ const truncated = stdoutSnap.truncated || stderrSnap.truncated;
878
+ const stdoutText = stdoutSnap.tail.toString("utf8");
879
+ const stderrText = stderrSnap.tail.toString("utf8");
880
+ let body = "";
881
+ if (stdoutText.trim()) {
882
+ body += stdoutText;
883
+ }
884
+ if (stderrText.trim()) {
885
+ if (body)
886
+ body += "\n";
887
+ body += `[STDERR]\n${stderrText}`;
888
+ }
889
+ if (exitCode !== 0 && exitCode !== null) {
890
+ if (body)
891
+ body += "\n";
892
+ body += `[EXIT CODE: ${exitCode}]`;
893
+ }
894
+ if (!body.trim()) {
895
+ body = exitCode === 0
896
+ ? "(Command completed successfully with no output)"
897
+ : `(Command exited with code ${exitCode} and no output)`;
898
+ }
899
+ if (truncated) {
900
+ const logPathLine = logWriter
901
+ ? `Full output saved to: ${logWriter.getPath()}\n`
902
+ : "Full output log was not available (disk write failed).\n";
903
+ const header = `[OUTPUT TRUNCATED]\n` +
904
+ `Total bytes: ${totalBytes} (stdout=${stdoutSnap.totalBytes}, stderr=${stderrSnap.totalBytes})\n` +
905
+ `Bytes dropped from head: ${totalDropped}\n` +
906
+ `Showing last <= ${maxOutputBytes} bytes per stream.\n` +
907
+ logPathLine +
908
+ `---\n`;
909
+ return header + body;
910
+ }
911
+ return body;
912
+ }
913
+ /**
914
+ * Execute SSH command with auto-retry on connection errors
915
+ *
916
+ * Features:
917
+ * - Validates command against whitelist/blacklist before execution
918
+ * - Auto-reconnects and retries on connection failures
919
+ * - Exponential backoff between retries (500ms, 1000ms, 2000ms)
920
+ * - Configurable timeout per command
921
+ */
922
+ async executeCommand(cmdString, name, options = {}) {
923
+ // Validate command input and security
924
+ const validationResult = this.validateCommand(cmdString, name);
925
+ if (!validationResult.isAllowed) {
926
+ throw new ToolError("COMMAND_VALIDATION_FAILED", `Command validation failed: ${validationResult.reason}`, false);
927
+ }
928
+ const timeout = options.timeout || 30000; // Default 30 seconds timeout
929
+ const maxRetries = options.maxRetries ?? 2; // Default 2 retries
930
+ const maxOutputBytes = options.maxOutputBytes ?? SSHConnectionManager.DEFAULT_MAX_OUTPUT_BYTES;
931
+ const key = name || this.defaultName;
932
+ let lastError = null;
933
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
934
+ try {
935
+ // Ensure SSH connection is established
936
+ const client = await this.ensureConnected(name);
937
+ // Per-attempt collectors + log writer. We rebuild them on each retry
938
+ // so partial output from a failed attempt is not mixed into the next.
939
+ const stdoutCollector = new OutputCollector(maxOutputBytes);
940
+ const stderrCollector = new OutputCollector(maxOutputBytes);
941
+ const logWriter = this.createLogWriter(key, cmdString);
942
+ const startedMs = Date.now();
943
+ let exitCode = null;
944
+ try {
945
+ exitCode = await this.runCommandStream(cmdString, client, timeout, {
946
+ stdoutCollector,
947
+ stderrCollector,
948
+ logWriter: logWriter ?? undefined,
949
+ });
950
+ }
951
+ finally {
952
+ logWriter?.close({ exitCode, durationMs: Date.now() - startedMs });
953
+ }
954
+ const result = this.buildExecuteResult({
955
+ stdoutCollector,
956
+ stderrCollector,
957
+ exitCode,
958
+ logWriter,
959
+ maxOutputBytes,
960
+ });
961
+ // Success - log if this was a retry
962
+ if (attempt > 0) {
963
+ Logger.log(`Command succeeded on retry attempt ${attempt} for [${key}]`, "info");
964
+ }
965
+ return result;
966
+ }
967
+ catch (error) {
968
+ lastError = error;
969
+ // Check if this is a connection error that can be retried
970
+ if (this.isConnectionError(lastError) && attempt < maxRetries) {
971
+ const backoffMs = 500 * Math.pow(2, attempt); // 500ms, 1000ms, 2000ms
972
+ Logger.log(`Connection error on attempt ${attempt + 1}/${maxRetries + 1} for [${key}]: ${lastError.message}. Retrying in ${backoffMs}ms...`, "info");
973
+ // Wait with exponential backoff
974
+ await this.sleep(backoffMs);
975
+ // Force reconnect before retry
976
+ try {
977
+ await this.reconnect(name);
978
+ }
979
+ catch (reconnectError) {
980
+ Logger.log(`Reconnect failed for [${key}]: ${reconnectError.message}`, "error");
981
+ // Continue to next retry attempt anyway
982
+ }
983
+ continue;
984
+ }
985
+ // Non-retryable error or max retries reached
986
+ break;
987
+ }
988
+ }
989
+ // All retries exhausted
990
+ throw lastError || new Error("Command execution failed after all retries");
991
+ }
992
+ /**
993
+ * Execute SSH command with real-time streaming output via progress callback
994
+ *
995
+ * Features:
996
+ * - Validates command against whitelist/blacklist before execution
997
+ * - Streams stdout/stderr chunks to the onProgress callback in real-time
998
+ * - Auto-reconnects and retries on connection failures
999
+ * - Longer default timeout suitable for long-running tasks
1000
+ *
1001
+ * @param cmdString - Command to execute
1002
+ * @param name - SSH connection name (optional)
1003
+ * @param options - Execution options including timeout and progress callback
1004
+ */
1005
+ async executeCommandWithProgress(cmdString, name, options = {}) {
1006
+ // Validate command input and security
1007
+ const validationResult = this.validateCommand(cmdString, name);
1008
+ if (!validationResult.isAllowed) {
1009
+ throw new ToolError("COMMAND_VALIDATION_FAILED", `Command validation failed: ${validationResult.reason}`, false);
1010
+ }
1011
+ const timeout = options.timeout || 300000; // Default 5 minutes for streaming
1012
+ const maxRetries = options.maxRetries ?? 2; // Default 2 retries
1013
+ const maxOutputBytes = options.maxOutputBytes ?? SSHConnectionManager.DEFAULT_MAX_OUTPUT_BYTES;
1014
+ const key = name || this.defaultName;
1015
+ let lastError = null;
1016
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1017
+ try {
1018
+ // Ensure SSH connection is established
1019
+ const client = await this.ensureConnected(name);
1020
+ // Per-attempt collectors + log writer. onProgress keeps streaming
1021
+ // every byte live; only the final returned string is capped.
1022
+ const stdoutCollector = new OutputCollector(maxOutputBytes);
1023
+ const stderrCollector = new OutputCollector(maxOutputBytes);
1024
+ const logWriter = this.createLogWriter(key, cmdString);
1025
+ const startedMs = Date.now();
1026
+ let exitCode = null;
1027
+ try {
1028
+ exitCode = await this.runCommandStream(cmdString, client, timeout, {
1029
+ stdoutCollector,
1030
+ stderrCollector,
1031
+ logWriter: logWriter ?? undefined,
1032
+ onProgress: options.onProgress,
1033
+ });
1034
+ }
1035
+ finally {
1036
+ logWriter?.close({ exitCode, durationMs: Date.now() - startedMs });
1037
+ }
1038
+ const result = this.buildExecuteResult({
1039
+ stdoutCollector,
1040
+ stderrCollector,
1041
+ exitCode,
1042
+ logWriter,
1043
+ maxOutputBytes,
1044
+ });
1045
+ // Success - log if this was a retry
1046
+ if (attempt > 0) {
1047
+ Logger.log(`Streaming command succeeded on retry attempt ${attempt} for [${key}]`, "info");
1048
+ }
1049
+ return result;
1050
+ }
1051
+ catch (error) {
1052
+ lastError = error;
1053
+ // Check if this is a connection error that can be retried
1054
+ if (this.isConnectionError(lastError) && attempt < maxRetries) {
1055
+ const backoffMs = 500 * Math.pow(2, attempt);
1056
+ Logger.log(`Connection error on streaming attempt ${attempt + 1}/${maxRetries + 1} for [${key}]: ${lastError.message}. Retrying in ${backoffMs}ms...`, "info");
1057
+ await this.sleep(backoffMs);
1058
+ try {
1059
+ await this.reconnect(name);
1060
+ }
1061
+ catch (reconnectError) {
1062
+ Logger.log(`Reconnect failed for [${key}]: ${reconnectError.message}`, "error");
1063
+ }
1064
+ continue;
1065
+ }
1066
+ break;
1067
+ }
1068
+ }
1069
+ throw lastError || new Error("Streaming command execution failed after all retries");
1070
+ }
1071
+ /**
1072
+ * Build an OutputLogWriter for a given server. Returns null if we cannot
1073
+ * resolve the configured username (in which case the command still runs
1074
+ * but its full output is not persisted to disk).
1075
+ */
1076
+ createLogWriter(serverName, command) {
1077
+ const config = this.getServerConfig(serverName);
1078
+ if (!config)
1079
+ return null;
1080
+ return new OutputLogWriter({
1081
+ rootDir: this.getOutputLogRoot(),
1082
+ serverName,
1083
+ username: config.username,
1084
+ command,
1085
+ });
1086
+ }
1087
+ /**
1088
+ * Validate a local filesystem path for SFTP transfer.
1089
+ *
1090
+ * The path must be inside the MCP working directory OR inside one of the
1091
+ * server's `allowedLocalDirectories` entries. The working directory is
1092
+ * always allowed implicitly for backward compatibility.
1093
+ */
1094
+ validateLocalPath(localPath, name) {
1095
+ const resolvedPath = path.resolve(localPath);
1096
+ const allowedRoots = new Set([process.cwd()]);
1097
+ // Add per-server allowedLocalDirectories if a server is targeted
1098
+ if (name) {
1099
+ const config = this.getServerConfig(name);
1100
+ if (config?.allowedLocalDirectories) {
1101
+ for (const dir of config.allowedLocalDirectories) {
1102
+ allowedRoots.add(dir);
1103
+ }
1104
+ }
1105
+ }
1106
+ const isAllowed = Array.from(allowedRoots).some((root) => resolvedPath === root || resolvedPath.startsWith(root + path.sep));
1107
+ if (!isAllowed) {
1108
+ const allowedList = Array.from(allowedRoots).join(", ");
1109
+ throw new ToolError("LOCAL_PATH_NOT_ALLOWED", `Local path '${resolvedPath}' is not inside any allowed directory. Allowed: ${allowedList}. ` +
1110
+ `Add the directory under 'allowedLocalDirectories' in the server's YAML config to permit it.`, false);
1111
+ }
1112
+ return resolvedPath;
1113
+ }
1114
+ /**
1115
+ * Validate a remote (POSIX) path for SFTP transfer.
1116
+ *
1117
+ * The path must be an absolute POSIX path inside one of the server's
1118
+ * `allowedRemoteDirectories` entries. If that list is unset or empty,
1119
+ * SFTP is rejected: configure the list explicitly before using
1120
+ * upload/download/transfer.
1121
+ */
1122
+ validateRemotePath(remotePath, name) {
1123
+ if (typeof remotePath !== "string" || remotePath.length === 0) {
1124
+ throw new ToolError("REMOTE_PATH_NOT_ALLOWED", "Remote path must be a non-empty string.", false);
1125
+ }
1126
+ if (remotePath.includes("\0")) {
1127
+ throw new ToolError("REMOTE_PATH_NOT_ALLOWED", "Remote path must not contain null bytes.", false);
1128
+ }
1129
+ if (!path.posix.isAbsolute(remotePath)) {
1130
+ throw new ToolError("REMOTE_PATH_NOT_ALLOWED", `Remote path must be an absolute POSIX path, got: ${remotePath}`, false);
1131
+ }
1132
+ // Reject '..' BEFORE normalization so an escape like /allowed/../etc/passwd
1133
+ // is rejected outright instead of collapsing to /etc/passwd and then
1134
+ // being checked against the (now-bypassed) allowlist.
1135
+ if (remotePath.split("/").includes("..")) {
1136
+ throw new ToolError("REMOTE_PATH_NOT_ALLOWED", `Remote path must not contain '..' segments: ${remotePath}`, false);
1137
+ }
1138
+ const normalized = path.posix.normalize(remotePath);
1139
+ const config = this.getConfig(name);
1140
+ const allowedRoots = config.allowedRemoteDirectories ?? [];
1141
+ if (allowedRoots.length === 0) {
1142
+ throw new ToolError("REMOTE_PATH_NOT_ALLOWED", `SFTP is disabled for server '${name}': no 'allowedRemoteDirectories' configured. ` +
1143
+ `Add at least one absolute POSIX directory to 'allowedRemoteDirectories' in the YAML config to permit upload/download.`, false);
1144
+ }
1145
+ const isAllowed = allowedRoots.some((root) => normalized === root || normalized.startsWith(root === "/" ? "/" : root + "/"));
1146
+ if (!isAllowed) {
1147
+ throw new ToolError("REMOTE_PATH_NOT_ALLOWED", `Remote path '${normalized}' is not inside any allowedRemoteDirectories entry for server '${name}'. ` +
1148
+ `Allowed: ${allowedRoots.join(", ")}.`, false);
1149
+ }
1150
+ return normalized;
1151
+ }
1152
+ /**
1153
+ * Upload file
1154
+ */
1155
+ async upload(localPath, remotePath, name, options) {
1156
+ const resolvedName = name || this.defaultName;
1157
+ const validatedLocalPath = this.validateLocalPath(localPath, resolvedName);
1158
+ const validatedRemotePath = this.validateRemotePath(remotePath, resolvedName);
1159
+ const skipIfIdentical = options?.skipIfIdentical !== false; // default true
1160
+ // ---- Read local file (stat + content as Buffer) ----
1161
+ let stat;
1162
+ try {
1163
+ stat = fs.statSync(validatedLocalPath);
1164
+ }
1165
+ catch (e) {
1166
+ throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to stat local file '${validatedLocalPath}': ${e.message}`, false);
1167
+ }
1168
+ if (!stat.isFile()) {
1169
+ throw new ToolError("LOCAL_FILE_READ_FAILED", `Local path '${validatedLocalPath}' is not a regular file (size=${stat.size}). ` +
1170
+ `Use uploadDirectory / recursive=true for directories.`, false);
1171
+ }
1172
+ let payload;
1173
+ try {
1174
+ payload = fs.readFileSync(validatedLocalPath);
1175
+ }
1176
+ catch (e) {
1177
+ throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read local file '${validatedLocalPath}': ${e.message}`, false);
1178
+ }
1179
+ // ---- CRLF auto-fix for shell scripts ----
1180
+ const crlfFixed = SSHConnectionManager.maybeFixShellScriptLineEndings(validatedLocalPath, payload);
1181
+ payload = crlfFixed.buffer;
1182
+ const crlfNote = crlfFixed.fixed
1183
+ ? ` (CRLF→LF auto-fix: converted ${crlfFixed.replacedCount} line endings to LF before upload because target is a shell script).`
1184
+ : "";
1185
+ const client = await this.ensureConnected(resolvedName);
1186
+ // ---- Skip-if-identical check ----
1187
+ const isShellScript = SSHConnectionManager.SHELL_SCRIPT_EXTENSIONS.has(path.extname(validatedLocalPath).toLowerCase());
1188
+ if (skipIfIdentical) {
1189
+ const decision = await this.shouldSkipUpload(client, payload, validatedRemotePath, isShellScript);
1190
+ if (decision.skip) {
1191
+ return (`Upload skipped: remote file '${validatedRemotePath}' is already identical to local ` +
1192
+ `'${validatedLocalPath}' (${decision.reason}).${crlfNote}`);
1193
+ }
1194
+ }
1195
+ // ---- Actually upload ----
1196
+ await this.sftpWriteBuffer(client, validatedRemotePath, payload);
1197
+ return `File uploaded successfully (${payload.length} bytes)${crlfNote}`;
1198
+ }
1199
+ /**
1200
+ * Threshold above which we use MD5 hash comparison instead of byte-content
1201
+ * comparison for skip-if-identical.
1202
+ */
1203
+ static SKIP_IF_IDENTICAL_HASH_THRESHOLD = 256 * 1024 * 1024;
1204
+ /**
1205
+ * Shell scripts that need CRLF→LF normalization on upload to a Linux host.
1206
+ */
1207
+ static SHELL_SCRIPT_EXTENSIONS = new Set([".sh", ".bash", ".zsh"]);
1208
+ /**
1209
+ * If the local file is a shell script (.sh / .bash / .zsh) and contains
1210
+ * any CRLF line endings, return a new buffer with all CRLF replaced by LF.
1211
+ * Otherwise return the buffer unchanged.
1212
+ */
1213
+ static maybeFixShellScriptLineEndings(localPath, buffer) {
1214
+ const ext = path.extname(localPath).toLowerCase();
1215
+ if (!SSHConnectionManager.SHELL_SCRIPT_EXTENSIONS.has(ext)) {
1216
+ return { buffer, fixed: false, replacedCount: 0 };
1217
+ }
1218
+ // Count CRLF occurrences. Buffer.indexOf is fast.
1219
+ let count = 0;
1220
+ let idx = buffer.indexOf("\r\n");
1221
+ while (idx !== -1) {
1222
+ count++;
1223
+ idx = buffer.indexOf("\r\n", idx + 2);
1224
+ }
1225
+ if (count === 0) {
1226
+ return { buffer, fixed: false, replacedCount: 0 };
1227
+ }
1228
+ // Replace via string conversion. Safe for shell scripts which are always
1229
+ // text. Use 'binary' encoding to avoid any UTF-8 normalization surprises.
1230
+ const fixed = Buffer.from(buffer.toString("binary").replace(/\r\n/g, "\n"), "binary");
1231
+ return { buffer: fixed, fixed: true, replacedCount: count };
1232
+ }
1233
+ /**
1234
+ * Decide whether an upload can be skipped because the remote file is
1235
+ * already identical to the local payload.
1236
+ *
1237
+ * Strategy for regular files:
1238
+ * - If remote file does not exist → don't skip.
1239
+ * - If sizes differ → don't skip.
1240
+ * - If size ≤ 256 MiB → fetch remote bytes and byte-compare.
1241
+ * - Else → MD5 both sides and compare hashes.
1242
+ *
1243
+ * Strategy for shell scripts (lineEndingAgnostic=true):
1244
+ * The local payload has already been LF-normalized. We must compare
1245
+ * against an LF-normalized view of the remote file too, so a remote that
1246
+ * still contains CRLF is treated as equal to an LF-only local file.
1247
+ * Because remote-side md5sum runs on raw bytes (including CRLF), we
1248
+ * cannot use the hash branch — we always download the remote and
1249
+ * byte-compare after normalizing it.
1250
+ *
1251
+ * Any error during the check is treated as 'don't skip' (i.e. fall through
1252
+ * to a normal upload), since correctness wins over an optimization.
1253
+ */
1254
+ async shouldSkipUpload(client, localPayload, remotePath, lineEndingAgnostic) {
1255
+ let remoteSize;
1256
+ try {
1257
+ const sftp = await this.openSftp(client, "dest");
1258
+ try {
1259
+ const stat = await this.sftpStat(sftp, remotePath, "dest");
1260
+ remoteSize = stat.size;
1261
+ }
1262
+ finally {
1263
+ sftp.end();
1264
+ }
1265
+ }
1266
+ catch {
1267
+ return { skip: false, reason: "remote-missing-or-unstat-able" };
1268
+ }
1269
+ if (lineEndingAgnostic) {
1270
+ // For shell scripts we can't trust raw size: remote may have CRLF.
1271
+ // Sanity-cap the remote download size to keep memory bounded.
1272
+ if (remoteSize > SSHConnectionManager.SKIP_IF_IDENTICAL_HASH_THRESHOLD) {
1273
+ // Shell scripts this large are pathological; just re-upload.
1274
+ return { skip: false, reason: `shell-script-too-large-for-content-compare(${remoteSize} bytes)` };
1275
+ }
1276
+ let remoteBuf;
1277
+ try {
1278
+ remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize);
1279
+ }
1280
+ catch {
1281
+ return { skip: false, reason: "remote-read-failed-during-content-compare" };
1282
+ }
1283
+ const remoteNormalized = SSHConnectionManager.normalizeCrlfToLf(remoteBuf);
1284
+ if (remoteNormalized.length === localPayload.length &&
1285
+ remoteNormalized.equals(localPayload)) {
1286
+ const note = remoteNormalized.length !== remoteBuf.length
1287
+ ? `identical-content-ignoring-line-endings(${remoteNormalized.length} bytes after LF-normalization, remote raw was ${remoteBuf.length} bytes with CRLF)`
1288
+ : `identical-content(${remoteNormalized.length} bytes)`;
1289
+ return { skip: true, reason: note };
1290
+ }
1291
+ return { skip: false, reason: "content-differs-after-line-ending-normalization" };
1292
+ }
1293
+ // -------- Non-shell-script path --------
1294
+ if (remoteSize !== localPayload.length) {
1295
+ return { skip: false, reason: `size-differs(local=${localPayload.length},remote=${remoteSize})` };
1296
+ }
1297
+ if (remoteSize <= SSHConnectionManager.SKIP_IF_IDENTICAL_HASH_THRESHOLD) {
1298
+ // Byte-content compare
1299
+ let remoteBuf;
1300
+ try {
1301
+ remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize);
1302
+ }
1303
+ catch {
1304
+ return { skip: false, reason: "remote-read-failed-during-content-compare" };
1305
+ }
1306
+ if (remoteBuf.length === localPayload.length && remoteBuf.equals(localPayload)) {
1307
+ return { skip: true, reason: `identical-content(${remoteSize} bytes)` };
1308
+ }
1309
+ return { skip: false, reason: "content-differs" };
1310
+ }
1311
+ // Large file → hash compare
1312
+ const localMd5 = crypto.createHash("md5").update(localPayload).digest("hex");
1313
+ let remoteMd5 = null;
1314
+ try {
1315
+ remoteMd5 = await this.remoteMd5(client, remotePath);
1316
+ }
1317
+ catch {
1318
+ return { skip: false, reason: "remote-md5-unavailable" };
1319
+ }
1320
+ if (remoteMd5 === localMd5) {
1321
+ return { skip: true, reason: `identical-md5(${localMd5}, ${remoteSize} bytes)` };
1322
+ }
1323
+ return { skip: false, reason: `md5-differs(local=${localMd5}, remote=${remoteMd5})` };
1324
+ }
1325
+ /**
1326
+ * Return a copy of `buf` with every CRLF replaced by LF. No-op if the
1327
+ * buffer contains no CRLF.
1328
+ */
1329
+ static normalizeCrlfToLf(buf) {
1330
+ if (buf.indexOf("\r\n") === -1)
1331
+ return buf;
1332
+ return Buffer.from(buf.toString("binary").replace(/\r\n/g, "\n"), "binary");
1333
+ }
1334
+ /**
1335
+ * Read an SFTP file fully into a Buffer.
1336
+ */
1337
+ async sftpReadBuffer(client, remotePath, expectedSize) {
1338
+ return new Promise((resolve, reject) => {
1339
+ client.sftp((err, sftp) => {
1340
+ if (err) {
1341
+ return reject(new ToolError("SFTP_ERROR", `SFTP open failed: ${err.message}`, true));
1342
+ }
1343
+ const chunks = [];
1344
+ let received = 0;
1345
+ const stream = sftp.createReadStream(remotePath);
1346
+ stream.on("data", (chunk) => {
1347
+ chunks.push(chunk);
1348
+ received += chunk.length;
1349
+ });
1350
+ stream.on("error", (e) => {
1351
+ sftp.end();
1352
+ reject(new ToolError("SFTP_ERROR", `Remote read failed: ${e.message}`, false));
1353
+ });
1354
+ stream.on("end", () => {
1355
+ sftp.end();
1356
+ if (received !== expectedSize) {
1357
+ return reject(new ToolError("SFTP_ERROR", `Remote read short: expected ${expectedSize} bytes, got ${received}`, false));
1358
+ }
1359
+ resolve(Buffer.concat(chunks, received));
1360
+ });
1361
+ });
1362
+ });
1363
+ }
1364
+ /**
1365
+ * Write a Buffer to an SFTP path (overwrites if exists).
1366
+ */
1367
+ async sftpWriteBuffer(client, remotePath, payload) {
1368
+ return new Promise((resolve, reject) => {
1369
+ client.sftp((err, sftp) => {
1370
+ if (err) {
1371
+ return reject(new ToolError("SFTP_ERROR", `SFTP open failed: ${err.message}`, true));
1372
+ }
1373
+ const writeStream = sftp.createWriteStream(remotePath);
1374
+ writeStream.on("close", () => {
1375
+ sftp.end();
1376
+ resolve();
1377
+ });
1378
+ writeStream.on("error", (e) => {
1379
+ sftp.end();
1380
+ reject(new ToolError("SFTP_ERROR", `File upload failed: ${e.message}`, false));
1381
+ });
1382
+ writeStream.end(payload);
1383
+ });
1384
+ });
1385
+ }
1386
+ /**
1387
+ * Download file
1388
+ */
1389
+ async download(remotePath, localPath, name) {
1390
+ const resolvedName = name || this.defaultName;
1391
+ const validatedLocalPath = this.validateLocalPath(localPath, resolvedName);
1392
+ const validatedRemotePath = this.validateRemotePath(remotePath, resolvedName);
1393
+ const client = await this.ensureConnected(resolvedName);
1394
+ return new Promise((resolve, reject) => {
1395
+ client.sftp((err, sftp) => {
1396
+ if (err) {
1397
+ return reject(new ToolError("SFTP_ERROR", `SFTP connection failed: ${err.message}`, true));
1398
+ }
1399
+ const readStream = sftp.createReadStream(validatedRemotePath);
1400
+ const writeStream = fs.createWriteStream(validatedLocalPath);
1401
+ const cleanup = () => {
1402
+ sftp.end();
1403
+ };
1404
+ writeStream.on("finish", () => {
1405
+ cleanup();
1406
+ resolve("File downloaded successfully");
1407
+ });
1408
+ writeStream.on("error", (err) => {
1409
+ cleanup();
1410
+ reject(new ToolError("LOCAL_FILE_WRITE_FAILED", `Failed to save file: ${err.message}`, false));
1411
+ });
1412
+ readStream.on("error", (err) => {
1413
+ cleanup();
1414
+ reject(new ToolError("SFTP_ERROR", `File download failed: ${err.message}`, false));
1415
+ });
1416
+ readStream.pipe(writeStream);
1417
+ });
1418
+ });
1419
+ }
1420
+ /**
1421
+ * Disconnect SSH connection
1422
+ */
1423
+ disconnect() {
1424
+ if (this.clients.size > 0) {
1425
+ for (const client of this.clients.values()) {
1426
+ client.end();
1427
+ }
1428
+ this.clients.clear();
1429
+ }
1430
+ }
1431
+ /**
1432
+ * Get basic information of all configured servers
1433
+ */
1434
+ getAllServerInfos() {
1435
+ return Object.keys(this.configs).map((key) => {
1436
+ const config = this.configs[key];
1437
+ const status = this.statusCache.get(key);
1438
+ return {
1439
+ name: key,
1440
+ host: config.host,
1441
+ port: config.port,
1442
+ username: config.username,
1443
+ connected: this.connected.get(key) === true,
1444
+ enabled: this.isServerEnabled(key),
1445
+ status: status,
1446
+ };
1447
+ });
1448
+ }
1449
+ /**
1450
+ * Refresh system status for a server (or all enabled servers)
1451
+ */
1452
+ async refreshStatus(name) {
1453
+ const results = {};
1454
+ const names = name
1455
+ ? [name]
1456
+ : (this.enabledServers ?? Object.keys(this.configs));
1457
+ await Promise.allSettled(names.map(async (key) => {
1458
+ try {
1459
+ const client = await this.ensureConnected(key);
1460
+ const status = await collectSystemStatus(client, key);
1461
+ this.statusCache.set(key, status);
1462
+ results[key] = status;
1463
+ }
1464
+ catch (error) {
1465
+ const fallback = {
1466
+ reachable: false,
1467
+ lastUpdated: new Date().toISOString(),
1468
+ };
1469
+ this.statusCache.set(key, fallback);
1470
+ results[key] = fallback;
1471
+ Logger.log(`Status refresh failed for [${key}]: ${error.message}`, "error");
1472
+ }
1473
+ }));
1474
+ return results;
1475
+ }
1476
+ /**
1477
+ * Transfer a file between two remote servers by piping SFTP streams
1478
+ * directly through the MCP host memory. No temp file, no SCP, no
1479
+ * authorized-key exchange between the two servers required -- each
1480
+ * side uses its own existing SSH session.
1481
+ * After the transfer, file sizes are compared via SFTP stat.
1482
+ * If both servers have md5sum, a hash verification is also performed.
1483
+ */
1484
+ async transferBetweenServers(sourceName, sourceRemotePath, destName, destRemotePath, options) {
1485
+ const validatedSourcePath = this.validateRemotePath(sourceRemotePath, sourceName);
1486
+ const validatedDestPath = this.validateRemotePath(destRemotePath, destName);
1487
+ const skipIfIdentical = options?.skipIfIdentical !== false; // default true
1488
+ const srcClient = await this.ensureConnected(sourceName);
1489
+ const dstClient = await this.ensureConnected(destName);
1490
+ const srcSftp = await this.openSftp(srcClient, "source");
1491
+ const dstSftp = await this.openSftp(dstClient, "dest");
1492
+ try {
1493
+ // Get source file size before transfer
1494
+ const srcStat = await this.sftpStat(srcSftp, validatedSourcePath, "source");
1495
+ // Skip-if-identical: same size on both sides AND matching md5sum (when
1496
+ // available on both). We never pull bytes through the MCP host for the
1497
+ // compare — if md5sum is missing on either side we just transfer.
1498
+ if (skipIfIdentical) {
1499
+ const dstStatProbe = await this.sftpStat(dstSftp, validatedDestPath, "dest")
1500
+ .catch(() => null);
1501
+ if (dstStatProbe && dstStatProbe.size === srcStat.size) {
1502
+ const [srcMd5, dstMd5] = await Promise.all([
1503
+ this.remoteMd5(srcClient, validatedSourcePath).catch(() => null),
1504
+ this.remoteMd5(dstClient, validatedDestPath).catch(() => null),
1505
+ ]);
1506
+ if (srcMd5 && dstMd5 && srcMd5 === dstMd5) {
1507
+ const srcConfig = this.getConfig(sourceName);
1508
+ const dstConfig = this.getConfig(destName);
1509
+ return (`Transfer skipped: destination already identical ` +
1510
+ `(size=${srcStat.size} bytes, md5=${srcMd5}). ` +
1511
+ `${srcConfig.username}@${srcConfig.host}:${validatedSourcePath}` +
1512
+ ` == ${dstConfig.username}@${dstConfig.host}:${validatedDestPath}`);
1513
+ }
1514
+ }
1515
+ }
1516
+ const readStream = srcSftp.createReadStream(validatedSourcePath);
1517
+ const writeStream = dstSftp.createWriteStream(validatedDestPath);
1518
+ // Bind the validated paths into the rest of the verification flow so
1519
+ // we never accidentally fall back to the un-validated originals.
1520
+ sourceRemotePath = validatedSourcePath;
1521
+ destRemotePath = validatedDestPath;
1522
+ await new Promise((resolve, reject) => {
1523
+ let settled = false;
1524
+ const settle = (err) => {
1525
+ if (settled)
1526
+ return;
1527
+ settled = true;
1528
+ err ? reject(err) : resolve();
1529
+ };
1530
+ writeStream.on("close", () => settle());
1531
+ writeStream.on("error", (err) => settle(new ToolError("SFTP_ERROR", `Dest write error: ${err.message}`, false)));
1532
+ readStream.on("error", (err) => settle(new ToolError("SFTP_ERROR", `Source read error: ${err.message}`, false)));
1533
+ readStream.pipe(writeStream);
1534
+ });
1535
+ // --- Verification ---
1536
+ const dstStat = await this.sftpStat(dstSftp, destRemotePath, "dest");
1537
+ const verification = [];
1538
+ // Size check
1539
+ if (srcStat.size !== dstStat.size) {
1540
+ throw new ToolError("SFTP_ERROR", `Transfer verification failed: size mismatch (source=${srcStat.size} bytes, dest=${dstStat.size} bytes)`, true);
1541
+ }
1542
+ verification.push(`size=${srcStat.size} bytes ✓`);
1543
+ // MD5 check (best-effort: if md5sum is available on both servers)
1544
+ const [srcMd5, dstMd5] = await Promise.all([
1545
+ this.remoteMd5(srcClient, sourceRemotePath).catch(() => null),
1546
+ this.remoteMd5(dstClient, destRemotePath).catch(() => null),
1547
+ ]);
1548
+ if (srcMd5 && dstMd5) {
1549
+ if (srcMd5 !== dstMd5) {
1550
+ throw new ToolError("SFTP_ERROR", `Transfer verification failed: MD5 mismatch (source=${srcMd5}, dest=${dstMd5})`, true);
1551
+ }
1552
+ verification.push(`md5=${srcMd5} ✓`);
1553
+ }
1554
+ const srcConfig = this.getConfig(sourceName);
1555
+ const dstConfig = this.getConfig(destName);
1556
+ return (`Transfer complete (streamed via SFTP, verified: ${verification.join(", ")}): ` +
1557
+ `${srcConfig.username}@${srcConfig.host}:${sourceRemotePath}` +
1558
+ ` → ${dstConfig.username}@${dstConfig.host}:${destRemotePath}`);
1559
+ }
1560
+ finally {
1561
+ srcSftp.end();
1562
+ dstSftp.end();
1563
+ }
1564
+ }
1565
+ /**
1566
+ * SFTP stat a remote file.
1567
+ */
1568
+ sftpStat(sftp, remotePath, label) {
1569
+ return new Promise((resolve, reject) => {
1570
+ sftp.stat(remotePath, (err, stats) => {
1571
+ if (err) {
1572
+ return reject(new ToolError("SFTP_ERROR", `Failed to stat ${label} file: ${err.message}`, false));
1573
+ }
1574
+ resolve({ size: stats.size });
1575
+ });
1576
+ });
1577
+ }
1578
+ /**
1579
+ * Compute MD5 of a remote file via ssh exec. Returns null if md5sum is unavailable.
1580
+ */
1581
+ remoteMd5(client, remotePath) {
1582
+ return new Promise((resolve, reject) => {
1583
+ client.exec(`md5sum ${this.shellQuote(remotePath)}`, (err, stream) => {
1584
+ if (err)
1585
+ return reject(err);
1586
+ let data = "";
1587
+ stream.on("data", (chunk) => { data += chunk.toString(); });
1588
+ stream.stderr.on("data", () => { });
1589
+ stream.on("close", (code) => {
1590
+ if (code !== 0)
1591
+ return reject(new Error("md5sum failed"));
1592
+ const hash = data.trim().split(/\s+/)[0];
1593
+ if (!hash || hash.length !== 32)
1594
+ return reject(new Error("unexpected md5sum output"));
1595
+ resolve(hash);
1596
+ });
1597
+ });
1598
+ });
1599
+ }
1600
+ /**
1601
+ * Minimal POSIX shell quoting for a file path.
1602
+ */
1603
+ shellQuote(s) {
1604
+ return "'" + s.replace(/'/g, "'\\''") + "'";
1605
+ }
1606
+ /**
1607
+ * Open an SFTP session from an existing SSH client.
1608
+ */
1609
+ openSftp(client, label) {
1610
+ return new Promise((resolve, reject) => {
1611
+ client.sftp((err, sftp) => {
1612
+ if (err) {
1613
+ return reject(new ToolError("SFTP_ERROR", `SFTP connection failed (${label}): ${err.message}`, true));
1614
+ }
1615
+ resolve(sftp);
1616
+ });
1617
+ });
1618
+ }
1619
+ /**
1620
+ * List remote files/directories via SFTP readdir
1621
+ */
1622
+ async listRemoteDir(remotePath, name) {
1623
+ const client = await this.ensureConnected(name);
1624
+ return new Promise((resolve, reject) => {
1625
+ client.sftp((err, sftp) => {
1626
+ if (err) {
1627
+ return reject(new ToolError("SFTP_ERROR", `SFTP connection failed: ${err.message}`, true));
1628
+ }
1629
+ sftp.readdir(remotePath, (err, list) => {
1630
+ sftp.end();
1631
+ if (err) {
1632
+ return reject(new ToolError("SFTP_ERROR", `Failed to list remote directory: ${err.message}`, false));
1633
+ }
1634
+ const entries = list.map((entry) => ({
1635
+ filename: entry.filename,
1636
+ isDirectory: (entry.attrs.mode & 0o40000) !== 0,
1637
+ size: entry.attrs.size,
1638
+ }));
1639
+ resolve(entries);
1640
+ });
1641
+ });
1642
+ });
1643
+ }
1644
+ /**
1645
+ * Upload a local directory recursively to a remote server
1646
+ */
1647
+ async uploadDirectory(localDir, remoteDir, name, options) {
1648
+ const resolvedName = name || this.defaultName;
1649
+ const resolvedLocal = this.validateLocalPath(localDir, resolvedName);
1650
+ const validatedRemoteDir = this.validateRemotePath(remoteDir, resolvedName);
1651
+ if (!fs.statSync(resolvedLocal).isDirectory()) {
1652
+ throw new ToolError("LOCAL_FILE_READ_FAILED", `Not a directory: ${localDir}`, false);
1653
+ }
1654
+ const results = [];
1655
+ const client = await this.ensureConnected(resolvedName);
1656
+ await this.sftpMkdirRecursive(client, validatedRemoteDir);
1657
+ const entries = fs.readdirSync(resolvedLocal, { withFileTypes: true });
1658
+ for (const entry of entries) {
1659
+ const localPath = path.join(localDir, entry.name);
1660
+ const remoteSub = `${validatedRemoteDir}/${entry.name}`;
1661
+ if (entry.isDirectory()) {
1662
+ const subResults = await this.uploadDirectory(localPath, remoteSub, resolvedName, options);
1663
+ results.push(...subResults);
1664
+ }
1665
+ else {
1666
+ await this.upload(localPath, remoteSub, resolvedName, options);
1667
+ results.push(remoteSub);
1668
+ }
1669
+ }
1670
+ return results;
1671
+ }
1672
+ /**
1673
+ * Download a remote directory recursively to a local path
1674
+ */
1675
+ async downloadDirectory(remoteDir, localDir, name) {
1676
+ const resolvedName = name || this.defaultName;
1677
+ const resolvedLocal = this.validateLocalPath(localDir, resolvedName);
1678
+ const validatedRemoteDir = this.validateRemotePath(remoteDir, resolvedName);
1679
+ if (!fs.existsSync(resolvedLocal)) {
1680
+ fs.mkdirSync(resolvedLocal, { recursive: true });
1681
+ }
1682
+ const results = [];
1683
+ const entries = await this.listRemoteDir(validatedRemoteDir, resolvedName);
1684
+ for (const entry of entries) {
1685
+ if (entry.filename === "." || entry.filename === "..")
1686
+ continue;
1687
+ const remotePath = `${validatedRemoteDir}/${entry.filename}`;
1688
+ const localPath = path.join(localDir, entry.filename);
1689
+ if (entry.isDirectory) {
1690
+ const subResults = await this.downloadDirectory(remotePath, localPath, resolvedName);
1691
+ results.push(...subResults);
1692
+ }
1693
+ else {
1694
+ await this.download(remotePath, localPath, resolvedName);
1695
+ results.push(localPath);
1696
+ }
1697
+ }
1698
+ return results;
1699
+ }
1700
+ /**
1701
+ * Create remote directory recursively via SFTP
1702
+ */
1703
+ async sftpMkdirRecursive(client, remotePath) {
1704
+ return new Promise((resolve, reject) => {
1705
+ client.sftp((err, sftp) => {
1706
+ if (err) {
1707
+ return reject(new ToolError("SFTP_ERROR", `SFTP connection failed: ${err.message}`, true));
1708
+ }
1709
+ const parts = remotePath.split("/").filter(Boolean);
1710
+ let current = "";
1711
+ const mkdirNext = (index) => {
1712
+ if (index >= parts.length) {
1713
+ sftp.end();
1714
+ return resolve();
1715
+ }
1716
+ current += "/" + parts[index];
1717
+ sftp.mkdir(current, (err) => {
1718
+ // EEXIST is fine
1719
+ mkdirNext(index + 1);
1720
+ });
1721
+ };
1722
+ mkdirNext(0);
1723
+ });
1724
+ });
1725
+ }
1726
+ }