@desplega.ai/agent-swarm 1.151.0 → 1.152.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 (104) hide show
  1. package/README.md +1 -1
  2. package/dist/{acp-adapter-ex8hzjfc.js → acp-adapter-d05sg0q4.js} +1 -1
  3. package/dist/{actions-2bgpyhww.js → actions-4sc0v342.js} +13 -13
  4. package/dist/{app-1706zhr1.js → app-2d0v6kt6.js} +3 -3
  5. package/dist/{assistant-bavke5p7.js → assistant-zs6wdnrf.js} +14 -14
  6. package/dist/{boot-reembed-nafcmmmc.js → boot-reembed-3dwdnnsr.js} +3 -3
  7. package/dist/{boot-reembed-3cmrrmpa.js → boot-reembed-vhh33520.js} +4 -4
  8. package/dist/{boot-scrub-logs-cd6a2sq0.js → boot-scrub-logs-e1fnabcg.js} +2 -2
  9. package/dist/{claude-adapter-wfp2gzvz.js → claude-adapter-h58xqzzd.js} +3 -3
  10. package/dist/{claude-sdk-session-bht9jjak.js → claude-sdk-session-pbaczbgt.js} +3 -3
  11. package/dist/{cli-a1z3h107.js → cli-4fqn2vpg.js} +4 -4
  12. package/dist/{cli-g69fdc9g.js → cli-5bqt4gwa.js} +2 -2
  13. package/dist/{cli-gjyvhmmh.js → cli-5p007m2q.js} +5 -5
  14. package/dist/{cli-5jd2z7av.js → cli-6k2athpf.js} +6 -2
  15. package/dist/{cli-5tpgq60f.js → cli-72vksk1n.js} +152 -23
  16. package/dist/{cli-vwm416m6.js → cli-87hdfyde.js} +1 -1
  17. package/dist/{cli-3v9xhjcn.js → cli-8fy9dmpv.js} +2 -2
  18. package/dist/{cli-czjtgfys.js → cli-awb59f08.js} +2 -2
  19. package/dist/{cli-j9xczhaq.js → cli-cs9z1hw3.js} +1 -1
  20. package/dist/{cli-r2q6pevn.js → cli-cwvey7n1.js} +43 -21
  21. package/dist/{cli-ppm4975n.js → cli-evgy5w3y.js} +1 -1
  22. package/dist/{cli-frcta2w4.js → cli-f1f9n77e.js} +1 -1
  23. package/dist/{cli-9004vfdf.js → cli-fhdssm2v.js} +2 -2
  24. package/dist/{cli-s14agm5s.js → cli-gacq5hg8.js} +12 -3
  25. package/dist/{cli-zkxymas5.js → cli-jh9txj4n.js} +1 -1
  26. package/dist/{cli-q88st9aj.js → cli-nd5s3m4y.js} +2 -2
  27. package/dist/{cli-sjaae6ff.js → cli-p2hx1pxm.js} +3 -3
  28. package/dist/{cli-2tbvf1z1.js → cli-t1mykkng.js} +8 -8
  29. package/dist/{cli-7tgcstn2.js → cli-t6axtdcg.js} +2 -2
  30. package/dist/{cli-f1wky6zp.js → cli-wp7cejrg.js} +1 -1
  31. package/dist/{cli-0qb1z6s2.js → cli-zdcdh02m.js} +1 -1
  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-v3cz462q.js} +2 -2
  35. package/dist/{codex-session-runner-nd6ayxkx.js → codex-session-runner-de1a5g5z.js} +2 -2
  36. package/dist/{commands-9v4psyq1.js → commands-wfqqpn95.js} +2 -2
  37. package/dist/{db-wnt58tpe.js → db-9zzv5y05.js} +2 -2
  38. package/dist/{dispatcher-05jh1aqk.js → dispatcher-hfm1jbgx.js} +12 -12
  39. package/dist/{handlers-tw90pkh5.js → handlers-wrrhsack.js} +15 -15
  40. package/dist/{hook-sxe3r349.js → hook-9skye8zd.js} +1 -1
  41. package/dist/{hook-123e2scb.js → hook-fgjv62k5.js} +2 -2
  42. package/dist/{http-ecsar8ez.js → http-7grttjfq.js} +37 -34
  43. package/dist/{index-nnrbpcjp.js → index-29gxx9ar.js} +12 -12
  44. package/dist/{index-hw26vj40.js → index-30pz2h9w.js} +12 -12
  45. package/dist/{index-zj518gf9.js → index-4ek1df87.js} +13 -13
  46. package/dist/{index-16js521z.js → index-myfwqqyq.js} +8 -8
  47. package/dist/{index-ymn7em0p.js → index-pfhzrh1d.js} +3 -3
  48. package/dist/{keepalive-asqtcm4y.js → keepalive-j75m2m7k.js} +4 -4
  49. package/dist/{lead-xbfr7fvt.js → lead-a1hpjt0a.js} +16 -16
  50. package/dist/{maintenance-qma55qvw.js → maintenance-tngjzqdq.js} +4 -4
  51. package/dist/{oauth-refresh-sweep-3s04c6qw.js → oauth-refresh-sweep-em1fn044.js} +3 -3
  52. package/dist/{onboard-drskd386.js → onboard-fv7ezvwx.js} +2 -2
  53. package/dist/{otel-impl-943c96er.js → otel-impl-6vbb568y.js} +1 -1
  54. package/dist/{pricing-refresh-mx51az05.js → pricing-refresh-nmm949an.js} +4 -4
  55. package/dist/{rbac-roles-wcmrg6sv.js → rbac-roles-0fz5qryx.js} +4 -4
  56. package/dist/{rbac-roles-78baecqd.js → rbac-roles-xtp05syf.js} +3 -3
  57. package/dist/{render-v2-8zjntgf5.js → render-v2-fpb8whme.js} +3 -3
  58. package/dist/{seed-pricing-jqjs6cws.js → seed-pricing-zwa8bs3t.js} +3 -3
  59. package/dist/{setup-4pks0vfk.js → setup-3ybwbdpd.js} +2 -2
  60. package/dist/{work-objects-wangt792.js → work-objects-rmayxcx7.js} +18 -17
  61. package/dist/{worker-p2mr422x.js → worker-rng4jhnh.js} +16 -16
  62. package/openapi.json +4 -4
  63. package/package.json +1 -1
  64. package/src/be/db.ts +1 -1
  65. package/src/be/scripts/typecheck.ts +4 -0
  66. package/src/be/swarm-config-guard.ts +7 -1
  67. package/src/extensions/lifecycle.ts +26 -4
  68. package/src/http/extensions.ts +8 -3
  69. package/src/rbac/legacy-policy.ts +13 -1
  70. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  71. package/src/scripts-runtime/types/stdlib.d.ts +4 -0
  72. package/src/scripts-runtime/types/swarm-sdk.d.ts +4 -0
  73. package/src/server.ts +12 -0
  74. package/src/slack/render-v2.ts +10 -4
  75. package/src/slack/responses.ts +7 -4
  76. package/src/slack/task-output.ts +21 -0
  77. package/src/slack/watcher.ts +3 -2
  78. package/src/slack/work-objects.ts +2 -1
  79. package/src/tests/extensions-example-mcp-install.test.ts +2 -2
  80. package/src/tests/extensions-http.test.ts +84 -3
  81. package/src/tests/extensions-lead-equivalence.test.ts +21 -0
  82. package/src/tests/extensions-mcp-tools.test.ts +111 -3
  83. package/src/tests/fixtures/store-progress-malformed-output.json +4 -0
  84. package/src/tests/rbac-admission-e2e.test.ts +35 -4
  85. package/src/tests/rbac-e2e-helpers.ts +18 -0
  86. package/src/tests/rbac-engine.test.ts +2 -2
  87. package/src/tests/rbac-mcp-admission-e2e.test.ts +22 -4
  88. package/src/tests/reload-config.test.ts +17 -0
  89. package/src/tests/scripts-only-gating.test.ts +4 -0
  90. package/src/tests/slack-render-v2.test.ts +24 -2
  91. package/src/tests/slack-task-output.test.ts +36 -0
  92. package/src/tests/store-progress-attachments-handler.test.ts +100 -1
  93. package/src/tests/swarm-config-reserved-keys.test.ts +6 -1
  94. package/src/tests/tool-annotations.test.ts +1 -1
  95. package/src/tools/extension-activate-version.ts +38 -0
  96. package/src/tools/extension-common.ts +2 -1
  97. package/src/tools/extension-delete.ts +29 -0
  98. package/src/tools/extension-disable.ts +36 -0
  99. package/src/tools/extension-enable.ts +36 -0
  100. package/src/tools/extension-install.ts +2 -2
  101. package/src/tools/store-progress.ts +30 -0
  102. package/src/tools/tool-config.ts +5 -1
  103. package/templates/skills/agent-browser/SKILL.md +84 -0
  104. package/templates/skills/agent-browser/content.md +21 -0
