@foldrun/core 0.3.0 → 0.5.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 (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -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/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
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
  //
@@ -31,25 +31,35 @@
31
31
  // that works is the quiet kind of good news, and a channel that pings on
32
32
  // every success gets muted, which un-pings the failures too.
33
33
 
34
+ import crypto from "node:crypto";
34
35
  import { readAgentsMd } from "./runner.ts";
35
- import { accountDir, workspaceDir, runCost, type RunRecord } from "./store.ts";
36
+ import { accountDir, workspaceDir, runCost, readFlow, runVerdict, type RunRecord } from "./store.ts";
36
37
  import { getSecret } from "./secrets.ts";
37
- import { approveToken, publicUrl } from "./webhook.ts";
38
+ import { publicUrl } from "./webhook.ts";
39
+ import { approveLinkPath, approveLinkTtlMs } from "./approvals.ts";
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";
38
44
 
39
45
  /**
40
- * The platform's own mail: an invite, a low balance.
46
+ * The platform's own mail: an invite, a password reset, a low balance.
41
47
  *
42
- * These are from foldrun, about the platform, to the account's owner — so
43
- * they go through the platform's Resend key and sender (FOLDRUN_RESEND_API_KEY,
44
- * FOLDRUN_EMAIL_FROM), never through anything the customer configured.
45
- *
46
- * Without a platform key — the CLI on a laptop, a test — the account's own
47
- * 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.
48
56
  */
49
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;
50
60
  const key = process.env.FOLDRUN_RESEND_API_KEY;
51
61
  if (key) return { key, from: process.env.FOLDRUN_EMAIL_FROM || "foldrun <hello@foldrun.io>" };
52
- return accountMail(tenant);
62
+ return own;
53
63
  }
54
64
 
55
65
  /** The account's own Resend key and sender, or null when it has none. */
@@ -74,10 +84,24 @@ export function notifyMail(tenant: string): { key: string; from: string } | null
74
84
  return accountMail(tenant) ?? platformMail(tenant);
75
85
  }
76
86
 
87
+ /** Was the mail sent on the account's own key? Only then is a refusal from
88
+ * Resend a fact about a secret the account holds. Recording a platform-key
89
+ * failure against a RESEND_API_KEY the account does not have would send
90
+ * them to rotate a key that does not exist. */
91
+ function noteMailUse(tenant: string, status: number): void {
92
+ if (accountMail(tenant)) noteSecretUse(tenant, healthKey("RESEND_API_KEY", "account"), { host: "api.resend.com", status });
93
+ }
94
+
77
95
  export interface NotifyConfig {
78
96
  url?: string;
79
97
  email?: string;
80
98
  events: string[];
99
+ /** `signing_secret:` — the NAME of a vault secret, never its value. With
100
+ * one, every webhook carries an HMAC of the body it delivers, so the
101
+ * receiver can tell a real notification from anyone who learned the URL.
102
+ * Inbound hooks have verified a signature since they existed; a delivery
103
+ * going the other way is the same problem in the same shape. */
104
+ signingSecret?: string;
81
105
  }
82
106
 
83
107
  const DEFAULT_EVENTS = ["failed", "awaiting-approval"];
@@ -94,7 +118,7 @@ export function notifyConfig(tenant: string, workspace: string): NotifyConfig |
94
118
  : { url: raw, events: DEFAULT_EVENTS };
95
119
  }
96
120
  if (typeof raw === "object" && raw !== null) {
97
- const o = raw as { url?: unknown; email?: unknown; events?: unknown };
121
+ const o = raw as { url?: unknown; email?: unknown; events?: unknown; signing_secret?: unknown };
98
122
  const url = typeof o.url === "string" ? o.url : undefined;
99
123
  const email = typeof o.email === "string" ? o.email : undefined;
100
124
  if (!url && !email) return null;
@@ -102,6 +126,8 @@ export function notifyConfig(tenant: string, workspace: string): NotifyConfig |
102
126
  url,
103
127
  email,
104
128
  events: Array.isArray(o.events) && o.events.length ? o.events.map(String) : DEFAULT_EVENTS,
129
+ signingSecret:
130
+ typeof o.signing_secret === "string" ? o.signing_secret.trim().replace(/^\$\{|\}$/g, "") || undefined : undefined,
105
131
  };
