@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.
- package/dist/src/a2a-client.d.ts +165 -0
- package/dist/src/a2a-client.js +440 -0
- package/dist/src/a2a-publish.d.ts +37 -0
- package/dist/src/a2a-publish.js +116 -0
- package/dist/src/a2a-remotes.d.ts +73 -0
- package/dist/src/a2a-remotes.js +192 -0
- package/dist/src/a2a-tools.d.ts +148 -0
- package/dist/src/a2a-tools.js +833 -0
- package/dist/src/api-tools.js +15 -10
- package/dist/src/approvals.d.ts +18 -10
- package/dist/src/approvals.js +51 -1
- package/dist/src/browser-pod.js +4 -0
- package/dist/src/catalog.d.ts +14 -0
- package/dist/src/catalog.js +28 -0
- package/dist/src/completions.js +8 -8
- package/dist/src/cron.d.ts +9 -0
- package/dist/src/cron.js +20 -0
- package/dist/src/frontmatter-fields.js +3 -1
- package/dist/src/frontmatter-schema.d.ts +5 -1
- package/dist/src/frontmatter-schema.js +44 -1
- package/dist/src/limits.js +22 -4
- package/dist/src/notify.d.ts +6 -3
- package/dist/src/notify.js +25 -12
- package/dist/src/package.d.ts +5 -1
- package/dist/src/package.js +5 -3
- package/dist/src/run-container.d.ts +10 -1
- package/dist/src/run-container.js +17 -0
- package/dist/src/runner.d.ts +10 -0
- package/dist/src/runner.js +163 -16
- package/dist/src/scan.js +123 -5
- package/dist/src/secrets.d.ts +122 -10
- package/dist/src/secrets.js +291 -27
- package/dist/src/starter.js +5 -1
- package/dist/src/store.d.ts +34 -0
- package/dist/src/store.js +28 -7
- package/dist/src/subagents.d.ts +16 -0
- package/dist/src/subagents.js +23 -2
- package/dist/src/triggers.js +2 -2
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +14 -5
- package/package.json +4 -1
- package/src/a2a-client.ts +517 -0
- package/src/a2a-publish.ts +120 -0
- package/src/a2a-remotes.ts +225 -0
- package/src/a2a-tools.ts +919 -0
- package/src/api-tools.ts +20 -8
- package/src/approvals.ts +55 -1
- package/src/browser-pod.ts +3 -0
- package/src/catalog.ts +27 -0
- package/src/completions.ts +8 -8
- package/src/cron.ts +19 -0
- package/src/frontmatter-fields.ts +3 -1
- package/src/frontmatter-schema.ts +42 -2
- package/src/limits.ts +22 -4
- package/src/notify.ts +23 -12
- package/src/package.ts +5 -3
- package/src/run-container.ts +22 -0
- package/src/runner.ts +157 -13
- package/src/scan.ts +146 -6
- package/src/secrets.ts +319 -33
- package/src/starter.ts +5 -1
- package/src/store.ts +47 -2
- package/src/subagents.ts +29 -2
- package/src/triggers.ts +2 -2
- 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,
|
|
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
|
-
|
|
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,
|
package/src/browser-pod.ts
CHANGED
|
@@ -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/");
|
package/src/completions.ts
CHANGED
|
@@ -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_]
|
|
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_]
|
|
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_]
|
|
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_]
|
|
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_]
|
|
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_]
|
|
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-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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);
|