@foldrun/core 9.1.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 (44) 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/browser-pod.js +4 -0
  11. package/dist/src/completions.js +8 -8
  12. package/dist/src/frontmatter-fields.js +2 -0
  13. package/dist/src/frontmatter-schema.js +4 -0
  14. package/dist/src/limits.js +22 -4
  15. package/dist/src/run-container.d.ts +10 -1
  16. package/dist/src/run-container.js +17 -0
  17. package/dist/src/runner.d.ts +10 -0
  18. package/dist/src/runner.js +158 -14
  19. package/dist/src/scan.js +102 -5
  20. package/dist/src/secrets.d.ts +30 -0
  21. package/dist/src/secrets.js +41 -1
  22. package/dist/src/starter.js +5 -1
  23. package/dist/src/store.d.ts +34 -0
  24. package/dist/src/store.js +28 -7
  25. package/dist/src/subagents.d.ts +16 -0
  26. package/dist/src/subagents.js +23 -2
  27. package/package.json +4 -1
  28. package/src/a2a-client.ts +517 -0
  29. package/src/a2a-publish.ts +120 -0
  30. package/src/a2a-remotes.ts +225 -0
  31. package/src/a2a-tools.ts +919 -0
  32. package/src/api-tools.ts +20 -8
  33. package/src/browser-pod.ts +3 -0
  34. package/src/completions.ts +8 -8
  35. package/src/frontmatter-fields.ts +2 -0
  36. package/src/frontmatter-schema.ts +4 -0
  37. package/src/limits.ts +22 -4
  38. package/src/run-container.ts +22 -0
  39. package/src/runner.ts +152 -11
  40. package/src/scan.ts +123 -6
  41. package/src/secrets.ts +43 -1
  42. package/src/starter.ts +5 -1
  43. package/src/store.ts +47 -2
  44. package/src/subagents.ts +29 -2
package/src/runner.ts CHANGED
@@ -38,7 +38,7 @@ const isolatedRun = () => {
38
38
  return mode === "container" || (!!mode && mode in platform.isolation);
39
39
  };
40
40
  import { gatherConsults, buildConsultTools } from "./agent-tools.ts";
41
- import { DELEGATE_TOOL, gatherSubagents, ownSandbox, subagentPrompt, subagentTools, type DelegateSpec, type SubagentSpec } from "./subagents.ts";
41
+ import { DELEGATE_TOOL, gatherSubagents, ownSandbox, subagentPrompt, subagentTools, SUBAGENT_NO_REMOTES, type DelegateSpec, type SubagentSpec } from "./subagents.ts";
42
42
  import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel, readCopyBack } from "./delegate-tools.ts";
43
43
  import type { DelegationResult } from "./operator.ts";
44
44
  import type { StepDelegation } from "./store.ts";
@@ -91,14 +91,17 @@ import {
91
91
  adoptLegacyVersionKey,
92
92
  listAgents,
93
93
  isOneSegment,
94
+ stoppedByText,
94
95
  } from "./store.ts";
95
96
  import { canReadBack } from "./tool-output.ts";
96
97
  import { PromptLayers, promptCacheKey } from "./prompt-layers.ts";
97
98
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf, type Catalog, type TokenPrice } from "./catalog.ts";
98
99
  import { resolveSecrets, getSecret, materializeSecrets, secretSites, isClientCertValue, clientCertOf, withoutHeldCerts, isIdentityValue, identityOf } from "./secrets.ts";
99
- import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
100
+ import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, siteCovers, EGRESS_CA_ENV } from "./network.ts";
100
101
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.ts";
101
102
  import { buildApiTools, secretsUsedByApi } from "./api-tools.ts";
103
+ import { buildA2ATools } from "./a2a-tools.ts";
104
+ import { parseRemotes, remoteSecretNames, remoteGrantHost, remoteNetworkHosts, remotesPromptLines, A2A_TOOLS, type RemoteSpec } from "./a2a-remotes.ts";
102
105
  import { attachOperations, prefetchOpenApi } from "./openapi.ts";
103
106
  import { buildSearchTools, buildHistoryTools, buildDeskTools, digestRuns, type SearchRoot, type RunDigest } from "./context-tools.ts";
104
107
  import { startTranslator, translatorSpecFor, type TranslatorSpec } from "./translator.ts";
@@ -632,6 +635,12 @@ export function buildSubagentSpecs(o: {
632
635
  continue;
633
636
  }
634
637
  const tools = subagentTools(own.allowed, o.parentAllowed);
638
+ // remotes: are the parent's server and credentials; withheld here
639
+ // (subagents.ts isA2aTool), and the sub-agent and the step are told.
640
+ const remotesWithheld = own.remotes.length > 0;
641
+ if (remotesWithheld) {
642
+ o.push("info", `subagents: "${sub.name}" declares remotes: (${own.remotes.map((r) => r.name).join(", ")}), which it does not get inside this step — ${SUBAGENT_NO_REMOTES}`);
643
+ }
635
644
  if (!tools.length) {
636
645
  o.push("error", `subagents: "${sub.name}" holds none of its tools inside this step — it can only answer from its prompt (use agents: for that)`);
637
646
  }
@@ -645,7 +654,7 @@ export function buildSubagentSpecs(o: {
645
654
  parent: o.self,
646
655
  workspace: path.basename(o.workspaceRoot),
647
656
  shared: sharedInstructions(sub.dir, o.tenant),
648
- }),
657
+ }, { remotesWithheld }),
649
658
  tools,
650
659
  disallowedTools: refNames(own.front.disallowedTools),
651
660
  model: own.front.model ? resolveModel(own.front.model) : "inherit",
