@sema-agent/server 7.43.0 → 7.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (345) hide show
  1. package/USAGE.md +18 -0
  2. package/dist/adoption/plan.js +31 -192
  3. package/dist/adoption/quiesce.js +3 -86
  4. package/dist/adoption/runner.js +3 -140
  5. package/dist/adoption/sql.js +0 -74
  6. package/dist/adoption/wire.js +0 -80
  7. package/dist/approval-ask-machine.js +0 -75
  8. package/dist/approval-card.js +0 -323
  9. package/dist/approval-deny-reasons.js +0 -42
  10. package/dist/approval-hmac.js +0 -35
  11. package/dist/approval-reconciler.js +8 -166
  12. package/dist/approval.js +4 -67
  13. package/dist/audit.js +1 -44
  14. package/dist/auth-bridge.js +4 -47
  15. package/dist/auth-keys.js +0 -23
  16. package/dist/bake-runner/main.js +4 -65
  17. package/dist/bake-runner/protocol.js +3 -58
  18. package/dist/bake-runner/runner.js +5 -91
  19. package/dist/bench/l8/artifact.js +2 -61
  20. package/dist/bench/l8/escape.js +0 -25
  21. package/dist/bench/l8/index.js +0 -14
  22. package/dist/bench/l8/probes.js +0 -60
  23. package/dist/bench/l8/run-probes.js +2 -45
  24. package/dist/bench/s1/arms.js +14 -189
  25. package/dist/bench/s1/live-deps.js +11 -253
  26. package/dist/bench/s1/oracle.js +1 -36
  27. package/dist/bench/s1/repair-oracle-adapter.js +0 -30
  28. package/dist/bench/s1/reviewer.js +0 -34
  29. package/dist/bench/s1/row.js +0 -62
  30. package/dist/bench/s1/run-firm.js +4 -70
  31. package/dist/bench/s1/runner-ctx.js +0 -40
  32. package/dist/bench/s1/tasks.js +0 -115
  33. package/dist/boot/adoption.js +0 -20
  34. package/dist/boot/budget-tracing.js +3 -55
  35. package/dist/boot/config-center.js +43 -551
  36. package/dist/boot/coordinators.js +1 -69
  37. package/dist/boot/crash-last.js +0 -18
  38. package/dist/boot/deferred-sandbox-path-env.js +3 -119
  39. package/dist/boot/execution-env.js +5 -171
  40. package/dist/boot/governance-seams.js +0 -92
  41. package/dist/boot/leader.js +0 -69
  42. package/dist/boot/limit-sync.js +0 -2
  43. package/dist/boot/memory-boundary.js +3 -91
  44. package/dist/boot/org-memory.js +1 -25
  45. package/dist/boot/parked-revive-gate.js +0 -150
  46. package/dist/boot/permission-rules-audit.js +1 -56
  47. package/dist/boot/reapers.js +15 -273
  48. package/dist/boot/resolve-spec.js +9 -768
  49. package/dist/boot/retention-lane.js +4 -87
  50. package/dist/boot/runner-deps.js +3 -297
  51. package/dist/boot/runtime-caps.js +0 -40
  52. package/dist/boot/session-faces.js +2 -133
  53. package/dist/boot/shutdown.js +9 -83
  54. package/dist/boot/side-query-lane.js +2 -137
  55. package/dist/boot/stores.js +15 -215
  56. package/dist/boot/task-list-lane.js +0 -18
  57. package/dist/boot/webfetch-summarize-lane.js +0 -47
  58. package/dist/boot/workflow-orchestration.js +3 -99
  59. package/dist/boot-reclaim.js +0 -20
  60. package/dist/bounded-session-map.js +0 -19
  61. package/dist/brain.js +2 -139
  62. package/dist/budget.js +9 -244
  63. package/dist/capabilities/builtin-tools.js +0 -3
  64. package/dist/capabilities/center-plugins.js +6 -51
  65. package/dist/capabilities/center-prompts.js +6 -71
  66. package/dist/capabilities/code-review-council.js +7 -40
  67. package/dist/capabilities/collab-workflows.js +1 -44
  68. package/dist/capabilities/hands-lane.js +0 -65
  69. package/dist/capabilities/memory-notice.js +0 -70
  70. package/dist/capabilities/prompt.js +0 -14
  71. package/dist/capabilities/prompts/code-review.js +0 -14
  72. package/dist/capabilities/prompts/identity.js +0 -6
  73. package/dist/capabilities/prompts/team.js +0 -4
  74. package/dist/capabilities/repo-tools.js +0 -34
  75. package/dist/capabilities/sandbox-file-send.js +3 -92
  76. package/dist/capabilities/scenarios.d.ts +0 -1
  77. package/dist/capabilities/scenarios.js +18 -279
  78. package/dist/capabilities/select-environment-tool.js +0 -32
  79. package/dist/capabilities/send-user-file-tool.js +2 -66
  80. package/dist/capabilities/skills.d.ts +1 -2
  81. package/dist/capabilities/skills.js +3 -21
  82. package/dist/capabilities/team.d.ts +3 -11
  83. package/dist/capabilities/team.js +3 -51
  84. package/dist/capabilities/tool-defer.js +0 -3
  85. package/dist/config-center/apply-effective.js +15 -330
  86. package/dist/config-center/apply-ledger.js +5 -38
  87. package/dist/config-center/facade.js +0 -41
  88. package/dist/config-center/hot-keys-registry.js +0 -20
  89. package/dist/config-center/http-client.js +2 -125
  90. package/dist/config-center/mcp-revocation.js +2 -34
  91. package/dist/config-center/read-face.js +0 -59
  92. package/dist/config-center/restart-signal.js +1 -85
  93. package/dist/config-center/skills-mcp.d.ts +1 -1
  94. package/dist/config-center/skills-mcp.js +9 -76
  95. package/dist/config-center/stage-limits.js +8 -35
  96. package/dist/config-invariants.js +0 -16
  97. package/dist/config-lkg.js +0 -42
  98. package/dist/config-provider.js +3 -186
  99. package/dist/config-types.js +0 -5
  100. package/dist/config.js +107 -1145
  101. package/dist/degenerate-instrument.js +3 -67
  102. package/dist/deployment-governance.js +0 -124
  103. package/dist/digest-form.js +0 -12
  104. package/dist/elicitation.js +3 -86
  105. package/dist/env-facts.js +7 -75
  106. package/dist/fleet/fleet-bus.js +34 -507
  107. package/dist/fleet/fleet-reconciler.js +9 -149
  108. package/dist/fleet/fleet-terminal-window.js +10 -178
  109. package/dist/fleet/subagent-tail-bus.js +3 -72
  110. package/dist/fleet-client.js +10 -70
  111. package/dist/fleet-lease.js +5 -79
  112. package/dist/git-api-kind.js +0 -3
  113. package/dist/governance-ask-marks.js +2 -78
  114. package/dist/hooks/branch-transcript.js +0 -74
  115. package/dist/hooks/cc-agent-hook-prompt.js +0 -29
  116. package/dist/hooks/cc-stop-prompt.js +1 -46
  117. package/dist/hooks/hook-llm.js +1 -53
  118. package/dist/hooks/hook-runner.js +20 -414
  119. package/dist/http/active-run-conflict.js +4 -129
  120. package/dist/http/cursor-fingerprint.d.ts +5 -0
  121. package/dist/http/cursor-fingerprint.js +5 -0
  122. package/dist/http/idempotency.js +0 -37
  123. package/dist/http/principal-gate.js +3 -40
  124. package/dist/http/route-ctx.js +0 -9
  125. package/dist/http/routes/a2a-serve.js +7 -319
  126. package/dist/http/routes/admin-config-refresh.js +0 -4
  127. package/dist/http/routes/admin-drain.js +0 -10
  128. package/dist/http/routes/adoption.js +1 -29
  129. package/dist/http/routes/agents-roster.js +1 -47
  130. package/dist/http/routes/approvals-assistant.js +32 -395
  131. package/dist/http/routes/attachments.js +4 -22
  132. package/dist/http/routes/capabilities.js +3 -420
  133. package/dist/http/routes/diagnostics.js +1 -81
  134. package/dist/http/routes/fleet.js +9 -185
  135. package/dist/http/routes/images.js +14 -239
  136. package/dist/http/routes/leader.js +0 -13
  137. package/dist/http/routes/memory-bundle.js +1 -61
  138. package/dist/http/routes/memory-policy.js +9 -99
  139. package/dist/http/routes/notify-wake.js +3 -37
  140. package/dist/http/routes/observability.js +2 -19
  141. package/dist/http/routes/retention-ops.js +2 -34
  142. package/dist/http/routes/rules.js +1 -93
  143. package/dist/http/routes/runs.js +64 -873
  144. package/dist/http/routes/session-sync.js +19 -258
  145. package/dist/http/routes/sessions-list.js +17 -43
  146. package/dist/http/routes/sessions.js +34 -226
  147. package/dist/http/routes/shared-memory.js +5 -36
  148. package/dist/http/routes/side-query.js +1 -87
  149. package/dist/http/routes/tasks.js +52 -722
  150. package/dist/http/routes/trace-usage.js +32 -195
  151. package/dist/http/routes/workflows.js +19 -195
  152. package/dist/http/run-meta.js +0 -6
  153. package/dist/http/send.js +0 -32
  154. package/dist/http/server.js +92 -1597
  155. package/dist/http/sse-lifecycle.js +2 -13
  156. package/dist/http/sse-log.js +3 -48
  157. package/dist/http/tar.js +5 -21
  158. package/dist/http/verify-rounds.js +0 -5
  159. package/dist/http/wire-gate.js +0 -9
  160. package/dist/http/workspace-content.js +0 -10
  161. package/dist/images/bake-validate.js +1 -70
  162. package/dist/images/manifest.js +1 -6
  163. package/dist/index.js +0 -21
  164. package/dist/key-resolver.js +2 -17
  165. package/dist/leader/diffout.js +1 -20
  166. package/dist/leader/diffup.js +0 -47
  167. package/dist/leader/endpoint.js +2 -57
  168. package/dist/leader/fanout.js +3 -45
  169. package/dist/leader/grader-env-factory.js +3 -72
  170. package/dist/leader/leader.js +5 -156
  171. package/dist/leader/merge.js +8 -115
  172. package/dist/leader/planner.js +3 -54
  173. package/dist/leader/repair-oracle.js +1 -60
  174. package/dist/leader/repair-wire.js +2 -79
  175. package/dist/leader/wire.js +8 -307
  176. package/dist/lsp/e2b-bridge.js +4 -64
  177. package/dist/lsp/e2b-manager.js +6 -94
  178. package/dist/lsp/lsp-frames.js +0 -12
  179. package/dist/lsp/manager.js +4 -96
  180. package/dist/lsp/ws-transport.js +5 -55
  181. package/dist/lsp-evict.js +1 -15
  182. package/dist/main.js +57 -765
  183. package/dist/memory-bundle-engine.js +0 -55
  184. package/dist/memory-export.js +0 -4
  185. package/dist/memory-posture.js +1 -15
  186. package/dist/memory-scope.js +11 -148
  187. package/dist/memory-sync-client.js +2 -44
  188. package/dist/memory-sync.js +1 -80
  189. package/dist/model-select.js +3 -80
  190. package/dist/observability/cost-quota.js +1 -17
  191. package/dist/observability/cost-taxonomy.js +0 -34
  192. package/dist/observability/fail-open.js +7 -86
  193. package/dist/observability/logger.js +0 -6
  194. package/dist/observability/metrics.js +0 -94
  195. package/dist/observability/otel-exporter.js +3 -13
  196. package/dist/observability/principal-context.js +0 -9
  197. package/dist/observability/prompt-manifest.js +1 -37
  198. package/dist/observability/rate-limit.js +0 -4
  199. package/dist/observability/secret-env-scrub.js +2 -56
  200. package/dist/observability/tool-trace.js +1 -70
  201. package/dist/orchestration/hardened-vm-runner.js +4 -118
  202. package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
  203. package/dist/orchestration/hardened-vm-worker.js +0 -27
  204. package/dist/orchestration/subagent-steer.js +1 -45
  205. package/dist/orchestration/workflow-agent-steer.js +1 -80
  206. package/dist/orchestration/workflow-completion-inbox.js +32 -285
  207. package/dist/orchestration/workflow-notify-journal.js +16 -259
  208. package/dist/org-memory-admission.js +3 -47
  209. package/dist/parent-watch.js +2 -48
  210. package/dist/parked-decide.js +1 -109
  211. package/dist/per-task-image.js +0 -57
  212. package/dist/plan-cache-probe.js +3 -27
  213. package/dist/plugins/adoption-log-sql.js +2 -119
  214. package/dist/plugins/approval-ask-store-memory.js +3 -38
  215. package/dist/plugins/approval-ask-store-sql.js +6 -188
  216. package/dist/plugins/approval-exemption-store.js +2 -28
  217. package/dist/plugins/background-agent-store-sql.js +4 -105
  218. package/dist/plugins/background-shell-support.js +14 -122
  219. package/dist/plugins/blob-backend.js +6 -169
  220. package/dist/plugins/breaker-state-sql.js +8 -46
  221. package/dist/plugins/caching-session-store.js +4 -106
  222. package/dist/plugins/checkpoint-store-sql.js +18 -505
  223. package/dist/plugins/e2b-orphan-reclaim.js +0 -45
  224. package/dist/plugins/file-outcome-sink.js +0 -9
  225. package/dist/plugins/file-resume-anchor-store.js +4 -43
  226. package/dist/plugins/file-run-store.js +26 -364
  227. package/dist/plugins/file-snapshot-store-sql.js +10 -181
  228. package/dist/plugins/fork-routing-session-store.js +8 -111
  229. package/dist/plugins/host-platform.js +2 -91
  230. package/dist/plugins/image-bake-store-sql.js +7 -250
  231. package/dist/plugins/image-index-sql.js +4 -123
  232. package/dist/plugins/k8s-bg-scripts.js +4 -88
  233. package/dist/plugins/k8s-exec-protocol.js +0 -41
  234. package/dist/plugins/leader-run-store-sql.js +0 -101
  235. package/dist/plugins/local-checkpoint-store.js +8 -128
  236. package/dist/plugins/local-session-store.js +34 -296
  237. package/dist/plugins/local-task-attachment-store.js +2 -16
  238. package/dist/plugins/mailbox-store-sql.js +8 -65
  239. package/dist/plugins/memory-embedder-fingerprint.js +5 -166
  240. package/dist/plugins/memory-embedder.js +4 -70
  241. package/dist/plugins/memory-engine-pg.js +6 -170
  242. package/dist/plugins/memory-engine-tidb.js +7 -155
  243. package/dist/plugins/memory-engine-vector-util.js +0 -10
  244. package/dist/plugins/memory-key-guards.js +0 -34
  245. package/dist/plugins/memory-origin-law.js +0 -187
  246. package/dist/plugins/memory-resume-anchor-store.js +0 -17
  247. package/dist/plugins/memory-run-store.js +12 -89
  248. package/dist/plugins/memory-session-policy-store.js +0 -17
  249. package/dist/plugins/memory-sync-store-pg.js +4 -49
  250. package/dist/plugins/memory-sync-store-tidb.js +3 -35
  251. package/dist/plugins/outcome-ledger-sql.js +3 -97
  252. package/dist/plugins/permission-rule-store-file.js +5 -133
  253. package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
  254. package/dist/plugins/permission-rule-store-sql.js +50 -307
  255. package/dist/plugins/pg-cost-quota.js +0 -7
  256. package/dist/plugins/pg-pool.js +0 -92
  257. package/dist/plugins/pg-rate-limiter.js +2 -13
  258. package/dist/plugins/pg-safe-json.js +4 -40
  259. package/dist/plugins/pg-session-storage.js +25 -189
  260. package/dist/plugins/posix-shell-fs.js +1 -31
  261. package/dist/plugins/remote-env-adb.js +12 -101
  262. package/dist/plugins/remote-env-e2b.js +40 -371
  263. package/dist/plugins/remote-env-file-error.js +0 -37
  264. package/dist/plugins/remote-env-host.js +63 -483
  265. package/dist/plugins/remote-env-k8s.js +32 -326
  266. package/dist/plugins/remote-env-local-docker.js +24 -157
  267. package/dist/plugins/remote-env-ssh.js +20 -128
  268. package/dist/plugins/remote-scratchpad.js +2 -32
  269. package/dist/plugins/remote-shell.js +1 -32
  270. package/dist/plugins/resume-anchor-store-sql.js +0 -11
  271. package/dist/plugins/retention-lane-store-sql.js +0 -108
  272. package/dist/plugins/retention-store-sql.js +4 -383
  273. package/dist/plugins/roster-store-sql.js +0 -55
  274. package/dist/plugins/run-store-sql.js +14 -251
  275. package/dist/plugins/s3-presign.js +2 -49
  276. package/dist/plugins/scheduler-support.js +3 -80
  277. package/dist/plugins/send-file-ledger.js +4 -53
  278. package/dist/plugins/send-user-file.js +4 -94
  279. package/dist/plugins/session-placement.js +1 -89
  280. package/dist/plugins/session-policy-store-sql.js +3 -81
  281. package/dist/plugins/session-store.js +0 -57
  282. package/dist/plugins/shared-memory-store-sql.js +4 -167
  283. package/dist/plugins/sql-driver.js +0 -17
  284. package/dist/plugins/sql-errors.js +0 -7
  285. package/dist/plugins/sql-escape.js +0 -8
  286. package/dist/plugins/sql-row-helpers.js +0 -25
  287. package/dist/plugins/store-backend.js +42 -222
  288. package/dist/plugins/store-contracts.js +2 -46
  289. package/dist/plugins/task-attachment-store.js +3 -49
  290. package/dist/plugins/task-list-store-sql.js +0 -79
  291. package/dist/plugins/tidb-cost-quota.js +1 -4
  292. package/dist/plugins/tidb-pool.js +1 -207
  293. package/dist/plugins/tidb-rate-limiter.js +3 -9
  294. package/dist/plugins/tidb-session-storage.js +4 -70
  295. package/dist/plugins/tidb-session-store.js +19 -341
  296. package/dist/plugins/tool-result-store-sql.js +4 -179
  297. package/dist/plugins/usage-window-store-sql.js +0 -8
  298. package/dist/plugins/web-search.js +10 -132
  299. package/dist/plugins/workflow-journal-store-sql.js +2 -58
  300. package/dist/plugins/workflow-run-store-sql.js +6 -91
  301. package/dist/plugins/worktree-isolation.js +6 -126
  302. package/dist/plugins/write-behind-counter.js +16 -75
  303. package/dist/principal-jwt.js +5 -60
  304. package/dist/project-memory.js +15 -146
  305. package/dist/prompts-domain-validate.js +1 -51
  306. package/dist/question.js +2 -111
  307. package/dist/resource-suspend.js +0 -18
  308. package/dist/router/route-orchestration.js +0 -77
  309. package/dist/rules-consent.d.ts +44 -5
  310. package/dist/rules-consent.js +75 -215
  311. package/dist/run-local.js +14 -381
  312. package/dist/runs.js +29 -617
  313. package/dist/runtime-caps-resolver.js +6 -132
  314. package/dist/runtime-governance.js +1 -232
  315. package/dist/sandbox-pkg-source.js +0 -37
  316. package/dist/sealed-key.js +3 -68
  317. package/dist/security.js +6 -291
  318. package/dist/session-leaf-bus.js +0 -32
  319. package/dist/session-sync-content.js +1 -69
  320. package/dist/session-sync-kernel.js +3 -60
  321. package/dist/session-sync.js +3 -66
  322. package/dist/session-titler.js +7 -41
  323. package/dist/session-watch.js +9 -73
  324. package/dist/shared-memory-scope-authorizer.js +0 -18
  325. package/dist/sighup-idle.js +1 -12
  326. package/dist/spec-fields.js +4 -143
  327. package/dist/store-live-probe.js +3 -49
  328. package/dist/task-a2a.js +1 -126
  329. package/dist/task-cwd.js +1 -103
  330. package/dist/task-mcp.js +1 -89
  331. package/dist/task-settings.js +10 -300
  332. package/dist/task-workflow.js +8 -75
  333. package/dist/tool-approval.d.ts +28 -1
  334. package/dist/tool-approval.js +70 -1430
  335. package/dist/trace/artifacts.js +5 -20
  336. package/dist/trace/engine-notice-wire.js +6 -138
  337. package/dist/trace/ledger-sink.js +7 -96
  338. package/dist/trace/project.js +4 -448
  339. package/dist/trace/redact.js +9 -77
  340. package/dist/turn-activity.js +1 -27
  341. package/dist/usage-analytics.js +4 -34
  342. package/dist/wall-clock-jump-guard.js +1 -68
  343. package/package.json +3 -3
  344. package/dist/capabilities/scenario-alias.d.ts +0 -27
  345. package/dist/capabilities/scenario-alias.js +0 -61
