@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.5

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 (147) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/APPROVAL-DRILL.md +42 -0
  12. package/docs/CONTRACT-INTEROP.md +77 -0
  13. package/docs/CRASH-SAFETY.md +69 -0
  14. package/docs/DECISION-RELAY.md +46 -0
  15. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  16. package/docs/FORK-SEED.md +64 -0
  17. package/docs/MAIN-CITY-GATE.md +58 -0
  18. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  19. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  20. package/docs/PM-DECISION-POLICY.md +61 -0
  21. package/docs/WORKFLOW-UI.md +86 -0
  22. package/icon.svg +8 -0
  23. package/locale/en.json +6 -0
  24. package/locale/zh.json +6 -0
  25. package/package.json +71 -4
  26. package/sdk/README.md +65 -0
  27. package/sdk/examples/status-badge/README.md +16 -0
  28. package/sdk/examples/status-badge/client.js +60 -0
  29. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  30. package/sdk/examples/status-badge/index.js +12 -0
  31. package/sdk/examples/status-badge/package.json +24 -0
  32. package/sdk/templates/plugin/README.md +36 -0
  33. package/sdk/templates/plugin/client.js +35 -0
  34. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  35. package/sdk/templates/plugin/icon.svg +5 -0
  36. package/sdk/templates/plugin/index.js +23 -0
  37. package/sdk/templates/plugin/locale/en.json +6 -0
  38. package/sdk/templates/plugin/package.json +36 -0
  39. package/src/compose.mjs +67 -0
  40. package/src/contract/VENDOR.json +16 -0
  41. package/src/contract/client.js +93 -0
  42. package/src/contract/constants.js +94 -0
  43. package/src/contract/envelope.js +83 -0
  44. package/src/contract/index.js +20 -0
  45. package/src/contract/negotiate.js +106 -0
  46. package/src/contract/relay.js +223 -0
  47. package/src/contract/server.js +77 -0
  48. package/src/contract/transports.js +319 -0
  49. package/src/index.mjs +11 -0
  50. package/src/kernel/core.mjs +100 -0
  51. package/src/kernel/extensions.mjs +61 -0
  52. package/src/kernel/fsx.mjs +349 -0
  53. package/src/kernel/index.mjs +21 -0
  54. package/src/kernel/ledger.mjs +94 -0
  55. package/src/kernel/paths.mjs +182 -0
  56. package/src/kernel/role.mjs +68 -0
  57. package/src/kernel/rounds.mjs +172 -0
  58. package/src/kernel/schema.mjs +5 -0
  59. package/src/kernel/types.mjs +266 -0
  60. package/src/kernel/vendor/cosmokit.mjs +421 -0
  61. package/src/kernel/vendor/schemastery.mjs +638 -0
  62. package/src/lib/adapter-client.mjs +383 -0
  63. package/src/lib/adapter-protocol.mjs +243 -0
  64. package/src/lib/ai-secret-store.mjs +202 -0
  65. package/src/lib/approval-drill.mjs +111 -0
  66. package/src/lib/boot-guard.mjs +186 -0
  67. package/src/lib/branch-store.mjs +84 -0
  68. package/src/lib/branch.mjs +74 -0
  69. package/src/lib/bridge-client.mjs +133 -0
  70. package/src/lib/broker-adapter.mjs +67 -0
  71. package/src/lib/broker.mjs +45 -0
  72. package/src/lib/business-producers.mjs +109 -0
  73. package/src/lib/command-cache.mjs +209 -0
  74. package/src/lib/compat.mjs +170 -0
  75. package/src/lib/composer-journal.mjs +128 -0
  76. package/src/lib/container/index.mjs +806 -0
  77. package/src/lib/decision-events.mjs +129 -0
  78. package/src/lib/decision-ingest-client.mjs +41 -0
  79. package/src/lib/decision-outbox.mjs +129 -0
  80. package/src/lib/decision-policy.mjs +52 -0
  81. package/src/lib/default-essence.mjs +61 -0
  82. package/src/lib/desktop-launch.mjs +138 -0
  83. package/src/lib/essence.mjs +1211 -0
  84. package/src/lib/fork-client.mjs +410 -0
  85. package/src/lib/fork-config-lock.mjs +166 -0
  86. package/src/lib/fork-seed.mjs +304 -0
  87. package/src/lib/fork-sessions.mjs +183 -0
  88. package/src/lib/fork-supervision-settings.mjs +58 -0
  89. package/src/lib/fork-supervisor.mjs +122 -0
  90. package/src/lib/fs-guard.mjs +28 -0
  91. package/src/lib/http-auth.mjs +162 -0
  92. package/src/lib/idle-audit.mjs +178 -0
  93. package/src/lib/interaction-poke.mjs +61 -0
  94. package/src/lib/main-gate.mjs +582 -0
  95. package/src/lib/mirror-etag.mjs +92 -0
  96. package/src/lib/mirror-official-source.mjs +93 -0
  97. package/src/lib/mirror.mjs +478 -0
  98. package/src/lib/mother-link.mjs +156 -0
  99. package/src/lib/mutex.mjs +31 -0
  100. package/src/lib/permissions.mjs +115 -0
  101. package/src/lib/pnpm-store.mjs +189 -0
  102. package/src/lib/preferences.mjs +212 -0
  103. package/src/lib/producer-contract.mjs +162 -0
  104. package/src/lib/reflow.mjs +41 -0
  105. package/src/lib/remote.mjs +408 -0
  106. package/src/lib/restart-contract.mjs +752 -0
  107. package/src/lib/roots.mjs +30 -0
  108. package/src/lib/rule-registry.mjs +1127 -0
  109. package/src/lib/runtime-capture.mjs +307 -0
  110. package/src/lib/semaphore.mjs +34 -0
  111. package/src/lib/session-bundle.mjs +138 -0
  112. package/src/lib/session-format.mjs +672 -0
  113. package/src/lib/sse-parser.mjs +102 -0
  114. package/src/lib/ssrf-guard.mjs +170 -0
  115. package/src/lib/status-store.mjs +257 -0
  116. package/src/lib/task-handle.mjs +622 -0
  117. package/src/lib/ui-actions.mjs +55 -0
  118. package/src/lib/umbrella.mjs +1661 -0
  119. package/src/lib/upgrade-coordinator.mjs +107 -0
  120. package/src/lib/upgrade-status.mjs +43 -0
  121. package/src/plugins/adapter.mjs +1468 -0
  122. package/src/plugins/approver.mjs +1210 -0
  123. package/src/plugins/auditor.mjs +330 -0
  124. package/src/plugins/buffer-keeper.mjs +522 -0
  125. package/src/plugins/decision-center.mjs +2581 -0
  126. package/src/plugins/forker.mjs +1610 -0
  127. package/src/plugins/healer.mjs +702 -0
  128. package/src/plugins/hot-reloader.mjs +274 -0
  129. package/src/plugins/imports.mjs +247 -0
  130. package/src/plugins/orchestrator.mjs +3979 -0
  131. package/src/plugins/restart-guard.mjs +1022 -0
  132. package/src/plugins/router.mjs +714 -0
  133. package/src/plugins/scorer.mjs +661 -0
  134. package/src/plugins/session-bridge.mjs +166 -0
  135. package/src/plugins/session-catalog.mjs +771 -0
  136. package/src/plugins/session-router.mjs +125 -0
  137. package/src/plugins/session-sync.mjs +171 -0
  138. package/src/plugins/umbrella.mjs +419 -0
  139. package/tools/approval-drill.mjs +21 -0
  140. package/tools/boot-guard.mjs +95 -0
  141. package/tools/decision-ingest.mjs +53 -0
  142. package/tools/desktop-launch.mjs +71 -0
  143. package/tools/fangctl.mjs +425 -0
  144. package/tools/gate/main-city.mjs +204 -0
  145. package/tools/mcp/server.mjs +121 -0
  146. package/tools/mcp/tools.mjs +65 -0
  147. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,1468 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // fang-adapter — 分身侧的跨进程桥(v2.0.0 第 11 行,契约:docs/CONTRACTS-V2.md §5.1、
