Skip to content

Commit e9d9bc2

Browse files
committed
fix(ssh-manager): add sftp + dual-mode forwardOut passthroughs to fix ssh_upload, ssh_download, ssh_deploy, ssh_tunnel_create which all expect the ssh2 Client surface on the wrapper. Rename internal cached sftp handle to _sftpHandle so it doesnt shadow the new sftp() method. Expand regression test to cover sftp + both forwardOut styles (callback for tunnels, Promise for proxy jumps).
1 parent b252458 commit e9d9bc2

File tree

2 files changed

+77
-30
lines changed

2 files changed

+77
-30
lines changed

src/ssh-manager.js

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class SSHManager {
1212
this.config = config;
1313
this.client = new Client();
1414
this.connected = false;
15-
this.sftp = null;
15+
this._sftpHandle = null; // cached SFTP subsystem; do not collide with sftp() passthrough
1616
this.cachedHomeDir = null;
1717
this.autoAcceptHostKey = config.autoAcceptHostKey || false;
1818
this.hostKeyVerification = config.hostKeyVerification !== false; // Default true
@@ -125,16 +125,22 @@ class SSHManager {
125125
});
126126
}
127127

128-
// Pass-through to ssh2 Client.exec so callers that hold an SSHManager can
129-
// use streaming exec helpers (stream-exec.js, tail-tools.js) which expect
130-
// a node-ssh2 Client interface.
128+
// Pass-throughs to the underlying ssh2 Client. Modular tool handlers
129+
// (transfer-tools, deploy-tools, stream-exec, tail-tools) expect the
130+
// node-ssh2 Client surface (.exec, .sftp, .forwardOut), but
131+
// getConnection() returns this SSHManager wrapper. Without these
132+
// shims every exec/sftp call fails with "client.{exec,sftp} is not a function".
131133
exec(command, opts, cb) {
132134
if (typeof opts === 'function') { cb = opts; opts = undefined; }
133135
return opts !== undefined
134136
? this.client.exec(command, opts, cb)
135137
: this.client.exec(command, cb);
136138
}
137139