@@ -3,19 +3,15 @@ import { principalFrom, decodeCheckpointScope, PRINCIPAL_TOKEN_HEADER, APPROVAL_
3
3
  import { verifyDirectDoorProof } from "../../principal-jwt.js";
4
4
  import { MAX_APPROVAL_REASON_CHARS } from "../../approval-hmac.js";
5
5
  import { redactedPreview, redactSecrets } from "../../trace/redact.js";
6
- import { fleetRunLabels } from "../../fleet/fleet-bus.js"; // [2069]④ §3 行展示名与 fleet 行同源(见用处的注)
6
+ import { fleetRunLabels } from "../../fleet/fleet-bus.js";
7
7
  import { sleep } from "../sse-log.js";
8
8
  import { bindSseLifecycle } from "../sse-lifecycle.js";
9
9
  import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_MS } from "../send.js";
10
10
  import { gatedPrincipal, explicitOperatorOk, isOperator } from "../principal-gate.js";
11
11
  import { governanceOriginOf } from "../active-run-conflict.js";
12
- // design/80 seam #2 (assistant-scheduler): graceful preempt (durable yield) + resource_limit resume of one task.
13
12
  export const ASSISTANT_PREEMPT_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/preempt$/;
14
13
  export const ASSISTANT_RESUME_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/resume$/;
