@desplega.ai/agent-swarm 1.164.1 → 1.166.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 (328) hide show
  1. package/README.md +1 -1
  2. package/dist/acp-adapter-yzfxn8vj.js +23 -0
  3. package/dist/{actions-hp2h32dx.js → actions-qz3m692w.js} +24 -28
  4. package/dist/{amp-adapter-zfd7djyx.js → amp-adapter-j1xbmsqz.js} +7 -7
  5. package/dist/{app-w2d26h4f.js → app-43vrdmv8.js} +9 -10
  6. package/dist/{assistant-45pc4en7.js → assistant-ertffmka.js} +25 -29
  7. package/dist/{boot-reembed-k1q2s4fc.js → boot-reembed-998jtpdg.js} +12 -15
  8. package/dist/{boot-reembed-6rhcpsjf.js → boot-reembed-s7k7c6v2.js} +13 -15
  9. package/dist/boot-scrub-sweep-nepsk4t1.js +211 -0
  10. package/dist/{boot-seeds-ex93edx7.js → boot-seeds-vvzs1zhk.js} +12 -12
  11. package/dist/{claude-adapter-hcxd0szm.js → claude-adapter-2zvmekrp.js} +15 -17
  12. package/dist/{claude-managed-adapter-fz7xps2r.js → claude-managed-adapter-s49wbk8t.js} +5 -5
  13. package/dist/{claude-managed-setup-xx6f15h9.js → claude-managed-setup-cvhqq8qn.js} +4 -4
  14. package/dist/{claude-sdk-session-6nb9raem.js → claude-sdk-session-kkbxtw5q.js} +2624 -2606
  15. package/dist/{cli-etbrt8x1.js → cli-0nhdwxd5.js} +4 -4
  16. package/dist/{cli-jeefgppq.js → cli-0qjcsxgz.js} +7 -7
  17. package/dist/{cli-6behs2bt.js → cli-32bca7nr.js} +1 -1
  18. package/dist/{cli-gt8v96qx.js → cli-3ddybbbx.js} +4 -4
  19. package/dist/{acp-adapter-rxvek4ym.js → cli-3qn4r334.js} +317 -35
  20. package/dist/{cli-1d3w4xm3.js → cli-4r1sjfc4.js} +14 -6
  21. package/dist/{cli-691f2rxm.js → cli-66zh26sp.js} +68 -16
  22. package/dist/{cli-et8wdyqh.js → cli-68r1j4sg.js} +9 -9
  23. package/dist/{cli-xb7kyanp.js → cli-8kgvmkak.js} +3 -3
  24. package/dist/{cli-fdpkw8vk.js → cli-8t229cmr.js} +8 -8
  25. package/dist/{cli-gc54qc0p.js → cli-aqcey741.js} +1 -1
  26. package/dist/{cli-4q4cmr4t.js → cli-cpfmqz3n.js} +8 -8
  27. package/dist/{cli-a9bw0xxt.js → cli-df56tqdw.js} +997 -408
  28. package/dist/{cli-n7h1kqpf.js → cli-dmtrxqhg.js} +6 -1
  29. package/dist/{cli-xfb7333f.js → cli-dw2q9f7g.js} +5 -5
  30. package/dist/{cli-4drstbb9.js → cli-ecknp7p1.js} +81 -23
  31. package/dist/{cli-v0pvvhfq.js → cli-efnd2kce.js} +36 -1
  32. package/dist/{cli-1wetf34q.js → cli-f2n17zhp.js} +1 -1
  33. package/dist/{cli-2c8eky5y.js → cli-feh3dkah.js} +1 -1
  34. package/dist/cli-fn697bnt.js +126 -0
  35. package/dist/{cli-v5pr2fgt.js → cli-gt8azyet.js} +3 -3
  36. package/dist/{cli-dbszf3rw.js → cli-h3x613ze.js} +4 -4
  37. package/dist/{cli-nwhb8y2e.js → cli-hf5s9pgf.js} +1 -1
  38. package/dist/cli-j2jbr8af.js +516 -0
  39. package/dist/{cli-agjzafns.js → cli-jhm6hzjn.js} +593 -10
  40. package/dist/{cli-j0tmc908.js → cli-jvj6a0cv.js} +484 -477
  41. package/dist/{cli-xxz89c2n.js → cli-mb75wpxf.js} +1 -1
  42. package/dist/{cli-07s39g1n.js → cli-mv2520nm.js} +1 -1
  43. package/dist/{cli-v90vfwyb.js → cli-mwegs4af.js} +5 -5
  44. package/dist/{cli-bz1gbac9.js → cli-p4ztb80h.js} +2 -2
  45. package/dist/{cli-1wd1n2ya.js → cli-rfphkzh5.js} +1 -1
  46. package/dist/{cli-976r1zqx.js → cli-rggdq8wq.js} +5 -5
  47. package/dist/{cli-3ajw5v5v.js → cli-rsy933yz.js} +6 -6
  48. package/dist/{cli-yj790e19.js → cli-tadfgd0t.js} +8 -1
  49. package/dist/{cli-cn53e7vc.js → cli-ttbsse3f.js} +6 -1
  50. package/dist/{cli-dyqsrqcf.js → cli-v2a8x45d.js} +4 -4
  51. package/dist/{cli-wbvnn6tk.js → cli-v6vv7dy8.js} +91 -9
  52. package/dist/{cli-z2ap2c0c.js → cli-vzc73wet.js} +90 -55
  53. package/dist/cli-wt7ccy4r.js +2654 -0
  54. package/dist/{cli-4cn19jvn.js → cli-x7kpr72a.js} +3 -3
  55. package/dist/{cli-003785v9.js → cli-xvc3q9br.js} +1 -1
  56. package/dist/{cli-v0e7jpr5.js → cli-xvdgasgy.js} +13 -4
  57. package/dist/{cli-asqeg4ve.js → cli-y59h2pk9.js} +2 -2
  58. package/dist/{cli-0wn3ypqv.js → cli-yshaazs1.js} +14121 -3478
  59. package/dist/{cli-wg8yjqtw.js → cli-ysnyhws9.js} +16 -3
  60. package/dist/cli-zp67qg5x.js +92 -0
  61. package/dist/{cli-g3qgrzck.js → cli-zsh093hk.js} +71 -3
  62. package/dist/{cli-ma3zvh39.js → cli-zy7vwsne.js} +6066 -5829
  63. package/dist/cli.js +79 -26
  64. package/dist/{codex-adapter-pcxdn10v.js → codex-adapter-dw1wxqt8.js} +10 -10
  65. package/dist/{codex-hook-b1fb7c2k.js → codex-hook-d2rkvf39.js} +1 -1
  66. package/dist/{codex-login-059nm145.js → codex-login-terbdsqq.js} +2 -2
  67. package/dist/{codex-session-runner-xzwwddp8.js → codex-session-runner-3cgb8y1g.js} +10 -10
  68. package/dist/{commands-98at8mkn.js → commands-mhxam6z2.js} +9 -9
  69. package/dist/{cursor-adapter-dkg6qnkp.js → cursor-adapter-2qda3st7.js} +6 -6
  70. package/dist/{db-01g8wtvw.js → db-sjks418k.js} +11 -9
  71. package/dist/{devin-adapter-qmttt2fz.js → devin-adapter-bffjj5n4.js} +2 -2
  72. package/dist/{dispatcher-nn2k1wxd.js → dispatcher-1es759rn.js} +22 -26
  73. package/dist/{dsh-adapter-w4d4vwdq.js → dsh-adapter-bek4cyw9.js} +6 -6
  74. package/dist/{e2b-9e4g2hvt.js → e2b-nd50eccg.js} +1 -1
  75. package/dist/{event-bus-z7v2t26e.js → event-bus-vnfthafg.js} +2 -2
  76. package/dist/grok-adapter-ar9cw9jg.js +29 -0
  77. package/dist/{handlers-b0kqsbg8.js → handlers-1gkbhktr.js} +25 -29
  78. package/dist/{hook-sap7v1aj.js → hook-ds2zxq9v.js} +16 -18
  79. package/dist/{hook-pz50gqep.js → hook-vxa3tcsy.js} +10 -12
  80. package/dist/{http-ncr8rmd0.js → http-3h21z9md.js} +679 -227
  81. package/dist/{index-c4aqcyz1.js → index-9b65j1m5.js} +11 -11
  82. package/dist/{index-r46s01nq.js → index-bn6cy8ps.js} +22 -26
  83. package/dist/{index-38kqdt3e.js → index-dkfxpkmm.js} +23 -27
  84. package/dist/{index-630kj5vd.js → index-f8xnqrye.js} +55 -43
  85. package/dist/{index-fdk1t4d6.js → index-n0rxa4ca.js} +11962 -9900
  86. package/dist/{index-9nrj7797.js → index-n99fycq7.js} +22 -26
  87. package/dist/{keepalive-31613r2h.js → keepalive-3xspa9jw.js} +14 -11
  88. package/dist/{lead-mx59tr5s.js → lead-6qphc124.js} +43 -43
  89. package/dist/{maintenance-55ff39ca.js → maintenance-0teem1bb.js} +10 -12
  90. package/dist/{node-f0ykebbv.js → node-9sg7ajby.js} +25 -7
  91. package/dist/{oauth-refresh-sweep-anma954w.js → oauth-refresh-sweep-sh9q25xp.js} +13 -10
  92. package/dist/{onboard-b2fy8ftb.js → onboard-54qmnqxe.js} +4 -3
  93. package/dist/{opencode-adapter-p1s92tmy.js → opencode-adapter-wjfa2wvd.js} +6 -6
  94. package/dist/{otel-impl-9brvt7xv.js → otel-impl-ycqzp2a1.js} +2 -2
  95. package/dist/{pi-mono-adapter-kz4rrwvd.js → pi-mono-adapter-e77e9zst.js} +6 -6
  96. package/dist/{pricing-refresh-7w8mbmva.js → pricing-refresh-atnkngqs.js} +10 -10
  97. package/dist/{rbac-roles-ejp7qwvb.js → rbac-roles-9pjphh9q.js} +10 -10
  98. package/dist/{rbac-roles-nhk4r7bc.js → rbac-roles-j78wdksm.js} +10 -10
  99. package/dist/{render-v2-2gzt8skq.js → render-v2-pxjs49fx.js} +9 -10
  100. package/dist/{setup-5et8t412.js → setup-ymej0xtx.js} +2 -2
  101. package/dist/{work-objects-tb0er7qa.js → work-objects-3nh4ymzb.js} +26 -30
  102. package/dist/{worker-62cv075d.js → worker-qa9fkz5s.js} +43 -43
  103. package/dist/{x-vbxb31jr.js → x-ma4gnfgy.js} +2 -2
  104. package/openapi.json +457 -31
  105. package/package.json +14 -6
  106. package/plugin/commands/create-pr.md +7 -1
  107. package/plugin/pi-skills/create-pr/SKILL.md +7 -1
  108. package/src/agentmail/app.ts +5 -4
  109. package/src/be/automation-preflight.ts +11 -9
  110. package/src/be/boot-scrub-sweep.ts +302 -0
  111. package/src/be/connector-codes.ts +81 -0
  112. package/src/be/crypto/key-bootstrap.ts +1 -1
  113. package/src/be/db/approvals.ts +15 -1
  114. package/src/be/db/codex-oauth-device-flows.ts +40 -0
  115. package/src/be/db/config-secrets.ts +31 -0
  116. package/src/be/db/runtime.ts +4 -0
  117. package/src/be/db/script-runs.ts +15 -0
  118. package/src/be/db/tasks/write.ts +1 -1
  119. package/src/be/db-queries/mcp-oauth.ts +5 -0
  120. package/src/be/db-queries/oauth.ts +77 -0
  121. package/src/be/db.ts +162 -59
  122. package/src/be/events.ts +17 -1
  123. package/src/be/memory/index-content.ts +2 -2
  124. package/src/be/memory/providers/sqlite-store.ts +63 -11
  125. package/src/be/memory/types.ts +14 -0
  126. package/src/be/migrations/197_connector_codes.sql +23 -0
  127. package/src/be/migrations/198_permission_audit_guest.sql +33 -0
  128. package/src/be/migrations/200_backfill_auto_memory_expiry.sql +20 -0
  129. package/src/be/migrations/201_workflow_replay_columns.sql +10 -0
  130. package/src/be/migrations/202_codex_oauth_device_flows.sql +24 -0
  131. package/src/be/model-tier-resolution.ts +2 -1
  132. package/src/be/model-validation.ts +1 -1
  133. package/src/be/pricing-normalize.ts +3 -0
  134. package/src/be/rbac-audit.ts +2 -1
  135. package/src/be/scripts/db.ts +28 -4
  136. package/src/be/scripts/source-secrets.ts +37 -0
  137. package/src/be/scripts/typecheck.ts +23 -2
  138. package/src/be/scrub-json.ts +32 -0
  139. package/src/be/sealed-json.ts +59 -0
  140. package/src/be/secret-registry.ts +131 -0
  141. package/src/be/seed-pricing.ts +14 -0
  142. package/src/be/users.ts +7 -1
  143. package/src/be/workflow-replay.ts +77 -0
  144. package/src/cli.tsx +7 -0
  145. package/src/commands/provider-credentials.ts +46 -8
  146. package/src/commands/runner.ts +2 -10
  147. package/src/extensions/contract-types.generated.ts +1 -1
  148. package/src/github/app.ts +4 -4
  149. package/src/heartbeat/heartbeat.ts +20 -8
  150. package/src/hooks/pr-body-guard.ts +1 -1
  151. package/src/http/agents.ts +1 -0
  152. package/src/http/approval-requests.ts +1 -0
  153. package/src/http/apps.ts +3 -0
  154. package/src/http/auth.ts +4 -1
  155. package/src/http/codex-oauth-device.ts +8 -10
  156. package/src/http/codex-oauth-keep-warm.ts +2 -2
  157. package/src/http/config-values.ts +59 -0
  158. package/src/http/config.ts +5 -0
  159. package/src/http/db-query-bounded.ts +3 -0
  160. package/src/http/db-query-shared.ts +107 -15
  161. package/src/http/db-query.ts +10 -2
  162. package/src/http/extensions.ts +1 -0
  163. package/src/http/index.ts +23 -6
  164. package/src/http/integrations.ts +1 -38
  165. package/src/http/ip-rate-limit.ts +82 -0
  166. package/src/http/kv.ts +38 -4
  167. package/src/http/mcp-servers.ts +1 -0
  168. package/src/http/memory.ts +9 -0
  169. package/src/http/models-catalog.ts +2 -0
  170. package/src/http/operator-actor.ts +4 -0
  171. package/src/http/page-proxy.ts +22 -0
  172. package/src/http/pages.ts +4 -2
  173. package/src/http/realtime.ts +1 -1
  174. package/src/http/request-principal.ts +14 -1
  175. package/src/http/script-connections.ts +8 -0
  176. package/src/http/script-runs.ts +12 -4
  177. package/src/http/scripts.ts +15 -0
  178. package/src/http/session-cost-recompute.ts +3 -2
  179. package/src/http/tasks.ts +9 -0
  180. package/src/http/users.ts +226 -0
  181. package/src/http/workflows.ts +3 -2
  182. package/src/http.ts +1 -0
  183. package/src/integrations/kapso/client.ts +3 -1
  184. package/src/jira/app.ts +2 -2
  185. package/src/kv-view.ts +324 -0
  186. package/src/linear/app.ts +2 -2
  187. package/src/oauth/mcp-wrapper.ts +1 -1
  188. package/src/prompts/memories.ts +42 -1
  189. package/src/providers/acp-adapter.ts +85 -24
  190. package/src/providers/acp-target-catalog.ts +54 -1
  191. package/src/providers/acp-targets.ts +358 -4
  192. package/src/providers/amp-adapter.ts +1 -1
  193. package/src/providers/cursor-adapter.ts +1 -1
  194. package/src/providers/grok-adapter.ts +186 -0
  195. package/src/providers/index.ts +5 -1
  196. package/src/providers/pricing-sources.md +6 -0
  197. package/src/providers/reasoning-effort.ts +17 -1
  198. package/src/providers/types.ts +2 -1
  199. package/src/rbac/can.ts +4 -3
  200. package/src/rbac/legacy-policy.ts +3 -3
  201. package/src/rbac/types.ts +6 -0
  202. package/src/script-workflows/supervisor.ts +11 -2
  203. package/src/scripts-runtime/eval-harness.ts +2 -12
  204. package/src/scripts-runtime/executors/quickjs-bundle.ts +152 -0
  205. package/src/scripts-runtime/executors/quickjs-prelude.ts +285 -0
  206. package/src/scripts-runtime/executors/quickjs-runner.ts +580 -0
  207. package/src/scripts-runtime/executors/quickjs-worker.ts +81 -0
  208. package/src/scripts-runtime/executors/quickjs.ts +196 -0
  209. package/src/scripts-runtime/executors/registry.ts +2 -0
  210. package/src/scripts-runtime/executors/sourcemap.ts +79 -0
  211. package/src/scripts-runtime/script-error-hints.ts +14 -0
  212. package/src/scripts-runtime/swarm-sdk.ts +9 -2
  213. package/src/scripts-runtime/types/stdlib.d.ts +27 -8
  214. package/src/scripts-runtime/types/swarm-extension.d.ts +3 -0
  215. package/src/scripts-runtime/types/swarm-sdk.d.ts +27 -8
  216. package/src/server-user.ts +2 -20
  217. package/src/server.ts +3 -1
  218. package/src/slack/app.ts +4 -0
  219. package/src/slack/config.ts +3 -1
  220. package/src/slack/egress-scrub.ts +87 -0
  221. package/src/tests/acp-adapter.test.ts +191 -2
  222. package/src/tests/boot-scrub-sweep.test.ts +390 -0
  223. package/src/tests/budget-user-scope.test.ts +7 -21
  224. package/src/tests/check-pr-body.test.ts +149 -6
  225. package/src/tests/check-text-columns.test.ts +145 -0
  226. package/src/tests/claude-adapter-binary.test.ts +1 -1
  227. package/src/tests/claude-sdk-process.fixture.ts +13 -2
  228. package/src/tests/codex-oauth-device.test.ts +23 -37
  229. package/src/tests/codex-oauth-keep-warm.test.ts +29 -0
  230. package/src/tests/console-scrub.test.ts +141 -0
  231. package/src/tests/credential-check.test.ts +90 -0
  232. package/src/tests/db-query-denylist.test.ts +202 -0
  233. package/src/tests/db-task-read-contract.test.ts +1 -0
  234. package/src/tests/db-task-write-contract.test.ts +1 -0
  235. package/src/tests/docs-index-footprint.test.js +3 -3
  236. package/src/tests/entrypoint-acp-target-binary.test.ts +44 -1
  237. package/src/tests/events-scrub.test.ts +88 -0
  238. package/src/tests/explicit-model-validation.test.ts +81 -0
  239. package/src/tests/extensions-pre-task.test.ts +33 -1
  240. package/src/tests/fixtures/grok/acp-updates-recorded.jsonl +53 -0
  241. package/src/tests/fixtures/grok/prompt-response-meta.json +36 -0
  242. package/src/tests/fixtures/secret-corpus.ts +320 -0
  243. package/src/tests/grok-adapter.test.ts +647 -0
  244. package/src/tests/heartbeat-checklist.test.ts +44 -0
  245. package/src/tests/integration-disable-flags.test.ts +156 -0
  246. package/src/tests/kv-http.test.ts +34 -0
  247. package/src/tests/kv-tool.test.ts +274 -1
  248. package/src/tests/kv-view.test.ts +147 -0
  249. package/src/tests/mcp-user-route.test.ts +257 -15
  250. package/src/tests/memory-injection-outcome.test.ts +143 -0
  251. package/src/tests/model-tier-resolution.test.ts +76 -3
  252. package/src/tests/page-guest-session.test.ts +424 -0
  253. package/src/tests/page-proxy.test.ts +1 -1
  254. package/src/tests/page-viewer-auth.test.ts +22 -1
  255. package/src/tests/pr-body-leaks.test.ts +157 -2
  256. package/src/tests/preload.ts +10 -0
  257. package/src/tests/provider-registration.test.ts +3 -1
  258. package/src/tests/rbac-audit.test.ts +32 -0
  259. package/src/tests/rbac-mcp-admission-e2e.test.ts +4 -62
  260. package/src/tests/script-connections.test.ts +9 -0
  261. package/src/tests/script-executor-conformance.test.ts +3 -0
  262. package/src/tests/script-executor-quickjs.test.ts +442 -0
  263. package/src/tests/script-executor-registry.test.ts +6 -1
  264. package/src/tests/script-runs-http.test.ts +126 -1
  265. package/src/tests/script-runs-scrub.test.ts +275 -0
  266. package/src/tests/scripts-http.test.ts +82 -1
  267. package/src/tests/scripts-mcp-e2e.test.ts +42 -6
  268. package/src/tests/scripts-runtime.test.ts +37 -0
  269. package/src/tests/scrubbed-text-12e.test.ts +289 -0
  270. package/src/tests/sealed-key-guard.test.ts +166 -0
  271. package/src/tests/secret-canary-sweep.test.ts +958 -0
  272. package/src/tests/secret-registry.test.ts +355 -0
  273. package/src/tests/secret-rules.test.ts +230 -0
  274. package/src/tests/secret-scrubber.test.ts +317 -1
  275. package/src/tests/session-costs-golden.test.ts +17 -0
  276. package/src/tests/slack-egress-scrub.test.ts +216 -0
  277. package/src/tests/swarm-tool-result-gate.test.ts +143 -30
  278. package/src/tests/task-tools-ctx.test.ts +3 -0
  279. package/src/tests/user-token-routes.test.ts +342 -3
  280. package/src/tests/workflow-replay.test.ts +518 -0
  281. package/src/tests/workflow-scrub.test.ts +242 -0
  282. package/src/tests/workflow-system-one-decision-providers.test.ts +737 -0
  283. package/src/tools/db-query.ts +1 -1
  284. package/src/tools/inject-learning.ts +1 -1
  285. package/src/tools/kv/kv-get.ts +136 -6
  286. package/src/tools/send-task.ts +61 -17
  287. package/src/tools/utils.ts +186 -43
  288. package/src/tools/workflows/get-workflow-run.ts +3 -2
  289. package/src/types.ts +16 -1
  290. package/src/utils/console-scrub-install.ts +5 -0
  291. package/src/utils/console-scrub.ts +57 -0
  292. package/src/utils/credentials.ts +8 -0
  293. package/src/utils/page-session.ts +6 -2
  294. package/src/utils/pr-body-leaks.ts +149 -10
  295. package/src/utils/request-auth-context.ts +2 -0
  296. package/src/utils/secret-rules.generated.ts +2055 -0
  297. package/src/utils/secret-scrubber.ts +662 -36
  298. package/src/workflows/engine.ts +39 -4
  299. package/src/workflows/executors/notify.ts +6 -1
  300. package/src/workflows/executors/system-one-decision.ts +31 -12
  301. package/src/workflows/executors/system-one-openai-wire.ts +168 -0
  302. package/src/workflows/executors/system-one-providers.ts +112 -11
  303. package/src/workflows/executors/system-one-wire.ts +79 -0
  304. package/src/workflows/recovery.ts +10 -1
  305. package/src/workflows/resume.ts +62 -3
  306. package/src/workflows/retry-poller.ts +3 -0
  307. package/templates/ai-toolbox.manifest.json +2 -2
  308. package/templates/skills/agent-browser/SKILL.md +5 -5
  309. package/templates/skills/agent-browser/config.json +1 -1
  310. package/templates/skills/agent-browser/content.md +4 -4
  311. package/templates/skills/artifacts/content.md +17 -7
  312. package/templates/skills/jira-interaction/config.json +1 -1
  313. package/templates/skills/jira-interaction/content.md +56 -205
  314. package/templates/skills/linear-interaction/config.json +1 -1
  315. package/templates/skills/linear-interaction/content.md +31 -50
  316. package/templates/skills/pages/content.md +8 -4
  317. package/templates/skills/qa/content.md +2 -2
  318. package/templates/skills/workflow-iterate/content.md +4 -4
  319. package/tsconfig.json +3 -3
  320. package/dist/boot-scrub-logs-t8xg7wd7.js +0 -99
  321. package/dist/cli-0a7r5331.js +0 -168
  322. package/dist/cli-93rxw9yd.js +0 -951
  323. package/dist/cli-cvk6yzw5.js +0 -40
  324. package/dist/cli-dgnj0zcc.js +0 -40
  325. package/dist/cli-qt5wzq6v.js +0 -256
  326. package/dist/cli-v7fqehqd.js +0 -9281
  327. package/dist/cli-vbr859rz.js +0 -572
  328. package/src/be/boot-scrub-logs.ts +0 -132
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, spyOn, test } from "bun:test";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
@@ -22,7 +22,12 @@ import {
22
22
  sanitizeAcpConfigOptions,
23
23
  toAcpMcpServers,
24
24
  } from "../providers/acp-adapter";
