@foldrun/core 0.4.0 → 0.6.0

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 (170) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.js +4 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/browser-pod.d.ts +148 -0
  9. package/dist/src/browser-pod.js +333 -0
  10. package/dist/src/catalog.js +2 -1
  11. package/dist/src/clock.d.ts +49 -0
  12. package/dist/src/clock.js +121 -0
  13. package/dist/src/completions.js +17 -8
  14. package/dist/src/confine.d.ts +22 -0
  15. package/dist/src/confine.js +218 -3
  16. package/dist/src/container.d.ts +9 -0
  17. package/dist/src/container.js +115 -5
  18. package/dist/src/context-tools.d.ts +1 -1
  19. package/dist/src/deploy.d.ts +33 -0
  20. package/dist/src/deploy.js +145 -6
  21. package/dist/src/egress.d.ts +7 -0
  22. package/dist/src/egress.js +6 -0
  23. package/dist/src/eol.d.ts +2 -0
  24. package/dist/src/eol.js +7 -0
  25. package/dist/src/evals.d.ts +5 -0
  26. package/dist/src/evals.js +9 -2
  27. package/dist/src/flow-lint.d.ts +8 -0
  28. package/dist/src/flow-lint.js +223 -1
  29. package/dist/src/flow-patterns.d.ts +141 -0
  30. package/dist/src/flow-patterns.js +568 -0
  31. package/dist/src/gitrepo.d.ts +18 -0
  32. package/dist/src/gitrepo.js +49 -0
  33. package/dist/src/host-env.js +5 -0
  34. package/dist/src/kinds.js +1 -1
  35. package/dist/src/language.d.ts +17 -0
  36. package/dist/src/language.js +55 -0
  37. package/dist/src/layout.d.ts +69 -0
  38. package/dist/src/layout.js +200 -0
  39. package/dist/src/library.d.ts +9 -0
  40. package/dist/src/library.js +30 -3
  41. package/dist/src/limits.d.ts +84 -0
  42. package/dist/src/limits.js +287 -0
  43. package/dist/src/locale.d.ts +47 -0
  44. package/dist/src/locale.js +177 -0
  45. package/dist/src/mail.d.ts +88 -0
  46. package/dist/src/mail.js +247 -0
  47. package/dist/src/model-credential.d.ts +14 -0
  48. package/dist/src/model-credential.js +58 -0
  49. package/dist/src/model-probe.d.ts +3 -0
  50. package/dist/src/model-probe.js +1 -0
  51. package/dist/src/notify.d.ts +53 -7
  52. package/dist/src/notify.js +194 -126
  53. package/dist/src/openapi.js +5 -3
  54. package/dist/src/operator.d.ts +201 -0
  55. package/dist/src/operator.js +251 -0
  56. package/dist/src/paths.d.ts +28 -0
  57. package/dist/src/paths.js +55 -0
  58. package/dist/src/platform.d.ts +43 -3
  59. package/dist/src/platform.js +9 -1
  60. package/dist/src/provider-health.js +3 -2
  61. package/dist/src/providers.d.ts +481 -0
  62. package/dist/src/providers.js +1105 -6
  63. package/dist/src/refusal.d.ts +83 -0
  64. package/dist/src/refusal.js +281 -0
  65. package/dist/src/run-container.d.ts +149 -13
  66. package/dist/src/run-container.js +424 -43
  67. package/dist/src/runner.d.ts +177 -9
  68. package/dist/src/runner.js +1146 -154
  69. package/dist/src/runtime.d.ts +52 -0
  70. package/dist/src/runtime.js +493 -58
  71. package/dist/src/script-tools.d.ts +17 -2
  72. package/dist/src/script-tools.js +82 -10
  73. package/dist/src/secret-files.d.ts +6 -5
  74. package/dist/src/secret-files.js +24 -11
  75. package/dist/src/secret-health.d.ts +30 -6
  76. package/dist/src/secret-health.js +42 -20
  77. package/dist/src/secrets.d.ts +19 -0
  78. package/dist/src/secrets.js +155 -86
  79. package/dist/src/starter.d.ts +9 -0
  80. package/dist/src/starter.js +92 -11
  81. package/dist/src/step-exec.d.ts +72 -2
  82. package/dist/src/step-exec.js +431 -37
  83. package/dist/src/step-removal.d.ts +15 -0
  84. package/dist/src/step-removal.js +49 -0
  85. package/dist/src/storage.d.ts +23 -4
  86. package/dist/src/storage.js +125 -23
  87. package/dist/src/store.d.ts +222 -37
  88. package/dist/src/store.js +349 -62
  89. package/dist/src/subagents.d.ts +75 -0
  90. package/dist/src/subagents.js +149 -0
  91. package/dist/src/test-mode.d.ts +17 -2
  92. package/dist/src/test-mode.js +53 -13
  93. package/dist/src/tool-names.d.ts +13 -1
  94. package/dist/src/tool-names.js +38 -11
  95. package/dist/src/tool-programs.d.ts +26 -0
  96. package/dist/src/tool-programs.js +172 -0
  97. package/dist/src/tool-test.js +104 -12
  98. package/dist/src/translator.d.ts +8 -2
  99. package/dist/src/translator.js +69 -23
  100. package/dist/src/trigger-log.d.ts +1 -1
  101. package/dist/src/triggers.d.ts +3 -0
  102. package/dist/src/triggers.js +10 -2
  103. package/dist/src/untrusted-fetch.d.ts +11 -0
  104. package/dist/src/untrusted-fetch.js +77 -0
  105. package/dist/src/web-actions.d.ts +91 -0
  106. package/dist/src/web-actions.js +199 -0
  107. package/dist/src/webhook.d.ts +4 -0
  108. package/dist/src/webhook.js +39 -0
  109. package/index.ts +4 -0
  110. package/package.json +3 -3
  111. package/src/account.ts +149 -0
  112. package/src/api-tools.ts +32 -4
  113. package/src/browser-pod.ts +350 -0
  114. package/src/catalog.ts +2 -1
  115. package/src/clock.ts +149 -0
  116. package/src/completions.ts +17 -8
  117. package/src/confine.ts +218 -3
  118. package/src/container.ts +112 -5
  119. package/src/context-tools.ts +1 -1
  120. package/src/deploy.ts +137 -6
  121. package/src/egress.ts +13 -1
  122. package/src/eol.ts +7 -0
  123. package/src/evals.ts +14 -2
  124. package/src/flow-lint.ts +253 -1
  125. package/src/flow-patterns.ts +597 -0
  126. package/src/gitrepo.ts +48 -0
  127. package/src/host-env.ts +5 -0
  128. package/src/kinds.ts +1 -1
  129. package/src/language.ts +62 -0
  130. package/src/layout.ts +228 -0
  131. package/src/library.ts +37 -3
  132. package/src/limits.ts +290 -0
  133. package/src/locale.ts +217 -0
  134. package/src/mail.ts +318 -0
  135. package/src/model-credential.ts +71 -0
  136. package/src/model-probe.ts +4 -0
  137. package/src/notify.ts +189 -103
  138. package/src/openapi.ts +5 -3
  139. package/src/operator.ts +300 -0
  140. package/src/paths.ts +54 -0
  141. package/src/platform.ts +52 -3
  142. package/src/provider-health.ts +3 -2
  143. package/src/providers.ts +1368 -6
  144. package/src/refusal.ts +320 -0
  145. package/src/run-container.ts +460 -44
  146. package/src/runner.ts +1168 -155
  147. package/src/runtime.ts +487 -55
  148. package/src/script-tools.ts +81 -10
  149. package/src/secret-files.ts +22 -10
  150. package/src/secret-health.ts +64 -19
  151. package/src/secrets.ts +149 -87
  152. package/src/starter.ts +93 -11
  153. package/src/step-exec.ts +447 -38
  154. package/src/step-removal.ts +59 -0
  155. package/src/storage.ts +141 -22
  156. package/src/store.ts +506 -70
  157. package/src/subagents.ts +189 -0
  158. package/src/test-mode.ts +51 -11
  159. package/src/tool-names.ts +40 -11
  160. package/src/tool-programs.ts +179 -0
  161. package/src/tool-test.ts +110 -12
  162. package/src/translator.ts +72 -22
  163. package/src/trigger-log.ts +1 -0
  164. package/src/triggers.ts +10 -2
  165. package/src/untrusted-fetch.ts +88 -0
  166. package/src/web-actions.ts +237 -0
  167. package/src/webhook.ts +41 -0
  168. package/templates/blog-desk/agents/writer/agent.md +1 -1
  169. package/templates/hello/agents/curator/agent.md +4 -4
  170. package/templates/hello/agents/notetaker/agent.md +3 -3
