@desplega.ai/agent-swarm 1.145.0 → 1.147.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 (221) hide show
  1. package/README.md +2 -1
  2. package/dist/{acp-adapter-580t41c0.js → acp-adapter-9n319wqc.js} +61 -6
  3. package/dist/{actions-m662tjng.js → actions-2vxqvpr9.js} +9 -8
  4. package/dist/{app-zb3rndm8.js → app-w066xfy0.js} +6 -5
  5. package/dist/assistant-kt31dj2h.js +236 -0
  6. package/dist/{boot-reembed-072x00vn.js → boot-reembed-az5rassp.js} +7 -6
  7. package/dist/{boot-reembed-65zz2smt.js → boot-reembed-j3mm3rfz.js} +8 -7
  8. package/dist/{boot-scrub-logs-yz0ams04.js → boot-scrub-logs-b6h54817.js} +4 -4
  9. package/dist/{claude-adapter-paj270y2.js → claude-adapter-w1z04ann.js} +8 -6
  10. package/dist/{claude-managed-setup-w619jncn.js → claude-managed-setup-f5pmsyrp.js} +24 -3
  11. package/dist/{claude-sdk-session-swt80j9z.js → claude-sdk-session-f0cc1kec.js} +8 -6
  12. package/dist/{cli-dt553ktb.js → cli-0b6y7kxm.js} +1 -1
  13. package/dist/{cli-bxdhw615.js → cli-12fz972k.js} +4 -4
  14. package/dist/{cli-s2f20r5w.js → cli-1th1728g.js} +2 -2
  15. package/dist/{cli-rs6ec3b9.js → cli-3tgwnf43.js} +3 -3
  16. package/dist/{cli-gxnzc6wh.js → cli-4nr988h6.js} +1 -1
  17. package/dist/{cli-axs8e6d4.js → cli-53s590z8.js} +1 -1
  18. package/dist/{cli-ygbjp3z8.js → cli-54wwve05.js} +5 -4
  19. package/dist/{cli-v0asr5yv.js → cli-719k9j2c.js} +2 -2
  20. package/dist/{cli-anktweqr.js → cli-89xfd7f9.js} +21 -47
  21. package/dist/{cli-q34a0e10.js → cli-8v0g1yc8.js} +1 -1
  22. package/dist/cli-9d8ntetj.js +42 -0
  23. package/dist/{cli-z9fprcrf.js → cli-atcve9y8.js} +881 -738
  24. package/dist/{cli-jmb7g47h.js → cli-b5bq9crk.js} +14 -31
  25. package/dist/cli-d8brzxjb.js +190 -0
  26. package/dist/{cli-gzky0gkr.js → cli-de14znh8.js} +2 -2
  27. package/dist/{cli-xytwff7f.js → cli-dttph1bw.js} +2 -2
  28. package/dist/cli-ep19k3mb.js +19 -0
  29. package/dist/{cli-m1xa8be7.js → cli-f146mn5s.js} +7 -50
  30. package/dist/{cli-1fj0chdk.js → cli-f7dsy61y.js} +5 -3
  31. package/dist/{cli-f567d5c1.js → cli-ftht3zzj.js} +2782 -2589
  32. package/dist/{cli-a0g1gbt5.js → cli-hbv0kq6w.js} +2 -2
  33. package/dist/{cli-fhsz3twr.js → cli-jsp14b6c.js} +171 -1
  34. package/dist/{cli-7fnkj1hj.js → cli-kpv03zhj.js} +21 -17
  35. package/dist/{cli-m4j6trn4.js → cli-m8yrsg97.js} +2 -2
  36. package/dist/cli-m9sxbkhm.js +779 -0
  37. package/dist/cli-mmemxxdc.js +823 -0
  38. package/dist/{cli-tqtvj5pt.js → cli-n8508nre.js} +1 -1
  39. package/dist/{cli-z96t0thj.js → cli-p1d5b073.js} +37 -2
  40. package/dist/{cli-gh8y8f36.js → cli-rw51bq3j.js} +13 -10
  41. package/dist/{cli-59ej1c07.js → cli-smby5m17.js} +7 -6
  42. package/dist/{cli-65vqzz3v.js → cli-tzvk9haz.js} +94 -372
  43. package/dist/{cli-dc9k2ba9.js → cli-w9kb3bk1.js} +1 -1
  44. package/dist/{cli-kpev657q.js → cli-y6b6fb76.js} +25 -3
  45. package/dist/{cli-123q2get.js → cli-yz3djrm0.js} +9 -9
  46. package/dist/{cli-wvp6bvy4.js → cli-zhjmqxzh.js} +1 -1
  47. package/dist/cli.js +13 -13
  48. package/dist/{codex-adapter-k98vaw2r.js → codex-adapter-zw4dwsx7.js} +2 -2
  49. package/dist/{codex-hook-9pf19cks.js → codex-hook-bw8p5nh4.js} +2 -2
  50. package/dist/{codex-session-runner-tngtphx7.js → codex-session-runner-k981s65v.js} +2 -2
  51. package/dist/{commands-y8nbr3fv.js → commands-sykrxx7e.js} +4 -4
  52. package/dist/{db-30p4h2t1.js → db-sfbsc8wt.js} +6 -4
  53. package/dist/handlers-0aspcejm.js +603 -0
  54. package/dist/{hook-dcw4jfbj.js → hook-4nvqj2sb.js} +7 -5
  55. package/dist/{hook-21jexvfx.js → hook-hxccs7m5.js} +24 -49
  56. package/dist/{http-r4kq5g8d.js → http-y06z517d.js} +230 -94
  57. package/dist/{index-q3qja6ta.js → index-hssww7kj.js} +15 -14
  58. package/dist/{index-gay66b9w.js → index-k8znfx0z.js} +13 -12
  59. package/dist/{index-53zyht4z.js → index-tce1rss4.js} +371 -110
  60. package/dist/{index-nqvf26za.js → index-x5j894zf.js} +14 -13
  61. package/dist/{keepalive-xs8fa55y.js → keepalive-w733ax66.js} +6 -6
  62. package/dist/{lead-d9m2t4s0.js → lead-75bjqa9d.js} +29 -27
  63. package/dist/{maintenance-3zh82g6z.js → maintenance-w4f1zjck.js} +8 -7
  64. package/dist/{oauth-refresh-sweep-03snsye5.js → oauth-refresh-sweep-wdetky26.js} +5 -5
  65. package/dist/{onboard-0cg1925x.js → onboard-bb5net8s.js} +2 -2
  66. package/dist/{otel-impl-0ab95z27.js → otel-impl-0w7c14ap.js} +1 -1
  67. package/dist/{pi-mono-adapter-dtkz4e7j.js → pi-mono-adapter-2has4gnp.js} +12 -2
  68. package/dist/{pricing-refresh-e5jhzz4j.js → pricing-refresh-9nzsrgcq.js} +6 -6
  69. package/dist/{rbac-roles-p1mf40z3.js → rbac-roles-r3hqjp09.js} +5 -5
  70. package/dist/{rbac-roles-2w3cmrkc.js → rbac-roles-z1jrffbt.js} +4 -4
  71. package/dist/{registry-9kdck5ye.js → registry-v5wytg0s.js} +1 -2
  72. package/dist/{render-v2-xhawry3k.js → render-v2-vbr89fnb.js} +6 -5
  73. package/dist/{seed-pricing-t223sw91.js → seed-pricing-g62hy1pk.js} +7 -5
  74. package/dist/{setup-zcs0rce7.js → setup-gnfqnptp.js} +2 -2
  75. package/dist/{worker-qnp2ka62.js → worker-kznxp4qm.js} +29 -27
  76. package/openapi.json +263 -12
  77. package/package.json +1 -1
  78. package/plugin/commands/start-worker.md +4 -9
  79. package/plugin/pi-skills/start-worker/SKILL.md +4 -9
  80. package/src/be/db/agents.ts +556 -0
  81. package/src/be/db/context-versions.ts +117 -0
  82. package/src/be/db/runtime.ts +406 -0
  83. package/src/be/db/tasks/read.ts +1167 -0
  84. package/src/be/db/tasks/write.ts +953 -0
  85. package/src/be/db.ts +584 -3429
  86. package/src/be/migrations/147_scheduled_tasks_parent_task.sql +7 -0
  87. package/src/be/migrations/148_pages_svg.sql +123 -0
  88. package/src/be/migrations/149_session_tokens.sql +24 -0
  89. package/src/be/scripts/typecheck.ts +1 -0
  90. package/src/be/seed-pricing.ts +13 -8
  91. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
  92. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
  93. package/src/be/seed-scripts/catalog/star-history-refresh.ts +101 -0
  94. package/src/be/seed-scripts/catalog/star-history-renderer.ts +119 -0
  95. package/src/be/seed-scripts/index.ts +17 -0
  96. package/src/be/seed-skills/index.ts +14 -0
  97. package/src/be/swarm-config-guard.ts +5 -2
  98. package/src/be/task-attachment-store.ts +70 -0
  99. package/src/be/users.ts +77 -0
  100. package/src/commands/attachments-section.ts +37 -0
  101. package/src/commands/claude-managed-setup.ts +45 -3
  102. package/src/commands/claude-md-session.ts +408 -0
  103. package/src/commands/context-preamble.ts +15 -3
  104. package/src/commands/profile-sync.ts +205 -5
  105. package/src/commands/provider-credentials.ts +60 -27
  106. package/src/commands/runner.ts +63 -58
  107. package/src/commands/templates.ts +25 -2
  108. package/src/hooks/hook.ts +26 -67
  109. package/src/http/agents.ts +40 -1
  110. package/src/http/auth.ts +13 -1
  111. package/src/http/fs.ts +21 -51
  112. package/src/http/index.ts +2 -0
  113. package/src/http/mcp.ts +32 -0
  114. package/src/http/pages-public.ts +20 -4
  115. package/src/http/pages.ts +11 -3
  116. package/src/http/poll.ts +4 -0
  117. package/src/http/repos.ts +3 -3
  118. package/src/http/sessions.ts +82 -0
  119. package/src/http/tasks.ts +10 -2
  120. package/src/http/utils.ts +79 -1
  121. package/src/prompts/session-templates.ts +12 -8
  122. package/src/providers/acp-adapter.ts +62 -8
  123. package/src/providers/pi-mono-adapter.ts +23 -6
  124. package/src/providers/pricing-sources.md +11 -0
  125. package/src/scheduler/scheduler.test.ts +49 -2
  126. package/src/scheduler/scheduler.ts +4 -0
  127. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  128. package/src/scripts-runtime/types/stdlib.d.ts +8 -0
  129. package/src/scripts-runtime/types/swarm-sdk.d.ts +8 -0
  130. package/src/server.ts +2 -0
  131. package/src/slack/assistant.ts +87 -46
  132. package/src/slack/handlers.test.ts +1 -3
  133. package/src/slack/handlers.ts +102 -87
  134. package/src/slack/inbound-files.ts +466 -0
  135. package/src/slack/templates.ts +14 -0
  136. package/src/tasks/task-terminal-effects.ts +158 -0
  137. package/src/tests/acp-adapter.test.ts +369 -117
  138. package/src/tests/budget-claim-gate.test.ts +1 -0
  139. package/src/tests/build-pi-skills.test.ts +5 -7
  140. package/src/tests/claude-managed-setup.test.ts +52 -4
  141. package/src/tests/claude-md-session.test.ts +603 -0
  142. package/src/tests/codex-skill-resolver.test.ts +19 -0
  143. package/src/tests/context-versioning.test.ts +107 -0
  144. package/src/tests/cors-allowlist.test.ts +309 -0
  145. package/src/tests/credential-check.test.ts +157 -14
  146. package/src/tests/db-agents.test.ts +91 -0
  147. package/src/tests/db-context-versions.test.ts +37 -0
  148. package/src/tests/db-extraction-contract.test.ts +45 -0
  149. package/src/tests/db-runtime.test.ts +70 -0
  150. package/src/tests/db-task-read-contract.test.ts +624 -0
  151. package/src/tests/db-task-write-contract.test.ts +653 -0
  152. package/src/tests/db-tasks-read.test.ts +183 -0
  153. package/src/tests/db-tasks-write.test.ts +230 -0
  154. package/src/tests/defer-task.test.ts +389 -0
  155. package/src/tests/docs-index-footprint.test.js +2 -2
  156. package/src/tests/e2e-visuals-stage.test.ts +151 -0
  157. package/src/tests/file-lock.test.ts +144 -0
  158. package/src/tests/fixtures/file-lock-holder.ts +13 -0
  159. package/src/tests/fixtures/hold-file-lock.ts +25 -0
  160. package/src/tests/heartbeat-expiry.test.ts +188 -0
  161. package/src/tests/http-api-integration.test.ts +78 -1
  162. package/src/tests/pages-public-html.test.ts +43 -0
  163. package/src/tests/pages-svg-migration.test.ts +42 -0
  164. package/src/tests/preload.ts +4 -1
  165. package/src/tests/profile-sync-warning.test.ts +16 -0
  166. package/src/tests/profile-sync.test.ts +126 -0
  167. package/src/tests/prompt-template-session.test.ts +20 -4
  168. package/src/tests/provider-registration.test.ts +93 -0
  169. package/src/tests/rbac-e2e-helpers.test.ts +53 -0
  170. package/src/tests/rbac-e2e-helpers.ts +19 -2
  171. package/src/tests/reload-config.test.ts +26 -0
  172. package/src/tests/routing-affinity-inventory.test.ts +1 -1
  173. package/src/tests/runner-attachments-section.test.ts +22 -2
  174. package/src/tests/runner-context-preamble.test.ts +30 -0
  175. package/src/tests/runner-deployment-resume.test.ts +107 -0
  176. package/src/tests/runner-repo-autostash.test.ts +12 -0
  177. package/src/tests/script-connections.test.ts +4 -0
  178. package/src/tests/seed-scripts.test.ts +2 -2
  179. package/src/tests/session-tokens.test.ts +279 -0
  180. package/src/tests/slack-file-tools-attachments.test.ts +408 -0
  181. package/src/tests/slack-inbound-files.test.ts +847 -0
  182. package/src/tests/star-history-refresh.test.ts +102 -0
  183. package/src/tests/swarm-config-reserved-keys.test.ts +21 -0
  184. package/src/tests/swarm-repos.test.ts +20 -0
  185. package/src/tests/tool-annotations.test.ts +4 -3
  186. package/src/tests/ui-logs-parser.test.ts +176 -0
  187. package/src/tools/create-page.ts +4 -2
  188. package/src/tools/defer-task.ts +239 -0
  189. package/src/tools/repos/update-repo.ts +2 -2
  190. package/src/tools/schedules/create-schedule.ts +1 -0
  191. package/src/tools/schedules/list-schedules.ts +1 -0
  192. package/src/tools/schedules/patch-schedule.ts +1 -0
  193. package/src/tools/schedules/update-schedule.ts +1 -0
  194. package/src/tools/slack-download-file.ts +60 -29
  195. package/src/tools/slack-read.ts +73 -32
  196. package/src/tools/store-progress.ts +16 -146
  197. package/src/tools/tool-config.ts +1 -0
  198. package/src/tools/utils.ts +21 -0
  199. package/src/types.ts +37 -4
  200. package/src/utils/acp-session-token.ts +56 -0
  201. package/src/utils/file-lock.ts +111 -0
  202. package/src/utils/heartbeat-expiry.ts +78 -0
  203. package/src/utils/request-auth-context.ts +2 -0
  204. package/src/utils/task-attachment-links.ts +22 -0
  205. package/templates/skills/code-quality/content.md +8 -5
  206. package/templates/skills/download-task-attachment/content.md +2 -1
  207. package/{plugin/pi-skills → templates/skills}/review-offered-task/SKILL.md +5 -5
  208. package/templates/skills/review-offered-task/config.json +14 -0
  209. package/{plugin/commands/review-offered-task.md → templates/skills/review-offered-task/content.md} +3 -8
  210. package/templates/skills/scheduling/content.md +4 -0
  211. package/templates/skills/slack-interaction/content.md +2 -2
  212. package/templates/skills/work-on-task/SKILL.md +16 -0
  213. package/templates/skills/work-on-task/config.json +14 -0
  214. package/templates/skills/work-on-task/content.md +11 -0
  215. package/dist/assistant-0qt42nzg.js +0 -212
  216. package/dist/cli-fs1kkqgr.js +0 -317
  217. package/dist/cli-jnf5zghe.js +0 -269
  218. package/dist/cli-n1q8q14d.js +0 -177
  219. package/dist/handlers-yfp1qn81.js +0 -592
  220. package/plugin/commands/work-on-task.md +0 -39
  221. package/plugin/pi-skills/work-on-task/SKILL.md +0 -39
