@desplega.ai/agent-swarm 1.80.0 → 1.80.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 (93) hide show
  1. package/openapi.json +399 -14
  2. package/package.json +3 -1
  3. package/src/artifact-sdk/server.ts +2 -1
  4. package/src/be/db.ts +1 -1
  5. package/src/be/migrations/064_scripts.sql +39 -0
  6. package/src/be/migrations/065_script_embeddings.sql +7 -0
  7. package/src/be/scripts/db.ts +391 -0
  8. package/src/be/scripts/embeddings.ts +231 -0
  9. package/src/be/scripts/maintenance.ts +9 -0
  10. package/src/be/scripts/typecheck.ts +193 -0
  11. package/src/cli.tsx +22 -5
  12. package/src/commands/artifact.ts +3 -2
  13. package/src/commands/claude-managed-setup.ts +2 -1
  14. package/src/commands/codex-login.ts +5 -3
  15. package/src/commands/onboard.tsx +2 -1
  16. package/src/commands/runner.ts +72 -10
  17. package/src/commands/setup.tsx +5 -3
  18. package/src/hooks/hook.ts +4 -3
  19. package/src/http/index.ts +40 -29
  20. package/src/http/memory.ts +28 -0
  21. package/src/http/openapi.ts +1 -0
  22. package/src/http/page-proxy.ts +2 -1
  23. package/src/http/route-def.ts +1 -0
  24. package/src/http/schedules.ts +37 -0
  25. package/src/http/scripts.ts +381 -0
  26. package/src/linear/outbound.ts +9 -2
  27. package/src/otel.ts +5 -0
  28. package/src/providers/claude-adapter.ts +22 -1
  29. package/src/scripts-runtime/ctx.ts +23 -0
  30. package/src/scripts-runtime/eval-harness.ts +39 -0
  31. package/src/scripts-runtime/executors/native.ts +229 -0
  32. package/src/scripts-runtime/executors/registry.ts +16 -0
  33. package/src/scripts-runtime/executors/types.ts +63 -0
  34. package/src/scripts-runtime/extract-signature.ts +81 -0
  35. package/src/scripts-runtime/import-allowlist.ts +109 -0
  36. package/src/scripts-runtime/loader.ts +96 -0
  37. package/src/scripts-runtime/redacted.ts +48 -0
  38. package/src/scripts-runtime/sdk-allowlist.ts +29 -0
  39. package/src/scripts-runtime/stdlib/fetch.ts +46 -0
  40. package/src/scripts-runtime/stdlib/glob.ts +8 -0
  41. package/src/scripts-runtime/stdlib/grep.ts +34 -0
  42. package/src/scripts-runtime/stdlib/index.ts +16 -0
  43. package/src/scripts-runtime/stdlib/table.ts +17 -0
  44. package/src/scripts-runtime/swarm-config.ts +35 -0
  45. package/src/scripts-runtime/swarm-sdk.ts +197 -0
  46. package/src/scripts-runtime/types/stdlib.d.ts +104 -0
  47. package/src/scripts-runtime/types/swarm-sdk.d.ts +86 -0
  48. package/src/server.ts +12 -0
  49. package/src/tests/api-key.test.ts +33 -0
  50. package/src/tests/codex-login.test.ts +1 -1
  51. package/src/tests/linear-outbound-sync.test.ts +109 -0
  52. package/src/tests/mcp-tools.test.ts +69 -0
  53. package/src/tests/redacted.test.ts +29 -0
  54. package/src/tests/runner-tool-spans.test.ts +268 -0
  55. package/src/tests/script-executor-conformance.test.ts +142 -0
  56. package/src/tests/script-executor-registry.test.ts +17 -0
  57. package/src/tests/scripts-db.test.ts +329 -0
  58. package/src/tests/scripts-embeddings.test.ts +291 -0
  59. package/src/tests/scripts-extract-signature.test.ts +47 -0
  60. package/src/tests/scripts-http.test.ts +350 -0
  61. package/src/tests/scripts-import-allowlist.test.ts +55 -0
  62. package/src/tests/scripts-mcp-e2e.test.ts +269 -0
  63. package/src/tests/scripts-runtime-secret-egress.test.ts +44 -0
  64. package/src/tests/scripts-runtime.test.ts +289 -0
  65. package/src/tests/sdk-allowlist.test.ts +59 -0
  66. package/src/tests/secret-scrubber.test.ts +35 -1
  67. package/src/tests/swarm-config.test.ts +38 -0
  68. package/src/tests/tool-annotations.test.ts +2 -2
  69. package/src/tests/tool-call-progress.test.ts +30 -0
  70. package/src/tests/workflow-e2e.test.ts +218 -0
  71. package/src/tests/workflow-executors.test.ts +32 -2
  72. package/src/tests/workflow-input-redaction.test.ts +232 -0
  73. package/src/tests/workflow-swarm-script.test.ts +273 -0
  74. package/src/tools/memory-rate.ts +2 -1
  75. package/src/tools/script-common.ts +88 -0
  76. package/src/tools/script-delete.ts +35 -0
  77. package/src/tools/script-query-types.ts +37 -0
  78. package/src/tools/script-run.ts +43 -0
  79. package/src/tools/script-search.ts +32 -0
  80. package/src/tools/script-upsert.ts +43 -0
  81. package/src/tools/tool-config.ts +7 -0
  82. package/src/types.ts +60 -1
  83. package/src/utils/api-key.ts +28 -0
  84. package/src/utils/page-session.ts +8 -6
  85. package/src/utils/secret-scrubber.ts +22 -1
  86. package/src/workflows/engine.ts +12 -4
  87. package/src/workflows/executors/index.ts +1 -0
  88. package/src/workflows/executors/registry.ts +2 -0
  89. package/src/workflows/executors/script.ts +12 -1
  90. package/src/workflows/executors/swarm-script.ts +170 -0
  91. package/src/workflows/input.ts +65 -0
  92. package/src/workflows/recovery.ts +31 -3
  93. package/src/workflows/resume.ts +43 -5