25
- import { AcpTargetResolutionError, resolveAcpTarget } from "../providers/acp-targets";
25
+ import {
26
+ AcpTargetResolutionError,
27
+ COPILOT_INSTRUCTIONS_FILE,
28
+ copilotInstructionsDir,
29
+ resolveAcpTarget,
30
+ } from "../providers/acp-targets";
26
31
  import type { ProviderEvent, ProviderSessionConfig } from "../providers/types";
27
32
  import { listenOnFreePort } from "./test-net";
28
33
 
@@ -536,6 +541,190 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
536
541
  });
537
542
  });
538
543
 
544
+ test("Gemini preset supplies command, credentials, trust, and a model environment fallback", () => {
545
+ const config = baseConfig({
546
+ model: "gemini-3-flash-preview",
547
+ env: {
548
+ PATH: "/bin",
549
+ HOME: "/home/test",
550
+ ACP_TARGET: "gemini",
551
+ GEMINI_API_KEY: "example-test-key",
552
+ GOOGLE_GENAI_USE_VERTEXAI: "true",
553
+ GOOGLE_CLOUD_PROJECT: "example-project",
554
+ OPENAI_API_KEY: "not-for-gemini",
555
+ },
556
+ });
557
+ const target = resolveAcpTarget(config);
558
+
559
+ expect(target.target).toBe("gemini");
560
+ expect(target.command(config)).toEqual(["gemini", "--acp"]);
561
+ const env = target.env(config);
562
+ expect(env).toMatchObject({
563
+ GEMINI_API_KEY: "example-test-key",
564
+ GOOGLE_GENAI_USE_VERTEXAI: "true",
565
+ GOOGLE_CLOUD_PROJECT: "example-project",
566
+ GEMINI_CLI_TRUST_WORKSPACE: "true",
567
+ GEMINI_MODEL: "gemini-3-flash-preview",
568
+ });
569
+ expect(env.OPENAI_API_KEY).toBeUndefined();
570
+ // No artifact written for this config, so the built-in prompt stays.
571
+ expect(env.GEMINI_SYSTEM_MD).toBeUndefined();
572
+ });
573
+
574
+ test("Gemini preset writes the system prompt outside the cwd and points GEMINI_SYSTEM_MD at it", async () => {
575
+ const cwd = mkdtempSync(join(tmpdir(), "acp-gemini-cwd-"));
576
+ try {
577
+ const config = baseConfig({
578
+ cwd,
579
+ systemPrompt: "You are a swarm worker.",
580
+ env: { PATH: "/bin", ACP_TARGET: "gemini" },
581
+ });
582
+ const target = resolveAcpTarget(config);
583
+ await target.writeSystemPromptArtifact(config);
584
+ const path = target.env(config).GEMINI_SYSTEM_MD;
585
+
586
+ expect(path).toBeDefined();
587
+ expect(path!.startsWith(cwd)).toBe(false);
588
+ expect(await Bun.file(path!).text()).toBe("You are a swarm worker.");
589
+ // A second session gets its own file.
590
+ const other = baseConfig({ cwd, systemPrompt: "other", env: config.env });
591
+ await target.writeSystemPromptArtifact(other);
592
+ expect(target.env(other).GEMINI_SYSTEM_MD).not.toBe(path);
593
+ rmSync(join(path!, ".."), { recursive: true, force: true });
594
+ rmSync(join(target.env(other).GEMINI_SYSTEM_MD!, ".."), { recursive: true, force: true });
595
+ } finally {
596
+ rmSync(cwd, { recursive: true, force: true });
597
+ }
598
+ });
599
+
600
+ test("Gemini preset keeps the built-in prompt when the swarm prompt is empty", async () => {
601
+ const config = baseConfig({ systemPrompt: " ", env: { PATH: "/bin", ACP_TARGET: "gemini" } });
602
+ const target = resolveAcpTarget(config);
603
+ await target.writeSystemPromptArtifact(config);
604
+
605
+ expect(target.env(config).GEMINI_SYSTEM_MD).toBeUndefined();
606
+ });
607
+
608
+ test("Copilot preset forwards its own credentials, pins the model, and never forwards GITHUB_TOKEN", () => {
609
+ const config = baseConfig({
610
+ model: "openai/gpt-5.2",
611
+ env: {
612
+ PATH: "/bin",
613
+ HOME: "/home/test",
614
+ ACP_TARGET: "copilot",
615
+ COPILOT_GITHUB_TOKEN: "example-copilot-token",
616
+ COPILOT_PROVIDER_BASE_URL: "https://llm.example/v1",
617
+ COPILOT_PROVIDER_API_KEY: "example-provider-key",
618
+ COPILOT_CUSTOM_INSTRUCTIONS_DIRS: "/opt/team-instructions",
619
+ GITHUB_TOKEN: "example-git-token",
620
+ GH_TOKEN: "example-gh-token",
621
+ },
622
+ });
623
+ const target = resolveAcpTarget(config);
624
+
625
+ expect(target.target).toBe("copilot");
626
+ expect(target.command(config)).toEqual(["copilot", "--acp"]);
627
+ const env = target.env(config);
628
+ expect(env).toMatchObject({
629
+ COPILOT_GITHUB_TOKEN: "example-copilot-token",
630
+ COPILOT_PROVIDER_BASE_URL: "https://llm.example/v1",
631
+ COPILOT_PROVIDER_API_KEY: "example-provider-key",
632
+ COPILOT_MODEL: "openai/gpt-5.2",
633
+ COPILOT_AUTO_UPDATE: "false",
634
+ COPILOT_CUSTOM_INSTRUCTIONS_DIRS: `${copilotInstructionsDir(config)},/opt/team-instructions`,
635
+ });
636
+ // The worker's git token lacks the Copilot Requests permission and would
637
+ // shadow a BYOK or COPILOT_GITHUB_TOKEN setup.
638
+ expect(env.GITHUB_TOKEN).toBeUndefined();
639
+ expect(env.GH_TOKEN).toBeUndefined();
640
+ });
641
+
642
+ test("Copilot preset delivers the system prompt outside the repo and removes it after the session", async () => {
643
+ const cwd = makeTempDir();
644
+ const binDir = makeTempDir();
645
+ const captureFile = join(binDir, "capture.json");
646
+ const agentPath = join(binDir, "fake-copilot-acp.ts");
647
+ const sdkPath = join(process.cwd(), "node_modules/@agentclientprotocol/sdk/dist/acp.js");
648
+ const repoAgentsMd = "# Repo rules\nKeep this file as is.\n";
649
+ await Bun.write(join(cwd, "AGENTS.md"), repoAgentsMd);
650
+ await Bun.write(
651
+ agentPath,
652
+ `
653
+ import { readFileSync } from "node:fs";
654
+ import { join } from "node:path";
655
+ import { Readable, Writable } from "node:stream";
656
+ import { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } from "${sdkPath}";
657
+ class FakeCopilot {
658
+ constructor(connection) { this.connection = connection; }
659
+ async initialize() {
660
+ return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: false } };
661
+ }
662
+ async newSession() {
663
+ const dir = process.env.COPILOT_CUSTOM_INSTRUCTIONS_DIRS.split(",")[0];
664
+ await Bun.write(${JSON.stringify(captureFile)}, JSON.stringify({
665
+ argv: process.argv.slice(2),
666
+ dir,
667
+ instructions: readFileSync(join(dir, ${JSON.stringify(COPILOT_INSTRUCTIONS_FILE)}), "utf8"),
668
+ model: process.env.COPILOT_MODEL,
669
+ }));
670
+ return { sessionId: "copilot-session-1" };
671
+ }
672
+ async prompt(params) {
673
+ await this.connection.sessionUpdate({
674
+ sessionId: params.sessionId,
675
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "done" }, messageId: "m1" },
676
+ });
677
+ return { stopReason: "end_turn" };
678
+ }
679
+ async cancel() {}
680
+ }
681
+ const stream = ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
682
+ new AgentSideConnection((connection) => new FakeCopilot(connection), stream);
683
+ `,
684
+ );
685
+ const copilotStub = join(binDir, "copilot");
686
+ await Bun.write(copilotStub, `#!/bin/sh\nexec "${process.execPath}" "${agentPath}" "$@"\n`);
687
+ chmodSync(copilotStub, 0o755);
688
+ mkdirSync(join(binDir, "home"));
689
+
690
+ const { server, apiUrl } = await startTokenStubServer("copilot-token-id", "example-aseph_x");
691
+ try {
692
+ const config = baseConfig({
693
+ cwd,
694
+ apiUrl,
695
+ model: "openai/gpt-5.2",
696
+ systemPrompt: "You are a swarm worker.",
697
+ env: {
698
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
699
+ HOME: join(binDir, "home"),
700
+ ACP_TARGET: "copilot",
701
+ },
702
+ });
703
+ const session = await new ACPAdapter().createSession(config);
704
+ const result = await session.waitForCompletion();
705
+ expect(result.isError).toBe(false);
706
+
707
+ const captured = JSON.parse(await Bun.file(captureFile).text()) as {
708
+ argv: string[];
709
+ dir: string;
710
+ instructions: string;
711
+ model: string;
712
+ };
713
+ expect(captured.argv).toEqual(["--acp"]);
714
+ expect(captured.dir).toBe(copilotInstructionsDir(config));
715
+ expect(captured.dir.startsWith(cwd)).toBe(false);
716
+ expect(captured.instructions).toBe('---\napplyTo: "**"\n---\n\nYou are a swarm worker.\n');
717
+ expect(captured.model).toBe("openai/gpt-5.2");
718
+ expect(await Bun.file(join(cwd, "AGENTS.md")).text()).toBe(repoAgentsMd);
719
+
720
+ const deadline = Date.now() + 2_000;
721
+ while (existsSync(captured.dir) && Date.now() < deadline) await Bun.sleep(10);
722
+ expect(existsSync(captured.dir)).toBe(false);
723
+ } finally {
724
+ await new Promise<void>((resolve) => server.close(() => resolve()));
725
+ }
726
+ });
727
+
539
728
  test("custom target passes through only explicitly named env and supports a model env fallback", () => {
540
729
  const config = baseConfig({
541
730
  model: "custom-model",
@@ -0,0 +1,390 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ BOOT_SCRUB_TARGETS,
5
+ bootScrubCursorKey,
6
+ bootScrubDoneKey,
7
+ runBootScrubSweep,
8
+ } from "../be/boot-scrub-sweep";
9
+ import { closeDb, getDb, getDbClient, initDb, isSqliteVecAvailable } from "../be/db";
10
+ import { getMemoryStore } from "../be/memory";
11
+ import { contentSha256 } from "../commands/profile-sync";
12
+ import {
13
+ refreshSecretScrubberCache,
14
+ registerVolatileSecret,
15
+ SCRUBBER_RULES_VERSION,
16
+ } from "../utils/secret-scrubber";
17
+ import { randomToken, type SyntheticSecret, syntheticSecret } from "./synthetic-secret-helpers";
18
+
19
+ const TEST_DB_PATH = "./test-boot-scrub-sweep.sqlite";
20
+ const AGENT_ID = "5c2b0000-0000-4000-8000-000000000001";
21
+ // Distinctive non-secret words: FTS must still find the row by them after redaction.
22
+ const MARKER_WORD = "quokkazebra";
23
+
24
+ let known: SyntheticSecret;
25
+ let envName: string;
26
+ let envValue: string;
27
+ let password: string;
28
+
29
+ /** Text carrying all three secret forms: known value, env value and a `*_PASSWORD=` assignment. */
30
+ function secretText(label: string): string {
31
+ return `${label} ${MARKER_WORD} token=${known.value} env=${envValue} X_DEMO_PASSWORD=${password} end`;
32
+ }
33
+
34
+ function expectRedacted(value: string | null | undefined, where: string): void {
35
+ expect(value, where).toBeString();
36
+ for (const secret of [known.value, envValue, password]) {
37
+ expect(value!.includes(secret), `${where} still holds a secret`).toBe(false);
38
+ }
39
+ expect(value!, where).toContain("[REDACTED:");
40
+ }
41
+
42
+ async function writeAudit(): Promise<{ tbl: string; id: string }[]> {
43
+ return getDbClient().query<{ tbl: string; id: string }>("SELECT tbl, id FROM scrub_write_audit");
44
+ }
45
+
46
+ async function ftsHits(term: string): Promise<string[]> {
47
+ const rows = await getDbClient().query<{ memory_id: string }>(
48
+ "SELECT memory_id FROM memory_fts WHERE memory_fts MATCH ?",
49
+ [`"${term}"`],
50
+ );
51
+ return rows.map((r) => r.memory_id);
52
+ }
53
+
54
+ const ids = {
55
+ log: crypto.randomUUID(),
56
+ logControl: crypto.randomUUID(),
57
+ task: crypto.randomUUID(),
58
+ taskControl: crypto.randomUUID(),
59
+ event: crypto.randomUUID(),
60
+ run: crypto.randomUUID(),
61
+ step: crypto.randomUUID(),
62
+ memory: "",
63
+ };
64
+
65
+ describe("boot retro-sweep", () => {
66
+ beforeAll(async () => {
67
+ for (const suffix of ["", "-wal", "-shm"]) {
68
+ try {
69
+ await unlink(TEST_DB_PATH + suffix);
70
+ } catch {}
71
+ }
72
+ initDb(TEST_DB_PATH);
73
+ // workflow_run_steps.runId references workflow_runs; the sweep never
74
+ // touches FKs, so seed the step without building a whole workflow.
75
+ getDb().run("PRAGMA foreign_keys = OFF");
76
+
77
+ known = syntheticSecret("sweep");
78
+ envName = "X_DEMO_SWEEP_API_KEY";
79
+ envValue = randomToken(32);
80
+ process.env[envName] = envValue;
81
+ refreshSecretScrubberCache();
82
+ password = randomToken(24);
83
+
84
+ const db = getDbClient();
85
+ // Every UPDATE on a swept table lands here: proves untouched rows saw no write.
86
+ await db.run("CREATE TABLE scrub_write_audit (tbl TEXT NOT NULL, id TEXT NOT NULL)");
87
+ for (const { table } of BOOT_SCRUB_TARGETS) {
88
+ await db.run(
89
+ `CREATE TRIGGER audit_${table} AFTER UPDATE ON ${table}
90
+ BEGIN INSERT INTO scrub_write_audit (tbl, id) VALUES ('${table}', NEW.id); END`,
91
+ );
92
+ }
93
+
94
+ const now = new Date().toISOString();
95
+ await db.run(
96
+ `INSERT INTO session_logs (id, sessionId, iteration, content, lineNumber, createdAt)
97
+ VALUES (?, 's1', 1, ?, 1, ?), (?, 's1', 1, ?, 2, ?)`,
98
+ [ids.log, secretText("log"), now, ids.logControl, "plain log line, nothing to hide", now],
99
+ );
100
+ await db.run(
101
+ `INSERT INTO agent_tasks (id, task, output, failureReason, progress, createdAt, lastUpdatedAt)
102
+ VALUES (?, ?, ?, ?, ?, ?, ?), (?, 'control task', 'control output', NULL, NULL, ?, ?)`,
103
+ [
104
+ ids.task,
105
+ secretText("task"),
106
+ secretText("output"),
107
+ secretText("failure"),
108
+ secretText("progress"),
109
+ now,
110
+ now,
111
+ ids.taskControl,
112
+ now,
113
+ now,
114
+ ],
115
+ );
116
+ await db.run(
117
+ `INSERT INTO events (id, category, event, source, data, createdAt)
118
+ VALUES (?, 'test', 'test.event', 'test', ?, ?)`,
119
+ [ids.event, JSON.stringify({ note: secretText("event"), nested: { n: 1 } }), now],
120
+ );
121
+ await db.run(
122
+ `INSERT INTO workflow_run_steps (id, runId, nodeId, nodeType, input, output, error, diagnostics)
123
+ VALUES (?, ?, 'n1', 'agent-task', ?, ?, ?, ?)`,
124
+ [
125
+ ids.step,
126
+ ids.run,
127
+ JSON.stringify({ prompt: secretText("input") }),
128
+ JSON.stringify({ result: secretText("step-output") }),
129
+ secretText("error"),
130
+ JSON.stringify({ trace: secretText("diagnostics") }),
131
+ ],
132
+ );
133
+
134
+ const store = getMemoryStore();
135
+ const legacy = {
136
+ name: `note ${known.value}`,
137
+ content: secretText("memory"),
138
+ summary: secretText("summary"),
139
+ };
140
+ const memory = await store.store({
141
+ agentId: AGENT_ID,
142
+ scope: "agent",
143
+ source: "manual",
144
+ ...legacy,
145
+ });
146
+ ids.memory = memory.id;
147
+ // store() now scrubs at write; put the raw text back so the row looks like
148
+ // one written before that, which is what the sweep exists for.
149
+ await db.run(
150
+ "UPDATE agent_memory SET name = ?, content = ?, summary = ?, contentHash = ? WHERE id = ?",
151
+ [legacy.name, legacy.content, legacy.summary, contentSha256(legacy.content), memory.id],
152
+ );
153
+ await db.run(
154
+ "UPDATE agent_memory_version SET content = ?, contentHash = ? WHERE memory_id = ?",
155
+ [legacy.content, contentSha256(legacy.content), memory.id],
156
+ );
157
+ await db.run("DELETE FROM memory_fts WHERE memory_id = ?", [memory.id]);
158
+ await db.run("INSERT INTO memory_fts(memory_id, name, content) VALUES (?, ?, ?)", [
159
+ memory.id,
160
+ legacy.name,
161
+ legacy.content,
162
+ ]);
163
+ const embedding = new Float32Array(512);
164
+ embedding[0] = 1;
165
+ await store.updateEmbedding(memory.id, embedding, "test");
166
+ });
167
+
168
+ // The test preload clears volatile secrets after every test.
169
+ beforeEach(() => registerVolatileSecret(known.value, known.name));
170
+
171
+ afterAll(async () => {
172
+ delete process.env[envName];
173
+ known.cleanup();
174
+ refreshSecretScrubberCache();
175
+ closeDb();
176
+ for (const suffix of ["", "-wal", "-shm"]) {
177
+ try {
178
+ await unlink(TEST_DB_PATH + suffix);
179
+ } catch {}
180
+ }
181
+ });
182
+
183
+ test("redacts every target column, keeps context, JSON and FTS, drops the stale vector", async () => {
184
+ const db = getDbClient();
185
+ // Positive controls: the secret is really in the index and the vector exists.
186
+ expect(await ftsHits(password)).toContain(ids.memory);
187
+ const before = await db.get<{ embedding: Uint8Array | null }>(
188
+ "SELECT embedding FROM agent_memory WHERE id = ?",
189
+ [ids.memory],
190
+ );
191
+ expect(before?.embedding).not.toBeNull();
192
+ if (isSqliteVecAvailable()) {
193
+ expect(
194
+ await db.get("SELECT memory_id FROM memory_vec WHERE memory_id = ?", [ids.memory]),
195
+ ).not.toBeNull();
196
+ }
197
+
198
+ const stats = await runBootScrubSweep({ version: SCRUBBER_RULES_VERSION, reembed: false });
199
+ expect(stats).not.toBeNull();
200
+ const byTable = Object.fromEntries(stats!.map((s) => [s.table, s]));
201
+ expect(byTable.session_logs!.changed).toBe(1);
202
+ expect(byTable.agent_tasks!.changed).toBe(1);
203
+ expect(byTable.agent_memory!.changed).toBe(1);
204
+ expect(byTable.agent_memory_version!.changed).toBe(1);
205
+ expect(byTable.events!.changed).toBe(1);
206
+ expect(byTable.workflow_run_steps!.changed).toBe(1);
207
+
208
+ const log = await db.get<{ content: string }>("SELECT content FROM session_logs WHERE id = ?", [
209
+ ids.log,
210
+ ]);
211
+ expectRedacted(log?.content, "session_logs.content");
212
+ expect(log!.content).toContain(MARKER_WORD);
213
+
214
+ const task = await db.get<Record<string, string>>(
215
+ "SELECT task, output, failureReason, progress FROM agent_tasks WHERE id = ?",
216
+ [ids.task],
217
+ );
218
+ for (const col of ["task", "output", "failureReason", "progress"]) {
219
+ expectRedacted(task?.[col], `agent_tasks.${col}`);
220
+ }
221
+
222
+ const mem = await db.get<{
223
+ name: string;
224
+ content: string;
225
+ summary: string;
226
+ contentHash: string;
227
+ embedding: Uint8Array | null;
228
+ }>("SELECT name, content, summary, contentHash, embedding FROM agent_memory WHERE id = ?", [
229
+ ids.memory,
230
+ ]);
231
+ expectRedacted(mem?.name, "agent_memory.name");
232
+ expectRedacted(mem?.content, "agent_memory.content");
233
+ expectRedacted(mem?.summary, "agent_memory.summary");
234
+ expect(mem!.contentHash).toBe(contentSha256(mem!.content));
235
+ expect(mem!.embedding).toBeNull();
236
+ if (isSqliteVecAvailable()) {
237
+ expect(
238
+ await db.get("SELECT memory_id FROM memory_vec WHERE memory_id = ?", [ids.memory]),
239
+ ).toBeNull();
240
+ }
241
+ // FTS was rewritten from the scrubbed text, not left stale.
242
+ expect(await ftsHits(MARKER_WORD)).toContain(ids.memory);
243
+ expect(await ftsHits(password)).not.toContain(ids.memory);
244
+
245
+ const version = await db.get<{ content: string; contentHash: string }>(
246
+ "SELECT content, contentHash FROM agent_memory_version WHERE memory_id = ?",
247
+ [ids.memory],
248
+ );
249
+ expectRedacted(version?.content, "agent_memory_version.content");
250
+ expect(version!.contentHash).toBe(contentSha256(version!.content));
251
+
252
+ const event = await db.get<{ data: string }>("SELECT data FROM events WHERE id = ?", [
253
+ ids.event,
254
+ ]);
255
+ expectRedacted(event?.data, "events.data");
256
+ const parsed = JSON.parse(event!.data) as { note: string; nested: { n: number } };
257
+ expect(parsed.nested.n).toBe(1);
258
+ expect(parsed.note).toContain(MARKER_WORD);
259
+
260
+ const step = await db.get<Record<string, string>>(
261
+ "SELECT input, output, error, diagnostics FROM workflow_run_steps WHERE id = ?",
262
+ [ids.step],
263
+ );
264
+ for (const col of ["input", "output", "error", "diagnostics"]) {
265
+ expectRedacted(step?.[col], `workflow_run_steps.${col}`);
266
+ }
267
+ for (const col of ["input", "output", "diagnostics"]) {
268
+ expect(() => JSON.parse(step![col]!)).not.toThrow();
269
+ }
270
+ });
271
+
272
+ test("control rows without a secret see no write", async () => {
273
+ const audit = await writeAudit();
274
+ // Positive control: the trigger fired for the rows that held a secret.
275
+ expect(audit).toContainEqual({ tbl: "session_logs", id: ids.log });
276
+ expect(audit).toContainEqual({ tbl: "agent_tasks", id: ids.task });
277
+ // The controls were never written.
278
+ expect(audit.map((a) => a.id)).not.toContain(ids.logControl);
279
+ expect(audit.map((a) => a.id)).not.toContain(ids.taskControl);
280
+ const control = await getDbClient().get<{ content: string }>(
281
+ "SELECT content FROM session_logs WHERE id = ?",
282
+ [ids.logControl],
283
+ );
284
+ expect(control?.content).toBe("plain log line, nothing to hide");
285
+ });
286
+
287
+ test("marks the version done and is a no-op on rerun; a new version runs again", async () => {
288
+ const db = getDbClient();
289
+ expect(
290
+ await db.get("SELECT key FROM seed_state WHERE kind = 'maintenance' AND key = ?", [
291
+ bootScrubDoneKey(SCRUBBER_RULES_VERSION),
292
+ ]),
293
+ ).not.toBeNull();
294
+ const cursors = await db.query("SELECT key FROM seed_state WHERE key LIKE 'boot-scrub-v%:%'");
295
+ expect(cursors).toHaveLength(0);
296
+
297
+ const auditBefore = (await writeAudit()).length;
298
+ expect(await runBootScrubSweep({ version: SCRUBBER_RULES_VERSION, reembed: false })).toBeNull();
299
+
300
+ const bumped = SCRUBBER_RULES_VERSION + 100;
301
+ const stats = await runBootScrubSweep({ version: bumped, reembed: false });
302
+ expect(stats).not.toBeNull();
303
+ expect(stats!.every((s) => s.changed === 0)).toBe(true);
304
+ expect(stats!.find((s) => s.table === "session_logs")!.scanned).toBeGreaterThanOrEqual(2);
305
+ expect((await writeAudit()).length).toBe(auditBefore);
306
+ expect(
307
+ await db.get("SELECT key FROM seed_state WHERE kind = 'maintenance' AND key = ?", [
308
+ bootScrubDoneKey(bumped),
309
+ ]),
310
+ ).not.toBeNull();
311
+ });
312
+
313
+ test("an interrupted run resumes from the cursor without rescanning batch 1", async () => {
314
+ const db = getDbClient();
315
+ const now = new Date().toISOString();
316
+ for (let i = 0; i < 5; i++) {
317
+ await db.run(
318
+ `INSERT INTO session_logs (id, sessionId, iteration, content, lineNumber, createdAt)
319
+ VALUES (?, 's2', 1, ?, ?, ?)`,
320
+ [crypto.randomUUID(), secretText(`resume-${i}`), 10 + i, now],
321
+ );
322
+ }
323
+ const version = SCRUBBER_RULES_VERSION + 200;
324
+ let firstBatch: string[] = [];
325
+ await expect(
326
+ runBootScrubSweep({
327
+ version,
328
+ batchSize: 2,
329
+ reembed: false,
330
+ afterBatch: ({ table, ids: batchIds }) => {
331
+ if (table !== "session_logs") return;
332
+ firstBatch = batchIds;
333
+ throw new Error("interrupted after batch 1");
334
+ },
335
+ }),
336
+ ).rejects.toThrow("interrupted after batch 1");
337
+ expect(firstBatch).toHaveLength(2);
338
+
339
+ const cursor = await db.get<{ seededHash: string }>(
340
+ "SELECT seededHash FROM seed_state WHERE kind = 'maintenance' AND key = ?",
341
+ [bootScrubCursorKey(version, "session_logs")],
342
+ );
343
+ expect(cursor?.seededHash).toBe(firstBatch[1]);
344
+ expect(
345
+ await db.get("SELECT key FROM seed_state WHERE key = ?", [bootScrubDoneKey(version)]),
346
+ ).toBeNull();
347
+
348
+ const resumed: string[] = [];
349
+ const stats = await runBootScrubSweep({
350
+ version,
351
+ batchSize: 2,
352
+ reembed: false,
353
+ afterBatch: ({ table, ids: batchIds }) => {
354
+ if (table === "session_logs") resumed.push(...batchIds);
355
+ },
356
+ });
357
+ expect(resumed.length).toBeGreaterThan(0);
358
+ for (const id of firstBatch) expect(resumed).not.toContain(id);
359
+ const total = await db.get<{ n: number }>("SELECT COUNT(*) AS n FROM session_logs");
360
+ expect(stats!.find((s) => s.table === "session_logs")!.scanned).toBe(total!.n - 2);
361
+
362
+ // Batch 1 was committed before the interrupt, the rest by the resumed run.
363
+ const remaining = await db.query<{ content: string }>(
364
+ "SELECT content FROM session_logs WHERE sessionId = 's2'",
365
+ );
366
+ for (const row of remaining) expectRedacted(row.content, "resumed session_logs.content");
367
+ });
368
+
369
+ test("skips a row whose scrub would break valid JSON", async () => {
370
+ const db = getDbClient();
371
+ const id = crypto.randomUUID();
372
+ const data = JSON.stringify({ msg: "BREAKJSON here" });
373
+ await db.run(
374
+ `INSERT INTO events (id, category, event, source, data, createdAt)
375
+ VALUES (?, 'test', 'test.event', 'test', ?, ?)`,
376
+ [id, data, new Date().toISOString()],
377
+ );
378
+ // Force a scrub result that is not valid JSON (an unbalanced quote).
379
+ const stats = await runBootScrubSweep({
380
+ version: SCRUBBER_RULES_VERSION + 300,
381
+ reembed: false,
382
+ scrub: (text) => text.replace("BREAKJSON", '"'),
383
+ });
384
+ const events = stats!.find((s) => s.table === "events")!;
385
+ expect(events.skippedInvalidJson).toBe(1);
386
+ expect(events.changed).toBe(0);
387
+ const row = await db.get<{ data: string }>("SELECT data FROM events WHERE id = ?", [id]);
388
+ expect(row?.data).toBe(data);
389
+ });
390
+ });
@@ -181,23 +181,15 @@ async function callPoll(agentId: string): Promise<{
181
181
  }> {
182
182
  let status = 200;
183
183
  let bodyStr = "";
184
- const headers: Record<string, string> = {};
185
-
186
184
  const req = {
187
185
  method: "GET",
188
186
  url: "/api/poll",
189
187
  headers: { "x-agent-id": agentId },
190
188
  } as unknown as Parameters<typeof handlePoll>[0];
191
-
192
189
  const res = {
193
- setHeader(name: string, value: string) {
194
- headers[name.toLowerCase()] = value;
195
- },
196
- writeHead(code: number, h?: Record<string, string>) {
190
+ setHeader() {},
191
+ writeHead(code: number) {
197
192
  status = code;
198
- if (h) {
199
- for (const [k, v] of Object.entries(h)) headers[k.toLowerCase()] = v;
200
- }
201
193
  },
202
194
  end(body?: string) {
203
195
  bodyStr = body ?? "";
@@ -288,17 +280,11 @@ describe("user budget scope", () => {
288
280
  expect(result.allowed).toBe(true);
289
281
  });
290
282
 
291
- test("/mcp-user task is refused at worker admission when user budget is spent", async () => {
283
+ test("/mcp-user task is refused when the Lead polls after the user budget is spent", async () => {
292
284
  const server = createMcpUserTestServer();
293
285
  const port = await listenOnFreePort(server, "127.0.0.1");
294
286
  try {
295
287
  const lead = await createAgent({ name: "lead", isLead: true, status: "idle", maxTasks: 1 });
296
- const worker = await createAgent({
297
- name: "worker",
298
- isLead: false,
299
- status: "idle",
300
- maxTasks: 1,
301
- });
302
288
  const user = await createUser({ name: "MCP Budget User", dailyBudgetUsd: 0.5 });
303
289
  await upsertBudget("user", user.id, 0.5);
304
290
  const token = await mintToken(user.id, "qa", ACTOR);
@@ -329,21 +315,21 @@ describe("user budget scope", () => {
329
315
  await createSessionCost({
330
316
  sessionId: `sess-${crypto.randomUUID()}`,
331
317
  taskId,
332
- agentId: worker.id,
318
+ agentId: lead.id,
333
319
  totalCostUsd: 0.5,
334
320
  durationMs: 1000,
335
321
  numTurns: 1,
336
322
  model: "test-model",
337
323
  });
338
324
 
339
- const firstPoll = await callPoll(worker.id);
325
+ const firstPoll = await callPoll(lead.id);
340
326
  expect(firstPoll.status).toBe(200);
341
327
  if ("error" in firstPoll.body) throw new Error("unexpected poll error");
342
328
  expect(firstPoll.body.trigger?.type).toBe("budget_refused");
343
329
  expect((firstPoll.body.trigger as { cause: string }).cause).toBe("user");
344
330
  expect((firstPoll.body.trigger as { userSpend: number }).userSpend).toBe(0.5);
345
331
  expect((firstPoll.body.trigger as { userBudget: number }).userBudget).toBe(0.5);
346
- expect((await getTaskById(taskId))?.status).toBe("unassigned");
332
+ expect((await getTaskById(taskId))?.status).toBe("pending");
347
333
 
348
334
  const firstDedup = await getDbClient().get<{
349
335
  follow_up_task_id: string | null;
@@ -357,7 +343,7 @@ describe("user budget scope", () => {
357
343
  const firstFollowUpId = firstDedup?.follow_up_task_id;
358
344
  expect(firstFollowUpId ? (await getTaskById(firstFollowUpId))?.agentId : null).toBe(lead.id);
359
345
 
360
- const secondPoll = await callPoll(worker.id);
346
+ const secondPoll = await callPoll(lead.id);
361
347
  expect(secondPoll.status).toBe(200);
362
348
  if ("error" in secondPoll.body) throw new Error("unexpected poll error");
363
349
  expect(secondPoll.body.trigger?.type).toBe("budget_refused");