dsh-remote-agent 0.8.1 → 0.9.0

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.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * 环境体检(`env-check`)—— 命令行与 Web 面板共用的同一份检查逻辑。
3
+ *
4
+ * ═══════════════════════════════════════════════════════════════════════════
5
+ * 为什么需要它(2026-10-17 的真实教训)
6
+ * ═══════════════════════════════════════════════════════════════════════════
7
+ *
8
+ * 用户装好插件、配好配对码,副机**永远连不上**,而主机侧**没有任何异常**:
9
+ * hub 自己 healthz 200、面板能开、工具都在,只有副机连不上。
10
+ *
11
+ * 根因是 DSH 的 `webserver` 默认绑 `127.0.0.1`(它面向"本机浏览器访问自己"),
12
+ * 而本插件是跨机用法。这个失败**完全静默** —— 用户只能猜"地址错了?token 错了?
13
+ * 防火墙?"
14
+ *
15
+ * 所以本模块把这些"静默失败的前置条件"逐条查出来,每条都给**人话原因**和
16
+ * **具体修法**,能自动修的给 `autoFix` 标记。
17
+ *
18
+ * ── 三档处置(产品决策,2026-10-17 用户确认)────────────────────────────
19
+ *
20
+ * A. **自动修**(只改我们自己的配置,不下载任何东西)
21
+ * webserver 绑 loopback、role/inject 缺失、roots 不存在
22
+ * B. **提示 + 复制命令**(需要用户装东西)
23
+ * Tailscale 未安装 —— 只给官方链接和命令,**不代下载**
24
+ * C. **只诊断**(需要人判断)
25
+ * 端口被占、防火墙、模型凭证失效
26
+ *
27
+ * ── 为什么不代下载(重要)──────────────────────────────────────────────
28
+ *
29
+ * 本插件的核心卖点是**零 npm 依赖、零构建**(README 明确写着)。代用户下载
30
+ * 第三方二进制会让它变成软件分发方:装哪个版本、从哪下、校验和怎么保证、
31
+ * 装失败算谁的、供应链攻击面 —— 全都落在我们头上。所以档位 B 只给提示。
32
+ *
33
+ * ── 设计约束 ────────────────────────────────────────────────────────────
34
+ *
35
+ * · **纯函数 + 注入探测**:本模块不自己 spawn 命令、不自己读 env,
36
+ * 所有外部探测由调用方传入(便于单测、也便于命令行走真进程而面板走缓存)
37
+ * · **绝不抛**:任何一项探测失败都落成"该项 ok=false + 说明",不影响其它项
38
+ * · **逐项独立**:一项失败不能让整份体检失败
39
+ */
40
+
41
+ /** 检查结果。`autoFix` 为真表示"我们能自动改配置修好它"。 */
42
+ function item(ok, id, label, { fix = '', detail = '', autoFix = false, severity = 'error' } = {}) {
43
+ return { ok: Boolean(ok), id, label, fix: ok ? '' : fix, detail, autoFix: ok ? false : autoFix, severity }
44
+ }
45
+
46
+ /**
47
+ * 判定一个 origin/URL 是否只对本机可见。
48
+ * @param {string} origin 形如 `http://127.0.0.1:19387`
49
+ * @returns {boolean|null} null = 无法判定
50
+ */
51
+ export function isLoopbackOrigin(origin) {
52
+ if (typeof origin !== 'string' || origin.trim() === '') return null
53
+ let host
54
+ try {
55
+ host = new URL(origin).hostname
56
+ } catch {
57
+ return null
58
+ }
59
+ const h = host.toLowerCase()
60
+ // IPv6 在 URL.hostname 里带方括号
61
+ return h === '127.0.0.1' || h === 'localhost' || h === '[::1]' || h === '::1'
62
+ }
63
+
64
+ /**
65
+ * 环境体检。
66
+ *
67
+ * @param {object} o
68
+ * @param {'hub'|'worker'} o.role
69
+ * @param {object} [o.config] 本行生效配置(只读快照)
70
+ * @param {object|null} [o.reachability] hub 的可达性自检结论(applyHub 产出)
71
+ * @param {number|null} [o.webPort] 实际监听的端口(探测结果)
72
+ * @param {boolean|null} [o.portFree] 目标端口是否空闲(探测结果)
73
+ * @param {boolean|null} [o.firewallOk] 防火墙是否放行(探测结果;null=未探测)
74
+ * @param {string[]} [o.missingRoots] roots 里不存在的路径(探测结果)
75
+ * @param {boolean|null} [o.tailscaleUp] Tailscale 是否可用(探测结果)
76
+ * @param {number} [o.nodesOnline] 已在线副机数
77
+ * @returns {{ok:boolean, items:Array, failed:number, autoFixable:number}}
78
+ */
79
+ export function envChecks({
80
+ role,
81
+ config = {},
82
+ reachability = null,
83
+ webPort = null,
84
+ portFree = null,
85
+ firewallOk = null,
86
+ missingRoots = [],
87
+ tailscaleUp = null,
88
+ nodesOnline = null,
89
+ } = {}) {
90
+ const out = []
91
+ const isHub = role === 'hub' || role === 'both'
92
+ // `config` 可能显式传 null(调用方拿不到配置快照时)—— 归一成空对象,
93
+ // 否则下面每一处 `config.role` 都会 TypeError,整份体检直接抛。
94
+ const cfg = config && typeof config === 'object' && !Array.isArray(config) ? config : {}
95
+
96
+ /* ── 1. 角色已配置(未配置时面板显示引导页,用户会以为装坏了)── */
97
+ const roleRaw = typeof cfg.role === 'string' ? cfg.role.trim().toLowerCase() : ''
98
+ out.push(item(
99
+ roleRaw === 'hub' || roleRaw === 'worker' || roleRaw === 'both',
100
+ 'role',
101
+ '本机角色已配置(hub / worker / both)',
102
+ {
103
+ fix: "打开 Web 面板「远程同事」,点「我是主机」或「我是副机」选一次角色",
104
+ detail: roleRaw === '' ? '当前 role 为空 = 未配置' : `当前 role = ${roleRaw}`,
105
+ autoFix: true,
106
+ },
107
+ ))
108
+
109
+ /* ── 2. inject 含必需服务(缺了 hub 半死 / agent 无工具)── */
110
+ const inject = Array.isArray(cfg.inject) ? cfg.inject : null
111
+ // inject 通常在 loader 行上,config 快照里可能没有 —— 拿不到就跳过(不误报)
112
+ if (inject !== null) {
113
+ const need = isHub ? ['webServer', 'tools', 'systemPrompt'] : ['tools', 'agentLoop', 'sessions']
114
+ const missing = need.filter((s) => !inject.includes(s))
115
+ out.push(item(
116
+ missing.length === 0,
117
+ 'inject',
118
+ `inject 含本角色必需的服务(${need.join(' / ')})`,
119
+ {
120
+ fix: `在 profile 的 cordis.patch.yml 里把本插件的 inject 改成 [${need.map((s) => `'${s}'`).join(', ')}]`,
121
+ detail: missing.length ? `缺少:${missing.join(', ')}` : '',
122
+ autoFix: true,
123
+ },
124
+ ))
125
+ }
126
+
127
+ /* ── 3. hub 可达性(**最贵的一条**:绑 loopback 时副机永远连不上)── */
128
+ if (isHub) {
129
+ const loop = reachability && typeof reachability.loopback === 'boolean'
130
+ ? reachability.loopback
131
+ : (reachability && typeof reachability.origin === 'string' ? isLoopbackOrigin(reachability.origin) : null)
132
+ if (loop === null) {
133
+ out.push(item(true, 'reachability', 'Web 服务可达性(无法判定,已跳过)', {
134
+ detail: reachability && reachability.hint ? reachability.hint : '未拿到 webServer.getOrigin()',
135
+ severity: 'info',
136
+ }))
137
+ } else {
138
+ out.push(item(
139
+ loop === false,
140
+ 'reachability',
141
+ 'Web 服务绑在可达地址(副机才能连上)',
142
+ {
143
+ fix: "在 profile 的 cordis.patch.yml 里给 webserver 行加 host: '0.0.0.0'(保留 port),然后重启 DSH",
144
+ detail: loop
145
+ ? `当前只监听 ${reachability.bound || '127.0.0.1'}(${reachability.origin || ''})—— 副机用任何地址都连不上`
146
+ : `当前监听 ${reachability.origin || ''}`,
147
+ autoFix: true,
148
+ },
149
+ ))
150
+ }
151
+ }
152
+
153
+ /* ── 4. 端口占用(hub 需要能绑上目标端口)── */
154
+ if (isHub && portFree !== null) {
155
+ out.push(item(portFree === true, 'port', 'hub 端口可用', {
156
+ fix: '换一个端口(改 webserver 的 port),或先关掉占用它的程序',
157
+ detail: portFree === false ? `端口 ${webPort ?? '?'} 已被占用` : '',
158
+ }))
159
+ }
160
+
161
+ /* ── 5. 防火墙(局域网/公网场景;Tailscale 不受影响)── */
162
+ if (isHub && firewallOk !== null) {
163
+ out.push(item(firewallOk === true, 'firewall', '防火墙已放行 hub 端口', {
164
+ fix: `以管理员运行:netsh advfirewall firewall add rule name="dsh-remote-agent" dir=in action=allow protocol=TCP localport=${webPort ?? 19387}`,
165
+ detail: firewallOk === false
166
+ ? '局域网/公网的副机会被挡住(走 Tailscale 的不受影响)'
167
+ : '',
168
+ autoFix: true,
169
+ }))
170
+ }
171
+
172
+ /* ── 6. roots 存在(worker:任务全报 workspace-denied)── */
173
+ const roots = Array.isArray(cfg.roots) ? cfg.roots.filter((r) => typeof r === 'string' && r.trim() !== '') : []
174
+ if (roleRaw === 'worker' || roleRaw === 'both') {
175
+ if (roots.length === 0) {
176
+ out.push(item(false, 'roots', 'roots 已配置且路径存在', {
177
+ fix: '在 profile 的 cordis.patch.yml 里给本插件加 roots: [\'D:\\\\work\'](绝对路径,必须真实存在)',
178
+ detail: 'roots 为空 —— 任务会被全部拒绝(workspace-denied)',
179
+ autoFix: true,
180
+ }))
181
+ } else if (Array.isArray(missingRoots) && missingRoots.length > 0) {
182
+ out.push(item(false, 'roots', 'roots 已配置且路径存在', {
183
+ fix: `创建这些目录,或把 roots 改成真实存在的路径:${missingRoots.join(', ')}`,
184
+ detail: `不存在的路径:${missingRoots.join(', ')}`,
185
+ autoFix: true,
186
+ }))
187
+ } else {
188
+ out.push(item(true, 'roots', 'roots 已配置且路径存在', { detail: roots.join(', ') }))
189
+ }
190
+ }
191
+
192
+ /* ── 7. Tailscale(跨网场景的推荐路径;**只提示不代下载**)── */
193
+ if (isHub && tailscaleUp !== null) {
194
+ out.push(item(tailscaleUp === true, 'tailscale', 'Tailscale 可用(跨网推荐)', {
195
+ // ⚠ 边界(产品决策,2026-10-17 用户确认):本插件**不代用户下载任何第三方软件**。
196
+ // 理由:零 npm 依赖 / 零构建是核心卖点;代下载会让我们变成软件分发方
197
+ // (装哪个版本、从哪下、校验和怎么保证、装失败算谁的、供应链攻击面)。
198
+ // 所以这里只给官方链接 + 说明,动作留给用户。
199
+ fix: '装 Tailscale 并登录同一账号:https://tailscale.com/download —— 装好后两台机器自动互通,'
200
+ + '再用 tailscale ip -4 拿主机 IP 填到副机。'
201
+ + '(本插件不会代你下载第三方软件,只做检测与提示。)',
202
+ detail: tailscaleUp === false
203
+ ? '没装 / 没登录。同一局域网可以直接用主机局域网 IP;跨网建议装 Tailscale'
204
+ : '',
205
+ severity: 'warn',
206
+ }))
207
+ }
208
+
209
+ /* ── 8. 副机是否连上过(最能说明"到底通不通"的一条)── */
210
+ if (isHub && nodesOnline !== null) {
211
+ out.push(item(nodesOnline > 0, 'nodes', '至少有一台副机在线', {
212
+ fix: '检查:① 副机的 hubUrl 是否含 :19387 端口 ② 副机 DSH 是否在跑 '
213
+ + '③ 上面那条「Web 服务绑在可达地址」是否通过 ④ 防火墙',
214
+ detail: nodesOnline > 0 ? `${nodesOnline} 台在线` : '还没有副机连上过',
215
+ severity: 'warn',
216
+ }))
217
+ }
218
+
219
+ const failed = out.filter((x) => !x.ok && x.severity === 'error').length
220
+ const autoFixable = out.filter((x) => !x.ok && x.autoFix).length
221
+ return { ok: failed === 0, items: out, failed, autoFixable }
222
+ }
223
+
224
+ /**
225
+ * 把体检结果渲染成人读文本(命令行用;面板自己渲染,不走这里)。
226
+ * @param {ReturnType<typeof envChecks>} result
227
+ */
228
+ export function renderEnvChecks(result, { title = '环境体检' } = {}) {
229
+ const lines = [title, '']
230
+ for (const it of result.items) {
231
+ const mark = it.ok ? '✓' : (it.severity === 'warn' ? '⚠' : '✗')
232
+ lines.push(`${mark} ${it.label}`)
233
+ if (it.detail) lines.push(` ${it.detail}`)
234
+ if (!it.ok && it.fix) lines.push(` ↳ 怎么修:${it.fix}`)
235
+ if (!it.ok && it.autoFix) lines.push(' ↳ 可以自动修(面板上点「修复」,或命令行加 --fix)')
236
+ }
237
+ lines.push('')
238
+ if (result.ok && result.failed === 0) {
239
+ lines.push('核心项全部通过。')
240
+ } else {
241
+ lines.push(`共 ${result.failed} 项需要处理${result.autoFixable > 0 ? `(其中 ${result.autoFixable} 项可自动修)` : ''}。`)
242
+ }
243
+ return { text: lines.join('\n'), failed: result.failed, autoFixable: result.autoFixable }
244
+ }
package/lib/hub/index.js CHANGED
@@ -81,7 +81,22 @@ export function normalizeConfig(config = {}) {
81
81
  helloTimeoutMs: numOr(limits.helloTimeoutMs, 10_000),
82
82
  },
