@foldrun/core 0.3.0 → 0.4.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 (83) hide show
  1. package/README.md +27 -10
  2. package/dist/src/approvals.d.ts +104 -1
  3. package/dist/src/approvals.js +192 -2
  4. package/dist/src/budget.d.ts +32 -0
  5. package/dist/src/budget.js +177 -0
  6. package/dist/src/catalog.js +2 -1
  7. package/dist/src/confine.js +6 -1
  8. package/dist/src/egress.d.ts +19 -2
  9. package/dist/src/egress.js +2 -1
  10. package/dist/src/evals.d.ts +4 -0
  11. package/dist/src/evals.js +20 -11
  12. package/dist/src/flow-lint.d.ts +4 -0
  13. package/dist/src/flow-lint.js +108 -0
  14. package/dist/src/host-env.d.ts +8 -0
  15. package/dist/src/host-env.js +48 -0
  16. package/dist/src/json-schema.d.ts +12 -0
  17. package/dist/src/json-schema.js +202 -0
  18. package/dist/src/library.js +13 -1
  19. package/dist/src/notify.d.ts +79 -1
  20. package/dist/src/notify.js +243 -9
  21. package/dist/src/paths.d.ts +5 -0
  22. package/dist/src/paths.js +32 -0
  23. package/dist/src/platform.d.ts +102 -2
  24. package/dist/src/platform.js +18 -2
  25. package/dist/src/provider-health.d.ts +55 -0
  26. package/dist/src/provider-health.js +232 -0
  27. package/dist/src/run-container.d.ts +57 -4
  28. package/dist/src/run-container.js +147 -41
  29. package/dist/src/runner.d.ts +39 -3
  30. package/dist/src/runner.js +422 -73
  31. package/dist/src/script-tools.d.ts +12 -0
  32. package/dist/src/script-tools.js +10 -2
  33. package/dist/src/secret-health.d.ts +59 -0
  34. package/dist/src/secret-health.js +155 -0
  35. package/dist/src/secrets.d.ts +19 -1
  36. package/dist/src/secrets.js +44 -12
  37. package/dist/src/starter.js +3 -0
  38. package/dist/src/step-exec.d.ts +47 -1
  39. package/dist/src/step-exec.js +155 -60
  40. package/dist/src/store.d.ts +276 -3
  41. package/dist/src/store.js +552 -26
  42. package/dist/src/tar.js +2 -1
  43. package/dist/src/test-mode.d.ts +111 -0
  44. package/dist/src/test-mode.js +285 -0
  45. package/dist/src/tool-test.js +48 -21
  46. package/dist/src/translator.js +2 -1
  47. package/dist/src/trigger-log.d.ts +43 -0
  48. package/dist/src/trigger-log.js +122 -0
  49. package/dist/src/triggers.d.ts +20 -0
  50. package/dist/src/triggers.js +91 -10
  51. package/dist/src/webhook.d.ts +15 -3
  52. package/dist/src/webhook.js +11 -11
  53. package/package.json +4 -4
  54. package/src/approvals.ts +253 -2
  55. package/src/budget.ts +178 -0
  56. package/src/catalog.ts +2 -1
  57. package/src/confine.ts +6 -1
  58. package/src/egress.ts +20 -4
  59. package/src/evals.ts +25 -9
  60. package/src/flow-lint.ts +118 -0
  61. package/src/host-env.ts +51 -0
  62. package/src/json-schema.ts +184 -0
  63. package/src/library.ts +13 -1
  64. package/src/notify.ts +272 -11
  65. package/src/paths.ts +29 -0
  66. package/src/platform.ts +107 -3
  67. package/src/provider-health.ts +274 -0
  68. package/src/run-container.ts +175 -36
  69. package/src/runner.ts +435 -65
  70. package/src/script-tools.ts +18 -2
  71. package/src/secret-health.ts +185 -0
  72. package/src/secrets.ts +44 -10
  73. package/src/starter.ts +3 -0
  74. package/src/step-exec.ts +128 -13
  75. package/src/storage.ts +8 -2
  76. package/src/store.ts +746 -25
  77. package/src/tar.ts +2 -1
  78. package/src/test-mode.ts +311 -0
  79. package/src/tool-test.ts +49 -20
  80. package/src/translator.ts +2 -1
  81. package/src/trigger-log.ts +159 -0
  82. package/src/triggers.ts +92 -10
  83. package/src/webhook.ts +17 -11
