dsh-remote-agent 0.0.0-stage → 0.4.1

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/setup.cmd +30 -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 +802 -0
  66. package/scripts/check-packaging.mjs +177 -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
package/lib/pairing.js ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * 配对码(pairing code):把「副机要抄的连接信息」压缩成一行可粘贴的串。
3
+ *
4
+ * 解决的问题:小白部署时最容易抄错的就是 hubUrl / token 这两行 ——
5
+ * 少半个斜杠、token 尾部多个空格,表现都是"副机永远不上线"且无报错。
6
+ * 配对码让主机向导输出**一行** `dshra1://…`,副机配置里粘一个 `pair:` 字段
7
+ * 就完成连接配置(roots 仍然由副机自己声明 —— 它是副机的安全边界,
8
+ * 不能由主机侧的串决定)。
9
+ *
10
+ * 格式:`dshra1://` + base64url(JSON)。选择理由:
11
+ * - base64url 字符集(A-Za-z0-9_-)不含 YAML/JSON 特殊字符,粘贴安全;
12
+ * - 带 `://` 看起来像一个链接,用户知道"这是一整段,别抄一半";
13
+ * - 版本号 v 在载荷里,未来改格式能平滑识别旧码。
14
+ *
15
+ * 安全注意:**配对码里含 token,等同凭证**。文档要求只通过可信渠道
16
+ * (当面/加密聊天)传给副机,不要贴公开群里。
17
+ *
18
+ * 纯函数、零依赖、永不抛 —— 解析失败一律返回结构化错误,让人话修法
19
+ * 能一路带到向导 / doctor / worker 日志三个出口。
20
+ */
21
+
22
+ export const PAIR_SCHEME = 'dshra1://'
23
+ /** 配对码长度上限:正常载荷 <300 字符,上限只防病态粘贴。 */
24
+ const MAX_PAIR_LENGTH = 4096
25
+ /** 载荷里允许出现的字段(多余字段忽略,防止未来字段变成注入面)。 */
26
+ const PAYLOAD_KEYS = ['v', 'transportMode', 'hubUrl', 'relayUrl', 'token', 'nodeId', 'hubFingerprint']
27
+ /**
28
+ * hub 指纹(P2-11)。与 lib/identity.js 的 normalizeFingerprint 同形 ——
29
+ * 刻意不 import:pairing 必须保持零 Node 内置模块依赖(client 的 web 包
30
+ * 也走这里,identity.js 里的 fs/child_process 进不了浏览器)。两处改一处必同步。
31
+ */
32
+ const HUB_FINGERPRINT_RE = /^hubfp1-[A-Za-z0-9_-]{22}$/
33
+ /** 与 protocol.validateHello / lib/setup.js NODE_ID_RE 一致。 */
34
+ const NODE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
35
+
36
+ function isPlainObject(v) {
37
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v)
38
+ }
39
+
40
+ function cleanStr(v) {
41
+ return typeof v === 'string' ? v.trim() : ''
42
+ }
43
+
44
+ /**
45
+ * 生成配对码。输入任意垃圾字段都安全(非法值直接不放进载荷)。
46
+ * @param {object} o
47
+ * @param {string} [o.hubUrl] direct 模式拨入地址(与 relayUrl 至少给一个)
48
+ * @param {string} [o.relayUrl] relay 模式会合地址
49
+ * @param {string} [o.token] 入网 token(可空 —— 仅限可信内网)
50
+ * @param {string} [o.nodeId] 建议副机使用的节点 id
51
+ * @returns {string} `dshra1://...`;输入不足(连地址都没有)时返回 ''
52
+ */
53
+ export function encodePairCode({ hubUrl = '', relayUrl = '', token = '', nodeId = '', hubFingerprint = '' } = {}) {
54
+ const payload = { v: 1 }
55
+ const mode = relayUrl && !hubUrl ? 'relay' : 'direct'
56
+ if (mode === 'relay' && cleanStr(relayUrl) !== '') {
57
+ payload.transportMode = 'relay'
58
+ payload.relayUrl = cleanStr(relayUrl)
59
+ } else if (cleanStr(hubUrl) !== '') {
60
+ payload.hubUrl = cleanStr(hubUrl)
61
+ } else {
62
+ return '' // 没有任何地址,无法构成有效配对码
63
+ }
64
+ if (cleanStr(token) !== '') payload.token = cleanStr(token)
65
+ if (cleanStr(nodeId) !== '' && NODE_ID_RE.test(cleanStr(nodeId))) payload.nodeId = cleanStr(nodeId)
66
+ // hub 指纹(P2-11):副机展开配对码时自动变成 pinnedHubFingerprint(TOFU 钉住)。
67
+ // 形态不对就整个不放进去 —— 载荷里永远只有合法字段。
68
+ if (cleanStr(hubFingerprint) !== '' && HUB_FINGERPRINT_RE.test(cleanStr(hubFingerprint))) {
69
+ payload.hubFingerprint = cleanStr(hubFingerprint)
70
+ }
71
+ const json = JSON.stringify(payload)
72
+ return PAIR_SCHEME + Buffer.from(json, 'utf8').toString('base64url')
73
+ }
74
+
75
+ /**
76
+ * 解析配对码。容错:首尾空白、中间换行(终端复制常见)、成对引号。
77
+ * @returns {{ok:true, payload:object}|{ok:false, error:string}}
78
+ * error 是给人看的中文(向导 / doctor 直接展示)。
79
+ */
80
+ export function decodePairCode(raw) {
81
+ let s = cleanStr(raw)
82
+ if (s === '') return { ok: false, error: '配对码为空 —— 请把主机向导输出的 dshra1:// 整行完整粘贴进来' }
83
+ // 去掉粘贴时裹上的成对引号(YAML/聊天软件常见)
84
+ if ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"'))) {
85
+ s = s.slice(1, -1).trim()
86
+ }
87
+ // 中间的换行/制表是"复制时被折行"的典型形态,拼接回来
88
+ s = s.replace(/[\r\n\t]+/g, '')
89
+ if (s.length > MAX_PAIR_LENGTH) {
90
+ return { ok: false, error: `配对码过长(>${MAX_PAIR_LENGTH} 字符)—— 疑似粘错了内容,请重新复制主机输出的 dshra1:// 整行` }
91
+ }
92
+ if (!s.toLowerCase().startsWith(PAIR_SCHEME)) {
93
+ return { ok: false, error: `不是配对码 —— 配对码以 ${PAIR_SCHEME} 开头(地址请填在 hubUrl 字段,两者别混)` }
94
+ }
95
+ const b64 = s.slice(PAIR_SCHEME.length)
96
+ if (b64 === '' || !/^[A-Za-z0-9_-]+$/.test(b64)) {
97
+ return { ok: false, error: '配对码内容不完整 —— 只含字母数字和 - _ 才是合法的 dshra1 码,请从主机重新完整复制' }
98
+ }
99
+ let payload
100
+ try {
101
+ payload = JSON.parse(Buffer.from(b64, 'base64url').toString('utf8'))
102
+ } catch {
103
+ return { ok: false, error: '配对码解析不了(内容被截断或改过)—— 请回主机向导重新生成,整行复制' }
104
+ }
105
+ if (!isPlainObject(payload)) {
106
+ return { ok: false, error: '配对码内容不是有效的 dshra1 载荷 —— 请回主机向导重新生成' }
107
+ }
108
+ if (payload.v !== 1) {
109
+ return { ok: false, error: `配对码版本不受支持(v=${payload.v})—— 请把两边的 dsh-remote-agent 都升级到同一版本` }
110
+ }
111
+ // 只保留白名单字段(ignore 未来新增字段,保证旧版本可读性)
112
+ const clean = {}
113
+ for (const k of PAYLOAD_KEYS) {
114
+ if (payload[k] !== undefined) clean[k] = payload[k]
115
+ }
116
+ const mode = clean.transportMode === 'relay' ? 'relay' : 'direct'
117
+ if (mode === 'relay') {
118
+ if (cleanStr(clean.relayUrl) === '') {
119
+ return { ok: false, error: '配对码是 relay 模式但缺 relayUrl —— 请回主机向导重新生成' }
120
+ }
121
+ clean.transportMode = 'relay'
122
+ clean.hubUrl = undefined
123
+ } else {
124
+ if (cleanStr(clean.hubUrl) === '') {
125
+ return { ok: false, error: '配对码里没有主机地址 —— 请回主机向导重新生成' }
126
+ }
127
+ clean.transportMode = undefined
128
+ clean.relayUrl = undefined
129
+ }
130
+ if (clean.token !== undefined && typeof clean.token !== 'string') {
131
+ return { ok: false, error: '配对码里的 token 字段损坏 —— 请回主机向导重新生成' }
132
+ }
133
+ if (clean.nodeId !== undefined && (typeof clean.nodeId !== 'string' || !NODE_ID_RE.test(clean.nodeId))) {
134
+ return { ok: false, error: '配对码里的 nodeId 不合法 —— 请回主机向导重新生成' }
135
+ }
136
+ if (clean.hubFingerprint !== undefined) {
137
+ if (typeof clean.hubFingerprint !== 'string' || !HUB_FINGERPRINT_RE.test(clean.hubFingerprint.trim())) {
138
+ return { ok: false, error: '配对码里的 hubFingerprint 不合法 —— 请回主机向导重新生成' }
139
+ }
140
+ clean.hubFingerprint = clean.hubFingerprint.trim()
141
+ }
142
+ // undefined 字段不进最终载荷(JSON.stringify 会丢,这里手动保持一致)
143
+ for (const k of Object.keys(clean)) {
144
+ if (clean[k] === undefined) delete clean[k]
145
+ }
146
+ return { ok: true, payload: clean }
147
+ }
148
+
149
+ /**
150
+ * 把配置里的 `pair` 字段展开成具体的连接字段。
151
+ *
152
+ * 规则:
153
+ * - **显式配置优先** —— 用户手写了 hubUrl/token/nodeId 就不覆盖(配对码
154
+ * 只是"默认值包",不是强覆盖;方便临时换地址调试)。
155
+ * - 展开成功后从结果里删掉 `pair` 键(避免 token 在配置对象里存两份)。
156
+ * - **永不抛**:pair 缺失/无效时原样返回 config 并给出 error,让调用方
157
+ * (worker 启动路径)只降级不炸。
158
+ *
159
+ * @param {object} config worker 的原始 config(容错:null/undefined 均可)
160
+ * @returns {{config:object, error:string}} error 为 '' 表示展开成功或本无 pair
161
+ */
162
+ export function expandPairConfig(config) {
163
+ const src = isPlainObject(config) ? config : {}
164
+ const pairRaw = cleanStr(src.pair)
165
+ if (pairRaw === '') return { config: { ...src }, error: '' }
166
+ const dec = decodePairCode(pairRaw)
167
+ if (!dec.ok) return { config: { ...src }, error: dec.error }
168
+ const p = dec.payload
169
+ const out = { ...src }
170
+ delete out.pair
171
+ const fill = (key, value) => {
172
+ if (value !== undefined && cleanStr(out[key]) === '') out[key] = value
173
+ }
174
+ if (p.transportMode === 'relay') {
175
+ out.transportMode = out.transportMode === 'relay' ? out.transportMode : (cleanStr(out.transportMode) === '' ? 'relay' : out.transportMode)
176
+ fill('relayUrl', p.relayUrl)
177
+ } else {
178
+ fill('transportMode', 'direct')
179
+ fill('hubUrl', p.hubUrl)
180
+ }
181
+ fill('token', p.token)
182
+ fill('nodeId', p.nodeId)
183
+ // P2-11:指纹随码分发 → 副机零终端完成"连接配置 + hub 身份钉住"两件事。
184
+ fill('pinnedHubFingerprint', p.hubFingerprint)
185
+ return { config: out, error: '' }
186
+ }
@@ -0,0 +1,414 @@
1
+ /**
2
+ * dsh-remote-agent 协议层(零外部依赖)。
3
+ *
4
+ * 设计要点:
5
+ * - 单向凭证:模型 key / 会话日志 / 插件只存在于各自机器,协议里永远不出现模型凭证。
6
+ * - 任务幂等:taskId 由 hub 生成,node 按 id 去重,重连重发不重复执行。
7
+ * - 清单先于字节:产物先回 manifest,hub 显式 pull 才传输字节。
8
+ * - 版本协商:hello/welcome 里带 protocol,不匹配时 node 主动拒绝而不是半懂半不懂地跑。
9
+ */
10
+
11
+ export const PROTOCOL_VERSION = 1
12
+
13
+ /** 帧类型。hub→node 与 node→hub 分列,避免拼写漂移。 */
14
+ export const HUB_TO_NODE = Object.freeze({
15
+ WELCOME: 'welcome',
16
+ /**
17
+ * hub 身份帧(P2-11)。direct 模式的身份证明放在 welcome 里;relay 模式
18
+ * 副机的"在线"信号来自中继的 accepted,hub 在握手期对副机静默,只能
19
+ * 在节点上线后**追发**这一帧补上身份证明。副机未验证通过前拒绝执行任务。
20
+ */
21
+ IDENTITY: 'hub/identity',
22
+ /**
23
+ * E2E 密钥交换确认(提案 #1):hub 对副机的 node/e2e 提议的签名回应。
24
+ * 载荷 { pub, sig, at } —— pub 是 hub 的 X25519 临时公钥,sig 用 hub 持久
25
+ * ed25519 身份签出(绑定双方公钥 + 连接 nonce)。中继只转发,无法伪造。
26
+ */
27
+ E2E_ACK: 'hub/e2e',
28
+ TASK_START: 'task/start',
29
+ TASK_CANCEL: 'task/cancel',
30
+ TASK_QUERY: 'task/query',
31
+ ARTIFACT_LIST: 'artifact/list',
32
+ ARTIFACT_PULL: 'artifact/pull',
33
+ ARTIFACT_CANCEL: 'artifact/cancel',
34
+ PING: 'ping',
35
+ ERROR: 'error',
36
+ })
37
+
38
+ export const NODE_TO_HUB = Object.freeze({
39
+ HELLO: 'hello',
40
+ /** 重连后主动上报本机全部任务状态 —— 任务状态的真身在副机,不在 hub。 */
41
+ TASK_SYNC: 'task/sync',
42
+ TASK_ACCEPT: 'task/accept',
43
+ TASK_PROGRESS: 'task/progress',
44
+ TASK_SUMMARY: 'task/summary',
45
+ TASK_END: 'task/end',
46
+ ARTIFACT_MANIFEST: 'artifact/manifest',
47
+ ARTIFACT_CHUNK: 'artifact/chunk',
48
+ ARTIFACT_DONE: 'artifact/done',
49
+ /** 资源遥测(CPU/内存/负载,~30s 一次):派发前先看节点负载。尽力而为,字段可缺。 */
50
+ RESOURCE: 'node/resource',
51
+ /**
52
+ * 仓库清单上报(提案 #13):副机把 roots 下扫到的 git 仓库(名字/远端
53
+ * url/本机路径)报给 hub,hub 建 repo→node 索引供 remote_task 的 repo:
54
+ * 参数路由。接入后与每 10min 各发一次;尽力而为,旧 hub 会按未知帧忽略。
55
+ */
56
+ REPOS: 'node/repos',
57
+ /**
58
+ * E2E 密钥交换提议(提案 #1):副机上线后发一次,载荷 { pub, nonce } ——
59
+ * pub 是副机的一次性 X25519 公钥。hub 用持久身份签名确认(hub/e2e)。
60
+ * 旧 hub 按未知帧忽略 → 协商不成就继续明文(混跑安全)。
61
+ */
62
+ E2E_OFFER: 'node/e2e',
63
+ PONG: 'pong',
64
+ ERROR: 'error',
65
+ })
66
+
67
+ /** 中继层信封(relay 只做寻址与转发,不理解业务帧)。 */
68
+ export const RELAY_FRAME = Object.freeze({
69
+ UP: 'up', // node → relay → hub
70
+ DOWN: 'down', // hub → relay → node
71
+ NODE_ONLINE: 'node/online',
72
+ NODE_OFFLINE: 'node/offline',
73
+ NODE_LIST: 'node/list',
74
+ HELLO_NODE: 'hello/node',
75
+ HELLO_HUB: 'hello/hub',
76
+ // 中继级错误(鉴权失败、nodeId 冲突、目标离线、被吊销)。
77
+ // 早期版本漏定义此常量,而 relay.js 有 8 处 sendJson(conn, RELAY_FRAME.ERROR, ...):
78
+ // t 为 undefined → JSON.stringify 丢弃该字段 → 对端 decodeFrame 报 missing-type,
79
+ // 于是**所有中继错误帧都送不到**,transport.js / node-registry.js 的 ERROR 分支
80
+ // 也因 `frame.t === undefined` 永假而永不触发。
81
+ ERROR: 'error',
82
+ })
83
+
84
+ export const TASK_STATUS = Object.freeze({
85
+ QUEUED: 'queued',
86
+ /** 两阶段审批(提案 #2):hub 已受理但**尚未派给副机**,等面板批准。 */
87
+ PENDING_APPROVAL: 'pending_approval',
88
+ RUNNING: 'running',
89
+ COMPLETED: 'completed',
90
+ FAILED: 'failed',
91
+ ABORTED: 'aborted',
92
+ INTERRUPTED: 'interrupted',
93
+ /** 两阶段审批终态:面板拒绝 / 审批超时 / 挂起期间被取消。 */
94
+ REJECTED: 'rejected',
95
+ })
96
+
97
+ export const TERMINAL_STATUS = new Set([
98
+ TASK_STATUS.COMPLETED,
99
+ TASK_STATUS.FAILED,
100
+ TASK_STATUS.ABORTED,
101
+ TASK_STATUS.INTERRUPTED,
102
+ TASK_STATUS.REJECTED,
103
+ ])
104
+
105
+ export const TASK_MODE = Object.freeze({
106
+ /** 副机本机起一个真实 DSH agent 会话,独立模型上下文,任务在副机持久运行。 */
107
+ AGENT: 'agent',
108
+ /** 副机直接执行一段命令(调试 / 简单活)。 */
109
+ EXEC: 'exec',
110
+ })
111
+
112
+ export const ARTIFACT_ROLE = Object.freeze({
113
+ /** 成品:允许 hub 显式 pull。 */
114
+ FINAL: 'final',
115
+ /** 中间产物:只在副机留存,仅上报元数据。 */
116
+ INTERMEDIATE: 'intermediate',
117
+ /** 未归类:上报元数据,默认不可 pull。 */
118
+ OTHER: 'other',
119
+ })
120
+
121
+ export const ARTIFACT_KIND = Object.freeze({
122
+ FILE: 'file',
123
+ DIR: 'dir',
124
+ DIFF: 'diff',
125
+ REPORT: 'report',
126
+ MEDIA: 'media',
127
+ })
128
+
129
+ export const ERROR_CODE = Object.freeze({
130
+ PROTOCOL_MISMATCH: 'protocol-mismatch',
131
+ UNAUTHORIZED: 'unauthorized',
132
+ DUPLICATE_NODE: 'duplicate-node',
133
+ TASK_DUPLICATE: 'task-duplicate',
134
+ TASK_UNKNOWN: 'task-unknown',
135
+ WORKSPACE_DENIED: 'workspace-denied',
136
+ ARTIFACT_DENIED: 'artifact-denied',
137
+ ARTIFACT_MISSING: 'artifact-missing',
138
+ ARTIFACT_TOO_LARGE: 'artifact-too-large',
139
+ HASH_MISMATCH: 'hash-mismatch',
140
+ /** hub 身份验证失败:指纹不匹配 / 签名无效 / nonce 对不上 / 时间戳超窗。 */
141
+ HUB_IDENTITY_MISMATCH: 'hub-identity-mismatch',
142
+ /** relay 模式下 hub 身份帧尚未到达(宽限窗口内),任务暂不执行。 */
143
+ HUB_IDENTITY_PENDING: 'hub-identity-pending',
144
+ /**
145
+ * E2E 会话丢失(提案 #1):hub 侧没有该节点的会话(典型:hub 重启而中继
146
+ * 与副机的连接还活着)。副机收到后重新走 node/e2e 握手。必须走明文 ERROR
147
+ * 帧 —— 此时双方已无共享密钥可言。
148
+ */
149
+ E2E_RESET: 'e2e-reset',
150
+ /** 副机侧拒绝未加密的业务帧(会话已建立却收到明文 = 伪造或 hub 丢会话)。 */
151
+ E2E_REQUIRED: 'e2e-required',
152
+ INTERNAL: 'internal',
153
+ })
154
+
155
+ export const DEFAULT_HEARTBEAT_MS = 15_000
156
+ export const DEFAULT_ACK_TIMEOUT_MS = 60_000
157
+ export const DEFAULT_PULL_TIMEOUT_MS = 30 * 60 * 1000
158
+ /** 单帧二进制分块上限:256 KiB(base64 回退时约 341 KiB)。 */
159
+ export const ARTIFACT_CHUNK_BYTES = 256 * 1024
160
+ /** 中继/控制帧的单帧文本上限,防止畸形帧吃内存。 */
161
+ export const MAX_CONTROL_FRAME_BYTES = 4 * 1024 * 1024
162
+ export const MAX_ARTIFACT_FRAME_BYTES = 8 * 1024 * 1024
163
+
164
+ let counter = 0
165
+
166
+ /** 生成一个短小、无依赖、可读的 id。 */
167
+ export function newId(prefix = 'x') {
168
+ counter = (counter + 1) & 0xffffff
169
+ const t = Date.now().toString(36)
170
+ const r = Math.floor(Math.random() * 0x10000).toString(36)
171
+ return `${prefix}-${t}${r}${counter.toString(36)}`
172
+ }
173
+
174
+ export function newTaskId() {
175
+ return newId('t')
176
+ }
177
+
178
+ export function newPullId() {
179
+ return newId('p')
180
+ }
181
+
182
+ /** 构造一个协议信封。 */
183
+ export function envelope(type, data, opts = {}) {
184
+ const frame = { v: PROTOCOL_VERSION, t: type, id: opts.id ?? newId('m'), d: data ?? {} }
185
+ if (opts.replyTo) frame.r = opts.replyTo
186
+ return frame
187
+ }
188
+
189
+ export function encodeFrame(frame) {
190
+ return JSON.stringify(frame)
191
+ }
192
+
193
+ /**
194
+ * 二进制帧:`[u16 BE 头部长度][头部 JSON][净荷字节]`。
195
+ *
196
+ * 为什么不用 base64 塞进 JSON:产物分块是纯字节流,base64 有 33% 膨胀,
197
+ * 而且中继必须能只读头部就完成路由 —— 净荷一个字节都不该被解码。
198
+ */
199
+ export function encodeBinaryFrame(header, payload) {
200
+ const head = Buffer.from(JSON.stringify(header), 'utf8')
201
+ if (head.length > 0xffff) throw new RemoteAgentError('binary frame header too large', ERROR_CODE.INTERNAL)
202
+ const out = Buffer.allocUnsafe(2 + head.length + payload.length)
203
+ out.writeUInt16BE(head.length, 0)
204
+ head.copy(out, 2)
205
+ payload.copy(out, 2 + head.length)
206
+ return out
207
+ }
208
+
209
+ /** 解析二进制帧。失败返回 {ok:false},绝不抛。 */
210
+ export function decodeBinaryFrame(buf) {
211
+ if (!Buffer.isBuffer(buf) || buf.length < 2) return { ok: false, error: 'binary-frame-too-short' }
212
+ const headLen = buf.readUInt16BE(0)
213
+ if (buf.length < 2 + headLen) return { ok: false, error: 'binary-frame-header-truncated' }
214
+ let header
215
+ try {
216
+ header = JSON.parse(buf.subarray(2, 2 + headLen).toString('utf8'))
217
+ } catch {
218
+ return { ok: false, error: 'binary-frame-header-malformed' }
219
+ }
220
+ if (!header || typeof header !== 'object') return { ok: false, error: 'binary-frame-header-not-object' }
221
+ return { ok: true, header, payload: buf.subarray(2 + headLen) }
222
+ }
223
+
224
+ /**
225
+ * 把二进制编解码器注入传输层,避免 ws.js ↔ transport.js 的模块初始化期循环依赖。
226
+ * 在 node/transport.js 加载时自动完成;单独使用 protocol.js 时不需要调用。
227
+ */
228
+ export function bindBinaryCodecInto(transportModule) {
229
+ transportModule.bindBinaryCodec(encodeBinaryFrame, decodeBinaryFrame)
230
+ }
231
+
232
+ /**
233
+ * 解析并校验一个信封。
234
+ * @returns {{ok:true, frame:object} | {ok:false, error:string}}
235
+ */
236
+ export function decodeFrame(raw) {
237
+ let parsed
238
+ try {
239
+ parsed = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf8'))
240
+ } catch {
241
+ return { ok: false, error: 'malformed-json' }
242
+ }
243
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
244
+ return { ok: false, error: 'malformed-envelope' }
245
+ }
246
+ if (typeof parsed.t !== 'string' || parsed.t === '') {
247
+ return { ok: false, error: 'missing-type' }
248
+ }
249
+ if (parsed.v !== PROTOCOL_VERSION) {
250
+ return { ok: false, error: ERROR_CODE.PROTOCOL_MISMATCH }
251
+ }
252
+ return { ok: true, frame: parsed }
253
+ }
254
+
255
+ /** 一个协议错误,携带稳定 code,便于上层映射到工具错误。 */
256
+ export class RemoteAgentError extends Error {
257
+ constructor(message, code = ERROR_CODE.INTERNAL, details = undefined) {
258
+ super(message)
259
+ this.name = 'RemoteAgentError'
260
+ this.code = code
261
+ this.details = details
262
+ }
263
+ }
264
+
265
+ /**
266
+ * 校验 hello 帧。node 与 hub 共用,保证双方对"什么是合法节点"理解一致。
267
+ * @returns {{ok:true, hello:object} | {ok:false, error:string}}
268
+ */
269
+ export function validateHello(d) {
270
+ if (!d || typeof d !== 'object') return { ok: false, error: 'hello-missing' }
271
+ if (typeof d.nodeId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(d.nodeId)) {
272
+ return { ok: false, error: 'hello-nodeId-invalid' }
273
+ }
274
+ if (d.protocol !== PROTOCOL_VERSION) {
275
+ return { ok: false, error: ERROR_CODE.PROTOCOL_MISMATCH }
276
+ }
277
+ const os = d.os === 'windows' || d.os === 'linux' || d.os === 'darwin' ? d.os : undefined
278
+ if (!os) return { ok: false, error: 'hello-os-invalid' }
279
+ return {
280
+ ok: true,
281
+ hello: {
282
+ nodeId: d.nodeId,
283
+ protocol: d.protocol,
284
+ os,
285
+ arch: typeof d.arch === 'string' ? d.arch : 'unknown',
286
+ hostname: typeof d.hostname === 'string' ? d.hostname : d.nodeId,
287
+ dshVersion: typeof d.dshVersion === 'string' ? d.dshVersion : '',
288
+ shells: Array.isArray(d.shells) ? d.shells.filter((s) => typeof s === 'string') : [],
289
+ roots: Array.isArray(d.roots) ? d.roots.filter((s) => typeof s === 'string') : [],
290
+ caps: Array.isArray(d.caps) ? d.caps.filter((s) => typeof s === 'string') : [],
291
+ profile: typeof d.profile === 'string' ? d.profile : '',
292
+ agentMode: d.agentMode === true,
293
+ // P2-11:本次连接的挑战随机数(hex)。hub 把它签进 welcome/hub-identity
294
+ // 的身份证明,副机核对一致 —— 防"录一帧旧 welcome 重放给新连接"。
295
+ // 缺失(旧版副机)时为空串,hub 退化成自拟 nonce + 时间戳窗口。
296
+ nonce: typeof d.nonce === 'string' && /^[0-9a-f]{16,128}$/.test(d.nonce) ? d.nonce : '',
297
+ time: typeof d.time === 'number' ? d.time : Date.now(),
298
+ },
299
+ }
300
+ }
301
+
302
+ /**
303
+ * 归一化 `artifactRules`,**含内层形状校验**。
304
+ *
305
+ * 合法形状:`{ final?: string[], intermediate?: string[], caseInsensitive?: boolean }`
306
+ * (见 lib/artifacts.js 的 `classify()` —— 它读 `rules.intermediate?.length` /
307
+ * `rules.final?.length` 后交给 `compileRules`,后者要求数组)。
308
+ *
309
+ * 为什么必须校验内层:`typeof x === 'object'` 只挡住 null/undefined/数字/布尔,
310
+ * **字符串与数组也能通过**。而 `'dist/**'.length` 是真值,于是:
311
+ * - 传 `{ final: 'dist/**' }` → `rules.final?.length` 为 7(真)→
312
+ * `compileRules('dist/**')` 把字符串按可迭代对象处理 → 逐字符编译成规则,
313
+ * 得到 `["d","i","s","t","/","*","*"]` 这类垃圾。
314
+ * - 实测后果:单字符文件被误判为可 pull 的 final,而真正的 `dist/app.js`
315
+ * 反而漏掉,**无异常、无日志**。
316
+ *
317
+ * 非数组一律丢弃(与 outputs / denyPaths 的处理方式一致),而不是静默透传。
318
+ * `caseInsensitive` 只接受真正的布尔。
319
+ */
320
+ export function normalizeArtifactRules(raw) {
321
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
322
+ const pick = (v) => (Array.isArray(v) ? v.filter((p) => typeof p === 'string' && p !== '') : undefined)
323
+ const out = {}
324
+ const final = pick(raw.final)
325
+ const intermediate = pick(raw.intermediate)
326
+ if (final !== undefined) out.final = final
327
+ if (intermediate !== undefined) out.intermediate = intermediate
328
+ if (typeof raw.caseInsensitive === 'boolean') out.caseInsensitive = raw.caseInsensitive
329
+ return Object.keys(out).length > 0 ? out : undefined
330
+ }
331
+
332
+ /**
333
+ * 归一化一个模型选择(可选)。
334
+ *
335
+ * 接受三种写法(对小白最友好到最精确):
336
+ * - `'gpt-4o'` → `{ model: 'gpt-4o' }`(provider 用副机默认)
337
+ * - `'openai/gpt-4o'` → `{ provider: 'openai', model: 'gpt-4o' }`
338
+ * - `{ provider, model, reasoningEffort? }` → 逐字段过滤后原样保留
339
+ *
340
+ * 其他输入(数字 / 数组 / 空串 / 字段类型不对)一律返回 undefined,
341
+ * 与 outputs / denyPaths 的处理方式一致 —— 宁可丢掉一个坏参数回到
342
+ * "副机默认模型",也不能把垃圾透传给 runner。
343
+ *
344
+ * 注意:这里只传**选择**,不传凭证 —— 模型 key 始终在副机(核心不变式 1)。
345
+ * 副机拿到选择后用自己的 provider 配置去跑;指定的 provider/model 在副机
346
+ * 不存在时,错误会经 agent/error 监听如实进入 handoff(2026-10-15 修复后
347
+ * 错误不再被静默吞掉)。
348
+ */
349
+ export function normalizeModelSelection(raw) {
350
+ if (raw === undefined || raw === null) return undefined
351
+
352
+ if (typeof raw === 'string') {
353
+ const s = raw.trim()
354
+ if (s === '') return undefined
355
+ // 'provider/model':provider 段不含 '/',model 段允许再带(如 openrouter 的 org/model)
356
+ const slash = s.indexOf('/')
357
+ if (slash > 0 && slash < s.length - 1) {
358
+ return { provider: s.slice(0, slash).slice(0, 64), model: s.slice(slash + 1).slice(0, 128) }
359
+ }
360
+ return { model: s.slice(0, 128) }
361
+ }
362
+
363
+ if (typeof raw !== 'object' || Array.isArray(raw)) return undefined
364
+ const out = {}
365
+ if (typeof raw.provider === 'string' && raw.provider !== '') out.provider = raw.provider.slice(0, 64)
366
+ if (typeof raw.model === 'string' && raw.model !== '') out.model = raw.model.slice(0, 128)
367
+ if (typeof raw.reasoningEffort === 'string' && raw.reasoningEffort !== '') {
368
+ out.reasoningEffort = raw.reasoningEffort.slice(0, 32)
369
+ }
370
+ // 没有 model 的选择不是合法选择(provider 必须配 model,单独 provider 无效)——
371
+ // 与 outputs / denyPaths 一致:坏参数丢掉,回到副机默认。
372
+ if (out.model === undefined) return undefined
373
+ return out
374
+ }
375
+
376
+ /**
377
+ * 校验 task/start 帧。
378
+ * @returns {{ok:true, spec:object} | {ok:false, error:string}}
379
+ */
380
+ export function validateTaskStart(d) {
381
+ if (!d || typeof d !== 'object') return { ok: false, error: 'task-missing' }
382
+ if (typeof d.taskId !== 'string' || d.taskId === '') return { ok: false, error: 'task-taskId-missing' }
383
+ if (typeof d.goal !== 'string' || d.goal.trim() === '') return { ok: false, error: 'task-goal-missing' }
384
+ const mode = d.mode === TASK_MODE.EXEC ? TASK_MODE.EXEC : TASK_MODE.AGENT
385
+ const c = d.constraints && typeof d.constraints === 'object' ? d.constraints : {}
386
+ return {
387
+ ok: true,
388
+ spec: {
389
+ taskId: d.taskId,
390
+ goal: d.goal,
391
+ workspace: typeof d.workspace === 'string' && d.workspace ? d.workspace : undefined,
392
+ mode,
393
+ model: normalizeModelSelection(d.model),
394
+ constraints: {
395
+ timeoutMs: Number.isFinite(c.timeoutMs) ? Math.max(1000, c.timeoutMs) : 60 * 60 * 1000,
396
+ maxBytesWritten: Number.isFinite(c.maxBytesWritten) ? c.maxBytesWritten : 2 * 1024 * 1024 * 1024,
397
+ allowNetwork: c.allowNetwork !== false,
398
+ denyPaths: Array.isArray(c.denyPaths) ? c.denyPaths.filter((p) => typeof p === 'string') : [],
399
+ extraEnv: c.extraEnv && typeof c.extraEnv === 'object' ? c.extraEnv : {},
400
+ },
401
+ outputs: Array.isArray(d.outputs) ? d.outputs.filter((p) => typeof p === 'string') : [],
402
+ // 两阶段审批(提案 #2):'manual' = hub 挂起待面板批准;缺省时 exec
403
+ // 任务默认 manual(保守默认,显式传 'auto' 退回旧行为),agent 任务
404
+ // 默认 auto(agent 会话本身就是交互式的,再卡一道审批只会添堵)。
405
+ approval: d.approval === 'manual' || d.approval === 'auto'
406
+ ? d.approval
407
+ : (mode === TASK_MODE.EXEC ? 'manual' : 'auto'),
408
+ artifactRules: normalizeArtifactRules(d.artifactRules),
409
+ summaryPolicy: d.summaryPolicy === 'stream' ? 'stream' : 'on-complete',
410
+ requestedAt: typeof d.requestedAt === 'number' ? d.requestedAt : Date.now(),
411
+ origin: typeof d.origin === 'string' ? d.origin : 'hub',
412
+ },
413
+ }
414
+ }