83
83
  defaults: {
84
- timeoutMs: numOr(defaults.timeoutMs, 60 * 60 * 1000),
84
+ // ⚠ **不要在这里兜底 1h**(2026-10-17 修,行为变更)。
85
+ //
86
+ // 原实现 `numOr(defaults.timeoutMs, 60 * 60 * 1000)` 让 explicitTimeoutMs
87
+ // 恒为 3600000,于是 tools.js 里 #26-1 的优先级
88
+ //
89
+ // args > 显式配置 > 分模式默认(exec 10min / agent 1h)
90
+ //
91
+ // 被压扁成「恒 1h」——**exec 的 10 分钟默认成了死代码**,一条失控命令
92
+ // 比文档承诺的多跑 50 分钟才被杀。而且那个兜底还掩盖了 tools.js 里的
93
+ // 一个 TDZ 引用 bug(`v.spec.mode` 自引用),让它在生产环境永不触发。
94
+ //
95
+ // 现在保持 undefined:不配置就是"没有显式指定",让 tools.js 按 mode
96
+ // 分模式取默认值。用户显式配 `defaults.timeoutMs` 时仍然优先。
97
+ timeoutMs: typeof defaults.timeoutMs === 'number' && Number.isFinite(defaults.timeoutMs)
98
+ ? defaults.timeoutMs
99
+ : undefined,
85
100
  },