package/src/notify.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  //
11
11
  // notify:
12
12
  // url: ${SLACK_WEBHOOK_URL} # a secret name, or a literal URL
13
- // events: [failed, awaiting-approval, completed]
13
+ // events: [failed, awaiting-approval, completed, blocked]
14
14
  //
15
15
  // Or, for people who live in an inbox rather than a channel:
16
16
  //
@@ -33,26 +33,33 @@
33
33
 
34
34
  import crypto from "node:crypto";
35
35
  import { readAgentsMd } from "./runner.ts";
36
- import { accountDir, workspaceDir, runCost, readFlow, type RunRecord } from "./store.ts";
36
+ import { accountDir, workspaceDir, runCost, readFlow, runVerdict, type RunRecord } from "./store.ts";
37
37
  import { getSecret } from "./secrets.ts";
38
38
  import { publicUrl } from "./webhook.ts";
39
39
  import { approveLinkPath, approveLinkTtlMs } from "./approvals.ts";
40
40
  import { noteSecretUse, healthKey } from "./secret-health.ts";
41
+ import { fetchUntrusted } from "./untrusted-fetch.ts";
42
+ import { platform } from "./platform.ts";
43
+ import { sendMail, type MailCategory } from "./mail.ts";
41
44
 
42
45
  /**
43
- * The platform's own mail: an invite, a low balance.
46
+ * The platform's own mail: an invite, a password reset, a low balance.
44
47
  *
45
- * These are from foldrun, about the platform, to the account's owner — so
46
- * they go through the platform's Resend key and sender (FOLDRUN_RESEND_API_KEY,
47
- * FOLDRUN_EMAIL_FROM), never through anything the customer configured.
48
- *
49
- * Without a platform key — the CLI on a laptop, a test — the account's own
50
- * key and EMAIL_FROM are the fallback.
48
+ * An account that configured its own mail — RESEND_API_KEY and EMAIL_FROM
49
+ * on the account — gets ALL of its mail from that sender, these included:
50
+ * a business that sends from its own domain does not want a second, foreign
51
+ * address turning up in its people's inboxes (2026-09-17, an owner found
52
+ * foldrun.io mail beside their own and asked for none of it). Only an
53
+ * account with no sender of its own falls to the platform's key and sender
54
+ * (FOLDRUN_RESEND_API_KEY, FOLDRUN_EMAIL_FROM), so invites still work
55
+ * before anyone has set mail up.
51
56
  */
