@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.
- 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/browser-pod.js +4 -0
- package/dist/src/completions.js +8 -8
- package/dist/src/frontmatter-fields.js +2 -0
- package/dist/src/frontmatter-schema.js +4 -0
- package/dist/src/limits.js +22 -4
- 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 +158 -14
- package/dist/src/scan.js +102 -5
- package/dist/src/secrets.d.ts +30 -0
- package/dist/src/secrets.js +41 -1
- 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/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/browser-pod.ts +3 -0
- package/src/completions.ts +8 -8
- package/src/frontmatter-fields.ts +2 -0
- package/src/frontmatter-schema.ts +4 -0
- package/src/limits.ts +22 -4
- package/src/run-container.ts +22 -0
- package/src/runner.ts +152 -11
- package/src/scan.ts +123 -6
- package/src/secrets.ts +43 -1
- package/src/starter.ts +5 -1
- package/src/store.ts +47 -2
- 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
|
-
|
|
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;
|
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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}
|
|
753
|
-
|
|
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(
|
|
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)
|