@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.
Files changed (130) hide show
  1. package/openapi.json +496 -32
  2. package/package.json +14 -6
  3. package/src/artifact-sdk/server.ts +2 -1
  4. package/src/be/db.ts +102 -31
  5. package/src/be/migrations/063_cost_context_schema_relax.sql +133 -0
  6. package/src/be/migrations/064_scripts.sql +39 -0
  7. package/src/be/migrations/065_script_embeddings.sql +7 -0
  8. package/src/be/pricing-normalize.ts +81 -0
  9. package/src/be/scripts/db.ts +391 -0
  10. package/src/be/scripts/embeddings.ts +231 -0
  11. package/src/be/scripts/maintenance.ts +9 -0
  12. package/src/be/scripts/typecheck.ts +193 -0
  13. package/src/be/seed-pricing.ts +293 -0
  14. package/src/cli.tsx +22 -5
  15. package/src/commands/artifact.ts +3 -2
  16. package/src/commands/claude-managed-setup.ts +21 -4
  17. package/src/commands/codex-login.ts +5 -3
  18. package/src/commands/onboard.tsx +2 -1
  19. package/src/commands/runner.ts +663 -246
  20. package/src/commands/setup.tsx +5 -3
  21. package/src/hooks/hook.ts +4 -3
  22. package/src/http/context.ts +6 -2
  23. package/src/http/index.ts +126 -68
  24. package/src/http/memory.ts +28 -0
  25. package/src/http/openapi.ts +1 -0
  26. package/src/http/page-proxy.ts +2 -1
  27. package/src/http/route-def.ts +1 -0
  28. package/src/http/schedules.ts +37 -0
  29. package/src/http/scripts.ts +381 -0
  30. package/src/http/session-data.ts +74 -23
  31. package/src/linear/outbound.ts +9 -2
  32. package/src/otel-impl.ts +200 -0
  33. package/src/otel.ts +132 -0
  34. package/src/providers/claude-adapter.ts +52 -6
  35. package/src/providers/claude-managed-adapter.ts +43 -17
  36. package/src/providers/claude-managed-pricing.ts +34 -0
  37. package/src/providers/codex-adapter.ts +38 -27
  38. package/src/providers/codex-models.ts +22 -3
  39. package/src/providers/devin-adapter.ts +11 -0
  40. package/src/providers/opencode-adapter.ts +31 -7
  41. package/src/providers/pi-mono-adapter.ts +39 -7
  42. package/src/providers/pricing-sources.md +52 -0
  43. package/src/providers/swarm-events-shared.ts +8 -4
  44. package/src/providers/types.ts +33 -10
  45. package/src/scripts-runtime/ctx.ts +23 -0
  46. package/src/scripts-runtime/eval-harness.ts +39 -0
  47. package/src/scripts-runtime/executors/native.ts +229 -0
  48. package/src/scripts-runtime/executors/registry.ts +16 -0
  49. package/src/scripts-runtime/executors/types.ts +63 -0
  50. package/src/scripts-runtime/extract-signature.ts +81 -0
  51. package/src/scripts-runtime/import-allowlist.ts +109 -0
  52. package/src/scripts-runtime/loader.ts +96 -0
  53. package/src/scripts-runtime/redacted.ts +48 -0
  54. package/src/scripts-runtime/sdk-allowlist.ts +29 -0
  55. package/src/scripts-runtime/stdlib/fetch.ts +46 -0
  56. package/src/scripts-runtime/stdlib/glob.ts +8 -0
  57. package/src/scripts-runtime/stdlib/grep.ts +34 -0
  58. package/src/scripts-runtime/stdlib/index.ts +16 -0
  59. package/src/scripts-runtime/stdlib/table.ts +17 -0
  60. package/src/scripts-runtime/swarm-config.ts +35 -0
  61. package/src/scripts-runtime/swarm-sdk.ts +197 -0
  62. package/src/scripts-runtime/types/stdlib.d.ts +104 -0
  63. package/src/scripts-runtime/types/swarm-sdk.d.ts +86 -0
  64. package/src/server.ts +18 -0
  65. package/src/tests/api-key.test.ts +33 -0
  66. package/src/tests/claude-managed-adapter.test.ts +17 -3
  67. package/src/tests/claude-managed-setup.test.ts +10 -1
  68. package/src/tests/codex-adapter.test.ts +20 -19
  69. package/src/tests/codex-login.test.ts +1 -1
  70. package/src/tests/context-snapshot.test.ts +2 -2
  71. package/src/tests/context-window.test.ts +65 -1
  72. package/src/tests/devin-adapter.test.ts +2 -0
  73. package/src/tests/http/context-routes.test.ts +161 -0
  74. package/src/tests/linear-outbound-sync.test.ts +109 -0
  75. package/src/tests/mcp-tools.test.ts +69 -0
  76. package/src/tests/migration-063-schema-relax.test.ts +109 -0
  77. package/src/tests/opencode-adapter.test.ts +146 -1
  78. package/src/tests/otel-impl-secret-scrubbing.test.ts +33 -0
  79. package/src/tests/pages-view-count.test.ts +30 -5
  80. package/src/tests/providers/codex-cost.test.ts +18 -0
  81. package/src/tests/providers/opencode-cost.test.ts +74 -0
  82. package/src/tests/providers/pi-cost.test.ts +128 -0
  83. package/src/tests/redacted.test.ts +29 -0
  84. package/src/tests/runner-tool-spans.test.ts +268 -0
  85. package/src/tests/script-executor-conformance.test.ts +142 -0
  86. package/src/tests/script-executor-registry.test.ts +17 -0
  87. package/src/tests/scripts-db.test.ts +329 -0
  88. package/src/tests/scripts-embeddings.test.ts +291 -0
  89. package/src/tests/scripts-extract-signature.test.ts +47 -0
  90. package/src/tests/scripts-http.test.ts +350 -0
  91. package/src/tests/scripts-import-allowlist.test.ts +55 -0
  92. package/src/tests/scripts-mcp-e2e.test.ts +269 -0
  93. package/src/tests/scripts-runtime-secret-egress.test.ts +44 -0
  94. package/src/tests/scripts-runtime.test.ts +289 -0
  95. package/src/tests/sdk-allowlist.test.ts +59 -0
  96. package/src/tests/secret-scrubber.test.ts +54 -1
  97. package/src/tests/session-costs-codex-recompute.test.ts +35 -22
  98. package/src/tests/session-costs-model-key-normalize.test.ts +271 -0
  99. package/src/tests/session-costs-recompute-all-providers.test.ts +170 -0
  100. package/src/tests/store-progress-cost.test.ts +6 -1
  101. package/src/tests/swarm-config.test.ts +38 -0
  102. package/src/tests/tool-annotations.test.ts +2 -2
  103. package/src/tests/tool-call-progress.test.ts +30 -0
  104. package/src/tests/workflow-e2e.test.ts +218 -0
  105. package/src/tests/workflow-executors.test.ts +32 -2
  106. package/src/tests/workflow-input-redaction.test.ts +232 -0
  107. package/src/tests/workflow-swarm-script.test.ts +273 -0
  108. package/src/tools/memory-rate.ts +2 -1
  109. package/src/tools/script-common.ts +88 -0
  110. package/src/tools/script-delete.ts +35 -0
  111. package/src/tools/script-query-types.ts +37 -0
  112. package/src/tools/script-run.ts +43 -0
  113. package/src/tools/script-search.ts +32 -0
  114. package/src/tools/script-upsert.ts +43 -0
  115. package/src/tools/store-progress.ts +16 -60
  116. package/src/tools/tool-config.ts +7 -0
  117. package/src/tools/utils.ts +65 -12
  118. package/src/types.ts +122 -10
  119. package/src/utils/api-key.ts +28 -0
  120. package/src/utils/context-window.ts +104 -4
  121. package/src/utils/page-session.ts +8 -6
  122. package/src/utils/secret-scrubber.ts +29 -1
  123. package/src/workflows/engine.ts +12 -4
  124. package/src/workflows/executors/index.ts +1 -0
  125. package/src/workflows/executors/registry.ts +2 -0
  126. package/src/workflows/executors/script.ts +12 -1
  127. package/src/workflows/executors/swarm-script.ts +170 -0
  128. package/src/workflows/input.ts +65 -0
  129. package/src/workflows/recovery.ts +31 -3
  130. 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 a token class is missing → falls back to worker value, costSource='harness'", async () => {
157
- // Only seed input + cached_input. Missing output forces fallback.
158
- insertPricingRow({
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
- expect(body.cost.costSource).toBe("harness");
190
- // Worker value preserved verbatim.
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 records harness USD as-is regardless of DB pricing rows", async () => {
195
- // Even if there are codex pricing rows, claude must NOT be touched.
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("harness");
209
+ expect(body.cost.costSource).toBe("unpriced");
213
210
  expect(body.cost.totalCostUsd).toBe(7.77);
214
211
  });
215
212
 
216
- test("provider=pi records harness USD as-is regardless of DB pricing rows", async () => {
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: 10,
224
- outputTokens: 5,
225
- model: "openrouter/google/gemini-3-flash-preview",
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("harness");
234
- expect(body.cost.totalCostUsd).toBe(0.42);
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 () => {