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.
Files changed (70) hide show
  1. package/CHANGELOG.md +700 -0
  2. package/CONTRIBUTING.md +358 -0
  3. package/KNOWN-ISSUES.md +601 -0
  4. package/LICENSE +21 -0
  5. package/README.en.md +353 -0
  6. package/README.md +317 -2
  7. package/SECURITY.md +348 -0
  8. package/bin/dsh-remote-relay.js +257 -0
  9. package/bin/dsh-remote-setup.js +440 -0
  10. 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
  11. package/cordis.patch.yml +87 -0
  12. package/deploy/relay-vps/Caddyfile +24 -0
  13. package/deploy/relay-vps/README.md +132 -0
  14. package/deploy/relay-vps/dsh-remote-relay.service +47 -0
  15. package/deploy/relay-vps/nginx.conf +49 -0
  16. package/deploy/relay-vps/relay.env.example +20 -0
  17. package/docs/dispatch-automation-eval.md +140 -0
  18. package/docs/screenshots/panel-main.png +0 -0
  19. package/docs/screenshots/panel-task-detail.png +0 -0
  20. package/docs/verification-agent-e2e.md +425 -0
  21. package/docs/verification-repos-routing-real.md +521 -0
  22. package/docs/verification-resume-real.md +254 -0
  23. package/docs/worker-setup-pitfalls.md +266 -0
  24. package/lib/artifacts.js +451 -0
  25. package/lib/client.js +1752 -0
  26. package/lib/e2e.js +304 -0
  27. package/lib/glob.js +225 -0
  28. package/lib/hub/audit-log.js +161 -0
  29. package/lib/hub/hub-util.js +114 -0
  30. package/lib/hub/index.js +500 -0
  31. package/lib/hub/node-registry.js +943 -0
  32. package/lib/hub/pull-manager.js +781 -0
  33. package/lib/hub/registry-handshake.js +403 -0
  34. package/lib/hub/registry-ingest.js +346 -0
  35. package/lib/hub/relay-link.js +228 -0
  36. package/lib/hub/subagent-provider.js +424 -0
  37. package/lib/hub/templates.js +693 -0
  38. package/lib/hub/tools.js +1059 -0
  39. package/lib/hub/ui-routes.js +856 -0
  40. package/lib/hub/views.js +351 -0
  41. package/lib/identity.js +271 -0
  42. package/lib/index.js +272 -0
  43. package/lib/node/artifact-service.js +231 -0
  44. package/lib/node/diff.js +124 -0
  45. package/lib/node/exec.js +536 -0
  46. package/lib/node/hub-pin.js +71 -0
  47. package/lib/node/index.js +1022 -0
  48. package/lib/node/repos.js +134 -0
  49. package/lib/node/resource.js +63 -0
  50. package/lib/node/runner.js +792 -0
  51. package/lib/node/task-engine.js +712 -0
  52. package/lib/node/task-store.js +273 -0
  53. package/lib/node/transport.js +763 -0
  54. package/lib/pairing.js +186 -0
  55. package/lib/protocol.js +414 -0
  56. package/lib/relay-config.js +317 -0
  57. package/lib/relay.js +732 -0
  58. package/lib/setup-routes.js +313 -0
  59. package/lib/setup.js +522 -0
  60. package/lib/ws.js +466 -0
  61. package/package.json +103 -4
  62. package/profiles/README.md +573 -0
  63. package/profiles/hub.cordis.yml +84 -0
  64. package/profiles/worker.cordis.yml +60 -0
  65. package/scripts/check-doc-drift.mjs +785 -0
  66. package/scripts/check-packaging.mjs +130 -0
  67. package/scripts/diag-inject.mjs +243 -0
  68. package/scripts/e2e-live-check.mjs +181 -0
  69. package/scripts/relay-init.ps1 +286 -0
  70. package/scripts/relay-init.sh +199 -0