@@ -10,6 +10,8 @@ import {
10
10
  initDb,
11
11
  updateAgentProfile,
12
12
  } from "../be/db";
13
+ import type { ProfileSyncConflict } from "../types";
14
+ import { IDENTITY_FIELD_BUDGETS } from "../utils/identity-field-budget";
13
15
 
14
16
  const TEST_DB_PATH = "./test-context-versioning.sqlite";
15
17
 
@@ -411,6 +413,111 @@ describe("Context Versioning", () => {
411
413
  });
412
414
  });
413
415
 
416
+ // ============================================================================
417
+ // Compare-and-set (expectedHashes)
418
+ // ============================================================================
419
+
420
+ describe("compare-and-set via expectedHashes", () => {
421
+ const casAgentId = "cccc0000-0000-4000-8000-000000000004";
422
+ const v1 = "# CLAUDE.md\n\nversion one";
423
+ const v2 = "# CLAUDE.md\n\nversion two — the Lead added a rule";
424
+ const v3 = "# CLAUDE.md\n\nversion three — edited in a session";
425
+
426
+ beforeAll(async () => {
427
+ await createAgent({ id: casAgentId, name: "CAS Agent", isLead: false, status: "idle" });
428
+ await updateAgentProfile(casAgentId, { claudeMd: v1 }, { changeSource: "self_edit" });
429
+ await updateAgentProfile(casAgentId, { claudeMd: v2 }, { changeSource: "self_edit" });
430
+ });
431
+
432
+ test("drops a copy based on a superseded version and reports the conflict", async () => {
433
+ // The 2026-09-11 incident: a session materialized v1, the Lead moved the
434
+ // DB to v2 meanwhile, and the session's end-of-run sync carried v1 back.
435
+ const conflicts: ProfileSyncConflict[] = [];
436
+ const agent = await updateAgentProfile(
437
+ casAgentId,
438
+ { claudeMd: v3 },
439
+ { changeSource: "session_sync" },
440
+ { expectedHashes: { claudeMd: sha256(v1) }, onConflict: (c) => conflicts.push(c) },
441
+ );
442
+
443
+ expect(agent!.claudeMd).toBe(v2);
444
+ expect((await getLatestContextVersion(casAgentId, "claudeMd"))!.version).toBe(2);
445
+ expect(conflicts).toEqual([
446
+ { field: "claudeMd", expectedHash: sha256(v1), currentHash: sha256(v2) },
447
+ ]);
448
+ });
449
+
450
+ test("applies a deliberate session_sync revert to an earlier version", async () => {
451
+ // The session was based on the current value (v2) and restored v1 on
452
+ // purpose. Its hash is in the history, and that must not matter.
453
+ const agent = await updateAgentProfile(
454
+ casAgentId,
455
+ { claudeMd: v1 },
456
+ { changeSource: "session_sync" },
457
+ { expectedHashes: { claudeMd: sha256(v2) } },
458
+ );
459
+
460
+ expect(agent!.claudeMd).toBe(v1);
461
+ const latest = await getLatestContextVersion(casAgentId, "claudeMd");
462
+ expect(latest!.version).toBe(3);
463
+ expect(latest!.content).toBe(v1);
464
+ expect(latest!.changeSource).toBe("session_sync");
465
+ });
466
+
467
+ test("the drop is per field: other fields in the same update land", async () => {
468
+ const soul = "s".repeat(600);
469
+ const agent = await updateAgentProfile(
470
+ casAgentId,
471
+ { claudeMd: v3, soulMd: soul },
472
+ { changeSource: "session_sync" },
473
+ { expectedHashes: { claudeMd: sha256(v2) } }, // stale: the DB is at v1 now
474
+ );
475
+
476
+ expect(agent!.claudeMd).toBe(v1);
477
+ expect(agent!.soulMd).toBe(soul);
478
+ expect((await getLatestContextVersion(casAgentId, "claudeMd"))!.version).toBe(3);
479
+ expect((await getLatestContextVersion(casAgentId, "soulMd"))!.version).toBe(1);
480
+ });
481
+
482
+ test("without expectedHashes the write stays unconditional", async () => {
483
+ const agent = await updateAgentProfile(
484
+ casAgentId,
485
+ { claudeMd: v2 },
486
+ { changeSource: "session_sync" },
487
+ );
488
+
489
+ expect(agent!.claudeMd).toBe(v2);
490
+ expect((await getLatestContextVersion(casAgentId, "claudeMd"))!.version).toBe(4);
491
+ });
492
+
493
+ test("a stale copy over the budget is dropped, not rejected", async () => {
494
+ // The profile has since been shortened under the budget. A stale copy of
495
+ // the old, oversized value must be dropped by the compare-and-set — not
496
+ // throw IdentityFieldBudgetError and roll back the other fields too.
497
+ const longAgentId = "cccc0000-0000-4000-8000-000000000005";
498
+ const oversized = "x".repeat(IDENTITY_FIELD_BUDGETS.claudeMd + 500);
499
+ const shortened = "# CLAUDE.md\n\nshortened below the budget";
500
+ await createAgent({
501
+ id: longAgentId,
502
+ name: "Long CAS Agent",
503
+ isLead: false,
504
+ status: "idle",
505
+ });
506
+ await updateAgentProfile(longAgentId, { claudeMd: shortened }, { changeSource: "self_edit" });
507
+
508
+ const soul = "t".repeat(600);
509
+ const agent = await updateAgentProfile(
510
+ longAgentId,
511
+ { claudeMd: oversized, soulMd: soul },
512
+ { changeSource: "session_sync" },
513
+ { expectedHashes: { claudeMd: sha256(oversized) } },
514
+ );
515
+
516
+ expect(agent!.claudeMd).toBe(shortened);
517
+ expect(agent!.soulMd).toBe(soul);
518
+ });
519
+ });
520
+
414
521
  // ============================================================================