106
132
  }
107
133
  return null;
@@ -111,20 +137,178 @@ export function notifyConfig(tenant: string, workspace: string): NotifyConfig |
111
137
  // line per parked run would bury the failures around it.
112
138
  let warnedNoPublicUrl = false;
113
139
 
140
+ /**
141
+ * The headers that prove a delivery came from this install.
142
+ *
143
+ * `x-foldrun-signature` is a hex SHA-256 HMAC over "<timestamp>.<body>",
144
+ * keyed by the named vault secret — the timestamp is inside the signed
145
+ * string so a captured delivery cannot be replayed later with a fresh one.
146
+ * `x-signature` is the plain HMAC of the body beside it, the scheme the
147
+ * inbound `signature: hmac` verifies.
148
+ *
149
+ * No secret named, no headers: signing is opt-in, and a receiver that does
150
+ * not check one is no worse off than before.
151
+ */
152
+ export function signatureHeaders(
153
+ tenant: string,
154
+ workspace: string,
155
+ config: NotifyConfig,
156
+ body: string,
157
+ ): Record<string, string> {
158
+ if (!config.signingSecret) return {};
159
+ const secret = getSecret(tenant, config.signingSecret, workspace);
160
+ if (!secret) {
161
+ console.error(
162
+ `[foldrun] notify: signing_secret names ${config.signingSecret}, which is not in the vault for ${tenant}/${workspace} — sending unsigned`,
163
+ );
164
+ return {};
165
+ }
166
+ const timestamp = Math.floor(Date.now() / 1000).toString();
167
+ const mac = crypto.createHmac("sha256", secret.value).update(`${timestamp}.${body}`).digest("hex");
168
+ // Two headers. `x-foldrun-signature` is the timestamped one, and what a
169
+ // receiver should check. `x-signature` is the plain HMAC of the body,
170
+ // which is what the inbound `signature: hmac` verifies — so a foldrun
171
+ // notification pointed at another foldrun webhook flow is accepted, and
172
+ // any receiver written against the plain scheme keeps working.
173
+ const plain = crypto.createHmac("sha256", secret.value).update(body).digest("hex");
174
+ return { "x-foldrun-timestamp": timestamp, "x-foldrun-signature": `sha256=${mac}`, "x-signature": plain };
175
+ }
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
+
279
+ /** One waiting step's pair of links. */
280
+ export interface StepLinks {
281
+ /** Index into run.steps. */
282
+ step: number;
283
+ agent: string;
284
+ approveUrl: string;
285
+ rejectUrl: string;
286
+ }
287
+
114
288
  /**
115
289
  * The approve/reject links for a run waiting on a person, or null when the
116
290
  * install cannot say where it lives. A notification that only says "waiting
117
291
  * for you" sends the reader to find a laptop; one that carries the decision
118
292
  * lets them make it where they read it.
119
293
  *
294
+ * One pair per waiting step, each bound to that step and to a moment (the
295
+ * flow's `approve_within:`, else a week) — see approvals.ts. A link bound
296
+ * to the run alone approved every later gate of the run for as long as it
297
+ * ran. When the notification goes to a named approver — an address in the
298
+ * flow's `approvers:` — the links are bound to that person too, so a
299
+ * forwarded mail decides as nobody. `approveUrl`/`rejectUrl` on the result
300
+ * are the first waiting step's pair, for receivers written against that.
301
+ *
120
302
  * The reject link goes to the same page with the choice pre-selected — a
121
303
  * GET must not decide anything, because inbox link-checkers follow links.
122
304
  */