@@ -0,0 +1,184 @@
1
+ // A JSON Schema validator small enough to read, with no dependency.
2
+ //
3
+ // `schema:` beside `output: json` says what shape the reply's value must
4
+ // have, and a value that does not have it fails the step with a sentence
5
+ // naming the field. The subset here is the part of JSON Schema people write
6
+ // by hand for a handoff — types, required fields, enums, ranges, patterns,
7
+ // arrays, the combinators, and `$ref` to a definition in the same document.
8
+ // Formats (`date-time`, `email`) are accepted and not checked; a check that
9
+ // needs one belongs in a `verify:`. Anything the validator does not know is
10
+ // ignored, the way JSON Schema itself says unknown keywords are.
11
+ //
12
+ // Not ajv: it sits in node_modules as a dependency of a dependency, which is
13
+ // nothing to build on, and the whole of it is far more than a step needs.
14
+
15
+ export interface SchemaError {
16
+ /** JSON pointer to the offending value: "" is the root, "/leads/0/url". */
17
+ path: string;
18
+ message: string;
19
+ }
20
+
21
+ type Schema = Record<string, unknown> | boolean;
22
+
23
+ const MAX_ERRORS = 8;
24
+
25
+ /** Is `value` what `schema` describes? Empty means yes. */
26
+ export function validateSchema(value: unknown, schema: unknown): SchemaError[] {
27
+ const errors: SchemaError[] = [];
28
+ check(value, schema as Schema, "", schema as Schema, errors, 0);
29
+ return errors.slice(0, MAX_ERRORS);
30
+ }
31
+
32
+ /** The errors as one sentence for a trace line. */
33
+ export function describeSchemaErrors(errors: SchemaError[]): string {
34
+ return errors.map((e) => `${e.path || "the value"}: ${e.message}`).join("; ");
35
+ }
36
+
37
+ /** Is this a schema at all? An object (or a boolean, which JSON Schema
38
+ * allows) — not a string, not a list. */
39
+ export function looksLikeSchema(schema: unknown): boolean {
40
+ return typeof schema === "boolean" || (typeof schema === "object" && schema !== null && !Array.isArray(schema));
41
+ }
42
+
43
+ const typeOf = (v: unknown): string =>
44
+ v === null ? "null" : Array.isArray(v) ? "array" : typeof v === "number" ? (Number.isInteger(v) ? "integer" : "number") : typeof v;
45
+
46
+ const matchesType = (v: unknown, t: string): boolean => {
47
+ const actual = typeOf(v);
48
+ return t === actual || (t === "number" && actual === "integer");
49
+ };
50
+
51
+ function resolveRef(ref: string, root: Schema): Schema | null {
52
+ if (!ref.startsWith("#")) return null;
53
+ let node: unknown = root;
54
+ for (const raw of ref.slice(1).split("/").filter(Boolean)) {
55
+ const key = raw.replaceAll("~1", "/").replaceAll("~0", "~");
56
+ if (typeof node !== "object" || node === null) return null;
57
+ node = (node as Record<string, unknown>)[key];
58
+ }
59
+ return typeof node === "object" && node !== null ? (node as Schema) : typeof node === "boolean" ? node : null;
60
+ }
61
+
62
+ function check(value: unknown, schema: Schema, path: string, root: Schema, errors: SchemaError[], depth: number) {
63
+ if (errors.length >= MAX_ERRORS || depth > 64) return;
64
+ if (schema === true) return;
65
+ if (schema === false) return void errors.push({ path, message: "nothing is allowed here" });
66
+ if (typeof schema !== "object" || schema === null) return;
67
+ const s = schema as Record<string, unknown>;
68
+ const fail = (message: string) => errors.push({ path, message });
69
+
70
+ if (typeof s.$ref === "string") {
71
+ const target = resolveRef(s.$ref, root);
72
+ if (target === null) return fail(`schema: unresolvable $ref ${s.$ref}`);
73
+ check(value, target, path, root, errors, depth + 1);
74
+ return;
75
+ }
76
+
77
+ // type — one, or a list of alternatives; "nullable: true" (OpenAPI's
78
+ // spelling) lets null through.
79
+ if (s.type !== undefined) {
80
+ const types = Array.isArray(s.type) ? (s.type as string[]) : [String(s.type)];
81
+ const ok = types.some((t) => matchesType(value, t)) || (s.nullable === true && value === null);
82
+ if (!ok) return fail(`expected ${types.join(" or ")}, got ${typeOf(value)}`);
83
+ }
84
+ if (Array.isArray(s.enum) && !s.enum.some((e) => deepEqual(e, value))) {
85
+ return fail(`must be one of ${s.enum.map((e) => JSON.stringify(e)).join(", ")}`);
86
+ }
87
+ if ("const" in s && !deepEqual(s.const, value)) return fail(`must be ${JSON.stringify(s.const)}`);
88
+
89
+ if (typeof value === "string") {
90
+ if (typeof s.minLength === "number" && value.length < s.minLength) fail(`shorter than ${s.minLength} characters`);
91
+ if (typeof s.maxLength === "number" && value.length > s.maxLength) fail(`longer than ${s.maxLength} characters`);
92
+ if (typeof s.pattern === "string") {
93
+ try {
94
+ if (!new RegExp(s.pattern, "u").test(value)) fail(`does not match ${s.pattern}`);
95
+ } catch {
96
+ fail(`schema: invalid pattern ${s.pattern}`);
97
+ }
98
+ }
99
+ }
100
+ if (typeof value === "number") {
101
+ if (typeof s.minimum === "number" && value < s.minimum) fail(`below the minimum ${s.minimum}`);
102
+ if (typeof s.maximum === "number" && value > s.maximum) fail(`above the maximum ${s.maximum}`);
103
+ if (typeof s.exclusiveMinimum === "number" && value <= s.exclusiveMinimum) fail(`must be above ${s.exclusiveMinimum}`);
104
+ if (typeof s.exclusiveMaximum === "number" && value >= s.exclusiveMaximum) fail(`must be below ${s.exclusiveMaximum}`);
105
+ if (typeof s.multipleOf === "number" && s.multipleOf > 0 && Math.abs(value / s.multipleOf - Math.round(value / s.multipleOf)) > 1e-9) {
106
+ fail(`not a multiple of ${s.multipleOf}`);
107
+ }
108
+ }
109
+ if (Array.isArray(value)) {
110
+ if (typeof s.minItems === "number" && value.length < s.minItems) fail(`fewer than ${s.minItems} items`);
111
+ if (typeof s.maxItems === "number" && value.length > s.maxItems) fail(`more than ${s.maxItems} items`);
112
+ if (s.uniqueItems === true) {
113
+ const seen = new Set(value.map((v) => JSON.stringify(v)));
114
+ if (seen.size !== value.length) fail("items are not unique");
115
+ }
116
+ if (s.items !== undefined && !Array.isArray(s.items)) {
117
+ value.forEach((item, i) => check(item, s.items as Schema, `${path}/${i}`, root, errors, depth + 1));
118
+ } else if (Array.isArray(s.items) || Array.isArray(s.prefixItems)) {
119
+ const tuple = (Array.isArray(s.prefixItems) ? s.prefixItems : s.items) as Schema[];
120
+ tuple.forEach((sub, i) => i < value.length && check(value[i], sub, `${path}/${i}`, root, errors, depth + 1));
121
+ }
122
+ }
123
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
124
+ const obj = value as Record<string, unknown>;
125
+ const props = (typeof s.properties === "object" && s.properties !== null ? s.properties : {}) as Record<string, Schema>;
126
+ if (Array.isArray(s.required)) {
127
+ for (const key of s.required as string[]) if (!(key in obj)) fail(`missing required field "${key}"`);
128
+ }
129
+ for (const [key, sub] of Object.entries(props)) {
130
+ if (key in obj) check(obj[key], sub, `${path}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`, root, errors, depth + 1);
131
+ }
132
+ const patterns = (typeof s.patternProperties === "object" && s.patternProperties !== null ? s.patternProperties : {}) as Record<string, Schema>;
133
+ for (const key of Object.keys(obj)) {
134
+ if (key in props) continue;
135
+ const pattern = Object.entries(patterns).find(([re]) => {
136
+ try {
137
+ return new RegExp(re, "u").test(key);
138
+ } catch {
139
+ return false;
140
+ }
141
+ });
142
+ if (pattern) {
143
+ check(obj[key], pattern[1], `${path}/${key}`, root, errors, depth + 1);
144
+ } else if (s.additionalProperties === false) {
145
+ fail(`unexpected field "${key}"`);
146
+ } else if (typeof s.additionalProperties === "object" && s.additionalProperties !== null) {
147
+ check(obj[key], s.additionalProperties as Schema, `${path}/${key}`, root, errors, depth + 1);
148
+ }
149
+ }
150
+ if (typeof s.minProperties === "number" && Object.keys(obj).length < s.minProperties) fail(`fewer than ${s.minProperties} fields`);
151
+ if (typeof s.maxProperties === "number" && Object.keys(obj).length > s.maxProperties) fail(`more than ${s.maxProperties} fields`);
152
+ }
153
+
154
+ if (Array.isArray(s.allOf)) {
155
+ for (const sub of s.allOf as Schema[]) check(value, sub, path, root, errors, depth + 1);
156
+ }
157
+ if (Array.isArray(s.anyOf)) {
158
+ const ok = (s.anyOf as Schema[]).some((sub) => validateSchema(value, withRoot(sub, root)).length === 0);
159
+ if (!ok) fail("matches none of the allowed shapes (anyOf)");
160
+ }
161
+ if (Array.isArray(s.oneOf)) {
162
+ const n = (s.oneOf as Schema[]).filter((sub) => validateSchema(value, withRoot(sub, root)).length === 0).length;
163
+ if (n !== 1) fail(n === 0 ? "matches none of the allowed shapes (oneOf)" : `matches ${n} of the shapes, must match exactly one (oneOf)`);
164
+ }
165
+ if (s.not !== undefined && validateSchema(value, withRoot(s.not as Schema, root)).length === 0) fail("matches a shape that is not allowed (not)");
166
+ }
167
+
168
+ /** A sub-schema checked on its own still resolves `$ref` against the
169
+ * document it came from: carry the root's definitions along. */
170
+ function withRoot(sub: Schema, root: Schema): Schema {
171
+ if (typeof sub !== "object" || sub === null || typeof root !== "object" || root === null) return sub;
172
+ const defs: Record<string, unknown> = {};
173
+ for (const key of ["$defs", "definitions"]) if (key in (root as Record<string, unknown>)) defs[key] = (root as Record<string, unknown>)[key];
174
+ return { ...defs, ...(sub as Record<string, unknown>) };
175
+ }
176
+
177
+ function deepEqual(a: unknown, b: unknown): boolean {
178
+ if (a === b) return true;
179
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== "object") return false;
180
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
181
+ const ka = Object.keys(a as object), kb = Object.keys(b as object);
182
+ if (ka.length !== kb.length) return false;
183
+ return ka.every((k) => deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]));
184
+ }
package/src/library.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // own file beats its workspace's, which beats the account's. So a team can set
12
12
  // a house style once and one agent can still override it.
