@desplega.ai/agent-swarm 2.7.2 → 2.7.3

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 (220) hide show
  1. package/README.md +6 -0
  2. package/dist/{acp-adapter-624yge7a.js → acp-adapter-pdzw76ja.js} +1 -1
  3. package/dist/{actions-rd9xn281.js → actions-a90cfpca.js} +21 -21
  4. package/dist/{amp-adapter-k27hc00p.js → amp-adapter-4vtywcjk.js} +2 -2
  5. package/dist/{app-sjm3xc7g.js → app-8k68gygk.js} +3 -3
  6. package/dist/{assistant-6jbzs8wa.js → assistant-ck01q2yg.js} +32 -21
  7. package/dist/{boot-reembed-e2348eqc.js → boot-reembed-33spavpd.js} +1 -1
  8. package/dist/{boot-reembed-w53pd5xt.js → boot-reembed-xzge83a2.js} +1 -1
  9. package/dist/{boot-scrub-sweep-cpy4yg97.js → boot-scrub-sweep-mxvb8dr3.js} +3 -3
  10. package/dist/{boot-seeds-f6j4t6k6.js → boot-seeds-rpm8bb5r.js} +4 -4
  11. package/dist/{claude-adapter-v3ac8esd.js → claude-adapter-q12bcfj9.js} +3 -3
  12. package/dist/{claude-managed-setup-yyrkqvtm.js → claude-managed-setup-11vb6q89.js} +1 -1
  13. package/dist/{claude-sdk-session-85j69v6d.js → claude-sdk-session-gkv1107j.js} +10325 -10243
  14. package/dist/{cli-rs59z1qx.js → cli-07pcqwaj.js} +1 -1
  15. package/dist/{cli-6vq3ymqm.js → cli-0tkm519n.js} +1 -1
  16. package/dist/{cli-s7cm1927.js → cli-0xzbk52h.js} +4 -4
  17. package/dist/{cli-g0zc1b40.js → cli-0zae6bs6.js} +2 -2
  18. package/dist/{cli-p8yrsmfn.js → cli-10kjx0dm.js} +1 -1
  19. package/dist/{cli-7xsq63pe.js → cli-157ykn37.js} +10 -9
  20. package/dist/{cli-vjcj9k2p.js → cli-2h0rfmqk.js} +1 -1
  21. package/dist/{cli-9wasax9f.js → cli-35nzvqaf.js} +1 -1
  22. package/dist/{cli-e1p7dqe1.js → cli-4aydmmdy.js} +24 -3
  23. package/dist/{cli-942pp3bc.js → cli-54ax65rn.js} +3 -3
  24. package/dist/{cli-10cp92df.js → cli-5ke7gxwr.js} +6 -6
  25. package/dist/{cli-femw4q81.js → cli-6151a7dr.js} +2 -2
  26. package/dist/{cli-3cywb44q.js → cli-77eq8txs.js} +85 -3
  27. package/dist/{cli-atpfk4yd.js → cli-7cwrwb5x.js} +5 -5
  28. package/dist/{cli-bfbpvccx.js → cli-8a1k6mat.js} +61 -49
  29. package/dist/{cli-c18941tx.js → cli-8ch3b2r7.js} +3 -3
  30. package/dist/{cli-rs2s1t05.js → cli-9fk56y8c.js} +4 -4
  31. package/dist/{cli-0wx63vvw.js → cli-9km88w11.js} +1 -1
  32. package/dist/{cli-qy0z3mfj.js → cli-9vttw9nz.js} +56 -46
  33. package/dist/{cli-mzgppb7q.js → cli-a0w50zw9.js} +4 -4
  34. package/dist/{cli-z8a4mfz1.js → cli-aqp59jmv.js} +4 -4
  35. package/dist/{cli-5d8wd50e.js → cli-bs0fwb23.js} +1 -1
  36. package/dist/{cli-fa4kxxx3.js → cli-ct5920jb.js} +2 -2
  37. package/dist/{cli-0awbtmv2.js → cli-dek8hqjc.js} +10 -10
  38. package/dist/{cli-qp6adcmw.js → cli-e24w2mvk.js} +1 -1
  39. package/dist/{cli-t3npd8hm.js → cli-ghfhn1qc.js} +1 -1
  40. package/dist/{cli-jmcf8cnz.js → cli-ja369zab.js} +1 -1
  41. package/dist/{cli-wtjg6je3.js → cli-jhh7tbx6.js} +1 -1
  42. package/dist/{cli-e19bgatr.js → cli-jmk31131.js} +2 -2
  43. package/dist/{cli-2cj8fqv1.js → cli-kjcdzea8.js} +1 -1
  44. package/dist/{cli-1xpr5992.js → cli-kjha1q9k.js} +3 -3
  45. package/dist/{cli-9jfty8px.js → cli-ktcdczth.js} +538 -30
  46. package/dist/{cli-exd52qn4.js → cli-mcmpbpcc.js} +2 -2
  47. package/dist/{cli-2m2p3snc.js → cli-mmf43vcp.js} +6 -6
  48. package/dist/{cli-b2rhbaty.js → cli-pnm99spe.js} +10 -10
  49. package/dist/{cli-9jn9cj0q.js → cli-v9jk2k63.js} +1 -1
  50. package/dist/{cli-mt7bxjfd.js → cli-vhgcwppn.js} +1 -1
  51. package/dist/{cli-qa4hgda3.js → cli-vvz64zc5.js} +14 -13
  52. package/dist/{cli-txr0z97c.js → cli-wmhajw68.js} +171 -32
  53. package/dist/{cli-hzrmv8pg.js → cli-x38rmhap.js} +3 -3
  54. package/dist/{cli-hq9hd4hp.js → cli-xz6qjdqj.js} +1 -1
  55. package/dist/{cli-g9ypd5en.js → cli-z52gq55b.js} +4 -0
  56. package/dist/{cli-2qbmjfta.js → cli-z6219sd3.js} +4 -4
  57. package/dist/cli.js +19 -19
  58. package/dist/{codex-adapter-zm1bkdde.js → codex-adapter-46kbvvkx.js} +1 -1
  59. package/dist/{codex-hook-tkag7fxq.js → codex-hook-tm9ecrd5.js} +1 -1
  60. package/dist/{codex-session-runner-8k02mqw2.js → codex-session-runner-qy93hdrv.js} +1 -1
  61. package/dist/{commands-nw1dn4av.js → commands-hwdhacfx.js} +2 -2
  62. package/dist/{controller-3kkkmps0.js → controller-hjza5p1f.js} +3 -3
  63. package/dist/{controllers-connect-n2c4167r.js → controllers-connect-7m25cay2.js} +20 -20
  64. package/dist/{cursor-adapter-h5028emw.js → cursor-adapter-1qh7ps8w.js} +1 -1
  65. package/dist/{db-76y2msnf.js → db-w23k9301.js} +2 -2
  66. package/dist/{db-8rk0p8j1.js → db-wm8by383.js} +3 -3
  67. package/dist/{dispatcher-xp4wsx30.js → dispatcher-en25ebgc.js} +18 -18
  68. package/dist/{dsh-adapter-vyrr31bn.js → dsh-adapter-zkme32vd.js} +1 -1
  69. package/dist/{e2b-eesfj689.js → e2b-9z9mvt2p.js} +1 -1
  70. package/dist/{e2b-03nha1wz.js → e2b-yhvn70es.js} +1 -1
  71. package/dist/{embedding-space-ejefyep1.js → embedding-space-rgf4aypt.js} +6 -6
  72. package/dist/{embeddings-w297xaw0.js → embeddings-z9cmxhx1.js} +1 -1
  73. package/dist/{external-channel-mf9vp1fe.js → external-channel-g6vv85zv.js} +2 -2
  74. package/dist/{grok-adapter-mpzyyprz.js → grok-adapter-f6n1tpq3.js} +2 -2
  75. package/dist/handlers-ss5948rq.js +42 -0
  76. package/dist/{heartbeat-qnaycjzs.js → heartbeat-5xcdzz45.js} +21 -21
  77. package/dist/{hook-h6sqfzxr.js → hook-6423rme3.js} +2 -2
  78. package/dist/{hook-4zqdgea4.js → hook-8dk1csdd.js} +4 -4
  79. package/dist/{http-65ts210h.js → http-6mfh2ww9.js} +255 -60
  80. package/dist/{index-sqe7f6mj.js → index-0js6bgff.js} +1 -1
  81. package/dist/{index-farm1zsn.js → index-0ng1dd1t.js} +10937 -9527
  82. package/dist/{index-k3ewf1jg.js → index-5eyn1wc5.js} +18 -18
  83. package/dist/{index-vp4wa3qn.js → index-71gnebsn.js} +18 -18
  84. package/dist/{index-hdfad00b.js → index-q5gzpjdd.js} +12 -10
  85. package/dist/index-z3b1vfjx.js +44 -0
  86. package/dist/{keepalive-52k6skha.js → keepalive-cymckeg6.js} +3 -3
  87. package/dist/{lead-y9rj0gr1.js → lead-baknj2dh.js} +29 -29
  88. package/dist/{maintenance-f86wqfq6.js → maintenance-dfrnsx3n.js} +1 -1
  89. package/dist/{oauth-refresh-sweep-vddv9ky7.js → oauth-refresh-sweep-5dztk8x6.js} +2 -2
  90. package/dist/{onboard-8z2qk6gq.js → onboard-h36vja6y.js} +11 -6
  91. package/dist/{otel-impl-epet9j18.js → otel-impl-jerq8vz8.js} +1 -1
  92. package/dist/{pricing-refresh-6wm42a51.js → pricing-refresh-t3nx5krn.js} +3 -3
  93. package/dist/{rbac-roles-2kfcs1sc.js → rbac-roles-dw1awawd.js} +2 -2
  94. package/dist/{rbac-roles-13a0vj6d.js → rbac-roles-km0fc3e8.js} +1 -1
  95. package/dist/{registry-ajhexe44.js → registry-hzfh268v.js} +1 -1
  96. package/dist/{render-v2-cy8zsfsf.js → render-v2-65x4ayj8.js} +3 -3
  97. package/dist/{resume-c783qe6b.js → resume-61znk3hj.js} +6 -6
  98. package/dist/{session-access-s4tv0az8.js → session-access-4yc9htrg.js} +4 -4
  99. package/dist/{session-source-backfill-qzapz0ne.js → session-source-backfill-kr5bdjtp.js} +2 -2
  100. package/dist/{setup-st7htx1k.js → setup-9h61yr2p.js} +1 -1
  101. package/dist/{work-objects-ftj4zw70.js → work-objects-x7rb53qn.js} +27 -22
  102. package/dist/{worker-859fk25k.js → worker-7f3khgn0.js} +29 -29
  103. package/openapi.json +134 -3
  104. package/package.json +5 -5
  105. package/src/be/db/agents.ts +16 -1
  106. package/src/be/db/runtime.ts +25 -15
  107. package/src/be/db/session-summaries.ts +120 -0
  108. package/src/be/db/tasks/read.ts +7 -4
  109. package/src/be/db/tasks/search-index.ts +159 -0
  110. package/src/be/db/tasks/write.ts +3 -0
  111. package/src/be/db-queries/slack-inbound.ts +77 -0
  112. package/src/be/db.ts +27 -8
  113. package/src/be/mcp-overflow-sweep.ts +39 -0
  114. package/src/be/migrations/253_session_summaries.sql +300 -0
  115. package/src/be/migrations/254_query_audit_indexes.sql +25 -0
  116. package/src/be/migrations/256_agent_slack_icon.sql +2 -0
  117. package/src/be/migrations/257_agent_tasks_fts.sql +55 -0
  118. package/src/comb/config.ts +6 -2
  119. package/src/comb/requester.ts +69 -0
  120. package/src/comb/review-batch.ts +24 -2
  121. package/src/commands/onboard/agent-fs-version.ts +1 -1
  122. package/src/commands/onboard/compose-generator.ts +7 -2
  123. package/src/commands/onboard/env-generator.ts +8 -3
  124. package/src/commands/runner.ts +41 -24
  125. package/src/fs/agent-fs-provider.ts +9 -0
  126. package/src/heartbeat/heartbeat.ts +13 -6
  127. package/src/http/agents.ts +10 -2
  128. package/src/http/index.ts +15 -0
  129. package/src/http/integrations.ts +20 -0
  130. package/src/http/list-total-cache.ts +62 -0
  131. package/src/http/sessions.ts +9 -2
  132. package/src/http/slack-inbound.ts +1 -0
  133. package/src/http/status.ts +4 -1
  134. package/src/http/tasks.ts +12 -2
  135. package/src/providers/amp-adapter.ts +1 -1
  136. package/src/providers/cursor-adapter.ts +1 -1
  137. package/src/providers/dsh-adapter.ts +1 -1
  138. package/src/queue-stall-alarm.ts +4 -1
  139. package/src/slack/actions.ts +2 -2
  140. package/src/slack/app-icon.ts +50 -0
  141. package/src/slack/app.ts +13 -1
  142. package/src/slack/assistant.ts +22 -1
  143. package/src/slack/event-dedup.test.ts +11 -0
  144. package/src/slack/event-dedup.ts +8 -4
  145. package/src/slack/inbound-dispatch.ts +58 -0
  146. package/src/slack/render-v2.ts +7 -8
  147. package/src/slack/responses.ts +21 -8
  148. package/src/slack/socket-ack.ts +237 -0
  149. package/src/slack/thread-buffer.ts +3 -5
  150. package/src/slack/watcher.ts +2 -2
  151. package/src/slack/work-objects.ts +5 -1
  152. package/src/tests/agent-fs-release-notes.test.ts +40 -0
  153. package/src/tests/agent-slack-icon.test.ts +85 -0
  154. package/src/tests/approval-cancellation-migration.test.ts +2 -0
  155. package/src/tests/asset-key-migration.test.ts +26 -0
  156. package/src/tests/comb-default.test.ts +159 -0
  157. package/src/tests/comb-review-batch.test.ts +192 -4
  158. package/src/tests/comb-status.test.ts +65 -2
  159. package/src/tests/db-agents.test.ts +13 -0
  160. package/src/tests/db-tasks-read.test.ts +10 -15
  161. package/src/tests/deploy-one-box.test.ts +161 -0
  162. package/src/tests/deploy-stubs.ts +68 -0
  163. package/src/tests/docs-index-footprint.test.js +3 -3
  164. package/src/tests/dsh-adapter.test.ts +1 -1
  165. package/src/tests/e2e-codex-auth-chain.test.ts +18 -1
  166. package/src/tests/fixtures/deploy-stubs/bun +3 -0
  167. package/src/tests/fixtures/deploy-stubs/bunx +27 -0
  168. package/src/tests/fixtures/deploy-stubs/curl +26 -0
  169. package/src/tests/fixtures/deploy-stubs/docker +122 -0
  170. package/src/tests/fixtures/onboard/content.compose.yml.txt +5 -5
  171. package/src/tests/fixtures/onboard/dev.compose.yml.txt +4 -4
  172. package/src/tests/fixtures/onboard/full-all.compose.yml.txt +12 -12
  173. package/src/tests/fixtures/onboard/full.compose.yml.txt +12 -12
  174. package/src/tests/fixtures/onboard/research.compose.yml.txt +4 -4
  175. package/src/tests/fixtures/onboard/solo.compose.yml.txt +2 -2
  176. package/src/tests/heartbeat-expiry.test.ts +151 -25
  177. package/src/tests/http-api-integration.test.ts +31 -0
  178. package/src/tests/human-free-reclassify.test.ts +6 -0
  179. package/src/tests/install-sh-bind.test.ts +242 -0
  180. package/src/tests/install-sh-tunnel.test.ts +89 -0
  181. package/src/tests/kv-storage.test.ts +28 -0
  182. package/src/tests/list-endpoint-slimming.test.ts +3 -0
  183. package/src/tests/list-total-cache.test.ts +63 -0
  184. package/src/tests/mcp-overflow-sweep.test.ts +84 -0
  185. package/src/tests/onboard-compose.test.ts +17 -3
  186. package/src/tests/onboard-env.test.ts +13 -0
  187. package/src/tests/onboarding.test.ts +1 -1
  188. package/src/tests/preload.ts +1 -0
  189. package/src/tests/query-audit-plans.test.ts +314 -0
  190. package/src/tests/script-connections.test.ts +6 -0
  191. package/src/tests/session-summaries.test.ts +384 -0
  192. package/src/tests/sessions-tree.test.ts +5 -1
  193. package/src/tests/slack-app-icon.test.ts +90 -0
  194. package/src/tests/slack-external-channel-egress.test.ts +1 -1
  195. package/src/tests/slack-inbound-receipts.test.ts +34 -0
  196. package/src/tests/slack-inline-output.test.ts +29 -2
  197. package/src/tests/slack-render-v2-query-plans.test.ts +3 -2
  198. package/src/tests/slack-render-v2.test.ts +35 -0
  199. package/src/tests/slack-socket-ack.test.ts +553 -0
  200. package/src/tests/task-lineage.test.ts +3 -0
  201. package/src/tests/task-search-filter.test.ts +4 -4
  202. package/src/tests/task-search-fts.test.ts +320 -0
  203. package/src/tests/update-profile-auth.test.ts +28 -0
  204. package/src/tools/get-swarm.ts +1 -0
  205. package/src/tools/get-tasks.ts +6 -1
  206. package/src/tools/join-swarm.ts +1 -0
  207. package/src/tools/my-agent-info.ts +1 -0
  208. package/src/tools/slack-post.ts +2 -2
  209. package/src/tools/slack-reply.ts +2 -2
  210. package/src/tools/slack-start-thread.ts +2 -2
  211. package/src/tools/slack-update.ts +1 -1
  212. package/src/tools/update-profile.ts +20 -4
  213. package/src/tools/utils.ts +3 -4
  214. package/src/types.ts +47 -0
  215. package/src/utils/constants.ts +127 -5
  216. package/templates/official/lead/HEARTBEAT.md +4 -2
  217. package/templates/official/lead/config.json +2 -2
  218. package/templates/skills/swarm-docs/content.md +2 -0
  219. package/dist/handlers-7x8sywgr.js +0 -42
  220. package/dist/index-mc30r1hs.js +0 -44
