@desplega.ai/agent-swarm 2.5.4 → 2.6.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 (271) hide show
  1. package/README.md +2 -2
  2. package/dist/{acp-adapter-ek38cwsv.js → acp-adapter-dgfakfpw.js} +9 -9
  3. package/dist/{actions-92mepq03.js → actions-fbqma8aa.js} +21 -21
  4. package/dist/{amp-adapter-9gaxg015.js → amp-adapter-en0jy6hs.js} +4 -4
  5. package/dist/{app-ctn8gsgx.js → app-k8a5cgpp.js} +3 -3
  6. package/dist/{assistant-zyr5kz47.js → assistant-a16433gr.js} +29 -25
  7. package/dist/{boot-reembed-dwzfttdr.js → boot-reembed-6c1sjfnr.js} +1 -1
  8. package/dist/{boot-reembed-w75maga2.js → boot-reembed-f4nn561n.js} +1 -1
  9. package/dist/{boot-scrub-sweep-e9kq43kf.js → boot-scrub-sweep-d4w9gajf.js} +4 -4
  10. package/dist/{boot-seeds-x28v3fhb.js → boot-seeds-9bmkspyw.js} +4 -4
  11. package/dist/{claude-adapter-f0afqnne.js → claude-adapter-nys1kjxa.js} +9 -9
  12. package/dist/{claude-managed-adapter-x81a7r65.js → claude-managed-adapter-55207973.js} +2 -2
  13. package/dist/{claude-managed-setup-bqzwjd9x.js → claude-managed-setup-3cm35gey.js} +10 -3
  14. package/dist/{claude-sdk-session-7b7whkaq.js → claude-sdk-session-nw5yd8cp.js} +11 -11
  15. package/dist/{cli-c61mbdds.js → cli-03gg9pd1.js} +4 -4
  16. package/dist/{cli-kxpfx2xr.js → cli-0z1xjzwa.js} +2 -2
  17. package/dist/cli-1tem6ns9.js +894 -0
  18. package/dist/{cli-jbnn4vxb.js → cli-24j1jeeb.js} +3 -3
  19. package/dist/{cli-pzatvw94.js → cli-2sjhpsns.js} +2 -2
  20. package/dist/{cli-dew6x255.js → cli-39aet655.js} +2 -2
  21. package/dist/{cli-g1cw7xtt.js → cli-39rrw8yp.js} +1 -1
  22. package/dist/{cli-x0f2v69c.js → cli-3aqd4jaa.js} +12 -7
  23. package/dist/{cli-gbfxp48k.js → cli-3fwqy71j.js} +15 -15
  24. package/dist/{cli-7p4jxgtt.js → cli-3gfvmzht.js} +6 -6
  25. package/dist/{cli-03vpq45j.js → cli-3ndmpxxv.js} +2 -2
  26. package/dist/{cli-asq5ydm4.js → cli-4hxrf0p4.js} +131 -50
  27. package/dist/{cli-mz9afxxf.js → cli-4rf0f5vg.js} +15 -3
  28. package/dist/{cli-k4tjsdx9.js → cli-5rj83bam.js} +272 -195
  29. package/dist/{cli-sbwsxvtd.js → cli-5sckpqcx.js} +2 -2
  30. package/dist/{cli-cz5nmdxt.js → cli-683y0b3a.js} +2 -2
  31. package/dist/{cli-ethgy7ha.js → cli-6y51ryva.js} +2 -2
  32. package/dist/{cli-n4c8e3s5.js → cli-708wdy32.js} +1 -1
  33. package/dist/{cli-pasyvjmm.js → cli-737pyqxr.js} +1 -1
  34. package/dist/{cli-gx4rm0qx.js → cli-7enjwya9.js} +1 -1
  35. package/dist/{cli-6x9ma5w5.js → cli-803188pb.js} +1 -1
  36. package/dist/{cli-hh5we352.js → cli-9abdgn5t.js} +7 -7
  37. package/dist/{cli-ff1tm9n9.js → cli-a2rr0fmg.js} +1 -1
  38. package/dist/{cli-c952c7ac.js → cli-a34pr3w1.js} +2 -2
  39. package/dist/{cli-s682zgqz.js → cli-abkm71vr.js} +1 -1
  40. package/dist/{cli-mmhj8vd7.js → cli-ag2yrnt5.js} +11 -2
  41. package/dist/{cli-bvpgr9ny.js → cli-aj8djyqt.js} +5 -5
  42. package/dist/{cli-gwhjmh8g.js → cli-art7c1zh.js} +5 -5
  43. package/dist/{cli-5bnsc0e1.js → cli-az25fn5f.js} +4 -4
  44. package/dist/{cli-9s05422w.js → cli-bqztfyve.js} +1 -1
  45. package/dist/{cli-bn0rewsy.js → cli-c081bj3b.js} +2 -2
  46. package/dist/{cli-1r5ad7j5.js → cli-c2habvsf.js} +12 -12
  47. package/dist/{cli-64mh9mf3.js → cli-ca6banwz.js} +69 -17
  48. package/dist/{cli-25n3mv5d.js → cli-cz37jqss.js} +5 -5
  49. package/dist/{cli-6n8axjv1.js → cli-d2dpf95n.js} +2 -2
  50. package/dist/{cli-gn5af0x5.js → cli-emhjcj2y.js} +11 -11
  51. package/dist/{cli-6a1xg1p7.js → cli-esakngh8.js} +2 -2
  52. package/dist/{cli-ha9rmwgq.js → cli-fb8m699c.js} +2 -2
  53. package/dist/{cli-4b2z6n0m.js → cli-ffvsj2qw.js} +17 -8
  54. package/dist/{cli-abs8ndtr.js → cli-g20jjwt4.js} +26 -26
  55. package/dist/{cli-jb30dzvk.js → cli-g8ka5j4j.js} +446 -164
  56. package/dist/{cli-19202kew.js → cli-gf95q3bm.js} +5 -5
  57. package/dist/{cli-eftzyh9y.js → cli-gnsb1zxa.js} +2 -2
  58. package/dist/{cli-nwyw3zhg.js → cli-gxee0bqa.js} +1 -1
  59. package/dist/{cli-e42vss7b.js → cli-hfeb68z4.js} +5 -5
  60. package/dist/{cli-g9vd9ece.js → cli-k21gx5ht.js} +14 -16
  61. package/dist/{cli-tsma7p8v.js → cli-kf4jree9.js} +2 -2
  62. package/dist/{cli-dabehxhp.js → cli-kt907da6.js} +1 -1
  63. package/dist/{cli-wx2nj19m.js → cli-m5a9m0ak.js} +6 -6
  64. package/dist/{cli-621ce7kw.js → cli-m70bajzr.js} +3 -3
  65. package/dist/{cli-9j129brq.js → cli-mtnj4fwa.js} +6 -6
  66. package/dist/{cli-xzfq4at3.js → cli-mvjz5an3.js} +15 -11
  67. package/dist/{cli-277c31dw.js → cli-n7asmce6.js} +225 -3
  68. package/dist/{cli-xtrz6bzs.js → cli-nn6vx2mq.js} +1 -1
  69. package/dist/{cli-2655k16q.js → cli-nq677csy.js} +1 -1
  70. package/dist/{cli-0shx9j93.js → cli-pfk92eqz.js} +2 -2
  71. package/dist/{cli-wghjkr0e.js → cli-sqma6vw1.js} +10 -7
  72. package/dist/{cli-nzfnt2gs.js → cli-tf1n0r0b.js} +1 -1
  73. package/dist/{cli-qqzmjxkf.js → cli-v49fkghp.js} +11 -6
  74. package/dist/{cli-x77gen2p.js → cli-w2656ka1.js} +7 -7
  75. package/dist/{cli-5kqnemen.js → cli-yvy48785.js} +1 -1
  76. package/dist/{cli-m4ecdxvz.js → cli-zqne1qcj.js} +7 -7
  77. package/dist/cli.js +17 -17
  78. package/dist/{codex-adapter-wch12q99.js → codex-adapter-d95q4qsn.js} +7 -7
  79. package/dist/{codex-login-rn7c5gxt.js → codex-login-8cg0vhf2.js} +1 -1
  80. package/dist/{codex-session-runner-41pfcr95.js → codex-session-runner-gdwd8h0c.js} +8 -8
  81. package/dist/{commands-9849dkj6.js → commands-jqmgk54b.js} +2 -2
  82. package/dist/{controller-ja0sd95p.js → controller-z3jq589f.js} +4 -4
  83. package/dist/{controllers-connect-xp05dj6x.js → controllers-connect-r2za39ef.js} +23 -23
  84. package/dist/{cursor-adapter-623qxqew.js → cursor-adapter-4mr4jgkw.js} +2 -2
  85. package/dist/{db-47vj40dy.js → db-ppv2tze7.js} +3 -3
  86. package/dist/{db-w759qmx0.js → db-w36wwb5g.js} +4 -2
  87. package/dist/{devin-adapter-4ycy4vrf.js → devin-adapter-byen2cxt.js} +1 -1
  88. package/dist/{dispatcher-7b8e4z2j.js → dispatcher-pv5h77vf.js} +20 -20
  89. package/dist/{docker-s84hjs91.js → docker-fdzyewpz.js} +2 -2
  90. package/dist/{dsh-adapter-2ezw3xea.js → dsh-adapter-d4pdpb3r.js} +2 -2
  91. package/dist/{e2b-9gkdhm3c.js → e2b-222hsajc.js} +2 -2
  92. package/dist/{e2b-cjtf8k1z.js → e2b-fn3zacvt.js} +2 -2
  93. package/dist/{easter-egg-3d-e4qv8dxj.js → easter-egg-3d-4sp4dvmc.js} +1 -1
  94. package/dist/{event-bus-3z78ep5n.js → event-bus-pghg8hqy.js} +1 -1
  95. package/dist/{execute-0xpz8950.js → execute-jev839y1.js} +1 -1
  96. package/dist/{external-channel-e2s028da.js → external-channel-y6nkzhnn.js} +2 -2
  97. package/dist/{grok-adapter-ys7gaqee.js → grok-adapter-t08b9ntm.js} +10 -10
  98. package/dist/handlers-eqsnmv95.js +41 -0
  99. package/dist/{heartbeat-w3mntghj.js → heartbeat-vn31xwdk.js} +23 -23
  100. package/dist/{hook-g7kvv6x9.js → hook-6d0zbvvt.js} +6 -6
  101. package/dist/{hook-3jjywnpb.js → hook-cpjs8q51.js} +8 -8
  102. package/dist/{http-tkp7d4kb.js → http-9c6medda.js} +856 -99
  103. package/dist/{index-xdaahthb.js → index-38t35h0n.js} +20 -20
  104. package/dist/{index-3n8m9ea2.js → index-g6k9tctj.js} +1 -1
  105. package/dist/{index-1ef1myq2.js → index-k0yt1tvq.js} +20 -20
  106. package/dist/{index-31ceyf42.js → index-pkma1yf8.js} +1 -1
  107. package/dist/{index-q7m26kvs.js → index-qhjf2htm.js} +13 -10
  108. package/dist/index-yqgbfgaj.js +43 -0
  109. package/dist/{keepalive-v4ta8wgg.js → keepalive-em0117bp.js} +4 -4
  110. package/dist/lead-23e7z59t.js +79 -0
  111. package/dist/{maintenance-84n1ex21.js → maintenance-kbme3qy9.js} +1 -1
  112. package/dist/{oauth-refresh-sweep-vjnnandw.js → oauth-refresh-sweep-xysj4yse.js} +3 -3
  113. package/dist/{onboard-sfmpe4np.js → onboard-p6q0vz97.js} +3 -3
  114. package/dist/{opencode-adapter-jmpst4q4.js → opencode-adapter-esv3k9yn.js} +7 -7
  115. package/dist/{otel-impl-v6cc0zve.js → otel-impl-54qhqh1z.js} +2 -2
  116. package/dist/{pi-mono-adapter-fk8fkcf6.js → pi-mono-adapter-mymbwhj0.js} +10 -10
  117. package/dist/{pricing-refresh-fsk1e1zc.js → pricing-refresh-yb75wv33.js} +3 -3
  118. package/dist/{rbac-roles-prmkdnkw.js → rbac-roles-1vp2mw4p.js} +2 -2
  119. package/dist/{rbac-roles-zxmxqa39.js → rbac-roles-dexhc9x8.js} +1 -1
  120. package/dist/render-v2-tjay5hhf.js +16 -0
  121. package/dist/{resume-frdtgfa0.js → resume-8ayf37g5.js} +9 -9
  122. package/dist/{runtime-bt5pkpm1.js → runtime-hqe5cqcd.js} +1 -1
  123. package/dist/{session-access-fpez4y5q.js → session-access-fdhp987s.js} +4 -4
  124. package/dist/session-source-backfill-qgtc86z1.js +110 -0
  125. package/dist/{setup-mq6m3m8q.js → setup-4rrmqcq3.js} +1 -1
  126. package/dist/{virtual-modules-mnfbh5mr.js → virtual-modules-52km00d6.js} +2 -2
  127. package/dist/{work-objects-kfahhjsq.js → work-objects-p28fgd70.js} +23 -23
  128. package/dist/worker-e3w7vye1.js +79 -0
  129. package/dist/{x-pbpezr0s.js → x-wxrzvzx8.js} +2 -2
  130. package/openapi.json +1274 -32
  131. package/package.json +1 -1
  132. package/src/assets/source-key.ts +95 -0
  133. package/src/be/db/api-keys.ts +80 -0
  134. package/src/be/db/session-labels.ts +28 -0
  135. package/src/be/db/session-moves.ts +5 -6
  136. package/src/be/db/session-tree.ts +153 -14
  137. package/src/be/db/tasks/read.ts +11 -0
  138. package/src/be/db/tasks/write.ts +6 -1
  139. package/src/be/db/workflow-runs.ts +183 -1
  140. package/src/be/db-retention.ts +61 -6
  141. package/src/be/db.ts +59 -114
  142. package/src/be/memory/providers/openai-embedding.ts +5 -1
  143. package/src/be/migrations/244_api_key_validation.sql +4 -0
  144. package/src/be/onboarding.ts +4 -0
  145. package/src/be/scripts/typecheck.ts +1 -1
  146. package/src/be/session-source-backfill.ts +163 -0
  147. package/src/be/swarm-config-guard.ts +1 -0
  148. package/src/comb/review-batch.ts +28 -1
  149. package/src/comb/templates.ts +7 -2
  150. package/src/commands/claude-managed-setup.ts +7 -1
  151. package/src/commands/credential-refresh.ts +76 -1
  152. package/src/commands/credential-wait.ts +2 -2
  153. package/src/commands/provider-credentials.ts +210 -170
  154. package/src/commands/runner.ts +25 -4
  155. package/src/extensions/apply-task-create.ts +8 -1
  156. package/src/extensions/contract-types.generated.ts +1 -1
  157. package/src/extensions/contract.ts +5 -0
  158. package/src/http/all-routes.ts +2 -0
  159. package/src/http/api-keys.ts +53 -0
  160. package/src/http/assets.ts +15 -0
  161. package/src/http/codex-oauth-accounts.ts +191 -0
  162. package/src/http/comb.ts +15 -2
  163. package/src/http/config.ts +1 -1
  164. package/src/http/core.ts +6 -0
  165. package/src/http/fleet-benchmark.ts +5 -2
  166. package/src/http/index.ts +11 -0
  167. package/src/http/integrations.ts +5 -1
  168. package/src/http/kv.ts +8 -3
  169. package/src/http/onboarding.ts +6 -1
  170. package/src/http/pool-keys.ts +354 -0
  171. package/src/http/sessions.ts +37 -12
  172. package/src/http/stats.ts +1 -0
  173. package/src/http/status.ts +26 -12
  174. package/src/http/tasks.ts +3 -0
  175. package/src/http/used-by.ts +7 -2
  176. package/src/http/workflows.ts +78 -9
  177. package/src/integrations/chat-sdk/inbound.ts +27 -0
  178. package/src/kv-reserved-namespaces.ts +19 -0
  179. package/src/providers/acp-targets.ts +1 -1
  180. package/src/providers/amp-adapter.ts +1 -1
  181. package/src/providers/claude-adapter.ts +5 -1
  182. package/src/providers/claude-managed-adapter.ts +16 -9
  183. package/src/providers/codex-adapter.ts +71 -11
  184. package/src/scheduler/scheduler.ts +3 -2
  185. package/src/scripts-runtime/swarm-sdk.ts +7 -1
  186. package/src/scripts-runtime/types/stdlib.d.ts +1 -1
  187. package/src/scripts-runtime/types/swarm-extension.d.ts +17 -8
  188. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -1
  189. package/src/slack/assistant.ts +7 -0
  190. package/src/slack/channel-labels.ts +74 -0
  191. package/src/slack/handlers.ts +8 -0
  192. package/src/slack/inbound-files.ts +5 -2
  193. package/src/slack/thread-buffer.ts +4 -0
  194. package/src/tasks/sibling-awareness.ts +12 -3
  195. package/src/telemetry-context.ts +5 -6
  196. package/src/telemetry-identity.ts +3 -3
  197. package/src/tests/api-keys-validation.test.ts +170 -0
  198. package/src/tests/claude-managed-adapter.test.ts +46 -0
  199. package/src/tests/claude-routes-e2e.test.ts +7 -2
  200. package/src/tests/codex-credential-pools.test.ts +120 -0
  201. package/src/tests/codex-gateway-route.test.ts +165 -0
  202. package/src/tests/codex-oauth-accounts.test.ts +357 -0
  203. package/src/tests/comb-review-batch.test.ts +63 -2
  204. package/src/tests/config-pool-keys.test.ts +533 -0
  205. package/src/tests/credential-check.test.ts +290 -2
  206. package/src/tests/credential-refresh.test.ts +172 -1
  207. package/src/tests/db-retention.test.ts +9 -3
  208. package/src/tests/db-task-read-contract.test.ts +1 -0
  209. package/src/tests/db-task-write-contract.test.ts +1 -3
  210. package/src/tests/docs-index-footprint.test.js +3 -3
  211. package/src/tests/extensions-pre-task.test.ts +53 -1
  212. package/src/tests/fixtures/extensions/record-task-create/hooks.ts +9 -0
  213. package/src/tests/fixtures/extensions/record-task-create/manifest.json +7 -0
  214. package/src/tests/fleet-benchmark.test.ts +8 -0
  215. package/src/tests/get-workflow-run-tool.test.ts +98 -0
  216. package/src/tests/integrations-http.test.ts +32 -2
  217. package/src/tests/internal-ai/complete-structured.test.ts +37 -1
  218. package/src/tests/internal-ai/credentials.test.ts +23 -0
  219. package/src/tests/kv-http.test.ts +32 -0
  220. package/src/tests/kv-tool.test.ts +25 -0
  221. package/src/tests/multi-runtime-registration.test.ts +12 -12
  222. package/src/tests/onboarding.test.ts +108 -1
  223. package/src/tests/openai-embedding-credentials.test.ts +35 -0
  224. package/src/tests/openrouter-attribution-call-sites.test.ts +2 -2
  225. package/src/tests/org-website.test.ts +36 -0
  226. package/src/tests/provider-key-probes.test.ts +181 -0
  227. package/src/tests/rbac-e2e-helpers.test.ts +88 -1
  228. package/src/tests/rbac-e2e-helpers.ts +36 -11
  229. package/src/tests/script-connections.test.ts +2 -0
  230. package/src/tests/script-runs-retention.test.ts +212 -0
  231. package/src/tests/sdk-allowlist.test.ts +39 -0
  232. package/src/tests/session-source-backfill.test.ts +429 -0
  233. package/src/tests/session-source-keys.test.ts +236 -0
  234. package/src/tests/session-source-labels.test.ts +419 -0
  235. package/src/tests/sessions-detail-batch.test.ts +2 -0
  236. package/src/tests/sessions-move.test.ts +131 -0
  237. package/src/tests/sessions-tree.test.ts +299 -2
  238. package/src/tests/slack-inbound-files.test.ts +103 -23
  239. package/src/tests/status.test.ts +101 -18
  240. package/src/tests/tasks-workflow-run-filter.test.ts +146 -0
  241. package/src/tests/test-net.ts +31 -0
  242. package/src/tests/transcription.test.ts +8 -0
  243. package/src/tests/used-by-route.test.ts +11 -0
  244. package/src/tests/workflow-cascade-cancel.test.ts +172 -0
  245. package/src/tests/workflow-http-v2.test.ts +131 -0
  246. package/src/tests/workflow-step-order.test.ts +92 -0
  247. package/src/tests/workflow-sub-workflow.test.ts +30 -0
  248. package/src/tools/get-tasks.ts +8 -0
  249. package/src/tools/kv/kv-read-auth.ts +4 -2
  250. package/src/tools/task-action.ts +1 -1
  251. package/src/tools/workflows/get-workflow-run.ts +14 -4
  252. package/src/types.ts +24 -0
  253. package/src/utils/company-profile.ts +51 -1
  254. package/src/utils/credentials.ts +5 -2
  255. package/src/utils/internal-ai/complete-structured.ts +5 -0
  256. package/src/utils/internal-ai/credentials.ts +4 -3
  257. package/src/utils/provider-key-probes.ts +328 -0
  258. package/src/utils/transcription.ts +4 -1
  259. package/src/workflows/executors/sub-workflow.ts +8 -1
  260. package/templates/ai-toolbox.manifest.json +2 -2
  261. package/templates/skills/researching/content.md +2 -0
  262. package/templates/skills/swarm-scripts/SKILL.md +1 -0
  263. package/templates/skills/swarm-scripts/content.md +1 -0
  264. package/dist/cli-7421jzsw.js +0 -84
  265. package/dist/cli-e45e4m6b.js +0 -413
  266. package/dist/cli-zw88zjba.js +0 -307
  267. package/dist/handlers-wy1dr6ja.js +0 -41
  268. package/dist/index-nmm79qgr.js +0 -43
  269. package/dist/lead-sawnyqxk.js +0 -79
  270. package/dist/render-v2-pjwxvnkw.js +0 -12
  271. package/dist/worker-v5e5rxj3.js +0 -79