52
57
  export function platformMail(tenant: string): { key: string; from: string } | null {
58
+ const own = accountMail(tenant);
59
+ if (own && getSecret(tenant, "EMAIL_FROM")?.value?.trim()) return own;
53
60
  const key = process.env.FOLDRUN_RESEND_API_KEY;
54
61
  if (key) return { key, from: process.env.FOLDRUN_EMAIL_FROM || "foldrun <hello@foldrun.io>" };
55
- return accountMail(tenant);
62
+ return own;
56
63
  }
57
64
 
58
65
  /** The account's own Resend key and sender, or null when it has none. */
@@ -167,6 +174,108 @@ export function signatureHeaders(
167
174
  return { "x-foldrun-timestamp": timestamp, "x-foldrun-signature": `sha256=${mac}`, "x-signature": plain };
168
175
  }
169
176
 
177
+ // ------------------------------------------------------------ deliveries
178
+ //
179
+ // A webhook used to be one POST with a timeout: a receiver that was down
180
+ // for the minute a run failed never heard about it, and nothing anywhere
181
+ // said so. Now every send is a DELIVERY with an id, attempted here and —
182
+ // on a platform — retried from the worker on the backoff below, each
183
+ // attempt recorded (webhook-deliveries.ts in the platform).
184
+
185
+ /** When each attempt is made, counted from the one before: at once, then
186
+ * 1m, 5m, 30m, 2h and 6h. Six attempts over about eight and a half hours,
187
+ * then the delivery is given up and stays in the log to be redelivered. */
188
+ export const WEBHOOK_BACKOFF_MS = [0, 60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000, 6 * 3600_000] as const;
189
+
190
+ /** When attempt `n + 1` is due after attempt `n` (1-based) failed at
191
+ * `at`, or null when `n` was the last. */
192
+ export function nextAttemptAt(n: number, at: number): number | null {
193
+ return n < WEBHOOK_BACKOFF_MS.length ? at + WEBHOOK_BACKOFF_MS[n] : null;
194
+ }
195
+
196
+ /** The delivery id: sent as X-Foldrun-Delivery on every attempt of one
197
+ * delivery, redeliveries included, so a receiver dedupes on it. */
198
+ export function newDeliveryId(): string {
199
+ return `dlv_${crypto.randomUUID().replace(/-/g, "")}`;
200
+ }
201
+
202
+ /** The URL with ${SECRET}s resolved, and how it may be shown: a Slack hook
203
+ * URL is itself a credential, so it is never echoed resolved. */
204
+ export function webhookTarget(tenant: string, workspace: string, config: NotifyConfig | null = notifyConfig(tenant, workspace)): { url: string | null; shown: string } {
205
+ const raw = config?.url ?? "";
206
+ const url = raw.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
207
+ const hit = getSecret(tenant, name, workspace);
208
+ return hit ? hit.value : whole;
209
+ });
210
+ const shown = raw.includes("${") ? raw : raw ? `${raw.slice(0, 40)}${raw.length > 40 ? "…" : ""}` : "none";
211
+ return { url: !url || url.includes("${") ? null : url, shown };
212
+ }
213
+
214
+ export interface WebhookAttempt {
215
+ /** 1-based. */
216
+ n: number;
217
+ at: string;
218
+ /** The receiver's status, or null when none came back (a timeout, DNS). */
219
+ statusCode: number | null;
220
+ durationMs: number;
221
+ /** The first 500 characters of what the receiver answered. */
222
+ response: string | null;
223
+ error: string | null;
224
+ /** A person pressed redeliver or test; not part of the backoff. */
225
+ manual?: boolean;
226
+ }
227
+
228
+ /**
229
+ * One attempt at one delivery, against the workspace's notify: as it reads
230
+ * NOW — a URL fixed between attempts is the one the retry goes to. Signed
231
+ * afresh each time (the timestamp is inside the signature, so a stale
232
+ * signature is one a receiver should refuse). Never throws.
233
+ */
234
+ export async function attemptWebhook(
235
+ tenant: string,
236
+ workspace: string,
237
+ d: { id: string; event: string; body: string; attempt: number; manual?: boolean },
238
+ ): Promise<WebhookAttempt & { ok: boolean }> {
239
+ const at = new Date().toISOString();
240
+ const started = Date.now();
241
+ const config = notifyConfig(tenant, workspace);
242
+ const base = { n: d.attempt, at, ...(d.manual ? { manual: true } : {}) };
243
+ if (!config?.url) {
244
+ return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "this workspace's notify: has no url any more" };
245
+ }
246
+ const { url } = webhookTarget(tenant, workspace, config);
247
+ if (!url) {
248
+ return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "the secret named in notify.url is not in this account's vault" };
249
+ }
250
+ try {
251
+ const res = await fetchUntrusted(url, {
252
+ method: "POST",
253
+ headers: {
254
+ "content-type": "application/json",
255
+ "user-agent": "foldrun-webhooks/1",
256
+ "x-foldrun-delivery": d.id,
257
+ "x-foldrun-event": d.event,
258
+ "x-foldrun-attempt": String(d.attempt),
259
+ ...signatureHeaders(tenant, workspace, config, d.body),
260
+ },
261
+ body: d.body,
262
+ signal: AbortSignal.timeout(Number(process.env.FOLDRUN_WEBHOOK_TIMEOUT_MS) || 8000),
263
+ });
264
+ const text = (await res.text().catch(() => "")).slice(0, 500);
265
+ return {
266
+ ...base,
267
+ ok: res.ok,
268
+ statusCode: res.status,
269
+ durationMs: Date.now() - started,
270
+ response: text || null,
271
+ error: res.ok ? null : `HTTP ${res.status}`,
272
+ };
273
+ } catch (err) {
274
+ const why = err instanceof Error ? (err.name === "TimeoutError" ? "timed out" : err.message) : String(err);
275
+ return { ...base, ok: false, statusCode: null, durationMs: Date.now() - started, response: null, error: why.slice(0, 500) };
276
+ }
277
+ }
278
+
170
279
  /** One waiting step's pair of links. */
