@desplega.ai/agent-swarm 1.79.4 → 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.
- package/openapi.json +496 -32
- package/package.json +14 -6
- package/src/artifact-sdk/server.ts +2 -1
- package/src/be/db.ts +102 -31
- package/src/be/migrations/063_cost_context_schema_relax.sql +133 -0
- package/src/be/migrations/064_scripts.sql +39 -0
- package/src/be/migrations/065_script_embeddings.sql +7 -0
- package/src/be/pricing-normalize.ts +81 -0
- package/src/be/scripts/db.ts +391 -0
- package/src/be/scripts/embeddings.ts +231 -0
- package/src/be/scripts/maintenance.ts +9 -0
- package/src/be/scripts/typecheck.ts +193 -0
- package/src/be/seed-pricing.ts +293 -0
- package/src/cli.tsx +22 -5
- package/src/commands/artifact.ts +3 -2
- package/src/commands/claude-managed-setup.ts +21 -4
- package/src/commands/codex-login.ts +5 -3
- package/src/commands/onboard.tsx +2 -1
- package/src/commands/runner.ts +663 -246
- package/src/commands/setup.tsx +5 -3
- package/src/hooks/hook.ts +4 -3
- package/src/http/context.ts +6 -2
- package/src/http/index.ts +126 -68
- 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 +381 -0
- package/src/http/session-data.ts +74 -23
- package/src/linear/outbound.ts +9 -2
- package/src/otel-impl.ts +200 -0
- package/src/otel.ts +132 -0
- package/src/providers/claude-adapter.ts +52 -6
- package/src/providers/claude-managed-adapter.ts +43 -17
- package/src/providers/claude-managed-pricing.ts +34 -0
- package/src/providers/codex-adapter.ts +38 -27
- package/src/providers/codex-models.ts +22 -3
- package/src/providers/devin-adapter.ts +11 -0
- package/src/providers/opencode-adapter.ts +31 -7
- package/src/providers/pi-mono-adapter.ts +39 -7
- package/src/providers/pricing-sources.md +52 -0
- package/src/providers/swarm-events-shared.ts +8 -4
- package/src/providers/types.ts +33 -10
- package/src/scripts-runtime/ctx.ts +23 -0
- package/src/scripts-runtime/eval-harness.ts +39 -0
- package/src/scripts-runtime/executors/native.ts +229 -0
- package/src/scripts-runtime/executors/registry.ts +16 -0
- package/src/scripts-runtime/executors/types.ts +63 -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 +18 -0
- package/src/tests/api-key.test.ts +33 -0
- package/src/tests/claude-managed-adapter.test.ts +17 -3
- package/src/tests/claude-managed-setup.test.ts +10 -1
- package/src/tests/codex-adapter.test.ts +20 -19
- package/src/tests/codex-login.test.ts +1 -1
- package/src/tests/context-snapshot.test.ts +2 -2
- package/src/tests/context-window.test.ts +65 -1
- package/src/tests/devin-adapter.test.ts +2 -0
- package/src/tests/http/context-routes.test.ts +161 -0
- package/src/tests/linear-outbound-sync.test.ts +109 -0
- package/src/tests/mcp-tools.test.ts +69 -0
- package/src/tests/migration-063-schema-relax.test.ts +109 -0
- package/src/tests/opencode-adapter.test.ts +146 -1
- package/src/tests/otel-impl-secret-scrubbing.test.ts +33 -0
- package/src/tests/pages-view-count.test.ts +30 -5
- package/src/tests/providers/codex-cost.test.ts +18 -0
- package/src/tests/providers/opencode-cost.test.ts +74 -0
- package/src/tests/providers/pi-cost.test.ts +128 -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 +350 -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 +289 -0
- package/src/tests/sdk-allowlist.test.ts +59 -0
- package/src/tests/secret-scrubber.test.ts +54 -1
- package/src/tests/session-costs-codex-recompute.test.ts +35 -22
- package/src/tests/session-costs-model-key-normalize.test.ts +271 -0
- package/src/tests/session-costs-recompute-all-providers.test.ts +170 -0
- package/src/tests/store-progress-cost.test.ts +6 -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/store-progress.ts +16 -60
- package/src/tools/tool-config.ts +7 -0
- package/src/tools/utils.ts +65 -12
- package/src/types.ts +122 -10
- package/src/utils/api-key.ts +28 -0
- package/src/utils/context-window.ts +104 -4
- package/src/utils/page-session.ts +8 -6
- package/src/utils/secret-scrubber.ts +29 -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,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,289 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, initDb } from "../be/db";
|
|
4
|
+
import { mcpToolNameForSdkMethod, SDK_ALLOWLIST } from "../scripts-runtime/sdk-allowlist";
|
|
5
|
+
import type { SwarmConfig } from "../scripts-runtime/swarm-config";
|
|
6
|
+
import { createSwarmSdk } from "../scripts-runtime/swarm-sdk";
|
|
7
|
+
import { createServer } from "../server";
|
|
8
|
+
|
|
9
|
+
const TEST_DB_PATH = "./test-sdk-allowlist.sqlite";
|
|
10
|
+
|
|
11
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
12
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
13
|
+
try {
|
|
14
|
+
await unlink(path + suffix);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("script SDK allowlist", () => {
|
|
22
|
+
let registeredTools: Record<string, unknown>;
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
26
|
+
initDb(TEST_DB_PATH);
|
|
27
|
+
const server = createServer();
|
|
28
|
+
registeredTools = (server as unknown as { _registeredTools: Record<string, unknown> })
|
|
29
|
+
._registeredTools;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
closeDb();
|
|
34
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("every SDK allowlist entry resolves to a live MCP tool", () => {
|
|
38
|
+
const missing = SDK_ALLOWLIST.map((name) => mcpToolNameForSdkMethod(name)).filter(
|
|
39
|
+
(name) => !(name in registeredTools),
|
|
40
|
+
);
|
|
41
|
+
expect(missing).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("runtime proxy rejects non-allowlisted tools before fetch", async () => {
|
|
45
|
+
const sdk = createSwarmSdk({} as SwarmConfig);
|
|
46
|
+
await expect(sdk.join_swarm({})).rejects.toThrow(
|
|
47
|
+
"Tool 'join_swarm' is not exposed to scripts (lifecycle/cred tool)",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("bundled swarm-sdk.d.ts exposes only allowlisted methods", async () => {
|
|
52
|
+
const types = await Bun.file("src/scripts-runtime/types/swarm-sdk.d.ts").text();
|
|
53
|
+
for (const name of SDK_ALLOWLIST) {
|
|
54
|
+
expect(types).toContain(`${name}(args`);
|
|
55
|
+
}
|
|
56
|
+
expect(types).not.toContain("join_swarm(");
|
|
57
|
+
expect(types).not.toContain("start_worker(");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { refreshSecretScrubberCache, scrubSecrets } from "../utils/secret-scrubber";
|
|
2
|
+
import { refreshSecretScrubberCache, scrubObject, scrubSecrets } from "../utils/secret-scrubber";
|
|
3
3
|
|
|
4
4
|
// Snapshot/restore process.env between tests so env-derived cache entries
|
|
5
5
|
// don't leak across cases.
|
|
@@ -113,6 +113,17 @@ describe("scrubSecrets — env-based replacement", () => {
|
|
|
113
113
|
expect(out).not.toContain("sk-proj-abcd1234567890");
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
test("redacts OTLP exporter headers from env", () => {
|
|
117
|
+
process.env.OTEL_EXPORTER_OTLP_HEADERS = "signoz-ingestion-key=localSignozKey_1234567890abcdef";
|
|
118
|
+
refreshSecretScrubberCache();
|
|
119
|
+
|
|
120
|
+
const out = scrubSecrets(
|
|
121
|
+
"OTEL_EXPORTER_OTLP_HEADERS=signoz-ingestion-key=localSignozKey_1234567890abcdef",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(out).toBe("OTEL_EXPORTER_OTLP_HEADERS=[REDACTED:OTEL_EXPORTER_OTLP_HEADERS]");
|
|
125
|
+
});
|
|
126
|
+
|
|
116
127
|
test("cache rebuilds after refresh when new secret is added", () => {
|
|
117
128
|
const out1 = scrubSecrets("no secret yet here_abcdefghij");
|
|
118
129
|
expect(out1).toBe("no secret yet here_abcdefghij");
|
|
@@ -202,6 +213,14 @@ describe("scrubSecrets — regex patterns", () => {
|
|
|
202
213
|
const out = scrubSecrets("token=ghp_1234567890abcdefABCDEF1234567890ABCD");
|
|
203
214
|
expect(out).toContain("[REDACTED:github_token]");
|
|
204
215
|
});
|
|
216
|
+
|
|
217
|
+
test("redacts SigNoz ingestion-key headers even when env is empty", () => {
|
|
218
|
+
const out = scrubSecrets(
|
|
219
|
+
"OTEL_EXPORTER_OTLP_HEADERS=signoz-ingestion-key=localSignozKey_1234567890abcdef",
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
expect(out).toBe("OTEL_EXPORTER_OTLP_HEADERS=[REDACTED:signoz_ingestion_key]");
|
|
223
|
+
});
|
|
205
224
|
});
|
|
206
225
|
|
|
207
226
|
describe("scrubSecrets — does not over-scrub", () => {
|
|
@@ -247,3 +266,37 @@ describe("scrubSecrets — does not over-scrub", () => {
|
|
|
247
266
|
expect(out).toBe("example: ghp_TOKEN and glpat-xyz (both too short)");
|
|
248
267
|
});
|
|
249
268
|
});
|
|
269
|
+
|
|
270
|
+
describe("scrubObject", () => {
|
|
271
|
+
test("scrubs nested object and array string leaves", () => {
|
|
272
|
+
process.env.NESTED_TOKEN = "nested-secret-value-1234567890";
|
|
273
|
+
refreshSecretScrubberCache();
|
|
274
|
+
|
|
275
|
+
const out = scrubObject({
|
|
276
|
+
keep: 1,
|
|
277
|
+
nested: {
|
|
278
|
+
secret: "nested-secret-value-1234567890",
|
|
279
|
+
list: ["safe", "nested-secret-value-1234567890"],
|
|
280
|
+
},
|
|
281
|
+
nullish: null,
|
|
282
|
+
bool: true,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(out).toEqual({
|
|
286
|
+
keep: 1,
|
|
287
|
+
nested: {
|
|
288
|
+
secret: "[REDACTED:NESTED_TOKEN]",
|
|
289
|
+
list: ["safe", "[REDACTED:NESTED_TOKEN]"],
|
|
290
|
+
},
|
|
291
|
+
nullish: null,
|
|
292
|
+
bool: true,
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("handles circular references without recursing forever", () => {
|
|
297
|
+
const value: Record<string, unknown> = { a: "ok" };
|
|
298
|
+
value.self = value;
|
|
299
|
+
|
|
300
|
+
expect(scrubObject(value)).toEqual({ a: "ok", self: "[Circular]" });
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -98,7 +98,7 @@ interface CreatedCostResponse {
|
|
|
98
98
|
cost: {
|
|
99
99
|
id: string;
|
|
100
100
|
totalCostUsd: number;
|
|
101
|
-
costSource: "harness" | "pricing-table";
|
|
101
|
+
costSource: "harness" | "pricing-table" | "unpriced";
|
|
102
102
|
model: string;
|
|
103
103
|
};
|
|
104
104
|
}
|
|
@@ -153,15 +153,9 @@ describe("Phase 6 — POST /api/session-costs: Codex USD recompute", () => {
|
|
|
153
153
|
expect(body.cost.totalCostUsd).toBeCloseTo(6.64, 5);
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
-
test("provider=codex but
|
|
157
|
-
// Only seed
|
|
158
|
-
|
|
159
|
-
provider: "codex",
|
|
160
|
-
model: "codex-test-synth",
|
|
161
|
-
tokenClass: "input",
|
|
162
|
-
effectiveFrom: 1,
|
|
163
|
-
pricePerMillionUsd: 2.0,
|
|
164
|
-
});
|
|
156
|
+
test("provider=codex but input/output rows missing → 'unpriced', worker value preserved", async () => {
|
|
157
|
+
// Only seed cached_input. Missing input + output blocks recompute and
|
|
158
|
+
// Phase 2 tags the row 'unpriced' (no rates means we can't trust harness USD either).
|
|
165
159
|
insertPricingRow({
|
|
166
160
|
provider: "codex",
|
|
167
161
|
model: "codex-test-synth",
|
|
@@ -186,13 +180,16 @@ describe("Phase 6 — POST /api/session-costs: Codex USD recompute", () => {
|
|
|
186
180
|
});
|
|
187
181
|
expect(res.status).toBe(201);
|
|
188
182
|
const body = (await res.json()) as CreatedCostResponse;
|
|
189
|
-
|
|
190
|
-
|
|
183
|
+
// Phase 2: provider tagged but no input/output rows ⇒ 'unpriced'.
|
|
184
|
+
expect(body.cost.costSource).toBe("unpriced");
|
|
185
|
+
// Worker value preserved verbatim — we don't fabricate one.
|
|
191
186
|
expect(body.cost.totalCostUsd).toBe(1.23);
|
|
192
187
|
});
|
|
193
188
|
|
|
194
|
-
test("provider=claude
|
|
195
|
-
//
|
|
189
|
+
test("provider=claude with no pricing rows for the model → 'unpriced' (Phase 2)", async () => {
|
|
190
|
+
// Phase 2 extended the recompute path from codex-only to every provider.
|
|
191
|
+
// With no pricing rows seeded for ('claude', 'sonnet-4'), the row is
|
|
192
|
+
// tagged 'unpriced' rather than 'harness' — the UI surfaces it as a yellow badge.
|
|
196
193
|
const res = await authedFetch(`/api/session-costs`, {
|
|
197
194
|
method: "POST",
|
|
198
195
|
body: JSON.stringify({
|
|
@@ -209,20 +206,35 @@ describe("Phase 6 — POST /api/session-costs: Codex USD recompute", () => {
|
|
|
209
206
|
});
|
|
210
207
|
expect(res.status).toBe(201);
|
|
211
208
|
const body = (await res.json()) as CreatedCostResponse;
|
|
212
|
-
expect(body.cost.costSource).toBe("
|
|
209
|
+
expect(body.cost.costSource).toBe("unpriced");
|
|
213
210
|
expect(body.cost.totalCostUsd).toBe(7.77);
|
|
214
211
|
});
|
|
215
212
|
|
|
216
|
-
test("provider=pi
|
|
213
|
+
test("provider=pi with seeded pricing rows → recomputes (Phase 2)", async () => {
|
|
214
|
+
// Phase 2 widens recompute beyond codex. Seed pi rows so we get a hit.
|
|
215
|
+
insertPricingRow({
|
|
216
|
+
provider: "pi",
|
|
217
|
+
model: "pi-test",
|
|
218
|
+
tokenClass: "input",
|
|
219
|
+
effectiveFrom: 1,
|
|
220
|
+
pricePerMillionUsd: 0.5,
|
|
221
|
+
});
|
|
222
|
+
insertPricingRow({
|
|
223
|
+
provider: "pi",
|
|
224
|
+
model: "pi-test",
|
|
225
|
+
tokenClass: "output",
|
|
226
|
+
effectiveFrom: 1,
|
|
227
|
+
pricePerMillionUsd: 3.0,
|
|
228
|
+
});
|
|
217
229
|
const res = await authedFetch(`/api/session-costs`, {
|
|
218
230
|
method: "POST",
|
|
219
231
|
body: JSON.stringify({
|
|
220
232
|
sessionId: "pi-passthrough-1",
|
|
221
233
|
agentId: testAgent.id,
|
|
222
|
-
totalCostUsd: 0.42,
|
|
223
|
-
inputTokens:
|
|
224
|
-
outputTokens:
|
|
225
|
-
model: "
|
|
234
|
+
totalCostUsd: 0.42, // expected to be overwritten
|
|
235
|
+
inputTokens: 1_000_000, // 1M input
|
|
236
|
+
outputTokens: 1_000_000, // 1M output
|
|
237
|
+
model: "pi-test",
|
|
226
238
|
provider: "pi",
|
|
227
239
|
durationMs: 1_000,
|
|
228
240
|
numTurns: 1,
|
|
@@ -230,8 +242,9 @@ describe("Phase 6 — POST /api/session-costs: Codex USD recompute", () => {
|
|
|
230
242
|
});
|
|
231
243
|
expect(res.status).toBe(201);
|
|
232
244
|
const body = (await res.json()) as CreatedCostResponse;
|
|
233
|
-
expect(body.cost.costSource).toBe("
|
|
234
|
-
|
|
245
|
+
expect(body.cost.costSource).toBe("pricing-table");
|
|
246
|
+
// 1M @ 0.5 + 1M @ 3.0 = $3.50
|
|
247
|
+
expect(body.cost.totalCostUsd).toBeCloseTo(3.5, 5);
|
|
235
248
|
});
|
|
236
249
|
|
|
237
250
|
test("provider field omitted → no recompute, costSource='harness' (back-compat)", async () => {
|