@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
@@ -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
  //
@@ -30,25 +30,36 @@
30
30
  // Defaults: failed and awaiting-approval. Completed is opt-in — a schedule
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
+ import crypto from "node:crypto";
33
34
  import { readAgentsMd } from "./runner.js";
34
- import { accountDir, workspaceDir, runCost } from "./store.js";
35
+ import { accountDir, workspaceDir, runCost, readFlow, runVerdict } from "./store.js";
35
36
  import { getSecret } from "./secrets.js";
36
- import { approveToken, publicUrl } from "./webhook.js";
37
+ import { publicUrl } from "./webhook.js";
38
+ import { approveLinkPath, approveLinkTtlMs } from "./approvals.js";
39
+ import { noteSecretUse, healthKey } from "./secret-health.js";
40
+ import { fetchUntrusted } from "./untrusted-fetch.js";
41
+ import { platform } from "./platform.js";
42
+ import { sendMail } from "./mail.js";
37
43
  /**
38
- * The platform's own mail: an invite, a low balance.
44
+ * The platform's own mail: an invite, a password reset, a low balance.
39
45
  *
40
- * These are from foldrun, about the platform, to the account's owner — so
41
- * they go through the platform's Resend key and sender (FOLDRUN_RESEND_API_KEY,
42
- * FOLDRUN_EMAIL_FROM), never through anything the customer configured.
43
- *
44
- * Without a platform key — the CLI on a laptop, a test — the account's own
45
- * key and EMAIL_FROM are the fallback.
46
+ * An account that configured its own mail — RESEND_API_KEY and EMAIL_FROM
47
+ * on the account — gets ALL of its mail from that sender, these included:
48
+ * a business that sends from its own domain does not want a second, foreign
49
+ * address turning up in its people's inboxes (2026-09-17, an owner found
50
+ * foldrun.io mail beside their own and asked for none of it). Only an
51
+ * account with no sender of its own falls to the platform's key and sender
52
+ * (FOLDRUN_RESEND_API_KEY, FOLDRUN_EMAIL_FROM), so invites still work
53
+ * before anyone has set mail up.
46
54
  */
47
55
  export function platformMail(tenant) {
56
+ const own = accountMail(tenant);
57
+ if (own && getSecret(tenant, "EMAIL_FROM")?.value?.trim())
58
+ return own;
48
59
  const key = process.env.FOLDRUN_RESEND_API_KEY;
49
60
  if (key)
50
61
  return { key, from: process.env.FOLDRUN_EMAIL_FROM || "foldrun <hello@foldrun.io>" };
51
- return accountMail(tenant);
62
+ return own;
52
63
  }
53
64
  /** The account's own Resend key and sender, or null when it has none. */