171
280
  export interface StepLinks {
172
281
  /** Index into run.steps. */
@@ -274,32 +383,20 @@ export async function sendPlainNotification(
274
383
  if (config.email) {
275
384
  const mail = notifyMail(tenant);
276
385
  if (!mail) return false;
277
- const res = await fetch("https://api.resend.com/emails", {
278
- method: "POST",
279
- headers: { authorization: `Bearer ${mail.key}`, "content-type": "application/json" },
280
- body: JSON.stringify({
281
- from: mail.from,
282
- to: config.email,
283
- subject: `${msg.headline}`.slice(0, 160),
284
- text: `${msg.headline}\n\n${msg.detail}\n\nworkspace: ${workspace}\n${msg.flow ? `flow: ${msg.flow}\n` : ""}${msg.runId ? `run: ${msg.runId}\n` : ""}`,
285
- }),
286
- signal: AbortSignal.timeout(8000),
386
+ const r = await sendMail({
387
+ tenant,
388
+ mail,
389
+ to: config.email,
390
+ category: "run-alerts",
391
+ workspace,
392
+ subject: `${msg.headline}`.slice(0, 160),
393
+ text: `${msg.headline}\n\n${msg.detail}\n\nworkspace: ${workspace}\n${msg.flow ? `flow: ${msg.flow}\n` : ""}${msg.runId ? `run: ${msg.runId}\n` : ""}`,
287
394
  });
288
- return res.ok;
395
+ return r.ok;
289
396
  }
290
- const url = (config.url ?? "").replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
291
- const hit = getSecret(tenant, name, workspace);
292
- return hit ? hit.value : whole;
293
- });
294
- if (!url || url.includes("${")) return false;
295
- const payload = JSON.stringify(body);
296
- const res = await fetch(url, {
297
- method: "POST",
298
- headers: { "content-type": "application/json", ...signatureHeaders(tenant, workspace, config, payload) },
299
- body: payload,
300
- signal: AbortSignal.timeout(5000),
301
- });
302
- return res.ok;
397
+ if (!webhookTarget(tenant, workspace, config).url) return false;
398
+ const r = await platform.deliverWebhook(tenant, workspace, { event: msg.event, body: JSON.stringify(body), retry: true });
399
+ return r.ok;
303
400
  } catch (err) {
304
401
  console.error(`[foldrun] notify (${msg.event}): ${tenant}/${workspace} →`, err instanceof Error ? err.message : err);
305
402
  return false;
@@ -334,7 +431,7 @@ export async function sendTestNotification(
334
431
  };
335
432
  }
336
433
  const sent = new Date().toISOString();
337
- const headline = "\u2713 foldrun test notification";
434
+ const headline = "\u2713 test notification";
338
435
  const detail = `Sent from ${workspace} at ${sent}. If you are reading this, failures and approval gates will reach you here too.`;
339
436
 
340
437
  if (config.email) {
@@ -347,54 +444,39 @@ export async function sendTestNotification(
347
444
  "email is configured but there is no mail credential — set RESEND_API_KEY (and EMAIL_FROM) on the account, or FOLDRUN_RESEND_API_KEY on the platform",
348
445
  };
349
446
  }
350
- try {
351
- const res = await fetch("https://api.resend.com/emails", {
352
- method: "POST",
353
- headers: { authorization: `Bearer ${mail.key}`, "content-type": "application/json" },
354
- body: JSON.stringify({ from: mail.from, to: config.email, subject: headline, text: `${headline}\n\n${detail}\n` }),
355
- signal: AbortSignal.timeout(8000),
356
- });
357
- noteMailUse(tenant, res.status);
358
- const body = (await res.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 400);
359
- return res.ok
360
- ? { ok: true, destination: `${config.email} (from ${mail.from})`, detail: "accepted by Resend for delivery" }
361
- : { ok: false, destination: config.email, detail: `HTTP ${res.status} from Resend — ${body}` };
362
- } catch (err) {
363
- return { ok: false, destination: config.email, detail: err instanceof Error ? err.message : String(err) };
447
+ const r = await sendMail({ tenant, mail, to: config.email, category: "run-alerts", workspace, subject: headline, text: `${headline}\n\n${detail}\n` });
448
+ if (r.status !== undefined) noteMailUse(tenant, r.status);
449
+ if (r.suppressed.length && !r.sent.length && !r.error) {
450
+ return {
451
+ ok: false,
452
+ destination: config.email,
453
+ detail: `not sent: ${r.suppressed.join(", ")} turned run alerts off — turn them back on in Profile → Notifications (or by \`foldrun notifications set run-alerts on\`)`,
454
+ };
364
455
  }
456
+ return r.ok
457
+ ? { ok: true, destination: `${config.email} (from ${mail.from})`, detail: `accepted by Resend for delivery${r.suppressed.length ? ` — not to ${r.suppressed.join(", ")}, who turned run alerts off` : ""}` }
458
+ : { ok: false, destination: config.email, detail: `${r.status ? `HTTP ${r.status} from Resend — ` : ""}${r.error ?? "not sent"}` };
365
459
  }
366
460
 
367
- const raw = config.url ?? "";
368
- const url = raw.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
369
- const hit = getSecret(tenant, name, workspace);
370
- return hit ? hit.value : whole;
371
- });
372
461
  // The destination is echoed back with the secret still unresolved: a
