@desplega.ai/agent-swarm 1.150.0 → 1.151.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 (293) hide show
  1. package/README.md +11 -0
  2. package/dist/{acp-adapter-d5mzggyk.js → acp-adapter-ex8hzjfc.js} +5 -5
  3. package/dist/{actions-wn6ew0p7.js → actions-2bgpyhww.js} +38 -11
  4. package/dist/{anthropic-messages-ec35ktvx.js → anthropic-messages-2y8nktar.js} +16 -16
  5. package/dist/{app-dty7p9hg.js → app-1706zhr1.js} +7 -7
  6. package/dist/{assistant-q4vhgjtx.js → assistant-bavke5p7.js} +41 -16
  7. package/dist/{azure-openai-responses-g85jv7m8.js → azure-openai-responses-f38x2avq.js} +13 -13
  8. package/dist/{boot-reembed-92292pgf.js → boot-reembed-3cmrrmpa.js} +8 -8
  9. package/dist/{boot-reembed-n15q6s9x.js → boot-reembed-nafcmmmc.js} +13 -15
  10. package/dist/{boot-scrub-logs-44pc955m.js → boot-scrub-logs-cd6a2sq0.js} +5 -5
  11. package/dist/{claude-adapter-cjegjbje.js → claude-adapter-wfp2gzvz.js} +12 -13
  12. package/dist/{claude-managed-adapter-p3ts5m9m.js → claude-managed-adapter-dsmkcrjy.js} +3 -3
  13. package/dist/{claude-sdk-session-7ywbn12v.js → claude-sdk-session-bht9jjak.js} +12 -13
  14. package/dist/{cli-ceb5y160.js → cli-0qb1z6s2.js} +1 -1
  15. package/dist/{cli-ryz8q8p1.js → cli-2tbvf1z1.js} +40 -40
  16. package/dist/{cli-zthatyrx.js → cli-3v9xhjcn.js} +5 -5
  17. package/dist/{cli-mzb0n642.js → cli-4wppfkcm.js} +350 -20
  18. package/dist/{cli-3ckzxdt1.js → cli-5jd2z7av.js} +90 -81
  19. package/dist/{cli-3mh119qv.js → cli-5tpgq60f.js} +9726 -693
  20. package/dist/{cli-ck1vqzdj.js → cli-7tgcstn2.js} +9 -4
  21. package/dist/{cli-ttje5a4z.js → cli-9004vfdf.js} +105 -56
  22. package/dist/{cli-asvrh7kj.js → cli-9xvdm3bx.js} +1 -1
  23. package/dist/{cli-9gqddmxs.js → cli-a1z3h107.js} +8 -8
  24. package/dist/{cli-72xdszyx.js → cli-czjtgfys.js} +2 -2
  25. package/dist/{cli-5my3bjsd.js → cli-d3p82erb.js} +7 -7
  26. package/dist/{cli-kyysm7m3.js → cli-f1wky6zp.js} +1 -1
  27. package/dist/{cli-a45d5v9b.js → cli-fm4ytsdk.js} +2 -2
  28. package/dist/{cli-111ey3ra.js → cli-frcta2w4.js} +5 -5
  29. package/dist/{cli-h505yhex.js → cli-g69fdc9g.js} +2 -2
  30. package/dist/{cli-cmp8g3sw.js → cli-gbfwcd3k.js} +44 -9
  31. package/dist/{cli-hbagregr.js → cli-gjyvhmmh.js} +88 -13
  32. package/dist/{cli-hmyynq9b.js → cli-ht5rs63n.js} +1 -1
  33. package/dist/{cli-dyx0237s.js → cli-j4nv2wqy.js} +2 -4
  34. package/dist/{cli-vr6qj104.js → cli-j9xczhaq.js} +1 -1
  35. package/dist/cli-pf9q3696.js +172 -0
  36. package/dist/{cli-7fesa1ky.js → cli-pgj4a5fb.js} +1 -1
  37. package/dist/{cli-7kwmt3r5.js → cli-ppm4975n.js} +9 -3
  38. package/dist/{cli-8chp8xab.js → cli-q88st9aj.js} +2 -2
  39. package/dist/{cli-kt7wevqh.js → cli-r2q6pevn.js} +132 -46
  40. package/dist/{cli-07jd68a1.js → cli-re8z5p8p.js} +1 -1
  41. package/dist/{cli-qp69dd5d.js → cli-s14agm5s.js} +105 -12
  42. package/dist/{cli-zd21jrrc.js → cli-sjaae6ff.js} +9872 -424
  43. package/dist/{cli-taym3dmg.js → cli-vwm416m6.js} +13 -13
  44. package/dist/{cli-1mx65de9.js → cli-wbyk465z.js} +1 -1
  45. package/dist/{cli-n75jt793.js → cli-z61ykvam.js} +6 -5
  46. package/dist/{cli-gm0fhz13.js → cli-zkxymas5.js} +2 -1
  47. package/dist/cli.js +22 -21
  48. package/dist/{codex-adapter-rn3aq7jm.js → codex-adapter-jjjk8w4m.js} +8 -9
  49. package/dist/{codex-hook-gthcmm0g.js → codex-hook-8jexc34x.js} +2 -2
  50. package/dist/{codex-session-runner-bge0cbsw.js → codex-session-runner-nd6ayxkx.js} +8 -9
  51. package/dist/{commands-26phw0pc.js → commands-9v4psyq1.js} +5 -5
  52. package/dist/{db-jxjx6s1b.js → db-wnt58tpe.js} +5 -5
  53. package/dist/dispatcher-05jh1aqk.js +74 -0
  54. package/dist/{google-generative-ai-tsskx7te.js → google-generative-ai-ndjmj5fa.js} +7 -7
  55. package/dist/{google-vertex-pkhpkbq9.js → google-vertex-fr665tqs.js} +10 -10
  56. package/dist/handlers-tw90pkh5.js +65 -0
  57. package/dist/{hook-t6fqsggw.js → hook-123e2scb.js} +9 -10
  58. package/dist/{hook-jw0ztn96.js → hook-sxe3r349.js} +9 -10
  59. package/dist/{http-hyc7s57s.js → http-ecsar8ez.js} +1431 -579
  60. package/dist/{index-sfhaq59d.js → index-16js521z.js} +119 -101
  61. package/dist/{index-wtwpthe1.js → index-hw26vj40.js} +25 -19
  62. package/dist/{index-bcyx5djw.js → index-nnrbpcjp.js} +25 -17
  63. package/dist/index-ymn7em0p.js +25 -0
  64. package/dist/{index-1d9r6a74.js → index-zj518gf9.js} +26 -20
  65. package/dist/{keepalive-fbsak4ya.js → keepalive-asqtcm4y.js} +7 -7
  66. package/dist/{lead-rdzdm627.js → lead-xbfr7fvt.js} +41 -49
  67. package/dist/{maintenance-25z1c1vf.js → maintenance-qma55qvw.js} +8 -8
  68. package/dist/{node-209sxmg3.js → node-x1g4v0pq.js} +9 -9
  69. package/dist/{oauth-refresh-sweep-j3d7gtse.js → oauth-refresh-sweep-3s04c6qw.js} +6 -6
  70. package/dist/{onboard-dkcwrc75.js → onboard-drskd386.js} +13 -4
  71. package/dist/{openai-codex-responses-pm5gnyfa.js → openai-codex-responses-wy6a43wd.js} +11 -11
  72. package/dist/{openai-responses-vwvk1taj.js → openai-responses-6xwf7bzg.js} +13 -13
  73. package/dist/{opencode-adapter-dce4wskd.js → opencode-adapter-28axqa09.js} +4 -4
  74. package/dist/{otel-impl-8rgyy3j8.js → otel-impl-943c96er.js} +1 -1
  75. package/dist/{pi-mono-adapter-4s2bbkgq.js → pi-mono-adapter-jcqt71cg.js} +14 -16
  76. package/dist/{pricing-refresh-9cek71nq.js → pricing-refresh-mx51az05.js} +7 -7
  77. package/dist/{rbac-roles-z98s2ypy.js → rbac-roles-78baecqd.js} +13 -9
  78. package/dist/{rbac-roles-vk8mqp9v.js → rbac-roles-wcmrg6sv.js} +7 -7
  79. package/dist/{render-v2-3j26g0qh.js → render-v2-8zjntgf5.js} +7 -7
  80. package/dist/{seed-pricing-7rxhcnzm.js → seed-pricing-jqjs6cws.js} +6 -6
  81. package/dist/{setup-e2t7vz0c.js → setup-4pks0vfk.js} +2 -2
  82. package/dist/{work-objects-8d3nd3x2.js → work-objects-wangt792.js} +38 -14
  83. package/dist/{worker-0x9fn7nq.js → worker-p2mr422x.js} +41 -49
  84. package/openapi.json +1351 -265
  85. package/package.json +2 -1
  86. package/src/agentmail/handlers.ts +95 -71
  87. package/src/be/db-client.ts +5 -0
  88. package/src/be/db.ts +11 -3
  89. package/src/be/extensions/db.ts +461 -0
  90. package/src/be/extensions/validate.ts +107 -0
  91. package/src/be/memory/boot-reembed.ts +17 -12
  92. package/src/be/memory/index.ts +12 -0
  93. package/src/be/memory/providers/openai-embedding.ts +4 -0
  94. package/src/be/memory/types.ts +2 -0
  95. package/src/be/migrations/155_extensions.sql +66 -0
  96. package/src/be/migrations/156_inbox_item_state_read_at.sql +7 -0
  97. package/src/be/rbac-roles.ts +3 -2
  98. package/src/be/scripts/typecheck.ts +52 -38
  99. package/src/be/seed-skills/index.ts +7 -0
  100. package/src/be/swarm-config-guard.ts +3 -1
  101. package/src/commands/onboard/compose-generator.ts +14 -2
  102. package/src/commands/onboard/env-generator.ts +7 -0
  103. package/src/commands/runner.ts +1 -1
  104. package/src/extensions/apply-task-create.ts +135 -0
  105. package/src/extensions/bundle-path.ts +10 -0
  106. package/src/extensions/contract-runtime.ts +7 -0
  107. package/src/extensions/contract-types.generated.ts +4 -0
  108. package/src/extensions/contract.ts +270 -0
  109. package/src/extensions/ctx.ts +186 -0
  110. package/src/extensions/dispatcher.ts +479 -0
  111. package/src/extensions/identity.ts +44 -0
  112. package/src/extensions/lifecycle.ts +281 -0
  113. package/src/extensions/loader.ts +207 -0
  114. package/src/extensions/post-bridge.ts +161 -0
  115. package/src/github/handlers.ts +185 -149
  116. package/src/gitlab/handlers.ts +97 -81
  117. package/src/heartbeat/heartbeat.ts +152 -83
  118. package/src/http/agents.ts +1 -1
  119. package/src/http/all-routes.ts +1 -0
  120. package/src/http/apps.ts +14 -10
  121. package/src/http/core.ts +34 -0
  122. package/src/http/extensions.ts +547 -0
  123. package/src/http/inbox-state.ts +2 -0
  124. package/src/http/index.ts +25 -0
  125. package/src/http/mcp-bridge.ts +96 -62
  126. package/src/http/mcp.ts +1 -1
  127. package/src/http/status.ts +6 -4
  128. package/src/http/tasks.ts +51 -35
  129. package/src/integrations/kapso/inbound.ts +15 -11
  130. package/src/jira/sync.ts +26 -18
  131. package/src/linear/sync.ts +26 -18
  132. package/src/prompts/session-templates.ts +5 -4
  133. package/src/rbac/admission.ts +1 -1
  134. package/src/rbac/elevated-agents.ts +24 -0
  135. package/src/rbac/legacy-policy.ts +35 -6
  136. package/src/rbac/permissions.ts +8 -0
  137. package/src/scheduler/deferred-task-waits.ts +133 -51
  138. package/src/scheduler/schedule-task.ts +50 -24
  139. package/src/scheduler/scheduler.ts +4 -2
  140. package/src/scripts-runtime/executors/native.ts +5 -1
  141. package/src/scripts-runtime/executors/types.ts +5 -0
  142. package/src/scripts-runtime/import-allowlist.ts +79 -19
  143. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  144. package/src/scripts-runtime/swarm-config.ts +8 -0
  145. package/src/scripts-runtime/swarm-sdk.ts +3 -0
  146. package/src/scripts-runtime/types/stdlib.d.ts +9 -0
  147. package/src/scripts-runtime/types/swarm-extension.d.ts +562 -0
  148. package/src/scripts-runtime/types/swarm-sdk.d.ts +9 -0
  149. package/src/server.ts +6 -0
  150. package/src/slack/HEURISTICS.md +1 -1
  151. package/src/slack/actions.ts +21 -17
  152. package/src/slack/blocks.ts +1 -1
  153. package/src/slack/handlers.ts +98 -5
  154. package/src/slack/inbound-files.ts +6 -2
  155. package/src/slack/render-v2.ts +171 -35
  156. package/src/slack/router.ts +13 -3
  157. package/src/slack/steering.ts +3 -3
  158. package/src/tasks/errors.ts +9 -0
  159. package/src/tasks/sibling-awareness.ts +35 -1
  160. package/src/tasks/worker-follow-up.ts +64 -12
  161. package/src/tests/base-prompt.test.ts +1 -0
  162. package/src/tests/deferred-task-wake.test.ts +161 -9
  163. package/src/tests/docs-index-footprint.test.js +3 -3
  164. package/src/tests/env-flag.test.ts +1 -0
  165. package/src/tests/extensions-db.test.ts +216 -0
  166. package/src/tests/extensions-dispatcher.test.ts +275 -0
  167. package/src/tests/extensions-example-heartbeat-remediate.test.ts +69 -0
  168. package/src/tests/extensions-example-mcp-install.test.ts +63 -0
  169. package/src/tests/extensions-example-no-exclamation-marks.test.ts +72 -0
  170. package/src/tests/extensions-example-rewrite-and-suppress.test.ts +70 -0
  171. package/src/tests/extensions-example-route-slack-channel.test.ts +217 -0
  172. package/src/tests/extensions-heartbeat.test.ts +291 -0
  173. package/src/tests/extensions-http.test.ts +431 -0
  174. package/src/tests/extensions-lead-equivalence.test.ts +49 -0
  175. package/src/tests/extensions-lifecycle.test.ts +209 -0
  176. package/src/tests/extensions-loader.test.ts +95 -0
  177. package/src/tests/extensions-mcp-tools.test.ts +245 -0
  178. package/src/tests/extensions-pre-task.test.ts +487 -0
  179. package/src/tests/extensions-run-context.test.ts +41 -0
  180. package/src/tests/extensions-tool-call.test.ts +288 -0
  181. package/src/tests/extensions-validate.test.ts +196 -0
  182. package/src/tests/fixtures/extensions/bad-import/hooks.ts +8 -0
  183. package/src/tests/fixtures/extensions/bad-import/manifest.json +9 -0
  184. package/src/tests/fixtures/extensions/bad-return-shape/hooks.ts +10 -0
  185. package/src/tests/fixtures/extensions/bad-return-shape/manifest.json +9 -0
  186. package/src/tests/fixtures/extensions/block-tasks-from-source/hooks.ts +22 -0
  187. package/src/tests/fixtures/extensions/block-tasks-from-source/manifest.json +9 -0
  188. package/src/tests/fixtures/extensions/ignore-channel/hooks.ts +22 -0
  189. package/src/tests/fixtures/extensions/ignore-channel/manifest.json +9 -0
  190. package/src/tests/fixtures/extensions/load.ts +10 -0
  191. package/src/tests/fixtures/extensions/minimal/hooks.ts +7 -0
  192. package/src/tests/fixtures/extensions/minimal/manifest.json +9 -0
  193. package/src/tests/fixtures/extensions/never-fail-long-tasks/hooks.ts +11 -0
  194. package/src/tests/fixtures/extensions/never-fail-long-tasks/manifest.json +9 -0
  195. package/src/tests/fixtures/extensions/no-exclamation-marks/hooks.ts +23 -0
  196. package/src/tests/fixtures/extensions/no-exclamation-marks/manifest.json +9 -0
  197. package/src/tests/fixtures/extensions/post-logger/hooks.ts +9 -0
  198. package/src/tests/fixtures/extensions/post-logger/manifest.json +9 -0
  199. package/src/tests/fixtures/extensions/priority-a/hooks.ts +7 -0
  200. package/src/tests/fixtures/extensions/priority-a/manifest.json +9 -0
  201. package/src/tests/fixtures/extensions/priority-b/hooks.ts +7 -0
  202. package/src/tests/fixtures/extensions/priority-b/manifest.json +9 -0
  203. package/src/tests/fixtures/extensions/record-only-on-tag/hooks.ts +16 -0
  204. package/src/tests/fixtures/extensions/record-only-on-tag/manifest.json +9 -0
  205. package/src/tests/fixtures/extensions/reserved-assets/hooks.ts +5 -0
  206. package/src/tests/fixtures/extensions/reserved-assets/manifest.json +10 -0
  207. package/src/tests/fixtures/extensions/rewrite-progress-prefix/hooks.ts +22 -0
  208. package/src/tests/fixtures/extensions/rewrite-progress-prefix/manifest.json +9 -0
  209. package/src/tests/fixtures/extensions/rewrite-task-priority/hooks.ts +7 -0
  210. package/src/tests/fixtures/extensions/rewrite-task-priority/manifest.json +9 -0
  211. package/src/tests/fixtures/extensions/route-channel-to-agent/hooks.ts +26 -0
  212. package/src/tests/fixtures/extensions/route-channel-to-agent/manifest.json +9 -0
  213. package/src/tests/fixtures/extensions/slow/hooks.ts +10 -0
  214. package/src/tests/fixtures/extensions/slow/manifest.json +9 -0
  215. package/src/tests/fixtures/extensions/suppress-lead-follow-up/hooks.ts +12 -0
  216. package/src/tests/fixtures/extensions/suppress-lead-follow-up/manifest.json +9 -0
  217. package/src/tests/fixtures/extensions/throws/hooks.ts +12 -0
  218. package/src/tests/fixtures/extensions/throws/manifest.json +9 -0
  219. package/src/tests/fixtures/extensions/worker-runtime/hooks.ts +5 -0
  220. package/src/tests/fixtures/extensions/worker-runtime/manifest.json +9 -0
  221. package/src/tests/memory-edit-authorization.test.ts +32 -0
  222. package/src/tests/multi-runtime-enabled-stats.test.ts +2 -2
  223. package/src/tests/multi-runtime-registration.test.ts +14 -14
  224. package/src/tests/onboard-compose.test.ts +44 -0
  225. package/src/tests/preload.ts +5 -0
  226. package/src/tests/rbac-admission.test.ts +18 -3
  227. package/src/tests/rbac-engine.test.ts +39 -0
  228. package/src/tests/realtime-room-auth.test.ts +1 -1
  229. package/src/tests/reload-config.test.ts +44 -0
  230. package/src/tests/routing-affinity-inventory.test.ts +1 -1
  231. package/src/tests/script-connections.test.ts +2 -0
  232. package/src/tests/scripts-boot-reembed.test.ts +4 -0
  233. package/src/tests/scripts-embeddings.test.ts +4 -0
  234. package/src/tests/scripts-import-allowlist.test.ts +44 -1
  235. package/src/tests/scripts-mcp-e2e.test.ts +80 -0
  236. package/src/tests/scripts-only-gating.test.ts +2 -0
  237. package/src/tests/scripts-runtime-identity.test.ts +1 -1
  238. package/src/tests/slack-blocks.test.ts +12 -1
  239. package/src/tests/slack-render-v2.test.ts +76 -3
  240. package/src/tests/slack-router.test.ts +17 -2
  241. package/src/tests/slack-steering.test.ts +19 -0
  242. package/src/tests/status.test.ts +12 -0
  243. package/src/tests/steering-enabled.test.ts +6 -8
  244. package/src/tests/task-tool-preload-mcp.test.ts +4 -1
  245. package/src/tests/tool-annotations.test.ts +1 -1
  246. package/src/tests/tool-registrar-no-input.test.ts +42 -1
  247. package/src/tools/defer-task.ts +1 -1
  248. package/src/tools/extension-common.ts +84 -0
  249. package/src/tools/extension-install.ts +65 -0
  250. package/src/tools/extension-list.ts +66 -0
  251. package/src/tools/script-common.ts +43 -18
  252. package/src/tools/send-task.ts +69 -107
  253. package/src/tools/task-action.ts +43 -22
  254. package/src/tools/task-tool-ctx.ts +2 -0
  255. package/src/tools/tool-config.ts +3 -1
  256. package/src/tools/utils.ts +140 -16
  257. package/src/types.ts +133 -0
  258. package/src/utils/multi-runtime.ts +2 -2
  259. package/src/utils/steering-enabled.ts +4 -4
  260. package/src/workflows/executors/agent-task.ts +13 -3
  261. package/templates/extensions/README.md +17 -0
  262. package/templates/extensions/notify-on-complete/README.md +11 -0
  263. package/templates/extensions/notify-on-complete/hooks.ts +61 -0
  264. package/templates/extensions/notify-on-complete/manifest.json +7 -0
  265. package/templates/extensions/require-ticket-ref/README.md +11 -0
  266. package/templates/extensions/require-ticket-ref/hooks.ts +30 -0
  267. package/templates/extensions/require-ticket-ref/manifest.json +7 -0
  268. package/templates/extensions/require-verification-note/README.md +11 -0
  269. package/templates/extensions/require-verification-note/hooks.ts +32 -0
  270. package/templates/extensions/require-verification-note/manifest.json +7 -0
  271. package/templates/skills/scheduling/content.md +2 -0
  272. package/templates/skills/swarm-extensions/config.json +14 -0
  273. package/templates/skills/swarm-extensions/content.md +120 -0
  274. package/dist/cli-b8x19vbh.js +0 -823
  275. package/dist/cli-n1yj5fd5.js +0 -164
  276. package/dist/cli-q04q68be.js +0 -3066
  277. package/dist/cli-qgm2pfpq.js +0 -6377
  278. package/dist/cli-rnsr8asm.js +0 -4323
  279. package/dist/cli-rwp3ksm7.js +0 -545
  280. package/dist/cli-xjb9wghj.js +0 -668
  281. package/dist/cli-xvkmedgn.js +0 -332
  282. package/dist/cli-ydk0wjj8.js +0 -338
  283. package/dist/handlers-vr1djg4n.js +0 -41
  284. package/dist/{claude-managed-setup-f5pmsyrp.js → claude-managed-setup-48dwg9bv.js} +3 -3
  285. package/dist/{cli-81r683bv.js → cli-ch38q5z4.js} +0 -0
  286. package/dist/{cli-wk0gjrn3.js → cli-kj76p1ez.js} +6 -6
  287. package/dist/{cli-c71mg51n.js → cli-r2fpe7cx.js} +5 -5
  288. package/dist/{cli-8qx7jnrk.js → cli-y6x0gqtg.js} +16 -16
  289. package/dist/{index-aa3sa6s8.js → index-2wsdsnmm.js} +1 -1
  290. package/dist/{mistral-conversations-4ryk7h83.js → mistral-conversations-qhr84ph3.js} +9 -9
  291. package/dist/{openai-completions-qdcmkfnr.js → openai-completions-32rdwmze.js} +17 -17
  292. package/dist/{openrouter-images-qx18fyp8.js → openrouter-images-9f9ek4rw.js} +3 -3
  293. package/dist/{pi-messages-79jvzz1d.js → pi-messages-b1mbr6gp.js} +4 -4