13
13
 
14
+ import { platform } from "./platform.ts";
14
15
  import fs from "node:fs";
15
16
  import path from "node:path";
16
17
  import { dataRoot, singleWorkspace } from "./paths.ts";
@@ -255,7 +256,18 @@ export function deleteLibraryPath(
255
256
 
256
257
  /** Library tool definitions, keyed by name — http or script, both grantable by name in `tools:`. */
257
258
  export function libraryTools(tenant: string): Record<string, ToolDef> {
258
- return readToolDir(libraryDir(tenant, "tools"), "account");
259
+ // The platform's gallery first, the account's own copies over it: a tool
260
+ // the platform ships is granted by name in every account without anyone
261
+ // installing it, and an account that installed (copied) one keeps its
262
+ // copy — that is what "install to customise" means. Both read at account
263
+ // scope, so a folder tool's `run:` resolves to /library/tools/<name>/…
264
+ // in the sandbox either way; the staging step lays the gallery down
265
+ // first and the account library over it for the same reason.
266
+ const gallery = platform.galleryDir();
267
+ return {
268
+ ...(gallery ? readToolDir(path.join(gallery, "tools"), "account") : {}),
269
+ ...readToolDir(libraryDir(tenant, "tools"), "account"),
270
+ };
259
271
  }
260
272
 
261
273
  /** Shared memory index — derived from the files, like every other scope. */
package/src/notify.ts CHANGED
@@ -31,10 +31,13 @@
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, 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";
38
41
 
39
42
  /**
40
43
  * The platform's own mail: an invite, a low balance.
@@ -74,10 +77,24 @@ export function notifyMail(tenant: string): { key: string; from: string } | null
74
77
  return accountMail(tenant) ?? platformMail(tenant);
75
78
  }
76
79
 
80
+ /** Was the mail sent on the account's own key? Only then is a refusal from
81
+ * Resend a fact about a secret the account holds. Recording a platform-key
82
+ * failure against a RESEND_API_KEY the account does not have would send
83
+ * them to rotate a key that does not exist. */
84
+ function noteMailUse(tenant: string, status: number): void {
85
+ if (accountMail(tenant)) noteSecretUse(tenant, healthKey("RESEND_API_KEY", "account"), { host: "api.resend.com", status });
86
+ }
87
+
77
88
  export interface NotifyConfig {
78
89
  url?: string;
79
90
  email?: string;
80
91
  events: string[];
92
+ /** `signing_secret:` — the NAME of a vault secret, never its value. With
93
+ * one, every webhook carries an HMAC of the body it delivers, so the
94
+ * receiver can tell a real notification from anyone who learned the URL.
95
+ * Inbound hooks have verified a signature since they existed; a delivery
96
+ * going the other way is the same problem in the same shape. */
97
+ signingSecret?: string;
81
98
  }
82
99
 
83
100
  const DEFAULT_EVENTS = ["failed", "awaiting-approval"];
@@ -94,7 +111,7 @@ export function notifyConfig(tenant: string, workspace: string): NotifyConfig |
94
111
  : { url: raw, events: DEFAULT_EVENTS };
95
112
  }
