@desplega.ai/agent-swarm 1.147.0 → 1.149.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +13 -1
  2. package/dist/{acp-adapter-9n319wqc.js → acp-adapter-4jncb126.js} +12 -5
  3. package/dist/{actions-2vxqvpr9.js → actions-31vp3ae7.js} +10 -9
  4. package/dist/{app-w066xfy0.js → app-1hh67x8w.js} +6 -6
  5. package/dist/{assistant-kt31dj2h.js → assistant-z66dmz6p.js} +13 -11
  6. package/dist/{boot-reembed-az5rassp.js → boot-reembed-fqzxxzjh.js} +7 -7
  7. package/dist/{boot-reembed-j3mm3rfz.js → boot-reembed-wxa8ya0s.js} +8 -8
  8. package/dist/{boot-scrub-logs-b6h54817.js → boot-scrub-logs-jnyndh79.js} +5 -5
  9. package/dist/{claude-adapter-w1z04ann.js → claude-adapter-3qpa9w0j.js} +9 -9
  10. package/dist/{claude-managed-adapter-sactwn31.js → claude-managed-adapter-qvsmh6mv.js} +2 -2
  11. package/dist/{claude-sdk-session-f0cc1kec.js → claude-sdk-session-2yxgjs5d.js} +9 -9
  12. package/dist/{cli-y6b6fb76.js → cli-08b07b4r.js} +1 -1
  13. package/dist/{cli-8fc8de14.js → cli-0z2v4nhw.js} +1 -1
  14. package/dist/{cli-de14znh8.js → cli-19f354qr.js} +2 -2
  15. package/dist/{cli-hbv0kq6w.js → cli-2qts1hys.js} +3 -3
  16. package/dist/{cli-kpv03zhj.js → cli-3apbzgyk.js} +27 -15
  17. package/dist/{cli-1th1728g.js → cli-3q5ejx9p.js} +2 -2
  18. package/dist/{cli-d8brzxjb.js → cli-4wtq5jjv.js} +2 -2
  19. package/dist/{cli-ct0et58h.js → cli-5my3bjsd.js} +1 -1
  20. package/dist/{cli-cn6mmc7f.js → cli-6664w7y4.js} +1 -1
  21. package/dist/{cli-yz3djrm0.js → cli-6pqcb4q0.js} +53 -13
  22. package/dist/{cli-719k9j2c.js → cli-7smrsr25.js} +3 -3
  23. package/dist/{cli-rw51bq3j.js → cli-9fy6nk6g.js} +1 -1
  24. package/dist/{cli-zhjmqxzh.js → cli-9hspd3dp.js} +2 -2
  25. package/dist/{cli-3tgwnf43.js → cli-aae6hz6a.js} +4 -4
  26. package/dist/{cli-89xfd7f9.js → cli-cr12paw2.js} +5 -5
  27. package/dist/{cli-b5bq9crk.js → cli-d8ftsp62.js} +8 -8
  28. package/dist/{cli-4nr988h6.js → cli-e95cx1eb.js} +3 -3
  29. package/dist/{cli-n8508nre.js → cli-gjhjfeeg.js} +1 -1
  30. package/dist/{cli-p1d5b073.js → cli-gv88e1vf.js} +1 -1
  31. package/dist/{cli-rvqz34y5.js → cli-jecc01cb.js} +1 -1
  32. package/dist/{cli-8v0g1yc8.js → cli-k0cr4kat.js} +1 -1
  33. package/dist/{cli-mmemxxdc.js → cli-kgz4np97.js} +4 -4
  34. package/dist/{cli-w9kb3bk1.js → cli-kjwt6hdf.js} +3 -1
  35. package/dist/{cli-f7dsy61y.js → cli-mnmsfd1w.js} +6 -2
  36. package/dist/{cli-54wwve05.js → cli-n3dz2942.js} +37 -26
  37. package/dist/{cli-12fz972k.js → cli-nye12xk5.js} +8 -5
  38. package/dist/{cli-tzvk9haz.js → cli-phsxnkp6.js} +20 -20
  39. package/dist/{cli-53s590z8.js → cli-q50ef8g0.js} +1 -1
  40. package/dist/{cli-f146mn5s.js → cli-qaacms9y.js} +7 -6
  41. package/dist/{cli-xqcq9y5e.js → cli-r2ap2czm.js} +1 -0
  42. package/dist/{cli-m8yrsg97.js → cli-sd5pv3b1.js} +3 -3
  43. package/dist/{cli-0b6y7kxm.js → cli-xm87hzbw.js} +1 -1
  44. package/dist/{cli-ftht3zzj.js → cli-yab68w40.js} +285 -11
  45. package/dist/{cli-atcve9y8.js → cli-yb9qhqam.js} +227 -59
  46. package/dist/{cli-smby5m17.js → cli-ygweqcn4.js} +133 -22
  47. package/dist/{cli-dttph1bw.js → cli-ykxrnd1q.js} +2 -2
  48. package/dist/{cli-m9sxbkhm.js → cli-zvz62chv.js} +3 -3
  49. package/dist/cli.js +16 -14
  50. package/dist/{codex-adapter-zw4dwsx7.js → codex-adapter-8meawydj.js} +4 -4
  51. package/dist/{codex-hook-bw8p5nh4.js → codex-hook-hfrt9shr.js} +2 -2
  52. package/dist/{codex-session-runner-k981s65v.js → codex-session-runner-gjgxyp5j.js} +4 -4
  53. package/dist/{commands-sykrxx7e.js → commands-hz3nb95f.js} +5 -5
  54. package/dist/{db-sfbsc8wt.js → db-ggz97zbm.js} +5 -5
  55. package/dist/{e2b-6hb103d8.js → e2b-p04vmtx0.js} +1 -1
  56. package/dist/{handlers-0aspcejm.js → handlers-nbzdzn9k.js} +14 -11
  57. package/dist/{hook-hxccs7m5.js → hook-bks2q1f6.js} +7 -7
  58. package/dist/{hook-4nvqj2sb.js → hook-kq9wp0ep.js} +8 -8
  59. package/dist/{http-y06z517d.js → http-hrf77v87.js} +96 -45
  60. package/dist/{index-tce1rss4.js → index-6nsbg88z.js} +443 -144
  61. package/dist/{index-x5j894zf.js → index-8hmeg8j1.js} +14 -14
  62. package/dist/{index-k8znfx0z.js → index-cqtebbhb.js} +13 -13
  63. package/dist/{index-hssww7kj.js → index-n28pw1zt.js} +15 -15
  64. package/dist/{keepalive-w733ax66.js → keepalive-dxc12gvg.js} +7 -7
  65. package/dist/{lead-75bjqa9d.js → lead-yckfn18d.js} +32 -32
  66. package/dist/{maintenance-w4f1zjck.js → maintenance-6kfb2m5e.js} +8 -8
  67. package/dist/{oauth-refresh-sweep-wdetky26.js → oauth-refresh-sweep-1x1ac722.js} +6 -6
  68. package/dist/{onboard-bb5net8s.js → onboard-dcyt1609.js} +4 -3
  69. package/dist/{opencode-adapter-fef1mrn2.js → opencode-adapter-dce4wskd.js} +2 -2
  70. package/dist/{otel-impl-0w7c14ap.js → otel-impl-tbk3e5dr.js} +2 -2
  71. package/dist/{pi-mono-adapter-2has4gnp.js → pi-mono-adapter-zvshk5cj.js} +2 -2
  72. package/dist/{pricing-refresh-9nzsrgcq.js → pricing-refresh-kaqg567e.js} +7 -7
  73. package/dist/{rbac-roles-z1jrffbt.js → rbac-roles-ammq6rd2.js} +5 -5
  74. package/dist/{rbac-roles-r3hqjp09.js → rbac-roles-cf97v1vd.js} +6 -6
  75. package/dist/{render-v2-vbr89fnb.js → render-v2-80w603vy.js} +6 -6
  76. package/dist/{seed-pricing-g62hy1pk.js → seed-pricing-zbrn83xv.js} +6 -6
  77. package/dist/{setup-gnfqnptp.js → setup-fe66kczs.js} +2 -2
  78. package/dist/{worker-kznxp4qm.js → worker-qmb2d1e2.js} +32 -32
  79. package/dist/{x-n2phr5vm.js → x-n3e0v3xa.js} +2 -2
  80. package/openapi.json +46 -4
  81. package/package.json +3 -1
  82. package/src/agentmail/handlers.ts +5 -0
  83. package/src/automation-preflight-alert.ts +41 -0
  84. package/src/be/automation-preflight.ts +3 -3
  85. package/src/be/budget-refusal-notify.ts +1 -0
  86. package/src/be/db/tasks/read.ts +5 -0
  87. package/src/be/db.ts +132 -2
  88. package/src/be/migrations/150_deferred_task_waits.sql +18 -0
  89. package/src/be/migrations/151_repair_scheduled_task_required_params.sql +39 -0
  90. package/src/be/migrations/152_routing_decisions.sql +44 -0
  91. package/src/be/scripts/typecheck.ts +33 -22
  92. package/src/be/seed-scripts/catalog/delegate.ts +5 -1
  93. package/src/be/seed-skills/bundled-files.generated.json +10 -0
  94. package/src/be/seed-skills/index.ts +3 -0
  95. package/src/be/steering.ts +1 -0
  96. package/src/be/swarm-config-guard.ts +11 -0
  97. package/src/commands/onboard/steps/post-task.tsx +1 -0
  98. package/src/github/handlers.ts +9 -0
  99. package/src/gitlab/handlers.ts +4 -0
  100. package/src/heartbeat/heartbeat.ts +8 -0
  101. package/src/http/approval-requests.ts +1 -0
  102. package/src/http/apps.ts +1 -0
  103. package/src/http/config.ts +15 -6
  104. package/src/http/mcp.ts +31 -4
  105. package/src/http/script-runs.ts +6 -0
  106. package/src/http/tasks.ts +52 -30
  107. package/src/integrations/kapso/inbound.ts +1 -0
  108. package/src/jira/sync.ts +2 -0
  109. package/src/linear/sync.ts +2 -0
  110. package/src/prompts/session-templates.ts +29 -15
  111. package/src/scheduler/deferred-task-waits.ts +123 -0
  112. package/src/scheduler/schedule-task.ts +42 -0
  113. package/src/scheduler/scheduler.ts +22 -31
  114. package/src/script-workflows/workflow-ctx.ts +2 -0
  115. package/src/scripts-runtime/types/stdlib.d.ts +38 -24
  116. package/src/scripts-runtime/types/swarm-sdk.d.ts +38 -24
  117. package/src/server.ts +10 -1
  118. package/src/slack/actions.ts +1 -0
  119. package/src/slack/assistant.ts +2 -0
  120. package/src/slack/handlers.ts +3 -0
  121. package/src/slack/thread-buffer.ts +2 -0
  122. package/src/tasks/worker-follow-up.ts +9 -0
  123. package/src/tests/acp-adapter.test.ts +35 -6
  124. package/src/tests/acp-session-token.test.ts +67 -0
  125. package/src/tests/asset-key-api.test.ts +15 -2
  126. package/src/tests/asset-key-mcp.test.ts +2 -0
  127. package/src/tests/automation-preflight-alert.test.ts +91 -0
  128. package/src/tests/claude-worker-parity.test.ts +3 -0
  129. package/src/tests/config-session-auth.test.ts +143 -0
  130. package/src/tests/defer-task.test.ts +117 -6
  131. package/src/tests/deferred-task-wake.test.ts +456 -0
  132. package/src/tests/heartbeat-reroute-decision.test.ts +1 -0
  133. package/src/tests/http-api-integration.test.ts +33 -4
  134. package/src/tests/mcp-input-ergonomics.test.ts +386 -0
  135. package/src/tests/model-control.test.ts +18 -5
  136. package/src/tests/opencode-adapter.test.ts +2 -2
  137. package/src/tests/promote-draft-task-route.test.ts +4 -0
  138. package/src/tests/prompt-template-session.test.ts +13 -4
  139. package/src/tests/rbac-wire-e2e.test.ts +2 -2
  140. package/src/tests/routing-decision-persistence.test.ts +520 -0
  141. package/src/tests/routing-reason-contract.test.ts +89 -0
  142. package/src/tests/routing-reason-inventory.test.ts +55 -0
  143. package/src/tests/schedule-target-type.test.ts +48 -1
  144. package/src/tests/scheduled-task-required-params-migration.test.ts +182 -0
  145. package/src/tests/scheduled-tasks.test.ts +9 -5
  146. package/src/tests/script-connections.test.ts +4 -0
  147. package/src/tests/script-runs-http.test.ts +31 -0
  148. package/src/tests/scripts-typecheck.test.ts +11 -0
  149. package/src/tests/secret-scrubber.test.ts +12 -0
  150. package/src/tests/send-task-output-schema.test.ts +66 -1
  151. package/src/tests/send-task-requested-by.test.ts +1 -0
  152. package/src/tests/send-task-slack-routing-guard.test.ts +2 -0
  153. package/src/tests/store-progress-blocked-waiting-gate.test.ts +151 -0
  154. package/src/tests/swarm-tool-result-gate.test.ts +24 -0
  155. package/src/tests/task-tool-manifest.test.ts +47 -0
  156. package/src/tests/task-tool-preload-mcp.test.ts +125 -0
  157. package/src/tests/task-tools-ctx.test.ts +2 -0
  158. package/src/tests/tool-output-agent-id.test.ts +1 -0
  159. package/src/tests/ui-followup-lead-delegation.test.ts +6 -1
  160. package/src/tests/workflow-agent-task.test.ts +24 -1
  161. package/src/tests/workflow-engine-v2.test.ts +58 -2
  162. package/src/tools/accept-steer.ts +0 -1
  163. package/src/tools/defer-task.ts +73 -21
  164. package/src/tools/memory-get.ts +10 -3
  165. package/src/tools/memory-rate.ts +19 -7
  166. package/src/tools/memory-search.ts +2 -1
  167. package/src/tools/memory-store.ts +25 -5
  168. package/src/tools/send-task.ts +40 -2
  169. package/src/tools/skills/skill-publish.ts +1 -0
  170. package/src/tools/store-progress.ts +78 -4
  171. package/src/tools/templates.ts +2 -1
  172. package/src/tools/utils.ts +33 -2
  173. package/src/types.ts +14 -1
  174. package/src/utils/acp-session-token.ts +14 -4
  175. package/src/utils/secret-scrubber.ts +2 -0
  176. package/src/utils/task-tool-manifest.ts +33 -0
  177. package/src/workflows/engine.ts +4 -1
  178. package/src/workflows/executors/agent-task.ts +7 -1
  179. package/templates/ai-toolbox.manifest.json +6 -1
  180. package/templates/skills/comms/config.json +18 -0
  181. package/templates/skills/comms/content.md +74 -0
  182. package/templates/skills/comms/files/references/ste-rules.md +73 -0
  183. package/templates/skills/comms/files/references/visual-shapes.md +112 -0
  184. package/templates/skills/swarm-scripts/SKILL.md +3 -2
  185. package/templates/skills/swarm-scripts/content.md +3 -2
