dsh-remote-agent 0.9.6 → 0.9.9
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 +213 -0
- package/README.md +6 -4
- package/lib/auto-fix.js +304 -0
- package/lib/client.js +279 -9
- package/lib/e2e.js +42 -1
- package/lib/external-tools.js +238 -0
- package/lib/hub/tools.js +289 -0
- package/lib/hub/ui-routes.js +182 -0
- package/lib/node/fs-service.js +276 -1
- package/lib/node/index.js +29 -2
- package/lib/node/process-service.js +406 -0
- package/package.json +1 -1
- package/scripts/check-doc-drift.mjs +97 -3
- package/scripts/diag-inject.mjs +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,219 @@
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
+
## [0.9.9] - 2026-10-20
|
|
13
|
+
|
|
14
|
+
> **0.9.8 的 CI 回归修复。** 本地全绿,但 macOS 与 Windows 上失败。
|
|
15
|
+
|
|
16
|
+
### 修复:测试断言直接比绝对路径(realpath 与平台表示差异)
|
|
17
|
+
|
|
18
|
+
**现象**(CI run `39389528713`,macOS + Windows 失败,Linux 通过):
|
|
19
|
+
|
|
20
|
+
macOS: actual '/private/var/folders/.../b.txt'
|
|
21
|
+
expected '/var/folders/.../b.txt'
|
|
22
|
+
|
|
23
|
+
Windows: actual 'C:\Users\runneradmin\AppData\Local\Temp\...\b.txt'
|
|
24
|
+
expected 'C:\Users\RUNNER~1\AppData\Local\Temp\...\b.txt'
|
|
25
|
+
|
|
26
|
+
**根因**:`fs-service` 的写操作返回 **realpath 后**的路径 —— 这是**刻意的正确行为**
|
|
27
|
+
(realpath 才能识破软链逃逸与 8.3 短名)。但测试用 `path.join(root, ...)`
|
|
28
|
+
直接比字符串:
|
|
29
|
+
|
|
30
|
+
- **macOS**:`/var` 是 `/private/var` 的软链
|
|
31
|
+
- **Windows CI**:`RUNNER~1` 是 `runneradmin` 的 8.3 短名
|
|
32
|
+
|
|
33
|
+
两者都让"直接比字符串"失败。**Linux 恰好没有这层间接,所以本地和 ubuntu 都过。**
|
|
34
|
+
|
|
35
|
+
**修法**:这条用例真正要验证的是"移动成功且内容对",不是"路径字符串相等"。
|
|
36
|
+
改成比 `basename` + 文件内容:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
assert.equal(path.basename(r.to), 'b.txt')
|
|
40
|
+
assert.equal(fs.readFileSync(b, 'utf8'), 'A')
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 0.9.8 的四项能力(本版包含)
|
|
44
|
+
|
|
45
|
+
| 能力 | 关键护栏 |
|
|
46
|
+
|---|---|
|
|
47
|
+
| **一键修复** | 只改托管块 / 写前备份 / 写后校验 / 二次确认 / 可撤销 / 绝不用 0.0.0.0 |
|
|
48
|
+
| **外部工具检测** | 只探测 + 给命令,**不代下载**(守供应链边界)|
|
|
49
|
+
| **写操作 ×4** | confirm 必填 / 默认不覆盖 / 不递归删 / 原子写 / 8MB 上限 / roots 护栏 |
|
|
50
|
+
| **进程诊断 ×3** | kill 三道护栏:confirm / 拒杀自身与父进程 / 拒杀系统关键进程 |
|
|
51
|
+
| **UI 折叠** | 首屏只留当下需要的;配置/体检/共享收进 `<details>` |
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
packaging 4 项
|
|
55
|
+
doc-drift 131/131
|
|
56
|
+
diag:inject 11 项
|
|
57
|
+
tests 1032(1030 pass / 0 fail / 2 skip)
|
|
58
|
+
lint 干净
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## [0.9.8] - 2026-10-20
|
|
62
|
+
|
|
63
|
+
### 四项能力(用户要求:一键修复 / 自动装依赖 / 更多工具 / 重写 UI)
|
|
64
|
+
|
|
65
|
+
这四项**全都在放宽边界**,所以每一道护栏都不是"最佳实践",而是**踩过的坑**。
|
|
66
|
+
|
|
67
|
+
#### ① 一键修复配置(会改 DSH 的 cordis.patch.yml)
|
|
68
|
+
|
|
69
|
+
用户此前明确说过「不要改 DSH」,而这个功能恰恰要改它。所以做成**两步流程**:
|
|
70
|
+
|
|
71
|
+
[预览修复] → 只读,列出"将要改什么" + "不会做什么"
|
|
72
|
+
[确认修复] → 才写盘(必须带 confirm: "yes")
|
|
73
|
+
|
|
74
|
+
护栏:
|
|
75
|
+
- **只改托管块**(`# --- dsh-remote-agent (managed) ---` 之间),用户手写的
|
|
76
|
+
其它条目一个字节不碰
|
|
77
|
+
- **写前必备份**(`.bak-<时间戳>`)
|
|
78
|
+
- **写后必校验**:重新解析,确认条目在、无重复 id
|
|
79
|
+
- **失败必回滚**,并给「撤销」按钮(`action: 'undo'`)
|
|
80
|
+
- **绝不用 0.0.0.0**(新版 DSH 硬拒绝,会让它启动崩溃 —— 我们踩过 10 份 crash 日志)
|
|
81
|
+
|
|
82
|
+
**只修机械可判定的项**(webserver host / role)。端口占用、防火墙、装软件、
|
|
83
|
+
模型凭证这些需要人判断的,只给建议不动手。
|
|
84
|
+
|
|
85
|
+
#### ② 外部工具检测(**不代下载**)
|
|
86
|
+
|
|
87
|
+
用户原话是"自动装依赖",我们把范围收窄到**外部工具**,且**不代下载**:
|
|
88
|
+
|
|
89
|
+
- 零 npm 依赖是核心卖点 —— 代装 npm 包会自毁卖点
|
|
90
|
+
- 代下载第三方二进制会让我们变成软件分发方(版本/来源/校验/责任/
|
|
91
|
+
供应链攻击面全落在我们头上)
|
|
92
|
+
|
|
93
|
+
所以只做三件事:**探测**(Tailscale / PowerShell / git / Node.js)、
|
|
94
|
+
**说清缺了会怎样**、**给官方链接 + 安装命令**(用户自己复制执行)。
|
|
95
|
+
|
|
96
|
+
实测在本机探测到:Tailscale 1.102.4 / PowerShell 5 / git 2.54.0 / Node v24.18.0。
|
|
97
|
+
|
|
98
|
+
#### ③ 更多工具:文件写操作(4 个)+ 进程诊断(3 个)
|
|
99
|
+
|
|
100
|
+
工具数 12 → **19**。
|
|
101
|
+
|
|
102
|
+
**写操作**(`remote_write` / `remote_mkdir` / `remote_move` / `remote_delete`):
|
|
103
|
+
|
|
104
|
+
- **必须传 `confirm: "yes"`**(不可逆操作,AI 会"顺手"多调一次工具)
|
|
105
|
+
- **默认不覆盖**已有文件 —— 防手滑清空
|
|
106
|
+
- **不递归删除**:非空目录一律拒绝(递归删除太容易造成不可逆损失)
|
|
107
|
+
- **原子写**(临时文件 + rename),失败不留半截文件
|
|
108
|
+
- 单次写入上限 8MB(防一次写爆磁盘)
|
|
109
|
+
- roots 护栏:新增 `resolveWritable`,允许目标不存在但对**已存在的祖先**
|
|
110
|
+
做 realpath 校验(防软链逃逸)
|
|
111
|
+
|
|
112
|
+
**进程诊断**(`remote_ps` / `remote_port` / `remote_kill`):
|
|
113
|
+
|
|
114
|
+
- `ps` / `port` **只读**
|
|
115
|
+
- `kill` 有三道护栏:
|
|
116
|
+
1. 必须 `confirm: "yes"`
|
|
117
|
+
2. **拒绝杀本进程与父进程** —— 否则副机失联,用户彻底失去远程修复能力
|
|
118
|
+
3. **拒绝杀系统关键进程**(System/csrss/wininit/services/lsass 等)
|
|
119
|
+
|
|
120
|
+
实测端口查询准确识别出 19387 的监听 PID。
|
|
121
|
+
|
|
122
|
+
#### ④ UI 改进:折叠区块(信息密度)
|
|
123
|
+
|
|
124
|
+
加了一键修复 / 外部工具 / SMB 共享之后,面板区块到了 **9 个**,首屏被淹没。
|
|
125
|
+
|
|
126
|
+
改用原生 `<details>` 折叠(零 JS 状态、可访问性好、无 hydration 问题):
|
|
127
|
+
|
|
128
|
+
- 首屏只留:错误横幅 + 状态卡 + 节点 + 任务 + 派发表单
|
|
129
|
+
- 「接入与配置」「环境体检与工具」「工作区共享」收进折叠块
|
|
130
|
+
- **默认展开策略**:没副机在线 → 展开「接入」;体检有失败项 → 展开「体检」
|
|
131
|
+
|
|
132
|
+
### 踩坑记录(都写进了代码注释)
|
|
133
|
+
|
|
134
|
+
**① `resolveAny` 要求路径已存在 → `remote_write` 100% 失败**
|
|
135
|
+
只读工具用 `realpathSync` 是对的("读不存在的文件没有借口"),但写操作的
|
|
136
|
+
目标**本来就是要新建的**。第一版直接复用,实测立刻暴露
|
|
137
|
+
`path not found: .../a.txt`。修法:新增 `resolveWritable` —— 目标不存在时
|
|
138
|
+
向上找第一个存在的祖先做 realpath,再校验 roots。
|
|
139
|
+
|
|
140
|
+
**② 多行 PowerShell 脚本 `join(' ')` 缺分号**
|
|
141
|
+
`$ErrorActionPreference="Stop" Get-Process ...` 被拼成一行,PowerShell 报
|
|
142
|
+
`UnexpectedToken`。修法:用 `;` 分隔语句。
|
|
143
|
+
|
|
144
|
+
**③ `process-service.js` 忘了导出 `createProcessService`**
|
|
145
|
+
6 个测试文件全部加载失败(`does not provide an export named`)。
|
|
146
|
+
|
|
147
|
+
**④ 测试锚点被新代码干扰**
|
|
148
|
+
`indexOf('confirm-required')` 找到的第一处不再是被测的那条(一键修复也用
|
|
149
|
+
同一个错误码)。修法:锚更具体的上下文。
|
|
150
|
+
|
|
151
|
+
### 测试
|
|
152
|
+
|
|
153
|
+
`test/capabilities-2026-10-20.test.js` **23 条**,重点不在"功能能用"而在
|
|
154
|
+
"护栏挡得住":
|
|
155
|
+
|
|
156
|
+
- 写操作:默认不覆盖 / 越界拒绝 / 目标不存在也能写 / 超限拒绝 /
|
|
157
|
+
非空目录拒绝 / 原子性无残留
|
|
158
|
+
- kill:无 confirm 拒绝 / 拒杀自己 / 拒杀父进程 / 非法 pid 拒绝
|
|
159
|
+
- 一键修复:只标机械可修项 / 绝不 0.0.0.0 / plan 是只读的 /
|
|
160
|
+
备份名必须符合命名空间
|
|
161
|
+
- 外部工具:结构完整 / **不得代执行安装命令**(反例断言)/ 清单与探测同源
|
|
162
|
+
|
|
163
|
+
doc-drift 新增 91(一键修复护栏)、92(写操作护栏)、93(kill 三道护栏)、
|
|
164
|
+
94(不代下载)、95(折叠 UI)。
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
packaging 4 项
|
|
168
|
+
doc-drift 131/131
|
|
169
|
+
diag:inject 11 项
|
|
170
|
+
tests 1032(1030 pass / 0 fail / 2 skip)
|
|
171
|
+
lint 干净
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## [0.9.7] - 2026-10-20
|
|
175
|
+
|
|
176
|
+
> **0.9.6 的 CI 回归修复。** 0.9.6 在本地全绿,但 GitHub Actions 的
|
|
177
|
+
> `ubuntu-latest / node 24.x` 上 E2E 握手全部超时。
|
|
178
|
+
|
|
179
|
+
### 修复:X25519 私钥重建改用 DER,不再依赖 JWK 的 `x` 校验
|
|
180
|
+
|
|
181
|
+
**现象**(CI run `39384173448`,只有 ubuntu/node24 失败):
|
|
182
|
+
|
|
183
|
+
Error: timeout waiting for e2e session on test-worker
|
|
184
|
+
code: 'ERR_CRYPTO_INVALID_JWK'
|
|
185
|
+
|
|
186
|
+
**根因**:`lib/e2e.js` 用 JWK 重建私钥:
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
createPrivateKey({ key: { kty: 'OKP', crv: 'X25519', d: b64, x: '' }, format: 'jwk' })
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
本地 Node 24.18 接受 `x: ''`,但 CI 那个 Node 24 小版本**不接受**
|
|
193
|
+
(空串不是合法 base64url;省略 `x` 同样被拒)。
|
|
194
|
+
|
|
195
|
+
**这个 bug 为什么藏了这么久**:失败表现为
|
|
196
|
+
`timeout waiting for e2e session` —— **真因(ERR_CRYPTO_INVALID_JWK)被
|
|
197
|
+
完全掩盖**,看起来像时序抖动,而本地又复现不了。
|
|
198
|
+
|
|
199
|
+
**修法**:直接用 DER 构造 —— X25519 私钥的 PKCS#8 就是
|
|
200
|
+
固定 16 字节前缀 + 32 字节标量。这条路径**不经过 JWK 校验**,跨 Node
|
|
201
|
+
小版本稳定。
|
|
202
|
+
|
|
203
|
+
实测验证:DER 构造出的私钥导出的公钥 `x` 与原始 JWK 完全一致,
|
|
204
|
+
DH 协商结果与正常生成的密钥对一致。
|
|
205
|
+
|
|
206
|
+
**测试**:`test/e2e.test.js` 新增 2 条 ——
|
|
207
|
+
双方派生会话密钥必须一致(E2E 握手的核心数学)、非法长度必须明确抛错。
|
|
208
|
+
|
|
209
|
+
### 0.9.6 的五项体验提升(本版包含)
|
|
210
|
+
|
|
211
|
+
- **④ 统一错误翻译层** —— errno + 17 个协议码 + HTTP + 文案特征,每条带"下一步"
|
|
212
|
+
- **① 副机连接状态可见** —— 记录每次连接尝试,空态显示原因与修法
|
|
213
|
+
- **② 一键复制修法** —— 体检项旁的「复制修法」按钮(只复制,不代改文件)
|
|
214
|
+
- **③ 首次使用向导** —— 编号步骤 + 每步"应该看到什么"
|
|
215
|
+
- **⑤ 任务进度摘要** —— 任务行直接显示"最近:调用工具 Write · 共 23 步"
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
packaging 4 项
|
|
219
|
+
doc-drift 126/126
|
|
220
|
+
diag:inject 11 项
|
|
221
|
+
tests 1009(1007 pass / 0 fail / 2 skip)
|
|
222
|
+
lint 干净
|
|
223
|
+
```
|
|
224
|
+
|
|
12
225
|
## [0.9.6] - 2026-10-20
|
|
13
226
|
|
|
14
227
|
### 用户体验:五项提升("出了问题不知道原因"的正面回应)
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
┌─────────────── 主电脑(hub)────────────────┐
|
|
17
17
|
│ DSH · 模型 · 会话日志 · 全部凭证 │
|
|
18
18
|
│ ┌─ dsh-remote-agent ─────────────────────┐ │
|
|
19
|
-
│ │
|
|
19
|
+
│ │ 19 个 remote_* 工具 + Web 面板 │ │
|
|
20
20
|
│ └────────────────────────────────────────┘ │
|
|
21
21
|
└──────────────────▲──────────────────────────┘
|
|
22
22
|
│ 节点主动拨入(direct / relay)
|
|
@@ -177,7 +177,7 @@ node bin/dsh-remote-setup.js --doctor 你的配置.yml
|
|
|
177
177
|
| **凭证不离开副机** | 协议里不带任何模型 key;副机用自己的模型配置跑自己的循环 |
|
|
178
178
|
| **零 npm 依赖** | 只用 `node:` 内置模块,无构建步骤,`lib/` 就是产物 |
|
|
179
179
|
|
|
180
|
-
### 工具速查(共
|
|
180
|
+
### 工具速查(共 19 个)
|
|
181
181
|
|
|
182
182
|
| 类别 | 工具 |
|
|
183
183
|
|---|---|
|
|
@@ -185,7 +185,9 @@ node bin/dsh-remote-setup.js --doctor 你的配置.yml
|
|
|
185
185
|
| **任务** | `remote_task`(派发)· `remote_status`(查进度)· `remote_cancel`(取消)|
|
|
186
186
|
| **审批** | `remote_task` 的 `approval: 'manual'` 会挂起 → 面板或工具批准/拒绝 |
|
|
187
187
|
| **结果** | `remote_handoff`(结果卡)· `remote_manifest`(产物清单)· `remote_pull`(拉成品)· `remote_purge`(清理)|
|
|
188
|
-
|
|
|
188
|
+
| **文件(只读)** | `remote_ls` · `remote_read` · `remote_grep` · `remote_tree` |
|
|
189
|
+
| **文件(写入)** | `remote_write` · `remote_mkdir` · `remote_move` · `remote_delete` —— **必须传 `confirm: "yes"`**;默认不覆盖、不递归删 |
|
|
190
|
+
| **诊断** | `remote_ps`(列进程)· `remote_port`(查端口占用)· `remote_kill`(杀进程,**不可逆**,须 `confirm: "yes"`)|
|
|
189
191
|
|
|
190
192
|
---
|
|
191
193
|
|
|
@@ -442,7 +444,7 @@ ack 自动按明文兼容旧中继继续),安全边界与已知局限见
|
|
|
442
444
|
| 路径 | 作用 |
|
|
443
445
|
|---|---|
|
|
444
446
|
| `lib/index.js` | 插件入口(角色分发器:`role: auto` / `hub` / `worker` / `both`) |
|
|
445
|
-
| `lib/hub/**` | 主机侧:节点注册表、**
|
|
447
|
+
| `lib/hub/**` | 主机侧:节点注册表、**19 个工具**、subagent provider、UI 路由、环境体检 |
|
|
446
448
|
| `lib/hub/views.js` | **投影共享层** —— 工具与 UI 用同一份字段白名单 |
|
|
447
449
|
| `lib/node/**` | 副机侧:拨入、任务引擎、产物服务、exec/agent 双后端、SMB 共享 |
|
|
448
450
|
| `lib/env-check.js` | 环境体检(命令行与面板共用一份逻辑) |
|
package/lib/auto-fix.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一键修复(2026-10-20,用户要求的第四项能力)。
|
|
3
|
+
*
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* 为什么这件事要极其小心
|
|
6
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
*
|
|
8
|
+
* 用户之前明确说过「**不要改 DSH**」。而"一键修复"恰恰要改
|
|
9
|
+
* `<profile>/cordis.patch.yml` —— DSH 自己的配置文件。
|
|
10
|
+
*
|
|
11
|
+
* 我们**自己**刚因为动这个文件出过事故:
|
|
12
|
+
* · 手写 `host: '0.0.0.0'` → DSH 启动崩溃(10 份 crash 日志)
|
|
13
|
+
* · 追加 remote-agent 段 → 被 DSH 下次启动重写冲掉
|
|
14
|
+
*
|
|
15
|
+
* 所以本模块的每一道防线都不是"最佳实践",而是**踩过的坑**:
|
|
16
|
+
*
|
|
17
|
+
* 1. **只改托管块**(`# --- dsh-remote-agent (managed) ---` 之间)。
|
|
18
|
+
* 用户手写的其它条目一个字节不碰 —— 复用 `writePatchBlock` 的既有保证。
|
|
19
|
+
* 2. **写前必备份**(`writePatchBlock` 自带 `.bak-<时间戳>`)。
|
|
20
|
+
* 3. **写后必校验**:重新解析一遍,确认我们的条目在、且没有重复 id。
|
|
21
|
+
* 4. **失败必回滚**(`writePatchBlock` 内部已做;本模块再兜一层)。
|
|
22
|
+
* 5. **面板二次确认**:`confirm: 'yes'` 才动手(与 SMB 共享同一模式)。
|
|
23
|
+
* 6. **可一键回滚**:记住上一次备份路径,给「撤销」按钮。
|
|
24
|
+
*
|
|
25
|
+
* ── 只修"机械可判定"的项 ───────────────────────────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* **不修**需要人判断的(端口占用、防火墙、模型凭证)—— 那些只给建议。
|
|
28
|
+
*
|
|
29
|
+
* 可修的两类:
|
|
30
|
+
* · `webserver.host` 绑在 loopback 或 0.0.0.0 → 改成自动选出的具体 IP
|
|
31
|
+
* · `remote-agent.role` 缺失 → 按当前推断结果补上
|
|
32
|
+
*
|
|
33
|
+
* ⚠ **绝不用 0.0.0.0**(新版 DSH 硬拒绝,会让它启动崩溃)。
|
|
34
|
+
* 地址由 `resolveListenHost` 自动选(Tailscale > 私网 > 回环)。
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import fs from 'node:fs'
|
|
38
|
+
import path from 'node:path'
|
|
39
|
+
|
|
40
|
+
import { resolveListenHost } from './listen-server.js'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 生成一个修复计划(**只读**,不改任何文件)。
|
|
44
|
+
*
|
|
45
|
+
* 面板先展示"将要改什么",用户确认后才调 `applyFixPlan`。
|
|
46
|
+
*
|
|
47
|
+
* @param {object} o
|
|
48
|
+
* @param {object} o.envCheck envChecks 的结果
|
|
49
|
+
* @param {string} o.patchFile 目标 cordis.patch.yml 绝对路径
|
|
50
|
+
* @param {string} [o.listenHost] 用户配置的监听地址(留空=自动选)
|
|
51
|
+
* @returns {{fixable:Array, skipped:Array, host:string, hostSource:string, file:string}}
|
|
52
|
+
*/
|
|
53
|
+
export function planFixes({ envCheck, patchFile, listenHost = '' } = {}) {
|
|
54
|
+
const picked = resolveListenHost({ listenHost })
|
|
55
|
+
const fixable = []
|
|
56
|
+
const skipped = []
|
|
57
|
+
|
|
58
|
+
const items = Array.isArray(envCheck?.items) ? envCheck.items : []
|
|
59
|
+
for (const it of items) {
|
|
60
|
+
if (!it || it.ok === true) continue
|
|
61
|
+
const id = typeof it.id === 'string' ? it.id : ''
|
|
62
|
+
|
|
63
|
+
if (id === 'reachability') {
|
|
64
|
+
// 这是**唯一**能机械修的:把 webserver.host 改成具体 IP
|
|
65
|
+
fixable.push({
|
|
66
|
+
id: 'reachability',
|
|
67
|
+
label: it.label,
|
|
68
|
+
// 说明将要做什么(面板必须展示,用户要知道自己在同意什么)
|
|
69
|
+
change: `把 webserver 的 host 改成 ${picked.host}(${describeSource(picked.source)})`,
|
|
70
|
+
// 明确的"不会做什么"—— 比承诺做了什么更重要
|
|
71
|
+
note: '只改 host 这一个字段,不动 port、不动你的其它配置条目',
|
|
72
|
+
})
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (id === 'role') {
|
|
77
|
+
fixable.push({
|
|
78
|
+
id: 'role',
|
|
79
|
+
label: it.label,
|
|
80
|
+
change: '在 remote-agent 的 config 里补上 role',
|
|
81
|
+
note: '按当前推断结果写入(没配 hubUrl 就是 hub)',
|
|
82
|
+
})
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 其它项(端口占用 / 防火墙 / 模型 / Tailscale / roots 不存在)都需要人判断
|
|
87
|
+
skipped.push({
|
|
88
|
+
id,
|
|
89
|
+
label: it.label,
|
|
90
|
+
reason: skippedReason(id),
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
fixable,
|
|
96
|
+
skipped,
|
|
97
|
+
host: picked.host,
|
|
98
|
+
hostSource: picked.source,
|
|
99
|
+
file: typeof patchFile === 'string' ? patchFile : '',
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 为什么这项不能自动修(如实说明,不含糊)。 */
|
|
104
|
+
function skippedReason(id) {
|
|
105
|
+
switch (id) {
|
|
106
|
+
case 'port':
|
|
107
|
+
return '端口被别的程序占用 —— 需要你决定换哪个端口,插件不能替你选'
|
|
108
|
+
case 'firewall':
|
|
109
|
+
return '防火墙规则需要管理员权限,且要你确认放行范围'
|
|
110
|
+
case 'tailscale':
|
|
111
|
+
return '装软件需要你同意(本插件不代下载第三方软件)'
|
|
112
|
+
case 'nodes':
|
|
113
|
+
return '副机连不上是**结果**不是原因 —— 先修上面那项「Web 服务绑在可达地址」'
|
|
114
|
+
case 'inject':
|
|
115
|
+
return 'inject 要跟着 role 一起改,且会影响插件能否加载 —— 请用面板「切换角色」,它会一次改对'
|
|
116
|
+
case 'roots':
|
|
117
|
+
return 'roots 目录不存在 —— 建目录要你确认路径(面板写入配置时会自动建)'
|
|
118
|
+
default:
|
|
119
|
+
return '这项需要你判断'
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function describeSource(src) {
|
|
124
|
+
switch (src) {
|
|
125
|
+
case 'tailscale':
|
|
126
|
+
return 'Tailscale IP,固定不变、跨网可达'
|
|
127
|
+
case 'private':
|
|
128
|
+
return '局域网 IP'
|
|
129
|
+
case 'config':
|
|
130
|
+
return '你在配置里指定的地址'
|
|
131
|
+
default:
|
|
132
|
+
return '本机回环(仅同机可用)'
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 应用修复计划(**会改文件**)。
|
|
138
|
+
*
|
|
139
|
+
* ⚠ 调用方必须已经拿到用户的明确确认(`confirm === 'yes'`)。
|
|
140
|
+
* 本函数不检查确认 —— 那是路由层的职责(分层:本模块只做"改得安全",
|
|
141
|
+
* 不负责"该不该改")。
|
|
142
|
+
*
|
|
143
|
+
* @param {object} o
|
|
144
|
+
* @param {object} o.plan planFixes 的返回值
|
|
145
|
+
* @param {object} o.writePatchBlock 注入的写盘函数(便于测试替身)
|
|
146
|
+
* @param {(file:string)=>string} o.readFile 注入的读函数
|
|
147
|
+
* @param {string} o.role 要写入的 role(hub/worker/both)
|
|
148
|
+
* @returns {{ok:boolean, file:string, backup:string, applied:Array, error?:string}}
|
|
149
|
+
*/
|
|
150
|
+
export function applyFixPlan({ plan, writePatchBlock, readFile, role = 'hub' } = {}) {
|
|
151
|
+
const applied = []
|
|
152
|
+
if (!plan || typeof plan !== 'object') {
|
|
153
|
+
return { ok: false, file: '', backup: '', applied, error: '缺少修复计划' }
|
|
154
|
+
}
|
|
155
|
+
const fixable = Array.isArray(plan.fixable) ? plan.fixable : []
|
|
156
|
+
if (fixable.length === 0) {
|
|
157
|
+
return { ok: false, file: plan.file || '', backup: '', applied, error: '没有可自动修的项' }
|
|
158
|
+
}
|
|
159
|
+
if (typeof writePatchBlock !== 'function') {
|
|
160
|
+
return { ok: false, file: plan.file || '', backup: '', applied, error: '写盘函数不可用' }
|
|
161
|
+
}
|
|
162
|
+
if (typeof readFile !== 'function') {
|
|
163
|
+
return { ok: false, file: plan.file || '', backup: '', applied, error: '读文件函数不可用' }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── 组装托管块(逐行构造,绝不把用户输入拼进 YAML)──────────────────
|
|
167
|
+
//
|
|
168
|
+
// ⚠ 与 setup-routes 的写法一致:每一行都是字面量 + 经过校验的值,
|
|
169
|
+
// 不做字符串插值以外的任何拼接(防 YAML 注入)。
|
|
170
|
+
const lines = [
|
|
171
|
+
'- id: remote-agent',
|
|
172
|
+
' name: dsh-remote-agent',
|
|
173
|
+
]
|
|
174
|
+
const wantRole = fixable.some((f) => f.id === 'role')
|
|
175
|
+
const wantHost = fixable.some((f) => f.id === 'reachability')
|
|
176
|
+
|
|
177
|
+
// inject 必须跟着 role 走(hub 要 webServer;worker 要 agentLoop/sessions)
|
|
178
|
+
const inject = role === 'worker'
|
|
179
|
+
? ['tools', 'agentLoop', 'sessions']
|
|
180
|
+
: ['webServer', 'tools', 'systemPrompt']
|
|
181
|
+
lines.push(` inject: [ ${inject.map((s) => `'${s}'`).join(', ')} ]`)
|
|
182
|
+
lines.push(' config:')
|
|
183
|
+
// role 总是写(它决定插件怎么跑,缺了会走包内默认 auto)。
|
|
184
|
+
// 不是"只在 wantRole 时写" —— 缺 role 的配置本身就是坏状态。
|
|
185
|
+
lines.push(` role: ${role === 'worker' || role === 'both' ? role : 'hub'}`)
|
|
186
|
+
if (wantHost) {
|
|
187
|
+
// host 必须是具体 IP —— 绝不写 0.0.0.0(新版 DSH 会拒绝并崩溃)
|
|
188
|
+
const host = typeof plan.host === 'string' ? plan.host.trim() : ''
|
|
189
|
+
if (host !== '' && host !== '0.0.0.0') {
|
|
190
|
+
lines.push(` listenHost: '${host.replace(/'/g, "''")}'`)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (wantRole) applied.push('role')
|
|
194
|
+
if (wantHost) applied.push('reachability')
|
|
195
|
+
|
|
196
|
+
const blockYaml = `${lines.join('\n')}\n`
|
|
197
|
+
|
|
198
|
+
// ── 写盘(writePatchBlock 自带:备份 + 冲突检测 + 写后校验 + 失败回滚)──
|
|
199
|
+
let result
|
|
200
|
+
try {
|
|
201
|
+
result = writePatchBlock({ file: plan.file, blockYaml, force: false })
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return { ok: false, file: plan.file, backup: '', applied, error: String(err?.message ?? err) }
|
|
204
|
+
}
|
|
205
|
+
if (!result || result.ok !== true) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
file: (result && result.file) || plan.file,
|
|
209
|
+
backup: (result && result.backup) || '',
|
|
210
|
+
applied,
|
|
211
|
+
error: (result && result.error) || '写盘失败',
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── 写后校验:重新读回来,确认我们的条目在、没有重复 id ──────────────
|
|
216
|
+
//
|
|
217
|
+
// 为什么再查一遍:writePatchBlock 校验的是"托管块格式",而这里要确认的是
|
|
218
|
+
// "**从用户视角看,文件现在的状态是能用的**"——两者角度不同。
|
|
219
|
+
try {
|
|
220
|
+
const after = readFile(plan.file)
|
|
221
|
+
const ids = [...String(after).matchAll(/^- id:\s*(\S+)/gm)].map((m) => m[1])
|
|
222
|
+
const dup = ids.filter((x, i) => ids.indexOf(x) !== i)
|
|
223
|
+
if (dup.length > 0) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
file: plan.file,
|
|
227
|
+
backup: result.backup || '',
|
|
228
|
+
applied,
|
|
229
|
+
error: `写后发现重复 id:${dup.join(', ')} —— 已保留备份,请用「撤销」恢复`,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!ids.includes('remote-agent')) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
file: plan.file,
|
|
236
|
+
backup: result.backup || '',
|
|
237
|
+
applied,
|
|
238
|
+
error: '写后没找到 remote-agent 条目 —— 已保留备份,请用「撤销」恢复',
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
file: plan.file,
|
|
245
|
+
backup: result.backup || '',
|
|
246
|
+
applied,
|
|
247
|
+
error: `写后校验读文件失败:${String(err?.message ?? err)}`,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { ok: true, file: plan.file, backup: result.backup || '', applied, error: undefined }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 撤销上一次修复(用备份覆盖回去)。
|
|
256
|
+
*
|
|
257
|
+
* @param {object} o
|
|
258
|
+
* @param {string} o.file 目标文件
|
|
259
|
+
* @param {string} o.backup 备份文件路径
|
|
260
|
+
* @returns {{ok:boolean, error?:string}}
|
|
261
|
+
*/
|
|
262
|
+
export function undoFix({ file, backup } = {}) {
|
|
263
|
+
if (typeof file !== 'string' || file === '') return { ok: false, error: '缺少目标文件' }
|
|
264
|
+
if (typeof backup !== 'string' || backup === '') return { ok: false, error: '没有可用的备份' }
|
|
265
|
+
try {
|
|
266
|
+
if (!fs.existsSync(backup)) return { ok: false, error: `备份文件不存在:${backup}` }
|
|
267
|
+
const content = fs.readFileSync(backup, 'utf8')
|
|
268
|
+
// 先写临时文件再 rename(原子,避免中途崩溃留下半截文件)
|
|
269
|
+
const tmp = `${file}.undo-${process.pid}-${Date.now().toString(36)}`
|
|
270
|
+
fs.writeFileSync(tmp, content, 'utf8')
|
|
271
|
+
fs.renameSync(tmp, file)
|
|
272
|
+
return { ok: true }
|
|
273
|
+
} catch (err) {
|
|
274
|
+
return { ok: false, error: String(err?.message ?? err) }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 从 writePatchBlock 的备份命名里认出"这是我们写的备份"(用于列出可撤销项)。 */
|
|
279
|
+
export function isManagedBackup(fileName) {
|
|
280
|
+
return typeof fileName === 'string' && /^cordis\.patch\.yml\.bak-/.test(fileName)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 列出某个目录下最近的备份(给面板「撤销」用)。 */
|
|
284
|
+
export function listBackups(dir, limit = 5) {
|
|
285
|
+
try {
|
|
286
|
+
return fs
|
|
287
|
+
.readdirSync(dir)
|
|
288
|
+
.filter(isManagedBackup)
|
|
289
|
+
.map((name) => {
|
|
290
|
+
const full = path.join(dir, name)
|
|
291
|
+
try {
|
|
292
|
+
const st = fs.statSync(full)
|
|
293
|
+
return { name, path: full, at: st.mtimeMs, bytes: st.size }
|
|
294
|
+
} catch {
|
|
295
|
+
return null
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
.filter(Boolean)
|
|
299
|
+
.sort((a, b) => b.at - a.at)
|
|
300
|
+
.slice(0, limit)
|
|
301
|
+
} catch {
|
|
302
|
+
return []
|
|
303
|
+
}
|
|
304
|
+
}
|