@@ -0,0 +1,242 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { type DeployStubs, makeDeployStubs } from "./deploy-stubs";
6
+ import { CHILD_PROCESS_TEST_BUDGET_MS, runChild } from "./test-proc";
7
+
8
+ // `install.sh --bind` runs for real against a fake docker that publishes ports
9
+ // the way Compose does (fixtures/deploy-stubs), so these tests check where the
10
+ // running stack listens, not what the script prints.
11
+
12
+ const INSTALL_SH = resolve(import.meta.dir, "../../install.sh");
13
+ const BOUND = "Publishing ports on 127.0.0.1 only";
14
+ // What the onboard compose generator puts before each published port.
15
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
16
+ const BIND_PREFIX = "${AGENT_SWARM_BIND_ADDRESS:+${AGENT_SWARM_BIND_ADDRESS:-}:}";
17
+
18
+ let dir: string;
19
+ let installDir: string;
20
+
21
+ beforeEach(async () => {
22
+ dir = await mkdtemp(join(tmpdir(), "install-sh-bind-"));
23
+ installDir = join(dir, "agent-swarm");
24
+ });
25
+
26
+ afterEach(async () => {
27
+ await rm(dir, { recursive: true, force: true });
28
+ });
29
+
30
+ // An install from before AGENT_SWARM_BIND_ADDRESS, with `ports` as given.
31
+ async function existingInstall(ports: string) {
32
+ await Bun.write(
33
+ join(installDir, "docker-compose.yml"),
34
+ `services:\n swarm-api:\n image: ghcr.io/desplega-ai/agent-swarm:latest\n ports:\n - ${ports}\n volumes:\n - swarm_data:/app/data\n`,
35
+ );
36
+ await Bun.write(join(installDir, ".env"), "API_KEY=k\nMCP_BASE_URL=http://localhost:3013\n");
37
+ }
38
+
39
+ interface InstallOptions {
40
+ env?: Record<string, string>;
41
+ args?: string[];
42
+ /** Runs against the fake docker before install.sh, e.g. to leave a network behind. */
43
+ before?: (stubs: DeployStubs) => Promise<void>;
44
+ }
45
+
46
+ async function install({ env = {}, args = ["--no-tunnel"], before }: InstallOptions = {}) {
47
+ const stubs = await makeDeployStubs(dir, env);
48
+ await before?.(stubs);
49
+ const r = await runChild(
50
+ ["bash", INSTALL_SH, "--dir", installDir, ...args, "--bind", "127.0.0.1"],
51
+ {
52
+ env: stubs.env,
53
+ },
54
+ );
55
+ return { ...r, out: r.stdout + r.stderr, stubs };
56
+ }
57
+
58
+ const NETWORK = "agent-swarm_default";
59
+ const NETWORK_OPTION = 'com.docker.network.bridge.host_binding_ipv4: "127.0.0.1"';
60
+ const override = () => readFile(join(installDir, "docker-compose.override.yml"), "utf8");
61
+
62
+ describe("install.sh --bind", () => {
63
+ test(
64
+ "rewrites an existing literal 3013:3013 mapping before starting the stack",
65
+ async () => {
66
+ await existingInstall('"3013:3013"');
67
+ // The earlier install's network, without a default address. Compose reuses it.
68
+ const r = await install({ before: (stubs) => stubs.network(NETWORK, "") });
69
+ expect(r.exitCode).toBe(0);
70
+ expect(await r.stubs.ups()).toEqual([`${NETWORK} 0.0.0.0`]);
71
+ expect(await r.stubs.history()).toEqual(["127.0.0.1:3013->3013/tcp"]);
72
+ const compose = await readFile(join(installDir, "docker-compose.yml"), "utf8");
73
+ expect(compose).toContain(`- "${BIND_PREFIX}3013:3013"`);
74
+ expect(compose).toContain("- swarm_data:/app/data");
75
+ expect(await readFile(join(installDir, "docker-compose.yml.bak"), "utf8")).toContain(
76
+ '- "3013:3013"',
77
+ );
78
+ expect(await readFile(join(installDir, ".env"), "utf8")).toContain(
79
+ "AGENT_SWARM_BIND_ADDRESS=127.0.0.1\n",
80
+ );
81
+ expect(r.out).toContain(BOUND);
82
+ },
83
+ CHILD_PROCESS_TEST_BUDGET_MS,
84
+ );
85
+
86
+ test(
87
+ "fails without starting the stack when a mapping names another host address",
88
+ async () => {
89
+ await existingInstall('"0.0.0.0:3013:3013"');
90
+ const r = await install();
91
+ expect(r.exitCode).not.toBe(0);
92
+ expect(r.out).toContain("publishes ports that ignore --bind: 0.0.0.0:3013:3013");
93
+ expect(r.out).not.toContain(BOUND);
94
+ expect(await r.stubs.history()).toEqual([]);
95
+ },
96
+ CHILD_PROCESS_TEST_BUDGET_MS,
97
+ );
98
+
99
+ test(
100
+ "stops the stack and fails when a running port still listens elsewhere",
101
+ async () => {
102
+ await existingInstall(`"${BIND_PREFIX}3013:3013"`);
103
+ await Bun.write(
104
+ join(installDir, "docker-compose.override.yml"),
105
+ 'services:\n swarm-api:\n ports:\n - "0.0.0.0:3014:3014"\n',
106
+ );
107
+ const r = await install();
108
+ expect(r.exitCode).not.toBe(0);
109
+ expect(r.out).toContain(
110
+ "ports are still published outside 127.0.0.1 (0.0.0.0:3014->3014/tcp",
111
+ );
112
+ expect(await override()).toContain(`ports:\n - "0.0.0.0:3014:3014"\nnetworks:`);
113
+ expect(r.out).not.toContain(BOUND);
114
+ expect(await r.stubs.ports()).toEqual([]);
115
+ },
116
+ CHILD_PROCESS_TEST_BUDGET_MS,
117
+ );
118
+
119
+ test(
120
+ "the first docker compose up, inside a CLI that ignores the bind address, already binds there",
121
+ async () => {
122
+ const r = await install();
123
+ expect(r.exitCode).toBe(0);
124
+ // onboard's own `up` comes first; it must find the network default in place.
125
+ expect((await r.stubs.ups())[0]).toBe(`${NETWORK} 127.0.0.1`);
126
+ const history = await r.stubs.history();
127
+ expect(history.length).toBeGreaterThan(0);
128
+ expect(history.filter((p) => !p.startsWith("127.0.0.1:"))).toEqual([]);
129
+ expect(await override()).toContain(
130
+ `networks:\n default:\n driver_opts:\n ${NETWORK_OPTION}\n`,
131
+ );
132
+ },
133
+ CHILD_PROCESS_TEST_BUDGET_MS,
134
+ );
135
+
136
+ test(
137
+ "the override keeps the network default next to the public URL",
138
+ async () => {
139
+ const r = await install({ args: ["--public-url", "https://swarm.example.com"] });
140
+ expect(r.exitCode).toBe(0);
141
+ const text = await override();
142
+ expect(text).toContain(NETWORK_OPTION);
143
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
144
+ expect(text).toContain("- PUBLIC_MCP_BASE_URL=${PUBLIC_MCP_BASE_URL:-}");
145
+ expect(text.match(/^# Written by install\.sh/gm)).toHaveLength(1);
146
+ expect((await r.stubs.history()).filter((p) => !p.startsWith("127.0.0.1:"))).toEqual([]);
147
+ },
148
+ CHILD_PROCESS_TEST_BUDGET_MS,
149
+ );
150
+
151
+ test(
152
+ "a re-run with another address rewrites the network default",
153
+ async () => {
154
+ await existingInstall('"3013:3013"');
155
+ await Bun.write(
156
+ join(installDir, "docker-compose.override.yml"),
157
+ `networks:\n default:\n driver_opts:\n com.docker.network.bridge.host_binding_ipv4: "10.0.0.5"\n`,
158
+ );
159
+ const r = await install();
160
+ expect(r.exitCode).toBe(0);
161
+ expect(await override()).toContain(NETWORK_OPTION);
162
+ expect(await override()).not.toContain("10.0.0.5");
163
+ },
164
+ CHILD_PROCESS_TEST_BUDGET_MS,
165
+ );
166
+
167
+ test(
168
+ "a fresh install refuses a network an earlier install left without the default",
169
+ async () => {
170
+ const r = await install({ before: (stubs) => stubs.network(NETWORK, "") });
171
+ expect(r.exitCode).not.toBe(0);
172
+ expect(r.out).toContain(
173
+ `Docker network ${NETWORK} from an earlier install exists and publishes ports on every interface`,
174
+ );
175
+ expect(await r.stubs.history()).toEqual([]);
176
+ },
177
+ CHILD_PROCESS_TEST_BUDGET_MS,
178
+ );
179
+
180
+ test(
181
+ "fails before starting anything when a compose.override.yml would hide the network default",
182
+ async () => {
183
+ await Bun.write(join(installDir, "compose.override.yml"), "services: {}\n");
184
+ const r = await install();
185
+ expect(r.exitCode).not.toBe(0);
186
+ expect(r.out).toContain("compose.override.yml hides docker-compose.override.yml");
187
+ expect(await r.stubs.history()).toEqual([]);
188
+ },
189
+ CHILD_PROCESS_TEST_BUDGET_MS,
190
+ );
191
+
192
+ test(
193
+ "a fresh install with a CLI that ignores the bind address ends on loopback",
194
+ async () => {
195
+ const r = await install();
196
+ expect(r.exitCode).toBe(0);
197
+ expect(await r.stubs.ports()).toEqual([
198
+ "127.0.0.1:3013->3013/tcp",
199
+ "127.0.0.1:3201->3000/tcp",
200
+ ]);
201
+ expect(r.out).toContain("Rewriting 2 port mapping(s)");
202
+ expect(r.out).toContain(BOUND);
203
+ },
204
+ CHILD_PROCESS_TEST_BUDGET_MS,
205
+ );
206
+
207
+ test(
208
+ "stops the stack when onboard fails after starting it",
209
+ async () => {
210
+ const r = await install({ env: { FAKE_ONBOARD_FAIL: "1" } });
211
+ expect(r.exitCode).not.toBe(0);
212
+ expect(await r.stubs.history()).toContain("127.0.0.1:3013->3013/tcp");
213
+ expect(await r.stubs.ports()).toEqual([]);
214
+ expect(r.out).not.toContain(BOUND);
215
+ },
216
+ CHILD_PROCESS_TEST_BUDGET_MS,
217
+ );
218
+
219
+ test(
220
+ "stops the stack when the installer is terminated after onboard starts it",
221
+ async () => {
222
+ const r = await install({ env: { FAKE_ONBOARD_FAIL: "term" } });
223
+ expect(r.exitCode).not.toBe(0);
224
+ expect(await r.stubs.history()).toContain("127.0.0.1:3013->3013/tcp");
225
+ expect(await r.stubs.ports()).toEqual([]);
226
+ expect(r.out).not.toContain(BOUND);
227
+ },
228
+ CHILD_PROCESS_TEST_BUDGET_MS,
229
+ );
230
+
231
+ test(
232
+ "stops the stack when the generated compose file names another host address",
233
+ async () => {
234
+ const r = await install({ env: { FAKE_ONBOARD_API_PORT: "0.0.0.0:3013:3013" } });
235
+ expect(r.exitCode).not.toBe(0);
236
+ expect(r.out).toContain("publishes ports that ignore --bind: 0.0.0.0:3013:3013");
237
+ expect(await r.stubs.history()).toContain("0.0.0.0:3013->3013/tcp");
238
+ expect(await r.stubs.ports()).toEqual([]);
239
+ },
240
+ CHILD_PROCESS_TEST_BUDGET_MS,
241
+ );
242
+ });
@@ -25,6 +25,8 @@ async function dryRun(args: string[] = [], env: Record<string, string> = {}, dot
25
25
  const base: Record<string, string | undefined> = { ...process.env };
26
26
  delete base.AGENT_SWARM_TUNNEL;
27
27
  delete base.AGENT_SWARM_PRESET;
28
+ delete base.AGENT_SWARM_PUBLIC_URL;
29
+ delete base.AGENT_SWARM_BIND_ADDRESS;
28
30
  const r = await runChild(["bash", INSTALL_SH, "--dry-run", "--dir", dir, ...args], {
29
31
  env: { ...base, ...env },
30
32
  });
@@ -123,3 +125,90 @@ describe("install.sh tunnel default", () => {
123
125
  CHILD_PROCESS_TEST_BUDGET_MS,
124
126
  );
125
127
  });
128
+
129
+ async function dryRunFails(args: string[]) {
130
+ const r = await runChild(["bash", INSTALL_SH, "--dry-run", "--dir", dir, ...args]);
131
+ expect(r.exitCode).not.toBe(0);
132
+ return r.stdout + r.stderr;
133
+ }
134
+
135
+ describe("install.sh public URL and bind address", () => {
136
+ const LINK = "/setup?apiUrl=https%3A%2F%2Fswarm.example.com&apiKey=";
137
+
138
+ test(
139
+ "--public-url turns the tunnel off, passes the URL to the API and uses it in the link",
140
+ async () => {
141
+ const out = await dryRun(["--public-url", "https://swarm.example.com/"]);
142
+ expect(out).not.toContain(TUNNEL_LINE);
143
+ expect(out).toContain("set AGENT_SWARM_TUNNEL=0");
144
+ expect(out).toContain("set PUBLIC_MCP_BASE_URL=https://swarm.example.com in");
145
+ expect(out).toContain("write docker-compose.override.yml");
146
+ expect(out).toContain("up -d swarm-api agent-fs");
147
+ expect(out).toContain(LINK);
148
+ expect(out).toContain("Public API: https://swarm.example.com\n");
149
+ },
150
+ CHILD_PROCESS_TEST_BUDGET_MS,
151
+ );
152
+
153
+ test(
154
+ "an own-domain PUBLIC_MCP_BASE_URL in .env reaches the API and the link",
155
+ async () => {
156
+ const out = await dryRun([], {}, "PUBLIC_MCP_BASE_URL=https://swarm.example.com\n");
157
+ expect(out).not.toContain(TUNNEL_LINE);
158
+ expect(out).toContain("write docker-compose.override.yml");
159
+ expect(out).toContain(LINK);
160
+ },
161
+ CHILD_PROCESS_TEST_BUDGET_MS,
162
+ );
163
+
164
+ test(
165
+ "--no-tunnel ignores a stale tunnel URL and links to localhost",
166
+ async () => {
167
+ const out = await dryRun(
168
+ ["--no-tunnel"],
169
+ {},
170
+ "PUBLIC_MCP_BASE_URL=https://abc.lt.desplega.ai\n",
171
+ );
172
+ expect(out).not.toContain("write docker-compose.override.yml");
173
+ expect(out).toContain("/setup?apiUrl=http%3A%2F%2Flocalhost%3A3013&apiKey=");
174
+ },
175
+ CHILD_PROCESS_TEST_BUDGET_MS,
176
+ );
177
+
178
+ test(
179
+ "--bind saves the address and plans the port check without claiming it",
180
+ async () => {
181
+ const out = await dryRun(["--no-tunnel", "--bind", "127.0.0.1"]);
182
+ expect(out).not.toContain("Publishing ports on 127.0.0.1 only");
183
+ expect(out).toContain("+ check that every published port listens on 127.0.0.1");
184
+ expect(out).toContain("set AGENT_SWARM_BIND_ADDRESS=127.0.0.1 in");
185
+ },
186
+ CHILD_PROCESS_TEST_BUDGET_MS,
187
+ );
188
+
189
+ test(
190
+ "--public-url wins over AGENT_SWARM_TUNNEL=1",
191
+ async () => {
192
+ const out = await dryRun(["--public-url", "https://swarm.example.com"], {
193
+ AGENT_SWARM_TUNNEL: "1",
194
+ });
195
+ expect(out).not.toContain(TUNNEL_LINE);
196
+ expect(out).toContain(LINK);
197
+ },
198
+ CHILD_PROCESS_TEST_BUDGET_MS,
199
+ );
200
+
201
+ test(
202
+ "rejects a URL without a scheme, a conflicting --tunnel, and a non-IP bind",
203
+ async () => {
204
+ expect(await dryRunFails(["--public-url", "swarm.example.com"])).toContain(
205
+ "must start with http:// or https://",
206
+ );
207
+ expect(
208
+ await dryRunFails(["--tunnel", "--public-url", "https://swarm.example.com"]),
209
+ ).toContain("conflict");
210
+ expect(await dryRunFails(["--bind", "localhost"])).toContain("needs an IP address");
211
+ },
212
+ CHILD_PROCESS_TEST_BUDGET_MS,
213
+ );
214
+ });
@@ -17,6 +17,7 @@ import {
17
17
  sweepExpiredKvPrefix,
18
18
  upsertKv,
19
19
  } from "../be/db";
20
+ import { runMcpOverflowSweep } from "../be/mcp-overflow-sweep";
20
21
 
21
22
  const TEST_DB_PATH = "./test-kv-storage.sqlite";
22
23
 
@@ -105,6 +106,33 @@ describe("kv-storage helpers", () => {
105
106
  expect((await getKv(NS, "live-spill"))?.value).toBe("new");
106
107
  });
107
108
 
109
+ test("the MCP overflow sweep removes expired spills of every agent", async () => {
110
+ const now = Date.now();
111
+ await upsertKv({
112
+ namespace: "mcp:overflow:agent-c",
113
+ key: "expired-c",
114
+ value: "x",
115
+ valueType: "string",
116
+ expiresAt: now - 1,
117
+ });
118
+ await upsertKv({
119
+ namespace: "mcp:overflow:agent-d",
120
+ key: "live-d",
121
+ value: "x",
122
+ valueType: "string",
123
+ expiresAt: now + 10_000,
124
+ });
125
+
126
+ expect(await runMcpOverflowSweep(now)).toBeGreaterThanOrEqual(1);
127
+ // Raw read: getKv deletes an expired row itself.
128
+ const expired = await getDbClient().get<{ key: string }>(
129
+ "SELECT key FROM kv_entries WHERE namespace = ? AND key = ?",
130
+ ["mcp:overflow:agent-c", "expired-c"],
131
+ );
132
+ expect(expired).toBeNull();
133
+ expect((await getKv("mcp:overflow:agent-d", "live-d"))?.value).toBe("x");
134
+ });
135
+
108
136
  test("prefix sweep removes expired entries across per-agent overflow namespaces", async () => {
109
137
  const now = Date.now();
110
138
  for (const [namespace, key, expiresAt] of [
@@ -62,6 +62,7 @@ describe("list-endpoint slimming", () => {
62
62
  heartbeatMd: "H".repeat(500),
63
63
  setupScript: "echo hi",
64
64
  avatar: { type: "lucide", icon: "rocket", color: "#8b5cf6" },
65
+ slackIcon: { type: "emoji", emoji: ":owl:" },
65
66
  });
66
67
 
67
68
  const slim = (await getAllAgents({ slim: true })).find((a) => a.id === agent.id);
@@ -79,11 +80,13 @@ describe("list-endpoint slimming", () => {
79
80
  // full, or the agents list would silently show the default avatar while
80
81
  // the detail page shows the custom one.
81
82
  expect(slim?.avatar).toEqual({ type: "lucide", icon: "rocket", color: "#8b5cf6" });
83
+ expect(slim?.slackIcon).toEqual({ type: "emoji", emoji: ":owl:" });
82
84
 
83
85
  const full = (await getAllAgents()).find((a) => a.id === agent.id);
84
86
  expect(full?.claudeMd).toBe("C".repeat(500));
85
87
  expect(full?.setupScript).toBe("echo hi");
86
88
  expect(full?.avatar).toEqual({ type: "lucide", icon: "rocket", color: "#8b5cf6" });
89
+ expect(full?.slackIcon).toEqual({ type: "emoji", emoji: ":owl:" });
87
90
  });
88
91
 
89
92
  test("updateAgentProfile — avatar set/reset round-trip", async () => {
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createListTotalCache } from "../http/list-total-cache";
3
+
4
+ function counter(values: number[]) {
5
+ let calls = 0;
6
+ return {
7
+ compute: async () => values[Math.min(calls++, values.length - 1)]!,
8
+ calls: () => calls,
9
+ };
10
+ }
11
+
12
+ describe("list total cache", () => {
13
+ test("page 1 always counts; later pages reuse its count", async () => {
14
+ const cache = createListTotalCache();
15
+ const count = counter([10, 11, 12]);
16
+
17
+ expect(await cache.get({ q: "a" }, 0, count.compute)).toBe(10);
18
+ expect(await cache.get({ q: "a" }, 25, count.compute)).toBe(10);
19
+ expect(await cache.get({ q: "a" }, 50, count.compute)).toBe(10);
20
+ expect(count.calls()).toBe(1);
21
+
22
+ expect(await cache.get({ q: "a" }, undefined, count.compute)).toBe(11);
23
+ expect(await cache.get({ q: "a" }, 25, count.compute)).toBe(11);
24
+ expect(count.calls()).toBe(2);
25
+ });
26
+
27
+ test("a cold later page counts, so total is always set", async () => {
28
+ const cache = createListTotalCache();
29
+ const count = counter([7]);
30
+ expect(await cache.get({ q: "b" }, 25, count.compute)).toBe(7);
31
+ expect(await cache.get({ q: "b" }, 50, count.compute)).toBe(7);
32
+ expect(count.calls()).toBe(1);
33
+ });
34
+
35
+ test("filter sets are keyed independently of key order", async () => {
36
+ const cache = createListTotalCache();
37
+ const count = counter([1, 2]);
38
+ await cache.get({ status: "open", viewer: { userId: "u", scope: "all" } }, 0, count.compute);
39
+ expect(
40
+ await cache.get({ viewer: { scope: "all", userId: "u" }, status: "open" }, 25, count.compute),
41
+ ).toBe(1);
42
+ expect(await cache.get({ status: "done" }, 25, count.compute)).toBe(2);
43
+ expect(count.calls()).toBe(2);
44
+ });
45
+
46
+ test("an entry expires after the TTL", async () => {
47
+ const cache = createListTotalCache(1);
48
+ const count = counter([1, 2]);
49
+ await cache.get({}, 0, count.compute);
50
+ await Bun.sleep(5);
51
+ expect(await cache.get({}, 25, count.compute)).toBe(2);
52
+ });
53
+
54
+ test("a failed count is not cached", async () => {
55
+ const cache = createListTotalCache();
56
+ await expect(
57
+ cache.get({}, 0, async () => {
58
+ throw new Error("boom");
59
+ }),
60
+ ).rejects.toThrow("boom");
61
+ expect(await cache.get({}, 25, async () => 3)).toBe(3);
62
+ });
63
+ });
@@ -0,0 +1,84 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, getDbClient, initDb, upsertKv } from "../be/db";
4
+ import { startMcpOverflowSweep, stopMcpOverflowSweep } from "../be/mcp-overflow-sweep";
5
+
6
+ const TEST_DB_PATH = "./test-mcp-overflow-sweep.sqlite";
7
+
8
+ async function removeDbFiles(): Promise<void> {
9
+ for (const suffix of ["", "-wal", "-shm"])
10
+ await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => undefined);
11
+ }
12
+
13
+ // Reads the raw row: getKv deletes an expired row itself, which would hide
14
+ // whether the sweep ran.
15
+ async function stored(namespace: string, key: string): Promise<boolean> {
16
+ const row = await getDbClient().get<{ key: string }>(
17
+ "SELECT key FROM kv_entries WHERE namespace = ? AND key = ?",
18
+ [namespace, key],
19
+ );
20
+ return row !== null;
21
+ }
22
+
23
+ async function spill(namespace: string, key: string, expiresAt: number): Promise<void> {
24
+ await upsertKv({ namespace, key, value: "x", valueType: "string", expiresAt });
25
+ }
26
+
27
+ describe("MCP overflow sweep lifecycle", () => {
28
+ beforeAll(async () => {
29
+ await removeDbFiles();
30
+ initDb(TEST_DB_PATH);
31
+ });
32
+
33
+ afterEach(() => stopMcpOverflowSweep());
34
+
35
+ afterAll(async () => {
36
+ stopMcpOverflowSweep();
37
+ closeDb();
38
+ await removeDbFiles();
39
+ });
40
+
41
+ test("start twice creates one interval, stop clears it, start after stop works", () => {
42
+ const set = spyOn(globalThis, "setInterval");
43
+ const clear = spyOn(globalThis, "clearInterval");
44
+ try {
45
+ startMcpOverflowSweep(60_000);
46
+ startMcpOverflowSweep(60_000);
47
+ expect(set).toHaveBeenCalledTimes(1);
48
+ const first = set.mock.results[0]?.value;
49
+
50
+ stopMcpOverflowSweep();
51
+ expect(clear).toHaveBeenCalledTimes(1);
52
+ expect(clear).toHaveBeenCalledWith(first);
53
+ stopMcpOverflowSweep();
54
+ expect(clear).toHaveBeenCalledTimes(1);
55
+
56
+ startMcpOverflowSweep(60_000);
57
+ expect(set).toHaveBeenCalledTimes(2);
58
+ } finally {
59
+ stopMcpOverflowSweep();
60
+ set.mockRestore();
61
+ clear.mockRestore();
62
+ }
63
+ });
64
+
65
+ test("a running sweep deletes expired spills on its tick and stops after stop", async () => {
66
+ const now = Date.now();
67
+ await spill("mcp:overflow:agent-a", "expired", now - 1);
68
+ await spill("mcp:overflow:agent-b", "live", now + 60_000);
69
+
70
+ expect(await stored("mcp:overflow:agent-a", "expired")).toBe(true);
71
+
72
+ startMcpOverflowSweep(5);
73
+ for (let i = 0; i < 100 && (await stored("mcp:overflow:agent-a", "expired")); i++) {
74
+ await Bun.sleep(5);
75
+ }
76
+ expect(await stored("mcp:overflow:agent-a", "expired")).toBe(false);
77
+ expect(await stored("mcp:overflow:agent-b", "live")).toBe(true);
78
+
79
+ stopMcpOverflowSweep();
80
+ await spill("mcp:overflow:agent-a", "after-stop", Date.now() - 1);
81
+ await Bun.sleep(30);
82
+ expect(await stored("mcp:overflow:agent-a", "after-stop")).toBe(true);
83
+ });
84
+ });
@@ -363,9 +363,23 @@ describe("generateCompose", () => {
363
363
 
364
364
  test("port allocation starts at 3201 and increments", () => {
365
365
  const yaml = generateCompose(devState);
366
- expect(yaml).toContain('"3201:3000"');
367
- expect(yaml).toContain('"3202:3000"');
368
- expect(yaml).toContain('"3203:3000"');
366
+ expect(yaml).toContain(':}3201:3000"');
367
+ expect(yaml).toContain(':}3202:3000"');
368
+ expect(yaml).toContain(':}3203:3000"');
369
+ });
370
+
371
+ test("every published port takes its host address from AGENT_SWARM_BIND_ADDRESS", () => {
372
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
373
+ const prefix = '"${AGENT_SWARM_BIND_ADDRESS:+${AGENT_SWARM_BIND_ADDRESS:-}:}';
374
+ const yaml = generateCompose({ ...devState, bundleAgentFs: true });
375
+ const published = yaml.match(/^ {6}- "[^"]*:30(?:13|00)"$/gm) ?? [];
376
+ // swarm-api plus three agents; agent-fs and tei publish nothing.
377
+ expect(published).toHaveLength(4);
378
+ for (const line of published) {
379
+ // Blank or unset keeps Compose's default (every interface) without an unset-variable warning.
380
+ expect(line).toContain(prefix);
381
+ }
382
+ expect(yaml).toContain(`${prefix}3013:3013"`);
369
383
  });
370
384
 
371
385
  // ── API service has healthcheck ──
@@ -257,6 +257,19 @@ describe("generateEnv with the agent-fs bundle flag", () => {
257
257
  });
258
258
  });
259
259
 
260
+ describe("generateEnv bind address forwarding", () => {
261
+ test("writes AGENT_SWARM_BIND_ADDRESS when set", () => {
262
+ const env = generateEnv(makeState({}), { AGENT_SWARM_BIND_ADDRESS: "127.0.0.1" });
263
+ expect(env).toContain("\nAGENT_SWARM_BIND_ADDRESS=127.0.0.1\n");
264
+ });
265
+
266
+ test("omits it when unset or blank", () => {
267
+ for (const source of [{}, { AGENT_SWARM_BIND_ADDRESS: " " }]) {
268
+ expect(generateEnv(makeState({}), source)).not.toContain("AGENT_SWARM_BIND_ADDRESS");
269
+ }
270
+ });
271
+ });
272
+
260
273
  describe("generateEnv telemetry forwarding", () => {
261
274
  test("writes ANONYMIZED_TELEMETRY and DESPLEGA_TELEMETRY_ENV when set", () => {
262
275
  const env = generateEnv(makeState({}), {
@@ -878,7 +878,7 @@ describe("onboarding state", () => {
878
878
  process.env.COMB_ENABLED = "true";
879
879
  process.env.AGENT_FS_API_URL = "http://agent-fs.example.test";
880
880
  await put({ action: "complete", step: "files", method: "connected" });
881
- delete process.env.COMB_ENABLED;
881
+ process.env.COMB_ENABLED = "false";
882
882
  const body = (await (await request("/api/onboarding")).json()) as { state: OnboardingState };
883
883
  expect(body.state.steps.files).toMatchObject({ status: "done", method: "connected" });
884
884
  });
@@ -33,6 +33,7 @@ for (const key of Object.keys(process.env)) {
33
33
  "APP_URL",
34
34
  "AGENT_FS_API_URL",
35
35
  "AGENT_FS_PROXY",
36
+ "AGENT_FS_PUBLIC_URL",
36
37
  "AGENT_FS_LIVE_URL",
37
38
  "AGENT_FS_LINK_TARGET",
38
39
  "AGENT_FS_DEFAULT_ORG_ID",