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,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 打包自洽性检查:`package.json` 的 `files` / `exports` / `bin` / `dsh.bundle.patch`
4
+ * 指向的路径必须**真实存在**。
5
+ *
6
+ * ═══════════════════════════════════════════════════════════════════════════
7
+ * 为什么需要这条
8
+ * ═══════════════════════════════════════════════════════════════════════════
9
+ *
10
+ * 本包没有构建步骤:`lib/**` 就是 DSH 实际加载的产物。所以"文件是否在包里"
11
+ * 完全由 `package.json` 的 `files` 字段与 git 追踪状态决定 —— 而这两个地方
12
+ * 都容易漏:
13
+ *
14
+ * - 新增一个 `lib/foo.js` 但忘了加进 `files` → 从 git 装的人拿到一个缺文件的包
15
+ * - `exports` 里写了一个已删除的路径 → `import 'dsh-remote-agent/hub'` 报错
16
+ * - `dsh.bundle.patch` 指向不存在的 yml → loader 读不到 patch,插件行永不插入
17
+ * (**这是 v0.1 的一个真实 blocker**,由独立验证发现)
18
+ *
19
+ * 这些错误在开发机上都不会暴露(本地有完整目录树),只有用户安装时才炸。
20
+ * 所以必须在 CI 里挡。
21
+ *
22
+ * 退出码:0 = 全部一致;1 = 有缺失(打印每一条)。
23
+ */
24
+ import fs from 'node:fs'
25
+ import path from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+
28
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
29
+ const ROOT = path.resolve(HERE, '..')
30
+
31
+ const pkgPath = path.join(ROOT, 'package.json')
32
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
33
+
34
+ /** 把 glob 简化成"存在性检查":只处理 `dir` 与 `dir/**` 两种形态。 */
35
+ const exists = (rel) => {
36
+ const abs = path.join(ROOT, rel)
37
+ return fs.existsSync(abs)
38
+ }
39
+
40
+ const problems = []
41
+ const notes = []
42
+
43
+ // ── 1) files 字段 ────────────────────────────────────────────────────────────
44
+ const files = Array.isArray(pkg.files) ? pkg.files : []
45
+ if (files.length === 0) {
46
+ problems.push('package.json 没有 files 字段 —— 从 git 安装会带上一堆无关文件')
47
+ } else {
48
+ for (const f of files) {
49
+ // files 里的条目可以是目录或文件;不接受 glob(npm 的 files 也不支持 ** 语义)
50
+ if (!exists(f)) problems.push(`files 里的路径不存在: ${f}`)
51
+ }
52
+ notes.push(`files: ${files.length} 项,全部存在`)
53
+ }
54
+
55
+ // ── 2) exports ───────────────────────────────────────────────────────────────
56
+ const exportsMap = pkg.exports && typeof pkg.exports === 'object' ? pkg.exports : {}
57
+ for (const [key, target] of Object.entries(exportsMap)) {
58
+ // 目标可以是字符串或条件对象
59
+ const targets = typeof target === 'string' ? [target] : Object.values(target ?? {})
60
+ for (const t of targets) {
61
+ if (typeof t !== 'string') continue
62
+ if (!exists(t)) problems.push(`exports["${key}"] 指向不存在的路径: ${t}`)
63
+ }
64
+ }
65
+ notes.push(`exports: ${Object.keys(exportsMap).length} 项`)
66
+
67
+ // ── 3) main / bin ────────────────────────────────────────────────────────────
68
+ if (typeof pkg.main === 'string' && !exists(pkg.main)) {
69
+ problems.push(`main 指向不存在的路径: ${pkg.main}`)
70
+ }
71
+ for (const [name, target] of Object.entries(pkg.bin ?? {})) {
72
+ if (!exists(target)) problems.push(`bin["${name}"] 指向不存在的路径: ${target}`)
73
+ }
74
+
75
+ // ── 4) dsh.bundle.patch(DSH 专用:loader 靠它插入插件行)────────────────────
76
+ const patch = pkg.dsh?.bundle?.patch
77
+ if (typeof patch !== 'string') {
78
+ problems.push('dsh.bundle.patch 缺失 —— loader 读不到 patch,插件行永不插入')
79
+ } else if (!exists(patch)) {
80
+ problems.push(`dsh.bundle.patch 指向不存在的路径: ${patch}`)
81
+ } else {
82
+ // patch 必须真的是一条 insert(不能是空文件)
83
+ const body = fs.readFileSync(path.join(ROOT, patch), 'utf8')
84
+ if (!/insert:/.test(body)) {
85
+ problems.push(`dsh.bundle.patch (${patch}) 里没有 insert: —— loader 不会插入任何行`)
86
+ }
87
+ if (!/dsh-remote-agent/.test(body)) {
88
+ problems.push(`dsh.bundle.patch (${patch}) 里没有引用 dsh-remote-agent`)
89
+ }
90
+ notes.push(`dsh.bundle.patch: ${patch}(含 insert)`)
91
+ }
92
+
93
+ // ── 5) dsh.client 声明 ───────────────────────────────────────────────────────
94
+ if (!pkg.dsh?.client) {
95
+ problems.push('dsh.client 缺失 —— Web 面板不会加载')
96
+ } else {
97
+ if (pkg.dsh.client.platform !== 'web') {
98
+ problems.push(`dsh.client.platform 应为 "web",实际 ${JSON.stringify(pkg.dsh.client.platform)}`)
99
+ }
100
+ // client 半边必须真实存在
101
+ const clientEntry = pkg.exports?.['./client']
102
+ if (typeof clientEntry === 'string' && !exists(clientEntry)) {
103
+ problems.push(`dsh.client 声明了 web 半边,但 exports["./client"] 指向的文件不存在`)
104
+ }
105
+ }
106
+
107
+ // ── 6) scripts 引用的脚本文件 ────────────────────────────────────────────────
108
+ for (const [name, cmd] of Object.entries(pkg.scripts ?? {})) {
109
+ // 只检查 `node scripts/xxx.mjs` 这种直接形态
110
+ const m = /^node\s+(scripts\/[\w.-]+)/.exec(cmd)
111
+ if (m && !exists(m[1])) {
112
+ problems.push(`scripts["${name}"] 引用了不存在的文件: ${m[1]}`)
113
+ }
114
+ }
115
+
116
+ // ── 输出 ─────────────────────────────────────────────────────────────────────
117
+ console.log('打包自洽性检查')
118
+ console.log('─'.repeat(60))
119
+ for (const n of notes) console.log(` ✓ ${n}`)
120
+
121
+ if (problems.length === 0) {
122
+ console.log('─'.repeat(60))
123
+ console.log(` ✓ 全部一致(${notes.length} 项通过)`)
124
+ process.exit(0)
125
+ }
126
+
127
+ console.log('─'.repeat(60))
128
+ console.log(` ✗ 发现 ${problems.length} 个问题:`)
129
+ for (const p of problems) console.log(` - ${p}`)
130
+ process.exit(1)
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 诊断:`inject` 契约是否与代码实际访问的服务匹配。
4
+ *
5
+ * ═══════════════════════════════════════════════════════════════════════════
6
+ * 为什么需要这个脚本
7
+ * ═══════════════════════════════════════════════════════════════════════════
8
+ *
9
+ * `inject` 声明与 `applyHub`/`applyWorker` 实际访问的服务**必须匹配**。
10
+ * 不匹配时的失败形态**特别隐蔽**,我们为它付出过一整轮排查:
11
+ *
12
+ * · **hub 半死**:`applyHub` 里工具注册在路由注册之后 —— 缺 `tools` 时
13
+ * 路由全部注册成功、`/healthz` 返回 200 还带着 `nodes`/`online` 实时计数,
14
+ * 但 **8 个 remote_* 工具全部消失**,插件管理页显示「异常」。
15
+ * · **agent 无工具**:副机缺 `tools` 时 agent 能推理、能调
16
+ * `load_workspace_dependencies`(DSH 全局注入的),但**没有 shell / 文件读写** ——
17
+ * 看起来"能用",实际干不了活。
18
+ *
19
+ * 两者的共同点是**没有明确报错**:一个是"一半活",一个是"能说话不能干活"。
20
+ *
21
+ * ── 本脚本做什么 ────────────────────────────────────────────────────────
22
+ *
23
+ * 1. 读包内 `cordis.patch.yml` 的 inject
24
+ * 2. 扫 `lib/hub/index.js` 的 `applyHub` 与 `lib/node/index.js` 的 worker 分支,
25
+ * 找出所有 `ctx.<service>` 属性访问(属性访问在未声明 inject 时会**抛**)
26
+ * 3. 断言这些服务都在 inject 里
27
+ * 4. 用**假 ctx** 真跑一遍 `applyHub`,确认:
28
+ * · inject 齐全时 8 个工具都注册
29
+ * · 缺 `tools` 时**不抛错**(只少工具 + 记日志)
30
+ *
31
+ * ── 用法 ────────────────────────────────────────────────────────────────
32
+ *
33
+ * node scripts/diag-inject.mjs # 人读
34
+ * node scripts/diag-inject.mjs --json # 机器读(CI 用)
35
+ *
36
+ * 退出码:0 = 契约成立;1 = 有问题(并打印是哪一条)。
37
+ */
38
+ import fs from 'node:fs'
39
+ import path from 'node:path'
40
+ import { fileURLToPath } from 'node:url'
41
+
42
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
43
+ const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8')
44
+ const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
45
+
46
+ const results = []
47
+ const check = (name, ok, detail = '') => results.push({ name, ok, detail })
48
+
49
+ /* ── 1. 读包内 inject ───────────────────────────────────────────────── */
50
+
51
+ /**
52
+ * 取**生效的** inject(锚定行首 —— 文件注释里有多处提到 inject,不锚定会命中注释)。
53
+ * 这个坑我们踩过:断言用了 `/inject:\s*(\[...\])/`,命中了注释里那句事故记录。
54
+ */
55
+ function declaredInject() {
56
+ const patch = read('cordis.patch.yml')
57
+ const m = patch.match(/^\s*inject:\s*(\[[^\]]*\])/m)
58
+ return m ? m[1] : ''
59
+ }
60
+
61
+ const injectRaw = declaredInject()
62
+ const declared = [...injectRaw.matchAll(/'([^']+)'|"([^"]+)"/g)].map((m) => m[1] || m[2])
63
+ check('包内 cordis.patch.yml 有 inject 声明', injectRaw !== '', injectRaw || '(未找到)')
64
+
65
+ /* ── 2. 扫代码里实际访问的服务 ──────────────────────────────────────── */
66
+
67
+ /** 非服务的方法名(`ctx.get` / `ctx.on` / …),不算服务访问。 */
68
+ const NON_SERVICES = new Set([
69
+ 'get', 'on', 'once', 'emit', 'effect', 'logger', 'plugin', 'root', 'scope',
70
+ 'inject', 'set', 'provide', 'isolate', 'intercept', 'parallel', 'serial',
71
+ 'waterfall', 'bail',
72
+ ])
73
+
74
+ /** 取某个具名函数的函数体(花括号配对)。 */
75
+ function bodyOf(src, header) {
76
+ const i = src.indexOf(header)
77
+ if (i < 0) return ''
78
+ const parenEnd = src.indexOf(')', i)
79
+ const braceStart = src.indexOf('{', parenEnd > 0 ? parenEnd : i)
80
+ if (braceStart < 0) return ''
81
+ let depth = 0
82
+ for (let k = braceStart; k < src.length; k += 1) {
83
+ if (src[k] === '{') depth += 1
84
+ else if (src[k] === '}') {
85
+ depth -= 1
86
+ if (depth === 0) return src.slice(i, k + 1)
87
+ }
88
+ }
89
+ return ''
90
+ }
91
+
92
+ /** 从一段代码里找出所有 `ctx.<service>` 属性访问。 */
93
+ function servicesAccessed(code) {
94
+ const found = new Set()
95
+ for (const m of code.matchAll(/ctx\.([a-zA-Z][\w]*)/g)) {
96
+ if (!NON_SERVICES.has(m[1])) found.add(m[1])
97
+ }
98
+ return found
99
+ }
100
+
101
+ const hubSrc = stripComments(read('lib/hub/index.js'))
102
+ const hubBody = bodyOf(hubSrc, 'export function applyHub')
103
+ check('找到 applyHub', hubBody !== '')
104
+ const hubServices = servicesAccessed(hubBody)
105
+
106
+ const workerSrc = stripComments(read('lib/node/index.js'))
107
+ const workerBody = bodyOf(workerSrc, 'function startWorker')
108
+ const workerServices = servicesAccessed(workerBody)
109
+
110
+ /* ── 3. 契约:hub 访问的服务必须在 inject 里 ────────────────────────── */
111
+
112
+ // 只查**会抛**的那几个(属性访问)。`ctx.get(name)` 是非严格的,不算。
113
+ const HUB_MUST = ['webServer', 'tools', 'systemPrompt']
114
+ for (const svc of HUB_MUST) {
115
+ if (!hubServices.has(svc)) continue
116
+ check(
117
+ `hub 需要的 '${svc}' 在 inject 里`,
118
+ declared.includes(svc),
119
+ declared.includes(svc) ? '' : `applyHub 访问 ctx.${svc},但 inject = ${injectRaw}`,
120
+ )
121
+ }
122
+
123
+ // worker 侧:agent 的 ctx 继承本行 inject,所以 tools 必须有
124
+ const WORKER_MUST = ['tools', 'agentLoop', 'sessions']
125
+ for (const svc of WORKER_MUST) {
126
+ if (!workerServices.has(svc)) continue
127
+ check(
128
+ `worker 需要的 '${svc}' 在 worker profile 模板里`,
129
+ (() => {
130
+ try {
131
+ return read('profiles/worker.cordis.yml').includes(`'${svc}'`)
132
+ } catch {
133
+ return false
134
+ }
135
+ })(),
136
+ `worker 分支访问 ctx.${svc}(agent 的 ctx 继承本行 inject,少了它 agent 拿不到该服务)`,
137
+ )
138
+ }
139
+
140
+ /* ── 4. 真跑一遍 applyHub(假 ctx)─────────────────────────────────── */
141
+
142
+ /**
143
+ * 造一个**模拟 cordis 语义**的 ctx:
144
+ * · 属性访问未声明的服务 → 抛(真实行为)
145
+ * · `ctx.get(name, false)` → 不管声明都返回(非严格)
146
+ */
147
+ function fakeCtx(injectList) {
148
+ const registered = { routes: [], tools: [], sections: [] }
149
+ const values = {
150
+ webServer: {
151
+ register(r) { registered.routes.push(r.path); return () => {} },
152
+ registerUpgrade(r) { registered.routes.push('upgrade:' + r.path); return () => {} },
153
+ },
154
+ tools: { register(t) { registered.tools.push(t.name); return () => {} } },
155
+ systemPrompt: { section(s) { registered.sections.push(s.name); return () => {} } },
156
+ }
157
+ const ctx = {
158
+ registered,
159
+ effect(fn) {
160
+ const d = fn()
161
+ return typeof d === 'function' ? d : () => {}
162
+ },
163
+ on() { return () => {} },
164
+ logger: { info() {}, warn() {}, error() {}, debug() {} },
165
+ get() { return undefined },
166
+ }
167
+ for (const [name, value] of Object.entries(values)) {
168
+ Object.defineProperty(ctx, name, {
169
+ get() {
170
+ if (!injectList.includes(name)) {
171
+ throw new Error(`cannot get property "${name}" without inject`)
172
+ }
173
+ return value
174
+ },
175
+ })
176
+ }
177
+ return ctx
178
+ }
179
+
180
+ let applyHub
181
+ try {
182
+ ;({ applyHub } = await import(new URL('../lib/hub/index.js', import.meta.url).href))
183
+ } catch (err) {
184
+ check('能 import applyHub', false, String(err?.message ?? err))
185
+ }
186
+
187
+ if (applyHub) {
188
+ // 4a. inject 齐全 → 不抛,且 8 个工具都在
189
+ try {
190
+ const ctx = fakeCtx(declared)
191
+ applyHub(ctx, { role: 'hub', nodeToken: 'nt_diag' })
192
+ check('inject 齐全时 applyHub 不抛', true)
193
+ check(
194
+ 'inject 齐全时注册 8 个 remote_* 工具',
195
+ ctx.registered.tools.length === 8,
196
+ `实际 ${ctx.registered.tools.length} 个:${ctx.registered.tools.join(', ') || '(无)'}`,
197
+ )
198
+ check('inject 齐全时注册了路由', ctx.registered.routes.length > 0, `${ctx.registered.routes.length} 条`)
199
+ } catch (err) {
200
+ check('inject 齐全时 applyHub 不抛', false, String(err?.message ?? err))
201
+ }
202
+
203
+ // 4b. 缺 tools → **不该抛**(容错),只少工具
204
+ try {
205
+ const ctx = fakeCtx(['webServer'])
206
+ applyHub(ctx, { role: 'hub', nodeToken: 'nt_diag' })
207
+ check('缺 tools 时 applyHub 仍不抛(容错生效)', true)
208
+ check(
209
+ '缺 tools 时没有工具(符合预期,不是 bug)',
210
+ ctx.registered.tools.length === 0,
211
+ `实际 ${ctx.registered.tools.length} 个`,
212
+ )
213
+ check('缺 tools 时路由仍注册(半活但不挂)', ctx.registered.routes.length > 0, `${ctx.registered.routes.length} 条`)
214
+ } catch (err) {
215
+ check(
216
+ '缺 tools 时 applyHub 仍不抛(容错生效)',
217
+ false,
218
+ `抛了 ${String(err?.message ?? err)} —— lib/hub/index.js 的 tools 注册段缺 try/catch`,
219
+ )
220
+ }
221
+ }
222
+
223
+ /* ── 输出 ───────────────────────────────────────────────────────────── */
224
+
225
+ const failed = results.filter((r) => !r.ok)
226
+
227
+ if (process.argv.includes('--json')) {
228
+ console.log(JSON.stringify({ total: results.length, failed: failed.length, results }, null, 2))
229
+ } else {
230
+ console.log('inject 契约诊断')
231
+ console.log('─'.repeat(64))
232
+ console.log(` 包内 inject: ${injectRaw || '(未找到)'}`)
233
+ console.log(` hub 访问的服务: ${[...hubServices].sort().join(', ') || '(无)'}`)
234
+ console.log(` worker 访问的服务: ${[...workerServices].sort().join(', ') || '(无)'}`)
235
+ console.log('─'.repeat(64))
236
+ for (const r of results) {
237
+ console.log(` ${r.ok ? 'OK ' : 'FAIL'} ${r.name}${r.detail ? `\n ${r.detail}` : ''}`)
238
+ }
239
+ console.log('─'.repeat(64))
240
+ console.log(failed.length === 0 ? ` ✓ 全部通过(${results.length} 项)` : ` ✗ ${failed.length}/${results.length} 项失败`)
241
+ }
242
+
243
+ process.exit(failed.length === 0 ? 0 : 1)
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * e2e-live-check —— 发布前的真实运行时端到端自检(挂到正在跑的 DSH 上)。
4
+ *
5
+ * 与 test/ 的区别:测试是**进程内**装配(真协议真传输,同一 node 进程);
6
+ * 本工具把 worker 跑在**独立的操作系统进程**里,通过真实 TCP/WebSocket
7
+ * 拨入**正在运行的 DSH 桌面版 hub**(真实 webServer 路由、真实 upgrade
8
+ * 握手、真实 registry)。
9
+ *
10
+ * ⚠ 派发为什么不是全自动:实测 hub 的 webserver 除 /healthz 外整台被
11
+ * DSH 会话围栏保护(requestRejection,桌面渲染器外一律 401)——这是
12
+ * **安全设计生效的证据**(UI 路由不裸奔)。所以本工具分两段:
13
+ * ① 自动:拨入 → 确认 online(最难的一段:真实握手/鉴权/注册)
14
+ * ② 人肉一步:由你从 DSH 桌面给这个节点派一个 exec 任务,
15
+ * 工具监视副机 TaskStore 落盘记录 + roots 产物,自动验证状态机
16
+ * 与 handoff/清单,并做 sha256 自校验。
17
+ *
18
+ * 用法:
19
+ * node scripts/e2e-live-check.mjs --hub-url ws://127.0.0.1:19387/api/remote-agent/node \
20
+ * [--token <nt_...>] [--node-id e2e-live-worker] [--watch-seconds 90] [--keep]
21
+ *
22
+ * 退出码:0 = 拨入+任务链路通过;1 = 失败(附人话诊断);3 = 仅拨入通过但超时未见任务。
23
+ */
24
+ import fs from 'node:fs'
25
+ import os from 'node:os'
26
+ import path from 'node:path'
27
+ import process from 'node:process'
28
+ import { createHash } from 'node:crypto'
29
+ import { parseArgs } from 'node:util'
30
+ import { applyWorker } from '../lib/node/index.js'
31
+
32
+ const { values } = parseArgs({
33
+ options: {
34
+ 'hub-url': { type: 'string' },
35
+ token: { type: 'string', default: '' },
36
+ 'node-id': { type: 'string', default: 'e2e-live-worker' },
37
+ 'roots': { type: 'string' }, // 逗号分隔;默认建临时目录
38
+ 'watch-seconds': { type: 'string', default: '120' },
39
+ keep: { type: 'boolean', default: false },
40
+ },
41
+ })
42
+
43
+ const say = (m = '') => console.log(m)
44
+ const hubUrl = values['hub-url'] ?? ''
45
+ if (hubUrl === '') {
46
+ say('用法:node scripts/e2e-live-check.mjs --hub-url ws://<hub>:19387/api/remote-agent/node')
47
+ process.exit(2)
48
+ }
49
+ const nodeId = values['node-id'] ?? 'e2e-live-worker'
50
+ const watchMs = Number(values['watch-seconds']) > 0 ? Number(values['watch-seconds']) * 1000 : 120000
51
+
52
+ const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-e2e-')))
53
+ const stateDir = path.join(root, 'worker-state')
54
+ const roots = (values.roots ? values.roots.split(/[,;]+/) : [path.join(root, 'ws')])
55
+ .map((s) => s.trim()).filter(Boolean)
56
+ for (const r of roots) fs.mkdirSync(r, { recursive: true })
57
+
58
+ /* ── worker:最小 ctx(logger/effect/get)──────────────────────────
59
+ * 与 DSH 里真实的副机一致:没有 webServer / systemPrompt —— 副机
60
+ * inject: [] 拿不到它们,worker 全部按可选处理。 */
61
+ const logs = []
62
+ const logger = {
63
+ info: (m, e) => logs.push(['info', m, e]),
64
+ warn: (m, e) => logs.push(['warn', m, e]),
65
+ error: (m, e) => logs.push(['error', m, e]),
66
+ debug: (m, e) => logs.push(['debug', m, e]),
67
+ }
68
+ const disposeWorker = applyWorker(
69
+ { logger, on: () => () => {}, effect: (fn) => fn(), get: () => undefined },
70
+ {
71
+ role: 'worker',
72
+ nodeId,
73
+ transportMode: 'direct',
74
+ hubUrl,
75
+ token: values.token ?? '',
76
+ stateDir,
77
+ roots,
78
+ maxConcurrent: 2,
79
+ resumeQueued: false,
80
+ },
81
+ )
82
+
83
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
84
+ async function waitOnline(ms) {
85
+ const started = Date.now()
86
+ for (;;) {
87
+ // logger 会给消息加 "[dsh-remote-agent:worker] " 前缀,匹配结尾
88
+ if (logs.some(([, m]) => typeof m === 'string' && m.endsWith(' online'))) return true
89
+ if (logs.some(([lv, m]) => lv === 'error' && /transport start failed/.test(m))) return false
90
+ if (Date.now() - started > ms) return false
91
+ await sleep(250)
92
+ }
93
+ }
94
+
95
+ /* ── ① 拨入(全自动) ───────────────────────────────────────────── */
96
+ if (!(await waitOnline(30000))) {
97
+ say('✗ worker 未上线 —— 最近日志:')
98
+ for (const [lv, m, e] of logs.slice(-8)) say(` [${lv}] ${m}${e ? ' ' + JSON.stringify(e) : ''}`)
99
+ say('排查:hub 是否在跑 / token 是否一致 / 防火墙端口')
100
+ disposeWorker()
101
+ process.exit(1)
102
+ }
103
+ say(`✓ worker 已拨入真实 hub(nodeId=${nodeId},pid=${process.pid})`)
104
+
105
+ /* ── ② 人肉派发 + 自动监视 ──────────────────────────────────────── */
106
+ say('')
107
+ say('现在请到 DSH 桌面对这个节点派一个 exec 任务,例如:')
108
+ say(` 「让 ${nodeId} 在它的工作区里创建 proof.txt 并写入 e2e-live-ok」`)
109
+ say(`(工具最多等 ${Math.round(watchMs / 1000)} 秒;等不到按 Ctrl+C 中断)`)
110
+ say('')
111
+
112
+ const tasksDir = path.join(stateDir, 'tasks')
113
+ const seen = new Set()
114
+ const started = Date.now()
115
+ let hit = null
116
+ while (Date.now() - started < watchMs) {
117
+ let files = []
118
+ try { files = fs.readdirSync(tasksDir) } catch { /* 还没有任务目录 */ }
119
+ for (const f of files) {
120
+ if (!f.endsWith('.json') || seen.has(f)) continue
121
+ seen.add(f)
122
+ let rec
123
+ try { rec = JSON.parse(fs.readFileSync(path.join(tasksDir, f), 'utf8')) } catch { continue }
124
+ say(`· 收到任务记录:${rec.taskId} status=${rec.status}`)
125
+ hit = rec
126
+ }
127
+ if (hit && !['queued', 'running'].includes(hit.status)) break
128
+ await sleep(1000)
129
+ }
130
+
131
+ if (!hit) {
132
+ say('✗ 超时未见任何任务记录 —— 请确认派发时选中的节点 id 是本节点')
133
+ say(` (本节点 nodeId=${nodeId};hub 侧可在面板/对话里让 agent 选节点)`)
134
+ disposeWorker()
135
+ process.exit(3)
136
+ }
137
+
138
+ /* ── ③ 验证:终态 + 产物 + handoff ─────────────────────────────── */
139
+ say('')
140
+ say(`✓ 任务到达终态:${hit.status}(${hit.taskId})`)
141
+ if (hit.error) say(` error:${JSON.stringify(hit.error).slice(0, 300)}`)
142
+ if (hit.status !== 'completed') {
143
+ say('✗ 任务未 completed(真实运行时下这同样是有效结论 —— 记下错误信息排查)')
144
+ disposeWorker()
145
+ process.exit(1)
146
+ }
147
+
148
+ // roots 下新增/变化的文件 = 副机真实执行痕迹
149
+ const before = new Set()
150
+ for (const r of roots) {
151
+ const walk = (d) => {
152
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
153
+ const p = path.join(d, e.name)
154
+ if (e.isDirectory()) walk(p)
155
+ else before.add(p)
156
+ }
157
+ }
158
+ try { walk(r) } catch { /* 目录可能被任务删了 */ }
159
+ }
160
+ // (记录是终态后才统计的,所以这里看到的就是任务产物全集)
161
+ const artifacts = [...before].filter((p) => !p.includes('worker-state'))
162
+ say(`✓ 副机 roots 产物 ${artifacts.length} 个:`)
163
+ for (const p of artifacts.slice(0, 10)) {
164
+ const buf = fs.readFileSync(p)
165
+ say(` - ${path.relative(root, p)} ${buf.length}B sha256=${createHash('sha256').update(buf).digest('hex').slice(0, 16)}`)
166
+ }
167
+ if (hit.manifest) {
168
+ say(`✓ manifest:final=${hit.manifest.counts?.final ?? '?'} changed=${hit.manifest.counts?.changed ?? '?'}`)
169
+ }
170
+
171
+ disposeWorker()
172
+ if (!values.keep) fs.rmSync(root, { recursive: true, force: true })
173
+ else say(`(--keep:worker 状态与产物保留在 ${root})`)
174
+
175
+ if (artifacts.length === 0) {
176
+ say('✗ 任务 completed 但 roots 无产物 —— 派的任务可能没写文件,换一个会落盘的任务再试')
177
+ process.exit(1)
178
+ }
179
+ say('')
180
+ say('✓ 真实运行时链路验证通过:独立进程 worker → 真实 hub → 任务落盘执行 → 终态/产物齐全')
181
+ process.exit(0)