96
113
  if (typeof raw === "object" && raw !== null) {
97
- const o = raw as { url?: unknown; email?: unknown; events?: unknown };
114
+ const o = raw as { url?: unknown; email?: unknown; events?: unknown; signing_secret?: unknown };
98
115
  const url = typeof o.url === "string" ? o.url : undefined;
99
116
  const email = typeof o.email === "string" ? o.email : undefined;
100
117
  if (!url && !email) return null;
@@ -102,6 +119,8 @@ export function notifyConfig(tenant: string, workspace: string): NotifyConfig |
102
119
  url,
103
120
  email,
104
121
  events: Array.isArray(o.events) && o.events.length ? o.events.map(String) : DEFAULT_EVENTS,
122
+ signingSecret:
123
+ typeof o.signing_secret === "string" ? o.signing_secret.trim().replace(/^\$\{|\}$/g, "") || undefined : undefined,
105
124
  };
106
125
  }
107
126
  return null;
@@ -111,20 +130,76 @@ export function notifyConfig(tenant: string, workspace: string): NotifyConfig |
111
130
  // line per parked run would bury the failures around it.
112
131
  let warnedNoPublicUrl = false;
113
132
 
133
+ /**
134
+ * The headers that prove a delivery came from this install.
135
+ *
136
+ * `x-foldrun-signature` is a hex SHA-256 HMAC over "<timestamp>.<body>",
137
+ * keyed by the named vault secret — the timestamp is inside the signed
138
+ * string so a captured delivery cannot be replayed later with a fresh one.
139
+ * `x-signature` is the plain HMAC of the body beside it, the scheme the
140
+ * inbound `signature: hmac` verifies.
141
+ *
142
+ * No secret named, no headers: signing is opt-in, and a receiver that does
143
+ * not check one is no worse off than before.
144
+ */
145
+ export function signatureHeaders(
146
+ tenant: string,
147
+ workspace: string,
148
+ config: NotifyConfig,
149
+ body: string,
150
+ ): Record<string, string> {
151
+ if (!config.signingSecret) return {};
152
+ const secret = getSecret(tenant, config.signingSecret, workspace);
153
+ if (!secret) {
154
+ console.error(
155
+ `[foldrun] notify: signing_secret names ${config.signingSecret}, which is not in the vault for ${tenant}/${workspace} — sending unsigned`,
156
+ );
157
+ return {};
158
+ }
159
+ const timestamp = Math.floor(Date.now() / 1000).toString();
160
+ const mac = crypto.createHmac("sha256", secret.value).update(`${timestamp}.${body}`).digest("hex");
161
+ // Two headers. `x-foldrun-signature` is the timestamped one, and what a
162
+ // receiver should check. `x-signature` is the plain HMAC of the body,
163
+ // which is what the inbound `signature: hmac` verifies — so a foldrun
164
+ // notification pointed at another foldrun webhook flow is accepted, and
165
+ // any receiver written against the plain scheme keeps working.
166
+ const plain = crypto.createHmac("sha256", secret.value).update(body).digest("hex");
167
+ return { "x-foldrun-timestamp": timestamp, "x-foldrun-signature": `sha256=${mac}`, "x-signature": plain };
168
+ }
169
+
170
+ /** One waiting step's pair of links. */
171
+ export interface StepLinks {
172
+ /** Index into run.steps. */
173
+ step: number;
174
+ agent: string;
175
+ approveUrl: string;
176
+ rejectUrl: string;
177
+ }
178
+
114
179
  /**
115
180
  * The approve/reject links for a run waiting on a person, or null when the
116
181
  * install cannot say where it lives. A notification that only says "waiting
117
182
  * for you" sends the reader to find a laptop; one that carries the decision
118
183
  * lets them make it where they read it.
119
184
  *
185
+ * One pair per waiting step, each bound to that step and to a moment (the
186
+ * flow's `approve_within:`, else a week) — see approvals.ts. A link bound
187
+ * to the run alone approved every later gate of the run for as long as it
188
+ * ran. When the notification goes to a named approver — an address in the
189
+ * flow's `approvers:` — the links are bound to that person too, so a
190
+ * forwarded mail decides as nobody. `approveUrl`/`rejectUrl` on the result
191
+ * are the first waiting step's pair, for receivers written against that.
192
+ *
120
193
  * The reject link goes to the same page with the choice pre-selected — a
121
194
  * GET must not decide anything, because inbox link-checkers follow links.
122
195
  */
