dsh-remote-agent 0.9.2 → 0.9.4

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.
@@ -31,10 +31,30 @@ import fs from 'node:fs'
31
31
  import path from 'node:path'
32
32
  import os from 'node:os'
33
33
  import { ERROR_CODE, RemoteAgentError } from './protocol.js'
34
- import { writePatchBlock } from './setup.js'
34
+ import {
35
+ FORM_OWNED_KEYS,
36
+ extractPreservedConfig,
37
+ newNodeToken,
38
+ readManagedBlockBody,
39
+ upsertTokenInBlock,
40
+ writePatchBlock,
41
+ } from './setup.js'
35
42
  // 角色解析必须**只有一份实现** —— 面板说"未配置"而插件实际在跑 hub,
36
43
  // 是最难排查的撕裂(v0.7.0 踩过)。所以这里直接复用 lib/index.js 的解析器。
37
44
  import { resolveRoleDetail } from './index.js'
45
+ // 配置分层(2026-10-18):DSH 插件管理器会重写 cordis.patch.yml 并冲掉手工段,
46
+ // 所以每次写配置时也在 <stateDir>/instance.json 存一份(插件加载时合并回来)。
47
+ import { writeInstanceConfig, readInstanceConfig } from './instance-config.js'
48
+
49
+ /**
50
+ * 面板表单**管辖**的顶层 config 键由 `lib/setup.js` 的 `FORM_OWNED_KEYS` 定义
51
+ * (CLI 向导与测试共用同一份,避免两处各写一份再分叉 —— 本版审查的 C2/L2
52
+ * 就是这么来的)。所以这里直接用那份常量,不再本地复制。
53
+ *
54
+ * 其余的一切(nodeToken / uiPullRoot / relay / listenHost / listenPort /
55
+ * agentMode…)在重写托管块时必须**原样保留** —— 否则用户在面板点一次保存,
56
+ * 凭证与设置就被静默抹掉(已实证)。
57
+ */
38
58
 
39
59
  /** 引导路由的前缀。与 hub 的 `/api/remote-agent/ui` 分开 —— 未配置时后者不存在。 */
40
60
  export const SETUP_API_PREFIX = '/api/remote-agent/setup'
@@ -216,12 +236,28 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
216
236
  // `configured` 的语义是"用户已经做过选择,不需要引导"。
217
237
  // auto 也算已选择:它是个明确的工作模式(按 hubUrl 推断),不是"还没配"。
218
238
  const configured = role === 'auto' || detail.unconfigured !== true