@@ -213,6 +213,10 @@ export function createAssistant(): Assistant {
213
213
  buildEffectiveText(messageText, inbound.files, inbound.failed, inbound.notes),
214
214
  cachedBotUserId ?? undefined,
215
215
  );
216
+ const slack = {
217
+ trigger: "mention" as const,
218
+ messages: [{ ts: message.ts, userId, text: messageText }],
219
+ };
216
220
 
217
221
  if (workingAgent && workingAgent.status !== "offline") {
218
222
  // Otherwise, create a follow-up task for the working agent
@@ -236,6 +240,7 @@ export function createAssistant(): Assistant {
236
240
  contextKey: slackContextKey({ channelId, threadTs }),
237
241
  },
238
242
  inbound,
243
+ slack,
239
244
  );
240
245
  await ackSlackMessage(
241
246
  client,
@@ -285,6 +290,7 @@ export function createAssistant(): Assistant {
285
290
  contextKey: slackContextKey({ channelId, threadTs }),
286
291
  },
287
292
  inbound,
293
+ slack,
288
294
  );
289
295
  await ackSlackMessage(
290
296
  client,
@@ -318,6 +324,7 @@ export function createAssistant(): Assistant {
318
324
  contextKey: slackContextKey({ channelId, threadTs }),
319
325
  },
320
326
  inbound,
327
+ slack,
321
328
  );
