@desplega.ai/agent-swarm 2.2.0 → 2.4.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 (266) hide show
  1. package/README.md +1 -0
  2. package/dist/{acp-adapter-k0vwfyvn.js → acp-adapter-kedstxx6.js} +6 -6
  3. package/dist/{actions-w4pnb8vz.js → actions-vget6r1k.js} +18 -16
  4. package/dist/{amp-adapter-0cmxb2t5.js → amp-adapter-xeb5mktv.js} +2 -2
  5. package/dist/{app-zneem7g9.js → app-mqap8t8m.js} +3 -3
  6. package/dist/{assistant-wbzd2rnr.js → assistant-h0gv97mv.js} +20 -18
  7. package/dist/{boot-reembed-4kx69j3g.js → boot-reembed-qr8a894z.js} +1 -1
  8. package/dist/{boot-reembed-db0wwgy1.js → boot-reembed-rj85jjhw.js} +1 -1
  9. package/dist/{boot-scrub-sweep-tfv4d3mm.js → boot-scrub-sweep-eyfvn6v9.js} +3 -3
  10. package/dist/{boot-seeds-w2hmkxn5.js → boot-seeds-xs9a8dx1.js} +4 -4
  11. package/dist/{claude-adapter-5qrc2paq.js → claude-adapter-bm23cye7.js} +5 -5
  12. package/dist/{claude-managed-adapter-n7ahjyx6.js → claude-managed-adapter-1mwrsm9d.js} +2 -2
  13. package/dist/{claude-managed-setup-7ra32em4.js → claude-managed-setup-gkfgks4j.js} +1 -1
  14. package/dist/{claude-sdk-session-cd02gc76.js → claude-sdk-session-apb4ypz3.js} +5 -5
  15. package/dist/cli-02vhhssk.js +848 -0
  16. package/dist/{cli-4ync7m60.js → cli-0ebey9hg.js} +1 -1
  17. package/dist/{cli-665j3feb.js → cli-0xk505e8.js} +1 -1
  18. package/dist/{cli-8c1m9mhy.js → cli-1cevz46c.js} +5 -5
  19. package/dist/{cli-gzvb3d4a.js → cli-1jx0srjt.js} +3 -3
  20. package/dist/{cli-zj4d6afs.js → cli-1pa3rz69.js} +21 -6
  21. package/dist/{cli-vmbakbq0.js → cli-2rwet3r7.js} +1 -1
  22. package/dist/{cli-azs9bhc5.js → cli-3wjs22x3.js} +2 -2
  23. package/dist/{cli-tba8fxf9.js → cli-4fhyq711.js} +45 -36
  24. package/dist/{cli-ak4c12mh.js → cli-5ff2sjyt.js} +1 -1
  25. package/dist/{cli-6q7pyd3k.js → cli-5s5hx57y.js} +5 -3
  26. package/dist/{cli-h23mr2s6.js → cli-6ftrrwm1.js} +9 -371
  27. package/dist/{cli-9ac0mkg5.js → cli-8b9wvjps.js} +1 -1
  28. package/dist/cli-8cxkw1f5.js +382 -0
  29. package/dist/{cli-xhgtay7w.js → cli-8rwvksbc.js} +2 -2
  30. package/dist/{cli-e09hj50q.js → cli-8wkb09q5.js} +11 -11
  31. package/dist/{cli-ksrphveb.js → cli-a1esmmee.js} +14 -2
  32. package/dist/{cli-xnp2vkx7.js → cli-avmqhatn.js} +7 -7
  33. package/dist/{cli-xx13gqf3.js → cli-b80a1100.js} +1 -1
  34. package/dist/{cli-z1hh99j0.js → cli-bfc3k1x5.js} +3 -3
  35. package/dist/{cli-0k9rcwfy.js → cli-bhwa8rzc.js} +33 -15
  36. package/dist/{cli-8cvvytad.js → cli-bn26xbcs.js} +2 -2
  37. package/dist/{cli-k28p6xh7.js → cli-c8fwpgqb.js} +1 -1
  38. package/dist/{cli-rc3r0bnh.js → cli-ccn0eybf.js} +1 -1
  39. package/dist/{cli-9td5ss65.js → cli-dsgj3e7a.js} +2 -2
  40. package/dist/{cli-jrk8mj0t.js → cli-ecshf504.js} +2 -2
  41. package/dist/{cli-raejy0mp.js → cli-eqgfs202.js} +1 -1
  42. package/dist/{cli-ppapy34w.js → cli-f3nx1h8z.js} +2 -2
  43. package/dist/{cli-c9vrwcpb.js → cli-ffbrkh1r.js} +74 -35
  44. package/dist/{cli-mnry16kh.js → cli-kk2r80n5.js} +4 -4
  45. package/dist/{cli-c0dpzwnx.js → cli-kz116kvw.js} +3 -3
  46. package/dist/{cli-e5y285rg.js → cli-mvg2hf8p.js} +4 -4
  47. package/dist/{cli-2jm992rm.js → cli-mz1ezzzh.js} +2 -2
  48. package/dist/{cli-dw0njd9q.js → cli-nme7wery.js} +6 -6
  49. package/dist/{cli-mvsfbbgq.js → cli-nrrg0fvy.js} +18 -4
  50. package/dist/{cli-8jtrekpy.js → cli-p1m2b6gc.js} +7 -7
  51. package/dist/{cli-4c7k75rq.js → cli-rg7ntypm.js} +378 -84
  52. package/dist/{cli-z3dk189k.js → cli-rsd7ga7v.js} +10 -8
  53. package/dist/{cli-etvn5ej2.js → cli-sppc0cmw.js} +1 -1
  54. package/dist/{cli-8w65vwee.js → cli-tg9ksbn8.js} +1 -1
  55. package/dist/{cli-th8krqbf.js → cli-tgvpmnwz.js} +2 -2
  56. package/dist/{cli-hwc4a9xm.js → cli-tj4j2ts4.js} +6 -6
  57. package/dist/{cli-jhhb2v9n.js → cli-ttaz6c7m.js} +2 -2
  58. package/dist/{cli-w7z94e6c.js → cli-tx00c0h6.js} +4 -2
  59. package/dist/{cli-4g629sgr.js → cli-wrv09v7q.js} +20 -1
  60. package/dist/{cli-cby329ny.js → cli-wyrr38xm.js} +2 -2
  61. package/dist/{cli-5ks6x36q.js → cli-x1p5dhq2.js} +3 -3
  62. package/dist/{cli-bddxamg7.js → cli-x719j3vs.js} +220 -629
  63. package/dist/{cli-ztcxv5nv.js → cli-xfe63zx3.js} +1 -1
  64. package/dist/{cli-q6gw45wj.js → cli-ywyrffff.js} +1 -1
  65. package/dist/{cli-p46xdr1m.js → cli-zvze9rbm.js} +1 -1
  66. package/dist/cli.js +15 -15
  67. package/dist/{codex-adapter-m0fx3v3s.js → codex-adapter-pp2gq8gr.js} +3 -3
  68. package/dist/{codex-hook-x6tm6y5m.js → codex-hook-qwzccd64.js} +1 -1
  69. package/dist/{codex-session-runner-j9m2gfyy.js → codex-session-runner-0vzn09t8.js} +3 -3
  70. package/dist/{commands-ktnqwrv4.js → commands-psqhkttb.js} +2 -2
  71. package/dist/{controller-nex2he8v.js → controller-t20vz2k8.js} +2 -2
  72. package/dist/{controllers-connect-6yhwqv9v.js → controllers-connect-gkxmqw9d.js} +24 -20
  73. package/dist/{cursor-adapter-02s5ekva.js → cursor-adapter-3v542qe5.js} +1 -1
  74. package/dist/{db-jvr86v7f.js → db-2xbyenjr.js} +2 -2
  75. package/dist/{db-19tw9r5s.js → db-nc1rd869.js} +3 -2
  76. package/dist/{dispatcher-d3mnsg2y.js → dispatcher-ewceb8ts.js} +17 -15
  77. package/dist/{dsh-adapter-63rvej88.js → dsh-adapter-75xwh78t.js} +1 -1
  78. package/dist/{easter-egg-3d-6hg2r942.js → easter-egg-3d-xsc47jwf.js} +1 -1
  79. package/dist/{execute-bcqc69a5.js → execute-gzmw1mnw.js} +1 -1
  80. package/dist/{external-channel-44cwer32.js → external-channel-a5dsmxn4.js} +2 -2
  81. package/dist/{google-generative-ai-phpmwxvg.js → google-generative-ai-bmq8gkf3.js} +1 -1
  82. package/dist/{google-vertex-es9ybgdy.js → google-vertex-b1rvp52s.js} +1 -1
  83. package/dist/{grok-adapter-ysp907f6.js → grok-adapter-vj88xr23.js} +7 -7
  84. package/dist/handlers-zbs90bny.js +39 -0
  85. package/dist/{heartbeat-2etacsqq.js → heartbeat-t2m9r3wa.js} +20 -18
  86. package/dist/{hook-mzxxs5n2.js → hook-8femx1bg.js} +4 -4
  87. package/dist/{hook-3kvmax6d.js → hook-xa677f8r.js} +5 -5
  88. package/dist/{http-fn978m0g.js → http-xpmmq028.js} +940 -269
  89. package/dist/{index-e4tsa1h7.js → index-8pwc1jc8.js} +17 -15
  90. package/dist/{index-w2wr89xd.js → index-cdzyfrsp.js} +1 -1
  91. package/dist/{index-1r8hw4vd.js → index-dg2tmj99.js} +17 -15
  92. package/dist/index-nxa6me0q.js +41 -0
  93. package/dist/{index-039x2sag.js → index-s7ckdajq.js} +42 -14
  94. package/dist/{index-wgsw9xcz.js → index-syztqdt6.js} +1 -1
  95. package/dist/{index-x0q2a4f9.js → index-ysd45kgc.js} +1 -1
  96. package/dist/{index-g909y089.js → index-zgvqzjsw.js} +8 -8
  97. package/dist/{keepalive-d4nzs76x.js → keepalive-3nr4st3r.js} +3 -3
  98. package/dist/{lead-8h5n9k5f.js → lead-td8h2sfn.js} +33 -31
  99. package/dist/{maintenance-ea6jfw42.js → maintenance-verwpe2c.js} +1 -1
  100. package/dist/{oauth-refresh-sweep-3f25djq3.js → oauth-refresh-sweep-9q2t0y3q.js} +2 -2
  101. package/dist/{onboard-pvk7hdb2.js → onboard-aytqedmq.js} +2 -2
  102. package/dist/{opencode-adapter-eeesgfqk.js → opencode-adapter-f4hryjny.js} +4 -4
  103. package/dist/{otel-impl-qb7nhgdp.js → otel-impl-hckw4wt1.js} +1 -1
  104. package/dist/{pi-mono-adapter-zasp3ze3.js → pi-mono-adapter-hxby0ce1.js} +6 -6
  105. package/dist/{pricing-refresh-6ehwkcmp.js → pricing-refresh-5nvme3sm.js} +3 -3
  106. package/dist/{rbac-roles-km0f9cmm.js → rbac-roles-pd8c26m1.js} +5 -3
  107. package/dist/{rbac-roles-9648nh00.js → rbac-roles-vbpy21mg.js} +2 -2
  108. package/dist/{render-v2-xkze1wef.js → render-v2-7nqgvk2q.js} +3 -3
  109. package/dist/{resume-tq0bbvt5.js → resume-b6djcj3c.js} +6 -6
  110. package/dist/{runtime-ar8310ba.js → runtime-txrxc9za.js} +1 -1
  111. package/dist/session-access-fc3f9y12.js +25 -0
  112. package/dist/{setup-rnrazxvd.js → setup-n39szm6b.js} +1 -1
  113. package/dist/{virtual-modules-wzk6jf3a.js → virtual-modules-wyp2k6z3.js} +2 -2
  114. package/dist/{work-objects-e75q2rqs.js → work-objects-jem2fre0.js} +20 -18
  115. package/dist/{worker-4ww4hjef.js → worker-wr6xgfr2.js} +33 -31
  116. package/openapi.json +1011 -115
  117. package/package.json +2 -2
  118. package/src/apps/action-caller.ts +6 -0
  119. package/src/apps/script-types.ts +16 -3
  120. package/src/be/asset-key-audit.ts +4 -13
  121. package/src/be/db/active-sessions.ts +5 -0
  122. package/src/be/db/session-visibility.ts +29 -0
  123. package/src/be/db/tasks/read.ts +61 -3
  124. package/src/be/db/tasks/write.ts +15 -6
  125. package/src/be/db.ts +149 -66
  126. package/src/be/events.ts +11 -0
  127. package/src/be/migrations/227_inbox_item_state_last_read_at.sql +1 -0
  128. package/src/be/migrations/230_session_shares.sql +23 -0
  129. package/src/be/rbac-roles.ts +2 -0
  130. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +22 -4
  131. package/src/be/session-shares.ts +244 -0
  132. package/src/be/swarm-config-guard.ts +23 -2
  133. package/src/commands/runner.ts +11 -0
  134. package/src/heartbeat/heartbeat.ts +25 -7
  135. package/src/http/active-sessions.ts +13 -2
  136. package/src/http/agents.ts +10 -3
  137. package/src/http/all-routes.ts +1 -0
  138. package/src/http/api-keys.ts +3 -1
  139. package/src/http/approval-requests.ts +2 -1
  140. package/src/http/apps.ts +36 -11
  141. package/src/http/assets.ts +26 -4
  142. package/src/http/config.ts +3 -0
  143. package/src/http/context.ts +2 -0
  144. package/src/http/core.ts +39 -2
  145. package/src/http/events.ts +6 -2
  146. package/src/http/favorite-owner.ts +1 -1
  147. package/src/http/fs.ts +38 -3
  148. package/src/http/inbox-state.ts +58 -8
  149. package/src/http/index.ts +3 -1
  150. package/src/http/memory.ts +3 -1
  151. package/src/http/ranking.ts +367 -0
  152. package/src/http/realtime.ts +26 -21
  153. package/src/http/route-def.ts +77 -11
  154. package/src/http/script-runs.ts +2 -1
  155. package/src/http/scripts.ts +8 -0
  156. package/src/http/session-access.ts +179 -0
  157. package/src/http/session-data.ts +10 -2
  158. package/src/http/sessions.ts +212 -12
  159. package/src/http/stats.ts +7 -3
  160. package/src/http/tasks.ts +83 -7
  161. package/src/http/used-by.ts +11 -3
  162. package/src/kv-reserved-namespaces.ts +1 -0
  163. package/src/prompts/session-templates.ts +3 -1
  164. package/src/providers/amp-adapter.ts +1 -1
  165. package/src/ranking/telemetry-ranking.ts +147 -0
  166. package/src/ranking/usage.ts +57 -0
  167. package/src/rbac/admission.ts +22 -7
  168. package/src/rbac/legacy-policy.ts +2 -0
  169. package/src/rbac/permissions.ts +9 -0
  170. package/src/realtime/auth.ts +69 -0
  171. package/src/realtime/tickets.ts +4 -1
  172. package/src/realtime/transport.ts +77 -9
  173. package/src/slack/blocks.ts +18 -0
  174. package/src/slack/dm.ts +29 -0
  175. package/src/telemetry-contract/EVENTS.md +3 -0
  176. package/src/telemetry-contract/SOURCE.json +37 -4
  177. package/src/telemetry-contract/catalog.json +69 -0
  178. package/src/telemetry-contract/ranking/fixtures/expected.json +30 -0
  179. package/src/telemetry-contract/ranking/fixtures/invalid/bad-category.json +11 -0
  180. package/src/telemetry-contract/ranking/fixtures/invalid/bad-claim.json +11 -0
  181. package/src/telemetry-contract/ranking/fixtures/invalid/blank-description.json +11 -0
  182. package/src/telemetry-contract/ranking/fixtures/invalid/blank-name.json +11 -0
  183. package/src/telemetry-contract/ranking/fixtures/invalid/description-too-long.json +11 -0
  184. package/src/telemetry-contract/ranking/fixtures/invalid/domain-uppercase.json +11 -0
  185. package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-scheme.json +11 -0
  186. package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-www.json +11 -0
  187. package/src/telemetry-contract/ranking/fixtures/invalid/http-source-url.json +11 -0
  188. package/src/telemetry-contract/ranking/fixtures/invalid/missing-description.json +10 -0
  189. package/src/telemetry-contract/ranking/fixtures/invalid/missing-version.json +10 -0
  190. package/src/telemetry-contract/ranking/fixtures/invalid/name-not-string.json +11 -0
  191. package/src/telemetry-contract/ranking/fixtures/invalid/name-too-long.json +11 -0
  192. package/src/telemetry-contract/ranking/fixtures/invalid/not-object.json +1 -0
  193. package/src/telemetry-contract/ranking/fixtures/invalid/null.json +1 -0
  194. package/src/telemetry-contract/ranking/fixtures/invalid/other-with-subcategory.json +11 -0
  195. package/src/telemetry-contract/ranking/fixtures/invalid/oversize.json +1 -0
  196. package/src/telemetry-contract/ranking/fixtures/invalid/short-claim.json +11 -0
  197. package/src/telemetry-contract/ranking/fixtures/invalid/subcategory-other-category.json +11 -0
  198. package/src/telemetry-contract/ranking/fixtures/invalid/trailing-data.json +1 -0
  199. package/src/telemetry-contract/ranking/fixtures/invalid/unknown-field.json +12 -0
  200. package/src/telemetry-contract/ranking/fixtures/invalid/unsupported-version.json +11 -0
  201. package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-not-date.json +11 -0
  202. package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-offset.json +11 -0
  203. package/src/telemetry-contract/ranking/fixtures/invalid/wrong-case-field.json +11 -0
  204. package/src/telemetry-contract/ranking/fixtures/valid/category-without-subcategory.json +11 -0
  205. package/src/telemetry-contract/ranking/fixtures/valid/full.json +11 -0
  206. package/src/telemetry-contract/ranking/fixtures/valid/minimal.json +11 -0
  207. package/src/telemetry-contract/ranking/fixtures/valid/padded-text.json +11 -0
  208. package/src/telemetry-contract/ranking/fixtures/valid/unicode-name.json +11 -0
  209. package/src/telemetry-contract/ranking/listing.v1.schema.json +61 -0
  210. package/src/telemetry-contract/ranking/taxonomy.json +8 -0
  211. package/src/telemetry-contract/ranking-taxonomy.gen.ts +11 -0
  212. package/src/telemetry-contract/types.gen.ts +708 -1
  213. package/src/telemetry.ts +26 -2
  214. package/src/tests/apps-rbac.test.ts +189 -2
  215. package/src/tests/apps-script-types.test.ts +16 -3
  216. package/src/tests/asset-key-api.test.ts +128 -0
  217. package/src/tests/asset-key-invariants.test.ts +179 -3
  218. package/src/tests/asset-key-migration.test.ts +35 -0
  219. package/src/tests/check-pr-body.test.ts +23 -0
  220. package/src/tests/check-ranking-entry.test.ts +146 -0
  221. package/src/tests/db-extraction-contract.test.ts +1 -0
  222. package/src/tests/db-task-read-contract.test.ts +1 -0
  223. package/src/tests/docs-index-footprint.test.js +2 -2
  224. package/src/tests/heartbeat-checklist.test.ts +88 -0
  225. package/src/tests/heartbeat-jev-gate.test.ts +394 -10
  226. package/src/tests/inbox-state.test.ts +167 -0
  227. package/src/tests/kv-tool.test.ts +15 -0
  228. package/src/tests/org-identity.test.ts +44 -0
  229. package/src/tests/ranking-listing.test.ts +112 -0
  230. package/src/tests/ranking-route.test.ts +319 -0
  231. package/src/tests/ranking-usage.test.ts +50 -0
  232. package/src/tests/rbac-admission-e2e.test.ts +8 -0
  233. package/src/tests/rbac-admission.test.ts +38 -0
  234. package/src/tests/rbac-charact-http.test.ts +4 -3
  235. package/src/tests/rbac-engine.test.ts +2 -0
  236. package/src/tests/rbac-roles.test.ts +2 -0
  237. package/src/tests/realtime-room-surfaces.test.ts +3 -1
  238. package/src/tests/realtime-session-join-race.test.ts +142 -0
  239. package/src/tests/realtime-tickets.test.ts +24 -17
  240. package/src/tests/realtime-transport.test.ts +91 -0
  241. package/src/tests/script-connections.test.ts +2 -0
  242. package/src/tests/session-route-posture.test.ts +639 -0
  243. package/src/tests/session-sharing-routes.test.ts +282 -0
  244. package/src/tests/session-sharing.test.ts +526 -0
  245. package/src/tests/sessions-detail-batch.test.ts +7 -1
  246. package/src/tests/slack-tool-blocks.test.ts +101 -0
  247. package/src/tests/slack-update.test.ts +55 -0
  248. package/src/tests/slack-user-dm.test.ts +177 -0
  249. package/src/tests/used-by-route.test.ts +10 -0
  250. package/src/tools/slack-post.ts +18 -12
  251. package/src/tools/slack-read.ts +13 -1
  252. package/src/tools/slack-reply.ts +7 -10
  253. package/src/tools/slack-start-thread.ts +18 -11
  254. package/src/tools/slack-update.ts +2 -9
  255. package/src/types.ts +37 -0
  256. package/src/utils/org-identity.ts +33 -0
  257. package/templates/skills/apps/SKILL.md +17 -0
  258. package/templates/skills/apps/content.md +17 -0
  259. package/templates/skills/code-quality/content.md +2 -2
  260. package/templates/skills/pages/content.md +4 -0
  261. package/templates/skills/swarm-scripts/SKILL.md +6 -0
  262. package/templates/skills/swarm-scripts/content.md +6 -0
  263. package/dist/handlers-t5pbabwf.js +0 -37
  264. package/dist/index-qyjbb7br.js +0 -39
  265. package/dist/{cli-n5mrsg0f.js → cli-60cgh1rv.js} +3 -3
  266. /package/dist/{cli-j0v75y13.js → cli-y83zk5sb.js} +0 -0
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { orgIdentityStatus } from "../utils/org-identity";
3
+
4
+ describe("orgIdentityStatus", () => {
5
+ test.each([
6
+ ["empty", {}, ["SWARM_ORG_NAME", "SWARM_ORG_WEBSITE"]],
7
+ [
8
+ "whitespace",
9
+ { SWARM_ORG_NAME: " ", SWARM_ORG_WEBSITE: " " },
10
+ ["SWARM_ORG_NAME", "SWARM_ORG_WEBSITE"],
11
+ ],
12
+ [
13
+ "the catalog default",
14
+ { SWARM_ORG_NAME: "Swarm", SWARM_ORG_WEBSITE: "acme.com" },
15
+ ["SWARM_ORG_NAME"],
16
+ ],
17
+ [
18
+ "the Slack fallback",
19
+ { SWARM_ORG_NAME: " your swarm ", SWARM_ORG_WEBSITE: "acme.com" },
20
+ ["SWARM_ORG_NAME"],
21
+ ],
22
+ [
23
+ "an invalid website",
24
+ { SWARM_ORG_NAME: "Acme", SWARM_ORG_WEBSITE: "localhost" },
25
+ ["SWARM_ORG_WEBSITE"],
26
+ ],
27
+ ["both set", { SWARM_ORG_NAME: " Acme ", SWARM_ORG_WEBSITE: "https://www.Acme.com/" }, []],
28
+ ] as const)("%s", (_label, src, missing) => {
29
+ const status = orgIdentityStatus(src);
30
+ expect(status.missing).toEqual([...missing]);
31
+ expect(status.ok).toBe(missing.length === 0);
32
+ });
33
+
34
+ test("returns the trimmed name and the normalized website", () => {
35
+ expect(
36
+ orgIdentityStatus({ SWARM_ORG_NAME: " Acme ", SWARM_ORG_WEBSITE: "https://www.Acme.com/" }),
37
+ ).toEqual({
38
+ name: "Acme",
39
+ website: "acme.com",
40
+ missing: [],
41
+ ok: true,
42
+ });
43
+ });
44
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { isInternalConfigKey, validateConfigValue } from "../be/swarm-config-guard";
5
+ import {
6
+ buildListing,
7
+ mintRankingClaim,
8
+ parseTelemetryRanking,
9
+ rankingEntryFile,
10
+ } from "../ranking/telemetry-ranking";
11
+ import { RANKING_TAXONOMY } from "../telemetry-contract/ranking-taxonomy.gen";
12
+ import { orgIdentityStatus } from "../utils/org-identity";
13
+
14
+ const FIXTURES = join(import.meta.dir, "..", "telemetry-contract", "ranking", "fixtures");
15
+ const read = (kind: string, file: string) => readFileSync(join(FIXTURES, kind, file), "utf8");
16
+
17
+ describe("telemetry_ranking contract fixtures", () => {
18
+ const expected = JSON.parse(readFileSync(join(FIXTURES, "expected.json"), "utf8")) as {
19
+ invalid: Record<string, string>;
20
+ };
21
+
22
+ test("the fixture set is complete", () => {
23
+ expect(readdirSync(join(FIXTURES, "valid")).length).toBeGreaterThan(0);
24
+ expect(readdirSync(join(FIXTURES, "invalid")).sort()).toEqual(
25
+ Object.keys(expected.invalid).sort(),
26
+ );
27
+ });
28
+
29
+ for (const file of readdirSync(join(FIXTURES, "valid"))) {
30
+ test(`valid/${file} parses`, () => {
31
+ expect(() => parseTelemetryRanking(read("valid", file))).not.toThrow();
32
+ });
33
+ }
34
+
35
+ for (const file of readdirSync(join(FIXTURES, "invalid"))) {
36
+ test(`invalid/${file} is rejected (${expected.invalid[file]})`, () => {
37
+ expect(() => parseTelemetryRanking(read("invalid", file))).toThrow();
38
+ });
39
+ }
40
+ });
41
+
42
+ describe("buildListing", () => {
43
+ const identity = orgIdentityStatus({ SWARM_ORG_NAME: "Acme", SWARM_ORG_WEBSITE: "acme.dev" });
44
+ const claim = mintRankingClaim();
45
+
46
+ test("copies the name and domain from the org identity and blanks to null", () => {
47
+ const listing = buildListing(
48
+ identity,
49
+ {
50
+ category: "engineering",
51
+ subcategory: "qa-testing",
52
+ description: " ",
53
+ sourceCodeUrl: null,
54
+ },
55
+ claim,
56
+ new Date("2026-10-18T10:00:00Z"),
57
+ );
58
+ expect(listing).toEqual({
59
+ v: 1,
60
+ claim,
61
+ name: "Acme",
62
+ domain: "acme.dev",
63
+ category: "engineering",
64
+ subcategory: "qa-testing",
65
+ description: null,
66
+ sourceCodeUrl: null,
67
+ updatedAt: "2026-10-18T10:00:00.000Z",
68
+ });
69
+ expect(JSON.parse(rankingEntryFile(listing))).toEqual({
70
+ $schema: "./schema.json",
71
+ domain: "acme.dev",
72
+ claim,
73
+ });
74
+ });
75
+
76
+ test("refuses a missing identity and an http source URL", () => {
77
+ expect(() =>
78
+ buildListing(
79
+ orgIdentityStatus({ SWARM_ORG_NAME: "Swarm", SWARM_ORG_WEBSITE: "acme.dev" }),
80
+ { category: "other" },
81
+ claim,
82
+ ),
83
+ ).toThrow("SWARM_ORG_NAME");
84
+ expect(() =>
85
+ buildListing(identity, { category: "other", sourceCodeUrl: "http://github.com/acme" }, claim),
86
+ ).toThrow();
87
+ });
88
+
89
+ test("mints rk_ + 32 hex claims", () => {
90
+ expect(mintRankingClaim()).toMatch(/^rk_[0-9a-f]{32}$/);
91
+ expect(mintRankingClaim()).not.toBe(mintRankingClaim());
92
+ });
93
+
94
+ test("the taxonomy comes from the vendored contract", () => {
95
+ expect(RANKING_TAXONOMY).toEqual(
96
+ JSON.parse(readFileSync(join(FIXTURES, "..", "taxonomy.json"), "utf8")),
97
+ );
98
+ });
99
+ });
100
+
101
+ describe("config guard", () => {
102
+ test("telemetry_ranking is internal and validated", () => {
103
+ expect(isInternalConfigKey("telemetry_ranking")).toBe(true);
104
+ expect(validateConfigValue("telemetry_ranking", "{}")).toContain("telemetry_ranking");
105
+ expect(validateConfigValue("telemetry_ranking", read("valid", "full.json"))).toBeNull();
106
+ });
107
+
108
+ test("PUBLIC_RANKING_ENABLED is a boolean", () => {
109
+ expect(validateConfigValue("PUBLIC_RANKING_ENABLED", "true")).toBeNull();
110
+ expect(validateConfigValue("PUBLIC_RANKING_ENABLED", "yes please")).not.toBeNull();
111
+ });
112
+ });
@@ -0,0 +1,319 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { Readable } from "node:stream";
4
+ import { closeDb, getSwarmConfigs, initDb, upsertSwarmConfig } from "../be/db";
5
+ import { handleConfig } from "../http/config";
6
+ import { handleRanking } from "../http/ranking";
7
+ import { getPathSegments, parseQueryParams } from "../http/utils";
8
+ import { parseTelemetryRanking } from "../ranking/telemetry-ranking";
9
+ import { _resetTelemetryStateForTests, initTelemetry } from "../telemetry";
10
+ import type { HttpRequestAuth } from "../utils/request-auth-context";
11
+ import { setRequestAuth } from "../utils/request-auth-context";
12
+
13
+ const TEST_DB_PATH = "./test-ranking-route.sqlite";
14
+ const OPERATOR: HttpRequestAuth = { kind: "operator", fingerprint: "op:test" };
15
+ const INSTALL_ID = "install_0123456789abcdef0123456789abcdef";
16
+ const ENV_KEYS = [
17
+ "ANONYMIZED_TELEMETRY",
18
+ "SWARM_ORG_ID",
19
+ "SWARM_ORG_NAME",
20
+ "SWARM_ORG_WEBSITE",
21
+ "PUBLIC_RANKING_ENABLED",
22
+ ] as const;
23
+
24
+ async function removeDbFiles(): Promise<void> {
25
+ for (const suffix of ["", "-wal", "-shm"]) {
26
+ try {
27
+ await unlink(TEST_DB_PATH + suffix);
28
+ } catch {
29
+ // Not there.
30
+ }
31
+ }
32
+ }
33
+
34
+ type Handler = typeof handleRanking;
35
+
36
+ async function call(
37
+ method: string,
38
+ url: string,
39
+ body?: unknown,
40
+ handler: Handler = handleRanking,
41
+ auth: HttpRequestAuth = OPERATOR,
42
+ ): Promise<{ statusCode: number; json: Record<string, unknown> }> {
43
+ const req = Readable.from(
44
+ body === undefined ? [] : [Buffer.from(JSON.stringify(body))],
45
+ ) as Readable & {
46
+ method: string;
47
+ url: string;
48
+ headers: Record<string, string>;
49
+ };
50
+ req.method = method;
51
+ req.url = url;
52
+ req.headers = { "content-type": "application/json" };
53
+ setRequestAuth(req as never, auth);
54
+ let statusCode = 200;
55
+ const chunks: string[] = [];
56
+ const res = {
57
+ setHeader: () => {},
58
+ writeHead: (code: number) => {
59
+ statusCode = code;
60
+ },
61
+ end: (chunk?: string) => {
62
+ if (chunk) chunks.push(chunk);
63
+ },
64
+ };
65
+ await handler(req as never, res as never, getPathSegments(url), parseQueryParams(url));
66
+ const text = chunks.join("");
67
+ return { statusCode, json: text ? JSON.parse(text) : {} };
68
+ }
69
+
70
+ const configValue = async (key: string) =>
71
+ (await getSwarmConfigs({ scope: "global", key }))?.[0]?.value;
72
+
73
+ describe("ranking routes", () => {
74
+ const originalFetch = globalThis.fetch;
75
+ const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
76
+ let sent: Array<{
77
+ event: string;
78
+ properties: Record<string, unknown>;
79
+ context: Record<string, unknown>;
80
+ }>;
81
+
82
+ beforeAll(async () => {
83
+ for (const key of ENV_KEYS) delete process.env[key];
84
+ process.env.ANONYMIZED_TELEMETRY = "true";
85
+ process.env.SWARM_ORG_ID = "org_0123456789abcdef";
86
+ await removeDbFiles();
87
+ initDb(TEST_DB_PATH);
88
+ globalThis.fetch = (async (url: string, init?: { body?: string }) => {
89
+ if (String(url).endsWith("/v1/events")) sent.push(JSON.parse(init?.body ?? "{}"));
90
+ return new Response(null, { status: 202 });
91
+ }) as unknown as typeof fetch;
92
+ _resetTelemetryStateForTests();
93
+ await initTelemetry(
94
+ "api-server",
95
+ async (key) => (key === "telemetry_installation_id" ? INSTALL_ID : undefined),
96
+ async () => {},
97
+ );
98
+ });
99
+
100
+ afterAll(async () => {
101
+ for (const key of ENV_KEYS) {
102
+ if (savedEnv[key] === undefined) delete process.env[key];
103
+ else process.env[key] = savedEnv[key];
104
+ }
105
+ globalThis.fetch = originalFetch;
106
+ _resetTelemetryStateForTests();
107
+ closeDb();
108
+ await removeDbFiles();
109
+ });
110
+
111
+ beforeEach(async () => {
112
+ sent = [];
113
+ process.env.ANONYMIZED_TELEMETRY = "true";
114
+ await upsertSwarmConfig({
115
+ scope: "global",
116
+ key: "telemetry_installation_id",
117
+ value: INSTALL_ID,
118
+ });
119
+ await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_NAME", value: "Acme" });
120
+ await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "acme.dev" });
121
+ await upsertSwarmConfig({ scope: "global", key: "PUBLIC_RANKING_ENABLED", value: "false" });
122
+ await upsertSwarmConfig({ scope: "global", key: "telemetry_ranking", value: "" });
123
+ });
124
+
125
+ const form = {
126
+ category: "engineering",
127
+ subcategory: "qa-testing",
128
+ description: " QA ",
129
+ sourceCodeUrl: "",
130
+ };
131
+
132
+ test("telemetry off is a 409 telemetry_off", async () => {
133
+ process.env.ANONYMIZED_TELEMETRY = "false";
134
+ const result = await call("POST", "/api/ranking/opt-in", form);
135
+ expect(result.statusCode).toBe(409);
136
+ expect(result.json.error).toBe("telemetry_off");
137
+ expect(await configValue("PUBLIC_RANKING_ENABLED")).toBe("false");
138
+ });
139
+
140
+ test("a placeholder name and no website is a 409 that lists both", async () => {
141
+ await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_NAME", value: "Swarm" });
142
+ await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "" });
143
+ const result = await call("POST", "/api/ranking/opt-in", form);
144
+ expect(result.statusCode).toBe(409);
145
+ expect(result.json).toMatchObject({
146
+ error: "org_identity_missing",
147
+ missing: ["SWARM_ORG_NAME", "SWARM_ORG_WEBSITE"],
148
+ });
149
+ const state = await call("GET", "/api/ranking");
150
+ expect(state.json).toMatchObject({
151
+ eligible: false,
152
+ missing: ["SWARM_ORG_NAME", "SWARM_ORG_WEBSITE"],
153
+ });
154
+ expect(sent).toEqual([]);
155
+ });
156
+
157
+ test("a subcategory from another category is a 400", async () => {
158
+ const result = await call("POST", "/api/ranking/opt-in", { ...form, subcategory: "seo" });
159
+ expect(result.statusCode).toBe(400);
160
+ expect(String(result.json.error)).toContain("subcategory");
161
+ });
162
+
163
+ test("opt-in stores the listing, turns the setting on and emits it with a matching envelope", async () => {
164
+ const result = await call("POST", "/api/ranking/opt-in", form);
165
+ expect(result.statusCode).toBe(200);
166
+ const claim = String(result.json.claim);
167
+ expect(claim).toMatch(/^rk_[0-9a-f]{32}$/);
168
+ expect(result.json.domain).toBe("acme.dev");
169
+ expect(String(result.json.prUrl)).toContain("filename=community%2Franking%2Facme.dev.json");
170
+ expect(JSON.parse(String(result.json.fileJson))).toEqual({
171
+ $schema: "./schema.json",
172
+ domain: "acme.dev",
173
+ claim,
174
+ });
175
+ expect(String(result.json.verifyUrl)).toBe(
176
+ `https://proxy.desplega.sh/v1/ranking/verify?domain=acme.dev&claim=${claim}`,
177
+ );
178
+
179
+ const stored = parseTelemetryRanking(String(await configValue("telemetry_ranking")));
180
+ expect(stored).toMatchObject({
181
+ claim,
182
+ name: "Acme",
183
+ domain: "acme.dev",
184
+ category: "engineering",
185
+ subcategory: "qa-testing",
186
+ description: "QA",
187
+ sourceCodeUrl: null,
188
+ });
189
+ expect(await configValue("PUBLIC_RANKING_ENABLED")).toBe("true");
190
+
191
+ expect(sent.map((event) => event.event)).toEqual(["ranking.opted_in"]);
192
+ const listing = parseTelemetryRanking(String(sent[0]?.properties.listing));
193
+ expect(listing).toEqual(stored);
194
+ expect(sent[0]?.context).toMatchObject({ org_name: "Acme", org_website: "acme.dev" });
195
+
196
+ // A second opt-in for the same domain keeps the claim.
197
+ const again = await call("POST", "/api/ranking/opt-in", { category: "other" });
198
+ expect(again.json.claim).toBe(claim);
199
+
200
+ const state = await call("GET", "/api/ranking");
201
+ expect(state.json).toMatchObject({
202
+ eligible: true,
203
+ enabled: true,
204
+ missing: [],
205
+ listingMatchesDomain: true,
206
+ });
207
+ });
208
+
209
+ test("only a config admin reads the ranking state and its claim", async () => {
210
+ const claim = String((await call("POST", "/api/ranking/opt-in", form)).json.claim);
211
+ const worker: HttpRequestAuth = {
212
+ kind: "agent",
213
+ agentId: crypto.randomUUID(),
214
+ taskId: null,
215
+ runtimeId: null,
216
+ tokenId: "tok_test",
217
+ expiresAt: "2099-01-01T00:00:00.000Z",
218
+ };
219
+ const guest: HttpRequestAuth = { kind: "guest" };
220
+ for (const auth of [worker, guest]) {
221
+ const denied = await call("GET", "/api/ranking", undefined, handleRanking, auth);
222
+ expect(denied.statusCode).toBe(403);
223
+ expect(JSON.stringify(denied.json)).not.toContain(claim);
224
+ }
225
+ const state = await call("GET", "/api/ranking");
226
+ expect(state.statusCode).toBe(200);
227
+ expect((state.json.listing as { claim: string }).claim).toBe(claim);
228
+ });
229
+
230
+ test("concurrent first opt-ins share one claim, and leave then rejoin keeps it", async () => {
231
+ const [first, second] = await Promise.all([
232
+ call("POST", "/api/ranking/opt-in", form),
233
+ call("POST", "/api/ranking/opt-in", form),
234
+ ]);
235
+ expect(first.statusCode).toBe(200);
236
+ expect(second.statusCode).toBe(200);
237
+ // Either response may be the one merged into community/ranking.
238
+ const merged = String(first.json.claim);
239
+ expect(second.json.claim).toBe(merged);
240
+ expect(parseTelemetryRanking(String(await configValue("telemetry_ranking")))?.claim).toBe(
241
+ merged,
242
+ );
243
+
244
+ expect((await call("POST", "/api/ranking/opt-out")).statusCode).toBe(200);
245
+ const rejoin = await call("POST", "/api/ranking/opt-in", form);
246
+ expect(rejoin.json.claim).toBe(merged);
247
+ });
248
+
249
+ test("opt-out stores false, emits ranking.opted_out and returns the delete page", async () => {
250
+ await call("POST", "/api/ranking/opt-in", form);
251
+ sent = [];
252
+ const result = await call("POST", "/api/ranking/opt-out");
253
+ expect(result.statusCode).toBe(200);
254
+ expect(result.json).toEqual({
255
+ enabled: false,
256
+ leaveUrl:
257
+ "https://github.com/desplega-ai/agent-swarm/delete/main/community/ranking/acme.dev.json",
258
+ });
259
+ expect(await configValue("PUBLIC_RANKING_ENABLED")).toBe("false");
260
+ expect(sent.map((event) => [event.event, event.properties.domain])).toEqual([
261
+ ["ranking.opted_out", "acme.dev"],
262
+ ]);
263
+ });
264
+
265
+ test("PUT /api/config refuses telemetry_ranking", async () => {
266
+ const result = await call(
267
+ "PUT",
268
+ "/api/config",
269
+ { scope: "global", key: "telemetry_ranking", value: "{}" },
270
+ handleConfig,
271
+ );
272
+ expect(result.statusCode).toBe(400);
273
+ expect(String(result.json.error)).toContain("/api/ranking");
274
+ });
275
+
276
+ test("renaming the org re-emits the listing with the same claim", async () => {
277
+ const optIn = await call("POST", "/api/ranking/opt-in", form);
278
+ sent = [];
279
+ const put = await call(
280
+ "PUT",
281
+ "/api/config",
282
+ { scope: "global", key: "SWARM_ORG_NAME", value: "Acme Labs" },
283
+ handleConfig,
284
+ );
285
+ expect(put.statusCode).toBe(200);
286
+ expect(sent.map((event) => event.event)).toEqual(["ranking.opted_in"]);
287
+ const listing = parseTelemetryRanking(String(sent[0]?.properties.listing));
288
+ expect(listing).toMatchObject({
289
+ name: "Acme Labs",
290
+ claim: optIn.json.claim,
291
+ subcategory: "qa-testing",
292
+ });
293
+ // The envelope carries the new name before the config reload reaches process.env.
294
+ expect(sent[0]?.context).toMatchObject({ org_name: "Acme Labs", org_website: "acme.dev" });
295
+ expect(await configValue("PUBLIC_RANKING_ENABLED")).toBe("true");
296
+ });
297
+
298
+ test("a placeholder name, a changed website or a name too long for the listing opts out", async () => {
299
+ for (const [key, value] of [
300
+ ["SWARM_ORG_NAME", "Your Swarm"],
301
+ ["SWARM_ORG_NAME", "x".repeat(61)],
302
+ ["SWARM_ORG_WEBSITE", "acme.io"],
303
+ ] as const) {
304
+ await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_NAME", value: "Acme" });
305
+ await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "acme.dev" });
306
+ await call("POST", "/api/ranking/opt-in", form);
307
+ sent = [];
308
+ const put = await call("PUT", "/api/config", { scope: "global", key, value }, handleConfig);
309
+ expect(put.statusCode).toBe(200);
310
+ expect(await configValue("PUBLIC_RANKING_ENABLED")).toBe("false");
311
+ expect(sent.map((event) => [event.event, event.properties.domain])).toEqual([
312
+ ["ranking.opted_out", "acme.dev"],
313
+ ]);
314
+ }
315
+ // After the last case (a new website) the old listing needs a new PR.
316
+ const state = await call("GET", "/api/ranking");
317
+ expect(state.json).toMatchObject({ enabled: false, listingMatchesDomain: false, prUrl: null });
318
+ });
319
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { rankingUsageProps } from "../ranking/usage";
3
+
4
+ const ON = {
5
+ ANONYMIZED_TELEMETRY: "true",
6
+ PUBLIC_RANKING_ENABLED: "true",
7
+ SWARM_ORG_NAME: "Acme",
8
+ SWARM_ORG_WEBSITE: "acme.dev",
9
+ };
10
+ const COST = { inputTokens: 100, outputTokens: 20, cacheReadTokens: 5, cacheWriteTokens: 3 };
11
+ const SESSION = { model: "claude-opus-5-5", provider: "claude" };
12
+
13
+ describe("rankingUsageProps (the worker's ranking.usage gate)", () => {
14
+ test("emits token counts, model and provider when every gate passes", () => {
15
+ const props = rankingUsageProps(COST, SESSION, ON);
16
+ expect(props).toEqual({
17
+ model: "claude-opus-5-5",
18
+ provider: "claude",
19
+ totalTokens: 128,
20
+ inputTokens: 100,
21
+ outputTokens: 20,
22
+ cacheReadTokens: 5,
23
+ cacheWriteTokens: 3,
24
+ });
25
+ // Tokens are the only metric: no cost, task or agent.
26
+ for (const key of ["totalCostUsd", "taskId", "agentId", "durationMs", "numTurns"]) {
27
+ expect(props).not.toHaveProperty(key);
28
+ }
29
+ });
30
+
31
+ test.each([
32
+ ["telemetry off", { ANONYMIZED_TELEMETRY: "false" }],
33
+ ["the setting off", { PUBLIC_RANKING_ENABLED: "false" }],
34
+ ["the setting unset", { PUBLIC_RANKING_ENABLED: undefined }],
35
+ ["no org name", { SWARM_ORG_NAME: "" }],
36
+ ["a placeholder org name", { SWARM_ORG_NAME: "Swarm" }],
37
+ ["no website", { SWARM_ORG_WEBSITE: undefined }],
38
+ ])("nothing with %s", (_label, override) => {
39
+ expect(rankingUsageProps(COST, SESSION, { ...ON, ...override })).toBeNull();
40
+ });
41
+
42
+ test("omits cache counts the harness did not report", () => {
43
+ expect(rankingUsageProps({ inputTokens: 7, outputTokens: 1 }, SESSION, ON)).toEqual({
44
+ ...SESSION,
45
+ totalTokens: 8,
46
+ inputTokens: 7,
47
+ outputTokens: 1,
48
+ });
49
+ });
50
+ });
@@ -254,6 +254,12 @@ describe("RBAC admission over real HTTP", () => {
254
254
  expect(deniedTaskCreate.status).toBe(403);
255
255
  expect(deniedTaskCreate.body.error).toContain("admission: route has no permission verb");
256
256
 
257
+ // The ranking state carries the private claim: a requester cannot read it.
258
+ const deniedRanking = await api(server.base, "GET", "/api/ranking", { bearer: userToken });
259
+ expect(deniedRanking.status).toBe(403);
260
+ expect(deniedRanking.body.error).toContain("missing permission 'config.write.any'");
261
+ expect(deniedRanking.body.listing).toBeUndefined();
262
+
257
263
  const deniedAppMove = await api(server.base, "PATCH", `/api/assets/app/${appId}/key`, {
258
264
  bearer: userToken,
259
265
  body: { key: "shared/rbac-denied/" },
@@ -369,6 +375,7 @@ describe("RBAC admission over real HTTP", () => {
369
375
  return (
370
376
  [
371
377
  "GET /api/agents/{id}/mcp-servers",
378
+ "GET /api/ranking",
372
379
  "GET /api/scripts/{id}/apis/{endpointId}/secret",
373
380
  "PATCH /api/assets/app/{id}/key",
374
381
  "POST /api/scripts/{id}/apis",
@@ -400,6 +407,7 @@ describe("RBAC admission over real HTTP", () => {
400
407
  const denyRows = userHttpRows.filter((row) => row.decision === "deny");
401
408
  expect(denyRows.map((row) => row.resourceId).sort()).toEqual([
402
409
  "GET /api/agents/{id}/mcp-servers",
410
+ "GET /api/ranking",
403
411
  "GET /api/scripts/{id}/apis/{endpointId}/secret",
404
412
  "PATCH /api/assets/app/{id}/key",
405
413
  "POST /api/scripts/{id}/apis",
@@ -20,6 +20,7 @@ import { type IdentityActor, mintToken } from "../be/users";
20
20
  import { handleCore } from "../http/core";
21
21
  import { handleFs } from "../http/fs";
22
22
  import { handleMcpOAuth } from "../http/mcp-oauth";
23
+ import { handleRealtimeTicket } from "../http/realtime";
23
24
  import { handleTasks } from "../http/tasks";
24
25
  import { getPathSegments, parseQueryParams } from "../http/utils";
25
26
  import { decideAdmission, decideToolAdmission, isRbacEnabled, type PermissionVerb } from "../rbac";
@@ -61,6 +62,7 @@ function createTestServer(): Server {
61
62
  if (await handleTasks(req, res, pathSegments, queryParams, myAgentId)) return;
62
63
  if (await handleFs(req, res, pathSegments, queryParams, myAgentId)) return;
63
64
  if (await handleMcpOAuth(req, res, pathSegments, queryParams)) return;
65
+ if (await handleRealtimeTicket(req, res, pathSegments, queryParams)) return;
64
66
 
65
67
  res.writeHead(404, { "Content-Type": "application/json" });
66
68
  res.end(JSON.stringify({ error: "Not Found" }));
@@ -483,6 +485,42 @@ describe("handleCore admission wiring", () => {
483
485
  });
484
486
  });
485
487
 
488
+ describe("realtime ticket admission", () => {
489
+ async function grantOnly(userId: string, verbs: PermissionVerb[]): Promise<void> {
490
+ const roleId = `rbac-role-test-${crypto.randomUUID()}`;
491
+ await detachRole(userId, "admin");
492
+ await getDbClient().run(
493
+ `INSERT INTO roles (id, name, description, isBuiltin, grantsAll) VALUES (?, ?, 'test', 0, 0)`,
494
+ [roleId, roleId],
495
+ );
496
+ for (const verb of verbs) {
497
+ await getDbClient().run("INSERT INTO role_permissions (roleId, verb) VALUES (?, ?)", [
498
+ roleId,
499
+ verb,
500
+ ]);
501
+ }
502
+ await getDbClient().run(
503
+ `INSERT INTO principal_roles (principalType, principalId, roleId) VALUES ('user', ?, ?)`,
504
+ [userId, roleId],
505
+ );
506
+ }
507
+
508
+ test("a role with either presence verb gets a ticket; a role with neither does not", async () => {
509
+ process.env.RBAC_ENABLED = "true";
510
+ const cases: [PermissionVerb[], number][] = [
511
+ [["comb.presence"], 200],
512
+ [["session.presence"], 200],
513
+ [["task.read.own"], 403],
514
+ ];
515
+ for (const [verbs, expected] of cases) {
516
+ const { userId, plaintext } = await createTokenForUser(`Presence ${verbs.join(",")}`);
517
+ await grantOnly(userId, verbs);
518
+ const response = await api(port, "POST", "/api/realtime/ticket", { bearer: plaintext });
519
+ expect({ verbs, status: response.status }).toEqual({ verbs, status: expected });
520
+ }
521
+ });
522
+ });
523
+
486
524
  test("RBAC defaults on and accepts explicit opt-outs", () => {
487
525
  const previous = process.env.RBAC_ENABLED;
488
526
  try {
@@ -6,7 +6,8 @@
6
6
  //
7
7
  // 1. request auth kind "operator" (swarm key) → allow — BEFORE any agent check,
8
8
  // so an operator bearer with a non-owner X-Agent-ID is still allowed.
9
- // 2. request auth kind "user" (aswt_ token) → allow.
9
+ // 2. request auth kind "user" (aswt_ token) → allow, unless it names an
10
+ // agent in X-Agent-ID: `handleCore` refuses that pair before any handler.
10
11
  // 3. otherwise (auth context unset): lead OR task-assignee OR task-creator
11
12
  // → allow; anything else → 403 {"error":"Caller cannot mutate this task's files"}.
12
13
  //
@@ -205,9 +206,9 @@ describe("canMutateTask — real pipeline (handleCore auth → handleFs)", () =>
205
206
  expect(res.status).toBe(201);
206
207
  });
207
208
 
208
- test("authenticated user with a non-owner X-Agent-ID is still allowed", async () => {
209
+ test("authenticated user naming an agent in X-Agent-ID is refused before the handler", async () => {
209
210
  const res = await pipelineUpload({ bearer: userToken, agentId: outsiderId });
210
- expect(res.status).toBe(201);
211
+ expect(res.status).toBe(403);
211
212
  });
212
213
  });
213
214
 
@@ -100,6 +100,8 @@ const LEAD_OR_OPERATOR_OR_USER_VERBS: PermissionVerb[] = [
100
100
  ];
101
101
  const OPERATOR_OR_USER_VERBS: PermissionVerb[] = [
102
102
  "comb.presence",
103
+ "session.share",
104
+ "session.presence",
103
105
  "approval.respond",
104
106
  "runtime_controller.write",
105
107
  "runtime_pool.write",
@@ -17,6 +17,8 @@ const TEST_DB_PATH = "./test-rbac-roles.sqlite";
17
17
  const REQUESTER_ROLE_ID = "rbac-role-requester";
18
18
  const REQUESTER_VERBS = [
19
19
  "favorite.write.own",
20
+ "session.presence",
21
+ "session.share",
20
22
  "task.action.own",
21
23
  "task.cancel.own",
22
24
  "task.create.own",
@@ -78,8 +78,10 @@ function request(path: string, body: unknown): Promise<Response> {
78
78
  }
79
79
 
80
80
  function getRequest(path: string, authorization = `Bearer ${API_KEY}`): Promise<Response> {
81
+ // A user token never names an agent; handleCore refuses that pair.
82
+ const asAgent = authorization === `Bearer ${API_KEY}`;
81
83
  return fetch(`http://localhost:${port}${path}`, {
82
- headers: { Authorization: authorization, "X-Agent-ID": agentId },
84
+ headers: { Authorization: authorization, ...(asAgent ? { "X-Agent-ID": agentId } : {}) },
83
85
  });
84
86
  }
85
87