@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
@@ -541,8 +541,29 @@ class ClaudeSession implements ProviderSession {
541
541
  // Tool use from assistant messages — emit tool_start for auto-progress
542
542
  if (json.type === "assistant" && json.message) {
543
543
  const message = json.message as {
544
- content?: Array<{ type: string; name?: string; id?: string; input?: unknown }>;
544
+ content?: Array<{
545
+ type: string;
546
+ name?: string;
547
+ id?: string;
548
+ input?: unknown;
549
+ text?: string;
550
+ }>;
545
551
  };
552
+
553
+ // Emit a `message` event BEFORE any tool_start events for this turn.
554
+ // The runner uses this as an "assistant turn boundary" to implicit-close
555
+ // any worker.tool spans left open by the previous turn (the Claude CLI
556
+ // doesn't emit per-tool completion events for harness-side tools like
557
+ // Bash/Read/Edit, so without this boundary their spans would stay open
558
+ // until session shutdown and report inflated duration_ms).
559
+ const text = Array.isArray(message.content)
560
+ ? message.content
561
+ .filter((b) => b.type === "text" && typeof b.text === "string")
562
+ .map((b) => b.text as string)
563
+ .join("")
564
+ : "";
565
+ this.emit({ type: "message", role: "assistant", content: text });
566
+
546
567
  if (message.content) {
547
568
  for (const block of message.content) {
548
569
  if (block.type === "tool_use" && block.name) {
@@ -0,0 +1,23 @@
1
+ import { stdlib } from "./stdlib";
2
+ import type { SwarmConfig } from "./swarm-config";
3
+ import { createSwarmSdk } from "./swarm-sdk";
4
+
5
+ export type RuntimeCtx = {
6
+ swarm: Record<string, unknown> & { config: SwarmConfig };
7
+ stdlib: typeof stdlib;
8
+ logger: {
9
+ log: (...args: unknown[]) => void;
10
+ warn: (...args: unknown[]) => void;
11
+ error: (...args: unknown[]) => void;
12
+ };
13
+ };
14
+
15
+ export function buildCtx({ swarmConfig }: { swarmConfig: SwarmConfig }): RuntimeCtx {
16
+ const swarm = createSwarmSdk(swarmConfig) as Record<string, unknown> & { config: SwarmConfig };
17
+ swarm.config = swarmConfig;
18
+ return {
19
+ swarm,
20
+ stdlib,
21
+ logger: console,
22
+ };
23
+ }
@@ -0,0 +1,39 @@
1
+ import { buildCtx } from "./ctx";
2
+ import type { SwarmConfigPayload } from "./executors/types";
3
+ import { SwarmConfig } from "./swarm-config";
4
+
5
+ function requiredEnv(name: string): string {
6
+ const value = process.env[name];
7
+ if (!value) throw new Error(`Missing required env ${name}`);
8
+ return value;
9
+ }
10
+
11
+ try {
12
+ const stdin = await Bun.stdin.text();
13
+ if (!stdin.trim()) {
14
+ console.error("Swarm script config payload was empty");
15
+ process.exit(2);
16
+ }
17
+
18
+ const payload = JSON.parse(stdin) as SwarmConfigPayload;
19
+ const swarmConfig = new SwarmConfig(payload);
20
+ const rawArgs = JSON.parse(await Bun.file(requiredEnv("SWARM_SCRIPT_ARGS_FILE")).text());
21
+ // Accept both shapes: callers may pass an already-serialized JSON string.
22
+ const parsedArgs = typeof rawArgs === "string" ? JSON.parse(rawArgs) : rawArgs;
23
+ const ctx = buildCtx({ swarmConfig });
24
+
25
+ const sourceText = await Bun.file(requiredEnv("SWARM_SCRIPT_SOURCE_FILE")).text();
26
+ const userModulePath = `${requiredEnv("SWARM_SCRIPT_TMPDIR")}/user-script.ts`;
27
+ await Bun.write(userModulePath, sourceText);
28
+
29
+ const mod = await import(userModulePath);
30
+ if (typeof mod.default !== "function") {
31
+ throw new Error("Swarm script must export a default function");
32
+ }
33
+
34
+ const result = await mod.default(parsedArgs, ctx);
35
+ await Bun.write(requiredEnv("SWARM_SCRIPT_RESULT_FILE"), JSON.stringify(result ?? null));
36
+ } catch (error) {
37
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
38
+ process.exit(1);
39
+ }
@@ -0,0 +1,229 @@
1
+ import type { ExecutorInput, ExecutorOutput, ScriptExecutor, ScriptExecutorError } from "./types";
2
+
3
+ type CappedText = { text: string; truncated: boolean };
4
+
5
+ function makeUnsupportedOutput(stderr: string): ExecutorOutput {
6
+ return {
7
+ result: undefined,
8
+ stdout: "",
9
+ stderr,
10
+ truncated: { stdout: false, stderr: false },
11
+ durationMs: 0,
12
+ exitCode: 1,
13
+ error: "executor_error",
14
+ };
15
+ }
16
+
17
+ async function readCapped(
18
+ stream: ReadableStream<Uint8Array> | null,
19
+ maxBytes: number,
20
+ ): Promise<CappedText> {
21
+ if (!stream) return { text: "", truncated: false };
22
+ const reader = stream.getReader();
23
+ const chunks: Uint8Array[] = [];
24
+ let total = 0;
25
+ let truncated = false;
26
+
27
+ while (true) {
28
+ const { done, value } = await reader.read();
29
+ if (done) break;
30
+ if (!value) continue;
31
+
32
+ const remaining = maxBytes - total;
33
+ if (remaining > 0) {
34
+ const accepted = value.byteLength > remaining ? value.slice(0, remaining) : value;
35
+ chunks.push(accepted);
36
+ total += accepted.byteLength;
37
+ }
38
+ if (value.byteLength > remaining) truncated = true;
39
+ }
40
+
41
+ return { text: new TextDecoder().decode(Buffer.concat(chunks)), truncated };
42
+ }
43
+
44
+ function classifyExit(
45
+ exitCode: number,
46
+ timedOut: boolean,
47
+ killed: boolean,
48
+ ): ScriptExecutorError | undefined {
49
+ if (timedOut) return "timeout";
50
+ if (killed) return "killed";
51
+ if (exitCode === 0) return undefined;
52
+ if (exitCode === 137 || exitCode === 9) return "killed";
53
+ return "eval_error";
54
+ }
55
+
56
+ async function readResultFile(path: string): Promise<unknown | undefined> {
57
+ const file = Bun.file(path);
58
+ if (!(await file.exists())) return undefined;
59
+ const text = await file.text();
60
+ if (!text) return undefined;
61
+ return JSON.parse(text);
62
+ }
63
+
64
+ async function writeBareImportShim(tmpdir: string, name: string, targetUrl: URL): Promise<void> {
65
+ const dir = `${tmpdir}/node_modules/${name}`;
66
+ await Bun.$`mkdir -p ${dir}`;
67
+ await Bun.write(`${dir}/package.json`, JSON.stringify({ type: "module", main: "index.ts" }));
68
+ await Bun.write(`${dir}/index.ts`, `export * from ${JSON.stringify(targetUrl.href)};\n`);
69
+ }
70
+
71
+ async function writeBareImportShims(tmpdir: string): Promise<void> {
72
+ const runtimeDir = process.env.SCRIPT_RUNTIME_DIR;
73
+ if (runtimeDir) {
74
+ // Compiled binary mode: use pre-built bundles on real filesystem.
75
+ // import.meta.url resolves to /$bunfs/ which spawned subprocesses can't access.
76
+ const shims: [string, string][] = [
77
+ ["stdlib", `${runtimeDir}/stdlib.bundle.js`],
78
+ ["swarm-sdk", `${runtimeDir}/swarm-sdk.bundle.js`],
79
+ ];
80
+ for (const [name, bundlePath] of shims) {
81
+ const dir = `${tmpdir}/node_modules/${name}`;
82
+ await Bun.$`mkdir -p ${dir}`;
83
+ await Bun.write(`${dir}/package.json`, JSON.stringify({ type: "module", main: "index.js" }));
84
+ await Bun.write(
85
+ `${dir}/index.js`,
86
+ `export * from ${JSON.stringify(`file://${bundlePath}`)};\n`,
87
+ );
88
+ }
89
+ return;
90
+ }
91
+ await writeBareImportShim(tmpdir, "stdlib", new URL("../stdlib/index.ts", import.meta.url));
92
+ await writeBareImportShim(tmpdir, "swarm-sdk", new URL("../swarm-sdk.ts", import.meta.url));
93
+ }
94
+
95
+ function harnessCommand(harnessPath: string, input: ExecutorInput): string[] {
96
+ if (process.platform === "win32") {
97
+ return ["bun", "run", harnessPath];
98
+ }
99
+
100
+ // Bun's Linux runtime reserves several GB of virtual address space at startup.
101
+ // A lower RLIMIT_AS kills the harness before user code runs, so keep vmem as
102
+ // a coarse guard and rely on the tighter CPU/proc/fd/file/output caps for v1.
103
+ const virtualMemoryMb = Math.max(input.resources.memoryMb, 4096);
104
+ const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
105
+ const ulimits = [
106
+ `ulimit -v ${Math.floor(virtualMemoryMb * 1024)} 2>/dev/null || true`,
107
+ `ulimit -t ${input.resources.cpuTimeSec} 2>/dev/null || true`,
108
+ `ulimit -u ${input.resources.maxProcs} 2>/dev/null || true`,
109
+ `ulimit -f ${Math.floor(input.resources.maxFileBytes / 1024)} 2>/dev/null || true`,
110
+ `ulimit -n ${input.resources.maxFdCount} 2>/dev/null || true`,
111
+ ].join("; ");
112
+ const harness = shellQuote(harnessPath);
113
+ return [
114
+ "sh",
115
+ "-c",
116
+ `${ulimits}; exec env -i PATH="$PATH" HOME="$HOME" LANG="$LANG" LC_ALL="$LC_ALL" TMPDIR="$TMPDIR" SWARM_SCRIPT_TMPDIR="$SWARM_SCRIPT_TMPDIR" SWARM_SCRIPT_ARGS_FILE="$SWARM_SCRIPT_ARGS_FILE" SWARM_SCRIPT_SOURCE_FILE="$SWARM_SCRIPT_SOURCE_FILE" SWARM_SCRIPT_RESULT_FILE="$SWARM_SCRIPT_RESULT_FILE" bun run ${harness}`,
117
+ ];
118
+ }
119
+
120
+ export class NativeScriptExecutor implements ScriptExecutor {
121
+ readonly name = "native";
122
+
123
+ async run(input: ExecutorInput): Promise<ExecutorOutput> {
124
+ if (input.fsMode === "workspace-rw") {
125
+ return makeUnsupportedOutput("workspace-rw not supported by native executor in v1");
126
+ }
127
+
128
+ const start = Date.now();
129
+ const tmpdir = `${process.env.TMPDIR ?? "/tmp"}/swarm-script-${crypto.randomUUID()}`;
130
+ await Bun.$`mkdir -p ${tmpdir}`;
131
+
132
+ const argsFile = `${tmpdir}/args.json`;
133
+ const sourceFile = `${tmpdir}/source.ts`;
134
+ const resultFile = `${tmpdir}/result.json`;
135
+ // In compiled binary mode, import.meta.url points into /$bunfs/ which spawned
136
+ // subprocesses cannot access. Use the pre-built bundle from real filesystem instead.
137
+ const harnessPath = process.env.SCRIPT_RUNTIME_DIR
138
+ ? `${process.env.SCRIPT_RUNTIME_DIR}/eval-harness.bundle.js`
139
+ : new URL("../eval-harness.ts", import.meta.url).pathname;
140
+ const controller = new AbortController();
141
+ let timedOut = false;
142
+ let killed = input.signal?.aborted ?? false;
143
+ let removeAbortListener: (() => void) | undefined;
144
+
145
+ try {
146
+ if (killed) {
147
+ return {
148
+ result: undefined,
149
+ stdout: "",
150
+ stderr: "",
151
+ truncated: { stdout: false, stderr: false },
152
+ durationMs: Date.now() - start,
153
+ exitCode: 1,
154
+ error: "killed",
155
+ };
156
+ }
157
+
158
+ await Bun.write(argsFile, JSON.stringify(input.args ?? null));
159
+ await Bun.write(sourceFile, input.source);
160
+ await writeBareImportShims(tmpdir);
161
+
162
+ const onExternalAbort = () => {
163
+ killed = true;
164
+ controller.abort();
165
+ };
166
+ input.signal?.addEventListener("abort", onExternalAbort, { once: true });
167
+ removeAbortListener = () => input.signal?.removeEventListener("abort", onExternalAbort);
168
+
169
+ const timeout = setTimeout(() => {
170
+ timedOut = true;
171
+ controller.abort();
172
+ }, input.resources.wallClockMs);
173
+
174
+ const proc = Bun.spawn(harnessCommand(harnessPath, input), {
175
+ env: {
176
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
177
+ HOME: process.env.HOME ?? "/tmp",
178
+ LANG: process.env.LANG ?? "C.UTF-8",
179
+ LC_ALL: process.env.LC_ALL ?? "C.UTF-8",
180
+ TMPDIR: tmpdir,
181
+ SWARM_SCRIPT_TMPDIR: tmpdir,
182
+ SWARM_SCRIPT_ARGS_FILE: argsFile,
183
+ SWARM_SCRIPT_SOURCE_FILE: sourceFile,
184
+ SWARM_SCRIPT_RESULT_FILE: resultFile,
185
+ },
186
+ cwd: tmpdir,
187
+ stdin: "pipe",
188
+ stdout: "pipe",
189
+ stderr: "pipe",
190
+ signal: controller.signal,
191
+ });
192
+
193
+ proc.stdin.write(JSON.stringify(input.configPayload));
194
+ proc.stdin.end();
195
+
196
+ const [stdout, stderr, exitCode] = await Promise.all([
197
+ readCapped(proc.stdout, input.resources.maxStdoutBytes),
198
+ readCapped(proc.stderr, input.resources.maxStdoutBytes),
199
+ proc.exited.catch(() => (timedOut ? 124 : 1)),
200
+ ]).finally(() => clearTimeout(timeout));
201
+
202
+ const result = exitCode === 0 ? await readResultFile(resultFile) : undefined;
203
+ const error = classifyExit(exitCode, timedOut, killed);
204
+
205
+ return {
206
+ result,
207
+ stdout: stdout.text,
208
+ stderr: stderr.text,
209
+ truncated: { stdout: stdout.truncated, stderr: stderr.truncated },
210
+ durationMs: Date.now() - start,
211
+ exitCode,
212
+ ...(error ? { error } : {}),
213
+ };
214
+ } catch (error) {
215
+ return {
216
+ result: undefined,
217
+ stdout: "",
218
+ stderr: error instanceof Error ? error.message : String(error),
219
+ truncated: { stdout: false, stderr: false },
220
+ durationMs: Date.now() - start,
221
+ exitCode: 1,
222
+ error: timedOut ? "timeout" : killed ? "killed" : "executor_error",
223
+ };
224
+ } finally {
225
+ removeAbortListener?.();
226
+ await Bun.$`rm -rf ${tmpdir}`;
227
+ }
228
+ }
229
+ }
@@ -0,0 +1,16 @@
1
+ import { NativeScriptExecutor } from "./native";
2
+ import type { ScriptExecutor } from "./types";
3
+
4
+ const EXECUTORS: Record<string, () => ScriptExecutor> = {
5
+ native: () => new NativeScriptExecutor(),
6
+ };
7
+
8
+ export function getScriptExecutor(name = process.env.SCRIPT_EXECUTOR ?? "native"): ScriptExecutor {
9
+ const factory = EXECUTORS[name];
10
+ if (!factory) {
11
+ throw new Error(
12
+ `Unknown SCRIPT_EXECUTOR: ${name}. Available: ${Object.keys(EXECUTORS).join(", ")}`,
13
+ );
14
+ }
15
+ return factory();
16
+ }
@@ -0,0 +1,63 @@
1
+ export type ScriptFsMode = "none" | "workspace-rw";
2
+
3
+ export type SwarmConfigPayload = {
4
+ system: {
5
+ apiKey: { value: string; isSecret: true };
6
+ agentId: { value: string; isSecret: false };
7
+ mcpBaseUrl: { value: string; isSecret: false };
8
+ };
9
+ user: Record<string, { value: string; isSecret: boolean }>;
10
+ };
11
+
12
+ export type ScriptResourcePolicy = {
13
+ memoryMb: number;
14
+ cpuTimeSec: number;
15
+ wallClockMs: number;
16
+ maxProcs: number;
17
+ maxFdCount: number;
18
+ maxFileBytes: number;
19
+ maxStdoutBytes: number;
20
+ };
21
+
22
+ export type ExecutorInput = {
23
+ source: string;
24
+ args: unknown;
25
+ configPayload: SwarmConfigPayload;
26
+ resources: ScriptResourcePolicy;
27
+ fsMode: ScriptFsMode;
28
+ network: "open" | { allowlist: string[] };
29
+ signal?: AbortSignal;
30
+ };
31
+
32
+ export type ScriptExecutorError =
33
+ | "timeout"
34
+ | "oom"
35
+ | "killed"
36
+ | "import_violation"
37
+ | "eval_error"
38
+ | "executor_error";
39
+
40
+ export type ExecutorOutput = {
41
+ result: unknown | undefined;
42
+ stdout: string;
43
+ stderr: string;
44
+ truncated: { stdout: boolean; stderr: boolean };
45
+ durationMs: number;
46
+ exitCode: number;
47
+ error?: ScriptExecutorError;
48
+ };
49
+
50
+ export interface ScriptExecutor {
51
+ readonly name: string;
52
+ run(input: ExecutorInput): Promise<ExecutorOutput>;
53
+ }
54
+
55
+ export const DEFAULT_SCRIPT_RESOURCES: ScriptResourcePolicy = {
56
+ memoryMb: 512,
57
+ cpuTimeSec: 60,
58
+ wallClockMs: 30_000,
59
+ maxProcs: 32,
60
+ maxFdCount: 64,
61
+ maxFileBytes: 64_000_000,
62
+ maxStdoutBytes: 1_048_576,
63
+ };
@@ -0,0 +1,81 @@
1
+ import ts from "typescript";
2
+
3
+ export type ScriptSignature = {
4
+ argsType: string;
5
+ resultType: string;
6
+ description: string;
7
+ };
8
+
9
+ const FALLBACK_SIGNATURE: ScriptSignature = {
10
+ argsType: "unknown",
11
+ resultType: "unknown",
12
+ description: "",
13
+ };
14
+
15
+ function getJsDocDescription(node: ts.Node, sourceFile: ts.SourceFile): string {
16
+ const comments = ts.getJSDocCommentsAndTags(node);
17
+ const comment = comments.find(ts.isJSDoc);
18
+ if (!comment?.comment) return "";
19
+ if (typeof comment.comment === "string") return comment.comment;
20
+ return comment.comment.map((part) => part.getText(sourceFile)).join("");
21
+ }
22
+
23
+ function unwrapPromise(typeText: string): string {
24
+ const trimmed = typeText.trim();
25
+ if (trimmed.startsWith("Promise<") && trimmed.endsWith(">")) {
26
+ return trimmed.slice("Promise<".length, -1).trim();
27
+ }
28
+ return trimmed;
29
+ }
30
+
31
+ function fromFunctionLike(
32
+ node: ts.FunctionLikeDeclarationBase,
33
+ sourceFile: ts.SourceFile,
34
+ ): ScriptSignature {
35
+ const [firstParam] = node.parameters;
36
+ return {
37
+ argsType: firstParam?.type?.getText(sourceFile) ?? "unknown",
38
+ resultType: unwrapPromise(node.type?.getText(sourceFile) ?? "unknown"),
39
+ description: getJsDocDescription(node, sourceFile),
40
+ };
41
+ }
42
+
43
+ function isExportDefault(node: ts.Node): boolean {
44
+ return (
45
+ ts.canHaveModifiers(node) &&
46
+ (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)
47
+ );
48
+ }
49
+
50
+ export function extractScriptSignature(source: string): ScriptSignature {
51
+ try {
52
+ const sourceFile = ts.createSourceFile(
53
+ "user-script.ts",
54
+ source,
55
+ ts.ScriptTarget.Latest,
56
+ true,
57
+ ts.ScriptKind.TS,
58
+ );
59
+ const parseDiagnostics = (
60
+ sourceFile as ts.SourceFile & { parseDiagnostics?: readonly ts.Diagnostic[] }
61
+ ).parseDiagnostics;
62
+ if (parseDiagnostics && parseDiagnostics.length > 0) return FALLBACK_SIGNATURE;
63
+
64
+ for (const statement of sourceFile.statements) {
65
+ if (ts.isFunctionDeclaration(statement) && isExportDefault(statement)) {
66
+ return fromFunctionLike(statement, sourceFile);
67
+ }
68
+
69
+ if (ts.isExportAssignment(statement)) {
70
+ const expression = statement.expression;
71
+ if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) {
72
+ return fromFunctionLike(expression, sourceFile);
73
+ }
74
+ }
75
+ }
76
+ } catch {
77
+ return FALLBACK_SIGNATURE;
78
+ }
79
+
80
+ return FALLBACK_SIGNATURE;
81
+ }
@@ -0,0 +1,109 @@
1
+ import ts from "typescript";
2
+
3
+ const ALLOWED_BARE_IMPORTS = new Set(["swarm-sdk", "stdlib"]);
4
+ const FORBIDDEN_HINTS = new Set(["node:", "bun:", "fs", "child_process", "crypto", "bun:sqlite"]);
5
+
6
+ export type ImportAllowlistResult =
7
+ | { ok: true }
8
+ | { ok: false; diagnostic: string; imports: string[] };
9
+
10
+ function isRelative(specifier: string): boolean {
11
+ return specifier.startsWith("./") || specifier.startsWith("../");
12
+ }
13
+
14
+ function isAllowed(specifier: string): boolean {
15
+ return isRelative(specifier) || ALLOWED_BARE_IMPORTS.has(specifier);
16
+ }
17
+
18
+ function collectImportSpecifiers(source: string): string[] {
19
+ const sourceFile = ts.createSourceFile(
20
+ "user-script.ts",
21
+ source,
22
+ ts.ScriptTarget.Latest,
23
+ true,
24
+ ts.ScriptKind.TS,
25
+ );
26
+ const imports: string[] = [];
27
+
28
+ const visit = (node: ts.Node) => {
29
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
30
+ const moduleSpecifier = node.moduleSpecifier;
31
+ if (moduleSpecifier && ts.isStringLiteral(moduleSpecifier))
32
+ imports.push(moduleSpecifier.text);
33
+ }
34
+
35
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
36
+ const [arg] = node.arguments;
37
+ if (arg && ts.isStringLiteral(arg)) imports.push(arg.text);
38
+ }
39
+
40
+ ts.forEachChild(node, visit);
41
+ };
42
+
43
+ visit(sourceFile);
44
+ return imports;
45
+ }
46
+
47
+ function findForbiddenDynamicCode(source: string): string | null {
48
+ const sourceFile = ts.createSourceFile(
49
+ "user-script.ts",
50
+ source,
51
+ ts.ScriptTarget.Latest,
52
+ true,
53
+ ts.ScriptKind.TS,
54
+ );
55
+ let diagnostic: string | null = null;
56
+
57
+ const visit = (node: ts.Node) => {
58
+ if (diagnostic) return;
59
+
60
+ if (
61
+ ts.isCallExpression(node) &&
62
+ node.expression.kind === ts.SyntaxKind.Identifier &&
63
+ node.expression.getText(sourceFile) === "eval"
64
+ ) {
65
+ diagnostic = "eval is not allowed in swarm scripts";
66
+ return;
67
+ }
68
+
69
+ if (
70
+ ts.isNewExpression(node) &&
71
+ node.expression.kind === ts.SyntaxKind.Identifier &&
72
+ node.expression.getText(sourceFile) === "Function"
73
+ ) {
74
+ diagnostic = "Function constructor is not allowed in swarm scripts";
75
+ return;
76
+ }
77
+
78
+ if (
79
+ ts.isCallExpression(node) &&
80
+ node.expression.kind === ts.SyntaxKind.Identifier &&
81
+ node.expression.getText(sourceFile) === "Function"
82
+ ) {
83
+ diagnostic = "Function constructor is not allowed in swarm scripts";
84
+ return;
85
+ }
86
+
87
+ ts.forEachChild(node, visit);
88
+ };
89
+
90
+ visit(sourceFile);
91
+ return diagnostic;
92
+ }
93
+
94
+ export function validateScriptImports(source: string): ImportAllowlistResult {
95
+ const dynamicDiagnostic = findForbiddenDynamicCode(source);
96
+ if (dynamicDiagnostic) return { ok: false, diagnostic: dynamicDiagnostic, imports: [] };
97
+
98
+ const imports = collectImportSpecifiers(source);
99
+ const rejected = imports.filter((specifier) => !isAllowed(specifier));
100
+ if (rejected.length === 0) return { ok: true };
101
+
102
+ const hint = rejected.find(
103
+ (specifier) => FORBIDDEN_HINTS.has(specifier) || specifier.startsWith("node:"),
104
+ );
105
+ const reason = hint
106
+ ? `Import '${hint}' is not allowed in swarm scripts`
107
+ : `Import '${rejected[0]}' is not on the swarm script allowlist`;
108
+ return { ok: false, diagnostic: reason, imports: rejected };
109
+ }
@@ -0,0 +1,96 @@
1
+ import { getApiKey } from "../utils/api-key";
2
+ import { scrubObject, scrubSecrets } from "../utils/secret-scrubber";
3
+ import { getScriptExecutor } from "./executors/registry";
4
+ import {
5
+ DEFAULT_SCRIPT_RESOURCES,
6
+ type ExecutorOutput,
7
+ type ScriptFsMode,
8
+ type ScriptResourcePolicy,
9
+ type SwarmConfigPayload,
10
+ } from "./executors/types";
11
+ import { validateScriptImports } from "./import-allowlist";
12
+
13
+ export type RunScriptInput = {
14
+ source: string;
15
+ args?: unknown;
16
+ fsMode?: ScriptFsMode;
17
+ agentId: string;
18
+ signal?: AbortSignal;
19
+ timeoutMs?: number;
20
+ mcpBaseUrl?: string;
21
+ resources?: Partial<ScriptResourcePolicy>;
22
+ userConfig?: Record<string, { value: string; isSecret: boolean }>;
23
+ };
24
+
25
+ export type RunScriptOutput = Omit<ExecutorOutput, "result" | "stdout" | "stderr"> & {
26
+ result: unknown | undefined;
27
+ stdout: string;
28
+ stderr: string;
29
+ };
30
+
31
+ function buildConfigPayload(input: RunScriptInput): SwarmConfigPayload {
32
+ const apiKey = getApiKey();
33
+ if (!apiKey) throw new Error("Swarm API key is required to run scripts");
34
+
35
+ return {
36
+ system: {
37
+ apiKey: { value: apiKey, isSecret: true },
38
+ agentId: { value: input.agentId, isSecret: false },
39
+ mcpBaseUrl: {
40
+ value: input.mcpBaseUrl ?? process.env.MCP_BASE_URL ?? "http://localhost:3013",
41
+ isSecret: false,
42
+ },
43
+ },
44
+ user: input.userConfig ?? {},
45
+ };
46
+ }
47
+
48
+ export async function runScript(input: RunScriptInput): Promise<RunScriptOutput> {
49
+ if (input.fsMode === "workspace-rw") {
50
+ return {
51
+ result: undefined,
52
+ stdout: "",
53
+ stderr: "workspace-rw not supported in scripts-runtime v1",
54
+ truncated: { stdout: false, stderr: false },
55
+ durationMs: 0,
56
+ exitCode: 1,
57
+ error: "executor_error",
58
+ };
59
+ }
60
+
61
+ const imports = validateScriptImports(input.source);
62
+ if (!imports.ok) {
63
+ return {
64
+ result: undefined,
65
+ stdout: "",
66
+ stderr: imports.diagnostic,
67
+ truncated: { stdout: false, stderr: false },
68
+ durationMs: 0,
69
+ exitCode: 1,
70
+ error: "import_violation",
71
+ };
72
+ }
73
+
74
+ const resources = {
75
+ ...DEFAULT_SCRIPT_RESOURCES,
76
+ ...input.resources,
77
+ ...(input.timeoutMs ? { wallClockMs: input.timeoutMs } : {}),
78
+ };
79
+
80
+ const output = await getScriptExecutor().run({
81
+ source: input.source,
82
+ args: input.args ?? null,
83
+ configPayload: buildConfigPayload(input),
84
+ resources,
85
+ fsMode: input.fsMode ?? "none",
86
+ network: "open",
87
+ signal: input.signal,
88
+ });
89
+
90
+ return {
91
+ ...output,
92
+ result: scrubObject(output.result),
93
+ stdout: scrubSecrets(output.stdout),
94
+ stderr: scrubSecrets(output.stderr),
95
+ };
96
+ }