@aaarc/handfree-ssh-mcp 1.0.5 → 1.0.7
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 +54 -28
- package/build/config/server.js +10 -5
- package/build/services/ssh-connection-manager.js +919 -227
- package/build/tools/close-connection.js +36 -0
- package/build/tools/download.js +15 -2
- package/build/tools/execute-command.js +15 -3
- package/build/tools/help.js +108 -41
- package/build/tools/index.js +2 -0
- package/build/tools/transfer.js +13 -6
- package/build/tools/upload.js +13 -1
- package/package.json +1 -1
|
@@ -41,6 +41,7 @@ export const BUILT_IN_DESTRUCTIVE_GUARDS = [
|
|
|
41
41
|
*/
|
|
42
42
|
export class SSHConnectionManager {
|
|
43
43
|
static instance;
|
|
44
|
+
static CLOSED_CLIENT_ERROR_SINK = () => { };
|
|
44
45
|
clients = new Map();
|
|
45
46
|
configs = {};
|
|
46
47
|
connected = new Map();
|
|
@@ -124,10 +125,7 @@ export class SSHConnectionManager {
|
|
|
124
125
|
toReset.add(name);
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
|
-
|
|
128
|
-
this.closeClient(name, true);
|
|
129
|
-
this.statusCache.delete(name);
|
|
130
|
-
}
|
|
128
|
+
this.closeClientSet(toReset, true);
|
|
131
129
|
this.setConfig(configs, enabledServers);
|
|
132
130
|
Logger.log(`Hot-reloaded SSH config: ${Object.keys(configs).length} server(s), ` +
|
|
133
131
|
`${toReset.size} connection(s) reset`, "info");
|
|
@@ -196,6 +194,30 @@ export class SSHConnectionManager {
|
|
|
196
194
|
this.connected.set(name, false);
|
|
197
195
|
this.connecting.delete(name);
|
|
198
196
|
}
|
|
197
|
+
closeClientSet(names, bumpGeneration = true) {
|
|
198
|
+
for (const name of names) {
|
|
199
|
+
this.closeClient(name, bumpGeneration);
|
|
200
|
+
this.statusCache.delete(name);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
closeConnection(name) {
|
|
204
|
+
const key = this.resolveServer(name);
|
|
205
|
+
this.getConfig(key);
|
|
206
|
+
const affected = new Set([key]);
|
|
207
|
+
const changed = new Set([key]);
|
|
208
|
+
for (const target of Object.keys(this.configs)) {
|
|
209
|
+
if (target === key)
|
|
210
|
+
continue;
|
|
211
|
+
if (this.jumpChainTouchesChanged(target, this.configs, changed)) {
|
|
212
|
+
affected.add(target);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
this.closeClientSet(affected, true);
|
|
216
|
+
return {
|
|
217
|
+
requested: key,
|
|
218
|
+
closed: Array.from(affected),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
199
221
|
bumpConnectionGeneration(name) {
|
|
200
222
|
this.connectionGenerations.set(name, (this.connectionGenerations.get(name) ?? 0) + 1);
|
|
201
223
|
}
|
|
@@ -278,7 +300,7 @@ export class SSHConnectionManager {
|
|
|
278
300
|
* Concurrent callers for the same server share a single in-flight promise,
|
|
279
301
|
* so we never create two SSH clients and leak the loser.
|
|
280
302
|
*/
|
|
281
|
-
async connect(name) {
|
|
303
|
+
async connect(name, timeout) {
|
|
282
304
|
const key = name || this.defaultName;
|
|
283
305
|
if (this.connected.get(key) && this.clients.get(key)) {
|
|
284
306
|
return;
|
|
@@ -287,21 +309,25 @@ export class SSHConnectionManager {
|
|
|
287
309
|
if (inFlight) {
|
|
288
310
|
return inFlight;
|
|
289
311
|
}
|
|
290
|
-
|
|
291
|
-
|
|
312
|
+
let trackedPromise;
|
|
313
|
+
trackedPromise = this.doConnect(key, timeout).finally(() => {
|
|
314
|
+
if (this.connecting.get(key) === trackedPromise) {
|
|
315
|
+
this.connecting.delete(key);
|
|
316
|
+
}
|
|
292
317
|
});
|
|
293
|
-
this.connecting.set(key,
|
|
294
|
-
return
|
|
318
|
+
this.connecting.set(key, trackedPromise);
|
|
319
|
+
return trackedPromise;
|
|
295
320
|
}
|
|
296
321
|
/**
|
|
297
322
|
* Actual SSH connect implementation. Callers must go through `connect()`
|
|
298
323
|
* so concurrent requests are deduped.
|
|
299
324
|
* @private
|
|
300
325
|
*/
|
|
301
|
-
async doConnect(key) {
|
|
326
|
+
async doConnect(key, timeout) {
|
|
302
327
|
const config = this.getConfig(key);
|
|
303
328
|
const client = new Client();
|
|
304
329
|
const generation = this.connectionGenerations.get(key) ?? 0;
|
|
330
|
+
const connectTimeout = this.normalizeConnectTimeout(timeout);
|
|
305
331
|
this.pendingClients.set(key, client);
|
|
306
332
|
try {
|
|
307
333
|
await new Promise(async (resolve, reject) => {
|
|
@@ -357,6 +383,9 @@ export class SSHConnectionManager {
|
|
|
357
383
|
port: config.port,
|
|
358
384
|
username: config.username,
|
|
359
385
|
};
|
|
386
|
+
if (connectTimeout) {
|
|
387
|
+
sshConfig.readyTimeout = connectTimeout;
|
|
388
|
+
}
|
|
360
389
|
const agent = config.agent === false
|
|
361
390
|
? undefined
|
|
362
391
|
: config.agent || (config.identitiesOnly ? undefined : process.env.SSH_AUTH_SOCK);
|
|
@@ -367,7 +396,15 @@ export class SSHConnectionManager {
|
|
|
367
396
|
// (enforced at config load time).
|
|
368
397
|
if (config.jumpHost) {
|
|
369
398
|
try {
|
|
370
|
-
const sock = await this.openJumpTunnel(key, config)
|
|
399
|
+
const sock = await this.withConnectionTimeout(this.openJumpTunnel(key, config, undefined, connectTimeout), connectTimeout, `jump tunnel for [${key}] via '${config.jumpHost}'`, undefined, () => this.teardownJumpChain(key), (stream) => {
|
|
400
|
+
try {
|
|
401
|
+
stream.destroy?.();
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// Ignore late stream cleanup errors.
|
|
405
|
+
}
|
|
406
|
+
this.teardownJumpChain(key);
|
|
407
|
+
});
|
|
371
408
|
sshConfig.sock = sock;
|
|
372
409
|
Logger.log(`Using jump host '${config.jumpHost}' for [${key}]`, "info");
|
|
373
410
|
}
|
|
@@ -390,7 +427,7 @@ export class SSHConnectionManager {
|
|
|
390
427
|
const proxyPort = parseInt(proxyUrl.port, 10);
|
|
391
428
|
Logger.log(`Using SOCKS proxy for [${key}]: ${config.socksProxy}`, "info");
|
|
392
429
|
// Create SOCKS connection
|
|
393
|
-
const { socket } = await SocksClient.createConnection({
|
|
430
|
+
const { socket } = await this.withConnectionTimeout(SocksClient.createConnection({
|
|
394
431
|
proxy: {
|
|
395
432
|
host: proxyHost,
|
|
396
433
|
port: proxyPort,
|
|
@@ -401,6 +438,14 @@ export class SSHConnectionManager {
|
|
|
401
438
|
host: config.host,
|
|
402
439
|
port: config.port,
|
|
403
440
|
},
|
|
441
|
+
timeout: connectTimeout,
|
|
442
|
+
}), connectTimeout, `SOCKS proxy connection for [${key}]`, undefined, undefined, (event) => {
|
|
443
|
+
try {
|
|
444
|
+
event.socket.destroy();
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// Ignore late socket cleanup errors.
|
|
448
|
+
}
|
|
404
449
|
});
|
|
405
450
|
// Set the socket as the sock for SSH connection
|
|
406
451
|
sshConfig.sock = socket;
|
|
@@ -451,6 +496,231 @@ export class SSHConnectionManager {
|
|
|
451
496
|
}
|
|
452
497
|
this.clients.set(key, client);
|
|
453
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
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Compatibility wrapper for tests and existing command call sites.
|
|
514
|
+
*/
|
|
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") {
|
|
524
|
+
const config = this.getConfig(key);
|
|
525
|
+
const client = new Client();
|
|
526
|
+
const jumpChainKey = `__${purpose}__:${key}:${Date.now()}:${crypto.randomBytes(4).toString("hex")}`;
|
|
527
|
+
const connectTimeout = this.normalizeConnectTimeout(timeout) ?? 30000;
|
|
528
|
+
let settled = false;
|
|
529
|
+
let closed = false;
|
|
530
|
+
const onLateError = (err) => {
|
|
531
|
+
if (!settled) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
debug?.(`[mcp] one-shot SSH client emitted error after ready/settle: ${err.message}`);
|
|
535
|
+
Logger.log(`One-shot SSH ${purpose} client [${key}] emitted error after settle: ${err.message}`, "error");
|
|
536
|
+
};
|
|
537
|
+
client.on("error", onLateError);
|
|
538
|
+
const close = () => {
|
|
539
|
+
if (closed) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
closed = true;
|
|
543
|
+
client.on("error", SSHConnectionManager.CLOSED_CLIENT_ERROR_SINK);
|
|
544
|
+
client.removeListener("error", onLateError);
|
|
545
|
+
try {
|
|
546
|
+
client.end();
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
// Ignore close errors for per-command clients.
|
|
550
|
+
}
|
|
551
|
+
this.connected.delete(jumpChainKey);
|
|
552
|
+
this.connecting.delete(jumpChainKey);
|
|
553
|
+
this.teardownJumpChain(jumpChainKey);
|
|
554
|
+
};
|
|
555
|
+
try {
|
|
556
|
+
const sshConfig = {
|
|
557
|
+
host: config.host,
|
|
558
|
+
port: config.port,
|
|
559
|
+
username: config.username,
|
|
560
|
+
readyTimeout: connectTimeout,
|
|
561
|
+
};
|
|
562
|
+
if (debug) {
|
|
563
|
+
sshConfig.debug = (line) => debug(`[ssh2] ${line}`);
|
|
564
|
+
}
|
|
565
|
+
const agent = config.agent === false
|
|
566
|
+
? undefined
|
|
567
|
+
: config.agent || (config.identitiesOnly ? undefined : process.env.SSH_AUTH_SOCK);
|
|
568
|
+
if (agent) {
|
|
569
|
+
sshConfig.agent = agent;
|
|
570
|
+
}
|
|
571
|
+
if (config.jumpHost) {
|
|
572
|
+
try {
|
|
573
|
+
sshConfig.sock = await this.withConnectionTimeout(this.openJumpTunnel(jumpChainKey, config, debug, connectTimeout), connectTimeout, `one-shot jump tunnel for [${key}] via '${config.jumpHost}'`, debug, () => this.teardownJumpChain(jumpChainKey), (stream) => {
|
|
574
|
+
try {
|
|
575
|
+
stream.destroy?.();
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
// Ignore late stream cleanup errors.
|
|
579
|
+
}
|
|
580
|
+
this.teardownJumpChain(jumpChainKey);
|
|
581
|
+
});
|
|
582
|
+
Logger.log(`Using one-shot jump host '${config.jumpHost}' for ${purpose} on [${key}]`, "info");
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
this.teardownJumpChain(jumpChainKey);
|
|
586
|
+
throw new ToolError("SSH_CONNECTION_FAILED", `Failed to open one-shot jump tunnel for ${purpose} [${key}] via '${config.jumpHost}': ${err.message}`, true);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (config.socksProxy) {
|
|
590
|
+
try {
|
|
591
|
+
const proxyUrl = new URL(config.socksProxy);
|
|
592
|
+
const { socket } = await this.withConnectionTimeout(SocksClient.createConnection({
|
|
593
|
+
proxy: {
|
|
594
|
+
host: proxyUrl.hostname,
|
|
595
|
+
port: parseInt(proxyUrl.port, 10),
|
|
596
|
+
type: 5,
|
|
597
|
+
},
|
|
598
|
+
command: "connect",
|
|
599
|
+
destination: {
|
|
600
|
+
host: config.host,
|
|
601
|
+
port: config.port,
|
|
602
|
+
},
|
|
603
|
+
timeout: connectTimeout,
|
|
604
|
+
}), connectTimeout, `SOCKS proxy connection for one-shot command [${key}]`, debug, undefined, (event) => {
|
|
605
|
+
try {
|
|
606
|
+
event.socket.destroy();
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// Ignore late socket cleanup errors.
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
sshConfig.sock = socket;
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
throw new ToolError("SSH_CONNECTION_FAILED", `Failed to create SOCKS proxy connection for one-shot command [${key}]: ${err.message}`, true);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (config.privateKey) {
|
|
619
|
+
try {
|
|
620
|
+
sshConfig.privateKey = fs.readFileSync(config.privateKey, "utf8");
|
|
621
|
+
if (config.passphrase) {
|
|
622
|
+
sshConfig.passphrase = config.passphrase;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read private key file for [${key}]: ${err.message}`, false);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
else if (config.password) {
|
|
630
|
+
sshConfig.password = config.password;
|
|
631
|
+
}
|
|
632
|
+
else if (agent || config.authOptional) {
|
|
633
|
+
Logger.log(`Using SSH agent/default authentication for one-shot ${purpose} [${key}]`, "info");
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
throw new ToolError("SSH_AUTHENTICATION_MISSING", `No valid authentication method provided for [${key}] (password or private key)`, false);
|
|
637
|
+
}
|
|
638
|
+
await new Promise((resolve, reject) => {
|
|
639
|
+
const done = (err) => {
|
|
640
|
+
if (settled)
|
|
641
|
+
return;
|
|
642
|
+
settled = true;
|
|
643
|
+
clearTimeout(timeoutId);
|
|
644
|
+
client.removeListener("ready", onReady);
|
|
645
|
+
client.removeListener("error", onError);
|
|
646
|
+
client.removeListener("close", onClose);
|
|
647
|
+
if (err)
|
|
648
|
+
reject(err);
|
|
649
|
+
else
|
|
650
|
+
resolve();
|
|
651
|
+
};
|
|
652
|
+
const onReady = () => done();
|
|
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));
|
|
655
|
+
const timeoutId = setTimeout(() => {
|
|
656
|
+
done(new ToolError("SSH_CONNECTION_FAILED", `SSH ${purpose} connection [${key}] timed out after ${connectTimeout}ms`, true));
|
|
657
|
+
close();
|
|
658
|
+
}, connectTimeout);
|
|
659
|
+
debug?.(`[mcp] opening one-shot SSH ${purpose} connection for [${key}]`);
|
|
660
|
+
client.once("ready", onReady);
|
|
661
|
+
client.once("error", onError);
|
|
662
|
+
client.once("close", onClose);
|
|
663
|
+
client.connect(sshConfig);
|
|
664
|
+
});
|
|
665
|
+
Logger.log(`Opened one-shot SSH ${purpose} connection for [${key}]`, "info");
|
|
666
|
+
return { client, close };
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
close();
|
|
670
|
+
throw err;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
withConnectionTimeout(operation, timeoutMs, description, debug, onTimeout, onLateResolve) {
|
|
674
|
+
if (!timeoutMs || timeoutMs <= 0) {
|
|
675
|
+
return operation;
|
|
676
|
+
}
|
|
677
|
+
return new Promise((resolve, reject) => {
|
|
678
|
+
let settled = false;
|
|
679
|
+
let timedOut = false;
|
|
680
|
+
const timeoutId = setTimeout(() => {
|
|
681
|
+
if (settled)
|
|
682
|
+
return;
|
|
683
|
+
settled = true;
|
|
684
|
+
timedOut = true;
|
|
685
|
+
debug?.(`[mcp] ${description} timed out after ${timeoutMs}ms`);
|
|
686
|
+
try {
|
|
687
|
+
onTimeout?.();
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
// Ignore cleanup failures after timeout.
|
|
691
|
+
}
|
|
692
|
+
reject(new ToolError("SSH_CONNECTION_FAILED", `${description} timed out after ${timeoutMs}ms`, true));
|
|
693
|
+
}, timeoutMs);
|
|
694
|
+
operation.then((value) => {
|
|
695
|
+
if (timedOut) {
|
|
696
|
+
try {
|
|
697
|
+
onLateResolve?.(value);
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
// Ignore cleanup failures for a late result.
|
|
701
|
+
}
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (settled)
|
|
705
|
+
return;
|
|
706
|
+
settled = true;
|
|
707
|
+
clearTimeout(timeoutId);
|
|
708
|
+
resolve(value);
|
|
709
|
+
}, (err) => {
|
|
710
|
+
if (settled)
|
|
711
|
+
return;
|
|
712
|
+
settled = true;
|
|
713
|
+
clearTimeout(timeoutId);
|
|
714
|
+
reject(err);
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
normalizeConnectTimeout(timeout) {
|
|
719
|
+
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
|
|
720
|
+
return undefined;
|
|
721
|
+
}
|
|
722
|
+
return Math.max(1, timeout);
|
|
723
|
+
}
|
|
454
724
|
/**
|
|
455
725
|
* Open a TCP tunnel from the target's jump chain to `config.host:config.port`
|
|
456
726
|
* and return the duplex stream to be used as `sock` for the target SSH client.
|
|
@@ -466,12 +736,12 @@ export class SSHConnectionManager {
|
|
|
466
736
|
* calls against a bastion stay isolated.
|
|
467
737
|
* @private
|
|
468
738
|
*/
|
|
469
|
-
async openJumpTunnel(targetKey, config) {
|
|
739
|
+
async openJumpTunnel(targetKey, config, debug, timeout) {
|
|
470
740
|
// Tear down any stale jump chain for this target before opening a new one.
|
|
471
741
|
this.teardownJumpChain(targetKey);
|
|
472
742
|
this.jumpClients.set(targetKey, []);
|
|
473
|
-
const jumpClient = await this.connectJumpChain(targetKey, config.jumpHost);
|
|
474
|
-
return this.forwardOutStream(jumpClient, config.host, config.port);
|
|
743
|
+
const jumpClient = await this.connectJumpChain(targetKey, config.jumpHost, debug, timeout);
|
|
744
|
+
return this.forwardOutStream(jumpClient, config.host, config.port, timeout);
|
|
475
745
|
}
|
|
476
746
|
/**
|
|
477
747
|
* Recursively connect the SSH client for `jumpName`, tunneling through its own
|
|
@@ -479,7 +749,7 @@ export class SSHConnectionManager {
|
|
|
479
749
|
* to the target's jump-client chain. Returns the connected client for this hop.
|
|
480
750
|
* @private
|
|
481
751
|
*/
|
|
482
|
-
async connectJumpChain(targetKey, jumpName) {
|
|
752
|
+
async connectJumpChain(targetKey, jumpName, debug, timeout) {
|
|
483
753
|
const jumpConfig = this.configs[jumpName];
|
|
484
754
|
if (!jumpConfig) {
|
|
485
755
|
// Should be caught at config load, but guard at runtime too.
|
|
@@ -489,10 +759,10 @@ export class SSHConnectionManager {
|
|
|
489
759
|
// tunnel first and hand its stream to this hop as `sock`.
|
|
490
760
|
let sock;
|
|
491
761
|
if (jumpConfig.jumpHost) {
|
|
492
|
-
const innerClient = await this.connectJumpChain(targetKey, jumpConfig.jumpHost);
|
|
493
|
-
sock = await this.forwardOutStream(innerClient, jumpConfig.host, jumpConfig.port);
|
|
762
|
+
const innerClient = await this.connectJumpChain(targetKey, jumpConfig.jumpHost, debug, timeout);
|
|
763
|
+
sock = await this.forwardOutStream(innerClient, jumpConfig.host, jumpConfig.port, timeout);
|
|
494
764
|
}
|
|
495
|
-
const jumpClient = await this.connectJumpClient(targetKey, jumpName, jumpConfig, sock);
|
|
765
|
+
const jumpClient = await this.connectJumpClient(targetKey, jumpName, jumpConfig, sock, debug, timeout);
|
|
496
766
|
const chain = this.jumpClients.get(targetKey);
|
|
497
767
|
if (chain) {
|
|
498
768
|
chain.push(jumpClient);
|
|
@@ -508,14 +778,57 @@ export class SSHConnectionManager {
|
|
|
508
778
|
* chain down so a dead hop surfaces as a clear failure on the next op.
|
|
509
779
|
* @private
|
|
510
780
|
*/
|
|
511
|
-
connectJumpClient(targetKey, jumpName, jumpConfig, sock) {
|
|
781
|
+
connectJumpClient(targetKey, jumpName, jumpConfig, sock, debug, timeout) {
|
|
512
782
|
return new Promise((resolve, reject) => {
|
|
513
783
|
const jumpClient = new Client();
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
784
|
+
let settled = false;
|
|
785
|
+
let timeoutId = null;
|
|
786
|
+
let closedErrorSinkInstalled = false;
|
|
787
|
+
const installClosedErrorSink = () => {
|
|
788
|
+
if (!closedErrorSinkInstalled) {
|
|
789
|
+
closedErrorSinkInstalled = true;
|
|
790
|
+
jumpClient.on("error", SSHConnectionManager.CLOSED_CLIENT_ERROR_SINK);
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
const onLateError = (err) => {
|
|
794
|
+
Logger.log(`Jump SSH client '${jumpName}' for [${targetKey}] emitted error after ready: ${err.message}`, "error");
|
|
795
|
+
this.teardownTargetViaJump(targetKey);
|
|
796
|
+
};
|
|
797
|
+
const cleanup = () => {
|
|
798
|
+
if (timeoutId) {
|
|
799
|
+
clearTimeout(timeoutId);
|
|
800
|
+
timeoutId = null;
|
|
801
|
+
}
|
|
802
|
+
jumpClient.removeListener("ready", onReady);
|
|
803
|
+
jumpClient.removeListener("error", onError);
|
|
804
|
+
};
|
|
805
|
+
const done = (err) => {
|
|
806
|
+
if (settled)
|
|
807
|
+
return;
|
|
808
|
+
settled = true;
|
|
809
|
+
cleanup();
|
|
810
|
+
if (err) {
|
|
811
|
+
installClosedErrorSink();
|
|
812
|
+
reject(err);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
jumpClient.on("error", onLateError);
|
|
816
|
+
resolve(jumpClient);
|
|
817
|
+
};
|
|
818
|
+
const onReady = () => done();
|
|
819
|
+
const onError = (err) => {
|
|
820
|
+
done(new Error(`jump SSH connect failed for '${jumpName}': ${err.message}`));
|
|
821
|
+
};
|
|
822
|
+
jumpClient.on("ready", onReady);
|
|
823
|
+
jumpClient.on("error", onError);
|
|
518
824
|
jumpClient.on("close", () => {
|
|
825
|
+
if (!settled) {
|
|
826
|
+
done(new Error(`jump SSH connect closed for '${jumpName}' before ready`));
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
jumpClient.removeListener("error", onError);
|
|
830
|
+
jumpClient.removeListener("error", onLateError);
|
|
831
|
+
installClosedErrorSink();
|
|
519
832
|
// If any hop dies, kill the target too so callers get a clear failure on
|
|
520
833
|
// the next op and reconnect through a fresh chain.
|
|
521
834
|
this.teardownTargetViaJump(targetKey);
|
|
@@ -528,6 +841,21 @@ export class SSHConnectionManager {
|
|
|
528
841
|
if (sock) {
|
|
529
842
|
jumpSsh.sock = sock;
|
|
530
843
|
}
|
|
844
|
+
if (timeout) {
|
|
845
|
+
jumpSsh.readyTimeout = timeout;
|
|
846
|
+
timeoutId = setTimeout(() => {
|
|
847
|
+
done(new Error(`jump SSH connect timed out for '${jumpName}' after ${timeout}ms`));
|
|
848
|
+
try {
|
|
849
|
+
jumpClient.end();
|
|
850
|
+
}
|
|
851
|
+
catch {
|
|
852
|
+
// Ignore close errors after timeout.
|
|
853
|
+
}
|
|
854
|
+
}, timeout);
|
|
855
|
+
}
|
|
856
|
+
if (debug) {
|
|
857
|
+
jumpSsh.debug = (line) => debug(`[ssh2:${jumpName}] ${line}`);
|
|
858
|
+
}
|
|
531
859
|
const jumpAgent = jumpConfig.agent === false
|
|
532
860
|
? undefined
|
|
533
861
|
: jumpConfig.agent || (jumpConfig.identitiesOnly ? undefined : process.env.SSH_AUTH_SOCK);
|
|
@@ -560,12 +888,39 @@ export class SSHConnectionManager {
|
|
|
560
888
|
* Open a forwarded TCP stream from `client` to `host:port`.
|
|
561
889
|
* @private
|
|
562
890
|
*/
|
|
563
|
-
forwardOutStream(client, host, port) {
|
|
891
|
+
forwardOutStream(client, host, port, timeout) {
|
|
564
892
|
return new Promise((resolve, reject) => {
|
|
893
|
+
let settled = false;
|
|
894
|
+
let timeoutId = null;
|
|
895
|
+
const done = (err, stream) => {
|
|
896
|
+
if (settled)
|
|
897
|
+
return;
|
|
898
|
+
settled = true;
|
|
899
|
+
if (timeoutId) {
|
|
900
|
+
clearTimeout(timeoutId);
|
|
901
|
+
}
|
|
902
|
+
if (err) {
|
|
903
|
+
reject(err);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
resolve(stream);
|
|
907
|
+
};
|
|
908
|
+
if (timeout) {
|
|
909
|
+
timeoutId = setTimeout(() => {
|
|
910
|
+
done(new Error(`forwardOut to ${host}:${port} timed out after ${timeout}ms`));
|
|
911
|
+
}, timeout);
|
|
912
|
+
}
|
|
565
913
|
client.forwardOut("127.0.0.1", 0, host, port, (err, ch) => {
|
|
566
|
-
if (
|
|
567
|
-
|
|
568
|
-
|
|
914
|
+
if (settled) {
|
|
915
|
+
try {
|
|
916
|
+
ch?.close();
|
|
917
|
+
}
|
|
918
|
+
catch {
|
|
919
|
+
// Ignore cleanup failures for a late forwarded channel.
|
|
920
|
+
}
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
done(err, ch);
|
|
569
924
|
});
|
|
570
925
|
});
|
|
571
926
|
}
|
|
@@ -622,10 +977,11 @@ export class SSHConnectionManager {
|
|
|
622
977
|
* Ensure SSH client is connected
|
|
623
978
|
* @private
|
|
624
979
|
*/
|
|
625
|
-
async ensureConnected(name) {
|
|
980
|
+
async ensureConnected(name, timeout) {
|
|
626
981
|
const key = name || this.defaultName;
|
|
627
982
|
if (!this.connected.get(key) || !this.clients.get(key)) {
|
|
628
|
-
|
|
983
|
+
const connectTimeout = this.normalizeConnectTimeout(timeout);
|
|
984
|
+
await this.withConnectionTimeout(this.connect(key, timeout), connectTimeout, `cached SSH connection [${key}]`, undefined, () => this.closeClient(key, true));
|
|
629
985
|
}
|
|
630
986
|
const client = this.clients.get(key);
|
|
631
987
|
if (!client) {
|
|
@@ -641,7 +997,10 @@ export class SSHConnectionManager {
|
|
|
641
997
|
if (error instanceof ToolError) {
|
|
642
998
|
return error.code === "SSH_CONNECTION_FAILED";
|
|
643
999
|
}
|
|
644
|
-
|
|
1000
|
+
return this.isConnectionShapedMessage(error.message);
|
|
1001
|
+
}
|
|
1002
|
+
isConnectionShapedMessage(message) {
|
|
1003
|
+
const msg = message.toLowerCase();
|
|
645
1004
|
return (msg.includes("not connected") ||
|
|
646
1005
|
msg.includes("connection") ||
|
|
647
1006
|
msg.includes("socket") ||
|
|
@@ -650,7 +1009,34 @@ export class SSHConnectionManager {
|
|
|
650
1009
|
msg.includes("epipe") ||
|
|
651
1010
|
msg.includes("closed") ||
|
|
652
1011
|
msg.includes("end of stream") ||
|
|
653
|
-
msg.includes("channel")
|
|
1012
|
+
msg.includes("channel") ||
|
|
1013
|
+
msg.includes("no response from server") ||
|
|
1014
|
+
msg.includes("timed out"));
|
|
1015
|
+
}
|
|
1016
|
+
makeSftpError(context, error) {
|
|
1017
|
+
const connectionFailure = this.isConnectionShapedMessage(error.message);
|
|
1018
|
+
return new ToolError(connectionFailure ? "SSH_CONNECTION_FAILED" : "SFTP_ERROR", `${context}: ${error.message}`, connectionFailure);
|
|
1019
|
+
}
|
|
1020
|
+
createSftpTransferOptions(options) {
|
|
1021
|
+
const transferOptions = {};
|
|
1022
|
+
const concurrency = this.optionalPositiveInteger(options?.sftpConcurrency, "sftpConcurrency");
|
|
1023
|
+
const chunkSize = this.optionalPositiveInteger(options?.chunkSize, "chunkSize");
|
|
1024
|
+
if (concurrency !== undefined) {
|
|
1025
|
+
transferOptions.concurrency = concurrency;
|
|
1026
|
+
}
|
|
1027
|
+
if (chunkSize !== undefined) {
|
|
1028
|
+
transferOptions.chunkSize = chunkSize;
|
|
1029
|
+
}
|
|
1030
|
+
return transferOptions;
|
|
1031
|
+
}
|
|
1032
|
+
optionalPositiveInteger(value, name) {
|
|
1033
|
+
if (value === undefined) {
|
|
1034
|
+
return undefined;
|
|
1035
|
+
}
|
|
1036
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
1037
|
+
throw new ToolError("INVALID_CONFIGURATION", `${name} must be a positive integer`, false);
|
|
1038
|
+
}
|
|
1039
|
+
return value;
|
|
654
1040
|
}
|
|
655
1041
|
/**
|
|
656
1042
|
* Force reconnect to SSH server
|
|
@@ -918,54 +1304,107 @@ export class SSHConnectionManager {
|
|
|
918
1304
|
*/
|
|
919
1305
|
async runCommandStream(cmdString, client, timeout, sinks) {
|
|
920
1306
|
return new Promise((resolve, reject) => {
|
|
921
|
-
let timeoutId;
|
|
1307
|
+
let timeoutId = null;
|
|
1308
|
+
let settled = false;
|
|
922
1309
|
const cleanup = () => {
|
|
923
|
-
if (timeoutId)
|
|
1310
|
+
if (timeoutId) {
|
|
924
1311
|
clearTimeout(timeoutId);
|
|
1312
|
+
timeoutId = null;
|
|
1313
|
+
}
|
|
925
1314
|
};
|
|
926
|
-
|
|
927
|
-
if (
|
|
928
|
-
cleanup();
|
|
929
|
-
reject(new ToolError("COMMAND_EXECUTION_ERROR", `Command execution error: ${err.message}`, false));
|
|
1315
|
+
const settle = (err, code) => {
|
|
1316
|
+
if (settled)
|
|
930
1317
|
return;
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
sinks.onProgress(chunk.toString());
|
|
937
|
-
});
|
|
938
|
-
stream.stderr.on("data", (chunk) => {
|
|
939
|
-
sinks.stderrCollector?.push(chunk);
|
|
940
|
-
sinks.logWriter?.appendStderr(chunk);
|
|
941
|
-
if (sinks.onProgress)
|
|
942
|
-
sinks.onProgress(`[STDERR] ${chunk.toString()}`);
|
|
943
|
-
});
|
|
944
|
-
stream.on("close", (code) => {
|
|
945
|
-
cleanup();
|
|
1318
|
+
settled = true;
|
|
1319
|
+
cleanup();
|
|
1320
|
+
if (err)
|
|
1321
|
+
reject(err);
|
|
1322
|
+
else
|
|
946
1323
|
resolve(code ?? null);
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
}
|
|
1324
|
+
};
|
|
1325
|
+
const closeLateStream = (stream) => {
|
|
1326
|
+
try {
|
|
1327
|
+
stream.close();
|
|
1328
|
+
}
|
|
1329
|
+
catch {
|
|
1330
|
+
// Ignore late-stream close errors after the promise has settled.
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
const armExecOpenTimeout = () => {
|
|
1334
|
+
const execOpenTimeout = Math.max(1, timeout);
|
|
952
1335
|
timeoutId = setTimeout(() => {
|
|
953
|
-
|
|
1336
|
+
sinks.debug?.(`[mcp] exec channel open timed out after ${execOpenTimeout}ms`);
|
|
1337
|
+
settle(new ToolError("SSH_CONNECTION_FAILED", `SSH exec channel timeout: no response from server within ${execOpenTimeout}ms while opening command channel`, true));
|
|
1338
|
+
}, execOpenTimeout);
|
|
1339
|
+
};
|
|
1340
|
+
const armCommandTimeout = (stream) => {
|
|
1341
|
+
timeoutId = setTimeout(() => {
|
|
1342
|
+
sinks.debug?.(`[mcp] remote command timed out after ${timeout}ms; closing command channel`);
|
|
1343
|
+
settle(new ToolError("COMMAND_TIMEOUT", `Command timeout: execution exceeded ${timeout}ms limit. Remote process killed.`, false));
|
|
954
1344
|
try {
|
|
955
1345
|
stream.signal("KILL");
|
|
956
1346
|
}
|
|
957
|
-
catch
|
|
958
|
-
// Ignore errors when sending signal
|
|
1347
|
+
catch {
|
|
1348
|
+
// Ignore errors when sending signal.
|
|
959
1349
|
}
|
|
960
1350
|
try {
|
|
961
1351
|
stream.close();
|
|
962
1352
|
}
|
|
963
|
-
catch
|
|
964
|
-
// Ignore errors when closing streams during timeout
|
|
1353
|
+
catch {
|
|
1354
|
+
// Ignore errors when closing streams during timeout.
|
|
965
1355
|
}
|
|
966
|
-
reject(new ToolError("COMMAND_TIMEOUT", `Command timeout: execution exceeded ${timeout}ms limit. Remote process killed.`, false));
|
|
967
1356
|
}, timeout);
|
|
968
|
-
}
|
|
1357
|
+
};
|
|
1358
|
+
armExecOpenTimeout();
|
|
1359
|
+
sinks.debug?.(`[mcp] opening exec channel for command: ${cmdString}`);
|
|
1360
|
+
try {
|
|
1361
|
+
client.exec(cmdString, (err, stream) => {
|
|
1362
|
+
if (settled) {
|
|
1363
|
+
if (stream)
|
|
1364
|
+
closeLateStream(stream);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
if (err) {
|
|
1368
|
+
const isConnectionFailure = this.isConnectionShapedMessage(err.message);
|
|
1369
|
+
const code = isConnectionFailure ? "SSH_CONNECTION_FAILED" : "COMMAND_EXECUTION_ERROR";
|
|
1370
|
+
sinks.debug?.(`[mcp] exec callback failed: ${err.message}`);
|
|
1371
|
+
settle(new ToolError(code, `Command execution error: ${err.message}`, isConnectionFailure));
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
cleanup();
|
|
1375
|
+
sinks.debug?.("[mcp] exec channel opened");
|
|
1376
|
+
stream.on("data", (chunk) => {
|
|
1377
|
+
sinks.stdoutCollector?.push(chunk);
|
|
1378
|
+
sinks.logWriter?.appendStdout(chunk);
|
|
1379
|
+
if (sinks.onProgress)
|
|
1380
|
+
sinks.onProgress(chunk.toString());
|
|
1381
|
+
});
|
|
1382
|
+
stream.stderr.on("data", (chunk) => {
|
|
1383
|
+
sinks.stderrCollector?.push(chunk);
|
|
1384
|
+
sinks.logWriter?.appendStderr(chunk);
|
|
1385
|
+
if (sinks.onProgress)
|
|
1386
|
+
sinks.onProgress(`[STDERR] ${chunk.toString()}`);
|
|
1387
|
+
});
|
|
1388
|
+
stream.on("close", (code) => {
|
|
1389
|
+
sinks.debug?.(`[mcp] exec channel closed with code ${code ?? "null"}`);
|
|
1390
|
+
settle(null, code ?? null);
|
|
1391
|
+
});
|
|
1392
|
+
stream.on("error", (err) => {
|
|
1393
|
+
const isConnectionFailure = this.isConnectionShapedMessage(err.message);
|
|
1394
|
+
const code = isConnectionFailure ? "SSH_CONNECTION_FAILED" : "COMMAND_EXECUTION_ERROR";
|
|
1395
|
+
sinks.debug?.(`[mcp] exec stream error: ${err.message}`);
|
|
1396
|
+
settle(new ToolError(code, `Stream error: ${err.message}`, isConnectionFailure));
|
|
1397
|
+
});
|
|
1398
|
+
armCommandTimeout(stream);
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
catch (err) {
|
|
1402
|
+
const error = err;
|
|
1403
|
+
const isConnectionFailure = this.isConnectionShapedMessage(error.message);
|
|
1404
|
+
const code = isConnectionFailure ? "SSH_CONNECTION_FAILED" : "COMMAND_EXECUTION_ERROR";
|
|
1405
|
+
sinks.debug?.(`[mcp] client.exec threw before callback: ${error.message}`);
|
|
1406
|
+
settle(new ToolError(code, `Command execution error: ${error.message}`, isConnectionFailure));
|
|
1407
|
+
}
|
|
969
1408
|
});
|
|
970
1409
|
}
|
|
971
1410
|
/**
|
|
@@ -974,6 +1413,73 @@ export class SSHConnectionManager {
|
|
|
974
1413
|
* full output is always persisted to disk regardless of this cap.
|
|
975
1414
|
*/
|
|
976
1415
|
static DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024;
|
|
1416
|
+
static DEFAULT_DEBUG_BYTES = 64 * 1024;
|
|
1417
|
+
createDebugCollector(enabled) {
|
|
1418
|
+
if (!enabled) {
|
|
1419
|
+
return { collector: null };
|
|
1420
|
+
}
|
|
1421
|
+
const collector = new OutputCollector(SSHConnectionManager.DEFAULT_DEBUG_BYTES);
|
|
1422
|
+
return {
|
|
1423
|
+
collector,
|
|
1424
|
+
debug: (line) => {
|
|
1425
|
+
collector.push(`${line}\n`);
|
|
1426
|
+
},
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
appendDebugOutput(result, collector) {
|
|
1430
|
+
const debugBlock = this.formatDebugBlock(collector);
|
|
1431
|
+
if (!debugBlock) {
|
|
1432
|
+
return result;
|
|
1433
|
+
}
|
|
1434
|
+
return `${result}\n\n${debugBlock}`;
|
|
1435
|
+
}
|
|
1436
|
+
appendDebugToError(error, collector) {
|
|
1437
|
+
const debugBlock = this.formatDebugBlock(collector);
|
|
1438
|
+
if (!debugBlock) {
|
|
1439
|
+
return error;
|
|
1440
|
+
}
|
|
1441
|
+
const message = `${error.message}\n\n${debugBlock}`;
|
|
1442
|
+
if (error instanceof ToolError) {
|
|
1443
|
+
return new ToolError(error.code, message, error.retriable);
|
|
1444
|
+
}
|
|
1445
|
+
const wrapped = new Error(message);
|
|
1446
|
+
wrapped.name = error.name;
|
|
1447
|
+
return wrapped;
|
|
1448
|
+
}
|
|
1449
|
+
formatDebugBlock(collector) {
|
|
1450
|
+
if (!collector || collector.getTotalBytes() === 0) {
|
|
1451
|
+
return null;
|
|
1452
|
+
}
|
|
1453
|
+
const snapshot = collector.getSnapshot();
|
|
1454
|
+
const header = snapshot.truncated
|
|
1455
|
+
? `[SSH DEBUG TRUNCATED: dropped ${snapshot.droppedBytes} bytes]\n`
|
|
1456
|
+
: "[SSH DEBUG]\n";
|
|
1457
|
+
return `${header}${snapshot.tail.toString("utf8").trimEnd()}`;
|
|
1458
|
+
}
|
|
1459
|
+
unpipeStream(readStream, writeStream) {
|
|
1460
|
+
const candidate = readStream;
|
|
1461
|
+
if (typeof candidate.unpipe !== "function") {
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
try {
|
|
1465
|
+
candidate.unpipe(writeStream);
|
|
1466
|
+
}
|
|
1467
|
+
catch {
|
|
1468
|
+
// Ignore cleanup errors after the original stream failure.
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
destroyStream(stream) {
|
|
1472
|
+
const candidate = stream;
|
|
1473
|
+
if (typeof candidate.destroy !== "function") {
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
try {
|
|
1477
|
+
candidate.destroy();
|
|
1478
|
+
}
|
|
1479
|
+
catch {
|
|
1480
|
+
// Ignore cleanup errors after the original stream failure.
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
977
1483
|
/**
|
|
978
1484
|
* Assemble the final user-visible result string from collected tails.
|
|
979
1485
|
* Adds a truncation header (with the on-disk log path) and the legacy
|
|
@@ -1039,12 +1545,20 @@ export class SSHConnectionManager {
|
|
|
1039
1545
|
const timeout = options.timeout || 30000; // Default 30 seconds timeout
|
|
1040
1546
|
const maxRetries = options.maxRetries ?? 2; // Default 2 retries
|
|
1041
1547
|
const maxOutputBytes = options.maxOutputBytes ?? SSHConnectionManager.DEFAULT_MAX_OUTPUT_BYTES;
|
|
1548
|
+
const reuseConnection = options.reuseConnection !== false;
|
|
1042
1549
|
const key = name || this.defaultName;
|
|
1550
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options.vvv === true);
|
|
1043
1551
|
let lastError = null;
|
|
1044
1552
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1045
1553
|
try {
|
|
1046
|
-
|
|
1047
|
-
const
|
|
1554
|
+
debug?.(`[mcp] command attempt ${attempt + 1}/${maxRetries + 1} on [${key}], reuseConnection=${reuseConnection}`);
|
|
1555
|
+
const commandConnection = await this.acquireSshClient(key, {
|
|
1556
|
+
reuseConnection,
|
|
1557
|
+
timeout,
|
|
1558
|
+
debug,
|
|
1559
|
+
purpose: "command",
|
|
1560
|
+
});
|
|
1561
|
+
const client = commandConnection.client;
|
|
1048
1562
|
// Per-attempt collectors + log writer. We rebuild them on each retry
|
|
1049
1563
|
// so partial output from a failed attempt is not mixed into the next.
|
|
1050
1564
|
const stdoutCollector = new OutputCollector(maxOutputBytes);
|
|
@@ -1057,10 +1571,12 @@ export class SSHConnectionManager {
|
|
|
1057
1571
|
stdoutCollector,
|
|
1058
1572
|
stderrCollector,
|
|
1059
1573
|
logWriter: logWriter ?? undefined,
|
|
1574
|
+
debug,
|
|
1060
1575
|
});
|
|
1061
1576
|
}
|
|
1062
1577
|
finally {
|
|
1063
1578
|
logWriter?.close({ exitCode, durationMs: Date.now() - startedMs });
|
|
1579
|
+
commandConnection.close();
|
|
1064
1580
|
}
|
|
1065
1581
|
const result = this.buildExecuteResult({
|
|
1066
1582
|
stdoutCollector,
|
|
@@ -1073,7 +1589,7 @@ export class SSHConnectionManager {
|
|
|
1073
1589
|
if (attempt > 0) {
|
|
1074
1590
|
Logger.log(`Command succeeded on retry attempt ${attempt} for [${key}]`, "info");
|
|
1075
1591
|
}
|
|
1076
|
-
return result;
|
|
1592
|
+
return this.appendDebugOutput(result, debugCollector);
|
|
1077
1593
|
}
|
|
1078
1594
|
catch (error) {
|
|
1079
1595
|
lastError = error;
|
|
@@ -1083,13 +1599,13 @@ export class SSHConnectionManager {
|
|
|
1083
1599
|
Logger.log(`Connection error on attempt ${attempt + 1}/${maxRetries + 1} for [${key}]: ${lastError.message}. Retrying in ${backoffMs}ms...`, "info");
|
|
1084
1600
|
// Wait with exponential backoff
|
|
1085
1601
|
await this.sleep(backoffMs);
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1602
|
+
if (reuseConnection) {
|
|
1603
|
+
try {
|
|
1604
|
+
await this.reconnect(name);
|
|
1605
|
+
}
|
|
1606
|
+
catch (reconnectError) {
|
|
1607
|
+
Logger.log(`Reconnect failed for [${key}]: ${reconnectError.message}`, "error");
|
|
1608
|
+
}
|
|
1093
1609
|
}
|
|
1094
1610
|
continue;
|
|
1095
1611
|
}
|
|
@@ -1098,7 +1614,7 @@ export class SSHConnectionManager {
|
|
|
1098
1614
|
}
|
|
1099
1615
|
}
|
|
1100
1616
|
// All retries exhausted
|
|
1101
|
-
throw lastError || new Error("Command execution failed after all retries");
|
|
1617
|
+
throw this.appendDebugToError(lastError || new Error("Command execution failed after all retries"), debugCollector);
|
|
1102
1618
|
}
|
|
1103
1619
|
/**
|
|
1104
1620
|
* Execute SSH command with real-time streaming output via progress callback
|
|
@@ -1122,12 +1638,20 @@ export class SSHConnectionManager {
|
|
|
1122
1638
|
const timeout = options.timeout || 300000; // Default 5 minutes for streaming
|
|
1123
1639
|
const maxRetries = options.maxRetries ?? 2; // Default 2 retries
|
|
1124
1640
|
const maxOutputBytes = options.maxOutputBytes ?? SSHConnectionManager.DEFAULT_MAX_OUTPUT_BYTES;
|
|
1641
|
+
const reuseConnection = options.reuseConnection !== false;
|
|
1125
1642
|
const key = name || this.defaultName;
|
|
1643
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options.vvv === true);
|
|
1126
1644
|
let lastError = null;
|
|
1127
1645
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1128
1646
|
try {
|
|
1129
|
-
|
|
1130
|
-
const
|
|
1647
|
+
debug?.(`[mcp] streaming command attempt ${attempt + 1}/${maxRetries + 1} on [${key}], reuseConnection=${reuseConnection}`);
|
|
1648
|
+
const commandConnection = await this.acquireSshClient(key, {
|
|
1649
|
+
reuseConnection,
|
|
1650
|
+
timeout,
|
|
1651
|
+
debug,
|
|
1652
|
+
purpose: "command",
|
|
1653
|
+
});
|
|
1654
|
+
const client = commandConnection.client;
|
|
1131
1655
|
// Per-attempt collectors + log writer. onProgress keeps streaming
|
|
1132
1656
|
// every byte live; only the final returned string is capped.
|
|
1133
1657
|
const stdoutCollector = new OutputCollector(maxOutputBytes);
|
|
@@ -1141,10 +1665,12 @@ export class SSHConnectionManager {
|
|
|
1141
1665
|
stderrCollector,
|
|
1142
1666
|
logWriter: logWriter ?? undefined,
|
|
1143
1667
|
onProgress: options.onProgress,
|
|
1668
|
+
debug,
|
|
1144
1669
|
});
|
|
1145
1670
|
}
|
|
1146
1671
|
finally {
|
|
1147
1672
|
logWriter?.close({ exitCode, durationMs: Date.now() - startedMs });
|
|
1673
|
+
commandConnection.close();
|
|
1148
1674
|
}
|
|
1149
1675
|
const result = this.buildExecuteResult({
|
|
1150
1676
|
stdoutCollector,
|
|
@@ -1157,7 +1683,7 @@ export class SSHConnectionManager {
|
|
|
1157
1683
|
if (attempt > 0) {
|
|
1158
1684
|
Logger.log(`Streaming command succeeded on retry attempt ${attempt} for [${key}]`, "info");
|
|
1159
1685
|
}
|
|
1160
|
-
return result;
|
|
1686
|
+
return this.appendDebugOutput(result, debugCollector);
|
|
1161
1687
|
}
|
|
1162
1688
|
catch (error) {
|
|
1163
1689
|
lastError = error;
|
|
@@ -1166,18 +1692,20 @@ export class SSHConnectionManager {
|
|
|
1166
1692
|
const backoffMs = 500 * Math.pow(2, attempt);
|
|
1167
1693
|
Logger.log(`Connection error on streaming attempt ${attempt + 1}/${maxRetries + 1} for [${key}]: ${lastError.message}. Retrying in ${backoffMs}ms...`, "info");
|
|
1168
1694
|
await this.sleep(backoffMs);
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1695
|
+
if (reuseConnection) {
|
|
1696
|
+
try {
|
|
1697
|
+
await this.reconnect(name);
|
|
1698
|
+
}
|
|
1699
|
+
catch (reconnectError) {
|
|
1700
|
+
Logger.log(`Reconnect failed for [${key}]: ${reconnectError.message}`, "error");
|
|
1701
|
+
}
|
|
1174
1702
|
}
|
|
1175
1703
|
continue;
|
|
1176
1704
|
}
|
|
1177
1705
|
break;
|
|
1178
1706
|
}
|
|
1179
1707
|
}
|
|
1180
|
-
throw lastError || new Error("Streaming command execution failed after all retries");
|
|
1708
|
+
throw this.appendDebugToError(lastError || new Error("Streaming command execution failed after all retries"), debugCollector);
|
|
1181
1709
|
}
|
|
1182
1710
|
/**
|
|
1183
1711
|
* Build an OutputLogWriter for a given server. Returns null if we cannot
|
|
@@ -1269,6 +1797,8 @@ export class SSHConnectionManager {
|
|
|
1269
1797
|
*/
|
|
1270
1798
|
async upload(localPath, remotePath, name, options) {
|
|
1271
1799
|
const resolvedName = name || this.defaultName;
|
|
1800
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
1801
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
1272
1802
|
const validatedLocalPath = this.validateLocalPath(localPath, resolvedName);
|
|
1273
1803
|
const validatedRemotePath = this.validateRemotePath(remotePath, resolvedName);
|
|
1274
1804
|
const skipIfIdentical = options?.skipIfIdentical !== false; // default true
|
|
@@ -1284,32 +1814,67 @@ export class SSHConnectionManager {
|
|
|
1284
1814
|
throw new ToolError("LOCAL_FILE_READ_FAILED", `Local path '${validatedLocalPath}' is not a regular file (size=${stat.size}). ` +
|
|
1285
1815
|
`Use uploadDirectory / recursive=true for directories.`, false);
|
|
1286
1816
|
}
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1817
|
+
const isShellScript = SSHConnectionManager.SHELL_SCRIPT_EXTENSIONS.has(path.extname(validatedLocalPath).toLowerCase());
|
|
1818
|
+
const fastUpload = options?.fast === true;
|
|
1819
|
+
const mustReadPayload = skipIfIdentical || !fastUpload || isShellScript;
|
|
1820
|
+
let payload = null;
|
|
1821
|
+
let crlfFixed = {
|
|
1822
|
+
buffer: Buffer.alloc(0),
|
|
1823
|
+
fixed: false,
|
|
1824
|
+
replacedCount: 0,
|
|
1825
|
+
};
|
|
1826
|
+
if (mustReadPayload) {
|
|
1827
|
+
try {
|
|
1828
|
+
payload = fs.readFileSync(validatedLocalPath);
|
|
1829
|
+
}
|
|
1830
|
+
catch (e) {
|
|
1831
|
+
throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read local file '${validatedLocalPath}': ${e.message}`, false);
|
|
1832
|
+
}
|
|
1833
|
+
// ---- CRLF auto-fix for shell scripts ----
|
|
1834
|
+
crlfFixed = SSHConnectionManager.maybeFixShellScriptLineEndings(validatedLocalPath, payload);
|
|
1835
|
+
payload = crlfFixed.buffer;
|
|
1293
1836
|
}
|
|
1294
|
-
// ---- CRLF auto-fix for shell scripts ----
|
|
1295
|
-
const crlfFixed = SSHConnectionManager.maybeFixShellScriptLineEndings(validatedLocalPath, payload);
|
|
1296
|
-
payload = crlfFixed.buffer;
|
|
1297
1837
|
const crlfNote = crlfFixed.fixed
|
|
1298
1838
|
? ` (CRLF→LF auto-fix: converted ${crlfFixed.replacedCount} line endings to LF before upload because target is a shell script).`
|
|
1299
1839
|
: "";
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1840
|
+
debug?.(`[mcp] sftp upload on [${resolvedName}], reuseConnection=${reuseConnection}`);
|
|
1841
|
+
let connection = null;
|
|
1842
|
+
try {
|
|
1843
|
+
connection = await this.acquireSshClient(resolvedName, {
|
|
1844
|
+
reuseConnection,
|
|
1845
|
+
timeout: options?.timeout,
|
|
1846
|
+
debug,
|
|
1847
|
+
purpose: "sftp",
|
|
1848
|
+
});
|
|
1849
|
+
const client = connection.client;
|
|
1850
|
+
// ---- Skip-if-identical check ----
|
|
1851
|
+
if (skipIfIdentical) {
|
|
1852
|
+
const decision = await this.shouldSkipUpload(client, payload, validatedRemotePath, isShellScript, options?.timeout, debug);
|
|
1853
|
+
if (decision.skip) {
|
|
1854
|
+
return this.appendDebugOutput(`Upload skipped: remote file '${validatedRemotePath}' is already identical to local ` +
|
|
1855
|
+
`'${validatedLocalPath}' (${decision.reason}).${crlfNote}`, debugCollector);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
// ---- Actually upload ----
|
|
1859
|
+
if (fastUpload && !crlfFixed.fixed) {
|
|
1860
|
+
await this.sftpFastPut(client, validatedLocalPath, validatedRemotePath, options, options?.timeout, debug);
|
|
1308
1861
|
}
|
|
1862
|
+
else {
|
|
1863
|
+
await this.sftpWriteBuffer(client, validatedRemotePath, payload, options?.timeout, debug);
|
|
1864
|
+
}
|
|
1865
|
+
const uploadedBytes = payload?.length ?? stat.size;
|
|
1866
|
+
const modeNote = fastUpload && !crlfFixed.fixed ? " via fast SFTP" : "";
|
|
1867
|
+
return this.appendDebugOutput(`File uploaded successfully (${uploadedBytes} bytes${modeNote})${crlfNote}`, debugCollector);
|
|
1868
|
+
}
|
|
1869
|
+
catch (error) {
|
|
1870
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
1871
|
+
this.closeClient(resolvedName, true);
|
|
1872
|
+
}
|
|
1873
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
1874
|
+
}
|
|
1875
|
+
finally {
|
|
1876
|
+
connection?.close();
|
|
1309
1877
|
}
|
|
1310
|
-
// ---- Actually upload ----
|
|
1311
|
-
await this.sftpWriteBuffer(client, validatedRemotePath, payload);
|
|
1312
|
-
return `File uploaded successfully (${payload.length} bytes)${crlfNote}`;
|
|
1313
1878
|
}
|
|
1314
1879
|
/**
|
|
1315
1880
|
* Threshold above which we use MD5 hash comparison instead of byte-content
|
|
@@ -1366,10 +1931,10 @@ export class SSHConnectionManager {
|
|
|
1366
1931
|
* Any error during the check is treated as 'don't skip' (i.e. fall through
|
|
1367
1932
|
* to a normal upload), since correctness wins over an optimization.
|
|
1368
1933
|
*/
|
|
1369
|
-
async shouldSkipUpload(client, localPayload, remotePath, lineEndingAgnostic) {
|
|
1934
|
+
async shouldSkipUpload(client, localPayload, remotePath, lineEndingAgnostic, timeout, debug) {
|
|
1370
1935
|
let remoteSize;
|
|
1371
1936
|
try {
|
|
1372
|
-
const sftp = await this.openSftp(client, "dest");
|
|
1937
|
+
const sftp = await this.openSftp(client, "dest", timeout, debug);
|
|
1373
1938
|
try {
|
|
1374
1939
|
const stat = await this.sftpStat(sftp, remotePath, "dest");
|
|
1375
1940
|
remoteSize = stat.size;
|
|
@@ -1390,7 +1955,7 @@ export class SSHConnectionManager {
|
|
|
1390
1955
|
}
|
|
1391
1956
|
let remoteBuf;
|
|
1392
1957
|
try {
|
|
1393
|
-
remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize);
|
|
1958
|
+
remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize, timeout, debug);
|
|
1394
1959
|
}
|
|
1395
1960
|
catch {
|
|
1396
1961
|
return { skip: false, reason: "remote-read-failed-during-content-compare" };
|
|
@@ -1413,7 +1978,7 @@ export class SSHConnectionManager {
|
|
|
1413
1978
|
// Byte-content compare
|
|
1414
1979
|
let remoteBuf;
|
|
1415
1980
|
try {
|
|
1416
|
-
remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize);
|
|
1981
|
+
remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize, timeout, debug);
|
|
1417
1982
|
}
|
|
1418
1983
|
catch {
|
|
1419
1984
|
return { skip: false, reason: "remote-read-failed-during-content-compare" };
|
|
@@ -1449,88 +2014,138 @@ export class SSHConnectionManager {
|
|
|
1449
2014
|
/**
|
|
1450
2015
|
* Read an SFTP file fully into a Buffer.
|
|
1451
2016
|
*/
|
|
1452
|
-
async sftpReadBuffer(client, remotePath, expectedSize) {
|
|
2017
|
+
async sftpReadBuffer(client, remotePath, expectedSize, timeout, debug) {
|
|
2018
|
+
const sftp = await this.openSftp(client, "read", timeout, debug);
|
|
1453
2019
|
return new Promise((resolve, reject) => {
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
2020
|
+
const chunks = [];
|
|
2021
|
+
let received = 0;
|
|
2022
|
+
const stream = sftp.createReadStream(remotePath);
|
|
2023
|
+
stream.on("data", (chunk) => {
|
|
2024
|
+
chunks.push(chunk);
|
|
2025
|
+
received += chunk.length;
|
|
2026
|
+
});
|
|
2027
|
+
stream.on("error", (e) => {
|
|
2028
|
+
sftp.end();
|
|
2029
|
+
reject(this.makeSftpError("Remote read failed", e));
|
|
2030
|
+
});
|
|
2031
|
+
stream.on("end", () => {
|
|
2032
|
+
sftp.end();
|
|
2033
|
+
if (received !== expectedSize) {
|
|
2034
|
+
return reject(new ToolError("SFTP_ERROR", `Remote read short: expected ${expectedSize} bytes, got ${received}`, false));
|
|
1457
2035
|
}
|
|
1458
|
-
|
|
1459
|
-
let received = 0;
|
|
1460
|
-
const stream = sftp.createReadStream(remotePath);
|
|
1461
|
-
stream.on("data", (chunk) => {
|
|
1462
|
-
chunks.push(chunk);
|
|
1463
|
-
received += chunk.length;
|
|
1464
|
-
});
|
|
1465
|
-
stream.on("error", (e) => {
|
|
1466
|
-
sftp.end();
|
|
1467
|
-
reject(new ToolError("SFTP_ERROR", `Remote read failed: ${e.message}`, false));
|
|
1468
|
-
});
|
|
1469
|
-
stream.on("end", () => {
|
|
1470
|
-
sftp.end();
|
|
1471
|
-
if (received !== expectedSize) {
|
|
1472
|
-
return reject(new ToolError("SFTP_ERROR", `Remote read short: expected ${expectedSize} bytes, got ${received}`, false));
|
|
1473
|
-
}
|
|
1474
|
-
resolve(Buffer.concat(chunks, received));
|
|
1475
|
-
});
|
|
2036
|
+
resolve(Buffer.concat(chunks, received));
|
|
1476
2037
|
});
|
|
1477
2038
|
});
|
|
1478
2039
|
}
|
|
1479
2040
|
/**
|
|
1480
2041
|
* Write a Buffer to an SFTP path (overwrites if exists).
|
|
1481
2042
|
*/
|
|
1482
|
-
async sftpWriteBuffer(client, remotePath, payload) {
|
|
2043
|
+
async sftpWriteBuffer(client, remotePath, payload, timeout, debug) {
|
|
2044
|
+
const sftp = await this.openSftp(client, "write", timeout, debug);
|
|
1483
2045
|
return new Promise((resolve, reject) => {
|
|
1484
|
-
|
|
2046
|
+
const writeStream = sftp.createWriteStream(remotePath);
|
|
2047
|
+
writeStream.on("close", () => {
|
|
2048
|
+
sftp.end();
|
|
2049
|
+
resolve();
|
|
2050
|
+
});
|
|
2051
|
+
writeStream.on("error", (e) => {
|
|
2052
|
+
sftp.end();
|
|
2053
|
+
reject(this.makeSftpError("File upload failed", e));
|
|
2054
|
+
});
|
|
2055
|
+
writeStream.end(payload);
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Upload a local file using ssh2's parallel SFTP fastPut implementation.
|
|
2060
|
+
*/
|
|
2061
|
+
async sftpFastPut(client, localPath, remotePath, options, timeout, debug) {
|
|
2062
|
+
const sftp = await this.openSftp(client, "fastPut", timeout, debug);
|
|
2063
|
+
const transferOptions = this.createSftpTransferOptions(options);
|
|
2064
|
+
debug?.(`[mcp] fastPut ${localPath} -> ${remotePath}, concurrency=${transferOptions.concurrency ?? "ssh2-default"}, chunkSize=${transferOptions.chunkSize ?? "ssh2-default"}`);
|
|
2065
|
+
return new Promise((resolve, reject) => {
|
|
2066
|
+
sftp.fastPut(localPath, remotePath, transferOptions, (err) => {
|
|
2067
|
+
sftp.end();
|
|
1485
2068
|
if (err) {
|
|
1486
|
-
|
|
2069
|
+
reject(this.makeSftpError("Fast upload failed", err));
|
|
2070
|
+
return;
|
|
1487
2071
|
}
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
2072
|
+
resolve();
|
|
2073
|
+
});
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
2077
|
+
* Download a remote file using ssh2's parallel SFTP fastGet implementation.
|
|
2078
|
+
*/
|
|
2079
|
+
async sftpFastGet(client, remotePath, localPath, options, timeout, debug) {
|
|
2080
|
+
const sftp = await this.openSftp(client, "fastGet", timeout, debug);
|
|
2081
|
+
const transferOptions = this.createSftpTransferOptions(options);
|
|
2082
|
+
debug?.(`[mcp] fastGet ${remotePath} -> ${localPath}, concurrency=${transferOptions.concurrency ?? "ssh2-default"}, chunkSize=${transferOptions.chunkSize ?? "ssh2-default"}`);
|
|
2083
|
+
return new Promise((resolve, reject) => {
|
|
2084
|
+
sftp.fastGet(remotePath, localPath, transferOptions, (err) => {
|
|
2085
|
+
sftp.end();
|
|
2086
|
+
if (err) {
|
|
2087
|
+
reject(this.makeSftpError("Fast download failed", err));
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
resolve();
|
|
1498
2091
|
});
|
|
1499
2092
|
});
|
|
1500
2093
|
}
|
|
1501
2094
|
/**
|
|
1502
2095
|
* Download file
|
|
1503
2096
|
*/
|
|
1504
|
-
async download(remotePath, localPath, name) {
|
|
2097
|
+
async download(remotePath, localPath, name, options) {
|
|
1505
2098
|
const resolvedName = name || this.defaultName;
|
|
2099
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
2100
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
1506
2101
|
const validatedLocalPath = this.validateLocalPath(localPath, resolvedName);
|
|
1507
2102
|
const validatedRemotePath = this.validateRemotePath(remotePath, resolvedName);
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
2103
|
+
debug?.(`[mcp] sftp download on [${resolvedName}], reuseConnection=${reuseConnection}`);
|
|
2104
|
+
let connection = null;
|
|
2105
|
+
try {
|
|
2106
|
+
connection = await this.acquireSshClient(resolvedName, {
|
|
2107
|
+
reuseConnection,
|
|
2108
|
+
timeout: options?.timeout,
|
|
2109
|
+
debug,
|
|
2110
|
+
purpose: "sftp",
|
|
2111
|
+
});
|
|
2112
|
+
if (options?.fast === true) {
|
|
2113
|
+
await this.sftpFastGet(connection.client, validatedRemotePath, validatedLocalPath, options, options?.timeout, debug);
|
|
2114
|
+
return this.appendDebugOutput("File downloaded successfully via fast SFTP", debugCollector);
|
|
2115
|
+
}
|
|
2116
|
+
const sftp = await this.openSftp(connection.client, "download", options?.timeout, debug);
|
|
2117
|
+
await new Promise((resolve, reject) => {
|
|
2118
|
+
let settled = false;
|
|
1514
2119
|
const readStream = sftp.createReadStream(validatedRemotePath);
|
|
1515
2120
|
const writeStream = fs.createWriteStream(validatedLocalPath);
|
|
1516
|
-
const
|
|
2121
|
+
const settle = (err) => {
|
|
2122
|
+
if (settled)
|
|
2123
|
+
return;
|
|
2124
|
+
settled = true;
|
|
2125
|
+
if (err) {
|
|
2126
|
+
this.unpipeStream(readStream, writeStream);
|
|
2127
|
+
this.destroyStream(readStream);
|
|
2128
|
+
this.destroyStream(writeStream);
|
|
2129
|
+
}
|
|
1517
2130
|
sftp.end();
|
|
2131
|
+
err ? reject(err) : resolve();
|
|
1518
2132
|
};
|
|
1519
|
-
writeStream.on("finish", () =>
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
});
|
|
1523
|
-
writeStream.on("error", (err) => {
|
|
1524
|
-
cleanup();
|
|
1525
|
-
reject(new ToolError("LOCAL_FILE_WRITE_FAILED", `Failed to save file: ${err.message}`, false));
|
|
1526
|
-
});
|
|
1527
|
-
readStream.on("error", (err) => {
|
|
1528
|
-
cleanup();
|
|
1529
|
-
reject(new ToolError("SFTP_ERROR", `File download failed: ${err.message}`, false));
|
|
1530
|
-
});
|
|
2133
|
+
writeStream.on("finish", () => settle());
|
|
2134
|
+
writeStream.on("error", (err) => settle(new ToolError("LOCAL_FILE_WRITE_FAILED", `Failed to save file: ${err.message}`, false)));
|
|
2135
|
+
readStream.on("error", (err) => settle(this.makeSftpError("File download failed", err)));
|
|
1531
2136
|
readStream.pipe(writeStream);
|
|
1532
2137
|
});
|
|
1533
|
-
|
|
2138
|
+
return this.appendDebugOutput("File downloaded successfully", debugCollector);
|
|
2139
|
+
}
|
|
2140
|
+
catch (error) {
|
|
2141
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
2142
|
+
this.closeClient(resolvedName, true);
|
|
2143
|
+
}
|
|
2144
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
2145
|
+
}
|
|
2146
|
+
finally {
|
|
2147
|
+
connection?.close();
|
|
2148
|
+
}
|
|
1534
2149
|
}
|
|
1535
2150
|
/**
|
|
1536
2151
|
* Disconnect SSH connection
|
|
@@ -1624,11 +2239,30 @@ export class SSHConnectionManager {
|
|
|
1624
2239
|
const validatedSourcePath = this.validateRemotePath(sourceRemotePath, sourceName);
|
|
1625
2240
|
const validatedDestPath = this.validateRemotePath(destRemotePath, destName);
|
|
1626
2241
|
const skipIfIdentical = options?.skipIfIdentical !== false; // default true
|
|
1627
|
-
const
|
|
1628
|
-
const
|
|
1629
|
-
|
|
1630
|
-
|
|
2242
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
2243
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
2244
|
+
debug?.(`[mcp] sftp relay ${sourceName} -> ${destName}, reuseConnection=${reuseConnection}`);
|
|
2245
|
+
let srcConnection = null;
|
|
2246
|
+
let dstConnection = null;
|
|
2247
|
+
let srcSftp = null;
|
|
2248
|
+
let dstSftp = null;
|
|
1631
2249
|
try {
|
|
2250
|
+
srcConnection = await this.acquireSshClient(sourceName, {
|
|
2251
|
+
reuseConnection,
|
|
2252
|
+
timeout: options?.timeout,
|
|
2253
|
+
debug,
|
|
2254
|
+
purpose: "sftp",
|
|
2255
|
+
});
|
|
2256
|
+
dstConnection = await this.acquireSshClient(destName, {
|
|
2257
|
+
reuseConnection,
|
|
2258
|
+
timeout: options?.timeout,
|
|
2259
|
+
debug,
|
|
2260
|
+
purpose: "sftp",
|
|
2261
|
+
});
|
|
2262
|
+
const srcClient = srcConnection.client;
|
|
2263
|
+
const dstClient = dstConnection.client;
|
|
2264
|
+
srcSftp = await this.openSftp(srcClient, "source", options?.timeout, debug);
|
|
2265
|
+
dstSftp = await this.openSftp(dstClient, "dest", options?.timeout, debug);
|
|
1632
2266
|
// Get source file size before transfer
|
|
1633
2267
|
const srcStat = await this.sftpStat(srcSftp, validatedSourcePath, "source");
|
|
1634
2268
|
// Skip-if-identical: same size on both sides AND matching md5sum (when
|
|
@@ -1645,10 +2279,10 @@ export class SSHConnectionManager {
|
|
|
1645
2279
|
if (srcMd5 && dstMd5 && srcMd5 === dstMd5) {
|
|
1646
2280
|
const srcConfig = this.getConfig(sourceName);
|
|
1647
2281
|
const dstConfig = this.getConfig(destName);
|
|
1648
|
-
return (`Transfer skipped: destination already identical ` +
|
|
2282
|
+
return this.appendDebugOutput(`Transfer skipped: destination already identical ` +
|
|
1649
2283
|
`(size=${srcStat.size} bytes, md5=${srcMd5}). ` +
|
|
1650
2284
|
`${srcConfig.username}@${srcConfig.host}:${validatedSourcePath}` +
|
|
1651
|
-
` == ${dstConfig.username}@${dstConfig.host}:${validatedDestPath}
|
|
2285
|
+
` == ${dstConfig.username}@${dstConfig.host}:${validatedDestPath}`, debugCollector);
|
|
1652
2286
|
}
|
|
1653
2287
|
}
|
|
1654
2288
|
}
|
|
@@ -1664,11 +2298,16 @@ export class SSHConnectionManager {
|
|
|
1664
2298
|
if (settled)
|
|
1665
2299
|
return;
|
|
1666
2300
|
settled = true;
|
|
2301
|
+
if (err) {
|
|
2302
|
+
this.unpipeStream(readStream, writeStream);
|
|
2303
|
+
this.destroyStream(readStream);
|
|
2304
|
+
this.destroyStream(writeStream);
|
|
2305
|
+
}
|
|
1667
2306
|
err ? reject(err) : resolve();
|
|
1668
2307
|
};
|
|
1669
2308
|
writeStream.on("close", () => settle());
|
|
1670
|
-
writeStream.on("error", (err) => settle(
|
|
1671
|
-
readStream.on("error", (err) => settle(
|
|
2309
|
+
writeStream.on("error", (err) => settle(this.makeSftpError("Dest write error", err)));
|
|
2310
|
+
readStream.on("error", (err) => settle(this.makeSftpError("Source read error", err)));
|
|
1672
2311
|
readStream.pipe(writeStream);
|
|
1673
2312
|
});
|
|
1674
2313
|
// --- Verification ---
|
|
@@ -1692,13 +2331,22 @@ export class SSHConnectionManager {
|
|
|
1692
2331
|
}
|
|
1693
2332
|
const srcConfig = this.getConfig(sourceName);
|
|
1694
2333
|
const dstConfig = this.getConfig(destName);
|
|
1695
|
-
return (`Transfer complete (streamed via SFTP, verified: ${verification.join(", ")}): ` +
|
|
2334
|
+
return this.appendDebugOutput(`Transfer complete (streamed via SFTP, verified: ${verification.join(", ")}): ` +
|
|
1696
2335
|
`${srcConfig.username}@${srcConfig.host}:${sourceRemotePath}` +
|
|
1697
|
-
` → ${dstConfig.username}@${dstConfig.host}:${destRemotePath}
|
|
2336
|
+
` → ${dstConfig.username}@${dstConfig.host}:${destRemotePath}`, debugCollector);
|
|
2337
|
+
}
|
|
2338
|
+
catch (error) {
|
|
2339
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
2340
|
+
this.closeClient(sourceName, true);
|
|
2341
|
+
this.closeClient(destName, true);
|
|
2342
|
+
}
|
|
2343
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
1698
2344
|
}
|
|
1699
2345
|
finally {
|
|
1700
|
-
srcSftp
|
|
1701
|
-
dstSftp
|
|
2346
|
+
srcSftp?.end();
|
|
2347
|
+
dstSftp?.end();
|
|
2348
|
+
srcConnection?.close();
|
|
2349
|
+
dstConnection?.close();
|
|
1702
2350
|
}
|
|
1703
2351
|
}
|
|
1704
2352
|
/**
|
|
@@ -1708,7 +2356,7 @@ export class SSHConnectionManager {
|
|
|
1708
2356
|
return new Promise((resolve, reject) => {
|
|
1709
2357
|
sftp.stat(remotePath, (err, stats) => {
|
|
1710
2358
|
if (err) {
|
|
1711
|
-
return reject(
|
|
2359
|
+
return reject(this.makeSftpError(`Failed to stat ${label} file`, err));
|
|
1712
2360
|
}
|
|
1713
2361
|
resolve({ size: stats.size });
|
|
1714
2362
|
});
|
|
@@ -1745,40 +2393,68 @@ export class SSHConnectionManager {
|
|
|
1745
2393
|
/**
|
|
1746
2394
|
* Open an SFTP session from an existing SSH client.
|
|
1747
2395
|
*/
|
|
1748
|
-
openSftp(client, label) {
|
|
1749
|
-
|
|
2396
|
+
openSftp(client, label, timeout, debug) {
|
|
2397
|
+
const open = new Promise((resolve, reject) => {
|
|
1750
2398
|
client.sftp((err, sftp) => {
|
|
1751
2399
|
if (err) {
|
|
1752
|
-
return reject(
|
|
2400
|
+
return reject(this.makeSftpError(`SFTP connection failed (${label})`, err));
|
|
1753
2401
|
}
|
|
2402
|
+
debug?.(`[mcp] sftp channel opened (${label})`);
|
|
1754
2403
|
resolve(sftp);
|
|
1755
2404
|
});
|
|
1756
2405
|
});
|
|
2406
|
+
return this.withConnectionTimeout(open, this.normalizeConnectTimeout(timeout), `SFTP channel open (${label})`, debug, undefined, (sftp) => {
|
|
2407
|
+
try {
|
|
2408
|
+
sftp.end();
|
|
2409
|
+
}
|
|
2410
|
+
catch {
|
|
2411
|
+
// Ignore late SFTP cleanup errors.
|
|
2412
|
+
}
|
|
2413
|
+
});
|
|
1757
2414
|
}
|
|
1758
2415
|
/**
|
|
1759
2416
|
* List remote files/directories via SFTP readdir
|
|
1760
2417
|
*/
|
|
1761
|
-
async listRemoteDir(remotePath, name) {
|
|
1762
|
-
const
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
const entries = list.map((entry) => ({
|
|
1774
|
-
filename: entry.filename,
|
|
1775
|
-
isDirectory: (entry.attrs.mode & 0o40000) !== 0,
|
|
1776
|
-
size: entry.attrs.size,
|
|
1777
|
-
}));
|
|
1778
|
-
resolve(entries);
|
|
1779
|
-
});
|
|
2418
|
+
async listRemoteDir(remotePath, name, options) {
|
|
2419
|
+
const resolvedName = name || this.defaultName;
|
|
2420
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
2421
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
2422
|
+
debug?.(`[mcp] sftp list on [${resolvedName}], reuseConnection=${reuseConnection}`);
|
|
2423
|
+
let connection = null;
|
|
2424
|
+
try {
|
|
2425
|
+
connection = await this.acquireSshClient(resolvedName, {
|
|
2426
|
+
reuseConnection,
|
|
2427
|
+
timeout: options?.timeout,
|
|
2428
|
+
debug,
|
|
2429
|
+
purpose: "sftp",
|
|
1780
2430
|
});
|
|
1781
|
-
|
|
2431
|
+
const entries = await new Promise((resolve, reject) => {
|
|
2432
|
+
this.openSftp(connection.client, "list", options?.timeout, debug).then((sftp) => {
|
|
2433
|
+
sftp.readdir(remotePath, (err, list) => {
|
|
2434
|
+
sftp.end();
|
|
2435
|
+
if (err) {
|
|
2436
|
+
return reject(this.makeSftpError("Failed to list remote directory", err));
|
|
2437
|
+
}
|
|
2438
|
+
const entries = list.map((entry) => ({
|
|
2439
|
+
filename: entry.filename,
|
|
2440
|
+
isDirectory: (entry.attrs.mode & 0o40000) !== 0,
|
|
2441
|
+
size: entry.attrs.size,
|
|
2442
|
+
}));
|
|
2443
|
+
resolve(entries);
|
|
2444
|
+
});
|
|
2445
|
+
}, reject);
|
|
2446
|
+
});
|
|
2447
|
+
return entries;
|
|
2448
|
+
}
|
|
2449
|
+
catch (error) {
|
|
2450
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
2451
|
+
this.closeClient(resolvedName, true);
|
|
2452
|
+
}
|
|
2453
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
2454
|
+
}
|
|
2455
|
+
finally {
|
|
2456
|
+
connection?.close();
|
|
2457
|
+
}
|
|
1782
2458
|
}
|
|
1783
2459
|
/**
|
|
1784
2460
|
* Upload a local directory recursively to a remote server
|
|
@@ -1791,8 +2467,28 @@ export class SSHConnectionManager {
|
|
|
1791
2467
|
throw new ToolError("LOCAL_FILE_READ_FAILED", `Not a directory: ${localDir}`, false);
|
|
1792
2468
|
}
|
|
1793
2469
|
const results = [];
|
|
1794
|
-
const
|
|
1795
|
-
|
|
2470
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
2471
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
2472
|
+
debug?.(`[mcp] sftp recursive upload mkdir on [${resolvedName}], reuseConnection=${reuseConnection}`);
|
|
2473
|
+
let connection = null;
|
|
2474
|
+
try {
|
|
2475
|
+
connection = await this.acquireSshClient(resolvedName, {
|
|
2476
|
+
reuseConnection,
|
|
2477
|
+
timeout: options?.timeout,
|
|
2478
|
+
debug,
|
|
2479
|
+
purpose: "sftp",
|
|
2480
|
+
});
|
|
2481
|
+
await this.sftpMkdirRecursive(connection.client, validatedRemoteDir, options?.timeout, debug);
|
|
2482
|
+
}
|
|
2483
|
+
catch (error) {
|
|
2484
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
2485
|
+
this.closeClient(resolvedName, true);
|
|
2486
|
+
}
|
|
2487
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
2488
|
+
}
|
|
2489
|
+
finally {
|
|
2490
|
+
connection?.close();
|
|
2491
|
+
}
|
|
1796
2492
|
const entries = fs.readdirSync(resolvedLocal, { withFileTypes: true });
|
|
1797
2493
|
for (const entry of entries) {
|
|
1798
2494
|
const localPath = path.join(localDir, entry.name);
|
|
@@ -1811,7 +2507,7 @@ export class SSHConnectionManager {
|
|
|
1811
2507
|
/**
|
|
1812
2508
|
* Download a remote directory recursively to a local path
|
|
1813
2509
|
*/
|
|
1814
|
-
async downloadDirectory(remoteDir, localDir, name) {
|
|
2510
|
+
async downloadDirectory(remoteDir, localDir, name, options) {
|
|
1815
2511
|
const resolvedName = name || this.defaultName;
|
|
1816
2512
|
const resolvedLocal = this.validateLocalPath(localDir, resolvedName);
|
|
1817
2513
|
const validatedRemoteDir = this.validateRemotePath(remoteDir, resolvedName);
|
|
@@ -1819,18 +2515,18 @@ export class SSHConnectionManager {
|
|
|
1819
2515
|
fs.mkdirSync(resolvedLocal, { recursive: true });
|
|
1820
2516
|
}
|
|
1821
2517
|
const results = [];
|
|
1822
|
-
const entries = await this.listRemoteDir(validatedRemoteDir, resolvedName);
|
|
2518
|
+
const entries = await this.listRemoteDir(validatedRemoteDir, resolvedName, options);
|
|
1823
2519
|
for (const entry of entries) {
|
|
1824
2520
|
if (entry.filename === "." || entry.filename === "..")
|
|
1825
2521
|
continue;
|
|
1826
2522
|
const remotePath = `${validatedRemoteDir}/${entry.filename}`;
|
|
1827
2523
|
const localPath = path.join(localDir, entry.filename);
|
|
1828
2524
|
if (entry.isDirectory) {
|
|
1829
|
-
const subResults = await this.downloadDirectory(remotePath, localPath, resolvedName);
|
|
2525
|
+
const subResults = await this.downloadDirectory(remotePath, localPath, resolvedName, options);
|
|
1830
2526
|
results.push(...subResults);
|
|
1831
2527
|
}
|
|
1832
2528
|
else {
|
|
1833
|
-
await this.download(remotePath, localPath, resolvedName);
|
|
2529
|
+
await this.download(remotePath, localPath, resolvedName, options);
|
|
1834
2530
|
results.push(localPath);
|
|
1835
2531
|
}
|
|
1836
2532
|
}
|
|
@@ -1839,27 +2535,23 @@ export class SSHConnectionManager {
|
|
|
1839
2535
|
/**
|
|
1840
2536
|
* Create remote directory recursively via SFTP
|
|
1841
2537
|
*/
|
|
1842
|
-
async sftpMkdirRecursive(client, remotePath) {
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
2538
|
+
async sftpMkdirRecursive(client, remotePath, timeout, debug) {
|
|
2539
|
+
const sftp = await this.openSftp(client, "mkdir", timeout, debug);
|
|
2540
|
+
return new Promise((resolve) => {
|
|
2541
|
+
const parts = remotePath.split("/").filter(Boolean);
|
|
2542
|
+
let current = "";
|
|
2543
|
+
const mkdirNext = (index) => {
|
|
2544
|
+
if (index >= parts.length) {
|
|
2545
|
+
sftp.end();
|
|
2546
|
+
return resolve();
|
|
1847
2547
|
}
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
current += "/" + parts[index];
|
|
1856
|
-
sftp.mkdir(current, (err) => {
|
|
1857
|
-
// EEXIST is fine
|
|
1858
|
-
mkdirNext(index + 1);
|
|
1859
|
-
});
|
|
1860
|
-
};
|
|
1861
|
-
mkdirNext(0);
|
|
1862
|
-
});
|
|
2548
|
+
current += "/" + parts[index];
|
|
2549
|
+
sftp.mkdir(current, () => {
|
|
2550
|
+
// EEXIST is fine
|
|
2551
|
+
mkdirNext(index + 1);
|
|
2552
|
+
});
|
|
2553
|
+
};
|
|
2554
|
+
mkdirNext(0);
|
|
1863
2555
|
});
|
|
1864
2556
|
}
|
|
1865
2557
|
}
|