54
65
  export function accountMail(tenant) {
@@ -71,6 +82,14 @@ export function accountMail(tenant) {
71
82
  export function notifyMail(tenant) {
72
83
  return accountMail(tenant) ?? platformMail(tenant);
73
84
  }
85
+ /** Was the mail sent on the account's own key? Only then is a refusal from
86
+ * Resend a fact about a secret the account holds. Recording a platform-key
87
+ * failure against a RESEND_API_KEY the account does not have would send
88
+ * them to rotate a key that does not exist. */
89
+ function noteMailUse(tenant, status) {
90
+ if (accountMail(tenant))
91
+ noteSecretUse(tenant, healthKey("RESEND_API_KEY", "account"), { host: "api.resend.com", status });
92
+ }
74
93
  const DEFAULT_EVENTS = ["failed", "awaiting-approval"];
75
94
  export function notifyConfig(tenant, workspace) {
76
95
  const raw = readAgentsMd(workspaceDir(tenant, workspace))?.data.notify ??
@@ -93,6 +112,7 @@ export function notifyConfig(tenant, workspace) {
93
112
  url,
94
113
  email,
95
114
  events: Array.isArray(o.events) && o.events.length ? o.events.map(String) : DEFAULT_EVENTS,
115
+ signingSecret: typeof o.signing_secret === "string" ? o.signing_secret.trim().replace(/^\$\{|\}$/g, "") || undefined : undefined,
96
116
  };
97
117
  }
98
118
  return null;
@@ -100,16 +120,135 @@ export function notifyConfig(tenant, workspace) {
100
120
  // Said once per process, not once per run: the fix is one env edit, and a
101
121
  // line per parked run would bury the failures around it.
102
122
  let warnedNoPublicUrl = false;
123
+ /**
124
+ * The headers that prove a delivery came from this install.
125
+ *
126
+ * `x-foldrun-signature` is a hex SHA-256 HMAC over "<timestamp>.<body>",
127
+ * keyed by the named vault secret — the timestamp is inside the signed
128
+ * string so a captured delivery cannot be replayed later with a fresh one.
129
+ * `x-signature` is the plain HMAC of the body beside it, the scheme the
130
+ * inbound `signature: hmac` verifies.
131
+ *
132
+ * No secret named, no headers: signing is opt-in, and a receiver that does
133
+ * not check one is no worse off than before.
134
+ */
135
+ export function signatureHeaders(tenant, workspace, config, body) {
136
+ if (!config.signingSecret)
137
+ return {};
138
+ const secret = getSecret(tenant, config.signingSecret, workspace);
139
+ if (!secret) {
140
+ console.error(`[foldrun] notify: signing_secret names ${config.signingSecret}, which is not in the vault for ${tenant}/${workspace} — sending unsigned`);
141
+ return {};
142
+ }
143
+ const timestamp = Math.floor(Date.now() / 1000).toString();
144
+ const mac = crypto.createHmac("sha256", secret.value).update(`${timestamp}.${body}`).digest("hex");
145
+ // Two headers. `x-foldrun-signature` is the timestamped one, and what a
146
+ // receiver should check. `x-signature` is the plain HMAC of the body,
147
+ // which is what the inbound `signature: hmac` verifies — so a foldrun
148
+ // notification pointed at another foldrun webhook flow is accepted, and
149
+ // any receiver written against the plain scheme keeps working.
150
+ const plain = crypto.createHmac("sha256", secret.value).update(body).digest("hex");
151
+ return { "x-foldrun-timestamp": timestamp, "x-foldrun-signature": `sha256=${mac}`, "x-signature": plain };
152
+ }
153
+ // ------------------------------------------------------------ deliveries
154
+ //
155
+ // A webhook used to be one POST with a timeout: a receiver that was down
156
+ // for the minute a run failed never heard about it, and nothing anywhere
157
+ // said so. Now every send is a DELIVERY with an id, attempted here and —
158
+ // on a platform — retried from the worker on the backoff below, each
159
+ // attempt recorded (webhook-deliveries.ts in the platform).
160
+ /** When each attempt is made, counted from the one before: at once, then
161
+ * 1m, 5m, 30m, 2h and 6h. Six attempts over about eight and a half hours,
162
+ * then the delivery is given up and stays in the log to be redelivered. */
163
+ export const WEBHOOK_BACKOFF_MS = [0, 60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000, 6 * 3600_000];
164
+ /** When attempt `n + 1` is due after attempt `n` (1-based) failed at
165
+ * `at`, or null when `n` was the last. */
166
+ export function nextAttemptAt(n, at) {
167
+ return n < WEBHOOK_BACKOFF_MS.length ? at + WEBHOOK_BACKOFF_MS[n] : null;
168
+ }
169
+ /** The delivery id: sent as X-Foldrun-Delivery on every attempt of one
170
+ * delivery, redeliveries included, so a receiver dedupes on it. */
171
+ export function newDeliveryId() {
172
+ return `dlv_${crypto.randomUUID().replace(/-/g, "")}`;
173
+ }
174
+ /** The URL with ${SECRET}s resolved, and how it may be shown: a Slack hook
175
+ * URL is itself a credential, so it is never echoed resolved. */
176
+ export function webhookTarget(tenant, workspace, config = notifyConfig(tenant, workspace)) {
177
+ const raw = config?.url ?? "";
178
+ const url = raw.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
179
+ const hit = getSecret(tenant, name, workspace);
180
+ return hit ? hit.value : whole;
181
+ });
182
+ const shown = raw.includes("${") ? raw : raw ? `${raw.slice(0, 40)}${raw.length > 40 ? "…" : ""}` : "none";
183
+ return { url: !url || url.includes("${") ? null : url, shown };
184
+ }
185
+ /**
186
+ * One attempt at one delivery, against the workspace's notify: as it reads
187
+ * NOW — a URL fixed between attempts is the one the retry goes to. Signed
188
+ * afresh each time (the timestamp is inside the signature, so a stale
189
+ * signature is one a receiver should refuse). Never throws.
190
+ */
191
+ export async function attemptWebhook(tenant, workspace, d) {
192
+ const at = new Date().toISOString();
193
+ const started = Date.now();
194
+ const config = notifyConfig(tenant, workspace);
195
+ const base = { n: d.attempt, at, ...(d.manual ? { manual: true } : {}) };
196
+ if (!config?.url) {
197
+ return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "this workspace's notify: has no url any more" };
198
+ }
199
+ const { url } = webhookTarget(tenant, workspace, config);
200
+ if (!url) {
201
+ return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "the secret named in notify.url is not in this account's vault" };
202
+ }
203
+ try {
204
+ const res = await fetchUntrusted(url, {
205
+ method: "POST",
206
+ headers: {
207
+ "content-type": "application/json",
208
+ "user-agent": "foldrun-webhooks/1",
209
+ "x-foldrun-delivery": d.id,
210
+ "x-foldrun-event": d.event,
211
+ "x-foldrun-attempt": String(d.attempt),
212
+ ...signatureHeaders(tenant, workspace, config, d.body),
213
+ },
214
+ body: d.body,
215
+ signal: AbortSignal.timeout(Number(process.env.FOLDRUN_WEBHOOK_TIMEOUT_MS) || 8000),
216
+ });
217
+ const text = (await res.text().catch(() => "")).slice(0, 500);
218
+ return {
219
+ ...base,
220
+ ok: res.ok,
221
+ statusCode: res.status,
222
+ durationMs: Date.now() - started,
223
+ response: text || null,
224
+ error: res.ok ? null : `HTTP ${res.status}`,
225
+ };
226
+ }
227
+ catch (err) {
228
+ const why = err instanceof Error ? (err.name === "TimeoutError" ? "timed out" : err.message) : String(err);
229
+ return { ...base, ok: false, statusCode: null, durationMs: Date.now() - started, response: null, error: why.slice(0, 500) };
230
+ }
231
+ }
103
232
  /**
104
233
  * The approve/reject links for a run waiting on a person, or null when the
105
234
  * install cannot say where it lives. A notification that only says "waiting
106
235
  * for you" sends the reader to find a laptop; one that carries the decision
107
236
  * lets them make it where they read it.
108
237
  *
238
+ * One pair per waiting step, each bound to that step and to a moment (the
239
+ * flow's `approve_within:`, else a week) — see approvals.ts. A link bound
240
+ * to the run alone approved every later gate of the run for as long as it
241
+ * ran. When the notification goes to a named approver — an address in the
242
+ * flow's `approvers:` — the links are bound to that person too, so a
243
+ * forwarded mail decides as nobody. `approveUrl`/`rejectUrl` on the result
244
+ * are the first waiting step's pair, for receivers written against that.
245
+ *
109
246
  * The reject link goes to the same page with the choice pre-selected — a
110
247
  * GET must not decide anything, because inbox link-checkers follow links.
111
248
  */
