@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,218 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ closeDb,
5
+ createAgent,
6
+ createWorkflow,
7
+ getDb,
8
+ getTaskByWorkflowRunStepId,
9
+ getWorkflowRun,
10
+ getWorkflowRunStepsByRunId,
11
+ initDb,
12
+ } from "../be/db";
13
+ import { upsertScriptByName } from "../be/scripts/db";
14
+ import { setScriptEmbeddingProviderForTests } from "../be/scripts/embeddings";
15
+ import type { Workflow, WorkflowDefinition } from "../types";
16
+ import { startWorkflowExecution } from "../workflows/engine";
17
+ import { InProcessEventBus } from "../workflows/event-bus";
18
+ import { AgentTaskExecutor } from "../workflows/executors/agent-task";
19
+ import type { ExecutorDependencies } from "../workflows/executors/base";
20
+ import { ExecutorRegistry } from "../workflows/executors/registry";
21
+ import { SwarmScriptExecutor } from "../workflows/executors/swarm-script";
22
+ import { setupWorkflowResumeListener } from "../workflows/resume";
23
+ import { interpolate } from "../workflows/template";
24
+
25
+ const TEST_DB_PATH = "./test-workflow-e2e.sqlite";
26
+ const API_KEY = "test-workflow-e2e-key-1234567890";
27
+
28
+ const noOpEmbeddingProvider = {
29
+ name: "test/noop-workflow-e2e-embedding",
30
+ dimensions: 1,
31
+ async embed() {
32
+ return null;
33
+ },
34
+ async embedBatch(texts: string[]) {
35
+ return texts.map(() => null);
36
+ },
37
+ };
38
+
39
+ const signatureJson = JSON.stringify({
40
+ args: { type: "object" },
41
+ result: { type: "object" },
42
+ });
43
+
44
+ let savedEnv: NodeJS.ProcessEnv;
45
+ let agentId: string;
46
+ let eventBus: InProcessEventBus;
47
+ let registry: ExecutorRegistry;
48
+
49
+ async function removeDbFiles(): Promise<void> {
50
+ for (const suffix of ["", "-wal", "-shm"]) {
51
+ try {
52
+ await unlink(TEST_DB_PATH + suffix);
53
+ } catch (error) {
54
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
55
+ }
56
+ }
57
+ }
58
+
59
+ function makeWorkflow(def: WorkflowDefinition): Workflow {
60
+ return createWorkflow({
61
+ name: `workflow-e2e-${crypto.randomUUID()}`,
62
+ definition: def,
63
+ createdByAgentId: agentId,
64
+ });
65
+ }
66
+
67
+ async function saveScript(name: string, source: string) {
68
+ return upsertScriptByName({
69
+ name,
70
+ scope: "agent",
71
+ scopeId: agentId,
72
+ source,
73
+ description: `${name} e2e script`,
74
+ intent: "workflow swarm-script e2e fixture",
75
+ signatureJson,
76
+ agentId,
77
+ typeChecked: true,
78
+ });
79
+ }
80
+
81
+ async function waitForRunStatus(runId: string, status: string): Promise<void> {
82
+ const deadline = Date.now() + 5_000;
83
+ while (Date.now() < deadline) {
84
+ if (getWorkflowRun(runId)?.status === status) return;
85
+ await new Promise((resolve) => setTimeout(resolve, 25));
86
+ }
87
+ throw new Error(`Timed out waiting for workflow run ${runId} to reach ${status}`);
88
+ }
89
+
90
+ beforeAll(async () => {
91
+ savedEnv = { ...process.env };
92
+ await removeDbFiles();
93
+ initDb(TEST_DB_PATH);
94
+ process.env.AGENT_SWARM_API_KEY = API_KEY;
95
+ delete process.env.API_KEY;
96
+ setScriptEmbeddingProviderForTests(noOpEmbeddingProvider);
97
+
98
+ const agent = createAgent({ name: "workflow-e2e-agent", isLead: true, status: "idle" });
99
+ agentId = agent.id;
100
+
101
+ eventBus = new InProcessEventBus();
102
+ const db = await import("../be/db");
103
+ const deps: ExecutorDependencies = {
104
+ db,
105
+ eventBus,
106
+ interpolate: (template, ctx) => interpolate(template, ctx).result,
107
+ };
108
+ registry = new ExecutorRegistry();
109
+ registry.register(new SwarmScriptExecutor(deps));
110
+ registry.register(new AgentTaskExecutor(deps));
111
+ setupWorkflowResumeListener(eventBus, registry);
112
+ });
113
+
114
+ afterAll(async () => {
115
+ setScriptEmbeddingProviderForTests(null);
116
+ closeDb();
117
+ await removeDbFiles();
118
+ for (const key of Object.keys(process.env)) {
119
+ if (!(key in savedEnv)) delete process.env[key];
120
+ }
121
+ for (const [key, value] of Object.entries(savedEnv)) {
122
+ if (value === undefined) delete process.env[key];
123
+ else process.env[key] = value;
124
+ }
125
+ });
126
+
127
+ beforeEach(() => {
128
+ getDb().run("DELETE FROM workflow_run_steps");
129
+ getDb().run("DELETE FROM workflow_runs");
130
+ getDb().run("DELETE FROM scripts");
131
+ getDb().run("DELETE FROM agent_tasks");
132
+ getDb().run("DELETE FROM workflows");
133
+ });
134
+
135
+ describe("workflow e2e swarm-script", () => {
136
+ test("swarm-script full workflow run executes through the engine", async () => {
137
+ await saveScript(
138
+ "square",
139
+ `export default async (args: { value: number }) => ({ squared: args.value * args.value });`,
140
+ );
141
+ const workflow = makeWorkflow({
142
+ nodes: [
143
+ {
144
+ id: "script",
145
+ type: "swarm-script",
146
+ config: { scriptName: "square", args: { value: 4 } },
147
+ },
148
+ ],
149
+ });
150
+
151
+ const runId = await startWorkflowExecution(workflow, {}, registry);
152
+ const run = getWorkflowRun(runId);
153
+ const steps = getWorkflowRunStepsByRunId(runId);
154
+
155
+ expect(run?.status).toBe("completed");
156
+ expect(steps).toHaveLength(1);
157
+ expect(steps[0]?.output).toMatchObject({ result: { squared: 16 } });
158
+ });
159
+
160
+ test("swarm-script agent-task interleave", async () => {
161
+ await saveScript(
162
+ "first-script",
163
+ `export default async (args: { value: number }) => ({ value: args.value + 1 });`,
164
+ );
165
+ await saveScript(
166
+ "second-script",
167
+ `export default async (args: { value: string }) => ({ final: Number(args.value) + 1 });`,
168
+ );
169
+ const workflow = makeWorkflow({
170
+ nodes: [
171
+ {
172
+ id: "first",
173
+ type: "swarm-script",
174
+ config: { scriptName: "first-script", args: { value: 1 } },
175
+ next: "task",
176
+ },
177
+ {
178
+ id: "task",
179
+ type: "agent-task",
180
+ inputs: { first: "first.result.value" },
181
+ config: { template: "Use {{first}}" },
182
+ next: "second",
183
+ },
184
+ {
185
+ id: "second",
186
+ type: "swarm-script",
187
+ inputs: { taskValue: "task.taskOutput.value" },
188
+ config: { scriptName: "second-script", args: { value: "{{taskValue}}" } },
189
+ },
190
+ ],
191
+ });
192
+
193
+ const runId = await startWorkflowExecution(workflow, {}, registry);
194
+ expect(getWorkflowRun(runId)?.status).toBe("waiting");
195
+
196
+ const waitingSteps = getWorkflowRunStepsByRunId(runId);
197
+ const taskStep = waitingSteps.find((step) => step.nodeId === "task");
198
+ expect(taskStep?.status).toBe("waiting");
199
+ const task = getTaskByWorkflowRunStepId(taskStep!.id);
200
+ expect(task?.task).toBe("Use 2");
201
+
202
+ eventBus.emit("task.completed", {
203
+ taskId: task!.id,
204
+ output: JSON.stringify({ value: 41 }),
205
+ workflowRunId: runId,
206
+ workflowRunStepId: taskStep!.id,
207
+ });
208
+
209
+ await waitForRunStatus(runId, "completed");
210
+ const completedSteps = getWorkflowRunStepsByRunId(runId);
211
+ expect(completedSteps).toHaveLength(3);
212
+ expect(completedSteps.find((step) => step.nodeId === "first")?.status).toBe("completed");
213
+ expect(completedSteps.find((step) => step.nodeId === "task")?.status).toBe("completed");
214
+ expect(completedSteps.find((step) => step.nodeId === "second")?.output).toMatchObject({
215
+ result: { final: 42 },
216
+ });
217
+ });
218
+ });
@@ -516,6 +516,34 @@ describe("ScriptExecutor", () => {
516
516
  const valid = ScriptOutputSchema.safeParse({ exitCode: 0, stdout: "hi", stderr: "" });
517
517
  expect(valid.success).toBe(true);
518
518
  });
