@desplega.ai/agent-swarm 1.100.4 → 1.101.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.
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.100.4",
5
+ "version": "1.101.0",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.100.4",
3
+ "version": "1.101.0",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -149,7 +149,7 @@ export interface SwarmSdk {
149
149
  workflow_patch(args: Record<string, unknown>): Promise<unknown>;
150
150
  workflow_patchNode(args: Record<string, unknown>): Promise<unknown>;
151
151
  workflow_delete(args: { id: string }): Promise<unknown>;
152
- workflow_trigger(args: { id: string; input?: Record<string, unknown> }): Promise<unknown>;
152
+ workflow_trigger(args: { id: string; triggerData?: Record<string, unknown> }): Promise<unknown>;
153
153
  workflow_retryRun(args: { id: string }): Promise<unknown>;
154
154
  workflow_cancelRun(args: { id: string }): Promise<unknown>;
155
155
 
@@ -182,7 +182,7 @@ Usage:
182
182
  Options:
183
183
  --api-url <url> Swarm API URL (default: MCP_BASE_URL or http://localhost:3013)
184
184
  --api-key <key> Swarm API key (default: API_KEY or 123123)
185
- --slot <n> Credential pool slot (0-9, default: next free slot)
185
+ --slot <n> Credential pool slot (0-100, default: next free slot)
186
186
  -h, --help Show this help
187
187
 
188
188
  Without flags, the command prompts interactively for the target API URL and
@@ -198,7 +198,7 @@ Deployed Codex workers automatically restore these credentials at boot.
198
198
  `);
199
199
  }
200
200
 
201
- const MAX_SLOT = 9;
201
+ const MAX_SLOT = 100;
202
202
 
203
203
  export async function runCodexLogin(args: string[], deps: RunCodexLoginDeps = {}): Promise<void> {
204
204
  const resolveConfig = deps.resolveConfig ?? resolveCodexLoginConfig;
@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { z } from "zod";
4
4
  import { createServer } from "@/server";
5
- import { isSdkToolAllowed } from "../scripts-runtime/sdk-allowlist";
5
+ import { isMcpToolAllowedForScripts } from "../scripts-runtime/sdk-allowlist";
6
6
  import { route } from "./route-def";
7
7
  import { json, jsonError } from "./utils";
8
8
 
@@ -55,7 +55,7 @@ export async function handleMcpBridge(
55
55
 
56
56
  const { tool: toolName, args } = parsed.body;
57
57
 
58
- if (!isSdkToolAllowed(toolName)) {
58
+ if (!isMcpToolAllowedForScripts(toolName)) {
59
59
  jsonError(res, `Tool '${toolName}' is not in the SDK allowlist`, 403);
60
60
  return true;
61
61
  }
@@ -1,3 +1,4 @@
1
+ import { mcpToolNameForSdkMethod } from "../scripts-runtime/sdk-allowlist";
1
2
  import { stdlib } from "../scripts-runtime/stdlib";
2
3
 
3
4
  type StepStatusResponse =
@@ -161,7 +162,7 @@ export function buildWorkflowCtx(input: {
161
162
  return (args?: unknown) =>
162
163
  fetchJson("/api/mcp-bridge", {
163
164
  method: "POST",
164
- body: JSON.stringify({ tool: prop.replaceAll("_", "-"), args: args ?? {} }),
165
+ body: JSON.stringify({ tool: mcpToolNameForSdkMethod(prop), args: args ?? {} }),
165
166
  });
166
167
  },
167
168
  });
@@ -145,10 +145,18 @@ export const SDK_ALLOWLIST = Object.keys(SDK_TOOL_NAME_MAP) as Array<
145
145
  keyof typeof SDK_TOOL_NAME_MAP
146
146
  >;
147
147
 
148
+ /** Set of MCP tool names (values of SDK_TOOL_NAME_MAP) that scripts may call via the bridge. */
149
+ const MCP_TOOL_NAMES: ReadonlySet<string> = new Set<string>(Object.values(SDK_TOOL_NAME_MAP));
150
+
148
151
  export function isSdkToolAllowed(name: string): boolean {
149
152
  return (SDK_ALLOWLIST as readonly string[]).includes(name);
150
153
  }
151
154
 
155
+ /** True if `mcpToolName` (e.g. "trigger-workflow") corresponds to an allowlisted SDK method. */
156
+ export function isMcpToolAllowedForScripts(mcpToolName: string): boolean {
157
+ return MCP_TOOL_NAMES.has(mcpToolName);
158
+ }
159
+
152
160
  export function mcpToolNameForSdkMethod(name: string): string {
153
161
  return SDK_TOOL_NAME_MAP[name as keyof typeof SDK_TOOL_NAME_MAP] ?? name;
154
162
  }
@@ -243,7 +243,7 @@ declare module "swarm-sdk" {
243
243
  workflow_patch(args: Record<string, unknown>): Promise<unknown>;
244
244
  workflow_patchNode(args: Record<string, unknown>): Promise<unknown>;
245
245
  workflow_delete(args: { id: string }): Promise<unknown>;
246
- workflow_trigger(args: { id: string; input?: Record<string, unknown> }): Promise<unknown>;
246
+ workflow_trigger(args: { id: string; triggerData?: Record<string, unknown> }): Promise<unknown>;
247
247
  workflow_retryRun(args: { id: string }): Promise<unknown>;
248
248
  workflow_cancelRun(args: { id: string }): Promise<unknown>;
249
249
 
@@ -225,7 +225,7 @@ declare module "swarm-sdk" {
225
225
  workflow_patch(args: Record<string, unknown>): Promise<unknown>;
226
226
  workflow_patchNode(args: Record<string, unknown>): Promise<unknown>;
227
227
  workflow_delete(args: { id: string }): Promise<unknown>;
228
- workflow_trigger(args: { id: string; input?: Record<string, unknown> }): Promise<unknown>;
228
+ workflow_trigger(args: { id: string; triggerData?: Record<string, unknown> }): Promise<unknown>;
229
229
  workflow_retryRun(args: { id: string }): Promise<unknown>;
230
230
  workflow_cancelRun(args: { id: string }): Promise<unknown>;
231
231
 
@@ -246,7 +246,7 @@ describe("runCodexLogin", () => {
246
246
  resolveConfig: async () => ({
247
247
  apiUrl: "http://localhost:3013",
248
248
  apiKey: "test-key",
249
- slot: 15,
249
+ slot: 101,
250
250
  }),
251
251
  login: mock(async () => {
252
252
  throw new Error("should not start oauth");
@@ -259,18 +259,46 @@ describe("runCodexLogin", () => {
259
259
  });
260
260
 
261
261
  expect(error).toHaveBeenCalledWith(
262
- expect.stringContaining("--slot must be an integer between 0 and 9"),
262
+ expect.stringContaining("--slot must be an integer between 0 and 100"),
263
263
  );
264
264
  expect(exit).toHaveBeenCalledWith(1);
265
265
  expect(store).not.toHaveBeenCalled();
266
266
  });
267
267
 
268
+ it("accepts slot 10 (previously rejected by the old 0-9 cap)", async () => {
269
+ let storedSlot: number | undefined;
270
+ const store = mock(async (_apiUrl: string, _apiKey: string, _creds: unknown, slot?: number) => {
271
+ storedSlot = slot;
272
+ });
273
+
274
+ await runCodexLogin([], {
275
+ resolveConfig: async () => ({
276
+ apiUrl: "http://localhost:3013",
277
+ apiKey: "test-key",
278
+ slot: 10,
279
+ }),
280
+ login: mock(async () => ({
281
+ access: "at_test",
282
+ refresh: "rt_test",
283
+ expires: Date.now() + 3600000,
284
+ accountId: "acc-test",
285
+ })),
286
+ store,
287
+ loadAllSlots: mock(async () => []),
288
+ log: () => {},
289
+ error: () => {},
290
+ exit: () => {},
291
+ });
292
+
293
+ expect(storedSlot).toBe(10);
294
+ });
295
+
268
296
  it("errors when all slots are occupied", async () => {
269
297
  const error = mock(() => {});
270
298
  const exit = mock(() => {});
271
299
 
272
- // All 10 slots occupied
273
- const allSlots = Array.from({ length: 10 }, (_, i) => ({
300
+ // All 101 slots occupied (0-100)
301
+ const allSlots = Array.from({ length: 101 }, (_, i) => ({
274
302
  slot: i,
275
303
  creds: { access: "", refresh: "", expires: 0, accountId: "" },
276
304
  }));
@@ -1,7 +1,14 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { Readable } from "node:stream";
3
5
  import { closeDb, initDb } from "../be/db";
4
- import { mcpToolNameForSdkMethod, SDK_ALLOWLIST } from "../scripts-runtime/sdk-allowlist";
6
+ import { handleMcpBridge } from "../http/mcp-bridge";
7
+ import {
8
+ isMcpToolAllowedForScripts,
9
+ mcpToolNameForSdkMethod,
10
+ SDK_ALLOWLIST,
11
+ } from "../scripts-runtime/sdk-allowlist";
5
12
  import type { SwarmConfig } from "../scripts-runtime/swarm-config";
6
13
  import { createSwarmSdk } from "../scripts-runtime/swarm-sdk";
7
14
  import { createServer } from "../server";
@@ -56,4 +63,119 @@ describe("script SDK allowlist", () => {
56
63
  expect(types).not.toContain("join_swarm(");
57
64
  expect(types).not.toContain("start_worker(");
58
65
  });
66
+
67
+ test("isMcpToolAllowedForScripts accepts every MCP name in the allowlist", () => {
68
+ for (const sdkName of SDK_ALLOWLIST) {
69
+ const mcpName = mcpToolNameForSdkMethod(sdkName);
70
+ expect(isMcpToolAllowedForScripts(mcpName)).toBe(true);
71
+ }
72
+ });
73
+
74
+ test("isMcpToolAllowedForScripts rejects non-mapped MCP names", () => {
75
+ // SDK method names (underscores) are not MCP names — must be rejected
76
+ expect(isMcpToolAllowedForScripts("workflow_trigger")).toBe(false);
77
+ expect(isMcpToolAllowedForScripts("slack_post")).toBe(false);
78
+ // Completely unknown tool names
79
+ expect(isMcpToolAllowedForScripts("tool-does-not-exist")).toBe(false);
80
+ expect(isMcpToolAllowedForScripts("start-worker")).toBe(false);
81
+ });
82
+
83
+ test("bundled swarm-sdk.d.ts uses triggerData (not input) for workflow_trigger", async () => {
84
+ const types = await Bun.file("src/scripts-runtime/types/swarm-sdk.d.ts").text();
85
+ expect(types).toContain("workflow_trigger(args: { id: string; triggerData?");
86
+ expect(types).not.toContain("workflow_trigger(args: { id: string; input?");
87
+ });
88
+ });
89
+
90
+ describe("mcp-bridge allowlist gate", () => {
91
+ const TEST_DB_PATH = "./test-sdk-allowlist-bridge.sqlite";
92
+ const API_KEY = "test-mcp-bridge-key-1234567890";
93
+ let prevApiKey: string | undefined;
94
+
95
+ async function removeDbFiles(path: string): Promise<void> {
96
+ for (const suffix of ["", "-wal", "-shm"]) {
97
+ try {
98
+ await unlink(path + suffix);
99
+ } catch (error) {
100
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
101
+ }
102
+ }
103
+ }
104
+
105
+ beforeAll(async () => {
106
+ await removeDbFiles(TEST_DB_PATH);
107
+ initDb(TEST_DB_PATH);
108
+ prevApiKey = process.env.AGENT_SWARM_API_KEY;
109
+ process.env.AGENT_SWARM_API_KEY = API_KEY;
110
+ });
111
+
112
+ afterAll(async () => {
113
+ closeDb();
114
+ await removeDbFiles(TEST_DB_PATH);
115
+ if (prevApiKey === undefined) {
116
+ delete process.env.AGENT_SWARM_API_KEY;
117
+ } else {
118
+ process.env.AGENT_SWARM_API_KEY = prevApiKey;
119
+ }
120
+ });
121
+
122
+ async function postBridge(
123
+ body: Record<string, unknown>,
124
+ ): Promise<{ status: number; body: unknown }> {
125
+ const raw = JSON.stringify(body);
126
+ const req = Readable.from([Buffer.from(raw)]) as IncomingMessage;
127
+ req.method = "POST";
128
+ req.url = "/api/mcp-bridge";
129
+ req.headers = {
130
+ authorization: `Bearer ${API_KEY}`,
131
+ "content-type": "application/json",
132
+ "x-agent-id": "test-agent-bridge",
133
+ };
134
+
135
+ let status = 200;
136
+ let text = "";
137
+ const res = {
138
+ headersSent: false,
139
+ writableEnded: false,
140
+ setHeader() {},
141
+ writeHead(code: number) {
142
+ status = code;
143
+ this.headersSent = true;
144
+ return this;
145
+ },
146
+ end(chunk?: unknown) {
147
+ if (chunk !== undefined) text += String(chunk);
148
+ this.writableEnded = true;
149
+ return this;
150
+ },
151
+ } as unknown as ServerResponse;
152
+
153
+ await handleMcpBridge(
154
+ req,
155
+ res,
156
+ ["api", "mcp-bridge"],
157
+ new URLSearchParams(),
158
+ "test-agent-bridge",
159
+ );
160
+ return { status, body: text ? JSON.parse(text) : {} };
161
+ }
162
+
163
+ test("trigger-workflow is NOT rejected with 403 (reaches tool handler)", async () => {
164
+ const result = await postBridge({
165
+ tool: "trigger-workflow",
166
+ args: { id: "00000000-0000-0000-0000-000000000001" },
167
+ });
168
+ // Must not be an allowlist 403; may be 404/500 (non-existent workflow) — that's fine.
169
+ expect(result.status).not.toBe(403);
170
+ });
171
+
172
+ test("genuinely non-mapped MCP names still return 403", async () => {
173
+ // SDK method names (underscores) are not valid MCP names — must 403
174
+ const sdkNameResult = await postBridge({ tool: "workflow_trigger", args: { id: "x" } });
175
+ expect(sdkNameResult.status).toBe(403);
176
+
177
+ // Completely unknown tool names
178
+ const unknownResult = await postBridge({ tool: "start-worker", args: {} });
179
+ expect(unknownResult.status).toBe(403);
180
+ });
59
181
  });
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildWorkflowCtx } from "../script-workflows/workflow-ctx";
3
+
4
+ describe("workflow-ctx: ctx.swarm proxy tool name resolution", () => {
5
+ test("non-mechanical SDK→MCP mappings are routed correctly", async () => {
6
+ const captured: string[] = [];
7
+ const origFetch = globalThis.fetch;
8
+
9
+ globalThis.fetch = async (url: unknown, init?: RequestInit) => {
10
+ if (typeof url === "string" && url.includes("/api/mcp-bridge")) {
11
+ const body = JSON.parse((init?.body as string) ?? "{}");
12
+ captured.push(body.tool);
13
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
14
+ }
15
+ return origFetch(url as URL, init);
16
+ };
17
+
18
+ try {
19
+ const ctx = buildWorkflowCtx({
20
+ runId: "test-run",
21
+ agentId: "test-agent",
22
+ apiKey: "test-key",
23
+ baseUrl: "http://localhost:9999",
24
+ args: {},
25
+ });
26
+
27
+ // Non-mechanical: SDK method name ≠ kebab-cased MCP name
28
+ await ctx.swarm.workflow_trigger({ id: "wf-1" }); // → "trigger-workflow" (not "workflow-trigger")
29
+ await ctx.swarm.page_create({ title: "T" }); // → "create_page" (not "page-create")
30
+ await ctx.swarm.memory_rate({ id: "x" }); // → "memory_rate" (not "memory-rate")
31
+
32
+ // Mechanical: verify mechanical mappings still work
33
+ await ctx.swarm.memory_search({ query: "q" }); // → "memory-search"
34
+ } finally {
35
+ globalThis.fetch = origFetch;
36
+ }
37
+
38
+ expect(captured[0]).toBe("trigger-workflow");
39
+ expect(captured[1]).toBe("create_page");
40
+ expect(captured[2]).toBe("memory_rate");
41
+ expect(captured[3]).toBe("memory-search");
42
+ });
43
+ });