112
- export function approvalLinks(tenant, workspace, runId) {
249
+ export function approvalLinks(tenant, workspace, run,
250
+ /** Who the notification is going to, when it is going to one address. */
251
+ recipient = null) {
113
252
  const base = publicUrl();
114
253
  if (!base) {
115
254
  if (!warnedNoPublicUrl) {
@@ -118,8 +257,20 @@ export function approvalLinks(tenant, workspace, runId) {
118
257
  }
119
258
  return null;
120
259
  }
121
- const approveUrl = `${base}/api/approve/${tenant}/${workspace}/${runId}?token=${approveToken(tenant, workspace, runId)}`;
122
- return { approveUrl, rejectUrl: `${approveUrl}&decision=reject` };
260
+ const flow = readFlow(tenant, workspace, run.flow);
261
+ const to = (recipient ?? "").trim().toLowerCase();
262
+ const approver = to && flow?.approvers?.includes(to) ? to : null;
263
+ const expiresAt = Date.now() + approveLinkTtlMs(flow);
264
+ const links = [];
265
+ run.steps.forEach((s, i) => {
266
+ if (s.status !== "awaiting-approval" || s.waitFor === "event")
267
+ return;
268
+ const approveUrl = `${base}${approveLinkPath(tenant, workspace, run.id, { step: i, expiresAt, approver })}`;
269
+ links.push({ step: i, agent: s.agent, approveUrl, rejectUrl: `${approveUrl}&decision=reject` });
270
+ });
271
+ if (links.length === 0)
272
+ return null;
273
+ return { approveUrl: links[0].approveUrl, rejectUrl: links[0].rejectUrl, links };
123
274
  }
124
275
  /**
125
276
  * Fire the webhook for a run's state, if the workspace asked to hear about
@@ -130,9 +281,150 @@ export function approvalLinks(tenant, workspace, runId) {
130
281
  export function isQuietFlow(flow) {
131
282
  return /^(eval|adhoc):/.test(flow);
132
283
  }
284
+ /**
285
+ * A notification that is not about one run finishing.
286
+ *
287
+ * Three things need to say something to the same destination a run
288
+ * notification goes to, and none of them has a RunRecord to hand: a flow
289
+ * that has been quarantined, a run that has outlived its `sla:`, and a
290
+ * budget that is nearly spent. They are the messages a person most needs
291
+ * and least expects, because each one is about something NOT happening —
292
+ * the failure mode that never sends mail is the one that costs a week.
293
+ *
294
+ * The event name is matched against `events:` exactly like a run status,
295
+ * so a workspace that wants none of this writes its own list and gets none
296
+ * of it. They are on by default: an alert nobody opted into is the point.
297
+ */
298
+ export async function sendPlainNotification(tenant, workspace, msg) {
299
+ const config = notifyConfig(tenant, workspace);
300
+ if (!config)
301
+ return false;
302
+ // These follow `failed`. Each one IS a failure — a flow that stopped
303
+ // running, a run that has stalled, a budget about to refuse work — that
304
+ // happens to produce no failed run for the normal path to report. A
305
+ // destination hearing about failures hears about these; naming one
306
+ // explicitly also turns it on; leaving `failed` out silences them.
307
+ if (!config.events.includes(msg.event) && !config.events.includes("failed"))
308
+ return false;
309
+ const body = {
310
+ text: `${msg.headline} — ${msg.detail} · ${workspace}`,
311
+ workspace,
312
+ status: msg.event,
313
+ summary: msg.detail,
314
+ ...(msg.flow ? { flow: msg.flow } : {}),
315
+ ...(msg.runId ? { runId: msg.runId } : {}),
316
+ };
317
+ try {
318
+ if (config.email) {
319
+ const mail = notifyMail(tenant);
320
+ if (!mail)
321
+ return false;
322
+ const r = await sendMail({
323
+ tenant,
324
+ mail,
325
+ to: config.email,
326
+ category: "run-alerts",
327
+ workspace,
328
+ subject: `${msg.headline}`.slice(0, 160),
329
+ text: `${msg.headline}\n\n${msg.detail}\n\nworkspace: ${workspace}\n${msg.flow ? `flow: ${msg.flow}\n` : ""}${msg.runId ? `run: ${msg.runId}\n` : ""}`,
330
+ });
331
+ return r.ok;
332
+ }
333
+ if (!webhookTarget(tenant, workspace, config).url)
334
+ return false;
335
+ const r = await platform.deliverWebhook(tenant, workspace, { event: msg.event, body: JSON.stringify(body), retry: true });
336
+ return r.ok;
337
+ }
338
+ catch (err) {
339
+ console.error(`[foldrun] notify (${msg.event}): ${tenant}/${workspace} →`, err instanceof Error ? err.message : err);
340
+ return false;
341
+ }
342
+ }
343
+ /**
344
+ * Prove the notification path works, now, while someone is looking.
345
+ *
346
+ * Every run notification is sent when nobody is watching, to a destination
347
+ * nobody has tested, through a mail domain nobody has verified. On
348
+ * 2026-09-06 every one of them had been going to a bin for weeks and the
349
+ * only symptom was silence — which is indistinguishable from nothing having
350
+ * gone wrong. An alert that has never been proven to arrive is not an alert.
351
+ *
352
+ * Unlike every other send here, this one reports its failure in full: the
353
+ * provider's own words, which are what actually name the problem ("domain
354
+ * not verified", "you can only send to your own address"). Swallowing them
355
+ * is right for a run notification and useless for a test.
356
+ */
357
+ export async function sendTestNotification(tenant, workspace) {
358
+ const config = notifyConfig(tenant, workspace);
359
+ if (!config) {
360
+ return {
361
+ ok: false,
362
+ destination: "none",
363
+ detail: "no notify: in this workspace's AGENTS.md or the account's — nothing would be sent for a failure or a gate either",
364
+ };
365
+ }
366
+ const sent = new Date().toISOString();
367
+ const headline = "\u2713 test notification";
368
+ const detail = `Sent from ${workspace} at ${sent}. If you are reading this, failures and approval gates will reach you here too.`;
369
+ if (config.email) {
370
+ const mail = notifyMail(tenant);
371
+ if (!mail) {
372
+ return {
373
+ ok: false,
374
+ destination: config.email,
375
+ detail: "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",
376
+ };
377
+ }
378
+ const r = await sendMail({ tenant, mail, to: config.email, category: "run-alerts", workspace, subject: headline, text: `${headline}\n\n${detail}\n` });
379
+ if (r.status !== undefined)
380
+ noteMailUse(tenant, r.status);
381
+ if (r.suppressed.length && !r.sent.length && !r.error) {
382
+ return {
383
+ ok: false,
384
+ destination: config.email,
385
+ 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\`)`,
386
+ };
387
+ }
388
+ return r.ok
389
+ ? { 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` : ""}` }
390
+ : { ok: false, destination: config.email, detail: `${r.status ? `HTTP ${r.status} from Resend — ` : ""}${r.error ?? "not sent"}` };
391
+ }
392
+ // The destination is echoed back with the secret still unresolved: a
393
+ // Slack webhook URL is itself a credential, and a page that prints it is
394
+ // a page that leaks it into a screenshot.
395
+ const { url, shown } = webhookTarget(tenant, workspace, config);
396
+ if (!url) {
397
+ return { ok: false, destination: shown, detail: `the secret named in notify.url is not in this account's vault` };
398
+ }
399
+ const payload = JSON.stringify({ text: `${headline} — ${detail}`, workspace, status: "test", summary: detail });
400
+ // One attempt, recorded in the delivery log like any other — and the way
401
+ // back for an endpoint that was switched off after days of failures: a
402
+ // test that is accepted turns it on again.
403
+ const r = await platform.deliverWebhook(tenant, workspace, { event: "test", body: payload, retry: false });
404
+ return r.ok
405
+ ? {
406
+ ok: true,
407
+ destination: shown,
408
+ detail: `${config.signingSecret ? "accepted, signed with " + config.signingSecret : "accepted"} (delivery ${r.deliveryId})`,
409
+ }
410
+ : { ok: false, destination: shown, detail: `${r.statusCode ? `HTTP ${r.statusCode} — ` : ""}${r.detail.replace(/\s+/g, " ").slice(0, 400)}` };
411
+ }
133
412
  export async function sendRunNotification(tenant, workspace, run) {
134
413
  const config = notifyConfig(tenant, workspace);
135
- if (!config || !config.events.includes(run.status))
414
+ if (!config)
415
+ return false;
416
+ // A completed run whose work refused itself — the verdict is BLOCKED — is
417
+ // its own event, `blocked`. It follows `failed` the way the plain alerts
418
+ // do: whoever hears about failures hears about it, because "nothing was
419
+ // published" is the news a person needs whether a step crashed or an
420
+ // agent stopped itself. Naming `blocked` turns it on alone; a list
421
+ // without either stays silent.
422
+ const blocked = run.status === "completed" && runVerdict(run) === "BLOCKED";
423
+ if (blocked) {
424
+ if (!config.events.includes("blocked") && !config.events.includes("failed"))
425
+ return false;
426
+ }
427
+ else if (!config.events.includes(run.status))
136
428
  return false;
137
429
  // A test is not news. An eval case or an adhoc single-agent run is started
138
430
  // by a person who is watching it, and a desk that emails "completed" for
@@ -141,6 +433,12 @@ export async function sendRunNotification(tenant, workspace, run) {
141
433
  // real reports with them. Failures and gates still send: a person asked.
142
434
  if (run.status === "completed" && isQuietFlow(run.flow))
143
435
  return false;
436
+ // A test run sent nothing outward and this must not be the exception: it
437
+ // reports only a failure, which is the one thing the person testing it
438
+ // needs told about without watching. Its gate mail would ask a real
439
+ // decision about a pretend send.
440
+ if (run.test && run.status !== "failed")
441
+ return false;
144
442
  const failed = run.steps.filter((s) => s.status === "failed").map((s) => s.agent);
145
443
  const waitingSteps = run.steps.filter((s) => s.status === "awaiting-approval");
146
444
  const waiting = waitingSteps.map((s) => s.agent);
@@ -148,19 +446,23 @@ export async function sendRunNotification(tenant, workspace, run) {
148
446
  // says what the run is waiting for, so nobody hunts for a button that
149
447
  // the outside world is meant to press.
150
448
  const onEvent = waitingSteps.length > 0 && waitingSteps.every((s) => s.waitFor === "event");
151
- const headline = run.status === "completed"
152
- ? `✓ ${run.flow} completed`
153
- : run.status === "failed"
154
- ? `✗ ${run.flow} failed${failed.length ? ` at ${failed.join(", ")}` : ""}`
155
- : onEvent
156
- ? `⏳ ${run.flow} is waiting for an external event${waiting.length ? ` (${waiting.join(", ")})` : ""}`
157
- : `⏸ ${run.flow} is waiting for your approval${waiting.length ? ` (${waiting.join(", ")})` : ""}`;
449
+ const headline = blocked
450
+ ? `⛔ ${run.flow} blocked`
451
+ : run.status === "completed"
452
+ ? `✓ ${run.flow} completed`
453
+ : run.status === "failed"
454
+ ? `✗ ${run.flow} failed${failed.length ? ` at ${failed.join(", ")}` : ""}`
455
+ : onEvent
456
+ ? `⏳ ${run.flow} is waiting for an external event${waiting.length ? ` (${waiting.join(", ")})` : ""}`
457
+ : `⏸ ${run.flow} is waiting for your approval${waiting.length ? ` (${waiting.join(", ")})` : ""}`;
158
458
  // What the run concluded, if it concluded anything. This is the whole point
159
459
  // of the message: "✓ health completed · $0.42" tells you a thing ran and
160
460
  // what it cost, and nothing at all about what it found. A scheduled desk
161
461
  // reporting only its own existence is a desk nobody reads by week three.
162
462
  const summary = run.summary?.trim() || null;
163
- const links = run.status === "awaiting-approval" ? approvalLinks(tenant, workspace, run.id) : null;
463
+ // An emailed link is minted for its reader: a named approver's mail
464
+ // carries links that decide as them. A webhook has no reader.
465
+ const links = run.status === "awaiting-approval" ? approvalLinks(tenant, workspace, run, config.email ?? null) : null;
164
466
  // At a gate the reader is being asked something; the question goes in the
165
467
  // mail, and so does what the run has concluded so far — the last finished
166
468
  // step's first line, which is the proposal's own summary ("… ·
@@ -180,6 +482,7 @@ export async function sendRunNotification(tenant, workspace, run) {
180
482
  runId: run.id,
181
483
  flow: run.flow,
182
484
  status: run.status,
485
+ verdict: runVerdict(run),
183
486
  summary,
184
487
  costUsd: runCost(run),
185
488
  startedAt: run.startedAt,
@@ -193,54 +496,53 @@ export async function sendRunNotification(tenant, workspace, run) {
193
496
  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`);
194
497
  return false;
195
498
  }
196
- const res = await fetch("https://api.resend.com/emails", {
197
- method: "POST",
198
- headers: { authorization: `Bearer ${mail.key}`, "content-type": "application/json" },
199
- body: JSON.stringify({
200
- from: mail.from,
201
- to: config.email,
202
- // The subject is the headline and what the run concluded, and
203
- // nothing else. Ids and costs belong in the body: a subject line is
204
- // read in a list, where the only useful question it can answer is
205
- // whether this one needs opening.
206
- subject: summary ? `${headline} — ${summary}`.slice(0, 160) : headline,
207
- text: `${headline}\n` +
208
- (summary ? `\n${summary}\n` : soFar ? `\n${soFar}\n` : "") +
209
- (asks.length ? `\n${asks.map((a) => `Question: ${a}`).join("\n")}\n` : "") +
210
- `\nworkspace: ${workspace}\nrun: ${run.id}\nflow: ${run.flow}\n` +
211
- `cost: $${runCost(run).toFixed(4)}\nstarted: ${run.startedAt}\nfinished: ${run.finishedAt ?? "-"}\n` +
212
- (links ? `\nApprove: ${links.approveUrl}\nReject: ${links.rejectUrl}\n` : ""),
213
- }),
214
- signal: AbortSignal.timeout(8000),
499
+ // A gate is its own category: a person may want failures and not
500
+ // the approval requests (they decide from the dashboard), or the
501
+ // other way round.
502
+ const category = run.status === "awaiting-approval" && !onEvent ? "approvals" : "run-alerts";
503
+ const r = await sendMail({
504
+ tenant,
505
+ mail,
506
+ to: config.email,
507
+ category,
508
+ workspace,
509
+ // The subject is the headline and what the run concluded, and
510
+ // nothing else. Ids and costs belong in the body: a subject line is
511
+ // read in a list, where the only useful question it can answer is
512
+ // whether this one needs opening.
513
+ subject: summary ? `${headline} — ${summary}`.slice(0, 160) : headline,
514
+ text: `${headline}\n` +
515
+ (summary ? `\n${summary}\n` : soFar ? `\n${soFar}\n` : "") +
516
+ (asks.length ? `\n${asks.map((a) => `Question: ${a}`).join("\n")}\n` : "") +
517
+ `\nworkspace: ${workspace}\nrun: ${run.id}\nflow: ${run.flow}\n` +
518
+ `cost: $${runCost(run).toFixed(4)}\nstarted: ${run.startedAt}\nfinished: ${run.finishedAt ?? "-"}\n` +
519
+ (links
520
+ ? links.links.length === 1
521
+ ? `\nApprove: ${links.approveUrl}\nReject: ${links.rejectUrl}\n`
522
+ : links.links.map((l) => `\nStep ${l.step + 1} (${l.agent})\nApprove: ${l.approveUrl}\nReject: ${l.rejectUrl}\n`).join("")
523
+ : ""),
215
524
  });
216
- if (!res.ok) {
525
+ if (r.status !== undefined)
526
+ noteMailUse(tenant, r.status);
527
+ if (!r.ok) {
217
528
  // Resend's body says WHY — "domain not verified", "can only send to
218
529
  // your own address" — and a status alone sent someone to the wrong
219
530
  // dashboard for twenty minutes on 2026-09-06.
220
- const why = (await res.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 300);
221
- console.error(`[foldrun] notify email: ${tenant}/${workspace} → HTTP ${res.status} ${why}`);
531
+ console.error(`[foldrun] notify email: ${tenant}/${workspace} → ${r.status ? `HTTP ${r.status} ` : ""}${r.error ?? ""}`);
222
532
  return false;
223
533
  }
224
534
  return true;
225
535
  }
226
536
  // ${SECRET} so the Slack URL — itself a credential — can live in the
227
537
  // vault instead of in a file that gets committed.
228
- const url = (config.url ?? "").replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
229
- const hit = getSecret(tenant, name, workspace);
230
- return hit ? hit.value : whole;
231
- });
232
- if (!url || url.includes("${")) {
538
+ if (!webhookTarget(tenant, workspace, config).url) {
233
539
  console.error(`[foldrun] notify: secret in URL not set for ${tenant}/${workspace}`);
234
540
  return false;
235
541
  }
236
- const res = await fetch(url, {
237
- method: "POST",
238
- headers: { "content-type": "application/json" },
239
- body: JSON.stringify(body),
240
- signal: AbortSignal.timeout(5000),
241
- });
242
- if (!res.ok) {
243
- console.error(`[foldrun] notify: ${tenant}/${workspace} → HTTP ${res.status}`);
542
+ const event = blocked ? "blocked" : run.status;
543
+ const r = await platform.deliverWebhook(tenant, workspace, { event, body: JSON.stringify(body), retry: true });
544
+ if (!r.ok) {
545
+ console.error(`[foldrun] notify: ${tenant}/${workspace} → ${r.statusCode ? `HTTP ${r.statusCode}` : r.detail}${r.retrying ? " (will retry)" : ""}`);
244
546
  return false;
245
547
  }
246
548
  return true;
@@ -22,6 +22,7 @@ import path from "node:path";
22
22
  import crypto from "node:crypto";
23
23
  import { createRequire } from "node:module";
24
24
  import { dataRoot } from "./paths.js";
25
+ import { fetchUntrusted } from "./untrusted-fetch.js";
25
26
  /** The most operations one API turns into tools. Past this a tool list
26
27
  * stops helping the model choose and starts crowding the prompt. */
27
28
  export const MAX_OPERATIONS = 60;
@@ -289,7 +290,7 @@ export async function prefetchOpenApi(tenant, sources) {
289
290
  if (cached && Date.now() - Date.parse(cached.fetchedAt) < CACHE_TTL_MS)
290
291
  continue;
291
292
  try {
292
- const res = await fetch(source, {
293
+ const res = await fetchUntrusted(source, {
293
294
  signal: AbortSignal.timeout(10_000),
294
295
  headers: { accept: "application/json, application/yaml, text/yaml, */*" },
295
296
  });
@@ -325,15 +326,16 @@ export function attachOperations(specs, tenant, workspaceRoot) {
325
326
  if (!spec.openapi)
326
327
  return spec;
327
328
  const { doc, error } = loadOpenApiDocument(tenant, workspaceRoot, spec.openapi);
329
+ const closed = spec.operations?.length ? " — its operations: allowlist fails closed, so it has no tools this run" : "";
328
330
  if (error) {
329
- warnings.push(`api ${spec.name}: openapi ${error}`);
331
+ warnings.push(`api ${spec.name}: openapi ${error}${closed}`);
330
332
  return spec;
331
333
  }
332
334
  const parsed = parseOpenApi(doc, { operations: spec.operations, methods: spec.methods });
333
335
  for (const w of parsed.warnings)
334
336
  warnings.push(`api ${spec.name}: ${w}`);
335
337
  if (!parsed.operations.length) {
336
- warnings.push(`api ${spec.name}: the OpenAPI document yielded no operations (methods: ${spec.methods.join(", ")})`);
338
+ warnings.push(`api ${spec.name}: the OpenAPI document yielded no operations (methods: ${spec.methods.join(", ")})${closed}`);
337
339
  return spec;
338
340
  }
339
341
  return { ...spec, resolvedOperations: parsed.operations };