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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,154 @@
9
9
 
10
10
  ---
11
11
 
12
+ ## [0.9.4] - 2026-10-20
13
+
14
+ > **0.9.3 审查的 19 项全部修完。** 0.9.3 把架构换对了(插件自己开 19387),
15
+ > 但落地时几乎每一项都只改了"多条路径中的一条" —— 每个新机制都带着一个
16
+ > 旧假设。本版逐条补齐。
17
+ >
18
+ > **0.9.3 有两个 Critical,请直接升级到 0.9.4**:
19
+ > 配对码端口取的是 DSH webserver 的端口(复制的码 100% 连不上);
20
+ > `nodeToken` 为空时独立监听成了 tailnet 内的无鉴权接入点。
21
+ >
22
+ > 架构(独立监听 / 配置分层 / 绑址优先链)见 0.9.3 条目,本版不改它。
23
+
24
+ ### 本版修掉的 19 个问题(0.9.3 审查)
25
+
26
+ **Critical**
27
+
28
+ - **C1 配对码端口是错的** —— 前端把浏览器地址栏端口(DSH webserver 的
29
+ 58626 之类)拼进配对码,而节点端点在 19387 → **复制的码 100% 连不上**。
30
+ 现在 `/pair-info` 从 `listenRuntime` 取**实际绑定端口**,响应带 `portSource`
31
+ (`listen-server` / `fallback`),每个候选地址标 `usable`,可用项排最前。
32
+ - **C2 配对码的地址顺序与绑定顺序相反** —— `detectLanIps()` 自己实现了一套
33
+ 分类(私网优先),而 `resolveListenHost()` 是 Tailscale 优先 → 首选地址
34
+ 根本不是监听地址。现在 `detectLanIps()` 复用 `listCandidateAddresses()`,
35
+ 判据只有一份。
36
+
37
+ **High**
38
+
39
+ - **H1 `nodeToken` 为空时端点暴露到网络** —— 现在 token 为空且没显式指定可达
40
+ `listenHost` 时**强制绑 `127.0.0.1`**,面板给黄色提示引导去「生成口令」。
41
+ - **H2 setup 表单只管 6 个字段,写盘却整块重写** —— 用户在 YAML 里手写的
42
+ `uiPullRoot` / `listenHost` 等被静默抹掉。新增 `extractPreservedConfig`
43
+ 文本级切分,**只覆盖表单管辖的键**(`role/nodeId/transportMode/hubUrl/
44
+ relayUrl/roots`),其余原样保留。
45
+ - **H3 面板没有生成口令的入口** —— token 为空时只能看到"绑回环"却无路可修。
46
+ 新增 `POST /setup/rotate-token`(**仅接受回环请求**),面板加「生成口令」
47
+ 按钮(带二次确认),明文只回一次。
48
+ - **H4 CI 连红两次还发了 npm** —— `test/doc-drift-selfcheck.test.js` 的手工
49
+ 复制清单漏了 `lib/listen-server.js`,副本里 `read()` 抛 ENOENT → **20 个
50
+ 自检用例全挂**(不是单条断言失败)。这是同一规矩**第六次**被踩。
51
+ 结构性修复:改成**自动扫 `lib/*.js` 全量复制**,手工清单只管目录与非 js 目标。
52
+ - **H5 架构换了,诊断层没换** —— reachability 快照、`/env-check`、面板横幅、
53
+ README 全停留在 `webserver` 语义 → **面板报假红**(独立监听 19387 明明在跑)
54
+ 并教用户做无效操作(改 webserver host,改了也会被冲掉)。现在:
55
+ `reachability` 变**两层结构 + getter**(运行时绑定后动态求值),
56
+ 新增 `listener` 四态(未就绪 / 就绪 / 已关闭 / 失败),横幅三态
57
+ (正常不显示 / 绑回环黄条 / 启动失败红条),README 安装段重写。
58
+
59
+ **Medium**
60
+
61
+ - **M1 `writeInstanceConfig(values)` 没传 `config`** —— 写默认 stateDir,而读
62
+ 的一方按 `config.stateDir` 找,两边对不上。补上第二个参数。
63
+ - **M2 `listenHost` / `listenPort` / `relay` 不在持久化白名单** —— 写进
64
+ instance.json 会被静默丢弃。`PERSISTED_KEYS` 补齐,`relay` 按对象白名单
65
+ (`url` / `token`)处理;schema 也补上 `listenHost` / `listenPort` 声明
66
+ (此前靠 schemastery passthrough 生效,**无表单、无校验**)。
67
+ - **M3 instance.json 静默覆盖 patch 值** —— 现在 `mergeInstanceConfig` 返回
68
+ `shadowed: string[]`,`apply()` 里 `log('info', …)` 明示谁覆盖了谁;
69
+ `--doctor` 也会报告"instance.json 覆盖了 patch 的:…"。
70
+ - **M4 配对码端口取错** —— 用的是浏览器地址栏的端口(DSH 的 webserver 端口,
71
+ 常见 58626),而副机该拨的是 19387。现在从 `listenRuntime` 取**实际绑定端口**,
72
+ 响应带 `portSource`,并给每个候选地址标 `usable`(只有与本机绑定地址一致的
73
+ 才算可用,可用项排最前)。
74
+ - **M5 CLI 写盘路径不写 instance.json** —— 向导/`--apply` 写完 patch 后,
75
+ 重启 DSH 又会被重写。`offerWrite` / `applyWrite` 增加 `persist` 参数,
76
+ 成功后同步写独立配置。
77
+
78
+ **Low / Info**
79
+
80
+ - **L1 `server.on('error')` 在 listen 成功后无声吞错** —— 加 `settled` 闸门:
81
+ 首次 reject,之后转 `log('error', 'listen server error (runtime)')`。
82
+ - **L2 回环判据三份实现**(listen-server / env-check / 面板各写一份)——
83
+ 统一导出 `isLoopbackHost()`,其余全部 import。
84
+ - **L3 `explicitPort` 判断看的是归一化后的值** —— 改成看**原始 config 是否
85
+ 有该键**(否则"没填"和"填了默认值"无法区分)。
86
+ - **L6 候选地址顺序随网卡枚举变化** —— `listCandidateAddresses()` 按
87
+ `score`(Tailscale=0 / 私网=1 / 其它=2)+ 接口名 + 地址稳定排序,
88
+ 避免同一台机器两次启动绑到不同 IP。
89
+
90
+ ### 断言与门禁
91
+
92
+ - **85 改向**:要求 CLI `--doctor` 用 `listen-server` 的回环判据,
93
+ 并**负向守卫**——不得再从 patch 里找 `- id: webserver` 判可达性
94
+ (扫剥注释后的代码,注释里的历史留档不算违规)。
95
+ - **79b 加固**:三处面向用户的文案必须同时满足"不建议 `0.0.0.0`"与
96
+ "包含不能用 `0.0.0.0` 的警告"。
97
+ - 自检不再依赖 import 的**排版**(`writePatchBlock` 的断言改为匹配符号
98
+ 而非单行字面量)—— 断言的是依赖关系,不是格式。
99
+
100
+ ```
101
+ packaging 4 项
102
+ doc-drift 123/123
103
+ diag:inject 11 项
104
+ tests 982(980 pass / 0 fail / 2 skip)
105
+ lint 干净(eslint 严格档,含 bin/ 与 test/)
106
+ ```
107
+
108
+ ### 升级须知
109
+
110
+ - **不用再给 `webserver` 填 host**。之前填过的可以删掉(留着也无害,DSH 会
111
+ 自己重写掉)。
112
+ - 想固定绑哪个地址:`listenHost: '100.x.x.x'`;端口冲突就换 `listenPort`。
113
+ - 防火墙要放行的是 **`listenPort`(默认 19387)**,不再是 DSH 的 Web 端口。
114
+ - **面板里点过一次「生成口令」的,副机要用新口令重新配对**(旧口令已换掉)。
115
+
116
+ ## [0.9.3] - 2026-10-18
117
+
118
+ > **架构级重做:插件自己开端口,副机拨入不再依赖 DSH 的 `webserver`。**
119
+ >
120
+ > ⚠ **0.9.2 那段"去 patch 里给 `webserver` 填一个具体 host"的做法从本版起
121
+ > 作废** —— 不用填了,填了也会被 DSH 每次启动重写掉。
122
+ >
123
+ > 🔴 **本版有两个 Critical,已被 0.9.4 修复,请不要停留在本版**:
124
+ > 面板生成的配对码用了 DSH webserver 的端口(粘了连不上);
125
+ > `nodeToken` 为空时独立监听变成了 tailnet 内的无鉴权接入点。
126
+ > 另外本版发布时 **CI 是红的**(win + ubuntu 全红、本地实测 20 fail)。
127
+
128
+ ### 为什么必须换架构(三个死结,均实证)
129
+
130
+ | 死结 | 现象 |
131
+ |---|---|
132
+ | **patch 被重写** | DSH 每次启动都重写 `cordis.patch.yml`,实测 3962B / 15 条 → **661B / 3 条**,写进去的 `webserver` 段被删光 |
133
+ | **webServer 无 rebind** | `webServer` 服务激活即 `listen`,没有改绑方法,插件拿不到控制权 |
134
+ | **硬拒通配地址** | 新版 `dsh-host-webserver` 在 schema 阶段就拒绝 `0.0.0.0`,绑了就 **DSH 启动崩溃**(已产生 10 份 crash 日志) |
135
+
136
+ 结论:**副机端点不能再挂在 `ctx.webServer` 上。**
137
+
138
+ ### 新方案
139
+
140
+ - **`lib/listen-server.js`** —— 用 `node:http` 自己开一个只服务节点端点的小
141
+ server:`GET <healthPath>` 回 200 JSON,`Upgrade: websocket` 复用 `ws.js`
142
+ 的 `acceptUpgrade`,**路径精确匹配**,其它一律 404;关闭时追踪并断开所有连接。
143
+ - **绑址优先链**:`listenHost` > Tailscale IP(`100.64.0.0/10`)> RFC1918 私网
144
+ > 任意非回环 > `127.0.0.1`;**拒绝** `0.0.0.0` / `::` / `[::]`。
145
+ 默认端口 **19387**,`listenPort: 0` = 关闭独立监听。
146
+ - **`lib/instance-config.js`** —— 配置分层:patch 只负责"让 loader 加载插件",
147
+ 真配置存 `<stateDir>/instance.json`(**0600 + 原子写 + 字段白名单**)。
148
+ `mergeInstanceConfig` 合并时**独立配置优先**,`apply()` 里重新归一化。
149
+ - **安全边界**:token 校验仍是同一份逻辑,独立监听不降低门槛。
150
+ (`nodeToken` 为空的处理是本版**没做到位**的地方,见 0.9.4 的 H1。)
151
+
152
+ ```
153
+ packaging 4 项
154
+ doc-drift 121/121
155
+ diag:inject 11 项
156
+ tests 975(953 pass / 20 fail / 2 skip) ← 发布时实际是红的
157
+ lint 干净
158
+ ```
159
+
12
160
  ## [0.9.2] - 2026-10-18