15
14
  export const ASSISTANT_PLAN_REVIEW_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/plan_review$/;
16
- /** Structural check for an operator-supplied QuestionAnswer (durable ask resume, TC-5.4). Strict on the
17
- * load-bearing shape — `answers[]` non-empty, each `{header: string, selected: string[], note?: string}` —
18
- * so a typo'd payload fails the request instead of resuming the task with an answer the tool can't use. */
19
15
  export function isQuestionAnswer(v) {
20
16
  if (!v || typeof v !== "object" || Array.isArray(v))
21
17
  return false;
@@ -38,28 +34,15 @@ export async function handleApprovalsAssistant(req, res, url, ctx) {
38
34
  await handleApprovalsAssistantBody(req, res, url, ctx, miss);
39
35
  return !miss.fell;
40
36
  }
41
- /** 路由体 = 从 `server.ts` 的 `handle()` 里**整段剪切**过来的原文(唯一改动:统一去缩进两格)。裸 `return;`
42
- * = 「本域已应答」;走到函数尾才是「没匹配上」。跨域的东西一律在首行解构出来。 */
43
37
  async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
44
38
  const { deps } = ctx;
45
39
  const { preemptableRuns } = ctx.registry;
46
40
  const { readJson, rateLimited, quotaExceeded } = ctx.helpers;
47
41
  const { resumeCheckpoint, resumePreempted, resumePlanReview } = ctx.legs;
48
- // Durable F4 (design/45): the approval queue over the CHECKPOINT store. Operators list pending (non-secret)
49
- // and decide by the session handle → the service maps session→token INTERNALLY and resumes (token never in
50
- // a URL/body/log). Takes precedence over the poll gate when a checkpointStore is wired.
51
42
  if (deps.checkpointStore && (url.startsWith("/v1/approvals") || url.startsWith("/v1/assistant"))) {
52
43
  const cs = deps.checkpointStore;
53
- // 🔴 D-G §6: a direct-mode worker NEVER trusts x-agent-principal NOT EVEN for the READ paths (else a forged
54
- // operator header leaks every tenant's pending tool-call args via GET/stream, adversarial HIGH). For reads there
55
- // is no per-decision checkpointToken, so identity comes from a verified principal JWT WITHOUT a cnf.bnd; an
56
- // absent/invalid token ⇒ undefined principal ⇒ scope "__none__" + operator=false (fail-closed). The POST /decide
57
- // path below re-verifies the SAME token WITH cnf.bnd (channel-bound to the action).
58
- const principal = gatedPrincipal(req, deps.config); // direct-door safe — identity-only proof; single source of truth (see gatedPrincipal)
44
+ const principal = gatedPrincipal(req, deps.config);
59
45
  const operator = isOperator(principal, deps.config.operatorPrincipals);
60
- // design/80 (native push): SSE stream of pending-approval deltas so the portal SUBSCRIBES ONCE instead of
61
- // polling GET /v1/approvals every ~10s. Same operator/owner scope as the list. Must precede the list/decide
62
- // matches (the decide regex would otherwise treat "stream" as a sessionId on a POST — but this is GET-only).
63
46
  if (req.method === "GET" && url === "/v1/approvals/stream") {
64
47
  const scope = operator
65
48
  ? (new URL(req.url ?? "", "http://x").searchParams.get("owner") ?? undefined)
@@ -69,26 +52,14 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
69
52
  }
70
53
  if (req.method === "GET" && url === "/v1/approvals") {
71
54
  const scope = operator
72
- ? (new URL(req.url ?? "", "http://x").searchParams.get("owner") ?? undefined) // operator: all (or ?owner)
73
- : (principal ?? "__none__"); // non-operator: only its own scope (never others' pending)
74
- // #209 件4 + [3684]②:行整只上 wire(键集契约照旧「整只透传、不按键投影」),但两格由投影函数
75
- // 负责——`riskDescriptor.shadowedRule` 脱敏、`governanceForced` 归因。与 `/v1/approvals/stream`
76
- // **共用同一个** `projectPendingForWire`(两条读面各投各的必漂,见其顶注)。
77
- // #315([4658] 案):第二顶层键 `livePending` = streamApproval 窗内的流内 ask(此前对轮询消费端
78
- // 结构性不可见,要等窗超时转 park 才浮现)。**分数组不混编**:两族行形与决议路由不同(live 行
79
- // 决议口=respond,durable 行=decide;数组名即路由判据)。scope 复用同一表达式(operator=全量,
80
- // principal=只见自己;"__none__" 恒空)。协调器未装配 ⇒ 键**缺席**不编 [](缺席=「本部署无
81
- // live 审批面」,空数组=「有面、此刻无待批」,两义须可判别)。
55
+ ? (new URL(req.url ?? "", "http://x").searchParams.get("owner") ?? undefined)
56
+ : (principal ?? "__none__");
82
57
  sendJson(res, 200, {
83
58
  pending: (await cs.listPending(scope)).map((r) => projectPendingForWire(r, deps.config)),
84
59
  ...(deps.toolApproval ? { livePending: deps.toolApproval.listLivePending(scope) } : {}),
85
60
  });
86
61
  return;
87
62
  }
88
- // exemptions surface — the UI's "本会话不再询问" state (list) + revoke. Same authz shape as
89
- // /decide: an explicit operator may inspect any session; otherwise the caller must OWN the session's scope
90
- // (peekPendingScope only knows PENDING sessions, so ownership here reads the session store's owner — the
91
- // durable authority). Non-owner ⇒ 404 (no existence oracle, decide-route parity).
92
63
  {
93
64
  const em = /^\/v1\/approvals\/([^/]+)\/exemptions(?:\/([^/]+))?$/.exec(url);
94
65
  if (em && (req.method === "GET" || req.method === "DELETE")) {
@@ -107,14 +78,8 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
107
78
  return;
108
79
  }
109
80
  const operators = deps.config.operatorPrincipals;
110
- const isExplicitOperator = explicitOperatorOk(principal, operators); // E1: single mint point (principal-gate)
81
+ const isExplicitOperator = explicitOperatorOk(principal, operators);
111
82
  if (!isExplicitOperator) {
112
- // 🔴 fail-CLOSED owner gate (both reviewers, 2026-07-13): mirror the sibling owner-gated routes
113
- // (session policy/delete) — ownerOf REQUIRED (absent ⇒ 501, never open), a store error PROPAGATES
114
- // (500, never open; the old `.catch(() => undefined)` flipped a DB blip into authorization), and an
115
- // UNKNOWN session (owner === undefined) 404s with no existence oracle. owner === null (ownerless
116
- // anonymous/dev session) stays open — decide-route parity. No uuid shape-gate: this population is
117
- // the checkpoint sessionId space (decide-route parity), not the sessions-API surface.
118
83
  const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
119
84
  if (!ownerOf) {
120
85
  sendError(res, 501, "capability.session_store_required", "approval exemptions require a session-store backend");
@@ -128,7 +93,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
128
93
  }
129
94
  if (req.method === "GET") {
130
95
  if (exToolName !== undefined) {
131
- sendError(res, 404, "request.route_unsupported", "not found"); // GET is list-only (no per-tool read)
96
+ sendError(res, 404, "request.route_unsupported", "not found");
132
97
  return;
133
98
  }
134
99
  sendJson(res, 200, { exemptions: await deps.approvalExemptionStore.list(exSessionId) });
@@ -138,46 +103,17 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
138
103
  sendError(res, 400, "request.route_unsupported", "DELETE needs /exemptions/:toolName");
139
104
  return;
140
105
  }
141
- const revoked = await deps.approvalExemptionStore.revoke(exSessionId, exToolName); // RAW(5.0.0):豁免键空间=CC canonical 名,live/存量行同名
106
+ const revoked = await deps.approvalExemptionStore.revoke(exSessionId, exToolName);
142
107
  sendJson(res, revoked ? 200 : 404, revoked ? { revoked: true } : { error: "not found", errorCode: "not_found.exemption" });
143
108
  return;
144
109
  }
145
110
  }
146
- // design/80 #2 (assistant-scheduler, design/75 §3): the SINGLE INBOX — one prioritized view of the HITL gates
147
- // across ALL of a user's N concurrent durable tasks. = listPending(scope) (already severity-sorted with the
148
- // riskDescriptor) ENRICHED with each task's objective so the user knows WHICH of their tasks each gate belongs
149
- // to (the missing cross-task aggregation). Scheduling priority = the severity sort; preemption is
150
- // a later policy layer. Same owner-scoping as listPending (a non-operator sees only its own tasks' gates).
151
111
  if (req.method === "GET" && url === "/v1/assistant/inbox") {
152
- // SECURITY (review w16yqkkxv): the assistant inbox is a PER-USER view — ALWAYS scope to a CONCRETE owner,
153
- // never a system-wide dump. A non-operator sees its own principal; an operator may inspect ONE owner via
154
- // ?owner, else its own. (≠ /v1/approvals, the admin queue, which intentionally lists all.) This removes the
155
- // scope=undefined / empty-?owner path that made listPending() return every tenant's pendings unbounded.
156
112
  const ownerParam = new URL(req.url ?? "", "http://x").searchParams.get("owner") || undefined;
157
113
  const scope = operator && ownerParam ? ownerParam : (principal ?? "__none__");
158
- // 🔴 (BUG FIX) the inbox is the documented CheckpointSummary view (gateKind + severity SCALAR +
159
- // spentMicroUsd + deadline), NOT the rich operator-queue PendingCheckpoint. It USED to spread listPending()'s
160
- // raw rich rows — which carried `riskDescriptor.severity` (an object, not the scalar), the tool input + the
161
- // decide binding, and NO gateKind — diverging from the contract + the tasks view (which already projects via
162
- // summarizeCheckpoint). Switched to listByScope (the SAME summarizeCheckpoint projection tasks uses) so the
163
- // inbox + tasks are consistent; the rich decide-binding lives on /v1/approvals (the operator queue), not here.
164
114
  const summaries = await cs.listByScope(scope);
165
115
  const inbox = (await Promise.all(summaries.map(async (s) => {
166
- // `createdAt` + `toolInput` are now PROJECTED into CheckpointSummary by core's summarizeCheckpoint
167
- // (core 1.116.0): listByScope returns them in
168
- // ONE query, so the per-entry `cs.get(token)` N+1 is GONE. `objective` lives in the task body
169
- // (checkpoint_ctx), not the summary, so it still needs getCtx (best-effort; missing ⇒ null).
170
116
  const ctx = await cs.getCtx(s.sessionId).catch(() => null);
171
- // 🔴 STRIP the resume `token` (the unexposed secret capability — resume is by sessionId, never the
172
- // token) AND the RAW `toolInput` (core bounds but does NOT redact it — redaction is
173
- // the consumer's job). Re-emit the REDACTED `input` instead.
174
- // 🔴 EXPLICIT projection([2029] cli 批准的收窄,辖域=仅本 inbox 面)—— 此前是「剥 token/toolInput、
175
- // 其余整行展开」,于是 core summarizeCheckpoint 在 tool_approval 门上填的 toolName/toolCallId/preview/
176
- // principal/sourceTaskId/contentKind 全部漏上 wire(其中 preview 还没过脱敏,而它同源的 toolInput 是
177
- // 显式脱敏的)。四客户端仓结构性零消费(inbox verb 无调用点,[2029] 取证),按 §2 原意收成显式白名单:
178
- // §1 镜像键(OMIT-when-absent)+ 两个显式 nullable 的 enrichment(objective/input)。富行
179
- // (toolName/toolCallId/decide 绑定)只住 /v1/approvals —— 那面有具名消费者(desktop session-host
180
- // 冷恢复),不在本刀辖域。`input` = REDACTED preview,只有 tool_approval 门携带(其余 ⇒ null)。
181
117
  const input = s.toolInput != null ? redactedPreview(s.toolInput) : null;
182
118
  return {
183
119
  sessionId: s.sessionId,
@@ -190,70 +126,25 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
190
126
  objective: ctx?.body?.objective ?? null,
191
127
  input,
192
128
  };
193
- }))
194
- // severity DESC, then **explicitly** oldest-first within a tier ([2027] 第五节欠单②,红先绿后:
195
- // test/assistant-triage-ordering.test.ts)。旧姿势只比 severity,靠「listByScope 是 created_at_ms ASC
196
- // + Array#sort 稳定」来兑现 §2 那句 oldest-first —— 而那个前提**只在本仓的 SQL 后端成立**
197
- // (checkpoint-store-sql.ts 的 `ORDER BY created_at_ms ASC`):core 的 `CheckpointStore.listByScope` 接口
198
- // 零顺序声明,InMemory/File 两个实现直接遍历 Map(LOCAL 车道包的正是 File store)⇒ 那句承诺过去是
199
- // **后端相关**的。比较器自带兜底后它与 store 顺序无关。形与 listPending 的同族 sort 逐字一致
200
- // (checkpoint-store-sql.ts `|| a.createdAt - b.createdAt`)。createdAt 缺席(pre-1.116 投影的老行)
201
- // 计 0 = 档内最老:确定性优于「看它恰好落在哪」。
202
- ).sort((a, b) => (b.severity ?? 0) - (a.severity ?? 0) || (a.createdAt ?? 0) - (b.createdAt ?? 0));
203
- sendJson(res, 200, { inbox }); // CheckpointSummary (token-stripped) + objective, severity-prioritized — the assistant single inbox
129
+ }))).sort((a, b) => (b.severity ?? 0) - (a.severity ?? 0) || (a.createdAt ?? 0) - (b.createdAt ?? 0));
130
+ sendJson(res, 200, { inbox });
204
131
  return;