415
522
  // Content hash consistency
416
523
  // ============================================================================
@@ -0,0 +1,309 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ spyOn,
9
+ test,
10
+ } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { closeDb, initDb } from "../be/db";
16
+ import { handlePageProxy } from "../http/page-proxy";
17
+ import { handlePages } from "../http/pages";
18
+ import { handlePagesPublic } from "../http/pages-public";
19
+ import {
20
+ isOriginAllowedForCredentials,
21
+ setCorsHeaders,
22
+ warnIfCorsAllowsAnyOrigin,
23
+ } from "../http/utils";
24
+ import { listenOnFreePort } from "./test-net";
25
+
26
+ const ENV_KEY = "CORS_ALLOWED_ORIGINS";
27
+ const originalEnv = process.env[ENV_KEY];
28
+ const originalOptOut = process.env.CORS_ALLOW_ANY_ORIGIN;
29
+ beforeEach(() => {
30
+ delete process.env.CORS_ALLOW_ANY_ORIGIN;
31
+ });
32
+
33
+ afterEach(() => {
34
+ if (originalOptOut === undefined) delete process.env.CORS_ALLOW_ANY_ORIGIN;
35
+ else process.env.CORS_ALLOW_ANY_ORIGIN = originalOptOut;
36
+ if (originalEnv === undefined) delete process.env[ENV_KEY];
37
+ else process.env[ENV_KEY] = originalEnv;
38
+ });
39
+
40
+ function fakeReq(origin: string | undefined): IncomingMessage {
41
+ return { headers: origin ? { origin } : {} } as unknown as IncomingMessage;
42
+ }
43
+
44
+ function fakeRes(): { res: ServerResponse; headers: Map<string, string> } {
45
+ const headers = new Map<string, string>();
46
+ const res = {
47
+ setHeader: (name: string, value: string) => {
48
+ headers.set(name.toLowerCase(), value);
49
+ },
50
+ } as unknown as ServerResponse;
51
+ return { res, headers };
52
+ }
53
+
54
+ describe("isOriginAllowedForCredentials", () => {
55
+ test("denies arbitrary origins when CORS_ALLOWED_ORIGINS is unset", () => {
56
+ delete process.env[ENV_KEY];
57
+ expect(isOriginAllowedForCredentials("https://evil.example")).toBe(false);
58
+ });
59
+
60
+ test("denies arbitrary origins when CORS_ALLOWED_ORIGINS is blank", () => {
61
+ process.env[ENV_KEY] = " ";
62
+ expect(isOriginAllowedForCredentials("https://evil.example")).toBe(false);
63
+ });
64
+
65
+ test("allows only listed origins when set", () => {
66
+ process.env[ENV_KEY] = "https://app.example.com, https://dashboard.example.com";
67
+ expect(isOriginAllowedForCredentials("https://app.example.com")).toBe(true);
68
+ expect(isOriginAllowedForCredentials("https://dashboard.example.com")).toBe(true);
69
+ expect(isOriginAllowedForCredentials("https://evil.example")).toBe(false);
70
+ });
71
+
72
+ test.each([
73
+ ["matches a subdomain", "https://app.agent-swarm.dev", true],
74
+ ["matches multiple subdomain labels", "https://a.b.agent-swarm.dev", true],
75
+ ["rejects suffix confusion", "https://evil-agent-swarm.dev", false],
76
+ ["rejects trailing-domain attacks", "https://app.agent-swarm.dev.evil.com", false],
77
+ ["rejects scheme mismatch", "http://app.agent-swarm.dev", false],
78
+ ["compares scheme exactly", "HTTPS://app.agent-swarm.dev", false],
79
+ ["rejects the apex", "https://agent-swarm.dev", false],
80
+ ["compares host case-insensitively", "https://APP.AGENT-SWARM.DEV", true],
81
+ ["rejects an unlisted port", "https://app.agent-swarm.dev:8443", false],
82
+ ["rejects empty labels", "https://.agent-swarm.dev", false],
83
+ ["rejects credentials", "https://evil.com@app.agent-swarm.dev", false],
84
+ ["rejects paths", "https://app.agent-swarm.dev/evil", false],
85
+ ])("wildcard %s", (_name, origin, allowed) => {
86
+ process.env[ENV_KEY] = "https://*.agent-swarm.dev";
87
+ expect(isOriginAllowedForCredentials(origin)).toBe(allowed);
88
+ });
89
+
90
+ test.each([
91
+ "*",
92
+ "https://*",
93
+ "https://*.*.agent-swarm.dev",
94
+ "https://app*.agent-swarm.dev",
95
+ ])("ignores invalid wildcard entry %s", (entry) => {
96
+ process.env[ENV_KEY] = entry;
97
+ expect(isOriginAllowedForCredentials("https://app.agent-swarm.dev")).toBe(false);
98
+ expect(isOriginAllowedForCredentials(entry)).toBe(false);
99
+ });
100
+
101
+ test("wildcard supports uppercase suffixes and requires the configured port", () => {
102
+ process.env[ENV_KEY] = "https://*.AGENT-SWARM.DEV:8443";
103
+ expect(isOriginAllowedForCredentials("https://app.agent-swarm.dev:8443")).toBe(true);
104
+ expect(isOriginAllowedForCredentials("https://app.agent-swarm.dev")).toBe(false);
105
+ expect(isOriginAllowedForCredentials("https://app.agent-swarm.dev:443")).toBe(false);
106
+ });
107
+
108
+ test("mixes wildcard and exact entries including a separately listed apex", () => {
109
+ process.env[ENV_KEY] =
110
+ "https://*.agent-swarm.dev,https://agent-swarm.dev,http://localhost:5274";
111
+ expect(isOriginAllowedForCredentials("https://app.agent-swarm.dev")).toBe(true);
112
+ expect(isOriginAllowedForCredentials("https://agent-swarm.dev")).toBe(true);
113
+ expect(isOriginAllowedForCredentials("http://localhost:5274")).toBe(true);
114
+ expect(isOriginAllowedForCredentials("http://localhost:5275")).toBe(false);
115
+ });
116
+
117
+ test("is an exact match, not a suffix/subdomain match", () => {
118
+ process.env[ENV_KEY] = "https://app.example.com";
119
+ expect(isOriginAllowedForCredentials("https://evil.app.example.com")).toBe(false);
120
+ expect(isOriginAllowedForCredentials("https://app.example.com.evil.example")).toBe(false);
121
+ });
122
+ });
123
+
124
+ describe("setCorsHeaders", () => {
125
+ test("opt-out reflects arbitrary origins without granting credentials", () => {
126
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
127
+ process.env.CORS_ALLOW_ANY_ORIGIN = "true";
128
+ process.env[ENV_KEY] = "https://trusted.example";
129
+ warnIfCorsAllowsAnyOrigin();
130
+ warnIfCorsAllowsAnyOrigin();
131
+ expect(warn).toHaveBeenCalledTimes(1);
132
+ expect(warn.mock.calls[0]![0]).toContain("CORS_ALLOW_ANY_ORIGIN=true");
133
+ warn.mockRestore();
134
+ const { res, headers } = fakeRes();
135
+ setCorsHeaders(fakeReq("https://evil.example"), res);
136
+ expect(headers.get("access-control-allow-origin")).toBe("https://evil.example");
137
+ expect(headers.has("access-control-allow-credentials")).toBe(false);
138
+ });
139
+
140
+ test("denies a non-allowlisted origin: no Allow-Origin, no Allow-Credentials", () => {
141
+ process.env[ENV_KEY] = "https://app.example.com";
142
+ const { res, headers } = fakeRes();
143
+ setCorsHeaders(fakeReq("https://evil.example"), res);
144
+ expect(headers.has("access-control-allow-origin")).toBe(false);
145
+ expect(headers.has("access-control-allow-credentials")).toBe(false);
146
+ // Vary: Origin still set so caches don't serve a denied response to an allowed origin.
147
+ expect(headers.get("vary")).toBe("Origin");
148
+ });
149
+
150
+ test("allows a listed origin with credentials", () => {
151
+ process.env[ENV_KEY] = "https://app.example.com";
152
+ const { res, headers } = fakeRes();
153
+ setCorsHeaders(fakeReq("https://app.example.com"), res);
154
+ expect(headers.get("access-control-allow-origin")).toBe("https://app.example.com");
155
+ expect(headers.get("access-control-allow-credentials")).toBe("true");
156
+ });
157
+
158
+ test("no-Origin requests still get wildcard regardless of allowlist", () => {
159
+ process.env[ENV_KEY] = "https://app.example.com";
160
+ const { res, headers } = fakeRes();
161
+ setCorsHeaders(fakeReq(undefined), res);
162
+ expect(headers.get("access-control-allow-origin")).toBe("*");
163
+ });
164
+ });
165
+
166
+ describe("secure defaults and reload", () => {
167
+ test.each([undefined, "", " "])("uses defaults for %j", (raw) => {
168
+ if (raw === undefined) delete process.env[ENV_KEY];
169
+ else process.env[ENV_KEY] = raw;
170
+ for (const origin of [
171
+ "https://app.agent-swarm.dev",
172
+ "https://a.b.agent-swarm.cloud",
173
+ "http://localhost:5274",
174
+ "http://127.0.0.1:5274",
175
+ "http://[::1]:5274",
176
+ "https://ui.swarm.localhost:1355",
177
+ ]) {
178
+ expect(isOriginAllowedForCredentials(origin)).toBe(true);
179
+ }
180
+ for (const origin of [
181
+ "https://evil.example",
182
+ "null",
183
+ "https://agent-swarm.dev",
184
+ "http://localhost:9999",
185
+ ]) {
186
+ const { res, headers } = fakeRes();
187
+ setCorsHeaders(fakeReq(origin), res);
188
+ expect(headers.has("access-control-allow-origin")).toBe(false);
189
+ expect(headers.has("access-control-allow-credentials")).toBe(false);
190
+ expect(headers.get("vary")).toBe("Origin");
191
+ }
192
+ });
193
+ test("custom lists replace defaults and opt-out changes are read dynamically", () => {
194
+ process.env[ENV_KEY] = "https://trusted.example";
195
+ expect(isOriginAllowedForCredentials("https://app.agent-swarm.dev")).toBe(false);
196
+ for (const value of ["true", "1", " TRUE "]) {
197
+ process.env.CORS_ALLOW_ANY_ORIGIN = value;
198
+ expect(isOriginAllowedForCredentials("https://evil.example")).toBe(false);
199
+ const { res, headers } = fakeRes();
200
+ setCorsHeaders(fakeReq("https://evil.example"), res);
201
+ expect(headers.get("access-control-allow-origin")).toBe("https://evil.example");
202
+ expect(headers.has("access-control-allow-credentials")).toBe(false);
203
+ }
204
+ for (const value of ["false", "0", "", "typo"]) {
205
+ process.env.CORS_ALLOW_ANY_ORIGIN = value;
206
+ expect(isOriginAllowedForCredentials("https://evil.example")).toBe(false);
207
+ }
208
+ process.env[ENV_KEY] = "https://evil.example";
209
+ expect(isOriginAllowedForCredentials("https://evil.example")).toBe(true);
210
+ });
211
+ });
212
+
213
+ describe("page-session CORS over HTTP", () => {
214
+ const originalPort = process.env.PORT;
215
+ const dir = mkdtempSync(join(tmpdir(), "cors-pages-"));
216
+ const server = createServer(async (req, res) => {
217
+ setCorsHeaders(req, res);
218
+ const url = new URL(req.url!, "http://localhost");
219
+ const segments = url.pathname.split("/").filter(Boolean);
220
+ if (req.method === "OPTIONS") {
221
+ res.writeHead(204).end();
222
+ return;
223
+ }
224
+ if (await handlePageProxy(req, res)) return;
225
+ if (
226
+ await handlePages(
227
+ req,
228
+ res,
229
+ segments,
230
+ url.searchParams,
231
+ "38d36438-58a0-45b5-8602-a5d52b07c2f1",
232
+ )
233
+ )
234
+ return;
235
+ if (await handlePagesPublic(req, res, segments, url.searchParams)) return;
236
+ res.writeHead(404).end();
237
+ });
238
+ let base: string;
239
+ let pageId: string;
240
+ beforeAll(async () => {
241
+ initDb(join(dir, "test.sqlite"));
242
+ const port = await listenOnFreePort(server);
243
+ process.env.PORT = String(port);
244
+ base = `http://localhost:${port}`;
245
+ const response = await fetch(`${base}/api/pages`, {
246
+ method: "POST",
247
+ headers: { "Content-Type": "application/json" },
248
+ body: JSON.stringify({
249
+ slug: "cors-session",
250
+ title: "CORS",
251
+ contentType: "text/html",
252
+ authMode: "authed",
253
+ body: "<h1>Private</h1>",
254
+ }),
255
+ });
256
+ expect(response.status).toBe(201);
257
+ pageId = ((await response.json()) as { id: string }).id;
258
+ });
259
+ afterAll(async () => {
260
+ await new Promise<void>((resolve) => server.close(() => resolve()));
261
+ closeDb();
262
+ if (originalPort === undefined) delete process.env.PORT;
263
+ else process.env.PORT = originalPort;
264
+ rmSync(dir, { recursive: true, force: true });
265
+ });
266
+ test.each([
267
+ undefined,
268
+ "",
269
+ " ",
270
+ "bypass",
271
+ ])("launch preflight, session cookie and page JSON use defaults for %j", async (raw) => {
272
+ const bypass = raw === "bypass";
273
+ if (bypass) process.env.CORS_ALLOW_ANY_ORIGIN = "true";
274
+ if (raw === undefined || bypass) delete process.env[ENV_KEY];
275
+ else process.env[ENV_KEY] = raw;
276
+ for (const origin of ["https://app.agent-swarm.dev", "https://evil.example"]) {
277
+ const allowed = origin.includes("agent-swarm.dev");
278
+ for (const method of ["OPTIONS", "POST"]) {
279
+ const response = await fetch(`${base}/api/pages/${pageId}/launch`, {
280
+ method,
281
+ headers: { Origin: origin },
282
+ });
283
+ expect(response.status).toBe(204);
284
+ expect(response.headers.get("access-control-allow-origin")).toBe(
285
+ allowed || bypass ? origin : null,
286
+ );
287
+ expect(response.headers.get("access-control-allow-credentials")).toBe(
288
+ allowed ? "true" : null,
289
+ );
290
+ if (method === "POST") {
291
+ const cookie = response.headers.get("set-cookie")!.split(";")[0]!;
292
+ for (const path of [`/p/${pageId}.json`, `/@swarm/api/pages/${pageId}`]) {
293
+ const page = await fetch(`${base}${path}`, {
294
+ headers: { Origin: origin, Cookie: cookie },
295
+ });
296
+ expect(page.status).toBe(200);
297
+ expect(page.headers.get("access-control-allow-origin")).toBe(
298
+ allowed || bypass ? origin : null,
299
+ );
300
+ expect(page.headers.get("access-control-allow-credentials")).toBe(
301
+ allowed ? "true" : null,
302
+ );
303
+ await page.text();
304
+ }
305
+ }
306
+ }
307
+ }
308
+ });
309
+ });
@@ -2,8 +2,11 @@ import { describe, expect, mock, test } from "bun:test";
2
2
  import {
3
3
  buildCredStatusReport,
4
4
  checkProviderCredentials,
5
+ isBedrockMode,
5
6
  isCredCheckDisabled,
6
7
  REQUIRED_CRED_VARS_BY_PROVIDER,
8
+ shouldRefreshBedrockStatus,
9
+ validateProviderCredentials,
7
10
  } from "../commands/provider-credentials";