13
161
 
14
162
  > **紧急修复:0.9.0/0.9.1 会让新版 DSH 启动崩溃。**
package/CONTRIBUTING.md CHANGED
@@ -351,7 +351,9 @@ Node 24 下会把目录当模块入口,必然失败。用 `npm test`(内部
351
351
  3. **角色**(`config.role`)与**传输方式**(`transportMode`)
352
352
  4. **现象 + 复现步骤**
353
353
  5. **日志**:
354
- - 主机侧:面板「日志尾部」区,或 `/api/remote-agent/healthz`
354
+ - 主机侧:面板「日志尾部」区,或 `GET /api/remote-agent/healthz`
355
+ 带 `x-dsh-token: <nodeToken>` 头(匿名只回 `{ "ok": true }`,
356
+ 拿不到节点/任务计数 —— 0.9.3 起的分级策略)
355
357
  - 副机侧:`%TEMP%\dsh-remote-agent-diag.log`(若有)
356
358
  - 启动日志:`<DSH_HOME>/logs/` 与 `%APPDATA%\@deepseek-ai\dsh-desktop\logs`
357
359
 
package/KNOWN-ISSUES.md CHANGED
@@ -572,6 +572,8 @@ $env:DSH_REMOTE_PWSH = 'C:\nope\pwsh.exe'
572
572
  2. **registry 上下线实时性**:worker 在线时 `healthz` 报 `online:2`,
573
573
  进程退出后数秒内回落到 `online:1` —— 真实 webServer 路由下的
574
574
  在线状态检测是活的。
575
+ (0.9.3 起计数只回给带正确 `x-dsh-token` 的请求;匿名只回 `{ ok: true }`,
576
+ 见 CHANGELOG 0.9.3 的 L4 条。)
575
577
  3. **UI 围栏确认(安全结论,非缺陷)**:hub 的 webserver 除 `/healthz`
576
578
  外**整台 401**(`/`、`/index.html`、`/ui/config`、`/ui/overview` 全中)
577
579
  —— 没有桌面会话的请求一律被 `requestRejection` 拦掉。这意味着:
package/README.en.md CHANGED
@@ -37,7 +37,7 @@ explicitly pull them.
37
37
  | **Platforms** | Windows (fully verified) · Linux / macOS (CI covered) |
38
38
  | **Dependencies** | **Zero external npm dependencies** —— `node:` built-ins only |
39
39
  | **Node** | `^22.19.0 \|\| >=24.0.0` |
40
- | **Tests** | 938 (937 pass / 1 skip) · 118 doc-drift assertions · eslint strict |
40
+ | **Tests** | 982 (980 pass / 2 skip) · 123 doc-drift assertions · eslint strict |
41
41
 
42
42
  **Web panel**(sidebar → "远程同事" / Remote co-worker):node list with CPU /
43
43
  memory badges, task dispatch, task states, result summaries, workspace changes,
@@ -47,13 +47,15 @@ change diffs (unified diffs of text-file edits), final artifacts, log tail,
47
47
  > **Works out of the box**: restart DSH after install and the bundled patch
48
48
  > defaults `role: auto` (no `hubUrl` ⇒ hub), which takes effect automatically.
49
49
  >
50
- > **For workers to connect, the hub's `webserver` must bind one concrete local
51
- > IP** (DSH binds `127.0.0.1` by default, which workers can never reach). You
52
- > fill this in once — the panel's *environment health-check* tells you exactly
53
- > what to put there.
50
+ > **Workers connect to the plugin's own port — no DSH config to touch.**
51
+ > Since 0.9.3 the plugin runs its own listener (default **19387**) that workers
52
+ > dial directly; it does **not** go through `webserver`, so you never edit a
53
+ > `host` in `cordis.patch.yml`.
54
54
  >
55
- > ⚠ **Never use `0.0.0.0`**: newer DSH rejects wildcard addresses and will
56
- > **fail to start**. See *Install* below.
55
+ > The bind address is chosen automatically: `listenHost` (if set) > Tailscale
56
+ > IP > LAN IP > loopback. Set `listenHost: '100.x.x.x'` to pin it.
57
+ > **`0.0.0.0` is always rejected** (newer DSH refuses wildcard addresses —
58
+ > binding one crashes DSH on startup). See *Install* below.
57
59
 
58
60
  <p align="center">
59
61
  <img src="docs/screenshots/panel-main.png" width="720" alt="Web panel: nodes, dispatch form, task list">
@@ -244,6 +246,23 @@ in-package patch inserts it automatically):
244
246
  role: hub
