@desplega.ai/agent-swarm 2.7.6 → 2.7.7

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 (244) hide show
  1. package/dist/{acp-adapter-bp91d37f.js → acp-adapter-7b4vk3h5.js} +5 -4
  2. package/dist/{actions-yj0eeaqh.js → actions-3tt153af.js} +18 -18
  3. package/dist/{amp-adapter-4pg9h34q.js → amp-adapter-njpts0yr.js} +1 -1
  4. package/dist/{anthropic-messages-x4q6rfq5.js → anthropic-messages-xey2skwx.js} +7 -7
  5. package/dist/{app-909e97cr.js → app-nky4378f.js} +3 -3
  6. package/dist/{assistant-qdyb5fyy.js → assistant-jnb669pa.js} +19 -19
  7. package/dist/{azure-openai-responses-s1w5n70z.js → azure-openai-responses-26t7j9ra.js} +5 -5
  8. package/dist/{boot-reembed-fk0s9z18.js → boot-reembed-9mgvaztt.js} +1 -1
  9. package/dist/{boot-reembed-gs1rzrnh.js → boot-reembed-qs2djdej.js} +1 -1
  10. package/dist/{boot-scrub-sweep-4qfd4kfx.js → boot-scrub-sweep-yjpdj7s1.js} +3 -3
  11. package/dist/{boot-seeds-yr00k13d.js → boot-seeds-5cm6ambd.js} +4 -4
  12. package/dist/{boxd-vdmfssd9.js → boxd-nv507va8.js} +1 -1
  13. package/dist/{claude-adapter-qta1f1xn.js → claude-adapter-fn287zmm.js} +5 -4
  14. package/dist/{claude-managed-setup-sc6j4dm0.js → claude-managed-setup-wtdz2mj2.js} +1 -1
  15. package/dist/{claude-sdk-session-zgrz6yvc.js → claude-sdk-session-x91kqyhm.js} +8288 -8224
  16. package/dist/{cli-wp9sp1qb.js → cli-08bmz9z9.js} +1 -1
  17. package/dist/{cli-yy3rhsdw.js → cli-1c6tzt8g.js} +7 -7
  18. package/dist/{cli-q0k1w5zd.js → cli-35rsp8ye.js} +1 -1
  19. package/dist/{cli-xnrwazav.js → cli-3fg0bdcv.js} +1 -1
  20. package/dist/{cli-89knwags.js → cli-5b456bpf.js} +2 -2
  21. package/dist/{cli-drr5v6av.js → cli-5jnhv72d.js} +1 -1
  22. package/dist/{cli-dxae2yg7.js → cli-5zx699j9.js} +2 -2
  23. package/dist/{cli-0at8w34w.js → cli-6g0hb4cp.js} +8 -8
  24. package/dist/{cli-e3r2neck.js → cli-6pfd54vx.js} +5 -5
  25. package/dist/{cli-bsn8nhrq.js → cli-6vcp76qk.js} +3 -3
  26. package/dist/{cli-ekwks2vs.js → cli-72v799xn.js} +1 -1
  27. package/dist/cli-82j36f2m.js +45 -0
  28. package/dist/{cli-yx4kfngt.js → cli-8305gbq0.js} +29 -16
  29. package/dist/{cli-4necwtsv.js → cli-8sckt74b.js} +8 -8
  30. package/dist/{cli-vvpx3gsx.js → cli-95jzxcp3.js} +1 -1
  31. package/dist/{cli-hr2e2fft.js → cli-adeegh7d.js} +7 -7
  32. package/dist/{cli-m48g6p95.js → cli-afkqkrtw.js} +3 -3
  33. package/dist/{cli-0qatpy8q.js → cli-ayayvfg4.js} +24 -4
  34. package/dist/{cli-bqt1mptt.js → cli-az48n0nr.js} +4 -4
  35. package/dist/{cli-zm72anhg.js → cli-bfv7vdg6.js} +9 -9
  36. package/dist/{cli-hgaazk69.js → cli-caj33vcq.js} +5 -5
  37. package/dist/{cli-3ypk9vk1.js → cli-cb2a8pna.js} +1 -1
  38. package/dist/{cli-waqbxg08.js → cli-cppkf404.js} +17 -17
  39. package/dist/{cli-yn4kxs06.js → cli-ddaanqkd.js} +4 -4
  40. package/dist/{cli-386tyxxg.js → cli-e0kmphd6.js} +2 -2
  41. package/dist/{cli-54t00gt8.js → cli-e46hg0xw.js} +1 -1
  42. package/dist/{cli-jm2vfpfd.js → cli-e5bzrn01.js} +1 -1
  43. package/dist/{cli-436532ck.js → cli-excnnjg2.js} +165 -153
  44. package/dist/{cli-71ptav7x.js → cli-ferkkrg9.js} +1 -1
  45. package/dist/{cli-vpf89f34.js → cli-fmjd0t1y.js} +3 -3
  46. package/dist/{cli-pd7vagab.js → cli-fyt2g4wd.js} +3 -3
  47. package/dist/{cli-gdhpcbhf.js → cli-g08zdaec.js} +1 -1
  48. package/dist/{cli-ehq736cn.js → cli-g7s47cc5.js} +4 -4
  49. package/dist/{cli-svj5090z.js → cli-gz6q4eve.js} +3 -3
  50. package/dist/{cli-8snvgs5r.js → cli-je518txm.js} +10 -10
  51. package/dist/{cli-rmmmrjkd.js → cli-jtjtabxe.js} +1 -1
  52. package/dist/{cli-611a1294.js → cli-k0wwfqbg.js} +18 -1
  53. package/dist/{cli-es6va8jk.js → cli-k1nje3v3.js} +38 -43
  54. package/dist/{cli-p5gp8pcx.js → cli-k8196sh2.js} +6 -6
  55. package/dist/{cli-nwj0ee2w.js → cli-k899ehvh.js} +2 -2
  56. package/dist/{cli-decfb6v8.js → cli-m0da8j35.js} +23 -9
  57. package/dist/{cli-he9wchme.js → cli-mkk9rnzz.js} +4 -4
  58. package/dist/{cli-nhrvpaj7.js → cli-n6bv12jn.js} +510 -4
  59. package/dist/{cli-8bc1w0tw.js → cli-nmzhp1b5.js} +14 -12
  60. package/dist/{cli-0yv5z2pg.js → cli-p82cnnjq.js} +1 -1
  61. package/dist/{cli-c60vetmm.js → cli-pc6n7fre.js} +1 -1
  62. package/dist/{cli-xds5f77d.js → cli-pjctyea3.js} +2 -2
  63. package/dist/{cli-xbbndvjy.js → cli-pq73h5mz.js} +3 -3
  64. package/dist/{cli-aa6yxc7m.js → cli-pz4rk021.js} +1 -1
  65. package/dist/{cli-xnqgpshy.js → cli-q3tkegby.js} +1 -1
  66. package/dist/cli-qghe0h66.js +67 -0
  67. package/dist/{cli-zbbwez0r.js → cli-qh4d4tvk.js} +4 -4
  68. package/dist/{cli-w6dt9ged.js → cli-rtt8g4v9.js} +1 -1
  69. package/dist/{cli-1h3wcpbw.js → cli-snpj5zbj.js} +2 -2
  70. package/dist/{cli-a7v854rf.js → cli-tgrv9cjx.js} +263 -12
  71. package/dist/{cli-faxe6thg.js → cli-vz22fyqv.js} +186 -54
  72. package/dist/{cli-k2gzwsj2.js → cli-wk5yrfwd.js} +160 -26
  73. package/dist/{cli-shpbx5ee.js → cli-ws19768n.js} +1641 -49
  74. package/dist/{cli-70r77n04.js → cli-xae4g51r.js} +1 -1
  75. package/dist/{cli-kqdg1bsb.js → cli-yndmfxb3.js} +2 -2
  76. package/dist/{cli-bjb3z51z.js → cli-yyj3j5m4.js} +2 -2
  77. package/dist/cli.js +21 -21
  78. package/dist/{cloudflare-workers-ai-system-one-bm60fjm0.js → cloudflare-workers-ai-system-one-ef2a980q.js} +2 -2
  79. package/dist/{codex-adapter-ayd1fb6h.js → codex-adapter-cf2yxe9m.js} +3 -2
  80. package/dist/{codex-hook-9efm2x1m.js → codex-hook-a0c6g6t6.js} +1 -1
  81. package/dist/{codex-session-runner-jesa32xc.js → codex-session-runner-6vfzdgbd.js} +3 -2
  82. package/dist/{commands-hanewgf2.js → commands-t3mfy7vn.js} +3 -3
  83. package/dist/{controller-dt1n3c1q.js → controller-q7xrbaz0.js} +3 -3
  84. package/dist/{controllers-connect-xx8x64n5.js → controllers-connect-c6nm70kg.js} +19 -19
  85. package/dist/{cursor-adapter-9s4dwwdw.js → cursor-adapter-ftd81ga8.js} +1 -1
  86. package/dist/{db-3vttcbv4.js → db-dk572x25.js} +3 -3
  87. package/dist/{db-9dv0g1ab.js → db-vemtpmcc.js} +4 -2
  88. package/dist/{dispatcher-tx9hd1sv.js → dispatcher-kh7ecxh9.js} +17 -17
  89. package/dist/{dsh-adapter-007y4ejs.js → dsh-adapter-v8628r89.js} +1 -1
  90. package/dist/{e2b-we6hs6q5.js → e2b-fr12yccx.js} +1 -1
  91. package/dist/{e2b-a19d28rz.js → e2b-x6ywzak3.js} +1 -1
  92. package/dist/{easter-egg-3d-rgbrqb8s.js → easter-egg-3d-nenw4x63.js} +1 -1
  93. package/dist/{embedding-space-ny1e2940.js → embedding-space-0jepb239.js} +6 -6
  94. package/dist/{embeddings-2t386t13.js → embeddings-xxt49yxg.js} +1 -1
  95. package/dist/{execute-pksh0ceh.js → execute-1mhw6e4x.js} +1 -1
  96. package/dist/{external-channel-phgmhmd7.js → external-channel-55v12e60.js} +2 -2
  97. package/dist/{fx-adapter-fy8rr921.js → fx-adapter-bs8wqrk5.js} +6 -5
  98. package/dist/{google-generative-ai-8y3m3k4b.js → google-generative-ai-v94hr7h0.js} +5 -5
  99. package/dist/{google-vertex-w3v98n7b.js → google-vertex-g7vr6cf8.js} +5 -5
  100. package/dist/{grok-adapter-q0abvpaj.js → grok-adapter-gpjqmkcq.js} +6 -5
  101. package/dist/handlers-zxmp1ta4.js +32 -0
  102. package/dist/{heartbeat-kadyek8p.js → heartbeat-k42svnr0.js} +20 -20
  103. package/dist/{hook-9k551xnj.js → hook-114m7hap.js} +4 -3
  104. package/dist/{hook-5zs3msf6.js → hook-6nphweh0.js} +24 -9
  105. package/dist/{http-mhz9vw1f.js → http-r8cc4wz7.js} +1025 -173
  106. package/dist/index-a8phxqnk.js +37 -0
  107. package/dist/{index-vdygbq10.js → index-e3s8wmxy.js} +1 -1
  108. package/dist/{index-q361cg2f.js → index-fpk4bcd9.js} +1 -1
  109. package/dist/index-k6h00t4c.js +30 -0
  110. package/dist/{index-rvr00sg7.js → index-nh3wyqdt.js} +1017 -574
  111. package/dist/index-xrz7te8h.js +33 -0
  112. package/dist/{keepalive-w56f32cg.js → keepalive-x060nt9p.js} +3 -3
  113. package/dist/lead-956sd2yc.js +71 -0
  114. package/dist/{maintenance-7mdgxcea.js → maintenance-2xxfexhf.js} +1 -1
  115. package/dist/{mistral-conversations-4pmwj1c0.js → mistral-conversations-3gjpdb4b.js} +4 -4
  116. package/dist/{oauth-refresh-sweep-29txy9vk.js → oauth-refresh-sweep-fckc4ar5.js} +2 -2
  117. package/dist/{onboard-kpsrkarj.js → onboard-zc48j237.js} +3 -3
  118. package/dist/{openai-codex-responses-44j573nm.js → openai-codex-responses-8pbxq9h5.js} +5 -5
  119. package/dist/{openai-completions-a4gee2a8.js → openai-completions-rm00s912.js} +4 -4
  120. package/dist/{openai-decisions-w4qkrcar.js → openai-decisions-8v957r2r.js} +1 -1
  121. package/dist/{openai-responses-rbp6h5kr.js → openai-responses-agxpf96k.js} +5 -5
  122. package/dist/{opencode-adapter-j25vm3rr.js → opencode-adapter-r690h1cr.js} +4 -3
  123. package/dist/{otel-impl-6kkct7a8.js → otel-impl-r5xbh81q.js} +4 -4
  124. package/dist/{pi-mono-adapter-exhkzhn2.js → pi-mono-adapter-c7v8sdec.js} +7 -6
  125. package/dist/{pricing-refresh-n2x7jh6k.js → pricing-refresh-3pmre11v.js} +3 -3
  126. package/dist/{rbac-roles-81ypx6es.js → rbac-roles-3vbshwsb.js} +1 -1
  127. package/dist/{rbac-roles-b5htkjby.js → rbac-roles-910mmkgz.js} +2 -2
  128. package/dist/{render-v2-qn6jmpk4.js → render-v2-pxykkbab.js} +3 -3
  129. package/dist/{resume-wd30wx23.js → resume-adda0bwy.js} +6 -6
  130. package/dist/{runtime-4hsg7h60.js → runtime-fnbm7stk.js} +1 -1
  131. package/dist/{session-access-5qvfevhk.js → session-access-2fvamrdc.js} +4 -4
  132. package/dist/{session-source-backfill-n1czavbr.js → session-source-backfill-08xvv0sm.js} +2 -2
  133. package/dist/{setup-dnb56qy5.js → setup-1aqmvmh1.js} +1 -1
  134. package/dist/{typesafe-system-one-5mavm259.js → typesafe-system-one-0bmqcxfg.js} +2 -2
  135. package/dist/{virtual-modules-76xxv9fs.js → virtual-modules-d2pjk9j1.js} +5 -5
  136. package/dist/{work-objects-4n21tfkm.js → work-objects-9s0yrm5j.js} +20 -20
  137. package/dist/{worker-follow-up-77hvzxww.js → worker-follow-up-3q78fqmr.js} +17 -17
  138. package/dist/worker-y15hy08h.js +71 -0
  139. package/openapi.json +197 -4
  140. package/package.json +5 -5
  141. package/src/apps/definition.ts +6 -5
  142. package/src/be/db/page-theme-tokens.ts +12 -0
  143. package/src/be/db/tasks/read.ts +9 -1
  144. package/src/be/db/tasks/write.ts +19 -0
  145. package/src/be/db-client.ts +107 -7
  146. package/src/be/db.ts +34 -2
  147. package/src/be/migrations/269_pages_design_system.sql +23 -0
  148. package/src/be/seed/schedules-seeder.ts +0 -10
  149. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +2 -0
  150. package/src/be/seed-scripts/catalog/catalog-report.ts +2 -0
  151. package/src/be/seed-scripts/catalog/memory-eval.ts +2 -0
  152. package/src/be/seed-scripts/catalog/swarm-update-check.ts +150 -0
  153. package/src/be/seed-scripts/index.ts +9 -0
  154. package/src/be/seed-skills/bundled-files.generated.json +3 -3
  155. package/src/be/swarm-config-guard.ts +32 -0
  156. package/src/be/task-auto-title-listener.ts +127 -0
  157. package/src/commands/onboard/agent-fs-version.ts +1 -1
  158. package/src/commands/profile-sync.ts +151 -16
  159. package/src/commands/runner.ts +15 -7
  160. package/src/hooks/hook.ts +20 -9
  161. package/src/http/index.ts +3 -0
  162. package/src/http/pages-public.ts +93 -129
  163. package/src/http/pages.ts +94 -21
  164. package/src/pages/browser-sdk.ts +48 -21
  165. package/src/pages/color-literals.ts +115 -0
  166. package/src/pages/page-head.ts +766 -0
  167. package/src/pages/version.ts +3 -0
  168. package/src/providers/amp-adapter.ts +1 -1
  169. package/src/providers/codex-adapter.ts +1 -0
  170. package/src/providers/codex-app-server.ts +27 -1
  171. package/src/providers/pi-mono-extension.ts +30 -15
  172. package/src/tasks/follow-up-gate.ts +318 -0
  173. package/src/tasks/worker-follow-up.ts +21 -1
  174. package/src/tests/codex-app-server.test.ts +59 -0
  175. package/src/tests/codex-gateway-route.test.ts +50 -1
  176. package/src/tests/create-page-tool.test.ts +501 -0
  177. package/src/tests/db-client-busy-retry.test.ts +81 -0
  178. package/src/tests/db-task-read-contract.test.ts +1 -0
  179. package/src/tests/db-task-write-contract.test.ts +2 -0
  180. package/src/tests/db-tasks-read.test.ts +21 -0
  181. package/src/tests/fixtures/pages/head-v1.html +56 -0
  182. package/src/tests/follow-up-gate-shadow.test.ts +513 -0
  183. package/src/tests/identity-field-budget.test.ts +1 -1
  184. package/src/tests/internal-ai/credentials.test.ts +62 -0
  185. package/src/tests/internal-ai/task-title.test.ts +283 -0
  186. package/src/tests/merge-gate-detect-changes.test.ts +145 -0
  187. package/src/tests/page-proxy.test.ts +1 -1
  188. package/src/tests/page-session-dev-cookie.test.ts +188 -0
  189. package/src/tests/pages-frame-ancestors.test.ts +28 -1
  190. package/src/tests/pages-head-v2.test.ts +534 -0
  191. package/src/tests/pages-http.test.ts +330 -0
  192. package/src/tests/pages-public-html.test.ts +43 -0
  193. package/src/tests/pages-storage.test.ts +182 -0
  194. package/src/tests/pages-versioning.test.ts +72 -0
  195. package/src/tests/profile-sync-stale-writeback.test.ts +334 -0
  196. package/src/tests/profile-sync.test.ts +11 -15
  197. package/src/tests/scheduled-task-required-params-migration.test.ts +3 -1
  198. package/src/tests/script-connections.test.ts +2 -0
  199. package/src/tests/seed-registry.test.ts +28 -6
  200. package/src/tests/seed-schedules.test.ts +61 -8
  201. package/src/tests/seed-scripts.test.ts +142 -2
  202. package/src/tests/swarm-diff.test.ts +121 -2
  203. package/src/tests/task-auto-title-config-guard.test.ts +37 -0
  204. package/src/tests/task-auto-title-listener.test.ts +165 -0
  205. package/src/tests/theme-import.test.ts +758 -0
  206. package/src/tests/themes-page-theme.test.ts +155 -0
  207. package/src/tests/themes-presets.test.ts +172 -0
  208. package/src/tests/workflow-script-principal.test.ts +2 -1
  209. package/src/themes/classics.ts +949 -0
  210. package/src/themes/import.ts +420 -0
  211. package/src/themes/page-fields.ts +40 -0
  212. package/src/themes/page-theme.ts +102 -0
  213. package/src/themes/presets.ts +561 -0
  214. package/src/themes/tokens.ts +218 -0
  215. package/src/tools/app-upsert.ts +1 -1
  216. package/src/tools/create-page.ts +118 -12
  217. package/src/tools/utils.ts +33 -0
  218. package/src/types.ts +45 -3
  219. package/src/utils/constants.ts +19 -0
  220. package/src/utils/follow-up-gate-schedule-ids.ts +13 -0
  221. package/src/utils/internal-ai/credentials.ts +146 -93
  222. package/src/utils/internal-ai/index.ts +10 -0
  223. package/src/utils/internal-ai/models.ts +18 -0
  224. package/src/utils/internal-ai/task-title.ts +282 -0
  225. package/src/utils/page-session.ts +37 -0
  226. package/templates/schedules/daily-status-report/content.md +88 -2
  227. package/templates/schedules/daily-swarm-update-check/content.md +16 -40
  228. package/templates/skills/apps/SKILL.md +1 -1
  229. package/templates/skills/apps/content.md +1 -1
  230. package/templates/skills/heartbeat-runbook/content.md +1 -1
  231. package/templates/skills/pages/config.json +1 -1
  232. package/templates/skills/pages/content.md +539 -86
  233. package/templates/skills/pages/files/examples/annotated-pr.html +21 -12
  234. package/templates/skills/pages/files/examples/multiplayer-board.html +41 -41
  235. package/templates/skills/pages/files/examples/report-page.html +125 -124
  236. package/dist/cli-2pnswgh8.js +0 -189
  237. package/dist/handlers-by3sfxyx.js +0 -32
  238. package/dist/index-4f2pvxae.js +0 -37
  239. package/dist/index-98gz7tmp.js +0 -30
  240. package/dist/index-k08z2cak.js +0 -33
  241. package/dist/lead-enq35nqp.js +0 -70
  242. package/dist/worker-gg1576j3.js +0 -70
  243. package/templates/schedules/daily-workflow-health-audit/config.json +0 -19
  244. package/templates/schedules/daily-workflow-health-audit/content.md +0 -191
