@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/approvals.d.ts
CHANGED
|
@@ -127,19 +127,27 @@ export declare function decideApproval(tenant: string, workspace: string, runId:
|
|
|
127
127
|
*/
|
|
128
128
|
export declare function expireStaleGate(tenant: string, workspace: string, run: RunRecord, now?: number): Promise<boolean>;
|
|
129
129
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* a park was opened through.
|
|
130
|
+
* What a presented event token opens on a run (30 Oct 2026): `known` when
|
|
131
|
+
* it is a real token of this run — one of its parks, or the old per-run
|
|
132
|
+
* form — and `steps`, the waiting steps it releases now (none: a 409, the
|
|
133
|
+
* gate it was for is no longer waiting).
|
|
135
134
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
135
|
+
* With `step` (the `&step=` every URL printed since carries), the token is
|
|
136
|
+
* that step's, bound to the moment it parked: the `t` of its "waiting for an
|
|
137
|
+
* external event" line. It releases that step only while that park is the
|
|
138
|
+
* step's current one — a released step is no longer waiting, and a step
|
|
139
|
+
* parked again (a loop) has a new `t` — so a URL handed out for one gate
|
|
140
|
+
* opens neither a later gate nor a later park.
|
|
138
141
|
*
|
|
139
|
-
* `
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
+
* Without `step`, the per-run token URLs carried before then. It releases
|
|
143
|
+
* only a step still waiting under such a URL — its park line has no
|
|
144
|
+
* `step=` — so a run parked when this shipped can still be released, and
|
|
145
|
+
* nothing parked since can be released by the old form.
|
|
142
146
|
*/
|
|
147
|
+
export declare function eventGatesFor(tenant: string, workspace: string, run: RunRecord, presented: string, step: number | null): {
|
|
148
|
+
known: boolean;
|
|
149
|
+
steps: number[];
|
|
150
|
+
};
|
|
143
151
|
export declare function deliverEvent(tenant: string, workspace: string, runId: string, payload: string, opts?: {
|
|
144
152
|
by?: string;
|
|
145
153
|
step?: number;
|
package/dist/src/approvals.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import crypto from "node:crypto";
|
|
13
13
|
import { readRun, writeRun, readFlow, mayApprove } from "./store.js";
|
|
14
14
|
import { platform } from "./platform.js";
|
|
15
|
-
import { approveToken, installKey } from "./webhook.js";
|
|
15
|
+
import { approveToken, eventToken, installKey } from "./webhook.js";
|
|
16
16
|
// ----------------------------------------------------------------- links
|
|
17
17
|
/** How long an emailed link decides for when the flow does not say. */
|
|
18
18
|
export const APPROVE_LINK_DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -276,6 +276,56 @@ export async function expireStaleGate(tenant, workspace, run, now = Date.now())
|
|
|
276
276
|
* key through the dashboard's approve route — and `step` releases one
|
|
277
277
|
* waiting step rather than all of them.
|
|
278
278
|
*/
|
|
279
|
+
const EVENT_WAIT_LINE = "waiting for an external event — release it with POST ";
|
|
280
|
+
/**
|
|
281
|
+
* What a presented event token opens on a run (30 Oct 2026): `known` when
|
|
282
|
+
* it is a real token of this run — one of its parks, or the old per-run
|
|
283
|
+
* form — and `steps`, the waiting steps it releases now (none: a 409, the
|
|
284
|
+
* gate it was for is no longer waiting).
|
|
285
|
+
*
|
|
286
|
+
* With `step` (the `&step=` every URL printed since carries), the token is
|
|
287
|
+
* that step's, bound to the moment it parked: the `t` of its "waiting for an
|
|
288
|
+
* external event" line. It releases that step only while that park is the
|
|
289
|
+
* step's current one — a released step is no longer waiting, and a step
|
|
290
|
+
* parked again (a loop) has a new `t` — so a URL handed out for one gate
|
|
291
|
+
* opens neither a later gate nor a later park.
|
|
292
|
+
*
|
|
293
|
+
* Without `step`, the per-run token URLs carried before then. It releases
|
|
294
|
+
* only a step still waiting under such a URL — its park line has no
|
|
295
|
+
* `step=` — so a run parked when this shipped can still be released, and
|
|
296
|
+
* nothing parked since can be released by the old form.
|
|
297
|
+
*/
|
|
298
|
+
export function eventGatesFor(tenant, workspace, run, presented, step) {
|
|
299
|
+
const none = { known: false, steps: [] };
|
|
300
|
+
if (!presented)
|
|
301
|
+
return none;
|
|
302
|
+
const same = (want) => {
|
|
303
|
+
const a = Buffer.from(presented), b = Buffer.from(want);
|
|
304
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
305
|
+
};
|
|
306
|
+
const parks = (i) => (run.steps[i]?.events ?? []).filter((e) => e?.text?.startsWith(EVENT_WAIT_LINE));
|
|
307
|
+
const waiting = (i) => {
|
|
308
|
+
const s = run.steps[i];
|
|
309
|
+
return !!s && s.status === "awaiting-approval" && s.waitFor === "event";
|
|
310
|
+
};
|
|
311
|
+
if (step !== null) {
|
|
312
|
+
const lines = parks(step);
|
|
313
|
+
const hit = lines.findIndex((e) => same(eventToken(tenant, workspace, run.id, { step, at: e.t })));
|
|
314
|
+
if (hit < 0)
|
|
315
|
+
return none;
|
|
316
|
+
// Only the step's latest park is open, and only while it waits.
|
|
317
|
+
return { known: true, steps: hit === lines.length - 1 && waiting(step) ? [step] : [] };
|
|
318
|
+
}
|
|
319
|
+
if (!same(eventToken(tenant, workspace, run.id)))
|
|
320
|
+
return none;
|
|
321
|
+
const legacy = [];
|
|
322
|
+
for (let i = 0; i < run.steps.length; i++) {
|
|
323
|
+
const last = parks(i).at(-1);
|
|
324
|
+
if (last && waiting(i) && !/[?&]step=\d/.test(last.text))
|
|
325
|
+
legacy.push(i);
|
|
326
|
+
}
|
|
327
|
+
return { known: true, steps: legacy };
|
|
328
|
+
}
|
|
279
329
|
export async function deliverEvent(tenant, workspace, runId, payload, opts = {}) {
|
|
280
330
|
const run = readRun(tenant, workspace, runId);
|
|
281
331
|
if (!run)
|
package/dist/src/catalog.d.ts
CHANGED
|
@@ -45,6 +45,20 @@ export declare const CATALOG_MAX_BYTES: number;
|
|
|
45
45
|
* there is nothing at all to fall back on.
|
|
46
46
|
*/
|
|
47
47
|
export declare function loadCatalog(baseUrl: string): Promise<Catalog | null>;
|
|
48
|
+
/** How long after an attempt a background refresh of the same gateway is
|
|
49
|
+
* not tried again, whether it worked or not. */
|
|
50
|
+
export declare const CATALOG_RETRY_MS: number;
|
|
51
|
+
/**
|
|
52
|
+
* Refresh a gateway's catalogue behind the caller, at most once per
|
|
53
|
+
* CATALOG_RETRY_MS per gateway (30 Oct 2026). For the editor's completions and
|
|
54
|
+
* the flows page, which ask on every request: a stale or missing catalogue
|
|
55
|
+
* used to start a fresh outbound fetch each time — a 10 s one per keystroke
|
|
56
|
+
* against an unreachable gateway, nothing remembering that it had just
|
|
57
|
+
* failed. The attempt is noted before it starts, so callers arriving while
|
|
58
|
+
* one is in flight do not start another. True when one was started. A run's
|
|
59
|
+
* own read (loadCatalog) is untouched.
|
|
60
|
+
*/
|
|
61
|
+
export declare function refreshCatalogSoon(baseUrl: string, load?: (baseUrl: string) => Promise<unknown>, now?: number): boolean;
|
|
48
62
|
/** A preset reference — settings live in the gateway's dashboard, opaque to
|
|
49
63
|
* us on purpose. The catalogue offers no opinion on one. */
|
|
50
64
|
export declare const isPresetRef: (model: string) => boolean;
|
package/dist/src/catalog.js
CHANGED
|
@@ -118,6 +118,34 @@ export async function loadCatalog(baseUrl) {
|
|
|
118
118
|
return cached; // stale beats nothing; null when there is no nothing
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
|
+
/** How long after an attempt a background refresh of the same gateway is
|
|
122
|
+
* not tried again, whether it worked or not. */
|
|
123
|
+
export const CATALOG_RETRY_MS = 5 * 60_000;
|
|
124
|
+
const lastTried = new Map();
|
|
125
|
+
/**
|
|
126
|
+
* Refresh a gateway's catalogue behind the caller, at most once per
|
|
127
|
+
* CATALOG_RETRY_MS per gateway (30 Oct 2026). For the editor's completions and
|
|
128
|
+
* the flows page, which ask on every request: a stale or missing catalogue
|
|
129
|
+
* used to start a fresh outbound fetch each time — a 10 s one per keystroke
|
|
130
|
+
* against an unreachable gateway, nothing remembering that it had just
|
|
131
|
+
* failed. The attempt is noted before it starts, so callers arriving while
|
|
132
|
+
* one is in flight do not start another. True when one was started. A run's
|
|
133
|
+
* own read (loadCatalog) is untouched.
|
|
134
|
+
*/
|
|
135
|
+
export function refreshCatalogSoon(baseUrl, load = loadCatalog, now = Date.now()) {
|
|
136
|
+
const cached = readCatalog(baseUrl);
|
|
137
|
+
if (cached && now - Date.parse(cached.fetchedAt) < TTL_MS)
|
|
138
|
+
return false; // fresh: nothing to fetch
|
|
139
|
+
const last = lastTried.get(baseUrl);
|
|
140
|
+
if (last !== undefined && now - last < CATALOG_RETRY_MS)
|
|
141
|
+
return false;
|
|
142
|
+
lastTried.delete(baseUrl);
|
|
143
|
+
lastTried.set(baseUrl, now);
|
|
144
|
+
if (lastTried.size > 1000)
|
|
145
|
+
lastTried.delete(lastTried.keys().next().value);
|
|
146
|
+
void load(baseUrl).catch(() => null);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
121
149
|
/** A preset reference — settings live in the gateway's dashboard, opaque to
|
|
122
150
|
* us on purpose. The catalogue offers no opinion on one. */
|
|
123
151
|
export const isPresetRef = (model) => model.startsWith("@preset/");
|
package/dist/src/cron.d.ts
CHANGED
|
@@ -15,3 +15,12 @@ export interface Cron {
|
|
|
15
15
|
export declare function parseCron(expr: string): Cron | null;
|
|
16
16
|
/** Why `schedule:` is not a cron the scheduler can run, or null when it is. */
|
|
17
17
|
export declare function cronProblem(expr: unknown): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Whether the calendar has any day this cron names (30 Oct 2026). `0 0 30 2 *`
|
|
20
|
+
* parses — every field is in range — yet no February has a 30th, so a search
|
|
21
|
+
* for its next fire walked the whole window every time it was asked (the
|
|
22
|
+
* schedule page did, 57,600 minutes per flow per request). Answered from the
|
|
23
|
+
* fields alone: only a day of the month with no weekday beside it can miss,
|
|
24
|
+
* since with a weekday either may match and every month has every weekday.
|
|
25
|
+
*/
|
|
26
|
+
export declare function cronCanFire(cron: Cron): boolean;
|
package/dist/src/cron.js
CHANGED
|
@@ -105,3 +105,23 @@ export function cronProblem(expr) {
|
|
|
105
105
|
? `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`
|
|
106
106
|
: `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`;
|
|
107
107
|
}
|
|
108
|
+
/** Days in each month, February at its leap-year 29: a cron naming the 29th
|
|
109
|
+
* of February does fire, every fourth year. */
|
|
110
|
+
const MONTH_DAYS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
111
|
+
/**
|
|
112
|
+
* Whether the calendar has any day this cron names (30 Oct 2026). `0 0 30 2 *`
|
|
113
|
+
* parses — every field is in range — yet no February has a 30th, so a search
|
|
114
|
+
* for its next fire walked the whole window every time it was asked (the
|
|
115
|
+
* schedule page did, 57,600 minutes per flow per request). Answered from the
|
|
116
|
+
* fields alone: only a day of the month with no weekday beside it can miss,
|
|
117
|
+
* since with a weekday either may match and every month has every weekday.
|
|
118
|
+
*/
|
|
119
|
+
export function cronCanFire(cron) {
|
|
120
|
+
if (!cron.domRestricted || cron.dowRestricted)
|
|
121
|
+
return true;
|
|
122
|
+
const firstDay = Math.min(...cron.dom);
|
|
123
|
+
for (const m of cron.month)
|
|
124
|
+
if (firstDay <= MONTH_DAYS[m - 1])
|
|
125
|
+
return true;
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
@@ -109,7 +109,7 @@ const FLOW = [
|
|
|
109
109
|
{ key: "catchup", type: "string", validated: true, help: "last | none" },
|
|
110
110
|
{ key: "on", type: "string", validated: true, help: "trigger: flow — completed | failed | blocked | any" },
|
|
111
111
|
{ key: "signature", type: "string", validated: true, help: "github | stripe | slack | hmac" },
|
|
112
|
-
{ key: "signing_secret", type: "string", help: "the secret a webhook's signature is checked with" },
|
|
112
|
+
{ key: "signing_secret", type: "string", help: "the NAME of the vault secret a webhook's signature is checked with — never the value" },
|
|
113
113
|
{ key: "after", type: ["string", "list"], help: "trigger: flow — the flow this one follows" },
|
|
114
114
|
{ key: "at", type: "string", help: "trigger: once — when" },
|
|
115
115
|
{ key: "url", type: "string", help: "trigger: watch — the page watched" },
|
|
@@ -2,7 +2,7 @@ import { type SchemaKind } from "./frontmatter-fields.ts";
|
|
|
2
2
|
export { FRONTMATTER, schemaKindOf, type FieldSpec, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
|
|
3
3
|
export interface FieldProblem {
|
|
4
4
|
key: string;
|
|
5
|
-
rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field" | "config/conflict" | "config/misplaced";
|
|
5
|
+
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";
|
|
6
6
|
level: "error" | "warn" | "info";
|
|
7
7
|
message: string;
|
|
8
8
|
}
|
|
@@ -13,3 +13,7 @@ export interface FieldProblem {
|
|
|
13
13
|
* `near` is the typo matcher; scan.ts passes its own.
|
|
14
14
|
*/
|
|
15
15
|
export declare function frontmatterProblems(kind: SchemaKind, data: Record<string, unknown>, near: (key: string, known: readonly string[]) => string | null): FieldProblem[];
|
|
16
|
+
/** Why a `signing_secret:` value is not a vault secret's name, or null. The
|
|
17
|
+
* runtime reads `NAME` and `${NAME}` alike (notify.ts, store.ts). Never
|
|
18
|
+
* says what the value is: it may be the secret itself. */
|
|
19
|
+
export declare function signingSecretProblem(v: unknown): string | null;
|
|
@@ -5,7 +5,8 @@ import { FRONTMATTER, JOBS } from "./frontmatter-fields.js";
|
|
|
5
5
|
import { jobFold } from "./matter.js";
|
|
6
6
|
import { resolveEffort, parseDuration, parseInstant, parseRate, STEP_OPTION_KEYS } from "./store.js";
|
|
7
7
|
import { budgetProblem } from "./budget.js";
|
|
8
|
-
import { cronProblem } from "./cron.js";
|
|
8
|
+
import { cronProblem, parseCron, cronCanFire } from "./cron.js";
|
|
9
|
+
import { SECRET_NAME } from "./secrets.js";
|
|
9
10
|
import { modelSecretCollision } from "./model-credential.js";
|
|
10
11
|
import { resourcesProblem, computeProblem } from "./resources.js";
|
|
11
12
|
export { FRONTMATTER, schemaKindOf } from "./frontmatter-fields.js";
|
|
@@ -163,11 +164,49 @@ export function frontmatterProblems(kind, data, near) {
|
|
|
163
164
|
if (why)
|
|
164
165
|
out.push({ key, rule: "config/invalid-value", level: "warn", message: `\`${key}:\` ${why.replace(/^\w+: /, "")}` });
|
|
165
166
|
}
|
|
167
|
+
// A cron every field of which is in range, on a day no month has —
|
|
168
|
+
// `0 0 30 2 *`. The scheduler's own cronCanFire decides (30 Oct 2026), so
|
|
169
|
+
// check and the scheduler say the same about it: it never fires.
|
|
170
|
+
if (kind === "flow" && typeof data.schedule === "string") {
|
|
171
|
+
const cron = parseCron(data.schedule);
|
|
172
|
+
if (cron && !cronCanFire(cron)) {
|
|
173
|
+
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` });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// A signing secret is the NAME of a vault entry (30 Oct 2026): a flow's
|
|
177
|
+
// `signing_secret:` and notify's `signing_secret:` are looked up with
|
|
178
|
+
// getSecret, so a literal value there is a secret written into a
|
|
179
|
+
// versioned, deployed file — and never matches anything in the vault.
|
|
180
|
+
// The message never repeats the value.
|
|
181
|
+
if (kind === "flow" && data.signing_secret !== undefined) {
|
|
182
|
+
const why = signingSecretProblem(data.signing_secret);
|
|
183
|
+
if (why)
|
|
184
|
+
out.push({ key: "signing_secret", rule: "secret/signing-secret-value", level: "warn", message: `\`signing_secret:\` ${why}` });
|
|
185
|
+
}
|
|
186
|
+
if (kind === "agents-md" && data.notify && typeof data.notify === "object" && !Array.isArray(data.notify)) {
|
|
187
|
+
const raw = data.notify.signing_secret;
|
|
188
|
+
const why = raw === undefined ? null : signingSecretProblem(raw);
|
|
189
|
+
if (why)
|
|
190
|
+
out.push({ key: "notify", rule: "secret/signing-secret-value", level: "warn", message: `\`notify: signing_secret:\` ${why}` });
|
|
191
|
+
}
|
|
166
192
|
if (kind === "skill" && !(typeof data.description === "string" && data.description.trim())) {
|
|
167
193
|
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" });
|
|
168
194
|
}
|
|
169
195
|
return out;
|
|
170
196
|
}
|
|
197
|
+
/** Why a `signing_secret:` value is not a vault secret's name, or null. The
|
|
198
|
+
* runtime reads `NAME` and `${NAME}` alike (notify.ts, store.ts). Never
|
|
199
|
+
* says what the value is: it may be the secret itself. */
|
|
200
|
+
export function signingSecretProblem(v) {
|
|
201
|
+
if (typeof v !== "string")
|
|
202
|
+
return null; // config/wrong-type says so
|
|
203
|
+
const name = v.trim().replace(/^\$\{|\}$/g, "");
|
|
204
|
+
if (!name)
|
|
205
|
+
return null;
|
|
206
|
+
return SECRET_NAME.test(name)
|
|
207
|
+
? null
|
|
208
|
+
: "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";
|
|
209
|
+
}
|
|
171
210
|
function askProblem(v) {
|
|
172
211
|
const t = v?.timeout;
|
|
173
212
|
return t === undefined ? null : DURATION(30, 86400, "30s to 24h")(t);
|
package/dist/src/notify.d.ts
CHANGED
|
@@ -15,8 +15,11 @@ export declare function platformMail(tenant: string): {
|
|
|
15
15
|
key: string;
|
|
16
16
|
from: string;
|
|
17
17
|
} | null;
|
|
18
|
-
/** The account's own Resend key and sender, or null when it has none.
|
|
19
|
-
|
|
18
|
+
/** The account's own Resend key and sender, or null when it has none.
|
|
19
|
+
* With a workspace — mail a workspace's run causes — an account key or
|
|
20
|
+
* sender limited to other workspaces is not used (30 Oct 2026); the
|
|
21
|
+
* account's own mail (resets, invites) names none and is not limited. */
|
|
22
|
+
export declare function accountMail(tenant: string, workspace?: string): {
|
|
20
23
|
key: string;
|
|
21
24
|
from: string;
|
|
22
25
|
} | null;
|
|
@@ -30,7 +33,7 @@ export declare function accountMail(tenant: string): {
|
|
|
30
33
|
* owner's bin. The platform's key is the fallback for an account that set
|
|
31
34
|
* none, so `notify:` works before anyone has configured mail at all.
|
|
32
35
|
*/
|
|
33
|
-
export declare function notifyMail(tenant: string): {
|
|
36
|
+
export declare function notifyMail(tenant: string, workspace?: string): {
|
|
34
37
|
key: string;
|
|
35
38
|
from: string;
|
|
36
39
|
} | null;
|
package/dist/src/notify.js
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
import crypto from "node:crypto";
|
|
34
34
|
import { readAgentsMd } from "./runner.js";
|
|
35
35
|
import { accountDir, workspaceDir, runCost, readFlow, runVerdict } from "./store.js";
|
|
36
|
-
import { getSecret, secretSites } from "./secrets.js";
|
|
36
|
+
import { getSecret, secretSites, signingSecretShown, accountSecretUsableIn } from "./secrets.js";
|
|
37
37
|
import { siteCovers } from "./network.js";
|
|
38
38
|
import { publicUrl } from "./webhook.js";
|
|
39
39
|
import { approveLinkPath, approveLinkTtlMs } from "./approvals.js";
|
|
@@ -62,12 +62,16 @@ export function platformMail(tenant) {
|
|
|
62
62
|
return { key, from: platform.setting("FOLDRUN_EMAIL_FROM") || "foldrun <hello@foldrun.io>" };
|
|
63
63
|
return own;
|
|
64
64
|
}
|
|
65
|
-
/** The account's own Resend key and sender, or null when it has none.
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
/** The account's own Resend key and sender, or null when it has none.
|
|
66
|
+
* With a workspace — mail a workspace's run causes — an account key or
|
|
67
|
+
* sender limited to other workspaces is not used (30 Oct 2026); the
|
|
68
|
+
* account's own mail (resets, invites) names none and is not limited. */
|
|
69
|
+
export function accountMail(tenant, workspace) {
|
|
70
|
+
const usable = (name) => !workspace || accountSecretUsableIn(tenant, name, workspace);
|
|
71
|
+
const own = usable("RESEND_API_KEY") ? getSecret(tenant, "RESEND_API_KEY") : null;
|
|
68
72
|
if (!own)
|
|
69
73
|
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
|
/**
|
|
@@ -80,8 +84,17 @@ export function accountMail(tenant) {
|
|
|
80
84
|
* owner's bin. The platform's key is the fallback for an account that set
|
|
81
85
|
* none, so `notify:` works before anyone has configured mail at all.
|
|
82
86
|
*/
|
|
83
|
-
export function notifyMail(tenant) {
|
|
84
|
-
|
|
87
|
+
export function notifyMail(tenant, workspace) {
|
|
88
|
+
if (!workspace)
|
|
89
|
+
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)
|
|
95
|
+
return own;
|
|
96
|
+
const key = process.env.FOLDRUN_RESEND_API_KEY;
|
|
97
|
+
return key ? { key, from: platform.setting("FOLDRUN_EMAIL_FROM") || "foldrun <hello@foldrun.io>" } : null;
|
|
85
98
|
}
|
|
86
99
|
/** Was the mail sent on the account's own key? Only then is a refusal from
|
|
87
100
|
* Resend a fact about a secret the account holds. Recording a platform-key
|
|
@@ -146,7 +159,7 @@ export function signatureHeaders(tenant, workspace, config, body) {
|
|
|
146
159
|
return {};
|
|
147
160
|
const secret = getSecret(tenant, config.signingSecret, workspace);
|
|
148
161
|
if (!secret) {
|
|
149
|
-
console.error(`[foldrun] notify: signing_secret names ${config.signingSecret}, which is not in the vault for ${tenant}/${workspace} — sending unsigned`);
|
|
162
|
+
console.error(`[foldrun] notify: signing_secret names ${signingSecretShown(config.signingSecret)}, which is not in the vault for ${tenant}/${workspace} — sending unsigned`);
|
|
150
163
|
return {};
|
|
151
164
|
}
|
|
152
165
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
@@ -404,7 +417,7 @@ export async function sendPlainNotificationOutcome(tenant, workspace, msg) {
|
|
|
404
417
|
};
|
|
405
418
|
try {
|
|
406
419
|
if (config.email) {
|
|
407
|
-
const mail = notifyMail(tenant);
|
|
420
|
+
const mail = notifyMail(tenant, workspace);
|
|
408
421
|
if (!mail)
|
|
409
422
|
return no;
|
|
410
423
|
const r = await sendMail({
|
|
@@ -455,7 +468,7 @@ export async function sendTestNotification(tenant, workspace) {
|
|
|
455
468
|
const headline = "\u2713 test notification";
|
|
456
469
|
const detail = `Sent from ${workspace} at ${sent}. If you are reading this, failures and approval gates will reach you here too.`;
|
|
457
470
|
if (config.email) {
|
|
458
|
-
const mail = notifyMail(tenant);
|
|
471
|
+
const mail = notifyMail(tenant, workspace);
|
|
459
472
|
if (!mail) {
|
|
460
473
|
return {
|
|
461
474
|
ok: false,
|
|
@@ -493,7 +506,7 @@ export async function sendTestNotification(tenant, workspace) {
|
|
|
493
506
|
? {
|
|
494
507
|
ok: true,
|
|
495
508
|
destination: shown,
|
|
496
|
-
detail: `${config.signingSecret ? "accepted, signed with " + config.signingSecret : "accepted"} (delivery ${r.deliveryId})`,
|
|
509
|
+
detail: `${config.signingSecret ? "accepted, signed with " + signingSecretShown(config.signingSecret) : "accepted"} (delivery ${r.deliveryId})`,
|
|
497
510
|
}
|
|
498
511
|
: { ok: false, destination: shown, detail: `${r.statusCode ? `HTTP ${r.statusCode} — ` : ""}${r.detail.replace(/\s+/g, " ").slice(0, 400)}` };
|
|
499
512
|
}
|
|
@@ -579,7 +592,7 @@ export async function sendRunNotification(tenant, workspace, run) {
|
|
|
579
592
|
};
|
|
580
593
|
try {
|
|
581
594
|
if (config.email) {
|
|
582
|
-
const mail = notifyMail(tenant);
|
|
595
|
+
const mail = notifyMail(tenant, workspace);
|
|
583
596
|
if (!mail) {
|
|
584
597
|
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`);
|
|
585
598
|
return false;
|
package/dist/src/package.d.ts
CHANGED
|
@@ -20,7 +20,11 @@ export interface Package {
|
|
|
20
20
|
/** An error the API layer turns into a status rather than a 500. */
|
|
21
21
|
export declare class PackageError extends Error {
|
|
22
22
|
readonly status: number;
|
|
23
|
-
|
|
23
|
+
/** `cause`: the error underneath a 500, so an HTTP caller can log it and
|
|
24
|
+
* answer without it — a Node error names the server's paths (30 Oct 2026). */
|
|
25
|
+
constructor(message: string, status: number, opts?: {
|
|
26
|
+
cause?: unknown;
|
|
27
|
+
});
|
|
24
28
|
}
|
|
25
29
|
export declare const MANIFEST_FILE = "foldrun-package.json";
|
|
26
30
|
export declare function isPackagedPath(rel: string): boolean;
|
package/dist/src/package.js
CHANGED
|
@@ -37,8 +37,10 @@ import { zip, unzip, ZipError } from "./zip.js";
|
|
|
37
37
|
/** An error the API layer turns into a status rather than a 500. */
|
|
38
38
|
export class PackageError extends Error {
|
|
39
39
|
status;
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
/** `cause`: the error underneath a 500, so an HTTP caller can log it and
|
|
41
|
+
* answer without it — a Node error names the server's paths (30 Oct 2026). */
|
|
42
|
+
constructor(message, status, opts) {
|
|
43
|
+
super(message, opts);
|
|
42
44
|
this.status = status;
|
|
43
45
|
}
|
|
44
46
|
}
|
|
@@ -371,7 +373,7 @@ export function applyImport(tenant, workspace, pkg, opts = {}) {
|
|
|
371
373
|
// best effort — the error below is the one to report
|
|
372
374
|
}
|
|
373
375
|
}
|
|
374
|
-
throw new PackageError(`the import could not be written, and nothing was kept: ${err instanceof Error ? err.message : String(err)}`, 500);
|
|
376
|
+
throw new PackageError(`the import could not be written, and nothing was kept: ${err instanceof Error ? err.message : String(err)}`, 500, { cause: err });
|
|
375
377
|
}
|
|
376
378
|
for (const c of changes) {
|
|
377
379
|
const abs = path.join(dir, c.path);
|
package/dist/src/runner.js
CHANGED
|
@@ -4804,15 +4804,18 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4804
4804
|
if (needsApproval.length) {
|
|
4805
4805
|
for (const step of needsApproval) {
|
|
4806
4806
|
step.status = "awaiting-approval";
|
|
4807
|
+
const t = new Date().toISOString();
|
|
4807
4808
|
step.events.push({
|
|
4808
|
-
t
|
|
4809
|
+
t,
|
|
4809
4810
|
type: "info",
|
|
4810
4811
|
// ask: is the same gate carrying a question — the answer
|
|
4811
4812
|
// arrives as the approval note and rides into the prompt.
|
|
4813
|
+
// An event URL is this step's and this park's alone (30 Oct
|
|
4814
|
+
// 2026): the index and this line's `t` are in its token.
|
|
4812
4815
|
text: step.ask
|
|
4813
4816
|
? `waiting for an answer: ${step.ask}`
|
|
4814
4817
|
: step.waitFor === "event"
|
|
4815
|
-
? `waiting for an external event — release it with POST ${eventUrl(tenant, workspace, run.id)}`
|
|
4818
|
+
? `waiting for an external event — release it with POST ${eventUrl(tenant, workspace, run.id, { step: run.steps.indexOf(step), at: t })}`
|
|
4816
4819
|
: "waiting for approval before running",
|
|
4817
4820
|
});
|
|
4818
4821
|
}
|
package/dist/src/scan.js
CHANGED
|
@@ -202,6 +202,27 @@ export const SCAN_RULES = [
|
|
|
202
202
|
title: "A required frontmatter field is missing",
|
|
203
203
|
help: "A skill with no description is never loaded: the model chooses skills by their descriptions.",
|
|
204
204
|
},
|
|
205
|
+
{
|
|
206
|
+
// 30 Oct 2026: check and the scheduler read a schedule with the same
|
|
207
|
+
// cronCanFire (core cron.ts), so check says what the scheduler does.
|
|
208
|
+
id: "config/cron-never-fires",
|
|
209
|
+
category: "config",
|
|
210
|
+
level: "warn",
|
|
211
|
+
title: "A schedule that names a day no month has",
|
|
212
|
+
help: "`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 " +
|
|
213
|
+
"scheduler never lists a next run for it. Pick a day every named month has, or add a weekday (either then matches).",
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
// 30 Oct 2026: a signing secret is looked up in the vault by name.
|
|
217
|
+
id: "secret/signing-secret-value",
|
|
218
|
+
category: "secret",
|
|
219
|
+
level: "warn",
|
|
220
|
+
title: "A signing_secret: that holds a value, not a secret's name",
|
|
221
|
+
help: "A flow's `signing_secret:` and `notify: signing_secret:` name a vault entry (`WEBHOOK_SIGNING_KEY`, or `${WEBHOOK_SIGNING_KEY}`). " +
|
|
222
|
+
"Anything else is looked up as a name and never found — every signed delivery is refused, every notification goes unsigned — " +
|
|
223
|
+
"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.",
|
|
224
|
+
owasp: ["ASI03"],
|
|
225
|
+
},
|
|
205
226
|
{
|
|
206
227
|
id: "check/ignore-needs-reason",
|
|
207
228
|
category: "config",
|