@desplega.ai/agent-swarm 1.80.0 → 1.80.2
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 +399 -14
- package/package.json +3 -1
- package/src/artifact-sdk/server.ts +2 -1
- package/src/be/db.ts +1 -1
- package/src/be/migrations/064_scripts.sql +39 -0
- package/src/be/migrations/065_script_embeddings.sql +7 -0
- package/src/be/migrations/066_scripts_args_json_schema.sql +1 -0
- package/src/be/scripts/db.ts +417 -0
- package/src/be/scripts/embeddings.ts +233 -0
- package/src/be/scripts/extract-schema.ts +55 -0
- package/src/be/scripts/maintenance.ts +9 -0
- package/src/be/scripts/typecheck.ts +199 -0
- package/src/cli.tsx +22 -5
- package/src/commands/artifact.ts +3 -2
- package/src/commands/claude-managed-setup.ts +2 -1
- package/src/commands/codex-login.ts +5 -3
- package/src/commands/onboard.tsx +2 -1
- package/src/commands/runner.ts +153 -20
- package/src/commands/setup.tsx +5 -3
- package/src/hooks/hook.ts +4 -3
- package/src/http/index.ts +40 -29
- package/src/http/memory.ts +28 -0
- package/src/http/openapi.ts +1 -0
- package/src/http/page-proxy.ts +2 -1
- package/src/http/route-def.ts +1 -0
- package/src/http/schedules.ts +37 -0
- package/src/http/scripts.ts +388 -0
- package/src/linear/outbound.ts +9 -2
- package/src/otel.ts +5 -0
- package/src/providers/claude-adapter.ts +23 -1
- package/src/providers/types.ts +8 -0
- package/src/scripts-runtime/ctx.ts +23 -0
- package/src/scripts-runtime/eval-harness.ts +63 -0
- package/src/scripts-runtime/executors/native.ts +232 -0
- package/src/scripts-runtime/executors/registry.ts +16 -0
- package/src/scripts-runtime/executors/types.ts +63 -0
- package/src/scripts-runtime/extract-args-schema.ts +69 -0
- package/src/scripts-runtime/extract-signature.ts +81 -0
- package/src/scripts-runtime/import-allowlist.ts +109 -0
- package/src/scripts-runtime/loader.ts +96 -0
- package/src/scripts-runtime/redacted.ts +48 -0
- package/src/scripts-runtime/sdk-allowlist.ts +29 -0
- package/src/scripts-runtime/stdlib/fetch.ts +46 -0
- package/src/scripts-runtime/stdlib/glob.ts +8 -0
- package/src/scripts-runtime/stdlib/grep.ts +34 -0
- package/src/scripts-runtime/stdlib/index.ts +16 -0
- package/src/scripts-runtime/stdlib/table.ts +17 -0
- package/src/scripts-runtime/swarm-config.ts +35 -0
- package/src/scripts-runtime/swarm-sdk.ts +197 -0
- package/src/scripts-runtime/types/stdlib.d.ts +104 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +86 -0
- package/src/server.ts +12 -0
- package/src/tests/api-key.test.ts +33 -0
- package/src/tests/codex-login.test.ts +1 -1
- package/src/tests/error-tracker.test.ts +44 -0
- package/src/tests/linear-outbound-sync.test.ts +109 -0
- package/src/tests/mcp-tools.test.ts +69 -0
- package/src/tests/rate-limit-event.test.ts +292 -0
- package/src/tests/redacted.test.ts +29 -0
- package/src/tests/runner-tool-spans.test.ts +268 -0
- package/src/tests/script-executor-conformance.test.ts +142 -0
- package/src/tests/script-executor-registry.test.ts +17 -0
- package/src/tests/scripts-db.test.ts +329 -0
- package/src/tests/scripts-embeddings.test.ts +291 -0
- package/src/tests/scripts-extract-signature.test.ts +47 -0
- package/src/tests/scripts-http.test.ts +403 -0
- package/src/tests/scripts-import-allowlist.test.ts +55 -0
- package/src/tests/scripts-mcp-e2e.test.ts +269 -0
- package/src/tests/scripts-runtime-secret-egress.test.ts +44 -0
- package/src/tests/scripts-runtime.test.ts +344 -0
- package/src/tests/sdk-allowlist.test.ts +59 -0
- package/src/tests/secret-scrubber.test.ts +35 -1
- package/src/tests/swarm-config.test.ts +38 -0
- package/src/tests/tool-annotations.test.ts +2 -2
- package/src/tests/tool-call-progress.test.ts +30 -0
- package/src/tests/workflow-e2e.test.ts +218 -0
- package/src/tests/workflow-executors.test.ts +32 -2
- package/src/tests/workflow-input-redaction.test.ts +232 -0
- package/src/tests/workflow-swarm-script.test.ts +273 -0
- package/src/tools/memory-rate.ts +2 -1
- package/src/tools/script-common.ts +88 -0
- package/src/tools/script-delete.ts +35 -0
- package/src/tools/script-query-types.ts +37 -0
- package/src/tools/script-run.ts +43 -0
- package/src/tools/script-search.ts +32 -0
- package/src/tools/script-upsert.ts +43 -0
- package/src/tools/tool-config.ts +7 -0
- package/src/types.ts +61 -1
- package/src/utils/api-key.ts +28 -0
- package/src/utils/error-tracker.ts +58 -0
- package/src/utils/page-session.ts +8 -6
- package/src/utils/secret-scrubber.ts +22 -1
- package/src/workflows/engine.ts +12 -4
- package/src/workflows/executors/index.ts +1 -0
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/executors/script.ts +12 -1
- package/src/workflows/executors/swarm-script.ts +170 -0
- package/src/workflows/input.ts +65 -0
- package/src/workflows/recovery.ts +31 -3
- package/src/workflows/resume.ts +43 -5
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { runScript } from "../scripts-runtime/loader";
|
|
3
|
+
import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
|
|
4
|
+
|
|
5
|
+
const savedEnv = { ...process.env };
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
process.env.AGENT_SWARM_API_KEY = "runtime-egress-secret-1234567890";
|
|
9
|
+
refreshSecretScrubberCache();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
for (const key of Object.keys(process.env)) {
|
|
14
|
+
if (!(key in savedEnv)) delete process.env[key];
|
|
15
|
+
}
|
|
16
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
17
|
+
if (value === undefined) delete process.env[key];
|
|
18
|
+
else process.env[key] = value;
|
|
19
|
+
}
|
|
20
|
+
refreshSecretScrubberCache();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("runtime secret egress", () => {
|
|
24
|
+
test("scrubObject catches unwrapped returned config secrets", async () => {
|
|
25
|
+
const output = await runScript({
|
|
26
|
+
agentId: "agent-1",
|
|
27
|
+
resources: { memoryMb: 2048 },
|
|
28
|
+
source:
|
|
29
|
+
"export default async (_args, ctx) => ({ leaked: ctx.stdlib.Redacted.value(ctx.swarm.config.apiKey) });",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(output.result).toEqual({ leaked: "[REDACTED:AGENT_SWARM_API_KEY]" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("wrapped config values stringify to redacted in the result file", async () => {
|
|
36
|
+
const output = await runScript({
|
|
37
|
+
agentId: "agent-1",
|
|
38
|
+
resources: { memoryMb: 2048 },
|
|
39
|
+
source: "export default async (_args, ctx) => ({ wrapped: ctx.swarm.config.apiKey });",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(output.result).toEqual({ wrapped: "<redacted>" });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { runScript } from "../scripts-runtime/loader";
|
|
3
|
+
import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
|
|
4
|
+
|
|
5
|
+
const savedEnv = { ...process.env };
|
|
6
|
+
const resources = { memoryMb: 2048, cpuTimeSec: 20, maxStdoutBytes: 1_048_576 };
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
process.env.AGENT_SWARM_API_KEY = "runtime-test-secret-1234567890";
|
|
10
|
+
delete process.env.API_KEY;
|
|
11
|
+
process.env.MCP_BASE_URL = "http://localhost:3013";
|
|
12
|
+
refreshSecretScrubberCache();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
for (const key of Object.keys(process.env)) {
|
|
17
|
+
if (!(key in savedEnv)) delete process.env[key];
|
|
18
|
+
}
|
|
19
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
20
|
+
if (value === undefined) delete process.env[key];
|
|
21
|
+
else process.env[key] = value;
|
|
22
|
+
}
|
|
23
|
+
refreshSecretScrubberCache();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("runScript", () => {
|
|
27
|
+
test("runs a trivial transform", async () => {
|
|
28
|
+
const output = await runScript({
|
|
29
|
+
agentId: "agent-1",
|
|
30
|
+
args: { x: 1 },
|
|
31
|
+
resources,
|
|
32
|
+
source: "export default async (args) => args.x + 1;",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(output.error).toBeUndefined();
|
|
36
|
+
expect(output.result).toBe(2);
|
|
37
|
+
expect(output.exitCode).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("ctx.stdlib.fetch returns a Response and fetchJson returns parsed JSON", async () => {
|
|
41
|
+
const output = await runScript({
|
|
42
|
+
agentId: "agent-1",
|
|
43
|
+
args: { url: 'data:application/json,{"ok":true}' },
|
|
44
|
+
resources,
|
|
45
|
+
source: `
|
|
46
|
+
export default async (args, ctx) => {
|
|
47
|
+
const response = await ctx.stdlib.fetch(args.url);
|
|
48
|
+
const parsed = await ctx.stdlib.fetchJson(args.url);
|
|
49
|
+
return { status: response.status, parsed };
|
|
50
|
+
};
|
|
51
|
+
`,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(output.error).toBeUndefined();
|
|
55
|
+
expect(output.result).toEqual({ status: 200, parsed: { ok: true } });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("ctx.swarm bridge round-trips kv_set then kv_get", async () => {
|
|
59
|
+
const entries = new Map<string, unknown>();
|
|
60
|
+
const server = Bun.serve({
|
|
61
|
+
port: 0,
|
|
62
|
+
async fetch(req) {
|
|
63
|
+
expect(req.headers.get("authorization")).toBe("Bearer runtime-test-secret-1234567890");
|
|
64
|
+
expect(req.headers.get("x-agent-id")).toBe("agent-1");
|
|
65
|
+
|
|
66
|
+
const url = new URL(req.url);
|
|
67
|
+
if (req.method === "PUT" && url.pathname.startsWith("/api/kv/")) {
|
|
68
|
+
const key = decodeURIComponent(url.pathname.slice("/api/kv/".length));
|
|
69
|
+
const body = (await req.json()) as { value: unknown };
|
|
70
|
+
entries.set(key, body.value);
|
|
71
|
+
return Response.json({ key, value: body.value });
|
|
72
|
+
}
|
|
73
|
+
if (req.method === "GET" && url.pathname.startsWith("/api/kv/")) {
|
|
74
|
+
const key = decodeURIComponent(url.pathname.slice("/api/kv/".length));
|
|
75
|
+
return Response.json({ key, value: entries.get(key) ?? null });
|
|
76
|
+
}
|
|
77
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const output = await runScript({
|
|
83
|
+
agentId: "agent-1",
|
|
84
|
+
mcpBaseUrl: `http://127.0.0.1:${server.port}`,
|
|
85
|
+
resources,
|
|
86
|
+
source: `
|
|
87
|
+
export default async (_args, ctx) => {
|
|
88
|
+
await ctx.swarm.kv_set({ key: "bridge-smoke", value: { ok: true } });
|
|
89
|
+
return await ctx.swarm.kv_get({ key: "bridge-smoke" });
|
|
90
|
+
};
|
|
91
|
+
`,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(output.error).toBeUndefined();
|
|
95
|
+
expect(output.result).toEqual({
|
|
96
|
+
success: true,
|
|
97
|
+
status: 200,
|
|
98
|
+
data: { key: "bridge-smoke", value: { ok: true } },
|
|
99
|
+
});
|
|
100
|
+
} finally {
|
|
101
|
+
server.stop(true);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("bare stdlib imports resolve through runtime shims", async () => {
|
|
106
|
+
const output = await runScript({
|
|
107
|
+
agentId: "agent-1",
|
|
108
|
+
resources,
|
|
109
|
+
source: `
|
|
110
|
+
import { table } from "stdlib";
|
|
111
|
+
export default async () => table([{ a: 1 }]);
|
|
112
|
+
`,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(output.error).toBeUndefined();
|
|
116
|
+
expect(output.result).toContain("a");
|
|
117
|
+
expect(output.result).toContain("1");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("timeout kills a running script", async () => {
|
|
121
|
+
const started = Date.now();
|
|
122
|
+
const output = await runScript({
|
|
123
|
+
agentId: "agent-1",
|
|
124
|
+
timeoutMs: 150,
|
|
125
|
+
resources: { ...resources, wallClockMs: 150 },
|
|
126
|
+
source: "export default async () => new Promise(() => {});",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(output.error).toBe("timeout");
|
|
130
|
+
expect(Date.now() - started).toBeLessThan(1000);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("stdout is capped and marked truncated", async () => {
|
|
134
|
+
const output = await runScript({
|
|
135
|
+
agentId: "agent-1",
|
|
136
|
+
resources: { ...resources, maxStdoutBytes: 128 },
|
|
137
|
+
source: "export default async () => { console.log('x'.repeat(2048)); return 'ok'; };",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(output.result).toBe("ok");
|
|
141
|
+
expect(output.truncated.stdout).toBe(true);
|
|
142
|
+
expect(output.stdout.length).toBeLessThanOrEqual(128);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("AbortSignal aborts a running script", async () => {
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
setTimeout(() => controller.abort(), 50);
|
|
148
|
+
const started = Date.now();
|
|
149
|
+
|
|
150
|
+
const output = await runScript({
|
|
151
|
+
agentId: "agent-1",
|
|
152
|
+
signal: controller.signal,
|
|
153
|
+
resources,
|
|
154
|
+
source: "export default async () => new Promise(() => {});",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(output.error).toBe("killed");
|
|
158
|
+
expect(Date.now() - started).toBeLessThan(1000);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("subprocess env is stripped to the explicit allowlist", async () => {
|
|
162
|
+
process.env.API_KEY = "legacy-secret-that-must-not-leak";
|
|
163
|
+
process.env.AGENT_SWARM_API_KEY = "preferred-secret-that-must-not-leak";
|
|
164
|
+
refreshSecretScrubberCache();
|
|
165
|
+
|
|
166
|
+
const output = await runScript({
|
|
167
|
+
agentId: "agent-1",
|
|
168
|
+
resources,
|
|
169
|
+
source: `
|
|
170
|
+
export default async () => ({
|
|
171
|
+
keys: Object.keys(process.env).sort(),
|
|
172
|
+
apiKey: process.env.API_KEY,
|
|
173
|
+
agentSwarmApiKey: process.env.AGENT_SWARM_API_KEY,
|
|
174
|
+
});
|
|
175
|
+
`,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(output.error).toBeUndefined();
|
|
179
|
+
expect(output.result).toEqual({
|
|
180
|
+
keys: [
|
|
181
|
+
"HOME",
|
|
182
|
+
"LANG",
|
|
183
|
+
"LC_ALL",
|
|
184
|
+
"PATH",
|
|
185
|
+
"SWARM_SCRIPT_ARGS_FILE",
|
|
186
|
+
"SWARM_SCRIPT_RESULT_FILE",
|
|
187
|
+
"SWARM_SCRIPT_SOURCE_FILE",
|
|
188
|
+
"SWARM_SCRIPT_TMPDIR",
|
|
189
|
+
"TMPDIR",
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("workspace-rw is rejected in v1", async () => {
|
|
195
|
+
const output = await runScript({
|
|
196
|
+
agentId: "agent-1",
|
|
197
|
+
fsMode: "workspace-rw",
|
|
198
|
+
source: "export default async () => true;",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(output.error).toBe("executor_error");
|
|
202
|
+
expect(output.stderr).toContain("workspace-rw");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("SCRIPT_RUNTIME_DIR bundle path works (compiled binary mode regression)", async () => {
|
|
206
|
+
// Simulate compiled binary mode: pre-build bundles to a temp dir and set
|
|
207
|
+
// SCRIPT_RUNTIME_DIR so the executor uses them instead of import.meta.url paths.
|
|
208
|
+
const tmpdir = `${process.env.TMPDIR ?? "/tmp"}/script-runtime-test-${crypto.randomUUID()}`;
|
|
209
|
+
await Bun.$`mkdir -p ${tmpdir}`;
|
|
210
|
+
try {
|
|
211
|
+
const runtimeSrc = new URL("../scripts-runtime", import.meta.url).pathname;
|
|
212
|
+
await Bun.$`bun build ${runtimeSrc}/eval-harness.ts --target bun --no-splitting --outfile ${tmpdir}/eval-harness.bundle.js`.quiet();
|
|
213
|
+
await Bun.$`bun build ${runtimeSrc}/stdlib/index.ts --target bun --no-splitting --outfile ${tmpdir}/stdlib.bundle.js`.quiet();
|
|
214
|
+
await Bun.$`bun build ${runtimeSrc}/swarm-sdk.ts --target bun --no-splitting --outfile ${tmpdir}/swarm-sdk.bundle.js`.quiet();
|
|
215
|
+
|
|
216
|
+
process.env.SCRIPT_RUNTIME_DIR = tmpdir;
|
|
217
|
+
|
|
218
|
+
const output = await runScript({
|
|
219
|
+
agentId: "agent-1",
|
|
220
|
+
args: { x: 42 },
|
|
221
|
+
resources,
|
|
222
|
+
source: "export default async (args) => args.x * 2;",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(output.error).toBeUndefined();
|
|
226
|
+
expect(output.result).toBe(84);
|
|
227
|
+
expect(output.exitCode).toBe(0);
|
|
228
|
+
} finally {
|
|
229
|
+
delete process.env.SCRIPT_RUNTIME_DIR;
|
|
230
|
+
await Bun.$`rm -rf ${tmpdir}`;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("args arrives as a parsed object, not a JSON string", async () => {
|
|
235
|
+
// Regression: eval-harness must deliver a parsed object to user code even
|
|
236
|
+
// when the caller serializes args as a JSON string (double-serialization).
|
|
237
|
+
// Before the fix, property access like args.foo would always be undefined.
|
|
238
|
+
const output = await runScript({
|
|
239
|
+
agentId: "agent-1",
|
|
240
|
+
args: { foo: "bar" },
|
|
241
|
+
resources,
|
|
242
|
+
source: `
|
|
243
|
+
export default async (args) => {
|
|
244
|
+
if (typeof args !== "object" || args === null) throw new Error("args is not an object: " + typeof args);
|
|
245
|
+
if (args.foo !== "bar") throw new Error("args.foo expected 'bar', got: " + args.foo);
|
|
246
|
+
return { ok: true, foo: args.foo };
|
|
247
|
+
};
|
|
248
|
+
`,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(output.error).toBeUndefined();
|
|
252
|
+
expect(output.result).toEqual({ ok: true, foo: "bar" });
|
|
253
|
+
expect(output.exitCode).toBe(0);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("args parsed correctly in compiled binary mode (SCRIPT_RUNTIME_DIR)", async () => {
|
|
257
|
+
// Same regression exercised through the compiled-binary (SCRIPT_RUNTIME_DIR) code path.
|
|
258
|
+
const tmpdir = `${process.env.TMPDIR ?? "/tmp"}/script-runtime-test-${crypto.randomUUID()}`;
|
|
259
|
+
await Bun.$`mkdir -p ${tmpdir}`;
|
|
260
|
+
try {
|
|
261
|
+
const runtimeSrc = new URL("../scripts-runtime", import.meta.url).pathname;
|
|
262
|
+
await Bun.$`bun build ${runtimeSrc}/eval-harness.ts --target bun --no-splitting --outfile ${tmpdir}/eval-harness.bundle.js`.quiet();
|
|
263
|
+
await Bun.$`bun build ${runtimeSrc}/stdlib/index.ts --target bun --no-splitting --outfile ${tmpdir}/stdlib.bundle.js`.quiet();
|
|
264
|
+
await Bun.$`bun build ${runtimeSrc}/swarm-sdk.ts --target bun --no-splitting --outfile ${tmpdir}/swarm-sdk.bundle.js`.quiet();
|
|
265
|
+
|
|
266
|
+
process.env.SCRIPT_RUNTIME_DIR = tmpdir;
|
|
267
|
+
|
|
268
|
+
const output = await runScript({
|
|
269
|
+
agentId: "agent-1",
|
|
270
|
+
args: { foo: "bar" },
|
|
271
|
+
resources,
|
|
272
|
+
source: `
|
|
273
|
+
export default async (args) => {
|
|
274
|
+
if (typeof args !== "object" || args === null) throw new Error("args is not an object: " + typeof args);
|
|
275
|
+
if (args.foo !== "bar") throw new Error("args.foo expected 'bar', got: " + args.foo);
|
|
276
|
+
return { ok: true, foo: args.foo };
|
|
277
|
+
};
|
|
278
|
+
`,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(output.error).toBeUndefined();
|
|
282
|
+
expect(output.result).toEqual({ ok: true, foo: "bar" });
|
|
283
|
+
expect(output.exitCode).toBe(0);
|
|
284
|
+
} finally {
|
|
285
|
+
delete process.env.SCRIPT_RUNTIME_DIR;
|
|
286
|
+
await Bun.$`rm -rf ${tmpdir}`;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("argsSchema rejects invalid args with a formatted Zod error", async () => {
|
|
291
|
+
const output = await runScript({
|
|
292
|
+
agentId: "agent-1",
|
|
293
|
+
args: {},
|
|
294
|
+
resources,
|
|
295
|
+
source: `
|
|
296
|
+
import { z } from "zod";
|
|
297
|
+
export const argsSchema = z.object({
|
|
298
|
+
repo: z.string(),
|
|
299
|
+
});
|
|
300
|
+
export default async (args: z.infer<typeof argsSchema>) => ({ repo: args.repo });
|
|
301
|
+
`,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
expect(output.error).toBeDefined();
|
|
305
|
+
expect(output.exitCode).not.toBe(0);
|
|
306
|
+
expect(output.stderr).toContain("argsSchema validation failed");
|
|
307
|
+
expect(output.stderr).toContain("repo");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("argsSchema applies .default() values when fields are omitted", async () => {
|
|
311
|
+
const output = await runScript({
|
|
312
|
+
agentId: "agent-1",
|
|
313
|
+
args: { repo: "owner/name" },
|
|
314
|
+
resources,
|
|
315
|
+
source: `
|
|
316
|
+
import { z } from "zod";
|
|
317
|
+
export const argsSchema = z.object({
|
|
318
|
+
repo: z.string(),
|
|
319
|
+
limit: z.number().default(10),
|
|
320
|
+
});
|
|
321
|
+
export default async (args: z.infer<typeof argsSchema>) => ({ repo: args.repo, limit: args.limit });
|
|
322
|
+
`,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
expect(output.error).toBeUndefined();
|
|
326
|
+
expect(output.result).toEqual({ repo: "owner/name", limit: 10 });
|
|
327
|
+
expect(output.exitCode).toBe(0);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("script without argsSchema still works (backward-compat)", async () => {
|
|
331
|
+
const output = await runScript({
|
|
332
|
+
agentId: "agent-1",
|
|
333
|
+
args: { value: 42 },
|
|
334
|
+
resources,
|
|
335
|
+
source: `
|
|
336
|
+
export default async (args: { value: number }) => ({ doubled: args.value * 2 });
|
|
337
|
+
`,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
expect(output.error).toBeUndefined();
|
|
341
|
+
expect(output.result).toEqual({ doubled: 84 });
|
|
342
|
+
expect(output.exitCode).toBe(0);
|
|
343
|
+
});
|
|
344
|
+
});
|