@desplega.ai/agent-swarm 2.4.5 → 2.5.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 (272) hide show
  1. package/README.md +4 -2
  2. package/dist/{acp-adapter-2wdkktv6.js → acp-adapter-11gp67nr.js} +8 -6
  3. package/dist/{actions-c40ebcm4.js → actions-8p2kze8j.js} +23 -21
  4. package/dist/{amp-adapter-wb67m95z.js → amp-adapter-z4qvhacj.js} +2 -2
  5. package/dist/{anthropic-messages-g6pqmqk4.js → anthropic-messages-txs190dd.js} +17 -20
  6. package/dist/{app-353vre8g.js → app-0qp0a2gk.js} +3 -3
  7. package/dist/{assistant-sj1njh23.js → assistant-jedd3ktm.js} +24 -22
  8. package/dist/{azure-openai-responses-9dbmj346.js → azure-openai-responses-9kfwhmn7.js} +18 -13
  9. package/dist/{boot-reembed-qdkvxeem.js → boot-reembed-4xwq5tg0.js} +1 -1
  10. package/dist/{boot-reembed-b9c3az3c.js → boot-reembed-n4wq5dd2.js} +1 -1
  11. package/dist/{boot-scrub-sweep-m3wh8eks.js → boot-scrub-sweep-69h90xcq.js} +4 -4
  12. package/dist/{boot-seeds-j2273s54.js → boot-seeds-3vn0m9sg.js} +4 -4
  13. package/dist/{claude-adapter-m7q0a085.js → claude-adapter-221mvad5.js} +8 -6
  14. package/dist/{claude-managed-adapter-5ptb6tzs.js → claude-managed-adapter-gt6pa35d.js} +2 -2
  15. package/dist/{claude-managed-setup-smrqk5j5.js → claude-managed-setup-c3hp36ph.js} +4 -4
  16. package/dist/{claude-sdk-session-38xnz21e.js → claude-sdk-session-8thjsk5e.js} +9012 -8928
  17. package/dist/{cli-b9fcxxev.js → cli-007cnhm1.js} +50 -4
  18. package/dist/{cli-j4rqdf2n.js → cli-03vpq45j.js} +2 -2
  19. package/dist/{cli-dk44dxec.js → cli-0y6f0ckq.js} +1 -1
  20. package/dist/{cli-kpw04va0.js → cli-0z3vjrpm.js} +1 -1
  21. package/dist/{cli-94gwyg7m.js → cli-179xcyky.js} +9 -9
  22. package/dist/{cli-3r8samv3.js → cli-1f0gp9vz.js} +3 -2
  23. package/dist/{cli-4zfqgjc5.js → cli-1pr672hz.js} +2 -2
  24. package/dist/{cli-0bqteszy.js → cli-2nf3b08t.js} +328 -25
  25. package/dist/{cli-43x64hv3.js → cli-36j4jnk3.js} +4 -4
  26. package/dist/{cli-k8g16gz3.js → cli-3qv8naq8.js} +115 -119
  27. package/dist/cli-3zwye9sn.js +1193 -0
  28. package/dist/{cli-60cgh1rv.js → cli-459fk97x.js} +7 -7
  29. package/dist/{cli-v8fjakwf.js → cli-4w24f7z3.js} +1 -1
  30. package/dist/{cli-n72rg53p.js → cli-5kzk3472.js} +812 -418
  31. package/dist/{cli-h7gc0b4r.js → cli-5m84zd2k.js} +55 -18
  32. package/dist/{cli-j99azg3g.js → cli-5rvg1ddk.js} +5 -5
  33. package/dist/{cli-dvkmp9bt.js → cli-6ctwrf1z.js} +20 -10
  34. package/dist/{cli-848hxdwj.js → cli-6rg2jn2y.js} +2 -1
  35. package/dist/{cli-fv5hepyy.js → cli-7bwtc6yx.js} +3 -3
  36. package/dist/{cli-ms85309d.js → cli-7g40g8hq.js} +11 -11
  37. package/dist/{cli-xpa64rek.js → cli-92mqenkc.js} +2362 -159
  38. package/dist/{cli-018r4jq6.js → cli-9ce2xw98.js} +8 -8
  39. package/dist/{cli-1syn2xy1.js → cli-bgdtv62r.js} +32 -18
  40. package/dist/{cli-wxbn3yfx.js → cli-cz5nmdxt.js} +2 -2
  41. package/dist/{cli-wt645v7q.js → cli-dabehxhp.js} +1 -1
  42. package/dist/{cli-vfha21c9.js → cli-e5bxnjps.js} +2 -2
  43. package/dist/{cli-wt0jf9hm.js → cli-fcc5qe3f.js} +553 -193
  44. package/dist/{cli-ftc1g5tj.js → cli-fes51ya2.js} +3 -3
  45. package/dist/{cli-3a45zcgm.js → cli-ftmq4nvk.js} +2 -2
  46. package/dist/{cli-37j04k05.js → cli-fy1jyyym.js} +13 -13
  47. package/dist/{cli-s95dknxh.js → cli-g1cw7xtt.js} +1 -1
  48. package/dist/{cli-74c1wpm0.js → cli-gpv60278.js} +1 -1
  49. package/dist/{cli-pemywhfq.js → cli-ha9rmwgq.js} +2 -1
  50. package/dist/{cli-g35m2xwm.js → cli-j1eghqfm.js} +2 -2
  51. package/dist/{cli-qfxc84gh.js → cli-jpxsrxcb.js} +1 -1
  52. package/dist/{cli-ejs3m1sx.js → cli-kxpfx2xr.js} +1 -1
  53. package/dist/{cli-p14m2cnp.js → cli-mbfj11fq.js} +1 -1
  54. package/dist/{cli-pnp2j5zg.js → cli-mmhj8vd7.js} +2 -2
  55. package/dist/{cli-xf234ws8.js → cli-n4c8e3s5.js} +2 -0
  56. package/dist/{cli-f01h25yt.js → cli-n6kmqngw.js} +2 -2
  57. package/dist/{cli-v0yrfmdh.js → cli-nnvjb17s.js} +3 -3
  58. package/dist/{cli-1dmcq2eh.js → cli-nzfnt2gs.js} +1 -1
  59. package/dist/{cli-625tb7aq.js → cli-p859hcc8.js} +2 -2
  60. package/dist/{cli-smvcw9z5.js → cli-pasyvjmm.js} +1 -1
  61. package/dist/cli-pkaqk57w.js +128 -0
  62. package/dist/{cli-8nyhf7za.js → cli-qqzmjxkf.js} +129 -105
  63. package/dist/{cli-9yqgm9nd.js → cli-r25qpaxt.js} +161 -63
  64. package/dist/{cli-eww120g8.js → cli-rh5smt8t.js} +18 -4
  65. package/dist/{cli-qwkcwjdn.js → cli-rycdaj0a.js} +1 -1
  66. package/dist/{cli-a92r1127.js → cli-s4sntwt4.js} +2 -2
  67. package/dist/{cli-be2161h1.js → cli-segs7hx9.js} +6 -6
  68. package/dist/{cli-36ckw0wx.js → cli-stxdrqs2.js} +2 -2
  69. package/dist/{cli-z7d29rw9.js → cli-sy8f9705.js} +4 -4
  70. package/dist/{cli-1m17dc9d.js → cli-ta8bd7th.js} +2 -2
  71. package/dist/{cli-st8aw1m6.js → cli-we0ra057.js} +4 -4
  72. package/dist/{cli-7c9qs530.js → cli-x21dqn18.js} +5 -5
  73. package/dist/{cli-1sp861s4.js → cli-x6vf4sad.js} +11 -11
  74. package/dist/{cli-d2mb5s8a.js → cli-x77gen2p.js} +680 -440
  75. package/dist/{cli-tw4p90jb.js → cli-xawhpgc9.js} +5 -5
  76. package/dist/{cli-f0g3pgvf.js → cli-xd4eqtr2.js} +2 -2
  77. package/dist/{cli-1f3qzw6y.js → cli-xt768r5m.js} +2 -2
  78. package/dist/{cli-cxxe61f6.js → cli-y4x8ww6s.js} +2 -2
  79. package/dist/{cli-bnes4z6b.js → cli-y9jvxfhk.js} +3 -3
  80. package/dist/{cli-qwhy2qma.js → cli-z63a269p.js} +2 -2
  81. package/dist/{cli-sm8rses9.js → cli-zfdp01hn.js} +8 -5
  82. package/dist/cli.js +29 -27
  83. package/dist/{cloudflare-workers-ai-system-one-ch2wxfcp.js → cloudflare-workers-ai-system-one-43v9qkjk.js} +7 -2
  84. package/dist/{codex-adapter-tn6k5mkg.js → codex-adapter-tcg1q3jk.js} +6 -4
  85. package/dist/{codex-login-m819c41r.js → codex-login-rn7c5gxt.js} +1 -1
  86. package/dist/{codex-session-runner-7vjt7jbw.js → codex-session-runner-xfx4wd9x.js} +7 -5
  87. package/dist/{commands-t7rz9146.js → commands-ew83nj70.js} +2 -2
  88. package/dist/{controller-m2jasma5.js → controller-gnq1k1mf.js} +4 -4
  89. package/dist/{controllers-connect-9ftpex99.js → controllers-connect-tw1dan9r.js} +25 -23
  90. package/dist/{cursor-adapter-mjv79e4z.js → cursor-adapter-yq5xgfcg.js} +1 -1
  91. package/dist/{db-6sfrn9g3.js → db-7gbc53fd.js} +14 -2
  92. package/dist/{db-rn673r06.js → db-t86xrnes.js} +3 -3
  93. package/dist/{devin-adapter-zgqadvnm.js → devin-adapter-4ycy4vrf.js} +1 -1
  94. package/dist/{dispatcher-c155xz0p.js → dispatcher-64bdyhps.js} +22 -20
  95. package/dist/{docker-fkgtr75t.js → docker-nfy2gc8t.js} +2 -2
  96. package/dist/{dsh-adapter-3mmszc1x.js → dsh-adapter-tvhc4k2d.js} +1 -1
  97. package/dist/{e2b-16d6ch39.js → e2b-9gkdhm3c.js} +2 -2
  98. package/dist/{e2b-zn0b4w5w.js → e2b-cjtf8k1z.js} +2 -2
  99. package/dist/{easter-egg-3d-5cgynaja.js → easter-egg-3d-e4qv8dxj.js} +1 -1
  100. package/dist/{event-bus-9vpk8gmq.js → event-bus-3z78ep5n.js} +1 -1
  101. package/dist/{execute-t4e6m3rw.js → execute-0xpz8950.js} +85 -23
  102. package/dist/{external-channel-35k6tkv8.js → external-channel-e9nw24zv.js} +2 -2
  103. package/dist/{google-generative-ai-bmq8gkf3.js → google-generative-ai-mk9vcpe1.js} +15 -15
  104. package/dist/{google-vertex-b1rvp52s.js → google-vertex-q580kz8k.js} +20 -20
  105. package/dist/{grok-adapter-tfrq7tg1.js → grok-adapter-v82srhrx.js} +9 -7
  106. package/dist/handlers-c9ajzhbn.js +41 -0
  107. package/dist/{heartbeat-dyg4npkp.js → heartbeat-2vqs1319.js} +25 -23
  108. package/dist/{hook-x26b31bg.js → hook-01nk13wx.js} +7 -5
  109. package/dist/{hook-drspbwpe.js → hook-jzpheegg.js} +9 -7
  110. package/dist/{http-pk972xyw.js → http-fc1k6ec8.js} +544 -76
  111. package/dist/index-07ef7nkm.js +42 -0
  112. package/dist/{index-g6tbb6ve.js → index-31ceyf42.js} +1 -1
  113. package/dist/index-7yts8n9s.js +43 -0
  114. package/dist/index-q3t40ryv.js +46 -0
  115. package/dist/{index-arytgj5q.js → index-sepja052.js} +1 -1
  116. package/dist/{index-jwxkkex2.js → index-zdycv3ja.js} +13 -11
  117. package/dist/{index-g216q6jr.js → index-zsyx51ap.js} +1 -4
  118. package/dist/{keepalive-7yrng7px.js → keepalive-5nh2rhmr.js} +4 -4
  119. package/dist/lead-aayq64jn.js +79 -0
  120. package/dist/{llama-cpp-classify-vv43gswy.js → llama-cpp-classify-ee7vmd7q.js} +4 -1
  121. package/dist/{maintenance-e6cemdqt.js → maintenance-hjhzdt3k.js} +1 -1
  122. package/dist/{mistral-conversations-1medfwv7.js → mistral-conversations-sd0xf1tn.js} +13 -13
  123. package/dist/{node-de6akczr.js → node-jzjj7r9x.js} +1 -1
  124. package/dist/{oauth-refresh-sweep-yw3xdbcj.js → oauth-refresh-sweep-y3n1knmd.js} +3 -3
  125. package/dist/{onboard-0a9y5yrc.js → onboard-1zq3m7db.js} +2 -2
  126. package/dist/{openai-codex-responses-de9pvs4d.js → openai-codex-responses-79s8xnbh.js} +29 -23
  127. package/dist/{openai-completions-mtqge3nf.js → openai-completions-tdbyts0x.js} +21 -20
  128. package/dist/{openai-decisions-ygk4kkxd.js → openai-decisions-bqhnjyfz.js} +2 -1
  129. package/dist/{openai-responses-dkyng71g.js → openai-responses-dm3t5vqp.js} +26 -17
  130. package/dist/{opencode-adapter-x4tqzhkh.js → opencode-adapter-e56086m1.js} +7 -5
  131. package/dist/{openrouter-images-wvv7camj.js → openrouter-images-ssttr1y4.js} +6 -3
  132. package/dist/{otel-impl-trjpkfb7.js → otel-impl-h9yyza47.js} +2 -2
  133. package/dist/{pi-messages-nhh1pkz2.js → pi-messages-3nf8bq1a.js} +6 -4
  134. package/dist/{pi-mono-adapter-mcfkn9vf.js → pi-mono-adapter-tzps8f38.js} +9 -37
  135. package/dist/{pricing-refresh-d2b5my0y.js → pricing-refresh-nevhat8h.js} +3 -3
  136. package/dist/{rbac-roles-nxh5ahwk.js → rbac-roles-4y6ngvzq.js} +1 -1
  137. package/dist/{rbac-roles-hey3yv9d.js → rbac-roles-exbg8xb6.js} +2 -2
  138. package/dist/{render-v2-rr3p2atg.js → render-v2-cfsh13z3.js} +3 -3
  139. package/dist/{resume-2efkf1s1.js → resume-5s2t0dbs.js} +10 -8
  140. package/dist/{runtime-xe3aqy4x.js → runtime-bt5pkpm1.js} +1 -1
  141. package/dist/{session-access-s2mttxpw.js → session-access-dnbyp3cn.js} +6 -4
  142. package/dist/{setup-vypma5yt.js → setup-1wj1y77j.js} +1 -1
  143. package/dist/{typesafe-system-one-2hgpayzy.js → typesafe-system-one-bre38hvs.js} +4 -2
  144. package/dist/{virtual-modules-4r3zkc20.js → virtual-modules-mnfbh5mr.js} +8 -2
  145. package/dist/{work-objects-rvhckdkp.js → work-objects-7mg6216g.js} +25 -23
  146. package/dist/worker-nwb789mg.js +79 -0
  147. package/dist/{x-y45mthwn.js → x-pbpezr0s.js} +2 -2
  148. package/openapi.json +785 -5
  149. package/package.json +9 -9
  150. package/src/assets/key.ts +17 -0
  151. package/src/azure-devops/handlers.ts +1 -1
  152. package/src/be/db/approvals.ts +18 -0
  153. package/src/be/db/session-moves.ts +164 -0
  154. package/src/be/db/session-tree.ts +384 -0
  155. package/src/be/db/tasks/read.ts +40 -4
  156. package/src/be/db/workflow-runs.ts +96 -0
  157. package/src/be/db.ts +80 -236
  158. package/src/be/onboarding.ts +3 -0
  159. package/src/be/skill-sync.ts +43 -2
  160. package/src/be/swarm-config-guard.ts +1 -0
  161. package/src/bitbucket/api.ts +81 -0
  162. package/src/bitbucket/auth.ts +64 -0
  163. package/src/bitbucket/handlers.ts +434 -0
  164. package/src/bitbucket/index.ts +28 -0
  165. package/src/bitbucket/templates.ts +134 -0
  166. package/src/bitbucket/types.ts +65 -0
  167. package/src/commands/runner.ts +55 -3
  168. package/src/e2b/env.ts +1 -0
  169. package/src/extensions/contract-types.generated.ts +1 -1
  170. package/src/extensions/contract.ts +2 -2
  171. package/src/extensions/post-bridge.ts +1 -0
  172. package/src/github/handlers.ts +6 -6
  173. package/src/gitlab/handlers.ts +5 -5
  174. package/src/http/assets.ts +14 -2
  175. package/src/http/core.ts +12 -0
  176. package/src/http/index.ts +4 -0
  177. package/src/http/onboarding.ts +1 -0
  178. package/src/http/sessions.ts +366 -12
  179. package/src/http/skills.ts +48 -1
  180. package/src/http/tasks.ts +1 -1
  181. package/src/http/users.ts +1 -0
  182. package/src/http/webhooks.ts +141 -0
  183. package/src/integrations/chat-sdk/adapters.ts +19 -3
  184. package/src/integrations/chat-sdk/facts.ts +30 -12
  185. package/src/integrations/chat-sdk/outbound.ts +38 -1
  186. package/src/providers/acp-targets.ts +1 -1
  187. package/src/providers/amp-adapter.ts +1 -1
  188. package/src/providers/pi-mono-adapter.ts +5 -53
  189. package/src/scripts-runtime/types/swarm-extension.d.ts +6 -4
  190. package/src/slack/handlers.test.ts +158 -0
  191. package/src/slack/handlers.ts +19 -28
  192. package/src/tasks/context-key.ts +61 -1
  193. package/src/tests/bitbucket-auth.test.ts +156 -0
  194. package/src/tests/bitbucket-handlers.test.ts +425 -0
  195. package/src/tests/bitbucket-webhook.test.ts +220 -0
  196. package/src/tests/chat-sdk-inbound.test.ts +39 -0
  197. package/src/tests/chat-sdk-telegram-markdown.test.ts +92 -0
  198. package/src/tests/context-key.test.ts +50 -0
  199. package/src/tests/core-auth.test.ts +5 -0
  200. package/src/tests/db-task-read-contract.test.ts +7 -0
  201. package/src/tests/db-task-write-contract.test.ts +6 -0
  202. package/src/tests/docs-index-footprint.test.js +3 -3
  203. package/src/tests/fixtures/bitbucket/pullrequest-comment-created-inline.json +174 -0
  204. package/src/tests/fixtures/bitbucket/pullrequest-comment-created.json +170 -0
  205. package/src/tests/fixtures/bitbucket/pullrequest-created-mention.json +132 -0
  206. package/src/tests/fixtures/bitbucket/pullrequest-created-reviewer.json +140 -0
  207. package/src/tests/fixtures/bitbucket/pullrequest-fulfilled.json +134 -0
  208. package/src/tests/fixtures/bitbucket/pullrequest-rejected.json +133 -0
  209. package/src/tests/generate-mcp-docs.test.ts +101 -0
  210. package/src/tests/gitlab-handlers.test.ts +3 -3
  211. package/src/tests/gitlab-vcs-db.test.ts +4 -4
  212. package/src/tests/integration-disable-flags.test.ts +36 -0
  213. package/src/tests/mcp-tools.test.ts +1 -1
  214. package/src/tests/onboarding.test.ts +12 -0
  215. package/src/tests/pi-codemode.test.ts +8 -2
  216. package/src/tests/preload-env.test.ts +57 -0
  217. package/src/tests/preload.ts +21 -0
  218. package/src/tests/prompt-template-remaining.test.ts +38 -0
  219. package/src/tests/run-root-coverage.test.ts +39 -0
  220. package/src/tests/runner-bitbucket-vcs.test.ts +191 -0
  221. package/src/tests/runner-skills-refresh.test.ts +58 -0
  222. package/src/tests/secret-scrubber.test.ts +15 -0
  223. package/src/tests/sessions-move.test.ts +576 -0
  224. package/src/tests/sessions-tree.test.ts +493 -0
  225. package/src/tests/skill-files-http.test.ts +50 -0
  226. package/src/tests/skills-bundle.test.ts +99 -0
  227. package/src/tests/swarm-config-export-to-env.test.ts +2 -1
  228. package/src/tests/vcs-bitbucket.test.ts +91 -0
  229. package/src/tests/vcs-provider-isolation.test.ts +123 -0
  230. package/src/tests/vcs-provider.test.ts +16 -1
  231. package/src/tests/vcs-tracking.test.ts +3 -3
  232. package/src/tests/workflow-foreach-bodies.test.ts +637 -0
  233. package/src/tests/workflow-foreach-concurrency.test.ts +342 -0
  234. package/src/tests/workflow-foreach-retry.test.ts +537 -0
  235. package/src/tests/workflow-foreach.test.ts +44 -23
  236. package/src/tests/workflow-step-claims.test.ts +6 -6
  237. package/src/tests/workflow-tla-races.test.ts +2 -2
  238. package/src/tools/script-delete.ts +1 -1
  239. package/src/tools/workflows/create-workflow.ts +5 -3
  240. package/src/types.ts +3 -2
  241. package/src/utils/config-env-export.ts +1 -0
  242. package/src/utils/secret-scrubber.ts +4 -0
  243. package/src/utils/skills-refresh.ts +83 -43
  244. package/src/vcs/bitbucket.ts +58 -0
  245. package/src/vcs/index.ts +2 -0
  246. package/src/vcs/types.ts +2 -2
  247. package/src/workflows/approval-resolution.ts +4 -1
  248. package/src/workflows/checkpoint.ts +9 -1
  249. package/src/workflows/constants.ts +3 -0
  250. package/src/workflows/definition.ts +48 -17
  251. package/src/workflows/engine.ts +25 -15
  252. package/src/workflows/executors/foreach.ts +45 -99
  253. package/src/workflows/foreach-body.ts +146 -0
  254. package/src/workflows/foreach-driver.ts +552 -0
  255. package/src/workflows/foreach-join.ts +206 -7
  256. package/src/workflows/node-timeout.ts +21 -0
  257. package/src/workflows/recovery.ts +68 -3
  258. package/src/workflows/resume.ts +221 -43
  259. package/src/workflows/retry-poller.ts +39 -24
  260. package/src/workflows/step-input.ts +59 -0
  261. package/src/workflows/task-step-routing.ts +114 -56
  262. package/templates/skills/slack-interaction/content.md +5 -0
  263. package/templates/skills/work-on-task/SKILL.md +1 -1
  264. package/templates/skills/work-on-task/content.md +1 -1
  265. package/dist/cli-9hstj09h.js +0 -2657
  266. package/dist/cli-yw91rnmw.js +0 -13
  267. package/dist/handlers-7gw64dxw.js +0 -39
  268. package/dist/index-bgdyvcnv.js +0 -40
  269. package/dist/index-gabbcdn2.js +0 -43
  270. package/dist/index-tfb26zhy.js +0 -41
  271. package/dist/lead-7e02z839.js +0 -77
  272. package/dist/worker-5snytpbd.js +0 -77