123
305
  export function approvalLinks(
124
306
  tenant: string,
125
307
  workspace: string,
126
- runId: string,
127
- ): { approveUrl: string; rejectUrl: string } | null {
308
+ run: RunRecord,
309
+ /** Who the notification is going to, when it is going to one address. */
310
+ recipient: string | null = null,
311
+ ): { approveUrl: string; rejectUrl: string; links: StepLinks[] } | null {
128
312
  const base = publicUrl();
129
313
  if (!base) {
130
314
  if (!warnedNoPublicUrl) {
@@ -135,8 +319,18 @@ export function approvalLinks(
135
319
  }
136
320
  return null;
137
321
  }
138
- const approveUrl = `${base}/api/approve/${tenant}/${workspace}/${runId}?token=${approveToken(tenant, workspace, runId)}`;
139
- return { approveUrl, rejectUrl: `${approveUrl}&decision=reject` };
322
+ const flow = readFlow(tenant, workspace, run.flow);
323
+ const to = (recipient ?? "").trim().toLowerCase();
324
+ const approver = to && flow?.approvers?.includes(to) ? to : null;
325
+ const expiresAt = Date.now() + approveLinkTtlMs(flow);
326
+ const links: StepLinks[] = [];
327
+ run.steps.forEach((s, i) => {
328
+ if (s.status !== "awaiting-approval" || s.waitFor === "event") return;
329
+ const approveUrl = `${base}${approveLinkPath(tenant, workspace, run.id, { step: i, expiresAt, approver })}`;
330
+ links.push({ step: i, agent: s.agent, approveUrl, rejectUrl: `${approveUrl}&decision=reject` });
331
+ });
332
+ if (links.length === 0) return null;
333
+ return { approveUrl: links[0].approveUrl, rejectUrl: links[0].rejectUrl, links };
140
334
  }
141
335
 
142
336
  /**
@@ -149,19 +343,170 @@ export function isQuietFlow(flow: string): boolean {
149
343
  return /^(eval|adhoc):/.test(flow);
150
344
  }
151
345
 
346
+ /**
347
+ * A notification that is not about one run finishing.
348
+ *
349
+ * Three things need to say something to the same destination a run
350
+ * notification goes to, and none of them has a RunRecord to hand: a flow
351
+ * that has been quarantined, a run that has outlived its `sla:`, and a
352
+ * budget that is nearly spent. They are the messages a person most needs
353
+ * and least expects, because each one is about something NOT happening —
354
+ * the failure mode that never sends mail is the one that costs a week.
355
+ *
356
+ * The event name is matched against `events:` exactly like a run status,
357
+ * so a workspace that wants none of this writes its own list and gets none
358
+ * of it. They are on by default: an alert nobody opted into is the point.
359
+ */
360
+ export async function sendPlainNotification(
361
+ tenant: string,
362
+ workspace: string,
363
+ msg: { event: string; headline: string; detail: string; flow?: string; runId?: string },
364
+ ): Promise<boolean> {
365
+ const config = notifyConfig(tenant, workspace);
366
+ if (!config) return false;
367
+ // These follow `failed`. Each one IS a failure — a flow that stopped
368
+ // running, a run that has stalled, a budget about to refuse work — that
369
+ // happens to produce no failed run for the normal path to report. A
370
+ // destination hearing about failures hears about these; naming one
371
+ // explicitly also turns it on; leaving `failed` out silences them.
372
+ if (!config.events.includes(msg.event) && !config.events.includes("failed")) return false;
373
+
374
+ const body = {
375
+ text: `${msg.headline} — ${msg.detail} · ${workspace}`,
376
+ workspace,
377
+ status: msg.event,
378
+ summary: msg.detail,
379
+ ...(msg.flow ? { flow: msg.flow } : {}),
380
+ ...(msg.runId ? { runId: msg.runId } : {}),
381
+ };
382
+ try {
383
+ if (config.email) {
384
+ const mail = notifyMail(tenant);
385
+ if (!mail) return false;
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` : ""}`,
394
+ });
395
+ return r.ok;
396
+ }
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;
400
+ } catch (err) {
401
+ console.error(`[foldrun] notify (${msg.event}): ${tenant}/${workspace} →`, err instanceof Error ? err.message : err);
402
+ return false;
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Prove the notification path works, now, while someone is looking.
408
+ *
409
+ * Every run notification is sent when nobody is watching, to a destination
410
+ * nobody has tested, through a mail domain nobody has verified. On
411
+ * 2026-09-06 every one of them had been going to a bin for weeks and the
412
+ * only symptom was silence — which is indistinguishable from nothing having
413
+ * gone wrong. An alert that has never been proven to arrive is not an alert.
414
+ *
415
+ * Unlike every other send here, this one reports its failure in full: the
416
+ * provider's own words, which are what actually name the problem ("domain
417
+ * not verified", "you can only send to your own address"). Swallowing them
418
+ * is right for a run notification and useless for a test.
419
+ */
420
+ export async function sendTestNotification(
421
+ tenant: string,
422
+ workspace: string,
423
+ ): Promise<{ ok: boolean; destination: string; detail: string }> {
424
+ const config = notifyConfig(tenant, workspace);
425
+ if (!config) {
426
+ return {
427
+ ok: false,
428
+ destination: "none",
429
+ detail:
430
+ "no notify: in this workspace's AGENTS.md or the account's — nothing would be sent for a failure or a gate either",
431
+ };
432
+ }
433
+ const sent = new Date().toISOString();
434
+ const headline = "\u2713 test notification";
435
+ const detail = `Sent from ${workspace} at ${sent}. If you are reading this, failures and approval gates will reach you here too.`;
436
+
437
+ if (config.email) {
438
+ const mail = notifyMail(tenant);
439
+ if (!mail) {
440
+ return {
441
+ ok: false,
442
+ destination: config.email,
443
+ detail:
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",
445
+ };
446
+ }
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
+ };
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"}` };
459
+ }
460
+
461
+ // The destination is echoed back with the secret still unresolved: a
462
+ // Slack webhook URL is itself a credential, and a page that prints it is
463
+ // a page that leaks it into a screenshot.
464
+ const { url, shown } = webhookTarget(tenant, workspace, config);
465
+ if (!url) {
466
+ return { ok: false, destination: shown, detail: `the secret named in notify.url is not in this account's vault` };
467
+ }
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)}` };
480
+ }
481
+
152
482
  export async function sendRunNotification(
153
483
  tenant: string,
154
484
  workspace: string,
155
485
  run: RunRecord,
156
486
  ): Promise<boolean> {
157
487
  const config = notifyConfig(tenant, workspace);
158
- 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;
159
499
  // A test is not news. An eval case or an adhoc single-agent run is started
160
500
  // by a person who is watching it, and a desk that emails "completed" for
161
501
  // every one of them buries the weekly verdict under two hundred of these —
162
502
  // on 2026-09-06 the reader's inbox rule had sent the lot to the bin, the
163
503
  // real reports with them. Failures and gates still send: a person asked.
164
504
  if (run.status === "completed" && isQuietFlow(run.flow)) return false;
505
+ // A test run sent nothing outward and this must not be the exception: it
506
+ // reports only a failure, which is the one thing the person testing it
507
+ // needs told about without watching. Its gate mail would ask a real
508
+ // decision about a pretend send.
509
+ if (run.test && run.status !== "failed") return false;
165
510
 
166
511
  const failed = run.steps.filter((s) => s.status === "failed").map((s) => s.agent);
167
512
  const waitingSteps = run.steps.filter((s) => s.status === "awaiting-approval");
@@ -170,8 +515,9 @@ export async function sendRunNotification(
170
515
  // says what the run is waiting for, so nobody hunts for a button that
171
516
  // the outside world is meant to press.
172
517
  const onEvent = waitingSteps.length > 0 && waitingSteps.every((s) => s.waitFor === "event");
173
- const headline =
174
- run.status === "completed"
518
+ const headline = blocked
519
+ ? `⛔ ${run.flow} blocked`
520
+ : run.status === "completed"
175
521
  ? `✓ ${run.flow} completed`
176
522
  : run.status === "failed"
177
523
  ? `✗ ${run.flow} failed${failed.length ? ` at ${failed.join(", ")}` : ""}`
@@ -185,7 +531,9 @@ export async function sendRunNotification(
185
531
  // reporting only its own existence is a desk nobody reads by week three.
186
532
  const summary = run.summary?.trim() || null;
187
533
 
188
- const links = run.status === "awaiting-approval" ? approvalLinks(tenant, workspace, run.id) : null;
534
+ // An emailed link is minted for its reader: a named approver's mail
535
+ // carries links that decide as them. A webhook has no reader.
536
+ const links = run.status === "awaiting-approval" ? approvalLinks(tenant, workspace, run, config.email ?? null) : null;
189
537
  // At a gate the reader is being asked something; the question goes in the
190
538
  // mail, and so does what the run has concluded so far — the last finished
191
539
  // step's first line, which is the proposal's own summary ("… ·
@@ -207,6 +555,7 @@ export async function sendRunNotification(
207
555
  runId: run.id,
208
556
  flow: run.flow,
209
557
  status: run.status,
558
+ verdict: runVerdict(run),
210
559
  summary,
211
560
  costUsd: runCost(run),
212
561
  startedAt: run.startedAt,
@@ -221,12 +570,16 @@ export async function sendRunNotification(
221
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`);
222
571
  return false;