205
132
  }
206
- // design/80 #2 (assistant-scheduler): the N-TASKS overview = the cross-task SCHEDULING view. A user's active
207
- // durable agent tasks, each joined (when suspended on a HITL gate) with the core scheduler seam
208
- // listByScope(scope) → {gateKind, severity, spentMicroUsd, deadline} (core 1.105, ONE query, no N+1). The
209
- // result is ordered for triage: blocked-on-a-gate first, then by risk severity DESC, then by budget spent
210
- // DESC — i.e. the order a scheduler would advance/escalate them. Same owner-scoping. Needs the run-store.
211
133
  if (req.method === "GET" && url === "/v1/assistant/tasks") {
212
- // SECURITY (review w16yqkkxv): per-user view — ALWAYS a CONCRETE owner (operator inspects ONE via ?owner,
213
- // else its own; non-operator = its own). No scope=undefined → no unbounded cross-tenant listPending()/listRuns.
214
134
  const ownerParam = new URL(req.url ?? "", "http://x").searchParams.get("owner") || undefined;
215
135
  const scope = operator && ownerParam ? ownerParam : (principal ?? "__none__");
216
136
  if (!deps.runStore) {
217
137
  sendJson(res, 200, { tasks: [] });
218
138
  return;
219
139
  }
220
- // Rich per-gate summary via the core seam (severity + budget) — ONE scoped query, bounded (LIMIT 500).
221
140
  const summaries = await cs.listByScope(scope);
222
141
  const byGate = new Map(summaries.map((s) => [s.sessionId, s]));
223
142
  const runs = await deps.runStore.listRuns({ owner: scope, limit: 100 });
224
143
  const tasks = runs
225
- // 🔴 wlzq75oac HIGH: a plan_review / dry-run review park persists as status='needs_review' (NOT 'suspended'),
226
- // so a running/suspended-only filter DROPPED exactly the HITL-gated tasks §3 promises to surface "needs-
227
- // attention first" — the operator could never discover the very plan_review tasks §4c says to resolve.
228
- // Same class as the inbox leak (handler vs contract drift). needs_review is a non-terminal park → include it.
229
- // 判据取词表属主(合并码重扫):park 两个词由 `isParkedRunStatus` 穷举,手抄形对 core 加词无编译期钉。
230
144
  .filter((r) => r.status === "running" || isParkedRunStatus(r.status))
231
145
  .map((r) => {
232
146
  const s = byGate.get(r.sessionId);
233
147
  const gate = s ? { kind: s.gateKind, severity: s.severity ?? null, spentMicroUsd: s.spentMicroUsd ?? null, deadline: s.deadline ?? null } : null;
234
- // 🔴 `needsAttention` = 「此刻有一个**可解的活门**」= `!!gate`,**不是**「run 行停着」([2034]/[2035]
235
- // 附带登记的 handler↔§3 文档出入,2026-07-30 裁定 (b):改文档对齐本行,常驻钉
236
- // `test/assistant-tasks-needs-attention.test.ts`)。gate 来自 `listByScope`,三个实现都只回
237
- // `status='pending'` 的行 ⇒ 分歧态 = run 停在 suspended/needs_review 而 pending checkpoint 已不在。
238
- // 该态**可达且周期性发生**:TTL 收割的两半(checkpointStore.reapExpired 只动 checkpoint /
239
- // runStore.failSuspendedWithExpiredCheckpoint 才动 run 行)在 boot/reapers.ts 同一 tick 里互不 await。
240
- // 三问:① 需要方=triage 视图,它拿这一位只做「哪张卡现在能动 + 排前面」(比较器第一级);
241
- // ② 若改按 status 算,分歧态会产出**不可解之卡**——标成待处理、排进前段,而 /decide 必 404、
242
- // /plan_review 与 /resume 同族拒,且与同一行的 `gate:null` 自相矛盾;现写法的代价是这段窗口里
243
- // 「只渲染 needsAttention 段」的客户端看不到它;
244
- // ③ 补偿=(a) 行**不消失**——它仍在本列表里带真实 `status`,「所有还停着的任务」由 status 回答;
245
- // (b) failSuspendedWithExpiredCheckpoint 三个 run store 全实装、每 tick 无条件跑(不受
246
- // APPROVAL_TIMEOUT_SEC 开关),窗口由 REAP_INTERVAL_SEC 封顶后收敛成 failed。
247
- // [2069]④(cli 2026-07-30):additive 展示位。行此前只有 taskId/status/needsAttention,面板
248
- // 渲不出「这是哪个任务」。源 = run 行自己的 `objectivePreview`(写入时已脱敏 + ~120 截断)——
249
- // **不**走 §2 inbox 那套 per-row `getCtx`:本视图头注逐字承诺「ONE query, no N+1」,为两个
250
- // 展示位把它变成 N+1 是拿契约换排版。
251
- // · `name` 经 `fleetRunLabels()`,与 fleet 行**同一个函数** ⇒ 同一个 run 在两个面板里名字一致
252
- // (两处各写一遍必然漂),顺带白拿 [1414]④ 的控制帧形保护(`<task-notification…>` 给中性名)。
253
- // · `description` = 未截断原值,**仅在与 name 不同时才发** —— 相同就是同一句话印两遍
254
- // (BC-1 在 fleet 行上杀掉的 objective-shown-twice)。
255
- // · 两键 OMIT-when-absent(无 preview ⇒ 都不发,占位归客户端),同批 [2070]③ 同一条纪律。
256
- // 钉:test/assistant-tasks-display-keys.test.ts。
257
148
  const preview = r.objectivePreview?.trim() ? r.objectivePreview : undefined;
258
149
  const label = preview !== undefined ? fleetRunLabels(preview).name : undefined;
259
150
  return {
@@ -267,32 +158,18 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
267
158
  sendJson(res, 200, { tasks });
268
159
  return;
269
160
  }
270
- // design/80 seam #2: POST /v1/assistant/tasks/:id/preempt — the scheduler's graceful "yield this task"
271
- // (durable + resumable; the OPPOSITE of /v1/runs/:id/cancel's kill). Durable cross-replica preempt flag
272
- // (the owning instance's heartbeat honors it) + a same-replica preempt-controller fast path. On a direct
273
- // worker the caller is the crypto-VERIFIED JWT principal (above) — owner-scoped, no trusted-header bypass.
274
161
  const preemptMatch = req.method === "POST" ? ASSISTANT_PREEMPT_RE.exec(url) : null;
275
162
  if (preemptMatch) {
276
163
  if (rateLimited(req, res) || quotaExceeded(req, res))
277
- return; // 🔴 复审 C3:preempt is NON-billable
278
- // (yields a task, runs no model — see the billable-route classifier) → NO lease gate, else an exhausted
279
- // tenant could not stop its own spending.
164
+ return;
280
165
  if (!deps.runStore) {
281
166
  sendError(res, 501, "capability.run_store_required", "preemption requires a durable run store (DB_BACKEND=mysql|pg|local)");
282
167
  return;
283
168
  }
284
- // requirePrincipal parity with the sibling mutating endpoints (runOwnerOk / cancel / runs): 401 before the
285
- // lookup when configured (uses the crypto-verified `principal` — never the trusted header on a direct door).
286
169
  if (deps.config.requirePrincipal && principal === undefined) {
287
170
  sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
288
171
  return;
289
172
  }
290
- // Precondition: a preempt only durably suspends when the worker opts tasks into resourceSuspend
291
- // (RESOURCE_SUSPEND=true) AND has the durable-suspend substrate (a checkpoint store — resolveSpec wires
292
- // resourceSuspend ONLY inside the durableEnabled block, so without a checkpoint store the preemptSignal is
293
- // never armed) AND core deems the task eligible (remote env + durable tool-results). Reject honestly when the
294
- // worker can't preempt at all (e.g. the local in-memory backend has no checkpoint store), rather than 202
295
- // "preempting" a signal nothing honors.
296
173
  if (!deps.config.resourceSuspend || !deps.checkpointStore) {
297
174
  sendError(res, 501, "feature.preemption_disabled", "preemption not enabled on this worker (needs RESOURCE_SUSPEND=true + a durable checkpoint store)");
298
175
  return;
@@ -303,41 +180,19 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
303
180
  sendError(res, 404, "not_found.run", "task not found");
304
181
  return;
305
182
  }
306
- // Owner-gate: an EXPLICIT operator may preempt any tenant's task (preemption is a scheduler action); a
307
- // normal caller only its own. 404 (not 403) for a non-owner — no existence oracle (parity with runOwnerOk
308
- // + the cancel/decide precedent). NOT the bare isOperator (its empty-list "true-for-all" would let any
309
- // caller preempt anyone — same trap the /decide owner-gate avoids).
310
183
  const operators = deps.config.operatorPrincipals;
311
- const explicitOperator = explicitOperatorOk(principal, operators); // E1: single mint point (principal-gate)
184
+ const explicitOperator = explicitOperatorOk(principal, operators);
312
185
  if (!explicitOperator && run.owner !== null && run.owner !== principal) {
313
186
  sendError(res, 404, "not_found.run", "task not found");
314
187
  return;
315
188
  }
316
189
  if (run.status === "running") {
317
- // Honesty guard (review wtyc7ist3): if THIS replica owns the run (instance match) yet it is NOT in
318
- // preemptableRuns, it is a verify/cascade leg — those durably suspend as a FAILED result, so they are
319
- // deliberately never registered + never honored (runs.ts). Don't set a dead durable flag or claim
320
- // "preempting"; report the honest no-op. (Owned-elsewhere → instanceId mismatch → fall through to the
321
- // durable flag so the owning replica's heartbeat honors it iff that leg is eligible — best-effort.)
322
190
  const ownedHere = run.instanceId != null && run.instanceId === deps.instanceId;
323
191
  if (ownedHere && !preemptableRuns.has(taskId)) {
324
192
  sendJson(res, 202, { taskId, status: run.status, note: "task is not preempt-eligible (verify/cascade leg) — preempt is a no-op" });
325
193
  return;
326
194
  }
327
- // Durable flag FIRST (only flips a still-running row), abort after — if the run suspended/terminal'd in
328
- // the getRun→here window the flag affects 0 rows, so re-read and answer by the ACTUAL state.
329
- let flagged = await deps.runStore.requestPreempt(taskId, run.owner); // owner guard: single-DB defense-in-depth (owner-gate already enforced above)
330
- // 🔴 抢跑输了(旗打空 0 行)⇒ 按 run 的**真实**状态**三向**重判(重扫二轮红先修;与 `runs.ts` 取消腿
331
- // 的孪生臂 `CANCEL_RECLASSIFY_ATTEMPTS` 那一段同源同形)。旧形无论重读到什么都发 202 no-op,于是
332
- // **同一条 park 行**:不经这个竞态窗时回 409 `conflict.already_suspended`(前置检查那一支,`403a841`),
333
- // 经过它时回 202「task no longer running」。按状态码分支的调度器对同一个状态拿到两种答案,而契约文
334
- // §4d 把它写成了绝对规则(parked ⇒ 409 / genuinely terminal ⇒ 202),只成文了其中一种。
335
- // · **park**(判据取词表属主 `isParkedRunStatus`,穷举 switch + never 臂)⇒ 409,`status` 带**行的真词**;
336
- // · **running**(park→running 二次跃迁真实存在:并发 approve 的恢复腿把行翻回来)⇒ **重挂旗**,
337
- // 有界重试;行活过来正是该重挂旗的时刻,不是该撒谎的时刻;
338
- // · 真终局 ⇒ 照旧幂等 202 no-op(带真实状态)。
339
- // 有界(park↔running 反复跳时不许把请求钉在这):次数用尽仍 running ⇒ 如实 409 让调用方重试,
340
- // 码与取消腿的同名情形共用(同一件事:并发决议赢了,它的恢复腿正在跑)。
195
+ let flagged = await deps.runStore.requestPreempt(taskId, run.owner);
341
196
  const PREEMPT_RECLASSIFY_ATTEMPTS = 3;
342
197
  for (let attempt = 0; !flagged; attempt++) {
343
198
  const now = await deps.runStore.getRun(taskId);
@@ -346,7 +201,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
346
201
  return;
347
202
  }
348
203
  if (now?.status === "running" && attempt < PREEMPT_RECLASSIFY_ATTEMPTS) {
349
- flagged = await deps.runStore.requestPreempt(taskId, run.owner); // 行又活了 ⇒ 重挂 durable 旗(赢了就走下面的快路)
204
+ flagged = await deps.runStore.requestPreempt(taskId, run.owner);
350
205
  continue;
351
206
  }
352
207
  if (now?.status === "running") {
@@ -356,12 +211,10 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
356
211
  sendJson(res, 202, { taskId, status: now?.status ?? "failed", note: "task no longer running — preempt is a no-op" });
357
212
  return;
358
213
  }
359
- preemptableRuns.get(taskId)?.abort(); // fast path: preempt landed on the running instance → suspend now
214
+ preemptableRuns.get(taskId)?.abort();
360
215
  sendJson(res, 202, { taskId, status: "preempting", note: "graceful durable yield — the task suspends at the next clean turn boundary if preempt-eligible; resume via POST /v1/assistant/tasks/:id/resume" });
361
216
  }
362
217
  else if (isParkedRunStatus(run.status)) {
363
- // 扫描P2 顺带件:park 是两个词(isParkedRunStatus 属主)——needs_review(plan_review/dry-run 门)
364
- // 此前掉进 else 被谎报「already terminal — no-op」(无状态破坏,纯指路错)。extras.status 带真词。
365
218
  sendError(res, 409, "conflict.already_suspended", "task is already suspended", { taskId, status: run.status });
366
219
  }
367
220
  else {
@@ -369,13 +222,10 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
369
222
  }
370
223
  return;
371
224
  }
372
- // design/80 seam #2: POST /v1/assistant/tasks/:id/resume — resume a task durably SUSPENDED on a
373
- // resource_limit gate (a scheduler preempt OR a budget/turns/walltime slice, design/74). NOT an approval
374
- // gate (resumePreempted's gate-kind guard 409s a policy_ask — that goes through POST /v1/approvals/:id/decide).
375
225
  const resumeMatch = req.method === "POST" ? ASSISTANT_RESUME_RE.exec(url) : null;
376
226
  if (resumeMatch) {
377
227
  if (rateLimited(req, res) || quotaExceeded(req, res))
378
- return; // 🔴 复审 C2:lease admitted INSIDE driveResumeIntoRunLog on the CHECKPOINT-OWNER principal (the billed tenant), not the request principal — a cross-tenant operator resume must charge the owner's lease, not the operator's. resume hits TiDB + runs the model
228
+ return;
379
229
  if (!deps.runStore) {
380
230
  sendError(res, 501, "capability.run_store_required", "resume requires a durable run store (DB_BACKEND=mysql|pg|local)");
381
231
  return;
@@ -383,7 +233,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
383
233
  if (deps.config.requirePrincipal && principal === undefined) {
384
234
  sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
385
235
  return;
386
- } // parity with sibling mutating endpoints
236
+ }
387
237
  const taskId = resumeMatch[1];
388
238
  const run = await deps.runStore.getRun(taskId);
389
239
  if (!run) {
@@ -391,33 +241,19 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
391
241
  return;
392
242
  }
393
243
  const operators = deps.config.operatorPrincipals;
394
- const explicitOperator = explicitOperatorOk(principal, operators); // E1: single mint point (principal-gate)
244
+ const explicitOperator = explicitOperatorOk(principal, operators);
395
245
  if (!explicitOperator && run.owner !== null && run.owner !== principal) {
396
246
  sendError(res, 404, "not_found.run", "task not found");
397
247
  return;
398
248
  }
399
- // 🕐 **#316 同族登记(本批不改行为)**:这条腿与 legacy `/decide` 同病 —— `driveResumeIntoRunLog`
400
- // 在响应之前同步跑完整条 resume 流,响应时长 = 模型往返、无服务端上限。#316 只对 legacy decide 腿
401
- // 做了 200 受理化(那是 [4660] 雪崩的实测首环),本腿维持终局形:**wire BREAKING 需要消费方先表态**
402
- // (能力显式表态制,[3780] 裁 A)。候消费方认领后同形改造 —— 改法现成:`resumePreempted` 一路把
403
- // `acceptEarly: true` 透到 `driveResumeIntoRunLog` 即可(受理点/判定边界全在 `DriveResumeArgs.acceptEarly`)。
404
- const out = await resumePreempted(run.sessionId, req); // gate-kind guard + drive into the run log (shared)
249
+ const out = await resumePreempted(run.sessionId, req);
405
250
  sendJson(res, out.status, out.body);
406
251
  return;
407
252
  }
408
- // design/80 D-B: POST /v1/assistant/tasks/:id/plan_review — resolve a PRE-ACTION `plan_review` gate, 3-state
409
- // (approve | edit | reject). A SIBLING of preempt/resume (a scheduler-resolvable gate with NO bound tool
410
- // action — the human reviews the PLAN, not a tool call), NOT the /decide wire (a tool-approval allow/deny +
411
- // action binding) and NOT the needs_review wire (never reuse it). `edit` carries the
412
- // operator's REVISED plan (core threads `editedPlan` into the resumed run). 🔴 D-G ruling:
413
- // a plan_review binds NO action, so on a direct-door worker the self-asserted x-agent-principal is NEVER
414
- // trusted — it carries the PRINCIPAL-ONLY proof (verifyDirectDoorProof actionBinding:false = JWT identity +
415
- // cnf.bnd + HMAC over {sessionId,decision}, no boundInputHash), then the SAME operator-or-owner gate on the
416
- // CRYPTO-VERIFIED principal. On a BFF worker the trusted-header principal owner-gate applies (parity with resume).
417
253
  const planReviewMatch = req.method === "POST" ? ASSISTANT_PLAN_REVIEW_RE.exec(url) : null;
418
254
  if (planReviewMatch) {
419
255
  if (rateLimited(req, res) || quotaExceeded(req, res))
420
- return; // 🔴 复审 C2:lease admitted in driveResumeIntoRunLog (owner principal). resume hits TiDB + runs the model (approve/edit)
256
+ return;
421
257
  if (!deps.runStore) {
422
258
  sendError(res, 501, "capability.run_store_required", "plan_review requires a durable run store (DB_BACKEND=mysql|pg|local)");
423
259
  return;
@@ -425,13 +261,8 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
425
261
  if (deps.config.requirePrincipal && principal === undefined) {
426
262
  sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
427
263
  return;
428
- } // parity with sibling mutating endpoints
264
+ }
429
265
  const taskId = planReviewMatch[1];
430
- // 3-state decision; `edit` MUST carry the revised plan, `approve`/`reject` must NOT (a stray editedPlan
431
- // signals a wrong-gate call — reject, don't drop it). Parsed + validated BEFORE the task is resolved so
432
- // every bad-body 400 is existence-INDEPENDENT (depends only on the body). 🔴 wq64gmm5e LOW: getRun's 404
433
- // used to fire BEFORE the body/proof — a task-existence oracle (404-vs-401) on a direct-door worker,
434
- // divergent from /decide. Now the existence branch comes AFTER and is status-uniform with a bad proof.
435
266
  const body = (await readJson(req));
436
267
  const decision = body?.decision === "approve" ? "approve" : body?.decision === "edit" ? "edit" : body?.decision === "reject" ? "reject" : undefined;
437
268
  if (!decision) {
@@ -450,38 +281,24 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
450
281
  sendError(res, 400, "request.field_invalid", "reason must be a string");
451
282
  return;
452
283
  }
453
- // 与 /decide 腿同一上限:reason 入签之后,超长只能**拒**(截断产出的字节签名器复现不了 ⇒ 恒 401)。
454
284
  if (typeof body.reason === "string" && body.reason.length > MAX_APPROVAL_REASON_CHARS) {
455
285
  sendError(res, 413, "reason_too_large", `reason too large (max ${MAX_APPROVAL_REASON_CHARS} chars)`);
456
286
  return;
457
287
  }
458
288
  const run = await deps.runStore.getRun(taskId);
459
289
  if (!run) {
460
- // No such task. On a DIRECT-DOOR worker return the SAME 401 a bad proof gets (parity with /decide): a
461
- // missing task is indistinguishable from a bad proof — the caller cannot mint a valid proof for an
462
- // unknown sessionId anyway — so existence never leaks. On a BFF worker the trusted-header owner posture 404s.
463
290
  if (deps.config.directDoorActive) {
464
291
  sendError(res, 401, "principal_unverified", "principal proof required");
465
292
  return;
466
293
  }
467
- // [4268]/[4274] 件5(#43 P0 联调观测件):**只进日志,不上 wire**。两支 404 在 wire 面必须逐字同形
468
- // (反枚举:「不存在」与「不属于你」不可区分是本门的要求,不是疏漏),代价是服务端自己也说不清撞的
469
- // 是哪一支 —— 联调一次 404 只能靠猜。判别位放在服务端日志:运维/联调拿得到成因,枚举者拿不到。
470
- // `principalPresent` 只记**有无**([4288]):principal / owner 两个身份串一个都不许进日志 ——
471
- // 日志的读者面比 wire 宽,把值写进去等于在日志面重建一条本来被封住的枚举通道。
472
294
  deps.logger?.info?.("decide_404", { reason: "no-row", taskId, principalPresent: principal !== undefined });
473
295
  sendError(res, 404, "not_found.run", "task not found");
474
296
  return;
475
297
  }
476
- // ── D-G principal-only proof (non-action gate): on a direct-door worker derive the CRYPTO-VERIFIED principal
477
- // from the signed proof, never the self-asserted header. The envelope binds {sessionId, decision} so a forged
478
- // or replayed plan decision is rejected; no boundInputHash (there is no tool action). fail-closed.
479
298
  let deciderPrincipal = principal;
480
299
  if (deps.config.directDoorActive) {
481
300
  const hdr = (n) => { const h = req.headers[n]; return Array.isArray(h) ? h[0] : h; };
482
- const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) },
483
- // reason 入签:同一条规则也适用于身份绑定腿(两个绑定位为 null,第五位照常参与)。
484
- { sessionId: run.sessionId, decision, reason: typeof body.reason === "string" ? body.reason : null }, deps.config, { actionBinding: false });
301
+ const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) }, { sessionId: run.sessionId, decision, reason: typeof body.reason === "string" ? body.reason : null }, deps.config, { actionBinding: false });
485
302
  if (!proof.ok) {
486
303
  sendError(res, proof.status, proof.errorCode, proof.error);
487
304
  return;
@@ -489,34 +306,23 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
489
306
  deciderPrincipal = proof.principal;
490
307
  }