@@ -0,0 +1,350 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { Readable } from "node:stream";
5
+ import { closeDb, createAgent, getDb, initDb } from "../be/db";
6
+ import { getScript, listScripts } from "../be/scripts/db";
7
+ import { setScriptEmbeddingProviderForTests } from "../be/scripts/embeddings";
8
+ import { handleCore } from "../http/core";
9
+ import { handleScripts } from "../http/scripts";
10
+ import { getPathSegments, parseQueryParams } from "../http/utils";
11
+ import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
12
+
13
+ const TEST_DB_PATH = "./test-scripts-http.sqlite";
14
+ const API_KEY = "test-scripts-http-key-1234567890";
15
+
16
+ function fakeEmbedding(text: string): Float32Array {
17
+ const lower = text.toLowerCase();
18
+ return new Float32Array([
19
+ lower.includes("lookup") ? 1 : 0,
20
+ lower.includes("multiply") ? 1 : 0,
21
+ lower.includes("linear") ? 1 : 0,
22
+ lower.includes("github") ? 1 : 0,
23
+ ]);
24
+ }
25
+
26
+ const fakeEmbeddingProvider = {
27
+ name: "test/fake-script-embedding",
28
+ dimensions: 4,
29
+ async embed(text: string) {
30
+ return fakeEmbedding(text);
31
+ },
32
+ async embedBatch(texts: string[]) {
33
+ return Promise.all(texts.map(fakeEmbedding));
34
+ },
35
+ };
36
+
37
+ async function removeDbFiles(path: string): Promise<void> {
38
+ for (const suffix of ["", "-wal", "-shm"]) {
39
+ try {
40
+ await unlink(path + suffix);
41
+ } catch (error) {
42
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
43
+ }
44
+ }
45
+ }
46
+
47
+ function validSource(multiplier: number) {
48
+ return `export default async (args: { value: number }): Promise<{ result: number }> => ({ result: args.value * ${multiplier} });`;
49
+ }
50
+
51
+ let workerId: string;
52
+ let leadId: string;
53
+ let savedEnv: NodeJS.ProcessEnv;
54
+
55
+ beforeAll(async () => {
56
+ savedEnv = { ...process.env };
57
+ await removeDbFiles(TEST_DB_PATH);
58
+ initDb(TEST_DB_PATH);
59
+ process.env.AGENT_SWARM_API_KEY = API_KEY;
60
+ delete process.env.API_KEY;
61
+ refreshSecretScrubberCache();
62
+ setScriptEmbeddingProviderForTests(fakeEmbeddingProvider);
63
+
64
+ const worker = createAgent({ name: "scripts-worker", isLead: false, status: "idle" });
65
+ const lead = createAgent({ name: "scripts-lead", isLead: true, status: "idle" });
66
+ workerId = worker.id;
67
+ leadId = lead.id;
68
+ });
69
+
70
+ afterAll(async () => {
71
+ closeDb();
72
+ setScriptEmbeddingProviderForTests(null);
73
+ await removeDbFiles(TEST_DB_PATH);
74
+ for (const key of Object.keys(process.env)) {
75
+ if (!(key in savedEnv)) delete process.env[key];
76
+ }
77
+ for (const [key, value] of Object.entries(savedEnv)) {
78
+ if (value === undefined) delete process.env[key];
79
+ else process.env[key] = value;
80
+ }
81
+ refreshSecretScrubberCache();
82
+ });
83
+
84
+ beforeEach(() => {
85
+ getDb().run("DELETE FROM scripts");
86
+ getDb().run("DELETE FROM events WHERE event = 'script.global_upsert'");
87
+ });
88
+
89
+ type TestResponse = {
90
+ status: number;
91
+ text: string;
92
+ json: () => Promise<unknown>;
93
+ };
94
+
95
+ async function dispatch(
96
+ path: string,
97
+ init: RequestInit & { agentId?: string } = {},
98
+ ): Promise<TestResponse> {
99
+ const headers: Record<string, string> = {
100
+ Authorization: `Bearer ${API_KEY}`,
101
+ "Content-Type": "application/json",
102
+ ...((init.headers as Record<string, string>) ?? {}),
103
+ };
104
+ if (init.agentId !== undefined) headers["X-Agent-ID"] = init.agentId;
105
+ const req = Readable.from(init.body ? [Buffer.from(String(init.body))] : []) as IncomingMessage;
106
+ req.method = init.method ?? "GET";
107
+ req.url = path;
108
+ req.headers = Object.fromEntries(
109
+ Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
110
+ );
111
+
112
+ let status = 200;
113
+ let text = "";
114
+ const res = {
115
+ headersSent: false,
116
+ writableEnded: false,
117
+ setHeader() {},
118
+ writeHead(code: number) {
119
+ status = code;
120
+ this.headersSent = true;
121
+ return this;
122
+ },
123
+ end(chunk?: unknown) {
124
+ if (chunk !== undefined) text += String(chunk);
125
+ this.writableEnded = true;
126
+ return this;
127
+ },
128
+ } as unknown as ServerResponse;
129
+
130
+ const agentId = req.headers["x-agent-id"] as string | undefined;
131
+ if (!(await handleCore(req, res, agentId, API_KEY))) {
132
+ const pathSegments = getPathSegments(req.url || "");
133
+ const queryParams = parseQueryParams(req.url || "");
134
+ if (!(await handleScripts(req, res, pathSegments, queryParams, agentId))) {
135
+ res.writeHead(404);
136
+ res.end("Not Found");
137
+ }
138
+ }
139
+
140
+ return {
141
+ status,
142
+ text,
143
+ json: async () => JSON.parse(text),
144
+ };
145
+ }
146
+
147
+ async function upsert(body: Record<string, unknown>, agentId = workerId): Promise<TestResponse> {
148
+ return dispatch("/api/scripts/upsert", {
149
+ method: "POST",
150
+ agentId,
151
+ body: JSON.stringify(body),
152
+ });
153
+ }
154
+
155
+ describe("/api/scripts HTTP", () => {
156
+ test("requires X-Agent-ID", async () => {
157
+ const res = await dispatch("/api/scripts/upsert", {
158
+ method: "POST",
159
+ body: JSON.stringify({ name: "missing-agent", source: validSource(2) }),
160
+ });
161
+ expect(res.status).toBe(400);
162
+ expect((await res.json()).error).toContain("X-Agent-ID");
163
+ });
164
+
165
+ test("upsert round-trips body and bumps version on source change", async () => {
166
+ const first = await upsert({
167
+ name: "double",
168
+ source: validSource(2),
169
+ description: "Double values",
170
+ intent: "Arithmetic reuse",
171
+ });
172
+ expect(first.status).toBe(200);
173
+ expect(await first.json()).toEqual({ name: "double", version: 1, contentDeduped: false });
174
+
175
+ const second = await upsert({
176
+ name: "double",
177
+ source: validSource(3),
178
+ description: "Triple values",
179
+ intent: "Arithmetic reuse",
180
+ });
181
+ expect(second.status).toBe(200);
182
+ expect(await second.json()).toEqual({ name: "double", version: 2, contentDeduped: false });
183
+ });
184
+
185
+ test("upsert typecheck failures return diagnostics and do not write rows", async () => {
186
+ const res = await upsert({
187
+ name: "bad-types",
188
+ source: `const x: number = "nope"; export default async () => x;`,
189
+ });
190
+ expect(res.status).toBe(400);
191
+ const body = await res.json();
192
+ expect(body.error).toBe("typecheck_failed");
193
+ expect(body.diagnostics.length).toBeGreaterThan(0);
194
+ expect(getScript({ name: "bad-types", scope: "agent", scopeId: workerId })).toBeNull();
195
+ });
196
+
197
+ test("upsert rejects unknown ctx.swarm tools", async () => {
198
+ const res = await upsert({
199
+ name: "unknown-tool",
200
+ source: `
201
+ import type { ScriptContext } from "swarm-sdk";
202
+ export default async (_args: unknown, ctx: ScriptContext) => ctx.swarm.no_such_tool({});
203
+ `,
204
+ });
205
+ expect(res.status).toBe(400);
206
+ expect((await res.json()).error).toBe("typecheck_failed");
207
+ });
208
+
209
+ test("global upsert is lead-only and writes audit events", async () => {
210
+ const denied = await upsert(
211
+ { name: "global-denied", scope: "global", source: validSource(2) },
212
+ workerId,
213
+ );
214
+ expect(denied.status).toBe(403);
215
+
216
+ const allowed = await upsert(
217
+ { name: "global-ok", scope: "global", source: validSource(2) },
218
+ leadId,
219
+ );
220
+ expect(allowed.status).toBe(200);
221
+
222
+ const event = getDb()
223
+ .prepare<{ data: string }, []>(
224
+ "SELECT data FROM events WHERE event = 'script.global_upsert' LIMIT 1",
225
+ )
226
+ .get();
227
+ expect(event).toBeTruthy();
228
+ expect(JSON.parse(event!.data).isNew).toBe(true);
229
+ });
230
+
231
+ test("global upsert promotion marks isPromotion when caller has agent script with same name", async () => {
232
+ await upsert({ name: "promote-me", source: validSource(2) }, leadId);
233
+ const res = await upsert(
234
+ { name: "promote-me", scope: "global", source: validSource(3) },
235
+ leadId,
236
+ );
237
+ expect(res.status).toBe(200);
238
+
239
+ const event = getDb()
240
+ .prepare<{ data: string }, []>(
241
+ "SELECT data FROM events WHERE event = 'script.global_upsert' ORDER BY createdAt DESC LIMIT 1",
242
+ )
243
+ .get();
244
+ expect(JSON.parse(event!.data).isPromotion).toBe(true);
245
+ });
246
+
247
+ test("failed promotion typecheck does not clear scratch flag", async () => {
248
+ const inline = await dispatch("/api/scripts/run", {
249
+ method: "POST",
250
+ agentId: workerId,
251
+ body: JSON.stringify({
252
+ source: `const x: number = "runtime-ok"; export default async () => "ok";`,
253
+ intent: "scratch promote",
254
+ }),
255
+ });
256
+ expect(inline.status).toBe(200);
257
+ const slug = (await inline.json()).autoSaved.slug as string;
258
+
259
+ const failed = await upsert({
260
+ name: slug,
261
+ source: `const x: number = "no"; export default async () => x;`,
262
+ });
263
+ expect(failed.status).toBe(400);
264
+ expect(getScript({ name: slug, scope: "agent", scopeId: workerId })?.isScratch).toBe(true);
265
+ });
266
+
267
+ test("run named scripts and inline scripts, auto-saving only successful inline source", async () => {
268
+ await upsert({ name: "named-runner", source: validSource(4) });
269
+ const named = await dispatch("/api/scripts/run", {
270
+ method: "POST",
271
+ agentId: workerId,
272
+ body: JSON.stringify({ name: "named-runner", args: { value: 3 }, intent: "run named" }),
273
+ });
274
+ expect(named.status).toBe(200);
275
+ expect((await named.json()).result).toEqual({ result: 12 });
276
+
277
+ const inline = await dispatch("/api/scripts/run", {
278
+ method: "POST",
279
+ agentId: workerId,
280
+ body: JSON.stringify({
281
+ source: `const x: number = "not typechecked"; export default async () => ({ ok: x });`,
282
+ intent: "inline type error still runs",
283
+ }),
284
+ });
285
+ expect(inline.status).toBe(200);
286
+ const inlineBody = await inline.json();
287
+ expect(inlineBody.result).toEqual({ ok: "not typechecked" });
288
+ expect(inlineBody.autoSaved.slug).toContain("scratch-inline-type-error-still-runs");
289
+
290
+ const beforeFailed = listScripts({
291
+ scope: "agent",
292
+ scopeId: workerId,
293
+ includeScratch: true,
294
+ }).length;
295
+ const failed = await dispatch("/api/scripts/run", {
296
+ method: "POST",
297
+ agentId: workerId,
298
+ body: JSON.stringify({
299
+ source: `export default async () => { throw new Error("boom"); };`,
300
+ intent: "failing inline",
301
+ }),
302
+ });
303
+ expect(failed.status).toBe(200);
304
+ expect((await failed.json()).autoSaved).toBeUndefined();
305
+ expect(listScripts({ scope: "agent", scopeId: workerId, includeScratch: true }).length).toBe(
306
+ beforeFailed,
307
+ );
308
+ });
309
+
310
+ test("workspace-rw named scripts return 501", async () => {
311
+ await upsert({ name: "workspace", source: validSource(2), fsMode: "workspace-rw" });
312
+ const res = await dispatch("/api/scripts/run", {
313
+ method: "POST",
314
+ agentId: workerId,
315
+ body: JSON.stringify({ name: "workspace", args: { value: 1 }, intent: "workspace" }),
316
+ });
317
+ expect(res.status).toBe(501);
318
+ });
319
+
320
+ test("search, types, and delete routes work", async () => {
321
+ await upsert({
322
+ name: "lookup-helper",
323
+ source: validSource(2),
324
+ description: "Find lookup rows",
325
+ });
326
+
327
+ const search = await dispatch("/api/scripts/search", {
328
+ method: "POST",
329
+ agentId: workerId,
330
+ body: JSON.stringify({ query: "lookup", limit: 5 }),
331
+ });
332
+ expect(search.status).toBe(200);
333
+ expect((await search.json()).results[0].name).toBe("lookup-helper");
334
+
335
+ const types = await dispatch("/api/scripts/lookup-helper/types", { agentId: workerId });
336
+ expect(types.status).toBe(200);
337
+ const typesBody = await types.json();
338
+ expect(typesBody.sdkTypes).toContain("SwarmSdk");
339
+ expect(typesBody.stdlibTypes).toContain('module "stdlib"');
340
+ expect(typesBody.signature.argsType).toContain("value");
341
+
342
+ const del = await dispatch("/api/scripts/lookup-helper", {
343
+ method: "DELETE",
344
+ agentId: workerId,
345
+ });
346
+ expect(del.status).toBe(200);
347
+ expect(await del.json()).toEqual({ deleted: true });
348
+ expect(getScript({ name: "lookup-helper", scope: "agent", scopeId: workerId })).toBeNull();
349
+ });
350
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validateScriptImports } from "../scripts-runtime/import-allowlist";
3
+
4
+ describe("script import allowlist", () => {
5
+ test("allows relative imports and runtime barrels", () => {
6
+ const result = validateScriptImports(`
7
+ import helper from './helper';
8
+ import '../other';
9
+ import { SwarmSdk } from 'swarm-sdk';
10
+ import { table } from 'stdlib';
11
+ export default () => helper;
12
+ `);
13
+ expect(result.ok).toBe(true);
14
+ });
15
+
16
+ test("rejects forbidden static imports", () => {
17
+ const result = validateScriptImports("import fs from 'node:fs'; export default () => fs");
18
+ expect(result.ok).toBe(false);
19
+ if (!result.ok) expect(result.diagnostic).toContain("node:fs");
20
+ });
21
+
22
+ test("rejects child_process imports", () => {
23
+ const result = validateScriptImports("import cp from 'child_process'; export default () => cp");
24
+ expect(result.ok).toBe(false);
25
+ if (!result.ok) expect(result.diagnostic).toContain("child_process");
26
+ });
27
+
28
+ test("rejects bun:sqlite imports", () => {
29
+ const result = validateScriptImports(
30
+ "import sqlite from 'bun:sqlite'; export default () => sqlite",
31
+ );
32
+ expect(result.ok).toBe(false);
33
+ if (!result.ok) expect(result.diagnostic).toContain("bun:sqlite");
34
+ });
35
+
36
+ test("rejects literal dynamic imports", () => {
37
+ const result = validateScriptImports("export default async () => import('fs')");
38
+ expect(result.ok).toBe(false);
39
+ if (!result.ok) expect(result.diagnostic).toContain("fs");
40
+ });
41
+
42
+ test("rejects Function constructor dynamic import bypasses", () => {
43
+ const result = validateScriptImports(
44
+ `export default async () => new Function("return import('node:fs')")()`,
45
+ );
46
+ expect(result.ok).toBe(false);
47
+ if (!result.ok) expect(result.diagnostic).toContain("Function constructor");
48
+ });
49
+
50
+ test("rejects eval dynamic import bypasses", () => {
51
+ const result = validateScriptImports(`export default async () => eval("import('node:fs')")`);
52
+ expect(result.ok).toBe(false);
53
+ if (!result.ok) expect(result.diagnostic).toContain("eval");
54
+ });
55
+ });
@@ -0,0 +1,269 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { Readable } from "node:stream";
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { closeDb, createAgent, getDb, initDb } from "../be/db";
7
+ import { setScriptEmbeddingProviderForTests } from "../be/scripts/embeddings";
8
+ import { handleCore } from "../http/core";
9
+ import { handleScripts } from "../http/scripts";
10
+ import { getPathSegments, parseQueryParams } from "../http/utils";
11
+ import { registerScriptDeleteTool } from "../tools/script-delete";
12
+ import { registerScriptRunTool } from "../tools/script-run";
13
+ import { registerScriptSearchTool } from "../tools/script-search";
14
+ import { registerScriptUpsertTool } from "../tools/script-upsert";
15
+ import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
16
+
17
+ const TEST_DB_PATH = "./test-scripts-mcp-e2e.sqlite";
18
+ const API_KEY = "test-scripts-mcp-key-1234567890";
19
+
20
+ function fakeEmbedding(text: string): Float32Array {
21
+ const lower = text.toLowerCase();
22
+ return new Float32Array([
23
+ lower.includes("multiply") ? 1 : 0,
24
+ lower.includes("seven") ? 1 : 0,
25
+ lower.includes("memory") ? 1 : 0,
26
+ lower.includes("typed") ? 1 : 0,
27
+ ]);
28
+ }
29
+
30
+ const fakeEmbeddingProvider = {
31
+ name: "test/fake-script-embedding",
32
+ dimensions: 4,
33
+ async embed(text: string) {
34
+ return fakeEmbedding(text);
35
+ },
36
+ async embedBatch(texts: string[]) {
37
+ return Promise.all(texts.map(fakeEmbedding));
38
+ },
39
+ };
40
+
41
+ type RegisteredTool = {
42
+ handler: (args: unknown, extra: unknown) => Promise<unknown>;
43
+ };
44
+
45
+ type StructuredResult<T> = {
46
+ structuredContent: {
47
+ success: boolean;
48
+ status: number;
49
+ data?: T;
50
+ error?: string;
51
+ };
52
+ };
53
+
54
+ async function removeDbFiles(path: string): Promise<void> {
55
+ for (const suffix of ["", "-wal", "-shm"]) {
56
+ try {
57
+ await unlink(path + suffix);
58
+ } catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
60
+ }
61
+ }
62
+ }
63
+
64
+ function buildToolServer() {
65
+ const server = new McpServer({ name: "scripts-mcp-e2e", version: "1.0.0" });
66
+ registerScriptSearchTool(server);
67
+ registerScriptRunTool(server);
68
+ registerScriptUpsertTool(server);
69
+ registerScriptDeleteTool(server);
70
+ const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
71
+ ._registeredTools;
72
+ return {
73
+ search: registered["script-search"]!,
74
+ run: registered["script-run"]!,
75
+ upsert: registered["script-upsert"]!,
76
+ del: registered["script-delete"]!,
77
+ };
78
+ }
79
+
80
+ function meta(agentId?: string) {
81
+ const headers: Record<string, string> = {};
82
+ if (agentId) headers["x-agent-id"] = agentId;
83
+ return { sessionId: "scripts-mcp-e2e", requestInfo: { headers } };
84
+ }
85
+
86
+ function headersRecord(headers: HeadersInit | undefined): Record<string, string> {
87
+ if (!headers) return {};
88
+ if (headers instanceof Headers) return Object.fromEntries(headers.entries());
89
+ if (Array.isArray(headers)) return Object.fromEntries(headers);
90
+ return headers as Record<string, string>;
91
+ }
92
+
93
+ async function dispatchScriptsApi(url: string, init: RequestInit = {}): Promise<Response> {
94
+ const parsedUrl = new URL(url);
95
+ const headers = Object.fromEntries(
96
+ Object.entries(headersRecord(init.headers)).map(([key, value]) => [
97
+ key.toLowerCase(),
98
+ String(value),
99
+ ]),
100
+ );
101
+ const body = init.body === undefined ? undefined : String(init.body);
102
+ const req = Readable.from(body ? [Buffer.from(body)] : []) as IncomingMessage;
103
+ req.method = init.method ?? "GET";
104
+ req.url = `${parsedUrl.pathname}${parsedUrl.search}`;
105
+ req.headers = headers;
106
+
107
+ let status = 200;
108
+ let text = "";
109
+ const res = {
110
+ headersSent: false,
111
+ writableEnded: false,
112
+ setHeader() {},
113
+ writeHead(code: number) {
114
+ status = code;
115
+ this.headersSent = true;
116
+ return this;
117
+ },
118
+ end(chunk?: unknown) {
119
+ if (chunk !== undefined) text += String(chunk);
120
+ this.writableEnded = true;
121
+ return this;
122
+ },
123
+ } as unknown as ServerResponse;
124
+
125
+ const agentId = headers["x-agent-id"];
126
+ if (!(await handleCore(req, res, agentId, API_KEY))) {
127
+ const pathSegments = getPathSegments(req.url || "");
128
+ const queryParams = parseQueryParams(req.url || "");
129
+ if (!(await handleScripts(req, res, pathSegments, queryParams, agentId))) {
130
+ res.writeHead(404);
131
+ res.end("Not Found");
132
+ }
133
+ }
134
+
135
+ return new Response(text, {
136
+ status,
137
+ headers: { "Content-Type": "application/json" },
138
+ });
139
+ }
140
+
141
+ let workerId: string;
142
+ let savedEnv: NodeJS.ProcessEnv;
143
+ let savedFetch: typeof globalThis.fetch;
144
+
145
+ beforeAll(async () => {
146
+ savedEnv = { ...process.env };
147
+ savedFetch = globalThis.fetch;
148
+ await removeDbFiles(TEST_DB_PATH);
149
+ initDb(TEST_DB_PATH);
150
+ process.env.AGENT_SWARM_API_KEY = API_KEY;
151
+ delete process.env.API_KEY;
152
+ refreshSecretScrubberCache();
153
+ setScriptEmbeddingProviderForTests(fakeEmbeddingProvider);
154
+ workerId = createAgent({ name: "scripts-mcp-worker", isLead: false, status: "idle" }).id;
155
+ process.env.MCP_BASE_URL = "http://scripts-mcp-e2e.test";
156
+ globalThis.fetch = (async (input, init) => {
157
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
158
+ if (url.startsWith("http://scripts-mcp-e2e.test/api/scripts/")) {
159
+ return dispatchScriptsApi(url, init);
160
+ }
161
+ return savedFetch(input, init);
162
+ }) as typeof globalThis.fetch;
163
+ });
164
+
165
+ afterAll(async () => {
166
+ globalThis.fetch = savedFetch;
167
+ setScriptEmbeddingProviderForTests(null);
168
+ closeDb();
169
+ await removeDbFiles(TEST_DB_PATH);
170
+ for (const key of Object.keys(process.env)) {
171
+ if (!(key in savedEnv)) delete process.env[key];
172
+ }
173
+ for (const [key, value] of Object.entries(savedEnv)) {
174
+ if (value === undefined) delete process.env[key];
175
+ else process.env[key] = value;
176
+ }
177
+ refreshSecretScrubberCache();
178
+ });
179
+
180
+ beforeEach(() => {
181
+ getDb().run("DELETE FROM scripts");
182
+ });
183
+
184
+ describe("script_ MCP HTTP proxy tools", () => {
185
+ test("exercise script-upsert -> script-search -> script-run -> script-delete", async () => {
186
+ const tools = buildToolServer();
187
+ const source = `export default async (args: { value: number }) => ({ result: args.value * 7 });`;
188
+
189
+ const upsert = (await tools.upsert.handler(
190
+ { name: "times-seven", source, description: "Multiply", intent: "MCP E2E" },
191
+ meta(workerId),
192
+ )) as StructuredResult<{ name: string; version: number }>;
193
+ expect(upsert.structuredContent.success).toBe(true);
194
+ expect(upsert.structuredContent.data?.name).toBe("times-seven");
195
+
196
+ const search = (await tools.search.handler(
197
+ { query: "seven", limit: 5 },
198
+ meta(workerId),
199
+ )) as StructuredResult<{ results: Array<{ name: string }> }>;
200
+ expect(search.structuredContent.success).toBe(true);
201
+ expect(search.structuredContent.data?.results.map((item) => item.name)).toContain(
202
+ "times-seven",
203
+ );
204
+
205
+ const run = (await tools.run.handler(
206
+ { name: "times-seven", args: { value: 6 }, intent: "MCP run" },
207
+ meta(workerId),
208
+ )) as StructuredResult<{ result: { result: number } }>;
209
+ expect(run.structuredContent.success).toBe(true);
210
+ expect(run.structuredContent.data?.result).toEqual({ result: 42 });
211
+
212
+ const del = (await tools.del.handler(
213
+ { name: "times-seven", scope: "agent" },
214
+ meta(workerId),
215
+ )) as StructuredResult<{ deleted: boolean }>;
216
+ expect(del.structuredContent.success).toBe(true);
217
+ expect(del.structuredContent.data?.deleted).toBe(true);
218
+ });
219
+
220
+ test("stdio-style missing agent identity short-circuits clearly", async () => {
221
+ const tools = buildToolServer();
222
+ const result = (await tools.search.handler({ query: "anything" }, meta())) as StructuredResult<{
223
+ error: string;
224
+ }>;
225
+ expect(result.structuredContent.success).toBe(false);
226
+ expect(result.structuredContent.error).toContain("HTTP MCP transport");
227
+ });
228
+
229
+ test("typed SDK fixture passes upsert typecheck and wrong arg type fails", async () => {
230
+ const tools = buildToolServer();
231
+ const source = `
232
+ import type { ScriptContext, SwarmSdk } from "swarm-sdk";
233
+ const compileOnly = (swarm: SwarmSdk) => swarm.memory_search({ query: "foo" });
234
+ export default async (_args: unknown, ctx: ScriptContext) => {
235
+ void compileOnly;
236
+ return { hasMemorySearch: typeof ctx.swarm.memory_search === "function" };
237
+ };
238
+ `;
239
+
240
+ const upsert = (await tools.upsert.handler(
241
+ { name: "typed-sdk", source, description: "Typed SDK fixture", intent: "typecheck" },
242
+ meta(workerId),
243
+ )) as StructuredResult<{ name: string }>;
244
+ expect(upsert.structuredContent.success).toBe(true);
245
+
246
+ const run = (await tools.run.handler(
247
+ { name: "typed-sdk", args: {}, intent: "typed SDK run" },
248
+ meta(workerId),
249
+ )) as StructuredResult<{ result: { hasMemorySearch: boolean } }>;
250
+ expect(run.structuredContent.success).toBe(true);
251
+ expect(run.structuredContent.data?.result).toEqual({ hasMemorySearch: true });
252
+
253
+ const bad = (await tools.upsert.handler(
254
+ {
255
+ name: "typed-sdk-bad",
256
+ source: `
257
+ import type { ScriptContext } from "swarm-sdk";
258
+ export default async (_args: unknown, ctx: ScriptContext) =>
259
+ ctx.swarm.memory_search({ query: 123 });
260
+ `,
261
+ description: "Bad SDK fixture",
262
+ intent: "typecheck",
263
+ },
264
+ meta(workerId),
265
+ )) as StructuredResult<{ diagnostics: string[] }>;
266
+ expect(bad.structuredContent.success).toBe(false);
267
+ expect(bad.structuredContent.error).toBe("typecheck_failed");
268
+ });
269
+ });