@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
@@ -106,11 +106,43 @@ function secretsFile(tenant, workspace) {
106
106
  // every rename a re-seal racing other writers. What that leaves open — a
107
107
  // ciphertext moved between two workspaces of the same account by someone who
108
108
  // can already write the volume — is the residual, and stays inside one account.
109
+ //
110
+ // Rolling out (30 Oct 2026, accepted, no switch): a pod from before this
111
+ // release cannot open a limited record (its AAD lacks the list), and one that
112
+ // sets a new value over it writes the record back without the list — open to
113
+ // every workspace again. So limits are set after every pod, worker and the
114
+ // egress proxy run this version; the secrets docs say so.
115
+ //
116
+ // The workspaces an account secret is limited to are in the seal too (30 Oct
117
+ // 2026), appended only when the record has the list: every record sealed
118
+ // before keeps the AAD it was sealed with, and a list added to, widened in or
119
+ // stripped from a sealed record on disk makes it fail to open — "not set",
120
+ // never "every workspace". These ARE workspace names, so a rename re-seals
121
+ // them (retargetSecretWorkspaces), under the vault lock.
109
122
  function vaultAad(tenant, workspace, name, rec) {
110
123
  if (rec.sites !== undefined && (!Array.isArray(rec.sites) || rec.sites.some((x) => typeof x !== "string"))) {
111
124
  throw new Error("malformed sites");
112
125
  }
113
- return Buffer.from(JSON.stringify(["foldrun-vault/2", tenant, workspace ? "w" : "a", name, rec.kind ?? "", rec.k ?? "install", [...(rec.sites ?? [])].sort()]), "utf8");
126
+ if (rec.workspaces !== undefined && (!Array.isArray(rec.workspaces) || rec.workspaces.some((x) => typeof x !== "string"))) {
127
+ throw new Error("malformed workspaces");
128
+ }
129
+ return Buffer.from(JSON.stringify([
130
+ "foldrun-vault/2",
131
+ tenant,
132
+ workspace ? "w" : "a",
133
+ name,
134
+ rec.kind ?? "",
135
+ rec.k ?? "install",
136
+ [...(rec.sites ?? [])].sort(),
137
+ ...(rec.workspaces !== undefined ? [["workspaces", [...rec.workspaces].sort()]] : []),
138
+ ]), "utf8");
139
+ }
140
+ /** May an account record be used in this workspace? No list: everywhere. A
141
+ * list that is not a list of names (rewritten on disk) allows nowhere. */
142
+ function usableIn(rec, workspace) {
143
+ if (!rec || rec.workspaces === undefined)
144
+ return true;
145
+ return Array.isArray(rec.workspaces) && rec.workspaces.includes(workspace);
114
146
  }
115
147
  const writeBound = () => process.env.FOLDRUN_VAULT_WRITE_BOUND === "1";
116
148
  const requireBound = () => process.env.FOLDRUN_VAULT_REQUIRE_BOUND === "1";
@@ -126,6 +158,8 @@ function sealRecord(tenant, workspace, name, value, meta, bound = writeBound())
126
158
  ...(meta.kind ? { kind: meta.kind } : {}),
127
159
  ...(mark ? { k: mark } : {}),
128
160
  ...(meta.sites?.length ? { sites: [...meta.sites] } : {}),
161
+ // An empty list is a choice (no workspace may use it), kept as written.
162
+ ...(meta.workspaces !== undefined ? { workspaces: [...meta.workspaces] } : {}),
129
163
  };
130
164
  const iv = crypto.randomBytes(12);
131
165
  const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
@@ -187,6 +221,16 @@ function readFile(file) {
187
221
  }
188
222
  }
189
223
  function lockVault(file) {
224
+ return lockFileExclusive(file, "the vault is busy — another change is being saved; try again");
225
+ }
226
+ /**
227
+ * An exclusive lock on `file` for a read-change-write, across processes and
228
+ * pods: `<file>.lock` opened O_EXCL (NFS 4.1 and EFS honour it), taken over
229
+ * when older than LOCK_STALE_MS (a holder that died), waited for up to
230
+ * LOCK_WAIT_MS, then `busy` is thrown. Returns the release. The vault's,
231
+ * and any other small state file several runs write (a2a-tools' handles).
232
+ */
233
+ export function lockFileExclusive(file, busy = `${path.basename(file)} is busy — another writer holds it; try again`) {
190
234
  const lock = `${file}.lock`;
191
235
  const deadline = Date.now() + LOCK_WAIT_MS;
192
236
  for (;;) {
@@ -209,7 +253,7 @@ function lockVault(file) {
209
253
  continue; // released between the open and the stat
210
254
  }
211
255
  if (Date.now() > deadline)
212
- throw new Error("the vault is busy — another change is being saved; try again");
256
+ throw new Error(busy);
213
257
  sleep(25);
214
258
  }
215
259
  }