519
+
520
+ test("keeps raw {exitCode, stdout, stderr} when stdout is not valid JSON", async () => {
521
+ const result = await executor.run(
522
+ input({ runtime: "bash", script: "echo 'not-json {at all'" }, {}),
523
+ );
524
+ expect(result.status).toBe("success");
525
+ const out = result.output as { exitCode: number; stdout: string; stderr: string } & {
526
+ parsed?: unknown;
527
+ };
528
+ expect(out.exitCode).toBe(0);
529
+ expect(out.stdout).toBe("not-json {at all");
530
+ expect(out.stderr).toBe("");
531
+ // No parsed key merged in — only the raw three fields are present.
532
+ expect(Object.keys(out).sort()).toEqual(["exitCode", "stderr", "stdout"]);
533
+ });
534
+
535
+ test("populates structured output on timeout instead of leaving it null", async () => {
536
+ const result = await executor.run(
537
+ input({ runtime: "bash", script: "sleep 5", timeout: 1000 }, {}),
538
+ );
539
+ expect(result.status).toBe("failed");
540
+ expect(result.error).toContain("Script timed out after 1000ms");
541
+ const out = result.output as { exitCode: number; stdout: string; stderr: string };
542
+ expect(out).toBeDefined();
543
+ expect(out.exitCode).toBe(-1);
544
+ expect(out.stdout).toBe("");
545
+ expect(out.stderr).toContain("Script timed out after 1000ms");
546
+ });
519
547
  });