@@ -31,6 +31,17 @@ import {
31
31
  isAzureDevOpsEnabled,
32
32
  verifyAzureDevOpsWebhook,
33
33
  } from "../azure-devops";
34
+ import {
35
+ pullRequestCommentEventOf as bitbucketCommentEventOf,
36
+ pullRequestEventOf as bitbucketPullRequestEventOf,
37
+ handlePullRequestClosed as handleBitbucketPullRequestClosed,
38
+ handlePullRequestCommentCreated as handleBitbucketPullRequestCommentCreated,
39
+ handlePullRequestCreated as handleBitbucketPullRequestCreated,
40
+ isBitbucketEnabled,
41
+ isDuplicateDelivery as isDuplicateBitbucketDelivery,
42
+ markDeliveryProcessed as markBitbucketDeliveryProcessed,
43
+ verifyBitbucketWebhook,
44
+ } from "../bitbucket";
34
45
  import type {
35
46
  CheckRunEvent,
36
47
  CheckSuiteEvent,
@@ -180,6 +191,28 @@ const azureDevOpsWebhook = route({
180
191
  },
181
192
  });
182
193
 
194
+ const bitbucketWebhook = route({
195
+ method: "post",
196
+ path: "/api/bitbucket/webhook",
197
+ pattern: ["api", "bitbucket", "webhook"],
198
+ summary: "Handle Bitbucket Cloud webhook events",
199
+ tags: ["Webhooks"],
200
+ auth: { apiKey: false },
201
+ rbac: {
202
+ ungated:
203
+ "provider webhook: authenticated by the per-webhook HMAC secret, not a swarm principal",
204
+ },
205
+ responses: {
206
+ 200: { description: "Event processed", schema: WebhookDispatchResultSchema },
207
+ 401: { description: "Invalid signature" },
208
+ 413: { description: "Body exceeds 1 MiB" },
209
+ 503: { description: "Bitbucket integration not configured" },
210
+ },
211
+ });
212
+
213
+ /** Bitbucket Cloud event payloads are far below this. */
214
+ const BITBUCKET_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
215
+
183
216
  const agentmailWebhook = route({
184
217
  method: "post",
185
218
  path: "/api/agentmail/webhook",
@@ -710,6 +743,114 @@ export async function handleWebhooks(
710
743
  return true;
711
744
  }
712
745
 
746
+ // Bitbucket Cloud webhook — HMAC over the raw body, event keyed by X-Event-Key
747
+ if (bitbucketWebhook.match(req.method, pathSegments)) {
748
+ if (!isBitbucketEnabled()) {
749
+ res.writeHead(503, { "Content-Type": "application/json" });
750
+ res.end(JSON.stringify({ error: "Bitbucket integration not configured" }));
751
+ return true;
752
+ }
753
+
754
+ // The body is read before the HMAC check, so cap it like the Chat SDK
755
+ // route: reject a declared or streamed body over the limit with 413.
756
+ if (enforceContentLengthCap(req, res, BITBUCKET_WEBHOOK_MAX_BODY_BYTES) === BODY_TOO_LARGE) {
757
+ return true;
758
+ }
759
+ const chunks: Buffer[] = [];
760
+ let receivedBytes = 0;
761
+ let tooLarge = false;
762
+ for await (const chunk of req) {
763
+ if (tooLarge) continue;
764
+ receivedBytes += (chunk as Buffer).byteLength;
765
+ if (receivedBytes > BITBUCKET_WEBHOOK_MAX_BODY_BYTES) {
766
+ tooLarge = true;
767
+ chunks.length = 0;
768
+ jsonError(res, `Payload too large (max ${BITBUCKET_WEBHOOK_MAX_BODY_BYTES} bytes)`, 413);
769
+ continue;
770
+ }
771
+ chunks.push(chunk as Buffer);
772
+ }
773
+ if (tooLarge) return true;
774
+ const rawBody = Buffer.concat(chunks).toString();
775
+
776
+ const signature = req.headers["x-hub-signature"] as string | undefined;
777
+ if (!verifyBitbucketWebhook(rawBody, signature)) {
778
+ console.log("[Bitbucket] Invalid webhook signature");
779
+ res.writeHead(401, { "Content-Type": "application/json" });
780
+ res.end(JSON.stringify({ error: "Invalid signature" }));
781
+ return true;
782
+ }
783
+
784
+ let body: unknown;
785
+ try {
786
+ body = JSON.parse(rawBody);
787
+ } catch {
788
+ res.writeHead(400, { "Content-Type": "application/json" });
789
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
790
+ return true;
791
+ }
792
+
793
+ const eventKey = req.headers["x-event-key"] as string | undefined;
794
+ const requestUuid = req.headers["x-request-uuid"] as string | undefined;
795
+ if (isDuplicateBitbucketDelivery(requestUuid)) {
796
+ console.log(`[Bitbucket] Skipping duplicate delivery ${requestUuid}`);
797
+ bitbucketWebhook.respond(res, 200, { created: false });
798
+ return true;
799
+ }
800
+ console.log(`[Bitbucket] Received ${eventKey} event`);
801
+
802
+ try {
803
+ const result = await runBlockableHandler("Bitbucket", async () => {
804
+ switch (eventKey) {
805
+ case "pullrequest:created":
806
+ return await handleBitbucketPullRequestCreated(body);
807
+ case "pullrequest:comment_created":
808
+ return await handleBitbucketPullRequestCommentCreated(body);
809
+ case "pullrequest:fulfilled":
810
+ return await handleBitbucketPullRequestClosed(body, "merged");
811
+ case "pullrequest:rejected":
812
+ return await handleBitbucketPullRequestClosed(body, "declined");
813
+ default:
814
+ console.log(`[Bitbucket] Ignoring unsupported event type: ${eventKey}`);
815
+ return { created: false };
816
+ }
817
+ });
818
+
819
+ // Emit workflow trigger events for Bitbucket
820
+ const workflowEvent = {
821
+ "pullrequest:created": "created",
822
+ "pullrequest:comment_created": "commented",
823
+ "pullrequest:fulfilled": "merged",
824
+ "pullrequest:rejected": "declined",
825
+ }[eventKey ?? ""];
826
+ const prEvent = workflowEvent ? bitbucketPullRequestEventOf(body) : null;
827
+ if (workflowEvent && prEvent) {
828
+ const pr = prEvent.pullrequest;
829
+ const comment =
830
+ workflowEvent === "commented" ? bitbucketCommentEventOf(body)?.comment : undefined;
831
+ const user = comment?.user ?? prEvent.actor;
832
+ workflowEventBus.emit(`bitbucket.pull_request.${workflowEvent}`, {
833
+ repo: prEvent.repository.full_name,
834
+ number: pr.id,
835
+ ...(workflowEvent === "commented"
836
+ ? { body: comment?.content.raw }
837
+ : { title: pr.title, body: pr.description ?? undefined }),
838
+ action: workflowEvent,
839
+ user_login: user.account_id ?? user.nickname ?? user.display_name,
840
+ });
841
+ }
842
+
843
+ markBitbucketDeliveryProcessed(requestUuid);
844
+ bitbucketWebhook.respond(res, 200, result);
845
+ } catch (err) {
846
+ const errorMessage = err instanceof Error ? err.message : String(err);
847
+ console.error(`[Bitbucket] Error handling ${eventKey} event: ${scrubSecrets(errorMessage)}`);
848
+ res.writeHead(500, { "Content-Type": "application/json" });
849
+ res.end(JSON.stringify({ error: "Internal server error" }));
850
+ }
851
+ return true;
852
+ }
853
+
713
854
  // AgentMail webhook — needs raw body for Svix signature verification
714
855
  if (agentmailWebhook.match(req.method, pathSegments)) {
715
856
  if (!isAgentMailEnabled()) {
@@ -1,5 +1,7 @@
1
+ import type { TelegramMessage } from "@chat-adapter/telegram";
1
2
  import type { Adapter, Logger } from "chat";
2
3
  import { type ChatSdkAdapterName, type ResolvedAdapterConfig, readApiUrlOverrides } from "./config";
4
+ import { isTelegramReplyToBot, type TelegramRawMessage } from "./facts";
3
5
  import { withInfoFilter } from "./logger";
4
6
  import { getChatSdkTestHooks } from "./test-hooks";
5
7
 
@@ -61,15 +63,29 @@ export async function loadAdapter(
61
63
  const base = { ...config, apiUrl, logger };
62
64
  switch (name) {
63
65
  case "telegram": {
64
- const { createTelegramAdapter } = await import("@chat-adapter/telegram");
66
+ const { TelegramAdapter } = await import("@chat-adapter/telegram");
67
+ // The adapter's `mentionOnReply` skips a reply whose target id equals
68
+ // `message_thread_id`, meant for forum topic roots. A plain supergroup
69
+ // reply to a bot message that starts a reply chain has that same shape,
70
+ // so it was never a mention and never reached a handler. Core decides
71
+ // with `is_topic_message`, as the task header facts do.
72
+ class SwarmTelegramAdapter extends TelegramAdapter {
73
+ protected override isBotMentioned(message: TelegramMessage, text: string): boolean {
74
+ return (
75
+ super.isBotMentioned(message, text) ||
76
+ (String(message.from?.id) !== this.botUserId &&
77
+ isTelegramReplyToBot(message as TelegramRawMessage, this.botUserId))
78
+ );
79
+ }
80
+ }
65
81
  // Explicit `false` blocks the TELEGRAM_ALLOW_UNVERIFIED_WEBHOOKS env fallback.
66
82
  // Explicit `true` makes a reply to the bot a mention whatever
67
83
  // TELEGRAM_MENTION_ON_REPLY says.
68
- return createTelegramAdapter({
84
+ return new SwarmTelegramAdapter({
69
85
  ...base,
70
86
  allowUnverifiedWebhooks: false,
71
87
  mentionOnReply: true,
72
- } as Parameters<typeof createTelegramAdapter>[0]);
88
+ } as ConstructorParameters<typeof TelegramAdapter>[0]);
73
89
  }
74
90
  case "slack": {
75
91
  const { createSlackAdapter } = await import("@chat-adapter/slack");
@@ -30,7 +30,7 @@ export interface ChatMessageFacts {
30
30
 
31
31
  const SNIPPET_MAX = 80;
32
32
 
33
- interface TelegramRawMessage {
33
+ export interface TelegramRawMessage {
34
34
  message_id?: number;
35
35
  message_thread_id?: number;
36
36
  is_topic_message?: boolean;
@@ -57,16 +57,37 @@ function personLabel(name: string | undefined, username: string | undefined, fal
57
57
  return name || fallback;
58
58
  }
59
59
 
60
- function telegramFacts(raw: TelegramRawMessage, message: Message, botUserId?: string) {
60
+ /**
61
+ * In a forum topic every plain message replies to the topic's creation
62
+ * message, so that reply is the topic, not an answer to anyone. Only
63
+ * `is_topic_message` marks a topic: a plain supergroup reply also carries
64
+ * `message_thread_id`, the id of its reply chain's first message.
65
+ */
66
+ function replyIsTopicRoot(raw: TelegramRawMessage): boolean {
61
67
  const reply = raw.reply_to_message;
62
- // In a forum topic every plain message replies to the topic's creation
63
- // message, so that reply is the topic, not an answer to anyone.
64
- const replyIsTopicRoot =
68
+ return (
65
69
  reply !== undefined &&
66
70
  (reply.forum_topic_created !== undefined ||
67
- (raw.is_topic_message === true && reply.message_id === raw.message_thread_id));
68
- const realReply = reply && !replyIsTopicRoot ? reply : undefined;
69
- const topicName = replyIsTopicRoot ? reply?.forum_topic_created?.name : undefined;
71
+ (raw.is_topic_message === true && reply.message_id === raw.message_thread_id))
72
+ );
73
+ }
74
+
75
+ /** The raw Telegram message replies to one of the bot's own messages, not to a forum topic root. */
76
+ export function isTelegramReplyToBot(raw: TelegramRawMessage, botUserId?: string): boolean {
77
+ const fromId = raw.reply_to_message?.from?.id;
78
+ return (
79
+ !replyIsTopicRoot(raw) &&
80
+ fromId !== undefined &&
81
+ botUserId !== undefined &&
82
+ String(fromId) === botUserId
83
+ );
84
+ }
85
+
86
+ function telegramFacts(raw: TelegramRawMessage, message: Message, botUserId?: string) {
87
+ const reply = raw.reply_to_message;
88
+ const topicRoot = replyIsTopicRoot(raw);
89
+ const realReply = reply && !topicRoot ? reply : undefined;
90
+ const topicName = topicRoot ? reply?.forum_topic_created?.name : undefined;
70
91
  const name = [raw.from?.first_name, raw.from?.last_name].filter(Boolean).join(" ").trim();
71
92
  return {
72
93
  chatType: raw.chat?.type,
@@ -79,10 +100,7 @@ function telegramFacts(raw: TelegramRawMessage, message: Message, botUserId?: st
79
100
  : `#${raw.message_thread_id}`
80
101
  : undefined,
81
102
  from: personLabel(name || undefined, raw.from?.username, message.author.userId),
82
- replyToBot:
83
- realReply?.from?.id !== undefined &&
84
- botUserId !== undefined &&
85
- String(realReply.from.id) === botUserId,
103
+ replyToBot: isTelegramReplyToBot(raw, botUserId),
86
104
  replyToSnippet: snippet(realReply?.text ?? realReply?.caption),
87
105
  } satisfies ChatMessageFacts;
88
106
  }
@@ -84,6 +84,42 @@ interface PostTarget {
84
84
  }): Promise<{ id: string; threadId?: string }>;
85
85
  }
86
86
 
87
+ /** A fence opener or closer: up to 3 spaces, then 3+ backticks or tildes. */
88
+ const FENCE_LINE = /^ {0,3}(`{3,}|~{3,})/;
89
+ /**
90
+ * A `#` run at the start of a line's text (after blockquote, list and task
91
+ * markers) that CommonMark does not read as a heading: 7+ `#`, or `#` followed
92
+ * by a character other than whitespace or `#`.
93
+ */
94
+ const NON_HEADING_HASH =
95
+ /^( {0,3}(?:>[ \t]?)*[ \t]*(?:(?:[-*+]|\d{1,9}[.)])[ \t]+(?:\[[ xX]\][ \t]+)?)?)(#{7,}|#{1,6}(?=[^\s#]))/;
96
+
97
+ /**
98
+ * Telegram renders `{ markdown }` posts with `sendRichMessage`, and Telegram's
99
+ * own Markdown parser reads a line-leading `#2203` or `#tag` as a heading.
100
+ * Escape the first `#` of such a line so only CommonMark ATX headings
101
+ * (`# Title`) stay headings. Fenced code is left as is. The MarkdownV2
102
+ * fallback parses the escape back to a literal `#`.
103
+ */
104
+ export function escapeTelegramHashLines(markdown: string): string {
105
+ let fence: string | undefined;
106
+ return markdown
107
+ .split("\n")
108
+ .map((line) => {
109
+ const marker = FENCE_LINE.exec(line)?.[1];
110
+ if (fence) {
111
+ if (marker && marker[0] === fence[0] && marker.length >= fence.length) fence = undefined;
112
+ return line;
113
+ }
114
+ if (marker) {
115
+ fence = marker;
116
+ return line;
117
+ }
118
+ return line.replace(NON_HEADING_HASH, "$1\\$2");
119
+ })
120
+ .join("\n");
121
+ }
122
+
87
123
  async function postChunks(
88
124
  adapter: string,
89
125
  target: PostTarget,
@@ -92,7 +128,8 @@ async function postChunks(
92
128
  const messageIds: string[] = [];
93
129
  let threadId: string | undefined;
94
130
  for (const chunk of splitChatMessage(text, chatMessageLimit(adapter))) {
95
- const sent = await target.post({ markdown: chunk });
131
+ const markdown = adapter === "telegram" ? escapeTelegramHashLines(chunk) : chunk;
132
+ const sent = await target.post({ markdown });
96
133
  messageIds.push(sent.id);
97
134
  threadId ??= sent.threadId;
98
135
  }
@@ -359,7 +359,7 @@ const copilotTargetProfile: AcpTargetProfile = {
359
359
  },
360
360
  };
361
361
 
362
- /** Qwen Code's `--auth-type` choices (0.25.3). `qwen-oauth` needs a browser, so it is left out. */
362
+ /** Qwen Code's `--auth-type` choices (0.26.0). `qwen-oauth` needs a browser, so it is left out. */
363
363
  const QWEN_AUTH_TYPES = ["openai", "openai-responses", "anthropic", "gemini", "vertex-ai"] as const;
364
364
 
365
365
  // Qwen Code is a Gemini CLI fork: QWEN_SYSTEM_MD replaces its built-in prompt
@@ -44,7 +44,7 @@ import type {
44
44
  * default; the image installs exactly this version and the per-task settings
45
45
  * set `amp.updates.mode: "disabled"`. Bump it deliberately, with a live run.
46
46
  */
47
- export const AMP_PACKAGE = "@ampcode/cli@0.0.1792584044-g15483c";
47
+ export const AMP_PACKAGE = "@ampcode/cli@0.0.1792670445-ge8d5b3";
48
48
 
49
49
  /** MCP server name. Amp exposes its tools as `agent_swarm` / `store_progress` (dashes become underscores). */
50
50
  const AMP_MCP_SERVER_NAME = "agent-swarm";
@@ -1523,8 +1523,10 @@ const DEFAULT_CODEMODE_LIMITS: CodemodeLimits = {
1523
1523
  * pi has no option for either: a script runs until it returns unless its own
1524
1524
  * `// @options` line sets `timeout_ms`. Both limits abort the signal pi hands
1525
1525
  * the sandbox, which interrupts the QuickJS worker and fails the codemode call.
1526
- * A call refused by pi's process-wide worker cap waits for a free worker
1527
- * within the same deadline instead of failing.
1526
+ * pi 1.4 caps live codemode workers per process (two unless
1527
+ * `PI_CODEMODE_MAX_WORKERS` raises it) and queues a script for a free one for
1528
+ * at most 60 seconds. The deadline's abort also ends that wait, before the
1529
+ * script starts.
1528
1530
  *
1529
1531
  * "on" keeps declared tools declared; "only" would hide the lifecycle tools
1530
1532
  * behind scripts. `models` stays off unless `PI_CODEMODE_MODELS` asks for it.
@@ -1551,43 +1553,6 @@ export function createBoundedCodemodeExtension(
1551
1553
  );
1552
1554
  }
1553
1555
 
1554
- const CODEMODE_WORKER_RETRY_MIN_MS = 25;
1555
- const CODEMODE_WORKER_RETRY_MAX_MS = 250;
1556
-
1557
- /**
1558
- * pi 1.3 admits at most two live codemode workers per process, across all
1559
- * sessions, and counts a stopped worker until its thread exits. A call over
1560
- * that cap fails before the script runs. The runner hosts every task's pi
1561
- * session in one process and pi runs a turn's tool calls in parallel, so a
1562
- * third concurrent script, or one started right after another, can be
1563
- * refused. Nothing ran (no nested calls, no store writes), so it is safe to
1564
- * retry until a worker frees up, within the same deadline.
1565
- */
1566
- function isCodemodeWorkerLimitRefusal(result: {
1567
- content: Array<{ type: string; text?: string }>;
1568
- details?: unknown;
1569
- isError?: boolean;
1570
- }): boolean {
1571
- if (!result.isError) return false;
1572
- const calls = (result.details as { calls?: unknown[] } | undefined)?.calls;
1573
- if (calls && calls.length > 0) return false;
1574
- return result.content.some((item) =>
1575
- item.text?.includes("Script sandbox failed: Codemode worker limit reached"),
1576
- );
1577
- }
1578
-
1579
- function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
1580
- return new Promise((resolve) => {
1581
- const timer = setTimeout(done, ms);
1582
- function done() {
1583
- clearTimeout(timer);
1584
- signal.removeEventListener("abort", done);
1585
- resolve();
1586
- }
1587
- signal.addEventListener("abort", done, { once: true });
1588
- });
1589
- }
1590
-
1591
1556
  export function boundCodemodeTool(tool: ToolDefinition, limits: CodemodeLimits): ToolDefinition {
1592
1557
  return {
1593
1558
  ...tool,
@@ -1653,20 +1618,7 @@ export function boundCodemodeTool(tool: ToolDefinition, limits: CodemodeLimits):
1653
1618
  : ctx;
1654
1619
 
1655
1620
  try {
1656
- let delayMs = CODEMODE_WORKER_RETRY_MIN_MS;
1657
- for (;;) {
1658
- const result = await tool.execute(
1659
- toolCallId,
1660
- params,
1661
- script.signal,
1662
- onUpdate,
1663
- boundedCtx,
1664
- );
1665
- if (!isCodemodeWorkerLimitRefusal(result) || script.signal.aborted) return result;
1666
- await abortableSleep(delayMs, script.signal);
1667
- if (script.signal.aborted) return result;
1668
- delayMs = Math.min(delayMs * 2, CODEMODE_WORKER_RETRY_MAX_MS);
1669
- }
1621
+ return await tool.execute(toolCallId, params, script.signal, onUpdate, boundedCtx);
1670
1622
  } finally {
1671
1623
  clearTimeout(timer);
1672
1624
  signal?.removeEventListener("abort", onOuterAbort);
@@ -15,6 +15,7 @@ declare module "swarm-extension" {
15
15
  | "github"
16
16
  | "gitlab"
17
17
  | "azure-devops"
18
+ | "bitbucket"
18
19
  | "system"
19
20
  | "linear"
20
21
  | "jira"
@@ -41,7 +42,7 @@ declare module "swarm-extension" {
41
42
  slackTriggerMessageTs?: string | undefined;
42
43
  slackUserId?: string | undefined;
43
44
  overrideSlackContext?: boolean | undefined;
44
- vcsProvider?: "github" | "gitlab" | "azure-devops" | undefined;
45
+ vcsProvider?: "github" | "gitlab" | "azure-devops" | "bitbucket" | undefined;
45
46
  vcsRepo?: string | undefined;
46
47
  vcsEventType?: string | undefined;
47
48
  vcsNumber?: number | undefined;
@@ -132,6 +133,7 @@ declare module "swarm-extension" {
132
133
  | "github"
133
134
  | "gitlab"
134
135
  | "azure-devops"
136
+ | "bitbucket"
135
137
  | "system"
136
138
  | "linear"
137
139
  | "jira"
@@ -172,7 +174,7 @@ declare module "swarm-extension" {
172
174
  slackUserId?: string | undefined;
173
175
  slackProgressMessageTs?: string | undefined;
174
176
  slackTreeRootMessageTs?: string | undefined;
175
- vcsProvider?: "github" | "gitlab" | "azure-devops" | undefined;
177
+ vcsProvider?: "github" | "gitlab" | "azure-devops" | "bitbucket" | undefined;
176
178
  vcsRepo?: string | undefined;
177
179
  vcsEventType?: string | undefined;
178
180
  vcsNumber?: number | undefined;
@@ -625,7 +627,7 @@ declare module "swarm-extension" {
625
627
  /** The text, or a placeholder naming the type for a non-text message. */
626
628
  text: string;
627
629
  }
628
- export type VcsProvider = "github" | "gitlab" | "azure-devops";
630
+ export type VcsProvider = "github" | "gitlab" | "azure-devops" | "bitbucket";
629
631
  /**
630
632
  * The provider object the event is about, as the provider names it. GitLab
631
633
  * calls a pull request a merge request, and a comment a `note`.
@@ -648,7 +650,7 @@ declare module "swarm-extension" {
648
650
  provider: VcsProvider;
649
651
  kind: VcsEventKind;
650
652
  action: string;
651
- /** `owner/name` on GitHub and GitLab, the repository URL on Azure DevOps. */
653
+ /** `owner/name` on GitHub and GitLab, `workspace/repo_slug` on Bitbucket, the repository URL on Azure DevOps. */
652
654
  repo: string;
653
655
  /** The PR, merge request, or issue number. Absent for a pipeline outside a merge request. */
654
656
  number?: number;
@@ -20,6 +20,7 @@ import {
20
20
  checkUserAccess,
21
21
  isBotMessage,
22
22
  isSwarmThreadRoot,
23
+ type isUserAllowed,
23
24
  registerMessageHandler,
24
25
  type UserFilterConfig,
25
26
  } from "./handlers";
@@ -189,6 +190,163 @@ describe("checkUserAccess", () => {
189
190
  });
190
191
  });
191
192
 
193
+ describe("isUserAllowed", () => {
194
+ const usersInfo = mock(async () => ({ ok: true, user: {} }));
195
+ const client = { users: { info: usersInfo } } as never;
196
+ let enrichSpy: ReturnType<typeof spyOn<typeof slackEnrichModule, "enrichSlackUserEmail">>;
197
+ let logSpy: ReturnType<typeof spyOn<typeof console, "log">>;
198
+
199
+ // handlers.ts reads SLACK_ALLOWED_* once at module load, so each case evaluates a fresh
200
+ // copy under its own env and calls the production two-argument isUserAllowed. The copy
201
+ // shares ./enrich with this file, so the enrichSlackUserEmail spy still applies.
202
+ const ENV_KEYS = ["SLACK_ALLOWED_EMAIL_DOMAINS", "SLACK_ALLOWED_USER_IDS"] as const;
203
+ let loadCount = 0;
204
+ async function loadIsUserAllowed(env: {
205
+ SLACK_ALLOWED_EMAIL_DOMAINS?: string;
206
+ SLACK_ALLOWED_USER_IDS?: string;
207
+ }): Promise<typeof isUserAllowed> {
208
+ const saved = ENV_KEYS.map((key) => [key, process.env[key]] as const);
209
+ try {
210
+ for (const key of ENV_KEYS) {
211
+ if (env[key] === undefined) delete process.env[key];
212
+ else process.env[key] = env[key];
213
+ }
214
+ loadCount += 1;
215
+ const fresh = (await import(`./handlers?env=${loadCount}`)) as typeof import("./handlers");
216
+ return fresh.isUserAllowed;
217
+ } finally {
218
+ for (const [key, value] of saved) {
219
+ if (value === undefined) delete process.env[key];
220
+ else process.env[key] = value;
221
+ }
222
+ }
223
+ }
224
+
225
+ beforeEach(() => {
226
+ usersInfo.mockClear();
227
+ enrichSpy = spyOn(slackEnrichModule, "enrichSlackUserEmail");
228
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
229
+ });
230
+
231
+ afterEach(() => {
232
+ enrichSpy.mockRestore();
233
+ logSpy.mockRestore();
234
+ });
235
+
236
+ const loggedLines = () => logSpy.mock.calls.map((call) => String(call[0]));
237
+
238
+ describe("filtering off (neither env var set)", () => {
239
+ test("allows everyone without an email lookup", async () => {
240
+ const allowed = await loadIsUserAllowed({});
241
+ expect(await allowed(client, "U_ANY")).toBe(true);
242
+ expect(enrichSpy).not.toHaveBeenCalled();
243
+ expect(usersInfo).not.toHaveBeenCalled();
244
+ });
245
+
246
+ test("treats blank and comma-only values as unset", async () => {
247
+ const allowed = await loadIsUserAllowed({
248
+ SLACK_ALLOWED_EMAIL_DOMAINS: " , ",
249
+ SLACK_ALLOWED_USER_IDS: ",",
250
+ });
251
+ expect(await allowed(client, "U_ANY")).toBe(true);
252
+ expect(enrichSpy).not.toHaveBeenCalled();
253
+ });
254
+ });
255
+
256
+ describe("user IDs only", () => {
257
+ const env = { SLACK_ALLOWED_USER_IDS: " U_LISTED , U_SECOND ," };
258
+
259
+ test("allows trimmed allowlisted IDs without an email lookup", async () => {
260
+ const allowed = await loadIsUserAllowed(env);
261
+ expect(await allowed(client, "U_LISTED")).toBe(true);
262
+ expect(await allowed(client, "U_SECOND")).toBe(true);
263
+ expect(enrichSpy).not.toHaveBeenCalled();
264
+ expect(usersInfo).not.toHaveBeenCalled();
265
+ });
266
+
267
+ test("denies an unlisted ID without an email lookup", async () => {
268
+ enrichSpy.mockResolvedValue("person@company.com");
269
+ const allowed = await loadIsUserAllowed(env);
270
+ expect(await allowed(client, "U_OTHER")).toBe(false);
271
+ expect(enrichSpy).not.toHaveBeenCalled();
272
+ expect(usersInfo).not.toHaveBeenCalled();
273
+ });
274
+ });
275
+
276
+ describe("email domains only", () => {
277
+ const env = { SLACK_ALLOWED_EMAIL_DOMAINS: " Company.COM , other.org " };
278
+
279
+ test("allows trimmed, case-insensitive domains after one lookup", async () => {
280
+ const allowed = await loadIsUserAllowed(env);
281
+ enrichSpy.mockResolvedValue("person@company.com");
282
+ expect(await allowed(client, "U_A")).toBe(true);
283
+ enrichSpy.mockResolvedValue("person@Other.Org");
284
+ expect(await allowed(client, "U_B")).toBe(true);
285
+ expect(enrichSpy).toHaveBeenCalledTimes(2);
286
+ expect(enrichSpy).toHaveBeenNthCalledWith(1, client, "U_A");
287
+ expect(enrichSpy).toHaveBeenNthCalledWith(2, client, "U_B");
288
+ expect(loggedLines()).toEqual([]);
289
+ });
290
+
291
+ test("denies and logs a domain that is not allowed", async () => {
292
+ enrichSpy.mockResolvedValue("person@evil.com");
293
+ const allowed = await loadIsUserAllowed(env);
294
+ expect(await allowed(client, "U_OTHER")).toBe(false);
295
+ expect(loggedLines()).toEqual([
296
+ '[Slack] User U_OTHER email domain "evil.com" not in allowed list',
297
+ ]);
298
+ });
299
+ });
300
+
301
+ describe("user IDs and email domains", () => {
302
+ const env = {
303
+ SLACK_ALLOWED_EMAIL_DOMAINS: "company.com",
304
+ SLACK_ALLOWED_USER_IDS: "U_LISTED",
305
+ };
306
+
307
+ test("allows an allowlisted ID without an email lookup", async () => {
308
+ const allowed = await loadIsUserAllowed(env);
309
+ expect(await allowed(client, "U_LISTED")).toBe(true);
310
+ expect(enrichSpy).not.toHaveBeenCalled();
311
+ expect(usersInfo).not.toHaveBeenCalled();
312
+ });
313
+
314
+ test("allows an unlisted ID whose email domain is allowed", async () => {
315
+ enrichSpy.mockResolvedValue("person@Company.com");
316
+ const allowed = await loadIsUserAllowed(env);
317
+ expect(await allowed(client, "U_OTHER")).toBe(true);
318
+ expect(enrichSpy).toHaveBeenCalledTimes(1);
319
+ expect(enrichSpy).toHaveBeenCalledWith(client, "U_OTHER");
320
+ expect(loggedLines()).toEqual([]);
321
+ });
322
+
323
+ test("denies and logs a domain that is not allowed", async () => {
324
+ enrichSpy.mockResolvedValue("person@other.com");
325
+ const allowed = await loadIsUserAllowed(env);
326
+ expect(await allowed(client, "U_OTHER")).toBe(false);
327
+ expect(loggedLines()).toEqual([
328
+ '[Slack] User U_OTHER email domain "other.com" not in allowed list',
329
+ ]);
330
+ });
331
+
332
+ test("denies and logs a user with no email", async () => {
333
+ enrichSpy.mockResolvedValue(null);
334
+ const allowed = await loadIsUserAllowed(env);
335
+ expect(await allowed(client, "U_OTHER")).toBe(false);
336
+ expect(loggedLines()).toEqual(["[Slack] User U_OTHER has no email, denying access"]);
337
+ });
338
+
339
+ test("denies and logs a user with a malformed email", async () => {
340
+ enrichSpy.mockResolvedValue("no-at-sign");
341
+ const allowed = await loadIsUserAllowed(env);
342
+ expect(await allowed(client, "U_OTHER")).toBe(false);
343
+ expect(loggedLines()).toEqual([
344
+ "[Slack] User U_OTHER has invalid email format, denying access",
345
+ ]);
346
+ });
347
+ });
348
+ });
349
+
192
350
  // Helper to create a minimal SlackFile for testing
193
351
  function makeFile(overrides: Partial<SlackFile> = {}): SlackFile {
194
352
  return {