4
+ // docs/PROTOCOL-V2.md §2/§3)。
5
+ //
6
+ // 母体与分身是两个独立 DSH 进程,不能共享 Cordis ctx/bus。本插件把分身内部的服务
7
+ // (scorer/router/healer/approver/hotReloader/buffer/auditor)暴露为 5 个 HTTP 端点,
8
+ // 由母体侧 `src/lib/remote.mjs` 调用:
9
+ //
10
+ // GET /fang/adapter/hello 握手:role / taskId / version / capabilities
11
+ // GET /fang/adapter/status 服务表 + 缓冲三态 + 待批数 + 审计头
12
+ // GET /fang/adapter/approvals 分身本地待审批项
13
+ // GET /fang/adapter/audit 审计尾部(?limit=n)
14
+ // POST /fang/adapter/command 控制命令 {op, payload}
15
+ //
16
+ // v2.6.1 起叠加 v3 接口(契约:docs/PROTOCOL-V3.md §4,向后兼容,旧端点全部保留):
17
+ //
18
+ // GET /fang/adapter/version 协议版本协商(protocol / sessionId / capabilities)
19
+ // GET /fang/adapter/health 轻量健康检查(Forker.waitReady / 存活探测)
20
+ // GET /fang/adapter/state 完整状态快照(带 lastCursor,幂等)
21
+ // GET /fang/adapter/events SSE 事件流(?since=<cursor>&session=<sid>)
22
+ // POST /fang/adapter/commands 幂等命令信封 {commandId, op, payload, async}
23
+ //
24
+ // 认证(v3):Forker spawn 时生成随机 token 经 env `DSH_ADAPTER_TOKEN` 注入,母体每次
25
+ // 请求带 `Authorization: Bearer <token>`;token 不落盘、不写 URL。未注入时退化为
26
+ // v2 语义(交由 DSH web 的 ?token= 守卫),以便旧分身/旧母体平滑共存。
27
+ //
28
+ // 硬约束:
29
+ // * 只部署在分身:apply 首行 roleGate('adapter'),mother 角色下不注册服务/路由;
30
+ // * 处理器与 service 方法**共用同一实现**,HTTP 只做方法校验与状态码映射;
31
+ // * 所有依赖服务通过 `ctx.get(SERVICE.x)` 可选获取,缺失显式降级为
32
+ // `{ok:false, code:'service-unavailable'}`,绝不假装成功、绝不抛出;
33
+ // * read-plugin 只读「本分身 buffer/<pluginId>」内的文本文件,拒绝路径逃逸/UNC/盘符/
34
+ // `..`,单文件 ≤1MB、总量 ≤2MB,二进制(含 NUL)拒绝;
35
+ // * archive = flush buffer + 归档标记(进程 kill 由母体 Forker 执行,绝不自杀)。
36
+ import fs from 'node:fs'
37
+ import path from 'node:path'
38
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
39
+
40
+ import {
41
+ z,
42
+ roleGate,
43
+ resolveFangLayout,
44
+ SERVICE,
45
+ ADAPTER_OP,
46
+ ADAPTER_ROUTE,
47
+ ADAPTER_EVENT,
48
+ ADAPTER_PROTOCOL_MIN,
49
+ ADAPTER_PROTOCOL_VERSION,
50
+ AUDIT,
51
+ FANG_VERSION,
52
+ bus,
53
+ exists,
54
+ iso,
55
+ newId,
56
+ nowMs,
57
+ sampleResources,
58
+ totalResources,
59
+ } from '../kernel/index.mjs'
60
+ import { authErrorBody, checkRequestAuth } from '../lib/http-auth.mjs'
61
+ import {
62
+ CommandCache,
63
+ EventLog,
64
+ SSE_HEARTBEAT_MS,
65
+ SSE_RETRY_FRAME,
66
+ normalizeCommandEnvelope,
67
+ parseBearer,
68
+ parseCursor,
69
+ sseComment,
70
+ sseFrame,
71
+ statusForAdapterError,
72
+ } from '../lib/adapter-protocol.mjs'
73
+
74
+ export const name = 'fang-adapter'
75
+ export const inject = []
76
+
77
+ /** 单文件 / 单次 read-plugin 总量的硬上限(契约 §5.1、PROTOCOL-V2 §3)。 */
78
+ export const MAX_FILE_BYTES = 1024 * 1024
79
+ export const MAX_TOTAL_BYTES = 2 * 1024 * 1024
80
+
81
+ /** forkId 的环境变量来源(forker 用 DSH_FANG_TASK_ID 注入分身身份)。 */
82
+ const TASK_ID_ENVS = ['DSH_FANG_TASK_ID', 'DSH_FANG_FORK_ID', 'DSH_FANG_FORK', 'FANG_TASK_ID']
83
+
84
+ const DEFAULTS = Object.freeze({
85
+ prefix: '/fang',
86
+ taskId: '',
87
+ auditLimit: 500,
88
+ commandTimeoutMs: 10000,
89
+ token: '',
90
+ eventCapacity: 1000,
91
+ commandCacheSize: 500,
92
+ heartbeatMs: 5000,
93
+ })
94
+
95
+ export const Config = z.object({
96
+ prefix: z.string().default('/fang')
97
+ .description('HTTP 路由前缀(与母体 Remote 的 prefix 一致)'),
98
+ taskId: z.string().default('')
99
+ .description('任务 id;留空 = 环境变量 DSH_FANG_TASK_ID / forkId'),
100
+ auditLimit: z.number().min(1).max(100000).default(500)
101
+ .description('单次审计拉取上限(HTTP ?limit= 会被夹在此值内)'),
102
+ commandTimeoutMs: z.number().min(1).max(600000).default(10000)
103
+ .description('单条控制命令的硬超时(毫秒);超时返回 code=timeout,不阻塞 HTTP'),
104
+ token: z.string().default('')
105
+ .description('v3 Bearer token;留空 = 环境变量 DSH_ADAPTER_TOKEN(Forker spawn 时注入)'),
106
+ eventCapacity: z.number().min(1).max(100000).default(1000)
107
+ .description('SSE 事件环形缓冲容量(母体断线后可续传的事件数)'),
108
+ commandCacheSize: z.number().min(1).max(10000).default(500)
109
+ .description('命令幂等缓存容量(LRU,按 commandId)'),
110
+ heartbeatMs: z.number().min(1000).max(60000).default(5000)
111
+ .description('SSE 心跳 + 待审批 diff 周期(毫秒)'),
112
+ allowLegacyNoToken: z.boolean().default(false)
113
+ .description('v3.0.5:fork 角色无 DSH_ADAPTER_TOKEN 时是否允许 v2 兼容(默认 fail-closed)'),
114
+ })
115
+
116
+ const msgOf = (e) => String(e && e.message !== undefined ? e.message : e)
117
+
118
+ function clampInt(value, min, max, fallback) {
119
+ const n = Math.floor(Number(value))
120
+ if (!Number.isFinite(n)) return fallback
121
+ return Math.min(max, Math.max(min, n))
122
+ }
123
+
124
+ /** '/fang/'、'fang' → '/fang';空值兜底 '/fang'(与 remote.normalizePrefix 同规则)。 */
125
+ export function normalizePrefix(prefix) {
126
+ const raw = String(prefix ?? '/fang').trim().replace(/\\/g, '/')
127
+ if (!raw) return '/fang'
128
+ const withSlash = raw.startsWith('/') ? raw : `/${raw}`
129
+ const trimmed = withSlash.replace(/\/+$/, '')
130
+ return trimmed || '/fang'
131
+ }
132
+
133
+ function resolveTaskId(cfg = {}) {
134
+ const explicit = typeof cfg.taskId === 'string' ? cfg.taskId.trim() : ''
135
+ if (explicit) return explicit
136
+ for (const key of TASK_ID_ENVS) {
137
+ const v = process.env?.[key]
138
+ if (typeof v === 'string' && v.trim()) return v.trim()
139
+ }
140
+ return ''
141
+ }
142
+
143
+ /**
144
+ * 把用户给的相对路径限制在 `root` 内(拒绝 `..`、绝对路径、盘符、UNC、空路径)。
145
+ * @returns {{ok:true, dir:string} | {ok:false, code:string, error:string}}
146
+ */
147
+ export function resolveInside(root, relative) {
148
+ const base = String(root ?? '').trim()
149
+ const raw = String(relative ?? '').trim()
150
+ if (!raw) return { ok: false, code: 'bad-request', error: 'empty-path' }
151
+ if (raw.includes('\0')) return { ok: false, code: 'path-escape', error: 'nul-byte-in-path' }
152
+ if (/^[A-Za-z]:/.test(raw)) return { ok: false, code: 'path-escape', error: `absolute-path:${raw}` }
153
+ if (raw.startsWith('\\\\') || raw.startsWith('//')) {
154
+ return { ok: false, code: 'path-escape', error: `unc-path:${raw}` }
155
+ }
156
+ if (path.isAbsolute(raw)) return { ok: false, code: 'path-escape', error: `absolute-path:${raw}` }
157
+ const segments = raw.replace(/\\/g, '/').split('/')
158
+ if (segments.some((seg) => seg === '..')) {
159
+ return { ok: false, code: 'path-escape', error: `parent-traversal:${raw}` }
160
+ }
161
+ const resolvedRoot = path.resolve(base)
162
+ const target = path.resolve(resolvedRoot, raw)
163
+ const rel = path.relative(resolvedRoot, target)
164
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
165
+ return { ok: false, code: 'path-escape', error: `escapes-buffer:${raw}` }
166
+ }
167
+ return { ok: true, dir: target }
168
+ }
169
+
170
+ /** 二进制启发式:前 8000 字节里出现 NUL 即判二进制(文本插件源码不会含 NUL)。 */
171
+ export function looksBinary(buf) {
172
+ if (!buf || typeof buf.length !== 'number') return false
173
+ const end = Math.min(buf.length, 8000)
174
+ for (let i = 0; i < end; i++) if (buf[i] === 0) return true
175
+ return false
176
+ }
177
+
178
+ /**
179
+ * 递归读取一个插件目录为 `{relPath: text}`。不跟随 symlink/junction(防逃逸),
180
+ * 文件按相对路径排序,失败时返回结构化错误而不是抛。
181
+ */
182
+ function readPluginTree(dir) {
183
+ const files = {}
184
+ let bytes = 0
185
+ const stack = [{ abs: dir, rel: '' }]
186
+ while (stack.length > 0) {
187
+ const current = stack.pop()
188
+ let entries
189
+ try {
190
+ entries = fs.readdirSync(current.abs, { withFileTypes: true })
191
+ } catch (e) {
192
+ return { ok: false, code: 'read-failed', error: `readdir-failed:${msgOf(e)}`, rel: current.rel || null }
193
+ }
194
+ entries.sort((a, b) => String(a.name).localeCompare(String(b.name)))
195
+ for (const entry of entries) {
196
+ const rel = current.rel ? `${current.rel}/${entry.name}` : entry.name
197
+ const abs = path.join(current.abs, entry.name)
198
+ if (entry.isSymbolicLink()) continue // 链接不跟随:junction 可以指向 buffer 之外
199
+ if (entry.isDirectory()) {
200
+ stack.push({ abs, rel })
201
+ continue
202
+ }
203
+ if (!entry.isFile()) continue
204
+ let stat
205
+ try {
206
+ stat = fs.statSync(abs)
207
+ } catch {
208
+ continue
209
+ }
210
+ if (stat.size > MAX_FILE_BYTES) {
211
+ return {
212
+ ok: false, code: 'file-too-large', file: rel, size: stat.size, limit: MAX_FILE_BYTES,
213
+ error: `file-too-large:${rel} (${stat.size} > ${MAX_FILE_BYTES})`,
214
+ }
215
+ }
216
+ if (bytes + stat.size > MAX_TOTAL_BYTES) {
217
+ return {
218
+ ok: false, code: 'total-too-large', file: rel, bytes: bytes + stat.size, limit: MAX_TOTAL_BYTES,
219
+ error: `total-too-large:${bytes + stat.size} > ${MAX_TOTAL_BYTES}`,
220
+ }
221
+ }
222
+ let buf
223
+ try {
224
+ buf = fs.readFileSync(abs)
225
+ } catch (e) {
226
+ return { ok: false, code: 'read-failed', error: `read-failed:${rel}:${msgOf(e)}`, file: rel }
227
+ }
228
+ if (looksBinary(buf)) {
229
+ return { ok: false, code: 'binary-file', file: rel, error: `binary-file:${rel}` }
230
+ }
231
+ bytes += buf.length
232
+ files[rel] = buf.toString('utf8')
233
+ }
234
+ }
235
+ return { ok: true, files, count: Object.keys(files).length, bytes }
236
+ }
237
+
238
+ /**
239
+ * 角色 → Web 端身份 kind(纯函数,便于测试)。
240
+ * 母体 = mother;元宝运行态 = essence(DSH_FANG_ESSENCE 或 rt- forkId);
241
+ * 其余 fork 实例 = 任务分身。
242
+ * @param {{role?:string, taskId?:string, essence?:boolean}} [input]
243
+ * @returns {'mother'|'essence'|'fork'}
244
+ */
245
+ export function identityKind({ role = 'fork', taskId = '', essence = false } = {}) {
246
+ if (role === 'mother') return 'mother'
247
+ return essence === true || String(taskId ?? '').startsWith('rt-') ? 'essence' : 'fork'
248
+ }
249
+
250
+ export function apply(ctx, config) {
251
+ const gate = roleGate('adapter', { logger: ctx.logger })
252
+ if (!gate.active) return
253
+
254
+ // schemastery 的 Config({}) 负责填默认值;后面再叠一层裸对象,宿主/测试绕过 schema 也不炸。
255
+ let configDefaults = {}
256
+ try {
257
+ configDefaults = Config({}) ?? {}
258
+ } catch {
259
+ configDefaults = {}
260
+ }
261
+ const cfg = { ...DEFAULTS, ...configDefaults, ...(config ?? {}) }
262
+
263
+ const prefix = normalizePrefix(cfg.prefix)
264
+ const taskId = resolveTaskId(cfg)
265
+ const auditLimit = clampInt(cfg.auditLimit, 1, 100000, DEFAULTS.auditLimit)
266
+ const commandTimeoutMs = clampInt(cfg.commandTimeoutMs, 1, 600000, DEFAULTS.commandTimeoutMs)
267
+ const eventCapacity = clampInt(cfg.eventCapacity, 1, 100000, DEFAULTS.eventCapacity)
268
+ const commandCacheSize = clampInt(cfg.commandCacheSize, 1, 10000, DEFAULTS.commandCacheSize)
269
+ const heartbeatMs = clampInt(cfg.heartbeatMs, 1000, 60000, DEFAULTS.heartbeatMs)
270
+ const role = gate.role
271
+ const startedAt = nowMs()
272
+
273
+ /**
274
+ * v3 进程实例 id:只要进程重启就换一个。母体据此判断「对端是不是同一个分身」,
275
+ * 避免把旧 cursor 用在新进程上(PROTOCOL-V3 §7.3)。
276
+ */
277
+ const sessionId = `s-${randomBytes(8).toString('hex')}`
278
+
279
+ /**
280
+ * Bearer token:显式配置 > env `DSH_ADAPTER_TOKEN`(Forker spawn 时注入,不落盘)。
281
+ * 两者都没有 = v2 兼容模式:仍由 DSH web 自身的 ?token= 守卫,Adapter 不再加一层。
282
+ */
283
+ const adapterToken = (() => {
284
+ const explicit = String(cfg.token ?? '').trim()
285
+ if (explicit) return explicit
286
+ const fromEnv = typeof process.env?.DSH_ADAPTER_TOKEN === 'string' ? process.env.DSH_ADAPTER_TOKEN.trim() : ''
287
+ return fromEnv
288
+ })()
289
+ const expectedTokenBuf = adapterToken ? Buffer.from(adapterToken, 'utf8') : null
290
+ // v3.0.5/B2:fork 角色无 token 时 fail-closed(v2 兼容仅限显式 allowLegacyNoToken)
291
+ const isForkRole = role === 'fork'
292
+ const legacyAllowed = !isForkRole || cfg.allowLegacyNoToken === true
293
+ if (isForkRole && expectedTokenBuf === null && !legacyAllowed) {
294
+ ctx.logger?.warn?.('[adapter] fork 角色未配置 DSH_ADAPTER_TOKEN,已按 fail-closed 拒绝控制面请求')
295
+ }
296
+
297
+ /** 恒定时间比较;长度不同直接 false(不泄露长度信息之外的任何东西)。 */
298
+ const authorized = (req) => {
299
+ if (!expectedTokenBuf) return legacyAllowed
300
+ const got = parseBearer(req?.headers?.authorization ?? req?.headers?.Authorization)
301
+ if (!got) return false
302
+ const actual = Buffer.from(got, 'utf8')
303
+ if (actual.length !== expectedTokenBuf.length) return false
304
+ try {
305
+ return timingSafeEqual(actual, expectedTokenBuf)
306
+ } catch {
307
+ return false
308
+ }
309
+ }
310
+
311
+ // ── v3 状态同步基础设施:事件环形缓冲 + 订阅扇出 + 命令幂等缓存 ─────────────
312
+ const eventLog = new EventLog({ capacity: eventCapacity, sessionId })
313
+ const commandCache = new CommandCache({ capacity: commandCacheSize })
314
+ const subscribers = new Set()
315
+ const activeStreams = new Set()
316
+
317
+ const publish = (event) => {
318
+ let envelope = null
319
+ try {
320
+ envelope = eventLog.append(event)
321
+ } catch {
322
+ return null
323
+ }
324
+ for (const sink of subscribers) {
325
+ try {
326
+ sink(envelope)
327
+ } catch {
328
+ /* 单个 SSE 连接写失败不得影响其它连接与命令处理 */
329
+ }
330
+ }
331
+ return envelope
332
+ }
333
+
334
+ const subscribe = (sink) => {
335
+ if (typeof sink !== 'function') return () => {}
336
+ subscribers.add(sink)
337
+ return () => subscribers.delete(sink)
338
+ }
339
+
340
+ // 待审批 diff:审批项没有「requested」事件,靠 pending() 快照比对;决议有 bus 事件。
341
+ const knownApprovals = new Map()
342
+ const decidedOutcomes = new Map()
343
+
344
+ const offApprovalDecided = (() => {
345
+ try {
346
+ if (typeof bus?.on !== 'function') return null
347
+ return bus.on('fang/approval-decided', (payload) => {
348
+ const id = String(payload?.id ?? payload?.approvalId ?? '').trim()
349
+ if (!id) return
350
+ const outcome = String(payload?.outcome ?? 'resolved')
351
+ const verdict = outcome === 'approved' ? 'approve' : outcome === 'denied' ? 'reject' : outcome
352
+ decidedOutcomes.set(id, verdict)
353
+ if (knownApprovals.has(id)) {
354
+ knownApprovals.delete(id)
355
+ publish({ kind: ADAPTER_EVENT.APPROVAL_RESOLVED, approvalId: id, verdict })
356
+ }
357
+ })
358
+ } catch {
359
+ return null
360
+ }
361
+ })()
362
+ if (typeof offApprovalDecided === 'function') {
363
+ ctx.effect(() => () => {
364
+ try {
365
+ offApprovalDecided()
366
+ } catch {
367
+ /* ignore */
368
+ }
369
+ })
370
+ }
371
+
372
+ let layout = null
373
+ try {
374
+ layout = resolveFangLayout()
375
+ } catch {
376
+ layout = null
377
+ }
378
+
379
+ let archivedAt = null
380
+
381
+ // ── 基础设施(全部容错:任何依赖缺失都不得让 HTTP 处理器抛出)──────────────
382
+ const safeGet = (key) => {
383
+ try {
384
+ return ctx.get(key)
385
+ } catch {
386
+ return undefined
387
+ }
388
+ }
389
+ const hasService = (key, method) => {
390
+ const svc = safeGet(key)
391
+ if (!svc) return false
392
+ return method ? typeof svc[method] === 'function' : true
393
+ }
394
+ const unavailable = () => ({ ok: false, code: 'service-unavailable', error: 'service-unavailable' })
395
+ const badRequest = (error) => ({ ok: false, code: 'bad-request', error: String(error ?? 'bad-request') })
396
+ const emit = (topic, payload) => {
397
+ try {
398
+ bus.emit(topic, payload)
399
+ } catch {
400
+ /* bus 只做通知,失败不影响命令 */
401
+ }
402
+ }
403
+ const appendAudit = (kind, data) => {
404
+ try {
405
+ const auditor = safeGet(SERVICE.auditor)
406
+ if (auditor && typeof auditor.append === 'function') auditor.append(kind, data)
407
+ } catch {
408
+ /* 审计失败不得阻塞控制面 */
409
+ }
410
+ }
411
+
412
+ /** 本分身自己的 buffer 根目录:优先 BufferKeeper 真实目录,其次布局默认值。 */
413
+ const bufferDir = () => {
414
+ const buffer = safeGet(SERVICE.buffer)
415
+ try {
416
+ if (buffer && typeof buffer.dir === 'string' && buffer.dir) return path.resolve(buffer.dir)
417
+ } catch {
418
+ /* getter 抛错则继续降级 */
419
+ }
420
+ try {
421
+ const state = buffer && typeof buffer.state === 'function' ? buffer.state() : null
422
+ if (state && typeof state.dir === 'string' && state.dir) return path.resolve(state.dir)
423
+ } catch {
424
+ /* ignore */
425
+ }
426
+ const fallback = layout?.bufferDir
427
+ return fallback ? path.resolve(fallback) : ''
428
+ }
429
+
430
+ const bufferView = () => {
431
+ const buffer = safeGet(SERVICE.buffer)
432
+ try {
433
+ const state = buffer && typeof buffer.state === 'function' ? buffer.state() : null
434
+ if (state && typeof state === 'object') {
435
+ return {
436
+ state: typeof state.state === 'string' ? state.state : 'unknown',
437
+ pending: Array.isArray(state.pending) ? [...state.pending] : [],
438
+ dir: typeof state.dir === 'string' ? state.dir : bufferDir(),
439
+ }
440
+ }
441
+ } catch {
442
+ /* ignore */
443
+ }
444
+ return { state: 'unavailable', pending: [], dir: bufferDir() }
445
+ }
446
+
447
+ /** null = approver 服务缺失;[] = 服务在但没有待办(两者语义不同)。 */
448
+ const approvalItems = () => {
449
+ const approver = safeGet(SERVICE.approver)
450
+ if (!approver || typeof approver.pending !== 'function') return null
451
+ try {
452
+ const items = approver.pending()
453
+ return Array.isArray(items) ? items : []
454
+ } catch {
455
+ return []
456
+ }
457
+ }
458
+
459
+ const orphanedApprovalItems = () => {
460
+ const approver = safeGet(SERVICE.approver)
461
+ if (!approver || typeof approver.orphaned !== 'function') return []
462
+ try {
463
+ const items = approver.orphaned()
464
+ return Array.isArray(items) ? items : []
465
+ } catch {
466
+ return []
467
+ }
468
+ }
469
+
470
+ const approvalItemsWithOrphans = () => {
471
+ const pending = approvalItems()
472
+ if (pending === null) return null
473
+ return [...pending, ...orphanedApprovalItems()]
474
+ }
475
+
476
+ const auditHead = () => {
477
+ const auditor = safeGet(SERVICE.auditor)
478
+ if (!auditor || typeof auditor.read !== 'function') {
479
+ return { records: 0, head: null, degraded: 'service-unavailable' }
480
+ }
481
+ try {
482
+ const records = auditor.read({ limit: Number.MAX_SAFE_INTEGER })
483
+ const list = Array.isArray(records) ? records : []
484
+ const last = list.length > 0 ? list[list.length - 1] : null
485
+ let head = null
486
+ if (last && typeof last === 'object') {
487
+ if (typeof last.hash === 'string') head = last.hash
488
+ else if (Number.isFinite(last.seq)) head = last.seq
489
+ }
490
+ return { records: list.length, head }
491
+ } catch (e) {
492
+ return { records: 0, head: null, degraded: `read-failed:${msgOf(e)}` }
493
+ }
494
+ }
495
+
496
+ const servicesView = () => ({
497
+ scorer: hasService(SERVICE.scorer),
498
+ umbrella: hasService(SERVICE.umbrella),
499
+ router: hasService(SERVICE.router),
500
+ healer: hasService(SERVICE.healer),
501
+ approver: hasService(SERVICE.approver),
502
+ hotReloader: hasService(SERVICE.hotReloader),
503
+ buffer: hasService(SERVICE.buffer),
504
+ auditor: hasService(SERVICE.auditor),
505
+ adapter: true,
506
+ })
507
+
508
+ // ── v3 快照 / 健康 / 版本 / 审批 diff(docs/PROTOCOL-V3.md §5)─────────────
509
+ const activeSessions = () => {
510
+ const catalog = safeGet(SERVICE.sessionCatalog)
511
+ if (!catalog || typeof catalog.list !== 'function') return null
512
+ try {
513
+ const list = catalog.list()
514
+ return Array.isArray(list) ? list.length : null
515
+ } catch {
516
+ return null
517
+ }
518
+ }
519
+
520
+ const metricsView = () => {
521
+ let memory = null
522
+ try {
523
+ memory = process.memoryUsage()
524
+ } catch {
525
+ memory = null
526
+ }
527
+ let cpu = null
528
+ try {
529
+ const usage = process.cpuUsage()
530
+ cpu = { userMs: Math.round(usage.user / 1000), systemMs: Math.round(usage.system / 1000) }
531
+ } catch {
532
+ cpu = null
533
+ }
534
+ return {
535
+ rssBytes: memory?.rss ?? null,
536
+ heapUsedBytes: memory?.heapUsed ?? null,
537
+ handleCount: totalResources(sampleResources()),
538
+ cpu,
539
+ activeSessions: activeSessions(),
540
+ pid: process.pid ?? null,
541
+ }
542
+ }
543
+
544
+ /** 审批项归一化:不假设 approver.pending() 的字段全在(缺失就 null,不假装有值)。 */
545
+ const approvalSummary = (item) => ({
546
+ approvalId: String(item?.id ?? item?.approvalId ?? ''),
547
+ action: String(item?.action ?? item?.summary ?? item?.reason ?? item?.title ?? ''),
548
+ light: item?.light ?? item?.tone ?? item?.risk ?? null,
549
+ createdAt: item?.createdAt ?? item?.at ?? null,
550
+ })
551
+
552
+ /** 待审批 diff → approval-requested / approval-resolved 事件(环形缓冲,SSE 顺序一致)。 */
553
+ const syncApprovals = () => {
554
+ const rawItems = approvalItems()
555
+ const items = Array.isArray(rawItems) ? rawItems : []
556
+ const seen = new Set()
557
+ for (const item of items) {
558
+ const summary = approvalSummary(item)
559
+ if (!summary.approvalId) continue
560
+ seen.add(summary.approvalId)
561
+ if (knownApprovals.has(summary.approvalId)) continue
562
+ knownApprovals.set(summary.approvalId, summary)
563
+ publish({ kind: ADAPTER_EVENT.APPROVAL_REQUESTED, approval: summary })
564
+ }
565
+ for (const id of [...knownApprovals.keys()]) {
566
+ if (seen.has(id)) continue
567
+ knownApprovals.delete(id)
568
+ const verdict = decidedOutcomes.get(id) ?? 'resolved'
569
+ decidedOutcomes.delete(id)
570
+ publish({ kind: ADAPTER_EVENT.APPROVAL_RESOLVED, approvalId: id, verdict })
571
+ }
572
+ }
573
+
574
+ /** 完整状态快照:幂等、无 cursor 参数,母体 UI / 首帧同步用。 */
575
+ const stateSnapshot = () => {
576
+ const rawItems = approvalItems()
577
+ const items = Array.isArray(rawItems) ? rawItems : []
578
+ const buffer = bufferView()
579
+ const audit = auditHead()
580
+ const services = servicesView()
581
+ return {
582
+ ok: true,
583
+ sessionId,
584
+ protocolVersion: ADAPTER_PROTOCOL_VERSION,
585
+ minProtocol: ADAPTER_PROTOCOL_MIN,
586
+ version: FANG_VERSION,
587
+ role,
588
+ taskId,
589
+ status: archivedAt !== null ? 'stopping' : 'running',
590
+ uptimeMs: nowMs() - startedAt,
591
+ at: nowMs(),
592
+ metrics: metricsView(),
593
+ services,
594
+ plugins: Object.entries(services).map(([pluginId, present]) => ({
595
+ pluginId,
596
+ state: present ? 'active' : 'unavailable',
597
+ })),
598
+ approvals: items.map((item) => approvalSummary(item)),
599
+ buffer: {
600
+ state: buffer.state,
601
+ pending: buffer.pending.length,
602
+ dirtyFiles: buffer.pending.length,
603
+ dir: buffer.dir,
604
+ },
605
+ audit: { records: audit.records, head: audit.head, degraded: audit.degraded ?? null },
606
+ archived: archivedAt !== null,
607
+ lastCursor: eventLog.lastCursor,
608
+ nextCursor: eventLog.nextCursor,
609
+ }
610
+ }
611
+
612
+ const versionInfo = () => ({
613
+ ok: true,
614
+ protocol: ADAPTER_PROTOCOL_VERSION,
615
+ minProtocol: ADAPTER_PROTOCOL_MIN,
616
+ version: FANG_VERSION,
617
+ sessionId,
618
+ taskId,
619
+ role,
620
+ capabilities: Object.values(ADAPTER_OP),
621
+ at: iso(),
622
+ })
623
+
624
+ const health = () => ({
625
+ ok: true,
626
+ status: archivedAt !== null ? 'archived' : 'running',
627
+ pid: process.pid ?? null,
628
+ taskId,
629
+ sessionId,
630
+ protocol: ADAPTER_PROTOCOL_VERSION,
631
+ uptimeMs: nowMs() - startedAt,
632
+ archived: archivedAt !== null,
633
+ at: iso(),
634
+ })
635
+
636
+ const state = () => {
637
+ syncApprovals()
638
+ return stateSnapshot()
639
+ }
640
+
641
+ /**
642
+ * 事件续传:session 不匹配 → session-changed(母体重置 cursor);
643
+ * cursor 过期 → gap=true(HTTP 层回 410,母体回退到 state 快照)。
644
+ */
645
+ const eventsSince = (cursor, session) => {
646
+ const sid = String(session ?? '').trim()
647
+ if (sid && sid !== sessionId) {
648
+ return { ok: false, code: 'session-changed', sessionId, events: [], gap: false }
649
+ }
650
+ const normalizedCursor = cursor === undefined || cursor === null || cursor === '' ? '0' : cursor
651
+ const result = eventLog.since(normalizedCursor)
652
+ if (!result.ok) return { ok: false, code: result.code, events: [], gap: false, sessionId }
653
+ return {
654
+ ok: true,
655
+ events: result.events,
656
+ gap: result.gap,
657
+ reason: result.reason,
658
+ lastCursor: result.lastCursor,
659
+ nextCursor: result.nextCursor,
660
+ sessionId,
661
+ }
662
+ }
663
+
664
+ // ── service 方法(HTTP 处理器与母体 Remote 共用同一实现)────────────────────
665
+
666
+ const hello = () => ({
667
+ ok: true,
668
+ role,
669
+ taskId, // Web 端身份徽标用:母体 / 元主(元宝运行态)/ 分身(任务)。
670
+ // 元宝运行态由 orchestrator 注入 DSH_FANG_ESSENCE=1;旧运行态按 rt- 前缀兜底。
671
+ kind: identityKind({ role, taskId, essence: typeof process !== 'undefined' && process.env?.DSH_FANG_ESSENCE === '1' }),
672
+ version: FANG_VERSION,
673
+ protocol: ADAPTER_PROTOCOL_VERSION,
674
+ sessionId,
675
+ capabilities: Object.values(ADAPTER_OP),
676
+ })
677
+
678
+ const status = () => {
679
+ const items = approvalItems()
680
+ return {
681
+ ok: true,
682
+ taskId,
683
+ role,
684
+ protocol: ADAPTER_PROTOCOL_VERSION,
685
+ sessionId,
686
+ lastCursor: eventLog.lastCursor,
687
+ services: servicesView(),
688
+ buffer: bufferView(),
689
+ approvals: { pending: items ? items.length : 0 },
690
+ audit: auditHead(),
691
+ startedAt,
692
+ archived: archivedAt !== null,
693
+ }
694
+ }
695
+
696
+ // v3.1.14:Web 端拿不到 adapter Bearer token,身份探测走公开只读路由;
697
+ // 只暴露 role/kind/version,不含 taskId/sessionId/token。
698
+ let clientReported = false
699
+ let clientReportedAt = 0
700
+
701
+ const identity = () => ({
702
+ ok: true,
703
+ role,
704
+ kind: identityKind({ role, taskId, essence: typeof process !== 'undefined' && process.env?.DSH_FANG_ESSENCE === '1' }),
705
+ version: FANG_VERSION,
706
+ protocol: ADAPTER_PROTOCOL_VERSION,
707
+ // v3.1.17:一眼判断「服务端有身份、客户端没加载」——不含 taskId/token 等敏感字段。
708
+ surface: 'web-adapter',
709
+ clientLoaded: clientReported,
710
+ clientReportedAt: clientReportedAt || null,
711
+ })
712
+ /**
713
+ * 客户端自报:Web 端加载了 fang client 后会 POST 到这里;
714
+ * 只记录时间,不记录任何身份/token(用于运维排查徽标不显示)。
715
+ */
716
+ const reportClient = () => {
717
+ clientReported = true
718
+ clientReportedAt = Date.now()
719
+ return { ok: true, clientReported: true, at: clientReportedAt }
720
+ }
721
+
722
+ const approvals = () => {
723
+ const items = approvalItemsWithOrphans()
724
+ if (items === null) return { ok: false, code: 'service-unavailable', error: 'service-unavailable', items: [] }
725
+ return { ok: true, items }
726
+ }
727
+
728
+ const audit = (options = {}) => {
729
+ const opts = options && typeof options === 'object' ? options : {}
730
+ let requested = 100
731
+ if (opts.limit !== undefined && opts.limit !== null) {
732
+ const n = Math.floor(Number(opts.limit))
733
+ requested = Number.isFinite(n) ? n : 100
734
+ }
735
+ const limit = Math.min(Math.max(0, requested), auditLimit)
736
+ const auditor = safeGet(SERVICE.auditor)
737
+ if (!auditor || typeof auditor.read !== 'function') {
738
+ return { ok: false, code: 'service-unavailable', error: 'service-unavailable', records: [] }
739
+ }
740
+ try {
741
+ const records = auditor.read({ limit })
742
+ return { ok: true, records: Array.isArray(records) ? records : [] }
743
+ } catch (e) {
744
+ return { ok: false, code: 'handler-threw', error: msgOf(e), records: [] }
745
+ }
746
+ }
747
+
748
+ /** 读 buffer/<pluginId> 下的文本文件(契约 §5.1)。 */
749
+ const readPlugin = (payload = {}) => {
750
+ const pluginId = String(payload?.pluginId ?? '').trim()
751
+ if (!pluginId) return badRequest('pluginId-required')
752
+ const root = bufferDir()
753
+ if (!root) return { ok: false, code: 'not-configured', error: 'buffer-root-unknown' }
754
+ const resolved = resolveInside(root, pluginId)
755
+ if (!resolved.ok) return resolved
756
+ if (!exists(resolved.dir)) {
757
+ return { ok: false, code: 'plugin-not-found', error: `plugin-not-found:${pluginId}`, pluginId }
758
+ }
759
+ let stat
760
+ try {
761
+ stat = fs.statSync(resolved.dir)
762
+ } catch (e) {
763
+ return { ok: false, code: 'plugin-not-found', error: `plugin-not-found:${msgOf(e)}`, pluginId }
764
+ }
765
+ if (!stat.isDirectory()) return badRequest(`not-a-directory:${pluginId}`)
766
+ const tree = readPluginTree(resolved.dir)
767
+ if (!tree.ok) return tree
768
+ return { files: tree.files, count: tree.count, bytes: tree.bytes, pluginId }
769
+ }
770
+
771
+ /** 解析并(在需要时)补齐 route-confirm 的 assessment:payload 可只带 pluginId。 */
772
+ const routeConfirmInput = (payload) => {
773
+ const input = payload && typeof payload === 'object' ? { ...payload } : {}
774
+ if (input.assessment || !input.pluginId) return input
775
+ const scorer = safeGet(SERVICE.scorer)
776
+ if (!scorer || typeof scorer.scoreDir !== 'function') return input
777
+ const root = bufferDir()
778
+ const resolved = root ? resolveInside(root, String(input.pluginId)) : { ok: false }
779
+ if (!resolved.ok || !exists(resolved.dir)) return input
780
+ try {
781
+ input.assessment = scorer.scoreDir(resolved.dir)
782
+ } catch {
783
+ /* assessment 可缺省:Router.confirm 应自行降级 */
784
+ }
785
+ return input
786
+ }
787
+
788
+ const flushBuffer = async (payload) => {
789
+ const buffer = safeGet(SERVICE.buffer)
790
+ if (!buffer || typeof buffer.flush !== 'function') return unavailable()
791
+ let opts = {}
792
+ if (payload && typeof payload === 'object') {
793
+ opts = payload.strictMs !== undefined ? { strictMs: payload.strictMs } : { ...payload }
794
+ }
795
+ const result = await buffer.flush(opts)
796
+ if (result && typeof result === 'object') return result
797
+ return { state: 'unknown', flushed: [], timedOut: false }
798
+ }
799
+
800
+ /**
801
+ * 控制命令分发表。每个 handler 返回「结果对象」;返回 `{ok:false, code}` 时作为
802
+ * 失败信封传播(依赖服务缺失 / 参数非法 / 路径逃逸…)。
803
+ */
804
+ const HANDLERS = new Map([
805
+ [ADAPTER_OP.PING, () => ({ pong: true, at: iso(), pid: process.pid, taskId, uptimeMs: nowMs() - startedAt })],
806
+
807
+ [ADAPTER_OP.STATUS, () => status()],
808
+
809
+ [ADAPTER_OP.FLUSH_BUFFER, (payload) => flushBuffer(payload)],
810
+
811
+ [ADAPTER_OP.SCORE_PLUGIN, (payload) => {
812
+ const scorer = safeGet(SERVICE.scorer)
813
+ if (!scorer || typeof scorer.scoreDir !== 'function') return unavailable()
814
+ const pluginId = String(payload?.pluginId ?? '').trim()
815
+ if (!pluginId) return badRequest('pluginId-required')
816
+ const root = bufferDir()
817
+ if (!root) return { ok: false, code: 'not-configured', error: 'buffer-root-unknown' }
818
+ const resolved = resolveInside(root, pluginId)
819
+ if (!resolved.ok) return resolved
820
+ if (!exists(resolved.dir)) {
821
+ return { ok: false, code: 'plugin-not-found', error: `plugin-not-found:${pluginId}`, pluginId }
822
+ }
823
+ try {
824
+ const assessment = scorer.scoreDir(resolved.dir)
825
+ return { ...(assessment && typeof assessment === 'object' ? assessment : {}), pluginId, dir: resolved.dir }
826
+ } catch (e) {
827
+ return { ok: false, code: 'handler-threw', error: `score-failed:${msgOf(e)}` }
828
+ }
829
+ }],
830
+
831
+ [ADAPTER_OP.DECISION_INGEST, async (payload) => {
832
+ const center = safeGet(SERVICE.decisionCenter)
833
+ if (!center || typeof center.submit !== 'function') return unavailable()
834
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return badRequest('payload-required')
835
+ try {
836
+ const out = await center.submit({ producer: 'adapter', ...payload })
837
+ return out && typeof out === 'object' ? out : { ok: true }
838
+ } catch (e) {
839
+ return { ok: false, code: 'handler-threw', error: `decision-ingest-failed:${msgOf(e)}` }
840
+ }
841
+ }],
842
+
843
+ [ADAPTER_OP.READ_PLUGIN, (payload) => readPlugin(payload)],
844
+
845
+ [ADAPTER_OP.ROUTE_CONFIRM, async (payload) => {
846
+ const router = safeGet(SERVICE.router)
847
+ if (!router || typeof router.confirm !== 'function') return unavailable()
848
+ const out = await router.confirm(routeConfirmInput(payload))
849
+ return out && typeof out === 'object' ? out : { route: null }
850
+ }],
851
+
852
+ [ADAPTER_OP.APPROVALS, () => {
853
+ const items = approvalItemsWithOrphans()
854
+ if (items === null) return unavailable()
855
+ return { items }
856
+ }],
857
+
858
+ [ADAPTER_OP.APPROVAL_DECIDE, (payload) => {
859
+ const approver = safeGet(SERVICE.approver)
860
+ if (!approver || typeof approver.decide !== 'function') return unavailable()
861
+ const id = String(payload?.id ?? payload?.approvalId ?? '').trim()
862
+ if (!id) return badRequest('id-required')
863
+ const raw = payload?.outcome ?? payload?.decision
864
+ const outcome = raw === 'approve' ? 'approved' : raw === 'deny' ? 'denied' : String(raw ?? '')
865
+ if (outcome !== 'approved' && outcome !== 'denied') {
866
+ return badRequest(`bad-outcome:${String(raw ?? '')}`)
867
+ }
868
+ const options = {
869
+ by: String(payload?.by ?? 'adapter'),
870
+ note: payload?.note === undefined || payload?.note === null ? null : String(payload.note),
871
+ }
872
+ const decided = approver.decide(id, outcome, options)
873
+ if (decided && decided.ok === false) {
874
+ const code = decided.reason === 'not-found' ? 'not-found'
875
+ : decided.reason === 'bad-outcome' ? 'bad-request' : 'conflict'
876
+ return { ok: false, code, error: String(decided.reason ?? 'decide-failed'), decided }
877
+ }
878
+ return decided && typeof decided === 'object' ? decided : { ok: true, id, outcome }
879
+ }],
880
+
881
+ [ADAPTER_OP.ARCHIVE, async (payload) => {
882
+ // archive 的语义是「flush + 归档标记」,buffer 是必需依赖;进程 kill 由母体做。
883
+ const flushed = await flushBuffer(payload?.flushOpts ?? {})
884
+ if (flushed && flushed.ok === false && flushed.code === 'service-unavailable') return flushed
885
+ archivedAt = nowMs()
886
+ appendAudit(AUDIT.TASK_ARCHIVED, {
887
+ taskId,
888
+ archivedAt: iso(archivedAt),
889
+ flushed: Array.isArray(flushed?.flushed) ? flushed.flushed : [],
890
+ })
891
+ emit('fang/task-archived', { taskId })
892
+ return {
893
+ archived: true,
894
+ archivedAt,
895
+ flushed: Array.isArray(flushed?.flushed) ? flushed.flushed : [],
896
+ }
897
+ }],
898
+
899
+ // 方案 A:物理分支。分身会话只存在分身自己的 DSH 进程里,母体侧够不到,
900
+ // 所以在分身宿主内调用 sessions.fork,把子会话挂回同一分身。
901
+ [ADAPTER_OP.FORK_SESSION, async (payload) => {
902
+ const sessions = safeGet('sessions')
903
+ if (!sessions || typeof sessions.fork !== 'function') {
904
+ return { ok: false, code: 'sessions-service-unavailable', error: 'sessions.fork unavailable in this instance' }
905
+ }
906
+ const sourceSessionId = String(payload?.sessionId ?? '').trim()
907
+ if (!sourceSessionId) return { ok: false, code: 'bad-session-id' }
908
+ const boundary = payload?.boundary
909
+ const childSessionId = String(payload?.childSessionId ?? '').trim()
910
+ try {
911
+ const child = childSessionId
912
+ ? await sessions.fork(sourceSessionId, boundary, childSessionId)
913
+ : await sessions.fork(sourceSessionId, boundary)
914
+ const id = typeof child === 'string' ? child : (child?.id ?? child?.sessionId ?? childSessionId ?? null)
915
+ appendAudit('fork-session', { sourceSessionId, childSessionId: id, boundary: boundary ?? null })
916
+ return { ok: true, sourceSessionId, childSessionId: id }
917
+ } catch (error) {
918
+ return { ok: false, code: 'fork-failed', error: String(error?.message ?? error) }
919
+ }
920
+ }],
921
+ ])
922
+
923
+ /**
924
+ * 外部插件注册的命令(v2.6.8)。与内置 HANDLERS 走同一条分派链:
925
+ * registerCommand(op, (payload, ctx) => result | {status:'accepted', trackingId})
926
+ * - 返回普通对象 → 同步成功(HTTP 200);
927
+ * - 返回 {status:'accepted', trackingId} → HTTP 202,executor 自行异步执行,
928
+ * 完成后必须通过 ctx.emit({kind:'command-done', trackingId, result}) 推送;
929
+ * - 返回 {status:'completed'|'rejected'} → 按对应形状返回;
930
+ * - 抛错 → handler-threw(fail-closed)。
931
+ * 内置 op 不允许被覆盖(避免协议语义被第三方悄悄替换)。
932
+ */
933
+ const REGISTERED = new Map()
934
+
935
+ const registerCommand = (op, executor) => {
936
+ const kind = String(op ?? '').trim()
937
+ if (!kind) throw new TypeError('registerCommand requires a non-empty op')
938
+ if (typeof executor !== 'function') throw new TypeError('registerCommand requires a function')
939
+ if (HANDLERS.has(kind)) throw new Error(`cannot override built-in op: ${kind}`)
940
+ REGISTERED.set(kind, executor)
941
+ emit('fang/adapter-command-registered', { op: kind })
942
+ return () => {
943
+ if (REGISTERED.get(kind) === executor) {
944
+ REGISTERED.delete(kind)
945
+ emit('fang/adapter-command-unregistered', { op: kind })
946
+ }
947
+ }
948
+ }
949
+
950
+ /** 给命令套一个硬超时:handler 悬挂时也返回 timeout,而不是让 HTTP 挂着。 */
951
+ const withTimeout = (promise, timeoutMs) => {
952
+ if (!(timeoutMs > 0)) return Promise.resolve(promise)
953
+ return new Promise((resolve) => {
954
+ let settled = false
955
+ const timer = setTimeout(() => {
956
+ if (settled) return
957
+ settled = true
958
+ resolve({ __timeout: true })
959
+ }, timeoutMs)
960
+ Promise.resolve(promise).then(
961
+ (value) => {
962
+ if (settled) return
963
+ settled = true
964
+ clearTimeout(timer)
965
+ resolve(value)
966
+ },
967
+ (err) => {
968
+ if (settled) return
969
+ settled = true
970
+ clearTimeout(timer)
971
+ resolve({ ok: false, code: 'handler-threw', error: msgOf(err) })
972
+ },
973
+ )
974
+ })
975
+ }
976
+
977
+ async function command(input = {}) {
978
+ const request = input && typeof input === 'object' ? input : {}
979
+ const op = String(request.op ?? '').trim()
980
+ const payload = request.payload && typeof request.payload === 'object' ? request.payload : {}
981
+ if (!op) {
982
+ const envelope = { ok: false, op: '', error: 'missing-op', code: 'bad-request' }
983
+ emit('fang/adapter-command', { op: '', ok: false, code: envelope.code })
984
+ return envelope
985
+ }
986
+ const builtin = HANDLERS.get(op)
987
+ const registered = builtin ? null : REGISTERED.get(op)
988
+ if (!builtin && !registered) {
989
+ const envelope = { ok: false, op, error: `unknown-op:${op}`, code: 'unknown-op' }
990
+ emit('fang/adapter-command', { op, ok: false, code: envelope.code })
991
+ return envelope
992
+ }
993
+
994
+ let raw
995
+ try {
996
+ if (registered) {
997
+ // 外部注册命令:executor 自己决定同步/异步,拿到 commandId/trackingId/emit 上下文。
998
+ const execCtx = {
999
+ commandId: String(request.commandId ?? '') || null,
1000
+ trackingId: String(request.trackingId ?? '') || (request.commandId ? `track_${String(request.commandId)}` : newId('cmd')),
1001
+ emit: (event) => publish(event),
1002
+ logger: {
1003
+ debug: (...args) => { try { ctx.logger?.debug?.(...args) } catch { /* ignore */ } },
1004
+ warn: (...args) => { try { ctx.logger?.warn?.(...args) } catch { /* ignore */ } },
1005
+ },
1006
+ }
1007
+ raw = await withTimeout(registered(payload, execCtx), commandTimeoutMs)
1008
+ if (raw && typeof raw === 'object' && raw.status === 'accepted') {
1009
+ const trackingId = String(raw.trackingId ?? execCtx.trackingId)
1010
+ const envelope = { ok: true, op, status: 'accepted', trackingId, commandId: execCtx.commandId, async: true }
1011
+ emit('fang/adapter-command', { op, ok: true, async: true, trackingId })
1012
+ return envelope
1013
+ }
1014
+ if (raw && typeof raw === 'object' && raw.status === 'completed') {
1015
+ raw = { ok: true, status: 'completed', result: raw.result === undefined ? null : raw.result }
1016
+ } else if (raw && typeof raw === 'object' && raw.status === 'rejected') {
1017
+ raw = { ok: false, code: String(raw.code ?? 'rejected'), error: String(raw.message ?? 'rejected') }
1018
+ }
1019
+ } else {
1020
+ raw = await withTimeout(builtin(payload), commandTimeoutMs)
1021
+ }
1022
+ } catch (e) {
1023
+ raw = { ok: false, code: 'handler-threw', error: msgOf(e) }
1024
+ }
1025
+ if (raw && typeof raw === 'object' && raw.__timeout === true) {
1026
+ const envelope = { ok: false, op, error: 'command-timeout', code: 'timeout' }
1027
+ emit('fang/adapter-command', { op, ok: false, code: envelope.code })
1028
+ return envelope
1029
+ }
1030
+ if (registered && raw && typeof raw === 'object' && raw.status === 'completed') {
1031
+ // 外部注册命令的 CommandResult 形状:保序返回 status/result,而不是塞进 result 字段。
1032
+ const envelope = { ok: true, op, status: 'completed', result: raw.result === undefined ? null : raw.result, commandId: String(request.commandId ?? '') || null }
1033
+ emit('fang/adapter-command', { op, ok: true })
1034
+ return envelope
1035
+ }
1036
+ if (raw && typeof raw === 'object' && raw.ok === false) {
1037
+ const envelope = {
1038
+ ok: false,
1039
+ op,
1040
+ error: String(raw.error ?? raw.reason ?? 'command-failed'),
1041
+ code: String(raw.code ?? 'conflict'),
1042
+ result: raw,
1043
+ }
1044
+ emit('fang/adapter-command', { op, ok: false, code: envelope.code })
1045
+ return envelope
1046
+ }
1047
+ emit('fang/adapter-command', { op, ok: true, taskId })
1048
+ return { ok: true, op, result: raw === undefined ? null : raw }
1049
+ }
1050
+
1051
+ /**
1052
+ * v3 命令入口:commandId 幂等 + async 202。
1053
+ * 幂等由 `CommandCache.run()` 保证:
1054
+ * * completed 命中 → 直接重放第一次的响应(不重复执行副作用);
1055
+ * * running 命中(同一 commandId 并发提交)→ 等待同一个 promise,不会执行两次;
1056
+ * * 未命中 → 执行并把结果落缓存;fn 抛错(传输/编程错误)不缓存,允许重试。
1057
+ * async 语义:立刻返回 {status:'accepted', trackingId},进度/结果走 SSE
1058
+ * 的 command-done 事件;accepted 本身也进缓存,重复提交返回同一个 trackingId。
1059
+ */
1060
+ async function commands(input = {}) {
1061
+ const normalized = normalizeCommandEnvelope(input)
1062
+ if (!normalized.ok) {
1063
+ return { ok: false, code: normalized.code, error: normalized.error, commandId: normalized.commandId ?? '' }
1064
+ }
1065
+ const { commandId, op, payload, async: isAsync } = normalized
1066
+ // 外部注册命令由 executor 自己决定同步/异步;通用 async 标志只作用于内置 op。
1067
+ const isRegistered = REGISTERED.has(op)
1068
+
1069
+ // 无 commandId = 不幂等(兼容 v2 用法),直接执行。
1070
+ if (!commandId) {
1071
+ if (isAsync && !isRegistered) {
1072
+ const trackingId = newId('cmd')
1073
+ void Promise.resolve()
1074
+ .then(() => command({ op, payload }))
1075
+ .then((result) => publish({ kind: ADAPTER_EVENT.COMMAND_DONE, trackingId, op, result }))
1076
+ .catch((e) => publish({ kind: ADAPTER_EVENT.ERROR, code: 'handler-threw', message: msgOf(e), trackingId, op }))
1077
+ return { ok: true, status: 'accepted', trackingId, op, commandId: null, async: true }
1078
+ }
1079
+ const result = await command({ op, payload })
1080
+ if (result && result.status === 'accepted') return { ...result, commandId: null }
1081
+ publish({ kind: ADAPTER_EVENT.COMMAND, op, ok: result.ok !== false, code: result.code ?? null, commandId: null })
1082
+ return { ...result, commandId: null }
1083
+ }
1084
+
1085
+ let executed = false
1086
+ const envelope = await commandCache.run(commandId, async () => {
1087
+ executed = true
1088
+ if (isAsync && !isRegistered) {
1089
+ return { ok: true, status: 'accepted', trackingId: commandId, op, commandId, async: true }
1090
+ }
1091
+ const result = await command({ op, payload, commandId })
1092
+ return { ...result, commandId }
1093
+ })
1094
+
1095
+ if (executed) {
1096
+ if (envelope && envelope.status === 'accepted') {
1097
+ // 外部注册命令的 accepted:executor 负责异步执行并通过 ctx.emit 推 command-done,
1098
+ // 这里不再二次调度(否则会执行两遍)。
1099
+ return envelope
1100
+ }
1101
+ if (isAsync && !isRegistered) {
1102
+ // 内置异步命令:结果通过事件返回;缓存里保留 accepted(重放仍是同一个 trackingId)。
1103
+ void Promise.resolve()
1104
+ .then(() => command({ op, payload }))
1105
+ .then((result) => publish({ kind: ADAPTER_EVENT.COMMAND_DONE, trackingId: commandId, op, result }))
1106
+ .catch((e) => publish({ kind: ADAPTER_EVENT.ERROR, code: 'handler-threw', message: msgOf(e), trackingId: commandId, op }))
1107
+ } else {
1108
+ publish({ kind: ADAPTER_EVENT.COMMAND, op, ok: envelope.ok !== false, code: envelope.code ?? null, commandId })
1109
+ }
1110
+ return envelope
1111
+ }
1112
+ return { ...envelope, deduplicated: true }
1113
+ }
1114
+
1115
+ const service = {
1116
+ role,
1117
+ taskId,
1118
+ version: FANG_VERSION,
1119
+ protocol: ADAPTER_PROTOCOL_VERSION,
1120
+ sessionId,
1121
+ /** 是否启用了 v3 Bearer 认证(false = v2 兼容模式)。 */
1122
+ authEnabled: expectedTokenBuf !== null,
1123
+ /** 已注册的 HTTP 路径(测试用;无 webServer 时为空数组)。 */
1124
+ routes: [],
1125
+
1126
+ hello,
1127
+ identity,
1128
+ reportClient,
1129
+ status,
1130
+ approvals,
1131
+ audit,
1132
+ command,
1133
+ commands,
1134
+ registerCommand,
1135
+ registeredCommands: () => [...REGISTERED.keys()],
1136
+ versionInfo,
1137
+ health,
1138
+ state,
1139
+ eventsSince,
1140
+ lastCursor: () => eventLog.lastCursor,
1141
+ }
1142
+
1143
+ ctx.effect(() => ctx.provide(SERVICE.adapter, service))
1144
+
1145
+ // 插件卸载:关掉所有 SSE 长连接,清空订阅与幂等缓存(不留悬挂定时器/引用)。
1146
+ ctx.effect(() => () => {
1147
+ for (const close of [...activeStreams]) {
1148
+ try {
1149
+ close()
1150
+ } catch {
1151
+ /* ignore */
1152
+ }
1153
+ }
1154
+ subscribers.clear()
1155
+ commandCache.clear()
1156
+ })
1157
+
1158
+ // ── HTTP 路由(处理器与 service 共用上面的实现)────────────────────────────
1159
+ const json = (res, code, obj) => {
1160
+ try {
1161
+ if (!res.headersSent) {
1162
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1163
+ }
1164
+ } catch {
1165
+ /* 假 res / 已写头的 res 都不能把处理器打挂 */
1166
+ }
1167
+ try {
1168
+ if (!res.writableEnded) res.end(JSON.stringify(obj))
1169
+ } catch {
1170
+ /* 同上 */
1171
+ }
1172
+ }
1173
+
1174
+ /** 错误码 → HTTP 状态码:唯一来源是 kernel 的 ADAPTER_ERROR_STATUS(PROTOCOL-V3 §8)。 */
1175
+ const statusForCode = (code) => statusForAdapterError(code)
1176
+
1177
+ /** 读取 JSON 请求体;不抛,返回 {body} 或 {error}。 */
1178
+ const readJsonBody = (req) => new Promise((resolve) => {
1179
+ let raw = ''
1180
+ let settled = false
1181
+ const finish = (value) => {
1182
+ if (settled) return
1183
+ settled = true
1184
+ resolve(value)
1185
+ }
1186
+ try {
1187
+ req.on('data', (chunk) => {
1188
+ if (settled) return
1189
+ raw += chunk
1190
+ if (raw.length > 1_000_000) {
1191
+ finish({ error: 'body-too-large' })
1192
+ try {
1193
+ req.destroy()
1194
+ } catch {
1195
+ /* ignore */
1196
+ }
1197
+ }
1198
+ })
1199
+ req.on('end', () => {
1200
+ if (settled) return
1201
+ if (!raw.trim()) return finish({ body: {} })
1202
+ try {
1203
+ finish({ body: JSON.parse(raw) })
1204
+ } catch {
1205
+ finish({ error: 'invalid-json' })
1206
+ }
1207
+ })
1208
+ req.on('error', () => finish({ error: 'request-error' }))
1209
+ } catch (e) {
1210
+ finish({ error: msgOf(e) })
1211
+ }
1212
+ })
1213
+
1214
+ /** 从原始 req.url 解析 ?limit=(测试里 req.url 可能是 undefined,缺省走 100)。 */
1215
+ const parseLimit = (rawUrl) => {
1216
+ const s = String(rawUrl ?? '')
1217
+ const m = /[?&]limit=([^&#]+)/.exec(s)
1218
+ if (!m) return undefined
1219
+ const n = Math.floor(Number(decodeURIComponentSafe(m[1])))
1220
+ return Number.isFinite(n) ? n : undefined
1221
+ }
1222
+ const decodeURIComponentSafe = (value) => {
1223
+ try {
1224
+ return decodeURIComponent(value)
1225
+ } catch {
1226
+ return value
1227
+ }
1228
+ }
1229
+
1230
+ /** 极简 query 解析(不依赖 URL 解析器,req.url 在测试里可能是 undefined)。 */
1231
+ const parseQuery = (rawUrl) => {
1232
+ const out = {}
1233
+ const text = String(rawUrl ?? '')
1234
+ const at = text.indexOf('?')
1235
+ if (at < 0) return out
1236
+ for (const pair of text.slice(at + 1).split('&')) {
1237
+ if (!pair) continue
1238
+ const eq = pair.indexOf('=')
1239
+ const key = decodeURIComponentSafe(eq < 0 ? pair : pair.slice(0, eq))
1240
+ const value = eq < 0 ? '' : decodeURIComponentSafe(pair.slice(eq + 1))
1241
+ if (!(key in out)) out[key] = value
1242
+ }
1243
+ return out
1244
+ }
1245
+
1246
+ const registerRoutes = (webServer) => {
1247
+ // v3.1.14:浏览器无法持有 adapter Bearer token;身份探测必须是公开只读路由。
1248
+ // 服务端仍只监听 loopback,且返回值不含 taskId/token(见 identity())。
1249
+ webServer.register({
1250
+ kind: 'exact',
1251
+ path: `${prefix}${ADAPTER_ROUTE.IDENTITY}`,
1252
+ method: 'GET',
1253
+ handler: (req, res) => json(res, 200, identity()),
1254
+ })
1255
+ try { service.routes.push(`${prefix}${ADAPTER_ROUTE.IDENTITY}`) } catch { /* fake ws */ }
1256
+ // v3.1.17:客户端自报加载状态(只记录时间戳,不含身份/token)。
1257
+ webServer.register({
1258
+ kind: 'exact',
1259
+ path: `${prefix}${ADAPTER_ROUTE.CLIENT_REPORT}`,
1260
+ method: 'POST',
1261
+ handler: (req, res) => json(res, 200, reportClient()),
1262
+ })
1263
+ try { service.routes.push(`${prefix}${ADAPTER_ROUTE.CLIENT_REPORT}`) } catch { /* fake ws */ }
1264
+ const route = (method, routePath, handler) => {
1265
+ webServer.register({
1266
+ kind: 'exact',
1267
+ path: routePath,
1268
+ method,
1269
+ handler: async (req, res) => {
1270
+ const actual = String(req?.method ?? 'GET').toUpperCase()
1271
+ // Same perimeter as /fang/api/*: loopback Host, Origin/Sec-Fetch-Site,
1272
+ // adapter Bearer token, JSON-only writes. Auth before method (401 not 405).
1273
+ const decision = checkRequestAuth(req, {
1274
+ token: adapterToken,
1275
+ // v2 compatibility (explicit allowLegacyNoToken) is the only case where
1276
+ // the token comparison is skipped; Host/Origin/CSRF still apply.
1277
+ publicRoute: adapterToken.length === 0 && legacyAllowed,
1278
+ })
1279
+ if (!decision.ok) {
1280
+ return json(res, decision.status, { ...authErrorBody(decision), path: routePath })
1281
+ }
1282
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(actual)) {
1283
+ const contentType = String(req?.headers?.['content-type'] ?? '').toLowerCase()
1284
+ if (contentType && !contentType.includes('application/json')) {
1285
+ return json(res, 415, { ok: false, code: 'content-type-required', error: 'content-type-required', path: routePath })
1286
+ }
1287
+ }
1288
+ if (!authorized(req)) {
1289
+ return json(res, 401, { ok: false, code: 'unauthorized', error: 'unauthorized', path: routePath })
1290
+ }
1291
+ if (actual !== method) {
1292
+ try { res.setHeader?.('allow', method) } catch { /* fake res */ }
1293
+ return json(res, 405, { ok: false, code: 'method-not-allowed', error: 'method-not-allowed', path: routePath })
1294
+ }
1295
+ try {
1296
+ return await handler(req, res)
1297
+ } catch (e) {
1298
+ if (res?.headersSent) {
1299
+ try {
1300
+ res.end()
1301
+ } catch {
1302
+ /* ignore */
1303
+ }
1304
+ return undefined
1305
+ }
1306
+ return json(res, 500, { ok: false, code: 'handler-threw', error: msgOf(e) })
1307
+ }
1308
+ },
1309
+ })
1310
+ service.routes.push(routePath)
1311
+ }
1312
+
1313
+ const sendResult = (res, out) => json(res, out && out.ok !== false ? 200 : statusForCode(out?.code), out)
1314
+
1315
+ // ── v2 五条(PROTOCOL-V2 §3,保留)─────────────────────────────────────
1316
+ route('GET', `${prefix}${ADAPTER_ROUTE.HELLO}`, (req, res) => json(res, 200, hello()))
1317
+
1318
+ route('GET', `${prefix}${ADAPTER_ROUTE.STATUS}`, (req, res) => json(res, 200, status()))
1319
+
1320
+ route('GET', `${prefix}${ADAPTER_ROUTE.APPROVALS}`, (req, res) => sendResult(res, approvals()))
1321
+
1322
+ route('GET', `${prefix}${ADAPTER_ROUTE.AUDIT}`, (req, res) => {
1323
+ const limit = parseLimit(req?.url)
1324
+ return sendResult(res, audit(limit === undefined ? {} : { limit }))
1325
+ })
1326
+
1327
+ route('POST', `${prefix}${ADAPTER_ROUTE.COMMAND}`, async (req, res) => {
1328
+ const parsed = await readJsonBody(req)
1329
+ if (parsed.error) {
1330
+ const code = parsed.error === 'body-too-large' ? 'body-too-large' : 'bad-request'
1331
+ return json(res, statusForCode(code), { ok: false, code, error: parsed.error })
1332
+ }
1333
+ const out = await command(parsed.body ?? {})
1334
+ return sendResult(res, out)
1335
+ })
1336
+
1337
+ // ── v3 五条(PROTOCOL-V3 §4)──────────────────────────────────────────
1338
+ route('GET', `${prefix}${ADAPTER_ROUTE.VERSION}`, (req, res) => json(res, 200, versionInfo()))
1339
+
1340
+ route('GET', `${prefix}${ADAPTER_ROUTE.HEALTH}`, (req, res) => json(res, 200, health()))
1341
+
1342
+ route('GET', `${prefix}${ADAPTER_ROUTE.STATE}`, (req, res) => json(res, 200, state()))
1343
+
1344
+ route('POST', `${prefix}${ADAPTER_ROUTE.COMMANDS}`, async (req, res) => {
1345
+ const parsed = await readJsonBody(req)
1346
+ if (parsed.error) {
1347
+ const code = parsed.error === 'body-too-large' ? 'body-too-large' : 'bad-request'
1348
+ return json(res, statusForCode(code), { ok: false, code, error: parsed.error })
1349
+ }
1350
+ const out = await commands(parsed.body ?? {})
1351
+ if (out && out.ok === false) return json(res, statusForCode(out.code), out)
1352
+ if (out && out.status === 'accepted') return json(res, 202, out)
1353
+ return json(res, 200, out)
1354
+ })
1355
+
1356
+ /**
1357
+ * SSE 长连接:鉴权由 route 包装器统一处理;session 不匹配 = 409,cursor 过期 = 410。
1358
+ * 先回放环形缓冲,再挂订阅;心跳注释帧每 heartbeatMs 一次(同时驱动审批 diff)。
1359
+ */
1360
+ route('GET', `${prefix}${ADAPTER_ROUTE.EVENTS}`, async (req, res) => {
1361
+ const query = parseQuery(req?.url)
1362
+ const since = query.since
1363
+ const session = query.session
1364
+ if (session && session !== sessionId) {
1365
+ return json(res, 409, { ok: false, code: 'session-changed', error: 'session-changed', sessionId })
1366
+ }
1367
+ syncApprovals()
1368
+ const replay = eventsSince(since, session)
1369
+ if (replay.ok === false) return json(res, statusForCode(replay.code), replay)
1370
+ if (replay.gap) {
1371
+ return json(res, 410, {
1372
+ ok: false,
1373
+ code: 'cursor-expired',
1374
+ error: 'cursor-expired',
1375
+ sessionId,
1376
+ lastCursor: replay.lastCursor,
1377
+ nextCursor: replay.nextCursor,
1378
+ })
1379
+ }
1380
+
1381
+ try {
1382
+ res.writeHead(200, {
1383
+ 'content-type': 'text/event-stream; charset=utf-8',
1384
+ 'cache-control': 'no-store, no-transform',
1385
+ connection: 'keep-alive',
1386
+ 'x-accel-buffering': 'no',
1387
+ })
1388
+ } catch {
1389
+ /* 假 res 的 writeHead 也能工作;真实 res 这里不应抛 */
1390
+ }
1391
+
1392
+ const write = (chunk) => {
1393
+ try {
1394
+ if (!res.writableEnded) res.write(chunk)
1395
+ } catch {
1396
+ /* 连接已断,等 close 回调清理 */
1397
+ }
1398
+ }
1399
+
1400
+ write(SSE_RETRY_FRAME)
1401
+ write(sseComment('connected'))
1402
+ for (const envelope of replay.events) write(sseFrame(envelope))
1403
+
1404
+ const off = subscribe((envelope) => write(sseFrame(envelope)))
1405
+ const heartbeat = setInterval(() => {
1406
+ syncApprovals()
1407
+ write(sseComment('heartbeat'))
1408
+ }, heartbeatMs)
1409
+ if (typeof heartbeat.unref === 'function') heartbeat.unref()
1410
+
1411
+ let closed = false
1412
+ const close = () => {
1413
+ if (closed) return
1414
+ closed = true
1415
+ clearInterval(heartbeat)
1416
+ off()
1417
+ activeStreams.delete(close)
1418
+ try {
1419
+ if (!res.writableEnded) res.end()
1420
+ } catch {
1421
+ /* ignore */
1422
+ }
1423
+ }
1424
+ activeStreams.add(close)
1425
+ try {
1426
+ req?.on?.('close', close)
1427
+ req?.on?.('aborted', close)
1428
+ res?.on?.('close', close)
1429
+ res?.on?.('error', close)
1430
+ } catch {
1431
+ /* ignore */
1432
+ }
1433
+ return undefined
1434
+ })
1435
+ }
1436
+
1437
+ try {
1438
+ if (typeof ctx.inject === 'function') {
1439
+ ctx.inject(['webServer'], (child) => {
1440
+ let webServer = null
1441
+ try {
1442
+ webServer = child?.get?.('webServer') ?? child?.webServer ?? null
1443
+ } catch {
1444
+ webServer = null
1445
+ }
1446
+ if (!webServer || typeof webServer.register !== 'function') return
1447
+ registerRoutes(webServer)
1448
+ })
1449
+ }
1450
+ } catch {
1451
+ /* webServer 不可用只意味着没有 HTTP 控制面,service 仍可被进程内调用 */
1452
+ }
1453
+
1454
+ return service
1455
+ }
1456
+
1457
+ export const _internals = {
1458
+ DEFAULTS,
1459
+ TASK_ID_ENVS,
1460
+ MAX_FILE_BYTES,
1461
+ MAX_TOTAL_BYTES,
1462
+ ADAPTER_PROTOCOL_VERSION,
1463
+ ADAPTER_PROTOCOL_MIN,
1464
+ resolveTaskId,
1465
+ readPluginTree,
1466
+ normalizePrefix,
1467
+ resolveInside,
1468
+ }