520
548
 
521
549
  // ─── VCS Executor ────────────────────────────────────────────
@@ -706,7 +734,7 @@ describe("ValidateExecutor", () => {
706
734
  // ─── Registry Wiring ─────────────────────────────────────────
707
735
 
708
736
  describe("createExecutorRegistry", () => {
709
- test("registers all 10 executors (7 instant + 3 async)", () => {
737
+ test("registers all 11 executors (8 instant + 3 async)", () => {
710
738
  const registry = createExecutorRegistry(mockDeps);
711
739
  const types = registry.types();
712
740
 
@@ -715,12 +743,13 @@ describe("createExecutorRegistry", () => {
715
743
  expect(types).toContain("notify");
716
744
  expect(types).toContain("raw-llm");
717
745
  expect(types).toContain("script");
746
+ expect(types).toContain("swarm-script");
718
747
  expect(types).toContain("vcs");
719
748
  expect(types).toContain("validate");
720
749
  expect(types).toContain("agent-task");
721
750
  expect(types).toContain("human-in-the-loop");
722
751
  expect(types).toContain("wait");
723
- expect(types).toHaveLength(10);
752
+ expect(types).toHaveLength(11);
724
753
  });
725
754
 
726
755
  test("instant executors have mode instant, async executors have mode async", () => {
@@ -731,6 +760,7 @@ describe("createExecutorRegistry", () => {
731
760
  "notify",
732
761
  "raw-llm",
733
762
  "script",
763
+ "swarm-script",
734
764
  "vcs",
735
765
  "validate",
736
766
  ];
@@ -0,0 +1,232 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { z } from "zod";
4
+ import {
5
+ closeDb,
6
+ createWorkflow,
7
+ deleteWorkflow,
8
+ getWorkflowRunStepsByRunId,
9
+ initDb,
10
+ upsertSwarmConfig,
11
+ } from "../be/db";
12
+ import type { WorkflowDefinition } from "../types";
13
+ import { startWorkflowExecution } from "../workflows/engine";
14
+ import {
15
+ BaseExecutor,
16
+ type ExecutorDependencies,
17
+ type ExecutorResult,
18
+ } from "../workflows/executors/base";
19
+ import { ExecutorRegistry } from "../workflows/executors/registry";
20
+ import {
21
+ getSecretInputKeys,
22
+ REDACTED_SECRET_VALUE,
23
+ redactSecretsForStorage,
24
+ resolveInputs,
25
+ } from "../workflows/input";
26
+
27
+ const TEST_DB_PATH = "./test-workflow-input-redaction.sqlite";
28
+
29
+ // Captures the input it was invoked with so we can assert what the executor
30
+ // actually receives (real value, not redacted).
31
+ class CaptureExecutor extends BaseExecutor<
32
+ typeof CaptureExecutor.schema,
33
+ typeof CaptureExecutor.outSchema
34
+ > {
35
+ static readonly schema = z.object({ tokenSeen: z.string().optional() });
36
+ static readonly outSchema = z.object({ ok: z.boolean() });
37
+
38
+ readonly type = "capture";
39
+ readonly mode = "instant" as const;
40
+ readonly configSchema = CaptureExecutor.schema;
41
+ readonly outputSchema = CaptureExecutor.outSchema;
42
+
43
+ static lastTokenSeen: string | undefined = undefined;
44
+
45
+ protected async execute(
46
+ config: z.infer<typeof CaptureExecutor.schema>,
47
+ ): Promise<ExecutorResult<z.infer<typeof CaptureExecutor.outSchema>>> {
48
+ CaptureExecutor.lastTokenSeen = config.tokenSeen;
49
+ return { status: "success", output: { ok: true } };
50
+ }
51
+ }
52
+
53
+ describe("getSecretInputKeys", () => {
54
+ test("flags secret.* references", () => {
55
+ const keys = getSecretInputKeys({ GITHUB_TOKEN: "secret.GITHUB_TOKEN" });
56
+ expect(keys.has("GITHUB_TOKEN")).toBe(true);
57
+ expect(keys.size).toBe(1);
58
+ });
59
+
60
+ test("flags sensitive env-var references", () => {
61
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: testing env-var syntax
62
+ const keys = getSecretInputKeys({ GH: "${GITHUB_TOKEN}" });
63
+ expect(keys.has("GH")).toBe(true);
64
+ });
65
+
66
+ test("does not flag non-sensitive env-var references", () => {
67
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: testing env-var syntax
68
+ const keys = getSecretInputKeys({ branch: "${GIT_BRANCH}", url: "${MCP_BASE_URL}" });
69
+ expect(keys.size).toBe(0);
70
+ });
71
+
72
+ test("does not flag literal strings", () => {
73
+ const keys = getSecretInputKeys({ name: "literal", count: "42" });
74
+ expect(keys.size).toBe(0);
75
+ });
76
+
77
+ test("handles undefined input", () => {
78
+ const keys = getSecretInputKeys(undefined);
79
+ expect(keys.size).toBe(0);
80
+ });
81
+
82
+ test("flags all sensitive-suffix env names", () => {
83
+ const keys = getSecretInputKeys({
84
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: testing env-var syntax
85
+ a: "${FOO_TOKEN}",
86
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: testing env-var syntax
87
+ b: "${BAR_API_KEY}",
88
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: testing env-var syntax
89
+ c: "${BAZ_SECRET}",
90
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: testing env-var syntax
91
+ d: "${QUX_PASSWORD}",
92
+ });
93
+ expect(keys.size).toBe(4);
94
+ });
95
+ });
96
+
97
+ describe("redactSecretsForStorage", () => {
98
+ test("returns ctx unchanged when no secret keys", () => {
99
+ const ctx = { input: { foo: "bar" } };
100
+ const out = redactSecretsForStorage(ctx, new Set());
101
+ expect(out).toBe(ctx);
102
+ });
103
+
104
+ test("redacts only declared secret keys in ctx.input", () => {
105
+ const ctx = {
106
+ trigger: { topic: "tuxedo" },
107
+ input: { GITHUB_TOKEN: "ghp_real_value_xxx", branch: "main" },
108
+ };
109
+ const out = redactSecretsForStorage(ctx, new Set(["GITHUB_TOKEN"]));
110
+ expect((out.input as Record<string, unknown>).GITHUB_TOKEN).toBe(REDACTED_SECRET_VALUE);
111
+ expect((out.input as Record<string, unknown>).branch).toBe("main");
112
+ // Trigger block untouched
113
+ expect(out.trigger).toEqual({ topic: "tuxedo" });
114
+ });
115
+
116
+ test("does not mutate the original ctx (executor still sees real value)", () => {
117
+ const ctx = { input: { GITHUB_TOKEN: "ghp_real" } };
118
+ redactSecretsForStorage(ctx, new Set(["GITHUB_TOKEN"]));
119
+ expect((ctx.input as Record<string, unknown>).GITHUB_TOKEN).toBe("ghp_real");
120
+ });
121
+
122
+ test("no-ops when ctx has no input block", () => {
123
+ const ctx = { trigger: {} };
124
+ const out = redactSecretsForStorage(ctx, new Set(["GITHUB_TOKEN"]));
125
+ expect(out).toBe(ctx);
126
+ });
127
+
128
+ test("no-ops when secret key isn't actually present in ctx.input", () => {
129
+ const ctx = { input: { unrelated: "x" } };
130
+ const out = redactSecretsForStorage(ctx, new Set(["GITHUB_TOKEN"]));
131
+ expect(out).toBe(ctx);
132
+ });
133
+ });
134
+
135
+ describe("end-to-end — workflow step persistence redacts secrets", () => {
136
+ beforeAll(() => {
137
+ initDb(TEST_DB_PATH);
138
+ });
139
+
140
+ afterAll(async () => {
141
+ closeDb();
142
+ try {
143
+ await unlink(TEST_DB_PATH);
144
+ } catch {
145
+ // ignore
146
+ }
147
+ });
148
+
149
+ let workflowId: string | null = null;
150
+
151
+ beforeEach(() => {
152
+ CaptureExecutor.lastTokenSeen = undefined;
153
+ });
154
+
155
+ afterEach(() => {
156
+ if (workflowId) {
157
+ try {
158
+ deleteWorkflow(workflowId);
159
+ } catch {
160
+ // ignore
161
+ }
162
+ workflowId = null;
163
+ }
164
+ });
165
+
166
+ test("ctx.input[secretKey] is redacted in workflow_run_steps but real value reaches the executor", async () => {
167
+ // Seed swarm config with a "secret" value
168
+ const SECRET_VALUE = "ghp_supersecret_token_value_abc123";
169
+ upsertSwarmConfig({
170
+ scope: "global",
171
+ key: "TEST_REDACTION_GITHUB_TOKEN",
172
+ value: SECRET_VALUE,
173
+ isSecret: true,
174
+ });
175
+
176
+ const def: WorkflowDefinition = {
177
+ nodes: [
178
+ {
179
+ id: "capture-node",
180
+ type: "capture",
181
+ config: { tokenSeen: "{{input.GITHUB_TOKEN}}" },
182
+ },
183
+ ],
184
+ };
185
+ const workflow = createWorkflow({
186
+ name: `redaction-test-${Date.now()}`,
187
+ definition: def,
188
+ triggers: [],
189
+ input: {
190
+ GITHUB_TOKEN: "secret.TEST_REDACTION_GITHUB_TOKEN",
191
+ plain: "not-a-secret",
192
+ },
193
+ });
194
+ workflowId = workflow.id;
195
+
196
+ const registry = new ExecutorRegistry();
197
+ const deps: ExecutorDependencies = {
198
+ db: {} as typeof import("../be/db"),
199
+ eventBus: { emit: () => {}, on: () => {}, off: () => {} },
200
+ interpolate: (t: string) => t,
201
+ };
202
+ registry.register(new CaptureExecutor(deps));
203
+
204
+ const runId = await startWorkflowExecution(workflow, {}, registry);
205
+ expect(runId).toBeDefined();
206
+
207
+ // Executor must see the REAL secret value (interpolated from live ctx)
208
+ expect(CaptureExecutor.lastTokenSeen).toBe(SECRET_VALUE);
209
+
210
+ // Persisted step.input must have the secret redacted
211
+ const steps = getWorkflowRunStepsByRunId(runId);
212
+ expect(steps.length).toBe(1);
213
+ const persistedInput = steps[0]!.input as Record<string, unknown>;
214
+ const persistedInputBlock = persistedInput.input as Record<string, unknown>;
215
+ expect(persistedInputBlock.GITHUB_TOKEN).toBe(REDACTED_SECRET_VALUE);
216
+ // Non-secret key untouched
217
+ expect(persistedInputBlock.plain).toBe("not-a-secret");
218
+ // The real secret string must NOT appear anywhere in the persisted JSON
219
+ const serialized = JSON.stringify(steps[0]);
220
+ expect(serialized.includes(SECRET_VALUE)).toBe(false);
221
+ });
222
+ });
223
+
224
+ describe("resolveInputs (unchanged behavior)", () => {
225
+ test("still resolves env-var references", () => {
226
+ process.env.TEST_REDACT_VAR = "resolved";
227
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: testing env-var syntax
228
+ const out = resolveInputs({ x: "${TEST_REDACT_VAR}" });
229
+ expect(out.x).toBe("resolved");
230
+ delete process.env.TEST_REDACT_VAR;
231
+ });
232
+ });