322
329
  await ackSlackMessage(client, channelId, message.ts, reactionName("accepted"), "accepted");
323
330
  await notifySlackFileFailures(client, channelId, threadTs, unattached);
@@ -0,0 +1,74 @@
1
+ import type { WebClient } from "@slack/web-api";
2
+ import { rememberSessionLabel } from "../be/db/session-labels";
3
+ import { getSlackApp } from "./app";
4
+ import { callSlackWithRetry, slackErrorCode } from "./render-v2";
5
+
6
+ const LABEL_TTL_MS = 24 * 60 * 60 * 1000;
7
+ const FAILURE_TTL_MS = 10 * 60 * 1000;
8
+ const FAILURE_CACHE_MAX = 500;
9
+ const LOOKUP_QUEUE_MAX = 50;
10
+ const inFlight = new Set<string>();
11
+ const failedUntil = new Map<string, number>();
12
+ let pending = Promise.resolve();
13
+
14
+ async function channelLabel(client: WebClient, id: string): Promise<string> {
15
+ const result = await callSlackWithRetry(client, "conversations.info", {
16
+ channel: id.toUpperCase(),
17
+ });
18
+ const channel = result.channel as
19
+ | { name?: string; is_im?: boolean; is_mpim?: boolean; user?: string }
20
+ | undefined;
21
+ if (result.ok === false || !channel) throw new Error("Channel lookup failed");
22
+ if (channel.is_mpim) return "Group DM";
23
+ if (channel.is_im && channel.user) {
24
+ const result = await callSlackWithRetry(client, "users.info", { user: channel.user });
25
+ const user = result.user as
26
+ | { profile?: { display_name?: string; real_name?: string }; real_name?: string }
27
+ | undefined;
28
+ const name = user?.profile?.display_name || user?.real_name || user?.profile?.real_name;
29
+ if (result.ok !== false && name) return `DM · ${name}`;
30
+ } else if (channel.name) {
31
+ return `#${channel.name}`;
32
+ }
33
+ throw new Error("Channel label unavailable");
34
+ }
35
+
36
+ const CHANNEL_GROUP_KEY = /^shared\/slack\/([^/]+)\/$/;
37
+
38
+ /**
39
+ * Queue a Slack lookup for each channel group key (`shared/slack/<channel>/`)
40
+ * without delaying the tree response. Other keys are ignored.
41
+ */
42
+ export function warmSlackChannelLabels(groupKeys: string[]): void {
43
+ const client = getSlackApp()?.client;
44
+ if (!client) return;
45
+ for (const groupKey of groupKeys) {
46
+ const id = CHANNEL_GROUP_KEY.exec(groupKey)?.[1];
47
+ if (!id) continue;
48
+ if (inFlight.has(id) || (failedUntil.get(id) ?? 0) > Date.now()) continue;
49
+ failedUntil.delete(id);
50
+ if (inFlight.size >= LOOKUP_QUEUE_MAX) continue;
51
+ // Reserve failure capacity for queued work. Live failures must retain their TTL.
52
+ if (failedUntil.size + inFlight.size >= FAILURE_CACHE_MAX) {
53
+ const now = Date.now();
54
+ for (const [failedId, until] of failedUntil) {
55
+ if (until <= now) failedUntil.delete(failedId);
56
+ }
57
+ if (failedUntil.size + inFlight.size >= FAILURE_CACHE_MAX) continue;
58
+ }
59
+ inFlight.add(id);
60
+ pending = pending.then(async () => {
61
+ try {
62
+ const label = await channelLabel(client, id);
63
+ await rememberSessionLabel(groupKey, label, LABEL_TTL_MS);
64
+ } catch (error) {
65
+ failedUntil.set(id, Date.now() + FAILURE_TTL_MS);
66
+ console.warn(
67
+ `[Slack] Could not cache a session channel label: ${slackErrorCode(error) ?? "unknown error"}`,
68
+ );
69
+ } finally {
70
+ inFlight.delete(id);
71
+ }
72
+ });
73
+ }
74
+ }
@@ -679,6 +679,11 @@ export function registerMessageHandler(app: App): void {
679
679
  routingThreadContext,
680
680
  );