@@ -624,6 +624,7 @@ export function registerMessageHandler(app: App): void {
624
624
  fullTaskDescription,
625
625
  {
626
626
  agentId: lead?.id,
627
+ routingReason: lead ? "skill" : undefined,
627
628
  source: "slack",
628
629
  slackChannelId: msg.channel,
629
630
  slackThreadTs: threadTs,
@@ -739,6 +740,7 @@ export function registerMessageHandler(app: App): void {
739
740
  fullTaskDescription,
740
741
  {
741
742
  agentId: agent.id,
743
+ routingReason: "human_pinned",
742
744
  source: "slack",
743
745
  slackChannelId: msg.channel,
744
746
  slackThreadTs: threadTs,
@@ -762,6 +764,7 @@ export function registerMessageHandler(app: App): void {
762
764
  fullTaskDescription,
763
765
  {
764
766
  agentId: agent.id,
767
+ routingReason: "human_pinned",
765
768
  source: "slack",
766
769
  slackChannelId: msg.channel,
767
770
  slackThreadTs: threadTs,
@@ -209,6 +209,8 @@ async function slackFlush(
209
209
  const mostRecentTask = await getMostRecentTaskInThread(channelId, threadTs);
210
210
  const task = await createTaskWithSiblingAwareness(fullDescription, {
211
211
  agentId: lead?.id,
212
+ routingReason:
213
+ lead && mostRecentTask?.agentId === lead.id ? "continuity" : lead ? "skill" : undefined,
212
214
  source: "slack",
213
215
  slackChannelId: channelId,
214
216
  slackThreadTs: threadTs,
@@ -219,6 +219,7 @@ export async function createWorkerTaskFollowUp(args: {
219
219
 
220
220
  return await createTaskExtended(followUpDescription, {
221
221
  agentId: leadAgent.id,
222
+ routingReason: "skill",
222
223
  source: "system",
223
224
  taskType: "follow-up",
224
225
  parentTaskId: task.id,
@@ -424,6 +425,12 @@ export async function createResumeFollowUp(args: {
424
425
  : sourceAffinity;
425
426
  const created = await createTaskExtended(followUpDescription, {
426
427
  agentId: preferredAgentId,
428
+ routingReason:
429
+ preferredAgentId === undefined
430
+ ? undefined
431
+ : preferredAgentId === parent.agentId
432
+ ? "continuity"
433
+ : "reroute_fault",
427
434
  creatorAgentId: parent.creatorAgentId,
428
435
  source: "system",
429
436
  taskType: "resume",
@@ -542,6 +549,7 @@ export async function createRerouteDecisionTask(args: {
542
549
  // via parentTaskId. taskType is the distinct "reroute-decision" marker.
543
550
  const created = await createTaskExtended(decision.text, {
544
551
  agentId: leadAgent.id,
552
+ routingReason: "reroute_fault",
545
553
  creatorAgentId: original.creatorAgentId,
546
554
  source: "system",
547
555
  taskType: "reroute-decision",
@@ -621,6 +629,7 @@ export async function createPoolStarvationDecisionTask(args: {
621
629
 
622
630
  const created = await createTaskExtended(decision.text, {
623
631
  agentId: leadAgent.id,
632
+ routingReason: "overflow",
624
633
  creatorAgentId: original.creatorAgentId,
625
634
  source: "system",
626
635
  taskType: "reroute-decision",
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
1
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
4
4
  import { tmpdir } from "node:os";
@@ -688,7 +688,7 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
688
688
  expect(toAcpMcpServers(null)).toEqual([]);
689
689
  });
690
690
 
691
- test("mints an ephemeral token and revokes it when the session ends", async () => {
691
+ test("completes before the revoke response and logs a failed revocation", async () => {
692
692
  const cwd = makeTempDir();
693
693
  const agentPath = join(cwd, "fake-acp-ephem-agent.ts");
694
694
  const sdkPath = join(process.cwd(), "node_modules/@agentclientprotocol/sdk/dist/acp.js");
@@ -734,6 +734,22 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
734
734
  let mintCalled = false;
735
735
  let revokeCalled = false;
736
736
  let mintBody: Record<string, unknown> = {};
737
+ let revokeResponse: ServerResponse | undefined;
738
+ let resolveRevokeRequested!: () => void;
739
+ const revokeRequested = new Promise<void>((resolve) => {
740
+ resolveRevokeRequested = resolve;
741
+ });
742
+ let resolveWarning!: () => void;
743
+ const warned = new Promise<void>((resolve) => {
744
+ resolveWarning = resolve;
745
+ });
746
+ const revokeWarnings: string[] = [];
747
+ const warning = spyOn(console, "warn").mockImplementation((message) => {
748
+ if (String(message).includes("Session token revoke failed")) {
749
+ revokeWarnings.push(String(message));
750
+ resolveWarning();
751
+ }
752
+ });
737
753
 
738
754
  const swarmServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
739
755
  if (req.method === "POST" && req.url === "/api/sessions/tokens") {
@@ -749,8 +765,8 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
749
765
  res.end(JSON.stringify({ tokenId: FAKE_TOKEN_ID, plaintext: FAKE_TOKEN }));
750
766
  } else if (req.method === "DELETE" && req.url === `/api/sessions/tokens/${FAKE_TOKEN_ID}`) {
751
767
  revokeCalled = true;
752
- res.writeHead(204);
753
- res.end();
768
+ revokeResponse = res;
769
+ resolveRevokeRequested();
754
770
  } else {
755
771
  res.writeHead(404);
756
772
  res.end();
@@ -778,8 +794,19 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
778
794
  const events: ProviderEvent[] = [];
779
795
  session.onEvent((e) => events.push(e));
780
796
  const result = await session.waitForCompletion();
781
- // Give the revoke call (fire-and-forget) a moment to complete.
782
- await Bun.sleep(100);
797
+ // Withhold the HTTP response until completion: cleanup must never block it.
798
+ await revokeRequested;
799
+ expect(revokeWarnings).toEqual([]);
800
+ expect(revokeResponse!.destroyed).toBe(false);
801
+ expect(revokeResponse!.writableEnded).toBe(false);
802
+ revokeResponse!.writeHead(503).end();
803
+ const observed = await Promise.race([
804
+ warned.then(() => true),
805
+ Bun.sleep(1000).then(() => false),
806
+ ]);
807
+ expect(observed).toBe(true);
808
+ expect(revokeWarnings).toHaveLength(1);
809
+ expect(revokeWarnings[0]).toContain("HTTP 503");
783
810
 
784
811
  expect(result.exitCode).toBe(0);
785
812
 
@@ -799,6 +826,8 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
799
826
  const captured = await Bun.file(captureFile).text();
800
827
  expect(captured).toBe(`Bearer ${FAKE_TOKEN}`);
801
828
  } finally {
829
+ warning.mockRestore();
830
+ revokeResponse?.end();
802
831
  await new Promise<void>((resolve) => swarmServer.close(() => resolve()));
803
832
  }
804
833
  });
@@ -0,0 +1,67 @@
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import { revokeAcpSessionToken } from "../utils/acp-session-token";
3
+
4
+ describe("ACP session token revocation", () => {
5
+ test("logs HTTP 503 without rejecting or disclosing credentials", async () => {
6
+ const server = Bun.serve({
7
+ port: 0,
8
+ fetch: () => new Response("sensitive body", { status: 503 }),
9
+ });
10
+ const warning = spyOn(console, "warn").mockImplementation(() => {});
11
+ try {
12
+ await expect(
13
+ revokeAcpSessionToken(server.url.origin, "test-operator-key", "test-token-id"),
14
+ ).resolves.toBeUndefined();
15
+ expect(warning).toHaveBeenCalled();
16
+ const log = JSON.stringify(warning.mock.calls);
17
+ expect(log).toContain("Session token revoke failed");
18
+ expect(log).toContain("503");
19
+ expect(log).not.toContain("test-operator-key");
20
+ expect(log).not.toContain("sensitive body");
21
+ } finally {
22
+ warning.mockRestore();
23
+ server.stop(true);
24
+ }
25
+ });
26
+
27
+ test("logs connection failure against a stopped server without rejecting", async () => {
28
+ const server = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 204 }) });
29
+ const url = server.url.origin;
30
+ await server.stop(true);
31
+ const warning = spyOn(console, "warn").mockImplementation(() => {});
32
+ try {
33
+ await expect(
34
+ revokeAcpSessionToken(url, "test-operator-key", "test-token-id"),
35
+ ).resolves.toBeUndefined();
36
+ expect(warning).toHaveBeenCalled();
37
+ expect(JSON.stringify(warning.mock.calls)).toContain("Session token revoke failed");
38
+ } finally {
39
+ warning.mockRestore();
40
+ }
41
+ });
42
+
43
+ test("aborts a stalled revoke request within its timeout and logs the failure", async () => {
44
+ const server = Bun.serve({ port: 0, fetch: () => new Promise<Response>(() => {}) });
45
+ const warning = spyOn(console, "warn").mockImplementation(() => {});
46
+ // Exercise the real fetch cancellation with a short test-only clock budget.
47
+ const realTimeout = AbortSignal.timeout.bind(AbortSignal);
48
+ const timeout = spyOn(AbortSignal, "timeout").mockImplementation(() => realTimeout(25));
49
+ try {
50
+ const pending = revokeAcpSessionToken(
51
+ server.url.origin,
52
+ "test-operator-key",
53
+ "test-token-id",
54
+ );
55
+ expect(timeout).toHaveBeenCalled();
56
+ expect(timeout.mock.calls[0]![0]).toBeGreaterThan(0);
57
+ expect(timeout.mock.calls[0]![0]).toBeLessThanOrEqual(5000);
58
+ await expect(pending).resolves.toBeUndefined();
59
+ expect(warning).toHaveBeenCalled();
60
+ expect(JSON.stringify(warning.mock.calls)).toContain("Session token revoke failed");
61
+ } finally {
62
+ server.stop(true);
63
+ timeout.mockRestore();
64
+ warning.mockRestore();
65
+ }
66
+ });
67
+ });
@@ -108,7 +108,12 @@ describe("asset namespace REST contract", () => {
108
108
  const task = await api(
109
109
  "POST",
110
110
  "/api/tasks",
111
- { task: "namespaced task", agentId, key: "Shared/Team" },
111
+ {
112
+ task: "namespaced task",
113
+ agentId,
114
+ routingReason: "human_pinned",
115
+ key: "Shared/Team",
116
+ },
112
117
  { agentId },
113
118
  );
114
119
  expect(task.status).toBe(201);
@@ -245,6 +250,7 @@ describe("asset namespace REST contract", () => {
245
250
  {
246
251
  task: "personal task",
247
252
  agentId,
253
+ routingReason: "human_pinned",
248
254
  key: `personal/${userId}/drafts/`,
249
255
  },
250
256
  { agentId, sourceTaskId },
@@ -258,6 +264,7 @@ describe("asset namespace REST contract", () => {
258
264
  {
259
265
  task: "wrong personal task",
260
266
  agentId,
267
+ routingReason: "human_pinned",
261
268
  key: `personal/${secondUserId}/drafts/`,
262
269
  },
263
270
  { agentId, sourceTaskId },
@@ -270,6 +277,7 @@ describe("asset namespace REST contract", () => {
270
277
  {
271
278
  task: "unattributed personal task",
272
279
  agentId: secondAgentId,
280
+ routingReason: "human_pinned",
273
281
  key: `personal/${secondUserId}/drafts/`,
274
282
  },
275
283
  { agentId: secondAgentId },
@@ -281,7 +289,12 @@ describe("asset namespace REST contract", () => {
281
289
  const task = await api(
282
290
  "POST",
283
291
  "/api/tasks",
284
- { task: "move target", agentId, key: "shared/inbox/" },
292
+ {
293
+ task: "move target",
294
+ agentId,
295
+ routingReason: "human_pinned",
296
+ key: "shared/inbox/",
297
+ },
285
298
  { agentId },
286
299
  );
287
300
  const move = await api(
@@ -76,6 +76,7 @@ describe("asset namespace MCP exposure", () => {
76
76
  "send-task",
77
77
  {
78
78
  agentId: targetAgentId,
79
+ routingReason: "human_pinned",
79
80
  task: `inherited namespace ${Date.now()}`,
80
81
  allowDuplicate: true,
81
82
  },
@@ -90,6 +91,7 @@ describe("asset namespace MCP exposure", () => {
90
91
  "send-task",
91
92
  {
92
93
  agentId: targetAgentId,
94
+ routingReason: "human_pinned",
93
95
  task: `wrong namespace ${Date.now()}`,
94
96
  key: `personal/${otherUserId}/drafts/`,
95
97
  allowDuplicate: true,
@@ -0,0 +1,91 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
2
+ import { notifyAutomationPreflightFailure } from "../automation-preflight-alert";
3
+ import type { AutomationPreflightResult } from "../be/automation-preflight";
4
+ import * as slack from "../slack/app";
5
+ import { registerVolatileSecret } from "../utils/secret-scrubber";
6
+
7
+ const savedEnv = { ...process.env };
8
+ const preflight: AutomationPreflightResult = {
9
+ id: "test-schedule",
10
+ kind: "schedule",
11
+ name: "daily-report",
12
+ state: "needs_setup",
13
+ missing: { params: ["REPORT_NAME", "REPO_URL"], integrations: ["github", "slack"] },
14
+ fixes: [],
15
+ fixUrl: "/schedules/test-schedule?param=REPORT_NAME",
16
+ };
17
+ const postMessage = mock(async (_message: unknown) => ({ ok: true }));
18
+
19
+ beforeEach(() => {
20
+ process.env.SLACK_ALERTS_CHANNEL = "C_TEST_ALERTS";
21
+ process.env.APP_URL = "https://dashboard.example.com/";
22
+ postMessage.mockReset();
23
+ postMessage.mockResolvedValue({ ok: true });
24
+ spyOn(slack, "getSlackApp").mockReturnValue({
25
+ client: { chat: { postMessage } },
26
+ } as unknown as NonNullable<ReturnType<typeof slack.getSlackApp>>);
27
+ });
28
+
29
+ afterEach(() => {
30
+ mock.restore();
31
+ process.env = { ...savedEnv };
32
+ });
33
+
34
+ describe("Automation preflight alerts", () => {
35
+ test("skips Slack notification when alerts channel env is unset", async () => {
36
+ delete process.env.SLACK_ALERTS_CHANNEL;
37
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
38
+
39
+ await expect(notifyAutomationPreflightFailure(preflight)).resolves.toBeUndefined();
40
+
41
+ expect(warn).toHaveBeenCalledWith(
42
+ "[Automation Preflight] SLACK_ALERTS_CHANNEL not set; skipping alert",
43
+ );
44
+ expect(slack.getSlackApp).not.toHaveBeenCalled();
45
+ expect(postMessage).not.toHaveBeenCalled();
46
+ });
47
+
48
+ test("posts the skipped automation, missing setup, and dashboard fix link", async () => {
49
+ await notifyAutomationPreflightFailure(preflight);
50
+
51
+ expect(postMessage).toHaveBeenCalledWith({
52
+ channel: "C_TEST_ALERTS",
53
+ text:
54
+ '⚠️ Automation needs setup\nAutomation: schedule "daily-report"\n' +
55
+ "Missing params: REPORT_NAME, REPO_URL\nMissing integrations: github, slack\n" +
56
+ "Fire SKIPPED. It was not retried or queued.\n" +
57
+ "Fix setup: https://dashboard.example.com/schedules/test-schedule?param=REPORT_NAME",
58
+ unfurl_links: false,
59
+ unfurl_media: false,
60
+ });
61
+ });
62
+
63
+ test("skips Slack notification when the app is unavailable", async () => {
64
+ spyOn(slack, "getSlackApp").mockReturnValue(null);
65
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
66
+
67
+ await expect(notifyAutomationPreflightFailure(preflight)).resolves.toBeUndefined();
68
+
69
+ expect(warn).toHaveBeenCalledWith(
70
+ "[Automation Preflight] Slack not available, cannot send notification",
71
+ );
72
+ expect(postMessage).not.toHaveBeenCalled();
73
+ });
74
+
75
+ test("scrubs alert text and logs Slack failures without throwing", async () => {
76
+ const secret = "preflight-alert-sensitive-fixture";
77
+ registerVolatileSecret(secret, "PREFLIGHT_TEST_SECRET");
78
+ postMessage.mockRejectedValueOnce(new Error(`Slack failed: ${secret}`));
79
+ const error = spyOn(console, "error").mockImplementation(() => {});
80
+
81
+ await expect(
82
+ notifyAutomationPreflightFailure({ ...preflight, name: secret }),
83
+ ).resolves.toBeUndefined();
84
+
85
+ expect(postMessage).toHaveBeenCalledTimes(1);
86
+ expect(JSON.stringify(postMessage.mock.calls)).not.toContain(secret);
87
+ expect(error).toHaveBeenCalledTimes(1);
88
+ expect(JSON.stringify(error.mock.calls)).toContain("Slack failed:");
89
+ expect(JSON.stringify(error.mock.calls)).not.toContain(secret);
90
+ });
91
+ });
@@ -235,6 +235,7 @@ async function runTransport(transport: "cli" | "sdk", agentId: string): Promise<
235
235
  const created = await api("POST", "/api/tasks", {
236
236
  body: {
237
237
  task: `Run the deterministic Claude ${transport} transport parity fixture and report completion.`,
238
+ routingReason: "human_pinned",
238
239
  agentId,
239
240
  source: "api",
240
241
  },
@@ -373,6 +374,7 @@ async function runCredentialRotation(): Promise<void> {
373
374
  const firstCreated = await api("POST", "/api/tasks", {
374
375
  body: {
375
376
  task: "Run the first deterministic credential rotation fixture task.",
377
+ routingReason: "human_pinned",
376
378
  agentId: ROTATION_AGENT_ID,
377
379
  source: "api",
378
380
  },
@@ -483,6 +485,7 @@ async function runCredentialRotation(): Promise<void> {
483
485
  const secondCreated = await api("POST", "/api/tasks", {
484
486
  body: {
485
487
  task: "Run the second deterministic credential rotation fixture task.",
488
+ routingReason: "human_pinned",
486
489
  agentId: ROTATION_AGENT_ID,
487
490
  source: "api",
488
491
  },
@@ -0,0 +1,143 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createServer, type Server } from "node:http";
3
+ import { closeDb, createAgent, initDb, upsertSwarmConfig } from "../be/db";
4
+ import { handleConfig } from "../http/config";
5
+ import { handleCore } from "../http/core";
6
+ import { handleSessions } from "../http/sessions";
7
+ import { getPathSegments, parseQueryParams } from "../http/utils";
8
+ import { listenOnFreePort } from "./test-net";
9
+
10
+ const API_KEY = "config-session-test-operator";
11
+ const SECRET = "config-session-test-secret-value";
12
+ let server: Server;
13
+ let baseUrl: string;
14
+ let workerId: string;
15
+ let leadId: string;
16
+ let configId: string;
17
+
18
+ function headers(bearer: string, agentId?: string): Record<string, string> {
19
+ return {
20
+ Authorization: `Bearer ${bearer}`,
21
+ "Content-Type": "application/json",
22
+ ...(agentId ? { "X-Agent-ID": agentId } : {}),
23
+ };
24
+ }
25
+
26
+ async function mint(agentId: string) {
27
+ const res = await fetch(`${baseUrl}/api/sessions/tokens`, {
28
+ method: "POST",
29
+ headers: headers(API_KEY),
30
+ body: JSON.stringify({ agentId, taskId: crypto.randomUUID(), ttlMs: 60_000 }),
31
+ });
32
+ expect(res.status).toBe(200);
33
+ return (await res.json()) as { tokenId: string; plaintext: string };
34
+ }
35
+
36
+ beforeAll(async () => {
37
+ initDb(":memory:");
38
+ workerId = (await createAgent({ name: "config-worker", isLead: false, status: "idle" })).id;
39
+ leadId = (await createAgent({ name: "config-lead", isLead: true, status: "idle" })).id;
40
+ configId = (
41
+ await upsertSwarmConfig({
42
+ scope: "agent",
43
+ scopeId: workerId,
44
+ key: "ACP_AUTH_TEST_SECRET",
45
+ value: SECRET,
46
+ isSecret: true,
47
+ })
48
+ ).id;
49
+ server = createServer(async (req, res) => {
50
+ if (await handleCore(req, res, req.headers["x-agent-id"] as string | undefined, API_KEY))
51
+ return;
52
+ const segments = getPathSegments(req.url || "");
53
+ const query = parseQueryParams(req.url || "");
54
+ if (await handleSessions(req, res, segments, query)) return;
55
+ if (await handleConfig(req, res, segments, query)) return;
56
+ res.writeHead(404).end();
57
+ });
58
+ baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
59
+ });
60
+
61
+ afterAll(async () => {
62
+ await new Promise<void>((resolve) => server.close(() => resolve()));
63
+ closeDb();
64
+ });
65
+
66
+ describe("config HTTP session-token authorization", () => {
67
+ for (const route of ["list", "resolved", "by-id"] as const) {
68
+ test(`${route} masks secrets for a non-lead session even with a spoofed lead header`, async () => {
69
+ const token = await mint(workerId);
70
+ const lead = await mint(leadId);
71
+ const path =
72
+ route === "by-id"
73
+ ? `/api/config/${configId}`
74
+ : route === "resolved"
75
+ ? "/api/config/resolved"
76
+ : "/api/config";
77
+ const url = `${baseUrl}${path}?includeSecrets=true&agentId=${workerId}`;
78
+ // Positive controls: operator and a token bound to the actual lead may read.
79
+ for (const bearer of [API_KEY, lead.plaintext]) {
80
+ const res = await fetch(url, { headers: headers(bearer) });
81
+ expect(res.status).toBe(200);
82
+ expect(await res.text()).toContain(SECRET);
83
+ }
84
+ const responses: string[] = [];
85
+ for (const agentId of [undefined, workerId, leadId]) {
86
+ const res = await fetch(url, { headers: headers(token.plaintext, agentId) });
87
+ expect(res.status).toBe(200);
88
+ responses.push(await res.text());
89
+ }
90
+ const revoked = await fetch(`${baseUrl}/api/sessions/tokens/${token.tokenId}`, {
91
+ method: "DELETE",
92
+ headers: headers(API_KEY),
93
+ });
94
+ expect(revoked.status).toBe(204);
95
+ expect((await fetch(url, { headers: headers(token.plaintext, leadId) })).status).toBe(401);
96
+ for (const body of responses) {
97
+ expect(body).not.toContain(SECRET);
98
+ expect(body).toContain("secret values masked");
99
+ }
100
+ });
101
+ }
102
+
103
+ for (const method of ["PUT", "DELETE"] as const) {
104
+ test(`${method} cannot escalate a non-lead session by changing only X-Agent-ID`, async () => {
105
+ const token = await mint(workerId);
106
+ const key = `ACP_AUTH_TEST_${method}`;
107
+ const config = await upsertSwarmConfig({
108
+ scope: "agent",
109
+ scopeId: workerId,
110
+ key,
111
+ value: "original",
112
+ });
113
+ const url = `${baseUrl}/api/config${method === "DELETE" ? `/${config.id}` : ""}`;
114
+ const request = (bearer: string, agentId?: string) =>
115
+ fetch(url, {
116
+ method,
117
+ headers: headers(bearer, agentId),
118
+ ...(method === "PUT"
119
+ ? { body: JSON.stringify({ scope: "agent", scopeId: workerId, key, value: "changed" }) }
120
+ : {}),
121
+ });
122
+ const own = await request(token.plaintext, workerId);
123
+ expect(own.status).toBe(403);
124
+ await own.text();
125
+ const spoofed = await request(token.plaintext, leadId);
126
+ await spoofed.text();
127
+ const stored = await fetch(`${baseUrl}/api/config/${config.id}`, {
128
+ headers: headers(API_KEY),
129
+ });
130
+ const storedBody = await stored.text();
131
+ // Confirm both the response and persisted state; an error after writing is insufficient.
132
+ expect({
133
+ status: spoofed.status,
134
+ storedStatus: stored.status,
135
+ unchanged: storedBody.includes("original"),
136
+ }).toEqual({ status: 403, storedStatus: 200, unchanged: true });
137
+ const lead = await mint(leadId);
138
+ const allowed = await request(lead.plaintext);
139
+ expect(allowed.ok).toBe(true);
140
+ await allowed.text();
141
+ });
142
+ }
143
+ });