@desplega.ai/agent-swarm 2.3.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 (247) hide show
  1. package/README.md +1 -0
  2. package/dist/{acp-adapter-v85a2bqy.js → acp-adapter-kedstxx6.js} +6 -6
  3. package/dist/{actions-zgcxfspj.js → actions-vget6r1k.js} +18 -16
  4. package/dist/{amp-adapter-jw9xfz42.js → amp-adapter-xeb5mktv.js} +2 -2
  5. package/dist/{app-4e1s4675.js → app-mqap8t8m.js} +3 -3
  6. package/dist/{assistant-v238n9dk.js → assistant-h0gv97mv.js} +20 -18
  7. package/dist/{boot-reembed-p666cw5n.js → boot-reembed-qr8a894z.js} +1 -1
  8. package/dist/{boot-reembed-w6pm64j4.js → boot-reembed-rj85jjhw.js} +1 -1
  9. package/dist/{boot-scrub-sweep-jcyjagn3.js → boot-scrub-sweep-eyfvn6v9.js} +3 -3
  10. package/dist/{boot-seeds-gbedmamp.js → boot-seeds-xs9a8dx1.js} +4 -4
  11. package/dist/{claude-adapter-q7wspzt8.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-wza784fb.js → claude-managed-setup-gkfgks4j.js} +1 -1
  14. package/dist/{claude-sdk-session-yf400ptr.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-30m5q8mc.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-fhvqt2fp.js → cli-2rwet3r7.js} +1 -1
  22. package/dist/{cli-azs9bhc5.js → cli-3wjs22x3.js} +2 -2
  23. package/dist/{cli-dxwh4j32.js → cli-4fhyq711.js} +45 -36
  24. package/dist/{cli-yv3n83x8.js → cli-5ff2sjyt.js} +1 -1
  25. package/dist/{cli-6vcyw6v7.js → cli-5s5hx57y.js} +5 -3
  26. package/dist/{cli-hz64bcnj.js → cli-6ftrrwm1.js} +9 -371
  27. package/dist/{cli-zes4tsbb.js → cli-8b9wvjps.js} +1 -1
  28. package/dist/cli-8cxkw1f5.js +382 -0
  29. package/dist/{cli-4s1kqrqg.js → cli-8rwvksbc.js} +2 -2
  30. package/dist/{cli-e09hj50q.js → cli-8wkb09q5.js} +11 -11
  31. package/dist/{cli-yv0vgscc.js → cli-a1esmmee.js} +13 -2
  32. package/dist/{cli-4hmaheb6.js → cli-avmqhatn.js} +7 -7
  33. package/dist/{cli-g6p7c2my.js → cli-b80a1100.js} +1 -1
  34. package/dist/{cli-tr24eeny.js → cli-bfc3k1x5.js} +3 -3
  35. package/dist/{cli-ka01m5rb.js → cli-bhwa8rzc.js} +13 -11
  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-a17tzmn0.js → cli-ecshf504.js} +2 -2
  41. package/dist/{cli-d43xqzbe.js → cli-eqgfs202.js} +1 -1
  42. package/dist/{cli-ppapy34w.js → cli-f3nx1h8z.js} +2 -2
  43. package/dist/{cli-ea7chd26.js → cli-ffbrkh1r.js} +74 -35
  44. package/dist/{cli-xgn4rwyv.js → cli-kk2r80n5.js} +4 -4
  45. package/dist/{cli-hn3pftj2.js → cli-kz116kvw.js} +3 -3
  46. package/dist/{cli-1v8w782f.js → cli-mvg2hf8p.js} +4 -4
  47. package/dist/{cli-5d1m1qca.js → cli-mz1ezzzh.js} +1 -1
  48. package/dist/{cli-jhmjwqcg.js → cli-nme7wery.js} +6 -6
  49. package/dist/{cli-65q70v2v.js → cli-nrrg0fvy.js} +18 -4
  50. package/dist/{cli-8jtrekpy.js → cli-p1m2b6gc.js} +7 -7
  51. package/dist/{cli-re4wkbzx.js → cli-rg7ntypm.js} +328 -47
  52. package/dist/{cli-fp4xbw07.js → cli-rsd7ga7v.js} +10 -8
  53. package/dist/{cli-etvn5ej2.js → cli-sppc0cmw.js} +1 -1
  54. package/dist/{cli-qrwp40st.js → cli-tg9ksbn8.js} +1 -1
  55. package/dist/{cli-6p7ddg72.js → cli-tgvpmnwz.js} +2 -2
  56. package/dist/{cli-q1812jbw.js → cli-tj4j2ts4.js} +6 -6
  57. package/dist/{cli-kqypty34.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-y3dvz6ge.js → cli-x1p5dhq2.js} +3 -3
  62. package/dist/{cli-8qsx817b.js → cli-x719j3vs.js} +219 -629
  63. package/dist/{cli-9e9kjqsj.js → cli-xfe63zx3.js} +1 -1
  64. package/dist/{cli-wtg6edvd.js → cli-ywyrffff.js} +1 -1
  65. package/dist/{cli-0rh20bp3.js → cli-zvze9rbm.js} +1 -1
  66. package/dist/cli.js +14 -14
  67. package/dist/{codex-adapter-nhjj2veb.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-6hggrjbx.js → codex-session-runner-0vzn09t8.js} +3 -3
  70. package/dist/{commands-mrw1ph2w.js → commands-psqhkttb.js} +2 -2
  71. package/dist/{controller-pt1zam5w.js → controller-t20vz2k8.js} +2 -2
  72. package/dist/{controllers-connect-ymw5541d.js → controllers-connect-gkxmqw9d.js} +24 -20
  73. package/dist/{cursor-adapter-cf4zh5x0.js → cursor-adapter-3v542qe5.js} +1 -1
  74. package/dist/{db-hy80219q.js → db-2xbyenjr.js} +2 -2
  75. package/dist/{db-hhpy6m4x.js → db-nc1rd869.js} +3 -2
  76. package/dist/{dispatcher-6pdjtkr1.js → dispatcher-ewceb8ts.js} +17 -15
  77. package/dist/{dsh-adapter-fbx06re2.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-j6vb6ya1.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-1193sprp.js → grok-adapter-vj88xr23.js} +7 -7
  84. package/dist/handlers-zbs90bny.js +39 -0
  85. package/dist/{heartbeat-h1w1ktce.js → heartbeat-t2m9r3wa.js} +20 -18
  86. package/dist/{hook-skrbja18.js → hook-8femx1bg.js} +4 -4
  87. package/dist/{hook-ejja4bpe.js → hook-xa677f8r.js} +5 -5
  88. package/dist/{http-mzh41mfj.js → http-xpmmq028.js} +868 -253
  89. package/dist/{index-vjn3wbzf.js → index-8pwc1jc8.js} +17 -15
  90. package/dist/{index-epr2gnbw.js → index-cdzyfrsp.js} +1 -1
  91. package/dist/{index-xmmkyx82.js → index-dg2tmj99.js} +17 -15
  92. package/dist/index-nxa6me0q.js +41 -0
  93. package/dist/{index-6rmnn89d.js → index-s7ckdajq.js} +9 -9
  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-4djvbm2j.js → keepalive-3nr4st3r.js} +3 -3
  98. package/dist/{lead-61kqp604.js → lead-td8h2sfn.js} +33 -31
  99. package/dist/{maintenance-f3xhnjsm.js → maintenance-verwpe2c.js} +1 -1
  100. package/dist/{oauth-refresh-sweep-fpd6x6rw.js → oauth-refresh-sweep-9q2t0y3q.js} +2 -2
  101. package/dist/{onboard-196tjgyn.js → onboard-aytqedmq.js} +2 -2
  102. package/dist/{opencode-adapter-eeesgfqk.js → opencode-adapter-f4hryjny.js} +4 -4
  103. package/dist/{otel-impl-g8fh4c21.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-sdcf5yaz.js → pricing-refresh-5nvme3sm.js} +3 -3
  106. package/dist/{rbac-roles-bsp5657v.js → rbac-roles-pd8c26m1.js} +5 -3
  107. package/dist/{rbac-roles-2x4g1wb5.js → rbac-roles-vbpy21mg.js} +2 -2
  108. package/dist/{render-v2-0pyb39bb.js → render-v2-7nqgvk2q.js} +3 -3
  109. package/dist/{resume-a86tkj6r.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-ypbzcapd.js → setup-n39szm6b.js} +1 -1
  113. package/dist/{virtual-modules-wzk6jf3a.js → virtual-modules-wyp2k6z3.js} +2 -2
  114. package/dist/{work-objects-yjp8de12.js → work-objects-jem2fre0.js} +20 -18
  115. package/dist/{worker-4ptnn1ge.js → worker-wr6xgfr2.js} +33 -31
  116. package/openapi.json +844 -110
  117. package/package.json +1 -1
  118. package/src/apps/action-caller.ts +6 -0
  119. package/src/apps/script-types.ts +16 -3
  120. package/src/be/db/active-sessions.ts +5 -0
  121. package/src/be/db/session-visibility.ts +29 -0
  122. package/src/be/db/tasks/read.ts +61 -3
  123. package/src/be/db/tasks/write.ts +15 -6
  124. package/src/be/db.ts +92 -33
  125. package/src/be/events.ts +11 -0
  126. package/src/be/migrations/230_session_shares.sql +23 -0
  127. package/src/be/rbac-roles.ts +2 -0
  128. package/src/be/session-shares.ts +244 -0
  129. package/src/be/swarm-config-guard.ts +23 -2
  130. package/src/commands/runner.ts +11 -0
  131. package/src/http/active-sessions.ts +13 -2
  132. package/src/http/agents.ts +10 -3
  133. package/src/http/all-routes.ts +1 -0
  134. package/src/http/api-keys.ts +3 -1
  135. package/src/http/approval-requests.ts +2 -1
  136. package/src/http/apps.ts +36 -11
  137. package/src/http/config.ts +3 -0
  138. package/src/http/context.ts +2 -0
  139. package/src/http/core.ts +39 -2
  140. package/src/http/events.ts +6 -2
  141. package/src/http/favorite-owner.ts +1 -1
  142. package/src/http/fs.ts +38 -3
  143. package/src/http/index.ts +3 -1
  144. package/src/http/memory.ts +3 -1
  145. package/src/http/ranking.ts +367 -0
  146. package/src/http/realtime.ts +26 -21
  147. package/src/http/route-def.ts +77 -11
  148. package/src/http/script-runs.ts +2 -1
  149. package/src/http/scripts.ts +8 -0
  150. package/src/http/session-access.ts +179 -0
  151. package/src/http/session-data.ts +10 -2
  152. package/src/http/sessions.ts +212 -12
  153. package/src/http/stats.ts +7 -3
  154. package/src/http/tasks.ts +83 -7
  155. package/src/http/used-by.ts +11 -3
  156. package/src/prompts/session-templates.ts +3 -1
  157. package/src/providers/amp-adapter.ts +1 -1
  158. package/src/ranking/telemetry-ranking.ts +147 -0
  159. package/src/ranking/usage.ts +57 -0
  160. package/src/rbac/admission.ts +22 -7
  161. package/src/rbac/legacy-policy.ts +2 -0
  162. package/src/rbac/permissions.ts +9 -0
  163. package/src/realtime/auth.ts +69 -0
  164. package/src/realtime/tickets.ts +4 -1
  165. package/src/realtime/transport.ts +77 -9
  166. package/src/slack/blocks.ts +18 -0
  167. package/src/slack/dm.ts +29 -0
  168. package/src/telemetry-contract/EVENTS.md +3 -0
  169. package/src/telemetry-contract/SOURCE.json +37 -4
  170. package/src/telemetry-contract/catalog.json +69 -0
  171. package/src/telemetry-contract/ranking/fixtures/expected.json +30 -0
  172. package/src/telemetry-contract/ranking/fixtures/invalid/bad-category.json +11 -0
  173. package/src/telemetry-contract/ranking/fixtures/invalid/bad-claim.json +11 -0
  174. package/src/telemetry-contract/ranking/fixtures/invalid/blank-description.json +11 -0
  175. package/src/telemetry-contract/ranking/fixtures/invalid/blank-name.json +11 -0
  176. package/src/telemetry-contract/ranking/fixtures/invalid/description-too-long.json +11 -0
  177. package/src/telemetry-contract/ranking/fixtures/invalid/domain-uppercase.json +11 -0
  178. package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-scheme.json +11 -0
  179. package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-www.json +11 -0
  180. package/src/telemetry-contract/ranking/fixtures/invalid/http-source-url.json +11 -0
  181. package/src/telemetry-contract/ranking/fixtures/invalid/missing-description.json +10 -0
  182. package/src/telemetry-contract/ranking/fixtures/invalid/missing-version.json +10 -0
  183. package/src/telemetry-contract/ranking/fixtures/invalid/name-not-string.json +11 -0
  184. package/src/telemetry-contract/ranking/fixtures/invalid/name-too-long.json +11 -0
  185. package/src/telemetry-contract/ranking/fixtures/invalid/not-object.json +1 -0
  186. package/src/telemetry-contract/ranking/fixtures/invalid/null.json +1 -0
  187. package/src/telemetry-contract/ranking/fixtures/invalid/other-with-subcategory.json +11 -0
  188. package/src/telemetry-contract/ranking/fixtures/invalid/oversize.json +1 -0
  189. package/src/telemetry-contract/ranking/fixtures/invalid/short-claim.json +11 -0
  190. package/src/telemetry-contract/ranking/fixtures/invalid/subcategory-other-category.json +11 -0
  191. package/src/telemetry-contract/ranking/fixtures/invalid/trailing-data.json +1 -0
  192. package/src/telemetry-contract/ranking/fixtures/invalid/unknown-field.json +12 -0
  193. package/src/telemetry-contract/ranking/fixtures/invalid/unsupported-version.json +11 -0
  194. package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-not-date.json +11 -0
  195. package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-offset.json +11 -0
  196. package/src/telemetry-contract/ranking/fixtures/invalid/wrong-case-field.json +11 -0
  197. package/src/telemetry-contract/ranking/fixtures/valid/category-without-subcategory.json +11 -0
  198. package/src/telemetry-contract/ranking/fixtures/valid/full.json +11 -0
  199. package/src/telemetry-contract/ranking/fixtures/valid/minimal.json +11 -0
  200. package/src/telemetry-contract/ranking/fixtures/valid/padded-text.json +11 -0
  201. package/src/telemetry-contract/ranking/fixtures/valid/unicode-name.json +11 -0
  202. package/src/telemetry-contract/ranking/listing.v1.schema.json +61 -0
  203. package/src/telemetry-contract/ranking/taxonomy.json +8 -0
  204. package/src/telemetry-contract/ranking-taxonomy.gen.ts +11 -0
  205. package/src/telemetry-contract/types.gen.ts +708 -1
  206. package/src/telemetry.ts +26 -2
  207. package/src/tests/apps-rbac.test.ts +189 -2
  208. package/src/tests/apps-script-types.test.ts +16 -3
  209. package/src/tests/check-pr-body.test.ts +23 -0
  210. package/src/tests/check-ranking-entry.test.ts +146 -0
  211. package/src/tests/db-extraction-contract.test.ts +1 -0
  212. package/src/tests/db-task-read-contract.test.ts +1 -0
  213. package/src/tests/docs-index-footprint.test.js +2 -2
  214. package/src/tests/org-identity.test.ts +44 -0
  215. package/src/tests/ranking-listing.test.ts +112 -0
  216. package/src/tests/ranking-route.test.ts +319 -0
  217. package/src/tests/ranking-usage.test.ts +50 -0
  218. package/src/tests/rbac-admission-e2e.test.ts +8 -0
  219. package/src/tests/rbac-admission.test.ts +38 -0
  220. package/src/tests/rbac-charact-http.test.ts +4 -3
  221. package/src/tests/rbac-engine.test.ts +2 -0
  222. package/src/tests/rbac-roles.test.ts +2 -0
  223. package/src/tests/realtime-room-surfaces.test.ts +3 -1
  224. package/src/tests/realtime-session-join-race.test.ts +142 -0
  225. package/src/tests/realtime-tickets.test.ts +24 -17
  226. package/src/tests/realtime-transport.test.ts +91 -0
  227. package/src/tests/session-route-posture.test.ts +639 -0
  228. package/src/tests/session-sharing-routes.test.ts +282 -0
  229. package/src/tests/session-sharing.test.ts +526 -0
  230. package/src/tests/sessions-detail-batch.test.ts +7 -1
  231. package/src/tests/slack-tool-blocks.test.ts +101 -0
  232. package/src/tests/slack-update.test.ts +55 -0
  233. package/src/tests/slack-user-dm.test.ts +177 -0
  234. package/src/tests/used-by-route.test.ts +10 -0
  235. package/src/tools/slack-post.ts +18 -12
  236. package/src/tools/slack-read.ts +13 -1
  237. package/src/tools/slack-reply.ts +7 -10
  238. package/src/tools/slack-start-thread.ts +18 -11
  239. package/src/tools/slack-update.ts +2 -9
  240. package/src/types.ts +35 -0
  241. package/src/utils/org-identity.ts +33 -0
  242. package/templates/skills/apps/SKILL.md +17 -0
  243. package/templates/skills/apps/content.md +17 -0
  244. package/dist/handlers-tb3xgjnm.js +0 -37
  245. package/dist/index-nyfcdpky.js +0 -39
  246. package/dist/{cli-n5mrsg0f.js → cli-60cgh1rv.js} +3 -3
  247. /package/dist/{cli-j0v75y13.js → cli-y83zk5sb.js} +0 -0
