dsh-remote 0.7.2 → 0.7.4
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/client.js +12 -0
- package/lib/index.js +146 -72
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -140,6 +140,13 @@ window.__ModuleLoader__.load({
|
|
|
140
140
|
),
|
|
141
141
|
),
|
|
142
142
|
err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
|
|
143
|
+
React.createElement('div', { style: { display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', fontSize: 11, opacity: 0.65, borderTop: '1px solid ' + T.border, paddingTop: 10, marginTop: 4 } },
|
|
144
|
+
React.createElement('span', {}, '觉得好用?'),
|
|
145
|
+
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ 去 GitHub 点个 Star'),
|
|
146
|
+
React.createElement('span', {}, '·'),
|
|
147
|
+
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '💬 反馈建议 / 提 issue'),
|
|
148
|
+
React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v0.7.4'),
|
|
149
|
+
),
|
|
143
150
|
)
|
|
144
151
|
}
|
|
145
152
|
|
|
@@ -451,6 +458,11 @@ window.__ModuleLoader__.load({
|
|
|
451
458
|
),
|
|
452
459
|
),
|
|
453
460
|
(tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
|
|
461
|
+
React.createElement('div', { style: { display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center', paddingTop: 6, fontSize: 10, opacity: 0.5 } },
|
|
462
|
+
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ Star dsh-remote'),
|
|
463
|
+
React.createElement('span', {}, '·'),
|
|
464
|
+
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '提建议 / 报问题'),
|
|
465
|
+
),
|
|
454
466
|
),
|
|
455
467
|
)
|
|
456
468
|
}
|
package/lib/index.js
CHANGED
|
@@ -545,6 +545,96 @@ function isHostKeyKnown(host, port) {
|
|
|
545
545
|
|
|
546
546
|
// ── SSH pool (key OR password) ──────────────────────────────────────────────
|
|
547
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Wrap a raw ssh2 SFTP handle so a stuck server can never hang a tool call:
|
|
550
|
+
* every method is bounded by the configured command timeout, and path args
|
|
551
|
+
* are normalized via toSftpPath() so Windows remotes (drive-letter paths)
|
|
552
|
+
* work too.
|
|
553
|
+
*/
|
|
554
|
+
function wrapSftp(client, sftp, config) {
|
|
555
|
+
const withTimeout = (fn) => (...args) =>
|
|
556
|
+
new Promise((r2, j2) => {
|
|
557
|
+
const timer = setTimeout(() => j2(new Error('sftp operation timed out')), config.commandTimeoutMs)
|
|
558
|
+
const done = (e, v) => {
|
|
559
|
+
clearTimeout(timer)
|
|
560
|
+
e ? j2(e) : r2(v)
|
|
561
|
+
}
|
|
562
|
+
try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
|
|
563
|
+
})
|
|
564
|
+
const P = (p) => toSftpPath(p)
|
|
565
|
+
return {
|
|
566
|
+
readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
|
|
567
|
+
stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
|
|
568
|
+
lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
|
|
569
|
+
mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
|
|
570
|
+
readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
|
|
571
|
+
writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Run one exec stream to completion: collects stdout/stderr (bounded), settles
|
|
577
|
+
* on channel close or the configured command timeout. On timeout the remote
|
|
578
|
+
* command gets SIGTERM and the channel is hard-closed so a runaway process
|
|
579
|
+
* cannot keep holding the SSH connection.
|
|
580
|
+
*/
|
|
581
|
+
function runStream(client, stream, config, timeoutMs) {
|
|
582
|
+
return new Promise((resolve, reject) => {
|
|
583
|
+
let stdout = ''
|
|
584
|
+
let stderr = ''
|
|
585
|
+
let settled = false
|
|
586
|
+
let exitCode = null
|
|
587
|
+
let exitSignal = null
|
|
588
|
+
const hardCap = Math.max(config.maxOutputChars * 4, 1024 * 1024)
|
|
589
|
+
const settle = () => {
|
|
590
|
+
if (settled) return
|
|
591
|
+
settled = true
|
|
592
|
+
clearTimeout(timer)
|
|
593
|
+
resolve({
|
|
594
|
+
code: exitCode,
|
|
595
|
+
signal: exitSignal,
|
|
596
|
+
stdout: truncate(stdout, config.maxOutputChars),
|
|
597
|
+
stderr: truncate(stderr, config.maxOutputChars),
|
|
598
|
+
})
|
|
599
|
+
}
|
|
600
|
+
const timer = setTimeout(() => {
|
|
601
|
+
if (settled) return
|
|
602
|
+
exitCode = -1
|
|
603
|
+
exitSignal = 'TIMEOUT'
|
|
604
|
+
// Kill the remote command (SIGTERM) rather than just dropping the
|
|
605
|
+
// channel, so a runaway process cannot keep running and holding
|
|
606
|
+
// the SSH connection after we've given up on its output. The hard
|
|
607
|
+
// close below frees the channel even if the process ignores it.
|
|
608
|
+
try {
|
|
609
|
+
if (typeof stream.signal === 'function') stream.signal('SIGTERM')
|
|
610
|
+
} catch {}
|
|
611
|
+
const hardClose = setTimeout(() => {
|
|
612
|
+
try { stream.close() } catch {}
|
|
613
|
+
}, 800)
|
|
614
|
+
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
615
|
+
settle()
|
|
616
|
+
}, timeoutMs || config.commandTimeoutMs)
|
|
617
|
+
stream.on('close', (code, signal) => {
|
|
618
|
+
if (settled) return
|
|
619
|
+
exitCode = code
|
|
620
|
+
exitSignal = signal
|
|
621
|
+
settle()
|
|
622
|
+
})
|
|
623
|
+
stream.on('data', (d) => {
|
|
624
|
+
if (stdout.length < hardCap) stdout += d
|
|
625
|
+
})
|
|
626
|
+
stream.stderr.on('data', (d) => {
|
|
627
|
+
if (stderr.length < hardCap) stderr += d
|
|
628
|
+
})
|
|
629
|
+
stream.on('error', (e) => {
|
|
630
|
+
if (settled) return
|
|
631
|
+
settled = true
|
|
632
|
+
clearTimeout(timer)
|
|
633
|
+
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
634
|
+
})
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
|
|
548
638
|
class SshPool {
|
|
549
639
|
constructor(config) {
|
|
550
640
|
this.config = config
|
|
@@ -590,6 +680,28 @@ class SshPool {
|
|
|
590
680
|
return pending
|
|
591
681
|
}
|
|
592
682
|
|
|
683
|
+
/**
|
|
684
|
+
* Drop the cached client and force a fresh connection on the next call.
|
|
685
|
+
* Called when a channel open fails (e.g. "Channel open failure: open
|
|
686
|
+
* failed") — the pooled SSH connection is usually dead server-side while
|
|
687
|
+
* keepalive has not yet noticed, and reusing it keeps failing. The epoch
|
|
688
|
+
* bump orphans any in-flight connect; the client is ended so ssh2 frees
|
|
689
|
+
* its sockets.
|
|
690
|
+
*/
|
|
691
|
+
invalidate() {
|
|
692
|
+
this.epoch++
|
|
693
|
+
const client = this.client
|
|
694
|
+
this.client = null
|
|
695
|
+
const pending = this.connecting
|
|
696
|
+
this.connecting = null
|
|
697
|
+
if (pending && typeof pending.catch === 'function') {
|
|
698
|
+
try { pending.catch(() => {}) } catch {}
|
|
699
|
+
}
|
|
700
|
+
if (client) {
|
|
701
|
+
try { client.end() } catch {}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
593
705
|
_doConnect(epoch) {
|
|
594
706
|
return new Promise((resolve, reject) => {
|
|
595
707
|
const client = new Client()
|
|
@@ -657,59 +769,23 @@ class SshPool {
|
|
|
657
769
|
(client) =>
|
|
658
770
|
new Promise((resolve, reject) => {
|
|
659
771
|
client.exec(command, (err, stream) => {
|
|
660
|
-
if (err)
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
stderr: truncate(stderr, this.config.maxOutputChars),
|
|
676
|
-
})
|
|
772
|
+
if (err) {
|
|
773
|
+
// Same dead-connection recovery as sftp(): a channel open
|
|
774
|
+
// failure means the pooled connection is stale — drop it and
|
|
775
|
+
// retry ONCE on a fresh connection.
|
|
776
|
+
if (/channel open failure|open failed/i.test(String((err && err.message) || err))) {
|
|
777
|
+
this.invalidate()
|
|
778
|
+
return this.connect().then(
|
|
779
|
+
(fresh) => fresh.exec(command, (err2, stream2) => {
|
|
780
|
+
if (err2) return reject(new Error('ssh exec failed: ' + ((err2 && err2.message) || err2)))
|
|
781
|
+
resolve(runStream(fresh, stream2, this.config, timeoutMs))
|
|
782
|
+
}),
|
|
783
|
+
(e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
|
|
677
787
|
}
|
|
678
|
-
|
|
679
|
-
if (settled) return
|
|
680
|
-
exitCode = -1
|
|
681
|
-
exitSignal = 'TIMEOUT'
|
|
682
|
-
// Kill the remote command (SIGTERM) rather than just dropping the
|
|
683
|
-
// channel, so a runaway process cannot keep running and holding
|
|
684
|
-
// the SSH connection after we've given up on its output. The hard
|
|
685
|
-
// close below frees the channel even if the process ignores it.
|
|
686
|
-
try {
|
|
687
|
-
if (typeof stream.signal === 'function') stream.signal('SIGTERM')
|
|
688
|
-
} catch {}
|
|
689
|
-
const hardClose = setTimeout(() => {
|
|
690
|
-
try { stream.close() } catch {}
|
|
691
|
-
}, 800)
|
|
692
|
-
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
693
|
-
settle()
|
|
694
|
-
}, timeoutMs || this.config.commandTimeoutMs)
|
|
695
|
-
stream.on('close', (code, signal) => {
|
|
696
|
-
if (settled) return
|
|
697
|
-
exitCode = code
|
|
698
|
-
exitSignal = signal
|
|
699
|
-
settle()
|
|
700
|
-
})
|
|
701
|
-
stream.on('data', (d) => {
|
|
702
|
-
if (stdout.length < hardCap) stdout += d
|
|
703
|
-
})
|
|
704
|
-
stream.stderr.on('data', (d) => {
|
|
705
|
-
if (stderr.length < hardCap) stderr += d
|
|
706
|
-
})
|
|
707
|
-
stream.on('error', (e) => {
|
|
708
|
-
if (settled) return
|
|
709
|
-
settled = true
|
|
710
|
-
clearTimeout(timer)
|
|
711
|
-
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
712
|
-
})
|
|
788
|
+
resolve(runStream(client, stream, this.config, timeoutMs))
|
|
713
789
|
})
|
|
714
790
|
}),
|
|
715
791
|
)
|
|
@@ -723,26 +799,24 @@ class SshPool {
|
|
|
723
799
|
(client) =>
|
|
724
800
|
new Promise((resolve, reject) => {
|
|
725
801
|
client.sftp((err, sftp) => {
|
|
726
|
-
if (err)
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
|
|
745
|
-
})
|
|
802
|
+
if (err) {
|
|
803
|
+
// Channel open failure usually means the pooled connection is
|
|
804
|
+
// dead server-side (idle timeout / network reset) while
|
|
805
|
+
// keepalive hasn't noticed yet. Drop it and retry ONCE on a
|
|
806
|
+
// fresh connection; a persistent error still rejects.
|
|
807
|
+
if (/channel open failure|open failed/i.test(String((err && err.message) || err))) {
|
|
808
|
+
this.invalidate()
|
|
809
|
+
return this.connect().then(
|
|
810
|
+
(fresh) => fresh.sftp((err2, sftp2) => {
|
|
811
|
+
if (err2) return reject(new Error('ssh sftp failed: ' + ((err2 && err2.message) || err2)))
|
|
812
|
+
resolve(wrapSftp(fresh, sftp2, this.config))
|
|
813
|
+
}),
|
|
814
|
+
(e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
815
|
+
)
|
|
816
|
+
}
|
|
817
|
+
return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
|
|
818
|
+
}
|
|
819
|
+
resolve(wrapSftp(client, sftp, this.config))
|
|
746
820
|
})
|
|
747
821
|
}),
|
|
748
822
|
)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password or key), pick a remote workspace, operate on it with rw_pick_workspace / rw_list_dir / rw_read_file / rw_exec / rw_sync tools, and mirror it to a real local directory (SFTP) so the DSH native workspace can adopt it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|