@desplega.ai/agent-swarm 1.80.1 → 1.80.3
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/README.md +3 -0
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/agentmail/types.ts +1 -0
- package/src/be/migrations/066_scripts_args_json_schema.sql +1 -0
- package/src/be/scripts/db.ts +34 -8
- package/src/be/scripts/embeddings.ts +2 -0
- package/src/be/scripts/extract-schema.ts +55 -0
- package/src/be/scripts/typecheck.ts +7 -1
- package/src/commands/runner.ts +81 -10
- package/src/http/scripts.ts +7 -0
- package/src/http/webhooks.ts +9 -0
- package/src/http/workflows.ts +2 -15
- package/src/providers/claude-adapter.ts +1 -0
- package/src/providers/types.ts +8 -0
- package/src/scripts-runtime/eval-harness.ts +25 -1
- package/src/scripts-runtime/executors/native.ts +3 -0
- package/src/scripts-runtime/extract-args-schema.ts +69 -0
- package/src/scripts-runtime/import-allowlist.ts +1 -1
- package/src/tests/error-tracker.test.ts +44 -0
- package/src/tests/http-api-integration.test.ts +8 -4
- package/src/tests/rate-limit-event.test.ts +292 -0
- package/src/tests/scripts-http.test.ts +53 -0
- package/src/tests/scripts-runtime.test.ts +55 -0
- package/src/tests/workflow-triggers-v2.test.ts +261 -20
- package/src/types.ts +1 -0
- package/src/utils/error-tracker.ts +58 -0
- package/src/workflows/input.ts +7 -2
- package/src/workflows/triggers.ts +89 -9
|
@@ -2,7 +2,14 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import { unlink } from "node:fs/promises";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
closeDb,
|
|
7
|
+
createWorkflow,
|
|
8
|
+
getWorkflowRun,
|
|
9
|
+
initDb,
|
|
10
|
+
updateWorkflow,
|
|
11
|
+
upsertSwarmConfig,
|
|
12
|
+
} from "../be/db";
|
|
6
13
|
import type { Workflow } from "../types";
|
|
7
14
|
import { startWorkflowExecution } from "../workflows/engine";
|
|
8
15
|
import { BaseExecutor, type ExecutorResult } from "../workflows/executors/base";
|
|
@@ -118,7 +125,12 @@ describe("handleWebhookTrigger", () => {
|
|
|
118
125
|
hmac.update(body);
|
|
119
126
|
const sig = `sha256=${hmac.digest("hex")}`;
|
|
120
127
|
|
|
121
|
-
const result = await handleWebhookTrigger(
|
|
128
|
+
const result = await handleWebhookTrigger(
|
|
129
|
+
workflow.id,
|
|
130
|
+
body,
|
|
131
|
+
{ "x-hub-signature-256": sig },
|
|
132
|
+
registry,
|
|
133
|
+
);
|
|
122
134
|
|
|
123
135
|
expect(result.runId).toBeDefined();
|
|
124
136
|
expect(typeof result.runId).toBe("string");
|
|
@@ -138,8 +150,7 @@ describe("handleWebhookTrigger", () => {
|
|
|
138
150
|
await handleWebhookTrigger(
|
|
139
151
|
workflow.id,
|
|
140
152
|
'{"test":true}',
|
|
141
|
-
"sha256=invalid",
|
|
142
|
-
"sha256=invalid",
|
|
153
|
+
{ "x-hub-signature-256": "sha256=invalid" },
|
|
143
154
|
registry,
|
|
144
155
|
);
|
|
145
156
|
expect(true).toBe(false); // Should not reach here
|
|
@@ -155,7 +166,7 @@ describe("handleWebhookTrigger", () => {
|
|
|
155
166
|
});
|
|
156
167
|
|
|
157
168
|
try {
|
|
158
|
-
await handleWebhookTrigger(workflow.id, '{"test":true}',
|
|
169
|
+
await handleWebhookTrigger(workflow.id, '{"test":true}', {}, registry);
|
|
159
170
|
expect(true).toBe(false);
|
|
160
171
|
} catch (err) {
|
|
161
172
|
expect(err).toBeInstanceOf(WebhookError);
|
|
@@ -168,13 +179,7 @@ describe("handleWebhookTrigger", () => {
|
|
|
168
179
|
triggers: [{ type: "webhook" }],
|
|
169
180
|
});
|
|
170
181
|
|
|
171
|
-
const result = await handleWebhookTrigger(
|
|
172
|
-
workflow.id,
|
|
173
|
-
'{"data":"hello"}',
|
|
174
|
-
undefined,
|
|
175
|
-
undefined,
|
|
176
|
-
registry,
|
|
177
|
-
);
|
|
182
|
+
const result = await handleWebhookTrigger(workflow.id, '{"data":"hello"}', {}, registry);
|
|
178
183
|
|
|
179
184
|
expect(result.runId).toBeDefined();
|
|
180
185
|
const run = getWorkflowRun(result.runId);
|
|
@@ -183,13 +188,7 @@ describe("handleWebhookTrigger", () => {
|
|
|
183
188
|
|
|
184
189
|
test("workflow not found returns 404", async () => {
|
|
185
190
|
try {
|
|
186
|
-
await handleWebhookTrigger(
|
|
187
|
-
"00000000-0000-0000-0000-000000000000",
|
|
188
|
-
"{}",
|
|
189
|
-
undefined,
|
|
190
|
-
undefined,
|
|
191
|
-
registry,
|
|
192
|
-
);
|
|
191
|
+
await handleWebhookTrigger("00000000-0000-0000-0000-000000000000", "{}", {}, registry);
|
|
193
192
|
expect(true).toBe(false);
|
|
194
193
|
} catch (err) {
|
|
195
194
|
expect(err).toBeInstanceOf(WebhookError);
|
|
@@ -205,7 +204,7 @@ describe("handleWebhookTrigger", () => {
|
|
|
205
204
|
updateWorkflow(workflow.id, { enabled: false });
|
|
206
205
|
|
|
207
206
|
try {
|
|
208
|
-
await handleWebhookTrigger(workflow.id, "{}",
|
|
207
|
+
await handleWebhookTrigger(workflow.id, "{}", {}, registry);
|
|
209
208
|
expect(true).toBe(false);
|
|
210
209
|
} catch (err) {
|
|
211
210
|
expect(err).toBeInstanceOf(WebhookError);
|
|
@@ -214,6 +213,248 @@ describe("handleWebhookTrigger", () => {
|
|
|
214
213
|
});
|
|
215
214
|
});
|
|
216
215
|
|
|
216
|
+
// ─── Custom HMAC header + secret refs ───────────────────────
|
|
217
|
+
|
|
218
|
+
describe("handleWebhookTrigger — custom hmacHeader", () => {
|
|
219
|
+
function signRaw(secret: string, body: string): string {
|
|
220
|
+
return crypto.createHmac("sha256", secret).update(body).digest("hex");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
test("custom hmacHeader (X-Webhook-Signature) is picked up and verified", async () => {
|
|
224
|
+
const secret = "kapso-secret";
|
|
225
|
+
const workflow = makeWorkflow({
|
|
226
|
+
triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const body = '{"event":"message"}';
|
|
230
|
+
// Kapso-style: raw hex, no `sha256=` prefix.
|
|
231
|
+
const result = await handleWebhookTrigger(
|
|
232
|
+
workflow.id,
|
|
233
|
+
body,
|
|
234
|
+
{ "x-webhook-signature": signRaw(secret, body) },
|
|
235
|
+
registry,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(result.runId).toBeDefined();
|
|
239
|
+
expect(getWorkflowRun(result.runId)).not.toBeNull();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("custom hmacHeader lookup is case-insensitive", async () => {
|
|
243
|
+
const secret = "kapso-secret-ci";
|
|
244
|
+
const workflow = makeWorkflow({
|
|
245
|
+
triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const body = '{"event":"ci"}';
|
|
249
|
+
const result = await handleWebhookTrigger(
|
|
250
|
+
workflow.id,
|
|
251
|
+
body,
|
|
252
|
+
{ "X-Webhook-Signature": signRaw(secret, body) },
|
|
253
|
+
registry,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(result.runId).toBeDefined();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("signature on a non-configured header is rejected as missing", async () => {
|
|
260
|
+
const secret = "kapso-secret-2";
|
|
261
|
+
const workflow = makeWorkflow({
|
|
262
|
+
triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const body = '{"event":"x"}';
|
|
266
|
+
// Use a header that is neither the configured one nor a known fallback.
|
|
267
|
+
try {
|
|
268
|
+
await handleWebhookTrigger(
|
|
269
|
+
workflow.id,
|
|
270
|
+
body,
|
|
271
|
+
{ "x-some-other-header": signRaw(secret, body) },
|
|
272
|
+
registry,
|
|
273
|
+
);
|
|
274
|
+
expect(true).toBe(false);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
expect(err).toBeInstanceOf(WebhookError);
|
|
277
|
+
expect((err as WebhookError).statusCode).toBe(401);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("fallback header (x-signature) still works without explicit hmacHeader", async () => {
|
|
282
|
+
const secret = "fallback-secret";
|
|
283
|
+
const workflow = makeWorkflow({
|
|
284
|
+
triggers: [{ type: "webhook", hmacSecret: secret }],
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const body = '{"event":"fallback"}';
|
|
288
|
+
const result = await handleWebhookTrigger(
|
|
289
|
+
workflow.id,
|
|
290
|
+
body,
|
|
291
|
+
{ "x-signature": signRaw(secret, body) },
|
|
292
|
+
registry,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
expect(result.runId).toBeDefined();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("default X-Hub-Signature-256 path still works (no regression)", async () => {
|
|
299
|
+
const secret = "default-header-secret";
|
|
300
|
+
const workflow = makeWorkflow({
|
|
301
|
+
triggers: [{ type: "webhook", hmacSecret: secret }],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const body = '{"event":"default"}';
|
|
305
|
+
const sig = `sha256=${signRaw(secret, body)}`;
|
|
306
|
+
const result = await handleWebhookTrigger(
|
|
307
|
+
workflow.id,
|
|
308
|
+
body,
|
|
309
|
+
{ "x-hub-signature-256": sig },
|
|
310
|
+
registry,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
expect(result.runId).toBeDefined();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe("handleWebhookTrigger — hmacSecret references", () => {
|
|
318
|
+
function signRaw(secret: string, body: string): string {
|
|
319
|
+
return crypto.createHmac("sha256", secret).update(body).digest("hex");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
test("hmacSecret as secret.NAME ref resolves and verifies", async () => {
|
|
323
|
+
const SECRET_VALUE = "resolved-kapso-hmac-value";
|
|
324
|
+
upsertSwarmConfig({
|
|
325
|
+
scope: "global",
|
|
326
|
+
key: "TEST_KAPSO_WEBHOOK_HMAC_SECRET",
|
|
327
|
+
value: SECRET_VALUE,
|
|
328
|
+
isSecret: true,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const workflow = makeWorkflow({
|
|
332
|
+
triggers: [
|
|
333
|
+
{
|
|
334
|
+
type: "webhook",
|
|
335
|
+
hmacSecret: "secret.TEST_KAPSO_WEBHOOK_HMAC_SECRET",
|
|
336
|
+
hmacHeader: "X-Webhook-Signature",
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const body = '{"event":"secret-ref"}';
|
|
342
|
+
const result = await handleWebhookTrigger(
|
|
343
|
+
workflow.id,
|
|
344
|
+
body,
|
|
345
|
+
{ "x-webhook-signature": signRaw(SECRET_VALUE, body) },
|
|
346
|
+
registry,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
expect(result.runId).toBeDefined();
|
|
350
|
+
expect(getWorkflowRun(result.runId)).not.toBeNull();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("unresolvable secret.NAME ref fails cleanly with a WebhookError", async () => {
|
|
354
|
+
const workflow = makeWorkflow({
|
|
355
|
+
triggers: [{ type: "webhook", hmacSecret: "secret.NONEXISTENT_HMAC_SECRET_12345" }],
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const body = '{"event":"missing-secret"}';
|
|
359
|
+
try {
|
|
360
|
+
await handleWebhookTrigger(
|
|
361
|
+
workflow.id,
|
|
362
|
+
body,
|
|
363
|
+
{ "x-hub-signature-256": "deadbeef" },
|
|
364
|
+
registry,
|
|
365
|
+
);
|
|
366
|
+
expect(true).toBe(false);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
expect(err).toBeInstanceOf(WebhookError);
|
|
369
|
+
expect((err as WebhookError).statusCode).toBe(500);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("a literal hmacSecret is not treated as a reference", async () => {
|
|
374
|
+
const secret = "plain.literal-not-a-ref";
|
|
375
|
+
const workflow = makeWorkflow({
|
|
376
|
+
triggers: [{ type: "webhook", hmacSecret: secret }],
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const body = '{"event":"literal"}';
|
|
380
|
+
const result = await handleWebhookTrigger(
|
|
381
|
+
workflow.id,
|
|
382
|
+
body,
|
|
383
|
+
{ "x-hub-signature-256": signRaw(secret, body) },
|
|
384
|
+
registry,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
expect(result.runId).toBeDefined();
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// ─── Trigger payload JSON parsing ───────────────────────────
|
|
392
|
+
|
|
393
|
+
describe("handleWebhookTrigger — triggerData JSON parsing", () => {
|
|
394
|
+
function signRaw(secret: string, body: string): string {
|
|
395
|
+
return crypto.createHmac("sha256", secret).update(body).digest("hex");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
test("JSON body is parsed and run.triggerData is a deep-equal object", async () => {
|
|
399
|
+
const workflow = makeWorkflow({ triggers: [{ type: "webhook" }] });
|
|
400
|
+
const payload = {
|
|
401
|
+
message: { from: "+34000111222", text: "hi" },
|
|
402
|
+
conversation: { id: "conv-abc-123" },
|
|
403
|
+
};
|
|
404
|
+
const body = JSON.stringify(payload);
|
|
405
|
+
|
|
406
|
+
const result = await handleWebhookTrigger(workflow.id, body, {}, registry);
|
|
407
|
+
|
|
408
|
+
const run = getWorkflowRun(result.runId);
|
|
409
|
+
expect(run).not.toBeNull();
|
|
410
|
+
expect(run!.triggerData).toEqual(payload);
|
|
411
|
+
// Deep paths must be reachable (this is what `{{trigger.message.from}}` needs).
|
|
412
|
+
expect((run!.triggerData as { message: { from: string } }).message.from).toBe("+34000111222");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("signed JSON body: HMAC verified against raw bytes, triggerData parsed to object", async () => {
|
|
416
|
+
const secret = "kapso-deep-secret";
|
|
417
|
+
const workflow = makeWorkflow({
|
|
418
|
+
triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
|
|
419
|
+
});
|
|
420
|
+
// Use whitespace + unsorted keys so any re-serialization would change the bytes.
|
|
421
|
+
const body = '{ "message": {"from":"+1","text":"hi"}, "id":"x" }';
|
|
422
|
+
const sig = signRaw(secret, body);
|
|
423
|
+
|
|
424
|
+
const result = await handleWebhookTrigger(
|
|
425
|
+
workflow.id,
|
|
426
|
+
body,
|
|
427
|
+
{ "x-webhook-signature": sig },
|
|
428
|
+
registry,
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
const run = getWorkflowRun(result.runId);
|
|
432
|
+
expect(run).not.toBeNull();
|
|
433
|
+
expect(run!.triggerData).toEqual({ message: { from: "+1", text: "hi" }, id: "x" });
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("non-JSON body falls back to the raw string and does not throw", async () => {
|
|
437
|
+
const workflow = makeWorkflow({ triggers: [{ type: "webhook" }] });
|
|
438
|
+
const body = "this is not json at all";
|
|
439
|
+
|
|
440
|
+
const result = await handleWebhookTrigger(workflow.id, body, {}, registry);
|
|
441
|
+
|
|
442
|
+
const run = getWorkflowRun(result.runId);
|
|
443
|
+
expect(run).not.toBeNull();
|
|
444
|
+
expect(run!.triggerData).toBe(body);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("empty body produces a run without throwing", async () => {
|
|
448
|
+
const workflow = makeWorkflow({ triggers: [{ type: "webhook" }] });
|
|
449
|
+
|
|
450
|
+
const result = await handleWebhookTrigger(workflow.id, "", {}, registry);
|
|
451
|
+
|
|
452
|
+
expect(result.runId).toBeDefined();
|
|
453
|
+
const run = getWorkflowRun(result.runId);
|
|
454
|
+
expect(run).not.toBeNull();
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
217
458
|
// ─── Manual Trigger ─────────────────────────────────────────
|
|
218
459
|
|
|
219
460
|
describe("manual trigger (startWorkflowExecution)", () => {
|
package/src/types.ts
CHANGED
|
@@ -1320,6 +1320,7 @@ export const ScriptRecordSchema = z.object({
|
|
|
1320
1320
|
description: z.string(),
|
|
1321
1321
|
intent: z.string(),
|
|
1322
1322
|
signatureJson: z.string(),
|
|
1323
|
+
argsJsonSchema: z.string().nullable(),
|
|
1323
1324
|
contentHash: z.string(),
|
|
1324
1325
|
version: z.number(),
|
|
1325
1326
|
isScratch: z.boolean(),
|
|
@@ -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;
|
package/src/workflows/input.ts
CHANGED
|
@@ -13,13 +13,18 @@ export function resolveInputs(input: Record<string, string>): Record<string, str
|
|
|
13
13
|
const resolved: Record<string, string> = {};
|
|
14
14
|
|
|
15
15
|
for (const [key, value] of Object.entries(input)) {
|
|
16
|
-
resolved[key] =
|
|
16
|
+
resolved[key] = resolveInputValue(value);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
return resolved;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a single input value, supporting `${ENV_VAR}` and `secret.NAME`
|
|
24
|
+
* references. A plain string is returned unchanged. Throws if a referenced
|
|
25
|
+
* env var or swarm secret cannot be found.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveInputValue(value: string): string {
|
|
23
28
|
// Env var reference: ${MY_VAR}
|
|
24
29
|
const envMatch = /^\$\{(.+)\}$/.exec(value);
|
|
25
30
|
if (envMatch?.[1]) {
|
|
@@ -3,19 +3,75 @@ import { getWorkflow, getWorkflowsByScheduleId } from "../be/db";
|
|
|
3
3
|
import type { ScheduledTask, TriggerConfig } from "../types";
|
|
4
4
|
import { startWorkflowExecution } from "./engine";
|
|
5
5
|
import type { ExecutorRegistry } from "./executors/registry";
|
|
6
|
+
import { resolveInputValue } from "./input";
|
|
7
|
+
|
|
8
|
+
/** Header name used to look up an HMAC signature when the trigger configures none. */
|
|
9
|
+
const DEFAULT_HMAC_HEADER = "X-Hub-Signature-256";
|
|
10
|
+
|
|
11
|
+
/** Fallback header names checked (case-insensitive) after the trigger's configured header. */
|
|
12
|
+
const FALLBACK_HMAC_HEADERS = ["x-hub-signature-256", "x-signature", "x-webhook-signature"];
|
|
13
|
+
|
|
14
|
+
/** A bag of HTTP headers — values may be a string, a string array, or absent. */
|
|
15
|
+
export type HeaderBag = Record<string, string | string[] | undefined>;
|
|
16
|
+
|
|
17
|
+
/** Case-insensitive header lookup; returns the first value when an array is given. */
|
|
18
|
+
function getHeader(headers: HeaderBag, name: string): string | undefined {
|
|
19
|
+
const target = name.toLowerCase();
|
|
20
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
21
|
+
if (key.toLowerCase() === target) {
|
|
22
|
+
return Array.isArray(value) ? value[0] : value;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the HMAC signature from request headers. Checks the trigger's
|
|
30
|
+
* configured `hmacHeader` first, then well-known fallback header names.
|
|
31
|
+
*/
|
|
32
|
+
function resolveSignature(headers: HeaderBag, hmacHeader: string): string | undefined {
|
|
33
|
+
for (const name of [hmacHeader, ...FALLBACK_HMAC_HEADERS]) {
|
|
34
|
+
const value = getHeader(headers, name);
|
|
35
|
+
if (value) return value;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the configured `hmacSecret`. Supports `secret.NAME` swarm-secret refs
|
|
42
|
+
* and `${ENV_VAR}` env refs (reusing the workflow input resolver); a plain
|
|
43
|
+
* string is treated as a literal. Resolved per request, never at create time.
|
|
44
|
+
*/
|
|
45
|
+
function resolveHmacSecret(raw: string): string {
|
|
46
|
+
if (/^secret\..+$/.test(raw) || /^\$\{.+\}$/.test(raw)) {
|
|
47
|
+
try {
|
|
48
|
+
return resolveInputValue(raw);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new WebhookError(
|
|
51
|
+
`Failed to resolve webhook HMAC secret: ${err instanceof Error ? err.message : String(err)}`,
|
|
52
|
+
500,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
6
58
|
|
|
7
59
|
/**
|
|
8
60
|
* Handle an incoming webhook trigger for a workflow.
|
|
9
61
|
*
|
|
10
62
|
* 1. Loads the workflow and finds a webhook trigger in `triggers[]`
|
|
11
|
-
* 2. If `hmacSecret` is set,
|
|
12
|
-
*
|
|
63
|
+
* 2. If `hmacSecret` is set, resolves the signature header + secret and
|
|
64
|
+
* verifies the HMAC-SHA256 signature against the raw body bytes
|
|
65
|
+
* 3. Parses the raw body as JSON (falling back to the raw string when the
|
|
66
|
+
* body is non-JSON) so downstream `{{trigger.deep.path}}` interpolation
|
|
67
|
+
* can traverse the object — matches the shape produced by the
|
|
68
|
+
* `trigger-workflow` MCP tool.
|
|
69
|
+
* 4. Starts the workflow execution with the parsed payload
|
|
13
70
|
*/
|
|
14
71
|
export async function handleWebhookTrigger(
|
|
15
72
|
workflowId: string,
|
|
16
73
|
payload: unknown,
|
|
17
|
-
|
|
18
|
-
signatureHeader: string | undefined,
|
|
74
|
+
headers: HeaderBag,
|
|
19
75
|
registry: ExecutorRegistry,
|
|
20
76
|
): Promise<{ runId: string }> {
|
|
21
77
|
const workflow = getWorkflow(workflowId);
|
|
@@ -31,16 +87,20 @@ export async function handleWebhookTrigger(
|
|
|
31
87
|
const webhookTrigger = workflow.triggers.find((t: TriggerConfig) => t.type === "webhook");
|
|
32
88
|
|
|
33
89
|
// If the workflow has a webhook trigger with an hmacSecret, verify the signature
|
|
90
|
+
// against the RAW body bytes — re-serializing would change whitespace / key order
|
|
91
|
+
// and break the HMAC.
|
|
34
92
|
if (webhookTrigger && webhookTrigger.type === "webhook" && webhookTrigger.hmacSecret) {
|
|
35
|
-
|
|
93
|
+
const hmacHeader = webhookTrigger.hmacHeader || DEFAULT_HMAC_HEADER;
|
|
94
|
+
const signature = resolveSignature(headers, hmacHeader);
|
|
95
|
+
if (!signature) {
|
|
36
96
|
throw new WebhookError("Missing signature", 401);
|
|
37
97
|
}
|
|
38
98
|
|
|
39
|
-
const
|
|
99
|
+
const secret = resolveHmacSecret(webhookTrigger.hmacSecret);
|
|
40
100
|
const isValid = verifyHmacSignature(
|
|
41
|
-
|
|
101
|
+
secret,
|
|
42
102
|
typeof payload === "string" ? payload : JSON.stringify(payload),
|
|
43
|
-
|
|
103
|
+
signature,
|
|
44
104
|
);
|
|
45
105
|
|
|
46
106
|
if (!isValid) {
|
|
@@ -48,10 +108,30 @@ export async function handleWebhookTrigger(
|
|
|
48
108
|
}
|
|
49
109
|
}
|
|
50
110
|
|
|
51
|
-
|
|
111
|
+
// Parse the raw body so downstream nodes can interpolate deep paths
|
|
112
|
+
// (e.g. `{{trigger.message.from}}`). A non-JSON body falls back to the raw
|
|
113
|
+
// string so non-JSON webhooks don't break.
|
|
114
|
+
const triggerData = parseTriggerPayload(payload);
|
|
115
|
+
|
|
116
|
+
const runId = await startWorkflowExecution(workflow, triggerData, registry);
|
|
52
117
|
return { runId };
|
|
53
118
|
}
|
|
54
119
|
|
|
120
|
+
/**
|
|
121
|
+
* If `payload` is a JSON string, parse and return the resulting value;
|
|
122
|
+
* otherwise return it as-is. Empty / non-JSON strings fall back to the raw
|
|
123
|
+
* value so non-JSON webhooks (text/plain, form-encoded, etc.) still produce
|
|
124
|
+
* a usable workflow run.
|
|
125
|
+
*/
|
|
126
|
+
function parseTriggerPayload(payload: unknown): unknown {
|
|
127
|
+
if (typeof payload !== "string" || payload.length === 0) return payload;
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(payload);
|
|
130
|
+
} catch {
|
|
131
|
+
return payload;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
55
135
|
/**
|
|
56
136
|
* Handle a schedule trigger: find workflows linked to this schedule and execute them.
|
|
57
137
|
* Returns an array of workflow run IDs. Empty array means no workflows matched
|