@@ -0,0 +1,351 @@
1
+ /**
2
+ * 视图投影层:把**副机上报的数据**投影成"可以安全交给宿主"的形状。
3
+ *
4
+ * ── 为什么需要这一层 ──────────────────────────────────────────────
5
+ * 副机是一个独立的、可被攻陷的远端。它上报的 handoff / changed / exit / error /
6
+ * manifest 全部是**不可信输入**:字段集由它决定,它可以往里面塞绝对路径
7
+ * (回显主机目录结构)、环境变量、诊断信息、甚至凭证片段。
8
+ *
9
+ * 宿主有两种消费方,两边的要求一致:**只拿它真正需要的字段**。
10
+ * 1. 工具返回值 → 进入会话日志 → 后续每个请求都带上(泄漏会被持久化)
11
+ * 2. Web 面板 → 进入浏览器 → 可能被扩展/调试工具/截图带走
12
+ *
13
+ * ── 为什么集中在这一个文件 ────────────────────────────────────────
14
+ * 曾经散在三处:`node-registry.taskView`、`tools.buildManifestView`、
15
+ * `ui-routes.*View`。结果 `remote_handoff` 因为"没有对应的 View 函数"就
16
+ * 裸直出了 `t.handoff` —— 加固做了一半,且是最该加固的那一半(工具路径
17
+ * 会被写进会话日志)。集中后新增消费方无处可"绕开"投影。
18
+ *
19
+ * ── 一条硬规则 ───────────────────────────────────────────────────
20
+ * 本文件的函数**只做逐字段构造**,永不 spread 输入对象。所有字符串截断,
21
+ * 所有数字做类型检查,缺失一律给安全默认值。这样副机怎么改它的上报形状,
22
+ * 都不可能让宿主这边出现"没预料到的字段"。
23
+ */
24
+
25
+ /**
26
+ * 取数组的**最后** n 条,`n <= 0` 回空数组。
27
+ *
28
+ * 为什么不用裸 `list.slice(-n)`:`slice(-0)` 等于 `slice(0)`,会把**整条数组**
29
+ * 都留下 —— 与"不要尾巴"的直觉相反,是个静默的语义反转。`tail` 是公开选项,
30
+ * 消费方传 0 时应当拿到空尾巴而不是全部(本层原先两处都踩了这个坑)。
31
+ */
32
+ function tailSlice(list, n) {
33
+ const want = Number.isFinite(n) ? Math.trunc(n) : 0
34
+ if (want <= 0) return []
35
+ return list.slice(-want)
36
+ }
37
+
38
+ /** 单个 changed 条目:只保留客户端/模型真正会读的四个字段。 */
39
+ export function changedItemView(c) {
40
+ if (!c || typeof c !== 'object') return null
41
+ return {
42
+ op: typeof c.op === 'string' ? c.op : 'unknown',
43
+ path: typeof c.path === 'string' ? c.path.slice(0, 500) : '',
44
+ bytes: Number.isFinite(c.bytes) ? c.bytes : 0,
45
+ deltaBytes: Number.isFinite(c.deltaBytes) ? c.deltaBytes : 0,
46
+ }
47
+ }
48
+
49
+ /** changed 数组:投影 + 丢弃非对象元素(否则消费方解引用 c.op 会抛)。 */
50
+ export function changedView(list, { max = 50 } = {}) {
51
+ if (!Array.isArray(list)) return []
52
+ return list.slice(0, max).map(changedItemView).filter(Boolean)
53
+ }
54
+
55
+ /**
56
+ * 单个 diff 条目:path + unified diff 文本 + truncated 标志。
57
+ *
58
+ * diff 文本是副机生成的机器可读输出,但仍按不可信输入对待:
59
+ * 逐条挑字段 + 长度封顶(单条 16KB,与副机 diff.js 的截断口径一致,
60
+ * 双保险 —— 副机版本更旧没有截断时,hub 侧兜底)。
61
+ */
62
+ export function diffItemView(d) {
63
+ if (!d || typeof d !== 'object') return null
64
+ if (typeof d.path !== 'string' || d.path === '') return null
65
+ if (typeof d.diff !== 'string' || d.diff === '') return null
66
+ return {
67
+ path: d.path.slice(0, 500),
68
+ diff: d.diff.slice(0, 16 * 1024),
69
+ truncated: d.truncated === true,
70
+ }
71
+ }
72
+
73
+ /** diffs 数组:投影 + 封顶条数(与副机 buildDiffs 的 12 文件上限同口径)。 */
74
+ export function diffsView(list, { max = 12 } = {}) {
75
+ if (!Array.isArray(list)) return []
76
+ return list.slice(0, max).map(diffItemView).filter(Boolean)
77
+ }
78
+
79
+ /**
80
+ * 单个进度条目:只留 `phase` / `detail` / `at`。
81
+ *
82
+ * 与 changedItemView 同理,progressTail 也是副机上报的数组(见下方
83
+ * progressTailView 的说明),元素必须逐字段投影后才能给消费方解引用。
84
+ */
85
+ export function progressItemView(p) {
86
+ if (!p || typeof p !== 'object') return null
87
+ return {
88
+ phase: typeof p.phase === 'string' ? p.phase : 'progress',
89
+ detail: typeof p.detail === 'string' ? p.detail.slice(0, 500) : '',
90
+ at: Number.isFinite(p.at) ? p.at : null,
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 进度尾巴:投影 + 丢弃非对象元素,保留**最后** tail 条。
96
+ *
97
+ * 为什么不能只 `slice(-tail)`(原实现,本层唯一的裸透传):
98
+ * progressTail 同样来自副机 —— `node-registry.applyView()` 把 task/sync 里的
99
+ * `view.progressTail` 原样存下,只有 task/progress 帧那条路是自己构造 entry。
100
+ * 而两个消费方都直接解引用元素:
101
+ * - `lib/hub/tools.js` 的 remote_status 渲染:`p.phase` / `p.detail`
102
+ * - `lib/hub/subagent-provider.js` 的兜底文本:`p.phase` / `p.detail`
103
+ * 副机上报一个 `null` 元素就能让它们抛 TypeError。tools 那条会让渲染失败;
104
+ * subagent 那条更隐蔽 —— 异常被 provider 的兜底 catch 吞掉,于是一个**已完成**
105
+ * 的任务被上报成"未完成:Cannot read properties of null"。这既是崩溃源,
106
+ * 也违反 remote_status 自己声明的输出 schema(`items: { type: 'object' }`)。
107
+ *
108
+ * `tail <= 0` 回空数组(注意 `slice(-0)` 会把整条数组都留下,不是"不要尾巴")。
109
+ */
110
+ export function progressTailView(list, { tail = 6, max = 20 } = {}) {
111
+ if (!Array.isArray(list)) return []
112
+ const cap = Number.isFinite(max) ? Math.trunc(max) : Infinity
113
+ const want = Number.isFinite(tail) ? Math.trunc(tail) : 0
114
+ const n = Math.min(want, cap)
115
+ if (n <= 0) return []
116
+ return tailSlice(list, n).map(progressItemView).filter(Boolean)
117
+ }
118
+
119
+ /** exit 摘要:副机可能往里塞诊断字段,只留可执行的语义。 */
120
+ export function exitView(e) {
121
+ if (!e || typeof e !== 'object') return null
122
+ return {
123
+ code: Number.isFinite(e.code) ? e.code : null,
124
+ timedOut: e.timedOut === true,
125
+ aborted: e.aborted === true,
126
+ runner: typeof e.runner === 'string' ? e.runner : '',
127
+ toolCalls: Number.isFinite(e.toolCalls) ? e.toolCalls : 0,
128
+ }
129
+ }
130
+
131
+ /** error:只留稳定 code 与截断后的消息。 */
132
+ export function errorView(e) {
133
+ if (!e || typeof e !== 'object') return null
134
+ return {
135
+ code: typeof e.code === 'string' ? e.code : '',
136
+ message: typeof e.message === 'string' ? e.message.slice(0, 500) : '',
137
+ }
138
+ }
139
+
140
+ /** 节点:不含 hello / conn 等内部对象。 */
141
+ export function nodeView(n) {
142
+ if (!n || typeof n !== 'object') return null
143
+ return {
144
+ nodeId: typeof n.nodeId === 'string' ? n.nodeId : '',
145
+ online: n.online === true,
146
+ os: typeof n.os === 'string' ? n.os : 'unknown',
147
+ arch: typeof n.arch === 'string' ? n.arch : 'unknown',
148
+ agentMode: n.agentMode === true,
149
+ roots: Array.isArray(n.roots) ? n.roots.filter((r) => typeof r === 'string') : [],
150
+ caps: Array.isArray(n.caps) ? n.caps.filter((c) => typeof c === 'string') : [],
151
+ since: Number.isFinite(n.since) ? n.since : null,
152
+ lastSeen: Number.isFinite(n.lastSeen) ? n.lastSeen : null,
153
+ taskCount: Number.isFinite(n.taskCount) ? n.taskCount : 0,
154
+ // 仓库清单(提案 #13):只给名字列表(url/path 仅供路由,不进 UI)
155
+ repos: Array.isArray(n.repos) ? n.repos.filter((x) => typeof x === 'string') : [],
156
+ reposAt: Number.isFinite(n.reposAt) ? n.reposAt : null,
157
+ // 资源遥测(最近一次 node/resource):字段级投影,缺/null 都如实透传
158
+ resource: (n.resource && typeof n.resource === 'object')
159
+ ? {
160
+ cpuPct: Number.isFinite(n.resource.cpuPct) ? n.resource.cpuPct : null,
161
+ load1: Number.isFinite(n.resource.load1) ? n.resource.load1 : null,
162
+ memUsedMb: Number.isFinite(n.resource.memUsedMb) ? n.resource.memUsedMb : null,
163
+ memTotalMb: Number.isFinite(n.resource.memTotalMb) ? n.resource.memTotalMb : null,
164
+ at: Number.isFinite(n.resource.at) ? n.resource.at : null,
165
+ }
166
+ : null,
167
+ }
168
+ }
169
+
170
+ /** manifest 的 final 条目。 */
171
+ export function finalItemView(f) {
172
+ if (!f || typeof f !== 'object') return null
173
+ return {
174
+ path: typeof f.path === 'string' ? f.path.slice(0, 500) : '',
175
+ kind: typeof f.kind === 'string' ? f.kind : 'file',
176
+ bytes: Number.isFinite(f.bytes) ? f.bytes : 0,
177
+ sha256: typeof f.sha256 === 'string' ? f.sha256 : null,
178
+ }
179
+ }
180
+
181
+ /** manifest:计数 + final 清单(不传字节)。 */
182
+ export function manifestView(m, { max = 200 } = {}) {
183
+ if (!m || typeof m !== 'object') return null
184
+ const counts = m.counts && typeof m.counts === 'object' ? m.counts : null
185
+ return {
186
+ counts: counts
187
+ ? {
188
+ total: Number.isFinite(counts.total) ? counts.total : 0,
189
+ final: Number.isFinite(counts.final) ? counts.final : 0,
190
+ intermediate: Number.isFinite(counts.intermediate) ? counts.intermediate : 0,
191
+ other: Number.isFinite(counts.other) ? counts.other : 0,
192
+ }
193
+ : null,
194
+ bytesFinal: Number.isFinite(m.bytesFinal) ? m.bytesFinal : 0,
195
+ final: Array.isArray(m.items)
196
+ ? m.items
197
+ .filter((i) => i && i.role === 'final')
198
+ .slice(0, max)
199
+ .map(finalItemView)
200
+ .filter(Boolean)
201
+ : [],
202
+ }
203
+ }
204
+
205
+ /**
206
+ * handoff 里的 `artifacts` 段。
207
+ *
208
+ * ⚠ 形状与 `manifestView` **不同**,不要混用:
209
+ * - `manifestView` 吃的是**副机的清单对象**(`items: [{role, path, ...}]`),
210
+ * 需要按 `role === 'final'` 筛。
211
+ * - handoff 里的 `artifacts.final` 是**副机已经筛好的数组**(见
212
+ * lib/node/task-engine.js 的 buildHandoff),只有 `{path, kind, bytes, sha256}`。
213
+ * 我第一版把两者当成一个形状,导致 final 恒为空数组(e2e 断言 `final 应含 产物.txt` 失败)。
214
+ */
215
+ export function artifactsView(a, { max = 200 } = {}) {
216
+ if (!a || typeof a !== 'object') return null
217
+ const counts = a.counts && typeof a.counts === 'object' ? a.counts : null
218
+ return {
219
+ counts: counts
220
+ ? {
221
+ total: Number.isFinite(counts.total) ? counts.total : 0,
222
+ final: Number.isFinite(counts.final) ? counts.final : 0,
223
+ intermediate: Number.isFinite(counts.intermediate) ? counts.intermediate : 0,
224
+ other: Number.isFinite(counts.other) ? counts.other : 0,
225
+ }
226
+ : null,
227
+ bytesFinal: Number.isFinite(a.bytesFinal) ? a.bytesFinal : 0,
228
+ final: Array.isArray(a.final)
229
+ ? a.final.slice(0, max).map(finalItemView).filter(Boolean)
230
+ : [],
231
+ intermediateCount: Number.isFinite(a.intermediateCount) ? a.intermediateCount : 0,
232
+ note: typeof a.note === 'string' ? a.note.slice(0, 500) : '',
233
+ }
234
+ }
235
+
236
+ /**
237
+ * handoff 卡片:**投影最严的一处**。
238
+ *
239
+ * 副机的 buildHandoff() 会带上 workspace(绝对路径)、logs(可能含环境变量)、
240
+ * 以及它版本的任何新字段。这里逐字段挑出消费方要用的部分,其余一律丢弃。
241
+ */
242
+ export function handoffView(h) {
243
+ if (!h || typeof h !== 'object') return null
244
+ const summary = h.summary && typeof h.summary === 'object' ? h.summary : null
245
+ return {
246
+ taskId: typeof h.taskId === 'string' ? h.taskId : null,
247
+ nodeId: typeof h.nodeId === 'string' ? h.nodeId : null,
248
+ status: typeof h.status === 'string' ? h.status : null,
249
+ mode: typeof h.mode === 'string' ? h.mode : null,
250
+ runner: typeof h.runner === 'string' ? h.runner : null,
251
+ durationMs: Number.isFinite(h.durationMs) ? h.durationMs : null,
252
+ startedAt: Number.isFinite(h.startedAt) ? h.startedAt : null,
253
+ endedAt: Number.isFinite(h.endedAt) ? h.endedAt : null,
254
+ exit: exitView(h.exit),
255
+ // **两个形状都提供**,因为两条消费路径的历史契约不同:
256
+ // - 工具路径(remote_handoff)的既有契约是嵌套 `summary.text`,
257
+ // 已被测试与下游依赖(e2e 断言 `card.summary.text` 是字符串)。
258
+ // - UI 路径读的是扁平 `summaryText`。
259
+ // 统一投影时若只留一个形状,就会静默破坏另一条路径 —— 这正是我第一版
260
+ // 改动造成的回归(9 个测试失败)。两个都给,各有明确来源。
261
+ summary: summary
262
+ ? { text: typeof summary.text === 'string' ? summary.text.slice(0, 4000) : '' }
263
+ : null,
264
+ summaryText: summary && typeof summary.text === 'string' ? summary.text.slice(0, 4000) : '',
265
+ changed: changedView(h.changed, { max: 50 }),
266
+ // 任务变更 diff 视图(提案 #21):副机对文本文件改动生成的 unified diff。
267
+ diffs: diffsView(h.diffs),
268
+ // handoff 里的 artifacts 走**专用**投影(它的 final 已是筛好的数组)
269
+ artifacts: artifactsView(h.artifacts, { max: 200 }),
270
+ pullHint: typeof h.pullHint === 'string' ? h.pullHint.slice(0, 1000) : '',
271
+ // 刻意**不含**:workspace(绝对路径,回显主机目录)、logs(可能带环境变量)、
272
+ // goal(原文已在任务投影里)、sessionId(副机内部标识)
273
+ }
274
+ }
275
+
276
+ /**
277
+ * 任务投影:工具与 UI 共用的"一条任务长什么样"。
278
+ *
279
+ * ── 必须**幂等**(对已投影过的对象再投影一次,结果不变) ──────────────
280
+ * 共享层会被两条路径**叠加**调用:`registry.taskView(t)` 内部先做一次共享投影
281
+ * (见 node-registry.js),而 UI 路由 `/tasks` 拿到它之后再调一次
282
+ * `taskView(t)`(ui-routes.js)。若本函数假设输入一定是"原始注册表对象",
283
+ * 第二次投影就会把第一次的成果丢掉:
284
+ * - `summary.text` 在投影结果里变成了 `summaryText`,再读 `t.summary.text`
285
+ * 就得到空串 → 面板展开任务时"结果摘要"整块消失
286
+ * - `changedCount` 从原始长度(例如 120)被降级成**已截断**的 `changed.length`(50)
287
+ * - `manifestCounts` 同理丢失(原始对象里是 `manifest`,投影后是 `manifestCounts`)
288
+ * 所以三个字段都做"原始形状优先,已投影形状兜底",让重复投影成为恒等操作。
289
+ *
290
+ * @param {object} t 节点注册表里的任务对象(含 nodeId/status/changed/manifest/…),
291
+ * 或本函数先前的输出(幂等)
292
+ * @param {object} [o]
293
+ * @param {number} [o.tail] 日志/进度尾部保留几条
294
+ * @param {boolean} [o.includeLogs] 是否带日志尾部(UI 要,工具的轻量视图可省)
295
+ */
296
+ export function taskView(t, { tail = 6, includeLogs = true } = {}) {
297
+ if (!t || typeof t !== 'object') return null
298
+ const summaryText = t.summary && typeof t.summary.text === 'string'
299
+ ? t.summary.text.slice(0, 2000)
300
+ : typeof t.summaryText === 'string'
301
+ ? t.summaryText.slice(0, 2000)
302
+ : ''
303
+ const out = {
304
+ taskId: typeof t.taskId === 'string' ? t.taskId : '',
305
+ nodeId: typeof t.nodeId === 'string' ? t.nodeId : '',
306
+ nodeOnline: t.nodeOnline === true,
307
+ status: typeof t.status === 'string' ? t.status : 'unknown',
308
+ mode: typeof t.mode === 'string' ? t.mode : '',
309
+ // 两阶段审批(提案 #2):'manual'(挂起待批)| 'auto'
310
+ approval: t.approval === 'manual' ? 'manual' : 'auto',
311
+ goal: typeof t.goal === 'string' ? t.goal.slice(0, 500) : '',
312
+ createdAt: Number.isFinite(t.createdAt) ? t.createdAt : 0,
313
+ updatedAt: Number.isFinite(t.updatedAt) ? t.updatedAt : 0,
314
+ // ⚠ 时间字段的 schema 声明是 number,**不能返回 null**。
315
+ //
316
+ // DSH 在工具返回值出边界时跑 schema 校验(`validateJsonSchemaValue`),
317
+ // null 会被判为 "must be a number" → 整个工具调用报
318
+ // `returned invalid output: "value.startedAt" must be a number`。
319
+ // 实测:remote_status 查一个刚创建、还没开始执行的任务就踩到
320
+ // (createdAt 有值,但 startedAt/endedAt/durationMs 都还没有)。
321
+ // 用 0 表达"还没有这个时间点" —— 消费方判 `> 0` 即可。
322
+ startedAt: Number.isFinite(t.startedAt) ? t.startedAt : 0,
323
+ endedAt: Number.isFinite(t.endedAt) ? t.endedAt : 0,
324
+ durationMs: Number.isFinite(t.durationMs) ? t.durationMs : 0,
325
+ exit: exitView(t.exit),
326
+ error: errorView(t.error),
327
+ summaryText,
328
+ // changedCount 是"原始条数"(面板标题用它),不能被投影后的截断值覆盖。
329
+ // 已投影对象里没有原始数组长度,只能信它带来的 changedCount。
330
+ changedCount: Array.isArray(t.changed)
331
+ ? (Number.isFinite(t.changedCount) ? t.changedCount : t.changed.length)
332
+ : (Number.isFinite(t.changedCount) ? t.changedCount : 0),
333
+ changed: changedView(t.changed, { max: 50 }),
334
+ // manifestCounts 的 schema 是 object,同样不能用 null → 空对象兜底。
335
+ manifestCounts: manifestView(t.manifest)?.counts
336
+ ?? (t.manifestCounts && typeof t.manifestCounts === 'object' ? t.manifestCounts : {}),
337
+ // workspace 刻意不回传:绝对路径会回显主机目录结构,而两个消费方都不读它。
338
+ }
339
+ if (includeLogs) {
340
+ // progressTail 必须做**元素级**投影,不能只 slice:它同样源自副机上报
341
+ // (node-registry 的 applyView 把 task/sync 里的数组原样存下),而消费方
342
+ // 会解引用 `p.phase` / `p.detail`。详见 progressTailView 的说明。
343
+ out.progressTail = progressTailView(t.progressTail, { tail })
344
+ // logTail 用同一个 tailSlice:原先的裸 slice(-tail) 在 tail=0 时会把**整条**
345
+ // 日志留下(slice(-0) === slice(0)),与"不要尾巴"相反。
346
+ out.logTail = Array.isArray(t.logTail)
347
+ ? tailSlice(t.logTail, tail).map((l) => (typeof l === 'string' ? l.slice(0, 2000) : String(l).slice(0, 2000)))
348
+ : []
349
+ }
350
+ return out
351
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * hub 身份:持久 ed25519 密钥对 + 指纹 + 身份帧签名(零外部依赖,node:crypto)。
3
+ *
4
+ * ═══════════════════════════════════════════════════════════════════════════
5
+ * 这个模块解决什么问题(评审报告 P2-11)
6
+ * ═══════════════════════════════════════════════════════════════════════════
7
+ *
8
+ * 协议此前只有**单向认证**:副机拿 token 向 hub 证明"我是被允许接入的节点",
9
+ * 但 hub 从不需要向副机证明"我就是你知道的那个 hub"。于是:
10
+ *
11
+ * - direct 模式:任何能 accept upgrade 的端点(假 hub / 中间人)回一帧
12
+ * welcome,副机就上线 —— 之后的 task/start(mode:"exec" = 副机任意代码
13
+ * 执行)照单全收。token 是副机交出去的东西,假 hub 根本不验。
14
+ * - relay 模式更糟:副机的"在线"信号来自中继的 hello/node accepted,
15
+ * hub 在握手期对副机完全静默 —— 假中继接受 hello 后可以直接冒充 hub
16
+ * 下发任务。SECURITY.md 里"中继可见明文帧"只是机密性问题,这一条是
17
+ * **身份 + 完整性**问题,且直接通向副机任意代码执行。
18
+ *
19
+ * 修法(本模块 + 协议扩展 + 副机验证,三处一起才闭环):
20
+ * 1. hub 生成持久 ed25519 身份密钥对,首次启动落盘(0600);
21
+ * 2. 握手时 hub 用私钥签一帧身份证明:指纹 + 公钥 + 签名 + 时间戳 +
22
+ * 本次连接 nonce。direct 模式放进 welcome,relay 模式用
23
+ * hub/identity 帧主动补(上线信号不归 hub 发,只能追发);
24
+ * 3. 副机侧验签 + 指纹比对。指纹经配对码零终端分发,首连自动钉住
25
+ * (TOFU),之后不匹配即拒绝上线并大声报错。
26
+ *
27
+ * 为什么是 ed25519 而不是 HMAC / 预共享密钥:
28
+ * - 指纹必须能公开分发(配对码里人眼可见),公钥验证不需要副机持有
29
+ * 任何秘密 —— 泄一个配对码不等于泄一把能冒充 hub 的钥匙;
30
+ * - hub 侧一把私钥对所有副机通用,不需要 per-node 预共享表;
31
+ * - node:crypto 原生支持,保持零外部依赖;
32
+ * - 签名确定性(RFC 8032)让黄金向量可以跨 Node 版本锁定字节。
33
+ *
34
+ * 指纹格式:`hubfp1-` + base64url(sha256(SPKI DER)[0:16]),共 22 字符。
35
+ * 16 字节(128 bit)抗碰撞余量对"人眼核对 + 机器比对"双重场景足够,
36
+ * 又短到能塞进配对码和终端里显示。
37
+ */
38
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign as edSign, verify as edVerify } from 'node:crypto'
39
+ import { spawnSync } from 'node:child_process'
40
+ import fs from 'node:fs'
41
+ import path from 'node:path'
42
+
43
+ /** 签名载荷的上下文前缀。改它 = 协议变更,旧签名全部作废(有意为之)。 */
44
+ export const IDENTITY_CONTEXT = 'dsh-remote-agent/hub-identity/v1'
45
+ export const FINGERPRINT_PREFIX = 'hubfp1-'
46
+ /** 指纹取 sha256 的前 16 字节 → base64url 恰好 22 字符(无填充)。 */
47
+ const FP_BYTES = 16
48
+ /** hello/welcome 里 nonce 的字节数(hex 后 32 字符)。 */
49
+ export const NONCE_BYTES = 16
50
+ /** 身份文件格式版本。 */
51
+ export const IDENTITY_FILE_VERSION = 1
52
+ /** 身份文件名(落在 hub stateDir 下)。 */
53
+ export const IDENTITY_FILE_NAME = 'hub-identity.json'
54
+ /** 副机 TOFU 钉住文件名(落在副机 stateDir 下)。 */
55
+ export const HUB_PIN_FILE_NAME = 'hub-pin.json'
56
+
57
+ /* ───────────────────────────── 指纹 ───────────────────────────── */
58
+
59
+ /** 由 SPKI DER 公钥字节算指纹。 */
60
+ export function fingerprintOf(publicKeyDer) {
61
+ const buf = Buffer.isBuffer(publicKeyDer) ? publicKeyDer : Buffer.from(publicKeyDer ?? '')
62
+ if (buf.length === 0) return ''
63
+ const digest = createHash('sha256').update(buf).digest()
64
+ return FINGERPRINT_PREFIX + digest.subarray(0, FP_BYTES).toString('base64url')
65
+ }
66
+
67
+ /**
68
+ * 归一化指纹:形态不对一律返回 ''(调用方按"没有指纹"处理)。
69
+ * base64url 大小写敏感,不做大小写折叠 —— 折叠会让两个不同指纹相等。
70
+ */
71
+ export function normalizeFingerprint(raw) {
72
+ if (typeof raw !== 'string') return ''
73
+ const s = raw.trim()
74
+ if (!s.startsWith(FINGERPRINT_PREFIX)) return ''
75
+ const body = s.slice(FINGERPRINT_PREFIX.length)
76
+ if (!/^[A-Za-z0-9_-]{22}$/.test(body)) return ''
77
+ return FINGERPRINT_PREFIX + body
78
+ }
79
+
80
+ /* ───────────────────────────── 密钥对 ───────────────────────────── */
81
+
82
+ /**
83
+ * 生成一把新身份。返回 Base64url(DER)形态的持久化表示 + KeyObject。
84
+ * KeyObject 只在内存里用;落盘的永远是可以 JSON 序列化的字符串。
85
+ */
86
+ export function generateIdentity() {
87
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519')
88
+ const publicDer = publicKey.export({ format: 'der', type: 'spki' })
89
+ return {
90
+ privateKeyB64: privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64url'),
91
+ publicKeyB64: publicDer.toString('base64url'),
92
+ fingerprint: fingerprintOf(publicDer),
93
+ }
94
+ }
95
+
96
+ /**
97
+ * 把持久化表示还原成可签/可验的身份。
98
+ * 任何字段缺失/损坏/指纹与公钥不符 → 返回 null(调用方决定重建还是报错)。
99
+ */
100
+ export function importIdentity(stored) {
101
+ if (!stored || typeof stored !== 'object') return null
102
+ const priv = typeof stored.privateKeyB64 === 'string' ? stored.privateKeyB64 : ''
103
+ const pub = typeof stored.publicKeyB64 === 'string' ? stored.publicKeyB64 : ''
104
+ if (priv === '' || pub === '') return null
105
+ try {
106
+ const privateKey = createPrivateKey({ key: Buffer.from(priv, 'base64url'), format: 'der', type: 'pkcs8' })
107
+ const publicKey = createPublicKey({ key: Buffer.from(pub, 'base64url'), format: 'der', type: 'spki' })
108
+ const fp = fingerprintOf(publicKey.export({ format: 'der', type: 'spki' }))
109
+ if (normalizeFingerprint(stored.fingerprint) !== fp) return null
110
+ return { privateKey, publicKey, privateKeyB64: priv, publicKeyB64: pub, fingerprint: fp }
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+
116
+ /* ───────────────────────────── 签名 / 验签 ───────────────────────────── */
117
+
118
+ /** 每次连接一个新 nonce(hex)。副机放 hello 里,hub 签进身份帧。 */
119
+ export function newNonce() {
120
+ return randomBytes(NONCE_BYTES).toString('hex')
121
+ }
122
+
123
+ /**
124
+ * 签名载荷的规范化序列化。字段序固定 —— 换行分隔、无转义,
125
+ * 任何一字段漂移都会让验签失败(fail-closed 方向,安全)。
126
+ */
127
+ export function identitySignPayload({ fingerprint, nodeId, nonce, at }) {
128
+ return [IDENTITY_CONTEXT, String(fingerprint ?? ''), String(nodeId ?? ''), String(nonce ?? ''), String(at ?? '')].join('\n')
129
+ }
130
+
131
+ /**
132
+ * hub 侧:签一帧身份证明。返回 base64url 签名(ed25519 = 64 字节 → 86 字符)。
133
+ * 抛错只可能是私钥形态坏了 —— 调用方(hub)按"身份不可用"降级。
134
+ */
135
+ export function signIdentity(privateKeyB64, { fingerprint, nodeId, nonce, at }) {
136
+ const key = createPrivateKey({ key: Buffer.from(privateKeyB64, 'base64url'), format: 'der', type: 'pkcs8' })
137
+ const sig = edSign(undefined, Buffer.from(identitySignPayload({ fingerprint, nodeId, nonce, at }), 'utf8'), key)
138
+ return sig.toString('base64url')
139
+ }
140
+
141
+ /**
142
+ * 副机侧:验签。任何异常(公钥坏/签名坏/字段缺)一律 false,绝不抛。
143
+ */
144
+ export function verifyIdentity(publicKeyB64, fields, sigB64) {
145
+ if (typeof publicKeyB64 !== 'string' || publicKeyB64 === '') return false
146
+ if (typeof sigB64 !== 'string' || sigB64 === '') return false
147
+ try {
148
+ const key = createPublicKey({ key: Buffer.from(publicKeyB64, 'base64url'), format: 'der', type: 'spki' })
149
+ return edVerify(
150
+ undefined,
151
+ Buffer.from(identitySignPayload(fields), 'utf8'),
152
+ key,
153
+ Buffer.from(sigB64, 'base64url'),
154
+ )
155
+ } catch {
156
+ return false
157
+ }
158
+ }
159
+
160
+ /* ───────────────────────────── 落盘 ───────────────────────────── */
161
+
162
+ /**
163
+ * 收窄文件权限到「只有本人可读写」。
164
+ *
165
+ * 与 lib/relay-config.js 的同名函数同构(chmod 0600 + Windows icacls),
166
+ * 这里不放公共模块是因为 relay-config.js 会拖进整条 relay 依赖链,
167
+ * 而 hub/副机两侧只需要这 15 行。改动务必两边同步。
168
+ */
169
+ export function restrictPermissions(target, { isDir = false } = {}) {
170
+ try {
171
+ fs.chmodSync(target, isDir ? 0o700 : 0o600)
172
+ } catch {
173
+ /* FAT/网络盘不支持 mode,忽略 */
174
+ }
175
+ if (process.platform !== 'win32') return
176
+ const user = process.env.USERDOMAIN && process.env.USERNAME
177
+ ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}`
178
+ : process.env.USERNAME
179
+ if (!user) return
180
+ try {
181
+ spawnSync(
182
+ 'icacls',
183
+ [target, '/inheritance:r', '/grant:r', isDir ? `${user}:(OI)(CI)F` : `${user}:F`],
184
+ { stdio: 'ignore', timeout: 5000, windowsHide: true },
185
+ )
186
+ } catch {
187
+ /* icacls 不存在或被策略拦住:保持默认 ACL */
188
+ }
189
+ }
190
+
191
+ /**
192
+ * 原子写一个 JSON(tmp + rename,0600)。失败抛可读错误。
193
+ * 导出给 lib/node/hub-pin.js 复用(副机 TOFU 钉住文件同一手法、同一口径)。
194
+ */
195
+ export function writeJsonAtomic(file, obj) {
196
+ const dir = path.dirname(file)
197
+ fs.mkdirSync(dir, { recursive: true })
198
+ restrictPermissions(dir, { isDir: true })
199
+ const tmp = `${file}.${process.pid}.tmp`
200
+ try {
201
+ fs.writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`, { mode: 0o600 })
202
+ restrictPermissions(tmp)
203
+ fs.renameSync(tmp, file)
204
+ } catch (err) {
205
+ try {
206
+ fs.rmSync(tmp, { force: true })
207
+ } catch {
208
+ /* 清理失败不影响报错 */
209
+ }
210
+ throw new Error(`写入身份文件失败:${file}(${err.message})`, { cause: err })
211
+ }
212
+ restrictPermissions(file)
213
+ }
214
+
215
+ /**
216
+ * hub 侧:读身份文件,没有/损坏就地重建。
217
+ *
218
+ * 三条语义:
219
+ * 1. 合法 → 原样返回(含 KeyObject),`created:false`;
220
+ * 2. 不存在 → 生成新的并落盘,`created:true`;
221
+ * 3. 存在但损坏 → **重建并覆盖**。这与 relay 的 tokens.json(损坏就抛错
222
+ * 等人修)不同,是刻意的:hub 身份重建后,所有已钉住指纹的副机会在
223
+ * 下次握手时**大声失败**——安全方向是 fail-closed,不会静默接受一个
224
+ * 新身份。而"hub 因为一个磁盘坏字节起不来"的可用性代价不可接受。
225
+ *
226
+ * 磁盘完全不可写时不抛:返回纯内存身份(hub 本次运行仍可认证,重启后
227
+ * 换身份 —— 副机会再次大声失败,可感知)。
228
+ */
229
+ export function loadOrCreateHubIdentity(file) {
230
+ if (typeof file !== 'string' || file.trim() === '') {
231
+ throw new Error('loadOrCreateHubIdentity: file 必须是非空路径')
232
+ }
233
+ const target = path.resolve(file)
234
+ if (fs.existsSync(target)) {
235
+ let parsed
236
+ try {
237
+ parsed = JSON.parse(fs.readFileSync(target, 'utf8'))
238
+ } catch {
239
+ parsed = null
240
+ }
241
+ const imported = importIdentity(parsed)
242
+ if (imported) {
243
+ restrictPermissions(target)
244
+ return { ...imported, file: target, created: false }
245
+ }
246
+ }
247
+ const fresh = generateIdentity()
248
+ try {
249
+ writeJsonAtomic(target, {
250
+ version: IDENTITY_FILE_VERSION,
251
+ ...fresh,
252
+ createdAt: new Date().toISOString(),
253
+ })
254
+ } catch {
255
+ /* 落盘失败见上:内存身份照用 */
256
+ }
257
+ const imported = importIdentity(fresh)
258
+ return { ...imported, file: target, created: true }
259
+ }
260
+
261
+ /** 只读身份(setup 向导/探活用):不创建、不写盘。损坏或缺失 → null。 */
262
+ export function readHubIdentity(file) {
263
+ if (typeof file !== 'string' || file.trim() === '') return null
264
+ try {
265
+ if (!fs.existsSync(file)) return null
266
+ const imported = importIdentity(JSON.parse(fs.readFileSync(file, 'utf8')))
267
+ return imported ? { ...imported, file: path.resolve(file) } : null
268
+ } catch {
269
+ return null
270
+ }
271
+ }