239
+
240
+ // ── 独立配置状态(2026-10-18)──────────────────────────────────────
241
+ //
242
+ // 报告 `<stateDir>/instance.json` 里存了什么。这不是给用户看的细节,
243
+ // 而是排查"配置被 DSH 重写冲掉"这个坑的关键线索:
244
+ // · instance 有值 = 配置抗住了重写(重启后 apply() 会合并回来)
245
+ // · instance 为 null 但 patch 有值 = 只靠 patch 那层,下次重写会丢
246
+ const saved = readInstanceConfig(config)
219
247
  writeJson(res, 200, {
220
248
  ok: true,
221
249
  configured,
222
250
  // 推断结果(面板据此显示"当前生效角色")—— auto 时它是有值的
223
251
  effectiveRole: detail.role,
224
252
  inferred: typeof detail.inferred === 'string' ? detail.inferred : '',
253
+ // 配置持久化的两层状态
254
+ persist: {
255
+ // DSH 会重写 cordis.patch.yml(实测会把我们的段整段冲掉)
256
+ patchFile: currentPatchFile(),
257
+ // 这一层 DSH 冲不掉,是"重启后配置还在"的真正保障
258
+ instanceFile: saved !== null,
259
+ instanceRole: saved && typeof saved.role === 'string' ? saved.role : null,
260
+ },
225
261
  // 回显当前配置里已有的值,方便用户"改一个字段"而不是从头填
226
262
  current: {
227
263
  role: typeof config.role === 'string' ? role : '',
@@ -230,6 +266,10 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
230
266
  hubUrl: typeof config.hubUrl === 'string' ? config.hubUrl : '',
231
267
  relayUrl: typeof config.relayUrl === 'string' ? config.relayUrl : '',
232
268
  roots: Array.isArray(config.roots) ? config.roots : [],
269
+ // H3(2026-10-20):面板必须知道"有没有口令"。此前完全不回显 → 用户
270
+ // 走面板配完 hub 永远是空口令,而独立监听因此只能绑回环、副机连不上。
271
+ // ⚠ 只回布尔,绝不回 token 本身(与 /config 的披露边界一致)。
272
+ tokenSet: typeof config.nodeToken === 'string' && config.nodeToken.trim() !== '',
233
273
  },
234
274
  // 写入目标(让用户知道改的是哪个文件)
235
275
  patchFile: currentPatchFile(),
@@ -330,7 +370,28 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
330
370
 
331
371
  const blockYaml = `${lines.join('\n')}\n`
332
372
  const file = currentPatchFile()
333
- const result = writePatchBlock({ file, blockYaml, force: body.force === true })
373
+
374
+ // ── 保留本次表单不管辖的字段(H2,2026-10-20)────────────────────
375
+ //
376
+ // 托管块是**整体替换**的:新块进来,旧块整段消失。而本表单只提交
377
+ // role/nodeId/transportMode/hubUrl/relayUrl/roots —— 于是用户在面板点一次
378
+ // 「保存」,托管块里的 nodeToken / uiPullRoot / relay / listenHost /
379
+ // listenPort 全部被静默删掉(已实证)。
380
+ //
381
+ // 这里把旧块里"不归表单管"的行原样搬到新块末尾(文本级搬运,保留用户的
382
+ // 格式与顺序;不解析 YAML,避免零依赖被打破)。
383
+ let preservedLines = []
384
+ try {
385
+ const existing = readManagedBlockBody(fs.readFileSync(file, 'utf8'))
386
+ preservedLines = extractPreservedConfig(existing, FORM_OWNED_KEYS)
387
+ } catch {
388
+ // 首次写入(文件不存在)—— 没有可保留的
389
+ }
390
+ const blockWithPreserved = preservedLines.length > 0
391
+ ? `${blockYaml.replace(/\s+$/, '')}\n${preservedLines.join('\n')}\n`
392
+ : blockYaml
393
+
394
+ const result = writePatchBlock({ file, blockYaml: blockWithPreserved, force: body.force === true })
334
395
  if (result.ok !== true) {
335
396
  // 冲突(有手写条目)是可预期的用户可修复状态,单独给个 code 让面板
336
397
  // 能显示"替换手写配置"按钮,而不是把整段错误文案丢给用户。
@@ -342,12 +403,50 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
342
403
  })
343
404
  }
344
405
  log('info', 'setup applied', { role, file: result.file, mode: result.mode, warnings: applyWarnings })
