@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.
Files changed (100) hide show
  1. package/openapi.json +399 -14
  2. package/package.json +3 -1
  3. package/src/artifact-sdk/server.ts +2 -1
  4. package/src/be/db.ts +1 -1
  5. package/src/be/migrations/064_scripts.sql +39 -0
  6. package/src/be/migrations/065_script_embeddings.sql +7 -0
  7. package/src/be/migrations/066_scripts_args_json_schema.sql +1 -0
  8. package/src/be/scripts/db.ts +417 -0
  9. package/src/be/scripts/embeddings.ts +233 -0
  10. package/src/be/scripts/extract-schema.ts +55 -0
  11. package/src/be/scripts/maintenance.ts +9 -0
  12. package/src/be/scripts/typecheck.ts +199 -0
  13. package/src/cli.tsx +22 -5
  14. package/src/commands/artifact.ts +3 -2
  15. package/src/commands/claude-managed-setup.ts +2 -1
  16. package/src/commands/codex-login.ts +5 -3
  17. package/src/commands/onboard.tsx +2 -1
  18. package/src/commands/runner.ts +153 -20
  19. package/src/commands/setup.tsx +5 -3
  20. package/src/hooks/hook.ts +4 -3
  21. package/src/http/index.ts +40 -29
  22. package/src/http/memory.ts +28 -0
  23. package/src/http/openapi.ts +1 -0
  24. package/src/http/page-proxy.ts +2 -1
  25. package/src/http/route-def.ts +1 -0
  26. package/src/http/schedules.ts +37 -0
  27. package/src/http/scripts.ts +388 -0
  28. package/src/linear/outbound.ts +9 -2
  29. package/src/otel.ts +5 -0
  30. package/src/providers/claude-adapter.ts +23 -1
  31. package/src/providers/types.ts +8 -0
  32. package/src/scripts-runtime/ctx.ts +23 -0
  33. package/src/scripts-runtime/eval-harness.ts +63 -0
  34. package/src/scripts-runtime/executors/native.ts +232 -0
  35. package/src/scripts-runtime/executors/registry.ts +16 -0
  36. package/src/scripts-runtime/executors/types.ts +63 -0
  37. package/src/scripts-runtime/extract-args-schema.ts +69 -0
  38. package/src/scripts-runtime/extract-signature.ts +81 -0
  39. package/src/scripts-runtime/import-allowlist.ts +109 -0
  40. package/src/scripts-runtime/loader.ts +96 -0
  41. package/src/scripts-runtime/redacted.ts +48 -0
  42. package/src/scripts-runtime/sdk-allowlist.ts +29 -0
  43. package/src/scripts-runtime/stdlib/fetch.ts +46 -0
  44. package/src/scripts-runtime/stdlib/glob.ts +8 -0
  45. package/src/scripts-runtime/stdlib/grep.ts +34 -0
  46. package/src/scripts-runtime/stdlib/index.ts +16 -0
  47. package/src/scripts-runtime/stdlib/table.ts +17 -0
  48. package/src/scripts-runtime/swarm-config.ts +35 -0
  49. package/src/scripts-runtime/swarm-sdk.ts +197 -0
  50. package/src/scripts-runtime/types/stdlib.d.ts +104 -0
  51. package/src/scripts-runtime/types/swarm-sdk.d.ts +86 -0
  52. package/src/server.ts +12 -0
  53. package/src/tests/api-key.test.ts +33 -0
  54. package/src/tests/codex-login.test.ts +1 -1
  55. package/src/tests/error-tracker.test.ts +44 -0
  56. package/src/tests/linear-outbound-sync.test.ts +109 -0
  57. package/src/tests/mcp-tools.test.ts +69 -0
  58. package/src/tests/rate-limit-event.test.ts +292 -0
  59. package/src/tests/redacted.test.ts +29 -0
  60. package/src/tests/runner-tool-spans.test.ts +268 -0
  61. package/src/tests/script-executor-conformance.test.ts +142 -0
  62. package/src/tests/script-executor-registry.test.ts +17 -0
  63. package/src/tests/scripts-db.test.ts +329 -0
  64. package/src/tests/scripts-embeddings.test.ts +291 -0
  65. package/src/tests/scripts-extract-signature.test.ts +47 -0
  66. package/src/tests/scripts-http.test.ts +403 -0
  67. package/src/tests/scripts-import-allowlist.test.ts +55 -0
  68. package/src/tests/scripts-mcp-e2e.test.ts +269 -0
  69. package/src/tests/scripts-runtime-secret-egress.test.ts +44 -0
  70. package/src/tests/scripts-runtime.test.ts +344 -0
  71. package/src/tests/sdk-allowlist.test.ts +59 -0
  72. package/src/tests/secret-scrubber.test.ts +35 -1
  73. package/src/tests/swarm-config.test.ts +38 -0
  74. package/src/tests/tool-annotations.test.ts +2 -2
  75. package/src/tests/tool-call-progress.test.ts +30 -0
  76. package/src/tests/workflow-e2e.test.ts +218 -0
  77. package/src/tests/workflow-executors.test.ts +32 -2
  78. package/src/tests/workflow-input-redaction.test.ts +232 -0
  79. package/src/tests/workflow-swarm-script.test.ts +273 -0
  80. package/src/tools/memory-rate.ts +2 -1
  81. package/src/tools/script-common.ts +88 -0
  82. package/src/tools/script-delete.ts +35 -0
  83. package/src/tools/script-query-types.ts +37 -0
  84. package/src/tools/script-run.ts +43 -0
  85. package/src/tools/script-search.ts +32 -0
  86. package/src/tools/script-upsert.ts +43 -0
  87. package/src/tools/tool-config.ts +7 -0
  88. package/src/types.ts +61 -1
  89. package/src/utils/api-key.ts +28 -0
  90. package/src/utils/error-tracker.ts +58 -0
  91. package/src/utils/page-session.ts +8 -6
  92. package/src/utils/secret-scrubber.ts +22 -1
  93. package/src/workflows/engine.ts +12 -4
  94. package/src/workflows/executors/index.ts +1 -0
  95. package/src/workflows/executors/registry.ts +2 -0
  96. package/src/workflows/executors/script.ts +12 -1
  97. package/src/workflows/executors/swarm-script.ts +170 -0
  98. package/src/workflows/input.ts +65 -0
  99. package/src/workflows/recovery.ts +31 -3
  100. package/src/workflows/resume.ts +43 -5