@@ -1134,6 +1143,10 @@ export function agentContext(
1134
1143
  // APIs — the agent's own definitions, plus workspace tools it granted by
1135
1144
  // name. Library tools are defined once and reused.
1136
1145
  const apis = parseApis(front.apis);
1146
+ // remotes: — outside A2A agents (a2a-remotes.ts). The tools are built
1147
+ // where the step runs (runStep, run-container.ts), like ask's: they keep
1148
+ // handles under the run and the step, which only the step knows.
1149
+ const remotes = parseRemotes(front.remotes);
1137
1150
  // Scripts declared inline on the agent, plus any script-type tool it
1138
1151
  // granted — one list grants both kinds.
1139
1152
  const scriptSpecs = parseScripts(front.scripts);
@@ -1212,6 +1225,10 @@ export function agentContext(
1212
1225
  if (apiTools.promptLines.length) {
1213
1226
  parts.add("apis", `# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
1214
1227
  }
1228
+ // Read-only (permissionMode: plan) holds no tool that sends; nor the line.
1229
+ if (remotes.length && front.permissionMode !== "plan") {
1230
+ parts.add("remotes", `# Outside agents you can call (A2A)\n\n${remotesPromptLines(remotes).join("\n")}`);
1231
+ }
1215
1232
 
1216
1233
  // Secrets an agent declared become env vars for its scripts and bash.
1217
1234
  //
@@ -1286,10 +1303,24 @@ export function agentContext(
1286
1303
  // One name once, however many places asked for it.
1287
1304
  declared.splice(0, declared.length, ...new Set(declared));
1288
1305
  const referenced = [...new Set(apis.flatMap(secretsUsedByApi))];
1306
+ // A remote's secret travels with the remote, as an API's does: naming it
1307
+ // under remotes: is enough. One nothing else in the step uses is resolved
1308
+ // apart, into remoteSecretEnv, and reaches the a2a tools alone — never
1309
+ // secretEnv, which every script, Bash, consult and the sandbox's
1310
+ // environment are built from, on either path. On the isolated path it is
1311
+ // then the egress proxy's to fill for the card's origin (runStep). A
1312
+ // secret the agent also declares, or an API also uses, is the agent's own
1313
+ // choice to expose, and stays where it was.
1314
+ const remoteSecrets = remoteSecretNames(remotes);
1315
+ const remoteOnlySecrets = remoteSecrets.filter((n) => !declared.includes(n) && !referenced.includes(n));
1289
1316
  const {
1290
1317
  env: secretEnv,
1291
1318
  from: secretScopes,
1292
1319
  } = resolveSecrets(tenant, [...new Set([...declared, ...referenced])], workspace);
1320
+ const { env: remoteSecretEnv, from: remoteScopes } = resolveSecrets(tenant, remoteOnlySecrets, workspace);
1321
+ // Which store each came from, for the trace's "secret X ← scope" lines and
1322
+ // the proxy's health keys.
1323
+ Object.assign(secretScopes, remoteScopes);
1293
1324
  const { missing: missingDeclared } = resolveSecrets(tenant, declared, workspace);
1294
1325
 
1295
1326
  // The step's pod size. Three classes, not a dial: a size is a price and a
@@ -1716,6 +1747,7 @@ export function agentContext(
1716
1747
  }
1717
1748
  }
1718
1749
  allowed.push(...apiTools.toolNames, ...scriptTools.toolNames);
1750
+ if (remotes.length) allowed.push(...A2A_TOOLS);
1719
1751
 
1720
1752
  // tools: [search] — every place a fact of this agent's could be, in the
1721
1753
  // same nearest-wins order the prompt lists them. tools: [history] — the
@@ -1813,8 +1845,18 @@ export function agentContext(
1813
1845
  // path, which serialises specs across the container boundary rather
1814
1846
  // than using the servers built here.
1815
1847
  apiSpecs: apis,
1848
+ // remotes: — the specs, and the secrets only they use (runStep binds
1849
+ // those to each card's origin).
1850
+ remotes,
1851
+ remoteOnlySecrets,
1852
+ // Never spread into an environment: the a2a tools' alone.
1853
+ remoteSecretEnv,
1816
1854
  scriptSpecs,
1817
- outward: outward || scriptSpecs.some((sc) => sc.outward),
1855
+ // A message to an outside agent acts outside the workspace as surely as
1856
+ // an outward tool: a retry after a failed check could have the same work
1857
+ // done twice, reworded past a2a_send's same-request guard. Granted only
1858
+ // (permissionMode: plan withholds the tools), so only then.
1859
+ outward: outward || scriptSpecs.some((sc) => sc.outward) || (remotes.length > 0 && finalAllowed.some((t) => A2A_TOOLS.includes(t))),
1818
1860
  searchRoots,
1819
1861
  historyDigest,
1820
1862
  deskDigest,
@@ -1861,7 +1903,7 @@ export function agentContext(
1861
1903
  // missing ones so the step can tell "you never made this" apart from
1862
1904
  // "it's right there with a typo in it".
1863
1905
  brokenTools: brokenToolReport(tenant, workspace),
1864
- missingSecrets: [...new Set([...missingDeclared, ...apiTools.missingSecrets])],
1906
+ missingSecrets: [...new Set([...missingDeclared, ...apiTools.missingSecrets, ...remoteSecrets.filter((n) => !(n in secretEnv) && !(n in remoteSecretEnv))])],
1865
1907
  };
1866
1908
  }
1867
1909
 
@@ -2299,6 +2341,7 @@ async function runStep(
2299
2341
  secretEnv, secretScopes, missingSecrets, missingTools, runtime,
2300
2342
  unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames,
2301
2343
  apiSpecs, scriptSpecs, brokenTools, size: agentSize,
2344
+ remotes, remoteOnlySecrets, remoteSecretEnv,
2302
2345
  providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning,
2303
2346
  searchChoice, fetchChoice, actionChoices, web, language, region,
2304
2347
  fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools,
@@ -2344,7 +2387,50 @@ async function runStep(
2344
2387
  // Populate before the first push: everything after this point may quote a
2345
2388
  // credential. Short values are skipped — a two-character secret would
2346
2389
  // redact half the English language, and anything that short is not one.
2347
- redactions = redactionsFor({ ...liveSecrets, ...providerSecrets, ...providerEnv });
2390
+ // remotes: — the credentials only they use, made live apart and never
2391
+ // merged into liveSecrets, which the scripts' and the sandbox's
2392
+ // environments are built from.
2393
+ const remoteLive = await materializeSecrets(remoteSecretEnv, { tenant, workspace: path.basename(workspaceRootOf(agentDir)) });
2394
+ const remoteValue = (name: string): string | undefined => remoteLive[name] ?? liveSecrets[name];
2395
+ redactions = redactionsFor({ ...liveSecrets, ...remoteLive, ...providerSecrets, ...providerEnv });
2396
+
2397
+ // remotes: a secret the vault binds to sites of its own is sent only to
2398
+ // them (egress.ts). A remote whose card is on none of them is not sent
2399
+ // it — not called at all — and the trace says why.
2400
+ const remoteBindings: Record<string, string[]> = remotes.length
2401
+ ? secretSites(tenant, remotes.flatMap((r) => (r.secret ? [r.secret] : [])), path.basename(workspaceRootOf(agentDir)))
2402
+ : {};
2403
+ // And the install's rule for a URL a tenant chose, decided here on the
2404
+ // host, which knows the install (a sandbox does not): https only where
2405
+ // untrusted fetches are guarded (platform.ts checkUntrustedUrl). A card a
2406
+ // hosted install would not call is not called from its sandbox either.
2407
+ const cardProblems = new Map<string, string>();
2408
+ for (const r of remotes) {
2409
+ const why = await platform.checkUntrustedUrl(r.card);
2410
+ if (why) cardProblems.set(r.name, why);
2411
+ }
2412
+ const stepRemotes: RemoteSpec[] = remotes.map((r) => {
2413
+ const refused = cardProblems.get(r.name);
2414
+ if (refused) {
2415
+ const why = `${r.name} cannot be called: ${refused}`;
2416
+ push("error", `remotes: ${why}`);
2417
+ return { ...r, unavailable: why };
2418
+ }
2419
+ const sites = r.secret ? remoteBindings[r.secret] : undefined;
2420
+ let hostname = "";
2421
+ try {
2422
+ hostname = new URL(r.card).hostname;
2423
+ } catch {
2424
+ // parseRemotes kept only readable URLs
2425
+ }
2426
+ if (!sites?.length || sites.some((site) => siteCovers(site, hostname))) return r;
2427
+ const why = `${r.name} cannot be called: its secret ${r.secret} is bound in the vault to ${sites.join(", ")}, not to ${hostname}. Bind it there too (\`foldrun secrets sites ${r.secret} …\`), or give the remote its own secret`;
2428
+ push("error", `remotes: ${why}`);
2429
+ return { ...r, unavailable: why };
2430
+ });
2431
+ if (stepRemotes.length) {
2432
+ push("info", `remotes: ${stepRemotes.map((r) => `${r.name} (${r.origin})`).join(", ")} — A2A 1.0 over JSON-RPC; only these origins, and a card naming another is refused`);
2433
+ }
2348
2434
 
2349
2435
  // One line for the clock: which zone this step works to, and which level
2350
2436
  // set it. "Why is this dated yesterday" should be answerable from the run.
@@ -2778,6 +2864,20 @@ async function runStep(
2778
2864
  for (const c of actionChoices) {
2779
2865
  if (c.provider && c.secret in liveSecrets) addGrant(grant, c.secret, liveSecrets[c.secret], c.host);
2780
2866
  }
2867
+ // remotes: each secret granted to its card's host and no other — the
2868
+ // origin the owner wrote. A card cannot move the call elsewhere
2869
+ // (a2a-client.ts validateCard), so the token can only go there.
2870
+ for (const r of stepRemotes) {
2871
+ const host = remoteGrantHost(r);
2872
+ const value = r.secret ? remoteValue(r.secret) : undefined;
2873
+ if (!host || !r.secret || r.unavailable || value === undefined) continue;
2874
+ addGrant(grant, r.secret, value, host);
2875
+ (grant.healthKeys ??= {})[r.secret] = healthKey(
2876
+ r.secret,
2877
+ secretScopes[r.secret] === "workspace" ? "workspace" : "account",
2878
+ path.basename(path.resolve(agentDir, "..", "..")),
2879
+ );
2880
+ }
2781
2881
  for (const api of apiSpecs) {
2782
2882
  const host = hostOf(api.base);
2783
2883
  if (!host) continue;
@@ -2814,6 +2914,8 @@ async function runStep(
2814
2914
  ...Object.values(mcpServers).map((m) => ("url" in m && typeof m.url === "string" ? hostOf(m.url) : null)).filter((h): h is string => !!h),
2815
2915
  ...Object.values(grant.secrets).flatMap((g) => g.hosts),
2816
2916
  ...Object.values(boundSites).flat(),
2917
+ // remotes: each card's own host — never one its card names.
2918
+ ...remoteNetworkHosts(stepRemotes),
2817
2919
  ],
2818
2920
  });
2819
2921
  grant.network = { level: network.level, hosts: network.hosts };
@@ -2904,7 +3006,10 @@ async function runStep(
2904
3006
  ];
2905
3007
  const declared = Object.keys(liveSecrets);
2906
3008
  // A bound secret is never materialised, whatever the step grants.
2907
- const unbound = declared.filter((name) => !bound.includes(name));
3009
+ // A secret only remotes: use is the proxy's to fill for the card's
3010
+ // origin, or crosses inside the remote's spec when there is no proxy —
3011
+ // never as an environment variable every program in the sandbox reads.
3012
+ const unbound = declared.filter((name) => !bound.includes(name) && !remoteOnlySecrets.includes(name));
2908
3013
  const materialise = (!lease || materialisers.length > 0) && unbound.length > 0;
2909
3014
  if (lease) {
2910
3015
  const boundNote = bound.length ? `${bound.join(", ")} bound to sites — filled by the egress proxy, never in the sandbox` : "";
@@ -2946,6 +3051,17 @@ async function runStep(
2946
3051
  // /identity door. Without a lease there is no door, and so no token.
2947
3052
  const identityDeclared = declared.filter((name) => typeof liveSecrets[name] === "string" && isIdentityValue(liveSecrets[name]!));
2948
3053
  for (const name of identityDeclared) delete sandboxSecrets[name];
3054
+ for (const name of remoteOnlySecrets) delete sandboxSecrets[name];
3055
+ // The remotes as they cross: with a lease the token is its `${NAME}`,
3056
+ // which the proxy fills for the card's host alone; without one (no
3057
+ // proxy configured) the value rides in the spec, as an API header's
3058
+ // does. A test run with no proxy carries none: it sends nothing.
3059
+ const remoteSpecs: RemoteSpec[] = stepRemotes.map((r) => {
3060
+ const value = r.secret ? remoteValue(r.secret) : undefined;
3061
+ if (!r.secret || r.unavailable || value === undefined) return r;
3062
+ if (lease) return { ...r, token: `\${${r.secret}}` };
3063
+ return testRun ? r : { ...r, token: value };
3064
+ });
2949
3065
  for (const name of identitiesHeld) {
2950
3066
  push("info", `test: ${name} withheld — no identity token on a test run`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
2951
3067
  }
@@ -3047,6 +3163,7 @@ async function runStep(
3047
3163
  // a bound one goes back to its ${NAME} for the proxy to fill.
3048
3164
  mcpServers: bound.length ? unbindMcpHeaders(mcpServers, bound, liveSecrets, grant) : mcpServers,
3049
3165
  apis: substitutedApis,
3166
+ ...(remoteSpecs.length && allowed.some((t) => A2A_TOOLS.includes(t)) ? { a2a: { remotes: remoteSpecs, runId: runId ?? null, step: stepIndex ?? null } } : {}),
3050
3167
  scripts: scriptSpecs,
3051
3168
  // The merged spec, not `front.runtime` — see the plan's `runtime`
3052
3169
  // field. A tool's own `runtime:` is part of what this step needs.
@@ -3495,6 +3612,18 @@ async function runStep(
3495
3612
  }
3496
3613
  // @file secrets become 0600 paths here (host run), cleaned up in the
3497
3614
  // finally — once, however many attempts follow.
3615
+ // remotes: the four tools, here in this process. No proxy: the token
3616
+ // goes straight to the card's origin, made live per request.
3617
+ const a2aTools = stepRemotes.length && allowed.some((t) => A2A_TOOLS.includes(t))
3618
+ ? buildA2ATools(tenant, stepRemotes, path.basename(workspaceRoot), { env: { ...secretEnv, ...remoteSecretEnv }, missing: [] }, {
3619
+ stateDir: path.join(workspaceRoot, "state", "a2a", step.agent),
3620
+ runId: runId ?? null,
3621
+ step: stepIndex ?? null,
3622
+ test: testRun,
3623
+ keepIn: { dir: agentDir, readable: () => canReadBack(allowed) },
3624
+ emit: (type, text) => push(type, text),
3625
+ })
3626
+ : null;
3498
3627
  const mat = materializeFileSecrets(agentDir, stepSecrets);
3499
3628
  fileDir = mat.dir;
3500
3629
  // install: pip and npm land in a directory of this step's own, gone in
@@ -3538,6 +3667,7 @@ async function runStep(
3538
3667
  ...(askTools ? { foldrun_ask: askTools.server } : {}),
3539
3668
  ...(peerTools ? { foldrun_peers: peerTools.server } : {}),
3540
3669
  ...(delegateTools?.server ? { foldrun_delegate: delegateTools.server } : {}),
3670
+ ...(a2aTools?.server ? { foldrun_a2a: a2aTools.server } : {}),
3541
3671
  ...(installTools ? { foldrun_install: installTools.server } : {}),
3542
3672
  ...mcpServers,
3543
3673
  },
@@ -3651,6 +3781,7 @@ async function runStep(
3651
3781
  step.stopReason = outcome.stopReason ?? null;
3652
3782
  for (const line of apiTools.drainLog()) push("info", `api: ${line}`);
3653
3783
  for (const line of scriptTools.drainLog()) push("info", `script: ${line}`);
3784
+ for (const line of a2aTools?.drainLog() ?? []) push("info", `a2a: ${line}`);
3654
3785
  }
3655
3786
  } catch (err) {
3656
3787
  step.status = "failed";
@@ -4196,6 +4327,9 @@ export function createFlowRun(
4196
4327
  tags: string[] = [],
4197
4328
  startedBy: string | null = null,
4198
4329
  opts: { test?: boolean } = {},
4330
+ /** An A2A task's correlation (foldrun-platform flow-admission.ts) — its
4331
+ * own trailing argument, so the signature only widened. */
4332
+ a2a?: { task: string; key: string },
4199
4333
  ): RunRecord {
4200
4334
  const run: RunRecord = {
4201
4335
  id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
@@ -4204,6 +4338,8 @@ export function createFlowRun(
4204
4338
  status,
4205
4339
  ...(startedBy ? { startedBy: startedBy.trim().toLowerCase() } : {}),
4206
4340
  ...(opts.test ? { test: true } : {}),
4341
+ // An outside agent asked, over A2A: the task it serves, and whose key.
4342
+ ...(a2a ? { trigger: "a2a" as const, a2a: { task: a2a.task, key: a2a.key } } : {}),
4207
4343
  startedAt: new Date().toISOString(),
4208
4344
  finishedAt: null,
4209
4345
  steps: steps.map((s) => ({
@@ -4439,7 +4575,11 @@ function driveRunInner(
4439
4575
  */
4440
4576
  const save = () => {
4441
4577
  const disk = run.stopRequested ? null : readRun(tenant, workspace, run.id);
4442
- if (disk?.stopRequested) run.stopRequested = true;
4578
+ if (disk?.stopRequested) {
4579
+ run.stopRequested = true;
4580
+ // Who stopped it (an A2A caller's cancel, say) travels with the flag.
4581
+ if (disk.stoppedBy) run.stoppedBy ??= disk.stoppedBy;
4582
+ }
4443
4583
  if (run.stopRequested) {
4444
4584
  if (run.status !== "completed" && run.status !== "failed") {
4445
4585
  run.status = "failed";
@@ -4891,6 +5031,7 @@ function driveRunInner(
4891
5031
  // this is what keeps the *next* group from starting.
4892
5032
  const stopCheck = readRun(tenant, workspace, run.id);
4893
5033
  if (stopCheck?.stopRequested) {
5034
+ if (stopCheck.stoppedBy) run.stoppedBy ??= stopCheck.stoppedBy;
4894
5035
  let skipped = 0;
4895
5036
  for (const s of run.steps) {
4896
5037
  if (s.status === "pending" || s.status === "running") {
@@ -4908,7 +5049,7 @@ function driveRunInner(
4908
5049
  witness?.events.push({
4909
5050
  t: new Date().toISOString(),
4910
5051
  type: "info",
4911
- text: `run stopped by a person between groups — ${skipped} remaining step${skipped === 1 ? "" : "s"} skipped`,
5052
+ text: `run stopped by ${stoppedByText(run)} between groups — ${skipped} remaining step${skipped === 1 ? "" : "s"} skipped`,
4912
5053
  });
4913
5054
  run.status = "failed";
4914
5055
  run.stopRequested = true;
@@ -5380,7 +5521,7 @@ function driveRunInner(
5380
5521
  if (run.stopRequested) {
5381
5522
  step.status = "skipped";
5382
5523
  step.skipReason = "run stopped";
5383
- step.events.push({ t: new Date().toISOString(), type: "info", text: "run stopped by a person while this step waited to start — skipped" });
5524
+ step.events.push({ t: new Date().toISOString(), type: "info", text: `run stopped by ${stoppedByText(run)} while this step waited to start — skipped` });
5384
5525
  save();
5385
5526
  gate.release(step);
5386
5527
  return;
@@ -5497,7 +5638,7 @@ function driveRunInner(
5497
5638
  step.events.push({
5498
5639
  t: new Date().toISOString(),
5499
5640
  type: "info",
5500
- text: "stopped by a person mid-step — its sandbox was destroyed, which is what ended this attempt; not retried",
5641
+ text: `stopped by ${stoppedByText(run)} mid-step — its sandbox was destroyed, which is what ended this attempt; not retried`,
5501
5642
  });
5502
5643
  save();
5503
5644
  break;
package/src/scan.ts CHANGED
@@ -31,6 +31,9 @@ import matter, { frontmatterErrorLine, frontmatterErrorText, parseLenient, confi
31
31
  import { refNames } from "./refs.ts";
32
32
  import { resolveNetwork } from "./network.ts";
33
33
  import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
34
+ import { parseRemotes } from "./a2a-remotes.ts";
35
+ import { isA2aPublished } from "./a2a-publish.ts";
36
+ import { parseFlow, type FlowStep } from "./store.ts";
34
37
 
35
38
  export type ScanLevel = "error" | "warn" | "info";
36
39
  export type ScanCategory = "secret" | "security" | "content" | "supply-chain" | "config";
@@ -137,6 +140,31 @@ export const SCAN_RULES: readonly ScanRule[] = [
137
140
  "or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
138
141
  owasp: ["ASI01", "ASI02", "ASI03"],
139
142
  },
143
+ {
144
+ // 1 Nov 2026, with A2A: a published flow's first input is whatever an
145
+ // outside caller sends. Shipped as a warning, like every new rule.
146
+ id: "security/published-flow-trifecta",
147
+ category: "security",
148
+ level: "warn",
149
+ title: "A flow published over A2A runs an agent that holds private data and can send it out",
150
+ help:
151
+ "An outside agent's message is untrusted input to every step of a published flow. A step whose agent also holds a secret, " +
152
+ "an API or an MCP server, and can reach an open network, an outward tool or an API it may write to, can be told by the caller " +
153
+ "where to send what it holds. Take a leg away from that agent (network: { level: trusted, allow: [...] }, split it, a hooks: gate), " +
154
+ "or do not publish the flow.",
155
+ owasp: ["ASI01", "ASI02", "ASI03"],
156
+ },
157
+ {
158
+ id: "security/published-flow-ungated",
159
+ category: "security",
160
+ level: "warn",
161
+ title: "A flow published over A2A acts outside the workspace with no person in the way",
162
+ help:
163
+ "An outside caller can start a published flow as often as its key allows. A step that posts, sends, writes to an API or calls " +
164
+ "another outside agent, with no approval (`!`) or `ask:` before it or on it, does what any caller asked. Put a gate before the " +
165
+ "first such step — an approval is a person reading what it will do.",
166
+ owasp: ["ASI02", "ASI09"],
167
+ },
140
168
  {
141
169
  id: "security/subagent-wider",
142
170
  category: "security",
@@ -720,8 +748,20 @@ interface ToolFacts {
720
748
  lines: string[];
721
749
  }
722
750
 
723
- function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<string, ToolFacts>, push: Push, networks: { account?: unknown; workspace?: unknown }) {
751
+ /** What one agent can reach, as the trifecta and the published-flow rules
752
+ * read it: each leg's items, for a message. */
753
+ interface AgentLegs {
754
+ privateLeg: string[];
755
+ outLeg: string[];
756
+ /** What it can do outside the workspace: outward tools, API writes, sends
757
+ * to outside agents. */
758
+ writes: string[];
759
+ gated: boolean;
760
+ }
761
+
762
+ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<string, ToolFacts>, push: Push, networks: { account?: unknown; workspace?: unknown }): Map<string, AgentLegs> {
724
763
  const byName = new Map(agents.map((a) => [a.name, a]));
764
+ const legs = new Map<string, AgentLegs>();
725
765
  for (const a of agents) {
726
766
  const d = a.data;
727
767
  const tools = refNames(d.tools);
@@ -731,6 +771,11 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
731
771
  // Untrusted: it reads the open web. (A webhook body is the other source;
732
772
  // that is a flow's trigger, and the flow-level rule is a later one.)
733
773
  const readsWeb = has("web") || tools.some((t) => /browse|fetch|scrape|crawl/i.test(t) && has(t));
774
+ // An outside agent's reply is untrusted too, and a message to one is a
775
+ // way out (remotes:). Its own token is not private data the agent holds:
776
+ // the proxy fills it, for that remote's origin alone.
777
+ const remotes = parseRemotes(d.remotes);
778
+ const untrusted = [...(readsWeb ? ["reads the web"] : []), ...(remotes.length ? [`reads replies from outside agents (${remotes.slice(0, 3).map((r) => r.name).join(", ")}${remotes.length > 3 ? ", …" : ""})`] : [])];
734
779
  // Private: a secret it spends itself, an API or MCP server it holds.
735
780
  const secrets = Array.isArray(d.secrets) ? d.secrets.map(String) : [];
736
781
  const apis = Array.isArray(d.apis) ? d.apis : [];
@@ -743,14 +788,16 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
743
788
  const openNet = resolveNetwork({ account: networks.account, workspace: networks.workspace, agent: d.network, readsWeb }).level === "open";
744
789
  const outwardTools = tools.filter((t) => outward.has(t) && !denied.has(t));
745
790
  const writeApis = apis.filter((x: any) => WRITE_METHODS.test(JSON.stringify(x?.methods ?? x?.method ?? "")));
746
- const outLeg = [...(openNet ? ["an open network"] : []), ...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x: any) => `writes to api ${x?.name ?? "?"}`)];
791
+ const writes = [...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x: any) => `writes to api ${x?.name ?? "?"}`), ...remotes.map((r) => `outside agent ${r.name}`)];
792
+ const outLeg = [...(openNet ? ["an open network"] : []), ...writes];
747
793
  const gated = refNames(d.hooks).length > 0 || d.permissionMode === "plan";
794
+ legs.set(a.name, { privateLeg, outLeg, writes, gated });
748
795
 
749
- if (readsWeb && privateLeg.length && outLeg.length && !gated) {
796
+ if (untrusted.length && privateLeg.length && outLeg.length && !gated) {
750
797
  push(
751
798
  "security/lethal-trifecta", a.file, a.line, `trifecta ${a.name}`,
752
- `${a.name} reads the web, holds ${privateLeg.slice(0, 3).join(", ")}${privateLeg.length > 3 ? ", …" : ""} and can reach ${outLeg.slice(0, 2).join(" and ")} — ` +
753
- `a page it reads can tell it where to send what it holds. Take it off the open network (network: { level: trusted, allow: [...] }), split it, or add a hooks: gate`,
799
+ `${a.name} ${untrusted.join(" and ")}, holds ${privateLeg.slice(0, 3).join(", ")}${privateLeg.length > 3 ? ", …" : ""} and can reach ${outLeg.slice(0, 2).join(" and ")} — ` +
800
+ `${readsWeb ? "a page it reads" : "a reply it reads"} can tell it where to send what it holds. Take it off the open network (network: { level: trusted, allow: [...] }), split it, or add a hooks: gate`,
754
801
  );
755
802
  }
756
803
 
@@ -784,6 +831,72 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
784
831
  if (pkg) push("supply-chain/unpinned-mcp", file, keyLine(lines, "command"), `mcp ${pkg}`, `command runs \`${pkg}\` — whatever was published last; pin a version`);
785
832
  runtimePackages(def.runtime, file, lines, push);
786
833
  }
834
+ return legs;
835
+ }
836
+
837
+ // ── flows published over A2A ───────────────────────────────────────────────
838
+
839
+ interface FlowFacts {
840
+ file: string;
841
+ content: string;
842
+ lines: string[];
843
+ }
844
+
845
+ /** The agents a step may run: its own, a rescuer, the ones it delegates to. */
846
+ function stepAgents(s: FlowStep): string[] {
847
+ if (s.subflow) return [];
848
+ return [...new Set([s.agent, ...(s.onFail ? [s.onFail] : []), ...(s.delegate ?? [])].filter(Boolean))];
849
+ }
850
+
851
+ /**
852
+ * A flow with `a2a: {publish: true}` takes its first input from an outside
853
+ * caller. Two findings: a step agent the caller could steer into sending
854
+ * what it holds (the trifecta, with the caller as the untrusted leg), and a
855
+ * step that acts outside the workspace with no person before it.
856
+ */
857
+ function scanPublishedFlows(flows: FlowFacts[], legs: Map<string, AgentLegs>, push: Push) {
858
+ for (const fl of flows) {
859
+ let info: ReturnType<typeof parseFlow>;
860
+ try {
861
+ info = parseFlow(fl.file, fl.content);
862
+ } catch {
863
+ continue; // a flow that does not parse is check's finding
864
+ }
865
+ if (!isA2aPublished(info.a2a)) continue;
866
+ const at = keyLine(fl.lines, "a2a");
867
+ const said = new Set<string>();
868
+ for (const step of info.steps) {
869
+ for (const name of stepAgents(step)) {
870
+ const l = legs.get(name);
871
+ if (!l || said.has(name) || l.gated || !l.privateLeg.length || !l.outLeg.length) continue;
872
+ said.add(name);
873
+ push(
874
+ "security/published-flow-trifecta", fl.file, step.line ?? at, `published trifecta ${name}`,
875
+ `${info.name} is published over A2A, so what an outside caller sends reaches ${name}, which holds ${l.privateLeg.slice(0, 3).join(", ")}${l.privateLeg.length > 3 ? ", …" : ""} ` +
876
+ `and can reach ${l.outLeg.slice(0, 2).join(" and ")} — a caller can tell it where to send what it holds. Take a leg away from ${name}, or do not publish the flow`,
877
+ );
878
+ }
879
+ }
880
+ // Groups in order. A gate (an approval, or ask:, which is one with a
881
+ // question) on any step of a group parks the run before ANY step of that
882
+ // group launches (runner.ts driveRunInner), so it holds its parallel
883
+ // siblings as well as every group after.
884
+ let gated = false;
885
+ for (const g of [...new Set(info.steps.map((s) => s.group))].sort((a, b) => a - b)) {
886
+ const group = info.steps.filter((s) => s.group === g);
887
+ if (group.some((s) => s.approve || s.ask)) gated = true;
888
+ for (const s of group) {
889
+ if (gated) continue;
890
+ const writes = [...new Set(stepAgents(s).flatMap((n) => legs.get(n)?.writes ?? []))];
891
+ if (!writes.length) continue;
892
+ push(
893
+ "security/published-flow-ungated", fl.file, s.line ?? at, `published ungated ${g} ${s.agent}`,
894
+ `step ${g} ([[${s.agent}]]) can act outside the workspace (${writes.slice(0, 2).join(", ")}${writes.length > 2 ? ", …" : ""}) and ${info.name} is published over A2A, ` +
895
+ `with no approval or ask: before it — any caller with a key starts it. Put a gate (\`${g}!.\`) on it or before it`,
896
+ );
897
+ }
898
+ }
899
+ }
787
900
  }
788
901
 
789
902
  function runtimePackages(runtime: unknown, file: string, lines: string[], push: Push) {
@@ -916,6 +1029,7 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
916
1029
 
917
1030
  const outward = new Set(options.outwardTools ?? []);
918
1031
  const agents: AgentFacts[] = [];
1032
+ const flows: FlowFacts[] = [];
919
1033
  const toolDefs = new Map<string, ToolFacts>();
920
1034
 
921
1035
  for (const f of files.filter(isText)) {
@@ -970,6 +1084,8 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
970
1084
  const rel = f.path.replace(/^library\//, "");
971
1085
  const name = /^agents\/([^/]+)\//.exec(rel)?.[1] ?? rel.replace(/^.*\/|\.md$/g, "");
972
1086
  agents.push({ name, file: f.path, line: 1, data, lines });
1087
+ } else if (kind === "flow") {
1088
+ if (!f.path.startsWith("library/")) flows.push({ file: f.path, content: f.content, lines });
973
1089
  } else if (kind === "tool") {
974
1090
  const name = typeof data.name === "string" ? data.name : f.path.replace(/^(?:library\/)?tools\//, "").replace(/(?:\/tool)?\.md$/, "");
975
1091
  if (data.outward === true) outward.add(name);
@@ -978,7 +1094,8 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
978
1094
  }
979
1095
  // project.md is the older name the runtime still reads (Codex review, FOL-58).
980
1096
  const workspaceMd = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
981
- scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
1097
+ const legs = scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
1098
+ scanPublishedFlows(flows, legs, push);
982
1099
  for (const m of more.values()) {
983
1100
  findings.push({ rule: m.rule, level: m.level ?? scanRule(m.rule)?.level ?? "warn", file: m.file, message: `and ${m.n} more like this in this file, not listed`, fingerprint: m.hash.digest("hex").slice(0, 16), truncated: true });
984
1101
  }
package/src/secrets.ts CHANGED
@@ -278,6 +278,17 @@ function readFile(file: string): SecretsFile {
278
278
  }
279
279
 
280
280
  function lockVault(file: string): () => void {
281
+ return lockFileExclusive(file, "the vault is busy — another change is being saved; try again");
282
+ }
283
+
284
+ /**
285
+ * An exclusive lock on `file` for a read-change-write, across processes and
286
+ * pods: `<file>.lock` opened O_EXCL (NFS 4.1 and EFS honour it), taken over
287
+ * when older than LOCK_STALE_MS (a holder that died), waited for up to
288
+ * LOCK_WAIT_MS, then `busy` is thrown. Returns the release. The vault's,
289
+ * and any other small state file several runs write (a2a-tools' handles).
290
+ */
291
+ export function lockFileExclusive(file: string, busy = `${path.basename(file)} is busy — another writer holds it; try again`): () => void {
281
292
  const lock = `${file}.lock`;
282
293
  const deadline = Date.now() + LOCK_WAIT_MS;
283
294
  for (;;) {
@@ -296,7 +307,7 @@ function lockVault(file: string): () => void {
296
307
  } catch {
297
308
  continue; // released between the open and the stat
298
309
  }
299
- if (Date.now() > deadline) throw new Error("the vault is busy — another change is being saved; try again");
310
+ if (Date.now() > deadline) throw new Error(busy);
300
311
  sleep(25);
301
312
  }
302
313
  }
@@ -1176,6 +1187,37 @@ export async function materializeSecrets(
1176
1187
  return out;
1177
1188
  }
1178
1189
 
1190
+ /**
1191
+ * materializeSecrets for a tool that holds secrets for a whole step and
1192
+ * makes them live per call (the API tools, the a2a tools), on a host with
1193
+ * the vault.
1194
+ *
1195
+ * Two things the step's copy of a recipe cannot do alone. A provider that
1196
+ * rotates refresh tokens has spent the refresh token the step started with —
1197
+ * the runner materialised it at step start, and any refresh since spent its
1198
+ * successor — so an oauth2 recipe is read again from the vault as it is now
1199
+ * (and `env` updated in place, for the next call). And it is made live in
1200
+ * the account's scope, as the runner and the egress proxy do: the cached
1201
+ * access token is keyed by account, and a rotation is written back to the
1202
+ * vault by the compare-and-set above. Refreshing the starting recipe outside
1203
+ * that scope missed the cache and sent a spent refresh token: invalid_grant.
1204
+ *
1205
+ * No `scope` (inside a sandbox, where there is no vault and the values
1206
+ * crossed already made live): materializeSecrets as it was.
1207
+ */
1208
+ export async function materializeCurrent(
1209
+ env: Record<string, string>,
1210
+ scope?: { tenant: string; workspace?: string } | null,
1211
+ ): Promise<Record<string, string>> {
1212
+ if (!scope?.tenant) return materializeSecrets(env);
1213
+ for (const [name, value] of Object.entries(env)) {
1214
+ if (typeof value !== "string" || !isOAuth2Value(value)) continue;
1215
+ const current = getSecret(scope.tenant, name, scope.workspace)?.value;
1216
+ if (current && isOAuth2Value(current)) env[name] = current;
1217
+ }
1218
+ return materializeSecrets(env, { tenant: scope.tenant, ...(scope.workspace ? { workspace: scope.workspace } : {}) });
1219
+ }
1220
+
1179
1221
  // ---------------------------------------------------------------- service account
1180
1222
 
1181
1223
  // Signed-key credentials: a Google service account, or anything that mints a
package/src/starter.ts CHANGED
@@ -389,6 +389,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
389
389
  | \`/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) |
390
390
  | \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
391
391
  | \`/vocabulary\` | GET | what this workspace's documents may say |
392
+ | \`/a2a\` | GET | what it serves to outside agents over A2A: flows that say \`a2a:\`, card URLs, recent tasks |
392
393
  | \`/check\` | GET | what \`foldrun check\` says about the deployed copy, as findings with rule ids (\`?security=1\`) |
393
394
  | \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
394
395
 
@@ -398,7 +399,8 @@ Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/
398
399
  \`/api/me/inbox\` (what waits on you: gates, questions, issues, proposed fixes, your failed runs),
399
400
  \`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
400
401
  \`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
401
- and \`/api/metrics\` (Prometheus).
402
+ and \`/api/metrics\` (Prometheus). Outside \`/api\`: \`/a2a/<account>/<ws>/flows/<flow>\`
403
+ (a published flow's agent card and A2A 1.0 JSON-RPC endpoint — an A2A key only, \`foldrun docs a2a\`).
402
404
 
403
405
  Every call answers — and the CLI sends — \`Foldrun-Version: YYYY-MM-DD\` (dated API
404
406
  versions; an account pin in Settings → API), \`X-RateLimit-*\` headers (a \`429\`
@@ -434,6 +436,7 @@ effort: low
434
436
  overlap: skip # skip | queue — what a second run does while one is live
435
437
  priority: normal # high | normal | low — where its runs stand in the queue
436
438
  compute: { vcpu: 4, memory: 8Gi, disk: 40Gi } # optional: what one run may hold at once
439
+ a2a: { publish: false } # true: outside agents may call it over A2A (admin only; callers need an A2A key)
437
440
  ---
438
441
 
439
442
  1. [[researcher]] — Find three sources on {{topic}}.
@@ -499,6 +502,7 @@ hooks: [no-force-push] # from hooks/ or the library: code at every tool cal
499
502
  scripts: [summarise.py] # from scripts/, each becomes a callable tool
500
503
  apis: [{ name: crm }] # an HTTP API, as a tool
501
504
  mcpServers: {} # an MCP server, as a tool
505
+ remotes: [] # outside A2A agents [{name, card, secret?}] — the a2a_send/status/reply/cancel tools
502
506
  agents: [writer] # colleagues this one may consult (an answer, no tools)
503
507
  subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
504
508
  sandbox: own # delegated to: run each job in its own sandbox (own tools, secrets, network)