@desplega.ai/agent-swarm 1.129.0 → 1.131.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 (226) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-kfv2dawe.js → actions-w53mbns9.js} +11 -11
  3. package/dist/{anthropic-messages-3xgme8my.js → anthropic-messages-rjmd2e7n.js} +5 -5
  4. package/dist/{app-q8vjk6e8.js → app-m9gwqsqv.js} +8 -8
  5. package/dist/{artifact-hg4t1nx7.js → artifact-jysj3wrg.js} +2 -2
  6. package/dist/{assistant-g037r345.js → assistant-bp65m6f3.js} +13 -13
  7. package/dist/{azure-openai-responses-xa4sh9f6.js → azure-openai-responses-c0yjmbfd.js} +8 -5
  8. package/dist/{boot-reembed-18pzsrxa.js → boot-reembed-62p0at2k.js} +6 -6
  9. package/dist/{boot-reembed-wpks2cj8.js → boot-reembed-xmfp6wdh.js} +7 -7
  10. package/dist/{boot-scrub-logs-n7szd57r.js → boot-scrub-logs-rr8q2dms.js} +5 -5
  11. package/dist/{claude-adapter-qz4r0ajt.js → claude-adapter-2y47mrkd.js} +9 -9
  12. package/dist/{cli-mhjnbgb6.js → cli-0rcyq6d9.js} +450 -186
  13. package/dist/{cli-ttkmehy0.js → cli-2q72qz2s.js} +274 -25
  14. package/dist/{cli-gztgc3hz.js → cli-2wadx7p2.js} +2 -2
  15. package/dist/cli-3fj60etf.js +153 -0
  16. package/dist/{cli-7ecgm5v0.js → cli-3kf51j15.js} +1 -1
  17. package/dist/{cli-vgexpfsr.js → cli-57khth04.js} +6 -3
  18. package/dist/{cli-2mbe7vk0.js → cli-5dsbcjjw.js} +1 -1
  19. package/dist/{cli-etr62zy6.js → cli-5kx36evn.js} +2 -2
  20. package/dist/{cli-v2tr6amf.js → cli-5n6e5yfn.js} +30 -8
  21. package/dist/{cli-hx02s9zq.js → cli-5v4rw6yd.js} +2 -2
  22. package/dist/{cli-v37yr6mf.js → cli-5ze9rv97.js} +4 -4
  23. package/dist/{cli-ch7t5v7n.js → cli-6map995v.js} +9 -4
  24. package/dist/{cli-h0457a67.js → cli-70xqj0z3.js} +3 -3
  25. package/dist/{cli-kk4f9r17.js → cli-72q8m8gk.js} +8 -6
  26. package/dist/{cli-7padk69a.js → cli-78xwvkhj.js} +2 -2
  27. package/dist/{cli-dxtxacy7.js → cli-9dr5qzc0.js} +45 -8
  28. package/dist/{cli-17zxdgsq.js → cli-a40rnf71.js} +6 -6
  29. package/dist/{cli-t53vnnxz.js → cli-a4jzdpa3.js} +2514 -1055
  30. package/dist/{cli-w10fkrf1.js → cli-akdvwc9v.js} +354 -115
  31. package/dist/{cli-1qkk5er0.js → cli-bmze986d.js} +1 -1
  32. package/dist/{cli-ga12dc1d.js → cli-bqw0mt1n.js} +3 -3
  33. package/dist/{cli-0sf92efp.js → cli-cdeg08wx.js} +1 -1
  34. package/dist/{cli-a8ynhkyj.js → cli-cvgjwnh1.js} +17 -15
  35. package/dist/{cli-9fxg3v5m.js → cli-d2tfkcga.js} +4 -4
  36. package/dist/{cli-nhcd7x43.js → cli-d3x3x1c9.js} +2 -2
  37. package/dist/{cli-6nh2qxvh.js → cli-darcwax3.js} +1 -1
  38. package/dist/{cli-yjbgmysx.js → cli-e7ckcg4v.js} +12 -12
  39. package/dist/{cli-4n2gcjpd.js → cli-efxfnq6q.js} +4 -3
  40. package/dist/{cli-z9xqqhfk.js → cli-er3a4bb0.js} +1 -1
  41. package/dist/{cli-xg2eezyv.js → cli-f1am9szv.js} +1 -1
  42. package/dist/{cli-xy4cwsm9.js → cli-fywms69x.js} +1 -1
  43. package/dist/{cli-33jj76y8.js → cli-g6b8x1h8.js} +1 -1
  44. package/dist/{cli-epsvdckx.js → cli-gm9cbhbq.js} +20 -10
  45. package/dist/{cli-rkndnn90.js → cli-k9qkf1hw.js} +6 -4
  46. package/dist/{cli-zejvt395.js → cli-kh3ba2zf.js} +1 -1
  47. package/dist/{cli-gb25p1h2.js → cli-nm5gtp1x.js} +2 -2
  48. package/dist/{cli-akxnpx17.js → cli-pm6xbh84.js} +1829 -1629
  49. package/dist/{cli-2k0ztbfp.js → cli-psh3c4va.js} +1 -1
  50. package/dist/{cli-39vgrsep.js → cli-qv61hp37.js} +2 -2
  51. package/dist/{cli-hv2fndhm.js → cli-r6kqqbj0.js} +6 -4
  52. package/dist/{cli-1y1cm570.js → cli-rzbpmxdr.js} +1 -1
  53. package/dist/{cli-mmrk00vp.js → cli-t1xze5qh.js} +1 -1
  54. package/dist/{cli-g252a9ky.js → cli-t55yw3a1.js} +2 -2
  55. package/dist/{cli-7bz8dzqa.js → cli-t76vw5db.js} +1184 -1522
  56. package/dist/{cli-4n4mdv21.js → cli-xpxde7rm.js} +7 -3
  57. package/dist/{cli-fpaa5k2q.js → cli-yw8vghx2.js} +54 -18
  58. package/dist/cli-zsspvvm2.js +490 -0
  59. package/dist/cli.js +27 -25
  60. package/dist/{codex-adapter-7v6t7ff6.js → codex-adapter-d24q75wk.js} +7 -7
  61. package/dist/{codex-hook-amndawyb.js → codex-hook-hq54t9ft.js} +2 -2
  62. package/dist/{codex-session-runner-e7j4khe2.js → codex-session-runner-g50vcsex.js} +7 -7
  63. package/dist/{commands-ex0k5tbf.js → commands-c3nrc5hg.js} +5 -5
  64. package/dist/{db-df7jatfc.js → db-nt7fg3tw.js} +5 -5
  65. package/dist/{google-generative-ai-y42hnw9p.js → google-generative-ai-hysrd8qx.js} +7 -5
  66. package/dist/{google-vertex-4sg66gb6.js → google-vertex-p5arrga0.js} +7 -5
  67. package/dist/{handlers-1j2cfqt2.js → handlers-fkr6swat.js} +13 -13
  68. package/dist/{hook-nx9a0mj3.js → hook-77wpvnjc.js} +7 -7
  69. package/dist/{hook-w6yaf6sy.js → hook-g019yehy.js} +8 -8
  70. package/dist/{http-4tz5h8wg.js → http-jewj0syk.js} +2993 -716
  71. package/dist/{index-0bpkzb09.js → index-070v4eyb.js} +3 -3
  72. package/dist/{index-0s67qv7h.js → index-6gm5he8h.js} +11 -11
  73. package/dist/{index-35gtv6gj.js → index-7n2xpbd4.js} +310 -40
  74. package/dist/{index-ker3gccb.js → index-9znhar3x.js} +4 -4
  75. package/dist/{index-5n2c8yay.js → index-hase6zbe.js} +2 -2
  76. package/dist/{index-kcf3tm9h.js → index-hfvggd0z.js} +12 -12
  77. package/dist/{index-7c0jqna0.js → index-hp1kdbjj.js} +13 -13
  78. package/dist/{keepalive-3j4a9168.js → keepalive-j0fjvqfd.js} +7 -7
  79. package/dist/{lead-6kpevymb.js → lead-1n8x30xe.js} +33 -33
  80. package/dist/{maintenance-m1k82ean.js → maintenance-gvev11sa.js} +7 -7
  81. package/dist/{mistral-conversations-bjkdfb6z.js → mistral-conversations-81fwav0j.js} +6 -6
  82. package/dist/{oauth-refresh-sweep-881k8e4q.js → oauth-refresh-sweep-s9ecsvh0.js} +6 -6
  83. package/dist/{onboard-c0ykffjn.js → onboard-pvm5b38w.js} +2 -2
  84. package/dist/{openai-codex-responses-xetseqvt.js → openai-codex-responses-5g27pd6f.js} +43 -23
  85. package/dist/{openai-completions-5r2g5axa.js → openai-completions-3vkmfz2a.js} +55 -11
  86. package/dist/{openai-responses-ce67hd23.js → openai-responses-dg4t7j7a.js} +8 -5
  87. package/dist/{openrouter-images-pzfp22jb.js → openrouter-images-g3gm8931.js} +1 -1
  88. package/dist/{otel-impl-sqagjerv.js → otel-impl-p6jvgd9c.js} +6 -4
  89. package/dist/{otel-3mp81ppd.js → otel-nqa12wf3.js} +2 -2
  90. package/dist/{pi-mono-adapter-k584k16g.js → pi-mono-adapter-t091s27g.js} +17117 -7517
  91. package/dist/{pricing-refresh-y82whbf9.js → pricing-refresh-k49rcyed.js} +7 -7
  92. package/dist/{rbac-roles-nvkqmzyd.js → rbac-roles-30pspvy7.js} +7 -7
  93. package/dist/{rbac-roles-4ht8gegc.js → rbac-roles-cr80g96t.js} +6 -6
  94. package/dist/{render-v2-wvqdgvvp.js → render-v2-xm457ng1.js} +8 -8
  95. package/dist/{seed-pricing-fy3rxhde.js → seed-pricing-yhpv35fr.js} +6 -6
  96. package/dist/{setup-ak5cnc6f.js → setup-nytnmwwr.js} +2 -2
  97. package/dist/{worker-d8769q8a.js → worker-msytr2cw.js} +33 -33
  98. package/openapi.json +34039 -11036
  99. package/package.json +8 -6
  100. package/src/apps/definition.ts +368 -26
  101. package/src/apps/row-store.ts +66 -10
  102. package/src/apps/schema-migrate.ts +95 -1
  103. package/src/apps/store.ts +13 -4
  104. package/src/apps/sync-run-as.ts +18 -0
  105. package/src/apps/sync.ts +934 -0
  106. package/src/apps/version.ts +16 -2
  107. package/src/assets/key.ts +8 -1
  108. package/src/be/asset-key-audit.ts +3 -1
  109. package/src/be/db-queries/mcp-oauth.ts +297 -24
  110. package/src/be/db.ts +42 -1
  111. package/src/be/migrations/129_asset_keys_apps_scripts.sql +161 -0
  112. package/src/be/scripts/db.ts +5 -2
  113. package/src/be/scripts/typecheck.ts +1 -0
  114. package/src/be/seed-scripts/catalog/app-sync-run.ts +32 -0
  115. package/src/be/seed-scripts/catalog/github-issues-pull.ts +102 -0
  116. package/src/be/seed-scripts/index.ts +18 -0
  117. package/src/be/seed-skills/index.ts +7 -0
  118. package/src/http/active-sessions.ts +26 -18
  119. package/src/http/agents.ts +94 -27
  120. package/src/http/api-keys.ts +91 -17
  121. package/src/http/approval-requests.ts +87 -10
  122. package/src/http/apps.ts +361 -53
  123. package/src/http/assets.ts +165 -31
  124. package/src/http/budgets.ts +7 -5
  125. package/src/http/codex-oauth-keep-warm.ts +21 -3
  126. package/src/http/config.ts +46 -16
  127. package/src/http/context.ts +33 -6
  128. package/src/http/ecosystem.ts +20 -6
  129. package/src/http/events.ts +24 -9
  130. package/src/http/favorites.ts +20 -6
  131. package/src/http/fs.ts +60 -17
  132. package/src/http/heartbeat.ts +18 -4
  133. package/src/http/inbox-state.ts +12 -6
  134. package/src/http/integrations.ts +25 -5
  135. package/src/http/kv.ts +36 -15
  136. package/src/http/mcp-bridge.ts +5 -1
  137. package/src/http/mcp-oauth.ts +332 -17
  138. package/src/http/mcp-servers.ts +56 -18
  139. package/src/http/memory.ts +282 -29
  140. package/src/http/metrics.ts +80 -17
  141. package/src/http/oauth-callback.ts +10 -4
  142. package/src/http/oauth-generic.ts +5 -1
  143. package/src/http/oauth-locks.ts +6 -3
  144. package/src/http/openapi.ts +57 -9
  145. package/src/http/page-proxy.ts +25 -5
  146. package/src/http/pages-public.ts +37 -20
  147. package/src/http/pages.ts +93 -28
  148. package/src/http/poll.ts +89 -5
  149. package/src/http/pricing.ts +9 -6
  150. package/src/http/prompt-templates.ts +85 -21
  151. package/src/http/route-def.ts +67 -4
  152. package/src/http/schedules.ts +63 -14
  153. package/src/http/script-connection-proxy.ts +30 -4
  154. package/src/http/script-connections.ts +393 -43
  155. package/src/http/script-runs.ts +73 -19
  156. package/src/http/scripts.ts +257 -39
  157. package/src/http/session-data.ts +87 -14
  158. package/src/http/sessions.ts +80 -5
  159. package/src/http/skills.ts +115 -35
  160. package/src/http/stats.ts +94 -13
  161. package/src/http/task-templates.ts +6 -4
  162. package/src/http/tasks.ts +230 -61
  163. package/src/http/trackers/jira.ts +90 -30
  164. package/src/http/trackers/linear.ts +46 -12
  165. package/src/http/users.ts +162 -25
  166. package/src/http/utils.ts +12 -2
  167. package/src/http/webhooks.ts +30 -16
  168. package/src/http/workflow-events.ts +18 -5
  169. package/src/http/workflows.ts +128 -39
  170. package/src/http/x.ts +37 -6
  171. package/src/oauth/ensure-mcp-token.ts +16 -1
  172. package/src/oauth/mcp-wrapper.ts +281 -26
  173. package/src/providers/pi-mono-adapter.ts +1 -1
  174. package/src/scripts-runtime/credential-broker/README.md +1 -1
  175. package/src/scripts-runtime/credential-broker/fetch-patch.ts +12 -2
  176. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  177. package/src/scripts-runtime/stdlib/fetch.ts +6 -0
  178. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  179. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  180. package/src/server.ts +2 -0
  181. package/src/tests/apps-rbac.test.ts +110 -0
  182. package/src/tests/apps-spike2.test.ts +4 -1
  183. package/src/tests/apps-sync-engine.test.ts +2540 -0
  184. package/src/tests/apps-sync.test.ts +1627 -0
  185. package/src/tests/asset-key-api.test.ts +48 -2
  186. package/src/tests/asset-key-invariants.test.ts +92 -0
  187. package/src/tests/asset-key-migration.test.ts +174 -8
  188. package/src/tests/asset-key-normalization.test.ts +2 -0
  189. package/src/tests/credential-broker.test.ts +27 -2
  190. package/src/tests/mcp-oauth-dcr-reuse.test.ts +743 -0
  191. package/src/tests/mcp-oauth-ensure-token.test.ts +45 -0
  192. package/src/tests/mcp-oauth-manual-client.test.ts +135 -2
  193. package/src/tests/mcp-oauth-queries.test.ts +153 -0
  194. package/src/tests/mcp-oauth-wrapper.test.ts +432 -0
  195. package/src/tests/memory-http-recall-gating.test.ts +2 -0
  196. package/src/tests/openapi-response-contract.test.ts +138 -0
  197. package/src/tests/rbac-admission-e2e.test.ts +68 -0
  198. package/src/tests/script-connections.test.ts +3 -0
  199. package/src/tests/scripts-runtime-fetch.test.ts +121 -0
  200. package/src/tests/scripts-runtime-secret-egress.test.ts +4 -2
  201. package/src/tests/seed-scripts.test.ts +2 -2
  202. package/src/tests/swarm-tool-result-gate.test.ts +32 -0
  203. package/src/tests/task-working-dir.test.ts +8 -4
  204. package/src/tests/whoami-route.test.ts +127 -0
  205. package/src/tests/workflow-engine-v2.test.ts +80 -7
  206. package/src/tests/workflow-swarm-script.test.ts +104 -0
  207. package/src/tools/app-get.ts +10 -2
  208. package/src/tools/app-rollback.ts +1 -0
  209. package/src/tools/app-sync.ts +112 -0
  210. package/src/tools/script-common.ts +20 -0
  211. package/src/tools/tool-config.ts +2 -1
  212. package/src/tools/utils.ts +11 -0
  213. package/src/tools/workflows/create-workflow.ts +4 -1
  214. package/src/types.ts +1394 -1206
  215. package/src/utils/template.ts +8 -1
  216. package/src/utils/zod-openapi.ts +11 -0
  217. package/src/workflows/engine.ts +27 -4
  218. package/src/workflows/executors/swarm-script.ts +28 -0
  219. package/src/workflows/retry-poller.ts +19 -2
  220. package/templates/skills/apps/content.md +94 -5
  221. package/templates/skills/asset-namespaces/content.md +2 -2
  222. package/templates/skills/scheduled-task-resilience/SKILL.md +102 -0
  223. package/templates/skills/scheduled-task-resilience/config.json +14 -0
  224. package/templates/skills/scheduled-task-resilience/content.md +97 -0
  225. package/dist/cli-7wmybrt5.js +0 -115
  226. package/dist/cli-d91pvmq1.js +0 -14