373
462
  // Slack webhook URL is itself a credential, and a page that prints it is
374
463
  // a page that leaks it into a screenshot.
375
- const shown = raw.includes("${") ? raw : `${url.slice(0, 40)}…`;
376
- if (!url || url.includes("${")) {
464
+ const { url, shown } = webhookTarget(tenant, workspace, config);
465
+ if (!url) {
377
466
  return { ok: false, destination: shown, detail: `the secret named in notify.url is not in this account's vault` };
378
467
  }
379
- try {
380
- const payload = JSON.stringify({ text: `${headline} — ${detail}`, workspace, status: "test", summary: detail });
381
- const res = await fetch(url, {
382
- method: "POST",
383
- headers: { "content-type": "application/json", ...signatureHeaders(tenant, workspace, config, payload) },
384
- body: payload,
385
- signal: AbortSignal.timeout(8000),
386
- });
387
- const body = (await res.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 400);
388
- return res.ok
389
- ? {
390
- ok: true,
391
- destination: shown,
392
- detail: config.signingSecret ? "accepted, signed with " + config.signingSecret : "accepted",
393
- }
394
- : { ok: false, destination: shown, detail: `HTTP ${res.status} — ${body}` };
395
- } catch (err) {
396
- return { ok: false, destination: shown, detail: err instanceof Error ? err.message : String(err) };
397
- }
468
+ const payload = JSON.stringify({ text: `${headline} — ${detail}`, workspace, status: "test", summary: detail });
469
+ // One attempt, recorded in the delivery log like any other — and the way
470
+ // back for an endpoint that was switched off after days of failures: a
471
+ // test that is accepted turns it on again.
472
+ const r = await platform.deliverWebhook(tenant, workspace, { event: "test", body: payload, retry: false });
473
+ return r.ok
474
+ ? {
475
+ ok: true,
476
+ destination: shown,
477
+ detail: `${config.signingSecret ? "accepted, signed with " + config.signingSecret : "accepted"} (delivery ${r.deliveryId})`,
478
+ }
479
+ : { ok: false, destination: shown, detail: `${r.statusCode ? `HTTP ${r.statusCode} — ` : ""}${r.detail.replace(/\s+/g, " ").slice(0, 400)}` };
398
480
  }
