dsh-remote-agent 0.0.0-stage → 0.4.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.
- package/CHANGELOG.md +700 -0
- package/CONTRIBUTING.md +358 -0
- package/KNOWN-ISSUES.md +601 -0
- package/LICENSE +21 -0
- package/README.en.md +353 -0
- package/README.md +317 -2
- package/SECURITY.md +348 -0
- package/bin/dsh-remote-relay.js +257 -0
- package/bin/dsh-remote-setup.js +440 -0
- package/bin//345/217/214/345/207/273/350/277/220/350/241/214-/351/205/215/347/275/256/345/220/221/345/257/274.cmd +28 -0
- package/cordis.patch.yml +87 -0
- package/deploy/relay-vps/Caddyfile +24 -0
- package/deploy/relay-vps/README.md +132 -0
- package/deploy/relay-vps/dsh-remote-relay.service +47 -0
- package/deploy/relay-vps/nginx.conf +49 -0
- package/deploy/relay-vps/relay.env.example +20 -0
- package/docs/dispatch-automation-eval.md +140 -0
- package/docs/screenshots/panel-main.png +0 -0
- package/docs/screenshots/panel-task-detail.png +0 -0
- package/docs/verification-agent-e2e.md +425 -0
- package/docs/verification-repos-routing-real.md +521 -0
- package/docs/verification-resume-real.md +254 -0
- package/docs/worker-setup-pitfalls.md +266 -0
- package/lib/artifacts.js +451 -0
- package/lib/client.js +1752 -0
- package/lib/e2e.js +304 -0
- package/lib/glob.js +225 -0
- package/lib/hub/audit-log.js +161 -0
- package/lib/hub/hub-util.js +114 -0
- package/lib/hub/index.js +500 -0
- package/lib/hub/node-registry.js +943 -0
- package/lib/hub/pull-manager.js +781 -0
- package/lib/hub/registry-handshake.js +403 -0
- package/lib/hub/registry-ingest.js +346 -0
- package/lib/hub/relay-link.js +228 -0
- package/lib/hub/subagent-provider.js +424 -0
- package/lib/hub/templates.js +693 -0
- package/lib/hub/tools.js +1059 -0
- package/lib/hub/ui-routes.js +856 -0
- package/lib/hub/views.js +351 -0
- package/lib/identity.js +271 -0
- package/lib/index.js +272 -0
- package/lib/node/artifact-service.js +231 -0
- package/lib/node/diff.js +124 -0
- package/lib/node/exec.js +536 -0
- package/lib/node/hub-pin.js +71 -0
- package/lib/node/index.js +1022 -0
- package/lib/node/repos.js +134 -0
- package/lib/node/resource.js +63 -0
- package/lib/node/runner.js +792 -0
- package/lib/node/task-engine.js +712 -0
- package/lib/node/task-store.js +273 -0
- package/lib/node/transport.js +763 -0
- package/lib/pairing.js +186 -0
- package/lib/protocol.js +414 -0
- package/lib/relay-config.js +317 -0
- package/lib/relay.js +732 -0
- package/lib/setup-routes.js +313 -0
- package/lib/setup.js +522 -0
- package/lib/ws.js +466 -0
- package/package.json +103 -4
- package/profiles/README.md +573 -0
- package/profiles/hub.cordis.yml +84 -0
- package/profiles/worker.cordis.yml +60 -0
- package/scripts/check-doc-drift.mjs +785 -0
- package/scripts/check-packaging.mjs +130 -0
- package/scripts/diag-inject.mjs +243 -0
- package/scripts/e2e-live-check.mjs +181 -0
- package/scripts/relay-init.ps1 +286 -0
- package/scripts/relay-init.sh +199 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
# 真机验证:仓库感知调度 / 负载路由 / 审计日志(2026-10-16)
|
|
2
|
+
|
|
3
|
+
> 验证对象:`#13 仓库感知调度`(`8e1df74`)、`#14 负载感知自动路由`(`86269a2`)、
|
|
4
|
+
> `#19 hub 审计日志`(`966bed1`)。仓库 `HEAD` = `8e1df74655af1d0aeffd48233f3c8c4bc1329717`。
|
|
5
|
+
> 部署与验证指引见 [profiles/README.md](../profiles/README.md)、[SECURITY.md](../SECURITY.md)。
|
|
6
|
+
|
|
7
|
+
## 结论速览
|
|
8
|
+
|
|
9
|
+
| # | 待验证项 | 结果 |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| 13 | worker 接入后 ~10s 内出现 `node repos updated` | ✅ 实测 **54ms** |
|
|
12
|
+
| 13 | `remote_nodes` 输出里能看到 repos 名单 | ✅ 有 `repos` 字段 + 渲染行 |
|
|
13
|
+
| 13 | 凭证剥离:url 里不含 `secrettoken123` | ✅ 未泄漏(但见问题 3 的 scp 边界) |
|
|
14
|
+
| 13 | `repo:` 路由 + workspace 自动兜底 | ✅ 命中,workspace = 仓库本机路径 |
|
|
15
|
+
| 13 | 不存在的仓库名报错列出已知名单 | ✅ 列出了 `dsh-remote-agent、fixture-cred` |
|
|
16
|
+
| 13 | 周期刷新(新 clone 仓库进清单) | ✅ 实测 **4298ms**(interval=5000ms) |
|
|
17
|
+
| 14 | 两台空闲按 nodeId 字典序 | ✅ `node-a` |
|
|
18
|
+
| 14 | 一台忙时第二单落到另一台 | ✅ 落到 `node-b` |
|
|
19
|
+
| 14 | `agentMode:false` 让 agent 任务只选支持的那台 | ⚠️ **过滤逻辑正确,但 `agentMode` 无法通过 config 关闭(硬编码 `true`)** |
|
|
20
|
+
| 19 | `audit.jsonl` 真实生成 | ✅ 生成;**真实 hub 已有 496 行** |
|
|
21
|
+
| 19 | `task.dispatch` / `task.cancel` / `pull.end` 都在 | ✅ 四类事件齐全(含 `pull.start`) |
|
|
22
|
+
| 19 | goal 里的 token 形态字符串被打码成 `***` | ❌ **未打码**(见问题 1) |
|
|
23
|
+
|
|
24
|
+
**一句话**:三个特性的**主链路全部真实跑通**(仓库扫描/凭证剥离/`repo:` 路由/负载避让/审计四事件),
|
|
25
|
+
但发现 **3 个真问题**:审计打码只覆盖**键名**不覆盖**值**(任务书里的 token 原样落盘)、
|
|
26
|
+
`agentMode` 硬编码导致 `#14` 的"能力过滤"在实际部署里**永远不起作用**、
|
|
27
|
+
以及**测试把审计事件写进了用户真实 stateDir**。
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 1. 环境与拓扑
|
|
32
|
+
|
|
33
|
+
| 项 | 值 |
|
|
34
|
+
|---|---|
|
|
35
|
+
| 主机 | Windows,`DESKTOP-OBOJM0B` |
|
|
36
|
+
| Node | v24.18.0 |
|
|
37
|
+
| git | 2.54.0.windows.1 |
|
|
38
|
+
| 代码快照 | `git archive HEAD`(`8e1df74`)解到 `D:\verify-lab\repo`,与工作区隔离 |
|
|
39
|
+
| hub | `applyHub()` —— `NodeRegistry` + `PullManager` + 7 工具 + UI 路由 |
|
|
40
|
+
| worker | `applyWorker()` —— `TaskStore` + `ArtifactService` + `TaskEngine` + `NodeTransport` |
|
|
41
|
+
| 传输 | 真 HTTP server + 真 WebSocket upgrade 握手(`ws://127.0.0.1:<随机端口>/api/remote-agent/node`) |
|
|
42
|
+
|
|
43
|
+
装配全是**真实实现,无替身**。所有验证脚本在 `D:\verify-lab\` 下,可重跑。
|
|
44
|
+
|
|
45
|
+
### 1.1 造的两个仓库 fixture
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
D:\verify-lab\ws\
|
|
49
|
+
├── dsh-remote-agent\ ← git clone 本仓库一份(真实仓库)
|
|
50
|
+
├── fixture-cred\ ← 故意把 origin url 写成内嵌凭证
|
|
51
|
+
└── late-clone\ ← 第 5 步"周期刷新"时运行中新 clone 的
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`fixture-cred` 的 `.git/config` 原文(测试前提):
|
|
55
|
+
|
|
56
|
+
```ini
|
|
57
|
+
[remote "origin"]
|
|
58
|
+
url = https://testuser:secrettoken123@corp.example/fake.git
|
|
59
|
+
fetch = +refs/heads/*:refs/remotes/origin/*
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
已确认 `secrettoken123` 真的写进了磁盘 —— 否则"凭证剥离"这条验证没有意义。
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 2. #13 仓库感知调度
|
|
67
|
+
|
|
68
|
+
### ① 扫描与凭证剥离(单元级,最直接的证据)
|
|
69
|
+
|
|
70
|
+
直接调 `scanRepos(['D:\verify-lab\ws'])`:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
[
|
|
74
|
+
{ "name": "dsh-remote-agent",
|
|
75
|
+
"url": "D:\\\\Deepseek Harness file\\\\dsh-remote-agent",
|
|
76
|
+
"path": "D:\\verify-lab\\ws\\dsh-remote-agent" },
|
|
77
|
+
{ "name": "fixture-cred",
|
|
78
|
+
"url": "https://corp.example/fake.git",
|
|
79
|
+
"path": "D:\\verify-lab\\ws\\fixture-cred" }
|
|
80
|
+
]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**fixture 的 url 从 `https://testuser:secrettoken123@corp.example/fake.git`
|
|
84
|
+
变成了 `https://corp.example/fake.git`** —— user 与 password 都被剥掉。
|
|
85
|
+
|
|
86
|
+
`sanitizeRepoUrl` 逐例:
|
|
87
|
+
|
|
88
|
+
| 输入 | 输出 | 判定 |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `https://testuser:secrettoken123@corp.example/fake.git` | `https://corp.example/fake.git` | ✅ 剥掉 |
|
|
91
|
+
| `https://user:tok@github.com/a/b.git` | `https://github.com/a/b.git` | ✅ 剥掉 |
|
|
92
|
+
| `https://github.com/a/b.git` | 原样 | ✅ |
|
|
93
|
+
| `git@github.com:a/b.git` | 原样 | ✅(scp 形态,见问题 3) |
|
|
94
|
+
| `D:\Deepseek Harness file\dsh-remote-agent` | 原样 | ✅ 本地路径 |
|
|
95
|
+
|
|
96
|
+
### ② hub 侧 `node repos updated` + `remote_nodes`
|
|
97
|
+
|
|
98
|
+
worker 上线后,hub 日志**实测 54ms** 出现(远小于 10s):
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
[info] [remote-agent] node repos updated {"nodeId":"repos-worker","count":2}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
> ⚠ 复现时注意:hub 日志消息带 `[remote-agent] ` 前缀,用精确相等匹配
|
|
105
|
+
> (`m === 'node repos updated'`)会**永远匹配不上**,必须用 `endsWith`。
|
|
106
|
+
> 这是本次踩到的第一个坑,记录在此以免下一个人重复。
|
|
107
|
+
|
|
108
|
+
`remote_nodes` 工具输出(JSON):
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
[{ "nodeId": "repos-worker", "online": true,
|
|
112
|
+
"repos": ["dsh-remote-agent", "fixture-cred"],
|
|
113
|
+
"reposAt": 1792152138357 }]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
渲染文本(模型实际看到的样子):
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
远程节点(1/1 在线):
|
|
120
|
+
|
|
121
|
+
- repos-worker [online] windows/x64 host=DESKTOP-OBOJM0B agentMode=true tasks=0
|
|
122
|
+
roots: D:\verify-lab\ws
|
|
123
|
+
caps: fs, exec, artifact, agent
|
|
124
|
+
repos: dsh-remote-agent, fixture-cred(remote_task 可用 repo:"名字" 按仓库路由)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### ③ 凭证剥离(hub 侧实测)
|
|
128
|
+
|
|
129
|
+
hub 进程内 `rec.repos` 的完整序列化:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
hub 侧 fixture 条目: {"name":"fixture-cred","url":"https://corp.example/fake.git","path":"D:\\verify-lab\\ws\\fixture-cred"}
|
|
133
|
+
hub 侧全部 repos 序列化后含 secrettoken123 ? false
|
|
134
|
+
hub 侧全部 repos 序列化后含 testuser ? false
|
|
135
|
+
→ ✓ 通过:凭证未离开副机
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**这是安全承诺的实测确认**:hub 侧从传输到投影到工具输出,`secrettoken123`
|
|
139
|
+
与 `testuser` 都不出现。
|
|
140
|
+
|
|
141
|
+
### ④ `repo:` 路由
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
4a) repo:"fixture-cred" 省略 workspace
|
|
145
|
+
node=repos-worker accepted=true workspace=D:\verify-lab\ws\fixture-cred
|
|
146
|
+
期望 workspace = D:\verify-lab\ws\fixture-cred
|
|
147
|
+
→ ✓ workspace 自动兜到仓库本机路径
|
|
148
|
+
|
|
149
|
+
4b) repo:"corp.example/fake"(url 片段)
|
|
150
|
+
→ node=repos-worker workspace=D:\verify-lab\ws\fixture-cred
|
|
151
|
+
|
|
152
|
+
4c) repo:"does-not-exist-xyz"
|
|
153
|
+
→ ✓ 报错: 没有在线节点持有仓库 "does-not-exist-xyz";当前已上报的仓库:dsh-remote-agent、fixture-cred
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
三种写法(仓库名 / url 片段 / 路径片段)都收,与 `pickNodeByRepo` 的注释一致。
|
|
157
|
+
**不存在的仓库名确实列出了已上报名单** —— 任务要求这一条满足。
|
|
158
|
+
|
|
159
|
+
补充验证(多节点 + 匹配优先级 + 离线过滤):
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
两节点都空闲 → alpha
|
|
163
|
+
alpha 忙 1 条 → beta ← 负载感知生效
|
|
164
|
+
beta 离线后 → alpha ← 离线节点不参与
|
|
165
|
+
两台都离线 → null
|
|
166
|
+
查 "my-app"(n1 有 my-app-extra,n2 有 my-app) → n2 ← 精确匹配优先于包含
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### ⑤ 周期刷新
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
✓ 已 clone 新仓库到 D:\verify-lab\ws\late-clone
|
|
173
|
+
扫描前 hub 侧仓库: ["dsh-remote-agent","fixture-cred"]
|
|
174
|
+
✓ 新仓库已出现在 hub 清单(耗时 4298ms,interval=5000ms)
|
|
175
|
+
扫描后 hub 侧仓库: ["dsh-remote-agent","fixture-cred","late-clone"]
|
|
176
|
+
hub 侧 "node repos updated" 日志条数: 2
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**周期刷新已验证**(任务书说第 5 步不方便可跳过 —— 实际跑通了)。
|
|
180
|
+
`reposIntervalMs: 5000` 生效,4298ms 后新仓库进清单。
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## 3. #14 负载感知自动路由
|
|
185
|
+
|
|
186
|
+
### ① 两台空闲 → nodeId 字典序
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
✓ 两个 worker 上线: node-a, node-b
|
|
190
|
+
第一条 → node=node-a ✓ 命中字典序
|
|
191
|
+
第二条 → node=node-a ✓ 一致
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### ② 一台忙 → 第二单落到另一台
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
长任务 → node=node-a taskId=t-mvax4eogclaj
|
|
198
|
+
此刻在跑任务数: node-a=2 node-b=0
|
|
199
|
+
第二单(不指定 node)→ node=node-b ✓ 正确避开了忙碌的 node-a
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
(`node-a` 显示 2 条是因为它自己那条长任务 + 上一步的收尾任务尚未翻终态;
|
|
203
|
+
关键是 `node-b=0` 时新任务**确实落到了 node-b**。)
|
|
204
|
+
|
|
205
|
+
agent 模式下同样成立:
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
① agent 模式 + node-a 忙 → node=node-b accepted=true runner=headless
|
|
209
|
+
→ ✓ agent 任务避开了忙碌的 node-a,落到 node-b
|
|
210
|
+
② 两台都空闲时 agent 模式 → node=node-a (期望 node-a) → ✓ 字典序
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### ③ `agentMode:false` —— 过滤逻辑对,但配置关不掉
|
|
214
|
+
|
|
215
|
+
任务书要求"把一台配置成不支持 agent(`agentMode:false`),确认 agent 任务只选支持的那台"。
|
|
216
|
+
|
|
217
|
+
**实测:`agentMode` 无法通过 config 关闭。**
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
node-a hello.agentMode = true
|
|
221
|
+
node-b hello.agentMode = true
|
|
222
|
+
尝试:重启 node-b 并传 agentMode:false
|
|
223
|
+
重启后 node-b hello.agentMode = true (期望 false)
|
|
224
|
+
✗ agentMode 无法通过 config 关闭(硬编码 true)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
根因(代码级):
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
lib/node/index.js:324: agentMode: true, ← buildNodeHello 里硬编码
|
|
231
|
+
lib/node/index.js:877: agentMode: true, ← statusSnapshot 里也硬编码
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
全仓 grep 确认:`agentMode` 在 `lib/` 下**只有这两处赋值,都是字面量 `true`**,
|
|
235
|
+
没有任何配置读取。`profiles/worker.cordis.yml` 也没有这个字段。
|
|
236
|
+
|
|
237
|
+
**过滤逻辑本身是正确的** —— 手工构造节点直接验证:
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
=== 场景:node-a(agentMode=true) / node-b(agentMode=false) ===
|
|
241
|
+
exec 模式自动选 → node-a (exec 无能力要求)
|
|
242
|
+
agent 模式自动选 → node-a (只有它支持 agent)
|
|
243
|
+
|
|
244
|
+
=== 场景:node-a(false) / node-b(true) ===
|
|
245
|
+
agent 模式自动选 → node-b
|
|
246
|
+
|
|
247
|
+
=== 场景:两台都不支持 agent ===
|
|
248
|
+
agent 模式自动选 → null (期望 null)
|
|
249
|
+
exec 模式自动选 → node-a (exec 无要求,仍能选)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
⇒ **`pickAutoNode` 的能力过滤实现无误,但因为 `agentMode` 恒为 `true`,
|
|
253
|
+
这段过滤在真实部署里永远走不到"排除"分支**。`CHANGELOG.md` 写的
|
|
254
|
+
"`mode:"agent"` 只考虑支持 agent 后端(agentMode)的节点"目前是**空承诺**。
|
|
255
|
+
|
|
256
|
+
> 注意:真实场景下"副机支不支持 agent"其实由 `runner.kind` 决定
|
|
257
|
+
> (`in-process` vs `headless`),而 `hello.agentMode` 与之无关联。
|
|
258
|
+
> 本次实验里副机 `runner=headless`(无 DSH 上下文,走 headless 回退),
|
|
259
|
+
> 但 `agentMode` 仍报 `true`。所以这个字段目前**既不可配、也不反映真实能力**。
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## 4. #19 hub 审计日志
|
|
264
|
+
|
|
265
|
+
### ① 文件生成(惰性创建)
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
hub 日志: {"file":"D:\\verify-lab\\run\\hub-state\\audit.jsonl","enabled":true}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
注意:`createAuditLog` **不在启动时创建文件**,而是首次 `record()` 才落盘
|
|
272
|
+
(已单独确认:创建后 `audit.jsonl 存在? false`,`record + dispose` 后才 `true`)。
|
|
273
|
+
所以"hub 起来但没派过任务"时看不到文件是**正常**的,不是 bug。
|
|
274
|
+
|
|
275
|
+
**真实生产 hub 也已在写**:
|
|
276
|
+
|
|
277
|
+
```
|
|
278
|
+
C:\Users\MR\.dsh-remote-agent\audit.jsonl 127054 bytes 496 行
|
|
279
|
+
事件分布: task.dispatch 240 / pull.end 112 / pull.start 112 / task.cancel 32
|
|
280
|
+
首行: {"ts":"2026-10-16T11:08:57.129Z","event":"task.dispatch",...,"nodeId":"test-worker",...}
|
|
281
|
+
末行: {"ts":"2026-10-16T11:52:04.723Z","event":"pull.end",...,"relPath":"f.txt","ok":true,"bytes":6}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### ② 四类事件齐全
|
|
285
|
+
|
|
286
|
+
派发 + 取消 + 拉取一轮后的完整文件(6 行):
|
|
287
|
+
|
|
288
|
+
```json
|
|
289
|
+
{"ts":"...","event":"task.dispatch","taskId":"t-mvax4s9p106t4","nodeId":"audit-worker","mode":"exec","workspace":"","goal":"echo hi; # api_key=sk-abc123","outputsCount":1,"timeoutMs":3600000}
|
|
290
|
+
{"ts":"...","event":"task.dispatch","taskId":"t-mvax4salp7o9",...,"goal":"Set-Content -LiteralPath 'D:\\verify-lab\\run\\ws-a\\out.txt' ..."}
|
|
291
|
+
{"ts":"...","event":"task.dispatch","taskId":"t-mvax4sl5pivi",...,"goal":"Start-Sleep -Seconds 30; echo long","outputsCount":0,"timeoutMs":3600000}
|
|
292
|
+
{"ts":"...","event":"task.cancel","taskId":"t-mvax4sl5pivi","nodeId":"audit-worker","delivered":true}
|
|
293
|
+
{"ts":"...","event":"pull.start","taskId":"t-mvax4salp7o9","nodeId":"audit-worker","relPath":"out.txt","dest":"D:\\verify-lab\\run\\dest"}
|
|
294
|
+
{"ts":"...","event":"pull.end","taskId":"t-mvax4salp7o9","nodeId":"audit-worker","relPath":"out.txt","ok":true,"bytes":16}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
```
|
|
298
|
+
- task.dispatch: 3 次 ✓
|
|
299
|
+
- task.cancel: 1 次 ✓
|
|
300
|
+
- pull.start: 1 次 ✓
|
|
301
|
+
- pull.end: 1 次 ✓
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**任务要求的三类事件(`task.dispatch` / `task.cancel` / `pull.end`)都在**,
|
|
305
|
+
外加 `pull.start`。
|
|
306
|
+
|
|
307
|
+
### ③ 打码 —— 这里发现真问题
|
|
308
|
+
|
|
309
|
+
任务书要求:"故意在任务 goal 里放一个长得像 token 的字符串(如 `api_key=sk-abc123`),
|
|
310
|
+
确认落盘内容被打码成 `***`。"
|
|
311
|
+
|
|
312
|
+
**实测:未打码。**
|
|
313
|
+
|
|
314
|
+
```
|
|
315
|
+
goal = "echo hi; # api_key=sk-abc123"
|
|
316
|
+
|
|
317
|
+
落盘内容含 "sk-abc123" ? true
|
|
318
|
+
落盘内容含 "api_key=sk-abc123" ? true
|
|
319
|
+
落盘内容含 "***" ? false
|
|
320
|
+
|
|
321
|
+
命中的 task.dispatch 行 goal 字段原文:
|
|
322
|
+
"echo hi; # api_key=sk-abc123"
|
|
323
|
+
→ ✗ 未打码:token 形态字符串原样落盘
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
根因(代码级):`lib/hub/hub-util.js:10` 的打码正则只用来匹配**键名**:
|
|
327
|
+
|
|
328
|
+
```js
|
|
329
|
+
export const REDACT_KEY_RE = /(token|secret|password|passwd|credential|apikey|api_key|authorization)/i
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
而 `sanitizeExtra`(`hub-util.js:43`)的用法是:
|
|
333
|
+
|
|
334
|
+
```js
|
|
335
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
336
|
+
if (REDACT_KEY_RE.test(k)) { out[k] = '***'; continue } // ← 只看 k(键名)
|
|
337
|
+
...
|
|
338
|
+
if (typeof v === 'string') out[k] = clip(v) // ← 值只做长度截断
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
逐例确认口径:
|
|
342
|
+
|
|
343
|
+
| 输入 | 输出 | 说明 |
|
|
344
|
+
|---|---|---|
|
|
345
|
+
| `{ token: 'sk-abc123' }` | `{"token":"***"}` | ✅ 键名命中 → 打码 |
|
|
346
|
+
| `{ api_key: 'sk-abc123' }` | `{"api_key":"***"}` | ✅ |
|
|
347
|
+
| `{ goal: 'echo hi; # api_key=sk-abc123' }` | 原样 | ❌ 值里有凭证也不打码 |
|
|
348
|
+
| `{ workspace: 'D:\\x\\sk-abc123-token' }` | 原样 | ❌ |
|
|
349
|
+
| `{ nodeId: 'sk-abc123' }` | 原样 | ❌ |
|
|
350
|
+
|
|
351
|
+
**影响**:`task.dispatch` 的 `goal` 字段就是**任务书原文**(`node-registry.js:485`
|
|
352
|
+
传的是 `spec.goal`)。用户在任务书里写 `curl -H "Authorization: Bearer sk-..."`
|
|
353
|
+
或 `mysql -p<password>` 这类命令是**极常见的**,这些凭证会以明文写进
|
|
354
|
+
`audit.jsonl` 并**长期留存**(8MB × 3 份轮转)。
|
|
355
|
+
|
|
356
|
+
对照文档承诺:
|
|
357
|
+
|
|
358
|
+
- `SECURITY.md:188-189`:*"字段过统一打码层:token/password/apiKey 类键值写 `***`…
|
|
359
|
+
——凭证与整段任务书**绝不整段落盘**"*
|
|
360
|
+
- `CHANGELOG.md:45`:*"字段统一打码截断(token 类键值写 `***`、超长 400 字符截断)"*
|
|
361
|
+
|
|
362
|
+
严格读,"token 类**键值**"可以解释成"键名匹配的才打码",所以实现**不算违背字面承诺**;
|
|
363
|
+
但"凭证绝不整段落盘"这句**在语义上被打破**了 —— goal 里的凭证确实整段落盘了。
|
|
364
|
+
建议在文档里明确"打码只按**键名**,任务书正文不做凭证扫描",或补一层值级扫描
|
|
365
|
+
(`sk-[A-Za-z0-9]{16,}`、`Bearer <token>`、`://user:pass@` 之类)。
|
|
366
|
+
|
|
367
|
+
### ④ 轮转与故障隔离(补充验证)
|
|
368
|
+
|
|
369
|
+
```
|
|
370
|
+
=== ① 轮转(maxBytes=400, maxFiles=3) ===
|
|
371
|
+
目录内容: audit.jsonl(284B), audit.jsonl.1(426B), audit.jsonl.2(426B)
|
|
372
|
+
→ 最多 3 份,符合 maxFiles=3 ✓
|
|
373
|
+
|
|
374
|
+
=== ② pull.end 失败路径 ===
|
|
375
|
+
审计事件序列:
|
|
376
|
+
pull.start {taskId:"tf",nodeId:"n1",relPath:"a.bin",dest:"..."}
|
|
377
|
+
pull.end {taskId:"tf",nodeId:"n1",relPath:"a.bin",ok:false,error:"internal"}
|
|
378
|
+
→ ✓ 失败路径也记(ok=false + error 码)
|
|
379
|
+
|
|
380
|
+
=== ③ 审计写入器抛错时任务链路是否受影响 ===
|
|
381
|
+
→ pull() 未因审计抛错而崩 ✓
|
|
382
|
+
→ 审计 record 抛错时 pull 仍成功: ✓ ← "绝不影响任务链路"成立
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### ⑤ 测试污染用户真实 stateDir(真问题)
|
|
386
|
+
|
|
387
|
+
验证过程中发现**真实 hub 的 `audit.jsonl` 里混进了测试数据**:
|
|
388
|
+
|
|
389
|
+
```
|
|
390
|
+
audit 里出现过的 nodeId: ui-worker: 272 test-worker: 224
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
这两个 nodeId 来自 `test/hub-worker-e2e.test.js`(`nodeId: 'test-worker'`)与
|
|
394
|
+
`test/ui-routes.test.js`(`ui-worker`)—— **不是真实副机**。
|
|
395
|
+
|
|
396
|
+
根因:这两个测试建 hubConfig 时**没有传 `stateDir`**:
|
|
397
|
+
|
|
398
|
+
```
|
|
399
|
+
test/hub-worker-e2e.test.js:239: const hubConfig = { role: 'hub', systemPrompt: false }
|
|
400
|
+
test/ui-routes.test.js:274: const hubConfig = { role: 'hub', systemPrompt: false, relay: {...} }
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
于是 `normalizeConfig` 落到 `defaultHubStateDir()`:
|
|
404
|
+
|
|
405
|
+
```js
|
|
406
|
+
function defaultHubStateDir() {
|
|
407
|
+
return path.join(os.homedir(), '.dsh-remote-agent') // ← 用户真实状态目录
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
**实测复现**:
|
|
412
|
+
|
|
413
|
+
```
|
|
414
|
+
测试前行数: 496
|
|
415
|
+
$ node --test test/hub-worker-e2e.test.js
|
|
416
|
+
测试后行数: 510
|
|
417
|
+
新增: 14 行
|
|
418
|
+
=== 新增的行 ===
|
|
419
|
+
{"ts":"...","event":"task.dispatch","taskId":"t-mvax5mw45yt5","nodeId":"test-worker","mode":"exec","workspace":"C:\\Users\\MR\\AppData\\Local\\Temp\\dsh-hubworker-e8JBSw\\ws","goal":"Set-Content -LiteralPath '报告.txt' ..."}
|
|
420
|
+
...
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
**跑一次测试就往用户真实状态目录写 14 行审计事件。** 影响:
|
|
424
|
+
|
|
425
|
+
1. 污染真实审计证据链(`test-worker` / `ui-worker` 这类条目会被误认为真实活动);
|
|
426
|
+
2. 测试间不隔离(并行跑测试会交错写同一文件);
|
|
427
|
+
3. 长期跑 CI/本地测试会不断撑大该文件,触发轮转、**把真实审计记录挤掉**。
|
|
428
|
+
|
|
429
|
+
**修法建议**(不在此处实施):给这两处 hubConfig 加
|
|
430
|
+
`stateDir: path.join(root, 'hub-state')`,与 `workerConfig.stateDir` 一样落在
|
|
431
|
+
临时目录里。另建议加一条测试断言"applyHub 未传 stateDir 时审计落在临时目录",
|
|
432
|
+
或在测试环境(`NODE_TEST_CONTEXT`)下强制审计默认关闭。
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## 5. 发现的问题汇总
|
|
437
|
+
|
|
438
|
+
| # | 严重度 | 问题 | 位置 | 复现 |
|
|
439
|
+
|---|---|---|---|---|
|
|
440
|
+
| 1 | **中** | 审计打码只按**键名**,任务书正文里的 token 明文落盘 | `lib/hub/hub-util.js:10,43` | `node task3-audit.mjs`(见 §4③) |
|
|
441
|
+
| 2 | **中** | `agentMode` 硬编码 `true`,`#14` 的能力过滤在真实部署里永不生效 | `lib/node/index.js:324,877` | `node task2-load.mjs`(见 §3③) |
|
|
442
|
+
| 3 | **中** | 测试未传 `stateDir` → 审计事件写进用户真实 `~/.dsh-remote-agent/` | `test/hub-worker-e2e.test.js:239`、`test/ui-routes.test.js:274` | `node --test test/hub-worker-e2e.test.js` 后对比行数(见 §4⑤) |
|
|
443
|
+
| 4 | 低 | scp 形态 url 的用户名位置放 token 不会被剥(`token123@github.com:...` 原样上报) | `lib/node/repos.js:38-48` | `node task1b-routing.mjs`(见下) |
|
|
444
|
+
| 5 | 低 | hub 日志消息带 `[remote-agent] ` 前缀,文档/脚本若用精确匹配会静默失效 | 测试/脚本侧 | 见 §2② |
|
|
445
|
+
|
|
446
|
+
### 问题 4 的实测证据
|
|
447
|
+
|
|
448
|
+
`sanitizeRepoUrl` 只在 `new URL()` **解析成功**时才剥 userinfo。scp 形态
|
|
449
|
+
(`user@host:path`,非标准 URL)走 `catch` 分支原样返回:
|
|
450
|
+
|
|
451
|
+
```
|
|
452
|
+
"git@github.com:a/b.git" -> "git@github.com:a/b.git" ✅ 公开用户名,无害
|
|
453
|
+
"token123@github.com:a/b.git" -> "token123@github.com:a/b.git" ⚠️ 用户名位置放 token → 不剥
|
|
454
|
+
"https://oauth2:glpat-XXXX@gitlab.com/a/b.git" -> "https://gitlab.com/a/b.git" ✅ 剥掉
|
|
455
|
+
"ssh://git@github.com/a/b.git" -> "ssh://github.com/a/b.git" ✅ 剥掉
|
|
456
|
+
"https://user@github.com/a/b.git" -> "https://github.com/a/b.git" ✅ 剥掉
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
代码注释说 scp 形态"`@` 前是公开用户名,不是凭证"—— 对标准 `git@` 成立,
|
|
460
|
+
但**用户名位置完全可以放 token**(有些自建 Git 服务就是这么用的)。
|
|
461
|
+
另外 `ssh://git@...` 会把 `git@` 一起剥掉(虽然无害,但说明这个分支
|
|
462
|
+
不是按"只剥密码"的精确口径在做事)。属低危边界,建议在注释里点明这一取舍。
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
466
|
+
## 6. 复现步骤
|
|
467
|
+
|
|
468
|
+
全部脚本在 `D:\verify-lab\`,自装配 hub + worker,**不需要真机双机**:
|
|
469
|
+
|
|
470
|
+
```powershell
|
|
471
|
+
# 前提:Node 22+、git 在 PATH
|
|
472
|
+
cd D:\verify-lab
|
|
473
|
+
|
|
474
|
+
node step1-scan.mjs # 扫描 + 凭证剥离(单元级)
|
|
475
|
+
node task1-repos.mjs # #13 全流程(②③④⑤ 四步)
|
|
476
|
+
node task1b-routing.mjs # #13 边界(多节点/精确匹配/离线/scp url)
|
|
477
|
+
node task2-load.mjs # #14 字典序 + 负载避让 + agentMode 配置
|
|
478
|
+
node task2b-filter.mjs # #14 能力过滤逻辑(直接构造节点)
|
|
479
|
+
node task2c-agentroute.mjs # #14 agent 模式实际路由决策
|
|
480
|
+
node task3-audit.mjs # #19 文件生成 + 四事件 + 打码
|
|
481
|
+
node task3b-redact.mjs # #19 打码口径精确刻画
|
|
482
|
+
node task3c-lazy.mjs # #19 惰性创建
|
|
483
|
+
node task3d-rotate.mjs # #19 轮转 + 失败路径 + 故障隔离
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
判定标准:
|
|
487
|
+
|
|
488
|
+
- `task1-repos.mjs` 的 ③ 打印 `→ ✓ 通过:凭证未离开副机`;
|
|
489
|
+
- `task2-load.mjs` 的 ①② 打印 `✓`,③ 会打印 `✗ agentMode 无法通过 config 关闭`(**预期就是这个**);
|
|
490
|
+
- `task3-audit.mjs` 的 ⑥ 会打印 `✗ 未打码`(**预期就是这个**)。
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## 7. 临时部署拆除
|
|
495
|
+
|
|
496
|
+
所有脚本末尾按 `worker → hub → http server` 顺序释放,每次都打印:
|
|
497
|
+
|
|
498
|
+
```
|
|
499
|
+
✓ 临时部署已拆除
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
- 实验全程在 `D:\verify-lab\run\` 下,hub/worker 的 `stateDir` 都指向该临时目录;
|
|
503
|
+
- **未触碰** `~/.dsh-remote-agent/` 与 DSH profile 的 `cordis.patch.yml`
|
|
504
|
+
(唯一的例外是问题 3 —— 那是**测试自身**的行为,不是本次实验脚本造成的);
|
|
505
|
+
- 已确认无残留 node 进程。
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
## 8. 边界与未覆盖
|
|
510
|
+
|
|
511
|
+
| 项 | 状态 |
|
|
512
|
+
|---|---|
|
|
513
|
+
| `reposIntervalMs: 0` 关闭上报 | ❌ 未覆盖(代码路径明确,未实测) |
|
|
514
|
+
| 仓库数 > 50(`MAX_REPOS` 截断) | ❌ 未覆盖 |
|
|
515
|
+
| 扫描深度 > 3(`MAX_DEPTH`) | ❌ 未覆盖 |
|
|
516
|
+
| worktree / submodule 的 `.git` 是文件 | ❌ 未覆盖(代码注释声明留 v2,只认目录) |
|
|
517
|
+
| 真实双机跨 Tailscale 的 repo 上报 | ❌ 未覆盖(本次进程内双 worker) |
|
|
518
|
+
| `audit: false` 显式关闭 | ❌ 未覆盖 |
|
|
519
|
+
| 审计 8MB 轮转的真实触发 | ⚠️ 用 `maxBytes: 400` 缩小验证了逻辑,未跑满 8MB |
|
|
520
|
+
| 面板节点列表的 repos 列渲染 | ❌ 未覆盖(只验了 `remote_nodes` 工具输出) |
|
|
521
|
+
| 真实 DSH 桌面版里的三个特性 | ❌ 未覆盖(本次是进程内装配;DSH 侧需重启加载新代码) |
|