dsh-remote 0.8.41 → 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/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 { toSftpPath, truncate } from './paths.js'
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
- // A dead channel must fail fast. ssh2's own EOF handling only
517
- // rejects the requests that were pending at that moment
518
- // (SFTP.js cleanupRequests); every request issued afterwards is
519
- // registered in `_requests` and never answered. Without the flag
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.41",
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": {