dsh-remote 0.8.70 → 0.8.72

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/audit.js ADDED
@@ -0,0 +1,45 @@
1
+ // dsh-remote — audit log domain: the pure parts (formatting + reading).
2
+ //
3
+ // Extracted from lib/index.js (issue #82): the god-file keeps only the thin
4
+ // wiring (config gate + file path); everything testable without a mounted
5
+ // plugin lives here. Behaviour is byte-identical to the inlined version —
6
+ // `audit-open-and-no-deploy.test.js` pins it end to end.
7
+
8
+ /**
9
+ * Build one audit line (without the trailing newline handling of the caller).
10
+ * `cmd` is flattened to a single line and capped at 400 chars.
11
+ *
12
+ * @param {object} p
13
+ * @param {string} p.op - operation name.
14
+ * @param {string} p.cmd - command text or path detail.
15
+ * @param {number|null} p.code - exit code, or null when not applicable.
16
+ * @param {string} [p.user]
17
+ * @param {string} [p.host]
18
+ * @param {number} [p.port]
19
+ * @param {Date} [p.now] - clock injection for tests.
20
+ * @returns {string} the line INCLUDING the trailing newline.
21
+ */
22
+ export function formatAuditLine({ op, cmd, code, user, host, port, now }) {
23
+ const iso = (now || new Date()).toISOString()
24
+ const flat = String(cmd || '').replace(/\s+/g, ' ').slice(0, 400)
25
+ return [iso, `${user || '?'}@${host || '?'}:${port}`, op, code == null ? '-' : String(code), flat].join(' | ') + '\n'
26
+ }
27
+
28
+ /**
29
+ * Read the last `limit` audit lines from a file (clamped to [1, 500]).
30
+ * A missing/corrupt file degrades to [] — the audit viewer must never throw.
31
+ *
32
+ * @param {(file: string, enc: string) => string} readFile - fs.readFileSync-compatible.
33
+ * @param {string} file
34
+ * @param {number|string} [limit]
35
+ * @returns {string[]}
36
+ */
37
+ export function readAuditLines(readFile, file, limit) {
38
+ try {
39
+ const text = readFile(file, 'utf8')
40
+ const lines = text.split('\n').filter(Boolean)
41
+ return lines.slice(-Math.max(1, Math.min(Number(limit) || 50, 500)))
42
+ } catch {
43
+ return []
44
+ }
45
+ }
package/lib/client.js CHANGED
@@ -206,6 +206,7 @@ window.__ModuleLoader__.load({
206
206
  'settings.checkUpdate': '检查更新',
207
207
  'settings.updateNow': '立即更新',
208
208
  'settings.updateMode': '更新模式:',
209
+ 'settings.explorerAutoOpen': '打开远程工作区时,自动在侧边栏展开「远程文件」(需安装 better-sidebar)',
209
210
  'settings.updateManual': '手动',
210
211
  'settings.updateAuto': '自动',
211
212
  'settings.updateOff': '关闭',
@@ -510,6 +511,7 @@ window.__ModuleLoader__.load({
510
511
  'settings.checkUpdate': 'Check for updates',
511
512
  'settings.updateNow': 'Update now',
512
513
  'settings.updateMode': 'Update mode:',
514
+ 'settings.explorerAutoOpen': 'Auto-open the Remote Files sidebar tab for remote workspaces (via better-sidebar)',
513
515
  'settings.updateManual': 'Manual',
514
516
  'settings.updateAuto': 'Auto',
515
517
  'settings.updateOff': 'Off',
@@ -1134,6 +1136,9 @@ window.__ModuleLoader__.load({
1134
1136
  // Mirrors the host default (DEFAULT_UPDATE_MODE = 'auto') so the panel
1135
1137
  // does not flash "manual" before the first /update-check answers.
1136
1138
  const [updMode, setUpdMode] = React.useState('auto')
1139
+ // Issue #78: initial value comes from localStorage, so a user's choice
1140
+ // survives restarts (the bug was: the panel popped again every restart).
1141
+ const [explorerAuto, setExplorerAuto] = React.useState(() => explorerAutoOpenEnabled())
1137
1142
  const checkUpdate = (quiet) => {
1138
1143
  if (updBusy) return
1139
1144
  setUpdBusy(true); setUpdMsg('')
@@ -1845,6 +1850,19 @@ window.__ModuleLoader__.load({
1845
1850
  }, m === 'manual' ? tr('settings.modeManual') : m === 'auto' ? tr('settings.modeAuto') : tr('settings.modeOff')),
1846
1851
  ),
1847
1852
  ),
1853
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6, fontSize: 12 } },
1854
+ React.createElement('label', { style: { display: 'flex', gap: 6, alignItems: 'center', cursor: 'pointer', opacity: 0.85 } },
1855
+ React.createElement('input', {
1856
+ type: 'checkbox', checked: explorerAuto, 'data-testid': 'explorer-auto-open',
1857
+ onChange: (e) => {
1858
+ const on = !!(e && e.target && e.target.checked)
1859
+ setExplorerAuto(on)
1860
+ try { window.localStorage.setItem(EXPLORER_AUTO_OPEN_KEY, on ? '1' : '0') } catch { /* private mode: choice lasts this session only */ }
1861
+ },
1862
+ }),
1863
+ tr('settings.explorerAutoOpen'),
1864
+ ),
1865
+ ),
1848
1866
  upd && upd.pendingReload
1849
1867
  ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: T.warn } },