123
196
  export function approvalLinks(
124
197
  tenant: string,
125
198
  workspace: string,
126
- runId: string,
127
- ): { approveUrl: string; rejectUrl: string } | null {
199
+ run: RunRecord,
200
+ /** Who the notification is going to, when it is going to one address. */
201
+ recipient: string | null = null,
202
+ ): { approveUrl: string; rejectUrl: string; links: StepLinks[] } | null {
128
203
  const base = publicUrl();
129
204
  if (!base) {
130
205
  if (!warnedNoPublicUrl) {
@@ -135,8 +210,18 @@ export function approvalLinks(
135
210
  }
136
211
  return null;
137
212
  }
138
- const approveUrl = `${base}/api/approve/${tenant}/${workspace}/${runId}?token=${approveToken(tenant, workspace, runId)}`;
139
- return { approveUrl, rejectUrl: `${approveUrl}&decision=reject` };
213
+ const flow = readFlow(tenant, workspace, run.flow);
214
+ const to = (recipient ?? "").trim().toLowerCase();
215
+ const approver = to && flow?.approvers?.includes(to) ? to : null;
216
+ const expiresAt = Date.now() + approveLinkTtlMs(flow);
217
+ const links: StepLinks[] = [];
218
+ run.steps.forEach((s, i) => {
219
+ if (s.status !== "awaiting-approval" || s.waitFor === "event") return;
220
+ const approveUrl = `${base}${approveLinkPath(tenant, workspace, run.id, { step: i, expiresAt, approver })}`;
221
+ links.push({ step: i, agent: s.agent, approveUrl, rejectUrl: `${approveUrl}&decision=reject` });
222
+ });
223
+ if (links.length === 0) return null;
224
+ return { approveUrl: links[0].approveUrl, rejectUrl: links[0].rejectUrl, links };
140
225
  }
141
226
 
142
227
  /**
@@ -149,6 +234,169 @@ export function isQuietFlow(flow: string): boolean {
149
234
  return /^(eval|adhoc):/.test(flow);
150
235
  }
151
236
 
237
+ /**
238
+ * A notification that is not about one run finishing.
239
+ *
240
+ * Three things need to say something to the same destination a run
241
+ * notification goes to, and none of them has a RunRecord to hand: a flow
242
+ * that has been quarantined, a run that has outlived its `sla:`, and a
243
+ * budget that is nearly spent. They are the messages a person most needs
244
+ * and least expects, because each one is about something NOT happening —
245
+ * the failure mode that never sends mail is the one that costs a week.
246
+ *
247
+ * The event name is matched against `events:` exactly like a run status,
248
+ * so a workspace that wants none of this writes its own list and gets none
249
+ * of it. They are on by default: an alert nobody opted into is the point.
250
+ */
251
+ export async function sendPlainNotification(
252
+ tenant: string,
253
+ workspace: string,
254
+ msg: { event: string; headline: string; detail: string; flow?: string; runId?: string },
255
+ ): Promise<boolean> {
256
+ const config = notifyConfig(tenant, workspace);
257
+ if (!config) return false;
258
+ // These follow `failed`. Each one IS a failure — a flow that stopped
259
+ // running, a run that has stalled, a budget about to refuse work — that
260
+ // happens to produce no failed run for the normal path to report. A
261
+ // destination hearing about failures hears about these; naming one
262
+ // explicitly also turns it on; leaving `failed` out silences them.
263
+ if (!config.events.includes(msg.event) && !config.events.includes("failed")) return false;
264
+
265
+ const body = {
266
+ text: `${msg.headline} — ${msg.detail} · ${workspace}`,
267
+ workspace,
268
+ status: msg.event,
269
+ summary: msg.detail,
270
+ ...(msg.flow ? { flow: msg.flow } : {}),
271
+ ...(msg.runId ? { runId: msg.runId } : {}),
272
+ };
273
+ try {
274
+ if (config.email) {
275
+ const mail = notifyMail(tenant);
276
+ 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),
287
+ });
288
+ return res.ok;
289
+ }
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;
303
+ } catch (err) {
304
+ console.error(`[foldrun] notify (${msg.event}): ${tenant}/${workspace} →`, err instanceof Error ? err.message : err);
305
+ return false;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Prove the notification path works, now, while someone is looking.
311
+ *
312
+ * Every run notification is sent when nobody is watching, to a destination
313
+ * nobody has tested, through a mail domain nobody has verified. On
314
+ * 2026-09-06 every one of them had been going to a bin for weeks and the
315
+ * only symptom was silence — which is indistinguishable from nothing having
316
+ * gone wrong. An alert that has never been proven to arrive is not an alert.
317
+ *
318
+ * Unlike every other send here, this one reports its failure in full: the
319
+ * provider's own words, which are what actually name the problem ("domain
320
+ * not verified", "you can only send to your own address"). Swallowing them
321
+ * is right for a run notification and useless for a test.
322
+ */
323
+ export async function sendTestNotification(
324
+ tenant: string,
325
+ workspace: string,
326
+ ): Promise<{ ok: boolean; destination: string; detail: string }> {
327
+ const config = notifyConfig(tenant, workspace);
328
+ if (!config) {
329
+ return {
330
+ ok: false,
331
+ destination: "none",
332
+ detail:
333
+ "no notify: in this workspace's AGENTS.md or the account's — nothing would be sent for a failure or a gate either",
334
+ };
335
+ }
336
+ const sent = new Date().toISOString();
337
+ const headline = "\u2713 foldrun test notification";
338
+ const detail = `Sent from ${workspace} at ${sent}. If you are reading this, failures and approval gates will reach you here too.`;
339
+
340
+ if (config.email) {
341
+ const mail = notifyMail(tenant);
342
+ if (!mail) {
343
+ return {
344
+ ok: false,
345
+ destination: config.email,
346
+ detail:
347
+ "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
+ };
349
+ }
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) };
364
+ }
365
+ }
366
+
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
+ // The destination is echoed back with the secret still unresolved: a
373
+ // Slack webhook URL is itself a credential, and a page that prints it is
374
+ // a page that leaks it into a screenshot.
375
+ const shown = raw.includes("${") ? raw : `${url.slice(0, 40)}…`;
376
+ if (!url || url.includes("${")) {
377
+ return { ok: false, destination: shown, detail: `the secret named in notify.url is not in this account's vault` };
378
+ }
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
+ }
398
+ }
399
+
152
400
  export async function sendRunNotification(
153
401
  tenant: string,
154
402
  workspace: string,
@@ -162,6 +410,11 @@ export async function sendRunNotification(
162
410
  // on 2026-09-06 the reader's inbox rule had sent the lot to the bin, the
163
411
  // real reports with them. Failures and gates still send: a person asked.
164
412
  if (run.status === "completed" && isQuietFlow(run.flow)) return false;
413
+ // A test run sent nothing outward and this must not be the exception: it
414
+ // reports only a failure, which is the one thing the person testing it
415
+ // needs told about without watching. Its gate mail would ask a real
416
+ // decision about a pretend send.
417
+ if (run.test && run.status !== "failed") return false;
165
418
 
166
419
  const failed = run.steps.filter((s) => s.status === "failed").map((s) => s.agent);
167
420
  const waitingSteps = run.steps.filter((s) => s.status === "awaiting-approval");
@@ -185,7 +438,9 @@ export async function sendRunNotification(
185
438
  // reporting only its own existence is a desk nobody reads by week three.
186
439
  const summary = run.summary?.trim() || null;
187
440
 
188
- const links = run.status === "awaiting-approval" ? approvalLinks(tenant, workspace, run.id) : null;
441
+ // An emailed link is minted for its reader: a named approver's mail
442
+ // carries links that decide as them. A webhook has no reader.
443
+ const links = run.status === "awaiting-approval" ? approvalLinks(tenant, workspace, run, config.email ?? null) : null;
189
444
  // At a gate the reader is being asked something; the question goes in the
190
445
  // mail, and so does what the run has concluded so far — the last finished
191
446
  // step's first line, which is the proposal's own summary ("… ·
@@ -238,10 +493,15 @@ export async function sendRunNotification(
238
493
  (asks.length ? `\n${asks.map((a) => `Question: ${a}`).join("\n")}\n` : "") +
239
494
  `\nworkspace: ${workspace}\nrun: ${run.id}\nflow: ${run.flow}\n` +
240
495
  `cost: $${runCost(run).toFixed(4)}\nstarted: ${run.startedAt}\nfinished: ${run.finishedAt ?? "-"}\n` +
241
- (links ? `\nApprove: ${links.approveUrl}\nReject: ${links.rejectUrl}\n` : ""),
496
+ (links
497
+ ? links.links.length === 1
498
+ ? `\nApprove: ${links.approveUrl}\nReject: ${links.rejectUrl}\n`
499
+ : links.links.map((l) => `\nStep ${l.step + 1} (${l.agent})\nApprove: ${l.approveUrl}\nReject: ${l.rejectUrl}\n`).join("")
500
+ : ""),
242
501
  }),
243
502
  signal: AbortSignal.timeout(8000),
244
503
  });
