@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.
Files changed (47) hide show
  1. package/dist/src/approvals.d.ts +18 -10
  2. package/dist/src/approvals.js +51 -1
  3. package/dist/src/catalog.d.ts +14 -0
  4. package/dist/src/catalog.js +28 -0
  5. package/dist/src/cron.d.ts +9 -0
  6. package/dist/src/cron.js +20 -0
  7. package/dist/src/frontmatter-fields.js +1 -1
  8. package/dist/src/frontmatter-schema.d.ts +5 -1
  9. package/dist/src/frontmatter-schema.js +40 -1
  10. package/dist/src/history.d.ts +5 -0
  11. package/dist/src/history.js +31 -4
  12. package/dist/src/hooks.d.ts +2 -0
  13. package/dist/src/hooks.js +13 -0
  14. package/dist/src/library.js +4 -0
  15. package/dist/src/notify.d.ts +21 -4
  16. package/dist/src/notify.js +105 -22
  17. package/dist/src/package.d.ts +5 -1
  18. package/dist/src/package.js +5 -3
  19. package/dist/src/runner.js +17 -9
  20. package/dist/src/scan.js +21 -0
  21. package/dist/src/secrets.d.ts +92 -10
  22. package/dist/src/secrets.js +258 -28
  23. package/dist/src/storage.d.ts +76 -4
  24. package/dist/src/storage.js +219 -22
  25. package/dist/src/store.d.ts +20 -2
  26. package/dist/src/store.js +50 -15
  27. package/dist/src/triggers.js +2 -2
  28. package/dist/src/webhook.d.ts +19 -3
  29. package/dist/src/webhook.js +19 -6
  30. package/package.json +1 -1
  31. package/src/approvals.ts +55 -1
  32. package/src/catalog.ts +27 -0
  33. package/src/cron.ts +19 -0
  34. package/src/frontmatter-fields.ts +1 -1
  35. package/src/frontmatter-schema.ts +38 -2
  36. package/src/history.ts +31 -4
  37. package/src/hooks.ts +14 -0
  38. package/src/library.ts +3 -0
  39. package/src/notify.ts +104 -23
  40. package/src/package.ts +5 -3
  41. package/src/runner.ts +17 -8
  42. package/src/scan.ts +23 -0
  43. package/src/secrets.ts +283 -34
  44. package/src/storage.ts +223 -19
  45. package/src/store.ts +49 -14
  46. package/src/triggers.ts +2 -2
  47. package/src/webhook.ts +26 -6
@@ -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
- * Release a run parked on `wait: event` with what arrived. The payload is
131
- * stored on the step — not as an approval note, which is a person's
132
- * guidance and capped as such — and the release itself is an approval
133
- * "by an external event", so the record reads the same way whichever door
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
- * Only steps waiting on an event are released: a run that is also asking a
137
- * person a question keeps asking.
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
- * `by` names who delivered it when that is known — a signed-in person or a
140
- * key through the dashboard's approve route — and `step` releases one
141
- * waiting step rather than all of them.
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;
@@ -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)
@@ -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;
@@ -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/");
@@ -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);
@@ -35,6 +35,11 @@ export interface RevisionSummary {
35
35
  commit: string | null;
36
36
  paths: string[];
37
37
  }
38
+ /** Refuse (400) a deploy's `commit` that cannot be a revision id. A writer
39
+ * calls this BEFORE it writes: checked only in recordRevision, after the
40
+ * files were replaced, a bad commit left the new source live with no
41
+ * revision, no snapshot and no audit entry (30 Oct 2026). */
42
+ export declare function assertCommitId(commit: string): void;
38
43
  /** A local revision id: sortable by time, unique enough, visibly not a sha. */
39
44
  export declare function newRevisionId(now?: Date): string;
40
45
  /**
@@ -55,6 +55,15 @@ const historyDir = (tenant, scope) => {
55
55
  assertSafeName(scope, "workspace");
56
56
  return path.join(dataRoot(), tenant, ".history", scope);
57
57
  };
58
+ /** Refuse (400) a deploy's `commit` that cannot be a revision id. A writer
59
+ * calls this BEFORE it writes: checked only in recordRevision, after the
60
+ * files were replaced, a bad commit left the new source live with no
61
+ * revision, no snapshot and no audit entry (30 Oct 2026). */
62
+ export function assertCommitId(commit) {
63
+ if (typeof commit !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._@-]{0,120}$/.test(commit)) {
64
+ 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 });
65
+ }
66
+ }
58
67
  /** A local revision id: sortable by time, unique enough, visibly not a sha. */
