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,781 @@
1
+ /**
2
+ * 主机侧产物拉取管理器(零外部依赖)。
3
+ *
4
+ * 契约(用户锁定的 v0.1):
5
+ * 1. **清单先于字节** —— 任务结束只回 manifest + handoff;文件要 hub 显式 pull 才过河。
6
+ * 2. **只有 role:final 过河** —— intermediate 在副机留存,hub 侧连字节都拿不到。
7
+ * 3. **hash 先声明后校验** —— 分块帧头带 manifest 里的 sha256;hub 边写边算,
8
+ * 收 done 时比对。不一致就删掉临时文件并报 hash-mismatch,绝不静默接受半截字节。
9
+ *
10
+ * 落盘策略:先写 `<dest>/.dsh-remote-agent-<pullId>.part`,校验通过才 rename 到最终路径。
11
+ * rename 在同一目录内是原子的,所以 dest 里要么是完整正确的文件,要么什么都没有。
12
+ *
13
+ * 断点续传(v0.2):传输中断类失败(空闲超时 / 节点掉线 / 不可达 / 调用方 abort)
14
+ * 不再丢弃已收字节,而是把半成品**晋升**为稳定命名的
15
+ * `.dsh-remote-agent-resume-<key>.part` + meta 旁车 `.json`(记录全文期望 sha256、
16
+ * 已收前缀的 sha256、字节数)。下次 pull 同一 (dest, relPath) 时校验通过就从
17
+ * 中段续传 —— 下发给副机的 pull 帧带 `startOffset`,副机从该偏移起流。
18
+ * 内容级失败(hash 不符 / 乱序 / 写失败 / 超限 / 显式取消 / hub 关闭)仍整体重来。
19
+ *
20
+ * 资源纪律:每个 pull 一个 fd + 一个定时器;任何失败路径都必须把 fd 关掉
21
+ * —— 半成品要么删掉、要么晋升为可续传 partial,绝不留悬空 fd。
22
+ */
23
+ import { createHash } from 'node:crypto'
24
+ import fs from 'node:fs'
25
+ import path from 'node:path'
26
+ import {
27
+ ARTIFACT_ROLE,
28
+ DEFAULT_PULL_TIMEOUT_MS,
29
+ ERROR_CODE,
30
+ HUB_TO_NODE,
31
+ RemoteAgentError,
32
+ newPullId,
33
+ } from '../protocol.js'
34
+ import { normalizePath } from '../glob.js'
35
+
36
+ /** 临时文件前缀:一眼能看出是远程产物拉取的中间态。 */
37
+ const TMP_PREFIX = '.dsh-remote-agent-'
38
+
39
+ /** 续传 partial 的稳定前缀(跨 pullId 持久,meta 旁车为同名 .json)。 */
40
+ const RESUME_PREFIX = `${TMP_PREFIX}resume-`
41
+
42
+ /** 断点续传 partial 默认保留时长:7 天。limits.partialTtlMs = 0 可整体禁用续传。 */
43
+ const DEFAULT_PARTIAL_TTL_MS = 7 * 24 * 60 * 60 * 1000
44
+
45
+ /**
46
+ * Windows 保留设备名(DOS 时代遗留,至今仍在 Win32 层生效)。
47
+ * `dest\con` 这类路径会被解析成设备而非文件;在部分 API 上会失败或产生怪异行为。
48
+ * 当前实测 openSync 落成普通文件,不构成逃逸,但跨机器可移植性隐患真实存在,
49
+ * 所以统一加前缀规避 —— 代价只是文件名多几个字符。
50
+ */
51
+ const WINDOWS_RESERVED = new Set([
52
+ 'con', 'prn', 'aux', 'nul',
53
+ 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
54
+ 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
55
+ ])
56
+
57
+ /**
58
+ * 把产物相对路径收敛成一个安全的落盘文件名。
59
+ *
60
+ * 只取最后一段:清单里的 `dist/app.exe` 落到 `dest/app.exe`。
61
+ * 这样 `../../etc/passwd` 这种路径在 hub 侧就被折平,dest 之外一个字节都写不出去。
62
+ */
63
+ export function safeDestName(relPath) {
64
+ const norm = normalizePath(String(relPath ?? ''))
65
+ const base = norm.slice(norm.lastIndexOf('/') + 1)
66
+ // eslint-disable-next-line no-control-regex -- 控制字符正是要清洗的对象(0x00-0x1f 落盘会截断/乱码)
67
+ let cleaned = base.replace(/[\u0000-\u001f<>:"|?*]/g, '_').replace(/^\.+$/, '')
68
+
69
+ // 去掉尾部空格与点:Windows 会在创建时静默剥掉,导致落盘名与清单名不一致
70
+ cleaned = cleaned.replace(/[ .]+$/, '')
71
+
72
+ // 保留设备名:`con.txt` 同样命中(Win32 只看扩展名之前的基名)
73
+ const stem = cleaned.split('.')[0].toLowerCase()
74
+ if (WINDOWS_RESERVED.has(stem)) cleaned = `_${cleaned}`
75
+
76
+ return cleaned === '' ? `artifact-${Date.now().toString(36)}` : cleaned
77
+ }
78
+
79
+ /**
80
+ * 断点续传的稳定键:由 (destDir, relPath) 派生。
81
+ *
82
+ * 半成品不能按 pullId 命名持久化 —— 每次重试 pullId 都是新随机的,旧半成品
83
+ * 永远配不上新 pull。按落盘位置 + 清单路径派生,重试天然命中同一份 partial。
84
+ */
85
+ export function resumeKeyOf(destDir, relPath) {
86
+ return createHash('sha256').update(`${destDir}\n${normalizePath(relPath)}`).digest('hex').slice(0, 16)
87
+ }
88
+
89
+ export class PullManager {
90
+ /**
91
+ * @param {object} o
92
+ * @param {import('./node-registry.js').NodeRegistry} o.registry
93
+ * @param {(level:string,msg:string,extra?:object)=>void} o.log
94
+ * @param {object} [o.limits]
95
+ * @param {object|null} [o.audit] 审计写入器(提案 #19,null/缺省 = 关闭)
96
+ */
97
+ constructor({ registry, log, limits = {}, audit = null }) {
98
+ this.registry = registry
99
+ this.log = log ?? (() => {})
100
+ /** 审计绝不影响 pull 链路:record 包 try,异常吞掉。 */
101
+ this.audit = audit && typeof audit.record === 'function' ? audit : null
102
+ this.limits = {
103
+ pullTimeoutMs: DEFAULT_PULL_TIMEOUT_MS,
104
+ maxPullBytes: 512 * 1024 * 1024,
105
+ partialTtlMs: DEFAULT_PARTIAL_TTL_MS,
106
+ ...limits,
107
+ }
108
+ /** pullId → session */
109
+ this.sessions = new Map()
110
+ /** taskId → Set<pullId>,便于按任务取消 */
111
+ this.byTask = new Map()
112
+ this.closed = false
113
+ }
114
+
115
+ get activeCount() {
116
+ return this.sessions.size
117
+ }
118
+
119
+ /**
120
+ * 拉一个产物。
121
+ *
122
+ * @param {object} o
123
+ * @param {string} o.nodeId
124
+ * @param {string} o.taskId
125
+ * @param {string} o.relPath 清单内的相对路径
126
+ * @param {string} o.dest 本机目标目录(绝对路径)
127
+ * @param {AbortSignal} [o.signal]
128
+ * @param {number} [o.timeoutMs]
129
+ * @param {object|null} [o.manifest] 已知清单,用于 hub 侧先拦一次
130
+ * @returns {Promise<{path:string, bytes:number, sha256:string|null, dest:string}>}
131
+ */
132
+ pull(o) {
133
+ const { nodeId, taskId, relPath, dest, signal, manifest } = o
134
+ const timeoutMs = Number.isFinite(o.timeoutMs) && o.timeoutMs > 0 ? o.timeoutMs : this.limits.pullTimeoutMs
135
+
136
+ if (this.closed) {
137
+ return Promise.reject(new RemoteAgentError('pull manager closed', ERROR_CODE.INTERNAL))
138
+ }
139
+
140
+ // hub 侧预检:清单已知时先拦掉 intermediate / 不存在 / 超限的路径。
141
+ // 副机侧还有一次权威校验(authorizePull),这里是省一次往返。
142
+ if (manifest && Array.isArray(manifest.final)) {
143
+ const item = manifest.final.find((i) => normalizePath(i.path) === normalizePath(relPath))
144
+ if (!item) {
145
+ return Promise.reject(
146
+ new RemoteAgentError(`artifact not in manifest: ${relPath}`, ERROR_CODE.ARTIFACT_MISSING),
147
+ )
148
+ }
149
+ if (typeof item.bytes === 'number' && item.bytes > this.limits.maxPullBytes) {
150
+ return Promise.reject(
151
+ new RemoteAgentError(`${item.bytes} bytes exceeds maxPullBytes=${this.limits.maxPullBytes}`, ERROR_CODE.ARTIFACT_TOO_LARGE),
152
+ )
153
+ }
154
+ }
155
+
156
+ const rec = this.registry.getNode(nodeId)
157
+ if (!rec) {
158
+ return Promise.reject(new RemoteAgentError(`unknown node: ${nodeId}`, ERROR_CODE.TASK_UNKNOWN))
159
+ }
160
+ if (!rec.online) {
161
+ return Promise.reject(new RemoteAgentError(`node offline: ${nodeId}`, ERROR_CODE.INTERNAL))
162
+ }
163
+
164
+ let destDir
165
+ try {
166
+ destDir = path.resolve(dest)
167
+ fs.mkdirSync(destDir, { recursive: true })
168
+ } catch (err) {
169
+ return Promise.reject(
170
+ new RemoteAgentError(`cannot create dest dir ${dest}: ${String(err?.message ?? err)}`, ERROR_CODE.INTERNAL),
171
+ )
172
+ }
173
+
174
+ const finalPath = path.join(destDir, safeDestName(relPath))
175
+ const pullId = newPullId()
176
+ const tmpPath = path.join(destDir, `${TMP_PREFIX}${pullId}.part`)
177
+ // 断点续传:半成品按 (dest, relPath) 稳定命名,跨 pullId 持久
178
+ const rKey = resumeKeyOf(destDir, relPath)
179
+ const resumePartPath = path.join(destDir, `${RESUME_PREFIX}${rKey}.part`)
180
+ const resumeMetaPath = path.join(destDir, `${RESUME_PREFIX}${rKey}.json`)
181
+
182
+ // 同一 (dest, relPath) 不允许并发 pull:两个 session 会写同一份 resume
183
+ // partial、互相覆盖 finalPath,结果不可预测。明确拒绝,不赌运气。
184
+ for (const other of this.sessions.values()) {
185
+ if (other.resumeKey === rKey) {
186
+ return Promise.reject(
187
+ new RemoteAgentError(`another pull for the same artifact is in progress: ${relPath}`, ERROR_CODE.INTERNAL),
188
+ )
189
+ }
190
+ }
191
+
192
+ // 清单已知时记录期望全文 sha:续传探测用它确认半成品属于同一份内容
193
+ let expectedSha256 = null
194
+ if (manifest && Array.isArray(manifest.final)) {
195
+ const item = manifest.final.find((i) => normalizePath(i.path) === normalizePath(relPath))
196
+ if (item && typeof item.sha256 === 'string') expectedSha256 = item.sha256
197
+ }
198
+
199
+ let fd
200
+ try {
201
+ fd = fs.openSync(tmpPath, 'wx')
202
+ } catch (err) {
203
+ return Promise.reject(
204
+ new RemoteAgentError(`cannot create temp file ${tmpPath}: ${String(err?.message ?? err)}`, ERROR_CODE.INTERNAL),
205
+ )
206
+ }
207
+
208
+ const session = {
209
+ pullId,
210
+ nodeId,
211
+ taskId,
212
+ relPath,
213
+ destDir,
214
+ finalPath,
215
+ tmpPath,
216
+ resumeKey: rKey,
217
+ resumePartPath,
218
+ resumeMetaPath,
219
+ // fd 实际打开的文件:全新 pull 是 tmpPath,续传时切到 resumePartPath
220
+ partPath: tmpPath,
221
+ isResume: false,
222
+ expectedSha256,
223
+ fd,
224
+ hash: createHash('sha256'),
225
+ received: 0,
226
+ total: null,
227
+ declaredSha256: null,
228
+ expectedBytes: null,
229
+ // 本条 pull 的空闲超时预算。**每块重置时必须用它自己这份**,
230
+ // 不能回退到 limits.pullTimeoutMs —— 否则调用方传进来的自定义
231
+ // 短超时在第一个分块之后就静默失效(实测踩过)。
232
+ timeoutMs,
233
+ done: false,
234
+ settled: false,
235
+ timer: undefined,
236
+ signal,
237
+ onAbort: undefined,
238
+ resolve: undefined,
239
+ reject: undefined,
240
+ startedAt: Date.now(),
241
+ }
242
+
243
+ const promise = new Promise((resolve, reject) => {
244
+ session.resolve = resolve
245
+ session.reject = reject
246
+ })
247
+
248
+ // 审计(提案 #19):拉取是"哪些成品过了河、落在哪"的证据链。start 在
249
+ // 校验全部通过后记(预检拒绝的 pull 没有审计价值);end 带 ok/error 收尾。
250
+ try {
251
+ this.audit?.record('pull.start', { taskId, nodeId, relPath, dest: destDir })
252
+ } catch {
253
+ /* 审计层的任何异常都不上抛 */
254
+ }
255
+ const audited = promise.then(
256
+ (v) => {
257
+ try {
258
+ this.audit?.record('pull.end', { taskId, nodeId, relPath, ok: true, bytes: v?.bytes })
259
+ } catch {
260
+ /* 已吞 */
261
+ }
262
+ return v
263
+ },
264
+ (err) => {
265
+ try {
266
+ this.audit?.record('pull.end', { taskId, nodeId, relPath, ok: false, error: err?.code ?? 'unknown' })
267
+ } catch {
268
+ /* 已吞 */
269
+ }
270
+ throw err
271
+ },
272
+ )
273
+
274
+ this.sessions.set(pullId, session)
275
+ const set = this.byTask.get(taskId) ?? new Set()
276
+ set.add(pullId)
277
+ this.byTask.set(taskId, set)
278
+ rec.pendingPulls.set(pullId, session)
279
+
280
+ if (signal) {
281
+ session.onAbort = () => this.failSession(pullId, new RemoteAgentError('pull aborted by caller', 'aborted'), true, true)
282
+ if (signal.aborted) {
283
+ session.onAbort()
284
+ return audited
285
+ }
286
+ signal.addEventListener('abort', session.onAbort, { once: true })
287
+ }
288
+
289
+ this.armTimeout(pullId, timeoutMs)
290
+
291
+ // 续传探测放在注册完成之后异步进行:探测要流式重读半成品(可能几百 MB),
292
+ // 不能挡事件循环;探测定夺 startOffset 后才向副机下发 pull 帧。
293
+ void this.dispatchPull(session)
294
+ this.log('info', 'artifact pull requested', { pullId, taskId, nodeId, path: relPath, dest: finalPath })
295
+ return audited
296
+ }
297
+
298
+ /** 续传探测 + 下发 pull 帧。探测/下发自身的错误按普通 pull 失败收尾。 */
299
+ async dispatchPull(session) {
300
+ try {
301
+ const startOffset = await this.probeResume(session)
302
+ if (session.settled) return
303
+ const sent = this.registry.send(session.nodeId, HUB_TO_NODE.ARTIFACT_PULL, {
304
+ pullId: session.pullId,
305
+ taskId: session.taskId,
306
+ path: session.relPath,
307
+ timeoutMs: session.timeoutMs,
308
+ startOffset,
309
+ })
310
+ if (!sent) {
311
+ this.failSession(
312
+ session.pullId,
313
+ new RemoteAgentError(`node unreachable: ${session.nodeId}`, ERROR_CODE.INTERNAL),
314
+ false,
315
+ true,
316
+ )
317
+ }
318
+ } catch (err) {
319
+ this.failSession(
320
+ session.pullId,
321
+ err instanceof RemoteAgentError ? err : new RemoteAgentError(String(err?.message ?? err), ERROR_CODE.INTERNAL),
322
+ false,
323
+ true,
324
+ )
325
+ }
326
+ }
327
+
328
+ /**
329
+ * 断点续传探测:找到同 (dest, relPath) 的半成品且全部校验通过时,把 session
330
+ * 切到续传形态(fd 开在 partial 上、hash 预喂前缀、received 从前缀起步)。
331
+ * 返回应下发给副机的 startOffset;任何校验不过都清掉半成品、从 0 开始。
332
+ */
333
+ async probeResume(session) {
334
+ if (this.limits.partialTtlMs === 0) return 0
335
+
336
+ let meta
337
+ try {
338
+ meta = JSON.parse(fs.readFileSync(session.resumeMetaPath, 'utf8'))
339
+ } catch {
340
+ // 没有 meta(首次 pull / 已被清理 / 崩溃残留的孤儿 partial)—— 一并清掉
341
+ this.cleanupPath(session.resumePartPath)
342
+ this.cleanupPath(session.resumeMetaPath)
343
+ return 0
344
+ }
345
+ const discard = () => {
346
+ this.cleanupPath(session.resumePartPath)
347
+ this.cleanupPath(session.resumeMetaPath)
348
+ return 0
349
+ }
350
+
351
+ if (meta?.v !== 1) return discard()
352
+ if (normalizePath(String(meta.relPath ?? '')) !== normalizePath(session.relPath)) return discard()
353
+ // 清单已知时:半成品必须属于同一份内容(文件在副机上被重新生成过就从头传)
354
+ if (session.expectedSha256 && meta.sha256 !== session.expectedSha256) return discard()
355
+ if (!Number.isFinite(meta.bytes) || meta.bytes <= 0 || !Number.isFinite(meta.updatedAt)) return discard()
356
+ if (Date.now() - meta.updatedAt > this.limits.partialTtlMs) return discard()
357
+
358
+ let st
359
+ try {
360
+ st = fs.statSync(session.resumePartPath)
361
+ } catch {
362
+ return discard()
363
+ }
364
+ // 磁盘上比 meta.bytes 大:hub 崩溃时最后一块已落盘但 meta 没更新 —— 只信
365
+ // meta.bytes 以内的前缀(hash 可验证),多余的尾部截掉。
366
+ if (!st.isFile() || st.size < meta.bytes) return discard()
367
+
368
+ // 前缀哈希校验。两个 hash 同读一份:hVerify 算完即弃,hSeed 喂进 session
369
+ // ——sha 对象 digest() 之后不能再 update,所以不能"先验证再复用同一个"。
370
+ const hSeed = createHash('sha256')
371
+ const hVerify = createHash('sha256')
372
+ try {
373
+ const stream = fs.createReadStream(session.resumePartPath, { end: meta.bytes - 1 })
374
+ for await (const chunk of stream) {
375
+ hSeed.update(chunk)
376
+ hVerify.update(chunk)
377
+ }
378
+ } catch {
379
+ return discard()
380
+ }
381
+ if (hVerify.digest('hex') !== meta.prefixSha256) return discard()
382
+
383
+ // 切到续传形态:弃掉空 tmp,fd 开在 partial 上,尾部截到 meta.bytes
384
+ try {
385
+ fs.closeSync(session.fd)
386
+ this.cleanupPath(session.tmpPath)
387
+ session.fd = fs.openSync(session.resumePartPath, 'r+')
388
+ fs.ftruncateSync(session.fd, meta.bytes)
389
+ } catch {
390
+ session.settled = true
391
+ throw new RemoteAgentError(
392
+ `cannot reopen partial for resume: ${session.resumePartPath}`,
393
+ ERROR_CODE.INTERNAL,
394
+ )
395
+ }
396
+ session.partPath = session.resumePartPath
397
+ session.isResume = true
398
+ session.resumeFrom = meta.bytes
399
+ session.hash = hSeed
400
+ session.received = meta.bytes
401
+ this.log('info', 'artifact pull resuming', { pullId: session.pullId, path: session.relPath, already: meta.bytes })
402
+ return meta.bytes
403
+ }
404
+
405
+ /** 空闲超时:每收到一块就重置。大文件慢传不会被误杀,卡死会被收掉。 */
406
+ armTimeout(pullId, timeoutMs) {
407
+ const s = this.sessions.get(pullId)
408
+ if (!s) return
409
+ if (s.timer) clearTimeout(s.timer)
410
+ s.timer = setTimeout(() => {
411
+ this.failSession(
412
+ pullId,
413
+ new RemoteAgentError(`pull timeout: no chunk for ${timeoutMs}ms`, ERROR_CODE.INTERNAL),
414
+ true,
415
+ true,
416
+ )
417
+ }, timeoutMs)
418
+ if (s.timer.unref) s.timer.unref()
419
+ }
420
+
421
+ /**
422
+ * 收一个二进制分块帧。顺序错、长度超限、pullId 未知一律按失败处理并清理。
423
+ */
424
+ ingestBinary(header, payload) {
425
+ const pullId = header?.pullId
426
+ if (typeof pullId !== 'string') {
427
+ this.log('warn', 'artifact chunk without pullId')
428
+ return
429
+ }
430
+ const s = this.sessions.get(pullId)
431
+ if (!s) {
432
+ // 已经超时/取消的 pull 还在收字节:丢弃即可,不能抛
433
+ return
434
+ }
435
+ if (s.settled) return
436
+
437
+ // offset 必填:副机的 artifact-service 总是带 offset/total 发块(同一份包
438
+ // 一起发版,协议 v1 内没有"不带 offset 的对端")。缺 offset 只可能来自
439
+ // 畸形帧 / 第三方实现 —— 按"顺序无法确认"处理,与注释声明的语义一致,
440
+ // 而不是静默跳过校验继续写盘。
441
+ const offset = Number(header.offset)
442
+ if (!Number.isFinite(offset)) {
443
+ this.failSession(
444
+ pullId,
445
+ new RemoteAgentError('artifact chunk missing offset (malformed frame)', ERROR_CODE.HASH_MISMATCH),
446
+ true,
447
+ )
448
+ return
449
+ }
450
+ if (offset !== s.received) {
451
+ this.failSession(
452
+ pullId,
453
+ new RemoteAgentError(
454
+ `artifact chunk out of order: expected offset ${s.received}, got ${offset}`,
455
+ ERROR_CODE.HASH_MISMATCH,
456
+ ),
457
+ true,
458
+ )
459
+ return
460
+ }
461
+
462
+ const declared = typeof header.sha256 === 'string' ? header.sha256 : null
463
+ if (declared) s.declaredSha256 = declared
464
+ if (Number.isFinite(header.total)) s.total = header.total
465
+ if (s.expectedBytes !== null && s.received + payload.length > s.expectedBytes) {
466
+ this.failSession(
467
+ pullId,
468
+ new RemoteAgentError(`artifact larger than declared: ${s.received + payload.length} > ${s.expectedBytes}`, ERROR_CODE.HASH_MISMATCH),
469
+ true,
470
+ )
471
+ return
472
+ }
473
+ if (s.received + payload.length > this.limits.maxPullBytes) {
474
+ this.failSession(
475
+ pullId,
476
+ new RemoteAgentError(`artifact exceeds maxPullBytes=${this.limits.maxPullBytes}`, ERROR_CODE.ARTIFACT_TOO_LARGE),
477
+ true,
478
+ )
479
+ return
480
+ }
481
+
482
+ try {
483
+ // ⚠ `writeSync` **可能部分写**,必须检查返回值。
484
+ //
485
+ // 原来忽略了它 —— 部分写会让文件缺字节,虽然最终 sha256 校验会兜住
486
+ // (不会把坏文件当成品),但代价是**白传一整份产物**才失败。
487
+ // 明确失败更早、更省,也让"为什么失败"可诊断。
488
+ //
489
+ // 注意 `writeSync(fd, buf, offset, length)` 可能写不完就返回,
490
+ // 所以这里循环直到写完或明确失败。
491
+ let written = 0
492
+ while (written < payload.length) {
493
+ // 第 5 参 position 必须显式:续传时 fd 开在 partial('r+')上,
494
+ // 隐式文件游标从 0 起会把前缀覆盖掉;显式按「已收位置 + 本次偏移」写。
495
+ const n = fs.writeSync(s.fd, payload, written, payload.length - written, s.received + written)
496
+ if (!Number.isFinite(n) || n <= 0) {
497
+ throw new Error(`short write: wrote ${written}/${payload.length} bytes`)
498
+ }
499
+ written += n
500
+ }
501
+ } catch (err) {
502
+ this.failSession(
503
+ pullId,
504
+ new RemoteAgentError(`write failed: ${String(err?.message ?? err)}`, ERROR_CODE.INTERNAL),
505
+ true,
506
+ )
507
+ return
508
+ }
509
+ s.hash.update(payload)
510
+ s.received += payload.length
511
+ s.lastChunkAt = Date.now()
512
+ // 用**本条 pull 自己的**预算重臂(见 session.timeoutMs 的说明)。
513
+ this.armTimeout(pullId, s.timeoutMs)
514
+ }
515
+
516
+ /**
517
+ * 收 artifact/done(文本帧或二进制帧头都走这里):校验 sha256 后收尾。
518
+ */
519
+ ingestDone(header) {
520
+ const pullId = header?.pullId
521
+ if (typeof pullId !== 'string') return
522
+ const s = this.sessions.get(pullId)
523
+ if (!s || s.settled) return
524
+
525
+ const declared = typeof header.sha256 === 'string' ? header.sha256 : null
526
+ const expected = typeof header.expected === 'string' ? header.expected : null
527
+ const declaredTotal = Number.isFinite(header.total) ? header.total : s.total
528
+ const expectedBytes = Number.isFinite(header.bytes) ? header.bytes : declaredTotal
529
+
530
+ const actual = s.hash.digest('hex')
531
+ const want = s.declaredSha256 ?? expected ?? null
532
+
533
+ // 三重比对:hub 本地算出的、副机声明的、分块头声明的,必须彼此一致。
534
+ if (declared && actual !== declared) {
535
+ this.failSession(
536
+ pullId,
537
+ new RemoteAgentError(
538
+ `hash-mismatch: node reported ${declared} but hub computed ${actual}`,
539
+ ERROR_CODE.HASH_MISMATCH,
540
+ ),
541
+ true,
542
+ )
543
+ return
544
+ }
545
+ if (want && actual !== want) {
546
+ this.failSession(
547
+ pullId,
548
+ new RemoteAgentError(
549
+ `hash-mismatch: declared ${want} but received bytes hash to ${actual}`,
550
+ ERROR_CODE.HASH_MISMATCH,
551
+ ),
552
+ true,
553
+ )
554
+ return
555
+ }
556
+ if (Number.isFinite(expectedBytes) && expectedBytes !== s.received) {
557
+ this.failSession(
558
+ pullId,
559
+ new RemoteAgentError(
560
+ `size-mismatch: declared ${expectedBytes} bytes, received ${s.received}`,
561
+ ERROR_CODE.HASH_MISMATCH,
562
+ ),
563
+ true,
564
+ )
565
+ return
566
+ }
567
+ if (header.verified === false && want) {
568
+ this.failSession(
569
+ pullId,
570
+ new RemoteAgentError('node-side hash verification failed', ERROR_CODE.HASH_MISMATCH),
571
+ true,
572
+ )
573
+ return
574
+ }
575
+
576
+ this.settleSuccess(pullId, actual)
577
+ }
578
+
579
+ settleSuccess(pullId, sha256) {
580
+ const s = this.sessions.get(pullId)
581
+ if (!s || s.settled) return
582
+ s.settled = true
583
+ this.clearSession(s)
584
+ try {
585
+ fs.closeSync(s.fd)
586
+ } catch {
587
+ /* 已关闭 */
588
+ }
589
+ s.fd = undefined
590
+
591
+ let renamed = false
592
+ try {
593
+ fs.renameSync(s.partPath, s.finalPath)
594
+ renamed = true
595
+ } catch (err) {
596
+ // 目标已存在时 rename 在 Windows 上会失败:先删目标再 rename,仍然失败就报错
597
+ try {
598
+ fs.unlinkSync(s.finalPath)
599
+ fs.renameSync(s.partPath, s.finalPath)
600
+ renamed = true
601
+ } catch (err2) {
602
+ this.log('warn', 'artifact rename failed', {
603
+ pullId,
604
+ error: String(err2?.message ?? err2 ?? err),
605
+ })
606
+ }
607
+ }
608
+
609
+ if (!renamed) {
610
+ this.cleanupTemp(s)
611
+ s.reject(new RemoteAgentError(`cannot finalize artifact at ${s.finalPath}`, ERROR_CODE.INTERNAL))
612
+ return
613
+ }
614
+
615
+ // 成功收尾:续传 meta 旁车一并清掉(partial 本体已 rename 成 finalPath)
616
+ this.cleanupPath(s.resumeMetaPath)
617
+
618
+ this.log('info', 'artifact pulled', {
619
+ pullId,
620
+ path: s.relPath,
621
+ bytes: s.received,
622
+ dest: s.finalPath,
623
+ resumed: s.resumeFrom ?? undefined,
624
+ })
625
+ s.resolve({
626
+ path: s.relPath,
627
+ bytes: s.received,
628
+ sha256: sha256 ?? null,
629
+ dest: s.finalPath,
630
+ })
631
+ }
632
+
633
+ /**
634
+ * 失败收尾:清定时器、关 fd,然后 reject。
635
+ *
636
+ * keepPartial=true(传输中断类:空闲超时 / 节点掉线 / 不可达 / 调用方 abort)
637
+ * 且已收到字节时,半成品**晋升**为稳定命名的 resume partial + meta 旁车,
638
+ * 等下次 pull 续传;其余失败(内容级 / 显式取消 / hub 关闭)照旧删干净。
639
+ */
640
+ failSession(pullId, err, notifyNode, keepPartial = false) {
641
+ const s = this.sessions.get(pullId)
642
+ if (!s || s.settled) return
643
+ s.settled = true
644
+ this.clearSession(s)
645
+ try {
646
+ fs.closeSync(s.fd)
647
+ } catch {
648
+ /* 已关闭 */
649
+ }
650
+ s.fd = undefined
651
+ if (keepPartial && s.received > 0) {
652
+ this.promotePartial(s)
653
+ } else {
654
+ this.cleanupTemp(s)
655
+ }
656
+ if (notifyNode) {
657
+ try {
658
+ this.registry.send(s.nodeId, HUB_TO_NODE.ARTIFACT_CANCEL, { pullId, reason: err?.code ?? 'error' })
659
+ } catch {
660
+ /* 节点可能已断 */
661
+ }
662
+ }
663
+ this.log('warn', 'artifact pull failed', {
664
+ pullId,
665
+ path: s.relPath,
666
+ code: err?.code,
667
+ message: err?.message,
668
+ keptForResume: keepPartial && s.received > 0,
669
+ })
670
+ s.reject(err)
671
+ }
672
+
673
+ /** 把半成品晋升为可续传 partial:稳定命名 + meta 旁车(全文期望 sha / 前缀 sha / 字节数)。 */
674
+ promotePartial(s) {
675
+ try {
676
+ if (!s.isResume) {
677
+ // 全新 pull:tmp → 稳定命名(已有同名先清,孤儿 partial 也会走到这)
678
+ this.cleanupPath(s.resumePartPath)
679
+ fs.renameSync(s.tmpPath, s.resumePartPath)
680
+ }
681
+ // isResume 时 fd 本来就开在稳定命名的 partial 上,只需更新 meta
682
+ fs.writeFileSync(s.resumeMetaPath, JSON.stringify({
683
+ v: 1,
684
+ relPath: normalizePath(s.relPath),
685
+ taskId: s.taskId,
686
+ nodeId: s.nodeId,
687
+ sha256: s.declaredSha256,
688
+ prefixSha256: s.hash.digest('hex'),
689
+ bytes: s.received,
690
+ updatedAt: Date.now(),
691
+ }))
692
+ this.log('info', 'artifact partial kept for resume', {
693
+ pullId: s.pullId,
694
+ path: s.relPath,
695
+ bytes: s.received,
696
+ })
697
+ } catch {
698
+ // 晋升失败不影响失败语义本身;残留文件下次 probeResume 会校验后清理
699
+ }
700
+ }
701
+
702
+ cleanupTemp(s) {
703
+ this.cleanupPath(s.partPath)
704
+ }
705
+
706
+ cleanupPath(p) {
707
+ try {
708
+ fs.unlinkSync(p)
709
+ } catch {
710
+ /* 文件可能已经不在了 */
711
+ }
712
+ }
713
+
714
+ clearSession(s) {
715
+ if (s.timer) {
716
+ clearTimeout(s.timer)
717
+ s.timer = undefined
718
+ }
719
+ if (s.onAbort && s.signal) {
720
+ try {
721
+ s.signal.removeEventListener('abort', s.onAbort)
722
+ } catch {
723
+ /* signal 已失效 */
724
+ }
725
+ }
726
+ this.sessions.delete(s.pullId)
727
+ const set = this.byTask.get(s.taskId)
728
+ if (set) {
729
+ set.delete(s.pullId)
730
+ if (set.size === 0) this.byTask.delete(s.taskId)
731
+ }
732
+ const rec = this.registry.getNode(s.nodeId)
733
+ rec?.pendingPulls?.delete(s.pullId)
734
+ }
735
+
736
+ /** 取消一个任务名下所有在途 pull。 */
737
+ cancelTask(taskId) {
738
+ const set = this.byTask.get(taskId)
739
+ if (!set) return 0
740
+ let n = 0
741
+ for (const pullId of [...set]) {
742
+ this.failSession(pullId, new RemoteAgentError('pull cancelled', 'aborted'), true)
743
+ n += 1
744
+ }
745
+ return n
746
+ }
747
+
748
+ /** 取消单个 pull(供内部/调试使用)。 */
749
+ cancel(pullId) {
750
+ if (!this.sessions.has(pullId)) return false
751
+ this.failSession(pullId, new RemoteAgentError('pull cancelled', 'aborted'), true)
752
+ return true
753
+ }
754
+
755
+ /** 节点掉线:该节点所有在途 pull 立即失败;传输中断类,半成品保留等续传。 */
756
+ onNodeOffline(nodeId) {
757
+ for (const s of [...this.sessions.values()]) {
758
+ if (s.nodeId !== nodeId) continue
759
+ this.failSession(s.pullId, new RemoteAgentError(`node offline: ${nodeId}`, ERROR_CODE.INTERNAL), false, true)
760
+ }
761
+ }
762
+
763
+ /** 插件卸载 / hub 关闭。 */
764
+ dispose() {
765
+ if (this.closed) return
766
+ this.closed = true
767
+ for (const s of [...this.sessions.values()]) {
768
+ this.failSession(s.pullId, new RemoteAgentError('hub shutting down', 'aborted'), true)
769
+ }
770
+ this.sessions.clear()
771
+ this.byTask.clear()
772
+ }
773
+
774
+ /** 从 handoff/manifest 里挑出可 pull 的成品(工具描述与提示用)。 */
775
+ static finalPathsOf(manifest) {
776
+ if (!manifest || !Array.isArray(manifest.final)) return []
777
+ return manifest.final
778
+ .filter((i) => !i.role || i.role === ARTIFACT_ROLE.FINAL)
779
+ .map((i) => i.path)
780
+ }
781
+ }