@foldrun/core 9.0.0 → 9.2.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 (65) hide show
  1. package/dist/src/a2a-client.d.ts +165 -0
  2. package/dist/src/a2a-client.js +440 -0
  3. package/dist/src/a2a-publish.d.ts +37 -0
  4. package/dist/src/a2a-publish.js +116 -0
  5. package/dist/src/a2a-remotes.d.ts +73 -0
  6. package/dist/src/a2a-remotes.js +192 -0
  7. package/dist/src/a2a-tools.d.ts +148 -0
  8. package/dist/src/a2a-tools.js +833 -0
  9. package/dist/src/api-tools.js +15 -10
  10. package/dist/src/approvals.d.ts +18 -10
  11. package/dist/src/approvals.js +51 -1
  12. package/dist/src/browser-pod.js +4 -0
  13. package/dist/src/catalog.d.ts +14 -0
  14. package/dist/src/catalog.js +28 -0
  15. package/dist/src/completions.js +8 -8
  16. package/dist/src/cron.d.ts +9 -0
  17. package/dist/src/cron.js +20 -0
  18. package/dist/src/frontmatter-fields.js +3 -1
  19. package/dist/src/frontmatter-schema.d.ts +5 -1
  20. package/dist/src/frontmatter-schema.js +44 -1
  21. package/dist/src/limits.js +22 -4
  22. package/dist/src/notify.d.ts +6 -3
  23. package/dist/src/notify.js +25 -12
  24. package/dist/src/package.d.ts +5 -1
  25. package/dist/src/package.js +5 -3
  26. package/dist/src/run-container.d.ts +10 -1
  27. package/dist/src/run-container.js +17 -0
  28. package/dist/src/runner.d.ts +10 -0
  29. package/dist/src/runner.js +163 -16
  30. package/dist/src/scan.js +123 -5
  31. package/dist/src/secrets.d.ts +122 -10
  32. package/dist/src/secrets.js +291 -27
  33. package/dist/src/starter.js +5 -1
  34. package/dist/src/store.d.ts +34 -0
  35. package/dist/src/store.js +28 -7
  36. package/dist/src/subagents.d.ts +16 -0
  37. package/dist/src/subagents.js +23 -2
  38. package/dist/src/triggers.js +2 -2
  39. package/dist/src/webhook.d.ts +19 -3
  40. package/dist/src/webhook.js +14 -5
  41. package/package.json +4 -1
  42. package/src/a2a-client.ts +517 -0
  43. package/src/a2a-publish.ts +120 -0
  44. package/src/a2a-remotes.ts +225 -0
  45. package/src/a2a-tools.ts +919 -0
  46. package/src/api-tools.ts +20 -8
  47. package/src/approvals.ts +55 -1
  48. package/src/browser-pod.ts +3 -0
  49. package/src/catalog.ts +27 -0
  50. package/src/completions.ts +8 -8
  51. package/src/cron.ts +19 -0
  52. package/src/frontmatter-fields.ts +3 -1
  53. package/src/frontmatter-schema.ts +42 -2
  54. package/src/limits.ts +22 -4
  55. package/src/notify.ts +23 -12
  56. package/src/package.ts +5 -3
  57. package/src/run-container.ts +22 -0
  58. package/src/runner.ts +157 -13
  59. package/src/scan.ts +146 -6
  60. package/src/secrets.ts +319 -33
  61. package/src/starter.ts +5 -1
  62. package/src/store.ts +47 -2
  63. package/src/subagents.ts +29 -2
  64. package/src/triggers.ts +2 -2
  65. package/src/webhook.ts +22 -5
package/src/api-tools.ts CHANGED
@@ -20,7 +20,12 @@ import { createSdkMcpServer, tool, type SdkMcpToolDefinition } from "@anthropic-
20
20
  import type { ApiSpec } from "./store.ts";
21
21
  import { previewOrKeep, RESULT_KEEP_CHARS, type KeepIn } from "./tool-output.ts";
22
22
  import type { OperationSpec } from "./openapi.ts";
23
- import { resolveSecrets, materializeSecrets } from "./secrets.ts";
23
+ import { resolveSecrets, materializeCurrent } from "./secrets.ts";
24
+
25
+ /** Whose vault the secrets came from, on a host step: the account (and
26
+ * workspace) they are made live in. Null inside a sandbox, where the values
27
+ * crossed already resolved and there is no vault. */
28
+ type VaultScope = { tenant: string; workspace?: string } | null;
24
29
 