406
+
407
+ // ── 同时写独立配置(2026-10-18,方案 B:防被 DSH 重写冲掉)──────────
408
+ //
409
+ // 事故:用户在 DSH 里卸载重装插件后,`cordis.patch.yml` 被重写成只剩
410
+ // 4 条 —— 我们的 role + webserver 整段消失,重启后又回引导页。
411
+ //
412
+ // 所以每次 setup 写入成功后,把**同样的值**再存一份到
413
+ // `<stateDir>/instance.json`。插件 `apply()` 时会把它合并回来
414
+ // (独立配置优先)。这样 DSH 怎么重写 patch 文件都不影响角色配置。
415
+ //
416
+ // ⚠ 2026-10-20 修两处:
417
+ // · **必须传 config**(第二个参数)。此前漏了 → 写默认目录、读自定义
418
+ // stateDir → 配了 stateDir 的机器持久化完全失效(M1,已复现)。
419
+ // · 先铺上已有值再覆盖表单字段,否则每次保存都会把 nodeToken / relay /
420
+ // listenHost 这类"表单不管辖"的字段从 instance.json 里抹掉(H2 的另一半)。
421
+ const prev = readInstanceConfig(config) ?? {}
422
+ const nextPersist = { ...prev }
423
+ // 表单管辖的键以本次提交为准(这样"清空"也能真正生效)
424
+ for (const k of FORM_OWNED_KEYS) delete nextPersist[k]
425
+ const persisted = writeInstanceConfig({
426
+ ...nextPersist,
427
+ role,
428
+ nodeId: str(body.nodeId),
429
+ transportMode: str(body.transportMode) || undefined,
430
+ hubUrl: str(body.hubUrl) || undefined,
431
+ relayUrl: str(body.relayUrl) || undefined,
432
+ roots,
433
+ }, config)
434
+ if (persisted.ok !== true) {
435
+ applyWarnings.push(`独立配置写入失败(${persisted.error})—— 角色配置仍写进了 patch 文件,`
436
+ + '但下次 DSH 重写该文件时可能丢失,建议重启后到面板确认一次。')
437
+ log('warn', 'instance config write failed', { file: persisted.file, error: persisted.error })
438
+ } else {
439
+ log('info', 'instance config persisted', { file: persisted.file })
440
+ }
441
+
345
442
  writeJson(res, 200, {
346
443
  ok: true,
347
444
  role,
348
445
  file: result.file,
349
446
  backup: result.backup,
350
447
  mode: result.mode,
448
+ // 独立配置的落点(让用户/排查者知道配置存在哪两处)
449
+ instanceFile: persisted.ok === true ? persisted.file : null,
351
450
  // 非致命提示(自动建目录的结果等)—— 面板要显示,否则用户以为
352
451
  // "目录已就绪"而实际没有
353
452
  warnings: applyWarnings,
@@ -355,5 +454,90 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
355
454
  })
356
455
  })
357
456
 
