@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,232 @@
|
|
|
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
|
+
// Allow `import { z } from "zod"` in user scripts (for argsSchema definitions).
|
|
94
|
+
const zodEntry = Bun.resolveSync("zod", import.meta.dir);
|
|
95
|
+
await writeBareImportShim(tmpdir, "zod", new URL(`file://${zodEntry}`));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function harnessCommand(harnessPath: string, input: ExecutorInput): string[] {
|
|
99
|
+
if (process.platform === "win32") {
|
|
100
|
+
return ["bun", "run", harnessPath];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Bun's Linux runtime reserves several GB of virtual address space at startup.
|
|
104
|
+
// A lower RLIMIT_AS kills the harness before user code runs, so keep vmem as
|
|
105
|
+
// a coarse guard and rely on the tighter CPU/proc/fd/file/output caps for v1.
|
|
106
|
+
const virtualMemoryMb = Math.max(input.resources.memoryMb, 4096);
|
|
107
|
+
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
108
|
+
const ulimits = [
|
|
109
|
+
`ulimit -v ${Math.floor(virtualMemoryMb * 1024)} 2>/dev/null || true`,
|
|
110
|
+
`ulimit -t ${input.resources.cpuTimeSec} 2>/dev/null || true`,
|
|
111
|
+
`ulimit -u ${input.resources.maxProcs} 2>/dev/null || true`,
|
|
112
|
+
`ulimit -f ${Math.floor(input.resources.maxFileBytes / 1024)} 2>/dev/null || true`,
|
|
113
|
+
`ulimit -n ${input.resources.maxFdCount} 2>/dev/null || true`,
|
|
114
|
+
].join("; ");
|
|
115
|
+
const harness = shellQuote(harnessPath);
|
|
116
|
+
return [
|
|
117
|
+
"sh",
|
|
118
|
+
"-c",
|
|
119
|
+
`${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}`,
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class NativeScriptExecutor implements ScriptExecutor {
|
|
124
|
+
readonly name = "native";
|
|
125
|
+
|
|
126
|
+
async run(input: ExecutorInput): Promise<ExecutorOutput> {
|
|
127
|
+
if (input.fsMode === "workspace-rw") {
|
|
128
|
+
return makeUnsupportedOutput("workspace-rw not supported by native executor in v1");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const start = Date.now();
|
|
132
|
+
const tmpdir = `${process.env.TMPDIR ?? "/tmp"}/swarm-script-${crypto.randomUUID()}`;
|
|
133
|
+
await Bun.$`mkdir -p ${tmpdir}`;
|
|
134
|
+
|
|
135
|
+
const argsFile = `${tmpdir}/args.json`;
|
|
136
|
+
const sourceFile = `${tmpdir}/source.ts`;
|
|
137
|
+
const resultFile = `${tmpdir}/result.json`;
|
|
138
|
+
// In compiled binary mode, import.meta.url points into /$bunfs/ which spawned
|
|
139
|
+
// subprocesses cannot access. Use the pre-built bundle from real filesystem instead.
|
|
140
|
+
const harnessPath = process.env.SCRIPT_RUNTIME_DIR
|
|
141
|
+
? `${process.env.SCRIPT_RUNTIME_DIR}/eval-harness.bundle.js`
|
|
142
|
+
: new URL("../eval-harness.ts", import.meta.url).pathname;
|
|
143
|
+
const controller = new AbortController();
|
|
144
|
+
let timedOut = false;
|
|
145
|
+
let killed = input.signal?.aborted ?? false;
|
|
146
|
+
let removeAbortListener: (() => void) | undefined;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
if (killed) {
|
|
150
|
+
return {
|
|
151
|
+
result: undefined,
|
|
152
|
+
stdout: "",
|
|
153
|
+
stderr: "",
|
|
154
|
+
truncated: { stdout: false, stderr: false },
|
|
155
|
+
durationMs: Date.now() - start,
|
|
156
|
+
exitCode: 1,
|
|
157
|
+
error: "killed",
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await Bun.write(argsFile, JSON.stringify(input.args ?? null));
|
|
162
|
+
await Bun.write(sourceFile, input.source);
|
|
163
|
+
await writeBareImportShims(tmpdir);
|
|
164
|
+
|
|
165
|
+
const onExternalAbort = () => {
|
|
166
|
+
killed = true;
|
|
167
|
+
controller.abort();
|
|
168
|
+
};
|
|
169
|
+
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
170
|
+
removeAbortListener = () => input.signal?.removeEventListener("abort", onExternalAbort);
|
|
171
|
+
|
|
172
|
+
const timeout = setTimeout(() => {
|
|
173
|
+
timedOut = true;
|
|
174
|
+
controller.abort();
|
|
175
|
+
}, input.resources.wallClockMs);
|
|
176
|
+
|
|
177
|
+
const proc = Bun.spawn(harnessCommand(harnessPath, input), {
|
|
178
|
+
env: {
|
|
179
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
180
|
+
HOME: process.env.HOME ?? "/tmp",
|
|
181
|
+
LANG: process.env.LANG ?? "C.UTF-8",
|
|
182
|
+
LC_ALL: process.env.LC_ALL ?? "C.UTF-8",
|
|
183
|
+
TMPDIR: tmpdir,
|
|
184
|
+
SWARM_SCRIPT_TMPDIR: tmpdir,
|
|
185
|
+
SWARM_SCRIPT_ARGS_FILE: argsFile,
|
|
186
|
+
SWARM_SCRIPT_SOURCE_FILE: sourceFile,
|
|
187
|
+
SWARM_SCRIPT_RESULT_FILE: resultFile,
|
|
188
|
+
},
|
|
189
|
+
cwd: tmpdir,
|
|
190
|
+
stdin: "pipe",
|
|
191
|
+
stdout: "pipe",
|
|
192
|
+
stderr: "pipe",
|
|
193
|
+
signal: controller.signal,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
proc.stdin.write(JSON.stringify(input.configPayload));
|
|
197
|
+
proc.stdin.end();
|
|
198
|
+
|
|
199
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
200
|
+
readCapped(proc.stdout, input.resources.maxStdoutBytes),
|
|
201
|
+
readCapped(proc.stderr, input.resources.maxStdoutBytes),
|
|
202
|
+
proc.exited.catch(() => (timedOut ? 124 : 1)),
|
|
203
|
+
]).finally(() => clearTimeout(timeout));
|
|
204
|
+
|
|
205
|
+
const result = exitCode === 0 ? await readResultFile(resultFile) : undefined;
|
|
206
|
+
const error = classifyExit(exitCode, timedOut, killed);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
result,
|
|
210
|
+
stdout: stdout.text,
|
|
211
|
+
stderr: stderr.text,
|
|
212
|
+
truncated: { stdout: stdout.truncated, stderr: stderr.truncated },
|
|
213
|
+
durationMs: Date.now() - start,
|
|
214
|
+
exitCode,
|
|
215
|
+
...(error ? { error } : {}),
|
|
216
|
+
};
|
|
217
|
+
} catch (error) {
|
|
218
|
+
return {
|
|
219
|
+
result: undefined,
|
|
220
|
+
stdout: "",
|
|
221
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
222
|
+
truncated: { stdout: false, stderr: false },
|
|
223
|
+
durationMs: Date.now() - start,
|
|
224
|
+
exitCode: 1,
|
|
225
|
+
error: timedOut ? "timeout" : killed ? "killed" : "executor_error",
|
|
226
|
+
};
|
|
227
|
+
} finally {
|
|
228
|
+
removeAbortListener?.();
|
|
229
|
+
await Bun.$`rm -rf ${tmpdir}`;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -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,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subprocess script: extracts the argsJsonSchema from a user script module.
|
|
3
|
+
* Spawned by src/be/scripts/extract-schema.ts during script_upsert.
|
|
4
|
+
*
|
|
5
|
+
* Env vars (all required):
|
|
6
|
+
* SWARM_SCHEMA_SOURCE_FILE path to the script source file
|
|
7
|
+
* SWARM_SCHEMA_RESULT_FILE path where JSON Schema (or "null") is written
|
|
8
|
+
* SWARM_SCHEMA_TMPDIR tmpdir used for shims + the user module
|
|
9
|
+
*/
|
|
10
|
+
import { toJSONSchema } from "zod";
|
|
11
|
+
|
|
12
|
+
async function createShims(tmpdir: string): Promise<void> {
|
|
13
|
+
const zodEntry = Bun.resolveSync("zod", import.meta.dir);
|
|
14
|
+
const shims: [string, URL][] = [
|
|
15
|
+
["stdlib", new URL("./stdlib/index.ts", import.meta.url)],
|
|
16
|
+
["swarm-sdk", new URL("./swarm-sdk.ts", import.meta.url)],
|
|
17
|
+
["zod", new URL(`file://${zodEntry}`)],
|
|
18
|
+
];
|
|
19
|
+
for (const [name, url] of shims) {
|
|
20
|
+
const dir = `${tmpdir}/node_modules/${name}`;
|
|
21
|
+
await Bun.$`mkdir -p ${dir}`.quiet();
|
|
22
|
+
await Bun.write(`${dir}/package.json`, JSON.stringify({ type: "module", main: "index.ts" }));
|
|
23
|
+
await Bun.write(`${dir}/index.ts`, `export * from ${JSON.stringify(url.href)};\n`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sourceFile = process.env.SWARM_SCHEMA_SOURCE_FILE;
|
|
28
|
+
const resultFile = process.env.SWARM_SCHEMA_RESULT_FILE;
|
|
29
|
+
const tmpdir = process.env.SWARM_SCHEMA_TMPDIR;
|
|
30
|
+
|
|
31
|
+
if (!sourceFile || !resultFile || !tmpdir) {
|
|
32
|
+
process.stderr.write("extract-args-schema: missing required env vars\n");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await createShims(tmpdir);
|
|
38
|
+
|
|
39
|
+
const source = await Bun.file(sourceFile).text();
|
|
40
|
+
const userModulePath = `${tmpdir}/user-script.ts`;
|
|
41
|
+
await Bun.write(userModulePath, source);
|
|
42
|
+
|
|
43
|
+
let mod: Record<string, unknown>;
|
|
44
|
+
try {
|
|
45
|
+
mod = (await import(userModulePath)) as Record<string, unknown>;
|
|
46
|
+
} catch {
|
|
47
|
+
// Import failed (e.g. unresolvable imports) — not an error, just no schema
|
|
48
|
+
await Bun.write(resultFile, "null");
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!mod.argsSchema || typeof mod.argsSchema !== "object") {
|
|
53
|
+
await Bun.write(resultFile, "null");
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// biome-ignore lint/suspicious/noExplicitAny: argsSchema is a Zod schema at runtime
|
|
58
|
+
const schema = toJSONSchema(mod.argsSchema as any);
|
|
59
|
+
await Bun.write(resultFile, JSON.stringify(schema));
|
|
60
|
+
process.exit(0);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
process.stderr.write(
|
|
63
|
+
`extract-args-schema: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
64
|
+
);
|
|
65
|
+
try {
|
|
66
|
+
await Bun.write(resultFile, "null");
|
|
67
|
+
} catch {}
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
@@ -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", "zod"]);
|
|
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
|
+
}
|