245
247
  ```
246
248
 
249
+ **No `webserver` tuning needed.** The plugin listens on its own port (default
250
+ `19387`) and workers dial that, so the DSH web server's bind address is
251
+ irrelevant. Optional overrides:
252
+
253
+ ```yaml
254
+ config:
255
+ role: hub
256
+ listenHost: '100.x.x.x' # optional: blank = auto (Tailscale > LAN > loopback)
257
+ listenPort: 19387 # optional: default 19387; 0 = disable the listener
258
+ ```
259
+
260
+ > **Security policy: no token, no network exposure.** With an empty
261
+ > `nodeToken` the listener binds `127.0.0.1` only (same-machine `both` mode
262
+ > still works) and the panel shows a **yellow** banner pointing at
263
+ > “生成口令”. Generate one and restart DSH to get the reachable address back.
264
+ > To override deliberately, set a concrete non-loopback `listenHost`.
265
+
247
266
  ### Worker
248
267
 
249
268
  **Same package, different config** —— the key point is that **`inject: []` is
package/README.md CHANGED
@@ -30,12 +30,12 @@
30
30
 
31
31
  | | |
32
32
  |---|---|
33
- | **状态** | 0.9.0 —— 主机 + 副机端到端可用;`exec` 与 `agent` 双链路均已在两台真机上验证通过;CI 覆盖 Windows / macOS / Linux |
33
+ | **状态** | 0.9.4 —— 主机 + 副机端到端可用;`exec` 与 `agent` 双链路均已在两台真机上验证通过;CI 覆盖 Windows / macOS / Linux |
34
34
  | **DSH** | 桌面版 / CLI,0.2.0-rc.2 验证 |
35
35
  | **平台** | Windows(全量验证)· Linux / macOS(CI 覆盖)|
36
36
  | **依赖** | **零外部 npm 依赖** —— 只用 `node:` 内置模块 |
37
37
  | **Node** | `^22.19.0 \|\| >=24.0.0` |
38
- | **测试** | 938 项(937 pass / 1 skip)· 118 条文档漂移断言 · eslint 严格档 |
38
+ | **测试** | 982 项(980 pass / 2 skip)· 123 条文档漂移断言 · eslint 严格档 |
39
39
 
40
40
  **Web 面板**(侧栏「远程同事」):节点列表(含 CPU/内存徽章)、派发任务、
41
41
  任务状态、结果摘要、工作区变更、成品清单、日志尾巴、**环境体检**、
@@ -44,11 +44,13 @@
44
44
  > **开箱即用**:装完重启 DSH 就能用。包内 patch 默认 `role: auto`(没配
45
45
  > `hubUrl` 就是主机),自动生效。
46
46
  >
47
- > **副机要能连上,主机的 `webserver` 必须绑一个具体的本机 IP**(DSH 默认只绑
48
- > `127.0.0.1`,那样副机永远连不上)。这一步需要你填一次 —— 打开面板时
49
- > 「环境体检」会检查并告诉你该填什么。
47
+ > **副机连的是插件自己的端口,不用改 DSH 的任何配置。** 0.9.3 起本插件自己
48
+ > 开一个独立监听(默认 **19387**),副机直接拨这个端口 —— **不再依赖
49
+ > `webserver`**,也就不用去 `cordis.patch.yml` 里填 host。
50
50
  >
51
- > ⚠ **不能用 `0.0.0.0`**:新版 DSH 会硬拒绝通配地址,导致 DSH **启动崩溃**。
51
+ > 绑哪个地址是自动选的:`listenHost`(如果你填了)> Tailscale IP >
52
+ > 局域网 IP > 回环。想指定就配 `listenHost: '100.x.x.x'`;
53
+ > **`0.0.0.0` 一律拒绝**(新版 DSH 硬拒通配地址,绑了会让 DSH 启动崩溃)。
52
54
  > 详见下方「安装」。
53
55
 
54
56
  <p align="center">
@@ -221,24 +223,30 @@ npm install -g dsh-remote-agent
221
223
  - 包内 patch 默认 `role: auto`。**没配 `hubUrl` 就是主机**,自动生效。
222
224
  - 打开 Web 面板「远程同事」,点「副机接入」生成配对码,发给副机。
223
225
 
224
- **唯一需要你填一次的是 `webserver` 的 host** —— 副机要能连上,主机必须绑一个
225
- **具体的本机 IP**(DSH 默认只绑 `127.0.0.1`,那样副机永远连不上,而且主机侧
226
- 毫无异常)。在 profile 的 `cordis.patch.yml` 里:
226
+ **不需要改 DSH 的 `webserver`。** 0.9.3 起插件自己监听一个端口(默认 19387),
227
+ 副机拨的是它,不是 DSH 的 Web 端口。历史上那套"去 patch 里给 webserver 填一个
228
+ 具体 host"的做法**已经作废** —— 它还有个副作用:DSH 每次启动都会重写 patch,
229
+ 填进去的 `webserver` 段会被删掉(实测 15 条配置剩 3 条)。
230
+
231
+ **想指定绑哪个地址**(多数情况不用,自动选的就是对的):
227
232
 
228
233
  ```yaml