86
101
  }
87
102
  }
@@ -241,6 +256,76 @@ export function applyHub(ctx, config = {}) {
241
256
  log('warn', 'audit log unavailable; running without audit', { error: String(err?.message ?? err) })
242
257
  audit = null
243
258
  }
259
+
260
+ // ── 可达性自检:webServer 绑在 loopback 时副机永远连不上 ────────────────
261
+ //
262
+ // ═══════════════════════════════════════════════════════════════════════════
263
+ // 这是一个**静默失败**,2026-10-17 实测踩到
264
+ // ═══════════════════════════════════════════════════════════════════════════
265
+ //
266
+ // DSH 的 `@deepseek-ai/dsh-host-webserver` 的 `host` 是**必填项、无默认值**,
267
+ // 而它面向的默认场景是"本机浏览器访问自己",所以多数 profile 里绑的是
268
+ // `127.0.0.1`。`webServer.getOrigin()` 的文档也明说它返回的是
269
+ // "the root URL **local clients** use to reach the bound listener"。
270
+ //
271
+ // 但本插件的整个用法是**跨机**:副机拿 `ws://<主机IP>:19387/...` 拨入。
272
+ // 绑 loopback 时:
273
+ // · hub 自己一切正常(healthz 200、面板能开、工具都在)
274
+ // · 副机**永远连不上**,报 `ECONNREFUSED` / `connect timeout`
275
+ // · 主机侧**没有任何异常**,用户只能去猜"地址填错了?token 错了?防火墙?"
276
+ //
277
+ // 三种用户会踩:
278
+ // A. 全新安装,没动过 webserver 配置(**绝大多数**)
279
+ // B. 装过某个改 lanBind 的插件又卸载,托管块残留但插件没了
280
+ // C. 自己改过 webserver,绑错了
281
+ //
282
+ // 所以这里做两件事:
283
+ // 1. **启动时 log 一条明确的 warn**(带修法,不是只说"不可达")
284
+ // 2. 把结论存进 `reachability`,由面板渲染成**红色横幅** ——
285
+ // 日志用户不会看,面板会看
286
+ //
287
+ // ⚠ 自检本身**绝不能**让 hub 起不来:`getOrigin()` 在 listen 之前会抛,
288
+ // 服务缺失时也不存在。整段包 try/catch,拿不到就记 `unknown`。
289
+ let reachability = { bound: null, origin: null, loopback: null, hint: '' }
290
+ try {
291
+ const ws = ctx.webServer
292
+ const origin = typeof ws?.getOrigin === 'function' ? ws.getOrigin() : null
293
+ if (typeof origin === 'string' && origin !== '') {
294
+ let hostname = null
295
+ try {
296
+ hostname = new URL(origin).hostname
297
+ } catch {
298
+ /* 非 URL 形态:保持 null */
299
+ }
300
+ const h = typeof hostname === 'string' ? hostname.toLowerCase() : ''
301
+ // IPv6 在 URL.hostname 里带方括号
302
+ const loopback = h === '127.0.0.1' || h === 'localhost' || h === '[::1]' || h === '::1'
303
+ reachability = {
304
+ bound: hostname,
305
+ origin,
306
+ loopback,
307
+ hint: loopback
308
+ ? "webServer 绑在 loopback,副机无法接入。在 profile 的 cordis.patch.yml 里给 webserver 行加 host: '0.0.0.0'(保留 port),然后重启 DSH。"
309
+ : '',
310
+ }
311
+ if (loopback) {
312
+ log('warn', 'hub 绑在 loopback:副机将无法接入', {
313
+ origin,
314
+ bound: hostname,
315
+ fix: "给 webserver 加 host: '0.0.0.0'",
316
+ })
317
+ } else {
318
+ log('info', 'hub reachable from other hosts', { origin, bound: hostname })
319
+ }
320
+ } else {
321
+ reachability = { bound: null, origin: null, loopback: null, hint: 'webServer.getOrigin() 不可用,无法判断可达性' }
322
+ }
323
+ } catch (err) {
324
+ // getOrigin() 在 listen 完成前会抛(文档明说);服务缺失也会抛。
325
+ reachability = { bound: null, origin: null, loopback: null, hint: `可达性自检失败:${String(err?.message ?? err)}` }
326
+ log('info', 'reachability self-check unavailable', { error: String(err?.message ?? err) })
327
+ }
328
+
244
329
  const registry = new NodeRegistry({ log, limits: cfg.limits, identity: hubIdentity, audit })
