dsh-remote-agent 0.9.0 → 0.9.2

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 CHANGED
@@ -342,6 +342,27 @@ window.__ModuleLoader__.load({
342
342
  const nodeS = React.useState(nodes.length > 0 ? nodes[0].nodeId : '')
343
343
  const nodeId = nodeS[0]
344
344
  const setNodeId = nodeS[1]
345
+
346
+ // ── 要共享哪个目录(2026-10-17 修 v0.9.0 审查 H2)────────────────
347
+ //
348
+ // 原实现**恒定发送 `dir: ''`** —— 面板里根本没有选择目录的 UI,
349
+ // 而副机侧 `if (dir === '') reply ok:false 'plan 需要 dir'` 按设计拒绝。
350
+ // 于是「生成共享命令」**100% 失败**,SMB 共享的唯一主动作完全不可用。
351
+ //
352
+ // 数据源现成:`nodeView` 一直透出 `roots`(views.js 的 `roots: [...]`),
353
+ // 那是副机的安全边界,也正是该共享的目录。这里做下拉,默认第一项。
354
+ //
355
+ // 目录随 `plan` 一起发;副机侧仍会 `resolveWorkspace` 校验它在 roots 内
356
+ // (面板给的只是候选,真正的边界判定在副机)。
357
+ const nodeRoots = (function () {
358
+ const n = nodes.filter(function (x) { return x.nodeId === nodeId })[0]
359
+ const rs = n && Array.isArray(n.roots) ? n.roots.filter(function (r) { return typeof r === 'string' && r !== '' }) : []
360
+ return rs
361
+ })()
362
+ const dirS = React.useState(nodeRoots.length > 0 ? nodeRoots[0] : '')
363
+ const shareDir = dirS[0]
364
+ const setShareDir = dirS[1]
365
+
345
366
  const accessS = React.useState('read')
346
367
  const access = accessS[0]
347
368
  const setAccess = accessS[1]
@@ -369,13 +390,15 @@ window.__ModuleLoader__.load({
369
390
  }
370
391
 
371
392
  function doPlan() {
372
- if (nodeId === '' || !ack) return
393
+ // dir 是**必填**(副机侧拒绝空目录)。面板已在上面从 node.roots 取到
394
+ // 候选并默认选第一项;这里再守一道,防止 roots 为空的极端情况。
395
+ if (nodeId === '' || !ack || shareDir === '') return
373
396
  setBusy(true)
374
397
  setNote('')
375
398
  apiPost('/share', {
376
399
  action: 'plan',
377
400
  node: nodeId,
378
- dir: '',
401
+ dir: shareDir,
379
402
  access: access,
380
403
  confirm: 'yes',
381
404
  })
@@ -418,13 +441,51 @@ window.__ModuleLoader__.load({
418
441
  h('select', {
419
442
  className: 'dra-in',
420
443
  value: nodeId,
421
- onChange: function (e) { setNodeId(e.target.value); setPlan(null); setProbe(null) },
444
+ onChange: function (e) {
445
+ // 换副机 → 目录候选也要换(roots 是每台机器自己的),
446
+ // 顺带清掉上一次的 plan/probe 结果(否则会显示别人的共享命令)。
447
+ const nextId = e.target.value
448
+ setNodeId(nextId)
449
+ setPlan(null)
450
+ setProbe(null)
451
+ const n = nodes.filter(function (x) { return x.nodeId === nextId })[0]
452
+ const rs = n && Array.isArray(n.roots) ? n.roots.filter(function (r) { return typeof r === 'string' && r !== '' }) : []
453
+ setShareDir(rs.length > 0 ? rs[0] : '')
454
+ setAck(false)
455
+ },
422
456
  }, nodes.map(function (n) {
423
457
  return h('option', { key: n.nodeId, value: n.nodeId }, n.nodeId)
424
458
  })),
425
459
  h('button', { className: 'dra-btn', disabled: busy, onClick: doProbe }, '探测当前状态'),
426
460
  ),
427
461
 
462
+ // ── 要共享哪个目录(2026-10-17 修 H2)────────────────────────────
463
+ //
464
+ // 数据源是副机的 roots —— 那是它的安全边界,也正是该共享的目录。
465
+ // 没有 roots 时(旧副机 / 未配置)退回可手填的输入框。
466
+ h('div', { className: 'dra-row' },
467
+ h('span', { className: 'dra-k' }, '共享目录'),
468
+ nodeRoots.length > 0
469
+ ? h('select', {
470
+ className: 'dra-in',
471
+ value: shareDir,
472
+ onChange: function (e) { setShareDir(e.target.value); setPlan(null) },
473
+ }, nodeRoots.map(function (r) {
474
+ return h('option', { key: r, value: r }, r)
475
+ }))
476
+ : h('input', {
477
+ className: 'dra-in',
478
+ placeholder: '副机工作区绝对路径(如 D:\\work),需在副机 roots 内',
479
+ value: shareDir,
480
+ onChange: function (e) { setShareDir(e.target.value); setPlan(null) },
481
+ }),
482
+ ),
483
+ nodeRoots.length === 0
484
+ ? h('div', { className: 'dra-dim', style: { marginLeft: '20px' } },
485
+ '这台副机没上报 roots —— 请先确认它已配置工作区(工作区共享只允许 roots 内的目录)。')
486
+ : h('div', { className: 'dra-dim', style: { marginLeft: '20px' } },
487
+ '只共享这一个目录,不要共享整个盘。'),
488
+
428
489
  probe
429
490
  ? h('div', { className: 'dra-dim', style: { marginLeft: '20px' } },
430
491
  probe.ok === false
@@ -460,7 +521,9 @@ window.__ModuleLoader__.load({
460
521
  h('div', { className: 'dra-actions' },
461
522
  h('button', {
462
523
  className: 'dra-btn',
463
- disabled: busy || !ack || nodeId === '',
524
+ // shareDir 为空时也要禁用 —— 副机按设计拒绝空 dir,
525
+ // 不禁用只会让用户点了才看到"生成失败"(v0.9.0 的 H2 就是这样)。
526
+ disabled: busy || !ack || nodeId === '' || shareDir.trim() === '',
464
527
  onClick: doPlan,
465
528
  }, busy ? '生成中…' : '生成共享命令'),
466
529
  note ? h('span', { className: 'dra-dim' }, note) : null,
@@ -899,6 +962,21 @@ window.__ModuleLoader__.load({
899
962
  const fv = form[0]
900
963
  const setForm = form[1]
901
964
 
965
+ // 模型下拉的「自定义…」开关(2026-10-17 修 v0.9.0 审查 M2)。
966
+ //
967
+ // 原实现靠 `fv.model === '__custom__'` 判定 —— 但表单里存的是**真实
968
+ // 模型名**(或空串),从来不是字面量 '__custom__',所以:
969
+ // · 从"跟随默认"(model='')选「自定义…」→ 旧值仍为 '' → isCustom=false
970
+ // → select 的 value 弹回原选项,文本框不出现
971
+ // · 从任一有效选项选「自定义…」→ 旧模型名被保留 → isCustom=false → 同样弹回
972
+ // 唯一能进 isCustom 的路径是"表单里预先有个不在清单里的模型名"(先有鸡)。
973
+ //
974
+ // 修法:用独立的布尔 state 表达"用户主动选了自定义",
975
+ // 与 model 的值解耦。isCustom = modelCustom || (model 非空且不在清单里)。
976
+ const customS = React.useState(false)
977
+ const modelCustom = customS[0]
978
+ const setModelCustom = customS[1]
979
+
902
980
  const busy = React.useState(false)
903
981
  const isBusy = busy[0]
904
982
  const setBusy = busy[1]
@@ -1607,7 +1685,7 @@ window.__ModuleLoader__.load({
1607
1685
  (envCheck.failed === 0
1608
1686
  ? '核心项全部通过'
1609
1687
  : '共 ' + envCheck.failed + ' 项需要处理')
1610
- + (envCheck.autoFixable > 0 ? '(其中 ' + envCheck.autoFixable + ' 项可自动修)' : '')),
1688
+ + (envCheck.autoFixable > 0 ? '(其中 ' + envCheck.autoFixable + ' 项修法机械,照抄即可)' : '')),
1611
1689
  (envCheck.items || []).map(function (it) {
1612
1690
  const mark = it.ok ? '✓' : (it.severity === 'warn' ? '⚠' : '✗')
1613
1691
  return h('div', { key: it.id, className: 'dra-env-item' },
@@ -1617,7 +1695,9 @@ window.__ModuleLoader__.load({
1617
1695
  style: { color: it.ok ? 'var(--dsw-alias-state-success-primary, currentColor)' : 'var(--dsw-alias-state-error-primary, currentColor)' },
1618
1696
  }, mark),
1619
1697
  h('span', { style: { flex: '1' } }, it.label),
1620
- it.autoFix ? h('span', { className: 'dra-badge' }, '可自动修') : null,
1698
+ // v0.9.0 审查 L3:原来写「可自动修」,但**没有修复按钮**(
1699
+ // 全仓库无 fix 路由)—— 虚假承诺。改成如实描述:修法是机械的。
1700
+ it.autoFix ? h('span', { className: 'dra-badge' }, '照抄即可') : null,
1621
1701
  ),
1622
1702
  it.detail
1623
1703
  ? h('div', { className: 'dra-dim', style: { paddingLeft: '20px' } }, it.detail)
@@ -1656,8 +1736,15 @@ window.__ModuleLoader__.load({
1656
1736
  h('div', null, '现在监听在 ', h('code', null, reach.origin || ''),
1657
1737
  ',只有本机能访问。副机用任何地址都连不上。'),
1658
1738
  h('div', { style: { marginTop: '6px' } }, '修法:在 profile 的 ',
1659
- h('code', null, 'cordis.patch.yml'), ' 里给 ', h('code', null, 'webserver'),
1660
- ' 行加 ', h('code', null, "host: '0.0.0.0'"), '(保留 port),然后重启 DSH。'),
1739
+ h('code', null, 'cordis.patch.yml'), ' 里把 ', h('code', null, 'webserver'),
1740
+ ' 的 ', h('code', null, 'host'), ' 改成一个具体的本机 IP,然后重启 DSH。'),
1741
+ // ⚠ 修法**不能写 0.0.0.0**(2026-10-18 事故):新版 dsh-host-webserver
1742
+ // 在 schema 校验阶段就拒绝通配地址,会让 **DSH 启动崩溃**
1743
+ // (webserver 是 required 插件)。必须绑一个具体的本机接口地址。
1744
+ h('div', { className: 'dra-dim', style: { marginTop: '2px' } },
1745
+ '⚠ 不能用 0.0.0.0 —— 新版 DSH 会拒绝并导致启动失败。'
1746
+ + '查地址:Tailscale 用 tailscale ip -4,局域网用 ipconfig。'
1747
+ + '推荐绑 Tailscale IP(固定不变、跨网可达)。'),
1661
1748
  reach.hint
1662
1749
  ? h('div', { className: 'dra-dim', style: { marginTop: '4px' } }, reach.hint)
1663
1750
  : null,
@@ -1848,9 +1935,14 @@ window.__ModuleLoader__.load({
1848
1935
  }
1849
1936
 
1850
1937
  // 有清单:下拉。值是 `provider/model` 或空串(跟随默认)或 `__custom__`
1851
- const isCustom = fv.model !== '' && !opts.some(function (o) {
1938
+ //
1939
+ // isCustom 必须**同时**看 `modelCustom` 开关与"值不在清单里":
1940
+ // 前者是用户主动选「自定义…」的信号(M2 修复),后者是模板/URL
1941
+ // 预填了一个清单外模型名的信号。缺前者会导致选「自定义…」后
1942
+ // 文本框不出现(v0.9.0 的实际 bug)。
1943
+ const isCustom = modelCustom || (fv.model !== '' && !opts.some(function (o) {
1852
1944
  return (o.provider ? o.provider + '/' : '') + o.id === fv.model
1853
- })
1945
+ }))
1854
1946
  const multi = targets.length > 1
1855
1947
  return h('div', null,
1856
1948
  h('select', {
@@ -1858,9 +1950,23 @@ window.__ModuleLoader__.load({
1858
1950
  value: isCustom ? '__custom__' : fv.model,
1859
1951
  onChange: function (e) {
1860
1952
  const v = e.target.value
1953
+ if (v === '__custom__') {
1954
+ // 选「自定义…」:置开关 + 清空 model(让用户从空白开始填)。
1955
+ // 注意**不能**只改 model —— 原实现那样做会在 model 为空时
1956
+ // 把 select 弹回"跟随默认",文本框永远不出现(M2)。
1957
+ setModelCustom(true)
1958
+ setForm(function (f) {
1959
+ const n = Object.assign({}, f)
1960
+ n.model = ''
1961
+ return n
1962
+ })
1963
+ return
1964
+ }
1965
+ // 选具体模型或「跟随默认」:关掉自定义开关
1966
+ setModelCustom(false)
1861
1967
  setForm(function (f) {
1862
1968
  const n = Object.assign({}, f)
1863
- n.model = v === '__custom__' ? (n.model === '__custom__' ? '' : n.model) : v
1969
+ n.model = v
1864
1970
  return n
1865
1971
  })
1866
1972
  },
package/lib/env-check.js CHANGED
@@ -38,11 +38,26 @@
38
38
  * · **逐项独立**:一项失败不能让整份体检失败
39
39
  */
40
40
 
41
- /** 检查结果。`autoFix` 为真表示"我们能自动改配置修好它"。 */
41
+ /**
42
+ * 检查结果。
43
+ *
44
+ * `autoFix` 的语义(2026-10-17 修 v0.9.0 审查 L3):
45
+ * 原先是"我们能自动改配置修好它",但**全仓库根本没有 fix 路由 / --fix 参数**
46
+ * (面板没有修复按钮、CLI 没有 --fix)—— 那是个虚假承诺。
47
+ *
48
+ * 现在改成"**该项的修法是一段可直接粘进配置的片段**"(`fix` 字段即以
49
+ * `yaml:` / `命令:` 开头可直接复制的形态),`autoFix` 只表示"修法机械、
50
+ * 不需要判断",用于 UI 分组,**不再暗示有一键修复**。
51
+ */
42
52
  function item(ok, id, label, { fix = '', detail = '', autoFix = false, severity = 'error' } = {}) {
43
53
  return { ok: Boolean(ok), id, label, fix: ok ? '' : fix, detail, autoFix: ok ? false : autoFix, severity }
44
54
  }
45
55
 
56
+ // 角色解析**必须只有一份实现**:`role: auto` 是 v0.9.0 的默认值,
57
+ // 凡是判定"这台机器是 hub 还是 worker"的地方都得过这一层。
58
+ // (v0.9.0 审查 H1 的根因就是这里漏了 —— 直接拿原始 'auto' 跟 'hub' 比。)
59
+ import { resolveRole } from './index.js'
60
+
46
61
  /**
47
62
  * 判定一个 origin/URL 是否只对本机可见。
48
63
  * @param {string} origin 形如 `http://127.0.0.1:19387`
@@ -88,20 +103,68 @@ export function envChecks({
88
103
  nodesOnline = null,
89
104
  } = {}) {
90
105
  const out = []
91
- const isHub = role === 'hub' || role === 'both'
92
106
  // `config` 可能显式传 null(调用方拿不到配置快照时)—— 归一成空对象,
93
107
  // 否则下面每一处 `config.role` 都会 TypeError,整份体检直接抛。
94
108
  const cfg = config && typeof config === 'object' && !Array.isArray(config) ? config : {}
95
109
 
110
+ // ── 角色解析:**必须归一化,不能拿原始 role 字符串直接比** ────────────
111
+ //
112
+ // ⚠ 2026-10-17 修(v0.9.0 审查 H1):
113
+ //
114
+ // 原实现 `const isHub = role === 'hub' || role === 'both'` —— 而 v0.9.0 起
115
+ // **默认安装的生效配置就是 `role: 'auto'`**(Config schema 的 `.default('auto')`
116
+ // + 包内 patch 显式写入)。于是:
117
+ //
118
+ // · `isHub` = false → reachability / port / firewall / tailscale / nodes
119
+ // **五项全部跳过** —— 而这五项正是体检存在的理由(副机连不上的静默失败)
120
+ // · inject 按 worker 三件套判定 → hub 正确的 inject 反被判"缺服务"
121
+ // · 检查 1 判 role 未配置 → 误报
122
+ //
123
+ // 更糟的是首次自动体检让这份"双误报 + 零诊断"在每个新装用户首次打开面板时
124
+ // **自动弹出**。
125
+ //
126
+ // 修法:调用 `resolveRole`(与 lib/index.js 同一份实现)把 auto 推断成实际
127
+ // 角色。项目的"判据只有一份"原则在这里同样适用 —— setup-routes 的 `/state`
128
+ // 早就复用了 resolveRoleDetail,env-check 当时漏了。
129
+ //
130
+ // 兼容:调用方仍可传 `role`(面板路由只在 hub/both 时注册,传 'hub' 也对)。
131
+ //
132
+ // 优先级(**顺序很重要**,两种写法都会误报,必须精确):
133
+ // 1. **配置里写了 role**(哪怕非法/空)→ 以配置为准,交给 resolveRole 判
134
+ // 2. **配置里完全没这个字段** → 退回调用方传的 `role`
135
+ //
136
+ // 为什么不能无条件用配置:会把"调用方明确说 hub、但配置里 role 是空"
137
+ // 误判成未配置(v0.9.0 审查 H1 修复时差点引入的另一种误报)。
138
+ // 为什么不能只认"解析出具体角色":那样 `config.role='hubb'`(非法)会退回
139
+ // 用调用方的 'hub' 而放行 —— 非法配置必须如实报出来。
140
+ const givenRole = typeof role === 'string' ? role.trim().toLowerCase() : ''
141
+ const cfgHasRole = Object.prototype.hasOwnProperty.call(cfg, 'role')
142
+ && cfg.role !== undefined && cfg.role !== null
143
+ let effectiveRole = givenRole
144
+ if (cfgHasRole) {
145
+ try {
146
+ // resolveRole 对 auto 推断、对空/非法返回 ''(= 未配置,由下面如实报出)
147
+ const resolved = resolveRole(cfg)
148
+ effectiveRole = typeof resolved === 'string' ? resolved : ''
149
+ } catch {
150
+ effectiveRole = ''
151
+ }
152
+ }
153
+ // 最后一道兜底:不认识的(含空)按未配置处理
154
+ const roleRaw = effectiveRole
155
+ const isHub = roleRaw === 'hub' || roleRaw === 'both'
156
+
96
157
  /* ── 1. 角色已配置(未配置时面板显示引导页,用户会以为装坏了)── */
97
- const roleRaw = typeof cfg.role === 'string' ? cfg.role.trim().toLowerCase() : ''
98
158
  out.push(item(
99
159
  roleRaw === 'hub' || roleRaw === 'worker' || roleRaw === 'both',
100
160
  'role',
101
161
  '本机角色已配置(hub / worker / both)',
102
162
  {
103
163
  fix: "打开 Web 面板「远程同事」,点「我是主机」或「我是副机」选一次角色",
104
- detail: roleRaw === '' ? '当前 role 为空 = 未配置' : `当前 role = ${roleRaw}`,
164
+ // 说明推断来源:auto 推断出来的角色要告诉用户"为什么是这个"
165
+ detail: roleRaw === ''
166
+ ? '当前 role 为空 = 未配置'
167
+ : (cfg.role === roleRaw ? `当前 role = ${roleRaw}` : `当前 role = ${roleRaw}(由 role: ${cfg.role || '未写'} 推断)`),
105
168
  autoFix: true,
106
169
  },
107
170
  ))
@@ -140,7 +203,33 @@ export function envChecks({
140
203
  'reachability',
141
204
  'Web 服务绑在可达地址(副机才能连上)',
142
205
  {
143
- fix: "在 profile 的 cordis.patch.yml 里给 webserver 行加 host: '0.0.0.0'(保留 port),然后重启 DSH",
206
+ // 修法给**可直接粘贴的片段**(v0.9.0 审查 L3:不假装有一键修复)。
207
+ // 这是用户 profile 的配置,插件客观上不能静默代改 ——
208
+ // 用户 profile 优先于包内 patch 是明确的设计。
209
+ //
210
+ // ⚠ **绝对不要建议 `0.0.0.0`**(2026-10-18 事故)。
211
+ //
212
+ // 新版 `dsh-host-webserver` 在 schema 校验阶段就硬拒绝通配地址:
213
+ // host "0.0.0.0" is an unspecified (wildcard) address, which is not
214
+ // supported: binding every interface would expose remote code
215
+ // execution to the network; bind one concrete IPv4 or IPv6 address
216
+ // of a local interface instead
217
+ //
218
+ // 后果是 **DSH 启动崩溃**(webserver 是 required 插件,它挂了
219
+ // `webServer` 服务就缺失,至少 6 个插件排队等它)。v0.9.0 曾把这个
220
+ // 建议写进包内 patch,实测产生 10 份 crash 日志。
221
+ //
222
+ // 正确做法:绑**一个具体的本机接口地址**。推荐 Tailscale IP ——
223
+ // 它固定不变、跨网可达,正好是本插件的使用场景。
224
+ fix: '把 profile 的 cordis.patch.yml 里 webserver 的 host 改成'
225
+ + '**一个具体的本机 IP**(不能用 0.0.0.0 —— 新版 DSH 会拒绝并启动失败),'
226
+ + '然后**重启 DSH**。\n'
227
+ + ' 查地址:Tailscale 用 `tailscale ip -4`,局域网用 `ipconfig`。\n'
228
+ + ' 推荐绑 Tailscale IP(固定不变、跨网可达):\n'
229
+ + ' - id: webserver\n'
230
+ + ' config:\n'
231
+ + " host: '<你的具体 IP,例如 100.x.x.x>'\n"
232
+ + ' port: 19387',
144
233
  detail: loop
145
234
  ? `当前只监听 ${reachability.bound || '127.0.0.1'}(${reachability.origin || ''})—— 副机用任何地址都连不上`
146
235
  : `当前监听 ${reachability.origin || ''}`,
@@ -232,13 +321,16 @@ export function renderEnvChecks(result, { title = '环境体检' } = {}) {
232
321
  lines.push(`${mark} ${it.label}`)
233
322
  if (it.detail) lines.push(` ${it.detail}`)
234
323
  if (!it.ok && it.fix) lines.push(` ↳ 怎么修:${it.fix}`)
235
- if (!it.ok && it.autoFix) lines.push(' ↳ 可以自动修(面板上点「修复」,或命令行加 --fix)')
324
+ // v0.9.0 审查 L3:原文案写"面板上点「修复」,或命令行加 --fix" ——
325
+ // **两者都不存在**(全仓库无 fix 路由 / 无 --fix 参数)。虚假承诺比没有
326
+ // 提示更糟:用户会去找那个按钮,找不到就以为功能坏了。改成如实说明。
327
+ if (!it.ok && it.autoFix) lines.push(' ↳ 修法是机械的(照上面复制粘贴即可,不需要判断)')
236
328
  }
237
329
  lines.push('')
238
330
  if (result.ok && result.failed === 0) {
239
331
  lines.push('核心项全部通过。')
240
332
  } else {
241
- lines.push(`共 ${result.failed} 项需要处理${result.autoFixable > 0 ? `(其中 ${result.autoFixable} 项可自动修)` : ''}。`)
333
+ lines.push(`共 ${result.failed} 项需要处理${result.autoFixable > 0 ? `(其中 ${result.autoFixable} 项修法机械,照抄即可)` : ''}。`)
242
334
  }
243
335
  return { text: lines.join('\n'), failed: result.failed, autoFixable: result.autoFixable }
244
336
  }
package/lib/hub/index.js CHANGED
@@ -305,14 +305,17 @@ export function applyHub(ctx, config = {}) {
305
305
  origin,
306
306
  loopback,
307
307
  hint: loopback
308
- ? "webServer 绑在 loopback,副机无法接入。在 profile 的 cordis.patch.yml 里给 webserver 行加 host: '0.0.0.0'(保留 port),然后重启 DSH。"
308
+ // ⚠ 不能建议 0.0.0.0(2026-10-18 事故):新版 dsh-host-webserver 在
309
+ // schema 校验阶段就拒绝通配地址 → webserver 无法激活 → webServer
310
+ // 服务缺失 → DSH 启动崩溃。必须绑一个**具体的本机接口地址**。
311
+ ? 'webServer 绑在 loopback,副机无法接入。在 profile 的 cordis.patch.yml 里把 webserver 的 host 改成一个具体的本机 IP(推荐 Tailscale IP:tailscale ip -4),然后重启 DSH。不能用 0.0.0.0 —— 新版 DSH 会拒绝并启动失败。'
309
312
  : '',
310
313
  }
311
314
  if (loopback) {
312
315
  log('warn', 'hub 绑在 loopback:副机将无法接入', {
313
316
  origin,
314
317
  bound: hostname,
315
- fix: "给 webserver 加 host: '0.0.0.0'",
318
+ fix: "把 webserver 的 host 改成具体的本机 IP(如 Tailscale IP),不能用 0.0.0.0",
316
319
  })
317
320
  } else {
318
321
  log('info', 'hub reachable from other hosts', { origin, bound: hostname })
@@ -102,7 +102,22 @@ export function withIngest(Base) {
102
102
  if (!t) return
103
103
  if (d.handoff && typeof d.handoff === 'object') {
104
104
  t.handoff = d.handoff
105
- if (typeof d.handoff.status === 'string') t.status = d.handoff.status
105
+ // ⚠ 终态守卫(2026-10-17 修 v0.9.0 审查 L5)。
106
+ //
107
+ // 场景:approveTask 时 send 返回 false → 我们已把任务置成
108
+ // FAILED(node-unreachable),但那一帧其实在途 → 节点跑完回传
109
+ // 真实终态。原实现无条件 `t.status = d.handoff.status`,会把
110
+ // 已置的 FAILED 改写掉。
111
+ //
112
+ // 后果良性(记录反而更真实),但破坏"终态即终态"的心智模型,
113
+ // 且让"批准者已收到 unreachable 错误、可能重派"的双跑风险更难察觉。
114
+ //
115
+ // 守卫规则:**已经是终态就不再被后来的帧改写**。
116
+ // 例外:同一个终态值重复到达是幂等的,允许(无副作用)。
117
+ const incoming = typeof d.handoff.status === 'string' ? d.handoff.status : ''
118
+ if (incoming !== '' && (t.status === incoming || !TERMINAL_STATUS.has(t.status))) {
119
+ t.status = incoming
120
+ }
106
121
  if (Array.isArray(d.handoff.changed)) t.changed = d.handoff.changed
107
122
  if (d.handoff.summary && typeof d.handoff.summary === 'object') t.summary = d.handoff.summary
108
123
  if (d.handoff.exit && typeof d.handoff.exit === 'object') t.exit = d.handoff.exit
@@ -126,7 +141,11 @@ export function withIngest(Base) {
126
141
  case NODE_TO_HUB.TASK_END: {
127
142
  const t = this.ensureTask(nodeId, d.taskId)
128
143
  if (!t) return
129
- if (typeof d.status === 'string') t.status = d.status
144
+ // ⚠ 终态守卫(2026-10-17 修 v0.9.0 审查 L5,与 TASK_SUMMARY 同款):
145
+ // 已是终态就不再被改写,除非到达的是**同一个**终态(幂等重发)。
146
+ if (typeof d.status === 'string' && (t.status === d.status || !TERMINAL_STATUS.has(t.status))) {
147
+ t.status = d.status
148
+ }
130
149
  t.endedAt = typeof d.endedAt === 'number' ? d.endedAt : Date.now()
131
150
  if (typeof d.durationMs === 'number') t.durationMs = d.durationMs
132
151
  if (d.error && typeof d.error === 'object') t.error = d.error
@@ -436,8 +436,11 @@ function projectRecord(raw) {
436
436
  * 保存 → 保存报错,而用户看到的只是"保存失败",无从知道重试就能成功。
437
437
  *
438
438
  * 所以这里对**瞬态锁**做有界重试(EPERM / EBUSY / EACCES)。三条边界:
439
- * - 有界:最多 RETRY 次,总等待 ~1.3s(指数退避封顶 200ms)—— 不把一次
440
- * 保存变成无限等待
439
+ * - 有界:最多 `RENAME_RETRIES`(16)次,指数退避封顶 `RENAME_BACKOFF_MAX_MS`
440
+ * (500ms),**总等待 ~6s** —— 不把一次保存变成无限等待
441
+ * (历史:最初 6 次×10ms = 210ms,被 GitHub windows runner 饿过窗口;
442
+ * 后来 10 次封顶 200ms ≈ 1.3s 仍失败,才拉到现在这个窗口。
443
+ * ⚠ 改这两个常量前先读 RENAME_RETRIES 定义处的完整事故史。)
441
444
  * - 只重试这三类码:真错误(ENOSPC / EROFS)立即抛出,重试只是浪费时间
442
445
  * - 退避递增:读者通常几毫秒就结束,前两次短等待就能成功
443
446
  *
@@ -104,6 +104,9 @@ import { createTemplateStore, defaultTemplatesFile } from './templates.js'
104
104
  // 存在的理由是那个最贵的静默失败 —— webserver 绑 loopback 时副机永远连不上,
105
105
  // 而主机侧毫无异常。面板必须把它渲染出来。
106
106
  import { envChecks } from '../env-check.js'
107
+ // 角色解析**只有一份实现**:v0.9.0 起默认 role 是 'auto',凡是判定
108
+ // "是不是 hub" 的地方都必须过这一层(v0.9.0 审查 H1 的根因就是这里漏了)。
109
+ import { resolveRole } from '../index.js'
107
110
 
108
111
  export const UI_API_PREFIX = '/api/remote-agent/ui'
109
112
 
@@ -680,7 +683,17 @@ export function registerUiRoutes({
680
683
  try {
681
684
  const nodes = registry.list()
682
685
  result = envChecks({
683
- role: typeof config.role === 'string' ? config.role : 'hub',
686
+ // ⚠ 必须传**解析后**的角色(2026-10-17 修 v0.9.0 审查 H1)。
687
+ //
688
+ // 原实现传的是原始 `config.role` —— 而 v0.9.0 起默认安装的生效配置
689
+ // 就是 `role: 'auto'`。envChecks 内部的 `isHub` 判据若拿到未解析的
690
+ // 'auto' 会判 false,导致 reachability/port/firewall/tailscale/nodes
691
+ // **五项全部跳过**,role 与 inject 两项还会误报 —— 体检对 100%
692
+ // 默认安装用户完全失效。
693
+ //
694
+ // 本路由只在 hub(或 both)分支注册,所以直接传 'hub' 也正确;
695
+ // 用 resolveRole 更稳妥(覆盖 both 与显式 hub 两种)。
696
+ role: resolveRole(config) || 'hub',
684
697
  config,
685
698
  reachability,
686
699
  nodesOnline: nodes.filter((n) => n.online).length,
@@ -110,18 +110,59 @@ export function isAdmin() {
110
110
  *
111
111
  * `net share` 不需要管理员(只读查询),所以即使非管理员也能用于**展示**。
112
112
  *
113
+ * ⚠ 2026-10-17 修(v0.9.0 审查 L2):优先用 `Get-SmbShare` 取**结构化**路径。
114
+ *
115
+ * 原来只解析 `net share` 的文本输出,正则 `[A-Za-z]:\\[^\s]*` 在路径含空格时
116
+ * 会截断 —— `D:\Deepseek Harness file` → `D:\Deepseek`。而 Windows 工作区路径
117
+ * 带空格很常见(本项目自己的目录就叫 `D:\Deepseek Harness file`!)。
118
+ *
119
+ * 截断的后果不是崩溃,而是**比对失败**:`findShareForDir` 找不到已存在的共享
120
+ * → 面板把已共享目录显示为未共享 → 用户再点一次才看到"共享名已存在"。
121
+ * 属"可见错误而非静默 corruption",但体验很差。
122
+ *
123
+ * 修法:`Get-SmbShare | ConvertTo-Json` 返回 `{Name, Path}` 精确值,
124
+ * 完全避开文本解析。拿不到(PowerShell 缺失/被禁)时回退 `net share`。
125
+ *
113
126
  * @returns {Array<{name:string, path:string}>}
114
127
  */
115
128
  export function listShares() {
116
129
  if (process.platform !== 'win32') return []
130
+
131
+ // ── 路径 1:Get-SmbShare(结构化,路径不截断)────────────────────
132
+ try {
133
+ const r = spawnSync(
134
+ 'powershell',
135
+ ['-NoProfile', '-NonInteractive', '-Command',
136
+ 'Get-SmbShare | Select-Object Name,Path | ConvertTo-Json -Compress'],
137
+ { encoding: 'utf8', windowsHide: true, timeout: 10000 },
138
+ )
139
+ if (r.status === 0 && typeof r.stdout === 'string' && r.stdout.trim() !== '') {
140
+ const parsed = JSON.parse(r.stdout)
141
+ const arr = Array.isArray(parsed) ? parsed : [parsed]
142
+ const out = []
143
+ for (const x of arr) {
144
+ if (!x || typeof x.Name !== 'string' || x.Name === '') continue
145
+ out.push({ name: x.Name, path: typeof x.Path === 'string' ? x.Path : '' })
146
+ }
147
+ if (out.length > 0) return out
148
+ }
149
+ } catch {
150
+ /* 回退到 net share */
151
+ }
152
+
153
+ // ── 路径 2:net share 文本解析(回退;含空格路径可能截断)──────────
117
154
  try {
118
155
  const r = spawnSync('net', ['share'], { encoding: 'utf8', windowsHide: true, timeout: 8000 })
119
156
  if (r.status !== 0 || typeof r.stdout !== 'string') return []
120
157
  const out = []
121
158
  for (const line of r.stdout.split(/\r?\n/)) {
122
159
  // 形如:`dsh-work-a1b2c3 D:\work `
123
- const m = /^(\S+)\s+([A-Za-z]:\\[^\s]*)/.exec(line.trim())
124
- if (m) out.push({ name: m[1], path: m[2] })
160
+ //
161
+ // 共享名不含空格(我们的 dsh-work-<hash> 与系统 C$/ADMIN$ 都是),
162
+ // 所以 `^(\S+)\s+(.+?)\s*$` 里第一段是名、**剩下的整段**是路径 ——
163
+ // 不要把路径按空白截断(L2 的原 bug)。
164
+ const m = /^(\S+)\s+([A-Za-z]:\\.*?)\s*$/.exec(line.trim())
165
+ if (m) out.push({ name: m[1], path: m[2].trim() })
125
166
  }
126
167
  return out
127
168
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-agent",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "type": "module",
5
5
  "description": "把另一台电脑变成你 DSH 的远程 Agent 同事:副机本地持久跑任务、独立 Agent 循环,只回传 handoff 摘要与产物清单,成品按需拉取。",
6
6
  "license": "MIT",