8
11
  import { checkClaudeCredentials } from "../providers/claude-adapter";
9
12
  import { checkClaudeManagedCredentials } from "../providers/claude-managed-adapter";
@@ -410,21 +413,69 @@ describe("checkPiMonoCredentials", () => {
410
413
  expect(status.ready).toBe(false);
411
414
  });
412
415
 
413
- test("BEDROCK_AUTH_MODE=bearer: does NOT trigger the sdk probe (falls through)", async () => {
414
- // The bearer path is declared/validated but the full implementation is
415
- // not implemented yet. With no other credentials set it should be not-ready
416
- // via the standard permissive check, not via the sdk probe.
417
- const env = { BEDROCK_AUTH_MODE: "bearer" };
418
- // No other keys set, no auth.json → not-ready from the permissive path.
419
- const status = await checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles });
416
+ // ─── BEDROCK_AUTH_MODE=bearer: explicit Bedrock API key ─────────────────────
417
+
418
+ test("BEDROCK_AUTH_MODE=bearer: missing AWS_BEARER_TOKEN_BEDROCK → not ready, no probe, no fall-through", async () => {
419
+ let probed = false;
420
+ // A standard key must not satisfy the bearer mode: it does not apply to Bedrock.
421
+ const env = { BEDROCK_AUTH_MODE: "bearer", AWS_REGION: "us-east-1", ANTHROPIC_API_KEY: "x" };
422
+ const status = await checkPiMonoCredentials(env, {
423
+ homeDir: HOME,
424
+ fs: noFiles,
425
+ bedrockProbe: async () => {
426
+ probed = true;
427
+ },
428
+ });
420
429
  expect(status.ready).toBe(false);
421
- // Satisfying via any standard key still works for the bearer mode today.
422
- const withKey = await checkPiMonoCredentials(
423
- { BEDROCK_AUTH_MODE: "bearer", ANTHROPIC_API_KEY: "x" },
424
- { homeDir: HOME, fs: noFiles },
425
- );
426
- expect(withKey.ready).toBe(true);
427
- expect(withKey.satisfiedBy).toBe("env");
430
+ expect(status.missing).toEqual(["AWS_BEARER_TOKEN_BEDROCK"]);
431
+ expect(status.hint).toContain("AWS_BEARER_TOKEN_BEDROCK");
432
+ expect(probed).toBe(false);
433
+ });
434
+
435
+ test("BEDROCK_AUTH_MODE=bearer: token without AWS_REGION → not ready with the region hint", async () => {
436
+ const env = { BEDROCK_AUTH_MODE: "bearer", AWS_BEARER_TOKEN_BEDROCK: "bedrock-api-key" };
437
+ const status = await checkPiMonoCredentials(env, {
438
+ homeDir: HOME,
439
+ fs: noFiles,
440
+ bedrockProbe: bedrockProbeSuccess,
441
+ });
442
+ expect(status.ready).toBe(false);
443
+ expect(status.hint).toContain("AWS_REGION");
444
+ expect(status.bedrockRegion).toBe("");
445
+ });
446
+
447
+ test("BEDROCK_AUTH_MODE=bearer: token + region → probe runs, ready via env", async () => {
448
+ const env = {
449
+ BEDROCK_AUTH_MODE: "bearer",
450
+ AWS_BEARER_TOKEN_BEDROCK: "bedrock-api-key",
451
+ AWS_REGION: "eu-central-1",
452
+ };
453
+ const status = await checkPiMonoCredentials(env, {
454
+ homeDir: HOME,
455
+ fs: noFiles,
456
+ bedrockProbe: async () => [{ id: "anthropic.claude-sonnet", name: "Claude Sonnet" }],
457
+ });
458
+ expect(status.ready).toBe(true);
459
+ expect(status.satisfiedBy).toBe("env");
460
+ expect(status.bedrockRegion).toBe("eu-central-1");
461
+ expect(status.bedrockModels).toEqual([
462
+ { id: "anthropic.claude-sonnet", name: "Claude Sonnet" },
463
+ ]);
464
+ });
465
+
466
+ test("BEDROCK_AUTH_MODE=bearer: probe failure → ready:false with the classified hint", async () => {
467
+ const env = {
468
+ BEDROCK_AUTH_MODE: "bearer",
469
+ AWS_BEARER_TOKEN_BEDROCK: "bedrock-api-key",
470
+ AWS_REGION: "us-east-1",
471
+ };
472
+ const status = await checkPiMonoCredentials(env, {
473
+ homeDir: HOME,
474
+ fs: noFiles,
475
+ bedrockProbe: bedrockProbeAuthFail,
476
+ });
477
+ expect(status.ready).toBe(false);
478
+ expect(status.bedrockRegion).toBe("us-east-1");
428
479
  });