491
308
  const operators = deps.config.operatorPrincipals;
492
- const explicitOperator = explicitOperatorOk(deciderPrincipal, operators); // E1: single mint point (principal-gate)
309
+ const explicitOperator = explicitOperatorOk(deciderPrincipal, operators);
493
310
  if (!explicitOperator && run.owner !== null && run.owner !== deciderPrincipal) {
494
- // no existence oracle (verified principal on a direct worker) —— wire 面与上面的「查无此行」逐字同形;
495
- // 判别位同样只进服务端日志(件5,理由见上一处注)。刻意**不记** owner/principal 两个值:判别成因用
496
- // reason 一个词就够,把两个身份写进日志等于在日志面重建一条本来被封住的枚举通道。
497
311
  deps.logger?.info?.("decide_404", { reason: "owner-mismatch", taskId, principalPresent: principal !== undefined });
498
312
  sendError(res, 404, "not_found.run", "task not found");
499
313
  return;
500
314
  }
501
- // 🕐 **#316 同族登记(本批不改行为)**:同 `/resume` 腿那条注 —— 本腿同样在响应前同步跑完整条
502
- // resume 流(时长 = 模型往返、无上限)。改造形现成(`acceptEarly` 透传),候消费方表态后同形改。
503
- const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req,
504
- // [4277]/[4288] 件5:让四个 reason 的判别日志共用同一个 id 字段 + 同一个 principalPresent 口径。
505
- { taskId, principalPresent: principal !== undefined });
315
+ const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req, { taskId, principalPresent: principal !== undefined });
506
316
  sendJson(res, out.status, out.body);
