@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
@@ -17,7 +17,7 @@ import { EGRESS_ENV, viaEgress } from "./egress.js";
17
17
  import { z } from "zod";
18
18
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
19
19
  import { previewOrKeep, RESULT_KEEP_CHARS } from "./tool-output.js";
20
- import { resolveSecrets, materializeSecrets } from "./secrets.js";
20
+ import { resolveSecrets, materializeCurrent } from "./secrets.js";
21
21
  // Collected whole up to this; the model sees a preview and where the rest is
22
22
  // kept (tool-output.ts), not a cut with the rest gone.
23
23
  const MAX_BODY_CHARS = RESULT_KEEP_CHARS;
@@ -97,7 +97,7 @@ export function typedToolName(apiName, opId) {
97
97
  */
98
98
  async function performRequest(api, env, bucket, log, req,
99
99
  /** Where a long body is kept, when the agent can read it back (tool-output.ts). */
100
- keepIn) {
100
+ keepIn, scope = null) {
101
101
  const started = Date.now();
102
102
  try {
103
103
  const waited = bucket ? await bucket.take() : 0;
@@ -105,8 +105,11 @@ keepIn) {
105
105
  // Per call, not per build: an oauth2 secret's recipe becomes a
106
106
  // live access token here, so a step that runs for an hour keeps
107
107
  // getting fresh tokens (the exchange is cached until near expiry)
108
- // — and a recipe can never end up inside a header.
109
- const live = await materializeSecrets(env);
108
+ // — and a recipe can never end up inside a header. On a host, the
109
+ // vault's recipe as it is now, made live in the account's scope: a
110
+ // rotating refresh token the runner (or an earlier call) spent is never
111
+ // sent again (secrets.ts materializeCurrent).
112
+ const live = await materializeCurrent(env, scope);
110
113
  const path = req.path.startsWith("/") ? req.path : `/${req.path}`;
111
114
  const url = new URL(api.base + path);
112
115
  // Base-URL confinement: a path can't escape the declared host.
@@ -183,7 +186,7 @@ function argKeys(op) {
183
186
  }
184
187
  return out;
185
188
  }
186
- function typedTool(api, op, env, bucket, log, keepIn) {
189
+ function typedTool(api, op, env, bucket, log, keepIn, scope = null) {
187
190
  const keys = argKeys(op);
188
191
  const shape = {};
189
192
  for (const [key, p] of keys)
@@ -235,10 +238,10 @@ function typedTool(api, op, env, bucket, log, keepIn) {
235
238
  query,
236
239
  headers,
237
240
  body: typeof values.body === "string" ? values.body : undefined,
238
- }, keepIn);
241
+ }, keepIn, scope);
239
242
  });
240
243
  }
241
- function genericTool(api, env, bucket, log, keepIn) {
244
+ function genericTool(api, env, bucket, log, keepIn, scope = null) {
242
245
  const typed = api.resolvedOperations?.length
243
246
  ? ` Prefer the typed ${api.name}_* tools; this one is the escape hatch for operations they do not cover.`
244
247
  : "";
@@ -258,7 +261,7 @@ function genericTool(api, env, bucket, log, keepIn) {
258
261
  query: args.query ?? {},
259
262
  headers: {},
260
263
  body: args.body,
261
- }, keepIn));
264
+ }, keepIn, scope));
262
265
  }
263
266
  export function buildApiTools(tenant, apis, workspace,
264
267
  // The runner container passes this: secrets were resolved before the specs
@@ -277,6 +280,8 @@ keepIn) {
277
280
  missing: resolved.missing,
278
281
  }
279
282
  : resolveSecrets(tenant, neededSecrets, workspace);
283
+ // Read from the vault here: made live per call in its account's scope.
284
+ const scope = !resolved && tenant ? { tenant, ...(workspace ? { workspace } : {}) } : null;
280
285
  const log = [];
281
286
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the SDK types its own list this way
282
287
  const tools = [];
@@ -311,12 +316,12 @@ keepIn) {
311
316
  }
312
317
  const typedNames = [];