429
480
 
430
481
  test("BEDROCK_AUTH_MODE absent + no MODEL_OVERRIDE=amazon-bedrock: no probe", async () => {
@@ -689,6 +740,98 @@ describe("snapshot: every provider", () => {
689
740
 
690
741
  // ─── REQUIRED_CRED_VARS_BY_PROVIDER documentation map ────────────────────────
691
742
 
743
+ describe("isBedrockMode (shared gate for the live test and the refresh loop)", () => {
744
+ test("recognizes both explicit modes and the MODEL_OVERRIDE prefix inference", () => {
745
+ expect(isBedrockMode({ BEDROCK_AUTH_MODE: "sdk" })).toBe(true);
746
+ expect(isBedrockMode({ BEDROCK_AUTH_MODE: "bearer" })).toBe(true);
747
+ expect(isBedrockMode({ BEDROCK_AUTH_MODE: "Bearer", MODEL_OVERRIDE: "some-model" })).toBe(true);
748
+ expect(isBedrockMode({ MODEL_OVERRIDE: "amazon-bedrock/anthropic.claude" })).toBe(true);
749
+ expect(isBedrockMode({ MODEL_OVERRIDE: "anthropic/claude" })).toBe(false);
750
+ expect(isBedrockMode({})).toBe(false);
751
+ });
752
+ });
753
+
754
+ describe("shouldRefreshBedrockStatus (runner post-task refresh gate)", () => {
755
+ const INTERVAL = 5 * 60 * 1000;
756
+ const base = { harnessProvider: "pi", lastRefreshAt: 1_000, intervalMs: INTERVAL };
757
+
758
+ test("bearer-only pi configuration is refreshed once the interval has elapsed", () => {
759
+ const env = {
760
+ BEDROCK_AUTH_MODE: "bearer",
761
+ AWS_BEARER_TOKEN_BEDROCK: "tok",
762
+ AWS_REGION: "us-east-1",
763
+ };
764
+ expect(shouldRefreshBedrockStatus({ ...base, env, now: 1_000 + INTERVAL + 1 })).toBe(true);
765
+ });
766
+
767
+ test("bearer mode stays throttled inside the interval", () => {
768
+ const env = {
769
+ BEDROCK_AUTH_MODE: "bearer",
770
+ AWS_BEARER_TOKEN_BEDROCK: "tok",
771
+ AWS_REGION: "us-east-1",
772
+ };
773
+ expect(shouldRefreshBedrockStatus({ ...base, env, now: 1_000 + INTERVAL })).toBe(false);
774
+ expect(shouldRefreshBedrockStatus({ ...base, env, now: 1_000 + 1 })).toBe(false);
775
+ });
776
+
777
+ test("sdk mode and the amazon-bedrock/ prefix inference refresh the same way", () => {
778
+ const now = 1_000 + INTERVAL + 1;
779
+ expect(shouldRefreshBedrockStatus({ ...base, env: { BEDROCK_AUTH_MODE: "sdk" }, now })).toBe(
780
+ true,
781
+ );
782
+ expect(
783
+ shouldRefreshBedrockStatus({
784
+ ...base,
785
+ env: { MODEL_OVERRIDE: "amazon-bedrock/anthropic.claude" },
786
+ now,
787
+ }),
788
+ ).toBe(true);
789
+ });
790
+
791
+ test("never fires outside Bedrock mode or for a non-pi harness", () => {
792
+ const now = 1_000 + INTERVAL + 1;
793
+ expect(shouldRefreshBedrockStatus({ ...base, env: { ANTHROPIC_API_KEY: "sk" }, now })).toBe(
794
+ false,
795
+ );
796
+ expect(
797
+ shouldRefreshBedrockStatus({ ...base, env: { MODEL_OVERRIDE: "anthropic/claude" }, now }),
798
+ ).toBe(false);
799
+ expect(
800
+ shouldRefreshBedrockStatus({
801
+ ...base,
802
+ harnessProvider: "claude",
803
+ env: { BEDROCK_AUTH_MODE: "bearer" },
804
+ now,
805
+ }),
806
+ ).toBe(false);
807
+ });
808
+ });
809
+
810
+ describe("validateProviderCredentials: pi Bedrock pass-through", () => {
811
+ const saved = { ...process.env };
812
+ const restore = () => {
813
+ for (const key of Object.keys(process.env)) if (!(key in saved)) delete process.env[key];
814
+ Object.assign(process.env, saved);
815
+ };
816
+
817
+ test("bearer mode is a pass-through like sdk mode: no provider-key live test is issued", async () => {
818
+ const realFetch = globalThis.fetch;
819
+ // A stray provider key must not be live-tested: the Bedrock enumeration already ran.
820
+ process.env.BEDROCK_AUTH_MODE = "bearer";
821
+ process.env.AWS_BEARER_TOKEN_BEDROCK = "bedrock-api-key";
822
+ process.env.ANTHROPIC_API_KEY = "x";
823
+ globalThis.fetch = (async () => {
824
+ throw new Error("live test must not reach the network in Bedrock mode");
825
+ }) as typeof fetch;
826
+ try {
827
+ expect(await validateProviderCredentials("pi")).toEqual({ ok: true, latency_ms: 0 });
828
+ } finally {
829
+ globalThis.fetch = realFetch;
830
+ restore();
831
+ }
832
+ });
833
+ });
834
+
692
835
  describe("REQUIRED_CRED_VARS_BY_PROVIDER", () => {
693
836
  test("covers every supported provider", () => {
694
837
  const providers = ["claude", "claude-managed", "codex", "devin", "opencode", "pi"] as const;