507
317
  return;
508
318
  }
509
- // [2373]C-4(5.0.0):裸 `POST /v1/approvals/:id` 装饰臂删——/decide 是唯一决裁形(裸形曾与
510
- // `GET /v1/approvals/:id` 撞 URL 空间,并把限流/计费/wire 注三处记账劈成两条)。
511
319
  const m = /^\/v1\/approvals\/([^/]+)\/decide$/.exec(url);
512
320
  if (m && req.method === "POST") {
513
321
  if (rateLimited(req, res) || quotaExceeded(req, res))
514
- return; // 🔴 复审 C2:lease admitted in driveResumeIntoRunLog (owner principal). resume hits TiDB — rate-limit (council)
515
- // NOTE: the operator gate is checked AFTER the body parse below — D-G's direct door needs the parsed
516
- // envelope (checkpointToken + decision) to verify the HMAC before it can derive the deciding principal.
322
+ return;
517
323
  let sessionId;
518
324
  try {
519
- sessionId = decodeURIComponent(m[1]); // a malformed %-sequence is a client error (400), not a 500
325
+ sessionId = decodeURIComponent(m[1]);
520
326
  }
521
327
  catch {
522
328
  sendError(res, 400, "request.path_malformed", "malformed approval id (invalid percent-encoding)");
@@ -528,11 +334,6 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
528
334
  sendError(res, 400, "request.body_shape", "body must be { decision: 'approve' | 'deny', reason?, answer?, checkpointToken?, boundCallId?, boundInputHash?, updatedInput?, remember? }");
529
335
  return;
530
336
  }
531
- // `reason` 现在**在签名载荷里**(三方裁 (a),见 security.ts `approvalHmacMessage` 顶注)⇒ 它的形必须先钉死:
532
- // · 非字符串会让规范化编码产出一个签名器不可能复现的消息(对象会被内联进 JSON)—— 此前这条腿
533
- // 根本没校验 reason 的类型(plan_review 腿有),入签之后这就成了硬前置。
534
- // · 超长**拒而不截**:服务端截过的字节与签名方签的字节必然不同 ⇒ MAC 恒不匹配,截断只会把一个
535
- // 可诊断的 413 变成费解的 401(与 steer 入参上限同一判据:诚实拒绝优于自作主张改写)。
536
337
  if (body.reason !== undefined && typeof body.reason !== "string") {
537
338
  sendError(res, 400, "request.field_invalid", "reason must be a string");
538
339
  return;
@@ -541,11 +342,6 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
541
342
  sendError(res, 413, "reason_too_large", `reason too large (max ${MAX_APPROVAL_REASON_CHARS} chars)`);
542
343
  return;
543
344
  }
544
- // `remember:"session"` — record a per-session per-toolName approval exemption alongside an
545
- // approve ("本会话不再询问"). Closed enum (extensible later, e.g. "principal"); deny+remember = 400 (a
546
- // "remembered deny" is a different semantic — not smuggled in here); direct-door v1 = 400 (remember is
547
- // NOT in the HMAC envelope's signed payload — accepting it unsigned would be a tamper hole; extending
548
- // the MAC payload is a v2 with the BFF, see docs/design/APPROVAL-SESSION-EXEMPTION.md).
549
345
  let remember = false;
550
346
  if (body.remember !== undefined) {
551
347
  if (body.remember !== "session") {
@@ -564,22 +360,12 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
564
360
  sendError(res, 501, "capability.store_required", "approval exemptions need a store backend (DB_BACKEND / LOCAL lane)");
565
361
  return;
566
362
  }
567
- // 🔴 remember REQUIRES the D-1 decision-binding echo (workflow audit 2026-07-13, my override of the
568
- // verify-reject): an unbound decide can consume a gate that was SWAPPED after the operator's GET (see
569
- // gate A, approve lands on gate B) — tolerable as a one-shot legacy quirk, NOT as the mint of a
570
- // SESSION-PERSISTENT auto-approval for a tool the operator never saw. remember is a NEW contract, so
571
- // it can demand the strict calling shape — the BFF receives boundCallId on the pending row
572
- // (HITL-7 注纠,[2400] 审计:checkpointToken 从不上 pending 行,listPending 的 SELECT 无 token 列;
573
- // 下面的门是「或」关系,boundCallId 单独即满足,所以旧措辞没造成真故障)。
574
- // Plain decide without remember keeps the legacy unbound fallback untouched.
575
363
  if (!body.checkpointToken && !body.boundCallId) {
576
364
  sendError(res, 400, "remember_requires_binding", "remember requires the decision binding (echo checkpointToken and/or boundCallId from the pending approval)");
577
365
  return;
578
366
  }
579
367
  remember = true;
580
368
  }
581
- // Durable ask (TC-5.4): the operator's out-of-band QuestionAnswer rides the approve. Trusted
582
- // control-plane input (the model never produces it); malformed = client error, absent = unchanged.
583
369
  let answer;
584
370
  if (body.answer !== undefined) {
585
371
  if (!isQuestionAnswer(body.answer)) {
@@ -588,27 +374,12 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
588
374
  }
589
375
  answer = body.answer;
590
376
  }
591
- // D-1 (design/80) decision-action binding: the operator echoes back the checkpointToken + boundCallId +
592
- // boundInputHash they SAW in GET /v1/approvals. The service binds the resume to that exact action (core
593
- // verifies opaque equality, fail-closed PRE-CAS) and rejects a stale view distinctly (approval_stale).
594
- // All OPTIONAL — a legacy BFF caller that omits them falls back to the current-pending values (deprecated).
595
377
  for (const f of ["checkpointToken", "boundCallId", "boundInputHash"]) {
596
378
  if (body[f] !== undefined && typeof body[f] !== "string") {
597
379
  sendError(res, 400, "request.field_invalid", `${f} must be a string (the value surfaced on the pending approval record)`);
598
380
  return;
599
381
  }
600
382
  }
601
- // 🔴 直连门上拒 `updatedInput`(2026-07-26 修),判据与上面拒 `remember` **逐字同一条**:
602
- // 「不在决策证明的签名载荷里」。此前只 `remember` 有这条门,而 `updatedInput` 改写的是
603
- // **将被执行的工具实参**(core `runtask.js` 在哈希比对**之后**用 `outcome.updatedInput ?? pendingAction.args`)
604
- // ⇒ 同一份 JWT+MAC 对「原 body」与「加了 updatedInput 的 body」**都放行**,而 `boundInputHash` 仍是
605
- // 原动作的哈希。直连门的威胁模型就是「线上字节不可信」(否则不需要 HMAC),能改字节的一方
606
- // (反代 / 被劫持的客户端)可在操作员批准 `ls` 的同一次决定里换掉实参。
607
- // ⚠️ 已亲验的缓解:core 会把改写后的实参**重新过一遍 base tool policy**,所以越权面被限制在策略仍允许的
608
- // 范围内、不是任意命令 —— 但那是"减轻",不是"这个字段被签名覆盖了"。
609
- // 修法刻意**不是**把它塞进 HMAC 载荷(那要求所有直连门客户端改签名 = 跨仓 wire 变更),而是把**既有的、
610
- // 代码自己写下的**判据施加到它漏掉的那个字段上:fail-closed、server 单方面可做、与 `remember` 对称。
611
- // BFF/gated 门(头已由上游验过)**不受影响** —— 这条只在 `directDoorActive` 生效。
612
383
  if (deps.config.directDoorActive && body.updatedInput !== undefined) {
613
384
  sendError(res, 400, "updated_input_not_in_proof", "updatedInput is not supported on a direct-door worker (not covered by the decision proof)");
614
385
  return;
@@ -619,75 +390,44 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
619
390
  boundInputHash: body.boundInputHash,
620
391
  updatedInput: body.updatedInput,
621
392
  };
622
- // ── design/80 D-G: the authorization gate. On a DIRECT-MODE worker the trusted-header path is DISABLED —
623
- // every /decide MUST carry the crypto proof (signed principal JWT + cnf.bnd + HMAC envelope), and the
624
- // self-asserted x-agent-principal is NEVER consulted. On a normal (BFF) worker, the existing trusted-header
625
- // operator gate is byte-identical. Fail-closed: a direct-door failure rejects, never falls back to the header.
626
393
  let deciderPrincipal = principal;
627
394
  if (deps.config.directDoorActive) {
628
395
  const hdr = (n) => {
629
396
  const h = req.headers[n];
630
397
  return Array.isArray(h) ? h[0] : h;
631
398
  };
632
- const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) },
633
- // reason 入签(三方裁 (a) + clay 干净切):落账理由属于决定内容,与 decision 同受完整性保护。
634
- { sessionId, boundCallId: binding.boundCallId, boundInputHash: binding.boundInputHash, decision, reason: typeof body.reason === "string" ? body.reason : null }, deps.config);
399
+ const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) }, { sessionId, boundCallId: binding.boundCallId, boundInputHash: binding.boundInputHash, decision, reason: typeof body.reason === "string" ? body.reason : null }, deps.config);
635
400
  if (!proof.ok) {
636
401
  sendError(res, proof.status, proof.errorCode, proof.error);
637
402
  return;
638
403
  }
