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/README.en.md +11 -0
- package/README.md +43 -0
- package/lib/client.js +15 -3
- package/lib/exec-job.js +221 -0
- package/lib/index.js +349 -18
- package/lib/instructions-dynamic.js +336 -0
- package/lib/instructions.js +288 -0
- package/lib/openssh.js +10 -1
- package/lib/pool.js +37 -1
- package/lib/remote-fs.js +26 -1
- package/lib/sftp-surface.js +46 -3
- package/lib/sshconfig.js +13 -3
- package/package.json +1 -1
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, {
|
|
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
|
|
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,
|
package/lib/sftp-surface.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = () =>
|
|
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.
|
|
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>",
|