@desplega.ai/agent-swarm 2.5.2 → 2.5.4

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 (236) hide show
  1. package/dist/{acp-adapter-csh0zz3b.js → acp-adapter-ek38cwsv.js} +5 -5
  2. package/dist/{actions-6xx079d1.js → actions-92mepq03.js} +19 -19
  3. package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-9gaxg015.js} +4 -4
  4. package/dist/{app-ft6r6v23.js → app-ctn8gsgx.js} +3 -3
  5. package/dist/{assistant-vt2cyfzd.js → assistant-zyr5kz47.js} +21 -21
  6. package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-dwzfttdr.js} +1 -1
  7. package/dist/{boot-reembed-r3e808ky.js → boot-reembed-w75maga2.js} +1 -1
  8. package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
  9. package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-x28v3fhb.js} +4 -4
  10. package/dist/{claude-adapter-s7j47k06.js → claude-adapter-f0afqnne.js} +5 -5
  11. package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-x81a7r65.js} +3 -3
  12. package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-bqzwjd9x.js} +2 -2
  13. package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
  14. package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
  15. package/dist/{cli-4qkg9968.js → cli-19202kew.js} +4 -4
  16. package/dist/{cli-cyfgxnvb.js → cli-1r5ad7j5.js} +19 -17
  17. package/dist/{cli-jmcjbjkt.js → cli-25n3mv5d.js} +9 -7
  18. package/dist/cli-2655k16q.js +140 -0
  19. package/dist/{cli-wadzapwb.js → cli-277c31dw.js} +1 -1
  20. package/dist/{cli-n6kmqngw.js → cli-4b2z6n0m.js} +29 -7
  21. package/dist/{cli-7ccf7akh.js → cli-5bnsc0e1.js} +9 -7
  22. package/dist/{cli-qrjv7t18.js → cli-621ce7kw.js} +1 -1
  23. package/dist/{cli-xzge46sj.js → cli-64mh9mf3.js} +20 -6
  24. package/dist/{cli-b6tnjdvx.js → cli-6a1xg1p7.js} +6 -2
  25. package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
  26. package/dist/{cli-e91vshb1.js → cli-7p4jxgtt.js} +5 -5
  27. package/dist/{cli-f3pfn4x3.js → cli-9j129brq.js} +9 -3
  28. package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
  29. package/dist/{cli-15xd0j7w.js → cli-abs8ndtr.js} +12 -12
  30. package/dist/{cli-h5d22pfs.js → cli-asq5ydm4.js} +219 -309
  31. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  32. package/dist/{cli-0vgy5wfw.js → cli-bvpgr9ny.js} +2 -2
  33. package/dist/{cli-3e9vs93g.js → cli-c61mbdds.js} +4 -4
  34. package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
  35. package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
  36. package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
  37. package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
  38. package/dist/{cli-b63d08rs.js → cli-eftzyh9y.js} +1 -1
  39. package/dist/{cli-fgn7yg5r.js → cli-ethgy7ha.js} +1 -1
  40. package/dist/{cli-86zpfje5.js → cli-ff1tm9n9.js} +1 -1
  41. package/dist/{cli-cf63dnxj.js → cli-g9vd9ece.js} +37 -12
  42. package/dist/{cli-j3dwd4kz.js → cli-gbfxp48k.js} +770 -126
  43. package/dist/{cli-n2918sed.js → cli-gn5af0x5.js} +21 -21
  44. package/dist/{cli-d172svcw.js → cli-gwhjmh8g.js} +4 -4
  45. package/dist/{cli-stxdrqs2.js → cli-hh5we352.js} +24 -5
  46. package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
  47. package/dist/{cli-csh8fnbg.js → cli-jb30dzvk.js} +473 -58
  48. package/dist/{cli-vaxw3bg0.js → cli-jbnn4vxb.js} +3 -3
  49. package/dist/{cli-ha22h0eg.js → cli-k4tjsdx9.js} +45 -40
  50. package/dist/{cli-f55b6ky0.js → cli-m4ecdxvz.js} +40 -13
  51. package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
  52. package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
  53. package/dist/{cli-3qv8naq8.js → cli-rr7zx19j.js} +1591 -353
  54. package/dist/{cli-b1tv22wr.js → cli-s682zgqz.js} +1 -1
  55. package/dist/{cli-bpbcpd8b.js → cli-sbwsxvtd.js} +1 -1
  56. package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
  57. package/dist/{cli-6y43v79h.js → cli-tsma7p8v.js} +1 -1
  58. package/dist/{cli-edtm741g.js → cli-wghjkr0e.js} +5 -5
  59. package/dist/{cli-aaqnqpyw.js → cli-wx2nj19m.js} +30 -11
  60. package/dist/{cli-y3dzwyy3.js → cli-x0f2v69c.js} +180 -60
  61. package/dist/{cli-kcnwn85g.js → cli-xtrz6bzs.js} +7 -7
  62. package/dist/{cli-14v1sxxy.js → cli-xzfq4at3.js} +7 -7
  63. package/dist/cli.js +20 -20
  64. package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-wch12q99.js} +3 -3
  65. package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
  66. package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-41pfcr95.js} +2 -2
  67. package/dist/{commands-1cnvseqx.js → commands-9849dkj6.js} +2 -2
  68. package/dist/{controller-kc7s9xhn.js → controller-ja0sd95p.js} +29 -3
  69. package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-xp05dj6x.js} +20 -20
  70. package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-623qxqew.js} +1 -2
  71. package/dist/{db-4vq88jkc.js → db-47vj40dy.js} +3 -3
  72. package/dist/{db-v5ytg5pw.js → db-w759qmx0.js} +2 -2
  73. package/dist/{dispatcher-trtcrqae.js → dispatcher-7b8e4z2j.js} +17 -17
  74. package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
  75. package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-2ezw3xea.js} +2 -3
  76. package/dist/{external-channel-n9bymc2n.js → external-channel-e2s028da.js} +2 -2
  77. package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-ys7gaqee.js} +6 -6
  78. package/dist/{handlers-ptsraqa8.js → handlers-wy1dr6ja.js} +20 -20
  79. package/dist/{heartbeat-60qtxtjf.js → heartbeat-w3mntghj.js} +20 -20
  80. package/dist/{hook-rbktcstg.js → hook-3jjywnpb.js} +9 -3
  81. package/dist/{hook-ngtvh729.js → hook-g7kvv6x9.js} +3 -2
  82. package/dist/{http-z8xy72gp.js → http-tkp7d4kb.js} +2984 -2483
  83. package/dist/{index-r70q0xjx.js → index-1ef1myq2.js} +17 -17
  84. package/dist/{index-a9nsavhx.js → index-3n8m9ea2.js} +1 -1
  85. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  86. package/dist/{index-s193j5fh.js → index-nmm79qgr.js} +20 -20
  87. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  88. package/dist/{index-06qkmcx6.js → index-q7m26kvs.js} +33 -23
  89. package/dist/{index-1cscynkb.js → index-xdaahthb.js} +17 -17
  90. package/dist/{keepalive-a9srcphj.js → keepalive-v4ta8wgg.js} +3 -3
  91. package/dist/{lead-3devneqy.js → lead-sawnyqxk.js} +35 -35
  92. package/dist/{maintenance-0er16h2y.js → maintenance-84n1ex21.js} +1 -1
  93. package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
  94. package/dist/{onboard-wqywz5py.js → onboard-sfmpe4np.js} +2 -2
  95. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-jmpst4q4.js} +4 -4
  96. package/dist/{otel-impl-vk32s884.js → otel-impl-v6cc0zve.js} +1 -1
  97. package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
  98. package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-fsk1e1zc.js} +3 -3
  99. package/dist/{rbac-roles-452665y0.js → rbac-roles-prmkdnkw.js} +2 -2
  100. package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-zxmxqa39.js} +1 -1
  101. package/dist/{render-v2-0ttsk2j4.js → render-v2-pjwxvnkw.js} +3 -3
  102. package/dist/{resume-a26kvr41.js → resume-frdtgfa0.js} +9 -5
  103. package/dist/{session-access-dgh9nkwk.js → session-access-fpez4y5q.js} +4 -4
  104. package/dist/{setup-9vc9qahm.js → setup-mq6m3m8q.js} +1 -1
  105. package/dist/{work-objects-vn8nkxwn.js → work-objects-kfahhjsq.js} +20 -20
  106. package/dist/{worker-1x1h8vnm.js → worker-v5e5rxj3.js} +35 -35
  107. package/openapi.json +3078 -2424
  108. package/package.json +7 -7
  109. package/plugin/commands/create-pr.md +2 -2
  110. package/plugin/pi-skills/create-pr/SKILL.md +2 -2
  111. package/src/be/approval-config-question.ts +389 -0
  112. package/src/be/automation-preflight.ts +37 -3
  113. package/src/be/db/approvals.ts +48 -0
  114. package/src/be/db/session-tree.ts +73 -15
  115. package/src/be/db/skill-search.ts +87 -0
  116. package/src/be/db/tasks/read.ts +29 -2
  117. package/src/be/db/tasks/write.ts +15 -5
  118. package/src/be/db/workflow-idempotency.ts +183 -0
  119. package/src/be/db/workflow-runs.ts +366 -8
  120. package/src/be/db.ts +92 -22
  121. package/src/be/migrations/239_skills_fts.sql +40 -0
  122. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  123. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  124. package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
  125. package/src/be/sealed-json.ts +1 -0
  126. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
  127. package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
  128. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  129. package/src/be/sql-connections.ts +7 -1
  130. package/src/be/swarm-config-guard.ts +25 -0
  131. package/src/be/users.ts +9 -0
  132. package/src/commands/controller.ts +33 -0
  133. package/src/commands/runner.ts +6 -2
  134. package/src/extensions/apply-task-create.ts +57 -11
  135. package/src/extensions/catalog.generated.json +4 -4
  136. package/src/extensions/contract-types.generated.ts +1 -1
  137. package/src/extensions/contract.ts +25 -0
  138. package/src/extensions/dispatcher.ts +4 -0
  139. package/src/extensions/post-bridge.ts +8 -0
  140. package/src/hooks/context-bound-note.ts +196 -0
  141. package/src/hooks/hook.ts +12 -0
  142. package/src/http/approval-requests.ts +209 -38
  143. package/src/http/config.ts +70 -53
  144. package/src/http/idempotency-key.ts +117 -0
  145. package/src/http/sessions.ts +27 -2
  146. package/src/http/workflows.ts +129 -24
  147. package/src/integrations/chat-sdk/inbound.ts +5 -4
  148. package/src/prompts/context-bound-note.ts +48 -0
  149. package/src/prompts/session-templates.ts +25 -1
  150. package/src/providers/amp-adapter.ts +30 -8
  151. package/src/providers/claude-adapter.ts +29 -4
  152. package/src/providers/claude-managed-adapter.ts +26 -0
  153. package/src/providers/claude-sdk-session.ts +10 -0
  154. package/src/providers/claude-session-events.ts +13 -0
  155. package/src/providers/codex-adapter.ts +12 -0
  156. package/src/providers/dsh-adapter.ts +1 -1
  157. package/src/providers/opencode-adapter.ts +18 -0
  158. package/src/providers/pi-mono-adapter.ts +35 -0
  159. package/src/runtime-providers/docker.ts +6 -2
  160. package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
  161. package/src/tests/amp-adapter.test.ts +97 -0
  162. package/src/tests/approval-config-question.test.ts +702 -0
  163. package/src/tests/approval-requests.test.ts +10 -7
  164. package/src/tests/approval-sweeps.test.ts +12 -6
  165. package/src/tests/base-prompt.test.ts +1 -1
  166. package/src/tests/chat-sdk-inbound.test.ts +20 -0
  167. package/src/tests/claude-hook-output.test.ts +79 -1
  168. package/src/tests/claude-managed-adapter.test.ts +275 -0
  169. package/src/tests/codex-adapter.test.ts +163 -0
  170. package/src/tests/context-bound-note.test.ts +269 -0
  171. package/src/tests/dsh-adapter.test.ts +1 -1
  172. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  173. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  174. package/src/tests/extensions-pre-task.test.ts +273 -0
  175. package/src/tests/fetch-resolved-env.test.ts +17 -0
  176. package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
  177. package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
  178. package/src/tests/fixtures/fake-amp.ts +22 -8
  179. package/src/tests/fixtures/held-template-render.ts +42 -0
  180. package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
  181. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  182. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  183. package/src/tests/openapi-route-headers.test.ts +2 -0
  184. package/src/tests/opencode-adapter.test.ts +134 -0
  185. package/src/tests/pi-mono-adapter.test.ts +105 -0
  186. package/src/tests/prompt-template-session.test.ts +4 -3
  187. package/src/tests/runner-push-delivery.test.ts +25 -0
  188. package/src/tests/runtime-controller-liveness.test.ts +133 -0
  189. package/src/tests/runtime-providers-docker.test.ts +2 -0
  190. package/src/tests/script-connections.test.ts +9 -0
  191. package/src/tests/script-run-config-scope.test.ts +91 -3
  192. package/src/tests/session-logs.test.ts +70 -0
  193. package/src/tests/sessions-tree.test.ts +205 -1
  194. package/src/tests/skill-search-long-content.test.ts +193 -0
  195. package/src/tests/skill-search.test.ts +173 -22
  196. package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
  197. package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
  198. package/src/tests/workflow-idempotency.test.ts +915 -0
  199. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  200. package/src/tools/request-human-input.ts +27 -5
  201. package/src/tools/workflows/cancel-workflow-run.ts +1 -1
  202. package/src/types.ts +7 -0
  203. package/src/utils/context-window.ts +8 -6
  204. package/src/workflows/approval-cancel.ts +15 -13
  205. package/src/workflows/approval-notifications.ts +15 -4
  206. package/src/workflows/checkpoint.ts +72 -35
  207. package/src/workflows/engine.ts +262 -93
  208. package/src/workflows/executors/base.ts +6 -0
  209. package/src/workflows/executors/foreach.ts +2 -2
  210. package/src/workflows/executors/human-in-the-loop.ts +25 -1
  211. package/src/workflows/executors/sub-workflow.ts +45 -16
  212. package/src/workflows/foreach-driver.ts +10 -3
  213. package/src/workflows/foreach-join.ts +29 -6
  214. package/src/workflows/recovery.ts +68 -1
  215. package/src/workflows/resume.ts +228 -77
  216. package/src/workflows/retry-poller.ts +74 -7
  217. package/src/workflows/task-step-routing.ts +29 -7
  218. package/templates/ai-toolbox.manifest.json +8 -8
  219. package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
  220. package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
  221. package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
  222. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  223. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  224. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  225. package/templates/skills/agent-browser/SKILL.md +4 -4
  226. package/templates/skills/agent-browser/content.md +4 -4
  227. package/templates/skills/code-quality/config.json +1 -1
  228. package/templates/skills/code-quality/content.md +2 -2
  229. package/templates/skills/delegate-work/content.md +29 -25
  230. package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
  231. package/templates/skills/qa/content.md +2 -2
  232. package/templates/skills/researching/content.md +2 -0
  233. package/templates/skills/turso-interaction/content.md +1 -1
  234. package/templates/skills/work-on-task/SKILL.md +1 -1
  235. package/templates/skills/work-on-task/content.md +1 -1
  236. package/templates/skills/workflow-iterate/content.md +8 -1