639
- deciderPrincipal = proof.principal; // the CRYPTO-VERIFIED principal replaces the self-asserted header
404
+ deciderPrincipal = proof.principal;
640
405
  }
641
- // Authorization (council finding): an EXPLICIT operator may decide ANY approval; otherwise the
642
- // caller must OWN the checkpoint's scope. 🔴 NOT the bare isOperator — its empty-OPERATOR_PRINCIPALS
643
- // "true-for-all" would let ANY authenticated caller decide ANOTHER user's checkpoint via a leaked, NON-
644
- // credential sessionId+boundInputHash (from a log/screenshot/ticket). A non-owner-non-operator gets 404
645
- // (no existence oracle — parity with runOwnerOk + the cancel P0 precedent), never 403.
646
406
  const operators = deps.config.operatorPrincipals;
647
- const explicitOperator = explicitOperatorOk(deciderPrincipal, operators); // E1: single mint point (principal-gate)
407
+ const explicitOperator = explicitOperatorOk(deciderPrincipal, operators);
648
408
  if (!explicitOperator) {
649
409
  const cpScope = await cs.peekPendingScope(sessionId);
650
- // "_" = the anonymous-submit scope SENTINEL (main.ts writes auth?.principal ?? "_" at suspend) — treat it
651
- // like null (open), as the sibling sites already do (the 5040/5599/5653 normalizations). Without this an
652
- // anonymous single-user durable worker DEADLOCKS its own approvals (decide → 404 forever; workflow audit
653
- // 2026-07-13, confirmed by both verify lenses).
654
410
  const decodedCpScope = cpScope != null ? decodeCheckpointScope(cpScope) : undefined;
655
- const ownsIt = cpScope == null || decodedCpScope === undefined || decodedCpScope === deciderPrincipal; // null/"_" = anonymous/dev (open)
411
+ const ownsIt = cpScope == null || decodedCpScope === undefined || decodedCpScope === deciderPrincipal;
656
412
  if (!ownsIt) {
657
413
  sendError(res, 404, "not_found.approval", "approval not found");
658
414
  return;
659
415
  }
660
416
  }
661
- // remember: capture the pending gate's toolName BEFORE the resume consumes the row. AskUserQuestion
662
- // gates are excluded (the durable-question policy owns them — an exemption row would be dead weight the
663
- // ask-gate probe explicitly skips). null toolName (legacy row) ⇒ nothing to remember.
664
417
  let rememberToolName = null;
665
418
  if (remember) {
666
- // [1.202 live 复验抓获] 旧姿势 `listPending(deciderPrincipal ?? "__none__")` 在匿名单用户 lane
667
- // (decider 无 principal、checkpoint scope="_" 哨兵)永远滤空 ⇒ remember 结构性失灵(rememberApplied
668
- // 恒 false,豁免从不落店)。授权已由上方 owner gate 把过(cpScope null/"_"/相符,同 5040/5599/5653
669
- // 的 "_" 归一先例)——这里直接读该 session 的 pending 行拿 toolName,不再按 decider 二次过滤。
670
419
  const tok = await cs.findPendingTokenBySession(sessionId);
671
420
  const cp0 = tok ? await cs.get(tok) : null;
672
421
  const pa0 = cp0?.pendingAction;
673
422
  const tn = pa0 && pa0.kind === "tool_approval" ? pa0.toolName : null;
674
423
  rememberToolName = tn && tn !== "AskUserQuestion" ? tn : null;
675
424
  }
676
- // grant timing(codex M2,1.294 isExempt 同腿生效):grant 挪进 onResumeCommitted——markResuming CAS
677
- // 赢了(decide 已被接受、并发 sibling 已输)之后、模型腿驱动**之前**落店,所以 resumed leg 自己的下一个
678
- // ask 就能命中豁免(gate isExempt + ask 政策层探针都实时读店);旧姿势 await 整条 resume 腿(可跑数分钟)
679
- // 之后才 grant,同腿第二次同工具 ask 必再 park 一轮。输掉 CAS(409)/stale(404)⇒ 钩不触发,绝不留
680
- // 豁免;CAS 赢后模型腿再失败 ⇒ 豁免保留(operator 的 approve+remember 意图在 accept 时已成立,与
681
- // 「grant 失败不回滚 approve」同向)。grant 失败只 warn 不阻断 resume,响应带 rememberApplied 如实。
682
425
  let rememberApplied = false;
683
- // overrideSessionId([1591] 候裁② server 修):parked 赎回腿把 grant 锚改到行的 root/host 会话
684
- // (部署侧豁免探针闭包统一捕获 host 会话键——落子代会话则键永不相交,rememberApplied 会是假话)。
685
- // legacy 任务腿不传 override,行为逐字不变。
686
426
  const grantOnCommit = remember && deps.approvalExemptionStore && rememberToolName
687
427
  ? async (overrideSessionId) => {
688
428
  const grantSessionId = overrideSessionId ?? sessionId;
689
429
  try {
690
- const canonical = rememberToolName; // RAW(5.0.0):行 toolName 由 core 铸,恒 canonical
430
+ const canonical = rememberToolName;
691
431
  await deps.approvalExemptionStore.grant(grantSessionId, canonical, deciderPrincipal ?? null);
692
432
  rememberApplied = true;
693
433
  deps.logger?.info?.("approval_exemption_granted", { sessionId: grantSessionId, toolName: canonical, grantedBy: deciderPrincipal ?? null });
@@ -697,12 +437,6 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
697
437
  }
698
438
  }
699
439
  : undefined;
700
- // #204 件6①:出处 = `"human"` —— 这条腿是运维**亲自**在 HTTP 上给的终局(approve/deny 两向都是)。
701
- // #316([4660] Inkglow 案 → [4664]① 定谳):末位 `true` = **200 受理语义**。这条腿此前在响应之前
702
- // 同步跑完整条 resume 流(模型往返、无服务端上限)⇒ 壳侧 60s AbortSignal 必然超时,而重试打回来
703
- // 又撞 CAS —— [4660] 雪崩的首环。改后 HTTP 立刻回 `{taskId, status:"resuming", bindingEnforced:true}`,
704
- // 结果面走既有 `GET /v1/runs/:id`(poll)/ `/events`(SSE)。**受理不是免检**:429/409/404 三类判定
705
- // 逐字仍在响应前给出(判据全文见 `DriveResumeArgs.acceptEarly`)。
706
440
  const out = await resumeCheckpoint(sessionId, decision, body.reason ?? undefined, "human", req, answer, binding, grantOnCommit, true);
707
441
  if (remember && deps.approvalExemptionStore) {
708
442
  sendJson(res, out.status, { ...out.body, rememberApplied });
@@ -714,154 +448,57 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
714
448
  sendError(res, 404, "not_found.route", "not found");
715
449
  return;
716
450
  }
717
- // [2354]/[2356] A-5(5.0.0):legacy F4 轮询腿(approvalStore list/by-id/decide,trusted-header 决裁)
718
- // 整体退役。无 checkpointStore 的部署打 /v1/approvals 一律落到本域尾部的未匹配路径(404 诚实缺席)。
719
451
  miss.fell = true;
720
452
  }
721
- /** Server-side poll cadence for the native-push approvals stream. The CLIENT subscribes ONCE and never polls;
722
- * the server diffs at this cadence (TiDB has no LISTEN/NOTIFY) and pushes deltas. 3s < the ~10s client poll it
723
- * replaces, so the operator sees a new suspend faster AND the portal loses its polling loop. (Each SSE
724
- * connection runs its OWN poll loop — fine for a handful of operators; a shared fan-out poll is a future
725
- * optimization if the operator count grows.) */
726
453
  const APPROVALS_STREAM_POLL_MS = 3000;
727
- /**
728
- * 🔴 #209 件4(codex 对抗复审 R3-[high],验真后修)—— **durable 读面的脱敏投影**。
729
- *
730
- * core 5.25.0 的 `RiskDescriptor.shadowedRule` = 被越级的那条持久规则的**原文**。core 侧只过
731
- * `inlineUntrusted`(中和 + 限长),**不**做秘密脱敏 —— 它的 JSDoc 也只承诺中和,不承诺无秘密
732
- * (与同结构里的 `summary` 不同:那一格 core 明写「NEVER raw secrets / full args / env」)。
733
- * 而规则原文是**人手写的文本**,一条 `Bash(curl -H "Authorization: Bearer …")` 形的规则完全拼得出来。
734
- *
735
- * 同步路(`tool_approval` 帧 / `card_json`)在铸点就 `redactSecrets` 了;durable 路两条读面
736
- * (`GET /v1/approvals` 与 `/v1/approvals/stream`)此前把 `listPending()` 的行**整只**上 wire ⇒
737
- * 同一份内容在两条腿上一条脱敏、一条不脱敏,而 operator 的队列是**跨租户**可见的那一条。
738
- *
739
- * 修在**读面**而不是落库点,理由是本仓成文的分工(同文件 inbox 段那条 `redactedPreview(s.toolInput)` 的
740
- * 逐字理由):「core bounds but does NOT redact it — redaction is the consumer's job」。落库点改写会与
741
- * core 的属主面打架,而且救不了**别的副本**已经写下的行。
742
- *
743
- * 🔴 只动这一格,不顺手洗整只 descriptor:`summary` 有 core 的无秘密承诺、`touchedPaths` 是路径,
744
- * 两者的现行为不在本批辖域内(要改得走各自的三问)。键集**不变** —— 「整只透传、不按键投影」那条契约
745
- * (`test/approval-shadowed-rule-wire.test.ts` §3)照旧成立,变的只是这一格的**内容**。
746
- */
747
454
  function redactPendingDisclosures(row) {
748
455
  const rd = row.riskDescriptor;
749
456
  if (rd === null || rd === undefined || typeof rd.shadowedRule !== "string")
750
457
  return row;
751
458
  return { ...row, riskDescriptor: { ...rd, shadowedRule: redactSecrets(rd.shadowedRule) } };
752
459
  }