223
572
  }
224
- const res = await fetch("https://api.resend.com/emails", {
225
- method: "POST",
226
- headers: { authorization: `Bearer ${mail.key}`, "content-type": "application/json" },
227
- body: JSON.stringify({
228
- 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,
229
580
  to: config.email,
581
+ category,
582
+ workspace,
230
583
  // The subject is the headline and what the run concluded, and
231
584
  // nothing else. Ids and costs belong in the body: a subject line is
232
585
  // read in a list, where the only useful question it can answer is
@@ -238,16 +591,18 @@ export async function sendRunNotification(
238
591
  (asks.length ? `\n${asks.map((a) => `Question: ${a}`).join("\n")}\n` : "") +
239
592
  `\nworkspace: ${workspace}\nrun: ${run.id}\nflow: ${run.flow}\n` +
240
593
  `cost: $${runCost(run).toFixed(4)}\nstarted: ${run.startedAt}\nfinished: ${run.finishedAt ?? "-"}\n` +
241
- (links ? `\nApprove: ${links.approveUrl}\nReject: ${links.rejectUrl}\n` : ""),
242
- }),
243
- signal: AbortSignal.timeout(8000),
594
+ (links
595
+ ? links.links.length === 1
596
+ ? `\nApprove: ${links.approveUrl}\nReject: ${links.rejectUrl}\n`
597
+ : links.links.map((l) => `\nStep ${l.step + 1} (${l.agent})\nApprove: ${l.approveUrl}\nReject: ${l.rejectUrl}\n`).join("")
598
+ : ""),
244
599
  });
245
- if (!res.ok) {
600
+ if (r.status !== undefined) noteMailUse(tenant, r.status);
601
+ if (!r.ok) {
246
602
  // Resend's body says WHY — "domain not verified", "can only send to
247
603
  // your own address" — and a status alone sent someone to the wrong
248
604
  // dashboard for twenty minutes on 2026-09-06.
249
- const why = (await res.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 300);
250
- 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 ?? ""}`);
251
606
  return false;
252
607
  }
253
608
  return true;
@@ -255,22 +610,14 @@ export async function sendRunNotification(
255
610
 
256
611
  // ${SECRET} so the Slack URL — itself a credential — can live in the
257
612
  // vault instead of in a file that gets committed.
258
- const url = (config.url ?? "").replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
259
- const hit = getSecret(tenant, name, workspace);
260
- return hit ? hit.value : whole;
261
- });
262
- if (!url || url.includes("${")) {
613
+ if (!webhookTarget(tenant, workspace, config).url) {
263
614
  console.error(`[foldrun] notify: secret in URL not set for ${tenant}/${workspace}`);
264
615
  return false;
265
616
  }
266
- const res = await fetch(url, {
267
- method: "POST",
268
- headers: { "content-type": "application/json" },
269
- body: JSON.stringify(body),
270
- signal: AbortSignal.timeout(5000),
271
- });
272
- if (!res.ok) {
273
- 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)" : ""}`);
274
621
  return false;
275
622
  }
276
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 };