@@ -10,6 +10,7 @@
10
10
  import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test";
11
11
  import type { OpenCodeEvent } from "@opencode/sdk";
12
12
  import { normalizeSessionLogs, type SessionLogRecord } from "../../apps/ui/src/logs-parser";
13
+ import { renderContextBoundNote } from "../prompts/context-bound-note";
13
14
  import {
14
15
  _setOpencodeSdkLoaderForTests,
15
16
  _setOpencodeSessionHooksForTests,
@@ -22,6 +23,7 @@ import {
22
23
  import { createSkillInvokeTracker, type SkillInvoke } from "../providers/skill-invoke";
23
24
  import type { ProviderEvent, ProviderSessionConfig } from "../providers/types";
24
25
  import { clearVolatileSecretsForTesting } from "../utils/secret-scrubber";
26
+ import { HELD_NOTE_TEXT, holdTemplateRender } from "./fixtures/held-template-render";
25
27
 
26
28
  const SESSION_ID = "ses_test";
27
29
 
@@ -68,6 +70,7 @@ type FakeHostOptions = {
68
70
  tools?: string[];
69
71
  /** Model responses handed to the plugin's `http.response` hook at startup. */
70
72
  responses?: Response[];
73
+ syntheticError?: Error;
71
74
  };
72
75
 
73
76
  /**
@@ -85,6 +88,7 @@ function makeFakeHost(events: OpenCodeEvent[], opts: FakeHostOptions = {}) {
85
88
  createOptions: undefined as unknown as Record<string, unknown>,
86
89
  created: [] as unknown[],
87
90
  prompts: [] as Array<Record<string, unknown>>,
91
+ synthetics: [] as Array<Record<string, unknown>>,
88
92
  interrupts: [] as unknown[],
89
93
  closeCalls: 0,
90
94
  mcpListCalls: 0,
@@ -116,6 +120,11 @@ function makeFakeHost(events: OpenCodeEvent[], opts: FakeHostOptions = {}) {
116
120
  host.interrupts.push(input);
117
121
  return {};
118
122
  },
123
+ synthetic: async (input: Record<string, unknown>) => {
124
+ host.synthetics.push(input);
125
+ if (opts.syntheticError) throw opts.syntheticError;
126
+ return {};
127
+ },
119
128
  },
120
129
  events: {
121
130
  async *subscribe() {
@@ -815,6 +824,131 @@ describe("OpencodeSession — abort and steering", () => {
815
824
  });
816
825
  });
817
826
 
827
+ describe("OpencodeSession — context-bound note", () => {
828
+ const boundConfig = (threshold: string) =>
829
+ testConfig({ env: { CONTEXT_BOUND_NOTE_TOKENS: threshold } });
830
+ const step = (sessionID: string, input: number, read = 0, write = 0) =>
831
+ ev("session.step.ended", { sessionID, tokens: tokens(input, 10, read, write) });
832
+
833
+ test("steers the note once at the threshold, not again on a larger step", async () => {
834
+ const { session, host } = await startSession([], boundConfig("1000"));
835
+ // 600 input + 300 cache read + 100 cache write = 1,000 (output does not count).
836
+ session.handleOpencodeEvent(step(SESSION_ID, 500, 300, 100));
837
+ session.handleOpencodeEvent(step(SESSION_ID, 600, 300, 100));
838
+ session.handleOpencodeEvent(step(SESSION_ID, 5_000));
839
+ await waitFor(() => host.synthetics.length > 0);
840
+ await Bun.sleep(20);
841
+ expect(host.synthetics).toEqual([
842
+ {
843
+ sessionID: SESSION_ID,
844
+ text: await renderContextBoundNote(1_000, "openrouter/google/gemini-3-flash-preview"),
845
+ delivery: "steer",
846
+ resume: false,
847
+ },
848
+ ]);
849
+ expect(host.synthetics[0]?.text).toContain("1,000");
850
+ session.handleOpencodeEvent(succeeded);
851
+ expect((await session.waitForCompletion()).isError).toBe(false);
852
+ });
853
+
854
+ test("the note shows the window share for a known model and omits it for an unknown one", async () => {
855
+ const noteFor = async (model: string) => {
856
+ const { session, host } = await startSession(
857
+ [],
858
+ testConfig({ model, env: { CONTEXT_BOUND_NOTE_TOKENS: "1000" } }),
859
+ );
860
+ session.handleOpencodeEvent(step(SESSION_ID, 524_288));
861
+ await waitFor(() => host.synthetics.length > 0);
862
+ session.handleOpencodeEvent(succeeded);
863
+ await session.waitForCompletion();
864
+ return host.synthetics[0]?.text;
865
+ };
866
+ const rest =
867
+ "Keep this task bounded: finish the current step, store-progress, and complete or defer instead of starting new exploration.";
868
+ expect(await noteFor("openrouter/google/gemini-3-flash-preview")).toBe(
869
+ `Context is 524,288 tokens (50% of the 1,048,576-token window). ${rest}`,
870
+ );
871
+ expect(await noteFor("openrouter/made-up/model")).toBe(`Context is 524,288 tokens. ${rest}`);
872
+ });
873
+
874
+ test("off when CONTEXT_BOUND_NOTE_TOKENS is unset", async () => {
875
+ const { session, host } = await startSession([]);
876
+ session.handleOpencodeEvent(step(SESSION_ID, 900_000));
877
+ await Bun.sleep(20);
878
+ expect(host.synthetics).toEqual([]);
879
+ session.handleOpencodeEvent(succeeded);
880
+ await session.waitForCompletion();
881
+ });
882
+
883
+ test("no-op below the threshold and for a child session's steps", async () => {
884
+ const { session, host } = await startSession([], boundConfig("1000"));
885
+ session.handleOpencodeEvent(step(SESSION_ID, 999));
886
+ session.handleOpencodeEvent(step("ses_child", 50_000));
887
+ await Bun.sleep(20);
888
+ expect(host.synthetics).toEqual([]);
889
+ session.handleOpencodeEvent(succeeded);
890
+ await session.waitForCompletion();
891
+ });
892
+
893
+ test("off at 0", async () => {
894
+ const { session, host } = await startSession([], boundConfig("0"));
895
+ session.handleOpencodeEvent(step(SESSION_ID, 250_000));
896
+ await Bun.sleep(20);
897
+ expect(host.synthetics).toEqual([]);
898
+ session.handleOpencodeEvent(succeeded);
899
+ await session.waitForCompletion();
900
+ });
901
+
902
+ test("fails open on corrupt step tokens", async () => {
903
+ const { session, host } = await startSession([], boundConfig("1000"));
904
+ session.handleOpencodeEvent(
905
+ ev("session.step.ended", {
906
+ sessionID: SESSION_ID,
907
+ tokens: { input: "lots", output: 1, reasoning: 0, cache: { read: null, write: {} } },
908
+ }),
909
+ );
910
+ await Bun.sleep(20);
911
+ expect(host.synthetics).toEqual([]);
912
+ session.handleOpencodeEvent(succeeded);
913
+ expect((await session.waitForCompletion()).isError).toBe(false);
914
+ });
915
+
916
+ test("a rejected steer still lets the session complete", async () => {
917
+ const { session, host } = await startSession([], boundConfig("1000"), {
918
+ syntheticError: new Error("synthetic refused"),
919
+ });
920
+ session.handleOpencodeEvent(step(SESSION_ID, 5_000));
921
+ await waitFor(() => host.synthetics.length > 0);
922
+ session.handleOpencodeEvent(succeeded);
923
+ expect((await session.waitForCompletion()).isError).toBe(false);
924
+ });
925
+
926
+ test("a note rendered after the session completed is dropped", async () => {
927
+ const lateSynthetics = async (releaseInSession: boolean) => {
928
+ const held = holdTemplateRender();
929
+ try {
930
+ const { session, host } = await startSession([], boundConfig("1000"));
931
+ session.handleOpencodeEvent(step(SESSION_ID, 5_000));
932
+ await held.requested;
933
+ if (releaseInSession) {
934
+ held.release();
935
+ await waitFor(() => host.synthetics.length > 0);
936
+ }
937
+ session.handleOpencodeEvent(succeeded);
938
+ expect((await session.waitForCompletion()).isError).toBe(false);
939
+ held.release();
940
+ await Bun.sleep(50);
941
+ return host.synthetics.map((s) => s.text);
942
+ } finally {
943
+ held.stop();
944
+ }
945
+ };
946
+ expect(await lateSynthetics(false)).toEqual([]);
947
+ // Control: ready while the session runs.
948
+ expect(await lateSynthetics(true)).toEqual([HELD_NOTE_TEXT]);
949
+ });
950
+ });
951
+
818
952
  describe("OpenRouter attribution config", () => {
819
953
  test("overrides harness attribution while preserving custom headers and options", () => {
820
954
  const config = {
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import { OPENAI_COMPATIBLE_PROVIDERS } from "@desplega/model-catalog";
14
14
  import * as piCodingAgent from "@earendil-works/pi-coding-agent";
15
+ import { renderContextBoundNote } from "../prompts/context-bound-note";
15
16
  import { configureHttpResolver, resetHttpResolver } from "../prompts/resolver";
16
17
  import {
17
18
  checkPiMonoCredentials,
@@ -24,6 +25,7 @@ import {
24
25
  writeSessionModelsJson,
25
26
  } from "../providers/pi-mono-adapter";
26
27
  import type { ProviderSessionConfig } from "../providers/types";
28
+ import { HELD_NOTE_TEXT, holdTemplateRender } from "./fixtures/held-template-render";
27
29
 
28
30
  describe("PiMonoAdapter", () => {
29
31
  test("name is 'pi'", () => {
@@ -1187,6 +1189,8 @@ function makeMockAgentSession(opts: {
1187
1189
  steerCalls?: string[];
1188
1190
  followUpCalls?: string[];
1189
1191
  steeringError?: string;
1192
+ /** Awaited after each call's events, as a real turn keeps running its tools. */
1193
+ afterEvents?: () => Promise<unknown>;
1190
1194
  }): AgentSession {
1191
1195
  const listeners: Array<(event: AgentSessionEvent) => void> = [];
1192
1196
  const scripts = opts.turns ?? [opts.events ?? []];
@@ -1210,6 +1214,7 @@ function makeMockAgentSession(opts: {
1210
1214
  for (const event of scripts[call] ?? []) {
1211
1215
  for (const l of listeners) l(event);
1212
1216
  }
1217
+ await opts.afterEvents?.();
1213
1218
  if (opts.throwError) throw new Error(opts.throwError);
1214
1219
  },
1215
1220
  getContextUsage: () => null,
@@ -1754,3 +1759,103 @@ describe("classifyAwsSdkError — all 4 categories (quick summary)", () => {
1754
1759
  }
1755
1760
  });
1756
1761
  });
