@aaarc/handfree-ssh-mcp 1.0.6 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -10
- package/build/config/server.js +8 -6
- package/build/services/ssh-connection-manager.js +579 -191
- package/build/tools/download.js +15 -2
- package/build/tools/execute-command.js +1 -0
- package/build/tools/help.js +63 -27
- package/build/tools/transfer.js +13 -6
- package/build/tools/upload.js +13 -1
- package/package.json +1 -1
|
@@ -125,10 +125,7 @@ export class SSHConnectionManager {
|
|
|
125
125
|
toReset.add(name);
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
-
|
|
129
|
-
this.closeClient(name, true);
|
|
130
|
-
this.statusCache.delete(name);
|
|
131
|
-
}
|
|
128
|
+
this.closeClientSet(toReset, true);
|
|
132
129
|
this.setConfig(configs, enabledServers);
|
|
133
130
|
Logger.log(`Hot-reloaded SSH config: ${Object.keys(configs).length} server(s), ` +
|
|
134
131
|
`${toReset.size} connection(s) reset`, "info");
|
|
@@ -197,6 +194,12 @@ export class SSHConnectionManager {
|
|
|
197
194
|
this.connected.set(name, false);
|
|
198
195
|
this.connecting.delete(name);
|
|
199
196
|
}
|
|
197
|
+
closeClientSet(names, bumpGeneration = true) {
|
|
198
|
+
for (const name of names) {
|
|
199
|
+
this.closeClient(name, bumpGeneration);
|
|
200
|
+
this.statusCache.delete(name);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
200
203
|
closeConnection(name) {
|
|
201
204
|
const key = this.resolveServer(name);
|
|
202
205
|
this.getConfig(key);
|
|
@@ -209,10 +212,7 @@ export class SSHConnectionManager {
|
|
|
209
212
|
affected.add(target);
|
|
210
213
|
}
|
|
211
214
|
}
|
|
212
|
-
|
|
213
|
-
this.closeClient(target, true);
|
|
214
|
-
this.statusCache.delete(target);
|
|
215
|
-
}
|
|
215
|
+
this.closeClientSet(affected, true);
|
|
216
216
|
return {
|
|
217
217
|
requested: key,
|
|
218
218
|
closed: Array.from(affected),
|
|
@@ -496,15 +496,34 @@ export class SSHConnectionManager {
|
|
|
496
496
|
}
|
|
497
497
|
this.clients.set(key, client);
|
|
498
498
|
}
|
|
499
|
+
async acquireSshClient(key, options = {}) {
|
|
500
|
+
const reuseConnection = options.reuseConnection !== false;
|
|
501
|
+
const purpose = options.purpose ?? "command";
|
|
502
|
+
if (reuseConnection) {
|
|
503
|
+
const client = await this.ensureConnected(key, options.timeout);
|
|
504
|
+
options.debug?.(`[mcp] using cached SSH connection for [${key}]; set reuseConnection=false to capture SSH handshake debug`);
|
|
505
|
+
return { client, close: () => { } };
|
|
506
|
+
}
|
|
507
|
+
if (purpose === "command") {
|
|
508
|
+
return this.connectCommandClient(key, options.timeout ?? 30000, options.debug);
|
|
509
|
+
}
|
|
510
|
+
return this.connectOneShotClient(key, options.timeout ?? 30000, options.debug, purpose);
|
|
511
|
+
}
|
|
499
512
|
/**
|
|
500
|
-
*
|
|
501
|
-
* Used when the caller disables connection reuse after a timeout or when a
|
|
502
|
-
* fresh TCP/SSH handshake is more important than latency.
|
|
513
|
+
* Compatibility wrapper for tests and existing command call sites.
|
|
503
514
|
*/
|
|
504
515
|
async connectCommandClient(key, timeout, debug) {
|
|
516
|
+
return this.connectOneShotClient(key, timeout, debug, "command");
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Open a one-shot SSH client for a single operation. Used when the caller
|
|
520
|
+
* disables connection reuse after a timeout or when a fresh TCP/SSH
|
|
521
|
+
* handshake is more important than latency.
|
|
522
|
+
*/
|
|
523
|
+
async connectOneShotClient(key, timeout, debug, purpose = "command") {
|
|
505
524
|
const config = this.getConfig(key);
|
|
506
525
|
const client = new Client();
|
|
507
|
-
const jumpChainKey = `
|
|
526
|
+
const jumpChainKey = `__${purpose}__:${key}:${Date.now()}:${crypto.randomBytes(4).toString("hex")}`;
|
|
508
527
|
const connectTimeout = this.normalizeConnectTimeout(timeout) ?? 30000;
|
|
509
528
|
let settled = false;
|
|
510
529
|
let closed = false;
|
|
@@ -513,7 +532,7 @@ export class SSHConnectionManager {
|
|
|
513
532
|
return;
|
|
514
533
|
}
|
|
515
534
|
debug?.(`[mcp] one-shot SSH client emitted error after ready/settle: ${err.message}`);
|
|
516
|
-
Logger.log(`One-shot SSH
|
|
535
|
+
Logger.log(`One-shot SSH ${purpose} client [${key}] emitted error after settle: ${err.message}`, "error");
|
|
517
536
|
};
|
|
518
537
|
client.on("error", onLateError);
|
|
519
538
|
const close = () => {
|
|
@@ -560,11 +579,11 @@ export class SSHConnectionManager {
|
|
|
560
579
|
}
|
|
561
580
|
this.teardownJumpChain(jumpChainKey);
|
|
562
581
|
});
|
|
563
|
-
Logger.log(`Using one-shot jump host '${config.jumpHost}' for
|
|
582
|
+
Logger.log(`Using one-shot jump host '${config.jumpHost}' for ${purpose} on [${key}]`, "info");
|
|
564
583
|
}
|
|
565
584
|
catch (err) {
|
|
566
585
|
this.teardownJumpChain(jumpChainKey);
|
|
567
|
-
throw new ToolError("SSH_CONNECTION_FAILED", `Failed to open one-shot jump tunnel for [${key}] via '${config.jumpHost}': ${err.message}`, true);
|
|
586
|
+
throw new ToolError("SSH_CONNECTION_FAILED", `Failed to open one-shot jump tunnel for ${purpose} [${key}] via '${config.jumpHost}': ${err.message}`, true);
|
|
568
587
|
}
|
|
569
588
|
}
|
|
570
589
|
if (config.socksProxy) {
|
|
@@ -611,7 +630,7 @@ export class SSHConnectionManager {
|
|
|
611
630
|
sshConfig.password = config.password;
|
|
612
631
|
}
|
|
613
632
|
else if (agent || config.authOptional) {
|
|
614
|
-
Logger.log(`Using SSH agent/default authentication for one-shot
|
|
633
|
+
Logger.log(`Using SSH agent/default authentication for one-shot ${purpose} [${key}]`, "info");
|
|
615
634
|
}
|
|
616
635
|
else {
|
|
617
636
|
throw new ToolError("SSH_AUTHENTICATION_MISSING", `No valid authentication method provided for [${key}] (password or private key)`, false);
|
|
@@ -631,19 +650,19 @@ export class SSHConnectionManager {
|
|
|
631
650
|
resolve();
|
|
632
651
|
};
|
|
633
652
|
const onReady = () => done();
|
|
634
|
-
const onError = (err) => done(new ToolError("SSH_CONNECTION_FAILED", `SSH
|
|
635
|
-
const onClose = () => done(new ToolError("SSH_CONNECTION_FAILED", `SSH
|
|
653
|
+
const onError = (err) => done(new ToolError("SSH_CONNECTION_FAILED", `SSH ${purpose} connection [${key}] failed: ${err.message}`, true));
|
|
654
|
+
const onClose = () => done(new ToolError("SSH_CONNECTION_FAILED", `SSH ${purpose} connection [${key}] closed before ready`, true));
|
|
636
655
|
const timeoutId = setTimeout(() => {
|
|
637
|
-
done(new ToolError("SSH_CONNECTION_FAILED", `SSH
|
|
656
|
+
done(new ToolError("SSH_CONNECTION_FAILED", `SSH ${purpose} connection [${key}] timed out after ${connectTimeout}ms`, true));
|
|
638
657
|
close();
|
|
639
658
|
}, connectTimeout);
|
|
640
|
-
debug?.(`[mcp] opening one-shot SSH connection for [${key}]`);
|
|
659
|
+
debug?.(`[mcp] opening one-shot SSH ${purpose} connection for [${key}]`);
|
|
641
660
|
client.once("ready", onReady);
|
|
642
661
|
client.once("error", onError);
|
|
643
662
|
client.once("close", onClose);
|
|
644
663
|
client.connect(sshConfig);
|
|
645
664
|
});
|
|
646
|
-
Logger.log(`Opened one-shot SSH
|
|
665
|
+
Logger.log(`Opened one-shot SSH ${purpose} connection for [${key}]`, "info");
|
|
647
666
|
return { client, close };
|
|
648
667
|
}
|
|
649
668
|
catch (err) {
|
|
@@ -700,7 +719,63 @@ export class SSHConnectionManager {
|
|
|
700
719
|
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
|
|
701
720
|
return undefined;
|
|
702
721
|
}
|
|
703
|
-
return Math.max(1,
|
|
722
|
+
return Math.max(1, timeout);
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Run a transfer that reports progress through a callback, aborting it if no
|
|
726
|
+
* progress arrives within `timeoutMs` (an INACTIVITY watchdog, not a total
|
|
727
|
+
* duration cap -- a long but actively-streaming transfer is never killed).
|
|
728
|
+
* When `timeoutMs` is falsy the operation runs unbounded, preserving the
|
|
729
|
+
* previous behavior. `onTimeout` is invoked to tear down the stalled session.
|
|
730
|
+
*/
|
|
731
|
+
runWithInactivityTimeout(start, timeoutMs, description, debug, onTimeout) {
|
|
732
|
+
if (!timeoutMs || timeoutMs <= 0) {
|
|
733
|
+
return start(() => { });
|
|
734
|
+
}
|
|
735
|
+
return new Promise((resolve, reject) => {
|
|
736
|
+
let settled = false;
|
|
737
|
+
let timer = null;
|
|
738
|
+
const clear = () => {
|
|
739
|
+
if (timer) {
|
|
740
|
+
clearTimeout(timer);
|
|
741
|
+
timer = null;
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
const arm = () => {
|
|
745
|
+
clear();
|
|
746
|
+
timer = setTimeout(() => {
|
|
747
|
+
if (settled)
|
|
748
|
+
return;
|
|
749
|
+
settled = true;
|
|
750
|
+
debug?.(`[mcp] ${description} stalled: no progress for ${timeoutMs}ms`);
|
|
751
|
+
try {
|
|
752
|
+
onTimeout?.();
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
// Ignore cleanup failures after a stall.
|
|
756
|
+
}
|
|
757
|
+
reject(new ToolError("SSH_CONNECTION_FAILED", `${description} stalled: no progress for ${timeoutMs}ms`, true));
|
|
758
|
+
}, timeoutMs);
|
|
759
|
+
};
|
|
760
|
+
const onProgress = () => {
|
|
761
|
+
if (!settled)
|
|
762
|
+
arm();
|
|
763
|
+
};
|
|
764
|
+
arm();
|
|
765
|
+
start(onProgress).then((value) => {
|
|
766
|
+
if (settled)
|
|
767
|
+
return;
|
|
768
|
+
settled = true;
|
|
769
|
+
clear();
|
|
770
|
+
resolve(value);
|
|
771
|
+
}, (err) => {
|
|
772
|
+
if (settled)
|
|
773
|
+
return;
|
|
774
|
+
settled = true;
|
|
775
|
+
clear();
|
|
776
|
+
reject(err);
|
|
777
|
+
});
|
|
778
|
+
});
|
|
704
779
|
}
|
|
705
780
|
/**
|
|
706
781
|
* Open a TCP tunnel from the target's jump chain to `config.host:config.port`
|
|
@@ -994,6 +1069,31 @@ export class SSHConnectionManager {
|
|
|
994
1069
|
msg.includes("no response from server") ||
|
|
995
1070
|
msg.includes("timed out"));
|
|
996
1071
|
}
|
|
1072
|
+
makeSftpError(context, error) {
|
|
1073
|
+
const connectionFailure = this.isConnectionShapedMessage(error.message);
|
|
1074
|
+
return new ToolError(connectionFailure ? "SSH_CONNECTION_FAILED" : "SFTP_ERROR", `${context}: ${error.message}`, connectionFailure);
|
|
1075
|
+
}
|
|
1076
|
+
createSftpTransferOptions(options) {
|
|
1077
|
+
const transferOptions = {};
|
|
1078
|
+
const concurrency = this.optionalPositiveInteger(options?.sftpConcurrency, "sftpConcurrency");
|
|
1079
|
+
const chunkSize = this.optionalPositiveInteger(options?.chunkSize, "chunkSize");
|
|
1080
|
+
if (concurrency !== undefined) {
|
|
1081
|
+
transferOptions.concurrency = concurrency;
|
|
1082
|
+
}
|
|
1083
|
+
if (chunkSize !== undefined) {
|
|
1084
|
+
transferOptions.chunkSize = chunkSize;
|
|
1085
|
+
}
|
|
1086
|
+
return transferOptions;
|
|
1087
|
+
}
|
|
1088
|
+
optionalPositiveInteger(value, name) {
|
|
1089
|
+
if (value === undefined) {
|
|
1090
|
+
return undefined;
|
|
1091
|
+
}
|
|
1092
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
1093
|
+
throw new ToolError("INVALID_CONFIGURATION", `${name} must be a positive integer`, false);
|
|
1094
|
+
}
|
|
1095
|
+
return value;
|
|
1096
|
+
}
|
|
997
1097
|
/**
|
|
998
1098
|
* Force reconnect to SSH server
|
|
999
1099
|
* @private
|
|
@@ -1262,12 +1362,29 @@ export class SSHConnectionManager {
|
|
|
1262
1362
|
return new Promise((resolve, reject) => {
|
|
1263
1363
|
let timeoutId = null;
|
|
1264
1364
|
let settled = false;
|
|
1265
|
-
|
|
1365
|
+
let channelOpened = false;
|
|
1366
|
+
let activeStream = null;
|
|
1367
|
+
const eventedClient = client;
|
|
1368
|
+
const canObserveClientLifecycle = typeof eventedClient.once === "function" &&
|
|
1369
|
+
typeof eventedClient.removeListener === "function";
|
|
1370
|
+
const clearCommandTimer = () => {
|
|
1266
1371
|
if (timeoutId) {
|
|
1267
1372
|
clearTimeout(timeoutId);
|
|
1268
1373
|
timeoutId = null;
|
|
1269
1374
|
}
|
|
1270
1375
|
};
|
|
1376
|
+
const removeClientLifecycleListeners = () => {
|
|
1377
|
+
if (!canObserveClientLifecycle) {
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
eventedClient.removeListener?.("error", onClientError);
|
|
1381
|
+
eventedClient.removeListener?.("end", onClientEnd);
|
|
1382
|
+
eventedClient.removeListener?.("close", onClientClose);
|
|
1383
|
+
};
|
|
1384
|
+
const cleanup = () => {
|
|
1385
|
+
clearCommandTimer();
|
|
1386
|
+
removeClientLifecycleListeners();
|
|
1387
|
+
};
|
|
1271
1388
|
const settle = (err, code) => {
|
|
1272
1389
|
if (settled)
|
|
1273
1390
|
return;
|
|
@@ -1286,8 +1403,26 @@ export class SSHConnectionManager {
|
|
|
1286
1403
|
// Ignore late-stream close errors after the promise has settled.
|
|
1287
1404
|
}
|
|
1288
1405
|
};
|
|
1406
|
+
const failOnClientEvent = (event, message) => {
|
|
1407
|
+
const phase = channelOpened ? "running command" : "opening command channel";
|
|
1408
|
+
const detail = message ? `: ${message}` : "";
|
|
1409
|
+
sinks.debug?.(`[mcp] SSH client ${event} while ${phase}${detail}`);
|
|
1410
|
+
if (activeStream) {
|
|
1411
|
+
closeLateStream(activeStream);
|
|
1412
|
+
}
|
|
1413
|
+
settle(new ToolError("SSH_CONNECTION_FAILED", `SSH connection ${event} while ${phase}${detail}`, true));
|
|
1414
|
+
};
|
|
1415
|
+
const onClientError = (err) => {
|
|
1416
|
+
failOnClientEvent("failed", err.message);
|
|
1417
|
+
};
|
|
1418
|
+
const onClientEnd = () => {
|
|
1419
|
+
failOnClientEvent("ended");
|
|
1420
|
+
};
|
|
1421
|
+
const onClientClose = () => {
|
|
1422
|
+
failOnClientEvent("closed");
|
|
1423
|
+
};
|
|
1289
1424
|
const armExecOpenTimeout = () => {
|
|
1290
|
-
const execOpenTimeout = Math.max(1,
|
|
1425
|
+
const execOpenTimeout = Math.max(1, timeout);
|
|
1291
1426
|
timeoutId = setTimeout(() => {
|
|
1292
1427
|
sinks.debug?.(`[mcp] exec channel open timed out after ${execOpenTimeout}ms`);
|
|
1293
1428
|
settle(new ToolError("SSH_CONNECTION_FAILED", `SSH exec channel timeout: no response from server within ${execOpenTimeout}ms while opening command channel`, true));
|
|
@@ -1312,6 +1447,11 @@ export class SSHConnectionManager {
|
|
|
1312
1447
|
}, timeout);
|
|
1313
1448
|
};
|
|
1314
1449
|
armExecOpenTimeout();
|
|
1450
|
+
if (canObserveClientLifecycle) {
|
|
1451
|
+
eventedClient.once?.("error", onClientError);
|
|
1452
|
+
eventedClient.once?.("end", onClientEnd);
|
|
1453
|
+
eventedClient.once?.("close", onClientClose);
|
|
1454
|
+
}
|
|
1315
1455
|
sinks.debug?.(`[mcp] opening exec channel for command: ${cmdString}`);
|
|
1316
1456
|
try {
|
|
1317
1457
|
client.exec(cmdString, (err, stream) => {
|
|
@@ -1327,7 +1467,9 @@ export class SSHConnectionManager {
|
|
|
1327
1467
|
settle(new ToolError(code, `Command execution error: ${err.message}`, isConnectionFailure));
|
|
1328
1468
|
return;
|
|
1329
1469
|
}
|
|
1330
|
-
|
|
1470
|
+
clearCommandTimer();
|
|
1471
|
+
channelOpened = true;
|
|
1472
|
+
activeStream = stream;
|
|
1331
1473
|
sinks.debug?.("[mcp] exec channel opened");
|
|
1332
1474
|
stream.on("data", (chunk) => {
|
|
1333
1475
|
sinks.stdoutCollector?.push(chunk);
|
|
@@ -1383,24 +1525,18 @@ export class SSHConnectionManager {
|
|
|
1383
1525
|
};
|
|
1384
1526
|
}
|
|
1385
1527
|
appendDebugOutput(result, collector) {
|
|
1386
|
-
|
|
1528
|
+
const debugBlock = this.formatDebugBlock(collector);
|
|
1529
|
+
if (!debugBlock) {
|
|
1387
1530
|
return result;
|
|
1388
1531
|
}
|
|
1389
|
-
|
|
1390
|
-
const header = snapshot.truncated
|
|
1391
|
-
? `[SSH DEBUG TRUNCATED: dropped ${snapshot.droppedBytes} bytes]\n`
|
|
1392
|
-
: "[SSH DEBUG]\n";
|
|
1393
|
-
return `${result}\n\n${header}${snapshot.tail.toString("utf8").trimEnd()}`;
|
|
1532
|
+
return `${result}\n\n${debugBlock}`;
|
|
1394
1533
|
}
|
|
1395
1534
|
appendDebugToError(error, collector) {
|
|
1396
|
-
|
|
1535
|
+
const debugBlock = this.formatDebugBlock(collector);
|
|
1536
|
+
if (!debugBlock) {
|
|
1397
1537
|
return error;
|
|
1398
1538
|
}
|
|
1399
|
-
const
|
|
1400
|
-
const header = snapshot.truncated
|
|
1401
|
-
? `[SSH DEBUG TRUNCATED: dropped ${snapshot.droppedBytes} bytes]\n`
|
|
1402
|
-
: "[SSH DEBUG]\n";
|
|
1403
|
-
const message = `${error.message}\n\n${header}${snapshot.tail.toString("utf8").trimEnd()}`;
|
|
1539
|
+
const message = `${error.message}\n\n${debugBlock}`;
|
|
1404
1540
|
if (error instanceof ToolError) {
|
|
1405
1541
|
return new ToolError(error.code, message, error.retriable);
|
|
1406
1542
|
}
|
|
@@ -1408,6 +1544,40 @@ export class SSHConnectionManager {
|
|
|
1408
1544
|
wrapped.name = error.name;
|
|
1409
1545
|
return wrapped;
|
|
1410
1546
|
}
|
|
1547
|
+
formatDebugBlock(collector) {
|
|
1548
|
+
if (!collector || collector.getTotalBytes() === 0) {
|
|
1549
|
+
return null;
|
|
1550
|
+
}
|
|
1551
|
+
const snapshot = collector.getSnapshot();
|
|
1552
|
+
const header = snapshot.truncated
|
|
1553
|
+
? `[SSH DEBUG TRUNCATED: dropped ${snapshot.droppedBytes} bytes]\n`
|
|
1554
|
+
: "[SSH DEBUG]\n";
|
|
1555
|
+
return `${header}${snapshot.tail.toString("utf8").trimEnd()}`;
|
|
1556
|
+
}
|
|
1557
|
+
unpipeStream(readStream, writeStream) {
|
|
1558
|
+
const candidate = readStream;
|
|
1559
|
+
if (typeof candidate.unpipe !== "function") {
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
try {
|
|
1563
|
+
candidate.unpipe(writeStream);
|
|
1564
|
+
}
|
|
1565
|
+
catch {
|
|
1566
|
+
// Ignore cleanup errors after the original stream failure.
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
destroyStream(stream) {
|
|
1570
|
+
const candidate = stream;
|
|
1571
|
+
if (typeof candidate.destroy !== "function") {
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
try {
|
|
1575
|
+
candidate.destroy();
|
|
1576
|
+
}
|
|
1577
|
+
catch {
|
|
1578
|
+
// Ignore cleanup errors after the original stream failure.
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1411
1581
|
/**
|
|
1412
1582
|
* Assemble the final user-visible result string from collected tails.
|
|
1413
1583
|
* Adds a truncation header (with the on-disk log path) and the legacy
|
|
@@ -1480,13 +1650,13 @@ export class SSHConnectionManager {
|
|
|
1480
1650
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1481
1651
|
try {
|
|
1482
1652
|
debug?.(`[mcp] command attempt ${attempt + 1}/${maxRetries + 1} on [${key}], reuseConnection=${reuseConnection}`);
|
|
1483
|
-
const commandConnection =
|
|
1484
|
-
|
|
1485
|
-
|
|
1653
|
+
const commandConnection = await this.acquireSshClient(key, {
|
|
1654
|
+
reuseConnection,
|
|
1655
|
+
timeout,
|
|
1656
|
+
debug,
|
|
1657
|
+
purpose: "command",
|
|
1658
|
+
});
|
|
1486
1659
|
const client = commandConnection.client;
|
|
1487
|
-
if (reuseConnection) {
|
|
1488
|
-
debug?.(`[mcp] using cached SSH connection for [${key}]; set reuseConnection=false to capture SSH handshake debug`);
|
|
1489
|
-
}
|
|
1490
1660
|
// Per-attempt collectors + log writer. We rebuild them on each retry
|
|
1491
1661
|
// so partial output from a failed attempt is not mixed into the next.
|
|
1492
1662
|
const stdoutCollector = new OutputCollector(maxOutputBytes);
|
|
@@ -1573,13 +1743,13 @@ export class SSHConnectionManager {
|
|
|
1573
1743
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1574
1744
|
try {
|
|
1575
1745
|
debug?.(`[mcp] streaming command attempt ${attempt + 1}/${maxRetries + 1} on [${key}], reuseConnection=${reuseConnection}`);
|
|
1576
|
-
const commandConnection =
|
|
1577
|
-
|
|
1578
|
-
|
|
1746
|
+
const commandConnection = await this.acquireSshClient(key, {
|
|
1747
|
+
reuseConnection,
|
|
1748
|
+
timeout,
|
|
1749
|
+
debug,
|
|
1750
|
+
purpose: "command",
|
|
1751
|
+
});
|
|
1579
1752
|
const client = commandConnection.client;
|
|
1580
|
-
if (reuseConnection) {
|
|
1581
|
-
debug?.(`[mcp] using cached SSH connection for [${key}]; set reuseConnection=false to capture SSH handshake debug`);
|
|
1582
|
-
}
|
|
1583
1753
|
// Per-attempt collectors + log writer. onProgress keeps streaming
|
|
1584
1754
|
// every byte live; only the final returned string is capped.
|
|
1585
1755
|
const stdoutCollector = new OutputCollector(maxOutputBytes);
|
|
@@ -1725,6 +1895,8 @@ export class SSHConnectionManager {
|
|
|
1725
1895
|
*/
|
|
1726
1896
|
async upload(localPath, remotePath, name, options) {
|
|
1727
1897
|
const resolvedName = name || this.defaultName;
|
|
1898
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
1899
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
1728
1900
|
const validatedLocalPath = this.validateLocalPath(localPath, resolvedName);
|
|
1729
1901
|
const validatedRemotePath = this.validateRemotePath(remotePath, resolvedName);
|
|
1730
1902
|
const skipIfIdentical = options?.skipIfIdentical !== false; // default true
|
|
@@ -1740,32 +1912,67 @@ export class SSHConnectionManager {
|
|
|
1740
1912
|
throw new ToolError("LOCAL_FILE_READ_FAILED", `Local path '${validatedLocalPath}' is not a regular file (size=${stat.size}). ` +
|
|
1741
1913
|
`Use uploadDirectory / recursive=true for directories.`, false);
|
|
1742
1914
|
}
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1915
|
+
const isShellScript = SSHConnectionManager.SHELL_SCRIPT_EXTENSIONS.has(path.extname(validatedLocalPath).toLowerCase());
|
|
1916
|
+
const fastUpload = options?.fast === true;
|
|
1917
|
+
const mustReadPayload = skipIfIdentical || !fastUpload || isShellScript;
|
|
1918
|
+
let payload = null;
|
|
1919
|
+
let crlfFixed = {
|
|
1920
|
+
buffer: Buffer.alloc(0),
|
|
1921
|
+
fixed: false,
|
|
1922
|
+
replacedCount: 0,
|
|
1923
|
+
};
|
|
1924
|
+
if (mustReadPayload) {
|
|
1925
|
+
try {
|
|
1926
|
+
payload = fs.readFileSync(validatedLocalPath);
|
|
1927
|
+
}
|
|
1928
|
+
catch (e) {
|
|
1929
|
+
throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read local file '${validatedLocalPath}': ${e.message}`, false);
|
|
1930
|
+
}
|
|
1931
|
+
// ---- CRLF auto-fix for shell scripts ----
|
|
1932
|
+
crlfFixed = SSHConnectionManager.maybeFixShellScriptLineEndings(validatedLocalPath, payload);
|
|
1933
|
+
payload = crlfFixed.buffer;
|
|
1749
1934
|
}
|
|
1750
|
-
// ---- CRLF auto-fix for shell scripts ----
|
|
1751
|
-
const crlfFixed = SSHConnectionManager.maybeFixShellScriptLineEndings(validatedLocalPath, payload);
|
|
1752
|
-
payload = crlfFixed.buffer;
|
|
1753
1935
|
const crlfNote = crlfFixed.fixed
|
|
1754
1936
|
? ` (CRLF→LF auto-fix: converted ${crlfFixed.replacedCount} line endings to LF before upload because target is a shell script).`
|
|
1755
1937
|
: "";
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1938
|
+
debug?.(`[mcp] sftp upload on [${resolvedName}], reuseConnection=${reuseConnection}`);
|
|
1939
|
+
let connection = null;
|
|
1940
|
+
try {
|
|
1941
|
+
connection = await this.acquireSshClient(resolvedName, {
|
|
1942
|
+
reuseConnection,
|
|
1943
|
+
timeout: options?.timeout,
|
|
1944
|
+
debug,
|
|
1945
|
+
purpose: "sftp",
|
|
1946
|
+
});
|
|
1947
|
+
const client = connection.client;
|
|
1948
|
+
// ---- Skip-if-identical check ----
|
|
1949
|
+
if (skipIfIdentical) {
|
|
1950
|
+
const decision = await this.shouldSkipUpload(client, payload, validatedRemotePath, isShellScript, options?.timeout, debug);
|
|
1951
|
+
if (decision.skip) {
|
|
1952
|
+
return this.appendDebugOutput(`Upload skipped: remote file '${validatedRemotePath}' is already identical to local ` +
|
|
1953
|
+
`'${validatedLocalPath}' (${decision.reason}).${crlfNote}`, debugCollector);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
// ---- Actually upload ----
|
|
1957
|
+
if (fastUpload && !crlfFixed.fixed) {
|
|
1958
|
+
await this.sftpFastPut(client, validatedLocalPath, validatedRemotePath, options, options?.timeout, debug);
|
|
1959
|
+
}
|
|
1960
|
+
else {
|
|
1961
|
+
await this.sftpWriteBuffer(client, validatedRemotePath, payload, options?.timeout, debug);
|
|
1962
|
+
}
|
|
1963
|
+
const uploadedBytes = payload?.length ?? stat.size;
|
|
1964
|
+
const modeNote = fastUpload && !crlfFixed.fixed ? " via fast SFTP" : "";
|
|
1965
|
+
return this.appendDebugOutput(`File uploaded successfully (${uploadedBytes} bytes${modeNote})${crlfNote}`, debugCollector);
|
|
1966
|
+
}
|
|
1967
|
+
catch (error) {
|
|
1968
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
1969
|
+
this.closeClient(resolvedName, true);
|
|
1764
1970
|
}
|
|
1971
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
1972
|
+
}
|
|
1973
|
+
finally {
|
|
1974
|
+
connection?.close();
|
|
1765
1975
|
}
|
|
1766
|
-
// ---- Actually upload ----
|
|
1767
|
-
await this.sftpWriteBuffer(client, validatedRemotePath, payload);
|
|
1768
|
-
return `File uploaded successfully (${payload.length} bytes)${crlfNote}`;
|
|
1769
1976
|
}
|
|
1770
1977
|
/**
|
|
1771
1978
|
* Threshold above which we use MD5 hash comparison instead of byte-content
|
|
@@ -1822,10 +2029,10 @@ export class SSHConnectionManager {
|
|
|
1822
2029
|
* Any error during the check is treated as 'don't skip' (i.e. fall through
|
|
1823
2030
|
* to a normal upload), since correctness wins over an optimization.
|
|
1824
2031
|
*/
|
|
1825
|
-
async shouldSkipUpload(client, localPayload, remotePath, lineEndingAgnostic) {
|
|
2032
|
+
async shouldSkipUpload(client, localPayload, remotePath, lineEndingAgnostic, timeout, debug) {
|
|
1826
2033
|
let remoteSize;
|
|
1827
2034
|
try {
|
|
1828
|
-
const sftp = await this.openSftp(client, "dest");
|
|
2035
|
+
const sftp = await this.openSftp(client, "dest", timeout, debug);
|
|
1829
2036
|
try {
|
|
1830
2037
|
const stat = await this.sftpStat(sftp, remotePath, "dest");
|
|
1831
2038
|
remoteSize = stat.size;
|
|
@@ -1846,7 +2053,7 @@ export class SSHConnectionManager {
|
|
|
1846
2053
|
}
|
|
1847
2054
|
let remoteBuf;
|
|
1848
2055
|
try {
|
|
1849
|
-
remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize);
|
|
2056
|
+
remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize, timeout, debug);
|
|
1850
2057
|
}
|
|
1851
2058
|
catch {
|
|
1852
2059
|
return { skip: false, reason: "remote-read-failed-during-content-compare" };
|
|
@@ -1869,7 +2076,7 @@ export class SSHConnectionManager {
|
|
|
1869
2076
|
// Byte-content compare
|
|
1870
2077
|
let remoteBuf;
|
|
1871
2078
|
try {
|
|
1872
|
-
remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize);
|
|
2079
|
+
remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize, timeout, debug);
|
|
1873
2080
|
}
|
|
1874
2081
|
catch {
|
|
1875
2082
|
return { skip: false, reason: "remote-read-failed-during-content-compare" };
|
|
@@ -1905,88 +2112,166 @@ export class SSHConnectionManager {
|
|
|
1905
2112
|
/**
|
|
1906
2113
|
* Read an SFTP file fully into a Buffer.
|
|
1907
2114
|
*/
|
|
1908
|
-
async sftpReadBuffer(client, remotePath, expectedSize) {
|
|
2115
|
+
async sftpReadBuffer(client, remotePath, expectedSize, timeout, debug) {
|
|
2116
|
+
const sftp = await this.openSftp(client, "read", timeout, debug);
|
|
1909
2117
|
return new Promise((resolve, reject) => {
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
2118
|
+
const chunks = [];
|
|
2119
|
+
let received = 0;
|
|
2120
|
+
const stream = sftp.createReadStream(remotePath);
|
|
2121
|
+
stream.on("data", (chunk) => {
|
|
2122
|
+
chunks.push(chunk);
|
|
2123
|
+
received += chunk.length;
|
|
2124
|
+
});
|
|
2125
|
+
stream.on("error", (e) => {
|
|
2126
|
+
sftp.end();
|
|
2127
|
+
reject(this.makeSftpError("Remote read failed", e));
|
|
2128
|
+
});
|
|
2129
|
+
stream.on("end", () => {
|
|
2130
|
+
sftp.end();
|
|
2131
|
+
if (received !== expectedSize) {
|
|
2132
|
+
return reject(new ToolError("SFTP_ERROR", `Remote read short: expected ${expectedSize} bytes, got ${received}`, false));
|
|
1913
2133
|
}
|
|
1914
|
-
|
|
1915
|
-
let received = 0;
|
|
1916
|
-
const stream = sftp.createReadStream(remotePath);
|
|
1917
|
-
stream.on("data", (chunk) => {
|
|
1918
|
-
chunks.push(chunk);
|
|
1919
|
-
received += chunk.length;
|
|
1920
|
-
});
|
|
1921
|
-
stream.on("error", (e) => {
|
|
1922
|
-
sftp.end();
|
|
1923
|
-
reject(new ToolError("SFTP_ERROR", `Remote read failed: ${e.message}`, false));
|
|
1924
|
-
});
|
|
1925
|
-
stream.on("end", () => {
|
|
1926
|
-
sftp.end();
|
|
1927
|
-
if (received !== expectedSize) {
|
|
1928
|
-
return reject(new ToolError("SFTP_ERROR", `Remote read short: expected ${expectedSize} bytes, got ${received}`, false));
|
|
1929
|
-
}
|
|
1930
|
-
resolve(Buffer.concat(chunks, received));
|
|
1931
|
-
});
|
|
2134
|
+
resolve(Buffer.concat(chunks, received));
|
|
1932
2135
|
});
|
|
1933
2136
|
});
|
|
1934
2137
|
}
|
|
1935
2138
|
/**
|
|
1936
2139
|
* Write a Buffer to an SFTP path (overwrites if exists).
|
|
1937
2140
|
*/
|
|
1938
|
-
async sftpWriteBuffer(client, remotePath, payload) {
|
|
2141
|
+
async sftpWriteBuffer(client, remotePath, payload, timeout, debug) {
|
|
2142
|
+
const sftp = await this.openSftp(client, "write", timeout, debug);
|
|
1939
2143
|
return new Promise((resolve, reject) => {
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
2144
|
+
const writeStream = sftp.createWriteStream(remotePath);
|
|
2145
|
+
writeStream.on("close", () => {
|
|
2146
|
+
sftp.end();
|
|
2147
|
+
resolve();
|
|
2148
|
+
});
|
|
2149
|
+
writeStream.on("error", (e) => {
|
|
2150
|
+
sftp.end();
|
|
2151
|
+
reject(this.makeSftpError("File upload failed", e));
|
|
2152
|
+
});
|
|
2153
|
+
writeStream.end(payload);
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* Upload a local file using ssh2's parallel SFTP fastPut implementation.
|
|
2158
|
+
*/
|
|
2159
|
+
async sftpFastPut(client, localPath, remotePath, options, timeout, debug) {
|
|
2160
|
+
// Validate transfer options BEFORE opening the channel so invalid options
|
|
2161
|
+
// can never leak an SFTP channel.
|
|
2162
|
+
const transferOptions = this.createSftpTransferOptions(options);
|
|
2163
|
+
const sftp = await this.openSftp(client, "fastPut", timeout, debug);
|
|
2164
|
+
debug?.(`[mcp] fastPut ${localPath} -> ${remotePath}, concurrency=${transferOptions.concurrency ?? "ssh2-default"}, chunkSize=${transferOptions.chunkSize ?? "ssh2-default"}`);
|
|
2165
|
+
const endSftp = () => {
|
|
2166
|
+
try {
|
|
2167
|
+
sftp.end();
|
|
2168
|
+
}
|
|
2169
|
+
catch {
|
|
2170
|
+
// Ignore late SFTP cleanup errors.
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
try {
|
|
2174
|
+
await this.runWithInactivityTimeout((onProgress) => new Promise((resolve, reject) => {
|
|
2175
|
+
sftp.fastPut(localPath, remotePath, { ...transferOptions, step: () => onProgress() }, (err) => {
|
|
2176
|
+
if (err) {
|
|
2177
|
+
reject(this.makeSftpError("Fast upload failed", err));
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
1947
2180
|
resolve();
|
|
1948
2181
|
});
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
2182
|
+
}), this.normalizeConnectTimeout(timeout), `fast upload ${localPath} -> ${remotePath}`, debug, endSftp);
|
|
2183
|
+
}
|
|
2184
|
+
finally {
|
|
2185
|
+
endSftp();
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* Download a remote file using ssh2's parallel SFTP fastGet implementation.
|
|
2190
|
+
*/
|
|
2191
|
+
async sftpFastGet(client, remotePath, localPath, options, timeout, debug) {
|
|
2192
|
+
// Validate transfer options BEFORE opening the channel so invalid options
|
|
2193
|
+
// can never leak an SFTP channel.
|
|
2194
|
+
const transferOptions = this.createSftpTransferOptions(options);
|
|
2195
|
+
const sftp = await this.openSftp(client, "fastGet", timeout, debug);
|
|
2196
|
+
debug?.(`[mcp] fastGet ${remotePath} -> ${localPath}, concurrency=${transferOptions.concurrency ?? "ssh2-default"}, chunkSize=${transferOptions.chunkSize ?? "ssh2-default"}`);
|
|
2197
|
+
const endSftp = () => {
|
|
2198
|
+
try {
|
|
2199
|
+
sftp.end();
|
|
2200
|
+
}
|
|
2201
|
+
catch {
|
|
2202
|
+
// Ignore late SFTP cleanup errors.
|
|
2203
|
+
}
|
|
2204
|
+
};
|
|
2205
|
+
try {
|
|
2206
|
+
await this.runWithInactivityTimeout((onProgress) => new Promise((resolve, reject) => {
|
|
2207
|
+
sftp.fastGet(remotePath, localPath, { ...transferOptions, step: () => onProgress() }, (err) => {
|
|
2208
|
+
if (err) {
|
|
2209
|
+
reject(this.makeSftpError("Fast download failed", err));
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
resolve();
|
|
1952
2213
|
});
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
2214
|
+
}), this.normalizeConnectTimeout(timeout), `fast download ${remotePath} -> ${localPath}`, debug, endSftp);
|
|
2215
|
+
}
|
|
2216
|
+
finally {
|
|
2217
|
+
endSftp();
|
|
2218
|
+
}
|
|
1956
2219
|
}
|
|
1957
2220
|
/**
|
|
1958
2221
|
* Download file
|
|
1959
2222
|
*/
|
|
1960
|
-
async download(remotePath, localPath, name) {
|
|
2223
|
+
async download(remotePath, localPath, name, options) {
|
|
1961
2224
|
const resolvedName = name || this.defaultName;
|
|
2225
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
2226
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
1962
2227
|
const validatedLocalPath = this.validateLocalPath(localPath, resolvedName);
|
|
1963
2228
|
const validatedRemotePath = this.validateRemotePath(remotePath, resolvedName);
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
2229
|
+
debug?.(`[mcp] sftp download on [${resolvedName}], reuseConnection=${reuseConnection}`);
|
|
2230
|
+
let connection = null;
|
|
2231
|
+
try {
|
|
2232
|
+
connection = await this.acquireSshClient(resolvedName, {
|
|
2233
|
+
reuseConnection,
|
|
2234
|
+
timeout: options?.timeout,
|
|
2235
|
+
debug,
|
|
2236
|
+
purpose: "sftp",
|
|
2237
|
+
});
|
|
2238
|
+
if (options?.fast === true) {
|
|
2239
|
+
await this.sftpFastGet(connection.client, validatedRemotePath, validatedLocalPath, options, options?.timeout, debug);
|
|
2240
|
+
return this.appendDebugOutput("File downloaded successfully via fast SFTP", debugCollector);
|
|
2241
|
+
}
|
|
2242
|
+
const sftp = await this.openSftp(connection.client, "download", options?.timeout, debug);
|
|
2243
|
+
await new Promise((resolve, reject) => {
|
|
2244
|
+
let settled = false;
|
|
1970
2245
|
const readStream = sftp.createReadStream(validatedRemotePath);
|
|
1971
2246
|
const writeStream = fs.createWriteStream(validatedLocalPath);
|
|
1972
|
-
const
|
|
2247
|
+
const settle = (err) => {
|
|
2248
|
+
if (settled)
|
|
2249
|
+
return;
|
|
2250
|
+
settled = true;
|
|
2251
|
+
if (err) {
|
|
2252
|
+
this.unpipeStream(readStream, writeStream);
|
|
2253
|
+
this.destroyStream(readStream);
|
|
2254
|
+
this.destroyStream(writeStream);
|
|
2255
|
+
}
|
|
1973
2256
|
sftp.end();
|
|
2257
|
+
err ? reject(err) : resolve();
|
|
1974
2258
|
};
|
|
1975
|
-
writeStream.on("finish", () =>
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
});
|
|
1979
|
-
writeStream.on("error", (err) => {
|
|
1980
|
-
cleanup();
|
|
1981
|
-
reject(new ToolError("LOCAL_FILE_WRITE_FAILED", `Failed to save file: ${err.message}`, false));
|
|
1982
|
-
});
|
|
1983
|
-
readStream.on("error", (err) => {
|
|
1984
|
-
cleanup();
|
|
1985
|
-
reject(new ToolError("SFTP_ERROR", `File download failed: ${err.message}`, false));
|
|
1986
|
-
});
|
|
2259
|
+
writeStream.on("finish", () => settle());
|
|
2260
|
+
writeStream.on("error", (err) => settle(new ToolError("LOCAL_FILE_WRITE_FAILED", `Failed to save file: ${err.message}`, false)));
|
|
2261
|
+
readStream.on("error", (err) => settle(this.makeSftpError("File download failed", err)));
|
|
1987
2262
|
readStream.pipe(writeStream);
|
|
1988
2263
|
});
|
|
1989
|
-
|
|
2264
|
+
return this.appendDebugOutput("File downloaded successfully", debugCollector);
|
|
2265
|
+
}
|
|
2266
|
+
catch (error) {
|
|
2267
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
2268
|
+
this.closeClient(resolvedName, true);
|
|
2269
|
+
}
|
|
2270
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
2271
|
+
}
|
|
2272
|
+
finally {
|
|
2273
|
+
connection?.close();
|
|
2274
|
+
}
|
|
1990
2275
|
}
|
|
1991
2276
|
/**
|
|
1992
2277
|
* Disconnect SSH connection
|
|
@@ -2080,11 +2365,35 @@ export class SSHConnectionManager {
|
|
|
2080
2365
|
const validatedSourcePath = this.validateRemotePath(sourceRemotePath, sourceName);
|
|
2081
2366
|
const validatedDestPath = this.validateRemotePath(destRemotePath, destName);
|
|
2082
2367
|
const skipIfIdentical = options?.skipIfIdentical !== false; // default true
|
|
2083
|
-
const
|
|
2084
|
-
const
|
|
2085
|
-
const
|
|
2086
|
-
|
|
2368
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
2369
|
+
const selfRelay = sourceName === destName;
|
|
2370
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
2371
|
+
debug?.(`[mcp] sftp relay ${sourceName} -> ${destName}, reuseConnection=${reuseConnection}`);
|
|
2372
|
+
let srcConnection = null;
|
|
2373
|
+
let dstConnection = null;
|
|
2374
|
+
let srcSftp = null;
|
|
2375
|
+
let dstSftp = null;
|
|
2087
2376
|
try {
|
|
2377
|
+
srcConnection = await this.acquireSshClient(sourceName, {
|
|
2378
|
+
reuseConnection,
|
|
2379
|
+
timeout: options?.timeout,
|
|
2380
|
+
debug,
|
|
2381
|
+
purpose: "sftp",
|
|
2382
|
+
});
|
|
2383
|
+
// Same host on both ends: reuse the one SSH client (two SFTP channels are
|
|
2384
|
+
// still opened below) so we never open or close a second connection.
|
|
2385
|
+
dstConnection = selfRelay
|
|
2386
|
+
? srcConnection
|
|
2387
|
+
: await this.acquireSshClient(destName, {
|
|
2388
|
+
reuseConnection,
|
|
2389
|
+
timeout: options?.timeout,
|
|
2390
|
+
debug,
|
|
2391
|
+
purpose: "sftp",
|
|
2392
|
+
});
|
|
2393
|
+
const srcClient = srcConnection.client;
|
|
2394
|
+
const dstClient = dstConnection.client;
|
|
2395
|
+
srcSftp = await this.openSftp(srcClient, "source", options?.timeout, debug);
|
|
2396
|
+
dstSftp = await this.openSftp(dstClient, "dest", options?.timeout, debug);
|
|
2088
2397
|
// Get source file size before transfer
|
|
2089
2398
|
const srcStat = await this.sftpStat(srcSftp, validatedSourcePath, "source");
|
|
2090
2399
|
// Skip-if-identical: same size on both sides AND matching md5sum (when
|
|
@@ -2101,10 +2410,10 @@ export class SSHConnectionManager {
|
|
|
2101
2410
|
if (srcMd5 && dstMd5 && srcMd5 === dstMd5) {
|
|
2102
2411
|
const srcConfig = this.getConfig(sourceName);
|
|
2103
2412
|
const dstConfig = this.getConfig(destName);
|
|
2104
|
-
return (`Transfer skipped: destination already identical ` +
|
|
2413
|
+
return this.appendDebugOutput(`Transfer skipped: destination already identical ` +
|
|
2105
2414
|
`(size=${srcStat.size} bytes, md5=${srcMd5}). ` +
|
|
2106
2415
|
`${srcConfig.username}@${srcConfig.host}:${validatedSourcePath}` +
|
|
2107
|
-
` == ${dstConfig.username}@${dstConfig.host}:${validatedDestPath}
|
|
2416
|
+
` == ${dstConfig.username}@${dstConfig.host}:${validatedDestPath}`, debugCollector);
|
|
2108
2417
|
}
|
|
2109
2418
|
}
|
|
2110
2419
|
}
|
|
@@ -2120,11 +2429,16 @@ export class SSHConnectionManager {
|
|
|
2120
2429
|
if (settled)
|
|
2121
2430
|
return;
|
|
2122
2431
|
settled = true;
|
|
2432
|
+
if (err) {
|
|
2433
|
+
this.unpipeStream(readStream, writeStream);
|
|
2434
|
+
this.destroyStream(readStream);
|
|
2435
|
+
this.destroyStream(writeStream);
|
|
2436
|
+
}
|
|
2123
2437
|
err ? reject(err) : resolve();
|
|
2124
2438
|
};
|
|
2125
2439
|
writeStream.on("close", () => settle());
|
|
2126
|
-
writeStream.on("error", (err) => settle(
|
|
2127
|
-
readStream.on("error", (err) => settle(
|
|
2440
|
+
writeStream.on("error", (err) => settle(this.makeSftpError("Dest write error", err)));
|
|
2441
|
+
readStream.on("error", (err) => settle(this.makeSftpError("Source read error", err)));
|
|
2128
2442
|
readStream.pipe(writeStream);
|
|
2129
2443
|
});
|
|
2130
2444
|
// --- Verification ---
|
|
@@ -2148,13 +2462,24 @@ export class SSHConnectionManager {
|
|
|
2148
2462
|
}
|
|
2149
2463
|
const srcConfig = this.getConfig(sourceName);
|
|
2150
2464
|
const dstConfig = this.getConfig(destName);
|
|
2151
|
-
return (`Transfer complete (streamed via SFTP, verified: ${verification.join(", ")}): ` +
|
|
2465
|
+
return this.appendDebugOutput(`Transfer complete (streamed via SFTP, verified: ${verification.join(", ")}): ` +
|
|
2152
2466
|
`${srcConfig.username}@${srcConfig.host}:${sourceRemotePath}` +
|
|
2153
|
-
` → ${dstConfig.username}@${dstConfig.host}:${destRemotePath}
|
|
2467
|
+
` → ${dstConfig.username}@${dstConfig.host}:${destRemotePath}`, debugCollector);
|
|
2468
|
+
}
|
|
2469
|
+
catch (error) {
|
|
2470
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
2471
|
+
this.closeClient(sourceName, true);
|
|
2472
|
+
if (!selfRelay)
|
|
2473
|
+
this.closeClient(destName, true);
|
|
2474
|
+
}
|
|
2475
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
2154
2476
|
}
|
|
2155
2477
|
finally {
|
|
2156
|
-
srcSftp
|
|
2157
|
-
dstSftp
|
|
2478
|
+
srcSftp?.end();
|
|
2479
|
+
dstSftp?.end();
|
|
2480
|
+
srcConnection?.close();
|
|
2481
|
+
if (!selfRelay)
|
|
2482
|
+
dstConnection?.close();
|
|
2158
2483
|
}
|
|
2159
2484
|
}
|
|
2160
2485
|
/**
|
|
@@ -2164,7 +2489,7 @@ export class SSHConnectionManager {
|
|
|
2164
2489
|
return new Promise((resolve, reject) => {
|
|
2165
2490
|
sftp.stat(remotePath, (err, stats) => {
|
|
2166
2491
|
if (err) {
|
|
2167
|
-
return reject(
|
|
2492
|
+
return reject(this.makeSftpError(`Failed to stat ${label} file`, err));
|
|
2168
2493
|
}
|
|
2169
2494
|
resolve({ size: stats.size });
|
|
2170
2495
|
});
|
|
@@ -2201,40 +2526,71 @@ export class SSHConnectionManager {
|
|
|
2201
2526
|
/**
|
|
2202
2527
|
* Open an SFTP session from an existing SSH client.
|
|
2203
2528
|
*/
|
|
2204
|
-
openSftp(client, label) {
|
|
2205
|
-
|
|
2529
|
+
openSftp(client, label, timeout, debug) {
|
|
2530
|
+
const open = new Promise((resolve, reject) => {
|
|
2206
2531
|
client.sftp((err, sftp) => {
|
|
2207
2532
|
if (err) {
|
|
2208
|
-
|
|
2533
|
+
// A client that cannot open an SFTP channel is unusable, so treat the
|
|
2534
|
+
// failure as connection-shaped regardless of wording. This lets the
|
|
2535
|
+
// caller force-drop the stale cached client and self-heal on retry.
|
|
2536
|
+
return reject(new ToolError("SSH_CONNECTION_FAILED", `SFTP connection failed (${label}): ${err.message}`, true));
|
|
2209
2537
|
}
|
|
2538
|
+
debug?.(`[mcp] sftp channel opened (${label})`);
|
|
2210
2539
|
resolve(sftp);
|
|
2211
2540
|
});
|
|
2212
2541
|
});
|
|
2542
|
+
return this.withConnectionTimeout(open, this.normalizeConnectTimeout(timeout), `SFTP channel open (${label})`, debug, undefined, (sftp) => {
|
|
2543
|
+
try {
|
|
2544
|
+
sftp.end();
|
|
2545
|
+
}
|
|
2546
|
+
catch {
|
|
2547
|
+
// Ignore late SFTP cleanup errors.
|
|
2548
|
+
}
|
|
2549
|
+
});
|
|
2213
2550
|
}
|
|
2214
2551
|
/**
|
|
2215
2552
|
* List remote files/directories via SFTP readdir
|
|
2216
2553
|
*/
|
|
2217
|
-
async listRemoteDir(remotePath, name) {
|
|
2218
|
-
const
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
const entries = list.map((entry) => ({
|
|
2230
|
-
filename: entry.filename,
|
|
2231
|
-
isDirectory: (entry.attrs.mode & 0o40000) !== 0,
|
|
2232
|
-
size: entry.attrs.size,
|
|
2233
|
-
}));
|
|
2234
|
-
resolve(entries);
|
|
2235
|
-
});
|
|
2554
|
+
async listRemoteDir(remotePath, name, options) {
|
|
2555
|
+
const resolvedName = name || this.defaultName;
|
|
2556
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
2557
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
2558
|
+
debug?.(`[mcp] sftp list on [${resolvedName}], reuseConnection=${reuseConnection}`);
|
|
2559
|
+
let connection = null;
|
|
2560
|
+
try {
|
|
2561
|
+
connection = await this.acquireSshClient(resolvedName, {
|
|
2562
|
+
reuseConnection,
|
|
2563
|
+
timeout: options?.timeout,
|
|
2564
|
+
debug,
|
|
2565
|
+
purpose: "sftp",
|
|
2236
2566
|
});
|
|
2237
|
-
|
|
2567
|
+
const entries = await new Promise((resolve, reject) => {
|
|
2568
|
+
this.openSftp(connection.client, "list", options?.timeout, debug).then((sftp) => {
|
|
2569
|
+
sftp.readdir(remotePath, (err, list) => {
|
|
2570
|
+
sftp.end();
|
|
2571
|
+
if (err) {
|
|
2572
|
+
return reject(this.makeSftpError("Failed to list remote directory", err));
|
|
2573
|
+
}
|
|
2574
|
+
const entries = list.map((entry) => ({
|
|
2575
|
+
filename: entry.filename,
|
|
2576
|
+
isDirectory: (entry.attrs.mode & 0o40000) !== 0,
|
|
2577
|
+
size: entry.attrs.size,
|
|
2578
|
+
}));
|
|
2579
|
+
resolve(entries);
|
|
2580
|
+
});
|
|
2581
|
+
}, reject);
|
|
2582
|
+
});
|
|
2583
|
+
return entries;
|
|
2584
|
+
}
|
|
2585
|
+
catch (error) {
|
|
2586
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
2587
|
+
this.closeClient(resolvedName, true);
|
|
2588
|
+
}
|
|
2589
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
2590
|
+
}
|
|
2591
|
+
finally {
|
|
2592
|
+
connection?.close();
|
|
2593
|
+
}
|
|
2238
2594
|
}
|
|
2239
2595
|
/**
|
|
2240
2596
|
* Upload a local directory recursively to a remote server
|
|
@@ -2247,8 +2603,28 @@ export class SSHConnectionManager {
|
|
|
2247
2603
|
throw new ToolError("LOCAL_FILE_READ_FAILED", `Not a directory: ${localDir}`, false);
|
|
2248
2604
|
}
|
|
2249
2605
|
const results = [];
|
|
2250
|
-
const
|
|
2251
|
-
|
|
2606
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
2607
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
2608
|
+
debug?.(`[mcp] sftp recursive upload mkdir on [${resolvedName}], reuseConnection=${reuseConnection}`);
|
|
2609
|
+
let connection = null;
|
|
2610
|
+
try {
|
|
2611
|
+
connection = await this.acquireSshClient(resolvedName, {
|
|
2612
|
+
reuseConnection,
|
|
2613
|
+
timeout: options?.timeout,
|
|
2614
|
+
debug,
|
|
2615
|
+
purpose: "sftp",
|
|
2616
|
+
});
|
|
2617
|
+
await this.sftpMkdirRecursive(connection.client, validatedRemoteDir, options?.timeout, debug);
|
|
2618
|
+
}
|
|
2619
|
+
catch (error) {
|
|
2620
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
2621
|
+
this.closeClient(resolvedName, true);
|
|
2622
|
+
}
|
|
2623
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
2624
|
+
}
|
|
2625
|
+
finally {
|
|
2626
|
+
connection?.close();
|
|
2627
|
+
}
|
|
2252
2628
|
const entries = fs.readdirSync(resolvedLocal, { withFileTypes: true });
|
|
2253
2629
|
for (const entry of entries) {
|
|
2254
2630
|
const localPath = path.join(localDir, entry.name);
|
|
@@ -2267,7 +2643,7 @@ export class SSHConnectionManager {
|
|
|
2267
2643
|
/**
|
|
2268
2644
|
* Download a remote directory recursively to a local path
|
|
2269
2645
|
*/
|
|
2270
|
-
async downloadDirectory(remoteDir, localDir, name) {
|
|
2646
|
+
async downloadDirectory(remoteDir, localDir, name, options) {
|
|
2271
2647
|
const resolvedName = name || this.defaultName;
|
|
2272
2648
|
const resolvedLocal = this.validateLocalPath(localDir, resolvedName);
|
|
2273
2649
|
const validatedRemoteDir = this.validateRemotePath(remoteDir, resolvedName);
|
|
@@ -2275,18 +2651,18 @@ export class SSHConnectionManager {
|
|
|
2275
2651
|
fs.mkdirSync(resolvedLocal, { recursive: true });
|
|
2276
2652
|
}
|
|
2277
2653
|
const results = [];
|
|
2278
|
-
const entries = await this.listRemoteDir(validatedRemoteDir, resolvedName);
|
|
2654
|
+
const entries = await this.listRemoteDir(validatedRemoteDir, resolvedName, options);
|
|
2279
2655
|
for (const entry of entries) {
|
|
2280
2656
|
if (entry.filename === "." || entry.filename === "..")
|
|
2281
2657
|
continue;
|
|
2282
2658
|
const remotePath = `${validatedRemoteDir}/${entry.filename}`;
|
|
2283
2659
|
const localPath = path.join(localDir, entry.filename);
|
|
2284
2660
|
if (entry.isDirectory) {
|
|
2285
|
-
const subResults = await this.downloadDirectory(remotePath, localPath, resolvedName);
|
|
2661
|
+
const subResults = await this.downloadDirectory(remotePath, localPath, resolvedName, options);
|
|
2286
2662
|
results.push(...subResults);
|
|
2287
2663
|
}
|
|
2288
2664
|
else {
|
|
2289
|
-
await this.download(remotePath, localPath, resolvedName);
|
|
2665
|
+
await this.download(remotePath, localPath, resolvedName, options);
|
|
2290
2666
|
results.push(localPath);
|
|
2291
2667
|
}
|
|
2292
2668
|
}
|
|
@@ -2295,27 +2671,39 @@ export class SSHConnectionManager {
|
|
|
2295
2671
|
/**
|
|
2296
2672
|
* Create remote directory recursively via SFTP
|
|
2297
2673
|
*/
|
|
2298
|
-
async sftpMkdirRecursive(client, remotePath) {
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2674
|
+
async sftpMkdirRecursive(client, remotePath, timeout, debug) {
|
|
2675
|
+
const sftp = await this.openSftp(client, "mkdir", timeout, debug);
|
|
2676
|
+
const walk = new Promise((resolve, reject) => {
|
|
2677
|
+
const parts = remotePath.split("/").filter(Boolean);
|
|
2678
|
+
let current = "";
|
|
2679
|
+
const mkdirNext = (index) => {
|
|
2680
|
+
if (index >= parts.length) {
|
|
2681
|
+
return resolve();
|
|
2303
2682
|
}
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2683
|
+
current += "/" + parts[index];
|
|
2684
|
+
sftp.mkdir(current, (err) => {
|
|
2685
|
+
// An existing directory (or other non-connection failure) is fine and
|
|
2686
|
+
// just means the path component already exists. A connection-shaped
|
|
2687
|
+
// error means the channel died mid-walk -- surface it instead of
|
|
2688
|
+
// silently marching on (and eventually hanging the per-file uploads).
|
|
2689
|
+
if (err && this.isConnectionShapedMessage(err.message)) {
|
|
2690
|
+
return reject(this.makeSftpError("Remote mkdir failed", err));
|
|
2310
2691
|
}
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
});
|
|
2316
|
-
};
|
|
2317
|
-
mkdirNext(0);
|
|
2318
|
-
});
|
|
2692
|
+
mkdirNext(index + 1);
|
|
2693
|
+
});
|
|
2694
|
+
};
|
|
2695
|
+
mkdirNext(0);
|
|
2319
2696
|
});
|
|
2697
|
+
try {
|
|
2698
|
+
await this.withConnectionTimeout(walk, this.normalizeConnectTimeout(timeout), `SFTP mkdir ${remotePath}`, debug);
|
|
2699
|
+
}
|
|
2700
|
+
finally {
|
|
2701
|
+
try {
|
|
2702
|
+
sftp.end();
|
|
2703
|
+
}
|
|
2704
|
+
catch {
|
|
2705
|
+
// Ignore late SFTP cleanup errors.
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2320
2708
|
}
|
|
2321
2709
|
}
|