@@ -44,6 +44,7 @@ import {
44
44
  import { buildAskClosure, type ClosureState, closureState } from "./closure";
45
45
  import { reactionName, type SlackReactionEvent } from "./reaction-shortcode";
46
46
  import { getAgentDisplayName, getAgentEmoji } from "./responses";
47
+ import { isSlackEtaOnlyNotice, slackTaskOutput } from "./task-output";
47
48
 
48
49
  const TREE_UPDATE_DEBOUNCE_MS = 500;
49
50
  const TREE_UPDATE_MIN_INTERVAL_MS = 3_000;
@@ -757,7 +758,7 @@ async function outcomeContent(task: AgentTask, slackReplySent: boolean): Promise
757
758
  : "Agent";
758
759
  return `✅ ${agentName} completed`;
759
760
  }
760
- return `✅\n\n${outcomeText(task.output, "Task completed.")}`;
761
+ return `✅\n\n${outcomeText(slackTaskOutput(task), "Task completed.")}`;
761
762
  }
762
763
 
763
764
  async function agentDisplayNameFor(task: AgentTask): Promise<string> {
@@ -786,7 +787,7 @@ export async function childOutcomeContent(
786
787
  if (slackReplySent && !task.tags?.includes("deferred")) {
787
788
  return `↳ ✅ ${agentName} completed`;
788
789
  }
789
- return `↳ ✅ ${agentName} — result\n\n${outcomeText(task.output, "Task completed.")}`;
790
+ return `↳ ✅ ${agentName} — result\n\n${outcomeText(slackTaskOutput(task), "Task completed.")}`;
790
791
  }
791
792
 
792
793
  // A deferred wake continues a human conversation even though the scheduler
@@ -808,6 +809,7 @@ function isSlackContinuation(task: AgentTask): boolean {
808
809
  */
809
810
  function isChildCardCandidate(task: AgentTask, delegationActivatedAt: string): boolean {
810
811
  return (
812
+ !isSlackEtaOnlyNotice(task) &&
811
813
  task.source !== "slack" &&
812
814
  !isSlackContinuation(task) &&
813
815
  !!task.slackChannelId &&
@@ -821,6 +823,7 @@ function isChildCardCandidate(task: AgentTask, delegationActivatedAt: string): b
821
823
 
822
824
  function taskNeedsDirectOutcome(task: AgentTask, activatedAt: string): boolean {
823
825
  return (
826
+ !isSlackEtaOnlyNotice(task) &&
824
827
  ((task.source === "slack" && isAskOutcomeStatus(task.status)) ||
825
828
  (isSlackContinuation(task) && isOutcomeStatus(task.status))) &&
826
829
  task.createdAt >= activatedAt
@@ -839,7 +842,7 @@ async function conclusionResultsLines(closure: AgentTask[]): Promise<string[]> {
839
842
  const lines: string[] = [];
840
843
  for (const member of closure) {
841
844
  if (member.taskType === "follow-up" || member.taskType === "reroute-decision") continue;
842
- if (!isOutcomeStatus(member.status)) continue;
845
+ if (!isOutcomeStatus(member.status) || isSlackEtaOnlyNotice(member)) continue;
843
846
  const glyph = await taskStateGlyph(member, new Date());
844
847
  const agentName = await agentDisplayNameFor(member);
845
848
  const card = await getSlackOutcomeMessage(member.id);
@@ -847,7 +850,10 @@ async function conclusionResultsLines(closure: AgentTask[]): Promise<string[]> {
847
850
  lines.push(`↳ ${glyph} ${agentName} — ${card.permalink}`);
848
851
  continue;
849
852
  }
850
- const raw = outcomeText(member.status === "failed" ? member.failureReason : member.output, "");
853
+ const raw = outcomeText(
854
+ member.status === "failed" ? member.failureReason : slackTaskOutput(member),
855
+ "",
856
+ );
851
857
  const digest =
852
858
  raw.length > CONCLUSION_DIGEST_LENGTH
853
859
  ? `${raw.slice(0, CONCLUSION_DIGEST_LENGTH).trimEnd()}…`
@@ -15,6 +15,7 @@ import {
15
15
  markdownToSlack,
16
16
  splitSlackSectionText,
17
17
  } from "./blocks";
18
+ import { isSlackEtaOnlyNotice, slackTaskOutput } from "./task-output";
18
19
 
19
20
  // Re-export for backward compatibility
20
21
  export { markdownToSlack } from "./blocks";
@@ -56,7 +57,7 @@ export function shouldPostInlineCompletionOutput(task: AgentTask): boolean {
56
57
  if (!task.slackChannelId || !task.slackThreadTs) return false;
57
58
  if (task.slackReplySent) return false;
58
59
 
59
- const output = task.output?.trim();
60
+ const output = slackTaskOutput(task)?.trim();
60
61
  return !!output && isTreeOutputTruncated(markdownToSlack(output));
61
62
  }
62
63
 
@@ -85,7 +86,7 @@ export async function sendInlineTaskOutput(task: AgentTask): Promise<boolean> {
85
86
  const chunks = formatInlineCompletionOutputChunks({
86
87
  agentName: agent.name,
87
88
  taskId: task.id,
88
- output: task.output,
89
+ output: slackTaskOutput(task) ?? "",
89
90
  });
90
91
 
91
92
  try {
@@ -115,6 +116,7 @@ export async function sendInlineTaskOutput(task: AgentTask): Promise<boolean> {
115
116
  * Send a task completion message to Slack with the agent's persona.
116
117
  */
117
118
  export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
119
+ if (isSlackEtaOnlyNotice(task)) return true;
118
120
  const app = getSlackApp();
119
121
  if (!app || !task.slackChannelId || !task.slackThreadTs) {
120
122
  return false;
@@ -136,7 +138,7 @@ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
136
138
 
137
139
  try {
138
140
  if (task.status === "completed") {
139
- const output = task.output || "Task completed.";
141
+ const output = slackTaskOutput(task) || "Task completed.";
140
142
  const slackOutput = markdownToSlack(output);
141
143
  const attachmentsBlock = formatAttachmentsBlockForSlack(await getTaskAttachments(task.id));
142
144
  const body = slackOutput + attachmentsBlock;
@@ -285,6 +287,7 @@ export async function updateToFinal(
285
287
  task: AgentTask,
286
288
  messageTs: string,
287
289
  ): Promise<SlackUpdateResult> {
290
+ if (isSlackEtaOnlyNotice(task)) return "ok";
288
291
  const app = getSlackApp();
289
292
  if (!app || !task.slackChannelId || !task.agentId) return "failed";
290
293
 
@@ -297,7 +300,7 @@ export async function updateToFinal(
297
300
  let completionBlockBatches: unknown[][] | undefined;
298
301
 
299
302
  if (task.status === "completed") {
300
- const output = task.output || "Task completed.";
303
+ const output = slackTaskOutput(task) || "Task completed.";
301
304
  const slackOutput = markdownToSlack(output);
302
305
  const attachmentsBlock = formatAttachmentsBlockForSlack(await getTaskAttachments(task.id));
303
306
  const body = slackOutput + attachmentsBlock;
@@ -0,0 +1,21 @@
1
+ import type { AgentTask } from "../types";
2
+
3
+ /** Keep the stored deferral ETA for task details, but omit it from Slack. */
4
+ export function slackTaskOutput(
5
+ task: Pick<AgentTask, "output" | "tags" | "outputSchema" | "status">,
6
+ ): string | undefined {
7
+ const output = task.output ?? undefined;
8
+ if (task.status !== "completed" || task.outputSchema || !task.tags?.includes("deferred")) {
9
+ return output;
10
+ }
11
+ // Only match the engine-authored line from defer-task, not continuation results.
12
+ const notice = output?.match(/^Deferred until [^\n]+? \([^\n]+?\) -> (.*)$/);
13
+ if (!notice) return output;
14
+ const pending = notice[1]?.trim();
15
+ return pending ? `Pending: ${pending}` : "";
16
+ }
17
+
18
+ /** An ETA-only notice has no useful content left to post. */
19
+ export function isSlackEtaOnlyNotice(task: AgentTask): boolean {
20
+ return !!task.output && slackTaskOutput(task) === "";
21
+ }
@@ -28,6 +28,7 @@ import {
28
28
  updateToFinal,
29
29
  updateTreeMessage,
30
30
  } from "./responses";
31
+ import { slackTaskOutput } from "./task-output";
31
32
 
32
33
  let watcherInterval: ReturnType<typeof setInterval> | null = null;
33
34
  let isProcessing = false;
@@ -169,7 +170,7 @@ export async function buildTreeNodes(tree: TreeMessageState): Promise<TreeNode[]
169
170
  progress: child.progress ?? undefined,
170
171
  duration: childDuration,
171
172
  slackReplySent: child.slackReplySent,
172
- output: child.output ?? undefined,
173
+ output: slackTaskOutput(child),
173
174
  failureReason: child.failureReason ?? undefined,
174
175
  attachments: childAttachments,
175
176
  steered: childSteered,
@@ -192,7 +193,7 @@ export async function buildTreeNodes(tree: TreeMessageState): Promise<TreeNode[]
192
193
  progress: task.progress ?? undefined,
193
194
  duration,
194
195
  slackReplySent: task.slackReplySent,
195
- output: task.output ?? undefined,
196
+ output: slackTaskOutput(task),
196
197
  failureReason: task.failureReason ?? undefined,
197
198
  attachments: rootAttachments,
198
199
  steered: rootSteered,
@@ -4,6 +4,7 @@ import { getAgentById, getTaskById } from "../be/db";
4
4
  import { isEnvFlagEnabled } from "../utils/env-flag";
5
5
  import { getTaskUrl } from "./blocks";
6
6
  import { isUserAllowed } from "./handlers";
7
+ import { slackTaskOutput } from "./task-output";
7
8
 
8
9
  type DetailsEvent = SlackEventMiddlewareArgs<"entity_details_requested">["event"];
9
10
  type Details = Omit<EntityPresentDetailsArguments, "trigger_id" | "token">;
@@ -36,7 +37,7 @@ async function getDetails(event: DetailsEvent, client: WebClient): Promise<Detai
36
37
  if (task.slackChannelId !== event.channel) return { error: { status: "restricted" } };
37
38
 
38
39
  const agent = task.agentId ? await getAgentById(task.agentId) : null;
39
- const result = task.output || task.failureReason || task.progress;
40
+ const result = slackTaskOutput(task) || task.failureReason || task.progress;
40
41
  return {
41
42
  metadata: {
42
43
  entity_type: "slack#/entities/task",
@@ -7,7 +7,7 @@ type RegisteredTool = {
7
7
  handler: (args: unknown, extra: unknown) => Promise<unknown>;
8
8
  };
9
9
 
10
- test("a lead can draft the minimal bundle while an operator retains activation", async () => {
10
+ test("a lead installs a disabled draft with explicit activation guidance", async () => {
11
11
  const previousBaseUrl = process.env.MCP_BASE_URL;
12
12
  const previousApiKey = process.env.AGENT_SWARM_API_KEY;
13
13
  const savedFetch = globalThis.fetch;
@@ -52,7 +52,7 @@ test("a lead can draft the minimal bundle while an operator retains activation",
52
52
 
53
53
  expect(result.isError).toBe(false);
54
54
  expect(result.structuredContent).toMatchObject({ enabled: false, status: "disabled" });
55
- expect(String(result.structuredContent.message)).toContain("operator must enable it");
55
+ expect(String(result.structuredContent.message)).toContain("extension-enable");
56
56
  } finally {
57
57
  globalThis.fetch = savedFetch;
58
58
  if (previousBaseUrl === undefined) delete process.env.MCP_BASE_URL;
@@ -2,11 +2,18 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
3
  import { Readable } from "node:stream";
4
4
  import { closeDb, createAgent, getDbClient, initDb } from "../be/db";
5
- import { getExtensionByName, insertExtensionRun, setExtensionState } from "../be/extensions/db";
5
+ import {
6
+ getExtensionByName,
7
+ insertExtensionRun,
8
+ listExtensionRuns,
9
+ setExtensionState,
10
+ } from "../be/extensions/db";
11
+ import { enqueueAuditRow, flushAuditBuffer } from "../be/rbac-audit";
6
12
  import { stopExtensionRuntime } from "../extensions/lifecycle";
7
13
  import { handleCore } from "../http/core";
8
14
  import { handleExtensions } from "../http/extensions";
9
15
  import { getPathSegments, parseQueryParams } from "../http/utils";
16
+ import { clearAuditSink, setAuditSink } from "../rbac";
10
17
  import type { User } from "../types";
11
18
  import { setRequestAuth } from "../utils/request-auth-context";
12
19
  import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
@@ -124,6 +131,80 @@ beforeEach(async () => {
124
131
  });
125
132
 
126
133
  describe("/api/extensions HTTP", () => {
134
+ test("lead activation defaults on, is attributable, and the deployment gate denies lifecycle calls", async () => {
135
+ const original = process.env.EXTENSION_ALLOW_LEAD_ACTIVATION;
136
+ const auditDisabled = process.env.RBAC_AUDIT_DISABLED;
137
+ delete process.env.EXTENSION_ALLOW_LEAD_ACTIVATION;
138
+ delete process.env.RBAC_AUDIT_DISABLED;
139
+ setAuditSink(enqueueAuditRow);
140
+ try {
141
+ expect((await install("minimal", leadId)).status).toBe(200);
142
+ const extension = (await getExtensionByName("minimal"))!;
143
+ const path = `/api/extensions/${extension.id}`;
144
+ expect((await dispatch(`${path}/enable`, { method: "POST", agentId: leadId })).status).toBe(
145
+ 200,
146
+ );
147
+ expect(await listExtensionRuns(extension.id)).toContainEqual(
148
+ expect.objectContaining({
149
+ event: "lifecycle.enable.requested",
150
+ agentId: leadId,
151
+ version: 1,
152
+ }),
153
+ );
154
+ for (const operation of ["activate-version", "disable"]) {
155
+ expect(
156
+ (
157
+ await dispatch(`${path}/${operation}`, {
158
+ method: "POST",
159
+ agentId: leadId,
160
+ body: JSON.stringify({ version: 1 }),
161
+ })
162
+ ).status,
163
+ ).toBe(200);
164
+ expect(await listExtensionRuns(extension.id)).toContainEqual(
165
+ expect.objectContaining({
166
+ event: `lifecycle.${operation}.requested`,
167
+ agentId: leadId,
168
+ version: 1,
169
+ }),
170
+ );
171
+ }
172
+ for (const value of ["false", "0"]) {
173
+ process.env.EXTENSION_ALLOW_LEAD_ACTIVATION = value;
174
+ for (const operation of ["enable", "disable", "activate-version"]) {
175
+ expect(
176
+ (
177
+ await dispatch(`${path}/${operation}`, {
178
+ method: "POST",
179
+ agentId: leadId,
180
+ body: JSON.stringify({ version: 1 }),
181
+ })
182
+ ).status,
183
+ ).toBe(403);
184
+ }
185
+ }
186
+ expect((await getExtensionByName("minimal"))!.enabled).toBe(false);
187
+ expect((await dispatch(`${path}/enable`, { method: "POST" })).status).toBe(200);
188
+ expect(
189
+ (await dispatch(`${path}/disable`, { method: "POST", asUser: crypto.randomUUID() })).status,
190
+ ).toBe(200);
191
+ await flushAuditBuffer();
192
+ const decisions = await getDbClient().query<{ decision: string }>(
193
+ "SELECT decision FROM permission_audit WHERE verb = ? AND principalId = ?",
194
+ ["extension.activate", leadId],
195
+ );
196
+ expect(decisions).toContainEqual({ decision: "allow" });
197
+ expect(decisions).toContainEqual({ decision: "deny" });
198
+ } finally {
199
+ await flushAuditBuffer();
200
+ clearAuditSink();
201
+ if (original === undefined) delete process.env.EXTENSION_ALLOW_LEAD_ACTIVATION;
202
+ else process.env.EXTENSION_ALLOW_LEAD_ACTIVATION = original;
203
+ if (auditDisabled === undefined) delete process.env.RBAC_AUDIT_DISABLED;
204
+ else process.env.RBAC_AUDIT_DISABLED = auditDisabled;
205
+ }
206
+ });
207
+
127
208
  test("operator install, list, get, versions, and changed hooks round-trip", async () => {
128
209
  const first = await install();
129
210
  expect(first.status).toBe(200);
@@ -251,7 +332,7 @@ describe("/api/extensions HTTP", () => {
251
332
  expect((await rejected.json()).error).toContain("Disable");
252
333
  });
253
334
 
254
- test("type definitions are plain text and activation routes are operator-only", async () => {
335
+ test("type definitions are plain text and workers cannot activate extensions", async () => {
255
336
  await install();
256
337
  const extension = await getExtensionByName("minimal");
257
338
  const types = await dispatch("/api/extensions/type-defs");
@@ -262,7 +343,7 @@ describe("/api/extensions HTTP", () => {
262
343
  (
263
344
  await dispatch(`/api/extensions/${extension!.id}/enable`, {
264
345
  method: "POST",
265
- agentId: leadId,
346
+ agentId: workerId,
266
347
  })
267
348
  ).status,
268
349
  ).toBe(403);
@@ -28,6 +28,27 @@ describe("extension agents act with lead privileges while registered", () => {
28
28
  expect(can(check).allow).toBe(false);
29
29
  });
30
30
 
31
+ test("the deployment activation gate also applies to lead-equivalent identities", () => {
32
+ const original = process.env.EXTENSION_ALLOW_LEAD_ACTIVATION;
33
+ grantLeadEquivalence(extensionAgentId);
34
+ try {
35
+ const check = {
36
+ principal: { kind: "agent" as const, agentId: extensionAgentId, isLead: false },
37
+ verb: "extension.activate" as const,
38
+ source: "mcp" as const,
39
+ };
40
+ delete process.env.EXTENSION_ALLOW_LEAD_ACTIVATION;
41
+ expect(can(check).allow).toBe(true);
42
+ process.env.EXTENSION_ALLOW_LEAD_ACTIVATION = "false";
43
+ expect(can(check).allow).toBe(false);
44
+ expect(can({ ...check, verb: "extension.write" }).allow).toBe(true);
45
+ } finally {
46
+ revokeLeadEquivalence(extensionAgentId);
47
+ if (original === undefined) delete process.env.EXTENSION_ALLOW_LEAD_ACTIVATION;
48
+ else process.env.EXTENSION_ALLOW_LEAD_ACTIVATION = original;
49
+ }
50
+ });
51
+
31
52
  test("operators and plain workers are unaffected", () => {
32
53
  expect(
33
54
  can({
@@ -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) =>