681
681
 
682
+ const slack = {
683
+ trigger: "mention" as const,
684
+ messages: [{ ts: msg.ts, userId: msg.user, text: msg.text ?? "" }],
685
+ };
686
+
682
687
  if (matches.length === 0) {
683
688
  if (!botMentioned && !isImplicitMention) return ignoreSlackInbound("not_addressed");
684
689
 
@@ -742,6 +747,7 @@ export function registerMessageHandler(app: App): void {
742
747
  contextKey: slackContextKey({ channelId: msg.channel, threadTs }),
743
748
  },
744
749
  inbound,
750
+ slack,
745
751
  );
746
752
  await ackSlackMessage(client, msg.channel, msg.ts, reactionName("accepted"), "accepted");
747
753
  await notifySlackFileFailures(client, msg.channel, threadTs, unattached);
@@ -864,6 +870,7 @@ export function registerMessageHandler(app: App): void {
864
870
  contextKey: slackContextKey({ channelId: msg.channel, threadTs }),
865
871
  },
866
872
  inbound,
873
+ slack,
867
874
  );
868
875
  const { task } = created;
869
876
  unattached.push(...created.unattached);
@@ -888,6 +895,7 @@ export function registerMessageHandler(app: App): void {
888
895
  contextKey: slackContextKey({ channelId: msg.channel, threadTs }),
889
896
  },