25
30
  // Collected whole up to this; the model sees a preview and where the rest is
26
31
  // kept (tool-output.ts), not a cut with the rest gone.
@@ -145,6 +150,7 @@ async function performRequest(
145
150
  req: Request,
146
151
  /** Where a long body is kept, when the agent can read it back (tool-output.ts). */
147
152
  keepIn?: KeepIn,
153
+ scope: VaultScope = null,
148
154
  ): Promise<ToolReply> {
149
155
  const started = Date.now();
150
156
  try {
@@ -153,8 +159,11 @@ async function performRequest(
153
159
  // Per call, not per build: an oauth2 secret's recipe becomes a
154
160
  // live access token here, so a step that runs for an hour keeps
155
161
  // getting fresh tokens (the exchange is cached until near expiry)
156
- // — and a recipe can never end up inside a header.
157
- const live = await materializeSecrets(env);
162
+ // — and a recipe can never end up inside a header. On a host, the
163
+ // vault's recipe as it is now, made live in the account's scope: a
164
+ // rotating refresh token the runner (or an earlier call) spent is never
165
+ // sent again (secrets.ts materializeCurrent).
166
+ const live = await materializeCurrent(env, scope);
158
167
  const path = req.path.startsWith("/") ? req.path : `/${req.path}`;
159
168
  const url = new URL(api.base + path);
160
169
  // Base-URL confinement: a path can't escape the declared host.
@@ -243,6 +252,7 @@ function typedTool(
243
252
  bucket: TokenBucket | null,
244
253
  log: string[],
245
254
  keepIn?: KeepIn,
255
+ scope: VaultScope = null,
246
256
  ) {
247
257
  const keys = argKeys(op);
248
258
  const shape: Record<string, z.ZodTypeAny> = {};
@@ -292,11 +302,11 @@ function typedTool(
292
302
  query,
293
303
  headers,
294
304
  body: typeof values.body === "string" ? values.body : undefined,
295
- }, keepIn);
305
+ }, keepIn, scope);
296
306
  });
297
307
  }
298
308
 
299
- function genericTool(api: ApiSpec, env: Record<string, string>, bucket: TokenBucket | null, log: string[], keepIn?: KeepIn) {
309
+ function genericTool(api: ApiSpec, env: Record<string, string>, bucket: TokenBucket | null, log: string[], keepIn?: KeepIn, scope: VaultScope = null) {
300
310
  const typed = api.resolvedOperations?.length
301
311
  ? ` Prefer the typed ${api.name}_* tools; this one is the escape hatch for operations they do not cover.`
302
312
  : "";
@@ -321,7 +331,7 @@ function genericTool(api: ApiSpec, env: Record<string, string>, bucket: TokenBuc
321
331
  query: args.query ?? {},
322
332
  headers: {},
323
333
  body: args.body,
324
- }, keepIn),
334
+ }, keepIn, scope),
325
335
  );
326
336
  }
327
337
 
@@ -351,6 +361,8 @@ export function buildApiTools(
351
361
  missing: resolved.missing,
352
362
  }
353
363
  : resolveSecrets(tenant, neededSecrets, workspace);
364
+ // Read from the vault here: made live per call in its account's scope.
365
+ const scope: VaultScope = !resolved && tenant ? { tenant, ...(workspace ? { workspace } : {}) } : null;
354
366
  const log: string[] = [];
355
367
 
