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,313 @@
1
+ /**
2
+ * 引导路由:**未配置时**注册的最小 UI 路由集。
3
+ *
4
+ * ═══════════════════════════════════════════════════════════════════════════
5
+ * 为什么需要一组独立的路由(而不是复用 hub 的那些)
6
+ * ═══════════════════════════════════════════════════════════════════════════
7
+ *
8
+ * 首次安装时插件**无法知道**这台机器是主机还是副机 —— 只有用户知道。
9
+ * 所以 `role` 的默认值是空(未配置),此时:
10
+ *
11
+ * · `applyHub` **不跑** → hub 的 10 条 UI 路由都不存在
12
+ * · `applyWorkerBranch` **不跑** → 也不启动拨入
13
+ *
14
+ * 如果这时什么都不注册,用户打开面板只会看到两个端点都失败的报错 ——
15
+ * **拿不到任何引导**。所以未配置时必须注册这一小组:
16
+ *
17
+ * GET /api/remote-agent/setup/state 当前配置状态(让面板知道该显示引导)
18
+ * POST /api/remote-agent/setup/apply 写入角色选择(安全写盘,含备份回滚)
19
+ * GET /api/remote-agent/setup/pair 生成配对码(选 hub 后给副机用)
20
+ *
21
+ * ── 与 hub 那组路由的关系 ────────────────────────────────────────────────
22
+ * 这组**不依赖** NodeRegistry / PullManager(未配置时它们不存在),所以参数
23
+ * 只有 `ctx` 与 `config`。它也不注册工具 —— 未配置时不该有任何工具可用。
24
+ *
25
+ * ── 安全 ────────────────────────────────────────────────────────────────
26
+ * 与其他 UI 路由一样自带同源围栏(`kind:'exact'` 会绕过 DSH 的 `/api` prefix
27
+ * 围栏,所以必须自己补)。写盘走 `lib/setup.js` 的 `writePatchBlock()` ——
28
+ * 它有冲突检测、备份、托管块、写后校验、失败回滚。
29
+ */
30
+ import path from 'node:path'
31
+ import os from 'node:os'
32
+ import { ERROR_CODE, RemoteAgentError } from './protocol.js'
33
+ import { writePatchBlock } from './setup.js'
34
+
35
+ /** 引导路由的前缀。与 hub 的 `/api/remote-agent/ui` 分开 —— 未配置时后者不存在。 */
36
+ export const SETUP_API_PREFIX = '/api/remote-agent/setup'
37
+
38
+ const MAX_BODY = 16 * 1024
39
+
40
+ /** 同源围栏(与 ui-routes.js 的 makeGuard 同策略,但更简单:这里没有 connection 可用时也要 fail-closed)。 */
41
+ function makeGuard(ctx, log) {
42
+ return function guard(req, res, pathname) {
43
+ let rejection
44
+ try {
45
+ // 与 webServer 同理:**属性访问优先**,`ctx.get(name, false)` 非严格兜底。
46
+ // `ctx.get` 的默认 strict=true 只返回"提供者 fiber 当前活跃"的服务,
47
+ // 在 apply() 早期可能拿不到。
48
+ let connection
49
+ try {
50
+ connection = ctx.connection
51
+ } catch {
52
+ /* 未声明 inject 时属性访问会抛 */
53
+ }
54
+ if (!connection && typeof ctx.get === 'function') connection = ctx.get('connection', false)
55
+ if (connection && typeof connection.requestRejection === 'function') {
56
+ rejection = connection.requestRejection({ headers: req.headers ?? {} })
57
+ }
58
+ } catch (err) {
59
+ log('warn', 'setup guard: connection fence threw, failing closed', { path: pathname, error: String(err?.message ?? err) })
60
+ rejection = 403
61
+ }
62
+ if (rejection === undefined) {
63
+ // connection 不可用(或没拒绝)时的兜底:只挡浏览器明确标注的跨站。
64
+ const h = req.headers ?? {}
65
+ if (h['sec-fetch-site'] === 'cross-site') rejection = 403
66
+ else if (h.origin !== undefined) {
67
+ try {
68
+ if (new URL(h.origin).host !== h.host) rejection = 403
69
+ } catch {
70
+ rejection = 403
71
+ }
72
+ }
73
+ }
74
+ if (rejection === undefined) return true
75
+ res.writeHead(rejection, { 'content-type': 'application/json; charset=utf-8' })
76
+ res.end(JSON.stringify({ error: rejection === 401 ? 'unauthorized' : 'forbidden' }))
77
+ return false
78
+ }
79
+ }
80
+
81
+ async function readJson(req, maxBody = MAX_BODY) {
82
+ const chunks = []
83
+ let size = 0
84
+ for await (const chunk of req) {
85
+ size += chunk.length
86
+ if (size > maxBody) return undefined
87
+ chunks.push(chunk)
88
+ }
89
+ if (chunks.length === 0) return {}
90
+ try {
91
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
92
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined
93
+ } catch {
94
+ return undefined
95
+ }
96
+ }
97
+
98
+ function writeJson(res, status, body) {
99
+ const text = JSON.stringify(body)
100
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
101
+ res.end(text)
102
+ }
103
+
104
+ /**
105
+ * 默认 profile 的 `cordis.patch.yml` 路径。
106
+ *
107
+ * 与 `lib/setup.js` 的 `defaultPatchFile()` 同源 —— 但这里**不 import 它**,
108
+ * 因为那条路径要处理 profile 名归一化,而这里只关心"当前生效的 profile"。
109
+ * 从环境变量 `DSH_PROFILE` 取(DSH 启动时设置),退回到 desktop。
110
+ */
111
+ function currentPatchFile() {
112
+ const raw = typeof process.env.DSH_PROFILE === 'string' && process.env.DSH_PROFILE.trim() !== ''
113
+ ? process.env.DSH_PROFILE.trim()
114
+ : 'desktop'
115
+ const safe = raw.replace(/[^A-Za-z0-9._-]/g, '')
116
+ return path.join(os.homedir(), '.dsh', 'profiles', safe === '' ? 'desktop' : safe, 'cordis.patch.yml')
117
+ }
118
+
119
+ /**
120
+ * 注册引导路由。
121
+ *
122
+ * @param {object} ctx cordis 上下文(需要 webServer;没有就静默跳过)
123
+ * @param {object} config 本行的生效配置(只读快照)
124
+ * @param {(level:string,msg:string,extra?:object)=>void} log
125
+ * @returns {Array<() => void>} disposers
126
+ */
127
+ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
128
+ const disposers = []
129
+ // ⚠ **必须优先用属性访问 `ctx.webServer`,不能只用 `ctx.get('webServer')`。**
130
+ //
131
+ // cordis 的 `ctx.get(name, strict = true)` 默认只返回"提供者 fiber **当前
132
+ // 活跃**"的服务(源码注释原文:"strict — when true, only return
133
+ // implementations whose providing fiber is currently active")。
134
+ //
135
+ // 而本函数在 `apply()` 的**早期**被调用(先于 hub/worker 分支),那时
136
+ // webServer 的提供者 fiber 可能还没激活 → `ctx.get` 返回 undefined →
137
+ // 整组引导路由静默跳过 → `/api/remote-agent/setup/state` 落到 DSH 的
138
+ // `/api` prefix 围栏 → 返回**纯文本** `unauthorized`(与"路径不存在"
139
+ // 完全一样的响应,实测极难排查)。
140
+ //
141
+ // 属性访问走的是另一条路(`internal/get` waterfall + fiber store 查找),
142
+ // 能拿到已就绪的服务 —— `applyHub` 一直用的就是 `ctx.webServer`,那是
143
+ // 被验证可用的形态。
144
+ //
145
+ // 保留 `ctx.get(name, false)`(**非严格**)作为兜底:万一属性访问在某个
146
+ // 组合下不可用(例如 loader 行没声明 inject),非严格查找仍能拿到值。
147
+ let webServer
148
+ try {
149
+ webServer = ctx.webServer
150
+ } catch {
151
+ /* 没声明 inject 时属性访问会抛 —— 走下面的兜底 */
152
+ }
153
+ if (!webServer) {
154
+ try {
155
+ webServer = typeof ctx.get === 'function' ? ctx.get('webServer', false) : undefined
156
+ } catch {
157
+ webServer = undefined
158
+ }
159
+ }
160
+ if (!webServer || typeof webServer.register !== 'function') {
161
+ // 没有 webServer 的组合(纯 headless):没有面板,也就不需要引导路由。
162
+ log('info', 'setup routes skipped: no webServer in this composition')
163
+ return disposers
164
+ }
165
+ const guard = makeGuard(ctx, log)
166
+
167
+ const route = (pathname, handler) => {
168
+ try {
169
+ disposers.push(webServer.register({
170
+ kind: 'exact',
171
+ path: pathname,
172
+ handler: async (req, res) => {
173
+ try {
174
+ const h = req.headers ?? {}
175
+ log('info', 'setup route in', {
176
+ path: pathname,
177
+ method: req.method,
178
+ hasCookie: typeof h.cookie === 'string' && h.cookie !== '',
179
+ origin: typeof h.origin === 'string' ? h.origin : null,
180
+ })
181
+ if (!guard(req, res, pathname)) return
182
+ await handler(req, res)
183
+ } catch (err) {
184
+ const code = err instanceof RemoteAgentError ? err.code : ERROR_CODE.INTERNAL
185
+ log('warn', 'setup route failed', { path: pathname, code, error: String(err?.message ?? err) })
186
+ try {
187
+ writeJson(res, 500, { error: code, message: String(err?.message ?? err).slice(0, 300) })
188
+ } catch {
189
+ /* 响应可能已发出 */
190
+ }
191
+ }
192
+ },
193
+ }))
194
+ log('info', 'setup route registered', { path: pathname })
195
+ } catch (err) {
196
+ log('error', 'setup route registration failed', { path: pathname, error: String(err?.message ?? err) })
197
+ }
198
+ }
199
+
200
+ // ── 状态:面板据此决定显示引导还是"切换角色" ─────────────────────────
201
+ //
202
+ // 这组路由**始终注册**(不只未配置时)—— 所以这里必须如实报告当前是否
203
+ // 已配置,面板才能区分:
204
+ // configured: false → 显示**首次引导**(两个大按钮)
205
+ // configured: true → 显示**切换角色**(在设置区,带当前值)
206
+ route(`${SETUP_API_PREFIX}/state`, (req, res) => {
207
+ if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
208
+ const role = typeof config.role === 'string' ? config.role.trim().toLowerCase() : ''
209
+ const valid = role === 'hub' || role === 'worker' || role === 'both'
210
+ writeJson(res, 200, {
211
+ ok: true,
212
+ // 未配置 = role 为空或无法识别(与 lib/index.js 的 resolveRoleDetail 同判据)
213
+ configured: valid,
214
+ // 回显当前配置里已有的值,方便用户"改一个字段"而不是从头填
215
+ current: {
216
+ role: valid ? role : '',
217
+ nodeId: typeof config.nodeId === 'string' ? config.nodeId : '',
218
+ transportMode: typeof config.transportMode === 'string' ? config.transportMode : 'relay',
219
+ hubUrl: typeof config.hubUrl === 'string' ? config.hubUrl : '',
220
+ relayUrl: typeof config.relayUrl === 'string' ? config.relayUrl : '',
221
+ roots: Array.isArray(config.roots) ? config.roots : [],
222
+ },
223
+ // 写入目标(让用户知道改的是哪个文件)
224
+ patchFile: currentPatchFile(),
225
+ // 提示:写盘后必须重启
226
+ note: '写入后需要重启 DSH 生效',
227
+ })
228
+ })
229
+
230
+ // ── 写入角色选择 ────────────────────────────────────────────────────
231
+ route(`${SETUP_API_PREFIX}/apply`, async (req, res) => {
232
+ if (req.method !== 'POST') return writeJson(res, 405, { error: 'method' })
233
+ const body = await readJson(req)
234
+ if (body === undefined) return writeJson(res, 400, { error: 'bad-body' })
235
+
236
+ const role = typeof body.role === 'string' ? body.role.trim().toLowerCase() : ''
237
+ if (role !== 'hub' && role !== 'worker' && role !== 'both') {
238
+ return writeJson(res, 400, { error: 'bad-role', message: 'role 必须是 hub / worker / both' })
239
+ }
240
+
241
+ // 组装要写入的 YAML 块。**逐字段构造**,绝不把 body 直接序列化进去 ——
242
+ // 那是把用户输入变成配置注入(YAML 里一个换行就能插入任意 loader 行)。
243
+ const lines = [
244
+ '- id: remote-agent',
245
+ ' name: dsh-remote-agent',
246
+ ]
247
+ // inject 必须跟着 role 走:
248
+ // hub → 需要 webServer/tools/systemPrompt(注册路由与工具)
249
+ // worker → 需要 tools/agentLoop/sessions(agent 的 ctx 继承依赖声明,
250
+ // 少了 tools 就挂不上 shell/文件工具 —— 实测踩过)
251
+ if (role === 'worker') {
252
+ lines.push(" inject: ['tools', 'agentLoop', 'sessions']")
253
+ } else {
254
+ lines.push(" inject: ['webServer', 'tools', 'systemPrompt']")
255
+ }
256
+ lines.push(' config:')
257
+ lines.push(` role: ${role}`)
258
+
259
+ const str = (v) => (typeof v === 'string' ? v.trim() : '')
260
+ const nodeId = str(body.nodeId)
261
+ if (nodeId !== '') {
262
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(nodeId)) {
263
+ return writeJson(res, 400, { error: 'bad-node-id', message: '节点 ID 只能含字母数字 . _ -,以字母或数字开头' })
264
+ }
265
+ lines.push(` nodeId: ${nodeId}`)
266
+ }
267
+ if (role === 'worker') {
268
+ const mode = str(body.transportMode) === 'direct' ? 'direct' : 'relay'
269
+ lines.push(` transportMode: ${mode}`)
270
+ if (mode === 'direct') {
271
+ const hubUrl = str(body.hubUrl)
272
+ if (hubUrl === '') {
273
+ return writeJson(res, 400, { error: 'hub-url-required', message: 'direct 模式必须填主机地址' })
274
+ }
275
+ // 引号包裹:URL 里可能有冒号/斜杠,YAML 裸标量在某些形态下会解析异常
276
+ lines.push(` hubUrl: '${hubUrl.replace(/'/g, "''")}'`)
277
+ } else {
278
+ const relayUrl = str(body.relayUrl)
279
+ if (relayUrl !== '') lines.push(` relayUrl: '${relayUrl.replace(/'/g, "''")}'`)
280
+ }
281
+ }
282
+ const roots = Array.isArray(body.roots) ? body.roots.map(str).filter((s) => s !== '') : []
283
+ if (roots.length > 0) {
284
+ lines.push(' roots:')
285
+ for (const r of roots) lines.push(` - '${r.replace(/'/g, "''")}'`)
286
+ }
287
+
288
+ const blockYaml = `${lines.join('\n')}\n`
289
+ const file = currentPatchFile()
290
+ const result = writePatchBlock({ file, blockYaml, force: body.force === true })
291
+ if (result.ok !== true) {
292
+ // 冲突(有手写条目)是可预期的用户可修复状态,单独给个 code 让面板
293
+ // 能显示"替换手写配置"按钮,而不是把整段错误文案丢给用户。
294
+ const conflict = /手写的 remote-agent 条目/.test(String(result.error ?? ''))
295
+ return writeJson(res, conflict ? 409 : 400, {
296
+ error: conflict ? 'handwritten-conflict' : 'write-failed',
297
+ message: result.error,
298
+ file: result.file,
299
+ })
300
+ }
301
+ log('info', 'setup applied', { role, file: result.file, mode: result.mode })
302
+ writeJson(res, 200, {
303
+ ok: true,
304
+ role,
305
+ file: result.file,
306
+ backup: result.backup,
307
+ mode: result.mode,
308
+ note: '已写入配置。**重启 DSH 后生效**。',
309
+ })
310
+ })
311
+
312
+ return disposers
313
+ }