@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 +1 -1
- package/package.json +1 -1
- package/src/be/scripts/typecheck.ts +1 -1
- package/src/commands/codex-login.ts +2 -2
- package/src/http/mcp-bridge.ts +2 -2
- package/src/script-workflows/workflow-ctx.ts +2 -1
- package/src/scripts-runtime/sdk-allowlist.ts +8 -0
- package/src/scripts-runtime/types/stdlib.d.ts +1 -1
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -1
- package/src/tests/codex-login.test.ts +32 -4
- package/src/tests/sdk-allowlist.test.ts +123 -1
- package/src/tests/workflow-ctx.test.ts +43 -0
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.
|
|
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.
|
|
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;
|
|
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-
|
|
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 =
|
|
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;
|
package/src/http/mcp-bridge.ts
CHANGED
|
@@ -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 {
|
|
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 (!
|
|
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
|
|
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;
|
|
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;
|
|
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:
|
|
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
|
|
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
|
|
273
|
-
const allSlots = Array.from({ length:
|
|
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 {
|
|
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
|
+
});
|