dsh-remote 0.8.67 → 0.8.71

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/openssh.js CHANGED
@@ -208,6 +208,10 @@ export function baseSshArgs(config, { forSecret = false } = {}) {
208
208
  const port = proxy.port && Number(proxy.port) !== 22 ? `:${Number(proxy.port)}` : ''
209
209
  args.push('-J', `${user}${proxy.host}${port}`)
210
210
  }
211
+ // An explicit known_hosts file (tests keep loopback keys OUT of the user's
212
+ // real ~/.ssh/known_hosts with this; ssh ignores $HOME on some platforms).
213
+ // Checking strength is unchanged — StrictHostKeyChecking below still applies.
214
+ if (config.knownHostsFile) args.push('-o', `UserKnownHostsFile=${config.knownHostsFile}`)
211
215
  args.push(...hostKeyArgs(config))
212
216
  const connectSecs = Math.max(1, Math.round((Number(config.connectTimeoutMs) || 15000) / 1000))
213
217
  args.push('-o', `ConnectTimeout=${connectSecs}`, '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3')
@@ -244,7 +248,7 @@ export class OpenSshConnection extends EventEmitter {
244
248
  spawnSsh(extraArgs, { tty = false } = {}) {
245
249
  if (this.closed) throw new Error('ssh connection closed')
246
250
  const args = [
247
- ...baseSshArgs(this.pool.config, { forSecret: !!this.secret }),
251
+ ...baseSshArgs(this.pool._argConfig(), { forSecret: !!this.secret }),
248
252
  ...extraArgs,
249
253
  ]
250
254
  const child = spawn(this.sshBinary, args, {
@@ -395,6 +399,19 @@ export class SystemOpenSshPool {
395
399
  this._secretPromise = null
396
400
  }
397
401
 
402
+ /**
403
+ * The config baseSshArgs should see. `opts.knownHostsFile` (a path, or a
404
+ * function returning one, exactly like the native pool) used to be stored
405
+ * and silently dropped — so a custom known_hosts never reached the CLI, and
406
+ * tests had no way to keep loopback keys out of the user's real file.
407
+ * Resolved per call so a function value is honored as late as the native
408
+ * backend resolves it.
409
+ */
410
+ _argConfig() {
411
+ const kh = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile
412
+ return kh ? { ...this.config, knownHostsFile: kh } : this.config
413
+ }
414
+
398
415
  /** The destination OpenSSH should connect to (an alias passes through). */
399
416
  destination() {
400
417
  // A bare alias resolves through ~/.ssh/config inside ssh itself, so it is
@@ -502,7 +519,12 @@ export class SystemOpenSshPool {
502
519
  * Run one `ssh` invocation and collect its result.
503
520
  *
504
521
  * @param {string[]} extraArgs - arguments appended after the shared base.
505
- * @param {object} [opts] - `timeoutMs`, `signal` (AbortSignal), `stdin`.
522
+ * @param {object} [opts] - `timeoutMs`, `signal` (AbortSignal), `stdin`,
523
+ * `onData(channel, chunk)` — a streaming tap the native backend has had
524
+ * since issue #70. A background job's value is its LIVE output, which the
525
+ * job ring must receive as it arrives; a backend that only returns the
526
+ * accumulated text loses every byte the caller stopped waiting for
527
+ * (issue #80). Tap errors are not command errors, so the call is guarded.
506
528
  * @returns {Promise<{code: number|null, signal: string|null, stdout: string, stderr: string}>}
507
529
  */
508
530
  async _runSsh(extraArgs, opts = {}) {
@@ -510,7 +532,7 @@ export class SystemOpenSshPool {
510
532
  const secret = await this._resolveSecret()
511
533
  const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
512
534
  const sshBinary = resolveSshBinary(this.config)
513
- const args = [...baseSshArgs(this.config, { forSecret: !!secret }), ...extraArgs]
535
+ const args = [...baseSshArgs(this._argConfig(), { forSecret: !!secret }), ...extraArgs]
514
536
  return new Promise((resolve) => {
515
537
  let child
516
538
  try {
@@ -524,6 +546,7 @@ export class SystemOpenSshPool {
524
546
  let exitCode = null
525
547
  let exitSignal = null
526
548
  const cap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
549
+ const onData = typeof opts.onData === 'function' ? opts.onData : null
527
550
  const cleanupAbort = () => { if (opts.signal && onAbort) opts.signal.removeEventListener('abort', onAbort) }
528
551
  let onAbort = null
529
552
  const finish = () => {
@@ -562,8 +585,17 @@ export class SystemOpenSshPool {
562
585
  }
563
586
  opts.signal.addEventListener('abort', onAbort, { once: true })
564
587
  }
565
- child.stdout.on('data', (d) => { if (stdout.length < cap) stdout += d })
566
- child.stderr.on('data', (d) => { if (stderr.length < cap) stderr += d })
588
+ // The tap fires even past the local cap, exactly like the native
589
+ // backend's: a promoted job's ring has its own bounds and must keep
590
+ // receiving output the truncated return value no longer holds.
591
+ child.stdout.on('data', (d) => {
592
+ if (stdout.length < cap) stdout += d
593
+ if (onData) { try { onData('stdout', d) } catch { /* tap errors are not command errors */ } }
594
+ })
595
+ child.stderr.on('data', (d) => {
596
+ if (stderr.length < cap) stderr += d
597
+ if (onData) { try { onData('stderr', d) } catch { /* tap errors are not command errors */ } }
598
+ })
567
599
  child.on('error', (e) => {
568
600
  if (settled) return
569
601
  stderr += String((e && e.message) || e)
@@ -651,9 +683,9 @@ export class SystemOpenSshPool {
651
683
  if (this.platform === 'windows' && this.gitBashPath) {
652
684
  // Pipe the script to `bash -s` over stdin: no quoting round-trip, so
653
685
  // quotes/backslashes/newlines survive verbatim (same as the native pool).
654
- return this._runSsh(['--', this.destination(), `"${this.gitBashPath}" -s`], { timeoutMs, signal: opts.signal, stdin: String(command) })
686
+ return this._runSsh(['--', this.destination(), `"${this.gitBashPath}" -s`], { timeoutMs, signal: opts.signal, stdin: String(command), onData: opts.onData })
655
687
  }
656
- return this._runSsh(['--', this.destination(), String(command)], { timeoutMs, signal: opts.signal })
688
+ return this._runSsh(['--', this.destination(), String(command)], { timeoutMs, signal: opts.signal, onData: opts.onData })
657
689
  })
658
690
  }
659
691
 
@@ -662,7 +694,7 @@ export class SystemOpenSshPool {
662
694
  const Sftp = loadSftpEngine()
663
695
  const secret = await this._resolveSecret()
664
696
  const sshBinary = resolveSshBinary(this.config)
665
- const args = [...baseSshArgs(this.config, { forSecret: !!secret }), '-s', this.destination(), 'sftp']
697
+ const args = [...baseSshArgs(this._argConfig(), { forSecret: !!secret }), '-s', this.destination(), 'sftp']
666
698
  const child = spawn(sshBinary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: secret ? askpassEnv(secret) : undefined })
667
699
 
668
700
  // ssh2's SFTP engine only reaches `_protocol` for channelData/channelClose,
package/lib/paths.js CHANGED
@@ -184,6 +184,33 @@ export function remotePathBase(p) {
184
184
  return base
185
185
  }
186
186
 
187
+ /**
188
+ * Workspace title for a REMOTE mirror: `<machine>-<remote basename>`.
189
+ *
190
+ * The Harness derives a Workspace title from the basename of the directory it
191
+ * registers, and a remote workspace is registered on its LOCAL mirror
192
+ * (`$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<basename>`). That last
193
+ * segment is still the remote basename, so a local `/mnt/xdfs` and a remote
194
+ * `/mnt/xdfs` both read "xdfs". dsh-remote pre-registers the mirror with this
195
+ * host-qualified title, so the two are distinguishable from the start.
196
+ *
197
+ * The machine's display name wins; a machine saved without one (or named after
198
+ * the folder itself) falls back to its host. A workspace title must be
199
+ * non-empty, so the bare basename is the last resort.
200
+ *
201
+ * @param {string} remotePath - remote workspace directory (POSIX or Windows).
202
+ * @param {{name?: string, host?: string}} [machine] - the machine registry record.
203
+ * @returns {string} a non-empty, host-qualified title.
204
+ */
205
+ export function remoteWorkspaceTitle(remotePath, machine) {
206
+ const base = remotePathBase(remotePath)
207
+ const label = String((machine && machine.name) || '').trim()
208
+ const host = String((machine && machine.host) || '').trim()
209
+ if (label && label !== base) return label + '-' + base
210
+ if (host && host !== base) return host + '-' + base
211
+ return base
212
+ }
213
+
187
214
  /** Relative path of `p` under `root` (POSIX form, no leading slash), or null
188
215
  * when `p` is not inside `root`. Both POSIX and Windows drive paths work
189
216
  * (Windows is compared in its `/D:/…` POSIX-equivalent form). */
package/lib/telemetry.js CHANGED
@@ -28,7 +28,7 @@ import path from 'node:path'
28
28
  * (默认行为不该需要配置),而是:① 测试必须能把它重定向掉,
29
29
  * 否则跑一次 npm test 就会往生产统计里灌假装机(发生过);
30
30
  * ② 作者换端点/灰度时不必为了改一行字符串发一次版。 */
31
- const DEFAULT_HEARTBEAT_URL = 'https://gitbolg-d7gmnsrw46e011706-1256429518.ap-shanghai.app.tcloudbase.com/dsh-hb/heartbeat'
31
+ const DEFAULT_HEARTBEAT_URL = 'https://slippery-iguana-d1fhb1xmd469ee8c-1256429518.ap-shanghai.app.tcloudbase.com/dsh-hb/heartbeat'
32
32
 
33
33
  /** 解析实际使用的端点(env 覆盖优先,仅接受 https,避免被降级到明文)。 */
34
34
  export function heartbeatUrl() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.67",
4
- "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive, multi-hop ProxyJump), pick a remote workspace, operate on it with 22 rw_* tools, conflict-aware mirror sync, an embedded PTY terminal, one-click remote dsh deploy + web UI attach, port forwarding, host grouping, remote sandbox fencing, audit log, keychain passwords.",
3
+ "version": "0.8.71",
4
+ "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive, multi-hop ProxyJump), list hosts from ~/.ssh/config, pick a remote workspace, operate on it with 21 rw_* tools, conflict-aware mirror sync, an embedded PTY terminal, remote DSH web UI attach, port forwarding, host grouping, remote sandbox fencing, audit log, keychain passwords.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",
7
7
  "readme": "README.md",