229
- - id: webserver
234
+ - id: remote-agent
230
235
  config:
231
- host: '100.x.x.x' # ← 换成你自己的地址
232
- port: 19387
236
+ listenHost: '100.x.x.x' # 可选:留空=自动(Tailscale > 局域网 > 回环)
237
+ listenPort: 19387 # 可选:默认 19387;填 0 = 关闭独立监听
233
238
  ```
234
239
 
240
+ **自动选的顺序**是 `listenHost`(填了就用它)→ Tailscale IP(`100.64.0.0/10`)
241
+ → 局域网私网 IP → 任意非回环 → `127.0.0.1`。
242
+
235
243
  **怎么查自己的地址**:
236
244
 
237
245
  - 跨网(推荐):`tailscale ip -4` —— Tailscale IP 固定不变、跨网可达
238
246
  - 同一局域网:`ipconfig` 看 WLAN/以太网那块网卡的 IPv4
239
247
 
240
- > ⚠ **绝对不能写 `0.0.0.0`**。新版 `@deepseek-ai/dsh-host-webserver` 在 schema
241
- > 校验阶段就会硬拒绝通配地址:
248
+ > ⚠ **`listenHost` 不能写 `0.0.0.0`**(`::` 也不行)。新版
249
+ > `@deepseek-ai/dsh-host-webserver` 在 schema 校验阶段就硬拒绝通配地址:
242
250
  >
243
251
  > ```
