dsh-remote 0.8.33 → 0.8.35

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
@@ -332,10 +332,16 @@ export class SshPool {
332
332
  * the remote process stdin (used by the Git Bash `-s` mode). */
333
333
  _execRaw(command, opts, stdinWriter) {
334
334
  const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
335
+ const signal = opts && opts.signal
335
336
  return this.connect().then(
336
337
  (client) =>
337
338
  new Promise((resolve, reject) => {
338
339
  let retried = false
340
+ // Caller cancellation (issue #44): a tool that hands us exec.signal
341
+ // expects the remote command to stop when the turn is cancelled.
342
+ // Resolve the same shape as a timeout so callers need no new branch.
343
+ let onAbort = null
344
+ const cleanupAbort = () => { if (signal && onAbort) signal.removeEventListener('abort', onAbort) }
339
345
  const runOn = (c) => {
340
346
  const execOpts = {}
341
347
  if (opts && opts.pty) execOpts.pty = true
@@ -366,6 +372,7 @@ export class SshPool {
366
372
  if (settled) return
367
373
  settled = true
368
374
  clearTimeout(timer)
375
+ cleanupAbort()
369
376
  resolve({
370
377
  code: exitCode,
371
378
  signal: exitSignal,
@@ -389,10 +396,33 @@ export class SshPool {
389
396
  if (typeof hardClose.unref === 'function') hardClose.unref()
390
397
  settle()
391
398
  }, timeoutMs)
392
- stream.on('close', (code, signal) => {
399
+ if (signal) {
400
+ if (signal.aborted) {
401
+ try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
402
+ try { stream.close() } catch {}
403
+ exitCode = -1
404
+ exitSignal = 'ABORTED'
405
+ return settle()
406
+ }
407
+ onAbort = () => {
408
+ if (settled) return
409
+ exitCode = -1
410
+ exitSignal = 'ABORTED'
411
+ try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
412
+ const hardClose = setTimeout(() => { try { stream.close() } catch {} }, 500)
413
+ if (typeof hardClose.unref === 'function') hardClose.unref()
414
+ settle()
415
+ }
416
+ signal.addEventListener('abort', onAbort, { once: true })
417
+ }
418
+ stream.on('close', (code, sigName) => {
393
419
  if (settled) return
394
420
  exitCode = code
395
- exitSignal = signal
421
+ // `sigName` is the signal NAME that killed the remote process
422
+ // (e.g. 'SIGTERM'), not the caller's AbortSignal. The rename
423
+ // matters: `signal` in this scope is the AbortSignal, and
424
+ // assigning it here would leak an object into the result.
425
+ exitSignal = sigName
396
426
  settle()
397
427
  })
398
428
  stream.on('data', (d) => {
@@ -405,6 +435,7 @@ export class SshPool {
405
435
  if (settled) return
406
436
  settled = true
407
437
  clearTimeout(timer)
438
+ cleanupAbort()
408
439
  reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
409
440
  })
410
441
  if (stdinWriter) {
@@ -424,60 +455,100 @@ export class SshPool {
424
455
  new Promise((resolve, reject) => {
425
456
  let retried = false
426
457
  const runOn = (c) => {
427
- c.sftp((err, sftp) => {
428
- if (err) {
429
- // Same dead-connection recovery as exec(): a channel open
430
- // failure — or a session termination (remote closed the
431
- // SFTP subchannel, e.g. transient network blip / sshd idle
432
- // drop) — means the pooled connection is stale: drop it and
433
- // retry ONCE on a fresh connection.
434
- if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
435
- retried = true
436
- this.invalidate()
437
- return this.connect().then(
438
- (fresh) => runOn(fresh),
439
- (e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
440
- )
441
- }
442
- return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
443
- }
444
- const withTimeout = (fn) => (...args) =>
445
- new Promise((r2, j2) => {
446
- const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
447
- const done = (e, v) => {
448
- clearTimeout(timer)
449
- e ? j2(e) : r2(v)
458
+ // Bound the subchannel open itself, per attempt. Without this, a
459
+ // remote whose sftp subsystem is unresponsive (overloaded sshd,
460
+ // exceeded MaxSessions, half-open TCP) leaves `c.sftp(cb)` parked
461
+ // forever with no callback — issue #44 point 3. ssh2 only calls back
462
+ // on success or on a channel error, and a wedged peer produces
463
+ // neither.
464
+ //
465
+ // The timer must be created per attempt, not once outside: the
466
+ // callback clears it, so a single outer timer would leave the retry
467
+ // (below) with no timeout at all.
468
+ let timedOut = false
469
+ const attemptTimer = setTimeout(() => {
470
+ timedOut = true
471
+ reject(new Error(`ssh sftp open timed out after ${this.config.commandTimeoutMs}ms`))
472
+ }, this.config.commandTimeoutMs)
473
+ if (typeof attemptTimer.unref === 'function') attemptTimer.unref()
474
+ const clearAttempt = () => { clearTimeout(attemptTimer) }
475
+ try {
476
+ c.sftp((err, sftp) => {
477
+ clearAttempt()
478
+ if (timedOut) return
479
+ if (err) {
480
+ // Same dead-connection recovery as exec(): a channel open
481
+ // failure — or a session termination (remote closed the
482
+ // SFTP subchannel, e.g. transient network blip / sshd idle
483
+ // drop) — means the pooled connection is stale: drop it and
484
+ // retry ONCE on a fresh connection.
485
+ if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
486
+ retried = true
487
+ this.invalidate()
488
+ return this.connect().then(
489
+ (fresh) => runOn(fresh),
490
+ (e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
491
+ )
450
492
  }
451
- try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
452
- })
453
- const P = (p) => toSftpPath(p)
454
- resolve({
455
- readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
456
- stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
457
- lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
458
- mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
459
- rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
460
- unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
461
- rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
462
- realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
463
- readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
464
- writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
465
- fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
466
- fastPut: (lp, p) => withTimeout((l, d, cb) => sftp.fastPut(l, d, cb))(lp, P(p)),
467
- // Range read for oversized previews: never downloads the whole file.
468
- readPartial: (p, offset, length) => withTimeout((d, off, len, cb) => {
469
- sftp.open(d, 'r', (e, handle) => {
470
- if (e) return cb(e)
471
- const buf = Buffer.alloc(len)
472
- sftp.read(handle, buf, 0, len, off, (e2, bytesRead, data) => {
473
- sftp.close(handle, () => {})
474
- if (e2) return cb(e2)
475
- cb(null, Buffer.isBuffer(data) ? data.subarray(0, bytesRead) : buf.subarray(0, bytesRead))
476
- })
493
+ return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
494
+ }
495
+ // A dead channel must fail fast. ssh2's own EOF handling only
496
+ // rejects the requests that were pending at that moment
497
+ // (SFTP.js cleanupRequests); every request issued afterwards is
498
+ // registered in `_requests` and never answered. Without the flag
499
+ // below each such call waits out the full commandTimeoutMs — so a
500
+ // tree walk over N entries costs N × 2 × timeout, i.e. days on a
501
+ // large directory (issue #44). Mark the channel dead on EOF/close
502
+ // and reject immediately from then on.
503
+ let dead = false
504
+ const markDead = () => { dead = true }
505
+ if (typeof sftp.on === 'function') {
506
+ sftp.on('end', markDead)
507
+ sftp.on('close', markDead)
508
+ sftp.on('error', markDead)
509
+ }
510
+ const withTimeout = (fn) => (...args) =>
511
+ new Promise((r2, j2) => {
512
+ if (dead) return j2(new Error('sftp channel closed'))
513
+ const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
514
+ const finish = (e, v) => {
515
+ clearTimeout(timer)
516
+ e ? j2(e) : r2(v)
517
+ }
518
+ try { fn(...args, finish) } catch (e) { clearTimeout(timer); j2(e) }
477
519
  })
478
- })(P(p), offset, length),
520
+ const P = (p) => toSftpPath(p)
521
+ resolve({
522
+ readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
523
+ stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
524
+ lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
525
+ mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
526
+ rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
527
+ unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
528
+ rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
529
+ realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
530
+ readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
531
+ writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
532
+ fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
533
+ fastPut: (lp, p) => withTimeout((l, d, cb) => sftp.fastPut(l, d, cb))(lp, P(p)),
534
+ // Range read for oversized previews: never downloads the whole file.
535
+ readPartial: (p, offset, length) => withTimeout((d, off, len, cb) => {
536
+ sftp.open(d, 'r', (e, handle) => {
537
+ if (e) return cb(e)
538
+ const buf = Buffer.alloc(len)
539
+ sftp.read(handle, buf, 0, len, off, (e2, bytesRead, data) => {
540
+ sftp.close(handle, () => {})
541
+ if (e2) return cb(e2)
542
+ cb(null, Buffer.isBuffer(data) ? data.subarray(0, bytesRead) : buf.subarray(0, bytesRead))
543
+ })
544
+ })
545
+ })(P(p), offset, length),
546
+ })
479
547
  })
480
- })
548
+ } catch (e) {
549
+ clearAttempt()
550
+ reject(new Error('ssh sftp failed: ' + ((e && e.message) || e)))
551
+ }
481
552
  }
482
553
  runOn(client)
483
554
  }),
package/lib/search.js CHANGED
@@ -46,7 +46,20 @@ function looksBinary(buf) {
46
46
 
47
47
  /**
48
48
  * Recursively search a remote directory.
49
- * @returns {Promise<{matches: Array<{path, line, text}>, scanned: number, truncated: boolean}>}
49
+ *
50
+ * Cancellation (issue #44): the walk takes an AbortSignal and checks it at every
51
+ * directory and every entry. Before this it was completely uninterruptible —
52
+ * no signal was accepted and no flag was consulted — so a large-tree search
53
+ * could never be stopped: DSH tool cancellation is *cooperative*
54
+ * (@deepseek-ai/dsh-tool-call-timeout-policy can only ask a tool to stop), which
55
+ * meant `session/cancel`, steering, and the tool timeout policy all had no
56
+ * effect and the turn stayed `running` forever. On abort we return the partial
57
+ * result instead of throwing, so the model still gets whatever was found.
58
+ *
59
+ * @param {object} sftp - pooled SFTP client
60
+ * @param {string} rootDir
61
+ * @param {object} opts - `signal` (AbortSignal), `maxDurationMs`, plus the caps below
62
+ * @returns {Promise<{matches: Array<{path, line, text}>, scanned: number, truncated: boolean, cancelled?: boolean}>}
50
63
  */
51
64
  export async function searchTree(sftp, rootDir, opts = {}) {
52
65
  const {
@@ -57,15 +70,27 @@ export async function searchTree(sftp, rootDir, opts = {}) {
57
70
  maxDepth = 12,
58
71
  maxFiles = 20000,
59
72
  maxScanBytes = 1024 * 1024,
73
+ maxDurationMs = 0, // 0 = no wall-clock budget
60
74
  isIgnored = () => false,
75
+ signal,
61
76
  } = opts
62
77
 
63
78
  const matches = []
64
79
  let scanned = 0
65
80
  let truncated = false
81
+ let cancelled = false
82
+ const startedAt = Date.now()
83
+
84
+ /** True when the caller asked us to stop (abort, or the time budget is spent). */
85
+ const shouldStop = () => {
86
+ if (cancelled || truncated) return true
87
+ if (signal && signal.aborted) { cancelled = true; return true }
88
+ if (maxDurationMs > 0 && Date.now() - startedAt >= maxDurationMs) { truncated = true; return true }
89
+ return false
90
+ }
66
91
 
67
92
  const walk = async (dir, depth) => {
68
- if (truncated || scanned >= maxFiles) return
93
+ if (shouldStop() || scanned >= maxFiles) return
69
94
  let entries = []
70
95
  try { entries = (await sftp.readdir(dir)) || [] } catch { return }
71
96
  const dirs = []
@@ -77,29 +102,32 @@ export async function searchTree(sftp, rootDir, opts = {}) {
77
102
  }
78
103
  // Depth-first, dirs first, but stop as soon as we hit caps.
79
104
  for (const name of dirs) {
80
- if (truncated || scanned >= maxFiles) return
105
+ if (shouldStop() || scanned >= maxFiles) return
81
106
  if (depth <= 0) continue
82
107
  const sub = joinRemotePath(dir, name)
83
108
  if (isIgnored(name, true)) continue
84
109
  await walk(sub, depth - 1)
85
110
  }
86
- if (truncated || scanned >= maxFiles) return
111
+ if (shouldStop() || scanned >= maxFiles) return
87
112
 
88
113
  const fileNames = entries
89
114
  .filter((e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()))
90
115
  .map((e) => String(e.filename))
91
116
  .filter((n) => n !== '.' && n !== '..')
92
117
  await mapLimit(fileNames, 4, async (name) => {
93
- if (truncated || scanned >= maxFiles || matches.length >= maxMatches) return
118
+ // Checked per entry: this is the loop that used to run for hours.
119
+ if (shouldStop() || scanned >= maxFiles || matches.length >= maxMatches) return
94
120
  if (glob && !matchGlob(name, glob)) return
95
121
  if (isIgnored(name, false)) return
96
122
  const fp = joinRemotePath(dir, name)
97
123
  scanned++
98
124
  let st
99
125
  try { st = await sftp.stat(fp) } catch { return }
126
+ if (shouldStop()) return
100
127
  if (st.size > maxScanBytes) return
101
128
  let buf
102
129
  try { buf = await sftp.readFile(fp) } catch { return }
130
+ if (shouldStop()) return
103
131
  if (looksBinary(buf)) return
104
132
  const content = buf.toString('utf8').replace(/\r\n/g, '\n')
105
133
  const lines = content.split('\n')
@@ -124,7 +152,7 @@ export async function searchTree(sftp, rootDir, opts = {}) {
124
152
 
125
153
  await walk(rootDir, maxDepth)
126
154
  if (scanned >= maxFiles) truncated = true
127
- return { matches, scanned, truncated }
155
+ return { matches, scanned, truncated, cancelled }
128
156
  }
129
157
 
130
158
  function parseGrepLines(stdout, maxMatches) {
@@ -151,7 +179,12 @@ export async function searchViaShell(pool, rootDir, opts = {}) {
151
179
  glob,
152
180
  contextLines = 0,
153
181
  maxMatches = 500,
182
+ signal,
154
183
  } = opts
184
+ // Honour an already-aborted signal before spawning a remote grep on a huge tree.
185
+ if (signal && signal.aborted) {
186
+ return { matches: [], scanned: 0, truncated: false, cancelled: true, via: 'none' }
187
+ }
155
188
  const ic = ignoreCase ? ' -i' : ''
156
189
  const globFlag = glob ? ` --glob ${shq(glob)}` : ''
157
190
  const ctx = contextLines > 0 ? ` -C ${Number(contextLines)}` : ''
@@ -159,7 +192,14 @@ export async function searchViaShell(pool, rootDir, opts = {}) {
159
192
  const grepGlob = glob ? ` --include=${shq(glob)}` : ''
160
193
  const grep = `grep -R -n -I -E${ic}${grepGlob}${ctx} -- ${shq(pattern)} ${shq(rootDir)}`
161
194
  const script = `if command -v rg >/dev/null 2>&1; then ${rg}; elif command -v grep >/dev/null 2>&1; then ${grep}; else exit 127; fi`
162
- const res = await pool.exec(script, { timeoutMs: opts.timeoutMs })
195
+ const res = await pool.exec(script, { timeoutMs: opts.timeoutMs, signal })
196
+ // A cancelled call must return as cancelled (with whatever partial output the
197
+ // remote produced), NOT throw: throwing would make searchRemote fall through
198
+ // to the slower SFTP walk, i.e. keep running after the caller said stop.
199
+ if (res.signal === 'ABORTED') {
200
+ const partial = parseGrepLines(res.stdout, maxMatches)
201
+ return { matches: partial.matches, scanned: partial.matches.length, truncated: false, cancelled: true, via: 'none' }
202
+ }
163
203
  // 0 = hits, 1 = no hits; other codes mean the tools are missing / failed.
164
204
  if (res.signal === 'TIMEOUT') throw new Error('search timed out')
165
205
  if (res.code !== 0 && res.code !== 1) throw new Error(res.stderr || `search exit ${res.code}`)
@@ -178,7 +218,12 @@ export async function searchRemote(pool, rootDir, opts = {}) {
178
218
  try {
179
219
  const fast = await searchViaShell(pool, rootDir, opts)
180
220
  if (fast && Array.isArray(fast.matches)) return fast
181
- } catch { /* missing rg/grep or Windows-like remote → SFTP walk */ }
221
+ } catch (e) {
222
+ // An abort is terminal, not a "try the slower fallback" signal.
223
+ if (opts.signal && opts.signal.aborted) {
224
+ return { matches: [], scanned: 0, truncated: false, cancelled: true, via: 'none' }
225
+ }
226
+ }
182
227
  }
183
228
  const sftp = await pool.sftp()
184
229
  return searchTree(sftp, rootDir, opts)
package/lib/update.js CHANGED
@@ -96,7 +96,18 @@ export function gtVersion(a, b) {
96
96
  return false
97
97
  }
98
98
 
99
- /** Query the npm registry for the latest release; resolves null on any failure. */
99
+ /**
100
+ * 查询 npm registry 的最新版本。
101
+ *
102
+ * 返回 `{ version }` 或 `{ error }`(**不再把一切失败压成 null**)。
103
+ *
104
+ * 为什么要区分失败原因:原先任何失败都返回 `null`,调用方只能笼统报
105
+ * "无法连接 npm registry"。实测踩过:本机被 npm 限流(`/latest` 返回 **429**,
106
+ * 而同一时刻 registry 根路径是 200、CI 从另一个出口 IP 也能正常取到),
107
+ * 却被显示成"连不上"——把"等一下就好"误导成"网络坏了"。429/超时/离线
108
+ * 三者的处置完全不同,必须让用户看到真实原因。
109
+ * @param timeoutMs - 超时(看门狗只负责中止,不影响进程存活)。
110
+ */
100
111
  export async function fetchLatestVersion(timeoutMs = 8000) {
101
112
  try {
102
113
  const controller = new AbortController()
@@ -104,16 +115,28 @@ export async function fetchLatestVersion(timeoutMs = 8000) {
104
115
  // 纯看门狗:调用方并不 await 它,所以必须 unref,否则这一段超时窗口
105
116
  // 会拖住事件循环、让本该退出的短命进程(脚本、测试)多挂数秒。
106
117
  if (typeof timer.unref === 'function') timer.unref()
118
+ let res
107
119
  try {
108
- const res = await fetch(REGISTRY_URL, { signal: controller.signal, headers: { accept: 'application/json' } })
109
- if (!res.ok) return null
110
- const data = await res.json()
111
- return typeof data?.version === 'string' && data.version ? data.version : null
120
+ res = await fetch(REGISTRY_URL, { signal: controller.signal, headers: { accept: 'application/json' } })
112
121
  } finally {
113
122
  clearTimeout(timer)
114
123
  }
115
- } catch {
116
- return null
124
+ if (res.status === 429) {
125
+ return { error: 'npm registry 限流(HTTP 429)——请求过于频繁,稍后会自动恢复', reason: 'rate-limited', status: 429 }
126
+ }
127
+ if (!res.ok) {
128
+ return { error: `npm registry 返回 HTTP ${res.status}`, reason: 'http', status: res.status }
129
+ }
130
+ const data = await res.json().catch(() => null)
131
+ const version = typeof data?.version === 'string' && data.version ? data.version : null
132
+ if (!version) return { error: 'npm registry 响应里没有可用的版本号', reason: 'malformed' }
133
+ return { version }
134
+ } catch (err) {
135
+ const aborted = err?.name === 'AbortError'
136
+ return {
137
+ error: aborted ? `连接 npm registry 超时(${timeoutMs}ms)` : '无法连接 npm registry',
138
+ reason: aborted ? 'timeout' : 'network',
139
+ }
117
140
  }
118
141
  }
119
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.33",
3
+ "version": "0.8.35",
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 20 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",