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.
Files changed (3) hide show
  1. package/lib/client.js +12 -0
  2. package/lib/index.js +146 -72
  3. 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) return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
661
- let stdout = ''
662
- let stderr = ''
663
- let settled = false
664
- let exitCode = null
665
- let exitSignal = null
666
- const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
667
- const settle = () => {
668
- if (settled) return
669
- settled = true
670
- clearTimeout(timer)
671
- resolve({
672
- code: exitCode,
673
- signal: exitSignal,
674
- stdout: truncate(stdout, this.config.maxOutputChars),
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
- const timer = setTimeout(() => {
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) return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
727
- // Wrap a raw SFTP method so a stuck server can never hang a tool call.
728
- const withTimeout = (fn) => (...args) =>
729
- new Promise((r2, j2) => {
730
- const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
731
- const done = (e, v) => {
732
- clearTimeout(timer)
733
- e ? j2(e) : r2(v)
734
- }
735
- try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
736
- })
737
- const P = (p) => toSftpPath(p)
738
- resolve({
739
- readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
740
- stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
741
- lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
742
- mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
743
- readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
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.2",
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>",