890
897
  inbound,
898
+ slack,
891
899
  );
892
900
  const { task } = created;
893
901
  unattached.push(...created.unattached);
@@ -22,6 +22,7 @@ import { join } from "node:path";
22
22
  import type { WebClient } from "@slack/web-api";
23
23
  import { getTaskAttachments, getTaskById, promoteDraftTask, refreshDraftTaskLease } from "../be/db";
24
24
  import { MAX_TASK_ATTACHMENT_BYTES, recordTaskAttachmentUpload } from "../be/task-attachment-store";
25
+ import type { TaskCreateEvent } from "../extensions/contract";
25
26
  import { providerPath } from "../fs/provider";
26
27
  import { getFileStorageProvider } from "../fs/registry";
27
28
  import { resolveTemplate } from "../prompts/resolver";
@@ -320,17 +321,19 @@ export async function createSlackTaskWithFiles(
320
321
  description: string,
321
322
  options: CreateTaskOptions,
322
323
  inbound: InboundSlackFiles,
324
+ slack?: TaskCreateEvent["slack"],
323
325
  ): Promise<{ task: AgentTask; unattached: SlackFileFailure[] }> {
324
326
  noteSlackInboundSideEffect("task_created");
327
+ const context = { origin: "slack" as const, slack };
325
328
  if (inbound.fetched.length === 0) {
326
- const task = await createTaskWithSiblingAwareness(description, options, { origin: "slack" });
329
+ const task = await createTaskWithSiblingAwareness(description, options, context);
327
330
  return { task, unattached: inbound.failed };
328
331
  }
329
332
 
330
333
  const task = await createTaskWithSiblingAwareness(
331
334
  description,
332
335
  { ...options, status: "draft" },
333
- { origin: "slack" },
336
+ context,
334
337
  );
335
338
  const unattached = [...inbound.failed];
336
339
  // Uploads run one after another and can outlast the abandoned-draft sweep's
@@ -313,6 +313,10 @@ async function slackFlush(
313
313
  contextKey: slackContextKey({ channelId, threadTs }),
314
314
  },
315
315
  inbound,
316
+ {
317
+ trigger: "thread-buffer",
318
+ messages: items.map(({ ts, userId, text }) => ({ ts, userId, text })),
319
+ },
316
320
  );
