dsh-remote 0.8.42 → 0.8.44
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.en.md +5 -0
- package/README.md +5 -0
- package/lib/client.js +334 -10
- package/lib/forwards.js +22 -0
- package/lib/index.js +228 -30
- package/lib/openssh.js +838 -0
- package/lib/pool.js +139 -54
- package/lib/registry.js +6 -0
- package/lib/sftp-surface.js +76 -0
- package/package.json +2 -1
package/lib/pool.js
CHANGED
|
@@ -3,11 +3,140 @@ import ssh2 from 'ssh2'
|
|
|
3
3
|
import { readFileSync } from 'node:fs'
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
import path from 'node:path'
|
|
6
|
-
import {
|
|
6
|
+
import { truncate } from './paths.js'
|
|
7
7
|
import { createHostKeyGuard } from './hostkey.js'
|
|
8
|
+
import { buildSftpSurface } from './sftp-surface.js'
|
|
9
|
+
import { SystemOpenSshPool, isSystemOpenSshBackend } from './openssh.js'
|
|
8
10
|
|
|
9
11
|
const { Client } = ssh2
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Build the pool for a config.
|
|
15
|
+
*
|
|
16
|
+
* Two backends exist (issue #51): the native one below, which speaks SSH itself
|
|
17
|
+
* via ssh2, and the "system OpenSSH" one (lib/openssh.js), which delegates to
|
|
18
|
+
* the local `ssh` executable so GSSAPI/Kerberos and the rest of the user's
|
|
19
|
+
* `~/.ssh/config` are honoured. Both expose one identical surface, so every
|
|
20
|
+
* caller can keep using `new SshPool(...)`-shaped code — this factory only has
|
|
21
|
+
* to decide which class to instantiate.
|
|
22
|
+
*/
|
|
23
|
+
export function createPool(config, opts) {
|
|
24
|
+
return isSystemOpenSshBackend(config)
|
|
25
|
+
? new SystemOpenSshPool(config, opts)
|
|
26
|
+
: new SshPool(config, opts)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The ACTIVE pool: one long-lived object whose concrete implementation can be
|
|
31
|
+
* swapped when the current machine's transport backend changes.
|
|
32
|
+
*
|
|
33
|
+
* Why a delegating facade instead of just constructing the right class once:
|
|
34
|
+
* `backend` is a PER-MACHINE field (an enterprise Kerberos host needs
|
|
35
|
+
* `openssh` while every other machine stays on `native`), but the plugin keeps
|
|
36
|
+
* exactly one active pool for its whole lifetime and hangs `passwordResolver` /
|
|
37
|
+
* `onReady` / `onCloseHook` off it once at startup. Replacing the object
|
|
38
|
+
* wholesale on every machine switch would silently drop those hooks — the port
|
|
39
|
+
* forwards and keychain passwords would stop working after the first switch.
|
|
40
|
+
*
|
|
41
|
+
* So the facade owns the hooks and the shared `config` object, and forwards
|
|
42
|
+
* them to whichever concrete pool is right for the current config. Everything
|
|
43
|
+
* else is a straight delegation, which is why callers cannot tell the
|
|
44
|
+
* difference (and why the class is deliberately not part of the public story).
|
|
45
|
+
*/
|
|
46
|
+
export class ActivePool {
|
|
47
|
+
constructor(config, opts = {}) {
|
|
48
|
+
this.config = config
|
|
49
|
+
this.opts = opts
|
|
50
|
+
// The hooks are assigned by the plugin AFTER construction, so they are
|
|
51
|
+
// stored here and re-applied to every delegate this facade ever creates.
|
|
52
|
+
this._passwordResolver = null
|
|
53
|
+
this._onReady = null
|
|
54
|
+
this._onCloseHook = null
|
|
55
|
+
this._delegate = null
|
|
56
|
+
this._sync()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Which concrete class the current config asks for. */
|
|
60
|
+
_wantsSystemOpenSsh() {
|
|
61
|
+
return isSystemOpenSshBackend(this.config)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_delegateIsCurrent() {
|
|
65
|
+
if (!this._delegate) return false
|
|
66
|
+
const isOpenssh = this._delegate instanceof SystemOpenSshPool
|
|
67
|
+
return isOpenssh === this._wantsSystemOpenSsh()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Create (or replace) the delegate so it matches the current config. */
|
|
71
|
+
_sync() {
|
|
72
|
+
if (this._delegateIsCurrent()) return this._delegate
|
|
73
|
+
const previous = this._delegate
|
|
74
|
+
const next = createPool(this.config, this.opts)
|
|
75
|
+
next.passwordResolver = this._passwordResolver
|
|
76
|
+
next.onReady = this._onReady
|
|
77
|
+
next.onCloseHook = this._onCloseHook
|
|
78
|
+
this._delegate = next
|
|
79
|
+
// Tear the old backend down LAST, and never let a teardown error mask the
|
|
80
|
+
// new pool: the connection it was holding belongs to the old host anyway.
|
|
81
|
+
if (previous) { try { previous.close() } catch { /* already gone */ } }
|
|
82
|
+
return next
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get delegate() { return this._sync() }
|
|
86
|
+
|
|
87
|
+
// ── hooks: stored here, mirrored onto every delegate ──────────────────────
|
|
88
|
+
get passwordResolver() { return this._passwordResolver }
|
|
89
|
+
set passwordResolver(fn) {
|
|
90
|
+
this._passwordResolver = fn
|
|
91
|
+
if (this._delegate) this._delegate.passwordResolver = fn
|
|
92
|
+
}
|
|
93
|
+
get onReady() { return this._onReady }
|
|
94
|
+
set onReady(fn) {
|
|
95
|
+
this._onReady = fn
|
|
96
|
+
if (this._delegate) this._delegate.onReady = fn
|
|
97
|
+
}
|
|
98
|
+
get onCloseHook() { return this._onCloseHook }
|
|
99
|
+
set onCloseHook(fn) {
|
|
100
|
+
this._onCloseHook = fn
|
|
101
|
+
if (this._delegate) this._delegate.onCloseHook = fn
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── pass-through state the plugin reads directly ──────────────────────────
|
|
105
|
+
get client() { return this._delegate ? this._delegate.client : null }
|
|
106
|
+
// Assignable because the plugin's own tests arm a fake live connection on the
|
|
107
|
+
// active pool; a getter-only facade would make that assignment a silent
|
|
108
|
+
// no-op in sloppy mode and a TypeError in the strict-mode ESM tests.
|
|
109
|
+
set client(v) { if (this._sync()) this._delegate.client = v }
|
|
110
|
+
get connecting() { return this._delegate ? this._delegate.connecting : null }
|
|
111
|
+
get proxyPool() { return this._delegate ? this._delegate.proxyPool : null }
|
|
112
|
+
get epoch() { return this._delegate ? this._delegate.epoch : 0 }
|
|
113
|
+
get platform() { return this._delegate ? this._delegate.platform : 'unknown' }
|
|
114
|
+
set platform(v) { if (this._sync()) this._delegate.platform = v }
|
|
115
|
+
get gitBashPath() { return this._delegate ? this._delegate.gitBashPath : '' }
|
|
116
|
+
set gitBashPath(v) { if (this._sync()) this._delegate.gitBashPath = v }
|
|
117
|
+
get shellMode() { return this._delegate ? this._delegate.shellMode : 'native' }
|
|
118
|
+
set shellMode(v) { if (this._sync()) this._delegate.shellMode = v }
|
|
119
|
+
|
|
120
|
+
// ── delegated operations ──────────────────────────────────────────────────
|
|
121
|
+
/** Re-point at a new target, switching backend first if the machine asks. */
|
|
122
|
+
setTarget(fields, previous) {
|
|
123
|
+
// The backend may have changed with the machine, so apply that part of the
|
|
124
|
+
// request to `config` BEFORE picking the delegate: _sync() chooses the class
|
|
125
|
+
// from config.backend, and the old backend must not be handed a host it can
|
|
126
|
+
// no longer reach. The delegate then receives the whole request as usual.
|
|
127
|
+
if (fields && fields.backend !== undefined) this.config.backend = String(fields.backend)
|
|
128
|
+
if (fields && fields.sshBinary !== undefined) this.config.sshBinary = String(fields.sshBinary)
|
|
129
|
+
const d = this._sync()
|
|
130
|
+
return d.setTarget(fields, previous)
|
|
131
|
+
}
|
|
132
|
+
connect() { return this.delegate.connect() }
|
|
133
|
+
exec(cmd, opts) { return this.delegate.exec(cmd, opts) }
|
|
134
|
+
sftp() { return this.delegate.sftp() }
|
|
135
|
+
detect() { return this.delegate.detect() }
|
|
136
|
+
invalidate() { return this.delegate.invalidate() }
|
|
137
|
+
close() { return this.delegate.close() }
|
|
138
|
+
}
|
|
139
|
+
|
|
11
140
|
export class SshPool {
|
|
12
141
|
constructor(config, opts = {}) {
|
|
13
142
|
this.config = config
|
|
@@ -41,7 +170,7 @@ export class SshPool {
|
|
|
41
170
|
return p
|
|
42
171
|
}
|
|
43
172
|
|
|
44
|
-
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }, previous) {
|
|
173
|
+
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, sshBinary }, previous) {
|
|
45
174
|
// Collect the requested changes first: an UNCHANGED target is a no-op. The
|
|
46
175
|
// old behavior closed the live connection and re-ran platform detection
|
|
47
176
|
// even for an identical target, so every picker keystroke
|
|
@@ -68,6 +197,10 @@ export class SshPool {
|
|
|
68
197
|
if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
|
|
69
198
|
if (proxy !== undefined) wanted.push(['proxy', proxy])
|
|
70
199
|
if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
|
|
200
|
+
// Issue #51: the transport backend is part of the target identity — changing
|
|
201
|
+
// it must close the live connection exactly like changing the host does.
|
|
202
|
+
if (backend !== undefined) wanted.push(['backend', String(backend)])
|
|
203
|
+
if (sshBinary !== undefined) wanted.push(['sshBinary', String(sshBinary)])
|
|
71
204
|
const differs = ([k, v]) => (k === 'proxy'
|
|
72
205
|
? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
|
|
73
206
|
: base[k] !== v)
|
|
@@ -513,58 +646,10 @@ export class SshPool {
|
|
|
513
646
|
}
|
|
514
647
|
return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
|
|
515
648
|
}
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
// below each such call waits out the full commandTimeoutMs — so a
|
|
521
|
-
// tree walk over N entries costs N × 2 × timeout, i.e. days on a
|
|
522
|
-
// large directory (issue #44). Mark the channel dead on EOF/close
|
|
523
|
-
// and reject immediately from then on.
|
|
524
|
-
let dead = false
|
|
525
|
-
const markDead = () => { dead = true }
|
|
526
|
-
if (typeof sftp.on === 'function') {
|
|
527
|
-
sftp.on('end', markDead)
|
|
528
|
-
sftp.on('close', markDead)
|
|
529
|
-
sftp.on('error', markDead)
|
|
530
|
-
}
|
|
531
|
-
const withTimeout = (fn) => (...args) =>
|
|
532
|
-
new Promise((r2, j2) => {
|
|
533
|
-
if (dead) return j2(new Error('sftp channel closed'))
|
|
534
|
-
const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
|
|
535
|
-
const finish = (e, v) => {
|
|
536
|
-
clearTimeout(timer)
|
|
537
|
-
e ? j2(e) : r2(v)
|
|
538
|
-
}
|
|
539
|
-
try { fn(...args, finish) } catch (e) { clearTimeout(timer); j2(e) }
|
|
540
|
-
})
|
|
541
|
-
const P = (p) => toSftpPath(p)
|
|
542
|
-
resolve({
|
|
543
|
-
readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
|
|
544
|
-
stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
|
|
545
|
-
lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
|
|
546
|
-
mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
|
|
547
|
-
rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
|
|
548
|
-
unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
|
|
549
|
-
rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
|
|
550
|
-
realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
|
|
551
|
-
readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
|
|
552
|
-
writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
|
|
553
|
-
fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
|
|
554
|
-
fastPut: (lp, p) => withTimeout((l, d, cb) => sftp.fastPut(l, d, cb))(lp, P(p)),
|
|
555
|
-
// Range read for oversized previews: never downloads the whole file.
|
|
556
|
-
readPartial: (p, offset, length) => withTimeout((d, off, len, cb) => {
|
|
557
|
-
sftp.open(d, 'r', (e, handle) => {
|
|
558
|
-
if (e) return cb(e)
|
|
559
|
-
const buf = Buffer.alloc(len)
|
|
560
|
-
sftp.read(handle, buf, 0, len, off, (e2, bytesRead, data) => {
|
|
561
|
-
sftp.close(handle, () => {})
|
|
562
|
-
if (e2) return cb(e2)
|
|
563
|
-
cb(null, Buffer.isBuffer(data) ? data.subarray(0, bytesRead) : buf.subarray(0, bytesRead))
|
|
564
|
-
})
|
|
565
|
-
})
|
|
566
|
-
})(P(p), offset, length),
|
|
567
|
-
})
|
|
649
|
+
// The promisified surface (timeouts, dead-channel fail-fast,
|
|
650
|
+
// range reads) is shared verbatim with the system-OpenSSH
|
|
651
|
+
// backend — see lib/sftp-surface.js for the rules it encodes.
|
|
652
|
+
resolve(buildSftpSurface(sftp, { commandTimeoutMs: this.config.commandTimeoutMs }))
|
|
568
653
|
})
|
|
569
654
|
} catch (e) {
|
|
570
655
|
clearAttempt()
|
package/lib/registry.js
CHANGED
|
@@ -80,6 +80,12 @@ export function applyMachine(config, m) {
|
|
|
80
80
|
if (m.hostKeyMode) config.hostKeyMode = m.hostKeyMode
|
|
81
81
|
if (typeof m.useAgent === 'boolean') config.useAgent = m.useAgent
|
|
82
82
|
if (typeof m.keyboardInteractive === 'boolean') config.keyboardInteractive = m.keyboardInteractive
|
|
83
|
+
// Issue #51: the transport backend is PER MACHINE. An enterprise box that only
|
|
84
|
+
// accepts Kerberos must go through the system OpenSSH backend while every
|
|
85
|
+
// other machine can stay on the native one, so this cannot be a purely global
|
|
86
|
+
// default.
|
|
87
|
+
if (m.backend) config.backend = m.backend
|
|
88
|
+
if (typeof m.sshBinary === 'string') config.sshBinary = m.sshBinary
|
|
83
89
|
config.proxy = m.proxy && m.proxy.host ? m.proxy : undefined
|
|
84
90
|
}
|
|
85
91
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// dsh-remote — the promisified SFTP surface every backend presents.
|
|
2
|
+
//
|
|
3
|
+
// Both SSH transports (the native ssh2 pool in lib/pool.js and the
|
|
4
|
+
// system-OpenSSH pool in lib/openssh.js) must expose the SAME object to
|
|
5
|
+
// callers: lib/sync.js, lib/search.js, lib/routes-fs.js, lib/remote-fs.js and
|
|
6
|
+
// the rw_* tools all consume this exact shape. Keeping one implementation
|
|
7
|
+
// matters more than it looks: the timeout, dead-channel and range-read rules
|
|
8
|
+
// below encode failures that were expensive to find (issue #44), and a second
|
|
9
|
+
// hand-copied version would drift away from the fixed one.
|
|
10
|
+
//
|
|
11
|
+
// The two rules that are easy to get wrong:
|
|
12
|
+
// • A dead channel must fail FAST. ssh2's own EOF handling only rejects the
|
|
13
|
+
// requests pending at that moment; every later request is registered and
|
|
14
|
+
// never answered, so each waits out the full commandTimeoutMs. Over a tree
|
|
15
|
+
// walk that is N × 2 × timeout — days on a large directory.
|
|
16
|
+
// • `readPartial` exists so an oversized preview never downloads the whole
|
|
17
|
+
// file to show its head.
|
|
18
|
+
import { toSftpPath } from './paths.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Wrap a live SFTP channel (ssh2's SFTP instance) in the promisified surface
|
|
22
|
+
* the plugin's callers expect.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} sftp - an ssh2 SFTP channel (already negotiated/ready).
|
|
25
|
+
* @param {object} opts
|
|
26
|
+
* @param {number} opts.commandTimeoutMs - per-operation timeout.
|
|
27
|
+
* @returns {object} promisified sftp surface.
|
|
28
|
+
*/
|
|
29
|
+
export function buildSftpSurface(sftp, { commandTimeoutMs }) {
|
|
30
|
+
// A dead channel must fail fast (see the header). ssh2's SFTP instance and the
|
|
31
|
+
// system-OpenSSH engine both emit these, so one hook covers both backends.
|
|
32
|
+
let dead = false
|
|
33
|
+
const markDead = () => { dead = true }
|
|
34
|
+
if (typeof sftp.on === 'function') {
|
|
35
|
+
sftp.on('end', markDead)
|
|
36
|
+
sftp.on('close', markDead)
|
|
37
|
+
sftp.on('error', markDead)
|
|
38
|
+
}
|
|
39
|
+
const withTimeout = (fn) => (...args) =>
|
|
40
|
+
new Promise((resolve, reject) => {
|
|
41
|
+
if (dead) return reject(new Error('sftp channel closed'))
|
|
42
|
+
const timer = setTimeout(() => reject(new Error('sftp operation timed out')), commandTimeoutMs)
|
|
43
|
+
const finish = (err, value) => {
|
|
44
|
+
clearTimeout(timer)
|
|
45
|
+
err ? reject(err) : resolve(value)
|
|
46
|
+
}
|
|
47
|
+
try { fn(...args, finish) } catch (err) { clearTimeout(timer); reject(err) }
|
|
48
|
+
})
|
|
49
|
+
const P = (p) => toSftpPath(p)
|
|
50
|
+
return {
|
|
51
|
+
readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
|
|
52
|
+
stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
|
|
53
|
+
lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
|
|
54
|
+
mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
|
|
55
|
+
rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
|
|
56
|
+
unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
|
|
57
|
+
rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
|
|
58
|
+
realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
|
|
59
|
+
readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
|
|
60
|
+
writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
|
|
61
|
+
fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
|
|
62
|
+
fastPut: (lp, p) => withTimeout((l, d, cb) => sftp.fastPut(l, d, cb))(lp, P(p)),
|
|
63
|
+
// Range read for oversized previews: never downloads the whole file.
|
|
64
|
+
readPartial: (p, offset, length) => withTimeout((d, off, len, cb) => {
|
|
65
|
+
sftp.open(d, 'r', (e, handle) => {
|
|
66
|
+
if (e) return cb(e)
|
|
67
|
+
const buf = Buffer.alloc(len)
|
|
68
|
+
sftp.read(handle, buf, 0, len, off, (e2, bytesRead, data) => {
|
|
69
|
+
sftp.close(handle, () => {})
|
|
70
|
+
if (e2) return cb(e2)
|
|
71
|
+
cb(null, Buffer.isBuffer(data) ? data.subarray(0, bytesRead) : buf.subarray(0, bytesRead))
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
})(P(p), offset, length),
|
|
75
|
+
}
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.44",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 22 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, one-click remote dsh deploy + web UI attach, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
|
|
81
81
|
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.8",
|
|
82
82
|
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
|
83
|
+
"react": "^18.3.1",
|
|
83
84
|
"semver": "^7.8.5"
|
|
84
85
|
},
|
|
85
86
|
"scripts": {
|