@desplega.ai/agent-swarm 1.151.0 → 1.152.1

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 (108) hide show
  1. package/README.md +3 -1
  2. package/dist/{acp-adapter-ex8hzjfc.js → acp-adapter-4s7q25ft.js} +1 -1
  3. package/dist/{actions-2bgpyhww.js → actions-cs6rhmvc.js} +13 -13
  4. package/dist/{app-1706zhr1.js → app-j54aq0dj.js} +3 -3
  5. package/dist/{assistant-bavke5p7.js → assistant-w1pgn7vf.js} +14 -14
  6. package/dist/{boot-reembed-3cmrrmpa.js → boot-reembed-42vgkrrj.js} +4 -4
  7. package/dist/{boot-reembed-nafcmmmc.js → boot-reembed-smy30z32.js} +3 -3
  8. package/dist/{boot-scrub-logs-cd6a2sq0.js → boot-scrub-logs-b6s72fsw.js} +2 -2
  9. package/dist/{claude-adapter-wfp2gzvz.js → claude-adapter-fh7rmcj9.js} +3 -3
  10. package/dist/{claude-sdk-session-bht9jjak.js → claude-sdk-session-xhynatn9.js} +3 -3
  11. package/dist/{cli-f1wky6zp.js → cli-3g61md6v.js} +1 -1
  12. package/dist/{cli-a1z3h107.js → cli-3pft2z6r.js} +4 -4
  13. package/dist/{cli-5tpgq60f.js → cli-72tgtzr1.js} +152 -23
  14. package/dist/{cli-0qb1z6s2.js → cli-7bwaerjt.js} +1 -1
  15. package/dist/{cli-czjtgfys.js → cli-b4dyetqt.js} +2 -2
  16. package/dist/{cli-s14agm5s.js → cli-bdt7w021.js} +12 -3
  17. package/dist/{cli-sjaae6ff.js → cli-dsyg66vt.js} +3 -3
  18. package/dist/{cli-2tbvf1z1.js → cli-ftdjz1mk.js} +8 -8
  19. package/dist/{cli-zkxymas5.js → cli-gfez0qmk.js} +1 -1
  20. package/dist/{cli-q88st9aj.js → cli-gxf2ebyz.js} +2 -2
  21. package/dist/{cli-ppm4975n.js → cli-jtvs5m8q.js} +1 -1
  22. package/dist/{cli-7tgcstn2.js → cli-jytf5mw9.js} +2 -2
  23. package/dist/{cli-g69fdc9g.js → cli-kf8qp2vf.js} +2 -2
  24. package/dist/{cli-5jd2z7av.js → cli-ks8a1cgq.js} +6 -2
  25. package/dist/{cli-gjyvhmmh.js → cli-ng4q6a9t.js} +5 -5
  26. package/dist/{cli-3v9xhjcn.js → cli-sakxd2rg.js} +2 -2
  27. package/dist/{cli-frcta2w4.js → cli-sqmh7jj1.js} +1 -1
  28. package/dist/{cli-vwm416m6.js → cli-v9sk4dzw.js} +1 -1
  29. package/dist/{cli-j9xczhaq.js → cli-vbk025fm.js} +1 -1
  30. package/dist/{cli-r2q6pevn.js → cli-wfve107r.js} +43 -21
  31. package/dist/{cli-9004vfdf.js → cli-y8tayczk.js} +2 -2
  32. package/dist/{cli-gbfwcd3k.js → cli-zfzznn7c.js} +7 -6
  33. package/dist/cli.js +11 -11
  34. package/dist/{codex-adapter-jjjk8w4m.js → codex-adapter-k6yq95sn.js} +2 -2
  35. package/dist/{codex-session-runner-nd6ayxkx.js → codex-session-runner-3rp84pk8.js} +2 -2
  36. package/dist/{commands-9v4psyq1.js → commands-pbr5jzy5.js} +2 -2
  37. package/dist/{db-wnt58tpe.js → db-z0xaa59a.js} +2 -2
  38. package/dist/{dispatcher-05jh1aqk.js → dispatcher-pwpgsj0x.js} +12 -12
  39. package/dist/{handlers-tw90pkh5.js → handlers-5mzk4mbg.js} +15 -15
  40. package/dist/{hook-sxe3r349.js → hook-8bz33zee.js} +1 -1
  41. package/dist/{hook-123e2scb.js → hook-kbh84zb1.js} +2 -2
  42. package/dist/{http-ecsar8ez.js → http-pppxg5vr.js} +100 -36
  43. package/dist/{index-hw26vj40.js → index-68bd9b5f.js} +12 -12
  44. package/dist/{index-16js521z.js → index-k1hq1qt6.js} +8 -8
  45. package/dist/{index-zj518gf9.js → index-mjpxsr6k.js} +13 -13
  46. package/dist/{index-ymn7em0p.js → index-y0te09pn.js} +3 -3
  47. package/dist/{index-nnrbpcjp.js → index-z0zwjtkn.js} +12 -12
  48. package/dist/{keepalive-asqtcm4y.js → keepalive-xvmwnh1d.js} +4 -4
  49. package/dist/{lead-xbfr7fvt.js → lead-pf8nvxt8.js} +16 -16
  50. package/dist/{maintenance-qma55qvw.js → maintenance-jk4q0wgg.js} +4 -4
  51. package/dist/{oauth-refresh-sweep-3s04c6qw.js → oauth-refresh-sweep-6207x9sj.js} +3 -3
  52. package/dist/{onboard-drskd386.js → onboard-b0csgm0q.js} +2 -2
  53. package/dist/{otel-impl-943c96er.js → otel-impl-b6rfehth.js} +1 -1
  54. package/dist/{pricing-refresh-mx51az05.js → pricing-refresh-4e201wtd.js} +4 -4
  55. package/dist/{rbac-roles-wcmrg6sv.js → rbac-roles-mkc5n5qa.js} +4 -4
  56. package/dist/{rbac-roles-78baecqd.js → rbac-roles-zp6e4geh.js} +3 -3
  57. package/dist/{render-v2-8zjntgf5.js → render-v2-4fazz9tf.js} +3 -3
  58. package/dist/{seed-pricing-jqjs6cws.js → seed-pricing-thxpn9vn.js} +3 -3
  59. package/dist/{setup-4pks0vfk.js → setup-wb0z8cat.js} +2 -2
  60. package/dist/{work-objects-wangt792.js → work-objects-h3498kwe.js} +18 -17
  61. package/dist/{worker-p2mr422x.js → worker-2m2tzxp3.js} +16 -16
  62. package/openapi.json +5 -5
  63. package/package.json +1 -1
  64. package/src/agentmail/inbound-archive.ts +71 -0
  65. package/src/agentmail/types.ts +2 -0
  66. package/src/be/db.ts +1 -1
  67. package/src/be/scripts/typecheck.ts +4 -0
  68. package/src/be/swarm-config-guard.ts +7 -1
  69. package/src/extensions/lifecycle.ts +26 -4
  70. package/src/http/extensions.ts +8 -3
  71. package/src/http/webhooks.ts +13 -5
  72. package/src/rbac/legacy-policy.ts +13 -1
  73. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  74. package/src/scripts-runtime/types/stdlib.d.ts +4 -0
  75. package/src/scripts-runtime/types/swarm-sdk.d.ts +4 -0
  76. package/src/server.ts +12 -0
  77. package/src/slack/render-v2.ts +10 -4
  78. package/src/slack/responses.ts +7 -4
  79. package/src/slack/task-output.ts +21 -0
  80. package/src/slack/watcher.ts +3 -2
  81. package/src/slack/work-objects.ts +2 -1
  82. package/src/tests/agentmail-inbound-archive.test.ts +418 -0
  83. package/src/tests/extensions-example-mcp-install.test.ts +2 -2
  84. package/src/tests/extensions-http.test.ts +84 -3
  85. package/src/tests/extensions-lead-equivalence.test.ts +21 -0
  86. package/src/tests/extensions-mcp-tools.test.ts +111 -3
  87. package/src/tests/fixtures/store-progress-malformed-output.json +4 -0
  88. package/src/tests/rbac-admission-e2e.test.ts +35 -4
  89. package/src/tests/rbac-e2e-helpers.ts +18 -0
  90. package/src/tests/rbac-engine.test.ts +2 -2
  91. package/src/tests/rbac-mcp-admission-e2e.test.ts +22 -4
  92. package/src/tests/reload-config.test.ts +17 -0
  93. package/src/tests/scripts-only-gating.test.ts +4 -0
  94. package/src/tests/slack-render-v2.test.ts +24 -2
  95. package/src/tests/slack-task-output.test.ts +36 -0
  96. package/src/tests/store-progress-attachments-handler.test.ts +100 -1
  97. package/src/tests/swarm-config-reserved-keys.test.ts +6 -1
  98. package/src/tests/tool-annotations.test.ts +1 -1
  99. package/src/tools/extension-activate-version.ts +38 -0
  100. package/src/tools/extension-common.ts +2 -1
  101. package/src/tools/extension-delete.ts +29 -0
  102. package/src/tools/extension-disable.ts +36 -0
  103. package/src/tools/extension-enable.ts +36 -0
  104. package/src/tools/extension-install.ts +2 -2
  105. package/src/tools/store-progress.ts +30 -0
  106. package/src/tools/tool-config.ts +5 -1
  107. package/templates/skills/agent-browser/SKILL.md +84 -0
  108. package/templates/skills/agent-browser/content.md +21 -0