245
330
  const pulls = new PullManager({ registry, log, limits: cfg.limits, audit })
246
331
  registry.bindPulls(pulls)
@@ -391,6 +476,9 @@ export function applyHub(ctx, config = {}) {
391
476
  // 本行生效配置的只读快照,供面板「主机设置」区展示。
392
477
  // 传**原始** config(而非归一化后的 cfg),因为面板要如实反映用户写了什么。
393
478
  config,
479
+ // 可达性自检结论(2026-10-17):绑 loopback 时副机永远连不上,
480
+ // 面板要渲染成红色横幅 —— 这个失败在主机侧是完全静默的。
481
+ reachability,
394
482
  }).map(once)
395
483
  } catch (err) {
396
484
  log('error', 'ui route registration threw', { error: String(err?.message ?? err) })
@@ -86,6 +86,14 @@ class NodeRegistryCore {
86
86
  this.tasks = new Map()
87
87
  /** reqId → { resolve, timer }(提案 #25:远程文件请求的等待者表) */
88
88
  this.fsPending = new Map()
89
+ /**
90
+ * reqId → { resolve, timer }(2026-10-17:工作区共享请求的等待者表)。
91
+ *
92
+ * 与 fsPending **刻意分开**:fs 是"读文件内容"、share 是"探测共享状态并
93
+ * 生成命令",两者语义不同。混用一张表会让超时归因变模糊(一条 share 超时
94
+ * 被记成 fs-timeout,排查方向直接跑偏)。
95
+ */
96
+ this.sharePending = new Map()
89
97
  /** @type {import('./pull-manager.js').PullManager | undefined} */
90
98
  this.pulls = undefined
91
99
  /** @type {import('./relay-link.js').RelayLink | undefined} */
@@ -563,11 +571,28 @@ class NodeRegistryCore {
563
571
  const rec = this.nodes.get(t.nodeId)
564
572
  const sent = rec?.online ? this.send(t.nodeId, HUB_TO_NODE.TASK_START, { ...t.spec }) : false
565
573
  if (!sent) {
566
- // 批准时节点已掉线:任务转 failed 终态,绝不静默退回挂起
574
+ // 批准时节点已掉线:任务转 **failed 终态**,绝不静默退回挂起
567
575
  // (退回挂起会让"批了但没跑"变成无头案)。
568
- this.tasks.delete(taskId)
576
+ //
577
+ // ⚠ 2026-10-17 修:原实现是 `this.tasks.delete(taskId)` —— 那让任务从
578
+ // hub 视图**彻底消失**(不是失败、不是挂起,是不存在),与这段注释
579
+ // 自称的"转 failed 终态"直接矛盾。后果:
580
+ // · `remote_status` / 面板都查不到该任务,用户以为批准成功
581
+ // · subagent provider 的 `awaitTerminal` 等不到终态,只能靠超时兜底
582
+ // (最长可挂 1 小时+)
583
+ // · 与 ui-routes 的 502 分支文案("任务已转 failed")不符
584
+ //
585
+ // 改成**保留记录并置终态**:状态可见、可查、可重试。
586
+ t.status = TASK_STATUS.FAILED
587
+ t.endedAt = Date.now()
588
+ t.error = {
589
+ code: 'node-unreachable',
590
+ message: `approval could not be delivered: node ${t.nodeId} is unreachable`,
591
+ }
569
592
  rec?.tasks.delete(taskId)
570
593
  this.auditRecord('approval.granted', { taskId, nodeId: t.nodeId, by, delivered: false })
594
+ // 唤醒 awaitTerminal 等待者(不唤醒会让它们挂到超时)
595
+ this.resolveWaiters(t)
571
596
  throw new RemoteAgentError(`node unreachable: ${t.nodeId}`, ERROR_CODE.INTERNAL)
572
597
  }
573
598
  t.status = TASK_STATUS.QUEUED
@@ -778,6 +803,73 @@ class NodeRegistryCore {
778
803
  }
779
804
  }
780
805
 
806
+ /**
807
+ * 工作区共享请求(2026-10-17)。
808
+ *
809
+ * 与 `fsRequest` 同款 RPC 模式(发帧 + 等 replyTo),但走独立的 SHARE 帧与
810
+ * 独立的 pending 表 —— 两者语义不同(fs 是"读文件",share 是"探测共享状态
811
+ * 并生成命令"),混用一张表会让超时归因变模糊。
812
+ *
813
+ * @param {string} nodeId
814
+ * @param {'probe'|'plan'|'unshare-plan'} op
815
+ * @param {object} [params]
816
+ * @returns {Promise<object>}
817
+ */
818
+ shareRequest(nodeId, op, params = {}, { timeoutMs = 15_000 } = {}) {
819
+ return new Promise((resolve, reject) => {
820
+ const rec = this.nodes.get(nodeId)
821
+ if (!rec?.online) {
822
+ reject(Object.assign(new Error(`node not online: ${nodeId}`), { code: ERROR_CODE.TASK_UNKNOWN }))
823
+ return
824
+ }
825
+ const env = envelope(HUB_TO_NODE.SHARE_REQ, { op, ...params })
826
+ const reqId = env.id
827
+ const timer = setTimeout(() => {
828
+ this.sharePending.delete(reqId)
829
+ this.log('warn', 'share request timeout', { nodeId, op, reqId, timeoutMs })
830
+ reject(Object.assign(new Error(`share ${op} timed out after ${timeoutMs}ms`), { code: ERROR_CODE.FS_TIMEOUT }))
831
+ }, timeoutMs)
832
+ if (typeof timer.unref === 'function') timer.unref()
833
+ this.sharePending.set(reqId, { resolve, reject, timer, op, nodeId })
834
+ const sent = this.send(nodeId, HUB_TO_NODE.SHARE_REQ, { op, ...params }, { id: reqId })
835
+ if (!sent) {
836
+ clearTimeout(timer)
837
+ this.sharePending.delete(reqId)
838
+ reject(Object.assign(new Error(`failed to send share request to ${nodeId}`), { code: ERROR_CODE.TASK_UNKNOWN }))
839
+ }
840
+ })
841
+ }
842
+
843
+ /** share/res 到达:按 replyTo 唤醒等待者。 */
844
+ handleShareRes(nodeId, d) {
845
+ const reqId = typeof d.replyTo === 'string' ? d.replyTo : ''
846
+ const waiter = reqId !== '' ? this.sharePending.get(reqId) : undefined
847
+ if (!waiter) {
848
+ this.log('info', 'late share response dropped', { nodeId, reqId })
849
+ return
850
+ }
851
+ clearTimeout(waiter.timer)
852
+ this.sharePending.delete(reqId)
853
+ if (d.ok === true) {
854
+ waiter.resolve(d.result ?? {})
855
+ } else {
856
+ waiter.reject(Object.assign(
857
+ new Error(typeof d.message === 'string' ? d.message : 'share request failed'),
858
+ { code: typeof d.code === 'string' ? d.code : ERROR_CODE.INTERNAL },
859
+ ))
860
+ }
861
+ }
862
+
863
+ /** 节点断连:清掉它名下还没等到 share/res 的等待者。 */
864
+ shareCancelForNode(nodeId) {
865
+ for (const [reqId, w] of this.sharePending) {
866
+ if (w.nodeId !== nodeId) continue
867
+ clearTimeout(w.timer)
868
+ w.reject(Object.assign(new Error('node disconnected during share request'), { code: ERROR_CODE.FS_TIMEOUT }))
869
+ this.sharePending.delete(reqId)
870
+ }
871
+ }
872
+
781
873
  /**
782
874
  * 发一个二进制帧给节点(header.to 由中继用来路由)。
783
875
  * E2E 会话存在时整体封密:外层只留 t/seq/iv + to。
@@ -894,6 +986,8 @@ class NodeRegistryCore {
894
986
  this.pulls?.onNodeOffline?.(nodeId)
895
987
  // 在途的远程文件请求同样 fail-fast(提案 #25):等不到 fs/res 就别挂调用方
896
988
  this.fsCancelForNode(nodeId)
989
+ // 在途的工作区共享请求同样 fail-fast(2026-10-17)
990
+ this.shareCancelForNode(nodeId)
897
991
  }
898
992
 
899
993
  startHeartbeat() {
@@ -164,6 +164,15 @@ export function withIngest(Base) {
164
164
  // 资源遥测(~30s 一次):只更新 rec.resource,绝不影响任务链路。
165
165
  // 尽力而为的字段级校验:缺/类型不对就落 null —— 一个畸形遥测帧
166
166
  // 不能让节点记录缺胳膊少腿,更不能抛(单帧故障隔离不变式)。
167
+ //
168
+ // ⚠ `rec` 必须判空(2026-10-17 修)。未知节点(hub 重启后副机还没
169
+ // 重连的"影子节点"竞态)发来 resource 帧 —— 30s 一次,**必然发生**:
170
+ // `rec.resource = {...}` 对 undefined 抛 TypeError。外层 try/catch
171
+ // 能兜住(不崩溃、不断连),但会打一条误导性 error 日志并丢一帧遥测。
172
+ //
173
+ // 同文件 REPOS 分支有 `if (rec)` 护栏 —— 同一份单帧隔离不变式,
174
+ // 两个分支一个做一个漏。
175
+ if (!rec) return
167
176
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
168
177
  rec.resource = {
169
178
  cpuPct: num(d.cpuPct),
@@ -268,6 +277,12 @@ export function withIngest(Base) {
268
277
  this.handleFsRes(nodeId, d)
269
278
  return
270
279
 
280
+ case NODE_TO_HUB.SHARE_RES:
281
+ // 工作区共享响应(2026-10-17):按 replyTo 唤醒 shareRequest 等待者。
282
+ // 与 FS_RES 分开 —— 两张 pending 表不能混(混了会让超时归因变模糊)。
283
+ this.handleShareRes(nodeId, d)
284
+ return
285
+
271
286
  case NODE_TO_HUB.ERROR:
272
287
  this.log('warn', 'node reported error', { nodeId, code: d.code, taskId: d.taskId })
273
288
  if (d.code === ERROR_CODE.E2E_REQUIRED) {
package/lib/hub/tools.js CHANGED
@@ -658,8 +658,33 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
658
658
  constraints: {
659
659
  // 提案 #26-1:缺省按模式分开(exec 10min / agent 1h),与副机兜底
660
660
  // 同源常量;defaults.timeoutMs(配置显式指定)优先于一切缺省。
661
+ //
662
+ // ⚠ 这里**必须用 `routeMode`,不能用 `v.spec.mode`**(2026-10-17 修)。
663
+ //
664
+ // 原实现写的是 `v.spec.mode`,而 `v` 正是这一行所属的
665
+ // `const v = validateTaskStart({...})` 的初始化目标 —— 参数对象字面量
666
+ // 在 `v` 初始化期间引用 `v` 自身,进入 TDZ(临时死区):
667
+ //
668
+ // const v = validateTaskStart({ constraints: {
669
+ // timeoutMs: ... ?? (v.spec.mode === 'exec' ? ... : ...)
670
+ // ^ Cannot access 'v' before initialization
671
+ //
672
+ // 当 `??` 链左侧全空(args.timeoutMs 未传 且 explicitTimeoutMs 为
673
+ // undefined)时抛 ReferenceError。
674
+ //
675
+ // 为什么 829 个测试全绿仍带此 bug:e2e helper 恒传 timeoutMs(`??`
676
+ // 第一项短路)、单测构造时 defaults 也总有值 —— TDZ 分支从未被测到。
677
+ //
678
+ // 为什么生产没炸但更糟:`normalizeConfig` 用
679
+ // `numOr(defaults.timeoutMs, 60*60*1000)` 兜底,使 explicitTimeoutMs
680
+ // 恒为 3600000 → TDZ 永不触发,但 #26-1 的「args > 显式配置 >
681
+ // 分模式默认」被压扁成「恒 1h」:exec 的 10min 默认成为死代码,
682
+ // 一条失控命令比承诺的多跑 50 分钟才被杀。
683
+ //
684
+ // `routeMode`(L614)与 `v.spec.mode` 同义(都是 'exec' | 'agent'),
685
+ // 且已在 `v` 之前求值 —— 用它彻底消除自引用。
661
686
  timeoutMs: num(args?.timeoutMs) ?? explicitTimeoutMs
662
- ?? (v.spec.mode === TASK_MODE.EXEC ? EXEC_DEFAULT_TIMEOUT_MS : AGENT_DEFAULT_TIMEOUT_MS),
687
+ ?? (routeMode === TASK_MODE.EXEC ? EXEC_DEFAULT_TIMEOUT_MS : AGENT_DEFAULT_TIMEOUT_MS),
663
688
  denyPaths: Array.isArray(args?.denyPaths) ? args.denyPaths : [],
664
689
  },
665
690
  origin: 'hub',