dsh-remote 0.8.51 → 0.8.53

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
@@ -197,6 +197,8 @@ export class SshPool {
197
197
  this.onReady = null
198
198
  /** Optional hook called when the pool closes. */
199
199
  this.onCloseHook = null
200
+ /** Consecutive SFTP subchannel-open timeouts (issue #65). */
201
+ this._sftpTimeouts = 0
200
202
  }
201
203
 
202
204
  resolveKeyPath() {
@@ -324,6 +326,7 @@ export class SshPool {
324
326
  return
325
327
  }
326
328
  this.client = client
329
+ this._sftpTimeouts = 0
327
330
  resolve(client)
328
331
  if (this.onReady) { try { this.onReady(client) } catch {} }
329
332
  })
@@ -517,6 +520,12 @@ export class SshPool {
517
520
  _execRaw(command, opts, stdinWriter) {
518
521
  const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
519
522
  const signal = opts && opts.signal
523
+ // Streaming tap for background jobs (issue #70): a promoted command's value
524
+ // is its LIVE output, which the job ring must receive as it arrives rather
525
+ // than only as a return value the caller already abandoned. Called with
526
+ // ('stdout'|'stderr', chunk); failures inside the tap must never break the
527
+ // command itself, so every call site guards it.
528
+ const onData = opts && typeof opts.onData === 'function' ? opts.onData : null
520
529
  return this.connect().then(
521
530
  (client) =>
522
531
  new Promise((resolve, reject) => {
@@ -611,9 +620,11 @@ export class SshPool {
611
620
  })
612
621
  stream.on('data', (d) => {
613
622
  if (stdout.length < hardCap) stdout += d
623
+ if (onData) { try { onData('stdout', d) } catch { /* tap errors are not command errors */ } }
614
624
  })
615
625
  stream.stderr.on('data', (d) => {
616
626
  if (stderr.length < hardCap) stderr += d
627
+ if (onData) { try { onData('stderr', d) } catch { /* tap errors are not command errors */ } }
617
628
  })
618
629
  stream.on('error', (e) => {
619
630
  if (settled) return
@@ -684,10 +695,25 @@ export class SshPool {
684
695
  }
685
696
  return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
686
697
  }
698
+ // A successful open proves the connection healthy: the strike
699
+ // counter counts CONSECUTIVE timeouts, so it resets here —
700
+ // two unrelated transient blips hours apart must not tear down
701
+ // a fine connection.
702
+ this._sftpTimeouts = 0
687
703
  // The promisified surface (timeouts, dead-channel fail-fast,
688
704
  // range reads) is shared verbatim with the system-OpenSSH
689
705
  // backend — see lib/sftp-surface.js for the rules it encodes.
690
- resolve(buildSftpSurface(sftp, { commandTimeoutMs: this.config.commandTimeoutMs }))
706
+ resolve(buildSftpSurface(sftp, {
707
+ commandTimeoutMs: this.config.commandTimeoutMs,
708
+ onTimeout: () => {
709
+ // A timed-out SFTP subchannel is dead: close it so the next
710
+ // operation does not queue behind the wedged one, and count
711
+ // the strike so a second consecutive timeout tears down the
712
+ // whole pooled connection (issue #65).
713
+ try { sftp.end() } catch { /* already gone */ }
714
+ this._noteSftpTimeout()
715
+ },
716
+ }))
691
717
  })
692
718
  } catch (e) {
693
719
  clearAttempt()
@@ -699,6 +725,16 @@ export class SshPool {
699
725
  )
700
726
  }
701
727
 
728
+ /**
729
+ * Count a timed-out SFTP subchannel open. A second consecutive timeout means
730
+ * the pooled connection itself is wedged (issue #65): tear it down so the
731
+ * next sftp() call re-handshakes instead of queueing behind the dead one.
732
+ */
733
+ _noteSftpTimeout() {
734
+ this._sftpTimeouts = (this._sftpTimeouts || 0) + 1
735
+ if (this._sftpTimeouts >= 2) this.invalidate()
736
+ }
737
+
702
738
  /**
703
739
  * Drop the cached client and force a fresh connection on the next call.
704
740
  * Called when a channel open fails (e.g. "Channel open failure: open
package/lib/remote-fs.js CHANGED
@@ -98,6 +98,17 @@ export async function removeRemoteTree(sftp, p, maxFiles = 2000) {
98
98
  return removed
99
99
  }
100
100
 
101
+ /** MIME types for image formats the preview panel can render inline. */
102
+ const IMAGE_MIME_TYPES = {
103
+ png: 'image/png',
104
+ jpg: 'image/jpeg',
105
+ jpeg: 'image/jpeg',
106
+ gif: 'image/gif',
107
+ webp: 'image/webp',
108
+ svg: 'image/svg+xml',
109
+ bmp: 'image/bmp',
110
+ }
111
+
101
112
  function looksBinary(buf) {
102
113
  const n = Math.min(buf.length, 8192)
103
114
  for (let i = 0; i < n; i++) if (buf[i] === 0) return true
@@ -107,6 +118,14 @@ function looksBinary(buf) {
107
118
  /**
108
119
  * Read a remote file for the sidebar/editor. Oversized files use a range read
109
120
  * (`readPartial`) so we never `fastGet` the whole blob onto disk.
121
+ *
122
+ * Images are the exception (issue #66): a PREFIX of an image is a broken
123
+ * picture, not a preview, so for a recognised image extension the caller's
124
+ * text-oriented cap is skipped and the file is read whole up to the hard 2MB
125
+ * ceiling; the panel renders it inline as `<img src="data:…">`. An image past
126
+ * the ceiling degrades to the plain binary stub + download button rather than
127
+ * shipping a guaranteed-broken half render. (`<img>` is also the safe sink
128
+ * for SVG: unlike inline markup it cannot run the file's scripts.)
110
129
  */
111
130
  export async function previewRemoteFile(sftp, target, opts = {}) {
112
131
  const {
@@ -114,7 +133,10 @@ export async function previewRemoteFile(sftp, target, opts = {}) {
114
133
  encoding = 'utf-8',
115
134
  decodeBuf = (buf) => buf.toString('utf8'),
116
135
  } = opts
117
- const cap = Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), 2 * 1024 * 1024)
136
+ const hardCap = 2 * 1024 * 1024
137
+ const name = String(target).split(/[\\/]/).pop() || ''
138
+ const imageMime = IMAGE_MIME_TYPES[(name.split('.').pop() || '').toLowerCase()]
139
+ const cap = imageMime ? hardCap : Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), hardCap)
118
140
  const st = await sftp.stat(target)
119
141
  const mtime = st && typeof st.mtime === 'number' ? st.mtime : 0
120
142
  const size = st && typeof st.size === 'number' ? st.size : 0
@@ -131,6 +153,9 @@ export async function previewRemoteFile(sftp, target, opts = {}) {
131
153
  } else {
132
154
  buf = await sftp.readFile(target)
133
155
  }
156
+ if (imageMime && !truncated) {
157
+ return { ok: true, binary: false, image: true, mime: imageMime, content: buf.toString('base64'), truncated, size, mtime }
158
+ }
134
159
  if (looksBinary(buf)) {
135
160
  return {
136
161
  ok: true, binary: true, size, mtime,
@@ -26,7 +26,7 @@ import { toSftpPath } from './paths.js'
26
26
  * @param {number} opts.commandTimeoutMs - per-operation timeout.
27
27
  * @returns {object} promisified sftp surface.
28
28
  */
29
- export function buildSftpSurface(sftp, { commandTimeoutMs }) {
29
+ export function buildSftpSurface(sftp, { commandTimeoutMs, onTimeout }) {
30
30
  // A dead channel must fail fast (see the header). ssh2's SFTP instance and the
31
31
  // system-OpenSSH engine both emit these, so one hook covers both backends.
32
32
  let dead = false
@@ -39,12 +39,19 @@ export function buildSftpSurface(sftp, { commandTimeoutMs }) {
39
39
  const withTimeout = (fn) => (...args) =>
40
40
  new Promise((resolve, reject) => {
41
41
  if (dead) return reject(new Error('sftp channel closed'))
42
- const timer = setTimeout(() => reject(new Error('sftp operation timed out')), commandTimeoutMs)
42
+ let timedOut = false
43
+ const timer = setTimeout(() => {
44
+ timedOut = true
45
+ markDead()
46
+ if (typeof onTimeout === 'function') { try { onTimeout() } catch { /* best effort */ } }
47
+ reject(new Error('sftp operation timed out'))
48
+ }, commandTimeoutMs)
43
49
  const finish = (err, value) => {
44
50
  clearTimeout(timer)
51
+ if (timedOut) return
45
52
  err ? reject(err) : resolve(value)
46
53
  }
47
- try { fn(...args, finish) } catch (err) { clearTimeout(timer); reject(err) }
54
+ try { fn(...args, finish) } catch (err) { clearTimeout(timer); if (!timedOut) reject(err) }
48
55
  })
49
56
  const P = (p) => toSftpPath(p)
50
57
  return {
@@ -55,7 +62,43 @@ export function buildSftpSurface(sftp, { commandTimeoutMs }) {
55
62
  rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
56
63
  unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
57
64
  rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
65
+ // POSIX rename(2) semantics: atomically REPLACE an existing destination.
66
+ // Plain SSH_FXP_RENAME fails when the target exists (OpenSSH's sftp-server
67
+ // enforces that even though it advertises posix-rename@openssh.com — ssh2
68
+ // only sends the extension when asked explicitly, verified against
69
+ // OpenSSH_9.6 in issue #65). Servers with the extension get one atomic
70
+ // request; servers without it get the move-aside dance, whose every hop
71
+ // targets a path that does not exist, which plain RENAME always permits.
72
+ // rw_edit's atomic write depends on this — without it every edit of an
73
+ // existing file fails with a bare "Failure".
74
+ renameOverwrite: (p, d) => withTimeout((a, b, cb) => {
75
+ const ext = sftp._extensions && sftp._extensions['posix-rename@openssh.com']
76
+ if (ext === '1' && typeof sftp.ext_openssh_rename === 'function') {
77
+ return sftp.ext_openssh_rename(a, b, cb)
78
+ }
79
+ const bak = `${b}.dsh-remote-bak-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.tmp`
80
+ sftp.rename(b, bak, (e1) => {
81
+ if (e1) {
82
+ // Destination absent → a plain rename is already overwrite-safe.
83
+ if (/no such/i.test(String((e1 && e1.message) || e1))) return sftp.rename(a, b, cb)
84
+ return cb(e1)
85
+ }
86
+ sftp.rename(a, b, (e2) => {
87
+ if (e2) {
88
+ // Roll the original back into place before reporting the failure.
89
+ return sftp.rename(bak, b, () => cb(e2))
90
+ }
91
+ // Best effort: a leftover backup must not fail the operation.
92
+ sftp.unlink(bak, () => cb(null))
93
+ })
94
+ })
95
+ })(P(p), P(d)),
58
96
  realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
97
+ // Attribute write (chmod semantics live in setstat at the protocol level,
98
+ // so BOTH backends — ssh2's SFTPWrapper and its raw protocol SFTP — answer
99
+ // this one shape). Used by rw_edit to restore the original mode after an
100
+ // atomic rename replace (issue #65).
101
+ setstat: (p, attrs) => withTimeout((d, a, cb) => sftp.setstat(d, a, cb))(P(p), attrs),
59
102
  readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
60
103
  writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
61
104
  fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
package/lib/sshconfig.js CHANGED
@@ -32,11 +32,21 @@ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSy
32
32
  import { homedir } from 'node:os'
33
33
  import path from 'node:path'
34
34
 
35
- /** Default location of the user's ssh config. */
36
- export const sshConfigPath = () => path.join(homedir(), '.ssh', 'config')
35
+ /** Default location of the user's ssh config. An env override exists so tests
36
+ * (and any deployment that isolates its home directory) can point the plugin
37
+ * at a substitute file instead of the real ~/.ssh/config. */
38
+ export const sshConfigPath = () => {
39
+ const env = process.env.DSH_REMOTE_SSH_CONFIG
40
+ if (env && String(env).trim()) return path.resolve(String(env).trim())
41
+ return path.join(homedir(), '.ssh', 'config')
42
+ }
37
43
 
38
44
  /** Directory `Include` paths are resolved against (ssh_config(5)). */
39
- export const sshConfigDir = () => path.join(homedir(), '.ssh')
45
+ export const sshConfigDir = () => {
46
+ const env = process.env.DSH_REMOTE_SSH_CONFIG
47
+ if (env && String(env).trim()) return path.dirname(path.resolve(String(env).trim()))
48
+ return path.join(homedir(), '.ssh')
49
+ }
40
50
 
41
51
  /** Parameters the resolver cares about, lowercase (as parsed). */
42
52
  const KNOWN_PARAMS = new Set([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.51",
3
+ "version": "0.8.53",
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>",