317
321
  } catch (error) {
318
322
  if (!(error instanceof TaskCreationBlockedError)) throw error;
@@ -18,7 +18,7 @@ import {
18
18
  getInProgressTasksByContextKey,
19
19
  } from "../be/db";
20
20
  import { applyPreTaskCreate } from "../extensions/apply-task-create";
21
- import type { TaskCreateOrigin } from "../extensions/contract";
21
+ import type { TaskCreateEvent, TaskCreateOrigin } from "../extensions/contract";
22
22
  import type { AgentTask } from "../types";
23
23
  import { TaskCreationBlockedError } from "./errors";
24
24
  import {
@@ -151,7 +151,11 @@ export type PreparedTaskCreate = { description: string; options: CreateTaskOptio
151
151
  export async function prepareTaskWithSiblingAwareness(
152
152
  description: string,
153
153
  options: CreateTaskOptions,
154
- args: { origin?: TaskCreateOrigin; allowCustomModel?: boolean } = {},
154
+ args: {
155
+ origin?: TaskCreateOrigin;
156
+ allowCustomModel?: boolean;
157
+ slack?: TaskCreateEvent["slack"];
158
+ } = {},
155
159
  ): Promise<PreparedTaskCreate> {
156
160
  const preCreate = await applyPreTaskCreate({
157
161
  description,
@@ -159,6 +163,7 @@ export async function prepareTaskWithSiblingAwareness(
159
163
  // Preserve the plan's REST default for callers without an explicit ingress origin.
160
164
  origin: args.origin ?? "rest",
161
165
  allowCustomModel: args.allowCustomModel,
166
+ slack: args.slack,
162
167
  });
163
168
  if (preCreate.kind === "blocked") {
164
169
  throw new TaskCreationBlockedError(preCreate.reason, preCreate.extension);
@@ -169,7 +174,11 @@ export async function prepareTaskWithSiblingAwareness(
169
174
  export async function createTaskWithSiblingAwareness(
170
175
  description: string,
171
176
  options: CreateTaskOptions,
172
- args: { origin?: TaskCreateOrigin; allowCustomModel?: boolean } = {},
177
+ args: {
178
+ origin?: TaskCreateOrigin;
179
+ allowCustomModel?: boolean;
180
+ slack?: TaskCreateEvent["slack"];
181
+ } = {},
173
182
  ): Promise<AgentTask> {
174
183
  const { description: d, options: o } = await prepareTaskWithSiblingAwareness(
175
184
  description,
@@ -12,8 +12,8 @@ import catalog from "./telemetry-contract/catalog.json";
12
12
  import {
13
13
  COMPANY_ACQUISITION_SOURCES,
14
14
  COMPANY_TEAM_SIZES,
15
- COMPANY_USE_CASES,
16
- USER_FUNCTIONS,
15
+ reportedUseCase,
16
+ reportedUserFunction,
17
17
  } from "./utils/company-profile";
18
18
 
19
19
  /** `org_` + 16 lowercase hex (self-host, minted here) or 27 alphanumerics (cloud). Same pattern the proxy enforces. */
@@ -115,10 +115,9 @@ export function buildContext(input: ContextInput): EventContext {
115
115
  const orgDomain = validOrgDomain(input.orgDomain);
116
116
  const orgWebsite = validOrgDomain(input.orgWebsite);
117
117
  const teamSize = COMPANY_TEAM_SIZES.find((value) => value === input.teamSize);
118
- const useCase = COMPANY_USE_CASES.find((value) => value === input.useCase);
119
- const userFunction = userId
120
- ? USER_FUNCTIONS.find((value) => value === input.userFunction)
121
- : undefined;
118
+ // A value the catalog does not list yet is left out, as if unset.
119
+ const useCase = reportedUseCase(input.useCase);
120
+ const userFunction = userId ? reportedUserFunction(input.userFunction) : undefined;
122
121
  return {
123
122
  org_id: input.orgId,
124
123
  org_name: orgName ? orgName : null,
@@ -13,7 +13,7 @@
13
13
  import { getDbClient } from "./be/db/runtime";
14
14
  import { setTelemetryOrgDomain } from "./telemetry";
15
15
  import { emailDomain, type TelemetryUserRole } from "./telemetry-context";
16
- import { USER_FUNCTIONS } from "./utils/company-profile";
16
+ import { reportedUserFunction } from "./utils/company-profile";
17
17
 
18
18
  const ROLE_TTL_MS = 60 * 60_000;
19
19
  const ROLE_CACHE_LIMIT = 1_000;
@@ -21,7 +21,7 @@ const DOMAIN_RECOMPUTE_DEBOUNCE_MS = 2_000;
21
21
 
22
22
  interface UserRoleEntry {
23
23
  role: TelemetryUserRole;
24
- userFunction?: (typeof USER_FUNCTIONS)[number];
24
+ userFunction?: ReturnType<typeof reportedUserFunction>;
25
25
  at: number;
26
26
  }
27
27
 
@@ -40,7 +40,7 @@ async function loadUserRole(userId: string): Promise<UserRoleEntry | undefined>
40
40
  );
41
41
  return {
42
42
  role,
43
- userFunction: USER_FUNCTIONS.find((value) => value === user?.jobFunction),
43
+ userFunction: reportedUserFunction(user?.jobFunction),
44
44
  at: Date.now(),
45
45
  };
46
46
  } catch {
@@ -0,0 +1,170 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { Readable } from "node:stream";
3
+ import {
4
+ closeDb,
5
+ getAvailableKeyIndices,
6
+ getDbClient,
7
+ getKeyStatuses,
8
+ initDb,
9
+ recordKeyValidation,
10
+ } from "../be/db";
11
+ import { handleApiKeys } from "../http/api-keys";
12
+ import { getPathSegments, parseQueryParams } from "../http/utils";
13
+
14
+ const TEST_DB = "./test-api-keys-validation.sqlite";
15
+ const keyType = "ANTHROPIC_API_KEY";
16
+ const rejected = { keyIndex: 0, keySuffix: "aaa11", ok: false, httpStatus: 401 };
17
+
18
+ async function call(method: string, url: string, body?: unknown) {
19
+ const req = Readable.from(body === undefined ? [] : [Buffer.from(JSON.stringify(body))]);
20
+ Object.assign(req, { method, url, headers: { "content-type": "application/json" } });
21
+ let statusCode = 200;
22
+ let responseBody = "";
23
+ const res = {
24
+ setHeader: () => {},
25
+ writeHead: (code: number) => {
26
+ statusCode = code;
27
+ },
28
+ end: (chunk?: string) => {
29
+ responseBody += chunk ?? "";
30
+ },
31
+ };
32
+ const handled = await handleApiKeys(
33
+ req as never,
34
+ res as never,
35
+ getPathSegments(url),
36
+ parseQueryParams(url),
37
+ );
38
+ return { handled, statusCode, body: JSON.parse(responseBody) };
39
+ }
40
+
41
+ describe("pooled key validation", () => {
42
+ beforeAll(() => {
43
+ initDb(TEST_DB);
44
+ });
45
+ beforeEach(async () => {
46
+ await getDbClient().run("DELETE FROM api_key_status");
47
+ });
48
+ afterAll(async () => {
49
+ closeDb();
50
+ for (const suffix of ["", "-wal", "-shm"]) {
51
+ await Bun.file(TEST_DB + suffix)
52
+ .delete()
53
+ .catch(() => {});
54
+ }
55
+ });
56
+
57
+ test("HTTP 401 marks the key as rejected", async () => {
58
+ await recordKeyValidation(keyType, [{ ...rejected, httpStatus: 401 }]);
59
+ const [row] = await getKeyStatuses(keyType);
60
+ expect(row).toMatchObject({
61
+ keyIndex: 0,
62
+ keySuffix: "aaa11",
63
+ provider: "claude",
64
+ lastValidationError: "HTTP 401",
65
+ });
66
+ expect(row!.lastValidationFailedAt).not.toBeNull();
67
+ });
68
+
69
+ test("a pass clears rejection and updates the index", async () => {
70
+ await recordKeyValidation(keyType, [rejected]);
71
+ await recordKeyValidation(keyType, [{ ...rejected, keyIndex: 2, ok: true }]);
72
+ expect((await getKeyStatuses(keyType))[0]).toMatchObject({
73
+ keyIndex: 2,
74
+ lastValidationError: null,
75
+ lastValidationFailedAt: null,
76
+ });
77
+ });
78
+
79
+ // 403 included: a valid key can lack the scope of the list endpoint.
80
+ test.each([
81
+ 403,
82
+ 500,
83
+ 429,
84
+ undefined,
85
+ ])("failure with status %s preserves existing state", async (httpStatus) => {
86
+ await recordKeyValidation(keyType, [rejected]);
87
+ const before = await getKeyStatuses(keyType);
88
+ await recordKeyValidation(keyType, [{ ...rejected, keyIndex: 3, httpStatus }]);
89
+ expect(await getKeyStatuses(keyType)).toEqual(before);
90
+ await recordKeyValidation("OPENAI_API_KEY", [{ ...rejected, httpStatus }]);
91
+ expect(await getKeyStatuses("OPENAI_API_KEY")).toEqual([]);
92
+ });
93
+
94
+ test("a removed suffix cannot block a replacement at the same index", async () => {
95
+ await recordKeyValidation(keyType, [rejected]);
96
+ await recordKeyValidation(keyType, [{ keyIndex: 0, keySuffix: "bbb22", ok: true }]);
97
+ const rows = await getKeyStatuses(keyType);
98
+ expect(rows.find((row) => row.keySuffix === "aaa11")).toMatchObject({
99
+ lastValidationError: null,
100
+ lastValidationFailedAt: null,
101
+ });
102
+ expect((await getAvailableKeyIndices(keyType, 1)).availableIndices).toEqual([0]);
103
+ });
104
+
105
+ test("rejections and removed-suffix cleanup stay within their scope and key type", async () => {
106
+ await recordKeyValidation(keyType, [rejected], "agent", "agent-a");
107
+ await recordKeyValidation(keyType, [rejected], "agent", "agent-b");
108
+ await recordKeyValidation("OPENAI_API_KEY", [rejected], "agent", "agent-a");
109
+ await recordKeyValidation(
110
+ keyType,
111
+ [{ keyIndex: 0, keySuffix: "bbb22", ok: true }],
112
+ "agent",
113
+ "agent-a",
114
+ );
115
+ expect((await getAvailableKeyIndices(keyType, 1, "agent", "agent-a")).availableIndices).toEqual(
116
+ [0],
117
+ );
118
+ expect((await getAvailableKeyIndices(keyType, 1, "agent", "agent-b")).availableIndices).toEqual(
119
+ [],
120
+ );
121
+ expect(
122
+ (await getAvailableKeyIndices("OPENAI_API_KEY", 1, "agent", "agent-a")).availableIndices,
123
+ ).toEqual([]);
124
+ });
125
+
126
+ test("availability excludes rejected keys and reports their indices", async () => {
127
+ await recordKeyValidation(keyType, [rejected, { keyIndex: 1, keySuffix: "bbb22", ok: true }]);
128
+ expect(await getAvailableKeyIndices(keyType, 3)).toMatchObject({
129
+ availableIndices: [1, 2],
130
+ rejectedIndices: [0],
131
+ });
132
+ });
133
+
134
+ test("availability is empty when every key is rejected", async () => {
135
+ await recordKeyValidation(keyType, [
136
+ rejected,
137
+ { ...rejected, keyIndex: 1, keySuffix: "bbb22" },
138
+ ]);
139
+ expect(await getAvailableKeyIndices(keyType, 2)).toMatchObject({
140
+ availableIndices: [],
141
+ rejectedIndices: [0, 1],
142
+ });
143
+ });
144
+
145
+ test("the report handler records validation and exposes it in reads", async () => {
146
+ expect(
147
+ await call("POST", "/api/keys/report-validation", { keyType, keys: [rejected] }),
148
+ ).toEqual({
149
+ handled: true,
150
+ statusCode: 200,
151
+ body: { success: true, message: "Key validation recorded" },
152
+ });
153
+ expect(
154
+ (await call("GET", `/api/keys/available?keyType=${keyType}&totalKeys=2`)).body,
155
+ ).toMatchObject({
156
+ availableIndices: [1],
157
+ rejectedIndices: [0],
158
+ });
159
+ const status = await call("GET", `/api/keys/status?keyType=${keyType}`);
160
+ expect(status.body.keys[0]).toMatchObject({ lastValidationError: "HTTP 401" });
161
+ expect(status.body.keys[0].lastValidationFailedAt).not.toBeNull();
162
+ });
163
+
164
+ test("the report handler rejects an empty pool", async () => {
165
+ expect(
166
+ (await call("POST", "/api/keys/report-validation", { keyType, keys: [] })).statusCode,
167
+ ).toBe(400);
168
+ expect(await getKeyStatuses(keyType)).toEqual([]);
169
+ });
170
+ });
@@ -59,6 +59,52 @@ describe("ClaudeManagedAdapter (Phase 1 skeleton)", () => {
59
59
  expect(adapter.formatCommand("plan")).toBe("/plan");
60
60
  });
61
61
 
62
+ for (const source of ["container", "task", "task-pool"] as const) {
63
+ test(`uses one Anthropic key from ${source}`, async () => {
64
+ const originalFetch = globalThis.fetch;
65
+ const originalKey = process.env.ANTHROPIC_API_KEY;
66
+ let apiKey: string | null = null;
67
+ process.env.ANTHROPIC_API_KEY = "example-container-first,example-container-second";
68
+ globalThis.fetch = (async (input, init) => {
69
+ apiKey =
70
+ new Request(input, init).headers.get("authorization")?.slice("Bearer ".length) ?? null;
71
+ return Response.json(
72
+ { error: { type: "authentication_error", message: "test rejection" } },
73
+ { status: 401 },
74
+ );
75
+ }) as typeof fetch;
76
+ try {
77
+ const adapter = new ClaudeManagedAdapter();
78
+ await expect(
79
+ adapter.createSession(
80
+ tConfig({
81
+ env:
82
+ source === "container"
83
+ ? {}
84
+ : {
85
+ ANTHROPIC_API_KEY:
86
+ source === "task"
87
+ ? "example-task"
88
+ : "example-task-first,example-task-second",
89
+ },
90
+ }),
91
+ ),
92
+ ).rejects.toThrow("test rejection");
93
+ expect(
94
+ source === "container"
95
+ ? ["example-container-first", "example-container-second"]
96
+ : source === "task"
97
+ ? ["example-task"]
98
+ : ["example-task-first", "example-task-second"],
99
+ ).toContain(apiKey);
100
+ } finally {
101
+ globalThis.fetch = originalFetch;
102
+ if (originalKey === undefined) delete process.env.ANTHROPIC_API_KEY;
103
+ else process.env.ANTHROPIC_API_KEY = originalKey;
104
+ }
105
+ });
106
+ }
107
+
62
108
  test("ctor throws when MANAGED_AGENT_ID is missing", () => {
63
109
  const saved = process.env.MANAGED_AGENT_ID;
64
110
  delete process.env.MANAGED_AGENT_ID;
@@ -10,10 +10,10 @@
10
10
  * auth redacted.
11
11
  */
12
12
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
13
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
14
14
  import { createRequire } from "node:module";
15
15
  import { tmpdir } from "node:os";
16
- import { dirname, join } from "node:path";
16
+ import { basename, dirname, join } from "node:path";
17
17
  import { deriveDefaultRoute } from "@desplega/model-routing";
18
18
  import { closeDb, createAgent, getAgentById, initDb, updateAgentCredentialState } from "../be/db";
19
19
  import { awaitCredentials } from "../commands/credential-wait";
@@ -240,6 +240,11 @@ describe.skipIf(!CLAUDE_BINARY)("claude routes e2e (real CLI, fake endpoints)",
240
240
 
241
241
  expect(result).toMatchObject({ isError: false, exitCode: 0 });
242
242
  expect(result.output).toContain(REPLY);
243
+ // Trust lands in the session's HOME, never in the host's ~/.claude.json.
244
+ const claudeJson = JSON.parse(readFileSync(join(env.HOME ?? "", ".claude.json"), "utf8"));
245
+ expect(
246
+ Object.keys(claudeJson.projects ?? {}).some((dir) => dir.endsWith(basename(cwd))),
247
+ ).toBe(true);
243
248
 
244
249
  const turn = fake.requests.find(
245
250
  (req) => req.method === "POST" && route.turnPath.test(req.path),
@@ -0,0 +1,120 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { CodexAdapter } from "../providers/codex-adapter";
6
+ import type { ProviderSessionConfig } from "../providers/types";
7
+
8
+ const keyVars = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY"];
9
+ let savedEnv: Record<string, string | undefined>;
10
+ let home: string;
11
+
12
+ beforeEach(async () => {
13
+ savedEnv = Object.fromEntries(["HOME", ...keyVars].map((key) => [key, process.env[key]]));
14
+ home = await mkdtemp(join(tmpdir(), "credential-validation-codex-"));
15
+ process.env.HOME = home;
16
+ for (const key of keyVars) process.env[key] = "example-first, example-second";
17
+ });
18
+
19
+ afterEach(async () => {
20
+ for (const [key, value] of Object.entries(savedEnv)) {
21
+ if (value === undefined) delete process.env[key];
22
+ else process.env[key] = value;
23
+ }
24
+ await rm(home, { recursive: true, force: true });
25
+ });
26
+
27
+ function config(env?: Record<string, string>): ProviderSessionConfig {
28
+ return {
29
+ agentId: crypto.randomUUID(),
30
+ prompt: "test",
31
+ systemPrompt: "",
32
+ cwd: home,
33
+ logFile: "/dev/null",
34
+ env,
35
+ };
36
+ }
37
+
38
+ describe("Codex credential pools", () => {
39
+ for (const source of ["container", "task", "task-pool"] as const) {
40
+ test(`app-server uses one OpenAI key from ${source}`, async () => {
41
+ let capturedEnv: Record<string, string> | undefined;
42
+ const adapter = new CodexAdapter({
43
+ bypassSubprocess: true,
44
+ appServerFactory: ({ env }) => {
45
+ capturedEnv = env;
46
+ throw new Error("test stops before app-server spawn");
47
+ },
48
+ });
49
+ const env =
50
+ source === "container"
51
+ ? {}
52
+ : {
53
+ OPENAI_API_KEY:
54
+ source === "task" ? "example-task" : "example-task-first, example-task-second",
55
+ };
56
+ await expect(adapter.createSession(config(env))).rejects.toThrow(
57
+ "test stops before app-server spawn",
58
+ );
59
+ expect(
60
+ source === "container"
61
+ ? ["example-first", "example-second"]
62
+ : source === "task"
63
+ ? ["example-task"]
64
+ : ["example-task-first", "example-task-second"],
65
+ ).toContain(capturedEnv?.OPENAI_API_KEY);
66
+ });
67
+
68
+ test(`subprocess forwards one key per provider from ${source}`, async () => {
69
+ let capturedEnv: Record<string, string | undefined> | undefined;
70
+ const spawn = spyOn(Bun, "spawn").mockImplementation(((
71
+ ...args: Parameters<typeof Bun.spawn>
72
+ ) => {
73
+ capturedEnv = args[1]?.env;
74
+ throw new Error("test stops before subprocess spawn");
75
+ }) as typeof Bun.spawn);
76
+ try {
77
+ const env =
78
+ source === "container"
79
+ ? {}
80
+ : Object.fromEntries(
81
+ keyVars.map((key) => [
82
+ key,
83
+ source === "task" ? "example-task" : "example-task-first, example-task-second",
84
+ ]),
85
+ );
86
+ await expect(new CodexAdapter().createSession(config(env))).rejects.toThrow(
87
+ "test stops before subprocess spawn",
88
+ );
89
+ for (const key of keyVars) {
90
+ expect(
91
+ source === "container"
92
+ ? ["example-first", "example-second"]
93
+ : source === "task"
94
+ ? ["example-task"]
95
+ : ["example-task-first", "example-task-second"],
96
+ ).toContain(capturedEnv?.[key]);
97
+ }
98
+ } finally {
99
+ spawn.mockRestore();
100
+ }
101
+ });
102
+ }
103
+
104
+ test("chatgpt mode still removes the OpenAI API key", async () => {
105
+ await Bun.write(join(home, ".codex", "auth.json"), JSON.stringify({ auth_mode: "chatgpt" }));
106
+ let capturedEnv: Record<string, string> | undefined;
107
+ const adapter = new CodexAdapter({
108
+ bypassSubprocess: true,
109
+ appServerFactory: ({ env }) => {
110
+ capturedEnv = env;
111
+ throw new Error("test stops before app-server spawn");
112
+ },
113
+ });
114
+ await expect(adapter.createSession(config({ OPENAI_API_KEY: "example-task" }))).rejects.toThrow(
115
+ "test stops before app-server spawn",
116
+ );
117
+ expect(capturedEnv).toBeDefined();
118
+ expect(capturedEnv?.OPENAI_API_KEY).toBeUndefined();
119
+ });
120
+ });