244
252
  > host "0.0.0.0" is an unspecified (wildcard) address, which is not supported:
@@ -246,11 +254,16 @@ npm install -g dsh-remote-agent
246
254
  > bind one concrete IPv4 or IPv6 address of a local interface instead
247
255
  > ```
248
256
  >
249
- > 后果是 **DSH 启动崩溃**(`webserver` 是 required 插件,它挂了 `webServer`
250
- > 服务就缺失,至少 6 个插件排队等它,包括本插件)。
257
+ > 本插件自己开端口,同样**主动拒绝**通配地址:填了会退回自动选择,而不是
258
+ > 把端点暴露到所有网卡。
251
259
  >
252
- > 面板的「环境体检」会自动检测这一项,绑错了会明确告诉你当前绑在哪、
253
- > 该改成什么 —— 面板加载时也会自动体检一次。
260
+ > 面板的「环境体检」会检查独立监听有没有起来、绑在哪、副机能不能拨进来;
261
+ > 连不上时按提示修(每条 ✗ 都带具体怎么做)。
262
+
263
+ > **安全策略:`nodeToken` 为空时不暴露到网络。** 没有入网口令的开放在公网/
264
+ > 局域网里等于把 RCE 送人。这种情况下插件只绑 `127.0.0.1`(本机 both 模式
265
+ > 仍可用),面板会显示一条**黄色**提示告诉你去点「生成口令」。生成后重启
266
+ > DSH 即恢复可达地址 —— 想强行对外就显式填一个非回环的 `listenHost`。
254
267
 
255
268
  ### 副机(worker)—— 贴个配对码就行
256
269
 
@@ -36,7 +36,9 @@ import { encodePairCode, decodePairCode } from '../lib/pairing.js'
36
36
  // loopback 诊断** —— 而它正是本版本最重要的那条。现在两段都跑、结果合并。
37
37
  // resolveRole 保证 auto 被推断成实际角色(与运行时同一份实现)。
38
38
  import { resolveRole } from '../lib/index.js'
39
- import { envChecks, isLoopbackOrigin } from '../lib/env-check.js'
39
+ import { envChecks } from '../lib/env-check.js'
40
+ import { resolveListenHost, isLoopbackHost } from '../lib/listen-server.js'
41
+ import { readInstanceConfig, writeInstanceConfig } from '../lib/instance-config.js'
40
42
  import { IDENTITY_FILE_NAME, readHubIdentity } from '../lib/identity.js'
41
43
 
42
44
  const DIM = '\x1b[2m'
@@ -114,8 +116,12 @@ function printYaml(name, yaml, outFile) {
114
116
  /**
115
117
  * 询问并把 YAML 写进本机 DSH 配置(备份 → 托管块合并 → 校验,失败自动回滚)。
116
118
  * 写盘是零终端小白的"最后一公里":不用自己找配置文件、开编辑器、粘 YAML。
119
+ *
120
+ * @param {object} [persist] 同时写入 `<stateDir>/instance.json` 的字段(2026-10-20,M5)。
121
+ * patch 那层会被 DSH 每次启动重写冲掉,instance.json 才是"重启后配置还在"的保障。
122
+ * 面板配的走这条,CLI 配的也必须走 —— 否则同一台机器换个配置方式行为就不同。
117
123
  */
118
- async function offerWrite(rl, values, yaml, roleLabel) {
124
+ async function offerWrite(rl, values, yaml, roleLabel, persist = null) {
119
125
  if (values['no-write']) return false
120
126
  const profile = String(values.profile ?? '').trim() || 'desktop'
121
127
  const file = patchFilePath(profile)
@@ -129,15 +135,29 @@ async function offerWrite(rl, values, yaml, roleLabel) {
129
135
  validate: (v) => (['y', 'yes', 'n', 'no', '1', '0'].includes(v.toLowerCase()) || '输 Y 或 N'),
130
136
  })
131
137
  if (!['y', 'yes', '1'].includes(yes.toLowerCase())) return false
132
- return applyWrite({ file, blockYaml: yaml, force: values.force === true })
138
+ return applyWrite({ file, blockYaml: yaml, force: values.force === true, persist })
133
139
  }
134
140
 
135
141
  /** 非交互写盘执行 + 结果播报。返回 true = 成功。 */
136
- function applyWrite({ file, blockYaml, force }) {
142
+ function applyWrite({ file, blockYaml, force, persist = null }) {
137
143
  const r = writePatchBlock({ file, blockYaml, force })
138
144
  if (r.ok) {
139
145
  say(` ✓ 已${r.mode === 'replaced' ? '更新(原托管块替换)' : '写入(追加托管块)'} ${r.file}`)
140
146
  say(` (原文件已备份:${r.backup})`)
147
+ // 独立配置这一层(2026-10-20):DSH 会重写 patch,只有这里存的能扛住
148
+ if (persist && typeof persist === 'object') {
149
+ try {
150
+ const prev = readInstanceConfig({}) ?? {}
151
+ const next = { ...prev }
152
+ for (const k of Object.keys(persist)) if (persist[k] === undefined) delete next[k]
153
+ Object.assign(next, persist)
154
+ const w = writeInstanceConfig(next, {})
155
+ if (w.ok) say(` ✓ 已写入独立配置 ${w.file}(DSH 重写 patch 也不会丢)`)
156
+ else hint(` ⚠ 独立配置写入失败(${w.error})—— 重启后角色配置可能被 DSH 冲掉`)
157
+ } catch (err) {
158
+ hint(` ⚠ 独立配置写入异常(${String(err?.message ?? err)})—— 不影响本次 patch 写入`)
159
+ }
160
+ }
141
161
  hint(' 重启 DSH 后生效。')
142
162
  } else {
143
163
  say(` ✗ ${r.error}`)
@@ -177,8 +197,37 @@ async function runDoctor(positionals) {
177
197
  pair: grab('pair'),
178
198
  roots,
179
199
  uiPullRoot: grab('uiPullRoot'),
200
+ // 独立监听(v0.9.3 起的主通道)。listenPort 必须是 number —— 0 有语义
201
+ // (= 关闭),所以不能用 `Number(x) || 19387` 那种写法把它吃掉。
202
+ listenHost: grab('listenHost'),
203
+ listenPort: (function () {
204
+ const v = grab('listenPort')
205
+ if (v === '') return 19387
206
+ const n = Number(v)
207
+ return Number.isFinite(n) && n >= 0 ? n : 19387
208
+ })(),
180
209
  }
181
210
 
211
+ // ⚠ 独立配置优先(v0.9.3 审查 M2/M3):DSH 每次启动都会重写 patch,
212
+ // 真正生效的字段存在 `<stateDir>/instance.json` 里并**覆盖** patch 的值。
213
+ // 只读 patch 体检 = 报告一个已经不在生效的配置(比如用户在面板里改了
214
+ // listenPort,patch 里还是旧的 19387)。
215
+ //
216
+ // 先拍一张 patch 快照,合并之后再拿它比对 —— 否则"谁覆盖了谁"永远看不出
217
+ // (config 已被改写,比较恒等)。
218
+ const patchSnapshot = { ...config }
219
+ let instanceSrc = null
220
+ try {
221
+ const saved = readInstanceConfig({})
222
+ if (saved && typeof saved === 'object') {
223
+ for (const k of Object.keys(saved)) {
224
+ if (!(k in config)) continue
225
+ if (saved[k] !== undefined) config[k] = saved[k]
226
+ }
227
+ instanceSrc = saved
228
+ }
229
+ } catch { /* 读不到就当没有 */ }
230
+
182
231
  // 角色解析用**与运行时同一份实现**(唯一真相):auto 在这里被推断成
183
232
  // 实际角色,这样 doctor 报告的 role 与 hub 实际行为一致。
184
233
  const resolvedRole = resolveRole(config) || ''
@@ -204,30 +253,60 @@ async function runDoctor(positionals) {
204
253
  //
205
254
  // 探测参数:CLI 能真探测的才传,拿不到的留 null(envChecks 会跳过该项,
206
255
  // 不会误报)。
207
- // · webserver host 从配置文件里读(静态可判 —— 正是 loopback 检查的关键)
256
+ // · 独立监听:从 **生效配置**(instance.json 覆盖后)用与运行时同一份
257
+ // `resolveListenHost` 算出来,再用本机 TCP 探活判断"插件是否真在听"
208
258
  // · roots 存在性真查文件系统
259
+ //
260
+ // ⚠ **绝不能再从 patch 里读 `- id: webserver`**(v0.9.3 审查 H5 的 CLI 侧):
261
+ // 0.9.3 起端点由插件自己的独立监听承载,webserver 的 host 与副机能否接入
262
+ // **毫无关系**。老代码读到 `host: 127.0.0.1` 就报"副机连不上,去改
263
+ // webserver",把用户引向一个改了也会被 DSH 重写冲掉、且修不好的方向。
209
264
  try {
210
- const wsHost = grab('host')
211
- const wsPort = grab('port')
212
- // 从配置里找 webserver 段(形如 `- id: webserver` 之后几行的 host/port)
213
- const wsBlock = /- id:\s*webserver[\s\S]{0,400}?host:\s*['"]?([^'"\n]+)['"]?[\s\S]{0,120}?port:\s*(\d+)/.exec(raw)
214
- const boundHost = wsBlock ? wsBlock[1].trim() : (wsHost || '')
215
- const boundPort = wsBlock ? Number(wsBlock[2]) : (Number(wsPort) || 19387)
216
- const loopback = isLoopbackOrigin(`http://${boundHost || '127.0.0.1'}:${boundPort}`)
265
+ const listenPort = config.listenPort
266
+ const tokenBlank = config.nodeToken === ''
267
+ const explicitNonLoop = config.listenHost !== '' && !isLoopbackHost(config.listenHost)
268
+ // 与 applyHub 同一条安全策略:token 为空且没显式指定可达地址 → 强制回环
269
+ const pick = listenPort === 0
270
+ ? { host: '', source: 'disabled' }
271
+ : (tokenBlank && !explicitNonLoop
272
+ ? { host: '127.0.0.1', source: 'loopback-no-token' }
273
+ : resolveListenHost({ listenHost: config.listenHost }))
274
+
275
+ // 本机 TCP 探活:能连上 = 插件此刻确实在听(比"配置看起来对"强得多)
276
+ let alive = null
277
+ if (pick.host !== '') {
278
+ const probe = await probeTcp(isLoopbackHost(pick.host) ? '127.0.0.1' : pick.host, listenPort)
279
+ if (probe.ok) alive = true
280
+ else if (probe.error === 'ECONNREFUSED') alive = false
281
+ // 其它错误(超时/EHOSTUNREACH)→ 无法判定,保持 null
282
+ }
283
+
284
+ const listener = {
285
+ // 连不上时**不要**断言"启动失败":DSH 可能根本没在跑(CLI 常在停机时用)
286
+ ok: pick.host === '' ? false : alive,
287
+ disabled: pick.host === '' ? '配置里 listenPort: 0(显式关闭独立监听)' : '',
288
+ host: pick.host,
289
+ port: listenPort,
290
+ source: pick.source,
291
+ error: alive === false ? `本机 ${listenPort} 端口没有在听(DSH 未运行?或端口被占)` : '',
292
+ }
293
+
217
294
  const missingRoots = config.roots.filter((r) => {
218
295
  try { return !fs.existsSync(r) } catch { return true }
219
296
  })
