@foldrun/core 8.0.1 → 9.1.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/src/approvals.d.ts +18 -10
- package/dist/src/approvals.js +51 -1
- package/dist/src/catalog.d.ts +14 -0
- package/dist/src/catalog.js +28 -0
- package/dist/src/cron.d.ts +9 -0
- package/dist/src/cron.js +20 -0
- package/dist/src/frontmatter-fields.js +1 -1
- package/dist/src/frontmatter-schema.d.ts +5 -1
- package/dist/src/frontmatter-schema.js +40 -1
- package/dist/src/history.d.ts +5 -0
- package/dist/src/history.js +31 -4
- package/dist/src/hooks.d.ts +2 -0
- package/dist/src/hooks.js +13 -0
- package/dist/src/library.js +4 -0
- package/dist/src/notify.d.ts +21 -4
- package/dist/src/notify.js +105 -22
- package/dist/src/package.d.ts +5 -1
- package/dist/src/package.js +5 -3
- package/dist/src/runner.js +17 -9
- package/dist/src/scan.js +21 -0
- package/dist/src/secrets.d.ts +92 -10
- package/dist/src/secrets.js +258 -28
- package/dist/src/storage.d.ts +76 -4
- package/dist/src/storage.js +219 -22
- package/dist/src/store.d.ts +20 -2
- package/dist/src/store.js +50 -15
- package/dist/src/triggers.js +2 -2
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +19 -6
- package/package.json +1 -1
- package/src/approvals.ts +55 -1
- package/src/catalog.ts +27 -0
- package/src/cron.ts +19 -0
- package/src/frontmatter-fields.ts +1 -1
- package/src/frontmatter-schema.ts +38 -2
- package/src/history.ts +31 -4
- package/src/hooks.ts +14 -0
- package/src/library.ts +3 -0
- package/src/notify.ts +104 -23
- package/src/package.ts +5 -3
- package/src/runner.ts +17 -8
- package/src/scan.ts +23 -0
- package/src/secrets.ts +283 -34
- package/src/storage.ts +223 -19
- package/src/store.ts +49 -14
- package/src/triggers.ts +2 -2
- package/src/webhook.ts +26 -6
package/src/approvals.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import crypto from "node:crypto";
|
|
14
14
|
import { readRun, writeRun, readFlow, mayApprove, type FlowInfo, type RunRecord } from "./store.ts";
|
|
15
15
|
import { platform } from "./platform.ts";
|
|
16
|
-
import { approveToken, installKey } from "./webhook.ts";
|
|
16
|
+
import { approveToken, eventToken, installKey } from "./webhook.ts";
|
|
17
17
|
|
|
18
18
|
export interface ApprovalDecision {
|
|
19
19
|
decision: "approve" | "reject";
|
|
@@ -361,6 +361,60 @@ export async function expireStaleGate(
|
|
|
361
361
|
* key through the dashboard's approve route — and `step` releases one
|
|
362
362
|
* waiting step rather than all of them.
|
|
363
363
|
*/
|
|
364
|
+
const EVENT_WAIT_LINE = "waiting for an external event — release it with POST ";
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* What a presented event token opens on a run (30 Oct 2026): `known` when
|
|
368
|
+
* it is a real token of this run — one of its parks, or the old per-run
|
|
369
|
+
* form — and `steps`, the waiting steps it releases now (none: a 409, the
|
|
370
|
+
* gate it was for is no longer waiting).
|
|
371
|
+
*
|
|
372
|
+
* With `step` (the `&step=` every URL printed since carries), the token is
|
|
373
|
+
* that step's, bound to the moment it parked: the `t` of its "waiting for an
|
|
374
|
+
* external event" line. It releases that step only while that park is the
|
|
375
|
+
* step's current one — a released step is no longer waiting, and a step
|
|
376
|
+
* parked again (a loop) has a new `t` — so a URL handed out for one gate
|
|
377
|
+
* opens neither a later gate nor a later park.
|
|
378
|
+
*
|
|
379
|
+
* Without `step`, the per-run token URLs carried before then. It releases
|
|
380
|
+
* only a step still waiting under such a URL — its park line has no
|
|
381
|
+
* `step=` — so a run parked when this shipped can still be released, and
|
|
382
|
+
* nothing parked since can be released by the old form.
|
|
383
|
+
*/
|
|
384
|
+
export function eventGatesFor(
|
|
385
|
+
tenant: string,
|
|
386
|
+
workspace: string,
|
|
387
|
+
run: RunRecord,
|
|
388
|
+
presented: string,
|
|
389
|
+
step: number | null,
|
|
390
|
+
): { known: boolean; steps: number[] } {
|
|
391
|
+
const none = { known: false, steps: [] as number[] };
|
|
392
|
+
if (!presented) return none;
|
|
393
|
+
const same = (want: string) => {
|
|
394
|
+
const a = Buffer.from(presented), b = Buffer.from(want);
|
|
395
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
396
|
+
};
|
|
397
|
+
const parks = (i: number) => (run.steps[i]?.events ?? []).filter((e) => e?.text?.startsWith(EVENT_WAIT_LINE));
|
|
398
|
+
const waiting = (i: number) => {
|
|
399
|
+
const s = run.steps[i];
|
|
400
|
+
return !!s && s.status === "awaiting-approval" && s.waitFor === "event";
|
|
401
|
+
};
|
|
402
|
+
if (step !== null) {
|
|
403
|
+
const lines = parks(step);
|
|
404
|
+
const hit = lines.findIndex((e) => same(eventToken(tenant, workspace, run.id, { step, at: e.t })));
|
|
405
|
+
if (hit < 0) return none;
|
|
406
|
+
// Only the step's latest park is open, and only while it waits.
|
|
407
|
+
return { known: true, steps: hit === lines.length - 1 && waiting(step) ? [step] : [] };
|
|
408
|
+
}
|
|
409
|
+
if (!same(eventToken(tenant, workspace, run.id))) return none;
|
|
410
|
+
const legacy: number[] = [];
|
|
411
|
+
for (let i = 0; i < run.steps.length; i++) {
|
|
412
|
+
const last = parks(i).at(-1);
|
|
413
|
+
if (last && waiting(i) && !/[?&]step=\d/.test(last.text)) legacy.push(i);
|
|
414
|
+
}
|
|
415
|
+
return { known: true, steps: legacy };
|
|
416
|
+
}
|
|
417
|
+
|
|
364
418
|
export async function deliverEvent(
|
|
365
419
|
tenant: string,
|
|
366
420
|
workspace: string,
|
package/src/catalog.ts
CHANGED
|
@@ -155,6 +155,33 @@ export async function loadCatalog(baseUrl: string): Promise<Catalog | null> {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/** How long after an attempt a background refresh of the same gateway is
|
|
159
|
+
* not tried again, whether it worked or not. */
|
|
160
|
+
export const CATALOG_RETRY_MS = 5 * 60_000;
|
|
161
|
+
const lastTried = new Map<string, number>();
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Refresh a gateway's catalogue behind the caller, at most once per
|
|
165
|
+
* CATALOG_RETRY_MS per gateway (30 Oct 2026). For the editor's completions and
|
|
166
|
+
* the flows page, which ask on every request: a stale or missing catalogue
|
|
167
|
+
* used to start a fresh outbound fetch each time — a 10 s one per keystroke
|
|
168
|
+
* against an unreachable gateway, nothing remembering that it had just
|
|
169
|
+
* failed. The attempt is noted before it starts, so callers arriving while
|
|
170
|
+
* one is in flight do not start another. True when one was started. A run's
|
|
171
|
+
* own read (loadCatalog) is untouched.
|
|
172
|
+
*/
|
|
173
|
+
export function refreshCatalogSoon(baseUrl: string, load: (baseUrl: string) => Promise<unknown> = loadCatalog, now = Date.now()): boolean {
|
|
174
|
+
const cached = readCatalog(baseUrl);
|
|
175
|
+
if (cached && now - Date.parse(cached.fetchedAt) < TTL_MS) return false; // fresh: nothing to fetch
|
|
176
|
+
const last = lastTried.get(baseUrl);
|
|
177
|
+
if (last !== undefined && now - last < CATALOG_RETRY_MS) return false;
|
|
178
|
+
lastTried.delete(baseUrl);
|
|
179
|
+
lastTried.set(baseUrl, now);
|
|
180
|
+
if (lastTried.size > 1000) lastTried.delete(lastTried.keys().next().value as string);
|
|
181
|
+
void load(baseUrl).catch(() => null);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
158
185
|
/** A preset reference — settings live in the gateway's dashboard, opaque to
|
|
159
186
|
* us on purpose. The catalogue offers no opinion on one. */
|
|
160
187
|
export const isPresetRef = (model: string) => model.startsWith("@preset/");
|
package/src/cron.ts
CHANGED
|
@@ -118,3 +118,22 @@ export function cronProblem(expr: unknown): string | null {
|
|
|
118
118
|
? `schedule: "${expr}" has ${fields} field${fields === 1 ? "" : "s"}; cron has five — minute hour day-of-month month day-of-week ("0 9 * * MON"), or @hourly, @daily, @weekly, @monthly. As written the flow never fires`
|
|
119
119
|
: `schedule: "${expr}" is not a cron the scheduler can read — a field is out of range or not a number (minute 0-59, hour 0-23, day 1-31, month 1-12, weekday 0-7 or sun-sat). As written the flow never fires`;
|
|
120
120
|
}
|
|
121
|
+
|
|
122
|
+
/** Days in each month, February at its leap-year 29: a cron naming the 29th
|
|
123
|
+
* of February does fire, every fourth year. */
|
|
124
|
+
const MONTH_DAYS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether the calendar has any day this cron names (30 Oct 2026). `0 0 30 2 *`
|
|
128
|
+
* parses — every field is in range — yet no February has a 30th, so a search
|
|
129
|
+
* for its next fire walked the whole window every time it was asked (the
|
|
130
|
+
* schedule page did, 57,600 minutes per flow per request). Answered from the
|
|
131
|
+
* fields alone: only a day of the month with no weekday beside it can miss,
|
|
132
|
+
* since with a weekday either may match and every month has every weekday.
|
|
133
|
+
*/
|
|
134
|
+
export function cronCanFire(cron: Cron): boolean {
|
|
135
|
+
if (!cron.domRestricted || cron.dowRestricted) return true;
|
|
136
|
+
const firstDay = Math.min(...cron.dom);
|
|
137
|
+
for (const m of cron.month) if (firstDay <= MONTH_DAYS[m - 1]!) return true;
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
@@ -156,7 +156,7 @@ const FLOW: FieldSpec[] = [
|
|
|
156
156
|
{ key: "catchup", type: "string", validated: true, help: "last | none" },
|
|
157
157
|
{ key: "on", type: "string", validated: true, help: "trigger: flow — completed | failed | blocked | any" },
|
|
158
158
|
{ key: "signature", type: "string", validated: true, help: "github | stripe | slack | hmac" },
|
|
159
|
-
{ key: "signing_secret", type: "string", help: "the secret a webhook's signature is checked with" },
|
|
159
|
+
{ key: "signing_secret", type: "string", help: "the NAME of the vault secret a webhook's signature is checked with — never the value" },
|
|
160
160
|
{ key: "after", type: ["string", "list"], help: "trigger: flow — the flow this one follows" },
|
|
161
161
|
{ key: "at", type: "string", help: "trigger: once — when" },
|
|
162
162
|
{ key: "url", type: "string", help: "trigger: watch — the page watched" },
|
|
@@ -6,7 +6,8 @@ import { FRONTMATTER, JOBS, type FieldType, type SchemaKind } from "./frontmatte
|
|
|
6
6
|
import { jobFold } from "./matter.ts";
|
|
7
7
|
import { resolveEffort, parseDuration, parseInstant, parseRate, STEP_OPTION_KEYS } from "./store.ts";
|
|
8
8
|
import { budgetProblem } from "./budget.ts";
|
|
9
|
-
import { cronProblem } from "./cron.ts";
|
|
9
|
+
import { cronProblem, parseCron, cronCanFire } from "./cron.ts";
|
|
10
|
+
import { SECRET_NAME } from "./secrets.ts";
|
|
10
11
|
import { modelSecretCollision } from "./model-credential.ts";
|
|
11
12
|
import { resourcesProblem, computeProblem } from "./resources.ts";
|
|
12
13
|
|
|
@@ -70,7 +71,7 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
|
|
|
70
71
|
|
|
71
72
|
export interface FieldProblem {
|
|
72
73
|
key: string;
|
|
73
|
-
rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field" | "config/conflict" | "config/misplaced";
|
|
74
|
+
rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field" | "config/conflict" | "config/misplaced" | "config/cron-never-fires" | "secret/signing-secret-value";
|
|
74
75
|
level: "error" | "warn" | "info";
|
|
75
76
|
message: string;
|
|
76
77
|
}
|
|
@@ -172,12 +173,47 @@ export function frontmatterProblems(
|
|
|
172
173
|
: null;
|
|
173
174
|
if (why) out.push({ key, rule: "config/invalid-value", level: "warn", message: `\`${key}:\` ${why.replace(/^\w+: /, "")}` });
|
|
174
175
|
}
|
|
176
|
+
// A cron every field of which is in range, on a day no month has —
|
|
177
|
+
// `0 0 30 2 *`. The scheduler's own cronCanFire decides (30 Oct 2026), so
|
|
178
|
+
// check and the scheduler say the same about it: it never fires.
|
|
179
|
+
if (kind === "flow" && typeof data.schedule === "string") {
|
|
180
|
+
const cron = parseCron(data.schedule);
|
|
181
|
+
if (cron && !cronCanFire(cron)) {
|
|
182
|
+
out.push({ key: "schedule", rule: "config/cron-never-fires", level: "warn", message: `\`schedule: "${data.schedule}"\` names a day no month it allows has — the flow never fires. Pick a day every named month has, or add a weekday` });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// A signing secret is the NAME of a vault entry (30 Oct 2026): a flow's
|
|
186
|
+
// `signing_secret:` and notify's `signing_secret:` are looked up with
|
|
187
|
+
// getSecret, so a literal value there is a secret written into a
|
|
188
|
+
// versioned, deployed file — and never matches anything in the vault.
|
|
189
|
+
// The message never repeats the value.
|
|
190
|
+
if (kind === "flow" && data.signing_secret !== undefined) {
|
|
191
|
+
const why = signingSecretProblem(data.signing_secret);
|
|
192
|
+
if (why) out.push({ key: "signing_secret", rule: "secret/signing-secret-value", level: "warn", message: `\`signing_secret:\` ${why}` });
|
|
193
|
+
}
|
|
194
|
+
if (kind === "agents-md" && data.notify && typeof data.notify === "object" && !Array.isArray(data.notify)) {
|
|
195
|
+
const raw = (data.notify as { signing_secret?: unknown }).signing_secret;
|
|
196
|
+
const why = raw === undefined ? null : signingSecretProblem(raw);
|
|
197
|
+
if (why) out.push({ key: "notify", rule: "secret/signing-secret-value", level: "warn", message: `\`notify: signing_secret:\` ${why}` });
|
|
198
|
+
}
|
|
175
199
|
if (kind === "skill" && !(typeof data.description === "string" && data.description.trim())) {
|
|
176
200
|
out.push({ key: "description", rule: "config/missing-field", level: "warn", message: "no `description:` — a skill without one is never loaded; the model chooses a skill by it" });
|
|
177
201
|
}
|
|
178
202
|
return out;
|
|
179
203
|
}
|
|
180
204
|
|
|
205
|
+
/** Why a `signing_secret:` value is not a vault secret's name, or null. The
|
|
206
|
+
* runtime reads `NAME` and `${NAME}` alike (notify.ts, store.ts). Never
|
|
207
|
+
* says what the value is: it may be the secret itself. */
|
|
208
|
+
export function signingSecretProblem(v: unknown): string | null {
|
|
209
|
+
if (typeof v !== "string") return null; // config/wrong-type says so
|
|
210
|
+
const name = v.trim().replace(/^\$\{|\}$/g, "");
|
|
211
|
+
if (!name) return null;
|
|
212
|
+
return SECRET_NAME.test(name)
|
|
213
|
+
? null
|
|
214
|
+
: "holds something that is not a secret's name (UPPER_SNAKE_CASE, like WEBHOOK_SIGNING_KEY) — it is looked up in the vault by name, so a value written here is never found, and is deployed and versioned in plain text. Put the value in the vault (`foldrun secrets set NAME`), write the name here, and rotate it";
|
|
215
|
+
}
|
|
216
|
+
|
|
181
217
|
function askProblem(v: unknown): string | null {
|
|
182
218
|
const t = (v as { timeout?: unknown } | null)?.timeout;
|
|
183
219
|
return t === undefined ? null : DURATION(30, 86400, "30s to 24h")(t);
|
package/src/history.ts
CHANGED
|
@@ -98,6 +98,16 @@ const historyDir = (tenant: string, scope: string) => {
|
|
|
98
98
|
return path.join(dataRoot(), tenant, ".history", scope);
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
+
/** Refuse (400) a deploy's `commit` that cannot be a revision id. A writer
|
|
102
|
+
* calls this BEFORE it writes: checked only in recordRevision, after the
|
|
103
|
+
* files were replaced, a bad commit left the new source live with no
|
|
104
|
+
* revision, no snapshot and no audit entry (30 Oct 2026). */
|
|
105
|
+
export function assertCommitId(commit: string) {
|
|
106
|
+
if (typeof commit !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._@-]{0,120}$/.test(commit)) {
|
|
107
|
+
throw Object.assign(new Error(`commit "${String(commit).slice(0, 130)}" is not a commit id — letters, digits and . _ @ -, at most 121 characters`), { status: 400 });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
101
111
|
/** A local revision id: sortable by time, unique enough, visibly not a sha. */
|
|
102
112
|
export function newRevisionId(now = new Date()): string {
|
|
103
113
|
return `r-${now.getTime().toString(36)}-${crypto.randomBytes(3).toString("hex")}`;
|
|
@@ -121,8 +131,16 @@ export function recordRevision(
|
|
|
121
131
|
|
|
122
132
|
const dir = historyDir(tenant, scope);
|
|
123
133
|
fs.mkdirSync(dir, { recursive: true });
|
|
134
|
+
// A commit is the revision's id only the first time it is recorded. A
|
|
135
|
+
// second deploy of one commit (a CI re-run after a dashboard edit), or a
|
|
136
|
+
// `commit` that names a revision already here, overwrote that revision's
|
|
137
|
+
// record and its snapshot (30 Oct 2026) — History could no longer restore
|
|
138
|
+
// to it. It gets its own id now; `commit` still says which one it was,
|
|
139
|
+
// and a restore by commit (resolveRevision) still finds it.
|
|
140
|
+
if (meta.commit) assertCommitId(meta.commit);
|
|
141
|
+
const commitId = meta.commit && !fs.existsSync(path.join(dir, `${meta.commit}.json`)) ? meta.commit : null;
|
|
124
142
|
const rev: Revision = {
|
|
125
|
-
id: meta.id ??
|
|
143
|
+
id: meta.id ?? commitId ?? newRevisionId(),
|
|
126
144
|
at: new Date().toISOString(),
|
|
127
145
|
by: meta.by ?? "system",
|
|
128
146
|
message: meta.message ?? describe(changed),
|
|
@@ -301,7 +319,11 @@ export function resolveRevision(
|
|
|
301
319
|
hit = all.find((r) => Date.parse(r.at) <= moment);
|
|
302
320
|
} else {
|
|
303
321
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/.test(t)) throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
|
|
304
|
-
|
|
322
|
+
// An exact id first, then a commit: a commit deployed twice is recorded
|
|
323
|
+
// once under its own id and again under a fresh one naming it (30 Oct
|
|
324
|
+
// 2026, recordRevision), and newest-first with one predicate answered
|
|
325
|
+
// the first revision's id with the second's snapshot.
|
|
326
|
+
hit = all.find((r) => r.id === t) ?? all.find((r) => r.commit === t || (t.length >= 7 && r.commit?.startsWith(t)));
|
|
305
327
|
}
|
|
306
328
|
return hit ? { id: hit.id, at: hit.at, by: hit.by, message: hit.message } : null;
|
|
307
329
|
}
|
|
@@ -409,8 +431,13 @@ export function changedPathsBetween(tenant: string, scope: string, from: string
|
|
|
409
431
|
if (!from || !to) return null;
|
|
410
432
|
if (from === to) return [];
|
|
411
433
|
const revs = listRevisions(tenant, scope, { limit: 500 }); // newest first
|
|
412
|
-
|
|
413
|
-
const
|
|
434
|
+
// An exact id before a commit, as resolveRevision.
|
|
435
|
+
const at = (x: string) => {
|
|
436
|
+
const byId = revs.findIndex((r) => r.id === x);
|
|
437
|
+
return byId >= 0 ? byId : revs.findIndex((r) => r.commit === x);
|
|
438
|
+
};
|
|
439
|
+
const ti = at(to);
|
|
440
|
+
const fi = at(from);
|
|
414
441
|
if (ti < 0 || fi < 0 || ti > fi) return null;
|
|
415
442
|
return [...new Set(revs.slice(ti, fi).flatMap((r) => r.paths))].sort();
|
|
416
443
|
}
|
package/src/hooks.ts
CHANGED
|
@@ -44,6 +44,11 @@ export const HOOK_TIMEOUT_MAX = 120;
|
|
|
44
44
|
export const HOOK_CODE_MAX = 100 * 1024;
|
|
45
45
|
const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
46
46
|
|
|
47
|
+
/** A hook's name: its folder's name, never a path. */
|
|
48
|
+
export function isHookName(name: unknown): name is string {
|
|
49
|
+
return typeof name === "string" && NAME.test(name);
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
export interface HookDef {
|
|
48
53
|
name: string;
|
|
49
54
|
/** The folder holding hook.md and run.mjs. */
|
|
@@ -139,6 +144,15 @@ export function resolveHooks(args: {
|
|
|
139
144
|
for (const name of args.names) {
|
|
140
145
|
if (seen.has(name)) continue;
|
|
141
146
|
seen.add(name);
|
|
147
|
+
// Checked before the name is joined to anything (30 Oct 2026): readHookDef
|
|
148
|
+
// tested only basename(dir), so `../../<tenant>/workspaces/<ws>/hooks/<h>`
|
|
149
|
+
// passed, and the hook test route answered which folders exist anywhere
|
|
150
|
+
// on the server — on a laptop it would have run that run.mjs. An error,
|
|
151
|
+
// not missing: a step refuses either way, and the name is the problem.
|
|
152
|
+
if (!isHookName(name)) {
|
|
153
|
+
errors.push(`"${name}" is not a hook name — lowercase letters, digits and dashes (the hook's folder name)`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
142
156
|
const own = path.join(args.workspaceRoot, "hooks", name);
|
|
143
157
|
const shared = path.join(args.libraryRoot, "hooks", name);
|
|
144
158
|
const dir = fs.existsSync(own) ? own : fs.existsSync(shared) ? shared : null;
|
package/src/library.ts
CHANGED
|
@@ -282,6 +282,9 @@ export function deleteLibraryPath(
|
|
|
282
282
|
) {
|
|
283
283
|
const norm = path.normalize(rel);
|
|
284
284
|
if (norm.startsWith("..") || path.isAbsolute(norm)) throw new Error(`illegal path: ${rel}`);
|
|
285
|
+
// "." (or "./", "a/..") is the shelf itself: one call took every shared
|
|
286
|
+
// tool or skill from every workspace (30 Oct 2026). Name what goes.
|
|
287
|
+
if (norm === "." || norm === "./") throw new Error(`illegal path: ${rel} — name the file or folder to delete, not the whole ${kind} shelf`);
|
|
285
288
|
const target = path.join(libraryDir(tenant, kind), norm);
|
|
286
289
|
const gone: { path: string; before: string | null; after: null }[] = [];
|
|
287
290
|
if (fs.existsSync(target)) {
|
package/src/notify.ts
CHANGED
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
import crypto from "node:crypto";
|
|
35
35
|
import { readAgentsMd } from "./runner.ts";
|
|
36
36
|
import { accountDir, workspaceDir, runCost, readFlow, runVerdict, type RunRecord } from "./store.ts";
|
|
37
|
-
import { getSecret } from "./secrets.ts";
|
|
37
|
+
import { getSecret, secretSites, signingSecretShown, accountSecretUsableIn } from "./secrets.ts";
|
|
38
|
+
import { siteCovers } from "./network.ts";
|
|
38
39
|
import { publicUrl } from "./webhook.ts";
|
|
39
40
|
import { approveLinkPath, approveLinkTtlMs } from "./approvals.ts";
|
|
40
41
|
import { noteSecretUse, healthKey } from "./secret-health.ts";
|
|
@@ -62,11 +63,15 @@ export function platformMail(tenant: string): { key: string; from: string } | nu
|
|
|
62
63
|
return own;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
/** The account's own Resend key and sender, or null when it has none.
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
/** The account's own Resend key and sender, or null when it has none.
|
|
67
|
+
* With a workspace — mail a workspace's run causes — an account key or
|
|
68
|
+
* sender limited to other workspaces is not used (30 Oct 2026); the
|
|
69
|
+
* account's own mail (resets, invites) names none and is not limited. */
|
|
70
|
+
export function accountMail(tenant: string, workspace?: string): { key: string; from: string } | null {
|
|
71
|
+
const usable = (name: string) => !workspace || accountSecretUsableIn(tenant, name, workspace);
|
|
72
|
+
const own = usable("RESEND_API_KEY") ? getSecret(tenant, "RESEND_API_KEY") : null;
|
|
68
73
|
if (!own) return null;
|
|
69
|
-
const from = getSecret(tenant, "EMAIL_FROM")?.value?.trim() || "foldrun <onboarding@resend.dev>";
|
|
74
|
+
const from = (usable("EMAIL_FROM") ? getSecret(tenant, "EMAIL_FROM")?.value?.trim() : "") || "foldrun <onboarding@resend.dev>";
|
|
70
75
|
return { key: own.value, from };
|
|
71
76
|
}
|
|
72
77
|
|
|
@@ -80,8 +85,15 @@ export function accountMail(tenant: string): { key: string; from: string } | nul
|
|
|
80
85
|
* owner's bin. The platform's key is the fallback for an account that set
|
|
81
86
|
* none, so `notify:` works before anyone has configured mail at all.
|
|
82
87
|
*/
|
|
83
|
-
export function notifyMail(tenant: string): { key: string; from: string } | null {
|
|
84
|
-
return accountMail(tenant) ?? platformMail(tenant);
|
|
88
|
+
export function notifyMail(tenant: string, workspace?: string): { key: string; from: string } | null {
|
|
89
|
+
if (!workspace) return accountMail(tenant) ?? platformMail(tenant);
|
|
90
|
+
// A workspace's notification: the account's sender only where it may be
|
|
91
|
+
// used, else the platform's — never platformMail's fall back to the
|
|
92
|
+
// account's own key, which would walk around the limit.
|
|
93
|
+
const own = accountMail(tenant, workspace);
|
|
94
|
+
if (own) return own;
|
|
95
|
+
const key = process.env.FOLDRUN_RESEND_API_KEY;
|
|
96
|
+
return key ? { key, from: platform.setting("FOLDRUN_EMAIL_FROM") || "foldrun <hello@foldrun.io>" } : null;
|
|
85
97
|
}
|
|
86
98
|
|
|
87
99
|
/** Was the mail sent on the account's own key? Only then is a refusal from
|
|
@@ -168,7 +180,7 @@ export function signatureHeaders(
|
|
|
168
180
|
const secret = getSecret(tenant, config.signingSecret, workspace);
|
|
169
181
|
if (!secret) {
|
|
170
182
|
console.error(
|
|
171
|
-
`[foldrun] notify: signing_secret names ${config.signingSecret}, which is not in the vault for ${tenant}/${workspace} — sending unsigned`,
|
|
183
|
+
`[foldrun] notify: signing_secret names ${signingSecretShown(config.signingSecret)}, which is not in the vault for ${tenant}/${workspace} — sending unsigned`,
|
|
172
184
|
);
|
|
173
185
|
return {};
|
|
174
186
|
}
|
|
@@ -209,15 +221,80 @@ export function newDeliveryId(): string {
|
|
|
209
221
|
}
|
|
210
222
|
|
|
211
223
|
/** The URL with ${SECRET}s resolved, and how it may be shown: a Slack hook
|
|
212
|
-
* URL is itself a credential, so it is never echoed resolved.
|
|
213
|
-
|
|
224
|
+
* URL is itself a credential, so it is never echoed resolved.
|
|
225
|
+
*
|
|
226
|
+
* A secret bound to sites (`foldrun secrets sites`) is filled only when the
|
|
227
|
+
* resolved URL's host is one of them — the rule the egress proxy and the
|
|
228
|
+
* tool tester keep (30 Oct 2026). Before, notify.url filled any secret into
|
|
229
|
+
* any host, so an editor could write `https://their.server/?k=${STRIPE_KEY}`
|
|
230
|
+
* and press Test. The host is read AFTER filling, because the usual shape is
|
|
231
|
+
* the whole URL as one secret (`${SLACK_WEBHOOK_URL}`, bound to
|
|
232
|
+
* hooks.slack.com). An unbound secret fills as it does for an agent's
|
|
233
|
+
* `secrets:` and a tool test — workspace first, then the account — since
|
|
234
|
+
* binding is how an account says where a value may go. `refused` says why
|
|
235
|
+
* `url` is null when a binding stopped it; `filled` maps each value put
|
|
236
|
+
* into the URL to its name, so an error that echoes the URL can be scrubbed. */
|
|
237
|
+
export function webhookTarget(
|
|
238
|
+
tenant: string,
|
|
239
|
+
workspace: string,
|
|
240
|
+
config: NotifyConfig | null = notifyConfig(tenant, workspace),
|
|
241
|
+
): { url: string | null; shown: string; refused?: string; filled: Record<string, string> } {
|
|
214
242
|
const raw = config?.url ?? "";
|
|
243
|
+
const filled: Record<string, string> = {};
|
|
244
|
+
// Every name put in, kept apart from `filled` (value → name, for
|
|
245
|
+
// scrubbing): two secrets with the same value would otherwise leave only
|
|
246
|
+
// the last name there, and the first one's binding would go unchecked.
|
|
247
|
+
const used = new Set<string>();
|
|
215
248
|
const url = raw.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
|
|
216
249
|
const hit = getSecret(tenant, name, workspace);
|
|
250
|
+
if (hit) {
|
|
251
|
+
filled[hit.value] = name;
|
|
252
|
+
used.add(name);
|
|
253
|
+
}
|
|
217
254
|
return hit ? hit.value : whole;
|
|
218
255
|
});
|
|
219
256
|
const shown = raw.includes("${") ? raw : raw ? `${raw.slice(0, 40)}${raw.length > 40 ? "…" : ""}` : "none";
|
|
220
|
-
|
|
257
|
+
if (!url || url.includes("${")) return { url: null, shown, filled: {} };
|
|
258
|
+
const bound = secretSites(tenant, [...used], workspace);
|
|
259
|
+
if (Object.keys(bound).length) {
|
|
260
|
+
let host = "";
|
|
261
|
+
try {
|
|
262
|
+
host = new URL(url).hostname;
|
|
263
|
+
} catch {
|
|
264
|
+
// Not a URL: no host can be one of its sites.
|
|
265
|
+
}
|
|
266
|
+
const offSite = Object.entries(bound).filter(([, sites]) => !host || !sites.some((site) => siteCovers(site, host))).map(([n]) => n);
|
|
267
|
+
if (offSite.length) {
|
|
268
|
+
return {
|
|
269
|
+
url: null,
|
|
270
|
+
shown,
|
|
271
|
+
filled: {},
|
|
272
|
+
// The host is not echoed: it is read from the FILLED url, so
|
|
273
|
+
// `https://${KEY}.their.server/` would print the key back.
|
|
274
|
+
refused: `${offSite.join(", ")} ${offSite.length === 1 ? "is" : "are"} bound to other sites than notify.url's host — not sent`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { url, shown, filled };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** An error about a request to a resolved notify.url, with every secret value
|
|
282
|
+
* put back as its ${NAME} — a redirect refusal quotes the URL it was sent to,
|
|
283
|
+
* and the delivery log that keeps it is read by every viewer. */
|
|
284
|
+
function scrubTarget(text: string, filled: Record<string, string>): string {
|
|
285
|
+
let out = text;
|
|
286
|
+
// Longest first, so a value inside another is not half-replaced.
|
|
287
|
+
for (const value of Object.keys(filled).sort((a, b) => b.length - a.length)) {
|
|
288
|
+
if (!value) continue;
|
|
289
|
+
const variants = new Set([value]);
|
|
290
|
+
try {
|
|
291
|
+
variants.add(encodeURI(value)).add(encodeURIComponent(value));
|
|
292
|
+
} catch {
|
|
293
|
+
// A lone surrogate cannot be encoded, so it cannot appear encoded.
|
|
294
|
+
}
|
|
295
|
+
for (const v of variants) out = out.split(v).join(`\${${filled[value]}}`);
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
221
298
|
}
|
|
222
299
|
|
|
223
300
|
export interface WebhookAttempt {
|
|
@@ -252,10 +329,13 @@ export async function attemptWebhook(
|
|
|
252
329
|
if (!config?.url) {
|
|
253
330
|
return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "this workspace's notify: has no url any more" };
|
|
254
331
|
}
|
|
255
|
-
const { url } = webhookTarget(tenant, workspace, config);
|
|
332
|
+
const { url, refused, filled } = webhookTarget(tenant, workspace, config);
|
|
256
333
|
if (!url) {
|
|
257
|
-
return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "the secret named in notify.url is not in this account's vault" };
|
|
334
|
+
return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: refused ?? "the secret named in notify.url is not in this account's vault" };
|
|
258
335
|
}
|
|
336
|
+
// A URL that carries a secret is a credential: a redirect is followed only
|
|
337
|
+
// within the origin it was sent to, as the tool tester does.
|
|
338
|
+
const carriesSecret = Object.keys(filled).length > 0;
|
|
259
339
|
try {
|
|
260
340
|
const res = await fetchUntrusted(url, {
|
|
261
341
|
method: "POST",
|
|
@@ -270,7 +350,7 @@ export async function attemptWebhook(
|
|
|
270
350
|
},
|
|
271
351
|
body: d.body,
|
|
272
352
|
signal: AbortSignal.timeout(Number(process.env.FOLDRUN_WEBHOOK_TIMEOUT_MS) || 8000),
|
|
273
|
-
});
|
|
353
|
+
}, { sameOrigin: carriesSecret });
|
|
274
354
|
const text = (await res.text().catch(() => "")).slice(0, 500);
|
|
275
355
|
return {
|
|
276
356
|
...base,
|
|
@@ -282,7 +362,7 @@ export async function attemptWebhook(
|
|
|
282
362
|
};
|
|
283
363
|
} catch (err) {
|
|
284
364
|
const why = err instanceof Error ? (err.name === "TimeoutError" ? "timed out" : err.message) : String(err);
|
|
285
|
-
return { ...base, ok: false, statusCode: null, durationMs: Date.now() - started, response: null, error: why.slice(0, 500) };
|
|
365
|
+
return { ...base, ok: false, statusCode: null, durationMs: Date.now() - started, response: null, error: scrubTarget(why, filled).slice(0, 500) };
|
|
286
366
|
}
|
|
287
367
|
}
|
|
288
368
|
|
|
@@ -406,7 +486,7 @@ export async function sendPlainNotificationOutcome(
|
|
|
406
486
|
};
|
|
407
487
|
try {
|
|
408
488
|
if (config.email) {
|
|
409
|
-
const mail = notifyMail(tenant);
|
|
489
|
+
const mail = notifyMail(tenant, workspace);
|
|
410
490
|
if (!mail) return no;
|
|
411
491
|
const r = await sendMail({
|
|
412
492
|
tenant,
|
|
@@ -460,7 +540,7 @@ export async function sendTestNotification(
|
|
|
460
540
|
const detail = `Sent from ${workspace} at ${sent}. If you are reading this, failures and approval gates will reach you here too.`;
|
|
461
541
|
|
|
462
542
|
if (config.email) {
|
|
463
|
-
const mail = notifyMail(tenant);
|
|
543
|
+
const mail = notifyMail(tenant, workspace);
|
|
464
544
|
if (!mail) {
|
|
465
545
|
return {
|
|
466
546
|
ok: false,
|
|
@@ -486,9 +566,9 @@ export async function sendTestNotification(
|
|
|
486
566
|
// The destination is echoed back with the secret still unresolved: a
|
|
487
567
|
// Slack webhook URL is itself a credential, and a page that prints it is
|
|
488
568
|
// a page that leaks it into a screenshot.
|
|
489
|
-
const { url, shown } = webhookTarget(tenant, workspace, config);
|
|
569
|
+
const { url, shown, refused } = webhookTarget(tenant, workspace, config);
|
|
490
570
|
if (!url) {
|
|
491
|
-
return { ok: false, destination: shown, detail: `the secret named in notify.url is not in this account's vault` };
|
|
571
|
+
return { ok: false, destination: shown, detail: refused ?? `the secret named in notify.url is not in this account's vault` };
|
|
492
572
|
}
|
|
493
573
|
const payload = JSON.stringify({ text: `${headline} — ${detail}`, workspace, status: "test", summary: detail });
|
|
494
574
|
// One attempt, recorded in the delivery log like any other — and the way
|
|
@@ -499,7 +579,7 @@ export async function sendTestNotification(
|
|
|
499
579
|
? {
|
|
500
580
|
ok: true,
|
|
501
581
|
destination: shown,
|
|
502
|
-
detail: `${config.signingSecret ? "accepted, signed with " + config.signingSecret : "accepted"} (delivery ${r.deliveryId})`,
|
|
582
|
+
detail: `${config.signingSecret ? "accepted, signed with " + signingSecretShown(config.signingSecret) : "accepted"} (delivery ${r.deliveryId})`,
|
|
503
583
|
}
|
|
504
584
|
: { ok: false, destination: shown, detail: `${r.statusCode ? `HTTP ${r.statusCode} — ` : ""}${r.detail.replace(/\s+/g, " ").slice(0, 400)}` };
|
|
505
585
|
}
|
|
@@ -590,7 +670,7 @@ export async function sendRunNotification(
|
|
|
590
670
|
|
|
591
671
|
try {
|
|
592
672
|
if (config.email) {
|
|
593
|
-
const mail = notifyMail(tenant);
|
|
673
|
+
const mail = notifyMail(tenant, workspace);
|
|
594
674
|
if (!mail) {
|
|
595
675
|
console.error(`[foldrun] notify: email configured but no mail credential — set RESEND_API_KEY (and EMAIL_FROM) on the account ${tenant}, or FOLDRUN_RESEND_API_KEY on the platform`);
|
|
596
676
|
return false;
|
|
@@ -635,8 +715,9 @@ export async function sendRunNotification(
|
|
|
635
715
|
|
|
636
716
|
// ${SECRET} so the Slack URL — itself a credential — can live in the
|
|
637
717
|
// vault instead of in a file that gets committed.
|
|
638
|
-
|
|
639
|
-
|
|
718
|
+
const target = webhookTarget(tenant, workspace, config);
|
|
719
|
+
if (!target.url) {
|
|
720
|
+
console.error(`[foldrun] notify: ${target.refused ?? "secret in URL not set"} for ${tenant}/${workspace}`);
|
|
640
721
|
return false;
|
|
641
722
|
}
|
|
642
723
|
const event = blocked ? "blocked" : run.status;
|
package/src/package.ts
CHANGED
|
@@ -74,8 +74,10 @@ export interface Package {
|
|
|
74
74
|
/** An error the API layer turns into a status rather than a 500. */
|
|
75
75
|
export class PackageError extends Error {
|
|
76
76
|
readonly status: number;
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
/** `cause`: the error underneath a 500, so an HTTP caller can log it and
|
|
78
|
+
* answer without it — a Node error names the server's paths (30 Oct 2026). */
|
|
79
|
+
constructor(message: string, status: number, opts?: { cause?: unknown }) {
|
|
80
|
+
super(message, opts);
|
|
79
81
|
this.status = status;
|
|
80
82
|
}
|
|
81
83
|
}
|
|
@@ -437,7 +439,7 @@ export function applyImport(
|
|
|
437
439
|
// best effort — the error below is the one to report
|
|
438
440
|
}
|
|
439
441
|
}
|
|
440
|
-
throw new PackageError(`the import could not be written, and nothing was kept: ${err instanceof Error ? err.message : String(err)}`, 500);
|
|
442
|
+
throw new PackageError(`the import could not be written, and nothing was kept: ${err instanceof Error ? err.message : String(err)}`, 500, { cause: err });
|
|
441
443
|
}
|
|
442
444
|
for (const c of changes) {
|
|
443
445
|
const abs = path.join(dir, c.path);
|
package/src/runner.ts
CHANGED
|
@@ -90,6 +90,7 @@ import {
|
|
|
90
90
|
STORAGE_DIR,
|
|
91
91
|
adoptLegacyVersionKey,
|
|
92
92
|
listAgents,
|
|
93
|
+
isOneSegment,
|
|
93
94
|
} from "./store.ts";
|
|
94
95
|
import { canReadBack } from "./tool-output.ts";
|
|
95
96
|
import { PromptLayers, promptCacheKey } from "./prompt-layers.ts";
|
|
@@ -4926,8 +4927,12 @@ function driveRunInner(
|
|
|
4926
4927
|
// finished steps to `failed`, destroying the history it was
|
|
4927
4928
|
// resuming around.
|
|
4928
4929
|
if (step.status !== "pending") continue;
|
|
4929
|
-
|
|
4930
|
-
|
|
4930
|
+
// One folder under agents/, never a path (30 Oct 2026): an ad-hoc
|
|
4931
|
+
// run's agent came from the URL, and `../../<tenant>/…` joined here
|
|
4932
|
+
// ran another account's agent. Refused as not found, before the
|
|
4933
|
+
// existence check could answer for a path outside.
|
|
4934
|
+
const aDir = isOneSegment(step.agent) ? path.join(pDir, "agents", step.agent) : null;
|
|
4935
|
+
if (!aDir || !fs.existsSync(path.join(aDir, "agent.md"))) {
|
|
4931
4936
|
step.status = "failed";
|
|
4932
4937
|
step.events.push({
|
|
4933
4938
|
t: new Date().toISOString(),
|
|
@@ -5035,15 +5040,18 @@ function driveRunInner(
|
|
|
5035
5040
|
if (needsApproval.length) {
|
|
5036
5041
|
for (const step of needsApproval) {
|
|
5037
5042
|
step.status = "awaiting-approval";
|
|
5043
|
+
const t = new Date().toISOString();
|
|
5038
5044
|
step.events.push({
|
|
5039
|
-
t
|
|
5045
|
+
t,
|
|
5040
5046
|
type: "info",
|
|
5041
5047
|
// ask: is the same gate carrying a question — the answer
|
|
5042
5048
|
// arrives as the approval note and rides into the prompt.
|
|
5049
|
+
// An event URL is this step's and this park's alone (30 Oct
|
|
5050
|
+
// 2026): the index and this line's `t` are in its token.
|
|
5043
5051
|
text: step.ask
|
|
5044
5052
|
? `waiting for an answer: ${step.ask}`
|
|
5045
5053
|
: step.waitFor === "event"
|
|
5046
|
-
? `waiting for an external event — release it with POST ${eventUrl(tenant, workspace, run.id)}`
|
|
5054
|
+
? `waiting for an external event — release it with POST ${eventUrl(tenant, workspace, run.id, { step: run.steps.indexOf(step), at: t })}`
|
|
5047
5055
|
: "waiting for approval before running",
|
|
5048
5056
|
});
|
|
5049
5057
|
}
|
|
@@ -5156,8 +5164,9 @@ function driveRunInner(
|
|
|
5156
5164
|
// step of the flow (isSplicedRecord). It gets no delegateJob of its
|
|
5157
5165
|
// own: depth one.
|
|
5158
5166
|
const delegateFor = (parent: StepRecord) => async (agent: string, job: string, cancelled: () => boolean): Promise<DelegationResult> => {
|
|
5159
|
-
|
|
5160
|
-
|
|
5167
|
+
// One folder under agents/, never a path — as the group check above.
|
|
5168
|
+
const dir = isOneSegment(agent) ? path.join(pDir, "agents", agent) : null;
|
|
5169
|
+
if (!dir || !fs.existsSync(path.join(dir, "agent.md"))) return { status: "failed", result: `${agent} is not an agent in this workspace` };
|
|
5161
5170
|
// A stable id on both records: an index into run.steps moves when a
|
|
5162
5171
|
// rescue or a fan-out is spliced in before the child.
|
|
5163
5172
|
const id = `d_${crypto.randomBytes(5).toString("hex")}`;
|
|
@@ -5597,8 +5606,8 @@ function driveRunInner(
|
|
|
5597
5606
|
};
|
|
5598
5607
|
const rescueStep = async (step: StepRecord): Promise<void> => {
|
|
5599
5608
|
if (!step.onFail) return;
|
|
5600
|
-
const rescuerDir = path.join(pDir, "agents", step.onFail);
|
|
5601
|
-
if (!fs.existsSync(path.join(rescuerDir, "agent.md"))) {
|
|
5609
|
+
const rescuerDir = isOneSegment(step.onFail) ? path.join(pDir, "agents", step.onFail) : null;
|
|
5610
|
+
if (!rescuerDir || !fs.existsSync(path.join(rescuerDir, "agent.md"))) {
|
|
5602
5611
|
step.events.push({
|
|
5603
5612
|
t: new Date().toISOString(),
|
|
5604
5613
|
type: "error",
|