@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.
- package/README.md +27 -10
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/confine.js +6 -1
- package/dist/src/egress.d.ts +19 -2
- package/dist/src/egress.js +2 -1
- package/dist/src/evals.d.ts +4 -0
- package/dist/src/evals.js +20 -11
- package/dist/src/flow-lint.d.ts +4 -0
- package/dist/src/flow-lint.js +108 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/library.js +13 -1
- package/dist/src/notify.d.ts +79 -1
- package/dist/src/notify.js +243 -9
- package/dist/src/paths.d.ts +5 -0
- package/dist/src/paths.js +32 -0
- package/dist/src/platform.d.ts +102 -2
- package/dist/src/platform.js +18 -2
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +232 -0
- package/dist/src/run-container.d.ts +57 -4
- package/dist/src/run-container.js +147 -41
- package/dist/src/runner.d.ts +39 -3
- package/dist/src/runner.js +422 -73
- package/dist/src/script-tools.d.ts +12 -0
- package/dist/src/script-tools.js +10 -2
- package/dist/src/secret-health.d.ts +59 -0
- package/dist/src/secret-health.js +155 -0
- package/dist/src/secrets.d.ts +19 -1
- package/dist/src/secrets.js +44 -12
- package/dist/src/starter.js +3 -0
- package/dist/src/step-exec.d.ts +47 -1
- package/dist/src/step-exec.js +155 -60
- package/dist/src/store.d.ts +276 -3
- package/dist/src/store.js +552 -26
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-test.js +48 -21
- package/dist/src/translator.js +2 -1
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +20 -0
- package/dist/src/triggers.js +91 -10
- package/dist/src/webhook.d.ts +15 -3
- package/dist/src/webhook.js +11 -11
- package/package.json +4 -4
- package/src/approvals.ts +253 -2
- package/src/budget.ts +178 -0
- package/src/catalog.ts +2 -1
- package/src/confine.ts +6 -1
- package/src/egress.ts +20 -4
- package/src/evals.ts +25 -9
- package/src/flow-lint.ts +118 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/library.ts +13 -1
- package/src/notify.ts +272 -11
- package/src/paths.ts +29 -0
- package/src/platform.ts +107 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +175 -36
- package/src/runner.ts +435 -65
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +3 -0
- package/src/step-exec.ts +128 -13
- package/src/storage.ts +8 -2
- package/src/store.ts +746 -25
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-test.ts +49 -20
- package/src/translator.ts +2 -1
- package/src/trigger-log.ts +159 -0
- package/src/triggers.ts +92 -10
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
127
|
-
|
|
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
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
+
}
|