140+
sftp(cb) {
141+
return this.client.sftp(cb);
142+
}
143+
138144
async execCommand(command, options = {}) {
139145
if (!this.connected) {
140146
throw new Error('Not connected to SSH server');
@@ -284,15 +290,15 @@ class SSHManager {
284290
}
285291

286292
async getSFTP() {
287-
if (this.sftp) return this.sftp;
293+
if (this._sftpHandle) return this._sftpHandle;
288294

289295
return new Promise((resolve, reject) => {
290296
this.client.sftp((err, sftp) => {
291297
if (err) {
292298
reject(err);
293299
return;
294300
}
295-
this.sftp = sftp;
301+
this._sftpHandle = sftp;
296302
resolve(sftp);
297303
});
298304
});
@@ -458,19 +464,28 @@ class SSHManager {
458464
}
459465

460466
dispose() {
461-
if (this.sftp) {
462-
this.sftp.end();
463-
this.sftp = null;
467+
if (this._sftpHandle) {
468+
this._sftpHandle.end();
469+
this._sftpHandle = null;
464470
}
465471
if (this.client) {
466472
this.client.end();
467473
this.connected = false;
468474
}
469475
}
470476

471-
async forwardOut(srcAddr, srcPort, dstAddr, dstPort) {
477+
// Dual-mode: callback-style (matches ssh2 Client surface used by
478+
// tunnel-tools.js) AND Promise-style (used by index.js for proxy jumps).
479+
// Detect by presence of a 5th function arg.
480+
forwardOut(srcAddr, srcPort, dstAddr, dstPort, cb) {
481+
if (typeof cb === 'function') {
482+
if (!this.connected) {
483+
return cb(new Error('Not connected to SSH server'));
484+
}
485+
return this.client.forwardOut(srcAddr, srcPort, dstAddr, dstPort, cb);
486+
}
472487
if (!this.connected) {
473-
throw new Error('Not connected to SSH server');
488+
return Promise.reject(new Error('Not connected to SSH server'));
474489
}
475490
return new Promise((resolve, reject) => {
476491
this.client.forwardOut(srcAddr, srcPort, dstAddr, dstPort, (err, stream) => {
Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
// Regression test: the modular tool handlers (exec, db, deploy, monitoring,
2-
// systemctl, journalctl, cat, tail, transfer, etc.) all pass the SSHManager
3-
// instance returned by getConnection() into stream-exec.js's streamExecCommand,
4-
// which then calls `client.exec(...)`. SSHManager wraps an ssh2 Client; if
5-
// .exec isn't exposed as a passthrough, every tool fails at runtime with
6-
// "client.exec is not a function" while every unit test still passes.
7-
//
8-
// This test asserts the passthrough exists and forwards arguments correctly.
2+
// systemctl, journalctl, cat, tail, transfer, tunnels, etc.) all expect the
3+
// node-ssh2 Client surface (.exec, .sftp, .forwardOut), but getConnection()
4+
// returns an SSHManager wrapper. Without passthroughs every call fails at
5+
// runtime with "client.{exec,sftp,forwardOut} is not a function" or hangs
6+
// silently — while every existing unit test passes (they mock SSH).
97

108
import SSHManager from '../src/ssh-manager.js';
119

@@ -17,28 +15,62 @@ function assert(cond, msg) {
1715
else { console.log(` [FAIL] ${msg}`); failed++; }
1816
}
1917

20-
// 1. The method exists
2118
const mgr = new SSHManager({ host: 'x', user: 'x' });
19+
mgr.connected = true;
20+
21+
// --- exec passthrough ---
2222
assert(typeof mgr.exec === 'function', 'SSHManager.exec is a function');
2323

24-
// 2. It forwards (cmd, cb) to the underlying ssh2 client
2524
let captured = null;
2625
mgr.client = {
27-
exec(cmd, optsOrCb, maybeCb) {
28-
captured = { cmd, optsOrCb, maybeCb };
29-
},
26+
exec(cmd, optsOrCb, maybeCb) { captured = { cmd, optsOrCb, maybeCb }; },
27+
sftp(cb) { captured = { sftpCb: cb }; },
28+
forwardOut(srcA, srcP, dstA, dstP, cb) { captured = { srcA, srcP, dstA, dstP, cb }; },
3029
};
30+
3131
mgr.exec('uname -a', () => {});
32-
assert(captured?.cmd === 'uname -a', 'forwards command string');
33-
assert(typeof captured?.optsOrCb === 'function', 'forwards callback as 2nd arg when no options given');
34-
assert(captured?.maybeCb === undefined, 'no 3rd arg when no options given');
32+
assert(captured?.cmd === 'uname -a', 'exec forwards command string');
33+
assert(typeof captured?.optsOrCb === 'function', 'exec forwards callback as 2nd arg without options');
34+
assert(captured?.maybeCb === undefined, 'exec no 3rd arg without options');
3535

36-
// 3. It forwards (cmd, opts, cb) when options are provided
3736
captured = null;
3837
mgr.exec('echo hi', { pty: true }, () => {});
39-
assert(captured?.cmd === 'echo hi', 'forwards command with options');
40-
assert(captured?.optsOrCb?.pty === true, 'forwards options object');
41-
assert(typeof captured?.maybeCb === 'function', 'forwards callback as 3rd arg when options given');
38+
assert(captured?.cmd === 'echo hi', 'exec forwards command with options');
39+
assert(captured?.optsOrCb?.pty === true, 'exec forwards options object');
40+
assert(typeof captured?.maybeCb === 'function', 'exec forwards callback as 3rd arg with options');
41+
42+
// --- sftp passthrough ---
43+
assert(typeof mgr.sftp === 'function', 'SSHManager.sftp is a function');
44+
captured = null;
45+
const sftpCb = (err, sftp) => {};
46+
mgr.sftp(sftpCb);
47+
assert(captured?.sftpCb === sftpCb, 'sftp forwards callback to underlying client');
48+
49+
// --- forwardOut callback-style (used by tunnel-tools.js) ---
50+
captured = null;
51+
const fwdCb = () => {};
52+
mgr.forwardOut('127.0.0.1', 1234, 'remote.host', 22, fwdCb);
53+
assert(captured?.cb === fwdCb, 'forwardOut callback-style passes cb to underlying client');
54+
assert(captured?.srcA === '127.0.0.1' && captured?.dstP === 22, 'forwardOut callback-style passes src/dst args');
55+
56+
// --- forwardOut Promise-style (used by index.js for proxy jumps) ---
57+
let resolvedStream = null;
58+
mgr.client.forwardOut = (srcA, srcP, dstA, dstP, cb) => cb(null, { tag: 'mockStream' });
59+
const promise = mgr.forwardOut('127.0.0.1', 0, 'jump.host', 22);
60+
assert(promise && typeof promise.then === 'function', 'forwardOut Promise-style returns a Promise');
61+
await promise.then(s => { resolvedStream = s; });
62+
assert(resolvedStream?.tag === 'mockStream', 'forwardOut Promise-style resolves with the stream');
63+
64+
// --- error paths ---
65+
mgr.connected = false;
66+
const fwdRejection = mgr.forwardOut('127.0.0.1', 0, 'jump.host', 22);
67+
let rejected = false;
68+
await fwdRejection.catch(() => { rejected = true; });
69+
assert(rejected, 'forwardOut Promise-style rejects when not connected');
70+
71+
let cbErr = null;
72+
mgr.forwardOut('127.0.0.1', 0, 'h', 22, e => { cbErr = e; });
73+
assert(cbErr instanceof Error, 'forwardOut callback-style invokes cb with error when not connected');
4274

4375
console.log(`\n${passed} passed, ${failed} failed`);
4476
process.exit(failed === 0 ? 0 : 1);

0 commit comments

Comments
 (0)