@@ -237,7 +281,38 @@ export function mutateVaultFile(file, change) {
237
281
  }
238
282
  }
239
283
  export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
240
- export function setSecret(tenant, name, value, workspace, kind) {
284
+ /**
285
+ * How a `signing_secret:` is shown in an answer, a log line or a delivery
286
+ * record: the name it holds, and only a name (30 Oct 2026). It is meant to
287
+ * name a vault entry, but nothing stopped the value itself being written
288
+ * there, and notify's test send echoed it back to the dashboard as "signed
289
+ * with <it>". Anything that is not a secret name is described, never
290
+ * repeated (foldrun check flags it as secret/signing-secret-value).
291
+ */
292
+ export function signingSecretShown(name) {
293
+ return SECRET_NAME.test(name) ? name : "the signing_secret: (not a secret name — see foldrun check)";
294
+ }
295
+ /** A list as stored: trimmed, de-duplicated, sorted; every name a safe one
296
+ * and, when the caller can say, one that exists. */
297
+ function cleanWorkspaceList(workspaces) {
298
+ if (workspaces === null)
299
+ return null;
300
+ if (!Array.isArray(workspaces) || workspaces.some((w) => typeof w !== "string")) {
301
+ throw new Error("workspaces is a list of workspace names, or null for every workspace");
302
+ }
303
+ const clean = [...new Set(workspaces.map((w) => w.trim()).filter(Boolean))].sort();
304
+ for (const w of clean)
305
+ assertSafeName(w, "workspace");
306
+ return clean;
307
+ }
308
+ function assertWorkspacesExist(list, exists) {
309
+ if (!list || !exists)
310
+ return;
311
+ const missing = list.filter((w) => !exists(w));
312
+ if (missing.length)
313
+ throw Object.assign(new Error(`no workspace ${missing.join(", ")} in this account`), { status: 400 });
314
+ }
315
+ export function setSecret(tenant, name, value, workspace, kind, limit = {}) {
241
316
  // A string, not anything that prints as one: RegExp.test and the vault's
242
317
  // keys both coerce, so ["RESEND_API_KEY"] from a JSON body was stored as
243
318
  // RESEND_API_KEY past a caller's check on the name (30 Oct 2026).
@@ -246,18 +321,35 @@ export function setSecret(tenant, name, value, workspace, kind) {
246
321
  }
247
322
  if (value.length > 8192)
248
323
  throw new Error("secret too long");
324
+ const wanted = limitFor(workspace, limit);
249
325
  mutateVaultFile(secretsFile(tenant, workspace), (all) => {
250
326
  // A new value keeps the sites the old one was bound to: binding is a
251
327
  // property of the credential's use, not of its current value, and a
252
328
  // rotation that silently unbound it would put the new value in pods.
253
329
  // Sealed here, under the lock: a bound record's AAD names those sites.
254
330
  const sites = all[name]?.sites;
331
+ // The workspaces it is limited to stay too, for the same reason: a new
332
+ // value that every workspace could suddenly read is a widening nobody
333
+ // asked for — unless this call says otherwise, in the same write.
334
+ if (wanted !== undefined)
335
+ assertWorkspacesExist(wanted, limit.exists);
336
+ const workspaces = wanted !== undefined ? (wanted ?? undefined) : all[name]?.workspaces;
255
337
  // Once bound, always bound: a re-save with the switch off must not
256
- // quietly drop the record's authentication.
257
- all[name] = sealRecord(tenant, workspace, name, value, { kind, sites }, all[name]?.v === 2 || writeBound());
338
+ // quietly drop the record's authentication. A limited record is always
339
+ // bound, so its list is in its seal (see setSecretWorkspaces).
340
+ all[name] = sealRecord(tenant, workspace, name, value, { kind, sites, workspaces }, all[name]?.v === 2 || workspaces !== undefined || writeBound());
258
341
  return all;
259
342
  });
260
343
  }
344
+ /** The list a write was asked to store, checked: undefined when it was not
345
+ * asked to change it. Only the account's store has lists. */
346
+ function limitFor(workspace, limit) {
347
+ if (limit.workspaces === undefined)
348
+ return undefined;
349
+ if (workspace)
350
+ throw new Error("a workspace's own secret is that workspace's already — only an account secret is limited to workspaces");
351
+ return cleanWorkspaceList(limit.workspaces);
352
+ }
261
353
  /**
262
354
  * Bind a secret to sites, or unbind it (an empty list). The secret must
263
355
  * exist in that scope. Sites are `api.example.com` or `*.example.com` — no
@@ -296,7 +388,7 @@ export function setSecretSites(tenant, name, sites, workspace) {
296
388
  const value = openRecord(tenant, workspace, name, rec);
297
389
  if (value === null)
298
390
  throw new Error(`${name} cannot be read with this install's keys — set its value again before binding it`);
299
- all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, updatedAt: rec.updatedAt }, true);
391
+ all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, workspaces: rec.workspaces, updatedAt: rec.updatedAt }, true);
300
392
  return all;
301
393
  }
302
394
  if (clean.length)
@@ -323,7 +415,11 @@ export function secretSites(tenant, names, workspace) {
323
415
  // when it opens, else the account's. Picking by presence paired an
324
416
  // account value with a workspace record's (unauthenticated) sites.
325
417
  const ownRec = own[name];
326
- const rec = ownRec && openRecord(tenant, workspace, name, ownRec) !== null ? ownRec : account[name];
418
+ const ownOpens = Boolean(ownRec && openRecord(tenant, workspace, name, ownRec) !== null);
419
+ // An account record limited to other workspaces is not this one's.
420
+ if (!ownOpens && workspace && !usableIn(account[name], workspace))
421
+ continue;
422
+ const rec = ownOpens ? ownRec : account[name];
327
423
  if (rec?.sites?.length)
328
424
  out[name] = [...rec.sites];
329
425
  }
@@ -398,16 +494,141 @@ function decrypt(tenant, rec, aad) {
398
494
  return null; // key rotated or file tampered with
399
495
  }
400
496
  }
401
- /** Nearest wins: a workspace secret shadows an account one of the same name. */
497
+ /** Nearest wins: a workspace secret shadows an account one of the same name.
498
+ * An account secret limited to some workspaces (setSecretWorkspaces) is
499
+ * absent for every other — the answer a secret that was never set gets, on
500
+ * every path that resolves one for a workspace: runs, tool tests, notify,
501
+ * the egress proxy, MCP servers. Without a workspace it is the account's
502
+ * own use (its model key, its mail sender), which no list limits. */
402
503
  export function getSecret(tenant, name, workspace) {
403
504
  if (workspace) {
404
505
  const own = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
405
506
  if (own !== null)
406
507
  return { value: own, scope: "workspace" };
407
508
  }
408
- const shared = openRecord(tenant, undefined, name, read(tenant)[name]);
509
+ const rec = read(tenant)[name];
510
+ if (workspace && !usableIn(rec, workspace))
511
+ return null;
512
+ const shared = openRecord(tenant, undefined, name, rec);
409
513
  return shared === null ? null : { value: shared, scope: "account" };
410
514
  }
515
+ /** May the ACCOUNT's secret `name` be used for this workspace? True when it
516
+ * is not limited (or not set); for callers that read the account's copy on
517
+ * a workspace's behalf without getSecret's workspace-first lookup — the
518
+ * account's mail sender for a workspace's notifications (notify.ts). */
519
+ export function accountSecretUsableIn(tenant, name, workspace) {
520
+ return usableIn(read(tenant)[name], workspace);
521
+ }
522
+ /**
523
+ * Limit an account secret to some workspaces, or lift the limit (null).
524
+ * An empty list means no workspace may use it. Returns the list as stored,
525
+ * sorted, or null.
526
+ *
527
+ * `exists` is asked about each name under the vault lock (the API passes
528
+ * "is there such a workspace"), so it cannot grant a name a delete has just
529
+ * taken off every list. `ifWorkspaces`, when given, is the list the caller
530
+ * saw (null: every workspace): a list changed since is refused (409), so
531
+ * two admins editing it at once do not silently overwrite each other.
532
+ *
533
+ * The record is sealed (v2) whatever it was, whatever
534
+ * FOLDRUN_VAULT_WRITE_BOUND says: a list in the clear on an unbound record
535
+ * could be stripped on disk to open the secret to every workspace; sealed,
536
+ * a stripped list makes it unreadable. Every reader since 29 Oct 2026 reads v2.
537
+ */
538
+ export function setSecretWorkspaces(tenant, name, workspaces, opts = {}) {
539
+ if (typeof name !== "string")
540
+ throw new Error("a secret's name is a string");
541
+ const clean = cleanWorkspaceList(workspaces);
542
+ const expected = opts.ifWorkspaces === undefined ? undefined : cleanWorkspaceList(opts.ifWorkspaces);
543
+ let found = false;
544
+ mutateVaultFile(secretsFile(tenant), (all) => {
545
+ const rec = all[name];
546
+ if (!rec)
547
+ return null;
548
+ found = true;
549
+ if (expected !== undefined) {
550
+ const now = Array.isArray(rec.workspaces) ? [...rec.workspaces].sort() : null;
551
+ if (JSON.stringify(now) !== JSON.stringify(expected)) {
552
+ throw Object.assign(new Error(`${name}'s workspaces changed since you loaded them (now ${now === null ? "every workspace" : now.length ? now.join(", ") : "none"}) — reload and try again`), { status: 409 });
553
+ }
554
+ }
555
+ assertWorkspacesExist(clean, opts.exists);
556
+ all[name] = withWorkspaces(tenant, name, rec, clean);
557
+ return all;
558
+ });
559
+ if (!found)
560
+ throw Object.assign(new Error(`no secret ${name} in the account`), { status: 404 });
561
+ return clean;
562
+ }
563
+ /** An account record with its workspace list replaced (null: removed),
564
+ * opened and sealed again as a bound record with the new list in its seal.
565
+ * One that will not open is refused, never relabelled (setSecretSites). */
566
+ function withWorkspaces(tenant, name, rec, workspaces) {
567
+ const value = openRecord(tenant, undefined, name, rec);
568
+ if (value === null)
569
+ throw new Error(`${name} cannot be read with this install's keys — set its value again before changing its workspaces`);
570
+ return sealRecord(tenant, undefined, name, value, { kind: rec.kind, sites: rec.sites, workspaces: workspaces ?? undefined, updatedAt: rec.updatedAt }, true);
571
+ }
572
+ /**
573
+ * A workspace is being renamed (to: its new name) or deleted (to: null):
574
+ * every account secret limited to it follows. Without this a rename quietly
575
+ * cut the workspace off its secrets, and a delete left the name on the list
576
+ * for the next workspace given it.
577
+ *
578
+ * `failed` names the records that hold the name but will not open with this
579
+ * install's keys: they are left exactly as they are, so the old name is
580
+ * STILL on their list — a key restored later would bring the grant back.
581
+ * The caller must not report the rename or delete as clean while any are
582
+ * left (the API refuses it, naming them: set them again, or delete them).
583
+ *
584
+ * Names, not ids: a grant naming a workspace's immutable id would survive a
585
+ * rename untouched and could never pass to a new workspace given an old
586
+ * name — the stronger design, not built yet (30 Oct 2026).
587
+ */
588
+ export function retargetSecretWorkspaces(tenant, from, to) {
589
+ const changed = [];
590
+ const failed = [];
591
+ const file = secretsFile(tenant);
592
+ if (!fs.existsSync(file))
593
+ return { changed, failed };
594
+ mutateVaultFile(file, (all) => {
595
+ changed.length = 0;
596
+ failed.length = 0;
597
+ const staged = {};
598
+ for (const [name, rec] of Object.entries(all)) {
599
+ const list = rec.workspaces;
600
+ if (list === undefined)
601
+ continue;
602
+ if (!Array.isArray(list)) {
603
+ // A list rewritten on disk into something else allows nowhere
604
+ // (usableIn) — nothing for this rename to carry.
605
+ continue;
606
+ }
607
+ if (!list.includes(from))
608
+ continue;
609
+ const next = [...new Set(list.map((w) => (w === from ? to : w)).filter((w) => typeof w === "string"))].sort();
610
+ try {
611
+ staged[name] = withWorkspaces(tenant, name, rec, next);
612
+ changed.push(name);
613
+ }
614
+ catch {
615
+ failed.push(name);
616
+ }
617
+ }
618
+ // All or nothing (Codex, 30 Oct 2026): writing the readable records and
619
+ // refusing the rename for the unreadable ones moved grants to a name the
620
+ // workspace never took. One failure leaves the vault exactly as it was.
621
+ if (failed.length) {
622
+ changed.length = 0;
623
+ return null;
624
+ }
625
+ if (!changed.length)
626
+ return null;
627
+ Object.assign(all, staged);
628
+ return all;
629
+ });
630
+ return { changed, failed };
631
+ }
411
632
  export function deleteSecret(tenant, name, workspace) {
412
633
  mutateVaultFile(secretsFile(tenant, workspace), (all) => {
413
634
  if (!(name in all))
@@ -420,12 +641,18 @@ export function deleteSecret(tenant, name, workspace) {
420
641
  // store; with one it lists what that workspace actually resolves, marking
421
642
  // which scope each name comes from.
422
643
  export function listSecrets(tenant, workspace) {
423
- const account = Object.entries(read(tenant)).map(([name, rec]) => ({
644
+ const records = Object.entries(read(tenant));
645
+ const account = records
646
+ // What a workspace resolves leaves out an account secret limited to
647
+ // others: it is not set, as far as that workspace is concerned.
648
+ .filter(([, rec]) => !workspace || usableIn(rec, workspace))
649
+ .map(([name, rec]) => ({
424
650
  name,
425
651
  updatedAt: rec.updatedAt,
426
652
  scope: "account",
427
653
  ...(rec.kind ? { kind: rec.kind } : {}),
428
654
  ...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
655
+ ...(!workspace && Array.isArray(rec.workspaces) ? { workspaces: [...rec.workspaces] } : {}),
429
656
  }));
430
657
  if (!workspace)
431
658
  return account.sort((a, b) => a.name.localeCompare(b.name));
@@ -559,6 +786,9 @@ export function rotateMasterKey(tenant, from, to) {
559
786
  // Kept too (it was dropped until 29 Oct 2026): a rotation that
560
787
  // unbinds every bound secret puts their values into pods.
561
788
  ...(rec.sites?.length ? { sites: rec.sites } : {}),
789
+ // And the workspaces it is limited to: dropped, it would read as
790
+ // "every workspace" (and fail its seal).
791
+ ...(rec.workspaces !== undefined ? { workspaces: rec.workspaces } : {}),
562
792
  ...(aad ? { v: 2 } : {}),
563
793
  };
564
794
  count++;
@@ -578,7 +808,7 @@ export function oauthRetryDelayMs(n) {
578
808
  const base = Number(process.env.FOLDRUN_OAUTH_RETRY_MS ?? 1000);
579
809
  return base * (n === 1 ? 1 : 3);
580
810
  }
581
- export function setOAuth2Secret(tenant, name, config, workspace) {
811
+ export function setOAuth2Secret(tenant, name, config, workspace, limit = {}) {
582
812
  for (const field of ["token_url", "client_id"]) {
583
813
  if (!config[field]?.trim())
584
814
  throw new Error(`oauth2 secret needs ${field}`);
@@ -596,7 +826,7 @@ export function setOAuth2Secret(tenant, name, config, workspace) {
596
826
  if (!/^https:\/\//.test(config.token_url) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(config.token_url)) {
597
827
  throw new Error("token_url must be https — a refresh token over http is a leaked one");
598
828
  }
599
- setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2");
829
+ setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2", limit);
600
830
  }
601
831
  export function isOAuth2Value(value) {
602
832
  return value.startsWith(OAUTH2_PREFIX);
@@ -638,7 +868,7 @@ export function replaceOAuth2RefreshToken(tenant, name, workspace, usedValue, re
638
868
  // Built whole, not spread over the old record: a spread kept a stale
639
869
  // `k: "tenant"` when this seal fell back to the install key, and the
640
870
  // record then opened with neither.
641
- all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites }, rec.v === 2 || writeBound());
871
+ all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites, workspaces: rec.workspaces }, rec.v === 2 || writeBound());
642
872
  replaced = true;
643
873
  }
644
874
  return all;
@@ -799,6 +1029,36 @@ ctx) {
799
1029
  }
800
1030
  return out;
801
1031
  }
1032
+ /**
1033
+ * materializeSecrets for a tool that holds secrets for a whole step and
1034
+ * makes them live per call (the API tools, the a2a tools), on a host with
1035
+ * the vault.
1036
+ *
1037
+ * Two things the step's copy of a recipe cannot do alone. A provider that
1038
+ * rotates refresh tokens has spent the refresh token the step started with —
1039
+ * the runner materialised it at step start, and any refresh since spent its
1040
+ * successor — so an oauth2 recipe is read again from the vault as it is now
1041
+ * (and `env` updated in place, for the next call). And it is made live in
1042
+ * the account's scope, as the runner and the egress proxy do: the cached
1043
+ * access token is keyed by account, and a rotation is written back to the
1044
+ * vault by the compare-and-set above. Refreshing the starting recipe outside
1045
+ * that scope missed the cache and sent a spent refresh token: invalid_grant.
1046
+ *
1047
+ * No `scope` (inside a sandbox, where there is no vault and the values
1048
+ * crossed already made live): materializeSecrets as it was.
1049
+ */
1050
+ export async function materializeCurrent(env, scope) {
1051
+ if (!scope?.tenant)
1052
+ return materializeSecrets(env);
1053
+ for (const [name, value] of Object.entries(env)) {
1054
+ if (typeof value !== "string" || !isOAuth2Value(value))
1055
+ continue;
1056
+ const current = getSecret(scope.tenant, name, scope.workspace)?.value;
1057
+ if (current && isOAuth2Value(current))
1058
+ env[name] = current;
1059
+ }
1060
+ return materializeSecrets(env, { tenant: scope.tenant, ...(scope.workspace ? { workspace: scope.workspace } : {}) });
1061
+ }
802
1062
  // ---------------------------------------------------------------- service account
803
1063
  // Signed-key credentials: a Google service account, or anything that mints a
804
1064
  // token by self-signing a JWT with a private key and exchanging it. No user,
@@ -808,7 +1068,7 @@ ctx) {
808
1068
  // Stored as the JSON key, encrypted. Materialised the same way oauth2 is: a
809
1069
  // live access token appears at the last host-side moment, cached until expiry.
810
1070
  const SERVICE_ACCOUNT_PREFIX = "@service-account ";
811
- export function setServiceAccountSecret(tenant, name, config, workspace) {
1071
+ export function setServiceAccountSecret(tenant, name, config, workspace, limit = {}) {
812
1072
  for (const field of ["token_url", "issuer", "private_key", "scope"]) {
813
1073
  if (!config[field]?.trim())
814
1074
  throw new Error(`service account secret needs ${field}`);
@@ -823,7 +1083,7 @@ export function setServiceAccountSecret(tenant, name, config, workspace) {
823
1083
  throw new Error("subject is one identifier, no spaces or control characters");
824
1084
  }
825
1085
  const stored = { ...config, ...(config.subject !== undefined ? { subject: config.subject.trim() } : {}) };
826
- setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account");
1086
+ setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account", limit);
827
1087
  }
828
1088
  function base64url(input) {
829
1089
  return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
@@ -886,10 +1146,10 @@ async function exchangeServiceAccount(config, cacheKey) {
886
1146
  // the only place that knows where a run's writable scratch is; here we only
887
1147
  // mark the kind and carry the content.
888
1148
  const FILE_PREFIX = "@file ";
889
- export function setFileSecret(tenant, name, content, workspace) {
1149
+ export function setFileSecret(tenant, name, content, workspace, limit = {}) {
890
1150
  if (!content)
891
1151
  throw new Error("a file secret needs content");
892
- setSecret(tenant, name, FILE_PREFIX + content, workspace, "file");
1152
+ setSecret(tenant, name, FILE_PREFIX + content, workspace, "file", limit);
893
1153
  }
894
1154
  export function isFileValue(value) {
895
1155
  return value.startsWith(FILE_PREFIX);
@@ -905,7 +1165,7 @@ export function fileContent(value) {
905
1165
  // config into an executable wrapper, so the agent runs `"$NAME" 'uptime'`
906
1166
  // and never learns which auth flavour it is.
907
1167
  const SSH_PREFIX = "@ssh ";
908
- export function setSshSecret(tenant, name, config, workspace) {
1168
+ export function setSshSecret(tenant, name, config, workspace, limit = {}) {
909
1169
  const host = config.host?.trim();
910
1170
  const user = config.user?.trim();
911
1171
  if (!host || /[\s'"`$\\]/.test(host))
@@ -928,7 +1188,7 @@ export function setSshSecret(tenant, name, config, workspace) {
928
1188
  host, user, port,
929
1189
  ...(hasKey ? { private_key: config.private_key } : { password: config.password }),
930
1190
  };
931
- setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh");
1191
+ setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh", limit);
932
1192
  }
933
1193
  export function isSshValue(value) {
934
1194
  return value.startsWith(SSH_PREFIX);
@@ -944,7 +1204,7 @@ export function sshConfigOf(value) {
944
1204
  // never appears in the bash command at all.
945
1205
  const API_PREFIX = "@api ";
946
1206
  const CLIENT_CERT_PREFIX = "@client-cert ";
947
- export function setClientCertSecret(tenant, name, config, workspace) {
1207
+ export function setClientCertSecret(tenant, name, config, workspace, limit = {}) {
948
1208
  if (!/-----BEGIN CERTIFICATE-----/.test(config.cert ?? ""))
949
1209
  throw new Error("cert must be a PEM certificate");
950
1210
  if (!/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/.test(config.key ?? ""))
@@ -973,23 +1233,27 @@ export function setClientCertSecret(tenant, name, config, workspace) {
973
1233
  // Written under the vault's own lock, with the binding checked in the same
974
1234
  // step: a value set over a secret keeps its site binding, and a wildcard
975
1235
  // one would present the certificate to a whole domain.
1236
+ const wanted = limitFor(workspace, limit);
976
1237
  mutateVaultFile(secretsFile(tenant, workspace), (all) => {
977
1238
  const sites = all[name]?.sites;
1239
+ if (wanted !== undefined)
1240
+ assertWorkspacesExist(wanted, limit.exists);
1241
+ const workspaces = wanted !== undefined ? (wanted ?? undefined) : all[name]?.workspaces;
978
1242
  if (sites?.some((site) => site.startsWith("*."))) {
979
1243
  throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
980
1244
  }
981
- all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites }, all[name]?.v === 2 || writeBound());
1245
+ all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites, workspaces }, all[name]?.v === 2 || workspaces !== undefined || writeBound());
982
1246
  return all;
983
1247
  });
984
1248
  }
985
1249
  const IDENTITY_PREFIX = "@identity ";
986
- export function setIdentitySecret(tenant, name, config, workspace) {
1250
+ export function setIdentitySecret(tenant, name, config, workspace, limit = {}) {
987
1251
  const audience = typeof config.audience === "string" ? config.audience.trim() : "";
988
1252
  if (!audience)
989
1253
  throw new Error("an identity needs an audience — the value your cloud's trust expects in aud");
990
1254
  if (audience.length > 600 || /[\u0000-\u001f\u007f]/.test(audience))
991
1255
  throw new Error("audience is one value, at most 600 characters, no control characters");
992
- setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity");
1256
+ setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity", limit);
993
1257
  }
994
1258
  export function isIdentityValue(value) {
995
1259
  return value.startsWith(IDENTITY_PREFIX);
@@ -1021,7 +1285,7 @@ export function basicAuthorization(username, password) {
1021
1285
  throw new Error("Basic credentials cannot contain control characters");
1022
1286
  return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
1023
1287
  }
1024
- export function setApiSecret(tenant, name, config, workspace) {
1288
+ export function setApiSecret(tenant, name, config, workspace, limit = {}) {
1025
1289
  let all = { ...(config.headers ?? {}) };
1026
1290
  if (config.basic) {
1027
1291
  if (Object.keys(all).some((h) => h.toLowerCase() === "authorization")) {
@@ -1056,7 +1320,7 @@ export function setApiSecret(tenant, name, config, workspace) {
1056
1320
  const base = config.base_url?.trim();
1057
1321
  if (base && !/^https?:\/\//.test(base))
1058
1322
  throw new Error("base_url must be http(s)");
1059
- setSecret(tenant, name, API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }), workspace, "api");
1323
+ setSecret(tenant, name, API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }), workspace, "api", limit);
1060
1324
  }
1061
1325
  export function isApiValue(value) {
1062
1326
  return value.startsWith(API_PREFIX);
@@ -1094,7 +1358,7 @@ export function sealVaultBindings(tenant) {
1094
1358
  const next = { ...all };
1095
1359
  for (const [name, value] of plain) {
1096
1360
  const rec = all[name];
1097
- next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, updatedAt: rec.updatedAt }, true);
1361
+ next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, workspaces: rec.workspaces, updatedAt: rec.updatedAt }, true);
1098
1362
  }
1099
1363
  sealed += plain.size;
1100
1364
  return next;
@@ -1142,7 +1406,7 @@ export function rewrapTenantSecrets(tenant) {
1142
1406
  return null;
1143
1407
  const next = { ...all };
1144
1408
  for (const [name, value] of plain) {
1145
- next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
1409
+ next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, workspaces: all[name].workspaces, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
1146
1410
  }
1147
1411
  moved += plain.size;
1148
1412
  return next;
@@ -373,6 +373,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
373
373
  | \`/export\` · \`/import\` | GET · POST | the workspace, a flow (\`?kind=flow&name=\`) or an agent as a .zip; take one in (\`?dryRun=1\` first, \`?overwrite=1\` to replace) |
374
374
  | \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
375
375
  | \`/vocabulary\` | GET | what this workspace's documents may say |
376
+ | \`/a2a\` | GET | what it serves to outside agents over A2A: flows that say \`a2a:\`, card URLs, recent tasks |
376
377
  | \`/check\` | GET | what \`foldrun check\` says about the deployed copy, as findings with rule ids (\`?security=1\`) |
377
378
  | \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
378
379
 
@@ -382,7 +383,8 @@ Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/
382
383
  \`/api/me/inbox\` (what waits on you: gates, questions, issues, proposed fixes, your failed runs),
383
384
  \`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
384
385
  \`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
385
- and \`/api/metrics\` (Prometheus).
386
+ and \`/api/metrics\` (Prometheus). Outside \`/api\`: \`/a2a/<account>/<ws>/flows/<flow>\`
387
+ (a published flow's agent card and A2A 1.0 JSON-RPC endpoint — an A2A key only, \`foldrun docs a2a\`).
386
388
 
387
389
  Every call answers — and the CLI sends — \`Foldrun-Version: YYYY-MM-DD\` (dated API
388
390
  versions; an account pin in Settings → API), \`X-RateLimit-*\` headers (a \`429\`
@@ -418,6 +420,7 @@ effort: low
418
420
  overlap: skip # skip | queue — what a second run does while one is live
419
421
  priority: normal # high | normal | low — where its runs stand in the queue
420
422
  compute: { vcpu: 4, memory: 8Gi, disk: 40Gi } # optional: what one run may hold at once
423
+ a2a: { publish: false } # true: outside agents may call it over A2A (admin only; callers need an A2A key)
421
424
  ---
422
425
 
423
426
  1. [[researcher]] — Find three sources on {{topic}}.
@@ -483,6 +486,7 @@ hooks: [no-force-push] # from hooks/ or the library: code at every tool cal
483
486
  scripts: [summarise.py] # from scripts/, each becomes a callable tool
484
487
  apis: [{ name: crm }] # an HTTP API, as a tool
485
488
  mcpServers: {} # an MCP server, as a tool
489
+ remotes: [] # outside A2A agents [{name, card, secret?}] — the a2a_send/status/reply/cancel tools
486
490
  agents: [writer] # colleagues this one may consult (an answer, no tools)
487
491
  subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
488
492
  sandbox: own # delegated to: run each job in its own sandbox (own tools, secrets, network)
@@ -14,6 +14,8 @@ import { frontmatterErrorText } from "./matter.ts";
14
14
  * the runtime, and there is nothing to fail to import.
15
15
  */
16
16
  export declare function readFrontmatter(file: string): Record<string, unknown>;
17
+ import { type A2aPublication } from "./a2a-publish.ts";
18
+ import { type RemoteSpec } from "./a2a-remotes.ts";
17
19
  import { type NetworkPolicy } from "./network.ts";
18
20
  import { type WireFormat, type AuthShape } from "./providers.ts";
19
21
  /**
@@ -306,6 +308,9 @@ export interface AgentInfo {
306
308
  effort: string | null;
307
309
  tools: string[];
308
310
  apis: ApiSpec[];
311
+ /** `remotes:` — the outside A2A agents it may call, the entries that read
312
+ * (a2a-remotes.ts). Never a token: names, card URLs, secret NAMES. */
313
+ remotes?: RemoteSpec[];
309
314
  /** The author's own tools named in `tools:` — the subset that is not a
310
315
  * built-in. Same list as `tools`, minus what the runtime provides. */
311
316
  ownTools: string[];
@@ -652,6 +657,10 @@ export interface FlowInfo {
652
657
  owner: string | null;
653
658
  /** Which of the three `owner` came from. */
654
659
  ownerFrom: OwnerSource | null;
660
+ /** `a2a:` — whether outside agents may call this flow over A2A, and what
661
+ * its agent card says (a2a-publish.ts). Null when absent or unreadable:
662
+ * a block with a problem publishes nothing. */
663
+ a2a?: A2aPublication | null;
655
664
  /** Flow-level frontmatter values the parser could not read — a bad
656
665
  * `overlap:`/`priority:`/`catchup:`/`on:`/`signature:`, or a step line
657
666
  * whose `[[target]]` is malformed. Errors, the way a bad step option is,
@@ -1445,6 +1454,11 @@ export interface RunRecord {
1445
1454
  * expressed only as a status, because "failed" and "someone stopped it"
1446
1455
  * are different facts and the trace should not conflate them. */
1447
1456
  stopRequested?: boolean;
1457
+ /** Who stopped it, as the trail says it, when that was not a person
1458
+ * stopping it from the dashboard, the CLI or the API: "A2A caller
1459
+ * <key label>" for an A2A CancelTask (1 Nov 2026). Absent: a person.
1460
+ * Written by stopRun beside stopRequested, carried the same way. */
1461
+ stoppedBy?: string;
1448
1462
  /**
1449
1463
  * What this run concluded, in one line — the first line of the last step
1450
1464
  * that produced a result, captured when the run finishes.
@@ -1523,6 +1537,17 @@ export interface RunRecord {
1523
1537
  * step of this run — never the tokens. A link is good once; the second
1524
1538
  * click is a 409, not a second approval. Written by approvals.ts. */
1525
1539
  approvalLinksUsed?: string[];
1540
+ /** What started it, when that is not a person, a schedule or an event the
1541
+ * flow's own trigger names: "a2a" — an outside agent, over A2A (1 Nov
1542
+ * 2026). Absent for every other run. */
1543
+ trigger?: "a2a";
1544
+ /** For an A2A run: the A2A task it serves and the id of the A2A key (its
1545
+ * first id, kept across rotations) that asked. A correlation only — the
1546
+ * protocol's own state lives in the platform's a2a_tasks. */
1547
+ a2a?: {
1548
+ task: string;
1549
+ key: string;
1550
+ };
1526
1551
  steps: StepRecord[];
1527
1552
  }
1528
1553
  /** Merge one part (the input, on the call; the output, on its completion)
@@ -1643,6 +1668,12 @@ export interface RunSummary {
1643
1668
  revision?: string | null;
1644
1669
  /** A step is parked for a person — what the approvals banner counts. */
1645
1670
  awaitingApproval: boolean;
1671
+ /** RunRecord.trigger and .a2a: an outside agent started it. */
1672
+ trigger?: "a2a";
1673
+ a2a?: {
1674
+ task: string;
1675
+ key: string;
1676
+ };
1646
1677
  steps: RunSummaryStep[];
1647
1678
  }
1648
1679
  /** One run's row. */
@@ -1661,6 +1692,9 @@ export declare function listRunSummaries(tenant: string, workspace: string): Run
1661
1692
  export declare function listAllRunSummaries(tenant: string): (RunSummary & {
1662
1693
  workspace: string;
1663
1694
  })[];
1695
+ /** Who stopped a run, for a trail line: "a person" unless stopRun was told
1696
+ * otherwise (RunRecord.stoppedBy). */
1697
+ export declare function stoppedByText(run: Pick<RunRecord, "stoppedBy"> | null | undefined): string;
1664
1698
  /**
1665
1699
  * The status a person should see. "failed" and "someone stopped it" are
1666
1700
  * different facts — the record keeps them apart (stopRequested), and the