@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
@@ -44,6 +44,7 @@ import { isOperatorEvent, type OperatorEvent } from "./operator.ts";
44
44
  import type { SearchRoot, RunDigest } from "./context-tools.ts";
45
45
  import type { TranslatorSpec } from "./translator.ts";
46
46
  import { parseBrowserPod, type BrowserPodOutcome } from "./browser-pod.ts";
47
+ import type { RemoteSpec } from "./a2a-remotes.ts";
47
48
 
48
49
  /** What crosses the boundary. Everything in here is values — pre-resolved
49
50
  * secrets in headers, assembled prompt, serializable MCP configs. */
@@ -61,6 +62,10 @@ export interface ContainerStepInput {
61
62
  /** http/stdio configs only — in-process servers are rebuilt inside. */
62
63
  mcpServers: Record<string, McpServerConfig>;
63
64
  apis: ApiSpec[]; // headers pre-substituted from the vault, host-side
65
+ /** `remotes:` — outside A2A agents, each spec's `token` filled host-side
66
+ * (`${NAME}` for the egress proxy, or the value with no proxy), and the
67
+ * run and step its handles are kept under (a2a-tools.ts). */
68
+ a2a?: { remotes: RemoteSpec[]; runId: string | null; step: number | null };
64
69
  scripts: ScriptSpec[];
65
70
  /** The agent's `runtime:` declaration — the driver builds the venv/npm
66
71
  * prefix inside the container, where the network is. Without this,
@@ -749,6 +754,7 @@ try {
749
754
  process.env.FOLDRUN_DATA = "/home/agent/.foldrun";
750
755
  const { executeStep } = await import("@foldrun/core/step-exec");
751
756
  const { buildApiTools } = await import("@foldrun/core/api-tools");
757
+ const { buildA2ATools } = await import("@foldrun/core/a2a-tools");
752
758
  const { buildScriptTools } = await import("@foldrun/core/script-tools");
753
759
  const { buildConsultTools } = await import("@foldrun/core/agent-tools");
754
760
  const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import("@foldrun/core/context-tools");
@@ -825,6 +831,20 @@ try {
825
831
  const toolEnv = { ...env };
826
832
  for (const [k, v] of Object.entries(env)) if (k.startsWith("FOLDRUN_DECLARED_")) toolEnv[k.slice("FOLDRUN_DECLARED_".length)] = v;
827
833
  const api = buildApiTools("", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);
834
+ // remotes: — the four a2a tools, rebuilt from values. Each token crossed in
835
+ // its spec (a placeholder the proxy fills for the card's origin alone), so
836
+ // nothing here asks a vault; handles go under the workspace's state/.
837
+ const a2a = input.a2a && Array.isArray(input.a2a.remotes) && input.a2a.remotes.length
838
+ ? buildA2ATools("", input.a2a.remotes, undefined, { env: {}, missing: [] }, {
839
+ stateDir: "/workspace/state/a2a/" + input.agentRel.split("/").pop(),
840
+ runId: input.a2a.runId,
841
+ step: input.a2a.step,
842
+ egress: env.FOLDRUN_EGRESS || null,
843
+ test: env.FOLDRUN_TEST_MODE === "1",
844
+ keepIn,
845
+ emit,
846
+ })
847
+ : { server: null, drainLog: () => [] };
828
848
  const script = buildScriptTools(
829
849
  agentDir,
830
850
  input.scripts,
@@ -901,6 +921,7 @@ try {
901
921
  mcpNames: input.mcpNames,
902
922
  mcpServers: {
903
923
  ...(api.server ? { foldrun_apis: api.server } : {}),
924
+ ...(a2a.server ? { foldrun_a2a: a2a.server } : {}),
904
925
  ...(script.server ? { foldrun_scripts: script.server } : {}),
905
926
  ...(consult.server ? { foldrun_agents: consult.server } : {}),
906
927
  ...(search.server ? { foldrun_search: search.server } : {}),
@@ -940,6 +961,7 @@ try {
940
961
  emit,
941
962
  });
942
963
  for (const line of api.drainLog()) emit("info", "api: " + line);
964
+ for (const line of a2a.drainLog()) emit("info", "a2a: " + line);
943
965
  for (const line of script.drainLog()) emit("info", "script: " + line);
944
966
  if (translator) {
945
967
  for (const line of translator.drainLog()) emit("info", line.startsWith("translator") ? line : "translator: " + line);
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;
@@ -5040,15 +5181,18 @@ function driveRunInner(
5040
5181
  if (needsApproval.length) {
5041
5182
  for (const step of needsApproval) {
5042
5183
  step.status = "awaiting-approval";
5184
+ const t = new Date().toISOString();
5043
5185
  step.events.push({
5044
- t: new Date().toISOString(),
5186
+ t,
5045
5187
  type: "info",
5046
5188
  // ask: is the same gate carrying a question — the answer
5047
5189
  // arrives as the approval note and rides into the prompt.
5190
+ // An event URL is this step's and this park's alone (30 Oct
5191
+ // 2026): the index and this line's `t` are in its token.
5048
5192
  text: step.ask
5049
5193
  ? `waiting for an answer: ${step.ask}`
5050
5194
  : step.waitFor === "event"
5051
- ? `waiting for an external event — release it with POST ${eventUrl(tenant, workspace, run.id)}`
5195
+ ? `waiting for an external event — release it with POST ${eventUrl(tenant, workspace, run.id, { step: run.steps.indexOf(step), at: t })}`
5052
5196
  : "waiting for approval before running",
5053
5197
  });
5054
5198
  }
@@ -5377,7 +5521,7 @@ function driveRunInner(
5377
5521
  if (run.stopRequested) {
5378
5522
  step.status = "skipped";
5379
5523
  step.skipReason = "run stopped";
5380
- 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` });
5381
5525
  save();
5382
5526
  gate.release(step);
5383
5527
  return;
@@ -5494,7 +5638,7 @@ function driveRunInner(
5494
5638
  step.events.push({
5495
5639
  t: new Date().toISOString(),
5496
5640
  type: "info",
5497
- 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`,
5498
5642
  });
5499
5643
  save();
5500
5644
  break;