@@ -0,0 +1,283 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { completeStructuredWithModel } from "../../utils/internal-ai/complete-structured.js";
3
+ import type {
4
+ CredentialKind,
5
+ ResolvedCredential,
6
+ resolveCredentials,
7
+ } from "../../utils/internal-ai/credentials.js";
8
+ import {
9
+ buildTaskTitlePlan,
10
+ generateTaskTitle,
11
+ kindForModelString,
12
+ normalizeTaskTitle,
13
+ prepareTaskTextForTitle,
14
+ TASK_TITLE_MAX_CHARS,
15
+ TASK_TITLE_MAX_TASK_CHARS,
16
+ TASK_TITLE_MODEL_CANDIDATES,
17
+ TASK_TITLE_MODEL_ENV,
18
+ } from "../../utils/internal-ai/task-title.js";
19
+
20
+ const LONG_TASK =
21
+ "Investigate why the nightly Playwright suite times out on the sessions page after the sidebar redesign and fix the flake.";
22
+
23
+ function credential(kind: CredentialKind): ResolvedCredential {
24
+ return kind === "claude-cli"
25
+ ? { kind, modelDefault: "haiku" }
26
+ : { kind, apiKey: `key-${kind}`, modelDefault: `${kind}/default` };
27
+ }
28
+
29
+ /** `resolveCredentials` stub that offers exactly `kinds` and records every lookup. */
30
+ function resolver(kinds: CredentialKind[], calls: CredentialKind[][] = []) {
31
+ const resolve: typeof resolveCredentials = async (opts) => {
32
+ const wanted = [...(opts?.kinds ?? [])];
33
+ calls.push(wanted);
34
+ return wanted.filter((kind) => kinds.includes(kind)).map(credential);
35
+ };
36
+ return { resolve, calls };
37
+ }
38
+
39
+ type CompleteFn = typeof completeStructuredWithModel;
40
+
41
+ /** `completeStructuredWithModel` stub keyed by model string; absent = failure (null). */
42
+ function completer(answers: Record<string, string | Error>, models: string[] = []) {
43
+ const complete: CompleteFn = async (opts) => {
44
+ const model = opts._credentialOverride?.modelDefault ?? "<none>";
45
+ models.push(model);
46
+ const answer = answers[model];
47
+ if (answer instanceof Error) throw answer;
48
+ if (answer === undefined) return null;
49
+ return { data: { title: answer }, model };
50
+ };
51
+ return { complete, models };
52
+ }
53
+
54
+ describe("normalizeTaskTitle", () => {
55
+ test("collapses whitespace, strips quotes, prefix, markdown and trailing punctuation", () => {
56
+ expect(normalizeTaskTitle(' "Fix sessions sidebar flake." ')).toBe(
57
+ "Fix sessions sidebar flake",
58
+ );
59
+ expect(normalizeTaskTitle("Title: **Rotate Slack token**")).toBe("Rotate Slack token");
60
+ expect(normalizeTaskTitle("“Add retry to poller”…")).toBe("Add retry to poller");
61
+ expect(normalizeTaskTitle("`Bump bun to 1.4`")).toBe("Bump bun to 1.4");
62
+ });
63
+
64
+ test("keeps question marks and inner punctuation", () => {
65
+ expect(normalizeTaskTitle("Why does the poller stall?")).toBe("Why does the poller stall?");
66
+ expect(normalizeTaskTitle("Migrate agent-fs 0.16.3 pins")).toBe("Migrate agent-fs 0.16.3 pins");
67
+ });
68
+
69
+ test("cuts long titles at a word boundary under the limit", () => {
70
+ const words = Array.from({ length: 40 }, (_, i) => `word${i}`).join(" ");
71
+ const title = normalizeTaskTitle(words);
72
+ expect(title.length).toBeLessThanOrEqual(TASK_TITLE_MAX_CHARS);
73
+ expect(title.endsWith("word")).toBe(false);
74
+ expect(words.startsWith(title)).toBe(true);
75
+ });
76
+
77
+ test("returns an empty string when nothing usable remains", () => {
78
+ expect(normalizeTaskTitle('""')).toBe("");
79
+ expect(normalizeTaskTitle(" ... ")).toBe("");
80
+ });
81
+ });
82
+
83
+ describe("prepareTaskTextForTitle", () => {
84
+ test("drops the sibling-awareness block and collapses blank lines", () => {
85
+ const task = `<sibling_tasks_in_progress>\n- other\n</sibling_tasks_in_progress>\n\n\n\nFix the login bug\n\n\n\nin the auth module`;
86
+ expect(prepareTaskTextForTitle(task)).toBe("Fix the login bug\n\nin the auth module");
87
+ });
88
+
89
+ test("keeps a prompt without the block and truncates long prompts", () => {
90
+ expect(prepareTaskTextForTitle(" hello\r\nworld ")).toBe("hello\nworld");
91
+ const long = "x".repeat(TASK_TITLE_MAX_TASK_CHARS + 500);
92
+ const prepared = prepareTaskTextForTitle(long);
93
+ expect(prepared.length).toBe(TASK_TITLE_MAX_TASK_CHARS + 2);
94
+ expect(prepared.endsWith(" …")).toBe(true);
95
+ });
96
+ });
97
+
98
+ describe("buildTaskTitlePlan", () => {
99
+ test("default plan is the candidate table", () => {
100
+ expect(buildTaskTitlePlan({})).toBe(TASK_TITLE_MODEL_CANDIDATES);
101
+ });
102
+
103
+ test("a pinned model moves its provider first and the model to the front", () => {
104
+ const plan = buildTaskTitlePlan({ [TASK_TITLE_MODEL_ENV]: "openai/gpt-5-nano" });
105
+ expect(plan[0]).toEqual({ kind: "openai", models: ["openai/gpt-5-nano", "openai/gpt-6-luna"] });
106
+ expect(plan.slice(1).map((group) => group.kind)).toEqual([
107
+ "openrouter",
108
+ "anthropic",
109
+ "openai-codex",
110
+ "claude-cli",
111
+ ]);
112
+ });
113
+
114
+ test("a pinned model outside the table is still tried first", () => {
115
+ const plan = buildTaskTitlePlan({ [TASK_TITLE_MODEL_ENV]: "anthropic/claude-haiku-4-5" });
116
+ expect(plan[0]?.models).toEqual(["anthropic/claude-haiku-4-5", "anthropic/claude-haiku-5-5"]);
117
+ });
118
+
119
+ test("a bare alias pins the claude CLI; an unsupported provider is ignored", () => {
120
+ expect(buildTaskTitlePlan({ [TASK_TITLE_MODEL_ENV]: "sonnet" })[0]).toEqual({
121
+ kind: "claude-cli",
122
+ models: ["sonnet", "haiku"],
123
+ });
124
+ expect(buildTaskTitlePlan({ [TASK_TITLE_MODEL_ENV]: "google/gemini-2.5-flash-lite" })).toBe(
125
+ TASK_TITLE_MODEL_CANDIDATES,
126
+ );
127
+ });
128
+
129
+ test("kindForModelString maps provider prefixes", () => {
130
+ expect(kindForModelString("openrouter/deepseek/deepseek-v4.1-flash")).toBe("openrouter");
131
+ expect(kindForModelString("openai-codex/gpt-6-luna")).toBe("openai-codex");
132
+ expect(kindForModelString("haiku")).toBe("claude-cli");
133
+ expect(kindForModelString("mistral/ministral-3b")).toBeNull();
134
+ });
135
+ });
136
+
137
+ describe("generateTaskTitle", () => {
138
+ test("short prompts are skipped before any credential lookup", async () => {
139
+ const { resolve, calls } = resolver(["openrouter"]);
140
+ const { complete, models } = completer({});
141
+ const result = await generateTaskTitle({
142
+ task: "fix the login bug",
143
+ env: {},
144
+ _resolveCredentials: resolve,
145
+ _completeStructured: complete,
146
+ });
147
+ expect(result).toEqual({ ok: false, reason: "short-task" });
148
+ expect(calls).toEqual([]);
149
+ expect(models).toEqual([]);
150
+ });
151
+
152
+ test("uses the first provider with a credential and its cheapest model", async () => {
153
+ const { resolve } = resolver(["anthropic", "openai"]);
154
+ const { complete, models } = completer({
155
+ "anthropic/claude-haiku-5-5": "Fix nightly Playwright timeout on sessions page",
156
+ });
157
+ const result = await generateTaskTitle({
158
+ task: LONG_TASK,
159
+ env: {},
160
+ _resolveCredentials: resolve,
161
+ _completeStructured: complete,
162
+ });
163
+ expect(result).toEqual({
164
+ ok: true,
165
+ title: "Fix nightly Playwright timeout on sessions page",
166
+ model: "anthropic/claude-haiku-5-5",
167
+ kind: "anthropic",
168
+ });
169
+ expect(models).toEqual(["anthropic/claude-haiku-5-5"]);
170
+ });
171
+
172
+ test("falls through models, then providers, on null results and thrown errors", async () => {
173
+ const { resolve } = resolver(["openrouter", "openai"]);
174
+ const { complete, models } = completer({
175
+ "openrouter/deepseek/deepseek-v4.1-flash": new Error("429 rate limited"),
176
+ // claude-haiku-5.5 absent → null
177
+ "openrouter/openai/gpt-6-luna": " ",
178
+ "openai/gpt-6-luna": "Sessions page Playwright flake",
179
+ });
180
+ const result = await generateTaskTitle({
181
+ task: LONG_TASK,
182
+ env: {},
183
+ _resolveCredentials: resolve,
184
+ _completeStructured: complete,
185
+ });
186
+ expect(result).toEqual({
187
+ ok: true,
188
+ title: "Sessions page Playwright flake",
189
+ model: "openai/gpt-6-luna",
190
+ kind: "openai",
191
+ });
192
+ expect(models).toEqual([
193
+ "openrouter/deepseek/deepseek-v4.1-flash",
194
+ "openrouter/anthropic/claude-haiku-5.5",
195
+ "openrouter/openai/gpt-6-luna",
196
+ "openai/gpt-6-luna",
197
+ ]);
198
+ });
199
+
200
+ test("resolves one credential kind at a time and stops before the codex probe when an earlier provider answers", async () => {
201
+ const { resolve, calls } = resolver(["openrouter", "openai-codex"]);
202
+ const { complete } = completer({ "openrouter/deepseek/deepseek-v4.1-flash": "A title" });
203
+ await generateTaskTitle({
204
+ task: LONG_TASK,
205
+ env: {},
206
+ apiUrl: "http://127.0.0.1:1",
207
+ apiKey: "k",
208
+ _resolveCredentials: resolve,
209
+ _completeStructured: complete,
210
+ });
211
+ expect(calls).toEqual([["openrouter"]]);
212
+ });
213
+
214
+ test("reports no-credentials when nothing resolves and all-models-failed otherwise", async () => {
215
+ const none = resolver([]);
216
+ const silent = completer({});
217
+ expect(
218
+ await generateTaskTitle({
219
+ task: LONG_TASK,
220
+ env: {},
221
+ _resolveCredentials: none.resolve,
222
+ _completeStructured: silent.complete,
223
+ }),
224
+ ).toEqual({ ok: false, reason: "no-credentials" });
225
+ // Every kind was asked exactly once, in precedence order.
226
+ expect(none.calls.map((call) => call[0])).toEqual(
227
+ TASK_TITLE_MODEL_CANDIDATES.map((group) => group.kind),
228
+ );
229
+
230
+ const some = resolver(["claude-cli"]);
231
+ expect(
232
+ await generateTaskTitle({
233
+ task: LONG_TASK,
234
+ env: {},
235
+ _resolveCredentials: some.resolve,
236
+ _completeStructured: silent.complete,
237
+ }),
238
+ ).toEqual({ ok: false, reason: "all-models-failed" });
239
+ expect(silent.models).toEqual(["haiku"]);
240
+ });
241
+
242
+ test("the pinned model runs first and the credential override carries it", async () => {
243
+ const { resolve } = resolver(["openrouter", "openai"]);
244
+ let seenOverride: ResolvedCredential | undefined;
245
+ const complete: CompleteFn = async (opts) => {
246
+ seenOverride = opts._credentialOverride;
247
+ return { data: { title: "Pinned" }, model: "openai/gpt-5-nano" };
248
+ };
249
+ const result = await generateTaskTitle({
250
+ task: LONG_TASK,
251
+ env: { [TASK_TITLE_MODEL_ENV]: "openai/gpt-5-nano" },
252
+ _resolveCredentials: resolve,
253
+ _completeStructured: complete,
254
+ });
255
+ expect(result).toEqual({
256
+ ok: true,
257
+ title: "Pinned",
258
+ model: "openai/gpt-5-nano",
259
+ kind: "openai",
260
+ });
261
+ expect(seenOverride).toEqual({
262
+ kind: "openai",
263
+ apiKey: "key-openai",
264
+ modelDefault: "openai/gpt-5-nano",
265
+ });
266
+ });
267
+
268
+ test("an aborted signal stops the walk", async () => {
269
+ const { resolve } = resolver(["openrouter"]);
270
+ const { complete, models } = completer({ "openrouter/deepseek/deepseek-v4.1-flash": "T" });
271
+ const controller = new AbortController();
272
+ controller.abort();
273
+ const result = await generateTaskTitle({
274
+ task: LONG_TASK,
275
+ env: {},
276
+ signal: controller.signal,
277
+ _resolveCredentials: resolve,
278
+ _completeStructured: complete,
279
+ });
280
+ expect(result).toEqual({ ok: false, reason: "no-credentials" });
281
+ expect(models).toEqual([]);
282
+ });
283
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Runs the merge gate's "Compute per-domain change flags" step, verbatim from
3
+ * .github/workflows/merge-gate.yml, against throwaway git repos.
4
+ *
5
+ * Regression: a rename from a covered path to an excluded one (src/x.ts ->
6
+ * docs/x.ts) used to list only the destination, so the E2E Contract job (and
7
+ * every other src/-keyed flag) skipped a PR that removed a server input.
8
+ */
9
+ import { afterAll, describe, expect, test } from "bun:test";
10
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import { CHILD_PROCESS_TEST_BUDGET_MS, expectChildOk, runChild } from "./test-proc";
14
+
15
+ type Workflow = {
16
+ jobs: Record<string, { steps: Array<{ id?: string; run?: string }> }>;
17
+ };
18
+
19
+ // The GitHub expression the step uses for the diff base; the test substitutes a local branch.
20
+ const BASE_REF_EXPR = ["$", "{{ steps.base.outputs.ref }}"].join("");
21
+ const WORKFLOW_PATH = join(import.meta.dir, "../../.github/workflows/merge-gate.yml");
22
+
23
+ async function loadFlagStep(): Promise<string> {
24
+ const wf = Bun.YAML.parse(await Bun.file(WORKFLOW_PATH).text()) as Workflow;
25
+ const step = wf.jobs["detect-changes"]?.steps.find((s) => s.id === "check");
26
+ if (!step?.run) throw new Error("detect-changes step `check` not found in merge-gate.yml");
27
+ return step.run.replaceAll(BASE_REF_EXPR, "base");
28
+ }
29
+
30
+ // Isolate from the host's git config (a global diff.renames=false would make
31
+ // the positive control pass for the wrong reason).
32
+ const GIT_ENV = {
33
+ PATH: process.env.PATH,
34
+ HOME: tmpdir(),
35
+ GIT_CONFIG_NOSYSTEM: "1",
36
+ GIT_CONFIG_GLOBAL: "/dev/null",
37
+ GIT_AUTHOR_NAME: "t",
38
+ GIT_AUTHOR_EMAIL: "t@example.com",
39
+ GIT_COMMITTER_NAME: "t",
40
+ GIT_COMMITTER_EMAIL: "t@example.com",
41
+ };
42
+
43
+ const dirs: string[] = [];
44
+ afterAll(async () => {
45
+ await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })));
46
+ });
47
+
48
+ async function git(cwd: string, ...args: string[]) {
49
+ return expectChildOk(await runChild(["git", ...args], { cwd, env: GIT_ENV }), `git ${args[0]}`);
50
+ }
51
+
52
+ // Large enough that git's default rename detection pairs the two sides.
53
+ const BODY = Array.from({ length: 40 }, (_, i) => `export const v${i} = ${i};`).join("\n");
54
+
55
+ /** Builds a repo whose `base` branch holds `files`, applies `change` on HEAD, and returns the step's flags. */
56
+ async function flagsFor(
57
+ files: string[],
58
+ change: (repo: string) => Promise<void>,
59
+ script?: string,
60
+ ): Promise<Record<string, string>> {
61
+ const repo = await mkdtemp(join(tmpdir(), "merge-gate-flags-"));
62
+ dirs.push(repo);
63
+ await git(repo, "init", "-q", "-b", "base");
64
+ for (const f of files) {
65
+ await mkdir(dirname(join(repo, f)), { recursive: true });
66
+ await writeFile(join(repo, f), `${BODY}\n// ${f}\n`);
67
+ }
68
+ await git(repo, "add", "-A");
69
+ await git(repo, "commit", "-qm", "base");
70
+ await git(repo, "checkout", "-qb", "pr");
71
+ await change(repo);
72
+ await git(repo, "add", "-A");
73
+ await git(repo, "commit", "-qm", "pr");
74
+
75
+ const out = join(repo, ".gh-output");
76
+ const res = expectChildOk(
77
+ await runChild(["bash", "-c", script ?? (await loadFlagStep())], {
78
+ cwd: repo,
79
+ env: { ...GIT_ENV, GITHUB_OUTPUT: out },
80
+ }),
81
+ "flag step",
82
+ );
83
+ expect(res.stdout).toContain("Changed files:");
84
+ const flags: Record<string, string> = {};
85
+ for (const line of (await Bun.file(out).text()).split("\n")) {
86
+ const [k, v] = line.split("=");
87
+ if (k && v) flags[k] = v;
88
+ }
89
+ return flags;
90
+ }
91
+
92
+ const mv = (from: string, to: string) => async (repo: string) => {
93
+ await mkdir(dirname(join(repo, to)), { recursive: true });
94
+ await git(repo, "mv", from, to);
95
+ };
96
+
97
+ describe("merge-gate detect-changes flags", () => {
98
+ test(
99
+ "renaming a server file out of src/ still runs E2E",
100
+ async () => {
101
+ const flags = await flagsFor(["src/critical.ts"], mv("src/critical.ts", "docs/critical.ts"));
102
+ expect(flags.e2e).toBe("true");
103
+ expect(flags.test).toBe("true");
104
+ expect(flags.lint).toBe("true");
105
+ },
106
+ CHILD_PROCESS_TEST_BUDGET_MS,
107
+ );
108
+
109
+ test(
110
+ "renaming a docs file into src/ runs E2E",
111
+ async () => {
112
+ const flags = await flagsFor(["docs/notes.ts"], mv("docs/notes.ts", "src/notes.ts"));
113
+ expect(flags.e2e).toBe("true");
114
+ },
115
+ CHILD_PROCESS_TEST_BUDGET_MS,
116
+ );
117
+
118
+ test(
119
+ "a docs-only change skips E2E, tests, and lint",
120
+ async () => {
121
+ const flags = await flagsFor(["src/critical.ts", "docs/readme.md"], async (repo) => {
122
+ await writeFile(join(repo, "docs/readme.md"), "edited\n");
123
+ });
124
+ expect(flags.e2e).toBe("false");
125
+ expect(flags.test).toBe("false");
126
+ expect(flags.lint).toBe("false");
127
+ },
128
+ CHILD_PROCESS_TEST_BUDGET_MS,
129
+ );
130
+
131
+ test(
132
+ "control: with rename detection on, the outbound move hides from E2E",
133
+ async () => {
134
+ const step = await loadFlagStep();
135
+ expect(step).toContain("git diff --no-renames --name-only");
136
+ const flags = await flagsFor(
137
+ ["src/critical.ts"],
138
+ mv("src/critical.ts", "docs/critical.ts"),
139
+ step.replace("git diff --no-renames --name-only", "git diff --name-only"),
140
+ );
141
+ expect(flags.e2e).toBe("false");
142
+ },
143
+ CHILD_PROCESS_TEST_BUDGET_MS,
144
+ );
145
+ });
@@ -252,7 +252,7 @@ describe("/api/pages/:id/launch", () => {
252
252
  expect(cookie!).toContain("HttpOnly");
253
253
  expect(cookie!).toContain("Path=/");
254
254
  expect(cookie!).toContain("Max-Age=3600");
255
- // In dev (NODE_ENV != production) the cookie should be SameSite=Lax sans Secure.
255
+ // Under bun test (NODE_ENV=test) from http://localhost the cookie should be SameSite=Lax sans Secure.
256
256
  expect(cookie!).toContain("SameSite=Lax");
257
257
  expect(cookie!).not.toMatch(/\bSecure\b/);
258
258
  const cookieValue = /page_session_[A-Za-z0-9_-]+=([^;]+)/.exec(cookie!)?.[1];
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The page-session cookie may only drop `Secure` (SameSite=Lax) for an explicit
3
+ * development or test NODE_ENV and a request from http://localhost or
4
+ * http://127.0.0.1. Deployed images leave NODE_ENV unset, so unset must issue
5
+ * `SameSite=None; Secure`, and the localhost check must parse the origin
6
+ * instead of matching a string prefix.
7
+ *
8
+ * Covers both issuers: the launch endpoint (`src/http/pages.ts`) and the
9
+ * password flow on `/p/:id` (`src/http/pages-public.ts`).
10
+ */
11
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
12
+ import crypto from "node:crypto";
13
+ import { unlink } from "node:fs/promises";
14
+ import {
15
+ createServer as createHttpServer,
16
+ type IncomingMessage,
17
+ type Server,
18
+ type ServerResponse,
19
+ } from "node:http";
20
+ import { closeDb, initDb } from "../be/db";
21
+ import { handlePages } from "../http/pages";
22
+ import { handlePagesPublic } from "../http/pages-public";
23
+ import { getPathSegments, parseQueryParams } from "../http/utils";
24
+ import { isLocalDevPageRequest } from "../utils/page-session";
25
+ import { listenOnFreePort } from "./test-net";
26
+
27
+ const TEST_DB_PATH = "./test-page-session-dev-cookie.sqlite";
28
+ const PASSWORD = "example-swordfish";
29
+ const ORIGINAL_NODE_ENV = process.env.NODE_ENV;
30
+
31
+ function setNodeEnv(value: string | undefined): void {
32
+ if (value === undefined) delete process.env.NODE_ENV;
33
+ else process.env.NODE_ENV = value;
34
+ }
35
+
36
+ function createTestServer(): Server {
37
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
38
+ const pathSegments = getPathSegments(req.url || "");
39
+ const queryParams = parseQueryParams(req.url || "");
40
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
41
+ if (await handlePagesPublic(req, res, pathSegments, queryParams)) return;
42
+ if (await handlePages(req, res, pathSegments, queryParams, myAgentId)) return;
43
+ res.writeHead(404);
44
+ res.end("not found");
45
+ });
46
+ }
47
+
48
+ type Case = { name: string; nodeEnv: string | undefined; origin?: string; secure: boolean };
49
+
50
+ const CASES: Case[] = [
51
+ { name: "NODE_ENV unset, no Origin", nodeEnv: undefined, secure: true },
52
+ {
53
+ name: "NODE_ENV unset, spoofed localhost prefix",
54
+ nodeEnv: undefined,
55
+ origin: "http://localhost.evil.example",
56
+ secure: true,
57
+ },
58
+ {
59
+ name: "NODE_ENV unset, real localhost origin",
60
+ nodeEnv: undefined,
61
+ origin: "http://localhost:5274",
62
+ secure: true,
63
+ },
64
+ {
65
+ name: "NODE_ENV=development, spoofed localhost prefix",
66
+ nodeEnv: "development",
67
+ origin: "http://localhost.evil.example",
68
+ secure: true,
69
+ },
70
+ {
71
+ name: "NODE_ENV=development, portless https origin",
72
+ nodeEnv: "development",
73
+ origin: "https://ui.swarm.localhost",
74
+ secure: true,
75
+ },
76
+ {
77
+ name: "NODE_ENV=development, localhost UI origin",
78
+ nodeEnv: "development",
79
+ origin: "http://localhost:5274",
80
+ secure: false,
81
+ },
82
+ {
83
+ name: "NODE_ENV=development, 127.0.0.1 UI origin",
84
+ nodeEnv: "development",
85
+ origin: "http://127.0.0.1:5274",
86
+ secure: false,
87
+ },
88
+ ];
89
+
90
+ function expectCookieShape(cookie: string | null, secure: boolean): void {
91
+ expect(cookie).toContain("page_session_");
92
+ if (secure) {
93
+ expect(cookie!).toContain("SameSite=None");
94
+ expect(cookie!).toMatch(/\bSecure\b/);
95
+ } else {
96
+ expect(cookie!).toContain("SameSite=Lax");
97
+ expect(cookie!).not.toMatch(/\bSecure\b/);
98
+ }
99
+ }
100
+
101
+ describe("page-session cookie dev softening fails closed", () => {
102
+ let server: Server;
103
+ let base = "";
104
+ let authedId = "";
105
+ let passwordId = "";
106
+ const agentId = crypto.randomUUID();
107
+
108
+ async function createPage(body: Record<string, unknown>): Promise<string> {
109
+ const post = await fetch(`${base}/api/pages`, {
110
+ method: "POST",
111
+ headers: { "Content-Type": "application/json", "X-Agent-ID": agentId },
112
+ body: JSON.stringify({ contentType: "text/html", body: "<h1>x</h1>", ...body }),
113
+ });
114
+ expect(post.status).toBe(201);
115
+ return ((await post.json()) as { id: string }).id;
116
+ }
117
+
118
+ beforeAll(async () => {
119
+ process.env.PAGE_SESSION_SECRET = "example-test-page-session-dev-cookie-secret";
120
+ for (const suffix of ["", "-wal", "-shm"]) {
121
+ try {
122
+ await unlink(`${TEST_DB_PATH}${suffix}`);
123
+ } catch {}
124
+ }
125
+ initDb(TEST_DB_PATH);
126
+ server = createTestServer();
127
+ const port = await listenOnFreePort(server);
128
+ base = `http://localhost:${port}`;
129
+ authedId = await createPage({ slug: "dev-cookie-authed", title: "A", authMode: "authed" });
130
+ passwordId = await createPage({
131
+ slug: "dev-cookie-password",
132
+ title: "P",
133
+ authMode: "password",
134
+ password: PASSWORD,
135
+ });
136
+ });
137
+
138
+ afterEach(() => setNodeEnv(ORIGINAL_NODE_ENV));
139
+
140
+ afterAll(async () => {
141
+ setNodeEnv(ORIGINAL_NODE_ENV);
142
+ await new Promise<void>((resolve) => server.close(() => resolve()));
143
+ closeDb();
144
+ for (const suffix of ["", "-wal", "-shm"]) {
145
+ try {
146
+ await unlink(`${TEST_DB_PATH}${suffix}`);
147
+ } catch {}
148
+ }
149
+ });
150
+
151
+ test.each(CASES)("launch endpoint: $name", async ({ nodeEnv, origin, secure }) => {
152
+ setNodeEnv(nodeEnv);
153
+ const res = await fetch(`${base}/api/pages/${authedId}/launch`, {
154
+ method: "POST",
155
+ headers: { "X-Agent-ID": agentId, ...(origin ? { Origin: origin } : {}) },
156
+ });
157
+ expect(res.status).toBe(204);
158
+ expectCookieShape(res.headers.get("set-cookie"), secure);
159
+ });
160
+
161
+ test.each(CASES)("/p/:id password flow: $name", async ({ nodeEnv, origin, secure }) => {
162
+ setNodeEnv(nodeEnv);
163
+ const res = await fetch(`${base}/p/${passwordId}?key=${PASSWORD}`, {
164
+ redirect: "manual",
165
+ headers: origin ? { Origin: origin } : {},
166
+ });
167
+ expect(res.status).toBeLessThan(400);
168
+ expectCookieShape(res.headers.get("set-cookie"), secure);
169
+ });
170
+ });
171
+
172
+ describe("isLocalDevPageRequest Host fallback", () => {
173
+ afterEach(() => setNodeEnv(ORIGINAL_NODE_ENV));
174
+
175
+ test.each([
176
+ [undefined, "localhost:3013", false],
177
+ ["production", "localhost:3013", false],
178
+ ["development", "localhost:3013", true],
179
+ ["development", "127.0.0.1:3013", true],
180
+ ["development", undefined, true],
181
+ ["development", "localhost.evil.example", false],
182
+ ["development", "localhost@evil.example", false],
183
+ ["development", "api.swarm.localhost", false],
184
+ ] as const)("NODE_ENV=%p Host=%p → %p", (nodeEnv, host, expected) => {
185
+ setNodeEnv(nodeEnv);
186
+ expect(isLocalDevPageRequest({ headers: host === undefined ? {} : { host } })).toBe(expected);
187
+ });
188
+ });
@@ -50,6 +50,7 @@ describe("PAGES_FRAME_ANCESTORS on /p/:id", () => {
50
50
  APP_URL: process.env.APP_URL,
51
51
  DASHBOARD_URL: process.env.DASHBOARD_URL,
52
52
  PAGE_SESSION_SECRET: process.env.PAGE_SESSION_SECRET,
53
+ NODE_ENV: process.env.NODE_ENV,
53
54
  };
54
55
 
55
56
  function restore(key: keyof typeof ORIG): void {
@@ -93,7 +94,10 @@ describe("PAGES_FRAME_ANCESTORS on /p/:id", () => {
93
94
  BASE = `http://localhost:${port}`;
94
95
  });
95
96
 
96
- afterEach(() => restore("PAGES_FRAME_ANCESTORS"));
97
+ afterEach(() => {
98
+ restore("PAGES_FRAME_ANCESTORS");
99
+ restore("NODE_ENV");
100
+ });
97
101
 
98
102
  afterAll(async () => {
99
103
  await new Promise<void>((resolve) => server.close(() => resolve()));
@@ -150,6 +154,29 @@ describe("PAGES_FRAME_ANCESTORS on /p/:id", () => {
150
154
  expect(blank).toBe(unset);
151
155
  });
152
156
 
157
+ // Deployed images leave NODE_ENV unset. Localhost dev origins must only be
158
+ // allowed to frame pages under an explicit development or test NODE_ENV.
159
+ for (const nodeEnv of [undefined, "production", "staging"]) {
160
+ test(`NODE_ENV=${nodeEnv ?? "<unset>"} emits no localhost ancestors`, async () => {
161
+ if (nodeEnv === undefined) delete process.env.NODE_ENV;
162
+ else process.env.NODE_ENV = nodeEnv;
163
+ const id = await createPage(`public-env-${nodeEnv ?? "unset"}`, "public");
164
+ const fa = frameAncestors(await fetch(`${BASE}/p/${id}`));
165
+ expect(fa.trim()).toBe("frame-ancestors 'self' https://app.example.test");
166
+ });
167
+ }
168
+
169
+ for (const nodeEnv of ["development", "test"]) {
170
+ test(`NODE_ENV=${nodeEnv} keeps the local dashboard origins`, async () => {
171
+ process.env.NODE_ENV = nodeEnv;
172
+ const id = await createPage(`public-env-${nodeEnv}`, "public");
173
+ const fa = frameAncestors(await fetch(`${BASE}/p/${id}`));
174
+ expect(fa).toContain("https://app.example.test");
175
+ expect(fa).toContain("http://localhost:5274");
176
+ expect(fa).toContain("https://*.localhost");
177
+ });
178
+ }
179
+
153
180
  test("parser accepts comma or space lists and drops invalid entries", () => {
154
181
  process.env.PAGES_FRAME_ANCESTORS =
155
182
  "https://www.agent-swarm.dev/, https://*.example.com:8443 blog.example.org,* 'none' https://evil.test;script-src javascript:alert(1) https://x.test/path";