220
297
  const env = envChecks({
221
298
  role: resolvedRole || 'hub',
222
299
  config,
223
- reachability: boundHost === '' && !wsBlock
300
+ reachability: pick.host === ''
224
301
  ? null
225
302
  : {
226
- bound: boundHost,
227
- origin: `http://${boundHost || '127.0.0.1'}:${boundPort}`,
228
- loopback: loopback === true,
303
+ bound: pick.host,
304
+ origin: `${pick.host}:${listenPort}`,
305
+ loopback: isLoopbackHost(pick.host),
306
+ via: 'listen-server',
229
307
  hint: '',
230
308
  },
309
+ listener,
231
310
  missingRoots,
232
311
  })
233
312
  // 合并:envChecks 的项追加到 CLI 结果后(格式一致:{ok, label, fix})
@@ -239,6 +318,20 @@ async function runDoctor(positionals) {
239
318
  fix: it.fix,
240
319
  })
241
320
  }
321
+
322
+ // 独立配置来源提示:patch 与 instance.json 不一致时说明以谁为准
323
+ if (instanceSrc && typeof instanceSrc === 'object') {
324
+ const overridden = ['role', 'hubUrl', 'listenHost', 'listenPort']
325
+ .filter((k) => instanceSrc[k] !== undefined
326
+ && String(instanceSrc[k]) !== String(patchSnapshot[k] === undefined ? '' : patchSnapshot[k]))
327
+ checks.push({
328
+ ok: true,
329
+ label: overridden.length === 0
330
+ ? '独立配置 instance.json 已加载(与 patch 一致)'
331
+ : `独立配置 instance.json 覆盖了 patch 的:${overridden.join(' / ')}(DSH 重写 patch 也不会丢)`,
332
+ fix: '',
333
+ })
334
+ }
242
335
  } catch (err) {
243
336
  // envChecks 自身出错不能挡 CLI 体检(它只是补充)
244
337
  checks.push({ ok: true, label: `(运行时态检查不可用:${String(err?.message ?? err)})`, fix: '' })