@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
@@ -35,6 +35,9 @@ export async function snapshotPage(
35
35
  passwordHash: page.passwordHash,
36
36
  body: page.body,
37
37
  needsCredentials: page.needsCredentials,
38
+ theme: page.theme,
39
+ themeTokens: page.themeTokens,
40
+ designVersion: page.designVersion,
38
41
  };
39
42
 
40
43
  return createPageVersion({
@@ -45,7 +45,7 @@ import type {
45
45
  * default; the image installs exactly this version and the per-task settings
46
46
  * set `amp.updates.mode: "disabled"`. Bump it deliberately, with a live run.
47
47
  */
48
- export const AMP_PACKAGE = "@ampcode/cli@0.0.1793363705-g5c9ae5";
48
+ export const AMP_PACKAGE = "@ampcode/cli@0.0.1793450357-gd44904";
49
49
 
50
50
  /** MCP server name. Amp exposes its tools as `agent_swarm` / `store_progress` (dashes become underscores). */
51
51
  const AMP_MCP_SERVER_NAME = "agent-swarm";
@@ -2053,6 +2053,7 @@ export async function createInProcessCodexSession(
2053
2053
  const appServerFactory = opts.appServerFactory ?? ((options) => new CodexAppServer(options));
2054
2054
  const client = appServerFactory({
2055
2055
  ...(codexPathOverride ? { codexPath: codexPathOverride } : {}),
2056
+ ...(gateway ? { configOverrides: codexGatewayConfig(gateway) } : {}),
2056
2057
  env,
2057
2058
  onStderr: (line) => {
2058
2059
  if (liveSession) liveSession.emitTransportStderr(line);
@@ -26,6 +26,7 @@ export interface CodexAppServerOptions {
26
26
  env: Readonly<Record<string, string | undefined>>;
27
27
  requestTimeoutMs?: number;
28
28
  onStderr?: (line: string) => void;
29
+ configOverrides?: Readonly<Record<string, CodexConfigValue | undefined>>;
29
30
  }
30
31
 
31
32
  type PendingRequest = {
@@ -53,6 +54,25 @@ export class CodexAppServerRpcError extends Error {
53
54
  }
54
55
  }
55
56
 
57
+ /** `-c key=<TOML value>` pairs, one per top-level key; tables become inline tables. */
58
+ export function configOverrideArgs(
59
+ overrides: Readonly<Record<string, CodexConfigValue | undefined>> = {},
60
+ ): string[] {
61
+ return Object.entries(overrides).flatMap(([key, value]) =>
62
+ value === undefined ? [] : ["-c", `${key}=${tomlValue(value)}`],
63
+ );
64
+ }
65
+
66
+ function tomlValue(value: CodexConfigValue): string {
67
+ if (typeof value === "string") return JSON.stringify(value);
68
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
69
+ if (Array.isArray(value)) return `[${value.map(tomlValue).join(", ")}]`;
70
+ const fields = Object.entries(value as Record<string, CodexConfigValue | undefined>).flatMap(
71
+ ([key, field]) => (field === undefined ? [] : [`${JSON.stringify(key)} = ${tomlValue(field)}`]),
72
+ );
73
+ return `{ ${fields.join(", ")} }`;
74
+ }
75
+
56
76
  /** Thin JSONL client for one `codex app-server --listen stdio://` process. */
57
77
  export class CodexAppServer {
58
78
  private readonly proc: ReturnType<typeof Bun.spawn>;
@@ -79,7 +99,13 @@ export class CodexAppServer {
79
99
  this.onStderr = options.onStderr;
80
100
 
81
101
  const codexPath = options.codexPath ?? resolveCodexPath(options.env.PATH);
82
- const command = [codexPath, "app-server", "--listen", "stdio://"];
102
+ const command = [
103
+ codexPath,
104
+ ...configOverrideArgs(options.configOverrides),
105
+ "app-server",
106
+ "--listen",
107
+ "stdio://",
108
+ ];
83
109
 
84
110
  const env = Object.fromEntries(
85
111
  Object.entries(options.env).filter(
@@ -8,6 +8,13 @@
8
8
 
9
9
  import type { ExtensionFactory, McpServerConfig } from "@earendil-works/pi-coding-agent";
10
10
  import { buildRatingsFromLlm, fetchRetrievalsForTask, postRatings } from "../be/memory/raters/llm";
11
+ import {
12
+ advanceIdentityBaselines,
13
+ contentSha256,
14
+ PROFILE_FILE_IO,
15
+ type ProfileFileIO,
16
+ readIdentityBaselines,
17
+ } from "../commands/profile-sync";
11
18
  import { checkToolLoop, clearToolHistory } from "../hooks/tool-loop-detection";
12
19
  import { renderGoalReminder } from "../prompts/goal-reminder";
13
20
  import { isSessionSummarySkipped } from "../utils/env-flag";
@@ -100,11 +107,16 @@ export async function fetchTaskDetails(
100
107
  }
101
108
  }
102
109
 
103
- /** Sync identity files (SOUL.md, IDENTITY.md, TOOLS.md) to server */
110
+ /**
111
+ * Sync identity files (SOUL.md, IDENTITY.md, TOOLS.md) to server. A file whose
112
+ * hash still equals its baseline was not edited and is skipped, so a stale copy
113
+ * never overwrites a DB-side edit made during the session.
114
+ */
104
115
  export async function syncIdentityFilesToServer(
105
116
  config: SwarmHooksConfig,
106
117
  changeSource: "self_edit" | "session_sync" = "session_sync",
107
118
  signal?: AbortSignal,
119
+ io: ProfileFileIO = PROFILE_FILE_IO,
108
120
  ): Promise<void> {
109
121
  const updates: Record<string, string> = {};
110
122
  const paths: Record<string, string> = {
@@ -112,15 +124,14 @@ export async function syncIdentityFilesToServer(
112
124
  identityMd: "/workspace/IDENTITY.md",
113
125
  toolsMd: "/workspace/TOOLS.md",
114
126
  };
127
+ const baselines = await readIdentityBaselines(io.readFile);
115
128
 
116
129
  for (const [key, path] of Object.entries(paths)) {
117
130
  try {
118
- const file = Bun.file(path);
119
- if (await file.exists()) {
120
- const content = await file.text();
121
- if (content.trim() && content.length <= 65536) {
122
- updates[key] = content;
123
- }
131
+ const content = await io.readFile(path);
132
+ if (content === undefined || baselines?.[key] === contentSha256(content)) continue;
133
+ if (content.trim() && content.length <= 65536) {
134
+ updates[key] = content;
124
135
  }
125
136
  } catch {
126
137
  /* skip */
@@ -130,28 +141,27 @@ export async function syncIdentityFilesToServer(
130
141
  if (Object.keys(updates).length === 0) return;
131
142
 
132
143
  try {
133
- await fetch(`${config.apiUrl}/api/agents/${config.agentId}/profile`, {
144
+ const resp = await fetch(`${config.apiUrl}/api/agents/${config.agentId}/profile`, {
134
145
  method: "PUT",
135
146
  headers: apiHeaders(config),
136
147
  body: JSON.stringify({ ...updates, changeSource }),
137
148
  signal,
138
149
  });
150
+ if (resp.ok) await advanceIdentityBaselines(updates, io);
139
151
  } catch {
140
152
  /* silently fail */
141
153
  }
142
154
  }
143
155
 
144
156
  /** Sync setup script to server */
145
- async function syncSetupScriptToServer(
157
+ export async function syncSetupScriptToServer(
146
158
  config: SwarmHooksConfig,
147
159
  changeSource: "self_edit" | "session_sync" = "session_sync",
160
+ io: ProfileFileIO = PROFILE_FILE_IO,
148
161
  ): Promise<void> {
149
162
  try {
150
- const file = Bun.file("/workspace/start-up.sh");
151
- if (!(await file.exists())) return;
152
-
153
- const raw = await file.text();
154
- if (!raw.trim()) return;
163
+ const raw = await io.readFile("/workspace/start-up.sh");
164
+ if (!raw?.trim()) return;
155
165
 
156
166
  const markerStart = "# === Agent-managed setup (from DB) ===";
157
167
  const markerEnd = "# === End agent-managed setup ===";
@@ -166,12 +176,17 @@ async function syncSetupScriptToServer(
166
176
  }
167
177
 
168
178
  if (!content || content.length > 65536) return;
179
+ const baselines = await readIdentityBaselines(io.readFile);
180
+ if (baselines?.setupScript === contentSha256(content)) return;
169
181
 
170
- await fetch(`${config.apiUrl}/api/agents/${config.agentId}/profile`, {
182
+ const resp = await fetch(`${config.apiUrl}/api/agents/${config.agentId}/profile`, {
171
183
  method: "PUT",
172
184
  headers: apiHeaders(config),
173
185
  body: JSON.stringify({ setupScript: content, changeSource }),
174
186
  });
187
+ if (resp.ok) {
188
+ await advanceIdentityBaselines({ setupScript: content }, io);
189
+ }
175
190
  } catch {
176
191
  /* silently fail */
177
192
  }
@@ -0,0 +1,318 @@
1
+ import { createLogEntry, getDbClient } from "../be/db";
2
+ import { getScript } from "../be/scripts/db";
3
+ import { runSavedScriptAsAgent } from "../be/scripts/run-saved";
4
+ import type { AgentTask, ScriptRecord } from "../types";
5
+ import {
6
+ isFollowUpGateScheduleId,
7
+ parseFollowUpGateScheduleIds,
8
+ } from "../utils/follow-up-gate-schedule-ids";
9
+ import { scrubSecrets } from "../utils/secret-scrubber";
10
+
11
+ /**
12
+ * Follow-up gate, shadow mode. When a gated scheduled run's Lead follow-up is
13
+ * created, a configured verifier script checks the run after commit and the
14
+ * server records whether the follow-up could have been skipped. Shadow mode
15
+ * never skips or delays a follow-up.
16
+ */
17
+
18
+ export const FOLLOW_UP_GATE_SHADOW_EVENT = "task_follow_up_gate_shadow" as const;
19
+
20
+ export type FollowUpGateConfig = {
21
+ mode: "off" | "shadow";
22
+ scheduleIds: ReadonlySet<string>;
23
+ verifierScript: string;
24
+ };
25
+
26
+ export type GateIneligibleReason =
27
+ | "mode_off"
28
+ | "not_scheduled"
29
+ | "schedule_not_gated"
30
+ | "status_failed"
31
+ | "follow_up_instructions";
32
+
33
+ /**
34
+ * Read the gate config on every call so a `swarm_config` reload applies. Any
35
+ * mode other than `shadow` reads as `off`, and malformed ids are ignored.
36
+ */
37
+ export function readFollowUpGateConfig(env: NodeJS.ProcessEnv = process.env): FollowUpGateConfig {
38
+ const mode = env.FOLLOW_UP_GATE_MODE?.trim().toLowerCase() === "shadow" ? "shadow" : "off";
39
+ const scheduleIds = new Set(
40
+ parseFollowUpGateScheduleIds(env.FOLLOW_UP_GATE_SCHEDULE_IDS)
41
+ .filter(isFollowUpGateScheduleId)
42
+ .map((id) => id.toLowerCase()),
43
+ );
44
+ return { mode, scheduleIds, verifierScript: env.FOLLOW_UP_GATE_VERIFIER_SCRIPT?.trim() ?? "" };
45
+ }
46
+
47
+ /** Pure decision on whether a follow-up's worker task may be verified. */
48
+ export function classifyFollowUpGate(input: {
49
+ task: Pick<AgentTask, "scheduleId" | "followUpConfig">;
50
+ status: "completed" | "failed";
51
+ config: FollowUpGateConfig;
52
+ }): { eligible: true } | { eligible: false; reason: GateIneligibleReason } {
53
+ const { task, status, config } = input;
54
+ if (config.mode === "off") return { eligible: false, reason: "mode_off" };
55
+ if (!task.scheduleId) return { eligible: false, reason: "not_scheduled" };
56
+ if (!config.scheduleIds.has(task.scheduleId.toLowerCase())) {
57
+ return { eligible: false, reason: "schedule_not_gated" };
58
+ }
59
+ if (status === "failed") return { eligible: false, reason: "status_failed" };
60
+ if (task.followUpConfig?.onCompleted?.trim() || task.followUpConfig?.onFailed?.trim()) {
61
+ return { eligible: false, reason: "follow_up_instructions" };
62
+ }
63
+ return { eligible: true };
64
+ }
65
+
66
+ type VerifierScript = Pick<ScriptRecord, "name" | "version" | "contentHash">;
67
+
68
+ export type FollowUpGateDeps = {
69
+ /** Resolve the global verifier script, or null when it does not exist. */
70
+ getVerifier: (name: string) => Promise<VerifierScript | null>;
71
+ /** Run the verifier as `agentId` and return its result, or throw. */
72
+ runVerifier: (
73
+ script: VerifierScript,
74
+ input: { taskId: string },
75
+ agentId: string,
76
+ signal: AbortSignal,
77
+ ) => Promise<unknown>;
78
+ };
79
+
80
+ const FOLLOW_UP_GATE_TIMEOUT_MS = 60_000;
81
+ const FOLLOW_UP_GATE_MAX_IN_FLIGHT = 2;
82
+ const TEXT_LIMIT = 300;
83
+ const MAX_UNVERIFIED = 20;
84
+
85
+ const defaultDeps: FollowUpGateDeps = {
86
+ getVerifier: (name) => getScript({ name, scope: "global" }),
87
+ runVerifier: async (script, input, agentId, signal) => {
88
+ const output = await runSavedScriptAsAgent({
89
+ script: script as ScriptRecord,
90
+ input,
91
+ agentId,
92
+ timeoutMs,
93
+ signal,
94
+ });
95
+ if (output.exitCode !== 0 || output.error || output.runtimeError) {
96
+ throw new Error(
97
+ output.runtimeError?.message ??
98
+ output.error ??
99
+ `script exited with code ${output.exitCode}`,
100
+ );
101
+ }
102
+ return output.result;
103
+ },
104
+ };
105
+
106
+ let timeoutMs = FOLLOW_UP_GATE_TIMEOUT_MS;
107
+ let depsForTests: FollowUpGateDeps | null = null;
108
+ let inFlight = 0;
109
+ const pending = new Set<Promise<void>>();
110
+
111
+ /** Replace the verifier lookup and runner. `null` restores the defaults. */
112
+ export function setFollowUpGateDepsForTests(deps: FollowUpGateDeps | null): void {
113
+ depsForTests = deps;
114
+ }
115
+
116
+ /** Override the verifier deadline. `null` restores the 60s default. */
117
+ export function setFollowUpGateTimeoutMsForTests(ms: number | null): void {
118
+ timeoutMs = ms ?? FOLLOW_UP_GATE_TIMEOUT_MS;
119
+ }
120
+
121
+ /** Wait until every scheduled shadow hook has written its row. */
122
+ export async function drainFollowUpGateShadowForTest(): Promise<void> {
123
+ // Hooks run in FIFO order behind the client lock, so once this marker runs,
124
+ // every earlier hook has started and registered its promise.
125
+ await new Promise<void>((resolve) => getDbClient().afterCommit(resolve));
126
+ while (pending.size > 0) await Promise.allSettled([...pending]);
127
+ }
128
+
129
+ type ShadowArgs = {
130
+ task: Pick<AgentTask, "id" | "scheduleId" | "followUpConfig">;
131
+ status: "completed" | "failed";
132
+ followUpTaskId: string;
133
+ leadAgentId: string;
134
+ };
135
+
136
+ type ShadowRecord = {
137
+ decision: "would_skip" | "would_keep" | "error" | "ineligible";
138
+ reason: string | null;
139
+ verdict?: { ok: boolean | null; check: string | null; detail: string; controlsOk: boolean };
140
+ unverified?: string[];
141
+ verifier?: VerifierScript | null;
142
+ latencyMs?: number | null;
143
+ error?: string;
144
+ };
145
+
146
+ /**
147
+ * Record a shadow gate decision for a just-created follow-up. Returns
148
+ * synchronously: all work, including the row write, runs after commit. With
149
+ * the gate off or the schedule not gated, nothing is registered.
150
+ */
151
+ export function scheduleFollowUpGateShadow(args: ShadowArgs): void {
152
+ const config = readFollowUpGateConfig();
153
+ const verdict = classifyFollowUpGate({ task: args.task, status: args.status, config });
154
+ if (
155
+ !verdict.eligible &&
156
+ (verdict.reason === "mode_off" ||
157
+ verdict.reason === "not_scheduled" ||
158
+ verdict.reason === "schedule_not_gated")
159
+ ) {
160
+ return;
161
+ }
162
+ const deps = depsForTests ?? defaultDeps;
163
+ getDbClient().afterCommit(() => {
164
+ const run = verdict.eligible
165
+ ? runShadow(args, config, deps)
166
+ : writeRecord(args, { decision: "ineligible", reason: verdict.reason });
167
+ pending.add(run);
168
+ const settle = () => {
169
+ pending.delete(run);
170
+ };
171
+ void run.then(settle, settle);
172
+ });
173
+ }
174
+
175
+ async function runShadow(
176
+ args: ShadowArgs,
177
+ config: FollowUpGateConfig,
178
+ deps: FollowUpGateDeps,
179
+ ): Promise<void> {
180
+ if (inFlight >= FOLLOW_UP_GATE_MAX_IN_FLIGHT) {
181
+ await writeRecord(args, { decision: "error", reason: "concurrency_cap" });
182
+ return;
183
+ }
184
+ inFlight++;
185
+ try {
186
+ await writeRecord(args, await verify(args, config, deps));
187
+ } finally {
188
+ inFlight--;
189
+ }
190
+ }
191
+
192
+ async function verify(
193
+ args: ShadowArgs,
194
+ config: FollowUpGateConfig,
195
+ deps: FollowUpGateDeps,
196
+ ): Promise<ShadowRecord> {
197
+ if (!config.verifierScript) return { decision: "error", reason: "no_verifier" };
198
+ const started = performance.now();
199
+ const latency = () => Math.round(performance.now() - started);
200
+ const controller = new AbortController();
201
+ const deadlineMs = timeoutMs;
202
+ let timer: ReturnType<typeof setTimeout> | undefined;
203
+ let script: VerifierScript | null = null;
204
+ let timedOut = false;
205
+ try {
206
+ const run = (async () => {
207
+ script = await deps.getVerifier(config.verifierScript);
208
+ if (!script) return null;
209
+ return {
210
+ result: await deps.runVerifier(
211
+ script,
212
+ { taskId: args.task.id },
213
+ args.leadAgentId,
214
+ controller.signal,
215
+ ),
216
+ };
217
+ })();
218
+ // A run that settles after the deadline must not surface as an unhandled rejection.
219
+ run.catch(() => {});
220
+ const deadline = new Promise<never>((_, reject) => {
221
+ timer = setTimeout(() => {
222
+ timedOut = true;
223
+ controller.abort();
224
+ reject(new Error(`verifier timed out after ${deadlineMs}ms`));
225
+ }, deadlineMs);
226
+ });
227
+ const outcome = await Promise.race([run, deadline]);
228
+ const verifier = verifierInfo(script);
229
+ if (!outcome) {
230
+ return { decision: "error", reason: "no_verifier", verifier, latencyMs: latency() };
231
+ }
232
+ const result = outcome.result;
233
+ if (!result || typeof result !== "object") {
234
+ return {
235
+ decision: "error",
236
+ reason: "verifier_error",
237
+ verifier,
238
+ latencyMs: latency(),
239
+ error: "verifier result is not an object",
240
+ };
241
+ }
242
+ return { ...mapVerdict(result as Record<string, unknown>), verifier, latencyMs: latency() };
243
+ } catch (err) {
244
+ const message = err instanceof Error ? err.message : String(err);
245
+ return {
246
+ decision: "error",
247
+ reason: timedOut ? "timeout" : "verifier_error",
248
+ verifier: verifierInfo(script),
249
+ latencyMs: latency(),
250
+ error: clip(message),
251
+ };
252
+ } finally {
253
+ clearTimeout(timer);
254
+ }
255
+ }
256
+
257
+ function verifierInfo(script: VerifierScript | null): VerifierScript | null {
258
+ return script
259
+ ? { name: script.name, version: script.version, contentHash: script.contentHash }
260
+ : null;
261
+ }
262
+
263
+ /** `ok:true` with passing controls is `would_skip`; anything else keeps the follow-up. */
264
+ function mapVerdict(result: Record<string, unknown>): ShadowRecord {
265
+ const ok = typeof result.ok === "boolean" ? result.ok : null;
266
+ const controls = result.controls as { ok?: unknown } | null | undefined;
267
+ const controlsOk = controls?.ok === true;
268
+ const unverified = Array.isArray(result.unverified)
269
+ ? result.unverified
270
+ .filter((name): name is string => typeof name === "string")
271
+ .slice(0, MAX_UNVERIFIED)
272
+ .map((name) => name.slice(0, 100))
273
+ : [];
274
+ return {
275
+ decision: ok === true && controlsOk ? "would_skip" : "would_keep",
276
+ reason: null,
277
+ verdict: {
278
+ ok,
279
+ check: typeof result.check === "string" ? result.check.slice(0, 100) : null,
280
+ detail: typeof result.detail === "string" ? clip(result.detail) : "",
281
+ controlsOk,
282
+ },
283
+ unverified,
284
+ };
285
+ }
286
+
287
+ function clip(text: string): string {
288
+ return scrubSecrets(text).slice(0, TEXT_LIMIT);
289
+ }
290
+
291
+ async function writeRecord(args: ShadowArgs, record: ShadowRecord): Promise<void> {
292
+ try {
293
+ await createLogEntry({
294
+ eventType: FOLLOW_UP_GATE_SHADOW_EVENT,
295
+ taskId: args.task.id,
296
+ agentId: args.leadAgentId,
297
+ metadata: {
298
+ v: 1,
299
+ mode: "shadow",
300
+ followUpTaskId: args.followUpTaskId,
301
+ scheduleId: args.task.scheduleId ?? null,
302
+ status: args.status,
303
+ decision: record.decision,
304
+ reason: record.reason,
305
+ verdict: record.verdict ?? null,
306
+ unverified: record.unverified ?? [],
307
+ verifier: record.verifier ?? null,
308
+ latencyMs: record.latencyMs ?? null,
309
+ ...(record.error ? { error: record.error } : {}),
310
+ },
311
+ });
312
+ } catch (err) {
313
+ console.warn(
314
+ `[follow-up-gate] Could not record shadow decision for task ${args.task.id.slice(0, 8)}:`,
315
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
316
+ );
317
+ }
318
+ }
@@ -34,6 +34,7 @@ import {
34
34
  import { isEnvFlagEnabled } from "../utils/env-flag";
35
35
  import { taskAttachmentDisplayUrl } from "../utils/task-attachment-links";
36
36
  import { chatThreadOfContextKey } from "./context-key";
37
+ import { scheduleFollowUpGateShadow } from "./follow-up-gate";
37
38
  import { taskModelPin } from "./model-pin";
38
39
  import { createTaskWithSiblingAwareness } from "./sibling-awareness";
39
40
  // Side-effect import: registers task lifecycle templates in the in-memory registry.
@@ -261,6 +262,9 @@ async function findDeferredWakeFor(taskId: string, agentId: string): Promise<Def
261
262
  * `all` wait with siblings still pending wakes nobody, so the follow-up stays.
262
263
  * A `followUpConfig.onCompleted` / `onFailed` instruction for this status also
263
264
  * keeps the follow-up, because the wake-up task does not carry that text.
265
+ *
266
+ * With `FOLLOW_UP_GATE_MODE=shadow`, a gated scheduled run also records a
267
+ * verifier's would-skip decision after commit; the follow-up is never skipped.
264
268
  */
265
269
  export async function createWorkerTaskFollowUp(args: {
266
270
  task: AgentTask;
@@ -392,7 +396,7 @@ export async function createWorkerTaskFollowUp(args: {
392
396
  );
393
397
  return null;
394
398
  }
395
- return await createTaskWithSiblingAwareness(
399
+ const followUp = await createTaskWithSiblingAwareness(
396
400
  changes.description ?? followUpDescription,
397
401
  {
398
402
  agentId: followUpAgentId,
@@ -413,6 +417,22 @@ export async function createWorkerTaskFollowUp(args: {
413
417
  },
414
418
  { origin: "followUp" },
415
419
  );
420
+ if (followUp) {
421
+ try {
422
+ scheduleFollowUpGateShadow({
423
+ task,
424
+ status,
425
+ followUpTaskId: followUp.id,
426
+ leadAgentId: leadAgent.id,
427
+ });
428
+ } catch (err) {
429
+ console.warn(
430
+ `[worker-follow-up] Follow-up gate shadow failed for task ${task.id.slice(0, 8)}:`,
431
+ err instanceof Error ? err.message : String(err),
432
+ );
433
+ }
434
+ }
435
+ return followUp;
416
436
  }
417
437
 
418
438
  /** Hop cap for `deferredWorkChatContextKey`; a defer/resume chain is short. */
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
2
4
  import { join } from "node:path";
3
5
  import {
4
6
  CodexAppServer,
@@ -205,4 +207,61 @@ describe("CodexAppServer", () => {
205
207
  },
206
208
  CHILD_PROCESS_TEST_BUDGET_MS,
207
209
  );
210
+
211
+ for (const { name, launchOnGateway } of [
212
+ {
213
+ name: "pinned Codex rejects a gateway turn on an app-server launched without it",
214
+ launchOnGateway: false,
215
+ },
216
+ {
217
+ name: "pinned Codex accepts a gateway turn on an app-server launched on the gateway",
218
+ launchOnGateway: true,
219
+ },
220
+ ]) {
221
+ test(
222
+ name,
223
+ async () => {
224
+ const gateway = {
225
+ model_provider: "swarm-gateway",
226
+ model_providers: {
227
+ "swarm-gateway": {
228
+ name: "Swarm gateway",
229
+ base_url: "http://127.0.0.1:9/v1",
230
+ env_key: "CODEX_GATEWAY_API_KEY",
231
+ wire_api: "responses",
232
+ requires_openai_auth: false,
233
+ },
234
+ },
235
+ };
236
+ const home = await mkdtemp(join(tmpdir(), "codex-app-server-provider-"));
237
+ const server = new CodexAppServer({
238
+ codexPath: join(import.meta.dir, "..", "..", "node_modules", ".bin", "codex"),
239
+ env: fixtureEnv({ HOME: home, CODEX_HOME: home, CODEX_GATEWAY_API_KEY: "example" }),
240
+ ...(launchOnGateway ? { configOverrides: gateway } : {}),
241
+ });
242
+ try {
243
+ const started = await server.request<{ thread: { id: string } }>("thread/start", {
244
+ cwd: home,
245
+ approvalPolicy: "never",
246
+ sandbox: "danger-full-access",
247
+ config: gateway,
248
+ ephemeral: true,
249
+ });
250
+ const turn = server.request("turn/start", {
251
+ threadId: started.thread.id,
252
+ input: [{ type: "text", text: "hi" }],
253
+ });
254
+ if (launchOnGateway) await turn;
255
+ else
256
+ await expect(turn).rejects.toThrow(
257
+ "This chat uses swarm-gateway, but your current provider is openai",
258
+ );
259
+ } finally {
260
+ await server.close();
261
+ await rm(home, { recursive: true, force: true });
262
+ }
263
+ },
264
+ CHILD_PROCESS_TEST_BUDGET_MS,
265
+ );
266
+ }
208
267
  });
@@ -4,7 +4,10 @@
4
4
  * selection in the adapter, the presence gate, and the live check.
5
5
  */
6
6
 
7
- import { afterEach, describe, expect, spyOn, test } from "bun:test";
7
+ import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test";
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
8
11
  import { validateProviderCredentials } from "../commands/provider-credentials";
9
12
  import { CodexAdapter, checkCodexCredentials } from "../providers/codex-adapter";
10
13
  import type { ProviderSessionConfig } from "../providers/types";
@@ -75,6 +78,22 @@ afterEach(() => {
75
78
  globalThis.fetch = realFetch;
76
79
  });
77
80
 
81
+ // The adapter reads `process.env.HOME` live (see `codexUserHome`) to decide
82
+ // whether `~/.codex/auth.json` exists. A developer's real codex login would
83
+ // make OAuth win over OPENAI_API_KEY and fail the API-key assertions, so run
84
+ // the file against an empty home. The real auth.json is never touched.
85
+ const realHome = process.env.HOME;
86
+ let isolatedHome: string | undefined;
87
+ beforeAll(() => {
88
+ isolatedHome = mkdtempSync(join(tmpdir(), "codex-gateway-route-home-"));
89
+ process.env.HOME = isolatedHome;
90
+ });
91
+ afterAll(() => {
92
+ if (realHome === undefined) delete process.env.HOME;
93
+ else process.env.HOME = realHome;
94
+ if (isolatedHome) rmSync(isolatedHome, { recursive: true, force: true });
95
+ });
96
+
78
97
  describe("codex gateway route: adapter", () => {
79
98
  test("adds a Responses provider block and keeps OAuth and OPENAI_API_KEY out of the CLI env", async () => {
80
99
  const log = spyOn(console, "log");
@@ -103,6 +122,36 @@ describe("codex gateway route: adapter", () => {
103
122
  expect(logged.some((line) => line.endsWith("Using credential: gateway"))).toBe(true);
104
123
  });
105
124
 
125
+ test("launches the app-server on the gateway provider, and on the default without a gateway", async () => {
126
+ const launched = async (env: Record<string, string>) => {
127
+ let overrides: unknown = "not launched";
128
+ const adapter = new CodexAdapter({
129
+ bypassSubprocess: true,
130
+ appServerFactory: ({ configOverrides }) => {
131
+ overrides = configOverrides;
132
+ throw new Error("test stops before app-server spawn");
133
+ },
134
+ });
135
+ await expect(adapter.createSession(testConfig({ env }))).rejects.toThrow(
136
+ "test stops before app-server spawn",
137
+ );
138
+ return overrides;
139
+ };
140
+ expect(await launched({ ...GATEWAY_ENV, OPENAI_API_KEY: "sk-openai" })).toEqual({
141
+ model_provider: "swarm-gateway",
142
+ model_providers: {
143
+ "swarm-gateway": {
144
+ name: "Swarm gateway",
145
+ base_url: "https://sub2api.example.com/v1",
146
+ env_key: "CODEX_GATEWAY_API_KEY",
147
+ wire_api: "responses",
148
+ requires_openai_auth: false,
149
+ },
150
+ },
151
+ });
152
+ expect(await launched({ OPENAI_API_KEY: "sk-openai" })).toBeUndefined();
153
+ });
154
+
106
155
  test("a gateway URL on api.openai.com is not a gateway: no provider block", async () => {
107
156
  const { codexConfig, env } = await captureSpawn({
108
157
  CODEX_GATEWAY_BASE_URL: "https://api.openai.com/v1",