@foldrun/core 2.10.1 → 2.11.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/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/catalog.d.ts +3 -0
- package/dist/src/catalog.js +4 -1
- package/dist/src/deploy.js +16 -0
- package/dist/src/eval-compare.d.ts +74 -0
- package/dist/src/eval-compare.js +187 -0
- package/dist/src/evals.d.ts +62 -3
- package/dist/src/evals.js +161 -36
- package/dist/src/flow-lint.js +4 -0
- package/dist/src/frontmatter-fields.js +4 -1
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +113 -0
- package/dist/src/gitrepo.js +404 -0
- package/dist/src/grades.d.ts +118 -0
- package/dist/src/grades.js +481 -0
- package/dist/src/judge-verdict.d.ts +1 -0
- package/dist/src/judge-verdict.js +3 -1
- package/dist/src/online-checks.d.ts +4 -0
- package/dist/src/online-checks.js +5 -1
- package/dist/src/openapi.d.ts +4 -0
- package/dist/src/openapi.js +5 -1
- package/dist/src/platform.d.ts +12 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/starter.js +5 -0
- package/dist/src/store.d.ts +8 -0
- package/dist/src/store.js +5 -1
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +112 -0
- package/dist/src/tool-test.js +19 -7
- package/dist/src/translator.d.ts +4 -0
- package/dist/src/translator.js +22 -3
- package/dist/src/trigger-filter.d.ts +28 -0
- package/dist/src/trigger-filter.js +272 -0
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +2 -0
- package/dist/src/triggers.js +13 -2
- package/dist/src/untrusted-fetch.d.ts +16 -0
- package/dist/src/untrusted-fetch.js +60 -2
- package/index.ts +3 -0
- package/package.json +2 -2
- package/src/catalog.ts +5 -1
- package/src/deploy.ts +10 -0
- package/src/eval-compare.ts +243 -0
- package/src/evals.ts +195 -38
- package/src/flow-lint.ts +4 -0
- package/src/frontmatter-fields.ts +4 -1
- package/src/grades.ts +532 -0
- package/src/judge-verdict.ts +4 -2
- package/src/online-checks.ts +5 -1
- package/src/openapi.ts +6 -1
- package/src/platform.ts +16 -4
- package/src/starter.ts +5 -0
- package/src/store.ts +11 -1
- package/src/tool-test.ts +21 -7
- package/src/translator.ts +24 -3
- package/src/trigger-filter.ts +264 -0
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +14 -2
- package/src/untrusted-fetch.ts +65 -2
package/src/store.ts
CHANGED
|
@@ -48,6 +48,7 @@ export function readFrontmatter(file: string): Record<string, unknown> {
|
|
|
48
48
|
}
|
|
49
49
|
import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.ts";
|
|
50
50
|
import { refNames } from "./refs.ts";
|
|
51
|
+
import { checkFilter } from "./trigger-filter.ts";
|
|
51
52
|
import { parseBudget, budgetProblem } from "./budget.ts";
|
|
52
53
|
import { timezoneProblem } from "./clock.ts";
|
|
53
54
|
import { parseStepLimits, readLimits } from "./limits.ts";
|
|
@@ -1410,6 +1411,13 @@ export interface FlowInfo {
|
|
|
1410
1411
|
/** `trigger: storage` — a path prefix under storage/; a file landing
|
|
1411
1412
|
* there starts the flow with the path as its task. */
|
|
1412
1413
|
path: string | null;
|
|
1414
|
+
/** `filter:` — the fields a delivery must have before it becomes a run
|
|
1415
|
+
* (trigger-filter.ts): matched against a webhook's JSON body, an email,
|
|
1416
|
+
* a chained run or a storage event. Null: no `filter:` line, every
|
|
1417
|
+
* delivery. Kept exactly as written — a list, `false`, a word — so one
|
|
1418
|
+
* that cannot be read matches nothing (fails closed) rather than reading
|
|
1419
|
+
* as no filter at all. Test it with `!= null`, never for truthiness. */
|
|
1420
|
+
filter: unknown;
|
|
1413
1421
|
|
|
1414
1422
|
// ---- how often a trigger is allowed to become a run ----
|
|
1415
1423
|
//
|
|
@@ -2055,6 +2063,7 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
2055
2063
|
badEnum("catchup", data.catchup, ["none", "last"]);
|
|
2056
2064
|
badEnum("on", data.on, ["completed", "failed", "blocked", "any"]);
|
|
2057
2065
|
badEnum("signature", data.signature, ["github", "stripe", "slack", "hmac"]);
|
|
2066
|
+
for (const p of checkFilter(data.filter)) frontProblems.push(p);
|
|
2058
2067
|
|
|
2059
2068
|
steps.sort((a, b) => a.group - b.group);
|
|
2060
2069
|
return {
|
|
@@ -2086,6 +2095,7 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
2086
2095
|
? data.signing_secret.trim().replace(/^\$\{|\}$/g, "")
|
|
2087
2096
|
: null,
|
|
2088
2097
|
path: typeof data.path === "string" ? data.path.trim().replace(/^\/+/, "") : null,
|
|
2098
|
+
filter: data.filter === undefined || data.filter === null ? null : data.filter,
|
|
2089
2099
|
// A header name is case-insensitive on the wire; a JSON field is not.
|
|
2090
2100
|
// `body:eventId` lower-cased to `body:eventid` matched nothing, and a
|
|
2091
2101
|
// dedupe that matches nothing admits everything.
|
|
@@ -2607,7 +2617,7 @@ export function isEditablePath(rel: string): boolean {
|
|
|
2607
2617
|
}
|
|
2608
2618
|
}
|
|
2609
2619
|
|
|
2610
|
-
function assertEditablePath(rel: string) {
|
|
2620
|
+
export function assertEditablePath(rel: string) {
|
|
2611
2621
|
const norm = path.normalize(rel);
|
|
2612
2622
|
if (norm.startsWith("..") || path.isAbsolute(norm)) throw new Error(`illegal path: ${rel}`);
|
|
2613
2623
|
assertCanonicalCase(norm);
|
package/src/tool-test.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { commandFor, resolveRunPath } from "./script-tools.ts";
|
|
|
28
28
|
import { parseRuntime, prepareRuntime } from "./runtime.ts";
|
|
29
29
|
import { hostSafeEnv } from "./host-env.ts";
|
|
30
30
|
import { linkWorkspace, workspaceLinkEnv } from "./confine.ts";
|
|
31
|
+
import { fetchUntrusted, ResponseTooLarge, MAX_BYTES } from "./untrusted-fetch.ts";
|
|
31
32
|
|
|
32
33
|
export interface ToolTestResult {
|
|
33
34
|
ok: boolean;
|
|
@@ -174,12 +175,23 @@ export async function testTool(
|
|
|
174
175
|
const headers: Record<string, string> = {};
|
|
175
176
|
for (const [k, v] of Object.entries(api.headers)) headers[k] = substitute(v, env);
|
|
176
177
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
178
|
+
// A URL the tenant chose, fetched from the web tier: through the
|
|
179
|
+
// platform's guard (no private or cluster address, each redirect hop
|
|
180
|
+
// checked), and the secrets in the headers go only to the origin
|
|
181
|
+
// they were written for.
|
|
182
|
+
const res = await fetchUntrusted(
|
|
183
|
+
url.href,
|
|
184
|
+
{ method, headers, signal: AbortSignal.timeout(TIMEOUT_MS) },
|
|
185
|
+
{ sameOrigin: true },
|
|
186
|
+
);
|
|
187
|
+
// The status is the test; a body past the limit is said, not shown.
|
|
188
|
+
let body: string;
|
|
189
|
+
try {
|
|
190
|
+
body = await res.text();
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (!(err instanceof ResponseTooLarge)) throw err;
|
|
193
|
+
body = `(the answer was larger than ${MAX_BYTES / (1024 * 1024)} MiB — not shown)`;
|
|
194
|
+
}
|
|
183
195
|
|
|
184
196
|
// An unset secret means the tool cannot authenticate, so it is not
|
|
185
197
|
// working — whatever a permissive endpoint chose to return. Reporting
|
|
@@ -366,7 +378,9 @@ export async function testTool(
|
|
|
366
378
|
// A remote server: the honest check we can make without a full client is
|
|
367
379
|
// that the endpoint answers.
|
|
368
380
|
try {
|
|
369
|
-
|
|
381
|
+
// Through the platform's guard, as the http probe is; only the status is read.
|
|
382
|
+
const res = await fetchUntrusted(spec.url, { signal: AbortSignal.timeout(TIMEOUT_MS) }, { sameOrigin: true });
|
|
383
|
+
await res.body?.cancel().catch(() => {});
|
|
370
384
|
return done({
|
|
371
385
|
ok: res.ok,
|
|
372
386
|
transport: "mcp",
|
package/src/translator.ts
CHANGED
|
@@ -32,6 +32,7 @@ import http from "node:http";
|
|
|
32
32
|
import crypto from "node:crypto";
|
|
33
33
|
import { PROTECTED_PARAMS } from "./providers.ts";
|
|
34
34
|
import { viaEgress } from "./egress.ts";
|
|
35
|
+
import { fetchUntrusted } from "./untrusted-fetch.ts";
|
|
35
36
|
import { trimSlashes } from "./paths.ts";
|
|
36
37
|
import { captureRefusalHeaders, isRefusalStatus, refusalLine } from "./refusal.ts";
|
|
37
38
|
|
|
@@ -907,6 +908,11 @@ function upstreamMessage(text: string, status: number): string {
|
|
|
907
908
|
|
|
908
909
|
// ---------------------------------------------------------------- server
|
|
909
910
|
|
|
911
|
+
/** The most one upstream answer may be, read, on the host: a long streamed
|
|
912
|
+
* reply is many small events, well past fetchUntrusted's 1 MiB default,
|
|
913
|
+
* and still far under this. */
|
|
914
|
+
export const UPSTREAM_MAX_BYTES = 64 * 1024 * 1024;
|
|
915
|
+
|
|
910
916
|
/**
|
|
911
917
|
* Start the translator for one step. Binds an ephemeral port on loopback,
|
|
912
918
|
* mints the key the SDK will present, and returns the env that points the
|
|
@@ -963,7 +969,8 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
|
|
|
963
969
|
|
|
964
970
|
let upstream: Response;
|
|
965
971
|
try {
|
|
966
|
-
|
|
972
|
+
const target = `${base}${responses ? "/responses" : "/chat/completions"}`;
|
|
973
|
+
const request: RequestInit = {
|
|
967
974
|
method: "POST",
|
|
968
975
|
headers: {
|
|
969
976
|
"content-type": "application/json",
|
|
@@ -971,7 +978,16 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
|
|
|
971
978
|
...(spec.headers ?? {}),
|
|
972
979
|
},
|
|
973
980
|
body: JSON.stringify(outbound),
|
|
974
|
-
}
|
|
981
|
+
};
|
|
982
|
+
// In a step the lease's proxy is the way out: it guards the address
|
|
983
|
+
// and fills the key itself. Without one this runs on the host — an
|
|
984
|
+
// eval's judge, a dashboard draft — at a base URL the tenant wrote,
|
|
985
|
+
// so through the platform's guard: no private or cluster address,
|
|
986
|
+
// the key never follows a redirect off the provider's origin, and
|
|
987
|
+
// the answer read to UPSTREAM_MAX_BYTES.
|
|
988
|
+
upstream = spec.egress
|
|
989
|
+
? await fetch(viaEgress(spec.egress, target), request)
|
|
990
|
+
: await fetchUntrusted(target, request, { sameOrigin: true, maxBytes: UPSTREAM_MAX_BYTES });
|
|
975
991
|
} catch (err) {
|
|
976
992
|
const message = err instanceof Error ? err.message : String(err);
|
|
977
993
|
log.push(`POST ${label} → unreachable: ${message}`);
|
|
@@ -1050,7 +1066,12 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
|
|
|
1050
1066
|
const message = err instanceof Error ? err.message : String(err);
|
|
1051
1067
|
log.push(`translator error: ${message}`);
|
|
1052
1068
|
if (!res.headersSent) send(500, errorBody(500, `translator: ${message}`));
|
|
1053
|
-
else
|
|
1069
|
+
else {
|
|
1070
|
+
// Mid-stream: say so as an Anthropic error event, so the SDK reads a
|
|
1071
|
+
// failed reply, not a short one that merely stopped.
|
|
1072
|
+
res.write(`event: error\ndata: ${errorBody(500, `translator: ${message}`)}\n\n`);
|
|
1073
|
+
res.end();
|
|
1074
|
+
}
|
|
1054
1075
|
}
|
|
1055
1076
|
});
|
|
1056
1077
|
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// `filter:` — which deliveries of an event trigger become runs.
|
|
2
|
+
//
|
|
3
|
+
// A webhook flow used to start for every POST that carried the right token:
|
|
4
|
+
// a GitHub hook for "pull requests" started a run for drafts, bot pushes and
|
|
5
|
+
// label changes alike, and the first step's only job was to say "not this
|
|
6
|
+
// one" — a sandbox, a model call and a run on the record, per non-event.
|
|
7
|
+
// A filter says it in the flow's own file, and nothing starts:
|
|
8
|
+
//
|
|
9
|
+
// filter:
|
|
10
|
+
// action: [opened, reopened] # any of these values
|
|
11
|
+
// pull_request:
|
|
12
|
+
// draft: false # exactly this value
|
|
13
|
+
// base:
|
|
14
|
+
// ref: { starts_with: release/ } # text, never a regular expression
|
|
15
|
+
// sender:
|
|
16
|
+
// type: { not_in: [Bot] } # none of these
|
|
17
|
+
//
|
|
18
|
+
// What the pattern is matched against depends on the trigger: a webhook's
|
|
19
|
+
// JSON body; an email's { from, to, subject, text }; a chained flow's
|
|
20
|
+
// { run, flow, status, verdict, summary }; a storage event's { by, paths }.
|
|
21
|
+
//
|
|
22
|
+
// The rules, all of them:
|
|
23
|
+
//
|
|
24
|
+
// · every key must match (AND); a nested object matches the nested field;
|
|
25
|
+
// · a list is the values allowed (any of them); a scalar is the one value;
|
|
26
|
+
// · an operator object — only the keys in, not_in, exists, starts_with,
|
|
27
|
+
// ends_with, contains — says it explicitly; the last three compare text
|
|
28
|
+
// (case-sensitive) and never run a regular expression, so a body a
|
|
29
|
+
// sender crafts cannot make one backtrack for minutes;
|
|
30
|
+
// · strings compare exactly and case-sensitively, numbers numerically
|
|
31
|
+
// (1 equals 1.0), booleans and null as themselves;
|
|
32
|
+
// · a field that is missing fails `in`, a value and the text operators, and passes
|
|
33
|
+
// `not_in`; `exists: false` passes only when it is missing;
|
|
34
|
+
// · a list in the payload: a nested pattern matches when any element
|
|
35
|
+
// does; `in` matches when any element is allowed, `not_in` when none is;
|
|
36
|
+
// · it fails closed — a body that is not JSON, or a pattern that cannot be
|
|
37
|
+
// evaluated, never matches.
|
|
38
|
+
//
|
|
39
|
+
// Pure and browser-safe: the gate, the check, the editor and the dashboard's
|
|
40
|
+
// Test box all call these two functions.
|
|
41
|
+
|
|
42
|
+
export const FILTER_LIMITS = { depth: 10, leaves: 64, values: 32, textChars: 500 } as const;
|
|
43
|
+
|
|
44
|
+
const TEXT_OPERATORS = ["starts_with", "ends_with", "contains"] as const;
|
|
45
|
+
const TEXT_VERB = { starts_with: "start with", ends_with: "end with", contains: "contain" } as const;
|
|
46
|
+
const OPERATORS = new Set<string>(["in", "not_in", "exists", ...TEXT_OPERATORS]);
|
|
47
|
+
|
|
48
|
+
type Scalar = string | number | boolean | null;
|
|
49
|
+
|
|
50
|
+
const isScalar = (v: unknown): v is Scalar =>
|
|
51
|
+
v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
52
|
+
|
|
53
|
+
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
54
|
+
!!v && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
|
|
55
|
+
|
|
56
|
+
/** An object whose every key is an operator — `{ not_in: [Bot] }`. A pattern
|
|
57
|
+
* object mixing operators with field names is refused by the check, since
|
|
58
|
+
* it cannot be read one way. */
|
|
59
|
+
function isOperatorObject(v: unknown): v is Record<string, unknown> {
|
|
60
|
+
if (!isPlainObject(v)) return false;
|
|
61
|
+
const keys = Object.keys(v);
|
|
62
|
+
return keys.length > 0 && keys.every((k) => OPERATORS.has(k));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** YAML reads `2026-10-01` as a Date; a filter means the text. */
|
|
66
|
+
const asScalar = (v: unknown): Scalar | undefined =>
|
|
67
|
+
v instanceof Date ? v.toISOString().slice(0, 10) : isScalar(v) ? v : undefined;
|
|
68
|
+
|
|
69
|
+
/** Strings exactly, numbers numerically, the rest by identity. */
|
|
70
|
+
function sameValue(a: Scalar, b: unknown): boolean {
|
|
71
|
+
if (typeof a === "number" && typeof b === "number") return a === b;
|
|
72
|
+
return a === b;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const show = (v: unknown) => (typeof v === "string" ? JSON.stringify(v) : JSON.stringify(v ?? null));
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What is wrong with a `filter:` as written, one sentence each — empty when
|
|
79
|
+
* it can be read. `foldrun check`, the deploy and the editor report these as
|
|
80
|
+
* `config/invalid-value`.
|
|
81
|
+
*/
|
|
82
|
+
export function checkFilter(filter: unknown): string[] {
|
|
83
|
+
const problems: string[] = [];
|
|
84
|
+
if (filter === undefined || filter === null) return problems;
|
|
85
|
+
if (!isPlainObject(filter) || Object.keys(filter).length === 0) {
|
|
86
|
+
return ["filter: is a map of field names to the values they must have — e.g. `action: [opened]`"];
|
|
87
|
+
}
|
|
88
|
+
let leaves = 0;
|
|
89
|
+
const walk = (node: Record<string, unknown>, where: string, depth: number) => {
|
|
90
|
+
if (depth > FILTER_LIMITS.depth) {
|
|
91
|
+
problems.push(`filter: nests deeper than ${FILTER_LIMITS.depth} levels at ${where}`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
for (const [key, value] of Object.entries(node)) {
|
|
95
|
+
const at = where ? `${where}.${key}` : key;
|
|
96
|
+
if (OPERATORS.has(key)) {
|
|
97
|
+
problems.push(`filter: ${at} — ${key} is an operator; write it under a field name, e.g. \`sender: { ${key}: … }\``);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (isPlainObject(value) && Object.keys(value).some((k) => OPERATORS.has(k))) {
|
|
101
|
+
if (!isOperatorObject(value)) {
|
|
102
|
+
problems.push(`filter: ${at} mixes operators (${[...OPERATORS].join(", ")}) with field names — split them`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
leaves++;
|
|
106
|
+
problems.push(...checkOperators(value, at));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (isPlainObject(value)) {
|
|
110
|
+
if (Object.keys(value).length === 0) problems.push(`filter: ${at} is an empty map, which says nothing`);
|
|
111
|
+
else walk(value, at, depth + 1);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
leaves++;
|
|
115
|
+
if (Array.isArray(value)) problems.push(...checkValues(value, at, "the list"));
|
|
116
|
+
else if (asScalar(value) === undefined) problems.push(`filter: ${at} — a value, a list of values, a map, or { in, not_in, exists, starts_with, ends_with, contains }`);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
walk(filter, "", 1);
|
|
120
|
+
if (leaves > FILTER_LIMITS.leaves) problems.push(`filter: has ${leaves} conditions — at most ${FILTER_LIMITS.leaves}`);
|
|
121
|
+
return problems;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function checkValues(list: unknown[], at: string, what: string): string[] {
|
|
125
|
+
const out: string[] = [];
|
|
126
|
+
if (list.length === 0) out.push(`filter: ${at} — ${what} is empty, so nothing could ever match`);
|
|
127
|
+
if (list.length > FILTER_LIMITS.values) out.push(`filter: ${at} — ${what} has ${list.length} values, at most ${FILTER_LIMITS.values}`);
|
|
128
|
+
if (list.some((v) => asScalar(v) === undefined)) out.push(`filter: ${at} — ${what} holds only plain values (text, numbers, true/false, null)`);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function checkOperators(op: Record<string, unknown>, at: string): string[] {
|
|
133
|
+
const out: string[] = [];
|
|
134
|
+
const list = (k: "in" | "not_in") => {
|
|
135
|
+
const v = op[k];
|
|
136
|
+
if (v === undefined) return null;
|
|
137
|
+
const arr = Array.isArray(v) ? v : [v];
|
|
138
|
+
out.push(...checkValues(arr, at, k));
|
|
139
|
+
return arr.map(asScalar).filter((x): x is Scalar => x !== undefined);
|
|
140
|
+
};
|
|
141
|
+
const inList = list("in");
|
|
142
|
+
const notIn = list("not_in");
|
|
143
|
+
if (inList && notIn) {
|
|
144
|
+
const both = inList.filter((v) => notIn.some((w) => sameValue(v, w)));
|
|
145
|
+
if (both.length) out.push(`filter: ${at} lists ${both.map(show).join(", ")} in both in and not_in`);
|
|
146
|
+
}
|
|
147
|
+
if (op.exists !== undefined && typeof op.exists !== "boolean") out.push(`filter: ${at} — exists: is true or false`);
|
|
148
|
+
const texts = TEXT_OPERATORS.filter((k) => op[k] !== undefined);
|
|
149
|
+
if (op.exists === false && (inList?.length || texts.length)) {
|
|
150
|
+
out.push(`filter: ${at} — exists: false cannot also require a value`);
|
|
151
|
+
}
|
|
152
|
+
for (const k of texts) {
|
|
153
|
+
const v = op[k];
|
|
154
|
+
if (typeof v !== "string" || !v) out.push(`filter: ${at} — ${k}: is text, e.g. \`${k}: "refs/heads/"\``);
|
|
155
|
+
else if (v.length > FILTER_LIMITS.textChars) out.push(`filter: ${at} — ${k}: is longer than ${FILTER_LIMITS.textChars} characters`);
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface FilterVerdict {
|
|
161
|
+
match: boolean;
|
|
162
|
+
/** Why, in one sentence: the first condition that failed, or "matched". */
|
|
163
|
+
reason: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const MISSING = Symbol("missing");
|
|
167
|
+
|
|
168
|
+
/** One field's value against one leaf of the pattern. */
|
|
169
|
+
function leafMatches(value: unknown, cond: unknown, at: string): FilterVerdict {
|
|
170
|
+
// An operator object.
|
|
171
|
+
if (isOperatorObject(cond)) {
|
|
172
|
+
if (cond.exists !== undefined) {
|
|
173
|
+
const present = value !== MISSING;
|
|
174
|
+
if (cond.exists === true && !present) return { match: false, reason: `${at} is missing (exists: true)` };
|
|
175
|
+
if (cond.exists === false && present) return { match: false, reason: `${at} is present (exists: false)` };
|
|
176
|
+
}
|
|
177
|
+
const values = Array.isArray(value) ? value : value === MISSING ? [] : [value];
|
|
178
|
+
if (cond.in !== undefined) {
|
|
179
|
+
const allowed = (Array.isArray(cond.in) ? cond.in : [cond.in]).map(asScalar).filter((x): x is Scalar => x !== undefined);
|
|
180
|
+
if (value === MISSING) return { match: false, reason: `${at} is missing` };
|
|
181
|
+
if (!values.some((v) => allowed.some((a) => sameValue(a, v)))) return { match: false, reason: `${at} is ${show(value)}, not one of ${allowed.map(show).join(", ")}` };
|
|
182
|
+
}
|
|
183
|
+
if (cond.not_in !== undefined) {
|
|
184
|
+
const refused = (Array.isArray(cond.not_in) ? cond.not_in : [cond.not_in]).map(asScalar).filter((x): x is Scalar => x !== undefined);
|
|
185
|
+
const hit = values.find((v) => refused.some((a) => sameValue(a, v)));
|
|
186
|
+
if (hit !== undefined) return { match: false, reason: `${at} is ${show(hit)}, which not_in refuses` };
|
|
187
|
+
}
|
|
188
|
+
for (const k of TEXT_OPERATORS) {
|
|
189
|
+
const want = cond[k];
|
|
190
|
+
if (want === undefined) continue;
|
|
191
|
+
if (value === MISSING) return { match: false, reason: `${at} is missing` };
|
|
192
|
+
const w = String(want);
|
|
193
|
+
const texts = values.filter((v) => typeof v === "string" || typeof v === "number").map(String);
|
|
194
|
+
const hit = texts.some((t) => (k === "starts_with" ? t.startsWith(w) : k === "ends_with" ? t.endsWith(w) : t.includes(w)));
|
|
195
|
+
if (!hit) return { match: false, reason: `${at} is ${show(value)}, which does not ${TEXT_VERB[k]} ${show(w)}` };
|
|
196
|
+
}
|
|
197
|
+
return { match: true, reason: "matched" };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A list of allowed values, or one value.
|
|
201
|
+
const allowed = (Array.isArray(cond) ? cond : [cond]).map(asScalar);
|
|
202
|
+
if (allowed.some((a) => a === undefined)) return { match: false, reason: `${at}: the filter's value cannot be compared` };
|
|
203
|
+
if (value === MISSING) return { match: false, reason: `${at} is missing` };
|
|
204
|
+
const values = Array.isArray(value) ? value : [value];
|
|
205
|
+
if (values.some((v) => allowed.some((a) => sameValue(a as Scalar, v)))) return { match: true, reason: "matched" };
|
|
206
|
+
return {
|
|
207
|
+
match: false,
|
|
208
|
+
reason: allowed.length === 1 ? `${at} is ${show(value)}, not ${show(allowed[0])}` : `${at} is ${show(value)}, not one of ${allowed.map(show).join(", ")}`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function nodeMatches(payload: unknown, pattern: Record<string, unknown>, where: string, depth: number): FilterVerdict {
|
|
213
|
+
if (depth > FILTER_LIMITS.depth) return { match: false, reason: `the filter nests deeper than ${FILTER_LIMITS.depth} levels` };
|
|
214
|
+
for (const [key, cond] of Object.entries(pattern)) {
|
|
215
|
+
const at = where ? `${where}.${key}` : key;
|
|
216
|
+
const value = isPlainObject(payload) && Object.prototype.hasOwnProperty.call(payload, key) ? payload[key] : MISSING;
|
|
217
|
+
if (isPlainObject(cond) && !isOperatorObject(cond)) {
|
|
218
|
+
// A nested pattern: the field must be an object that matches it, or a
|
|
219
|
+
// list holding one that does.
|
|
220
|
+
if (value === MISSING) return { match: false, reason: `${at} is missing` };
|
|
221
|
+
// A nested pattern needs an object (or a list of them) to look inside:
|
|
222
|
+
// a scalar or null there is not "every field missing", which would let
|
|
223
|
+
// not_in and exists: false pass on a malformed delivery.
|
|
224
|
+
if (!Array.isArray(value) && !isPlainObject(value)) return { match: false, reason: `${at} is ${show(value)}, not an object` };
|
|
225
|
+
if (Array.isArray(value)) {
|
|
226
|
+
const objects = value.filter(isPlainObject);
|
|
227
|
+
if (objects.some((o) => nodeMatches(o, cond, at, depth + 1).match)) continue;
|
|
228
|
+
return { match: false, reason: `no item of ${at} matches` };
|
|
229
|
+
}
|
|
230
|
+
const inner = nodeMatches(value, cond, at, depth + 1);
|
|
231
|
+
if (!inner.match) return inner;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const leaf = leafMatches(value, cond, at);
|
|
235
|
+
if (!leaf.match) return leaf;
|
|
236
|
+
}
|
|
237
|
+
return { match: true, reason: "matched" };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Does this payload pass this filter? No filter passes everything. A filter
|
|
242
|
+
* the check would refuse never matches — it fails closed, so a typo holds
|
|
243
|
+
* deliveries back instead of letting all of them through.
|
|
244
|
+
*/
|
|
245
|
+
export function matchFilter(filter: unknown, payload: unknown): FilterVerdict {
|
|
246
|
+
if (filter === undefined || filter === null) return { match: true, reason: "no filter" };
|
|
247
|
+
const problems = checkFilter(filter);
|
|
248
|
+
if (problems.length) return { match: false, reason: `the filter cannot be read: ${problems[0].replace(/^filter: /, "")}` };
|
|
249
|
+
if (!isPlainObject(payload)) return { match: false, reason: "the delivery is not a JSON object, so no field can match" };
|
|
250
|
+
return nodeMatches(payload, filter as Record<string, unknown>, "", 1);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** A webhook body as the filter reads it: its JSON, or null when it is not
|
|
254
|
+
* JSON (which no filter matches). */
|
|
255
|
+
export function webhookFilterPayload(raw: string): unknown {
|
|
256
|
+
try {
|
|
257
|
+
return JSON.parse(raw);
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The triggers a filter means something for. */
|
|
264
|
+
export const FILTERABLE_TRIGGERS = ["webhook", "email", "flow", "storage"] as const;
|
package/src/trigger-log.ts
CHANGED
package/src/triggers.ts
CHANGED
|
@@ -59,6 +59,18 @@ export function chainedFlows(tenant: string, workspace: string, finished: RunRec
|
|
|
59
59
|
);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** What a chained flow's `filter:` reads: the run that just settled. */
|
|
63
|
+
export function chainFields(finished: RunRecord): Record<string, unknown> {
|
|
64
|
+
return {
|
|
65
|
+
run: finished.id,
|
|
66
|
+
flow: finished.flow,
|
|
67
|
+
status: finished.status,
|
|
68
|
+
ending: endingOf(finished),
|
|
69
|
+
verdict: runVerdict(finished) ?? null,
|
|
70
|
+
summary: finished.summary ?? null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
/**
|
|
63
75
|
* Start every flow chained on a run that just settled. The finished run's
|
|
64
76
|
* one-line summary and its final result are the task; the run id rides
|
|
@@ -78,7 +90,7 @@ export async function fireChainedFlows(tenant: string, workspace: string, finish
|
|
|
78
90
|
(result ? `\n${result}` : "");
|
|
79
91
|
for (const flow of flows) {
|
|
80
92
|
try {
|
|
81
|
-
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run" });
|
|
93
|
+
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run", fields: chainFields(finished) });
|
|
82
94
|
if (!gate.admit) {
|
|
83
95
|
console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
|
|
84
96
|
continue;
|
|
@@ -143,7 +155,7 @@ export async function fireStorageTriggers(
|
|
|
143
155
|
const body = `by: ${by}\n${hits.map((h) => `- storage/${h}`).join("\n")}`;
|
|
144
156
|
// A deploy that writes forty files under one prefix is one event, not
|
|
145
157
|
// forty runs — which is exactly what `debounce:` is for.
|
|
146
|
-
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event" });
|
|
158
|
+
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event", fields: { by, paths: hits } });
|
|
147
159
|
if (!gate.admit) {
|
|
148
160
|
console.log(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
|
|
149
161
|
continue;
|
package/src/untrusted-fetch.ts
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
// the first one was. A hop to another origin keeps only the headers that
|
|
15
15
|
// cannot carry a credential, and a caller whose request IS a credential
|
|
16
16
|
// (a token exchange, a provider key in a header) follows none at all.
|
|
17
|
+
//
|
|
18
|
+
// And every answer is read to a limit (maxBytes, 1 MiB unless the caller
|
|
19
|
+
// says otherwise): a tenant's URL can answer forever, and the worker or web
|
|
20
|
+
// tier holding it all in memory is the platform's problem, not the tenant's.
|
|
21
|
+
// Past the limit the body errors — a caller never gets the first megabyte
|
|
22
|
+
// passed off as the whole answer.
|
|
17
23
|
|
|
18
24
|
import { platform } from "./platform.ts";
|
|
19
25
|
|
|
@@ -28,6 +34,60 @@ const REDIRECT = new Set([301, 302, 303, 307, 308]);
|
|
|
28
34
|
* webhook's signature — stays with the origin it was meant for. */
|
|
29
35
|
const CROSS_ORIGIN_SAFE = new Set(["accept", "accept-language", "content-type", "user-agent"]);
|
|
30
36
|
|
|
37
|
+
/** What one answer may be, read, unless the caller says otherwise. */
|
|
38
|
+
export const MAX_BYTES = 1024 * 1024;
|
|
39
|
+
|
|
40
|
+
export class ResponseTooLarge extends Error {
|
|
41
|
+
constructor(url: string, maxBytes: number) {
|
|
42
|
+
super(`${url} answered more than ${sizeLabel(maxBytes)} — not read`);
|
|
43
|
+
this.name = "ResponseTooLarge";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sizeLabel = (n: number) => (n % (1024 * 1024) === 0 ? `${n / (1024 * 1024)} MiB` : `${n} bytes`);
|
|
48
|
+
|
|
49
|
+
/** Statuses whose Response may carry no body (the constructor throws on one). */
|
|
50
|
+
const NULL_BODY = new Set([101, 204, 205, 304]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `res`, its body read to at most `maxBytes`. The status and headers stand;
|
|
54
|
+
* the body errors with ResponseTooLarge at the limit — `text()`, `json()`
|
|
55
|
+
* and a reader all reject, never resolve short. A declared length past the
|
|
56
|
+
* limit errors at the first read, before a byte is fetched; an undeclared
|
|
57
|
+
* one is counted as it streams (after decompression, so a small gzip that
|
|
58
|
+
* unpacks huge is caught too). Infinity: no limit.
|
|
59
|
+
*/
|
|
60
|
+
export function capBody(res: Response, maxBytes: number, url: string): Response {
|
|
61
|
+
if (!Number.isFinite(maxBytes) || !res.body || NULL_BODY.has(res.status)) return res;
|
|
62
|
+
const declared = Number(res.headers.get("content-length") ?? NaN);
|
|
63
|
+
const tooLong = Number.isFinite(declared) && declared > maxBytes;
|
|
64
|
+
const reader = res.body.getReader();
|
|
65
|
+
let seen = 0;
|
|
66
|
+
const body = new ReadableStream<Uint8Array>({
|
|
67
|
+
async pull(controller) {
|
|
68
|
+
if (tooLong) {
|
|
69
|
+
reader.cancel().catch(() => {});
|
|
70
|
+
return controller.error(new ResponseTooLarge(url, maxBytes));
|
|
71
|
+
}
|
|
72
|
+
const { done, value } = await reader.read();
|
|
73
|
+
if (done) return controller.close();
|
|
74
|
+
seen += value.byteLength;
|
|
75
|
+
if (seen > maxBytes) {
|
|
76
|
+
reader.cancel().catch(() => {});
|
|
77
|
+
return controller.error(new ResponseTooLarge(url, maxBytes));
|
|
78
|
+
}
|
|
79
|
+
controller.enqueue(value);
|
|
80
|
+
},
|
|
81
|
+
cancel(reason) {
|
|
82
|
+
return reader.cancel(reason);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const out = new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
86
|
+
// Where the answer came from, as the original said.
|
|
87
|
+
Object.defineProperty(out, "url", { value: res.url });
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
31
91
|
export class RedirectRefused extends Error {
|
|
32
92
|
constructor(message: string) {
|
|
33
93
|
super(message);
|
|
@@ -42,14 +102,17 @@ export async function fetchUntrusted(
|
|
|
42
102
|
/** The request carries a credential (in its body or its headers):
|
|
43
103
|
* follow a redirect only within the origin it was sent to. */
|
|
44
104
|
sameOrigin?: boolean;
|
|
105
|
+
/** The most the answer may be, read; MAX_BYTES unless said. */
|
|
106
|
+
maxBytes?: number;
|
|
45
107
|
} = {},
|
|
46
108
|
): Promise<Response> {
|
|
109
|
+
const maxBytes = opts.maxBytes ?? MAX_BYTES;
|
|
47
110
|
let current = url;
|
|
48
111
|
let req: RequestInit = { ...init };
|
|
49
112
|
for (let hop = 0; ; hop++) {
|
|
50
|
-
const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" });
|
|
113
|
+
const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, { maxBytes });
|
|
51
114
|
const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
|
|
52
|
-
if (!location) return res;
|
|
115
|
+
if (!location) return capBody(res, maxBytes, current);
|
|
53
116
|
// The body of a redirect is never read; free the socket.
|
|
54
117
|
await res.body?.cancel().catch(() => {});
|
|
55
118
|
if (hop >= MAX_REDIRECTS) throw new RedirectRefused(`${url}: more than ${MAX_REDIRECTS} redirects — not followed`);
|