457
+ // ── 生成 / 轮换入网口令(nodeToken)──────────────────────────────
458
+ //
459
+ // ═══════════════════════════════════════════════════════════════════════════
460
+ // 为什么必须有这条(v0.9.3 审查 H3,2026-10-20)
461
+ // ═══════════════════════════════════════════════════════════════════════════
462
+ //
463
+ // CLI 向导(`dsh-remote-setup --role hub`)会生成 `nt_` 开头的口令,
464
+ // **面板这条路却完全没有** —— YAML 里不写 nodeToken、instance.json 里也不写。
465
+ //
466
+ // 后果链(实测):
467
+ // 面板配 hub → nodeToken 空 → 独立监听按安全策略只能绑回环
468
+ // → 副机连不上 → 体检报"需要口令" → 而面板没有任何生成入口
469
+ // → 用户只能去手改 patch → 被 DSH 重写冲掉 → 死循环
470
+ //
471
+ // 安全边界:
472
+ // · 只对**回环请求**生成并回显明文(与 pair-info 的 token 披露边界一致)
473
+ // · 明文只回这一次,之后任何接口都不再吐 token(`/state` 只给 tokenSet)
474
+ // · 走的是同一套 patch 写盘(备份 / 校验 / 回滚)
475
+ route(`${SETUP_API_PREFIX}/rotate-token`, async (req, res) => {
476
+ if (req.method !== 'POST') return writeJson(res, 405, { error: 'method' })
477
+
478
+ const ra = String(req.socket?.remoteAddress ?? '')
479
+ const loopback = ra === '127.0.0.1' || ra === '::1' || ra === '::ffff:127.0.0.1'
480
+ if (!loopback) {
481
+ return writeJson(res, 403, {
482
+ error: 'loopback-only',
483
+ message: '生成口令只能在主机本机打开的面板里操作(避免局域网内其他人拿到凭证)',
484
+ })
485
+ }
486
+
487
+ const token = newNodeToken()
488
+ const file = currentPatchFile()
489
+
490
+ // 只改 nodeToken 这一行,其余字段(roots / uiPullRoot / relay / listen*)原样不动
491
+ let blockBody = ''
492
+ try {
493
+ blockBody = readManagedBlockBody(fs.readFileSync(file, 'utf8'))
494
+ } catch {
495
+ // 还没有 patch 文件:生成一个最小的托管块,role 取当前生效角色
496
+ }
497
+ if (blockBody.trim() === '') {
498
+ const detail = resolveRoleDetail(config)
499
+ const r = detail.role === 'worker' ? 'worker' : 'hub'
500
+ const inject = r === 'worker'
501
+ ? "['tools', 'agentLoop', 'sessions']"
502
+ : "['webServer', 'tools', 'systemPrompt']"
503
+ blockBody = `- id: remote-agent\n name: dsh-remote-agent\n inject: ${inject}\n config:\n role: ${r}\n`
504
+ }
505
+
506
+ const result = writePatchBlock({
507
+ file,
508
+ blockYaml: upsertTokenInBlock(blockBody, token),
509
+ force: false,
510
+ })
511
+ if (result.ok !== true) {
512
+ const conflict = /手写的 remote-agent 条目/.test(String(result.error ?? ''))
513
+ return writeJson(res, conflict ? 409 : 400, {
514
+ error: conflict ? 'handwritten-conflict' : 'write-failed',
515
+ message: result.error,
516
+ file: result.file,
517
+ })
518
+ }
519
+
520
+ // instance.json 也存一份(这一层 DSH 冲不掉)—— 同样保留已有字段
521
+ const prev = readInstanceConfig(config) ?? {}
522
+ const persisted = writeInstanceConfig({ ...prev, nodeToken: token }, config)
523
+
524
+ log('info', 'node token rotated', {
525
+ file: result.file,
526
+ instance: persisted.ok === true ? persisted.file : null,
527
+ instanceError: persisted.ok === true ? undefined : persisted.error,
528
+ })
529
+
530
+ writeJson(res, 200, {
531
+ ok: true,
532
+ // 明文只此一次:面板要提示用户"复制保存",之后不再回显
533
+ token,
534
+ file: result.file,
535
+ backup: result.backup,
536
+ instanceFile: persisted.ok === true ? persisted.file : null,
537
+ note: '口令已写入。**重启 DSH 后生效**;副机需要用新口令重新配对。'
538
+ + '这串口令只显示这一次,请立即复制保存。',
539
+ })
540
+ })
541
+
358
542
  return disposers
359
543
  }
package/lib/setup.js CHANGED
@@ -15,6 +15,10 @@ import fs from 'node:fs'
15
15
  import net from 'node:net'
16
16
  import { randomBytes } from 'node:crypto'
17
17
  import { expandPairConfig } from './pairing.js'
18
+ // 地址枚举**必须只有一份实现**(2026-10-20 收口):
19
+ // 此前 detectLanIps(私网优先)与 resolveListenHost(Tailscale 优先)各写一套,
20
+ // 排序方向相反 → 面板生成的配对码第一条必定不是实际绑定地址(C2)。
21
+ import { listCandidateAddresses } from './listen-server.js'
18
22
 
19
23
  /** 与 hub 侧 newNodeToken 完全同构(nt_ 前缀 + 24 字节 base64url)。 */
