@foldrun/core 9.0.0 → 9.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/a2a-client.d.ts +165 -0
- package/dist/src/a2a-client.js +440 -0
- package/dist/src/a2a-publish.d.ts +37 -0
- package/dist/src/a2a-publish.js +116 -0
- package/dist/src/a2a-remotes.d.ts +73 -0
- package/dist/src/a2a-remotes.js +192 -0
- package/dist/src/a2a-tools.d.ts +148 -0
- package/dist/src/a2a-tools.js +833 -0
- package/dist/src/api-tools.js +15 -10
- package/dist/src/approvals.d.ts +18 -10
- package/dist/src/approvals.js +51 -1
- package/dist/src/browser-pod.js +4 -0
- package/dist/src/catalog.d.ts +14 -0
- package/dist/src/catalog.js +28 -0
- package/dist/src/completions.js +8 -8
- package/dist/src/cron.d.ts +9 -0
- package/dist/src/cron.js +20 -0
- package/dist/src/frontmatter-fields.js +3 -1
- package/dist/src/frontmatter-schema.d.ts +5 -1
- package/dist/src/frontmatter-schema.js +44 -1
- package/dist/src/limits.js +22 -4
- package/dist/src/notify.d.ts +6 -3
- package/dist/src/notify.js +25 -12
- package/dist/src/package.d.ts +5 -1
- package/dist/src/package.js +5 -3
- package/dist/src/run-container.d.ts +10 -1
- package/dist/src/run-container.js +17 -0
- package/dist/src/runner.d.ts +10 -0
- package/dist/src/runner.js +163 -16
- package/dist/src/scan.js +123 -5
- package/dist/src/secrets.d.ts +122 -10
- package/dist/src/secrets.js +291 -27
- package/dist/src/starter.js +5 -1
- package/dist/src/store.d.ts +34 -0
- package/dist/src/store.js +28 -7
- package/dist/src/subagents.d.ts +16 -0
- package/dist/src/subagents.js +23 -2
- package/dist/src/triggers.js +2 -2
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +14 -5
- package/package.json +4 -1
- package/src/a2a-client.ts +517 -0
- package/src/a2a-publish.ts +120 -0
- package/src/a2a-remotes.ts +225 -0
- package/src/a2a-tools.ts +919 -0
- package/src/api-tools.ts +20 -8
- package/src/approvals.ts +55 -1
- package/src/browser-pod.ts +3 -0
- package/src/catalog.ts +27 -0
- package/src/completions.ts +8 -8
- package/src/cron.ts +19 -0
- package/src/frontmatter-fields.ts +3 -1
- package/src/frontmatter-schema.ts +42 -2
- package/src/limits.ts +22 -4
- package/src/notify.ts +23 -12
- package/src/package.ts +5 -3
- package/src/run-container.ts +22 -0
- package/src/runner.ts +157 -13
- package/src/scan.ts +146 -6
- package/src/secrets.ts +319 -33
- package/src/starter.ts +5 -1
- package/src/store.ts +47 -2
- package/src/subagents.ts +29 -2
- package/src/triggers.ts +2 -2
- package/src/webhook.ts +22 -5
package/src/run-container.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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;
|