@foldrun/core 9.0.0 → 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/notify.d.ts +6 -3
- package/dist/src/notify.js +25 -12
- package/dist/src/package.d.ts +5 -1
- package/dist/src/package.js +5 -3
- package/dist/src/runner.js +5 -2
- package/dist/src/scan.js +21 -0
- package/dist/src/secrets.d.ts +92 -10
- package/dist/src/secrets.js +250 -26
- package/dist/src/triggers.js +2 -2
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +14 -5
- 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/notify.ts +23 -12
- package/src/package.ts +5 -3
- package/src/runner.ts +5 -2
- package/src/scan.ts +23 -0
- package/src/secrets.ts +276 -32
- package/src/triggers.ts +2 -2
- package/src/webhook.ts +22 -5
package/dist/src/triggers.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import crypto from "node:crypto";
|
|
15
15
|
import { platform } from "./platform.js";
|
|
16
16
|
import { listFlows, readRun, runVerdict } from "./store.js";
|
|
17
|
-
import { getSecret } from "./secrets.js";
|
|
17
|
+
import { getSecret, signingSecretShown } from "./secrets.js";
|
|
18
18
|
/** The last step's result — the same reading runner.ts makes, kept local so
|
|
19
19
|
* this module never imports the runner (the queue imports both). */
|
|
20
20
|
function runResult(run) {
|
|
@@ -251,7 +251,7 @@ export function verifyWebhookSignature(tenant, workspace, flow, rawBody, header,
|
|
|
251
251
|
return { ok: false, reason: `signature: ${flow.signature} needs signing_secret: \${NAME}` };
|
|
252
252
|
const secret = getSecret(tenant, flow.signingSecret, workspace);
|
|
253
253
|
if (!secret)
|
|
254
|
-
return { ok: false, reason: `signing secret ${flow.signingSecret} is not set` };
|
|
254
|
+
return { ok: false, reason: `signing secret ${signingSecretShown(flow.signingSecret)} is not set` };
|
|
255
255
|
const hmac = (data) => crypto.createHmac("sha256", secret.value).update(data).digest("hex");
|
|
256
256
|
const fresh = (ts) => Math.abs(now / 1000 - ts) <= 300;
|
|
257
257
|
switch (flow.signature) {
|
package/dist/src/webhook.d.ts
CHANGED
|
@@ -27,14 +27,30 @@ export declare function webhookPath(tenant: string, workspace: string, flow: str
|
|
|
27
27
|
* working hook token.
|
|
28
28
|
*/
|
|
29
29
|
export declare function approveToken(tenant: string, workspace: string, runId: string): string;
|
|
30
|
+
/** One parked `wait: event` gate: the step's index in the run record and
|
|
31
|
+
* the moment it parked (the `t` of its "waiting for an external event"
|
|
32
|
+
* trace line). */
|
|
33
|
+
export interface EventGate {
|
|
34
|
+
step: number;
|
|
35
|
+
at: string;
|
|
36
|
+
}
|
|
30
37
|
/**
|
|
31
38
|
* The token that releases a run parked on `wait: event`. Its own HMAC
|
|
32
39
|
* family, so an approval link cannot double as an event URL or the reverse.
|
|
40
|
+
*
|
|
41
|
+
* With `gate`, the token is for that one park of that one step (30 Oct
|
|
42
|
+
* 2026). The per-run token released every later `wait: event` of the run,
|
|
43
|
+
* so a URL handed to a third party for the first gate also opened the
|
|
44
|
+
* second, and never expired. A gate's token dies with its park: the step
|
|
45
|
+
* released is no longer waiting, and parking again (a loop) stamps a new
|
|
46
|
+
* `at`. The per-run form, without `gate`, is what URLs printed before then
|
|
47
|
+
* carry — the events route still takes it for a step parked under it.
|
|
33
48
|
*/
|
|
34
|
-
export declare function eventToken(tenant: string, workspace: string, runId: string): string;
|
|
49
|
+
export declare function eventToken(tenant: string, workspace: string, runId: string, gate?: EventGate): string;
|
|
35
50
|
/** The URL a `wait: event` step is released at — absolute when the install
|
|
36
|
-
* knows its origin, a path otherwise.
|
|
37
|
-
|
|
51
|
+
* knows its origin, a path otherwise. With `gate`, that step's own URL
|
|
52
|
+
* (`&step=<index>`); the route finds the park's moment on the record. */
|
|
53
|
+
export declare function eventUrl(tenant: string, workspace: string, runId: string, gate?: EventGate): string;
|
|
38
54
|
/**
|
|
39
55
|
* Where this install can be reached from outside, or null. The platform has
|
|
40
56
|
* never needed to know its own address — every URL it hands out has been a
|
package/dist/src/webhook.js
CHANGED
|
@@ -80,18 +80,27 @@ export function approveToken(tenant, workspace, runId) {
|
|
|
80
80
|
/**
|
|
81
81
|
* The token that releases a run parked on `wait: event`. Its own HMAC
|
|
82
82
|
* family, so an approval link cannot double as an event URL or the reverse.
|
|
83
|
+
*
|
|
84
|
+
* With `gate`, the token is for that one park of that one step (30 Oct
|
|
85
|
+
* 2026). The per-run token released every later `wait: event` of the run,
|
|
86
|
+
* so a URL handed to a third party for the first gate also opened the
|
|
87
|
+
* second, and never expired. A gate's token dies with its park: the step
|
|
88
|
+
* released is no longer waiting, and parking again (a loop) stamps a new
|
|
89
|
+
* `at`. The per-run form, without `gate`, is what URLs printed before then
|
|
90
|
+
* carry — the events route still takes it for a step parked under it.
|
|
83
91
|
*/
|
|
84
|
-
export function eventToken(tenant, workspace, runId) {
|
|
92
|
+
export function eventToken(tenant, workspace, runId, gate) {
|
|
85
93
|
return crypto
|
|
86
94
|
.createHmac("sha256", installKey())
|
|
87
|
-
.update(`event:${tenant}/${workspace}/${runId}`)
|
|
95
|
+
.update(gate ? `event:${tenant}/${workspace}/${runId}#${gate.step}@${gate.at}` : `event:${tenant}/${workspace}/${runId}`)
|
|
88
96
|
.digest("hex")
|
|
89
97
|
.slice(0, 32);
|
|
90
98
|
}
|
|
91
99
|
/** The URL a `wait: event` step is released at — absolute when the install
|
|
92
|
-
* knows its origin, a path otherwise.
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
* knows its origin, a path otherwise. With `gate`, that step's own URL
|
|
101
|
+
* (`&step=<index>`); the route finds the park's moment on the record. */
|
|
102
|
+
export function eventUrl(tenant, workspace, runId, gate) {
|
|
103
|
+
const p = `/api/events/${tenant}/${workspace}/${runId}?token=${eventToken(tenant, workspace, runId, gate)}${gate ? `&step=${gate.step}` : ""}`;
|
|
95
104
|
const base = publicUrl();
|
|
96
105
|
return base ? `${base}${p}` : p;
|
|
97
106
|
}
|
package/package.json
CHANGED
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/notify.ts
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
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, secretSites } from "./secrets.ts";
|
|
37
|
+
import { getSecret, secretSites, signingSecretShown, accountSecretUsableIn } from "./secrets.ts";
|
|
38
38
|
import { siteCovers } from "./network.ts";
|
|
39
39
|
import { publicUrl } from "./webhook.ts";
|
|
40
40
|
import { approveLinkPath, approveLinkTtlMs } from "./approvals.ts";
|
|
@@ -63,11 +63,15 @@ export function platformMail(tenant: string): { key: string; from: string } | nu
|
|
|
63
63
|
return own;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
/** The account's own Resend key and sender, or null when it has none.
|
|
67
|
-
|
|
68
|
-
|
|
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;
|
|
69
73
|
if (!own) return null;
|
|
70
|
-
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>";
|
|
71
75
|
return { key: own.value, from };
|
|
72
76
|
}
|
|
73
77
|
|
|
@@ -81,8 +85,15 @@ export function accountMail(tenant: string): { key: string; from: string } | nul
|
|
|
81
85
|
* owner's bin. The platform's key is the fallback for an account that set
|
|
82
86
|
* none, so `notify:` works before anyone has configured mail at all.
|
|
83
87
|
*/
|
|
84
|
-
export function notifyMail(tenant: string): { key: string; from: string } | null {
|
|
85
|
-
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;
|
|
86
97
|
}
|
|
87
98
|
|
|
88
99
|
/** Was the mail sent on the account's own key? Only then is a refusal from
|
|
@@ -169,7 +180,7 @@ export function signatureHeaders(
|
|
|
169
180
|
const secret = getSecret(tenant, config.signingSecret, workspace);
|
|
170
181
|
if (!secret) {
|
|
171
182
|
console.error(
|
|
172
|
-
`[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`,
|
|
173
184
|
);
|
|
174
185
|
return {};
|
|
175
186
|
}
|
|
@@ -475,7 +486,7 @@ export async function sendPlainNotificationOutcome(
|
|
|
475
486
|
};
|
|
476
487
|
try {
|
|
477
488
|
if (config.email) {
|
|
478
|
-
const mail = notifyMail(tenant);
|
|
489
|
+
const mail = notifyMail(tenant, workspace);
|
|
479
490
|
if (!mail) return no;
|
|
480
491
|
const r = await sendMail({
|
|
481
492
|
tenant,
|
|
@@ -529,7 +540,7 @@ export async function sendTestNotification(
|
|
|
529
540
|
const detail = `Sent from ${workspace} at ${sent}. If you are reading this, failures and approval gates will reach you here too.`;
|
|
530
541
|
|
|
531
542
|
if (config.email) {
|
|
532
|
-
const mail = notifyMail(tenant);
|
|
543
|
+
const mail = notifyMail(tenant, workspace);
|
|
533
544
|
if (!mail) {
|
|
534
545
|
return {
|
|
535
546
|
ok: false,
|
|
@@ -568,7 +579,7 @@ export async function sendTestNotification(
|
|
|
568
579
|
? {
|
|
569
580
|
ok: true,
|
|
570
581
|
destination: shown,
|
|
571
|
-
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})`,
|
|
572
583
|
}
|
|
573
584
|
: { ok: false, destination: shown, detail: `${r.statusCode ? `HTTP ${r.statusCode} — ` : ""}${r.detail.replace(/\s+/g, " ").slice(0, 400)}` };
|
|
574
585
|
}
|
|
@@ -659,7 +670,7 @@ export async function sendRunNotification(
|
|
|
659
670
|
|
|
660
671
|
try {
|
|
661
672
|
if (config.email) {
|
|
662
|
-
const mail = notifyMail(tenant);
|
|
673
|
+
const mail = notifyMail(tenant, workspace);
|
|
663
674
|
if (!mail) {
|
|
664
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`);
|
|
665
676
|
return false;
|
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
|
@@ -5040,15 +5040,18 @@ function driveRunInner(
|
|
|
5040
5040
|
if (needsApproval.length) {
|
|
5041
5041
|
for (const step of needsApproval) {
|
|
5042
5042
|
step.status = "awaiting-approval";
|
|
5043
|
+
const t = new Date().toISOString();
|
|
5043
5044
|
step.events.push({
|
|
5044
|
-
t
|
|
5045
|
+
t,
|
|
5045
5046
|
type: "info",
|
|
5046
5047
|
// ask: is the same gate carrying a question — the answer
|
|
5047
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.
|
|
5048
5051
|
text: step.ask
|
|
5049
5052
|
? `waiting for an answer: ${step.ask}`
|
|
5050
5053
|
: step.waitFor === "event"
|
|
5051
|
-
? `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 })}`
|
|
5052
5055
|
: "waiting for approval before running",
|
|
5053
5056
|
});
|
|
5054
5057
|
}
|
package/src/scan.ts
CHANGED
|
@@ -270,6 +270,29 @@ export const SCAN_RULES: readonly ScanRule[] = [
|
|
|
270
270
|
title: "A required frontmatter field is missing",
|
|
271
271
|
help: "A skill with no description is never loaded: the model chooses skills by their descriptions.",
|
|
272
272
|
},
|
|
273
|
+
{
|
|
274
|
+
// 30 Oct 2026: check and the scheduler read a schedule with the same
|
|
275
|
+
// cronCanFire (core cron.ts), so check says what the scheduler does.
|
|
276
|
+
id: "config/cron-never-fires",
|
|
277
|
+
category: "config",
|
|
278
|
+
level: "warn",
|
|
279
|
+
title: "A schedule that names a day no month has",
|
|
280
|
+
help:
|
|
281
|
+
"`0 0 30 2 *` is a valid cron — every field is in range — but no February has a 30th, so the flow never runs and the " +
|
|
282
|
+
"scheduler never lists a next run for it. Pick a day every named month has, or add a weekday (either then matches).",
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
// 30 Oct 2026: a signing secret is looked up in the vault by name.
|
|
286
|
+
id: "secret/signing-secret-value",
|
|
287
|
+
category: "secret",
|
|
288
|
+
level: "warn",
|
|
289
|
+
title: "A signing_secret: that holds a value, not a secret's name",
|
|
290
|
+
help:
|
|
291
|
+
"A flow's `signing_secret:` and `notify: signing_secret:` name a vault entry (`WEBHOOK_SIGNING_KEY`, or `${WEBHOOK_SIGNING_KEY}`). " +
|
|
292
|
+
"Anything else is looked up as a name and never found — every signed delivery is refused, every notification goes unsigned — " +
|
|
293
|
+
"and the value itself is deployed and versioned in plain text. Put it in the vault (`foldrun secrets set NAME`), write the name, and rotate it.",
|
|
294
|
+
owasp: ["ASI03"],
|
|
295
|
+
},
|
|
273
296
|
{
|
|
274
297
|
id: "check/ignore-needs-reason",
|
|
275
298
|
category: "config",
|