@@ -26,6 +26,7 @@ setDefaultTimeout(120_000);
26
26
 
27
27
  const REQUESTER_ROLE_ID = "rbac-role-requester";
28
28
  const CONFIG_SECRET_READER_ROLE_ID = "rbac-test-config-secret-reader";
29
+ const APP_MANAGER_ROLE_ID = "rbac-test-app-manager";
29
30
  const CONFIG_SECRET_KEY = "RBAC_ADMISSION_SECRET";
30
31
  const CONFIG_SECRET_VALUE = "rbac-admission-secret-value";
31
32
 
@@ -81,6 +82,29 @@ function grantUserConfigSecretRead(dbPath: string, userId: string): void {
81
82
  }
82
83
  }
83
84
 
85
+ function grantUserAppManage(dbPath: string, userId: string): void {
86
+ const db = new Database(dbPath);
87
+ try {
88
+ db.run("PRAGMA busy_timeout = 5000");
89
+ db.transaction(() => {
90
+ db.prepare(
91
+ `INSERT OR IGNORE INTO roles (id, name, description, isBuiltin, grantsAll)
92
+ VALUES (?, ?, ?, 0, 0)`,
93
+ ).run(APP_MANAGER_ROLE_ID, "app-manager", "Test app manager");
94
+ db.prepare("INSERT OR IGNORE INTO role_permissions (roleId, verb) VALUES (?, ?)").run(
95
+ APP_MANAGER_ROLE_ID,
96
+ "app.manage",
97
+ );
98
+ db.prepare(
99
+ `INSERT OR IGNORE INTO principal_roles (principalType, principalId, roleId)
100
+ VALUES ('user', ?, ?)`,
101
+ ).run(userId, APP_MANAGER_ROLE_ID);
102
+ })();
103
+ } finally {
104
+ db.close();
105
+ }
106
+ }
107
+
84
108
  describe("RBAC admission over real HTTP", () => {
85
109
  afterAll(async () => {
86
110
  if (server) {
@@ -205,6 +229,21 @@ describe("RBAC admission over real HTTP", () => {
205
229
  expect(defaultCreate.status).toBe(201);
206
230
  const taskId = defaultCreate.body.id as string;
207
231
 
232
+ const appCreate = await api(server.base, "POST", "/api/apps", {
233
+ body: {
234
+ name: "RBAC admission app",
235
+ definition: {
236
+ models: { note: { columns: { title: { kind: "string" } } } },
237
+ pages: {
238
+ main: { root: "root", elements: { root: { type: "Container", props: {} } } },
239
+ },
240
+ defaultPage: "main",
241
+ },
242
+ },
243
+ });
244
+ expect(appCreate.status).toBe(201);
245
+ const appId = appCreate.body.app.id as string;
246
+
208
247
  rewriteUserToRequester(dbPath, userId);
209
248
 
210
249
  const deniedTaskCreate = await api(server.base, "POST", "/api/tasks", {
@@ -214,6 +253,26 @@ describe("RBAC admission over real HTTP", () => {
214
253
  expect(deniedTaskCreate.status).toBe(403);
215
254
  expect(deniedTaskCreate.body.error).toContain("admission: route has no permission verb");
216
255
 
256
+ const deniedAppMove = await api(server.base, "PATCH", `/api/assets/app/${appId}/key`, {
257
+ bearer: userToken,
258
+ body: { key: "shared/rbac-denied/" },
259
+ });
260
+ expect(deniedAppMove.status).toBe(403);
261
+ expect(deniedAppMove.body.error).toContain("missing permission 'app.manage'");
262
+
263
+ grantUserAppManage(dbPath, userId);
264
+
265
+ const allowedAppMove = await api(server.base, "PATCH", `/api/assets/app/${appId}/key`, {
266
+ bearer: userToken,
267
+ body: { key: "shared/rbac-allowed/" },
268
+ });
269
+ expect(allowedAppMove.status).toBe(200);
270
+ expect(allowedAppMove.body).toEqual({
271
+ entityType: "app",
272
+ id: appId,
273
+ key: "shared/rbac-allowed/",
274
+ });
275
+
217
276
  const allowedFavorite = await api(server.base, "PUT", "/api/favorites", {
218
277
  bearer: userToken,
219
278
  body: { itemType: "workflow", itemId: "wf-rbac-admission", favorite: true },
@@ -311,6 +370,7 @@ describe("RBAC admission over real HTTP", () => {
311
370
  expect(denyRows.map((row) => row.resourceId).sort()).toEqual([
312
371
  "GET /api/agents/{id}/mcp-servers",
313
372
  "GET /api/scripts/{id}/apis/{endpointId}/secret",
373
+ "PATCH /api/assets/app/{id}/key",
314
374
  "POST /api/scripts/{id}/apis",
315
375
  "POST /api/tasks",
316
376
  ]);
@@ -336,6 +396,14 @@ describe("RBAC admission over real HTTP", () => {
336
396
  row.decision === "allow",
337
397
  ),
338
398
  ).toBe(true);
399
+ expect(
400
+ userHttpRows.some(
401
+ (row) =>
402
+ row.resourceId === "PATCH /api/assets/app/{id}/key" &&
403
+ row.verb === "app.manage" &&
404
+ row.decision === "allow",
405
+ ),
406
+ ).toBe(true);
339
407
 
340
408
  rewriteUserToRequester(dbPath, userId);
341
409
 
@@ -535,6 +535,9 @@ describe("script connections", () => {
535
535
  markMigrationApplied(database, "127_backfill_user_attribution.sql");
536
536
  // 128 alters session_costs, which this partial fixture does not create.
537
537
  markMigrationApplied(database, "128_session_costs_accuracy.sql");
538
+ // 129 alters scripts and rebuilds asset_key_history, neither of which this
539
+ // migration-112-only fixture creates.
540
+ markMigrationApplied(database, "129_asset_keys_apps_scripts.sql");
538
541
 
539
542
  const id = crypto.randomUUID();
540
543
  const now = new Date().toISOString();
@@ -0,0 +1,121 @@
1
+ // DES-770: runtimeFetch must honor already-aborted / mid-flight caller aborts
2
+ // instead of running (and retrying) attempts for a cancelled request. All tests
3
+ // count real attempts against a Bun.serve fixture — no mock.module (leaks
4
+ // process-wide across the test run).
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { runtimeFetch } from "../scripts-runtime/stdlib/fetch";
7
+
8
+ describe("runtimeFetch abort handling", () => {
9
+ let fixture: ReturnType<typeof Bun.serve>;
10
+ let origin = "";
11
+ const hits: Record<string, number> = {};
12
+ // Stalled handlers park their resolvers here; afterAll releases them so the
13
+ // fixture can shut down cleanly.
14
+ const stallReleases: Array<() => void> = [];
15
+ let onStallHit: (() => void) | undefined;
16
+
17
+ beforeAll(() => {
18
+ fixture = Bun.serve({
19
+ port: 0,
20
+ hostname: "127.0.0.1",
21
+ fetch: async (req: Request) => {
22
+ const path = new URL(req.url).pathname;
23
+ hits[path] = (hits[path] ?? 0) + 1;
24
+ if (path.startsWith("/stall")) {
25
+ onStallHit?.();
26
+ await new Promise<void>((resolve) => stallReleases.push(resolve));
27
+ }
28
+ if (path === "/flaky" && hits[path] === 1) {
29
+ return new Response("boom", { status: 500 });
30
+ }
31
+ return new Response("ok");
32
+ },
33
+ });
34
+ origin = `http://127.0.0.1:${fixture.port}`;
35
+ });
36
+
37
+ afterAll(() => {
38
+ for (const release of stallReleases) release();
39
+ fixture.stop(true);
40
+ });
41
+
42
+ async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
43
+ return promise.then(
44
+ () => {
45
+ throw new Error("expected runtimeFetch to reject");
46
+ },
47
+ (error: unknown) => error,
48
+ );
49
+ }
50
+
51
+ test("already-aborted signal rejects immediately with zero fetch attempts", async () => {
52
+ const error = await rejectionOf(
53
+ runtimeFetch(`${origin}/pre-aborted`, {
54
+ signal: AbortSignal.abort(),
55
+ retries: 3,
56
+ timeoutMs: 30_000,
57
+ }),
58
+ );
59
+ expect((error as DOMException).name).toBe("AbortError");
60
+ expect(hits["/pre-aborted"]).toBeUndefined();
61
+ });
62
+
63
+ test("already-aborted signal propagates the caller's abort reason", async () => {
64
+ const reason = new Error("caller cancelled");
65
+ const error = await rejectionOf(
66
+ runtimeFetch(`${origin}/pre-aborted-reason`, {
67
+ signal: AbortSignal.abort(reason),
68
+ retries: 3,
69
+ timeoutMs: 30_000,
70
+ }),
71
+ );
72
+ expect(error).toBe(reason);
73
+ expect(hits["/pre-aborted-reason"]).toBeUndefined();
74
+ });
75
+
76
+ test("mid-flight abort rejects after exactly one attempt, no retry", async () => {
77
+ const arrived = new Promise<void>((resolve) => {
78
+ onStallHit = resolve;
79
+ });
80
+ const controller = new AbortController();
81
+ const pending = runtimeFetch(`${origin}/stall-abort`, {
82
+ signal: controller.signal,
83
+ retries: 3,
84
+ timeoutMs: 5_000,
85
+ });
86
+ await arrived;
87
+ onStallHit = undefined;
88
+ controller.abort();
89
+ const error = await rejectionOf(pending);
90
+ expect((error as DOMException).name).toBe("AbortError");
91
+ // Give a would-be retry time to land before asserting the attempt count.
92
+ await Bun.sleep(50);
93
+ expect(hits["/stall-abort"]).toBe(1);
94
+ });
95
+
96
+ test("timeout abort still retries up to the retry budget", async () => {
97
+ // Live, never-aborted caller signal: proves signal presence doesn't turn
98
+ // the controller's own timeout abort into a terminal error.
99
+ const controller = new AbortController();
100
+ const error = await rejectionOf(
101
+ runtimeFetch(`${origin}/stall-timeout`, {
102
+ signal: controller.signal,
103
+ retries: 2,
104
+ timeoutMs: 100,
105
+ }),
106
+ );
107
+ expect(error).toBeInstanceOf(Error);
108
+ expect(hits["/stall-timeout"]).toBe(2);
109
+ });
110
+
111
+ test("5xx responses still retry and succeed on a later attempt", async () => {
112
+ const controller = new AbortController();
113
+ const res = await runtimeFetch(`${origin}/flaky`, {
114
+ signal: controller.signal,
115
+ retries: 3,
116
+ timeoutMs: 5_000,
117
+ });
118
+ expect(res.status).toBe(200);
119
+ expect(hits["/flaky"]).toBe(2);
120
+ });
121
+ });
@@ -111,7 +111,7 @@ describe("egress-substitution", () => {
111
111
  expect(capturedHeaders?.get("authorization")).toBe("Bearer ghp_real_secret_value_123");
112
112
  });
113
113
 
114
- test("does NOT substitute for non-allowlisted host", async () => {
114
+ test("drops the placeholder header for a non-allowlisted host", async () => {
115
115
  let capturedHeaders: Headers | undefined;
116
116
  globalThis.fetch = async (_input: any, init?: any) => {
117
117
  capturedHeaders = new Headers(init?.headers);
@@ -134,7 +134,9 @@ describe("egress-substitution", () => {
134
134
  headers: { Authorization: "Bearer [REDACTED:GITHUB_TOKEN]" },
135
135
  });
136
136
 
137
- expect(capturedHeaders?.get("authorization")).toBe("Bearer [REDACTED:GITHUB_TOKEN]");
137
+ // The placeholder header is DROPPED for non-allowlisted hosts: the
138
+ // literal marker is useless to the destination and must never egress.
139
+ expect(capturedHeaders?.get("authorization")).toBeNull();
138
140
  });
139
141
 
140
142
  test("passes through requests with no redacted placeholders", async () => {
@@ -55,8 +55,8 @@ afterAll(async () => {
55
55
  });
56
56
 
57
57
  describe("seed-scripts catalog", () => {
58
- test("manifest holds 24 unique, well-described scripts", () => {
59
- expect(SEED_SCRIPTS.length).toBe(24);
58
+ test("manifest holds 26 unique, well-described scripts", () => {
59
+ expect(SEED_SCRIPTS.length).toBe(26);
60
60
  const names = SEED_SCRIPTS.map((s) => s.name);
61
61
  expect(new Set(names).size).toBe(names.length);
62
62
  for (const s of SEED_SCRIPTS) {
@@ -267,6 +267,38 @@ describe("finalizeSwarmToolResult", () => {
267
267
  expect((nonEmpty.structuredContent as { nudge?: string }).nudge).toBeUndefined();
268
268
  });
269
269
 
270
+ test("NUDGES map: app-get steers to the callable surface only when one exists", () => {
271
+ const withSurface = finalizeSwarmToolResult("app-get", {
272
+ ok: true,
273
+ message: 'App "PM Inbox" (a1).',
274
+ data: {
275
+ app: {
276
+ definition: {
277
+ models: {},
278
+ queries: { open: { model: "issue" } },
279
+ actions: { refresh: { kind: "sync" } },
280
+ },
281
+ },
282
+ },
283
+ });
284
+ expect((withSurface.structuredContent as { nudge?: string }).nudge).toContain(
285
+ "callable surface",
286
+ );
287
+
288
+ const bare = finalizeSwarmToolResult("app-get", {
289
+ ok: true,
290
+ message: 'App "Bare" (a2).',
291
+ data: { app: { definition: { models: {} } } },
292
+ });
293
+ expect((bare.structuredContent as { nudge?: string }).nudge).toBeUndefined();
294
+
295
+ const failed = finalizeSwarmToolResult("app-get", {
296
+ ok: false,
297
+ message: "App a3 not found.",
298
+ });
299
+ expect((failed.structuredContent as { nudge?: string }).nudge).toBeUndefined();
300
+ });
301
+
270
302
  test("NUDGES map: memory-search rating steer fires only when a result carries a rateHint", () => {
271
303
  const withHint = finalizeSwarmToolResult("memory-search", {
272
304
  ok: true,
@@ -55,10 +55,14 @@ describe("Task Working Directory", () => {
55
55
  expect(() => dirSchema.parse("")).toThrow();
56
56
  });
57
57
 
58
- test("rejects relative paths", () => {
59
- expect(() => dirSchema.parse("relative/path")).toThrow();
60
- expect(() => dirSchema.parse("./local")).toThrow();
61
- expect(() => dirSchema.parse("../parent")).toThrow();
58
+ test("accepts relative paths (row contract mirrors what the create body stores verbatim)", () => {
59
+ // The create-task body validates dir as z.string() and the DB stores it
60
+ // as-is, so rows CAN hold relative paths — the entity schema must not
61
+ // pin .startsWith("/") or response validation would reject honest rows
62
+ // after the write already landed (PR #1141 review). Absolute paths stay
63
+ // the documented expectation; enforcement would belong on the INPUT side.
64
+ expect(dirSchema.parse("relative/path")).toBe("relative/path");
65
+ expect(dirSchema.parse("./local")).toBe("./local");
62
66
  });
63
67
  });
64
68
 
@@ -0,0 +1,127 @@
1
+ /**
2
+ * GET /api/whoami — bearer → principal resolution (DES-771).
3
+ *
4
+ * The embedded dashboard authenticates with a per-user `aswt_` token and asks
5
+ * this endpoint which user it is acting as (the server forces requester/audit
6
+ * attribution from the token, see user-token-rest-auth.test.ts). The operator
7
+ * key resolves to `kind: "operator"` with no bound user; inactive tokens are
8
+ * rejected by the transport-level auth (401), never mapped to a user.
9
+ */
10
+
11
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
+ import { unlinkSync } from "node:fs";
13
+ import {
14
+ createServer as createHttpServer,
15
+ type IncomingMessage,
16
+ type Server,
17
+ type ServerResponse,
18
+ } from "node:http";
19
+ import { closeDb, createUser, initDb, updateUser } from "../be/db";
20
+ import { type IdentityActor, mintToken, revokeToken } from "../be/users";
21
+ import { handleCore } from "../http/core";
22
+ import { handleUsers } from "../http/users";
23
+ import { getPathSegments, parseQueryParams } from "../http/utils";
24
+
25
+ const TEST_DB_PATH = "./test-whoami-route.sqlite";
26
+ const API_KEY = "test-api-key";
27
+ const ACTOR: IdentityActor = { kind: "operator", id: "op:test" };
28
+
29
+ interface WhoamiBody {
30
+ kind: "operator" | "user";
31
+ user: { id: string; name: string; status: string } | null;
32
+ }
33
+
34
+ function createTestServer(): Server {
35
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
36
+ const pathSegments = getPathSegments(req.url || "");
37
+ const queryParams = parseQueryParams(req.url || "");
38
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
39
+
40
+ if (await handleCore(req, res, myAgentId, API_KEY)) return;
41
+ if (await handleUsers(req, res, pathSegments, queryParams)) return;
42
+
43
+ res.writeHead(404);
44
+ res.end("Not Found");
45
+ });
46
+ }
47
+
48
+ async function listen(server: Server): Promise<number> {
49
+ await new Promise<void>((resolve) => server.listen(0, resolve));
50
+ const addr = server.address();
51
+ if (!addr || typeof addr === "string") throw new Error("no port");
52
+ return addr.port;
53
+ }
54
+
55
+ function cleanupDb() {
56
+ for (const suffix of ["", "-wal", "-shm"]) {
57
+ try {
58
+ unlinkSync(`${TEST_DB_PATH}${suffix}`);
59
+ } catch {}
60
+ }
61
+ }
62
+
63
+ describe("GET /api/whoami", () => {
64
+ let server: Server;
65
+ let port: number;
66
+
67
+ beforeAll(async () => {
68
+ cleanupDb();
69
+ initDb(TEST_DB_PATH);
70
+ server = createTestServer();
71
+ port = await listen(server);
72
+ });
73
+
74
+ afterAll(() => {
75
+ server.close();
76
+ closeDb();
77
+ cleanupDb();
78
+ });
79
+
80
+ const whoami = (bearer?: string) =>
81
+ fetch(`http://localhost:${port}/api/whoami`, {
82
+ headers: bearer ? { Authorization: `Bearer ${bearer}` } : {},
83
+ });
84
+
85
+ test("operator key resolves to kind=operator with no user", async () => {
86
+ const res = await whoami(API_KEY);
87
+ expect(res.status).toBe(200);
88
+ const body = (await res.json()) as WhoamiBody;
89
+ expect(body.kind).toBe("operator");
90
+ expect(body.user).toBeNull();
91
+ });
92
+
93
+ test("active user token resolves to its bound user", async () => {
94
+ const user = createUser({ name: "Whoami User", email: "whoami@example.com" });
95
+ const { plaintext } = mintToken(user.id, "rest", ACTOR);
96
+
97
+ const res = await whoami(plaintext);
98
+ expect(res.status).toBe(200);
99
+ const body = (await res.json()) as WhoamiBody;
100
+ expect(body.kind).toBe("user");
101
+ expect(body.user?.id).toBe(user.id);
102
+ expect(body.user?.name).toBe("Whoami User");
103
+ });
104
+
105
+ test("revoked token is rejected at auth (401)", async () => {
106
+ const user = createUser({ name: "Revoked User" });
107
+ const { plaintext, tokenId } = mintToken(user.id, "rest", ACTOR);
108
+ revokeToken(tokenId, ACTOR);
109
+
110
+ const res = await whoami(plaintext);
111
+ expect(res.status).toBe(401);
112
+ });
113
+
114
+ test("suspended user's token is rejected at auth (401)", async () => {
115
+ const user = createUser({ name: "Suspended User" });
116
+ const { plaintext } = mintToken(user.id, "rest", ACTOR);
117
+ updateUser(user.id, { status: "suspended" });
118
+
119
+ const res = await whoami(plaintext);
120
+ expect(res.status).toBe(401);
121
+ });
122
+
123
+ test("missing bearer is rejected (401)", async () => {
124
+ const res = await whoami();
125
+ expect(res.status).toBe(401);
126
+ });
127
+ });
@@ -786,6 +786,81 @@ describe("Workflow Engine v2 (Phase 3)", () => {
786
786
  const nodeIds = steps.map((s) => s.nodeId);
787
787
  expect(nodeIds).not.toContain("after");
788
788
  });
789
+
790
+ test("unresolved inline script-body tokens fail with the node and token named", async () => {
791
+ const registry = createTestRegistry();
792
+ registry.register(new ScriptExecutor(mockDeps));
793
+ const workflow = makeWorkflow({
794
+ nodes: [
795
+ {
796
+ id: "unsafe-script",
797
+ type: "script",
798
+ config: { runtime: "bash", script: "echo {{trigger.payload}}" },
799
+ },
800
+ ],
801
+ });
802
+
803
+ const runId = await startWorkflowExecution(workflow, { payload: "attacker-data" }, registry);
804
+ const run = getWorkflowRun(runId);
805
+ const [step] = getWorkflowRunStepsByRunId(runId);
806
+
807
+ expect(run?.status).toBe("failed");
808
+ expect(run?.error).toContain('node "unsafe-script"');
809
+ expect(run?.error).toContain("{{trigger.payload}}");
810
+ expect(run?.error).toContain("config.args");
811
+ expect(step?.status).toBe("failed");
812
+ expect(step?.error).toContain("{{trigger.payload}}");
813
+ expect(step?.retryCount).toBe(0);
814
+ expect(step?.output).toBeUndefined();
815
+ });
816
+
817
+ test("bundled ralph-loop scripts execute with trigger and upstream values passed as args", async () => {
818
+ const receipt = (await Bun.file(
819
+ new URL("../../docs-site/public/receipts/workflows/ralph-loop.json", import.meta.url),
820
+ ).json()) as { definition: WorkflowDefinition };
821
+ const receiptNode = (id: string) => {
822
+ const node = receipt.definition.nodes.find((candidate) => candidate.id === id);
823
+ if (!node) throw new Error(`Missing ${id} in bundled ralph-loop receipt`);
824
+ return node;
825
+ };
826
+
827
+ const registry = createTestRegistry();
828
+ registry.register(new ScriptExecutor(mockDeps));
829
+ const workflow = makeWorkflow({
830
+ nodes: [
831
+ { ...receiptNode("validate-trigger"), next: "ralph-iteration" },
832
+ {
833
+ id: "ralph-iteration",
834
+ type: "script",
835
+ config: {
836
+ runtime: "bash",
837
+ script: `jq -n '{taskOutput:{iteration:1}}'`,
838
+ },
839
+ next: "max-check",
840
+ },
841
+ { ...receiptNode("max-check"), next: undefined },
842
+ ],
843
+ });
844
+
845
+ const runId = await startWorkflowExecution(
846
+ workflow,
847
+ { goal: "ship safely", maxIterations: 3 },
848
+ registry,
849
+ );
850
+ const run = getWorkflowRun(runId);
851
+ const steps = getWorkflowRunStepsByRunId(runId);
852
+
853
+ expect(run?.status).toBe("completed");
854
+ expect(steps.map((step) => [step.nodeId, step.status])).toEqual([
855
+ ["validate-trigger", "completed"],
856
+ ["ralph-iteration", "completed"],
857
+ ["max-check", "completed"],
858
+ ]);
859
+ expect(run?.context).toMatchObject({
860
+ "validate-trigger": { ok: true, goal: "ship safely", maxIterations: 3 },
861
+ "max-check": { atMax: false, iteration: 1, max: 3 },
862
+ });
863
+ });
789
864
  });
790
865
 
791
866
  // ─── Context Interpolation ────────────────────────────────
@@ -836,9 +911,7 @@ describe("interpolateNodeConfig — script node", () => {
836
911
 
837
912
  const script = (value as { script: string }).script;
838
913
  expect(script).not.toContain("curl attacker.example");
839
- // Blanked to empty (existing deepInterpolate behavior for an unresolved
840
- // token), not substituted with the attacker-controlled value.
841
- expect(script).toBe("echo ");
914
+ expect(script).toBe("echo {{trigger.payload}}");
842
915
  expect(unresolved).toContain("trigger.payload");
843
916
  });
844
917
 
@@ -900,7 +973,7 @@ describe("interpolateNodeConfig — script node", () => {
900
973
 
901
974
  const script = (value as { script: string }).script;
902
975
  expect(script).not.toContain("curl attacker.example");
903
- expect(script).toBe("echo ");
976
+ expect(script).toBe("echo {{payload}}");
904
977
  expect(unresolved).toContain("payload");
905
978
  });
906
979
 
@@ -914,7 +987,7 @@ describe("interpolateNodeConfig — script node", () => {
914
987
 
915
988
  const { value } = interpolateNodeConfig(node, ctx);
916
989
 
917
- expect((value as { script: string }).script).toBe("echo ");
990
+ expect((value as { script: string }).script).toBe("echo {{input}}");
918
991
  });
919
992
 
920
993
  test("an inputs alias pointing at workflow input is still interpolated into the script", () => {
@@ -940,7 +1013,7 @@ describe("interpolateNodeConfig — script node", () => {
940
1013
 
941
1014
  const { value, unresolved } = interpolateNodeConfig(node, ctx);
942
1015
 
943
- expect((value as { script: string }).script).toBe("echo ");
1016
+ expect((value as { script: string }).script).toBe("echo {{fetchStep.stdout}}");
944
1017
  expect(unresolved).toContain("fetchStep.stdout");
945
1018
  });
946
1019
 
@@ -954,6 +1027,6 @@ describe("interpolateNodeConfig — script node", () => {
954
1027
 
955
1028
  const { value } = interpolateNodeConfig(node, ctx);
956
1029
 
957
- expect((value as { script: string }).script).toBe("echo ");
1030
+ expect((value as { script: string }).script).toBe("echo {{prev}}");
958
1031
  });
959
1032
  });