313
318
  for (const op of ops) {
314
- tools.push(typedTool(api, op, env, bucket, log, keepIn));
319
+ tools.push(typedTool(api, op, env, bucket, log, keepIn, scope));
315
320
  typedNames.push(typedToolName(api.name, op.id));
316
321
  methods[`mcp__foldrun_apis__${typedToolName(api.name, op.id)}`] = String(op.method ?? "").toUpperCase();
317
322
  }
318
323
  if (withGeneric)
319
- tools.push(genericTool(api, env, bucket, log, keepIn));
324
+ tools.push(genericTool(api, env, bucket, log, keepIn, scope));
320
325
  const names = [...typedNames, ...(withGeneric ? [`call_${api.name}`] : [])];
321
326
  for (const n of names) {
322
327
  toolNames.push(`mcp__foldrun_apis__${n}`);
@@ -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)
@@ -173,6 +173,10 @@ export function classifyCall(tool, input, ctx = {}) {
173
173
  // colleague is said, and a re-run would say it twice.
174
174
  if (server === "foldrun_peers")
175
175
  return name === "peers_list" ? { write: false } : write("a message to a colleague");
176
+ // remotes: — reading where a request got to changes nothing; a send, a
177
+ // reply or a cancel acts on an outside agent's system.
178
+ if (server === "foldrun_a2a")
179
+ return name === "a2a_status" ? { write: false } : write(`${name.replace(/^a2a_/, "")} to an outside agent`);
176
180
  if (server === "foldrun_scripts") {
177
181
  if (name !== "web")
178
182
  return write(`script ${name}`);
@@ -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/");
@@ -331,9 +331,9 @@ export function completionsAt(path, text, cursor, vocab) {
331
331
  // that field's files and nothing else. `[[search]]` in tools: is how a
332
332
  // tool named after a runtime group gets granted; offering the group
333
333
  // here would defeat the point of the brackets.
334
- const inlineOwner = line.match(/^\s*([a-zA-Z_]+):\s*(?:\[.*)?\[\[[^\]]*$/)?.[1];
334
+ const inlineOwner = line.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*):\s*(?:\[.*)?\[\[[^\]]*$/)?.[1];
335
335
  const blockOwner = /^\s+-\s*\[\[/.test(line)
336
- ? before.slice(0, lineStart).split("\n").reverse().find((l) => /^\s*[a-zA-Z_]+:\s*$/.test(l))?.trim().replace(":", "")
336
+ ? before.slice(0, lineStart).split("\n").reverse().find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))?.trim().replace(":", "")
337
337
  : undefined;
338
338
  const owner = inlineOwner ?? blockOwner;
339
339
  const byField = {
@@ -391,7 +391,7 @@ export function completionsAt(path, text, cursor, vocab) {
391
391
  // been chosen yet, one at a time. Fixed combinations were the wrong
392
392
  // model: nobody wants a menu of every permutation, they want to add the
393
393
  // next verb.
394
- const inlineList = line.match(/^(\s*)([a-zA-Z_]+):\s*\[([^\]]*)$/);
394
+ const inlineList = line.match(/^(\s*)([a-zA-Z_][a-zA-Z0-9_]*):\s*\[([^\]]*)$/);
395
395
  if (inlineList) {
396
396
  const [, , key, inside] = inlineList;
397
397
  // The inline and block forms of a list are the same field — `tools: [a, b]`
@@ -435,7 +435,7 @@ export function completionsAt(path, text, cursor, vocab) {
435
435
  }
436
436
  }
437
437
  // 4. `key: value` — the value side of a known field.
438
- const kv = line.match(/^(\s*)([a-zA-Z_]+):\s*(\S*)$/);
438
+ const kv = line.match(/^(\s*)([a-zA-Z_][a-zA-Z0-9_]*):\s*(\S*)$/);
439
439
  if (kv) {
440
440
  const [, , key, partial] = kv;
441
441
  const from = cursor - partial.length;
@@ -517,7 +517,7 @@ export function completionsAt(path, text, cursor, vocab) {
517
517
  const partial = listItem[2];
518
518
  const from = cursor - partial.length;
519
519
  const priorLines = before.slice(0, lineStart).split("\n").reverse();
520
- const owner = priorLines.find((l) => /^\s*[a-zA-Z_]+:\s*$/.test(l))?.trim().replace(":", "");
520
+ const owner = priorLines.find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))?.trim().replace(":", "");
521
521
  const byOwner = {
522
522
  // One list now: built-ins and your own tools are granted the same way.
523
523
  // Groups first (they cover most needs), then your own tools — the ones
@@ -558,7 +558,7 @@ export function completionsAt(path, text, cursor, vocab) {
558
558
  const partial = mapping[2] ?? "";
559
559
  const priorLines = before.slice(0, lineStart).split("\n").reverse();
560
560
  const owner = priorLines
561
- .find((l) => /^\s*[a-zA-Z_]+:\s*$/.test(l))
561
+ .find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))
562
562
  ?.trim()
563
563
  .replace(":", "");
564
564
  const byOwner = {
@@ -593,7 +593,7 @@ export function completionsAt(path, text, cursor, vocab) {
593
593
  // kind of file — the block takes nothing else.
594
594
  const fmClose = text.indexOf("\n---", text.indexOf("---") + 3);
595
595
  const insideFrontmatter = text.startsWith("---") && (fmClose === -1 || cursor <= fmClose);
596
- const nested = line.match(/^(\s+)([a-zA-Z_]*)$/);
596
+ const nested = line.match(/^(\s+)([a-zA-Z_][a-zA-Z0-9_]*|)$/);
597
597
  if (insideFrontmatter && nested) {
598
598
  const header = before.slice(0, lineStart).split("\n").reverse().find((l) => /^\S/.test(l));
599
599
  const job = JOBS.find((j) => new RegExp(`^${j.name}\\s*:\\s*$`).test(header ?? ""));
@@ -624,7 +624,7 @@ export function completionsAt(path, text, cursor, vocab) {
624
624
  // this kind of file. This is where you learn the format by writing it.
625
625
  const fmEnd = text.indexOf("\n---", text.indexOf("---") + 3);
626
626
  const inFrontmatter = text.startsWith("---") && (fmEnd === -1 || cursor <= fmEnd);
627
- if (inFrontmatter && /^[a-zA-Z_][a-zA-Z_-]*$|^$/.test(line)) {
627
+ if (inFrontmatter && /^[a-zA-Z_][a-zA-Z0-9_-]*$|^$/.test(line)) {
628
628
  const from = cursor - line.length;
629
629
  return { from, query: line, items: filter(fieldsFor(path, kind), line), title: `${kind} fields` };
630
630
  }
@@ -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
+ }
@@ -43,6 +43,7 @@ const AGENT = [
43
43
  { key: "scripts", type: "list", help: "programs in scripts/, each a tool" },
44
44
  { key: "apis", type: "list", help: "an HTTP API as a tool" },
45
45
  { key: "mcpServers", type: "object", help: "MCP servers, name → {command | url, args, env, headers}" },
46
+ { 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?}]" },
46
47
  { key: "agents", type: ["list", "string"], help: "colleagues it may consult" },
47
48
  { key: "subagents", type: ["list", "string"], help: "colleagues it may delegate to" },
48
49
  { 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" },
@@ -109,7 +110,7 @@ const FLOW = [
109
110
  { key: "catchup", type: "string", validated: true, help: "last | none" },
110
111
  { key: "on", type: "string", validated: true, help: "trigger: flow — completed | failed | blocked | any" },
111
112
  { 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" },
113
+ { key: "signing_secret", type: "string", help: "the NAME of the vault secret a webhook's signature is checked with — never the value" },
113
114
  { key: "after", type: ["string", "list"], help: "trigger: flow — the flow this one follows" },
114
115
  { key: "at", type: "string", help: "trigger: once — when" },
115
116
  { key: "url", type: "string", help: "trigger: watch — the page watched" },
@@ -127,6 +128,7 @@ const FLOW = [
127
128
  { key: "budget", type: ["number", "string"], help: "USD per run" },
128
129
  { 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" },
129
130
  { key: "live", type: "boolean", help: "true: steps may act for real in a test run" },
131
+ { 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" },
130
132
  ];
131
133
  const TOOL = [
132
134
  { key: "name", type: "string", help: "defaults to the file or folder name" },
@@ -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,9 +5,12 @@ 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";
12
+ import { remotesProblem } from "./a2a-remotes.js";
13
+ import { a2aPublishProblem } from "./a2a-publish.js";
11
14
  export { FRONTMATTER, schemaKindOf } from "./frontmatter-fields.js";
12
15
  /** A value for a message: a scalar as written, anything else by its shape.
13
16
  * Never String() of a map — its `toString` is whatever the YAML says. */
@@ -30,6 +33,7 @@ const CHECKS = {
30
33
  "agent": {
31
34
  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`),
32
35
  apis: apisProblem,
36
+ remotes: remotesProblem,
33
37
  budget: (v) => budgetProblem(v, ["run"]),
34
38
  ask: (v) => askProblem(v),
35
39
  resources: resourcesProblem,
@@ -54,6 +58,7 @@ const CHECKS = {
54
58
  pause_when: (v) => (String(v).length <= 60 ? null : "is longer than 60 characters"),
55
59
  budget: (v) => budgetProblem(v, ["run"]),
56
60
  compute: computeProblem,
61
+ a2a: a2aPublishProblem,
57
62
  },
58
63
  "tool": {
59
64
  transport: (v) => (["http", "script", "mcp"].includes(String(v).toLowerCase()) ? null : `"${String(v)}" is not a transport — http, script or mcp`),
@@ -163,11 +168,49 @@ export function frontmatterProblems(kind, data, near) {
163
168
  if (why)
164
169
  out.push({ key, rule: "config/invalid-value", level: "warn", message: `\`${key}:\` ${why.replace(/^\w+: /, "")}` });
165
170
  }
171
+ // A cron every field of which is in range, on a day no month has —
172
+ // `0 0 30 2 *`. The scheduler's own cronCanFire decides (30 Oct 2026), so
173
+ // check and the scheduler say the same about it: it never fires.
174
+ if (kind === "flow" && typeof data.schedule === "string") {
175
+ const cron = parseCron(data.schedule);
176
+ if (cron && !cronCanFire(cron)) {
177
+ 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` });
178
+ }
179
+ }
180
+ // A signing secret is the NAME of a vault entry (30 Oct 2026): a flow's
181
+ // `signing_secret:` and notify's `signing_secret:` are looked up with
182
+ // getSecret, so a literal value there is a secret written into a
183
+ // versioned, deployed file — and never matches anything in the vault.
184
+ // The message never repeats the value.
185
+ if (kind === "flow" && data.signing_secret !== undefined) {
186
+ const why = signingSecretProblem(data.signing_secret);
187
+ if (why)
188
+ out.push({ key: "signing_secret", rule: "secret/signing-secret-value", level: "warn", message: `\`signing_secret:\` ${why}` });
189
+ }
190
+ if (kind === "agents-md" && data.notify && typeof data.notify === "object" && !Array.isArray(data.notify)) {
191
+ const raw = data.notify.signing_secret;
192
+ const why = raw === undefined ? null : signingSecretProblem(raw);
193
+ if (why)
194
+ out.push({ key: "notify", rule: "secret/signing-secret-value", level: "warn", message: `\`notify: signing_secret:\` ${why}` });
195
+ }
166
196
  if (kind === "skill" && !(typeof data.description === "string" && data.description.trim())) {
167
197
  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
198
  }
169
199
  return out;
170
200
  }
201
+ /** Why a `signing_secret:` value is not a vault secret's name, or null. The
202
+ * runtime reads `NAME` and `${NAME}` alike (notify.ts, store.ts). Never
203
+ * says what the value is: it may be the secret itself. */
204
+ export function signingSecretProblem(v) {
205
+ if (typeof v !== "string")
206
+ return null; // config/wrong-type says so
207
+ const name = v.trim().replace(/^\$\{|\}$/g, "");
208
+ if (!name)
209
+ return null;
210
+ return SECRET_NAME.test(name)
211
+ ? null
212
+ : "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";
213
+ }
171
214
  function askProblem(v) {
172
215
  const t = v?.timeout;
173
216
  return t === undefined ? null : DURATION(30, 86400, "30s to 24h")(t);
@@ -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
  //
@@ -21,6 +23,7 @@
21
23
  import crypto from "node:crypto";
22
24
  import { WEB_ACTIONS } from "./providers.js";
23
25
  import { DELEGATE_TOOLS } from "./subagents.js";
26
+ import { A2A_SERVER, A2A_LIMIT_KEY, A2A_LIMIT_ACTIONS } from "./a2a-remotes.js";
24
27
  /** The key that caps every call in the step. */
25
28
  export const TOTAL_KEY = "calls";
26
29
  /** The key that caps one identical call: a model asking the same tool the
@@ -96,8 +99,11 @@ export function readLimits(raw, where = "limits") {
96
99
  }
97
100
  if (k.includes(".")) {
98
101
  const [head, action] = k.split(".");
99
- if (head !== "web" || !WEB_ACTIONS.includes(action)) {
100
- problems.push(`${where}: "${k}" is not a key — the dotted keys are web.<action>: ${WEB_ACTIONS.map((a) => `web.${a}`).join(", ")}.`);
102
+ const web = head === "web" && WEB_ACTIONS.includes(action);
103
+ const a2a = head === A2A_LIMIT_KEY && A2A_LIMIT_ACTIONS.includes(action);
104
+ if (!web && !a2a) {
105
+ problems.push(`${where}: "${k}" is not a key — the dotted keys are web.<action>: ${WEB_ACTIONS.map((a) => `web.${a}`).join(", ")}, ` +
106
+ `and ${A2A_LIMIT_ACTIONS.map((a) => `${A2A_LIMIT_KEY}.${a}`).join(", ")}.`);
101
107
  continue;
102
108
  }
103
109
  }
@@ -166,6 +172,16 @@ export function limitKeysFor(toolName, input, owners = {}) {
166
172
  }
167
173
  if (DELEGATE_TOOLS.includes(toolName))
168
174
  return keys;
175
+ // remotes: — a message to an outside agent (a2a_send, a2a_reply) counts
176
+ // under `a2a`; every tool also under its own `a2a.<action>`, so polling
177
+ // (`a2a.status`) is capped apart from sending.
178
+ const a2a = new RegExp(`^mcp__${A2A_SERVER}__a2a_([a-z]+)$`).exec(toolName);
179
+ if (a2a) {
180
+ if (a2a[1] === "send" || a2a[1] === "reply")
181
+ keys.push(A2A_LIMIT_KEY);
182
+ keys.push(`${A2A_LIMIT_KEY}.${a2a[1]}`);
183
+ return keys;
184
+ }
169
185
  const owner = owners[toolName];
170
186
  const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
171
187
  const name = owner
@@ -263,7 +279,7 @@ export class CallCounter {
263
279
  */
264
280
  export function limitKeyProblems(limits, ctx) {
265
281
  const out = [];
266
- const builtinKeys = ["read", "write", "code", ...Object.keys(BUILTIN_GROUP), "TodoWrite", "search", "history", "desks", "ask", "web"];
282
+ const builtinKeys = ["read", "write", "code", ...Object.keys(BUILTIN_GROUP), "TodoWrite", "search", "history", "desks", "ask", "web", A2A_LIMIT_KEY];
267
283
  const known = new Set([...builtinKeys, ...ctx.known].map(normKey));
268
284
  const granted = new Set([...ctx.granted].flatMap((g) => {
269
285
  const n = normKey(g);
@@ -281,10 +297,12 @@ export function limitKeyProblems(limits, ctx) {
281
297
  for (const key of Object.keys(limits)) {
282
298
  if (key === TOTAL_KEY || key === REPEAT_KEY)
283
299
  continue;
284
- const base = key.startsWith("web.") ? "web" : key;
300
+ const base = key.startsWith("web.") ? "web" : key.startsWith(`${A2A_LIMIT_KEY}.`) ? A2A_LIMIT_KEY : key;
285
301
  const n = normKey(base);
286
302
  if (!known.has(n)) {
287
303
  const valid = [TOTAL_KEY, "web", ...WEB_ACTIONS.map((a) => `web.${a}`), ...[...new Set([...ctx.granted].map(String))].filter((g) => g !== "web")];
304
+ if ([...ctx.granted].map(String).includes(A2A_LIMIT_KEY))
305
+ valid.push(...A2A_LIMIT_ACTIONS.map((a) => `${A2A_LIMIT_KEY}.${a}`));
288
306
  out.push({ level: "error", message: `limits: ${key} — not a tool this agent could call. The keys: ${[...new Set(valid)].join(", ")}.` });
289
307
  }
290
308
  else if (!granted.has(n)) {
@@ -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;