753
- /**
754
- * 🔴 [3683]-2 / [3684]②:两条 durable 读面(`GET /v1/approvals` + `/v1/approvals/stream`)的**唯一**
755
- * 投影 —— 脱敏(#209 件4)+ 出身归因(#220 的另一半)一次做完。
756
- *
757
- * 出身格的病灶如实记:`governanceForced` 此前只在 409 体上算(`buildActiveRunConflict`),而运维队列
758
- * 的行不算 —— 同一道 park 门,客户端从 409 看得见「这是运维治理层下的」,从队列看不见。它**不是**一个
759
- * 落库缺口:判据的两半(行上 `riskDescriptor.shellGateDoctrine` + 本部署的治理姿态)在这条读面上都够得着,
760
- * 缺的只是**没有人算**。所以补法 = 让两面共用 `governanceOriginOf` 这一个属主(在读面算的已知失真、
761
- * 以及「根治要 core 在 mint 写进行里」的登记,逐字在 `runtime-governance.ts` 那个函数的顶注,不复述)。
762
- *
763
- * 🔴 `true` 才写键,恒不写 `false` —— 与活卡帧 / 409 体 / `card_json` 同一条纪律:缺席 = 「没有治理来源
764
- * 的证据」,不是「这不是治理门」。
765
- *
766
- * ⚠️ **`delegation` 刻意不在这里**(同批如实登记,禁编造键):park 行上**没有**这条信息 —— core 的
767
- * `Checkpoint` / `PendingAction` / `CheckpointGate` 三个面都不带 `{parentToolCallId, depth, agentName}`
768
- * (`Checkpoint.state.delegationProvenance` 是 design/180 的内容安全聚合 `{version, coverageStartTurn,
769
- * sawExternal, incomplete}`,与 ask 的出处链是两件事)。同步腿的这一格来自 `AskRequest.delegation`,
770
- * 那条路 park 时不随行。⇒ 无供给,诚实缺席;要它得先有 core 侧的属主面。
771
- */
772
460
  function projectPendingForWire(row, governance) {
773
461
  const redacted = redactPendingDisclosures(row);
774
462
  const governanceForced = governanceOriginOf({ riskDescriptor: row.riskDescriptor ?? undefined }, governance);
775
463
  return governanceForced ? { ...redacted, governanceForced } : redacted;
776
464
  }
777
- /**
778
- * GET /v1/approvals/stream (design/80 native push): SSE — pushes pending-approval deltas so the portal
779
- * SUBSCRIBES ONCE instead of polling GET /v1/approvals every ~10s (better UX: near-real-time + no client poll
780
- * loop). Polls listPending(scope) and diffs against the prior snapshot: `pending` for a new suspend, `resolved`
781
- * for a decided/expired/gone one. Cross-replica BY CONSTRUCTION — the poll reads the SHARED checkpoint table,
782
- * so a suspend on ANY replica is seen by an operator streaming on a DIFFERENT replica. A transient DB blip
783
- * heartbeats + retries (never kills the stream); a 15-min cap + req-close end it (parity with streamTaskTrace).
784
- * Poll-granularity caveat (**narrowed** by [3684]②): the delta now re-emits a `pending` frame whenever a key's
785
- * WIRE PROJECTION changes (upsert), not only when the key enters — so a same-key re-suspend within one interval
786
- * IS visible as long as anything on the row differs. What still shows no delta is the strictly degenerate case:
787
- * a resolve + re-suspend on the same (session,toolCallId) whose projection is BYTE-IDENTICAL — indistinguishable
788
- * by construction, and harmless (the card the operator sees is the card that is pending). Pins: the two halves of
789
- * `test/wire-pairing-approvals-stream.test.ts` S3b (changed ⇒ re-emit with the NEW payload; unchanged ⇒ silence).
790
- *
791
- * A-002.19(2026-08-09 亲验定性=设计接受,非欠账):每连接自跑 pollMs 轮询打共享 checkpoint 表,
792
- * 之所以不建 fan-out/单轮询器基建——①消费方=portal 运营面,并发连接数量级为个位(不是用户面);
793
- * ②listPending 走 (scope,status) 索引,单查成本与一次 /v1/approvals 轮询同阶,而本路正是替代客户端
794
- * ~10s 轮询的(净负载持平或更低);③DB blip 已 fail-soft(心跳+下拍重试),15-min cap 兜顶。连接
795
- * 数假设若被打破(portal 多开成常态),届时把 poll 收敛为进程内单轮询器多路复用——那是量级触发的
796
- * 演化,不是现在的缺陷。
797
- */
798
- export async function streamApprovals(req, res, cs, scope,
799
- /** [3684]②:出身归因的部署侧一半(缺席 ⇒ 归不出治理出身 ⇒ 键缺席,与 best-effort 同方向)。**位置在
800
- * `pollMs` 之前**是刻意的:`pollMs` 只有测试传,而本参数是每个生产调用点都必须给的那一个。 */
801
- governance, pollMs = APPROVALS_STREAM_POLL_MS) {
802
- // JSON-encode the (sessionId, toolCallId) pair so distinct pendings can NEVER collide into one Map key,
803
- // regardless of what a caller-supplied sessionId contains (any single-char delimiter — space OR even NUL — is
804
- // injectable by an adversarial id, masking/wrong-removing an approval card; adversarial finding). JSON escaping
805
- // makes every distinct (sessionId, toolCallId) pair a distinct key.
465
+ export async function streamApprovals(req, res, cs, scope, governance, pollMs = APPROVALS_STREAM_POLL_MS) {
806
466
  const keyOf = (p) => JSON.stringify([p.sessionId, p.toolCallId ?? null]);
807
467
  sseHeaders(res);
808
- // Each data payload ALSO carries a `type` field mirroring the SSE event name — so a client can dispatch on
809
- // `data.type` without relying on the SSE `event:` line (some proxies strip event names; `onmessage` ignores
810
- // named events). The SSE event name stays for native EventSource addEventListener.
811
468
  res.write(`event: meta\ndata: ${JSON.stringify({ type: "meta", version: 1, mode: "approvals-delta", pollMs: APPROVALS_STREAM_POLL_MS })}\n\n`);
812
469
  let closed = false;
813
- // design/245 件1:req+res 双挂 + 一次性闸(理由全文在 `sse-lifecycle.ts` 顶注)。本腿每连接自跑一条
814
- // 打共享 checkpoint 表的轮询(见上方 A-002.19 的定性),旗翻不过来的代价就是这条 DB 轮询对着死
815
- // socket 跑满 15 分钟 —— 断连清理在本腿是**负载**问题,不只是收摊问题。
816
470
  bindSseLifecycle(req, res, () => { closed = true; });
817
471
  const start = Date.now();
818
472
  let lastBeat = Date.now();
819
473
  let prev = new Map();
820
- /**
821
- * 🔴 [3684]② / codex 对抗复审 [medium](验真后修):**上一拍每行的 wire 投影字节**。
822
- *
823
- * 旧 delta 只判「键进/键出」——对一条内容**不变**的 park 行那是对的,而 `governanceForced` 是**派生位**,
824
- * 它随 `config.autonomy`(config-center 的 `apply-effective` 就地热改)在**同一条行还挂着**的时候翻面。
825
- * 只判键的话,常驻订阅的 portal 会一直渲着旧徽标(最长到 15 分钟 cap 重连),而同一时刻
826
- * `GET /v1/approvals` 已经给出另一个答案 —— 两条本该同源的读面当场分家。
827
- *
828
- * ⇒ 判据改成**投影字节变了就重发**。`pending` 帧对同一 (sessionId, toolCallId) 键因此是 **upsert**
829
- * 语义(消费端本来就按这个键建表);重复发一帧是无害的(幂等覆盖),漏发一帧是错的徽标。
830
- */
831
474
  let prevFrames = new Map();
832
475
  let first = true;
833
476
  while (!closed) {
834
477
  let pending;
835
478
  try {
836
- // #209 件4 + [3684]②:与 `GET /v1/approvals` **同一个**投影函数(脱敏 + 出身归因)—— 两条 durable
837
- // 读面各投各的就会漂(SSE 那条恰恰是 operator 常驻订阅的那条,漏掉它等于没修)。
838
479
  pending = (await cs.listPending(scope)).map((r) => projectPendingForWire(r, governance));
839
480
  }
840
481
  catch {
841
- // a transient TiDB blip must NOT kill the subscription — heartbeat + retry next tick (fail-soft)
842
482
  if (!res.writableEnded)
843
483
  res.write(`event: heartbeat\ndata: ${JSON.stringify({ type: "heartbeat" })}\n\n`);
844
484
  await sleep(pollMs);
845
485
  continue;
846
486
  }
847
487
  const cur = new Map(pending.map((p) => [keyOf(p), p]));
848
- // 一行只序列化一次:既是要发的帧体,也是与上一拍比对的判据(两处各算一遍必然漂)。
849
488
  const curFrames = new Map([...cur].map(([k, p]) => [k, JSON.stringify({ type: "pending", ...p })]));
850
489
  if (first) {
851
- // initial snapshot so a freshly-subscribed portal renders the current queue immediately (no first-poll gap)
852
490
  for (const frame of curFrames.values())
853
491
  res.write(`event: pending\ndata: ${frame}\n\n`);
854
492
  res.write(`event: synced\ndata: ${JSON.stringify({ type: "synced", count: pending.length })}\n\n`);
855
493
  first = false;
856
494
  }
857
495
  else {
858
- // 新 park **或**同一行的投影内容变了(见 `prevFrames` 顶注:派生位会在行不变时翻面)⇒ upsert 一帧
859
496
  for (const [k, frame] of curFrames)
860
497
  if (prevFrames.get(k) !== frame)
861
498
  res.write(`event: pending\ndata: ${frame}\n\n`);
862
499
  for (const [k, p] of prev)
863
500
  if (!cur.has(k))
864
- res.write(`event: resolved\ndata: ${JSON.stringify({ type: "resolved", sessionId: p.sessionId, toolCallId: p.toolCallId })}\n\n`); // decided/expired/gone
501
+ res.write(`event: resolved\ndata: ${JSON.stringify({ type: "resolved", sessionId: p.sessionId, toolCallId: p.toolCallId })}\n\n`);
865
502
  }
866
503
  prev = cur;
867
504
  prevFrames = curFrames;