20
24
  export function newNodeToken() {
@@ -26,23 +30,38 @@ export const NODE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
26
30
 
27
31
  /**
28
32
  * 枚举本机局域网 IPv4(排除 loopback / 链路本地 / 虚拟接口)。
29
- * 返回 [{ name, address, private }],private = RFC1918。
33
+ * 返回 [{ name, address, private }],private = RFC1918 或 Tailscale(可信内网)。
34
+ *
35
+ * ═══════════════════════════════════════════════════════════════════════════
36
+ * 2026-10-20 改为复用 `listCandidateAddresses()`(v0.9.3 审查 C2)
37
+ * ═══════════════════════════════════════════════════════════════════════════
38
+ *
39
+ * 原来这里自己实现了"枚举 + 私网优先排序",而 `lib/listen-server.js` 的
40
+ * `resolveListenHost()` 实现了"Tailscale 优先"。两套判据方向**相反**,于是:
41
+ *
42
+ * 独立监听实际绑 → 100.86.48.54(Tailscale)
43
+ * 面板配对码第一条 → ws://10.239.22.41:56594/...(WLAN,私网优先排第一)
44
+ *
45
+ * 用户复制第一条 → 连不上,而两端都不报错。
46
+ *
47
+ * 现在**一份实现**:排序 / 私网判定 / APIPA 过滤全部交给 listen-server,
48
+ * 这里只做字段名适配(它返回 iface,历史调用方要 name)。
49
+ *
50
+ * @returns {Array<{name:string, address:string, private:boolean, score:number}>}
30
51
  */
31
52
  export function detectLanIps() {
32
- const out = []
33
- const ifaces = os.networkInterfaces()
34
- for (const [name, addrs] of Object.entries(ifaces)) {
35
- for (const a of addrs ?? []) {
36
- if (a.family !== 'IPv4' || a.internal) continue
37
- if (a.address.startsWith('169.254.')) continue // 链路本地:没拿到 DHCP 的假地址
38
- const [o1, o2] = a.address.split('.').map(Number)
39
- const priv = o1 === 10 || (o1 === 172 && o2 >= 16 && o2 <= 31) || (o1 === 192 && o2 === 168)
40
- out.push({ name, address: a.address, private: priv })
41
- }
53
+ try {
54
+ return listCandidateAddresses().map((c) => ({
55
+ name: c.iface,
56
+ address: c.address,
57
+ // Tailscale(100.64/10)在语义上属于"可信内网",与 RFC1918 同等看待:
58
+ // 面板用它区分"要不要提示公网风险",把它算公网会给出错误警告。
59
+ private: c.score <= 1,
60
+ score: c.score,
61
+ }))
62
+ } catch {
63
+ return []
42
64
  }
43
- // 私网优先,稳定的地址排前面
44
- out.sort((x, y) => (x.private === y.private ? 0 : x.private ? -1 : 1))
45
- return out
46
65
  }
47
66
 
48
67
  /**
@@ -455,6 +474,145 @@ export function hasUnmanagedRemoteAgent(fileText) {
455
474
  return RA_ENTRY_RE.test(src.slice(0, i) + src.slice(j + MANAGED_END.length))
456
475
  }
457
476
 
477
+ /**
478
+ * 面板 setup 表单**管辖**的顶层 config 键。
479
+ *
480
+ * 只有这些字段允许被"整块重写"覆盖;其余(`nodeToken` / `uiPullRoot` /
481
+ * `listenHost` / `listenPort` / `relay` / `pair` / `token` …)一律原样保留。
482
+ *
483
+ * 放在这里而不是 setup-routes.js:它是「哪些字段归表单管」这个**领域事实**,
484
+ * CLI 向导与测试也要按同一份清单判断(两处各写一份就会分叉 —— 本版审查里
485
+ * C2/L2 就是这么来的)。
486
+ */
487
+ export const FORM_OWNED_KEYS = Object.freeze([
488
+ 'role',
489
+ 'nodeId',
490
+ 'transportMode',
491
+ 'hubUrl',
492
+ 'relayUrl',
493
+ 'roots',
494
+ ])
495
+
496
+ /**
497
+ * 从**现有托管块**里抽出"本次表单不管辖"的 config 字段,原样返回(含缩进)。
498
+ *
499
+ * ═══════════════════════════════════════════════════════════════════════════
500
+ * 为什么需要(2026-10-20,v0.9.3 审查 H2)
501
+ * ═══════════════════════════════════════════════════════════════════════════
502
+ *
503
+ * `upsertManagedBlock` 是**块级整体替换**:新块进来,旧块整段消失。
504
+ * 而面板的 setup 表单只提交 role/nodeId/transportMode/hubUrl/relayUrl/roots。
505
+ *
506
+ * 于是用户在面板点一次「保存」,托管块里的 `nodeToken`、`uiPullRoot`、
507
+ * `listenHost`、`listenPort`、`relay` **全部被静默删掉**(已实证)。
508
+ * 而 instance.json 那边也没存这些 —— 等于"点一次保存,凭证和设置就没了"。
509
+ *
510
+ * 修法不是让表单管更多字段(字段会一直加),而是**只重写自己管辖的那几个**,
511
+ * 其余原样搬回来。
512
+ *
513
+ * 实现刻意走**文本级切分**而不是 YAML 解析:
514
+ * · 不需要引入解析器(本包零运行时依赖是硬约束)
515
+ * · 保留用户的原始格式(注释、引号风格、键顺序)—— 解析再序列化会全部丢失
516
+ *
517
+ * @param {string} blockText 托管块正文(`MANAGED_BEGIN/END` 之间的内容)
518
+ * @param {Set<string>|string[]} ownedKeys 本次由表单管辖的顶层 config 键
519
+ * @returns {string[]} 需要原样保留的行
520
+ */
521
+ export function extractPreservedConfig(blockText, ownedKeys) {
522
+ const src = typeof blockText === 'string' ? blockText : ''
523
+ const owned = ownedKeys instanceof Set ? ownedKeys : new Set(Array.isArray(ownedKeys) ? ownedKeys : [])
524
+ const lines = src.split('\n')
525
+
526
+ // 定位 ` config:`(两个空格缩进 —— loader 条目的标准形态)
527
+ const start = lines.findIndex((l) => /^\s{2}config:\s*$/.test(l))
528
+ if (start === -1) return []
529
+
530
+ const groups = []
531
+ let cur = null
532
+ for (let i = start + 1; i < lines.length; i += 1) {
533
+ const line = lines[i]
534
+ if (line.trim() === '') {
535
+ // 空行跟着当前组(保留段落感),组还没开始就忽略
536
+ if (cur) cur.lines.push(line)
537
+ continue
538
+ }
539
+ // 顶层字段:4 个空格 + key:
540
+ const m = /^ {4}([A-Za-z0-9_]+):/.exec(line)
541
+ if (m) {
542
+ cur = { key: m[1], lines: [line] }
543
+ groups.push(cur)
544
+ continue
545
+ }
546
+ // 更深缩进(嵌套对象 / 列表项)→ 属于当前组
547
+ if (cur && /^ {5,}\S/.test(line)) {
548
+ cur.lines.push(line)
549
+ continue
550
+ }
551
+ // 缩进 ≤3 或新条目 —— config 段结束
552
+ break
553
+ }
554
+
555
+ const kept = []
556
+ for (const g of groups) {
557
+ if (owned.has(g.key)) continue
558
+ // 去掉尾部多余空行,避免拼回去时积累空行
559
+ const ls = g.lines.slice()
560
+ while (ls.length > 0 && ls[ls.length - 1].trim() === '') ls.pop()
561
+ kept.push(...ls)
562
+ }
563
+ return kept
564
+ }
565
+
566
+ /**
567
+ * 读取文件里现有托管块的正文(不含标记行)。没有托管块时返回 ''。
568
+ * @param {string} fileText
569
+ * @returns {string}
570
+ */
571
+ export function readManagedBlockBody(fileText) {
572
+ const src = typeof fileText === 'string' ? fileText : ''
573
+ const i = src.indexOf(MANAGED_BEGIN)
574
+ if (i === -1) return ''
575
+ const j = src.indexOf(MANAGED_END, i)
576
+ if (j === -1) return ''
577
+ return src.slice(i + MANAGED_BEGIN.length, j)
578
+ }
579
+
580
+ /**
581
+ * 在托管块正文里设置 `nodeToken`(有则替换,无则插入),返回**新的块正文**。
582
+ *
583
+ * 为什么不是"重新生成整个块":那会把用户手写的其它字段一起冲掉(H2)。
584
+ * 这里只动 token 这一行,其余字节保持原样。
585
+ *
586
+ * @param {string} blockText 托管块正文(不含标记)
587
+ * @param {string} token 新口令
588
+ * @returns {string}
589
+ */
590
+ export function upsertTokenInBlock(blockText, token) {
591
+ const body = typeof blockText === 'string' ? blockText : ''
592
+ const t = typeof token === 'string' ? token.trim() : ''
593
+ if (t === '') return body
594
+ // 单引号包裹 + 转义:token 是 nt_ + base64url,理论上不含引号,但不赌
595
+ const line = ` nodeToken: '${t.replace(/'/g, "''")}'`
596
+ const lines = body.split('\n')
597
+
598
+ const i = lines.findIndex((l) => /^ {4}nodeToken:/.test(l))
599
+ if (i !== -1) {
600
+ lines[i] = line
601
+ return lines.join('\n')
602
+ }
603
+
604
+ // 没有 → 插到 ` config:` 之后(保持它作为 config 的第一个子键,可读性好)
605
+ const ci = lines.findIndex((l) => /^\s{2}config:\s*$/.test(l))
606
+ if (ci !== -1) {
607
+ lines.splice(ci + 1, 0, line)
608
+ return lines.join('\n')
609
+ }
610
+
611
+ // 连 config 行都没有(极早期手写块)→ 追加一个 config 段
612
+ const trimmed = body.replace(/\s+$/, '')
613
+ return `${trimmed}${trimmed === '' ? '' : '\n'} config:\n${line}\n`
614
+ }
615
+
458
616
  /**
459
617
  * 写盘主流程:备份 → 合并 → 写入 → 校验,失败自动回滚。
460
618
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-agent",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "type": "module",
5
5
  "description": "把另一台电脑变成你 DSH 的远程 Agent 同事:副机本地持久跑任务、独立 Agent 循环,只回传 handoff 摘要与产物清单,成品按需拉取。",
6
6
  "license": "MIT",
@@ -1113,7 +1113,7 @@ check('65', 'eslint 严格档:lint script + 配置文件 + CI job 三件套齐
1113
1113
  })(),
1114
1114
  'lib/hub/ui-routes.js(role: resolveRole(config) 而非原始 config.role)')
1115
1115
 
1116
- check('85', 'SMB 面板发真实 dir + CLI doctor 有 loopback 检查(H2 / M1)',
1116
+ check('85', 'SMB 面板发真实 dir + CLI doctor 走独立监听判可达性(H2 / M1 / H5)',
1117
1117
  // H2:不得写死 dir: ''
1118
1118
  (() => {
1119
1119
  const i = clientSrc5.indexOf('function doPlan()')
@@ -1125,8 +1125,56 @@ check('65', 'eslint 严格档:lint script + 配置文件 + CI job 三件套齐
1125
1125
  && /nodeRoots/.test(clientSrc5)
1126
1126
  // M1:CLI 必须调 envChecks(否则没有 loopback 诊断)
1127
1127
  && /envChecks/.test(setupBin)
1128
- && /isLoopbackOrigin/.test(setupBin),
1129
- 'lib/client.js(dir: shareDir + nodeRoots UI)+ bin/dsh-remote-setup.js(envChecks)')
1128
+ // H5:回环判据必须来自 listen-server(一份实现),不是 CLI 自己拼 origin
1129
+ && /isLoopbackHost/.test(setupBin)
1130
+ // H5 负向守卫:CLI **不得**再从 patch 里找 webserver 段判可达性。
1131
+ // 0.9.3 起端点由插件自己的独立监听承载,webserver 的 host 与副机接入
1132
+ // 毫无关系 —— 读了会把人引向"改 webserver"这个改了也会被冲掉的死路。
1133
+ // ⚠ 扫**剥注释后**的代码:注释里必须保留事故说明(含 `- id: webserver`
1134
+ // 字样),那是给后来者的警示,不该被这条断言判违规。
1135
+ && !/- id:\s*webserver/.test(stripComments(setupBin))
1136
+ // 生效配置以 instance.json 为准(否则体检报告的是已失效的 patch 值)
1137
+ && /readInstanceConfig/.test(setupBin),
1138
+ 'lib/client.js(dir: shareDir + nodeRoots UI)+ bin/dsh-remote-setup.js(envChecks + listen-server 判据)')
1139
+ }
1140
+
1141
+ /* ── 86~87. 独立监听端口(2026-10-18,架构级修复)──
1142
+ *
1143
+ * 副机端点原来注册在 `ctx.webServer` 上 → 副机必须访问 DSH 的 webserver 端口。
1144
+ * 而那个端口:
1145
+ * · **DSH 每次启动重写 profile patch**,把写进去的 `webserver` 段删掉
1146
+ * (实测:3962B/15 条 → 661B/3 条)→ 绑回 127.0.0.1 → 副机永远连不上
1147
+ * · `webServer` 激活即 listen,没有 rebind 方法
1148
+ * · 新版硬拒绝 0.0.0.0(会让 DSH 启动崩溃,踩过 10 份 crash 日志)
1149
+ *
1150
+ * 所以插件自己开一个 node:http server 服务节点端点,完全绕开 DSH 的 webserver。
1151
+ * 这两条断言钉住"这条通道存在且不依赖 webServer"。
1152
+ */
1153
+ {
1154
+ const hubSrc2 = read('lib/hub/index.js')
1155
+ const listenSrc = read('lib/listen-server.js')
1156
+
1157
+ check('86', '独立监听:插件自己开端口服务节点端点(不依赖 webServer 配置)',
1158
+ /startListenServer/.test(hubSrc2)
1159
+ && /resolveListenHost/.test(hubSrc2)
1160
+ // 默认端口 19387(与历史配置/配对码/文档一致)
1161
+ && /DEFAULT_LISTEN_PORT/.test(listenSrc)
1162
+ && /19387/.test(listenSrc)
1163
+ // 必须真的用 node:http 起 server(不是只 import 不用)
1164
+ && /createServer/.test(listenSrc)
1165
+ && /node:http/.test(listenSrc)
1166
+ // 路径精确匹配(只承载节点端点,不做通用 web server)
1167
+ && /pathname !== upgradePath/.test(listenSrc),
1168
+ 'lib/hub/index.js + lib/listen-server.js(node:http + acceptUpgrade + 精确路径)')
1169
+
1170
+ check('87', '独立监听:拒绝 0.0.0.0 + Tailscale 优先 + 测试环境不占端口',
1171
+ // 拒绝通配地址(与新版 DSH 同规则;0.0.0.0 会让 DSH 崩溃,我们踩过)
1172
+ /h !== '0\.0\.0\.0'/.test(listenSrc)
1173
+ // 自动选地址时 Tailscale 优先(本插件的目标场景就是跨网)
1174
+ && /isTailscaleIp/.test(listenSrc)
1175
+ // 测试环境下不真的监听(否则 npm test 会挂住 —— 实测过)
1176
+ && /NODE_TEST_CONTEXT/.test(hubSrc2),
1177
+ 'lib/listen-server.js(拒 0.0.0.0 + Tailscale 优先)+ hub/index.js(测试环境跳过)')
1130
1178
  }
1131
1179
 
1132
1180
  /* ── 输出 ── */