356
368
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the SDK types its own list this way
@@ -393,11 +405,11 @@ export function buildApiTools(
393
405
 
394
406
  const typedNames: string[] = [];
395
407
  for (const op of ops) {
396
- tools.push(typedTool(api, op, env, bucket, log, keepIn));
408
+ tools.push(typedTool(api, op, env, bucket, log, keepIn, scope));
397
409
  typedNames.push(typedToolName(api.name, op.id));
398
410
  methods[`mcp__foldrun_apis__${typedToolName(api.name, op.id)}`] = String(op.method ?? "").toUpperCase();
399
411
  }
400
- if (withGeneric) tools.push(genericTool(api, env, bucket, log, keepIn));
412
+ if (withGeneric) tools.push(genericTool(api, env, bucket, log, keepIn, scope));
401
413
 
402
414
  const names = [...typedNames, ...(withGeneric ? [`call_${api.name}`] : [])];
403
415
  for (const n of names) {
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,
@@ -162,6 +162,9 @@ export function classifyCall(tool: string, input: Record<string, unknown> | unde
162
162
  // tools: [peers] — reading the roster changes nothing; a message to a
163
163
  // colleague is said, and a re-run would say it twice.
164
164
  if (server === "foldrun_peers") return name === "peers_list" ? { write: false } : write("a message to a colleague");
165
+ // remotes: — reading where a request got to changes nothing; a send, a
166
+ // reply or a cancel acts on an outside agent's system.
167
+ if (server === "foldrun_a2a") return name === "a2a_status" ? { write: false } : write(`${name.replace(/^a2a_/, "")} to an outside agent`);
165
168
  if (server === "foldrun_scripts") {
166
169
  if (name !== "web") return write(`script ${name}`);
167
170
  const action = String(args.action ?? "").trim().toLowerCase();
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/");
@@ -394,9 +394,9 @@ export function completionsAt(
394
394
  // that field's files and nothing else. `[[search]]` in tools: is how a
395
395
  // tool named after a runtime group gets granted; offering the group
396
396
  // here would defeat the point of the brackets.
397
- const inlineOwner = line.match(/^\s*([a-zA-Z_]+):\s*(?:\[.*)?\[\[[^\]]*$/)?.[1];
397
+ const inlineOwner = line.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*):\s*(?:\[.*)?\[\[[^\]]*$/)?.[1];
398
398
  const blockOwner = /^\s+-\s*\[\[/.test(line)
399
- ? before.slice(0, lineStart).split("\n").reverse().find((l) => /^\s*[a-zA-Z_]+:\s*$/.test(l))?.trim().replace(":", "")
399
+ ? before.slice(0, lineStart).split("\n").reverse().find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))?.trim().replace(":", "")
400
400
  : undefined;
401
401
  const owner = inlineOwner ?? blockOwner;
402
402
  const byField: Record<string, Completion[]> = {
@@ -460,7 +460,7 @@ export function completionsAt(
460
460
  // been chosen yet, one at a time. Fixed combinations were the wrong
461
461
  // model: nobody wants a menu of every permutation, they want to add the
462
462
  // next verb.
463
- const inlineList = line.match(/^(\s*)([a-zA-Z_]+):\s*\[([^\]]*)$/);
463
+ const inlineList = line.match(/^(\s*)([a-zA-Z_][a-zA-Z0-9_]*):\s*\[([^\]]*)$/);
464
464
  if (inlineList) {
465
465
  const [, , key, inside] = inlineList;
466
466
  // The inline and block forms of a list are the same field — `tools: [a, b]`
@@ -505,7 +505,7 @@ export function completionsAt(
505
505
  }
506
506
 
507
507
  // 4. `key: value` — the value side of a known field.
508
- const kv = line.match(/^(\s*)([a-zA-Z_]+):\s*(\S*)$/);
508
+ const kv = line.match(/^(\s*)([a-zA-Z_][a-zA-Z0-9_]*):\s*(\S*)$/);
509
509
  if (kv) {
510
510
  const [, , key, partial] = kv;
511
511
  const from = cursor - partial.length;
@@ -587,7 +587,7 @@ export function completionsAt(
587
587
  const partial = listItem[2];
588
588
  const from = cursor - partial.length;
589
589
  const priorLines = before.slice(0, lineStart).split("\n").reverse();
590
- const owner = priorLines.find((l) => /^\s*[a-zA-Z_]+:\s*$/.test(l))?.trim().replace(":", "");
590
+ const owner = priorLines.find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))?.trim().replace(":", "");
591
591
  const byOwner: Record<string, Completion[]> = {
592
592
  // One list now: built-ins and your own tools are granted the same way.
593
593
  // Groups first (they cover most needs), then your own tools — the ones
@@ -629,7 +629,7 @@ export function completionsAt(
629
629
  const partial = mapping[2] ?? "";
630
630
  const priorLines = before.slice(0, lineStart).split("\n").reverse();
631
631
  const owner = priorLines
632
- .find((l) => /^\s*[a-zA-Z_]+:\s*$/.test(l))
632
+ .find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))
633
633
  ?.trim()
634
634
  .replace(":", "");
635
635
  const byOwner: Record<string, Completion[]> = {
@@ -666,7 +666,7 @@ export function completionsAt(
666
666
  // kind of file — the block takes nothing else.
667
667
  const fmClose = text.indexOf("\n---", text.indexOf("---") + 3);
668
668
  const insideFrontmatter = text.startsWith("---") && (fmClose === -1 || cursor <= fmClose);
669
- const nested = line.match(/^(\s+)([a-zA-Z_]*)$/);
669
+ const nested = line.match(/^(\s+)([a-zA-Z_][a-zA-Z0-9_]*|)$/);
670
670
  if (insideFrontmatter && nested) {
671
671
  const header = before.slice(0, lineStart).split("\n").reverse().find((l) => /^\S/.test(l));
672
672
  const job = JOBS.find((j) => new RegExp(`^${j.name}\\s*:\\s*$`).test(header ?? ""));
@@ -699,7 +699,7 @@ export function completionsAt(
699
699
  // this kind of file. This is where you learn the format by writing it.
700
700
  const fmEnd = text.indexOf("\n---", text.indexOf("---") + 3);
701
701
  const inFrontmatter = text.startsWith("---") && (fmEnd === -1 || cursor <= fmEnd);
702
- if (inFrontmatter && /^[a-zA-Z_][a-zA-Z_-]*$|^$/.test(line)) {
702
+ if (inFrontmatter && /^[a-zA-Z_][a-zA-Z0-9_-]*$|^$/.test(line)) {
703
703
  const from = cursor - line.length;
704
704
  return { from, query: line, items: filter(fieldsFor(path, kind), line), title: `${kind} fields` };
705
705
  }
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
+ }
@@ -88,6 +88,7 @@ const AGENT: FieldSpec[] = [
88
88
  { key: "scripts", type: "list", help: "programs in scripts/, each a tool" },
89
89
  { key: "apis", type: "list", help: "an HTTP API as a tool" },
90
90
  { key: "mcpServers", type: "object", help: "MCP servers, name → {command | url, args, env, headers}" },
91
+ { key: "remotes", type: "list", help: "outside A2A agents it may call — [{name, card: the https URL of its agent card, secret?: a vault NAME sent as a bearer token, description?}]" },
91
92
  { key: "agents", type: ["list", "string"], help: "colleagues it may consult" },
92
93
  { key: "subagents", type: ["list", "string"], help: "colleagues it may delegate to" },
93
94
  { key: "sandbox", type: "string", values: ["own", "parent"], help: "when delegated to as a sub-agent: own = its own sandbox, tools, secrets and network; parent (default) = inside the delegating step's" },
@@ -156,7 +157,7 @@ const FLOW: FieldSpec[] = [
156
157
  { key: "catchup", type: "string", validated: true, help: "last | none" },
157
158
  { key: "on", type: "string", validated: true, help: "trigger: flow — completed | failed | blocked | any" },
158
159
  { 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" },
160
+ { key: "signing_secret", type: "string", help: "the NAME of the vault secret a webhook's signature is checked with — never the value" },
160
161
  { key: "after", type: ["string", "list"], help: "trigger: flow — the flow this one follows" },
161
162
  { key: "at", type: "string", help: "trigger: once — when" },
162
163
  { key: "url", type: "string", help: "trigger: watch — the page watched" },
@@ -174,6 +175,7 @@ const FLOW: FieldSpec[] = [
174
175
  { key: "budget", type: ["number", "string"], help: "USD per run" },
175
176
  { key: "compute", type: "object", help: "{vcpu, memory, disk} one run's steps may hold together at once — only narrows the workspace's and account's" },
176
177
  { key: "live", type: "boolean", help: "true: steps may act for real in a test run" },
178
+ { key: "a2a", type: "object", help: "serve this flow to outside agents over A2A 1.0 — {publish: true, name?, description?, examples?, public_card?, input?}; changing it needs workspace:manage" },
177
179
  ];
178
180
 
179
181
  const TOOL: FieldSpec[] = [
@@ -6,9 +6,12 @@ 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";
13
+ import { remotesProblem } from "./a2a-remotes.ts";
14
+ import { a2aPublishProblem } from "./a2a-publish.ts";
12
15
 
13
16
  export { FRONTMATTER, schemaKindOf, type FieldSpec, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
14
17
 
@@ -32,6 +35,7 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
32
35
  "agent": {
33
36
  effort: (v) => (resolveEffort(v) ? null : `"${String(v)}" is not an effort — low, medium, high, xhigh or max; the step runs at the model's default`),
34
37
  apis: apisProblem,
38
+ remotes: remotesProblem,
35
39
  budget: (v) => budgetProblem(v, ["run"]),
36
40
  ask: (v) => askProblem(v),
37
41
  resources: resourcesProblem,
@@ -56,6 +60,7 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
56
60
  pause_when: (v) => (String(v).length <= 60 ? null : "is longer than 60 characters"),
57
61
  budget: (v) => budgetProblem(v, ["run"]),
58
62
  compute: computeProblem,
63
+ a2a: a2aPublishProblem,
59
64
  },
60
65
  "tool": {
61
66
  transport: (v) => (["http", "script", "mcp"].includes(String(v).toLowerCase()) ? null : `"${String(v)}" is not a transport — http, script or mcp`),
@@ -70,7 +75,7 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
70
75
 
71
76
  export interface FieldProblem {
72
77
  key: string;
73
- rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field" | "config/conflict" | "config/misplaced";
78
+ 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
79
  level: "error" | "warn" | "info";
75
80
  message: string;
76
81
  }
@@ -172,12 +177,47 @@ export function frontmatterProblems(
172
177
  : null;
173
178
  if (why) out.push({ key, rule: "config/invalid-value", level: "warn", message: `\`${key}:\` ${why.replace(/^\w+: /, "")}` });
174
179
  }
180
+ // A cron every field of which is in range, on a day no month has —
181
+ // `0 0 30 2 *`. The scheduler's own cronCanFire decides (30 Oct 2026), so
182
+ // check and the scheduler say the same about it: it never fires.
183
+ if (kind === "flow" && typeof data.schedule === "string") {
184
+ const cron = parseCron(data.schedule);
185
+ if (cron && !cronCanFire(cron)) {
186
+ 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` });
187
+ }
188
+ }
189
+ // A signing secret is the NAME of a vault entry (30 Oct 2026): a flow's
190
+ // `signing_secret:` and notify's `signing_secret:` are looked up with
191
+ // getSecret, so a literal value there is a secret written into a
192
+ // versioned, deployed file — and never matches anything in the vault.
193
+ // The message never repeats the value.
194
+ if (kind === "flow" && data.signing_secret !== undefined) {
195
+ const why = signingSecretProblem(data.signing_secret);
196
+ if (why) out.push({ key: "signing_secret", rule: "secret/signing-secret-value", level: "warn", message: `\`signing_secret:\` ${why}` });
197
+ }
198
+ if (kind === "agents-md" && data.notify && typeof data.notify === "object" && !Array.isArray(data.notify)) {
199
+ const raw = (data.notify as { signing_secret?: unknown }).signing_secret;
200
+ const why = raw === undefined ? null : signingSecretProblem(raw);
201
+ if (why) out.push({ key: "notify", rule: "secret/signing-secret-value", level: "warn", message: `\`notify: signing_secret:\` ${why}` });
202
+ }
175
203
  if (kind === "skill" && !(typeof data.description === "string" && data.description.trim())) {
176
204
  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
205
  }
178
206
  return out;
179
207
  }
180
208
 
209
+ /** Why a `signing_secret:` value is not a vault secret's name, or null. The
210
+ * runtime reads `NAME` and `${NAME}` alike (notify.ts, store.ts). Never
211
+ * says what the value is: it may be the secret itself. */
212
+ export function signingSecretProblem(v: unknown): string | null {
213
+ if (typeof v !== "string") return null; // config/wrong-type says so
214
+ const name = v.trim().replace(/^\$\{|\}$/g, "");
215
+ if (!name) return null;
216
+ return SECRET_NAME.test(name)
217
+ ? null
218
+ : "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";
219
+ }
220
+
181
221
  function askProblem(v: unknown): string | null {
182
222
  const t = (v as { timeout?: unknown } | null)?.timeout;
183
223
  return t === undefined ? null : DURATION(30, 86400, "30s to 24h")(t);
package/src/limits.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  // web.search: 40 # one action of the web tool
6
6
  // web: 150 # every web action together
7
7
  // crm: 20 # an API, script or MCP tool, by the name it is granted as
8
+ // a2a: 10 # messages to outside agents (a2a_send and a2a_reply)
9
+ // a2a.status: 30 # polls of them, apart from the sends
8
10
  // calls: 300 # every tool call in the step, whatever the tool
9
11
  // repeat: 5 # the same call — one tool, the same input — at most this often
10
12
  //
@@ -22,6 +24,7 @@
22
24
  import crypto from "node:crypto";
23
25
  import { WEB_ACTIONS } from "./providers.ts";
24
26
  import { DELEGATE_TOOLS } from "./subagents.ts";
27
+ import { A2A_SERVER, A2A_LIMIT_KEY, A2A_LIMIT_ACTIONS } from "./a2a-remotes.ts";
25
28
 
26
29
  export type Limits = Record<string, number>;
27
30
 
@@ -103,8 +106,13 @@ export function readLimits(raw: unknown, where = "limits"): { limits: Limits; pr
103
106
  }
104
107
  if (k.includes(".")) {
105
108
  const [head, action] = k.split(".");
106
- if (head !== "web" || !(WEB_ACTIONS as readonly string[]).includes(action)) {
107
- problems.push(`${where}: "${k}" is not a key — the dotted keys are web.<action>: ${WEB_ACTIONS.map((a) => `web.${a}`).join(", ")}.`);
109
+ const web = head === "web" && (WEB_ACTIONS as readonly string[]).includes(action);
110
+ const a2a = head === A2A_LIMIT_KEY && (A2A_LIMIT_ACTIONS as readonly string[]).includes(action);
111
+ if (!web && !a2a) {
112
+ problems.push(
113
+ `${where}: "${k}" is not a key — the dotted keys are web.<action>: ${WEB_ACTIONS.map((a) => `web.${a}`).join(", ")}, ` +
114
+ `and ${A2A_LIMIT_ACTIONS.map((a) => `${A2A_LIMIT_KEY}.${a}`).join(", ")}.`,
115
+ );
108
116
  continue;
109
117
  }
110
118
  }
@@ -166,6 +174,15 @@ export function limitKeysFor(toolName: string, input: Record<string, unknown> |
166
174
  if (toolName === "WebSearch") { webAction("search"); return keys; }
167
175
  if (toolName === "WebFetch") { webAction("fetch"); return keys; }
168
176
  if ((DELEGATE_TOOLS as readonly string[]).includes(toolName)) return keys;
177
+ // remotes: — a message to an outside agent (a2a_send, a2a_reply) counts
178
+ // under `a2a`; every tool also under its own `a2a.<action>`, so polling
179
+ // (`a2a.status`) is capped apart from sending.
180
+ const a2a = new RegExp(`^mcp__${A2A_SERVER}__a2a_([a-z]+)$`).exec(toolName);
181
+ if (a2a) {
182
+ if (a2a[1] === "send" || a2a[1] === "reply") keys.push(A2A_LIMIT_KEY);
183
+ keys.push(`${A2A_LIMIT_KEY}.${a2a[1]}`);
184
+ return keys;
185
+ }
169
186
  const owner = owners[toolName];
170
187
  const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
171
188
  const name = owner
@@ -274,7 +291,7 @@ export function limitKeyProblems(
274
291
  ctx: { known: Iterable<string>; granted: Iterable<string> },
275
292
  ): { level: "error" | "warn"; message: string }[] {
276
293
  const out: { level: "error" | "warn"; message: string }[] = [];
277
- const builtinKeys = ["read", "write", "code", ...Object.keys(BUILTIN_GROUP), "TodoWrite", "search", "history", "desks", "ask", "web"];
294
+ const builtinKeys = ["read", "write", "code", ...Object.keys(BUILTIN_GROUP), "TodoWrite", "search", "history", "desks", "ask", "web", A2A_LIMIT_KEY];
278
295
  const known = new Set([...builtinKeys, ...ctx.known].map(normKey));
279
296
  const granted = new Set([...ctx.granted].flatMap((g) => {
280
297
  const n = normKey(g);
@@ -287,10 +304,11 @@ export function limitKeyProblems(
287
304
  }).map(normKey));
288
305
  for (const key of Object.keys(limits)) {
289
306
  if (key === TOTAL_KEY || key === REPEAT_KEY) continue;
290
- const base = key.startsWith("web.") ? "web" : key;
307
+ const base = key.startsWith("web.") ? "web" : key.startsWith(`${A2A_LIMIT_KEY}.`) ? A2A_LIMIT_KEY : key;
291
308
  const n = normKey(base);
292
309
  if (!known.has(n)) {
293
310
  const valid = [TOTAL_KEY, "web", ...WEB_ACTIONS.map((a) => `web.${a}`), ...[...new Set([...ctx.granted].map(String))].filter((g) => g !== "web")];
311
+ if ([...ctx.granted].map(String).includes(A2A_LIMIT_KEY)) valid.push(...A2A_LIMIT_ACTIONS.map((a) => `${A2A_LIMIT_KEY}.${a}`));
294
312
  out.push({ level: "error", message: `limits: ${key} — not a tool this agent could call. The keys: ${[...new Set(valid)].join(", ")}.` });
295
313
  } else if (!granted.has(n)) {
296
314
  out.push({ level: "warn", message: `limits: ${key} — this agent is not granted ${base}, so the limit never applies` });
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);