@@ -0,0 +1,43 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { createToolRegistrar } from "@/tools/utils";
4
+ import {
5
+ proxyScriptsApi,
6
+ scriptFsModeSchema,
7
+ scriptNameSchema,
8
+ scriptScopeSchema,
9
+ scriptToolOutputSchema,
10
+ } from "./script-common";
11
+
12
+ export const SCRIPT_RUN_DESCRIPTION =
13
+ "Run a named swarm-shared script (callable across agents and from workflow `swarm-script` nodes), OR inline source (auto-saved as scratch to the catalog). Use for swarm-visible, durable scripts. For local-only throwaway TS, use code-mode `run`.";
14
+
15
+ export const registerScriptRunTool = (server: McpServer) => {
16
+ createToolRegistrar(server)(
17
+ "script-run",
18
+ {
19
+ title: "Script Run",
20
+ description: SCRIPT_RUN_DESCRIPTION,
21
+ annotations: { openWorldHint: true },
22
+ inputSchema: z.object({
23
+ name: scriptNameSchema.optional().describe("Name of a reusable script to run."),
24
+ source: z.string().min(1).optional().describe("Inline TypeScript source to run."),
25
+ args: z.unknown().optional().describe("JSON-serializable script arguments."),
26
+ intent: z.string().default("").describe("Why this script is being run."),
27
+ scope: scriptScopeSchema.optional().describe("Optional scope for named script resolution."),
28
+ fsMode: scriptFsModeSchema
29
+ .default("none")
30
+ .describe("Filesystem mode. v1 supports none only."),
31
+ }),
32
+ outputSchema: scriptToolOutputSchema,
33
+ },
34
+ async (args, requestInfo) =>
35
+ proxyScriptsApi({
36
+ method: "POST",
37
+ path: "/api/scripts/run",
38
+ body: args,
39
+ requestInfo,
40
+ successMessage: () => "Script run completed.",
41
+ }),
42
+ );
43
+ };
@@ -0,0 +1,32 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { createToolRegistrar } from "@/tools/utils";
4
+ import { proxyScriptsApi, scriptScopeSchema, scriptToolOutputSchema } from "./script-common";
5
+
6
+ export const SCRIPT_SEARCH_DESCRIPTION =
7
+ "Semantic search over swarm-shared TypeScript scripts (catalog persisted in the agent-swarm DB; callable from agents and workflows). For ephemeral throwaway TS on your local machine, use code-mode instead.";
8
+
9
+ export const registerScriptSearchTool = (server: McpServer) => {
10
+ createToolRegistrar(server)(
11
+ "script-search",
12
+ {
13
+ title: "Script Search",
14
+ description: SCRIPT_SEARCH_DESCRIPTION,
15
+ annotations: { readOnlyHint: true, openWorldHint: false },
16
+ inputSchema: z.object({
17
+ query: z.string().default("").describe("Search query for reusable scripts."),
18
+ scope: scriptScopeSchema.optional().describe("Optional script scope filter."),
19
+ limit: z.number().int().min(1).max(100).default(10).describe("Maximum results."),
20
+ }),
21
+ outputSchema: scriptToolOutputSchema,
22
+ },
23
+ async (args, requestInfo) =>
24
+ proxyScriptsApi({
25
+ method: "POST",
26
+ path: "/api/scripts/search",
27
+ body: args,
28
+ requestInfo,
29
+ successMessage: () => "Script search completed.",
30
+ }),
31
+ );
32
+ };
@@ -0,0 +1,43 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { createToolRegistrar } from "@/tools/utils";
4
+ import {
5
+ proxyScriptsApi,
6
+ scriptFsModeSchema,
7
+ scriptNameSchema,
8
+ scriptScopeSchema,
9
+ scriptToolOutputSchema,
10
+ } from "./script-common";
11
+
12
+ export const SCRIPT_UPSERT_DESCRIPTION =
13
+ "Persist a TypeScript script to the swarm catalog under your agent scope (or global if you're a lead). Other agents and workflow nodes will be able to find and run it. For local-only scripts, use code-mode `save`.";
14
+
15
+ export const registerScriptUpsertTool = (server: McpServer) => {
16
+ createToolRegistrar(server)(
17
+ "script-upsert",
18
+ {
19
+ title: "Script Upsert",
20
+ description: SCRIPT_UPSERT_DESCRIPTION,
21
+ annotations: { openWorldHint: false },
22
+ inputSchema: z.object({
23
+ name: scriptNameSchema.describe("Stable script name within the selected scope."),
24
+ source: z.string().min(1).describe("TypeScript source with a default export function."),
25
+ description: z.string().default("").describe("Human-readable script description."),
26
+ intent: z.string().default("").describe("Why this script exists."),
27
+ scope: scriptScopeSchema.default("agent").describe("Persist under agent or global scope."),
28
+ fsMode: scriptFsModeSchema
29
+ .default("none")
30
+ .describe("Filesystem mode. v1 supports none only."),
31
+ }),
32
+ outputSchema: scriptToolOutputSchema,
33
+ },
34
+ async (args, requestInfo) =>
35
+ proxyScriptsApi({
36
+ method: "POST",
37
+ path: "/api/scripts/upsert",
38
+ body: args,
39
+ requestInfo,
40
+ successMessage: () => "Script upsert completed.",
41
+ }),
42
+ );
43
+ };
@@ -154,6 +154,13 @@ export const DEFERRED_TOOLS = new Set([
154
154
  "kv-incr",
155
155
  "kv-list",
156
156
 
157
+ // Reusable scripts (5)
158
+ "script-search",
159
+ "script-run",
160
+ "script-upsert",
161
+ "script-delete",
162
+ "script-query-types",
163
+
157
164
  // Other (3)
158
165
  "cancel-task",
159
166
  "inject-learning",
package/src/types.ts CHANGED
@@ -660,6 +660,8 @@ export const EventNameSchema = z.enum([
660
660
  "system.boot",
661
661
  "system.migration",
662
662
  "system.error",
663
+ // Script catalog events
664
+ "script.global_upsert",
663
665
  ]);
664
666
 
665
667
  export const SwarmEventSchema = z.object({
@@ -876,18 +878,30 @@ export const StepValidationConfigSchema = z.object({
876
878
  });
877
879
  export type StepValidationConfig = z.infer<typeof StepValidationConfigSchema>;
878
880
 
881
+ export const SwarmScriptNodeConfigSchema = z.object({
882
+ scriptName: z.string().min(1),
883
+ scope: z.enum(["global", "agent"]).optional(),
884
+ pinHash: z.string().min(1).optional(),
885
+ args: z.record(z.string(), z.unknown()).optional(),
886
+ fsMode: z.enum(["none", "workspace-rw"]).optional(),
887
+ });
888
+ export type SwarmScriptNodeConfig = z.infer<typeof SwarmScriptNodeConfigSchema>;
889
+
879
890
  // --- Workflow Node (nodes-with-next) ---
880
891
 
881
892
  export const WorkflowNodeSchema = z.object({
882
893
  id: z.string().describe("Unique node identifier, used in 'next' and 'inputs' mappings"),
883
894
  type: z
884
895
  .string()
885
- .describe("Executor type: 'agent-task', 'script', 'raw-llm', 'validate', 'property-match'"),
896
+ .describe(
897
+ "Executor type: 'agent-task', 'script', 'swarm-script', 'raw-llm', 'validate', 'property-match'",
898
+ ),
886
899
  label: z.string().optional().describe("Human-readable label for UI display"),
887
900
  config: z
888
901
  .record(z.string(), z.unknown())
889
902
  .describe(
890
903
  "Executor-specific config. For agent-task: { template, outputSchema?, agentId?, tags?, priority?, dir?, vcsRepo?, model? }. " +
904
+ "For swarm-script: { scriptName, scope?, pinHash?, args?, fsMode? }. " +
891
905
  "Values support {{interpolation}} from the node's inputs context. " +
892
906
  "NOTE: config.outputSchema on agent-task nodes validates the AGENT's raw JSON output, " +
893
907
  "while node-level outputSchema validates the EXECUTOR's return value ({taskId, taskOutput}).",
@@ -1287,6 +1301,52 @@ export const PromptTemplateHistorySchema = z.object({
1287
1301
  });
1288
1302
  export type PromptTemplateHistory = z.infer<typeof PromptTemplateHistorySchema>;
1289
1303
 
1304
+ // ============================================================================
1305
+ // Script Types
1306
+ // ============================================================================
1307
+
1308
+ export const ScriptScopeSchema = z.enum(["global", "agent"]);
1309
+ export type ScriptScope = z.infer<typeof ScriptScopeSchema>;
1310
+
1311
+ export const ScriptFsModeSchema = z.enum(["none", "workspace-rw"]);
1312
+ export type ScriptFsMode = z.infer<typeof ScriptFsModeSchema>;
1313
+
1314
+ export const ScriptRecordSchema = z.object({
1315
+ id: z.string(),
1316
+ name: z.string(),
1317
+ scope: ScriptScopeSchema,
1318
+ scopeId: z.string().nullable(),
1319
+ source: z.string(),
1320
+ description: z.string(),
1321
+ intent: z.string(),
1322
+ signatureJson: z.string(),
1323
+ argsJsonSchema: z.string().nullable(),
1324
+ contentHash: z.string(),
1325
+ version: z.number(),
1326
+ isScratch: z.boolean(),
1327
+ typeChecked: z.boolean(),
1328
+ fsMode: ScriptFsModeSchema,
1329
+ createdByAgentId: z.string().nullable(),
1330
+ createdAt: z.string(),
1331
+ updatedAt: z.string(),
1332
+ });
1333
+ export type ScriptRecord = z.infer<typeof ScriptRecordSchema>;
1334
+
1335
+ export const ScriptVersionRecordSchema = z.object({
1336
+ id: z.string(),
1337
+ scriptId: z.string(),
1338
+ version: z.number(),
1339
+ source: z.string(),
1340
+ description: z.string(),
1341
+ intent: z.string(),
1342
+ signatureJson: z.string(),
1343
+ contentHash: z.string(),
1344
+ changedByAgentId: z.string().nullable(),
1345
+ changedAt: z.string(),
1346
+ changeReason: z.string().nullable(),
1347
+ });
1348
+ export type ScriptVersionRecord = z.infer<typeof ScriptVersionRecordSchema>;
1349
+
1290
1350
  // ============================================================================
1291
1351
  // Skill Types
1292
1352
  // ============================================================================
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Centralized resolution of the swarm API key from the environment.
3
+ *
4
+ * Precedence:
5
+ * 1. AGENT_SWARM_API_KEY (preferred — namespaced, safe to set globally)
6
+ * 2. API_KEY (legacy — kept for back-compat with existing setups)
7
+ *
8
+ * All swarm code (CLI, server, hooks, worker, scripts) must read the key via
9
+ * `getApiKey()` so a user can configure either env var and have it work end
10
+ * to end. Direct access to `process.env.API_KEY` / `process.env.AGENT_SWARM_API_KEY`
11
+ * outside this module is enforced against by `scripts/check-api-key-boundary.sh`.
12
+ */
13
+
14
+ type EnvLike = Record<string, string | undefined>;
15
+
16
+ export function getApiKey(env: EnvLike = process.env): string {
17
+ return env.AGENT_SWARM_API_KEY ?? env.API_KEY ?? "";
18
+ }
19
+
20
+ /**
21
+ * Mirror a resolved key onto both env var names so any downstream code that
22
+ * still reads the raw env (third-party libraries, spawned subprocesses that
23
+ * inherit env, etc.) sees a consistent value.
24
+ */
25
+ export function setApiKey(key: string, env: EnvLike = process.env): void {
26
+ env.AGENT_SWARM_API_KEY = key;
27
+ env.API_KEY = key;
28
+ }
@@ -10,8 +10,21 @@ export interface ErrorSignal {
10
10
  timestamp: string;
11
11
  }
12
12
 
13
+ /**
14
+ * Clamps a candidate reset timestamp (ms) to [now+60s, now+6h].
15
+ * Protects against past timestamps (clock skew) and absurdly far future values (malformed).
16
+ */
17
+ function clampRateLimitResetMs(candidateMs: number): number {
18
+ const nowMs = Date.now();
19
+ const minMs = nowMs + 60_000;
20
+ const maxMs = nowMs + 6 * 60 * 60 * 1000;
21
+ return Math.min(Math.max(candidateMs, minMs), maxMs);
22
+ }
23
+
13
24
  export class SessionErrorTracker {
14
25
  private errors: ErrorSignal[] = [];
26
+ /** Stashed reset time (ms) from the last rejected rate_limit_event in this session. */
27
+ private rateLimitResetAtMs: number | undefined;
15
28
 
16
29
  /** Record an error from an assistant message with message.error field */
17
30
  addApiError(errorCategory: string, message: string): void {
@@ -53,6 +66,45 @@ export class SessionErrorTracker {
53
66
  });
54
67
  }
55
68
 
69
+ /**
70
+ * Process a parsed rate_limit_event JSON object from the Claude CLI stream.
71
+ * Only stashes the reset time when status === "rejected"; ignores all others.
72
+ * Last call wins — if the CLI emits multiple events, the final rejected one is used.
73
+ *
74
+ * `resetsAt` is **seconds** since epoch (empirically verified; Linear description is wrong).
75
+ * Conversion to ms happens here at this single well-named boundary.
76
+ */
77
+ processRateLimitEvent(json: Record<string, unknown>): void {
78
+ try {
79
+ const info = json.rate_limit_info as Record<string, unknown> | undefined;
80
+ if (!info) return;
81
+
82
+ if (info.status !== "rejected") return;
83
+
84
+ const resetsAtSec = info.resetsAt;
85
+ if (typeof resetsAtSec !== "number" || !Number.isFinite(resetsAtSec) || resetsAtSec <= 0) {
86
+ console.warn(
87
+ `[rate_limit_event] Malformed resetsAt value: ${JSON.stringify(resetsAtSec)} — ignoring`,
88
+ );
89
+ return;
90
+ }
91
+
92
+ const resetsAtMs = resetsAtSec * 1000;
93
+ this.rateLimitResetAtMs = clampRateLimitResetMs(resetsAtMs);
94
+ } catch (err) {
95
+ console.warn(`[rate_limit_event] Failed to process event: ${err}`);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Returns the stashed rate limit reset time as an ISO string, or undefined
101
+ * if no rejected rate_limit_event was seen in this session.
102
+ */
103
+ getRateLimitResetAt(): string | undefined {
104
+ if (this.rateLimitResetAtMs === undefined) return undefined;
105
+ return new Date(this.rateLimitResetAtMs).toISOString();
106
+ }
107
+
56
108
  hasErrors(): boolean {
57
109
  return this.errors.length > 0;
58
110
  }
@@ -137,6 +189,12 @@ export function trackErrorFromJson(
137
189
  json: Record<string, unknown>,
138
190
  tracker: SessionErrorTracker,
139
191
  ): void {
192
+ // 0. Structured rate limit event — stash resetsAt for the three-tier resolver in runner.ts
193
+ if (json.type === "rate_limit_event") {
194
+ tracker.processRateLimitEvent(json);
195
+ return;
196
+ }
197
+
140
198
  // 1. Assistant messages with API errors (rate_limit, auth, billing, etc.)
141
199
  if (json.type === "assistant") {
142
200
  const message = json.message as Record<string, unknown> | undefined;
@@ -6,14 +6,16 @@
6
6
  *
7
7
  * Cookie payload: `{pageId, exp}` where `exp` is a unix seconds timestamp.
8
8
  * Wire shape: `${base64url(JSON.stringify(payload))}.${base64url(HMAC-SHA256(payload, secret))}`.
9
- * Secret resolution: `process.env.PAGE_SESSION_SECRET || process.env.API_KEY`
10
- * — the API_KEY fallback keeps existing dev setups working without forcing a
11
- * new env var. Verification is constant-time via `crypto.timingSafeEqual` so
12
- * we don't leak bits via signature-comparison timing.
9
+ * Secret resolution: `process.env.PAGE_SESSION_SECRET || getApiKey()`
10
+ * — the swarm API-key fallback keeps existing dev setups working without
11
+ * forcing a new env var. Verification is constant-time via
12
+ * `crypto.timingSafeEqual` so we don't leak bits via signature-comparison
13
+ * timing.
13
14
  *
14
15
  * Both functions are async because `crypto.subtle.sign` is async.
15
16
  */
16
17
  import { timingSafeEqual } from "node:crypto";
18
+ import { getApiKey } from "./api-key";
17
19
 
18
20
  export interface PageSessionPayload {
19
21
  pageId: string;
@@ -43,12 +45,12 @@ function base64urlDecode(input: string): Uint8Array {
43
45
 
44
46
  /** Resolve the HMAC secret. */
45
47
  function getSecret(): string {
46
- const secret = process.env.PAGE_SESSION_SECRET || process.env.API_KEY;
48
+ const secret = process.env.PAGE_SESSION_SECRET || getApiKey();
47
49
  if (!secret) {
48
50
  // Fail-closed: better to refuse to issue cookies than to mint with an
49
51
  // empty key (any attacker who learns the implementation can forge).
50
52
  throw new Error(
51
- "page-session: neither PAGE_SESSION_SECRET nor API_KEY is set; refusing to sign/verify",
53
+ "page-session: neither PAGE_SESSION_SECRET nor swarm API key is set; refusing to sign/verify",
52
54
  );
53
55
  }
54
56
  return secret;
@@ -150,7 +150,7 @@ function snapshotEnv(): string {
150
150
  return parts.join("|");
151
151
  }
152
152
 
153
- function isSensitiveKey(key: string): boolean {
153
+ export function isSensitiveKey(key: string): boolean {
154
154
  if (NON_SECRET_EXCEPTIONS.has(key)) return false;
155
155
  if (SENSITIVE_KEY_EXACT.has(key)) return true;
156
156
  for (const suffix of SENSITIVE_KEY_SUFFIXES) {
@@ -227,6 +227,27 @@ export function scrubSecrets(text: string | null | undefined): string {
227
227
  return out;
228
228
  }
229
229
 
230
+ export function scrubObject<T>(value: T, seen = new WeakSet<object>()): T {
231
+ if (value === null || value === undefined) return value;
232
+ if (typeof value === "string") return scrubSecrets(value) as T;
233
+ if (typeof value !== "object") return value;
234
+
235
+ if (seen.has(value)) {
236
+ return "[Circular]" as T;
237
+ }
238
+ seen.add(value);
239
+
240
+ if (Array.isArray(value)) {
241
+ return value.map((item) => scrubObject(item, seen)) as T;
242
+ }
243
+
244
+ const out: Record<string, unknown> = {};
245
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
246
+ out[key] = scrubObject(child, seen);
247
+ }
248
+ return out as T;
249
+ }
250
+
230
251
  /**
231
252
  * Force the env-value cache to rebuild on the next scrub call. Callers should
232
253
  * invoke this whenever the swarm_config is reloaded (`/internal/reload-config`
@@ -16,7 +16,7 @@ import { shouldSkipCooldown } from "./cooldown";
16
16
  import { findEntryNodes, getNextTargets, getSuccessors } from "./definition";
17
17
  import type { AsyncExecutorResult } from "./executors/base";
18
18
  import type { ExecutorRegistry } from "./executors/registry";
19
- import { resolveInputs } from "./input";
19
+ import { getSecretInputKeys, redactSecretsForStorage, resolveInputs } from "./input";
20
20
  import { validateJsonSchema } from "./json-schema-validator";
21
21
  import { deepInterpolate } from "./template";
22
22
  import { runStepValidation, type ValidationRunResult } from "./validation";
@@ -97,7 +97,8 @@ export async function startWorkflowExecution(
97
97
  }
98
98
 
99
99
  const entryNodes = findEntryNodes(workflow.definition);
100
- await walkGraph(workflow.definition, runId, ctx, entryNodes, registry, workflow.id);
100
+ const secretKeys = getSecretInputKeys(workflow.input);
101
+ await walkGraph(workflow.definition, runId, ctx, entryNodes, registry, workflow.id, secretKeys);
101
102
  return runId;
102
103
  }
103
104
 
@@ -125,6 +126,7 @@ export async function walkGraph(
125
126
  startNodes: WorkflowNode[],
126
127
  registry: ExecutorRegistry,
127
128
  workflowId?: string,
129
+ secretKeys: Set<string> = new Set(),
128
130
  ): Promise<void> {
129
131
  let nodeExecutionCount = 0;
130
132
  const completedNodeIds = new Set(getCompletedStepNodeIds(runId));
@@ -232,7 +234,7 @@ export async function walkGraph(
232
234
  // Execute all pending nodes in parallel
233
235
  const results = await Promise.all(
234
236
  pendingNodes.map((node) =>
235
- executeStep(def, runId, ctx, node, registry, workflowId).catch(
237
+ executeStep(def, runId, ctx, node, registry, workflowId, secretKeys).catch(
236
238
  (_err): StepResult => ({
237
239
  outcome: "failed",
238
240
  successors: [],
@@ -382,6 +384,7 @@ async function executeStep(
382
384
  node: WorkflowNode,
383
385
  registry: ExecutorRegistry,
384
386
  workflowId?: string,
387
+ secretKeys: Set<string> = new Set(),
385
388
  ): Promise<StepResult> {
386
389
  // Use iteration-aware idempotency key to support loops.
387
390
  // Count existing steps for this node to determine the current iteration.
@@ -407,13 +410,18 @@ async function executeStep(
407
410
  }
408
411
 
409
412
  // 2. Create step
413
+ // Redact resolved secret values from the persisted input so credentials
414
+ // stored in `ctx.input` (resolved via `secret.*` or sensitive `${ENV}`
415
+ // references) don't leak through `get-workflow-run` or any other reader of
416
+ // the `workflow_run_steps` table. The live `ctx` is untouched — executors
417
+ // still see real values.
410
418
  const stepId = crypto.randomUUID();
411
419
  createWorkflowRunStep({
412
420
  id: stepId,
413
421
  runId,
414
422
  nodeId: node.id,
415
423
  nodeType: node.type,
416
- input: ctx,
424
+ input: redactSecretsForStorage(ctx, secretKeys),
417
425
  });
418
426
 
419
427
  // Set idempotency key
@@ -12,6 +12,7 @@ export { PropertyMatchExecutor } from "./property-match";
12
12
  export { RawLlmExecutor } from "./raw-llm";
13
13
  export { createExecutorRegistry, ExecutorRegistry } from "./registry";
14
14
  export { ScriptExecutor } from "./script";
15
+ export { SwarmScriptExecutor } from "./swarm-script";
15
16
  export { ValidateExecutor } from "./validate";
16
17
  export { VcsExecutor } from "./vcs";
17
18
  export { WaitExecutor } from "./wait";
@@ -7,6 +7,7 @@ import { NotifyExecutor } from "./notify";
7
7
  import { PropertyMatchExecutor } from "./property-match";
8
8
  import { RawLlmExecutor } from "./raw-llm";
9
9
  import { ScriptExecutor } from "./script";
10
+ import { SwarmScriptExecutor } from "./swarm-script";
10
11
  import { ValidateExecutor } from "./validate";
11
12
  import { VcsExecutor } from "./vcs";
12
13
  import { WaitExecutor } from "./wait";
@@ -68,6 +69,7 @@ export function createExecutorRegistry(deps: ExecutorDependencies): ExecutorRegi
68
69
  registry.register(new NotifyExecutor(deps));
69
70
  registry.register(new RawLlmExecutor(deps));
70
71
  registry.register(new ScriptExecutor(deps));
72
+ registry.register(new SwarmScriptExecutor(deps));
71
73
  registry.register(new VcsExecutor(deps));
72
74
  registry.register(new ValidateExecutor(deps));
73
75
 
@@ -72,9 +72,20 @@ export class ScriptExecutor extends BaseExecutor<
72
72
  nextPort: "success",
73
73
  };
74
74
  } catch (err) {
75
+ // Populate a structured output payload so the failure surfaces in
76
+ // get-workflow-run instead of leaving `output: null` and forcing operators
77
+ // to dig through logs. Mirrors the non-zero-exit path above and the
78
+ // litmus-gate `mustPass: false` mirror-into-output convention.
79
+ const message = err instanceof Error ? err.message : String(err);
80
+ const isTimeout = message.startsWith("Script timed out after");
75
81
  return {
76
82
  status: "failed",
77
- error: `Script execution error: ${err instanceof Error ? err.message : String(err)}`,
83
+ error: `Script execution error: ${message}`,
84
+ output: {
85
+ exitCode: -1,
86
+ stdout: "",
87
+ stderr: isTimeout ? message : `Script execution error: ${message}`,
88
+ } as z.infer<typeof ScriptOutputSchema>,
78
89
  };
79
90
  }
80
91
  }