@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.
@@ -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) {
@@ -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
- export declare function eventUrl(tenant: string, workspace: string, runId: string): string;
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
@@ -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
- export function eventUrl(tenant, workspace, runId) {
94
- const p = `/api/events/${tenant}/${workspace}/${runId}?token=${eventToken(tenant, workspace, runId)}`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "9.0.0",
3
+ "version": "9.1.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
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
- export function accountMail(tenant: string): { key: string; from: string } | null {
68
- const own = getSecret(tenant, "RESEND_API_KEY");
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
- constructor(message: string, status: number) {
78
- super(message);
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: new Date().toISOString(),
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",