dsh-remote-agent 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +700 -0
- package/CONTRIBUTING.md +358 -0
- package/KNOWN-ISSUES.md +601 -0
- package/LICENSE +21 -0
- package/README.en.md +353 -0
- package/README.md +317 -2
- package/SECURITY.md +348 -0
- package/bin/dsh-remote-relay.js +257 -0
- package/bin/dsh-remote-setup.js +440 -0
- package/bin//345/217/214/345/207/273/350/277/220/350/241/214-/351/205/215/347/275/256/345/220/221/345/257/274.cmd +28 -0
- package/cordis.patch.yml +87 -0
- package/deploy/relay-vps/Caddyfile +24 -0
- package/deploy/relay-vps/README.md +132 -0
- package/deploy/relay-vps/dsh-remote-relay.service +47 -0
- package/deploy/relay-vps/nginx.conf +49 -0
- package/deploy/relay-vps/relay.env.example +20 -0
- package/docs/dispatch-automation-eval.md +140 -0
- package/docs/screenshots/panel-main.png +0 -0
- package/docs/screenshots/panel-task-detail.png +0 -0
- package/docs/verification-agent-e2e.md +425 -0
- package/docs/verification-repos-routing-real.md +521 -0
- package/docs/verification-resume-real.md +254 -0
- package/docs/worker-setup-pitfalls.md +266 -0
- package/lib/artifacts.js +451 -0
- package/lib/client.js +1752 -0
- package/lib/e2e.js +304 -0
- package/lib/glob.js +225 -0
- package/lib/hub/audit-log.js +161 -0
- package/lib/hub/hub-util.js +114 -0
- package/lib/hub/index.js +500 -0
- package/lib/hub/node-registry.js +943 -0
- package/lib/hub/pull-manager.js +781 -0
- package/lib/hub/registry-handshake.js +403 -0
- package/lib/hub/registry-ingest.js +346 -0
- package/lib/hub/relay-link.js +228 -0
- package/lib/hub/subagent-provider.js +424 -0
- package/lib/hub/templates.js +693 -0
- package/lib/hub/tools.js +1059 -0
- package/lib/hub/ui-routes.js +856 -0
- package/lib/hub/views.js +351 -0
- package/lib/identity.js +271 -0
- package/lib/index.js +272 -0
- package/lib/node/artifact-service.js +231 -0
- package/lib/node/diff.js +124 -0
- package/lib/node/exec.js +536 -0
- package/lib/node/hub-pin.js +71 -0
- package/lib/node/index.js +1022 -0
- package/lib/node/repos.js +134 -0
- package/lib/node/resource.js +63 -0
- package/lib/node/runner.js +792 -0
- package/lib/node/task-engine.js +712 -0
- package/lib/node/task-store.js +273 -0
- package/lib/node/transport.js +763 -0
- package/lib/pairing.js +186 -0
- package/lib/protocol.js +414 -0
- package/lib/relay-config.js +317 -0
- package/lib/relay.js +732 -0
- package/lib/setup-routes.js +313 -0
- package/lib/setup.js +522 -0
- package/lib/ws.js +466 -0
- package/package.json +103 -4
- package/profiles/README.md +573 -0
- package/profiles/hub.cordis.yml +84 -0
- package/profiles/worker.cordis.yml +60 -0
- package/scripts/check-doc-drift.mjs +785 -0
- package/scripts/check-packaging.mjs +130 -0
- package/scripts/diag-inject.mjs +243 -0
- package/scripts/e2e-live-check.mjs +181 -0
- package/scripts/relay-init.ps1 +286 -0
- package/scripts/relay-init.sh +199 -0
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI 控制路由:给 Web 面板提供只读数据 + 少量动作。
|
|
3
|
+
*
|
|
4
|
+
* 为什么用 HTTP 路由而不是别的通道:
|
|
5
|
+
* 静态 bundle 的 client 半(浏览器)能用的 Client 服务只有 layout/sessions/slots/
|
|
6
|
+
* theme/timer/workspaces —— **没有任何一个能读到远程节点或任务**。而 `host.call()`
|
|
7
|
+
* 属于动态包(cordis_run)机制,静态 bundle 不走那条路。同 profile 的
|
|
8
|
+
* `dsh-context` 与 `dsh-better-sidebar` 都是用 `fetch('<自己的路由>')` 拿 Host 数据,
|
|
9
|
+
* 这是已在本机验证过可行的模式。
|
|
10
|
+
*
|
|
11
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
12
|
+
* 安全边界(v0.1 加固版,逐条结论见下方各段注释)
|
|
13
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
14
|
+
*
|
|
15
|
+
* 【结论 1 · 这些路由**不在** DSH 的 /api 鉴权围栏内】
|
|
16
|
+
*
|
|
17
|
+
* 这是本文件最重要的一条。DSH 的 Host/Origin + 浏览器 cookie 围栏由
|
|
18
|
+
* `@deepseek-ai/dsh-client-connection` 注册,而它的注册形态是:
|
|
19
|
+
*
|
|
20
|
+
* kind: 'prefix', path: '/api' // ← prefix
|
|
21
|
+
* handler: 先 admit(req)(Host/Origin + cookie),再 bridge 到 RPC
|
|
22
|
+
*
|
|
23
|
+
* 而本文件的路由是:
|
|
24
|
+
*
|
|
25
|
+
* kind: 'exact', path: '/api/remote-agent/ui/...' // ← exact
|
|
26
|
+
*
|
|
27
|
+
* `@deepseek-ai/dsh-host-webserver` 的 `match()` 是 **exact 表优先于 prefix 表**:
|
|
28
|
+
*
|
|
29
|
+
* match(pathname) {
|
|
30
|
+
* const exact = this.exact.get(pathname);
|
|
31
|
+
* if (exact !== void 0) return exact; // ← 命中 exact 就直接返回
|
|
32
|
+
* ...longest-prefix-wins...
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* 结论:命中 exact 的请求**不会**走到 `/api` prefix 的 admit(),即我们的 UI 路由
|
|
36
|
+
* 绕过了 DSH 的 token/cookie 围栏。已用真实 dsh-host-webserver 实测确认:
|
|
37
|
+
* 同一台 server 上,`/api/remote-agent/ui/overview`(exact)返回 200,
|
|
38
|
+
* 而 `/api/some-other/thing`(只命中 prefix)返回 401。
|
|
39
|
+
*
|
|
40
|
+
* 因此本文件**不能**假设"外层会替我们鉴权",必须自带请求围栏 —— 见 `guard()`。
|
|
41
|
+
* 这与 `dsh-context` 的做法一致:它走 `ctx.connection.fetch.register`(受围栏保护),
|
|
42
|
+
* 而不是 `webServer.register`;我们走 webServer 是因为静态 bundle 拿不到 connection。
|
|
43
|
+
*
|
|
44
|
+
* 【结论 2 · CSRF:判定"低风险,但已显式加固"】
|
|
45
|
+
*
|
|
46
|
+
* 判定:第三方页面**无法**诱导这些动作,理由三条(前两条来自框架,第三条来自本文件):
|
|
47
|
+
*
|
|
48
|
+
* a) **cookie 是 SameSite=Strict**(client-connection `sessionCookie()`:
|
|
49
|
+
* `Path=/; HttpOnly; SameSite=Strict`)。跨站发起的 fetch/XHR 不会带上
|
|
50
|
+
* 这个 cookie,所以跨站请求拿不到任何浏览器凭证。
|
|
51
|
+
* b) **JSON content-type 触发 CORS 预检**。`apiPost` 发的是
|
|
52
|
+
* `content-type: application/json`,属于"非简单请求",浏览器必先发
|
|
53
|
+
* OPTIONS 预检;本文件不返回任何 `access-control-allow-*` 头,
|
|
54
|
+
* 预检必然失败,真实请求不会发出。
|
|
55
|
+
* (`text/plain` 表单可绕过预检,但 body 是 `a=b` 形式,readJson() 解析失败 → 400。)
|
|
56
|
+
* c) **本文件现在主动拒绝跨源**(结论 1 的 guard):带 `Origin` 且与 `Host`
|
|
57
|
+
* 不一致 → 403;`sec-fetch-site: cross-site` → 403。即使 (a)(b) 因浏览器
|
|
58
|
+
* 行为差异或未来框架改动而失效,这一层仍然拦得住。
|
|
59
|
+
*
|
|
60
|
+
* 结论:CSRF **不是**本组路由的主要风险面 —— 因为 DSH 的会话 cookie 是
|
|
61
|
+
* SameSite=Strict 且动作要求 JSON,浏览器的同源模型已经把它挡在门外。
|
|
62
|
+
* 主要风险面是**绕过围栏的本地/同源访问**(结论 1),以及 pull 的路径逃逸(结论 4)。
|
|
63
|
+
* 因此 guard 的设计目标是"补上被绕过的围栏",而不是"防 CSRF"。
|
|
64
|
+
* 注意 guard 对无 Origin 的请求放行(curl/本机进程),这类客户端本就不携带
|
|
65
|
+
* 浏览器凭证,不属于 CSRF 载体。
|
|
66
|
+
*
|
|
67
|
+
* 【结论 3 · 响应体逐字段白名单,绝不 spread】
|
|
68
|
+
*
|
|
69
|
+
* `nodeView()` / `taskView()` / `handoffView()` 都是**逐字段构造**的字面量对象。
|
|
70
|
+
* 反例是 `{...registry.list()[i]}` 或 `{...t.handoff}` —— 那会让将来新增的
|
|
71
|
+
* 内部字段(conn、hello 全量、pendingPulls…)自动泄漏到浏览器。
|
|
72
|
+
* `handoffView()` 尤其重要:handoff 由副机上报,字段集不受本文件控制。
|
|
73
|
+
*
|
|
74
|
+
* 【结论 4 · pull 是唯一写盘动作,双道校验】
|
|
75
|
+
*
|
|
76
|
+
* 顺序必须是:字符串层 isInside → mkdir → realpath 复查。
|
|
77
|
+
* 先字符串(挡 `../` 与反斜杠),再 realpath(挡符号链接与 8.3 短名)。
|
|
78
|
+
* 注意 realpath **必须在 mkdir 之后**,否则新目录 realpath 会失败;
|
|
79
|
+
* 而"先 mkdir 再校验"意味着一个越界目录可能被**创建**出来 —— 这是可接受的,
|
|
80
|
+
* 因为创建空目录不是数据泄漏,而写入越界文件才是。见 `resolvePullDest()`。
|
|
81
|
+
*/
|
|
82
|
+
import fs from 'node:fs'
|
|
83
|
+
import path from 'node:path'
|
|
84
|
+
import { ERROR_CODE, RemoteAgentError, TASK_MODE } from '../protocol.js'
|
|
85
|
+
import { isInside, normalizePath } from '../glob.js'
|
|
86
|
+
// 副机接入(pair-info):复用向导的同一份纯逻辑 —— 面板生成的配对码与
|
|
87
|
+
// dsh-remote-setup 生成的一字不差,避免两套实现漂移。
|
|
88
|
+
import { detectLanIps, buildHubUrl } from '../setup.js'
|
|
89
|
+
import { encodePairCode } from '../pairing.js'
|
|
90
|
+
// 视图投影共享层:工具路径与 UI 路径用**同一份**白名单,
|
|
91
|
+
// 不让任一方绕开投影(见下方「视图投影」段的说明)。
|
|
92
|
+
import {
|
|
93
|
+
handoffView,
|
|
94
|
+
nodeView,
|
|
95
|
+
taskView,
|
|
96
|
+
} from './views.js'
|
|
97
|
+
// 任务模板库(配方):模板是"派发表单的预设值",存 hub 本地 JSON。
|
|
98
|
+
// 校验/原子写/并发串行化都在那个模块里(本文件只做路由与围栏)。
|
|
99
|
+
import { createTemplateStore, defaultTemplatesFile } from './templates.js'
|
|
100
|
+
|
|
101
|
+
export const UI_API_PREFIX = '/api/remote-agent/ui'
|
|
102
|
+
|
|
103
|
+
/** 单次请求体上限:UI 只发小 JSON,超过就是异常。 */
|
|
104
|
+
const MAX_BODY = 256 * 1024
|
|
105
|
+
|
|
106
|
+
/** 错误消息回给浏览器的上限(避免把绝对路径/内部细节整段回显)。 */
|
|
107
|
+
const MAX_ERR_MESSAGE = 300
|
|
108
|
+
|
|
109
|
+
function writeJson(res, status, body) {
|
|
110
|
+
const payload = JSON.stringify(body)
|
|
111
|
+
res.writeHead(status, {
|
|
112
|
+
'content-type': 'application/json; charset=utf-8',
|
|
113
|
+
// 面板数据不进任何缓存:任务状态是活的
|
|
114
|
+
'cache-control': 'no-store',
|
|
115
|
+
})
|
|
116
|
+
res.end(payload)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function readJson(req, maxBody = MAX_BODY) {
|
|
120
|
+
const chunks = []
|
|
121
|
+
let size = 0
|
|
122
|
+
for await (const chunk of req) {
|
|
123
|
+
size += chunk.length
|
|
124
|
+
if (size > maxBody) return undefined
|
|
125
|
+
chunks.push(chunk)
|
|
126
|
+
}
|
|
127
|
+
if (chunks.length === 0) return {}
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
130
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined
|
|
131
|
+
} catch {
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 错误消息净化:只回稳定 code + 截断后的短消息,绝不回堆栈。
|
|
138
|
+
*
|
|
139
|
+
* 为什么必须截断:`RemoteAgentError` 的消息里经常带绝对路径
|
|
140
|
+
* (例如 `artifact vanished: C:\Users\...\x`、`workspace outside allowed roots: ...`),
|
|
141
|
+
* 整段回显等于把主机目录结构交给浏览器。堆栈更糟(含源码路径与行号)。
|
|
142
|
+
* 所以这里**只取 message 的前 N 字符**,并且从不把 `err.stack` 写进响应。
|
|
143
|
+
*/
|
|
144
|
+
function shortMessage(err) {
|
|
145
|
+
const raw = String(err?.message ?? err ?? '')
|
|
146
|
+
// 去掉可能夹带的换行/堆栈形态尾巴
|
|
147
|
+
const firstLine = raw.split(/\r?\n/)[0]
|
|
148
|
+
return firstLine.slice(0, MAX_ERR_MESSAGE)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 本地请求围栏的兜底实现(当 `connection` 服务不可用时使用)。
|
|
153
|
+
*
|
|
154
|
+
* 刻意做成 DSH `isTrustedApiRequest` 的**保守子集**:
|
|
155
|
+
* - `sec-fetch-site: cross-site` → 拒(浏览器明确标注的跨站)
|
|
156
|
+
* - 带 `Origin` 且其 host 与 `Host` 不一致 → 拒(跨源)
|
|
157
|
+
* - 无 `Origin`(curl / 本机进程 / 测试)→ 放行
|
|
158
|
+
*
|
|
159
|
+
* 为什么无 Origin 放行:非浏览器客户端不会自动携带凭证,本就不是 CSRF 载体;
|
|
160
|
+
* 而要求 Origin 会让所有 CLI/测试调用失效。真正的边界是 Host/Origin 一致性。
|
|
161
|
+
*/
|
|
162
|
+
function sameOriginOk(req) {
|
|
163
|
+
const headers = req?.headers ?? {}
|
|
164
|
+
const host = headers.host
|
|
165
|
+
if (headers['sec-fetch-site'] === 'cross-site') return false
|
|
166
|
+
const origin = headers.origin
|
|
167
|
+
if (origin === undefined) return true
|
|
168
|
+
if (typeof host !== 'string' || host === '') return false
|
|
169
|
+
try {
|
|
170
|
+
return new URL(origin).host === host
|
|
171
|
+
} catch {
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 请求围栏:每个 UI 路由的第一道门。
|
|
178
|
+
*
|
|
179
|
+
* 【为什么必须有这一道】
|
|
180
|
+
* 见文件头"结论 1":本文件注册的是 `kind:'exact'`,而 DSH 的 /api 围栏是
|
|
181
|
+
* `kind:'prefix'`,WebServer.match() exact 优先 → 我们的路由**绕过**了 DSH 的
|
|
182
|
+
* Host/Origin + cookie 鉴权。所以不能假设"外层会替我们鉴权"。
|
|
183
|
+
*
|
|
184
|
+
* 【怎么补】优先复用框架自己的围栏,而不是自己造一套:
|
|
185
|
+
* `ctx.connection.requestRejection(request)` 的官方用途正是
|
|
186
|
+
* "Apply Connection's Host/Origin checks and browser authentication to
|
|
187
|
+
* **another** Web route" —— 我们就是那个 another route。
|
|
188
|
+
* 返回 401 / 403 / undefined,语义与 DSH 内建 /api 完全一致。
|
|
189
|
+
*
|
|
190
|
+
* 该服务不存在时(例如无 web 组件的组合、单元测试)退回到 sameOriginOk(),
|
|
191
|
+
* 保证"没有围栏"不会退化成"完全不检查跨站"。
|
|
192
|
+
*
|
|
193
|
+
* @returns {true} 放行;{false} 已写响应,调用方应立即返回
|
|
194
|
+
*/
|
|
195
|
+
function makeGuard(ctx, log) {
|
|
196
|
+
return function guard(req, res, pathname) {
|
|
197
|
+
let rejection
|
|
198
|
+
try {
|
|
199
|
+
// 与 webServer 同理:**属性访问优先**,`ctx.get(name, false)` 非严格兜底。
|
|
200
|
+
// `ctx.get` 默认 strict=true 只返回"提供者 fiber 当前活跃"的服务,
|
|
201
|
+
// 在 apply() 早期可能拿不到(实测:引导路由因此静默不注册)。
|
|
202
|
+
let connection
|
|
203
|
+
try {
|
|
204
|
+
connection = ctx.connection
|
|
205
|
+
} catch {
|
|
206
|
+
/* 未声明 inject 时属性访问会抛 */
|
|
207
|
+
}
|
|
208
|
+
if (!connection && typeof ctx.get === 'function') connection = ctx.get('connection', false)
|
|
209
|
+
if (connection && typeof connection.requestRejection === 'function') {
|
|
210
|
+
rejection = connection.requestRejection({ headers: req.headers ?? {} })
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
// 围栏自身出错时**失败关闭**:宁可面板不可用,也不放行未鉴权请求。
|
|
214
|
+
log('warn', 'ui guard: connection fence threw, failing closed', { path: pathname, error: shortMessage(err) })
|
|
215
|
+
rejection = 403
|
|
216
|
+
}
|
|
217
|
+
if (rejection === undefined && !sameOriginOk(req)) rejection = 403
|
|
218
|
+
if (rejection === undefined) return true
|
|
219
|
+
writeJson(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' })
|
|
220
|
+
return false
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── 视图投影:**从 lib/hub/views.js 共享** ──────────────────────────
|
|
225
|
+
//
|
|
226
|
+
// 这里的六个投影函数(nodeView / changedView / exitView / errorView /
|
|
227
|
+
// taskView / handoffView)原本定义在本文件里。提取出去的原因是:它们只被
|
|
228
|
+
// UI 路径使用,而 `remote_handoff` 工具因为"没有对应的 View 函数"就裸直出了
|
|
229
|
+
// `t.handoff` —— 加固做了一半,而且漏掉的正是更该加固的那一半
|
|
230
|
+
// (工具返回值会进入会话日志,被后续每个请求带上)。
|
|
231
|
+
//
|
|
232
|
+
// 集中到 views.js 后,新增消费方无处可绕开投影。
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 计算 pull 的落盘目录,双道校验。
|
|
236
|
+
*
|
|
237
|
+
* 顺序(**不可调换**):
|
|
238
|
+
* 1. 字符串层 `isInside(pullRoot, destDir)` —— 挡 `../`、`..\`、绝对路径
|
|
239
|
+
* 2. `mkdirSync(destDir)` —— 目录不存在时 realpath 会失败,所以必须先建
|
|
240
|
+
* 3. `realpathSync(destDir)` 后复查 `isInside(realRoot, realDest)`
|
|
241
|
+
* —— 挡符号链接、Windows 8.3 短名、junction 等字符串层看不出的逃逸
|
|
242
|
+
*
|
|
243
|
+
* 已知的取舍:第 2 步可能创建一个"最终会被判定越界"的空目录
|
|
244
|
+
* (例如 pullRoot 内已有一个指向 /etc 的符号链接,我们会在 /etc 下 mkdir)。
|
|
245
|
+
* 这是可接受的 —— 创建空目录不是数据泄漏,越界**写入**才是,
|
|
246
|
+
* 而第 3 步保证了越界写入不会发生。反过来若先校验再 mkdir,
|
|
247
|
+
* 新建目录的 realpath 会抛 ENOENT,正常路径反而不可用。
|
|
248
|
+
*
|
|
249
|
+
* @returns {{ok:true, dir:string} | {ok:false, code:string, dest:string}}
|
|
250
|
+
*/
|
|
251
|
+
export function resolvePullDest(pullRoot, sub) {
|
|
252
|
+
const destDir = path.resolve(pullRoot, typeof sub === 'string' ? sub : '')
|
|
253
|
+
|
|
254
|
+
// ① 字符串层:挡 ../ 与反斜杠写法
|
|
255
|
+
if (!isInside(normalizePath(pullRoot), normalizePath(destDir), {})) {
|
|
256
|
+
return { ok: false, code: 'dest-outside-pull-root', dest: typeof sub === 'string' ? sub : '' }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let realRoot
|
|
260
|
+
let realDest
|
|
261
|
+
try {
|
|
262
|
+
fs.mkdirSync(pullRoot, { recursive: true })
|
|
263
|
+
realRoot = fs.realpathSync.native ? fs.realpathSync.native(pullRoot) : fs.realpathSync(pullRoot)
|
|
264
|
+
// ② 建目录(realpath 要求路径存在)
|
|
265
|
+
fs.mkdirSync(destDir, { recursive: true })
|
|
266
|
+
// ③ realpath 归一后复查:挡符号链接逃逸
|
|
267
|
+
realDest = fs.realpathSync.native ? fs.realpathSync.native(destDir) : fs.realpathSync(destDir)
|
|
268
|
+
} catch (err) {
|
|
269
|
+
return { ok: false, code: 'pull-root-unusable', dest: typeof sub === 'string' ? sub : '', message: shortMessage(err) }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!isInside(normalizePath(realRoot), normalizePath(realDest), {})) {
|
|
273
|
+
return { ok: false, code: 'dest-outside-pull-root-after-resolve', dest: typeof sub === 'string' ? sub : '' }
|
|
274
|
+
}
|
|
275
|
+
return { ok: true, dir: realDest }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 注册 UI 路由。
|
|
280
|
+
*
|
|
281
|
+
* @param {object} o
|
|
282
|
+
* @param {object} o.ctx dsh 上下文(需要 webServer)
|
|
283
|
+
* @param {object} o.registry NodeRegistry
|
|
284
|
+
* @param {object} o.pulls PullManager
|
|
285
|
+
* @param {(level:string,msg:string,extra?:object)=>void} o.log
|
|
286
|
+
* @param {object} o.defaults remote_task 默认值
|
|
287
|
+
* @param {string} [o.pullRoot] 允许 UI 拉取产物落地的根目录(绝对路径)。未配置则禁用 pull。
|
|
288
|
+
* @param {string} [o.uiTemplatesFile] 任务模板库的 JSON 绝对路径。未配置则落在
|
|
289
|
+
* `config.stateDir/templates.json`(见 templates.js 的 defaultTemplatesFile)。
|
|
290
|
+
* @param {object} [o.config] 本插件行的**生效配置**(只读快照),供面板展示
|
|
291
|
+
* @returns {Array<() => void>} disposers
|
|
292
|
+
*/
|
|
293
|
+
export function registerUiRoutes({
|
|
294
|
+
ctx,
|
|
295
|
+
registry,
|
|
296
|
+
pulls,
|
|
297
|
+
log,
|
|
298
|
+
defaults = {},
|
|
299
|
+
pullRoot,
|
|
300
|
+
uiTemplatesFile,
|
|
301
|
+
config = {},
|
|
302
|
+
}) {
|
|
303
|
+
const disposers = []
|
|
304
|
+
const guard = makeGuard(ctx, log)
|
|
305
|
+
|
|
306
|
+
// 注册计数:`applyHub` 结束时会打一行日志汇总。
|
|
307
|
+
// 为什么用 log 而不是写文件:上一轮排查用的文件诊断在清理时被删了,
|
|
308
|
+
// 而**路由注册失败是静默致命的**(面板只看到 404,启动日志里什么都没有)。
|
|
309
|
+
// 用 log 至少能在 DSH 的宿主 stdout / 日志里看到,且不会留垃圾文件。
|
|
310
|
+
let registeredCount = 0
|
|
311
|
+
let failedCount = 0
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 面板可读的配置视图。
|
|
317
|
+
*
|
|
318
|
+
* **为什么只读**:改配置需要写回 profile 的 patch 层(`cordis.patch.yml`),
|
|
319
|
+
* 而那条路径由 loader 拥有 —— 插件在运行时改它会让下次启动的配置与当前进程
|
|
320
|
+
* 不一致,且没有事务保证。所以面板只**展示**当前生效值,要改请编辑 profile。
|
|
321
|
+
* 这比提供一个"半可靠"的写入按钮更诚实。
|
|
322
|
+
*/
|
|
323
|
+
const configView = () => ({
|
|
324
|
+
role: typeof config.role === 'string' ? config.role : 'hub',
|
|
325
|
+
// ⚠ 凭证一律**只回"是否已设置"的布尔**,绝不回明文 —— 面板是浏览器里的东西,
|
|
326
|
+
// 它看到的每个字节都可能被扩展、调试工具或被截图带走。
|
|
327
|
+
// (nodeToken 曾经原样回传,是实测发现的泄漏;relay.token 一开始就是布尔。)
|
|
328
|
+
nodeTokenSet: typeof config.nodeToken === 'string' && config.nodeToken !== '',
|
|
329
|
+
relayTokenSet: typeof config.relay?.token === 'string' && config.relay.token !== '',
|
|
330
|
+
uiPullRoot: typeof config.uiPullRoot === 'string' ? config.uiPullRoot : '',
|
|
331
|
+
uiEnabled: config.uiEnabled !== false,
|
|
332
|
+
relayUrl: typeof config.relay?.url === 'string' ? config.relay.url : '',
|
|
333
|
+
nodeId: typeof config.nodeId === 'string' ? config.nodeId : '',
|
|
334
|
+
transportMode: typeof config.transportMode === 'string' ? config.transportMode : 'relay',
|
|
335
|
+
roots: Array.isArray(config.roots) ? config.roots : [],
|
|
336
|
+
stateDir: typeof config.stateDir === 'string' ? config.stateDir : '',
|
|
337
|
+
// 让面板能提示"改这些要去哪改"
|
|
338
|
+
editHint: '以下配置在 profile 的 cordis.patch.yml 里修改,改完重启 DSH 生效',
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 每个 handler 都包两层:
|
|
343
|
+
* 1. **请求围栏**(guard):见文件头"结论 1" —— 这些 exact 路由绕过 DSH 的
|
|
344
|
+
* /api prefix 围栏,必须自己复用 connection.requestRejection。
|
|
345
|
+
* 2. **异常收口**:稳定 code + 截断消息(**不含堆栈**,堆栈里有绝对路径),
|
|
346
|
+
* 且 UI 抛错不能让 host 崩。
|
|
347
|
+
*/
|
|
348
|
+
const route = (pathname, handler) => {
|
|
349
|
+
try {
|
|
350
|
+
disposers.push(ctx.webServer.register({
|
|
351
|
+
kind: 'exact',
|
|
352
|
+
path: pathname,
|
|
353
|
+
handler: async (req, res) => {
|
|
354
|
+
try {
|
|
355
|
+
// 请求到达取证。**这一行是排查面板 404/401 的唯一可靠手段** ——
|
|
356
|
+
// 面板请求经 Electron 的 forwardWebRequest 改写(剥 host/origin/
|
|
357
|
+
// cookie/sec-fetch-site,再注入 hostCookie),从外面 curl 永远
|
|
358
|
+
// 复现不出那个形态。所以必须记录"真实到达的请求长什么样"。
|
|
359
|
+
//
|
|
360
|
+
// 只记**判定所需**的字段,且 cookie 只记"是否存在 + 长度",
|
|
361
|
+
// 绝不记明文(日志可能被带走)。
|
|
362
|
+
const h = req.headers ?? {}
|
|
363
|
+
const ck = typeof h.cookie === 'string' ? h.cookie : ''
|
|
364
|
+
log('info', 'ui request in', {
|
|
365
|
+
path: pathname,
|
|
366
|
+
method: req.method,
|
|
367
|
+
host: typeof h.host === 'string' ? h.host : null,
|
|
368
|
+
hasCookie: ck !== '',
|
|
369
|
+
cookieLen: ck.length,
|
|
370
|
+
origin: typeof h.origin === 'string' ? h.origin : null,
|
|
371
|
+
secFetchSite: typeof h['sec-fetch-site'] === 'string' ? h['sec-fetch-site'] : null,
|
|
372
|
+
ua: typeof h['user-agent'] === 'string' ? h['user-agent'].slice(0, 60) : null,
|
|
373
|
+
})
|
|
374
|
+
if (!guard(req, res, pathname)) {
|
|
375
|
+
log('warn', 'ui route rejected by guard', {
|
|
376
|
+
path: pathname,
|
|
377
|
+
hasCookie: ck !== '',
|
|
378
|
+
origin: typeof h.origin === 'string' ? h.origin : null,
|
|
379
|
+
secFetchSite: typeof h['sec-fetch-site'] === 'string' ? h['sec-fetch-site'] : null,
|
|
380
|
+
})
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
await handler(req, res)
|
|
384
|
+
} catch (err) {
|
|
385
|
+
const code = err instanceof RemoteAgentError ? err.code : ERROR_CODE.INTERNAL
|
|
386
|
+
log('warn', 'ui route failed', { path: pathname, code, error: shortMessage(err) })
|
|
387
|
+
try {
|
|
388
|
+
writeJson(res, 500, { error: code, message: shortMessage(err) })
|
|
389
|
+
} catch {
|
|
390
|
+
/* 响应可能已发出 */
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
}))
|
|
395
|
+
registeredCount += 1
|
|
396
|
+
|
|
397
|
+
} catch (err) {
|
|
398
|
+
// 注册抛错是**静默致命**的:路由不存在 → 请求落到 /api prefix → 面板看到
|
|
399
|
+
// 404/401,而启动日志里什么都没有。必须留痕。
|
|
400
|
+
failedCount += 1
|
|
401
|
+
log('error', 'ui route registration failed', { path: pathname, error: shortMessage(err) })
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── 总览:节点 + 任务计数 + 面板自身能力 ──────────────────────
|
|
406
|
+
route(`${UI_API_PREFIX}/overview`, (req, res) => {
|
|
407
|
+
if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
|
|
408
|
+
const nodes = registry.list().map(nodeView)
|
|
409
|
+
const tasks = registry.listTasks().map((t) => registry.taskView(t))
|
|
410
|
+
writeJson(res, 200, {
|
|
411
|
+
ok: true,
|
|
412
|
+
nodes,
|
|
413
|
+
counts: {
|
|
414
|
+
nodes: nodes.length,
|
|
415
|
+
online: nodes.filter((n) => n.online).length,
|
|
416
|
+
tasks: tasks.length,
|
|
417
|
+
running: tasks.filter((t) => t.status === 'running' || t.status === 'queued').length,
|
|
418
|
+
activePulls: pulls.activeCount,
|
|
419
|
+
},
|
|
420
|
+
// 面板据此决定按钮是否可用,而不是让用户点了才知道不行
|
|
421
|
+
capabilities: {
|
|
422
|
+
pull: typeof pullRoot === 'string' && pullRoot !== '',
|
|
423
|
+
// 未配置时回 null(而不是空串):normalizeConfig 会把未配置归一成 '',
|
|
424
|
+
// 面板据此判断"该不该显示落地目录",空串会渲染成 "落地到 " 这种残缺文案。
|
|
425
|
+
pullRoot: typeof pullRoot === 'string' && pullRoot !== '' ? pullRoot : null,
|
|
426
|
+
dispatch: true,
|
|
427
|
+
},
|
|
428
|
+
})
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
// ── 节点列表 ──────────────────────────────────────────────────
|
|
432
|
+
route(`${UI_API_PREFIX}/nodes`, (req, res) => {
|
|
433
|
+
if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
|
|
434
|
+
writeJson(res, 200, { nodes: registry.list().map(nodeView) })
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
// ── 任务列表(可按节点过滤) ──────────────────────────────────
|
|
438
|
+
route(`${UI_API_PREFIX}/tasks`, (req, res) => {
|
|
439
|
+
if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
|
|
440
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
441
|
+
const nodeId = url.searchParams.get('node') ?? ''
|
|
442
|
+
let tasks = registry.listTasks().map((t) => registry.taskView(t))
|
|
443
|
+
if (nodeId !== '') tasks = tasks.filter((t) => t.nodeId === nodeId)
|
|
444
|
+
// 最近的在前,面板顶部就是最新活动
|
|
445
|
+
tasks.sort((a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0))
|
|
446
|
+
writeJson(res, 200, { tasks: tasks.slice(0, 200).map((t) => taskView(t)) })
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// ── 单任务详情(含 handoff 卡片) ─────────────────────────────
|
|
450
|
+
route(`${UI_API_PREFIX}/task`, (req, res) => {
|
|
451
|
+
if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
|
|
452
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
453
|
+
const taskId = url.searchParams.get('taskId') ?? ''
|
|
454
|
+
const t = registry.getTask(taskId)
|
|
455
|
+
if (!t) return writeJson(res, 404, { error: ERROR_CODE.TASK_UNKNOWN, taskId })
|
|
456
|
+
const view = taskView(t, { tail: 20 })
|
|
457
|
+
// handoff 经白名单投影后再回(副机上报的字段集不受本文件控制)
|
|
458
|
+
writeJson(res, 200, { task: view, handoff: handoffView(t.handoff) })
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
// ── 动作:派任务 ──────────────────────────────────────────────
|
|
462
|
+
route(`${UI_API_PREFIX}/dispatch`, async (req, res) => {
|
|
463
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'method' })
|
|
464
|
+
const body = await readJson(req)
|
|
465
|
+
if (!body) return writeJson(res, 400, { error: 'bad-body' })
|
|
466
|
+
|
|
467
|
+
const goal = typeof body.goal === 'string' ? body.goal.trim() : ''
|
|
468
|
+
if (goal === '') return writeJson(res, 400, { error: 'goal-required' })
|
|
469
|
+
|
|
470
|
+
const nodes = registry.list()
|
|
471
|
+
const nodeId = typeof body.node === 'string' && body.node !== ''
|
|
472
|
+
? body.node
|
|
473
|
+
: (nodes.length === 1 ? nodes[0].nodeId : '')
|
|
474
|
+
if (nodeId === '') {
|
|
475
|
+
return writeJson(res, 400, { error: 'node-required', message: '多个节点时必须在 body.node 指定' })
|
|
476
|
+
}
|
|
477
|
+
if (!registry.getNode(nodeId)) return writeJson(res, 404, { error: 'node-unknown', nodeId })
|
|
478
|
+
|
|
479
|
+
const mode = body.mode === TASK_MODE.EXEC ? TASK_MODE.EXEC : TASK_MODE.AGENT
|
|
480
|
+
const taskId = `ui-${Date.now().toString(36)}${Math.floor(Math.random() * 0x1000).toString(36)}`
|
|
481
|
+
|
|
482
|
+
// 复用与 remote_task 工具完全相同的注册路径,避免两套行为漂移
|
|
483
|
+
const { validateTaskStart } = await import('../protocol.js')
|
|
484
|
+
const v = validateTaskStart({
|
|
485
|
+
taskId,
|
|
486
|
+
goal,
|
|
487
|
+
workspace: typeof body.workspace === 'string' && body.workspace !== '' ? body.workspace : undefined,
|
|
488
|
+
mode,
|
|
489
|
+
// 两阶段审批(提案 #2):面板同样可显式选 auto/manual;缺省语义在
|
|
490
|
+
// validateTaskStart(exec→manual、agent→auto),与 remote_task 一致。
|
|
491
|
+
approval: body.approval,
|
|
492
|
+
// 面板派单也可以指定模型(字符串 "provider/model" 或 {provider,model} 对象,
|
|
493
|
+
// 归一与容错都在 validateTaskStart / normalizeModelSelection 里)
|
|
494
|
+
model: body.model,
|
|
495
|
+
outputs: Array.isArray(body.outputs) ? body.outputs.filter((s) => typeof s === 'string') : [],
|
|
496
|
+
constraints: {
|
|
497
|
+
timeoutMs: Number.isFinite(body.timeoutMs) ? body.timeoutMs : (defaults.timeoutMs ?? 3600000),
|
|
498
|
+
denyPaths: [],
|
|
499
|
+
},
|
|
500
|
+
origin: 'ui',
|
|
501
|
+
})
|
|
502
|
+
if (!v.ok) return writeJson(res, 400, { error: v.error })
|
|
503
|
+
|
|
504
|
+
registry.createTask({ nodeId, spec: v.spec })
|
|
505
|
+
// approval(提案 #2):exec 任务默认 manual —— 面板要如实告知"已挂起待批"
|
|
506
|
+
writeJson(res, 200, { ok: true, taskId, nodeId, mode, approval: v.spec.approval })
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
// ── 动作:取消任务 ────────────────────────────────────────────
|
|
510
|
+
route(`${UI_API_PREFIX}/cancel`, async (req, res) => {
|
|
511
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'method' })
|
|
512
|
+
const body = await readJson(req)
|
|
513
|
+
const taskId = typeof body?.taskId === 'string' ? body.taskId : ''
|
|
514
|
+
if (taskId === '') return writeJson(res, 400, { error: 'taskId-required' })
|
|
515
|
+
const t = registry.getTask(taskId)
|
|
516
|
+
if (!t) return writeJson(res, 404, { error: ERROR_CODE.TASK_UNKNOWN, taskId })
|
|
517
|
+
// cancelTask 返回 false 有两种含义:任务不存在(上面已排除)或 cancel 帧没发出去
|
|
518
|
+
// (节点离线)。所以 requested=false 表示"请求未能送达",面板应据此提示。
|
|
519
|
+
const delivered = registry.cancelTask(taskId) === true
|
|
520
|
+
writeJson(res, 200, { ok: true, requested: delivered, nodeId: t.nodeId })
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
// ── 动作:审批(提案 #2 两阶段审批)──────────────────────────
|
|
524
|
+
// 批准挂起的任务:此刻才真正派给副机。任何错误都给明确状态码 ——
|
|
525
|
+
// 批准动作绝不能"看起来成功其实没派"。
|
|
526
|
+
route(`${UI_API_PREFIX}/approve`, async (req, res) => {
|
|
527
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'method' })
|
|
528
|
+
const body = await readJson(req)
|
|
529
|
+
const taskId = typeof body?.taskId === 'string' ? body.taskId : ''
|
|
530
|
+
if (taskId === '') return writeJson(res, 400, { error: 'taskId-required' })
|
|
531
|
+
const t = registry.getTask(taskId)
|
|
532
|
+
if (!t) return writeJson(res, 404, { error: ERROR_CODE.TASK_UNKNOWN, taskId })
|
|
533
|
+
let approved
|
|
534
|
+
try {
|
|
535
|
+
approved = registry.approveTask(taskId, { by: 'ui' })
|
|
536
|
+
} catch (err) {
|
|
537
|
+
// 批准时节点掉线:任务已转 failed,如实告诉面板
|
|
538
|
+
return writeJson(res, 502, { error: 'node-unreachable', taskId, message: shortMessage(err) })
|
|
539
|
+
}
|
|
540
|
+
if (!approved) return writeJson(res, 409, { error: 'not-pending', taskId, status: t.status })
|
|
541
|
+
writeJson(res, 200, { ok: true, taskId, nodeId: t.nodeId })
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
// 拒绝挂起的任务:任务转 rejected 终态(幂等;对已派发的任务返回 409)。
|
|
545
|
+
route(`${UI_API_PREFIX}/reject`, async (req, res) => {
|
|
546
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'method' })
|
|
547
|
+
const body = await readJson(req)
|
|
548
|
+
const taskId = typeof body?.taskId === 'string' ? body.taskId : ''
|
|
549
|
+
if (taskId === '') return writeJson(res, 400, { error: 'taskId-required' })
|
|
550
|
+
const reason = typeof body?.reason === 'string' ? body.reason.slice(0, 300) : ''
|
|
551
|
+
const t = registry.getTask(taskId)
|
|
552
|
+
if (!t) return writeJson(res, 404, { error: ERROR_CODE.TASK_UNKNOWN, taskId })
|
|
553
|
+
const rejected = registry.rejectTask(taskId, { by: 'ui', reason })
|
|
554
|
+
if (!rejected) return writeJson(res, 409, { error: 'not-pending', taskId, status: t.status })
|
|
555
|
+
writeJson(res, 200, { ok: true, taskId, status: 'rejected' })
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
// ── 动作:清理任务视图 ────────────────────────────────────────
|
|
559
|
+
//
|
|
560
|
+
// 面板上的「清理」按钮。为什么需要它:hub 侧任务视图会累积(副机的任务
|
|
561
|
+
// 记录默认保留 7 天,所以它会持续 sync 回来),累积到 maxTasksPerNode
|
|
562
|
+
// 就会**拒绝一切新任务** —— 那种情况下连"派一条清理命令"都做不到,
|
|
563
|
+
// 必须有一个不依赖派任务本身的入口。这个按钮就是那个入口。
|
|
564
|
+
//
|
|
565
|
+
// 语义:**只清 hub 视图,不碰副机状态**(副机是任务状态的权威)。
|
|
566
|
+
// 默认只清终态;在跑的任务保留,除非显式传 includeActive。
|
|
567
|
+
route(`${UI_API_PREFIX}/purge`, async (req, res) => {
|
|
568
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'method' })
|
|
569
|
+
const body = await readJson(req)
|
|
570
|
+
const wanted = typeof body?.node === 'string' ? body.node : ''
|
|
571
|
+
const includeActive = body?.includeActive === true
|
|
572
|
+
// 显式指定 node 时先校验存在 —— 否则会静默清 0 条,用户以为成功了。
|
|
573
|
+
let nodeId
|
|
574
|
+
if (wanted !== '') {
|
|
575
|
+
const rec = registry.pickNode(wanted)
|
|
576
|
+
if (!rec) return writeJson(res, 404, { error: ERROR_CODE.TASK_UNKNOWN, node: wanted })
|
|
577
|
+
nodeId = rec.nodeId
|
|
578
|
+
}
|
|
579
|
+
const { removed, kept } = registry.purgeTasks({ nodeId, includeActive })
|
|
580
|
+
log('info', 'ui purge', { removed, kept, nodeId: nodeId ?? '(all)', includeActive })
|
|
581
|
+
writeJson(res, 200, { ok: true, removed, kept, includeActive })
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
// ── 动作:拉产物 ──────────────────────────────────────────────
|
|
585
|
+
//
|
|
586
|
+
// 这是唯一会写磁盘的 UI 动作,所以约束最严:
|
|
587
|
+
// - 必须配置 pullRoot(否则整个动作禁用)
|
|
588
|
+
// - dest 只能是 pullRoot 内的相对子路径,且经 realpath 归一后仍在其中
|
|
589
|
+
route(`${UI_API_PREFIX}/pull`, async (req, res) => {
|
|
590
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'method' })
|
|
591
|
+
if (typeof pullRoot !== 'string' || pullRoot === '') {
|
|
592
|
+
return writeJson(res, 403, { error: 'pull-disabled', message: '未配置 uiPullRoot,UI 拉取已禁用' })
|
|
593
|
+
}
|
|
594
|
+
const body = await readJson(req)
|
|
595
|
+
const taskId = typeof body?.taskId === 'string' ? body.taskId : ''
|
|
596
|
+
const paths = Array.isArray(body?.paths) ? body.paths.filter((p) => typeof p === 'string') : []
|
|
597
|
+
if (taskId === '' || paths.length === 0) {
|
|
598
|
+
return writeJson(res, 400, { error: 'taskId-and-paths-required' })
|
|
599
|
+
}
|
|
600
|
+
const t = registry.getTask(taskId)
|
|
601
|
+
if (!t) return writeJson(res, 404, { error: ERROR_CODE.TASK_UNKNOWN, taskId })
|
|
602
|
+
|
|
603
|
+
// 目标目录:pullRoot + 可选相对子目录(双道校验,见 resolvePullDest)
|
|
604
|
+
const resolved = resolvePullDest(pullRoot, body?.dest)
|
|
605
|
+
if (!resolved.ok) {
|
|
606
|
+
const status = resolved.code === 'pull-root-unusable' ? 500 : 403
|
|
607
|
+
return writeJson(res, status, { error: resolved.code, dest: resolved.dest })
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const results = []
|
|
611
|
+
for (const p of paths.slice(0, 50)) {
|
|
612
|
+
try {
|
|
613
|
+
// pull() 的契约是 { nodeId, taskId, relPath, dest }(见 pull-manager.js)
|
|
614
|
+
const r = await pulls.pull({ nodeId: t.nodeId, taskId, relPath: p, dest: resolved.dir })
|
|
615
|
+
results.push({ path: p, ok: true, bytes: r?.bytes ?? null, sha256: r?.sha256 ?? null })
|
|
616
|
+
} catch (err) {
|
|
617
|
+
const code = err instanceof RemoteAgentError ? err.code : ERROR_CODE.INTERNAL
|
|
618
|
+
results.push({ path: p, ok: false, error: code, message: shortMessage(err) })
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
writeJson(res, 200, { ok: true, dest: resolved.dir, results })
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
// ── 配置视图:面板里的「主机设置」区读它 ──────────────────────
|
|
625
|
+
//
|
|
626
|
+
// 只读。改配置要编辑 profile 的 patch 层并重启 —— 插件在运行时写它会让
|
|
627
|
+
// 下次启动的配置与当前进程不一致,且没有事务保证。面板负责**展示**当前
|
|
628
|
+
// 生效值 + 指明去哪改,比提供一个半可靠的写入按钮更诚实。
|
|
629
|
+
route(`${UI_API_PREFIX}/config`, (req, res) => {
|
|
630
|
+
if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
|
|
631
|
+
writeJson(res, 200, { ok: true, config: configView() })
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
// ── 副机接入:生成配对码(面板 GUI 化的落点)──────────────────
|
|
635
|
+
//
|
|
636
|
+
// 端口不查内部状态:面板本身就由 hub 的 webserver 服务,浏览器侧把
|
|
637
|
+
// window.location.port 传回来即是节点拨入端口(节点端点与面板同端口)。
|
|
638
|
+
//
|
|
639
|
+
// ⚠ token 披露边界(与 /config 的"只回 set 布尔"同一精神):
|
|
640
|
+
// 含 token 的完整配对码**只对本机回环请求**生成 —— 面板可能被 lanBind
|
|
641
|
+
// 暴露到局域网,局域网内的另一台机器能打开面板 ≠ 应该拿到主机凭证。
|
|
642
|
+
// 非回环请求得到不含 token 的码(对可信内网直连仍然够用)+ 一条提示。
|
|
643
|
+
route(`${UI_API_PREFIX}/pair-info`, (req, res) => {
|
|
644
|
+
if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
|
|
645
|
+
const url = new URL(req.url, 'http://localhost')
|
|
646
|
+
const portRaw = url.searchParams.get('port') ?? ''
|
|
647
|
+
const port = /^\d+$/.test(portRaw) && +portRaw > 0 && +portRaw < 65536 ? +portRaw : 19387
|
|
648
|
+
const ra = String(req.socket?.remoteAddress ?? '')
|
|
649
|
+
const loopback = ra === '127.0.0.1' || ra === '::1' || ra === '::ffff:127.0.0.1'
|
|
650
|
+
const tokenSet = typeof config.nodeToken === 'string' && config.nodeToken !== ''
|
|
651
|
+
const token = loopback && tokenSet ? config.nodeToken : ''
|
|
652
|
+
const codes = detectLanIps()
|
|
653
|
+
.map((ip) => {
|
|
654
|
+
const hubUrl = buildHubUrl(ip.address, port)
|
|
655
|
+
const pairCode = encodePairCode({ hubUrl, token })
|
|
656
|
+
return {
|
|
657
|
+
address: ip.address,
|
|
658
|
+
name: ip.name,
|
|
659
|
+
private: ip.private === true,
|
|
660
|
+
hubUrl,
|
|
661
|
+
pairCode,
|
|
662
|
+
includesToken: token !== '' && pairCode !== '',
|
|
663
|
+
}
|
|
664
|
+
})
|
|
665
|
+
.filter((c) => c.pairCode !== '')
|
|
666
|
+
writeJson(res, 200, {
|
|
667
|
+
ok: true,
|
|
668
|
+
port,
|
|
669
|
+
tokenSet,
|
|
670
|
+
scope: token !== '' ? 'full' : 'tokenless',
|
|
671
|
+
codes,
|
|
672
|
+
firewallHint: `netsh advfirewall firewall add rule name="dsh-remote-agent" dir=in action=allow protocol=TCP localport=${port}`,
|
|
673
|
+
note: token !== ''
|
|
674
|
+
? ''
|
|
675
|
+
: (tokenSet
|
|
676
|
+
? '当前不是从主机本机打开的面板 —— 配对码不含 token;完整版请在主机本机打开面板生成,或用 dsh-remote-setup 向导'
|
|
677
|
+
: '主机未设置 nodeToken —— 配对码不含 token,仅限完全可信内网;公网部署前必须先设置'),
|
|
678
|
+
})
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
// ── 诊断:面板自报(用于排查"面板 404 但路由存在"这类问题)──────
|
|
682
|
+
//
|
|
683
|
+
// 背景:桌面版(Electron)的页面来源是 `dsh-app://app/`,它对非静态资源的
|
|
684
|
+
// 请求会经 `forwardWebRequest` 转发到本机 HTTP 服务。这条链路上任何一环出问题
|
|
685
|
+
// (页面 origin 不同、转发改写、路径不符)都会表现为面板里的一个 HTTP 错误码,
|
|
686
|
+
// 而**光看那个码无法区分是哪一环**。
|
|
687
|
+
//
|
|
688
|
+
// 所以给面板一个"把现场报回来"的端点:client 在首次加载时 POST 自己的
|
|
689
|
+
// location.href / origin / 以及它实际请求的 URL。这条会进 DSH 日志。
|
|
690
|
+
//
|
|
691
|
+
// 安全:只接受同源请求(走同一个 guard),且**只记录不执行任何动作**;
|
|
692
|
+
// 请求体上限 4KB,字段逐个白名单,绝不回显。
|
|
693
|
+
route(`${UI_API_PREFIX}/diag`, async (req, res) => {
|
|
694
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'method' })
|
|
695
|
+
let body = {}
|
|
696
|
+
try {
|
|
697
|
+
body = (await readJson(req, 4096)) ?? {}
|
|
698
|
+
} catch {
|
|
699
|
+
/* 体不合法也继续记,现场信息在 header 里 */
|
|
700
|
+
}
|
|
701
|
+
const pick = (v) => (typeof v === 'string' ? v.slice(0, 300) : null)
|
|
702
|
+
log('info', 'panel self-report', {
|
|
703
|
+
pageHref: pick(body.href),
|
|
704
|
+
pageOrigin: pick(body.origin),
|
|
705
|
+
pageProtocol: pick(body.protocol),
|
|
706
|
+
apiBase: pick(body.apiBase),
|
|
707
|
+
probeUrl: pick(body.probeUrl),
|
|
708
|
+
probeStatus: typeof body.probeStatus === 'number' ? body.probeStatus : null,
|
|
709
|
+
probeError: pick(body.probeError),
|
|
710
|
+
reqHost: pick(req.headers?.host),
|
|
711
|
+
reqOrigin: pick(req.headers?.origin),
|
|
712
|
+
reqSecFetchSite: pick(req.headers?.['sec-fetch-site']),
|
|
713
|
+
})
|
|
714
|
+
writeJson(res, 200, { ok: true })
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
// ── 任务模板库(配方):list / save / delete ────────────────────
|
|
718
|
+
//
|
|
719
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
720
|
+
// 为什么这组路由值得单独一段说明
|
|
721
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
722
|
+
//
|
|
723
|
+
// 它是本文件里**唯一把浏览器输入持久化到磁盘**的一组(pull 是往磁盘写文件,
|
|
724
|
+
// 但写的是副机传回来的产物;这里是"把面板上填的东西存下来,之后原样回填")。
|
|
725
|
+
// 于是它同时踩到两个已有结论:
|
|
726
|
+
// · 结论 1(绕过 DSH 的 /api 围栏)→ 必须走同一个 guard()
|
|
727
|
+
// · 结论 3(响应体逐字段白名单)→ 回给浏览器的每条模板都要**逐字段构造**,
|
|
728
|
+
// 绝不 spread 磁盘上的对象(文件可能被手工编辑过,里面什么都有)
|
|
729
|
+
// 两条都不另起一套:`route()` 已经包了 guard + 异常收口,这里直接用。
|
|
730
|
+
//
|
|
731
|
+
// ── 为什么模板不是"注入通道"(本组的设计目标) ────────────────────────
|
|
732
|
+
// 模板会被面板回填成 `/dispatch` 的请求体。若它能带任意字段,就等于绕过
|
|
733
|
+
// `/dispatch` 与 `validateTaskStart` 那道收口。所以校验全部在 templates.js
|
|
734
|
+
// 里做(白名单 + 长度 + 数量 + 枚举),本文件只负责把错误码翻成 HTTP 状态。
|
|
735
|
+
//
|
|
736
|
+
// ── 存储位置 ─────────────────────────────────────────────────────────
|
|
737
|
+
// 默认落在 hub 的状态目录(与 hub-identity.json 同目录),可用
|
|
738
|
+
// `uiTemplatesFile` 显式指定;再退一步用 `stateDir` 拼默认文件名。
|
|
739
|
+
// 都不给时用 createTemplateStore 自己的默认(homedir 不可写退 tmp)。
|
|
740
|
+
//
|
|
741
|
+
// 为什么优先读**调用方显式传入**的那份 config(而不是只认参数):
|
|
742
|
+
// hub 的配置入口是 profile 的 cordis.patch.yml,用户能写的是 config.*;
|
|
743
|
+
// 参数只是给测试与将来的装配层留的直通口。
|
|
744
|
+
const pickPath = (v) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : '')
|
|
745
|
+
const templatesFile =
|
|
746
|
+
pickPath(uiTemplatesFile)
|
|
747
|
+
|| pickPath(config.uiTemplatesFile)
|
|
748
|
+
|| defaultTemplatesFile(pickPath(config.stateDir) || undefined)
|
|
749
|
+
const templateStore = createTemplateStore({ file: templatesFile, log })
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* 单条模板的**出边界投影**。
|
|
753
|
+
*
|
|
754
|
+
* 与 views.js 的六个投影同一条硬规则:逐字段构造,永不 spread。
|
|
755
|
+
* 磁盘上的记录是"可能被手工编辑过的文件",而且未来模板结构会加字段 ——
|
|
756
|
+
* 让浏览器自动看到新字段等于把"加字段"变成一次静默的契约变更。
|
|
757
|
+
*/
|
|
758
|
+
const templateView = (t) => {
|
|
759
|
+
if (!t || typeof t !== 'object') return null
|
|
760
|
+
const str = (x, max) => (typeof x === 'string' ? x.slice(0, max) : '')
|
|
761
|
+
const out = {
|
|
762
|
+
id: str(t.id, 64),
|
|
763
|
+
name: str(t.name, 60),
|
|
764
|
+
goal: str(t.goal, 2000),
|
|
765
|
+
mode: t.mode === 'exec' ? 'exec' : 'agent',
|
|
766
|
+
createdAt: Number.isFinite(t.createdAt) ? t.createdAt : 0,
|
|
767
|
+
updatedAt: Number.isFinite(t.updatedAt) ? t.updatedAt : 0,
|
|
768
|
+
}
|
|
769
|
+
// 可选字段:**缺就省略**,而不是给 null/空串 ——
|
|
770
|
+
// 面板据此判断"这条模板有没有预设 workspace",空串会渲染成一个假的预设值。
|
|
771
|
+
if (typeof t.workspace === 'string' && t.workspace !== '') out.workspace = t.workspace.slice(0, 500)
|
|
772
|
+
if (t.model && typeof t.model === 'object') {
|
|
773
|
+
const m = {}
|
|
774
|
+
if (typeof t.model.provider === 'string') m.provider = t.model.provider.slice(0, 64)
|
|
775
|
+
if (typeof t.model.model === 'string') m.model = t.model.model.slice(0, 128)
|
|
776
|
+
if (typeof t.model.reasoningEffort === 'string') m.reasoningEffort = t.model.reasoningEffort.slice(0, 32)
|
|
777
|
+
if (Object.keys(m).length > 0) out.model = m
|
|
778
|
+
}
|
|
779
|
+
if (Array.isArray(t.outputs) && t.outputs.length > 0) {
|
|
780
|
+
out.outputs = t.outputs.filter((o) => typeof o === 'string').slice(0, 32).map((o) => o.slice(0, 200))
|
|
781
|
+
}
|
|
782
|
+
if (Number.isFinite(t.timeoutMs)) out.timeoutMs = t.timeoutMs
|
|
783
|
+
return out
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ── 列模板 ────────────────────────────────────────────────────
|
|
787
|
+
//
|
|
788
|
+
// 面板每次打开派发表单时拉它。**不缓存**:模板是用户刚点"保存"就能看到的东西,
|
|
789
|
+
// 缓存只会让"我明明存了怎么没有"这种问题多一个来源。
|
|
790
|
+
route(`${UI_API_PREFIX}/templates`, async (req, res) => {
|
|
791
|
+
if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
|
|
792
|
+
const r = await templateStore.list()
|
|
793
|
+
writeJson(res, 200, {
|
|
794
|
+
ok: true,
|
|
795
|
+
templates: r.templates.map(templateView).filter(Boolean),
|
|
796
|
+
count: r.count,
|
|
797
|
+
max: templateStore.max,
|
|
798
|
+
// degraded=true 表示文件坏了/读不了,已降级为空库(不是"你一条都没存过")。
|
|
799
|
+
// 面板据此显示提示 —— 否则用户会以为模板被自己删光了。
|
|
800
|
+
degraded: r.degraded === true,
|
|
801
|
+
reason: typeof r.reason === 'string' ? r.reason : '',
|
|
802
|
+
// 模板存哪:面板显示它,让"我的模板文件在哪"不必靠猜。
|
|
803
|
+
// (只回路径本身,不含任何凭证 —— 模板里本就没有凭证字段。)
|
|
804
|
+
file: templateStore.file,
|
|
805
|
+
})
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
// ── 保存模板(新建或更新)─────────────────────────────────────
|
|
809
|
+
//
|
|
810
|
+
// body.id 命中已有模板 → 就地更新;不带 id → 新建。
|
|
811
|
+
// 状态码映射刻意区分三类,面板据此给不同的提示:
|
|
812
|
+
// 400 输入不合法(把 message 原文给用户看 —— 它是人话,且不含路径)
|
|
813
|
+
// 404 id 指了一条不存在的模板(可能已被别的会话删了)
|
|
814
|
+
// 409 数量到上限
|
|
815
|
+
route(`${UI_API_PREFIX}/templates/save`, async (req, res) => {
|
|
816
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'method' })
|
|
817
|
+
const body = await readJson(req)
|
|
818
|
+
if (!body) return writeJson(res, 400, { error: 'bad-body' })
|
|
819
|
+
const r = await templateStore.save(body)
|
|
820
|
+
if (!r.ok) return writeJson(res, r.status ?? 400, { error: r.error, message: r.message })
|
|
821
|
+
log('info', 'ui template saved', { id: r.template.id, updated: r.updated, count: r.count })
|
|
822
|
+
writeJson(res, 200, {
|
|
823
|
+
ok: true,
|
|
824
|
+
template: templateView(r.template),
|
|
825
|
+
count: r.count,
|
|
826
|
+
updated: r.updated === true,
|
|
827
|
+
recovered: r.recovered === true,
|
|
828
|
+
})
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
// ── 删除模板 ──────────────────────────────────────────────────
|
|
832
|
+
//
|
|
833
|
+
// 用 POST + body.id 而不是 `DELETE /templates/:id`:本文件的路由是
|
|
834
|
+
// `kind:'exact'`(**不支持路径参数**,见文件头结论 1),要支持路径参数就得
|
|
835
|
+
// 改成 prefix 路由,那会把 DSH 的 /api 围栏语义一起改掉 —— 代价远大于收益。
|
|
836
|
+
route(`${UI_API_PREFIX}/templates/delete`, async (req, res) => {
|
|
837
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'method' })
|
|
838
|
+
const body = await readJson(req)
|
|
839
|
+
if (!body) return writeJson(res, 400, { error: 'bad-body' })
|
|
840
|
+
const r = await templateStore.remove(body.id)
|
|
841
|
+
if (!r.ok) return writeJson(res, r.status ?? 400, { error: r.error, message: r.message })
|
|
842
|
+
log('info', 'ui template deleted', { id: String(body.id).slice(0, 64), count: r.count })
|
|
843
|
+
writeJson(res, 200, { ok: true, removed: r.removed, count: r.count })
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
// 注册结果汇总。**必须打** —— 路由注册失败是静默致命的(面板只看到 404/401,
|
|
847
|
+
// 而宿主启动日志里什么都没有)。有了这一行,排查时一眼能看出"注册了几条、
|
|
848
|
+
// 失败几条"。走 log 而不是写文件:不会留垃圾,且宿主日志里有痕迹。
|
|
849
|
+
log('info', 'ui routes registered', {
|
|
850
|
+
registered: registeredCount,
|
|
851
|
+
failed: failedCount,
|
|
852
|
+
prefix: UI_API_PREFIX,
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
return disposers
|
|
856
|
+
}
|