@@ -4,11 +4,16 @@ import type { IncomingMessage, ServerResponse } from "node:http";
4
4
  import { Readable } from "node:stream";
5
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
6
  import { closeDb, createAgent, getDbClient, initDb } from "../be/db";
7
- import { setExtensionState } from "../be/extensions/db";
7
+ import { getExtensionById, setExtensionState } from "../be/extensions/db";
8
8
  import { typecheckScript } from "../be/scripts/typecheck";
9
+ import { stopExtensionRuntime } from "../extensions/lifecycle";
9
10
  import { handleCore } from "../http/core";
10
11
  import { handleExtensions } from "../http/extensions";
11
12
  import { getPathSegments, parseQueryParams } from "../http/utils";
13
+ import { registerExtensionActivateVersionTool } from "../tools/extension-activate-version";
14
+ import { registerExtensionDeleteTool } from "../tools/extension-delete";
15
+ import { registerExtensionDisableTool } from "../tools/extension-disable";
16
+ import { registerExtensionEnableTool } from "../tools/extension-enable";
12
17
  import { registerExtensionInstallTool } from "../tools/extension-install";
13
18
  import { registerExtensionListTool } from "../tools/extension-list";
14
19
  import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
@@ -40,11 +45,22 @@ async function removeDbFiles(): Promise<void> {
40
45
 
41
46
  function buildToolServer() {
42
47
  const server = new McpServer({ name: "extensions-mcp-tools", version: "1" });
48
+ registerExtensionDeleteTool(server);
49
+ registerExtensionEnableTool(server);
50
+ registerExtensionDisableTool(server);
51
+ registerExtensionActivateVersionTool(server);
43
52
  registerExtensionInstallTool(server);
44
53
  registerExtensionListTool(server);
45
54
  const tools = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
46
55
  ._registeredTools;
47
- return { install: tools["extension-install"]!, list: tools["extension-list"]! };
56
+ return {
57
+ install: tools["extension-install"]!,
58
+ list: tools["extension-list"]!,
59
+ enable: tools["extension-enable"]!,
60
+ disable: tools["extension-disable"]!,
61
+ activate: tools["extension-activate-version"]!,
62
+ delete: tools["extension-delete"]!,
63
+ };
48
64
  }
49
65
 
50
66
  function meta(agentId: string) {
@@ -128,6 +144,7 @@ beforeAll(async () => {
128
144
  });
129
145
 
130
146
  afterAll(async () => {
147
+ await stopExtensionRuntime();
131
148
  globalThis.fetch = savedFetch;
132
149
  closeDb();
133
150
  await removeDbFiles();
@@ -142,11 +159,12 @@ afterAll(async () => {
142
159
  });
143
160
 
144
161
  beforeEach(async () => {
162
+ await stopExtensionRuntime();
145
163
  await getDbClient().run("DELETE FROM extensions");
146
164
  });
147
165
 
148
166
  describe("extension MCP HTTP proxy tools", () => {
149
- test("script SDK typechecks extension install and list calls", async () => {
167
+ test("script SDK typechecks the full extension lifecycle", async () => {
150
168
  const result = await typecheckScript(`
151
169
  import type { ScriptContext } from "swarm-sdk";
152
170
  export default async (_args: unknown, ctx: ScriptContext) => {
@@ -154,6 +172,11 @@ describe("extension MCP HTTP proxy tools", () => {
154
172
  manifest: {},
155
173
  files: { "hooks.ts": "export default () => {}" },
156
174
  });
175
+ const id = "00000000-0000-4000-8000-000000000001";
176
+ await ctx.swarm.extension_enable({ id });
177
+ await ctx.swarm.extension_activate_version({ id, version: 1 });
178
+ await ctx.swarm.extension_disable({ id });
179
+ await ctx.swarm.extension_delete({ id });
157
180
  return await ctx.swarm.extension_list({ enabledOnly: true });
158
181
  };
159
182
  `);
@@ -243,3 +266,88 @@ describe("extension MCP HTTP proxy tools", () => {
243
266
  expect(enabledOnly.structuredContent).toMatchObject({ success: true, extensions: [] });
244
267
  });
245
268
  });
269
+
270
+ describe("extension lifecycle MCP tools", () => {
271
+ test("lead enables, activates, disables, and deletes through the shared HTTP routes", async () => {
272
+ const tools = buildToolServer();
273
+ const installed = (await tools.install.handler(
274
+ await loadBundleFixture("minimal"),
275
+ meta(leadId),
276
+ )) as ToolResult;
277
+ const id = String(installed.structuredContent.id);
278
+ const enabled = (await tools.enable.handler({ id }, meta(leadId))) as ToolResult;
279
+ expect(enabled.structuredContent).toMatchObject({
280
+ success: true,
281
+ enabled: true,
282
+ activeVersion: 1,
283
+ });
284
+
285
+ const rejected = await dispatchExtensionsApi(`${API_URL}/api/extensions/${id}`, {
286
+ method: "DELETE",
287
+ headers: { "x-agent-id": leadId, authorization: `Bearer ${API_KEY}` },
288
+ });
289
+ expect(rejected.status).toBe(409);
290
+ const blocked = (await tools.delete.handler({ id }, meta(leadId))) as ToolResult;
291
+ expect(blocked.isError).toBe(true);
292
+ expect(blocked.structuredContent).toMatchObject({ success: false });
293
+ expect(blocked.content[0]?.text).toContain("Disable");
294
+ expect(await getExtensionById(id)).not.toBeNull();
295
+
296
+ const changed = await loadBundleFixture("minimal");
297
+ changed.files["hooks.ts"] += "\n// next lifecycle version\n";
298
+ await tools.install.handler(changed, meta(leadId));
299
+ const activated = (await tools.activate.handler(
300
+ { id, version: 2 },
301
+ meta(leadId),
302
+ )) as ToolResult;
303
+ expect(activated.structuredContent).toMatchObject({
304
+ success: true,
305
+ activeVersion: 2,
306
+ enabled: true,
307
+ });
308
+ const disabled = (await tools.disable.handler({ id }, meta(leadId))) as ToolResult;
309
+ expect(disabled.structuredContent).toMatchObject({ success: true, enabled: false });
310
+ const deleted = (await tools.delete.handler({ id }, meta(leadId))) as ToolResult;
311
+ expect(deleted.isError).toBe(false);
312
+ expect(deleted.structuredContent).toMatchObject({ success: true, id, deleted: true });
313
+ expect(await getExtensionById(id)).toBeNull();
314
+ });
315
+
316
+ test("workers cannot mutate the extension lifecycle", async () => {
317
+ const tools = buildToolServer();
318
+ const installed = (await tools.install.handler(
319
+ await loadBundleFixture("minimal"),
320
+ meta(leadId),
321
+ )) as ToolResult;
322
+ const id = String(installed.structuredContent.id);
323
+ for (const tool of [tools.enable, tools.disable, tools.activate, tools.delete]) {
324
+ const result = (await tool.handler({ id, version: 1 }, meta(workerId))) as ToolResult;
325
+ expect(result.isError).toBe(true);
326
+ expect(String(result.structuredContent.message)).toContain("Forbidden");
327
+ }
328
+ expect(await getExtensionById(id)).toMatchObject({ enabled: false, activeVersion: 1 });
329
+ });
330
+
331
+ test("missing extensions and versions return error envelopes", async () => {
332
+ const tools = buildToolServer();
333
+ const id = crypto.randomUUID();
334
+ for (const tool of [tools.enable, tools.disable, tools.activate, tools.delete]) {
335
+ const result = (await tool.handler({ id, version: 1 }, meta(leadId))) as ToolResult;
336
+ expect(result.isError).toBe(true);
337
+ expect(result.structuredContent.success).toBe(false);
338
+ }
339
+ const installed = (await tools.install.handler(
340
+ await loadBundleFixture("minimal"),
341
+ meta(leadId),
342
+ )) as ToolResult;
343
+ const result = (await tools.activate.handler(
344
+ { id: installed.structuredContent.id, version: 99 },
345
+ meta(leadId),
346
+ )) as ToolResult;
347
+ expect(result.isError).toBe(true);
348
+ expect(await getExtensionById(String(installed.structuredContent.id))).toMatchObject({
349
+ activeVersion: 1,
350
+ enabled: false,
351
+ });
352
+ });
353
+ });
@@ -0,0 +1,4 @@
1
+ {
2
+ "sourceTaskId": "59811b57-05d4-47d1-9691-ad9013698a0a",
3
+ "output": "Replied in the thread: https://x.com/desplegalabs/status/2100595056939827590\n\nBuilt what Guli said nobody had shown and Jan asked to use: `jev-lead-qualifier`, a global swarm script anyone can drop into their own swarm, plus a live demo — https://api.desplega.agent-swarm.dev/p/556b11781dbb40bf9b1eb1b3315adae6\n\nIt scores ICP fit / intent / authority / urgency as calibrated Jev probabilities and abstains to human_review when genuinely unsure. Money shot: the student \"free trial\" lead reads 0.63 intent, 0.03 fit — eager, not a customer.\n\nOne cosmetic miss: X auto-prepends thread handles, so @ironcarbs and @GuliMoreno render twice. Not deleting a live post over it; say the word if you want it reposted clean.</output>\n<parameter name=\"attachments\">[{\"kind\": \"url\", \"url\": \"https://x.com/desplegalabs/status/2100595056939827590\", \"name\": \"X reply in the Jev thread\", \"description\": \"The @desplegalabs reply to @voidisomorphism tagging @ironcarbs and @GuliMoreno\", \"intent\": \"The deliverable Taras asked for: reply in that thread @ jan and guli\", \"isPrimary\": true}, {\"kind\": \"url\", \"url\": \"https://api.desplega.agent-swarm.dev/p/556b11781dbb40bf9b1eb1b3315adae6\", \"name\": \"Lead qualifier demo page\", \"description\": \"Public demo: 8 leads scored live, abstain band explained, copy-paste block to run it in your own swarm\", \"intent\": \"The demo Jan asked to see and the link the tweet points at\"}]"
4
+ }
@@ -13,13 +13,12 @@ import { join } from "node:path";
13
13
  import {
14
14
  api,
15
15
  makeScratchDir,
16
- readAuditRows,
17
16
  registerAgent,
18
17
  removeScratchDir,
19
18
  type SwarmServer,
20
19
  spawnSwarmServer,
21
20
  WORKER_A,
22
- waitForAuditCount,
21
+ waitForAuditRows,
23
22
  } from "./rbac-e2e-helpers";
24
23
 
25
24
  setDefaultTimeout(120_000);
@@ -358,8 +357,40 @@ describe("RBAC admission over real HTTP", () => {
358
357
  });
359
358
  expect(operatorCreate.status).toBe(201);
360
359
 
361
- expect(await waitForAuditCount(dbPath, 2)).toBeGreaterThanOrEqual(2);
362
- const userHttpRows = readAuditRows(dbPath).filter(
360
+ const userHttpRows = (
361
+ await waitForAuditRows(dbPath, (rows) => {
362
+ const userRows = rows.filter(
363
+ (row) =>
364
+ row.principalType === "user" &&
365
+ row.principalId === userId &&
366
+ row.source === "http" &&
367
+ row.resourceType === "http-route",
368
+ );
369
+ return (
370
+ [
371
+ "GET /api/agents/{id}/mcp-servers",
372
+ "GET /api/scripts/{id}/apis/{endpointId}/secret",
373
+ "PATCH /api/assets/app/{id}/key",
374
+ "POST /api/scripts/{id}/apis",
375
+ "POST /api/tasks",
376
+ ].every((resourceId) =>
377
+ userRows.some((row) => row.resourceId === resourceId && row.decision === "deny"),
378
+ ) &&
379
+ userRows.some(
380
+ (row) =>
381
+ row.resourceId === "PUT /api/favorites" &&
382
+ row.verb === "favorite.write.own" &&
383
+ row.decision === "allow",
384
+ ) &&
385
+ userRows.some(
386
+ (row) =>
387
+ row.resourceId === "PATCH /api/assets/app/{id}/key" &&
388
+ row.verb === "app.manage" &&
389
+ row.decision === "allow",
390
+ )
391
+ );
392
+ })
393
+ ).filter(
363
394
  (row) =>
364
395
  row.principalType === "user" &&
365
396
  row.principalId === userId &&
@@ -314,3 +314,21 @@ export async function waitForAuditCount(
314
314
  }
315
315
  return n;
316
316
  }
317
+
318
+ /** Poll until audit rows satisfy `predicate` (writer flushes every 2s). */
319
+ export async function waitForAuditRows(
320
+ dbPath: string,
321
+ predicate: (rows: AuditRow[]) => boolean,
322
+ deadlineMs = 8_000,
323
+ ): Promise<AuditRow[]> {
324
+ const start = Date.now();
325
+ let rows = readAuditRows(dbPath);
326
+ while (!predicate(rows)) {
327
+ if (Date.now() - start >= deadlineMs) {
328
+ throw new Error(`audit rows did not satisfy predicate within ${deadlineMs}ms`);
329
+ }
330
+ await Bun.sleep(250);
331
+ rows = readAuditRows(dbPath);
332
+ }
333
+ return rows;
334
+ }
@@ -81,8 +81,8 @@ const LEAD_ONLY_VERBS: PermissionVerb[] = [
81
81
  "script.api.delete",
82
82
  ];
83
83
 
84
- const OPERATOR_ONLY_VERBS: PermissionVerb[] = ["extension.activate"];
85
- const LEAD_OR_OPERATOR_VERBS: PermissionVerb[] = ["extension.write"];
84
+ const OPERATOR_ONLY_VERBS: PermissionVerb[] = [];
85
+ const LEAD_OR_OPERATOR_VERBS: PermissionVerb[] = ["extension.write", "extension.activate"];
86
86
 
87
87
  const LEAD_OR_RESOURCE_OWNER_VERBS: PermissionVerb[] = [
88
88
  "memory.edit.any",
@@ -12,12 +12,11 @@ import {
12
12
  countAuditRows,
13
13
  LEAD,
14
14
  makeScratchDir,
15
- readAuditRows,
16
15
  registerAgent,
17
16
  removeScratchDir,
18
17
  type SwarmServer,
19
18
  spawnSwarmServer,
20
- waitForAuditCount,
19
+ waitForAuditRows,
21
20
  } from "./rbac-e2e-helpers";
22
21
 
23
22
  setDefaultTimeout(120_000);
@@ -312,8 +311,27 @@ describe("RBAC admission over /mcp-user", () => {
312
311
  }),
313
312
  );
314
313
 
315
- expect(await waitForAuditCount(enabledDbPath, 10)).toBeGreaterThanOrEqual(10);
316
- const mcpRows = readAuditRows(enabledDbPath).filter((row) => row.source === "mcp");
314
+ const mcpRows = (
315
+ await waitForAuditRows(enabledDbPath, (rows) => {
316
+ const auditRows = rows.filter((row) => row.source === "mcp");
317
+ return (
318
+ auditRows.some(
319
+ (row) =>
320
+ row.principalId === requester.userId &&
321
+ row.resourceType === "mcp-tool" &&
322
+ row.resourceId === "send-task" &&
323
+ row.verb === "task.create.own" &&
324
+ row.decision === "allow",
325
+ ) &&
326
+ ["task.create.own", "task.cancel.own", "task.action.own"].every((verb) =>
327
+ auditRows.some(
328
+ (row) =>
329
+ row.principalId === empty.userId && row.decision === "deny" && row.verb === verb,
330
+ ),
331
+ )
332
+ );
333
+ })
334
+ ).filter((row) => row.source === "mcp");
317
335
  expect(
318
336
  mcpRows.some(
319
337
  (row) =>
@@ -227,6 +227,23 @@ describe("reload-config", () => {
227
227
  }
228
228
  });
229
229
 
230
+ test("stored lead activation gate cannot override the deployment at boot or reload", async () => {
231
+ const key = "EXTENSION_ALLOW_LEAD_ACTIVATION";
232
+ const original = process.env[key];
233
+ const rowId = await insertLegacyReservedRow(key, "true");
234
+ try {
235
+ process.env[key] = "false";
236
+ for (const override of [false, true]) {
237
+ expect(await loadGlobalConfigsIntoEnv(override)).not.toContain(key);
238
+ expect(process.env[key]).toBe("false");
239
+ }
240
+ } finally {
241
+ await deleteSwarmConfig(rowId);
242
+ if (original === undefined) delete process.env[key];
243
+ else process.env[key] = original;
244
+ }
245
+ });
246
+
230
247
  test("loadGlobalConfigsIntoEnv skips unreadable reserved secret rows before decrypting them", async () => {
231
248
  const id = await insertUnreadableReservedSecretRow("SECRETS_ENCRYPTION_KEY");
232
249
 
@@ -17,6 +17,10 @@ import { listenOnFreePort } from "./test-net";
17
17
 
18
18
  const TEST_DB_PATH = "./test-scripts-only-gating.sqlite";
19
19
  const SCRIPT_TOOL_NAMES = [
20
+ "extension-delete",
21
+ "extension-enable",
22
+ "extension-disable",
23
+ "extension-activate-version",
20
24
  "extension-install",
21
25
  "extension-list",
22
26
  "get-script-run",
@@ -1789,7 +1789,7 @@ describe("Slack renderer v2", () => {
1789
1789
  expect(outcome?.finalizedAt).toBeDefined();
1790
1790
  });
1791
1791
 
1792
- test("keeps a deferral's human-facing line even when the agent already sent a slack-reply", async () => {
1792
+ test("omits the deferral ETA even when the agent already sent a slack-reply", async () => {
1793
1793
  const lead = await createAgent({ name: "Deferring Lead", isLead: true, status: "idle" });
1794
1794
  const { channelId, threadTs } = uniqueSlackAddress("C_RENDER_DEFER_AFTER_REPLY");
1795
1795
  const ask = await createTaskExtended("defer after replying by hand", {
@@ -1814,10 +1814,32 @@ describe("Slack renderer v2", () => {
1814
1814
  await processSlackRenderV2();
1815
1815
 
1816
1816
  const started = calls.find((call) => call.method === "chat.startStream");
1817
- expect(started?.payload.markdown_text).toBe(`✅\n\n${deferralLine}`);
1817
+ expect(started?.payload.markdown_text).toBe("✅\n\nPending: checking the new defer card");
1818
+ expect(JSON.stringify(calls)).not.toContain("17:30:19");
1819
+ expect(JSON.stringify(calls)).not.toContain("Deferred until");
1820
+ expect((await getTaskById(ask.id))?.output).toBe(deferralLine);
1818
1821
  expect(started?.payload.markdown_text).not.toBe(`✅ ${lead.name} completed`);
1819
1822
  });
1820
1823
 
1824
+ test("does not post an ETA-only deferral notice", async () => {
1825
+ const { channelId, threadTs } = uniqueSlackAddress("C_ETA_ONLY");
1826
+ const ask = await createTaskExtended("wait", {
1827
+ source: "slack",
1828
+ slackChannelId: channelId,
1829
+ slackThreadTs: threadTs,
1830
+ });
1831
+ await startTask(ask.id);
1832
+ await completeTask(
1833
+ ask.id,
1834
+ "Deferred until today 17:30 UTC ([12345678](https://example.com/schedules/12345678)) -> ",
1835
+ { addTags: ["deferred"] },
1836
+ );
1837
+ calls.length = 0;
1838
+ await processSlackRenderV2();
1839
+ expect(calls.filter((call) => call.method === "chat.startStream")).toHaveLength(0);
1840
+ expect(JSON.stringify(calls)).not.toContain("17:30");
1841
+ });
1842
+
1821
1843
  test("refreshes a stream started with stale content before finalizing it", async () => {
1822
1844
  const lead = await createAgent({ name: "Refresh Lead", isLead: true, status: "idle" });
1823
1845
  const { channelId, threadTs } = uniqueSlackAddress("C_RENDER_REFRESH_STALE");
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { slackTaskOutput } from "../slack/task-output";
3
+
4
+ const task = {
5
+ status: "completed" as const,
6
+ tags: ["deferred"],
7
+ output:
8
+ "Deferred until tomorrow 17:30 UTC ([12345678](https://example.com/schedules/12345678)) -> check the build",
9
+ };
10
+
11
+ describe("Slack deferral output", () => {
12
+ test("keeps the pending work without the ETA or schedule link", () => {
13
+ expect(slackTaskOutput(task)).toBe("Pending: check the build");
14
+ expect(task.output).toContain("tomorrow 17:30 UTC");
15
+ });
16
+
17
+ test("removes an ETA-only line and preserves punctuation in the pending description", () => {
18
+ expect(slackTaskOutput({ ...task, output: task.output.replace("check the build", "") })).toBe(
19
+ "",
20
+ );
21
+ expect(slackTaskOutput({ ...task, output: `${task.output} (CI) -> deploy` })).toBe(
22
+ "Pending: check the build (CI) -> deploy",
23
+ );
24
+ });
25
+
26
+ test("preserves final continuation results even with inherited deferred tags", () => {
27
+ expect(slackTaskOutput({ ...task, output: "Build passed." })).toBe("Build passed.");
28
+ });
29
+
30
+ test("preserves schema output and non-deferral output verbatim", () => {
31
+ expect(slackTaskOutput({ ...task, outputSchema: { type: "string" } })).toBe(task.output);
32
+ expect(slackTaskOutput({ ...task, tags: [] })).toBe(task.output);
33
+ expect(slackTaskOutput({ ...task, status: "in_progress" })).toBe(task.output);
34
+ expect(slackTaskOutput({ ...task, output: undefined })).toBeUndefined();
35
+ });
36
+ });
@@ -17,7 +17,7 @@
17
17
  * org/drive and credentials
18
18
  * 4. missing files and wrong drives both block attachment + task writes
19
19
  */
20
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
20
+ import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
21
21
  import crypto from "node:crypto";
22
22
  import { unlink } from "node:fs/promises";
23
23
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -34,6 +34,7 @@ import {
34
34
  upsertSwarmConfig,
35
35
  } from "../be/db";
36
36
  import { registerStoreProgressTool } from "../tools/store-progress";
37
+ import malformedOutput from "./fixtures/store-progress-malformed-output.json";
37
38
 
38
39
  const TEST_DB_PATH = "./test-store-progress-attachments-handler.sqlite";
39
40
  const ORIGINAL_FETCH = globalThis.fetch;
@@ -137,6 +138,104 @@ describe("store-progress handler — attachments insert path", () => {
137
138
  };
138
139
  }
139
140
 
141
+ test("salvages the literal 59811b57 output and registers both URL attachments", async () => {
142
+ const task = await createTaskExtended("salvage leaked tool parameters", { agentId });
143
+ await startTask(task.id);
144
+ const result = (await buildServer().handler(
145
+ { taskId: task.id, status: "completed", output: malformedOutput.output },
146
+ buildMeta(),
147
+ )) as StoreProgressResult;
148
+
149
+ expect(result.structuredContent.success).toBe(true);
150
+ const saved = await getTaskById(task.id);
151
+ expect(saved?.output).toBe(malformedOutput.output.split("</output>")[0]);
152
+ expect(saved?.output?.endsWith("reposted clean.")).toBe(true);
153
+ const attachments = await getTaskAttachments(task.id);
154
+ expect(attachments).toHaveLength(2);
155
+ expect(attachments.every((a) => a.kind === "url")).toBe(true);
156
+ expect(attachments.map((a) => a.url).sort()).toEqual([
157
+ "https://api.desplega.agent-swarm.dev/p/556b11781dbb40bf9b1eb1b3315adae6",
158
+ "https://x.com/desplegalabs/status/2100595056939827590",
159
+ ]);
160
+ expect(attachments.find((a) => a.isPrimary)?.name).toBe("X reply in the Jev thread");
161
+ });
162
+
163
+ test("preserves clean output byte-for-byte", async () => {
164
+ const output = "The output is ready: 2 < 3.\n Keep this whitespace. ";
165
+ const task = await createTaskExtended("clean output control", { agentId });
166
+ await startTask(task.id);
167
+ const result = (await buildServer().handler(
168
+ { taskId: task.id, status: "completed", output },
169
+ buildMeta(),
170
+ )) as StoreProgressResult;
171
+ expect(result.structuredContent.success).toBe(true);
172
+ expect((await getTaskById(task.id))?.output).toBe(output);
173
+ expect(await getTaskAttachments(task.id)).toHaveLength(0);
174
+ });
175
+
176
+ test.each([
177
+ ["invalid JSON", '</output>\n<parameter name="attachments">[broken'],
178
+ ["invalid attachment schema", '</output><parameter name="attachments">[{"kind":"url"}]'],
179
+ ["unknown parameter", '</output><parameter name="status">"failed"'],
180
+ ["alternate closing tag", '</parameter>\n<parameter name="progress">Not JSON'],
181
+ ["bare opener", '<parameter name="progress">Not JSON'],
182
+ ["non-array JSON", '</output><parameter name="attachments">null'],
183
+ ])("strips %s without losing the clean result", async (_name, tail) => {
184
+ const task = await createTaskExtended("unrecoverable tail", { agentId });
185
+ await startTask(task.id);
186
+ const warning = spyOn(console, "warn").mockImplementation(() => {});
187
+ try {
188
+ const result = (await buildServer().handler(
189
+ { taskId: task.id, status: "completed", output: `Clean result.${tail}` },
190
+ buildMeta(),
191
+ )) as StoreProgressResult;
192
+ expect(result.structuredContent.success).toBe(true);
193
+ expect((await getTaskById(task.id))?.output).toBe("Clean result.");
194
+ expect((await getTaskById(task.id))?.status).toBe("completed");
195
+ expect(await getTaskAttachments(task.id)).toHaveLength(0);
196
+ expect(warning).toHaveBeenCalledTimes(1);
197
+ expect(warning.mock.calls[0]?.[0]).not.toContain(tail);
198
+ } finally {
199
+ warning.mockRestore();
200
+ }
201
+ });
202
+
203
+ test("appends recovered pointers to explicit attachments through the normal dedupe path", async () => {
204
+ const task = await createTaskExtended("mixed explicit and leaked attachments", { agentId });
205
+ await startTask(task.id);
206
+ const attachment = { kind: "url", name: "link", url: "https://example.com/link" };
207
+ const result = (await buildServer().handler(
208
+ {
209
+ taskId: task.id,
210
+ status: "completed",
211
+ output: `Done.<parameter name="attachments">${JSON.stringify([attachment])}</parameter>`,
212
+ attachments: [attachment],
213
+ },
214
+ buildMeta(),
215
+ )) as StoreProgressResult;
216
+ expect(result.structuredContent.success).toBe(true);
217
+ expect((await getTaskById(task.id))?.output).toBe("Done.");
218
+ expect(await getTaskAttachments(task.id)).toHaveLength(1);
219
+ });
220
+
221
+ test("recovered agent-fs pointers still require ordinary scope verification", async () => {
222
+ const task = await createTaskExtended("verify recovered pointer", { agentId });
223
+ await startTask(task.id);
224
+ const result = (await buildServer().handler(
225
+ {
226
+ taskId: task.id,
227
+ status: "completed",
228
+ output:
229
+ 'Done.</output><parameter name="attachments">[{"kind":"agent-fs","name":"missing.md","path":"/missing.md"}]',
230
+ },
231
+ buildMeta(),
232
+ )) as StoreProgressResult;
233
+ expect(result.structuredContent.success).toBe(false);
234
+ expect(result.structuredContent.message).toContain("both orgId and driveId must resolve");
235
+ expect((await getTaskById(task.id))?.status).toBe("in_progress");
236
+ expect(await getTaskAttachments(task.id)).toHaveLength(0);
237
+ });
238
+
140
239
  test("inserts attachment row on an in-progress task (baseline)", async () => {
141
240
  const task = await createTaskExtended("handler in-progress baseline", {
142
241
  agentId,
@@ -134,7 +134,12 @@ describe("swarm-config reserved keys guard", () => {
134
134
  await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
135
135
  });
136
136
 
137
- for (const key of ["CORS_ALLOW_ANY_ORIGIN", "cors_allow_any_origin"]) {
137
+ for (const key of [
138
+ "CORS_ALLOW_ANY_ORIGIN",
139
+ "cors_allow_any_origin",
140
+ "EXTENSION_ALLOW_LEAD_ACTIVATION",
141
+ "extension_allow_lead_activation",
142
+ ]) {
138
143
  test(`rejects deployment-only ${key} through DB, HTTP, and MCP`, async () => {
139
144
  const config = { scope: "global" as const, key, value: "true" };
140
145
  await expect(upsertSwarmConfig(config)).rejects.toThrow(EXPECTED_MESSAGE(key));
@@ -378,7 +378,7 @@ describe("Tool Annotations & Classification", () => {
378
378
  // Includes 11 skill tools, 7 MCP server tools, reusable script tools, and the
379
379
  // native Kapso/WhatsApp tools (register/unregister number + send/reply message).
380
380
  expect(count).toBeGreaterThanOrEqual(45);
381
- expect(count).toBeLessThanOrEqual(143);
381
+ expect(count).toBeLessThanOrEqual(147);
382
382
  });
383
383
 
384
384
  test("core tools are fewer than deferred tools", () => {
@@ -0,0 +1,38 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { createToolRegistrar, toolOk } from "@/tools/utils";
4
+ import {
5
+ coerceExtensionSummary,
6
+ extensionToolOutputSchema,
7
+ proxyExtensionsApi,
8
+ } from "./extension-common";
9
+
10
+ export const registerExtensionActivateVersionTool = (server: McpServer) => {
11
+ createToolRegistrar(server)(
12
+ "extension-activate-version",
13
+ {
14
+ title: "Extension Activate Version",
15
+ description:
16
+ "Activate a stored extension version, reloading it if enabled. Requires a lead, operator, or dashboard user.",
17
+ annotations: { openWorldHint: false },
18
+ inputSchema: z
19
+ .object({
20
+ id: z.string().uuid().describe("Installed extension ID from extension-list."),
21
+ version: z.number().int().min(1).describe("Stored version number to activate."),
22
+ })
23
+ .strict(),
24
+ outputSchema: extensionToolOutputSchema,
25
+ },
26
+ async (args, requestInfo) =>
27
+ proxyExtensionsApi({
28
+ method: "POST",
29
+ path: `/api/extensions/${args.id}/activate-version`,
30
+ body: { version: args.version },
31
+ requestInfo,
32
+ success: (data) =>
33
+ toolOk("Extension activated.", {
34
+ data: coerceExtensionSummary((data as { extension?: unknown }).extension),
35
+ }),
36
+ }),
37
+ );
38
+ };
@@ -26,6 +26,7 @@ export const extensionToolOutputSchema = swarmToolOutputSchema({
26
26
  priority: z.number().optional(),
27
27
  consecutiveFailures: z.number().optional(),
28
28
  contentDeduped: z.boolean().optional(),
29
+ deleted: z.boolean().optional(),
29
30
  extensions: z.array(extensionListItemSchema).optional(),
30
31
  });
31
32
 
@@ -61,7 +62,7 @@ function renderDiagnostics(data: unknown): string | undefined {
61
62
  }
62
63
 
63
64
  export async function proxyExtensionsApi<T>(args: {
64
- method: "GET" | "POST";
65
+ method: "GET" | "POST" | "DELETE";
65
66
  path: string;
66
67
  body?: unknown;
67
68
  requestInfo: RequestInfo;
@@ -0,0 +1,29 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { createToolRegistrar, toolOk } from "@/tools/utils";
4
+ import { extensionToolOutputSchema, proxyExtensionsApi } from "./extension-common";
5
+
6
+ export const registerExtensionDeleteTool = (server: McpServer) => {
7
+ createToolRegistrar(server)(
8
+ "extension-delete",
9
+ {
10
+ title: "Extension Delete",
11
+ description:
12
+ "Uninstall a disabled extension and delete its stored history. Disable it first with extension-disable. Requires a lead, operator, or dashboard user.",
13
+ annotations: { openWorldHint: false, destructiveHint: true },
14
+ inputSchema: z
15
+ .object({
16
+ id: z.string().uuid().describe("Installed extension ID from extension-list."),
17
+ })
18
+ .strict(),
19
+ outputSchema: extensionToolOutputSchema,
20
+ },
21
+ async (args, requestInfo) =>
22
+ proxyExtensionsApi({
23
+ method: "DELETE",
24
+ path: `/api/extensions/${args.id}`,
25
+ requestInfo,
26
+ success: () => toolOk("Extension deleted.", { data: { id: args.id, deleted: true } }),
27
+ }),
28
+ );
29
+ };