399
481
 
400
482
  export async function sendRunNotification(
@@ -403,7 +485,17 @@ export async function sendRunNotification(
403
485
  run: RunRecord,
404
486
  ): Promise<boolean> {
405
487
  const config = notifyConfig(tenant, workspace);
406
- if (!config || !config.events.includes(run.status)) return false;
488
+ if (!config) return false;
489
+ // A completed run whose work refused itself — the verdict is BLOCKED — is
490
+ // its own event, `blocked`. It follows `failed` the way the plain alerts
491
+ // do: whoever hears about failures hears about it, because "nothing was
492
+ // published" is the news a person needs whether a step crashed or an
493
+ // agent stopped itself. Naming `blocked` turns it on alone; a list
494
+ // without either stays silent.
495
+ const blocked = run.status === "completed" && runVerdict(run) === "BLOCKED";
496
+ if (blocked) {
497
+ if (!config.events.includes("blocked") && !config.events.includes("failed")) return false;
498
+ } else if (!config.events.includes(run.status)) return false;
407
499
  // A test is not news. An eval case or an adhoc single-agent run is started
408
500
  // by a person who is watching it, and a desk that emails "completed" for
409
501
  // every one of them buries the weekly verdict under two hundred of these —
@@ -423,8 +515,9 @@ export async function sendRunNotification(
423
515
  // says what the run is waiting for, so nobody hunts for a button that
424
516
  // the outside world is meant to press.
425
517
  const onEvent = waitingSteps.length > 0 && waitingSteps.every((s) => s.waitFor === "event");
426
- const headline =
427
- run.status === "completed"
518
+ const headline = blocked
519
+ ? `⛔ ${run.flow} blocked`
520
+ : run.status === "completed"
428
521
  ? `✓ ${run.flow} completed`
429
522
  : run.status === "failed"
430
523
  ? `✗ ${run.flow} failed${failed.length ? ` at ${failed.join(", ")}` : ""}`
@@ -462,6 +555,7 @@ export async function sendRunNotification(
462
555
  runId: run.id,
463
556
  flow: run.flow,
464
557
  status: run.status,
558
+ verdict: runVerdict(run),
465
559
  summary,
466
560
  costUsd: runCost(run),
467
561
  startedAt: run.startedAt,
@@ -476,12 +570,16 @@ export async function sendRunNotification(
476
570
  console.error(`[foldrun] notify: email configured but no mail credential — set RESEND_API_KEY (and EMAIL_FROM) on the account ${tenant}, or FOLDRUN_RESEND_API_KEY on the platform`);
477
571
  return false;
478
572
  }
479
- const res = await fetch("https://api.resend.com/emails", {
480
- method: "POST",
481
- headers: { authorization: `Bearer ${mail.key}`, "content-type": "application/json" },
482
- body: JSON.stringify({
483
- from: mail.from,
573
+ // A gate is its own category: a person may want failures and not
574
+ // the approval requests (they decide from the dashboard), or the
575
+ // other way round.
576
+ const category: MailCategory = run.status === "awaiting-approval" && !onEvent ? "approvals" : "run-alerts";
577
+ const r = await sendMail({
578
+ tenant,
579
+ mail,
484
580
  to: config.email,
581
+ category,
582
+ workspace,
485
583
  // The subject is the headline and what the run concluded, and
486
584
  // nothing else. Ids and costs belong in the body: a subject line is
487
585
  // read in a list, where the only useful question it can answer is
@@ -498,16 +596,13 @@ export async function sendRunNotification(
498
596
  ? `\nApprove: ${links.approveUrl}\nReject: ${links.rejectUrl}\n`
499
597
  : links.links.map((l) => `\nStep ${l.step + 1} (${l.agent})\nApprove: ${l.approveUrl}\nReject: ${l.rejectUrl}\n`).join("")
500
598
  : ""),
501
- }),
502
- signal: AbortSignal.timeout(8000),
503
599
  });
504
- noteMailUse(tenant, res.status);
505
- if (!res.ok) {
600
+ if (r.status !== undefined) noteMailUse(tenant, r.status);
601
+ if (!r.ok) {
506
602
  // Resend's body says WHY — "domain not verified", "can only send to
507
603
  // your own address" — and a status alone sent someone to the wrong
508
604
  // dashboard for twenty minutes on 2026-09-06.
509
- const why = (await res.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 300);
510
- console.error(`[foldrun] notify email: ${tenant}/${workspace} → HTTP ${res.status} ${why}`);
605
+ console.error(`[foldrun] notify email: ${tenant}/${workspace} → ${r.status ? `HTTP ${r.status} ` : ""}${r.error ?? ""}`);
511
606
  return false;
512
607
  }
513
608
  return true;
@@ -515,23 +610,14 @@ export async function sendRunNotification(
515
610
 
516
611
  // ${SECRET} so the Slack URL — itself a credential — can live in the
517
612
  // vault instead of in a file that gets committed.
518
- const url = (config.url ?? "").replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
519
- const hit = getSecret(tenant, name, workspace);
520
- return hit ? hit.value : whole;
521
- });
522
- if (!url || url.includes("${")) {
613
+ if (!webhookTarget(tenant, workspace, config).url) {
523
614
  console.error(`[foldrun] notify: secret in URL not set for ${tenant}/${workspace}`);
524
615
  return false;
525
616
  }
526
- const payload = JSON.stringify(body);
527
- const res = await fetch(url, {
528
- method: "POST",
529
- headers: { "content-type": "application/json", ...signatureHeaders(tenant, workspace, config, payload) },
530
- body: payload,
531
- signal: AbortSignal.timeout(5000),
532
- });
533
- if (!res.ok) {
534
- console.error(`[foldrun] notify: ${tenant}/${workspace} → HTTP ${res.status}`);
617
+ const event = blocked ? "blocked" : run.status;
618
+ const r = await platform.deliverWebhook(tenant, workspace, { event, body: JSON.stringify(body), retry: true });
619
+ if (!r.ok) {
620
+ console.error(`[foldrun] notify: ${tenant}/${workspace} → ${r.statusCode ? `HTTP ${r.statusCode}` : r.detail}${r.retrying ? " (will retry)" : ""}`);
535
621
  return false;
536
622
  }
537
623
  return true;
package/src/openapi.ts CHANGED
@@ -24,6 +24,7 @@ import crypto from "node:crypto";
24
24
  import { createRequire } from "node:module";
25
25
  import { dataRoot } from "./paths.ts";
26
26
  import type { ApiSpec } from "./store.ts";
27
+ import { fetchUntrusted } from "./untrusted-fetch.ts";
27
28
 
28
29
  export interface OperationParam {
29
30
  name: string;
@@ -328,7 +329,7 @@ export async function prefetchOpenApi(tenant: string, sources: string[]): Promis
328
329
  const cached = readCache(tenant, source);
329
330
  if (cached && Date.now() - Date.parse(cached.fetchedAt) < CACHE_TTL_MS) continue;
330
331
  try {
331
- const res = await fetch(source, {
332
+ const res = await fetchUntrusted(source, {
332
333
  signal: AbortSignal.timeout(10_000),
333
334
  headers: { accept: "application/json, application/yaml, text/yaml, */*" },
334
335
  });
@@ -367,14 +368,15 @@ export function attachOperations(
367
368
  const out = specs.map((spec) => {
368
369
  if (!spec.openapi) return spec;
369
370
  const { doc, error } = loadOpenApiDocument(tenant, workspaceRoot, spec.openapi);
371
+ const closed = spec.operations?.length ? " — its operations: allowlist fails closed, so it has no tools this run" : "";
370
372
  if (error) {
371
- warnings.push(`api ${spec.name}: openapi ${error}`);
373
+ warnings.push(`api ${spec.name}: openapi ${error}${closed}`);
372
374
  return spec;
373
375
  }
374
376
  const parsed = parseOpenApi(doc, { operations: spec.operations, methods: spec.methods });
375
377
  for (const w of parsed.warnings) warnings.push(`api ${spec.name}: ${w}`);
376
378
  if (!parsed.operations.length) {
377
- warnings.push(`api ${spec.name}: the OpenAPI document yielded no operations (methods: ${spec.methods.join(", ")})`);
379
+ warnings.push(`api ${spec.name}: the OpenAPI document yielded no operations (methods: ${spec.methods.join(", ")})${closed}`);
378
380
  return spec;
379
381
  }
380
382
  return { ...spec, resolvedOperations: parsed.operations };