59
68
  export function newRevisionId(now = new Date()) {
60
69
  return `r-${now.getTime().toString(36)}-${crypto.randomBytes(3).toString("hex")}`;
@@ -72,8 +81,17 @@ export function recordRevision(tenant, scope, files, meta = {}) {
72
81
  return null;
73
82
  const dir = historyDir(tenant, scope);
74
83
  fs.mkdirSync(dir, { recursive: true });
84
+ // A commit is the revision's id only the first time it is recorded. A
85
+ // second deploy of one commit (a CI re-run after a dashboard edit), or a
86
+ // `commit` that names a revision already here, overwrote that revision's
87
+ // record and its snapshot (30 Oct 2026) — History could no longer restore
88
+ // to it. It gets its own id now; `commit` still says which one it was,
89
+ // and a restore by commit (resolveRevision) still finds it.
90
+ if (meta.commit)
91
+ assertCommitId(meta.commit);
92
+ const commitId = meta.commit && !fs.existsSync(path.join(dir, `${meta.commit}.json`)) ? meta.commit : null;
75
93
  const rev = {
76
- id: meta.id ?? meta.commit ?? newRevisionId(),
94
+ id: meta.id ?? commitId ?? newRevisionId(),
77
95
  at: new Date().toISOString(),
78
96
  by: meta.by ?? "system",
79
97
  message: meta.message ?? describe(changed),
@@ -252,7 +270,11 @@ export function resolveRevision(tenant, scope, to, now = Date.now()) {
252
270
  else {
253
271
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/.test(t))
254
272
  throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
255
- hit = all.find((r) => r.id === t || r.commit === t || (t.length >= 7 && r.commit?.startsWith(t)));
273
+ // An exact id first, then a commit: a commit deployed twice is recorded
274
+ // once under its own id and again under a fresh one naming it (30 Oct
275
+ // 2026, recordRevision), and newest-first with one predicate answered
276
+ // the first revision's id with the second's snapshot.
277
+ hit = all.find((r) => r.id === t) ?? all.find((r) => r.commit === t || (t.length >= 7 && r.commit?.startsWith(t)));
256
278
  }
257
279
  return hit ? { id: hit.id, at: hit.at, by: hit.by, message: hit.message } : null;
258
280
  }
@@ -353,8 +375,13 @@ export function changedPathsBetween(tenant, scope, from, to) {
353
375
  if (from === to)
354
376
  return [];
355
377
  const revs = listRevisions(tenant, scope, { limit: 500 }); // newest first
356
- const ti = revs.findIndex((r) => r.id === to || r.commit === to);
357
- const fi = revs.findIndex((r) => r.id === from || r.commit === from);
378
+ // An exact id before a commit, as resolveRevision.
379
+ const at = (x) => {
380
+ const byId = revs.findIndex((r) => r.id === x);
381
+ return byId >= 0 ? byId : revs.findIndex((r) => r.commit === x);
382
+ };
383
+ const ti = at(to);
384
+ const fi = at(from);
358
385
  if (ti < 0 || fi < 0 || ti > fi)
359
386
  return null;
360
387
  return [...new Set(revs.slice(ti, fi).flatMap((r) => r.paths))].sort();
@@ -6,6 +6,8 @@ export declare const HOOK_TIMEOUT_DEFAULT: {
6
6
  };
7
7
  export declare const HOOK_TIMEOUT_MAX = 120;
8
8
  export declare const HOOK_CODE_MAX: number;
9
+ /** A hook's name: its folder's name, never a path. */
10
+ export declare function isHookName(name: unknown): name is string;
9
11
  export interface HookDef {
10
12
  name: string;
11
13
  /** The folder holding hook.md and run.mjs. */
package/dist/src/hooks.js CHANGED
@@ -38,6 +38,10 @@ export const HOOK_TIMEOUT_DEFAULT = { gate: 30, watch: 60 };
38
38
  export const HOOK_TIMEOUT_MAX = 120;
39
39
  export const HOOK_CODE_MAX = 100 * 1024;
40
40
  const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
41
+ /** A hook's name: its folder's name, never a path. */
42
+ export function isHookName(name) {
43
+ return typeof name === "string" && NAME.test(name);
44
+ }
41
45
  /** Read hook.md, or say in one sentence why it is not a hook. */
42
46
  export function readHookDef(dir, scope) {
43
47
  const name = path.basename(dir);
@@ -117,6 +121,15 @@ export function resolveHooks(args) {
117
121
  if (seen.has(name))
118
122
  continue;
119
123
  seen.add(name);
124
+ // Checked before the name is joined to anything (30 Oct 2026): readHookDef
125
+ // tested only basename(dir), so `../../<tenant>/workspaces/<ws>/hooks/<h>`
126
+ // passed, and the hook test route answered which folders exist anywhere
127
+ // on the server — on a laptop it would have run that run.mjs. An error,
128
+ // not missing: a step refuses either way, and the name is the problem.
129
+ if (!isHookName(name)) {
130
+ errors.push(`"${name}" is not a hook name — lowercase letters, digits and dashes (the hook's folder name)`);
131
+ continue;
132
+ }
120
133
  const own = path.join(args.workspaceRoot, "hooks", name);
121
134
  const shared = path.join(args.libraryRoot, "hooks", name);
122
135
  const dir = fs.existsSync(own) ? own : fs.existsSync(shared) ? shared : null;
@@ -234,6 +234,10 @@ export function deleteLibraryPath(tenant, kind, rel, meta = {}) {
234
234
  const norm = path.normalize(rel);
235
235
  if (norm.startsWith("..") || path.isAbsolute(norm))
236
236
  throw new Error(`illegal path: ${rel}`);
237
+ // "." (or "./", "a/..") is the shelf itself: one call took every shared
238
+ // tool or skill from every workspace (30 Oct 2026). Name what goes.
239
+ if (norm === "." || norm === "./")
240
+ throw new Error(`illegal path: ${rel} — name the file or folder to delete, not the whole ${kind} shelf`);
237
241
  const target = path.join(libraryDir(tenant, kind), norm);
238
242
  const gone = [];
239
243
  if (fs.existsSync(target)) {
@@ -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
- export declare function accountMail(tenant: string): {
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;
@@ -78,10 +81,24 @@ export declare function nextAttemptAt(n: number, at: number): number | null;
78
81
  * delivery, redeliveries included, so a receiver dedupes on it. */
79
82
  export declare function newDeliveryId(): string;
80
83
  /** The URL with ${SECRET}s resolved, and how it may be shown: a Slack hook
81
- * URL is itself a credential, so it is never echoed resolved. */
84
+ * URL is itself a credential, so it is never echoed resolved.
85
+ *
86
+ * A secret bound to sites (`foldrun secrets sites`) is filled only when the
87
+ * resolved URL's host is one of them — the rule the egress proxy and the
88
+ * tool tester keep (30 Oct 2026). Before, notify.url filled any secret into
89
+ * any host, so an editor could write `https://their.server/?k=${STRIPE_KEY}`
90
+ * and press Test. The host is read AFTER filling, because the usual shape is
91
+ * the whole URL as one secret (`${SLACK_WEBHOOK_URL}`, bound to
92
+ * hooks.slack.com). An unbound secret fills as it does for an agent's
93
+ * `secrets:` and a tool test — workspace first, then the account — since
94
+ * binding is how an account says where a value may go. `refused` says why
95
+ * `url` is null when a binding stopped it; `filled` maps each value put
96
+ * into the URL to its name, so an error that echoes the URL can be scrubbed. */
82
97
  export declare function webhookTarget(tenant: string, workspace: string, config?: NotifyConfig | null): {
83
98
  url: string | null;
84
99
  shown: string;
100
+ refused?: string;
101
+ filled: Record<string, string>;
85
102
  };
86
103
  export interface WebhookAttempt {
87
104
  /** 1-based. */