@@ -0,0 +1,431 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { Readable } from "node:stream";
4
+ import { closeDb, createAgent, getDbClient, initDb } from "../be/db";
5
+ import { getExtensionByName, insertExtensionRun, setExtensionState } from "../be/extensions/db";
6
+ import { stopExtensionRuntime } from "../extensions/lifecycle";
7
+ import { handleCore } from "../http/core";
8
+ import { handleExtensions } from "../http/extensions";
9
+ import { getPathSegments, parseQueryParams } from "../http/utils";
10
+ import type { User } from "../types";
11
+ import { setRequestAuth } from "../utils/request-auth-context";
12
+ import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
13
+ import { loadBundleFixture } from "./fixtures/extensions/load";
14
+
15
+ const TEST_DB_PATH = "./test-extensions-http.sqlite";
16
+ const API_KEY = "test-extensions-http-key-1234567890";
17
+
18
+ async function removeDbFiles(): Promise<void> {
19
+ for (const suffix of ["", "-wal", "-shm"]) {
20
+ await Bun.file(TEST_DB_PATH + suffix)
21
+ .delete()
22
+ .catch(() => {});
23
+ }
24
+ }
25
+
26
+ type TestResponse = {
27
+ status: number;
28
+ text: string;
29
+ json: () => Promise<Record<string, unknown>>;
30
+ };
31
+
32
+ async function dispatch(
33
+ path: string,
34
+ init: RequestInit & { agentId?: string; asUser?: string } = {},
35
+ ): Promise<TestResponse> {
36
+ const headers: Record<string, string> = {
37
+ Authorization: `Bearer ${API_KEY}`,
38
+ "Content-Type": "application/json",
39
+ ...((init.headers as Record<string, string>) ?? {}),
40
+ };
41
+ if (init.agentId) headers["X-Agent-ID"] = init.agentId;
42
+ const req = Readable.from(init.body ? [Buffer.from(String(init.body))] : []) as IncomingMessage;
43
+ req.method = init.method ?? "GET";
44
+ req.url = path;
45
+ req.headers = Object.fromEntries(
46
+ Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
47
+ );
48
+
49
+ let status = 200;
50
+ let text = "";
51
+ const res = {
52
+ headersSent: false,
53
+ writableEnded: false,
54
+ setHeader() {},
55
+ writeHead(code: number) {
56
+ status = code;
57
+ this.headersSent = true;
58
+ return this;
59
+ },
60
+ end(chunk?: unknown) {
61
+ if (chunk !== undefined) text += String(chunk);
62
+ this.writableEnded = true;
63
+ return this;
64
+ },
65
+ } as unknown as ServerResponse;
66
+
67
+ const agentId = req.headers["x-agent-id"] as string | undefined;
68
+ if (!(await handleCore(req, res, agentId, API_KEY))) {
69
+ if (init.asUser) {
70
+ setRequestAuth(req, { kind: "user", userId: init.asUser, user: { id: init.asUser } as User });
71
+ }
72
+ const pathSegments = getPathSegments(path);
73
+ const queryParams = parseQueryParams(path);
74
+ if (!(await handleExtensions(req, res, pathSegments, queryParams, agentId))) {
75
+ res.writeHead(404);
76
+ res.end("Not Found");
77
+ }
78
+ }
79
+
80
+ return {
81
+ status,
82
+ text,
83
+ json: async () => JSON.parse(text) as Record<string, unknown>,
84
+ };
85
+ }
86
+
87
+ async function install(name = "minimal", agentId?: string): Promise<TestResponse> {
88
+ return dispatch("/api/extensions/install", {
89
+ method: "POST",
90
+ agentId,
91
+ body: JSON.stringify(await loadBundleFixture(name)),
92
+ });
93
+ }
94
+
95
+ let leadId: string;
96
+ let workerId: string;
97
+ let savedEnv: NodeJS.ProcessEnv;
98
+
99
+ beforeAll(async () => {
100
+ savedEnv = { ...process.env };
101
+ await removeDbFiles();
102
+ initDb(TEST_DB_PATH);
103
+ process.env.AGENT_SWARM_API_KEY = API_KEY;
104
+ delete process.env.API_KEY;
105
+ refreshSecretScrubberCache();
106
+ leadId = (await createAgent({ name: "extensions-lead", isLead: true, status: "idle" })).id;
107
+ workerId = (await createAgent({ name: "extensions-worker", isLead: false, status: "idle" })).id;
108
+ });
109
+
110
+ afterAll(async () => {
111
+ await stopExtensionRuntime();
112
+ closeDb();
113
+ await removeDbFiles();
114
+ for (const key of Object.keys(process.env)) {
115
+ if (!(key in savedEnv)) delete process.env[key];
116
+ }
117
+ Object.assign(process.env, savedEnv);
118
+ refreshSecretScrubberCache();
119
+ });
120
+
121
+ beforeEach(async () => {
122
+ await stopExtensionRuntime();
123
+ await getDbClient().run("DELETE FROM extensions");
124
+ });
125
+
126
+ describe("/api/extensions HTTP", () => {
127
+ test("operator install, list, get, versions, and changed hooks round-trip", async () => {
128
+ const first = await install();
129
+ expect(first.status).toBe(200);
130
+ const firstBody = await first.json();
131
+ const extension = firstBody.extension as { id: string; version: number };
132
+ expect(extension.version).toBe(1);
133
+
134
+ const list = await dispatch("/api/extensions");
135
+ expect(list.status).toBe(200);
136
+ expect((await list.json()).extensions).toHaveLength(1);
137
+
138
+ const detail = await dispatch(`/api/extensions/${extension.id}`);
139
+ expect(detail.status).toBe(200);
140
+ const detailBody = await detail.json();
141
+ expect((detailBody.manifest as { name: string }).name).toBe("minimal");
142
+ expect((detailBody.files as Record<string, string>)["hooks.ts"]).toContain("SwarmExtension");
143
+
144
+ const changedBundle = await loadBundleFixture("minimal");
145
+ changedBundle.files["hooks.ts"] += "\n// changed hooks\n";
146
+ const changed = await dispatch("/api/extensions/install", {
147
+ method: "POST",
148
+ body: JSON.stringify(changedBundle),
149
+ });
150
+ expect(changed.status).toBe(200);
151
+ expect(((await changed.json()).extension as { version: number }).version).toBe(2);
152
+
153
+ const versions = await dispatch(`/api/extensions/${extension.id}/versions`);
154
+ expect(versions.status).toBe(200);
155
+ expect((await versions.json()).versions).toHaveLength(2);
156
+ });
157
+
158
+ test("GET runs returns both rows newest-first", async () => {
159
+ await install();
160
+ const extension = await getExtensionByName("minimal");
161
+ const first = await insertExtensionRun({
162
+ extensionId: extension!.id,
163
+ version: 1,
164
+ event: "pre.task.create",
165
+ action: "continue",
166
+ message: "First run",
167
+ });
168
+ const second = await insertExtensionRun({
169
+ extensionId: extension!.id,
170
+ version: 1,
171
+ event: "post.task.created",
172
+ action: "continue",
173
+ message: "Second run",
174
+ });
175
+
176
+ const response = await dispatch(`/api/extensions/${extension!.id}/runs`);
177
+ expect(response.status).toBe(200);
178
+ expect((await response.json()).runs).toEqual([second, first]);
179
+ });
180
+
181
+ test("lead agents may install, workers may not, and agent installs stay disabled", async () => {
182
+ expect((await install("minimal", workerId)).status).toBe(403);
183
+ const response = await install("minimal", leadId);
184
+ expect(response.status).toBe(200);
185
+ const extension = (await response.json()).extension as {
186
+ enabled: boolean;
187
+ createdByAgentId: string | null;
188
+ };
189
+ expect(extension.enabled).toBe(false);
190
+ expect(extension.createdByAgentId).toBe(leadId);
191
+ });
192
+
193
+ test("operator reinstall activates a new version when the extension is enabled", async () => {
194
+ await install();
195
+ const existing = await getExtensionByName("minimal");
196
+ await setExtensionState(existing!.id, { enabled: true, status: "enabled" });
197
+
198
+ const changedBundle = await loadBundleFixture("minimal");
199
+ changedBundle.files["hooks.ts"] += "\n// operator update\n";
200
+ const response = await dispatch("/api/extensions/install", {
201
+ method: "POST",
202
+ body: JSON.stringify(changedBundle),
203
+ });
204
+
205
+ expect(response.status).toBe(200);
206
+ const extension = (await response.json()).extension as {
207
+ version: number;
208
+ activeVersion: number;
209
+ enabled: boolean;
210
+ };
211
+ expect(extension).toMatchObject({ version: 2, activeVersion: 2, enabled: true });
212
+ });
213
+
214
+ test("lead reinstall never activates a new version", async () => {
215
+ await install();
216
+ const existing = await getExtensionByName("minimal");
217
+ await setExtensionState(existing!.id, { enabled: true, status: "enabled" });
218
+
219
+ const changedBundle = await loadBundleFixture("minimal");
220
+ changedBundle.files["hooks.ts"] += "\n// lead update\n";
221
+ const response = await dispatch("/api/extensions/install", {
222
+ method: "POST",
223
+ agentId: leadId,
224
+ body: JSON.stringify(changedBundle),
225
+ });
226
+
227
+ expect(response.status).toBe(200);
228
+ const extension = (await response.json()).extension as {
229
+ version: number;
230
+ activeVersion: number;
231
+ enabled: boolean;
232
+ };
233
+ expect(extension).toMatchObject({ version: 2, activeVersion: 1, enabled: true });
234
+ });
235
+
236
+ test("PATCH updates priority and DELETE rejects enabled extensions", async () => {
237
+ await install();
238
+ const extension = await getExtensionByName("minimal");
239
+ expect(extension).not.toBeNull();
240
+
241
+ const patched = await dispatch(`/api/extensions/${extension!.id}`, {
242
+ method: "PATCH",
243
+ body: JSON.stringify({ priority: 10 }),
244
+ });
245
+ expect(patched.status).toBe(200);
246
+ expect(((await patched.json()).extension as { priority: number }).priority).toBe(10);
247
+
248
+ await setExtensionState(extension!.id, { enabled: true, status: "enabled" });
249
+ const rejected = await dispatch(`/api/extensions/${extension!.id}`, { method: "DELETE" });
250
+ expect(rejected.status).toBe(409);
251
+ expect((await rejected.json()).error).toContain("Disable");
252
+ });
253
+
254
+ test("type definitions are plain text and activation routes are operator-only", async () => {
255
+ await install();
256
+ const extension = await getExtensionByName("minimal");
257
+ const types = await dispatch("/api/extensions/type-defs");
258
+ expect(types.status).toBe(200);
259
+ expect(types.text).toContain('declare module "swarm-extension"');
260
+
261
+ expect(
262
+ (
263
+ await dispatch(`/api/extensions/${extension!.id}/enable`, {
264
+ method: "POST",
265
+ agentId: leadId,
266
+ })
267
+ ).status,
268
+ ).toBe(403);
269
+ expect(
270
+ (await dispatch(`/api/extensions/${extension!.id}/enable`, { method: "POST" })).status,
271
+ ).toBe(200);
272
+ expect(
273
+ (await dispatch(`/api/extensions/${extension!.id}/disable`, { method: "POST" })).status,
274
+ ).toBe(200);
275
+ expect(
276
+ (
277
+ await dispatch(`/api/extensions/${extension!.id}/activate-version`, {
278
+ method: "POST",
279
+ body: JSON.stringify({ version: 1 }),
280
+ })
281
+ ).status,
282
+ ).toBe(200);
283
+ });
284
+
285
+ test("dashboard users can install and activate extensions", async () => {
286
+ const installed = await install("minimal", undefined);
287
+ expect(installed.status).toBe(200);
288
+ const extension = await getExtensionByName("minimal");
289
+ const patched = await dispatch(`/api/extensions/${extension!.id}`, {
290
+ method: "PATCH",
291
+ asUser: "user-1",
292
+ body: JSON.stringify({ priority: 7 }),
293
+ });
294
+ expect(patched.status).toBe(200);
295
+ expect(
296
+ (
297
+ await dispatch(`/api/extensions/${extension!.id}/enable`, {
298
+ method: "POST",
299
+ asUser: "user-1",
300
+ })
301
+ ).status,
302
+ ).toBe(200);
303
+ });
304
+
305
+ test("enable returns 400 for config that fails the hooks schema", async () => {
306
+ const bundle = await loadBundleFixture("minimal");
307
+ bundle.manifest = { ...bundle.manifest, name: "configured" };
308
+ bundle.files["hooks.ts"] = `
309
+ import { z } from "zod";
310
+ import type { SwarmExtension } from "swarm-extension";
311
+ export const config = z.object({ channelId: z.string() });
312
+ const extension: SwarmExtension = () => {};
313
+ export default extension;
314
+ `;
315
+ const installed = await dispatch("/api/extensions/install", {
316
+ method: "POST",
317
+ body: JSON.stringify(bundle),
318
+ });
319
+ const extension = (await installed.json()).extension as { id: string };
320
+
321
+ const invalid = await dispatch(`/api/extensions/${extension.id}/enable`, { method: "POST" });
322
+ expect(invalid.status).toBe(400);
323
+ expect((await invalid.json()).error).toContain("config is invalid");
324
+
325
+ expect(
326
+ (
327
+ await dispatch(`/api/extensions/${extension.id}`, {
328
+ method: "PATCH",
329
+ body: JSON.stringify({ config: { channelId: "C1" } }),
330
+ })
331
+ ).status,
332
+ ).toBe(200);
333
+ expect(
334
+ (await dispatch(`/api/extensions/${extension.id}/enable`, { method: "POST" })).status,
335
+ ).toBe(200);
336
+ });
337
+
338
+ test("configJson is scrubbed on every read path", async () => {
339
+ process.env.EXT_QA_TOKEN = "extqaTOKENvalue_1234567890abcdef";
340
+ refreshSecretScrubberCache();
341
+ try {
342
+ const bundle = await loadBundleFixture("minimal");
343
+ const installed = await dispatch("/api/extensions/install", {
344
+ method: "POST",
345
+ body: JSON.stringify({
346
+ ...bundle,
347
+ config: { token: "extqaTOKENvalue_1234567890abcdef", other: "a" },
348
+ }),
349
+ });
350
+ expect(installed.status).toBe(200);
351
+ const extension = await getExtensionByName("minimal");
352
+ expect(extension!.configJson).toContain("extqaTOKENvalue_1234567890abcdef");
353
+ for (const body of [
354
+ await installed.json(),
355
+ await (await dispatch(`/api/extensions/${extension!.id}`)).json(),
356
+ await (
357
+ await dispatch(`/api/extensions/${extension!.id}`, {
358
+ method: "PATCH",
359
+ body: JSON.stringify({ priority: 3 }),
360
+ })
361
+ ).json(),
362
+ ]) {
363
+ expect(JSON.stringify(body)).not.toContain("extqaTOKENvalue_1234567890abcdef");
364
+ expect(JSON.stringify(body)).toContain("[REDACTED:EXT_QA_TOKEN]");
365
+ }
366
+ const list = JSON.stringify(await (await dispatch("/api/extensions")).json());
367
+ expect(list).not.toContain("extqaTOKENvalue_1234567890abcdef");
368
+
369
+ const patched = await dispatch(`/api/extensions/${extension!.id}`, {
370
+ method: "PATCH",
371
+ body: JSON.stringify({
372
+ config: { token: "[REDACTED:EXT_QA_TOKEN]", other: "b" },
373
+ }),
374
+ });
375
+ expect(patched.status).toBe(200);
376
+ expect(JSON.parse((await getExtensionByName("minimal"))!.configJson)).toEqual({
377
+ token: "extqaTOKENvalue_1234567890abcdef",
378
+ other: "b",
379
+ });
380
+
381
+ // Nested objects, arrays, and structural (lowercase) markers survive a round trip.
382
+ const ghToken = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
383
+ const nested = await dispatch(`/api/extensions/${extension!.id}`, {
384
+ method: "PATCH",
385
+ body: JSON.stringify({
386
+ config: {
387
+ token: "extqaTOKENvalue_1234567890abcdef",
388
+ github: { token: ghToken, list: [ghToken, "plain"] },
389
+ },
390
+ }),
391
+ });
392
+ expect(nested.status).toBe(200);
393
+ const nestedBody = JSON.stringify(await nested.json());
394
+ expect(nestedBody).not.toContain(ghToken);
395
+ expect(nestedBody).toContain("[REDACTED:github_token]");
396
+ const roundTrip = await dispatch(`/api/extensions/${extension!.id}`, {
397
+ method: "PATCH",
398
+ body: JSON.stringify({
399
+ config: {
400
+ token: "[REDACTED:EXT_QA_TOKEN]",
401
+ github: {
402
+ token: "[REDACTED:github_token]",
403
+ list: ["[REDACTED:github_token]", "changed"],
404
+ },
405
+ },
406
+ }),
407
+ });
408
+ expect(roundTrip.status).toBe(200);
409
+ expect(JSON.parse((await getExtensionByName("minimal"))!.configJson)).toEqual({
410
+ token: "extqaTOKENvalue_1234567890abcdef",
411
+ github: { token: ghToken, list: [ghToken, "changed"] },
412
+ });
413
+ } finally {
414
+ delete process.env.EXT_QA_TOKEN;
415
+ refreshSecretScrubberCache();
416
+ }
417
+ });
418
+
419
+ test("invalid fixtures return readable diagnostics", async () => {
420
+ const expected = [
421
+ ["bad-import", "node:fs"],
422
+ ["worker-runtime", 'runtime "worker" is not supported in v1'],
423
+ ["reserved-assets", "assets.skills is not supported in v1"],
424
+ ];
425
+ for (const [fixture, diagnostic] of expected) {
426
+ const response = await install(fixture);
427
+ expect(response.status).toBe(400);
428
+ expect(((await response.json()).diagnostics as string[]).join("\n")).toContain(diagnostic!);
429
+ }
430
+ });
431
+ });
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { can } from "@/rbac/can";
3
+ import {
4
+ grantLeadEquivalence,
5
+ hasLeadEquivalence,
6
+ revokeLeadEquivalence,
7
+ } from "@/rbac/elevated-agents";
8
+
9
+ const extensionAgentId = "11111111-2222-4333-8444-555555555555";
10
+
11
+ describe("extension agents act with lead privileges while registered", () => {
12
+ test("lead-only verbs follow the registry", () => {
13
+ const check = {
14
+ principal: { kind: "agent" as const, agentId: extensionAgentId, isLead: false },
15
+ verb: "integration.slack.post" as const,
16
+ resource: { kind: "none" as const },
17
+ source: "mcp" as const,
18
+ };
19
+ expect(hasLeadEquivalence(extensionAgentId)).toBe(false);
20
+ expect(can(check).allow).toBe(false);
21
+
22
+ grantLeadEquivalence(extensionAgentId);
23
+ expect(hasLeadEquivalence(extensionAgentId)).toBe(true);
24
+ expect(can(check).allow).toBe(true);
25
+ expect(can({ ...check, verb: "extension.write", resource: { kind: "none" } }).allow).toBe(true);
26
+
27
+ revokeLeadEquivalence(extensionAgentId);
28
+ expect(can(check).allow).toBe(false);
29
+ });
30
+
31
+ test("operators and plain workers are unaffected", () => {
32
+ expect(
33
+ can({
34
+ principal: { kind: "agent", agentId: "worker-1", isLead: false },
35
+ verb: "integration.slack.post",
36
+ resource: { kind: "none" },
37
+ source: "mcp",
38
+ }).allow,
39
+ ).toBe(false);
40
+ expect(
41
+ can({
42
+ principal: { kind: "operator" },
43
+ verb: "extension.activate",
44
+ resource: { kind: "none" },
45
+ source: "http",
46
+ }).allow,
47
+ ).toBe(true);
48
+ });
49
+ });
@@ -0,0 +1,209 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { closeDb, createTaskExtended, getAllAgents, getDbClient, getKv, initDb } from "../be/db";
3
+ import {
4
+ getExtensionById,
5
+ installExtension,
6
+ listExtensionRuns,
7
+ setExtensionState,
8
+ } from "../be/extensions/db";
9
+ import { dispatchPre, listRegistered } from "../extensions/dispatcher";
10
+ import {
11
+ activateVersion,
12
+ disableExtension,
13
+ enableExtension,
14
+ loadEnabledExtensions,
15
+ stopExtensionRuntime,
16
+ } from "../extensions/lifecycle";
17
+ import type { ExtensionInstallBody } from "../types";
18
+ import { loadBundleFixture } from "./fixtures/extensions/load";
19
+
20
+ const TEST_DB_PATH = "./test-extensions-lifecycle.sqlite";
21
+
22
+ async function removeDbFiles(): Promise<void> {
23
+ for (const suffix of ["", "-wal", "-shm"]) {
24
+ await Bun.file(TEST_DB_PATH + suffix)
25
+ .delete()
26
+ .catch(() => {});
27
+ }
28
+ }
29
+
30
+ async function install(bundle: ExtensionInstallBody) {
31
+ return (await installExtension(bundle)).extension;
32
+ }
33
+
34
+ async function waitFor(predicate: () => Promise<boolean>, timeoutMs = 10_000): Promise<void> {
35
+ const deadline = Date.now() + timeoutMs;
36
+ while (!(await predicate())) {
37
+ if (Date.now() >= deadline) throw new Error("Timed out while waiting for extension event");
38
+ await Bun.sleep(20);
39
+ }
40
+ }
41
+
42
+ describe("extension lifecycle", () => {
43
+ beforeAll(async () => {
44
+ await removeDbFiles();
45
+ initDb(TEST_DB_PATH);
46
+ });
47
+
48
+ afterAll(async () => {
49
+ await stopExtensionRuntime();
50
+ closeDb();
51
+ await removeDbFiles();
52
+ });
53
+
54
+ beforeEach(async () => {
55
+ await stopExtensionRuntime();
56
+ const client = getDbClient();
57
+ await client.run("DELETE FROM agent_tasks");
58
+ await client.run("DELETE FROM extensions");
59
+ await client.run("DELETE FROM kv_entries");
60
+ });
61
+
62
+ test("enable creates an offline extension agent and disable unregisters", async () => {
63
+ const extension = await install(await loadBundleFixture("minimal"));
64
+ const enabled = await enableExtension(extension.id);
65
+ expect(enabled).toMatchObject({ enabled: true, status: "enabled" });
66
+ expect(listRegistered().map((loaded) => loaded.record.id)).toEqual([extension.id]);
67
+
68
+ const agent = (await getAllAgents()).find((candidate) => candidate.name === "ext:minimal");
69
+ expect(agent).toMatchObject({
70
+ id: enabled.agentId,
71
+ status: "offline",
72
+ role: "extension",
73
+ description: "System agent for extension minimal",
74
+ maxTasks: 0,
75
+ });
76
+
77
+ expect(await disableExtension(extension.id)).toMatchObject({
78
+ enabled: false,
79
+ status: "disabled",
80
+ });
81
+ expect(listRegistered()).toHaveLength(0);
82
+ });
83
+
84
+ test("activate-version swaps source and reloads an enabled extension", async () => {
85
+ const bundle = await loadBundleFixture("priority-a");
86
+ const extension = await install(bundle);
87
+ await enableExtension(extension.id);
88
+
89
+ const versionTwo: ExtensionInstallBody = {
90
+ manifest: bundle.manifest,
91
+ files: {
92
+ "hooks.ts": `
93
+ import { block, type SwarmExtension } from "swarm-extension";
94
+ const extension: SwarmExtension = (api) => {
95
+ api.on("pre.task.create", () => block("version two"));
96
+ };
97
+ export default extension;
98
+ `,
99
+ },
100
+ };
101
+ await installExtension(versionTwo);
102
+
103
+ expect(await activateVersion(extension.id, 2)).toMatchObject({
104
+ activeVersion: 2,
105
+ enabled: true,
106
+ });
107
+ expect(
108
+ await dispatchPre("pre.task.create", {
109
+ options: {},
110
+ description: "start",
111
+ origin: "rest",
112
+ }),
113
+ ).toMatchObject({ action: "block", reason: "version two" });
114
+
115
+ expect(await activateVersion(extension.id, 1)).toMatchObject({
116
+ activeVersion: 1,
117
+ enabled: true,
118
+ });
119
+ expect(
120
+ await dispatchPre("pre.task.create", {
121
+ options: {},
122
+ description: "start",
123
+ origin: "rest",
124
+ }),
125
+ ).toEqual({ action: "modify", data: { description: "starta" } });
126
+ });
127
+
128
+ test("boot loads enabled rows and continues after one load error", async () => {
129
+ const valid = await install(await loadBundleFixture("post-logger"));
130
+ const invalidBundle = await loadBundleFixture("minimal");
131
+ invalidBundle.manifest = { ...invalidBundle.manifest, name: "boot-invalid" };
132
+ invalidBundle.files["hooks.ts"] = "export const invalid = true;";
133
+ const invalid = await install(invalidBundle);
134
+ await setExtensionState(valid.id, { enabled: true, status: "enabled" });
135
+ await setExtensionState(invalid.id, { enabled: true, status: "enabled" });
136
+
137
+ await loadEnabledExtensions();
138
+
139
+ expect(listRegistered().map((loaded) => loaded.record.id)).toEqual([valid.id]);
140
+ expect(await getExtensionById(invalid.id)).toMatchObject({ enabled: true, status: "error" });
141
+ expect(await listExtensionRuns(invalid.id)).toMatchObject([{ action: "load-error" }]);
142
+
143
+ await createTaskExtended("boot-loaded extension test", { source: "api" });
144
+ await waitFor(async () =>
145
+ (await listExtensionRuns(valid.id)).some(
146
+ (run) => run.event === "post.task.created" && run.action === "continue",
147
+ ),
148
+ );
149
+ });
150
+
151
+ test("the post bridge dispatches post.task.created after commit", async () => {
152
+ const extension = await install(await loadBundleFixture("post-logger"));
153
+ const enabled = await enableExtension(extension.id);
154
+ await createTaskExtended("extension post bridge test", { source: "api" });
155
+
156
+ await waitFor(async () =>
157
+ (await listExtensionRuns(extension.id)).some(
158
+ (run) => run.event === "post.task.created" && run.action === "continue",
159
+ ),
160
+ );
161
+ expect(await getExtensionById(extension.id)).toMatchObject({
162
+ enabled: true,
163
+ consecutiveFailures: 0,
164
+ });
165
+ expect(await getKv(`task:agent:${enabled.agentId}`, "ext:post-logger:created")).toMatchObject({
166
+ value: 1,
167
+ });
168
+ });
169
+
170
+ test("five post handler failures auto-disable and re-enable resets the counter", async () => {
171
+ const extension = await install(await loadBundleFixture("throws"));
172
+ await enableExtension(extension.id);
173
+
174
+ for (let index = 0; index < 5; index += 1) {
175
+ await createTaskExtended(`throwing extension test ${index}`, { source: "api" });
176
+ await waitFor(
177
+ async () => (await getExtensionById(extension.id))!.consecutiveFailures > index,
178
+ );
179
+ }
180
+
181
+ expect(await getExtensionById(extension.id)).toMatchObject({
182
+ enabled: false,
183
+ status: "auto-disabled",
184
+ consecutiveFailures: 5,
185
+ });
186
+ expect(listRegistered()).toHaveLength(0);
187
+ expect(await enableExtension(extension.id)).toMatchObject({
188
+ enabled: true,
189
+ status: "enabled",
190
+ consecutiveFailures: 0,
191
+ });
192
+ });
193
+
194
+ test("the post bridge skips the extension that created a task", async () => {
195
+ const first = await install(await loadBundleFixture("post-logger"));
196
+ const secondBundle = await loadBundleFixture("post-logger");
197
+ secondBundle.manifest = { ...secondBundle.manifest, name: "other-logger" };
198
+ const second = await install(secondBundle);
199
+ const creator = await enableExtension(first.id);
200
+ await enableExtension(second.id);
201
+ await createTaskExtended("extension-created task", {
202
+ source: "api",
203
+ creatorAgentId: creator.agentId!,
204
+ });
205
+ await waitFor(async () => (await listExtensionRuns(second.id)).length > 0);
206
+ expect(await listExtensionRuns(first.id)).toEqual([]);
207
+ expect(await listExtensionRuns(second.id)).toMatchObject([{ action: "continue" }]);
208
+ });
209
+ });