1850
1868
  tr('settings.updatePendingReload', { disk: upd.disk, loaded: upd.loaded }))
@@ -2646,6 +2664,19 @@ window.__ModuleLoader__.load({
2646
2664
  // resource type, never a better-sidebar card (see registerSidebarIntegration).
2647
2665
  const SIDEBAR_EXPLORER_ID = 'dsh-remote:explorer'
2648
2666
 
2667
+ // Issue #78: durable opt-out for the sidebar auto-open. The in-memory
2668
+ // autoOpenedFor set forgetting across restarts is by design; THIS key is
2669
+ // the user's persistent "别再给我弹远程文件面板". Browser-local because the
2670
+ // feature itself is browser-local. Default ON preserves the behaviour
2671
+ // existing users know — only an explicit uncheck turns it off.
2672
+ const EXPLORER_AUTO_OPEN_KEY = 'dsh-remote:explorerAutoOpen'
2673
+ function explorerAutoOpenEnabled() {
2674
+ try {
2675
+ if (typeof window === 'undefined' || !window.localStorage) return true
2676
+ return window.localStorage.getItem(EXPLORER_AUTO_OPEN_KEY) !== '0'
2677
+ } catch { return true }
2678
+ }
2679
+
2649
2680
  function fetchRemoteStatus(sessionId) {
2650
2681
  return api('GET', withSessionQuery('/dsh-remote/status', sessionId)).catch(() => null)
2651
2682
  }
@@ -3610,6 +3641,7 @@ window.__ModuleLoader__.load({
3610
3641
  let autoOpenedFor = new Set()
3611
3642
  let disposeAuto = null
3612
3643
  const tryAutoOpen = () => {
3644
+ if (!explorerAutoOpenEnabled()) return // issue #78: the user said no, persistently
3613
3645
  if (typeof bs.subscribeState !== 'function') return
3614
3646
  const snap = bs.getSnapshot && bs.getSnapshot()
3615
3647
  if (!snap || !snap.sessionId) return
package/lib/errors.js CHANGED
@@ -69,6 +69,17 @@ export function classifyError(err, opts = {}) {
69
69
  raw: msg,
70
70
  }
71
71
  }
72
+ // ECONNRESET is the same class of failure as ECONNREFUSED one step later: the
73
+ // peer (or a firewall/middlebox) tore the TCP connection down. Windows surfaces
74
+ // it where POSIX returns ECONNREFUSED (e.g. loopback under load), so leaving it
75
+ // unclassified made a raw OS errno the user-facing message on exactly one OS.
76
+ if (/ECONNRESET|connection reset/i.test(msg)) {
77
+ return {
78
+ category: 'network',
79
+ hint: `连接被重置${where}:对端或中间设备强行断开了 TCP——常见于防火墙/安全组拦截、端口上跑的不是 sshd、或远端刚重启。重试一次;持续出现则确认端口 ${port || 22} 确实指向 sshd 并检查防火墙。`,
80
+ raw: msg,
81
+ }
82
+ }
72
83
  if (/sftp operation timed out|sftp failed|readdir|writeFile|fastGet|fastPut/i.test(msg)) {
73
84
  return {
74
85
  category: 'sftp',
@@ -106,8 +117,20 @@ export function classifyError(err, opts = {}) {
106
117
  *
107
118
  * An UNCLASSIFIED failure still gets the host appended: "Connection lost before
108
119
  * handshake" or a raw errno on its own leaves the user unable to tell WHICH
109
- * machine failed, which is the one thing a multi-machine plugin must never hide. */
120
+ * machine failed, which is the one thing a multi-machine plugin must never hide.
121
+ *
122
+ * IDEMPOTENT on purpose: the pool already classifies connect-stage errors, and
123
+ * callers (rw_connect, routes) classify again at their own layer. A message
124
+ * starting with a known hint prefix passes through unchanged instead of
125
+ * growing a second "(原始错误:…)" shell. */
126
+ const KNOWN_HINT_PREFIXES = [
127
+ '主机指纹校验失败', '认证失败', 'SSH agent 不可用', '缺少凭据', '主机不可达',
128
+ 'SSH 握手未完成', '连接被拒绝', '连接被重置', 'SFTP 操作失败', '连接/操作超时',
129
+ '无法读取私钥文件', '私钥解析失败',
130
+ ]
110
131
  export function friendlyMessage(err, opts = {}) {
132
+ const incoming = String((err && err.message) || err || '')
133
+ if (KNOWN_HINT_PREFIXES.some((p) => incoming.startsWith(p))) return incoming
111
134
  const c = classifyError(err, opts)
112
135
  const where = opts.host ? ` (${opts.host}:${opts.port || 22})` : ''
113
136
  if (c.category === 'other') {
package/lib/index.js CHANGED
@@ -50,6 +50,7 @@ import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessio
50
50
  import { TaskManager } from './tasks.js'
51
51
  import { ForwardManager } from './forwards.js'
52
52
  import { WebAttach } from './web-attach.js'
53
+ import { formatAuditLine, readAuditLines } from './audit.js'
53
54
 
54
55
  import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload, lastSelfReload, isInstalledCopy } from './update.js'
55
56
  import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
@@ -1030,8 +1031,7 @@ export async function apply(ctx, config) {
1030
1031
  const user = (target && target.username) || config.username
1031
1032
  const port = (target && target.port) || config.port
1032
1033
  try {
1033
- const line = [new Date().toISOString(), `${user || '?'}@${host || '?'}:${port}`, op, code == null ? '-' : String(code), String(cmd || '').replace(/\s+/g, ' ').slice(0, 400)].join(' | ') + '\n'
1034
- appendFileSync(auditFile(), line, 'utf8')
1034
+ appendFileSync(auditFile(), formatAuditLine({ op, cmd, code, user, host, port }), 'utf8')
1035
1035
  } catch {}
1036
1036
  }
1037
1037
 
@@ -1076,15 +1076,7 @@ export async function apply(ctx, config) {
1076
1076
  return [fmt('environment', envs), fmt('location', locs), fmt('tags', tags)].filter(Boolean).join('\n')
1077
1077
  }
1078
1078
 
1079
- const readAudit = (limit) => {
1080
- try {
1081
- const text = readFileSync(auditFile(), 'utf8')
1082
- const lines = text.split('\n').filter(Boolean)
1083
- return lines.slice(-Math.max(1, Math.min(Number(limit) || 50, 500)))
1084
- } catch {
1085
- return []
1086
- }
1087
- }
1079
+ const readAudit = (limit) => readAuditLines(readFileSync, auditFile(), limit)
1088
1080
 
1089
1081
  // ── ignore rules (defaults + user file) ───────────────────────────────────
1090
1082
  // NOTE: this matcher is shared by mirror sync (rw_sync/rw_push) and search.
package/lib/pool.js CHANGED
@@ -6,6 +6,7 @@ import path from 'node:path'
6
6
  import { truncate } from './paths.js'
7
7
  import { createHostKeyGuard } from './hostkey.js'
8
8
  import { buildSftpSurface } from './sftp-surface.js'
9
+ import { friendlyMessage } from './errors.js'
9
10
  import { SystemOpenSshPool, isSystemOpenSshBackend } from './openssh.js'
10
11
 
11
12
  const { Client } = ssh2
@@ -283,11 +284,17 @@ export class SshPool {
283
284
  const guard = createHostKeyGuard(this.config, knownHostsFile)
284
285
  const client = new Client()
285
286
  let settled = false
287
+ // Every connect-stage failure is classified HERE, at the layer that knows
288
+ // which machine was being dialed: a bare ssh2 errno ("Timed out while
289
+ // waiting for handshake") names no host, and on a multi-machine plugin the
290
+ // user cannot tell WHICH box failed. friendlyMessage is idempotent, so
291
+ // callers that classify again (rw_connect, routes) do not double-wrap.
292
+ const classify = (err) => new Error(friendlyMessage(err, { host: this.config.host, port: this.config.port }))
286
293
  const fail = (err) => {
287
294
  if (settled) return
288
295
  settled = true
289
296
  if (isCurrent() && this.client === client) this.client = null
290
- throw guard.lastError ? new Error(guard.lastError) : err
297
+ throw classify(guard.lastError ? new Error(guard.lastError) : err)
291
298
  }
292
299
 
293
300
  // Proxy jump: SSH to the bastion first, then tunnel to the target through it.
@@ -345,14 +352,14 @@ export class SshPool {
345
352
  if (settled) return
346
353
  settled = true
347
354
  if (isCurrent() && this.client === client) this.client = null
348
- reject(guard.lastError ? new Error(guard.lastError) : err)
355
+ reject(classify(guard.lastError ? new Error(guard.lastError) : err))
349
356
  }
350
357
  client.on('ready', () => {
351
358
  if (settled) return
352
359
  settled = true
353
360
  if (!isCurrent()) {
354
361
  try { client.end() } catch {}
355
- reject(new Error('ssh target changed during connect'))
362
+ reject(classify(new Error('ssh target changed during connect')))
356
363
  return
357
364
  }
358
365
  this.client = client
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,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.70",
3
+ "version": "0.8.72",
4
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>",
@@ -94,6 +94,6 @@
94
94
  "scripts": {
95
95
  "test": "node --import ./test/setup-telemetry-off.mjs --test --test-timeout=40000 --test-force-exit --test-concurrency=8",
96
96
  "test:coverage": "node scripts/coverage-union.mjs",
97
- "test:coverage:gate": "node scripts/coverage-union.mjs --gate 70"
97
+ "test:coverage:gate": "node scripts/coverage-union.mjs --gate 70 --file-gate 60"
98
98
  }
99
99
  }