504
+ noteMailUse(tenant, res.status);
245
505
  if (!res.ok) {
246
506
  // Resend's body says WHY — "domain not verified", "can only send to
247
507
  // your own address" — and a status alone sent someone to the wrong
@@ -263,10 +523,11 @@ export async function sendRunNotification(
263
523
  console.error(`[foldrun] notify: secret in URL not set for ${tenant}/${workspace}`);
264
524
  return false;
265
525
  }
526
+ const payload = JSON.stringify(body);
266
527
  const res = await fetch(url, {
267
528
  method: "POST",
268
- headers: { "content-type": "application/json" },
269
- body: JSON.stringify(body),
529
+ headers: { "content-type": "application/json", ...signatureHeaders(tenant, workspace, config, payload) },
530
+ body: payload,
270
531
  signal: AbortSignal.timeout(5000),
271
532
  });
272
533
  if (!res.ok) {
package/src/paths.ts CHANGED
@@ -77,3 +77,32 @@ export function dataRoot(): string {
77
77
  export function singleWorkspace(): string | null {
78
78
  return process.env.FOLDRUN_WORKSPACE ?? null;
79
79
  }
80
+
81
+ // ------------------------------------------------------- linear trimming
82
+ //
83
+ // `s.replace(/\/+$/, "")` is the obvious way to drop trailing slashes and
84
+ // it is quadratic: the engine tries `\/+` from every position and fails at
85
+ // `$` each time, so a string of ten thousand slashes is fifty million
86
+ // steps. Several of these strings come from a customer's own files — a
87
+ // base_url, a tool's base, a storage prefix — which is exactly the input
88
+ // that must not be able to stall a worker. A loop is linear and reads the
89
+ // same.
90
+
91
+ /** Without leading and/or trailing slashes. */
92
+ export function trimSlashes(s: string, where: "start" | "end" | "both" = "end"): string {
93
+ let a = 0;
94
+ let b = s.length;
95
+ if (where !== "end") while (a < b && s[a] === "/") a++;
96
+ if (where !== "start") while (b > a && s[b - 1] === "/") b--;
97
+ return s.slice(a, b);
98
+ }
99
+
100
+ /** Without any of `chars` at the ends — the linear form of
101
+ * `.replace(/^[chars]+/, "").replace(/[chars]+$/, "")`. */
102
+ export function trimChars(s: string, chars: string): string {
103
+ let a = 0;
104
+ let b = s.length;
105
+ while (a < b && chars.includes(s[a])) a++;
106
+ while (b > a && chars.includes(s[b - 1])) b--;
107
+ return s.slice(a, b);
108
+ }