1762
+
1763
+ describe("PiMonoSession context-bound note", () => {
1764
+ /** An assistant `message_end` with this request usage; a tool call unless `final`. */
1765
+ function assistantTurn(usage: unknown, final = false): AgentSessionEvent {
1766
+ return {
1767
+ type: "message_end",
1768
+ message: {
1769
+ role: "assistant",
1770
+ content: final
1771
+ ? [{ type: "text", text: "Done" }]
1772
+ : [{ type: "toolCall", id: "call-1", name: "bash", arguments: {} }],
1773
+ stopReason: final ? "stop" : "toolUse",
1774
+ usage,
1775
+ },
1776
+ } as unknown as AgentSessionEvent;
1777
+ }
1778
+
1779
+ const usage = (input: number) => ({ input, cacheRead: 300, cacheWrite: 200, output: 50 });
1780
+
1781
+ async function steersFor(
1782
+ threshold: string,
1783
+ events: AgentSessionEvent[],
1784
+ afterEvents: () => Promise<unknown> = () => Bun.sleep(50),
1785
+ model?: string,
1786
+ ): Promise<string[]> {
1787
+ const steerCalls: string[] = [];
1788
+ const session = new PiMonoSession(
1789
+ makeMockAgentSession({ events, steerCalls, afterEvents }),
1790
+ {
1791
+ ...makeSessionConfig(join(tmpLogDir, `bound-note-${crypto.randomUUID()}.log`)),
1792
+ env: { CONTEXT_BOUND_NOTE_TOKENS: threshold },
1793
+ ...(model ? { model } : {}),
1794
+ },
1795
+ false,
1796
+ );
1797
+ await session.waitForCompletion();
1798
+ return steerCalls;
1799
+ }
1800
+
1801
+ test("steers the note once when a tool-call request reaches the threshold", async () => {
1802
+ const steers = await steersFor("1000", [
1803
+ assistantTurn(usage(400)),
1804
+ assistantTurn(usage(500)),
1805
+ assistantTurn(usage(900)),
1806
+ assistantTurn(usage(1500), true),
1807
+ ]);
1808
+ // 500 + 300 + 200 reaches 1000; the larger later request does not steer again.
1809
+ expect(steers).toEqual([await renderContextBoundNote(1000)]);
1810
+ });
1811
+
1812
+ test("the note shows the window share for a known model and omits it for an unknown one", async () => {
1813
+ const events = () => [assistantTurn(usage(209_500)), assistantTurn(usage(400), true)];
1814
+ const rest =
1815
+ "Keep this task bounded: finish the current step, store-progress, and complete or defer instead of starting new exploration.";
1816
+ expect(await steersFor("200000", events(), undefined, "anthropic/claude-sonnet-4-6")).toEqual([
1817
+ `Context is 210,000 tokens (21% of the 1,000,000-token window). ${rest}`,
1818
+ ]);
1819
+ expect(await steersFor("200000", events(), undefined, "made-up/model")).toEqual([
1820
+ `Context is 210,000 tokens. ${rest}`,
1821
+ ]);
1822
+ });
1823
+
1824
+ test("no-op below the threshold and when disabled at 0", async () => {
1825
+ const events = [assistantTurn(usage(400)), assistantTurn(usage(400), true)];
1826
+ expect(await steersFor("1000", events)).toEqual([]);
1827
+ expect(await steersFor("0", events)).toEqual([]);
1828
+ });
1829
+
1830
+ test("fails open on missing or corrupt usage", async () => {
1831
+ const events = [
1832
+ assistantTurn(undefined),
1833
+ assistantTurn({ input: "lots", cacheRead: {}, cacheWrite: null }),
1834
+ assistantTurn(undefined, true),
1835
+ ];
1836
+ expect(await steersFor("1", events)).toEqual([]);
1837
+ });
1838
+
1839
+ test("never steers after the final reply, which would start another turn", async () => {
1840
+ expect(await steersFor("1", [assistantTurn(usage(5000), true)])).toEqual([]);
1841
+ });
1842
+
1843
+ test("a note rendered after its tool turn ended is dropped", async () => {
1844
+ const turnEnd = { type: "turn_end" } as unknown as AgentSessionEvent;
1845
+ const lateSteers = async (events: AgentSessionEvent[]) => {
1846
+ const held = holdTemplateRender();
1847
+ try {
1848
+ return await steersFor("1", events, async () => {
1849
+ held.release();
1850
+ await Bun.sleep(50);
1851
+ });
1852
+ } finally {
1853
+ held.stop();
1854
+ }
1855
+ };
1856
+ // Pi already read steering for that turn; the next reply could be the final one.
1857
+ expect(await lateSteers([assistantTurn(usage(5000)), turnEnd])).toEqual([]);
1858
+ // Control: ready while the tool turn is still running.
1859
+ expect(await lateSteers([assistantTurn(usage(5000))])).toEqual([HELD_NOTE_TEXT]);
1860
+ });
1861
+ });
@@ -22,6 +22,7 @@ const SYSTEM_TEMPLATES = [
22
22
  "system.agent.communication",
23
23
  "system.agent.communication.remote",
24
24
  "system.agent.compaction.goal_reminder",
25
+ "system.agent.context.bound_note",
25
26
  "system.agent.cursor.first_message",
26
27
  "system.agent.lead",
27
28
  "system.agent.memory",
@@ -117,11 +118,11 @@ describe("Session templates: registration", () => {
117
118
  expect(sessionSystemEventTypes("session")).toEqual([...SESSION_TEMPLATES].sort());
118
119
  });
119
120
 
120
- test("registers 31 system and session templates in total", () => {
121
+ test("registers 32 system and session templates in total", () => {
121
122
  const all = getAllTemplateDefinitions();
122
123
  const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
123
- // 26 system blocks + 5 session composites.
124
- expect(sessionSystem.length).toBe(31);
124
+ // 27 system blocks + 5 session composites.
125
+ expect(sessionSystem.length).toBe(32);
125
126
  });
126
127
 
127
128
  test("drops the v1 blocks that prompt v2 deleted", () => {
@@ -139,6 +139,31 @@ async function nudge(kind: WorkerNudge["kind"], taskId?: string) {
139
139
  expect((await waiting)?.kind).toBe(kind);
140
140
  }
141
141
 
142
+ test("shutdown stops an open poll window before it can claim a resume", async () => {
143
+ let shuttingDown = false;
144
+ // The second poll would claim the resume created during shutdown.
145
+ pollTriggerAfter = 2;
146
+ const sleep = spyOn(Bun, "sleep").mockImplementation(async () => {
147
+ expect(requests.filter((r) => r === "GET /api/poll")).toHaveLength(1);
148
+ shuttingDown = true;
149
+ });
150
+ try {
151
+ const trigger = await pollForTrigger({
152
+ apiUrl,
153
+ apiKey,
154
+ agentId,
155
+ pollInterval: 2,
156
+ pollTimeout: 5000,
157
+ shouldStop: () => shuttingDown,
158
+ });
159
+ expect(shuttingDown).toBe(true);
160
+ expect(trigger).toBeNull();
161
+ expect(requests.filter((r) => r === "GET /api/poll")).toHaveLength(1);
162
+ } finally {
163
+ sleep.mockRestore();
164
+ }
165
+ });
166
+
142
167
  async function poll(now: number, mode: DeliveryMode = "push") {
143
168
  const checks = delivery.checks(mode, now);
144
169
  const schedule = delivery.schedule({
@@ -0,0 +1,133 @@
1
+ import { afterEach, expect, spyOn, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import { createServer as createHttpServer } from "node:http";
4
+ import { createServer as createNetServer, type Socket } from "node:net";
5
+ import { WebSocketServer } from "ws";
6
+ import { startControllerClient } from "../commands/controller";
7
+ import { CONTROLLER_PROTOCOL_VERSION } from "../runtime-providers/controller-protocol";
8
+ import type { RuntimeProvider } from "../runtime-providers/types";
9
+ import { listenOnFreePort } from "./test-net";
10
+
11
+ const LIVENESS_MS = 300;
12
+ const WELCOME = JSON.stringify({
13
+ type: "welcome",
14
+ controllerId: "controller-1",
15
+ version: CONTROLLER_PROTOCOL_VERSION,
16
+ });
17
+
18
+ const provider = {
19
+ kind: "docker",
20
+ traits: { canProvision: true, ephemeralDisk: true, supportsPause: false },
21
+ checkConfig: () => ({ ready: true, missing: [] }),
22
+ } as unknown as RuntimeProvider;
23
+
24
+ const cleanups: Array<() => void> = [];
25
+ afterEach(() => {
26
+ for (const cleanup of cleanups.splice(0).reverse()) cleanup();
27
+ });
28
+
29
+ // A raw TCP WebSocket peer. Under Bun, `ws` maps to a native shim that always answers
30
+ // pings, so it cannot model a dead link. This peer completes the handshake, sends
31
+ // welcome, and then never answers a frame: what a cut network looks like to the client.
32
+ async function startRawServer(options: { pingEveryMs?: number } = {}) {
33
+ let connections = 0;
34
+ const sockets: Socket[] = [];
35
+ const server = createNetServer((socket) => {
36
+ sockets.push(socket);
37
+ socket.once("data", (data) => {
38
+ const key = /sec-websocket-key: *(\S+)/i.exec(data.toString())?.[1];
39
+ const accept = createHash("sha1")
40
+ .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
41
+ .digest("base64");
42
+ socket.write(
43
+ `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`,
44
+ );
45
+ const payload = Buffer.from(WELCOME);
46
+ socket.write(Buffer.concat([Buffer.from([0x81, payload.length]), payload]));
47
+ connections++;
48
+ if (options.pingEveryMs) {
49
+ const timer = setInterval(
50
+ () => socket.write(Buffer.from([0x89, 0x00])),
51
+ options.pingEveryMs,
52
+ );
53
+ socket.once("close", () => clearInterval(timer));
54
+ }
55
+ });
56
+ socket.on("error", () => {});
57
+ });
58
+ const port = await listenOnFreePort(server);
59
+ cleanups.push(() => {
60
+ for (const socket of sockets) socket.destroy();
61
+ server.close();
62
+ });
63
+ return { apiUrl: `http://127.0.0.1:${port}`, connections: () => connections };
64
+ }
65
+
66
+ function startClient(apiUrl: string) {
67
+ const logSpy = spyOn(console, "log").mockImplementation(() => {});
68
+ const client = startControllerClient({
69
+ apiUrl,
70
+ token: "aseph_test-token",
71
+ provider,
72
+ target: {},
73
+ livenessTimeoutMs: LIVENESS_MS,
74
+ });
75
+ cleanups.push(() => {
76
+ client.stop();
77
+ logSpy.mockRestore();
78
+ });
79
+ return { logs: () => logSpy.mock.calls.flat().join("\n") };
80
+ }
81
+
82
+ async function until(check: () => boolean, timeoutMs: number) {
83
+ const deadline = Date.now() + timeoutMs;
84
+ while (!check()) {
85
+ if (Date.now() > deadline) throw new Error("Timed out waiting for condition");
86
+ await Bun.sleep(20);
87
+ }
88
+ }
89
+
90
+ test("a server that goes silent without closing makes the client reconnect", async () => {
91
+ const server = await startRawServer();
92
+ const { logs } = startClient(server.apiUrl);
93
+ await until(() => logs().includes("Connected as controller-1"), 2000);
94
+ const silentAt = Date.now();
95
+ // Detection within the timeout plus one check interval, then the first backoff (at most 1000ms).
96
+ await until(() => server.connections() === 2, LIVENESS_MS * 2 + 1500);
97
+ expect(logs()).toContain(`No traffic for ${LIVENESS_MS}ms`);
98
+ expect(logs()).toContain("Disconnected. Reconnect in");
99
+ expect(Date.now() - silentAt).toBeGreaterThanOrEqual(LIVENESS_MS);
100
+ });
101
+
102
+ test("server ping frames keep an idle controller connected", async () => {
103
+ // Matches the API: it pings on an interval and sends no app message to an idle controller.
104
+ // This peer never answers the client's pings, so only the server pings count.
105
+ const server = await startRawServer({ pingEveryMs: LIVENESS_MS / 3 });
106
+ const { logs } = startClient(server.apiUrl);
107
+ await until(() => logs().includes("Connected as controller-1"), 2000);
108
+ await Bun.sleep(LIVENESS_MS * 4);
109
+ expect(server.connections()).toBe(1);
110
+ expect(logs()).not.toContain("Disconnected");
111
+ });
112
+
113
+ test("pongs to client pings keep an idle controller connected", async () => {
114
+ // A healthy peer that sends nothing on its own still answers pings.
115
+ const http = createHttpServer();
116
+ const wss = new WebSocketServer({ server: http });
117
+ let connections = 0;
118
+ wss.on("connection", (ws) => {
119
+ connections++;
120
+ ws.send(WELCOME);
121
+ });
122
+ const port = await listenOnFreePort(http);
123
+ cleanups.push(() => {
124
+ for (const ws of wss.clients) ws.terminate();
125
+ wss.close();
126
+ http.close();
127
+ });
128
+ const { logs } = startClient(`http://127.0.0.1:${port}`);
129
+ await until(() => logs().includes("Connected as controller-1"), 2000);
130
+ await Bun.sleep(LIVENESS_MS * 4);
131
+ expect(connections).toBe(1);
132
+ expect(logs()).not.toContain("Disconnected");
133
+ });
@@ -310,6 +310,7 @@ describe("Docker runtime provider", () => {
310
310
  await provider.terminate(target, handle);
311
311
  expect(calls[0]?.init.method).toBe("DELETE");
312
312
  expect(calls[0]?.url.searchParams.get("force")).toBe("true");
313
+ expect(calls[0]?.url.searchParams.get("v")).toBe("true");
313
314
  });
314
315
 
315
316
  test("termination succeeds when the container is already absent", async () => {
@@ -376,6 +377,7 @@ describe("Docker runtime provider", () => {
376
377
  "DELETE /v1.47/containers/container-1",
377
378
  ]);
378
379
  expect(calls[2]?.url.searchParams.get("force")).toBe("true");
380
+ expect(calls[2]?.url.searchParams.get("v")).toBe("true");
379
381
  }
380
382
  });
381
383
 
@@ -570,6 +570,9 @@ describe("script connections", () => {
570
570
  markMigrationApplied(database, "151_repair_scheduled_task_required_params.sql");
571
571
  // 152 indexes task routing context absent from this migration-112-only fixture.
572
572
  markMigrationApplied(database, "152_routing_decisions.sql");
573
+ // 153 drops a column, so SQLite revalidates 144's agent_tasks triggers
574
+ // against this fixture's id-only agent_tasks (Bun < 1.4.4 hid the error).
575
+ markMigrationApplied(database, "153_deferred_task_wait_members.sql");
573
576
  // 156 alters inbox_item_state, which this migration-112-only fixture does not create.
574
577
  markMigrationApplied(database, "156_inbox_item_state_read_at.sql");
575
578
  // 157 alters scheduled_tasks and slack_messages, neither of which this
@@ -642,6 +645,12 @@ describe("script connections", () => {
642
645
  markMigrationApplied(database, "235_chat_relay_defer.sql");
643
646
  // 237 reads approval_requests and inbox_item_state, which this migration-112-only fixture does not create.
644
647
  markMigrationApplied(database, "237_session_inbox_cleanup.sql");
648
+ // 239 indexes skills, which this migration-112-only fixture does not create.
649
+ markMigrationApplied(database, "239_skills_fts.sql");
650
+ // 240 alters workflow_runs, which this migration-112-only fixture does not create.
651
+ markMigrationApplied(database, "240_workflow_run_parent_step_attempt.sql");
652
+ // 241 indexes skills, which this migration-112-only fixture does not create.
653
+ markMigrationApplied(database, "241_skills_meta_fts.sql");
645
654
 
646
655
  const id = crypto.randomUUID();
647
656
  const now = new Date().toISOString();
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { createServer, type Server } from "node:http";
4
4
  import { closeDb, createAgent, initDb, upsertSwarmConfig } from "../be/db";
5
- import { mintScriptRunToken, revokeSessionToken } from "../be/users";
5
+ import { mintAgentToken, mintScriptRunToken, revokeSessionToken } from "../be/users";
6
6
  import { handleConfig } from "../http/config";
7
7
  import { handleCore } from "../http/core";
8
8
  import { handleSessions } from "../http/sessions";
@@ -17,12 +17,16 @@ import { listenOnFreePort } from "./test-net";
17
17
  const API_KEY = `script-run-config-operator-${randomBytes(8).toString("hex")}`;
18
18
  const WORKER_SECRET = `worker-secret-${randomBytes(8).toString("hex")}`;
19
19
  const OTHER_SECRET = `other-secret-${randomBytes(8).toString("hex")}`;
20
+ const GLOBAL_SECRET = `global-secret-${randomBytes(8).toString("hex")}`;
21
+ const SHADOWED_GLOBAL = `shadowed-global-${randomBytes(8).toString("hex")}`;
22
+ const SHADOWING_AGENT = `shadowing-agent-${randomBytes(8).toString("hex")}`;
20
23
 
21
24
  let server: Server;
22
25
  let baseUrl: string;
23
26
  let workerId: string;
24
27
  let otherId: string;
25
28
  let otherConfigId: string;
29
+ let ownConfigId: string;
26
30
 
27
31
  function headers(bearer: string, agentId?: string): Record<string, string> {
28
32
  return {
@@ -36,11 +40,32 @@ beforeAll(async () => {
36
40
  initDb(":memory:");
37
41
  workerId = (await createAgent({ name: "script-run-worker", isLead: false, status: "idle" })).id;
38
42
  otherId = (await createAgent({ name: "script-run-other", isLead: false, status: "idle" })).id;
43
+ ownConfigId = (
44
+ await upsertSwarmConfig({
45
+ scope: "agent",
46
+ scopeId: workerId,
47
+ key: "OWN_SECRET",
48
+ value: WORKER_SECRET,
49
+ isSecret: true,
50
+ })
51
+ ).id;
52
+ await upsertSwarmConfig({
53
+ scope: "global",
54
+ key: "GLOBAL_SECRET",
55
+ value: GLOBAL_SECRET,
56
+ isSecret: true,
57
+ });
58
+ await upsertSwarmConfig({
59
+ scope: "global",
60
+ key: "SHADOWED",
61
+ value: SHADOWED_GLOBAL,
62
+ isSecret: true,
63
+ });
39
64
  await upsertSwarmConfig({
40
65
  scope: "agent",
41
66
  scopeId: workerId,
42
- key: "OWN_SECRET",
43
- value: WORKER_SECRET,
67
+ key: "SHADOWED",
68
+ value: SHADOWING_AGENT,
44
69
  isSecret: true,
45
70
  });
46
71
  otherConfigId = (
@@ -118,3 +143,66 @@ describe("script run token is scoped to its executing agent", () => {
118
143
  }
119
144
  });
120
145
  });
146
+
147
+ // `ctx.swarm.config` holds the run's resolved config (own agent over global).
148
+ // The raw list and by-id reads must hand the run's token those same values,
149
+ // and only those, or a script that reads a key through `config_list` gets a
150
+ // masked placeholder and fails while the run already holds the real value.
151
+ describe("script run token reads its held secrets on the raw config routes", () => {
152
+ test("list reveals the rows the run holds and masks the rest", async () => {
153
+ const run = await mintScriptRunToken(workerId, 60_000);
154
+ try {
155
+ const res = await fetch(`${baseUrl}/api/config?includeSecrets=true`, {
156
+ headers: headers(run.plaintext),
157
+ });
158
+ expect(res.status).toBe(200);
159
+ const body = await res.text();
160
+ expect(body).toContain(WORKER_SECRET);
161
+ expect(body).toContain(GLOBAL_SECRET);
162
+ expect(body).toContain(SHADOWING_AGENT);
163
+ // The run holds the agent row for SHADOWED, not the global one.
164
+ expect(body).not.toContain(SHADOWED_GLOBAL);
165
+ expect(body).not.toContain(OTHER_SECRET);
166
+ } finally {
167
+ await revokeSessionToken(run.tokenId);
168
+ }
169
+ });
170
+
171
+ test("by-id reveals an own row and masks another agent's row", async () => {
172
+ const run = await mintScriptRunToken(workerId, 60_000);
173
+ try {
174
+ const own = await fetch(`${baseUrl}/api/config/${ownConfigId}?includeSecrets=true`, {
175
+ headers: headers(run.plaintext),
176
+ });
177
+ expect(await own.text()).toContain(WORKER_SECRET);
178
+ const other = await fetch(`${baseUrl}/api/config/${otherConfigId}?includeSecrets=true`, {
179
+ headers: headers(run.plaintext),
180
+ });
181
+ expect(other.status).toBe(200);
182
+ expect(await other.text()).not.toContain(OTHER_SECRET);
183
+ } finally {
184
+ await revokeSessionToken(run.tokenId);
185
+ }
186
+ });
187
+
188
+ test("without includeSecrets the run token still gets masked values", async () => {
189
+ const run = await mintScriptRunToken(workerId, 60_000);
190
+ try {
191
+ const res = await fetch(`${baseUrl}/api/config`, { headers: headers(run.plaintext) });
192
+ expect(await res.text()).not.toContain(WORKER_SECRET);
193
+ } finally {
194
+ await revokeSessionToken(run.tokenId);
195
+ }
196
+ });
197
+
198
+ test("the worker's own agent token keeps the masked list", async () => {
199
+ const worker = await mintAgentToken(workerId);
200
+ const res = await fetch(`${baseUrl}/api/config?includeSecrets=true`, {
201
+ headers: headers(worker.plaintext),
202
+ });
203
+ expect(res.status).toBe(200);
204
+ const body = await res.text();
205
+ expect(body).not.toContain(WORKER_SECRET);
206
+ expect(body).not.toContain(GLOBAL_SECRET);
207
+ });
208
+ });