package/src/telemetry.ts CHANGED
@@ -630,6 +630,7 @@ function emit(
630
630
  metadata?: Record<string, unknown>,
631
631
  actor?: TelemetryActor,
632
632
  emitter: string = source,
633
+ identity?: { orgName: string; orgWebsite: string },
633
634
  ): void {
634
635
  if (!isTelemetryEnabled() || !installationId) return;
635
636
  maybeRefreshOrgIdentity();
@@ -639,7 +640,9 @@ function emit(
639
640
  const occurredAt = new Date().toISOString();
640
641
  const sentFor = installationId;
641
642
  const isCloud = cachedIsCloud || isCloudDeployment();
642
- const orgIdentity = getOrgIdentity();
643
+ const orgIdentity = identity
644
+ ? { ...getOrgIdentity(), organization_name: identity.orgName }
645
+ : getOrgIdentity();
643
646
  const userId = actor?.userId?.trim() || undefined;
644
647
  const payload = {
645
648
  schema_version: 2 as const,
@@ -653,7 +656,7 @@ function emit(
653
656
  orgId,
654
657
  orgName: orgIdentity.organization_name,
655
658
  orgDomain: cachedOrgDomain,
656
- orgWebsite: normalizeOrgWebsite(process.env.SWARM_ORG_WEBSITE),
659
+ orgWebsite: identity?.orgWebsite ?? normalizeOrgWebsite(process.env.SWARM_ORG_WEBSITE),
657
660
  installationId,
658
661
  actor: userId ? { userId, role: actor?.role ?? roleResolver?.(userId) ?? null } : undefined,
659
662
  isCloud,
@@ -820,6 +823,27 @@ export const telemetry = {
820
823
  );
821
824
  },
822
825
 
826
+ /**
827
+ * Public-ranking events. The API passes the org identity it just read from
828
+ * config, so a listing built right after an identity edit still matches the
829
+ * envelope's `org_name` / `org_website` (the proxy rejects a mismatch) before
830
+ * the debounced config reload updates `process.env`.
831
+ */
832
+ ranking<S extends EventSuffix<"ranking">>(
833
+ event: S,
834
+ props: FamilyProps<"ranking", S>,
835
+ identity?: { orgName: string; orgWebsite: string },
836
+ ): void {
837
+ emit(
838
+ `ranking.${event}`,
839
+ props as Record<string, unknown>,
840
+ undefined,
841
+ undefined,
842
+ source,
843
+ identity,
844
+ );
845
+ },
846
+
823
847
  org<S extends EventSuffix<"org">>(event: S, props: FamilyProps<"org", S>): void {
824
848
  emit(`org.${event}`, props as Record<string, unknown>, undefined);
825
849
  },
@@ -7,9 +7,20 @@ import {
7
7
  type ServerResponse,
8
8
  } from "node:http";
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
- import { closeDb, createAgent, createUser, getDbClient, getTaskById, initDb } from "../be/db";
10
+ import type { AppActionCaller } from "../apps/action-caller";
11
+ import {
12
+ closeDb,
13
+ createAgent,
14
+ createPage,
15
+ createUser,
16
+ getDbClient,
17
+ getTaskById,
18
+ initDb,
19
+ } from "../be/db";
20
+ import { createRuntimeController } from "../be/runtime-pools/db";
21
+ import { mintControllerToken } from "../be/runtime-pools/tokens";
11
22
  import { upsertScriptByName } from "../be/scripts/db";
12
- import { type IdentityActor, mintToken } from "../be/users";
23
+ import { type IdentityActor, mintRuntimeToken, mintToken } from "../be/users";
13
24
  import { handleApps } from "../http/apps";
14
25
  import { resolveHttpRequestAuth } from "../http/auth";
15
26
  import { getPathSegments, parseQueryParams } from "../http/utils";
@@ -19,13 +30,18 @@ import { registerAppDiffTool } from "../tools/app-diff";
19
30
  import { registerAppGetTool, registerAppQueryTool } from "../tools/app-get";
20
31
  import { registerAppHistoryTool } from "../tools/app-history";
21
32
  import { registerAppPatchTool } from "../tools/app-patch";
33
+ import { signPageSession } from "../utils/page-session";
22
34
  import { setRequestAuth } from "../utils/request-auth-context";
35
+ import { SKIP_SANDBOX_SPAWN_TESTS } from "./sandbox-spawn-test-helpers";
36
+ import { CHILD_PROCESS_TEST_BUDGET_MS } from "./test-proc";
23
37
 
24
38
  const TEST_DB_PATH = `/private/tmp/test-apps-rbac-${process.pid}.sqlite`;
25
39
  const API_KEY = "example-apps-rbac-test-key";
26
40
  const AGENT_ID = crypto.randomUUID();
27
41
  const LEAD_ID = crypto.randomUUID();
28
42
  const OPERATOR_ACTOR: IdentityActor = { kind: "operator", id: "apps-rbac-test" };
43
+ const previousPageSecret = process.env.PAGE_SESSION_SECRET;
44
+ const previousSwarmKey = process.env.AGENT_SWARM_API_KEY;
29
45
 
30
46
  const definition = {
31
47
  models: { note: { columns: { title: { kind: "string" } } } },
@@ -45,6 +61,7 @@ let server: Server;
45
61
  let base = "";
46
62
  let userId = "";
47
63
  let userToken = "";
64
+ let agentToken = "";
48
65
  let appId = "";
49
66
  let tools: Record<string, RegisteredTool>;
50
67
 
@@ -144,12 +161,15 @@ async function callTool(name: string, args: unknown): Promise<unknown> {
144
161
  }
145
162
 
146
163
  beforeAll(async () => {
164
+ process.env.PAGE_SESSION_SECRET = "example-apps-rbac-page-session-secret";
165
+ process.env.AGENT_SWARM_API_KEY = API_KEY;
147
166
  for (const suffix of ["", "-wal", "-shm"]) {
148
167
  await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => undefined);
149
168
  }
150
169
  initDb(TEST_DB_PATH);
151
170
  await createAgent({ id: AGENT_ID, name: "apps-rbac-worker", isLead: false, status: "idle" });
152
171
  await createAgent({ id: LEAD_ID, name: "apps-rbac-lead", isLead: true, status: "idle" });
172
+ agentToken = (await mintRuntimeToken(AGENT_ID, crypto.randomUUID())).plaintext;
153
173
  const user = await createUser({ name: "Apps RBAC User" });
154
174
  userId = user.id;
155
175
  userToken = (await mintToken(user.id, "apps-rbac", OPERATOR_ACTOR)).plaintext;
@@ -177,6 +197,10 @@ afterEach(() => {
177
197
  afterAll(async () => {
178
198
  await new Promise<void>((resolve) => server.close(() => resolve()));
179
199
  closeDb();
200
+ if (previousPageSecret === undefined) delete process.env.PAGE_SESSION_SECRET;
201
+ else process.env.PAGE_SESSION_SECRET = previousPageSecret;
202
+ if (previousSwarmKey === undefined) delete process.env.AGENT_SWARM_API_KEY;
203
+ else process.env.AGENT_SWARM_API_KEY = previousSwarmKey;
180
204
  for (const suffix of ["", "-wal", "-shm"]) {
181
205
  await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => undefined);
182
206
  }
@@ -323,6 +347,9 @@ describe("app viewer identity", () => {
323
347
  });
324
348
  expect(action.status).toBe(200);
325
349
  expect((await getTaskById(action.body.taskId))?.requestedByUserId).toBe(userId);
350
+ expect((await getTaskById(action.body.taskId))?.task).toContain(
351
+ `Caller (server-verified): ${JSON.stringify({ kind: "user", id: userId, name: "Apps RBAC User" })}`,
352
+ );
326
353
 
327
354
  const operatorAction = await request<{ taskId: string }>(
328
355
  `/api/apps/${appId}/actions/triage`,
@@ -342,6 +369,166 @@ describe("app viewer identity", () => {
342
369
  });
343
370
  });
344
371
 
372
+ describe("verified app action caller", () => {
373
+ beforeEach(async () => {
374
+ const saved = await upsertScriptByName({
375
+ name: `apps_caller_${crypto.randomUUID().replaceAll("-", "")}`,
376
+ scope: "agent",
377
+ scopeId: AGENT_ID,
378
+ agentId: AGENT_ID,
379
+ source: "export default function run(args: Record<string, unknown>) { return args; }",
380
+ description: "Action caller fixture",
381
+ intent: "Return the arguments received by a script action",
382
+ signatureJson: JSON.stringify({ args: { type: "object" }, result: { type: "object" } }),
383
+ typeChecked: true,
384
+ embeddingMode: "skip",
385
+ });
386
+ const patched = await request(`/api/apps/${appId}`, "operator", {
387
+ method: "PATCH",
388
+ body: JSON.stringify({
389
+ definition: {
390
+ actions: {
391
+ echo: {
392
+ kind: "script",
393
+ scriptId: saved.script.id,
394
+ args: { caller: { kind: "guest", id: "default-forgery" }, base: 2 },
395
+ },
396
+ },
397
+ },
398
+ }),
399
+ });
400
+ expect(patched.status).toBe(200);
401
+ });
402
+
403
+ const scriptTest = test.skipIf(SKIP_SANDBOX_SPAWN_TESTS);
404
+ scriptTest.each([
405
+ ["user", undefined],
406
+ ["operator", AGENT_ID],
407
+ ["operator", crypto.randomUUID()],
408
+ ["operator", undefined],
409
+ ["agent", AGENT_ID],
410
+ ] as const)(
411
+ "%s caller with agent header %s preserves identity and overwrites forged input",
412
+ async (principal, agentId) => {
413
+ const caller: AppActionCaller =
414
+ principal === "user"
415
+ ? { kind: "user", id: userId, name: "Apps RBAC User" }
416
+ : agentId === AGENT_ID
417
+ ? { kind: "agent", id: AGENT_ID, name: "apps-rbac-worker" }
418
+ : { kind: "operator", id: null };
419
+ const requestHeaders = {
420
+ ...(agentId ? { "X-Agent-ID": agentId } : {}),
421
+ ...(principal === "agent" ? { Authorization: `Bearer ${agentToken}` } : {}),
422
+ };
423
+ const checks: RbacCheck[] = [];
424
+ setAuditSink((check) => checks.push(check));
425
+ const row = await request<{ row: { createdBy: string } }>(
426
+ `/api/apps/${appId}/models/note/rows`,
427
+ principal,
428
+ {
429
+ method: "POST",
430
+ headers: requestHeaders,
431
+ body: JSON.stringify({ values: { title: "caller row" } }),
432
+ },
433
+ );
434
+ expect(row.status).toBe(201);
435
+ expect(row.body.row.createdBy).toBe(caller.id ? `${caller.kind}:${caller.id}` : "operator");
436
+
437
+ const action = await request<{
438
+ ok: boolean;
439
+ result: { caller: AppActionCaller; app: { id: string }; base: number; add: number };
440
+ }>(`/api/apps/${appId}/actions/echo`, principal, {
441
+ method: "POST",
442
+ headers: requestHeaders,
443
+ body: JSON.stringify({
444
+ input: { caller: { kind: "operator", id: "input-forgery" }, app: { id: "fake" }, add: 3 },
445
+ }),
446
+ });
447
+ expect(action.status).toBe(200);
448
+ expect(action.body.ok).toBe(true);
449
+ expect(action.body.result).toEqual({ caller, app: { id: appId }, base: 2, add: 3 });
450
+
451
+ const taskAction = await request<{ taskId: string }>(
452
+ `/api/apps/${appId}/actions/triage`,
453
+ principal,
454
+ { method: "POST", headers: requestHeaders, body: JSON.stringify({ input: {} }) },
455
+ );
456
+ expect(taskAction.status).toBe(200);
457
+ expect((await getTaskById(taskAction.body.taskId))?.task).toContain(
458
+ `Caller (server-verified): ${JSON.stringify(caller)}`,
459
+ );
460
+ expect(checks).toHaveLength(3);
461
+ expect(checks.every((check) => check.principal.kind === principal)).toBe(true);
462
+ },
463
+ CHILD_PROCESS_TEST_BUDGET_MS,
464
+ );
465
+
466
+ scriptTest(
467
+ "a signed page session includes the verified page id and viewer identity",
468
+ async () => {
469
+ const page = await createPage({
470
+ agentId: AGENT_ID,
471
+ slug: `caller-${crypto.randomUUID()}`,
472
+ title: "Caller page",
473
+ contentType: "text/html",
474
+ body: "<p>Caller page</p>",
475
+ });
476
+ const session = await signPageSession({
477
+ pageId: page.id,
478
+ exp: Math.floor(Date.now() / 1000) + 3600,
479
+ uid: userId,
480
+ });
481
+ const action = await request<{ ok: boolean; result: { caller: AppActionCaller } }>(
482
+ `/api/apps/${appId}/actions/echo`,
483
+ "operator",
484
+ {
485
+ method: "POST",
486
+ headers: {
487
+ "X-Agent-ID": AGENT_ID,
488
+ "X-Page-ID": page.id,
489
+ "X-Page-Session": session,
490
+ },
491
+ body: JSON.stringify({ input: { caller: { pageId: "fake" } } }),
492
+ },
493
+ );
494
+ expect(action.status).toBe(200);
495
+ expect(action.body.ok).toBe(true);
496
+ expect(action.body.result.caller).toEqual({
497
+ kind: "user",
498
+ id: userId,
499
+ name: "Apps RBAC User",
500
+ pageId: page.id,
501
+ });
502
+ },
503
+ CHILD_PROCESS_TEST_BUDGET_MS,
504
+ );
505
+
506
+ test("controller tokens use the guest principal even with a registered agent header", async () => {
507
+ const controller = await createRuntimeController({
508
+ name: "apps-caller-controller",
509
+ kind: "docker",
510
+ placement: "remote",
511
+ });
512
+ const token = await mintControllerToken(controller.id);
513
+ const checks: RbacCheck[] = [];
514
+ setAuditSink((check) => checks.push(check));
515
+ const action = await request(`/api/apps/${appId}/actions/echo`, "operator", {
516
+ method: "POST",
517
+ headers: { Authorization: `Bearer ${token.plaintext}`, "X-Agent-ID": AGENT_ID },
518
+ body: JSON.stringify({ input: {} }),
519
+ });
520
+ expect(action.status).toBe(403);
521
+ expect(checks).toEqual([
522
+ {
523
+ principal: { kind: "guest" },
524
+ verb: "app.use",
525
+ resource: { kind: "app", appId },
526
+ source: "http",
527
+ },
528
+ ]);
529
+ });
530
+ });
531
+
345
532
  describe("user writers and the script ownership gate", () => {
346
533
  async function saveScript(scope: "agent" | "global", ownerId: string | null): Promise<string> {
347
534
  const result = await upsertScriptByName({
@@ -127,9 +127,16 @@ describe("renderAppTypes", () => {
127
127
  expect(rendered).toContain("params: { value: never };");
128
128
  });
129
129
 
130
- test("renders a documentation-grade action union", () => {
130
+ test("renders action names and arguments with server-stamped caller context", () => {
131
131
  const rendered = renderAppTypes([app("PM Inbox", issueDefinition())]);
132
132
  expect(rendered).toContain('export type ActionName = "closeIssue" | "notifyOwner";');
133
+ expect(rendered).toContain("export interface ActionArgs");
134
+ expect(rendered).toContain('app: { id: "PM Inbox-id" };');
135
+ expect(rendered).toContain("caller: AppActionCaller;");
136
+ expect(rendered).toContain('kind: "user" | "agent" | "operator" | "guest";');
137
+ expect(rendered).toContain("id: string | null;");
138
+ expect(rendered).toContain("name?: string;");
139
+ expect(rendered).toContain("pageId?: string;");
133
140
  });
134
141
 
135
142
  test("derives and dedupes namespaces and model interface names", () => {
@@ -262,14 +269,20 @@ describe("renderAppTypes", () => {
262
269
  expect(rendered).not.toMatch(/^export const pwned2/m);
263
270
  });
264
271
 
265
- test("a model named actionName does not collide with the ActionName alias", () => {
272
+ test("model names do not collide with generated action types", () => {
266
273
  const rendered = renderAppTypes([
267
274
  app("Collision", {
268
- models: { actionName: { columns: { title: { kind: "string" } } } },
275
+ models: {
276
+ actionName: { columns: { title: { kind: "string" } } },
277
+ actionArgs: { columns: { title: { kind: "string" } } },
278
+ appActionCaller: { columns: { title: { kind: "string" } } },
279
+ },
269
280
  actions: { close: { kind: "task", prompt: "close" } },
270
281
  }),
271
282
  ]);
272
283
  expect(rendered).toContain("export interface ActionName_2");
284
+ expect(rendered).toContain("export interface ActionArgs_2");
285
+ expect(rendered).toContain("export interface AppActionCaller_2");
273
286
  expect(rendered).toContain('export type ActionName = "close";');
274
287
  });
275
288
 
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  checkPrBody,
4
4
  isFixTitle,
5
+ isRankingEntryPr,
5
6
  pickedChoices,
6
7
  prBodyWarnings,
7
8
  proseWordCount,
@@ -34,6 +35,28 @@ const TEMPLATE = `<!-- top comment -->
34
35
 
35
36
  const URGENT = "## Urgency\n- [ ] asap\n- [x] nice to have\n";
36
37
 
38
+ describe("isRankingEntryPr", () => {
39
+ test("exempts only a one-file entry PR with the matching title", () => {
40
+ expect(
41
+ isRankingEntryPr("chore(ranking): add acme.dev", ["community/ranking/acme.dev.json"]),
42
+ ).toBe(true);
43
+ expect(
44
+ isRankingEntryPr("chore(ranking): remove acme.dev", ["community/ranking/acme.dev.json"]),
45
+ ).toBe(true);
46
+ expect(
47
+ isRankingEntryPr("chore(ranking): add acme.dev", ["community/ranking/acme.com.json"]),
48
+ ).toBe(false);
49
+ expect(
50
+ isRankingEntryPr("chore(ranking): add acme.dev", [
51
+ "community/ranking/acme.dev.json",
52
+ "src/server.ts",
53
+ ]),
54
+ ).toBe(false);
55
+ expect(isRankingEntryPr("feat: add acme.dev", ["community/ranking/acme.dev.json"])).toBe(false);
56
+ expect(isRankingEntryPr("chore(ranking): add acme.dev", undefined)).toBe(false);
57
+ });
58
+ });
59
+
37
60
  describe("check-pr-body", () => {
38
61
  test("template markers set when a section is required", () => {
39
62
  expect(templateSections(TEMPLATE)).toEqual([
@@ -0,0 +1,146 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ checkRankingEntries,
4
+ type Entry,
5
+ isEntryPath,
6
+ parseEntry,
7
+ type Verification,
8
+ } from "../../scripts/check-ranking-entry";
9
+
10
+ const CLAIM = "rk_3f9a1c0e7b2d4a6f8e1c3b5d7f9a2c4e";
11
+ const OTHER_CLAIM = "rk_00000000000000000000000000000001";
12
+ const file = (domain: string, claim = CLAIM) =>
13
+ JSON.stringify({ $schema: "./schema.json", domain, claim });
14
+
15
+ const VERIFIED: Verification = {
16
+ verified: true,
17
+ reasons: [],
18
+ telemetryActive7d: true,
19
+ websiteMatchesDomain: true,
20
+ listing: {
21
+ name: "Acme",
22
+ domain: "acme.dev",
23
+ category: "engineering",
24
+ subcategory: "qa-testing",
25
+ description: "QA | release automation",
26
+ sourceCodeUrl: null,
27
+ },
28
+ };
29
+
30
+ describe("parseEntry", () => {
31
+ test("accepts the opt-in file", () => {
32
+ expect(parseEntry("acme.dev.json", file("acme.dev"))).toEqual({
33
+ domain: "acme.dev",
34
+ claim: CLAIM,
35
+ });
36
+ });
37
+
38
+ test.each([
39
+ ["acme.dev.json", "{", "valid JSON"],
40
+ ["acme.dev.json", "[]", "JSON object"],
41
+ [
42
+ "acme.dev.json",
43
+ JSON.stringify({ domain: "acme.dev", claim: CLAIM, name: "Acme" }),
44
+ "unknown keys",
45
+ ],
46
+ ["www.acme.dev.json", file("www.acme.dev"), "bare lowercase hostname"],
47
+ ["Acme.ba.json", file("Acme.ba"), "bare lowercase hostname"],
48
+ ["other.ba.json", file("acme.dev"), "must be named acme.dev.json"],
49
+ ["acme.dev.json", file("acme.dev", "rk_short"), "claim"],
50
+ ])("rejects %s %s", (name, text, message) => {
51
+ expect(() => parseEntry(name, text)).toThrow(message);
52
+ });
53
+
54
+ test("README and schema are not entries", () => {
55
+ expect(isEntryPath("community/ranking/acme.dev.json")).toBe(true);
56
+ expect(isEntryPath("community/ranking/schema.json")).toBe(false);
57
+ expect(isEntryPath("community/ranking/README.md")).toBe(false);
58
+ expect(isEntryPath("community/ranking/nested/acme.dev.json")).toBe(false);
59
+ });
60
+ });
61
+
62
+ describe("checkRankingEntries", () => {
63
+ const files: Record<string, string> = {
64
+ "community/ranking/acme.dev.json": file("acme.dev"),
65
+ "community/ranking/acme.com.json": file("acme.com", CLAIM),
66
+ };
67
+ const run = (
68
+ paths: string[],
69
+ verify: (entry: Entry) => Promise<Verification>,
70
+ all = ["acme.dev.json"],
71
+ ) =>
72
+ checkRankingEntries({
73
+ changes: paths.map((path) => ({ status: "A", path })),
74
+ readEntry: (path) => files[path] ?? "",
75
+ allEntries: all.map((name) => [name, files[`community/ranking/${name}`] ?? ""]),
76
+ verify,
77
+ });
78
+
79
+ test("a verified entry passes and the summary shows the listing", async () => {
80
+ const asked: Entry[] = [];
81
+ const result = await run(["community/ranking/acme.dev.json"], async (entry) => {
82
+ asked.push(entry);
83
+ return VERIFIED;
84
+ });
85
+ expect(result.problems).toEqual([]);
86
+ expect(asked).toEqual([{ domain: "acme.dev", claim: CLAIM }]);
87
+ const summary = result.summary.join("\n");
88
+ expect(summary).toContain("### acme.dev: verified");
89
+ expect(summary).toContain(
90
+ "| Acme | engineering | qa-testing | QA \\| release automation | — |",
91
+ );
92
+ });
93
+
94
+ test("an unverified entry fails with the proxy's reasons", async () => {
95
+ const result = await run(["community/ranking/acme.dev.json"], async () => ({
96
+ ...VERIFIED,
97
+ verified: false,
98
+ reasons: ["telemetry_inactive_7d"],
99
+ }));
100
+ expect(result.problems).toHaveLength(1);
101
+ expect(result.problems[0]).toContain("telemetry_inactive_7d");
102
+ });
103
+
104
+ test("a proxy error fails without throwing", async () => {
105
+ const result = await run(["community/ranking/acme.dev.json"], async () => {
106
+ throw new Error("the proxy answered 503");
107
+ });
108
+ expect(result.problems[0]).toContain("503");
109
+ });
110
+
111
+ test("a claim shared with another entry fails before verify", async () => {
112
+ let called = false;
113
+ const result = await run(["community/ranking/acme.com.json"], async () => {
114
+ called = true;
115
+ return VERIFIED;
116
+ }, ["acme.dev.json", "acme.com.json"]);
117
+ expect(called).toBe(false);
118
+ expect(result.problems[0]).toContain("acme.dev.json");
119
+ });
120
+
121
+ test("a removal needs no proof, and README edits are ignored", async () => {
122
+ const result = await checkRankingEntries({
123
+ changes: [
124
+ { status: "D", path: "community/ranking/acme.dev.json" },
125
+ { status: "M", path: "community/ranking/README.md" },
126
+ ],
127
+ readEntry: () => "",
128
+ allEntries: [],
129
+ verify: async () => {
130
+ throw new Error("must not be called");
131
+ },
132
+ });
133
+ expect(result.problems).toEqual([]);
134
+ expect(result.summary).toEqual(["- Removed `acme.dev.json`."]);
135
+ });
136
+
137
+ test("the claim differs per entry in a valid two-entry PR", async () => {
138
+ files["community/ranking/acme.com.json"] = file("acme.com", OTHER_CLAIM);
139
+ const result = await run(
140
+ ["community/ranking/acme.com.json", "community/ranking/acme.dev.json"],
141
+ async () => VERIFIED,
142
+ ["acme.dev.json", "acme.com.json"],
143
+ );
144
+ expect(result.problems).toEqual([]);
145
+ });
146
+ });
@@ -15,6 +15,7 @@ test("facade exposes repository bindings without internal exports", () => {
15
15
  "rowToAgentTaskSummary",
16
16
  "configureTaskReadDependencies",
17
17
  "NEVER_STARTED_TASK_STATUSES",
18
+ "readableSessionCondition",
18
19
  ].includes(name)
19
20
  ) {
20
21
  expect(name in db).toBe(false);
@@ -660,6 +660,7 @@ const internal = [
660
660
  "type:AgentTaskRow",
661
661
  "value:configureTaskReadDependencies",
662
662
  "value:NEVER_STARTED_TASK_STATUSES",
663
+ "value:readableSessionCondition",
663
664
  "value:rowToAgentTask",
664
665
  "value:rowToAgentTaskSummary",
665
666
  ].sort();
@@ -103,8 +103,8 @@ describe("docs API index footprint", () => {
103
103
  expect(spec.paths["/api/agents/{id}/runtime"]?.get).toBeDefined();
104
104
 
105
105
  const generatedKeys = await generatedOperationKeys();
106
- expect(operationKeys).toHaveLength(431);
107
- expect(generatedKeys).toHaveLength(431);
106
+ expect(operationKeys).toHaveLength(436);
107
+ expect(generatedKeys).toHaveLength(436);
108
108
  expect(new Set(generatedKeys)).toEqual(new Set(operationKeys));
109
109
  });
110
110
 
@@ -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
+ });