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/CHANGELOG.md +211 -0
- package/README.en.md +12 -5
- package/README.md +39 -12
- package/bin/dsh-remote-setup.js +75 -2
- package/cordis.patch.yml +31 -24
- package/lib/client.js +117 -11
- package/lib/env-check.js +99 -7
- package/lib/hub/index.js +5 -2
- package/lib/hub/registry-ingest.js +21 -2
- package/lib/hub/templates.js +5 -2
- package/lib/hub/ui-routes.js +14 -1
- package/lib/node/share-service.js +43 -2
- package/package.json +1 -1
- package/scripts/check-doc-drift.mjs +92 -15
- package/scripts/diag-worker.ps1 +148 -0
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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'), '
|
|
1660
|
-
'
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/lib/hub/templates.js
CHANGED
|
@@ -436,8 +436,11 @@ function projectRecord(raw) {
|
|
|
436
436
|
* 保存 → 保存报错,而用户看到的只是"保存失败",无从知道重试就能成功。
|
|
437
437
|
*
|
|
438
438
|
* 所以这里对**瞬态锁**做有界重试(EPERM / EBUSY / EACCES)。三条边界:
|
|
439
|
-
* - 有界:最多
|
|
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
|
*
|
package/lib/hub/ui-routes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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 {
|