@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/dist/src/runner.js
CHANGED
|
@@ -35,20 +35,22 @@ const isolatedRun = () => {
|
|
|
35
35
|
return mode === "container" || (!!mode && mode in platform.isolation);
|
|
36
36
|
};
|
|
37
37
|
import { gatherConsults, buildConsultTools } from "./agent-tools.js";
|
|
38
|
-
import { DELEGATE_TOOL, gatherSubagents, ownSandbox, subagentPrompt, subagentTools } from "./subagents.js";
|
|
38
|
+
import { DELEGATE_TOOL, gatherSubagents, ownSandbox, subagentPrompt, subagentTools, SUBAGENT_NO_REMOTES } from "./subagents.js";
|
|
39
39
|
import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel, readCopyBack } from "./delegate-tools.js";
|
|
40
40
|
import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine } from "./operator.js";
|
|
41
41
|
import { sendPlainNotification } from "./notify.js";
|
|
42
42
|
import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
|
|
43
43
|
import { refNames } from "./refs.js";
|
|
44
|
-
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isSplicedRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, isOneSegment, } from "./store.js";
|
|
44
|
+
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isSplicedRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, isOneSegment, stoppedByText, } from "./store.js";
|
|
45
45
|
import { canReadBack } from "./tool-output.js";
|
|
46
46
|
import { PromptLayers, promptCacheKey } from "./prompt-layers.js";
|
|
47
47
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf } from "./catalog.js";
|
|
48
48
|
import { resolveSecrets, getSecret, materializeSecrets, secretSites, isClientCertValue, clientCertOf, withoutHeldCerts, isIdentityValue, identityOf } from "./secrets.js";
|
|
49
|
-
import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.js";
|
|
49
|
+
import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, siteCovers, EGRESS_CA_ENV } from "./network.js";
|
|
50
50
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
|
|
51
51
|
import { buildApiTools, secretsUsedByApi } from "./api-tools.js";
|
|
52
|
+
import { buildA2ATools } from "./a2a-tools.js";
|
|
53
|
+
import { parseRemotes, remoteSecretNames, remoteGrantHost, remoteNetworkHosts, remotesPromptLines, A2A_TOOLS } from "./a2a-remotes.js";
|
|
52
54
|
import { attachOperations, prefetchOpenApi } from "./openapi.js";
|
|
53
55
|
import { buildSearchTools, buildHistoryTools, buildDeskTools, digestRuns } from "./context-tools.js";
|
|
54
56
|
import { startTranslator, translatorSpecFor } from "./translator.js";
|
|
@@ -539,6 +541,12 @@ export function buildSubagentSpecs(o) {
|
|
|
539
541
|
continue;
|
|
540
542
|
}
|
|
541
543
|
const tools = subagentTools(own.allowed, o.parentAllowed);
|
|
544
|
+
// remotes: are the parent's server and credentials; withheld here
|
|
545
|
+
// (subagents.ts isA2aTool), and the sub-agent and the step are told.
|
|
546
|
+
const remotesWithheld = own.remotes.length > 0;
|
|
547
|
+
if (remotesWithheld) {
|
|
548
|
+
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}`);
|
|
549
|
+
}
|
|
542
550
|
if (!tools.length) {
|
|
543
551
|
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)`);
|
|
544
552
|
}
|
|
@@ -552,7 +560,7 @@ export function buildSubagentSpecs(o) {
|
|
|
552
560
|
parent: o.self,
|
|
553
561
|
workspace: path.basename(o.workspaceRoot),
|
|
554
562
|
shared: sharedInstructions(sub.dir, o.tenant),
|
|
555
|
-
}),
|
|
563
|
+
}, { remotesWithheld }),
|
|
556
564
|
tools,
|
|
557
565
|
disallowedTools: refNames(own.front.disallowedTools),
|
|
558
566
|
model: own.front.model ? resolveModel(own.front.model) : "inherit",
|
|
@@ -995,6 +1003,10 @@ grantsOnly = false) {
|
|
|
995
1003
|
// APIs — the agent's own definitions, plus workspace tools it granted by
|
|
996
1004
|
// name. Library tools are defined once and reused.
|
|
997
1005
|
const apis = parseApis(front.apis);
|
|
1006
|
+
// remotes: — outside A2A agents (a2a-remotes.ts). The tools are built
|
|
1007
|
+
// where the step runs (runStep, run-container.ts), like ask's: they keep
|
|
1008
|
+
// handles under the run and the step, which only the step knows.
|
|
1009
|
+
const remotes = parseRemotes(front.remotes);
|
|
998
1010
|
// Scripts declared inline on the agent, plus any script-type tool it
|
|
999
1011
|
// granted — one list grants both kinds.
|
|
1000
1012
|
const scriptSpecs = parseScripts(front.scripts);
|
|
@@ -1075,6 +1087,10 @@ grantsOnly = false) {
|
|
|
1075
1087
|
if (apiTools.promptLines.length) {
|
|
1076
1088
|
parts.add("apis", `# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
|
|
1077
1089
|
}
|
|
1090
|
+
// Read-only (permissionMode: plan) holds no tool that sends; nor the line.
|
|
1091
|
+
if (remotes.length && front.permissionMode !== "plan") {
|
|
1092
|
+
parts.add("remotes", `# Outside agents you can call (A2A)\n\n${remotesPromptLines(remotes).join("\n")}`);
|
|
1093
|
+
}
|
|
1078
1094
|
// Secrets an agent declared become env vars for its scripts and bash.
|
|
1079
1095
|
//
|
|
1080
1096
|
// Plus the credentials its API tools reference. An agent that opted into
|
|
@@ -1152,7 +1168,21 @@ grantsOnly = false) {
|
|
|
1152
1168
|
// One name once, however many places asked for it.
|
|
1153
1169
|
declared.splice(0, declared.length, ...new Set(declared));
|
|
1154
1170
|
const referenced = [...new Set(apis.flatMap(secretsUsedByApi))];
|
|
1171
|
+
// A remote's secret travels with the remote, as an API's does: naming it
|
|
1172
|
+
// under remotes: is enough. One nothing else in the step uses is resolved
|
|
1173
|
+
// apart, into remoteSecretEnv, and reaches the a2a tools alone — never
|
|
1174
|
+
// secretEnv, which every script, Bash, consult and the sandbox's
|
|
1175
|
+
// environment are built from, on either path. On the isolated path it is
|
|
1176
|
+
// then the egress proxy's to fill for the card's origin (runStep). A
|
|
1177
|
+
// secret the agent also declares, or an API also uses, is the agent's own
|
|
1178
|
+
// choice to expose, and stays where it was.
|
|
1179
|
+
const remoteSecrets = remoteSecretNames(remotes);
|
|
1180
|
+
const remoteOnlySecrets = remoteSecrets.filter((n) => !declared.includes(n) && !referenced.includes(n));
|
|
1155
1181
|
const { env: secretEnv, from: secretScopes, } = resolveSecrets(tenant, [...new Set([...declared, ...referenced])], workspace);
|
|
1182
|
+
const { env: remoteSecretEnv, from: remoteScopes } = resolveSecrets(tenant, remoteOnlySecrets, workspace);
|
|
1183
|
+
// Which store each came from, for the trace's "secret X ← scope" lines and
|
|
1184
|
+
// the proxy's health keys.
|
|
1185
|
+
Object.assign(secretScopes, remoteScopes);
|
|
1156
1186
|
const { missing: missingDeclared } = resolveSecrets(tenant, declared, workspace);
|
|
1157
1187
|
// The step's pod size. Three classes, not a dial: a size is a price and a
|
|
1158
1188
|
// promise, and three of each is a menu while ten is a support queue. Default
|
|
@@ -1560,6 +1590,8 @@ grantsOnly = false) {
|
|
|
1560
1590
|
}
|
|
1561
1591
|
}
|
|
1562
1592
|
allowed.push(...apiTools.toolNames, ...scriptTools.toolNames);
|
|
1593
|
+
if (remotes.length)
|
|
1594
|
+
allowed.push(...A2A_TOOLS);
|
|
1563
1595
|
// tools: [search] — every place a fact of this agent's could be, in the
|
|
1564
1596
|
// same nearest-wins order the prompt lists them. tools: [history] — the
|
|
1565
1597
|
// workspace's finished runs, minus the one being driven. Both are values
|
|
@@ -1656,8 +1688,18 @@ grantsOnly = false) {
|
|
|
1656
1688
|
// path, which serialises specs across the container boundary rather
|
|
1657
1689
|
// than using the servers built here.
|
|
1658
1690
|
apiSpecs: apis,
|
|
1691
|
+
// remotes: — the specs, and the secrets only they use (runStep binds
|
|
1692
|
+
// those to each card's origin).
|
|
1693
|
+
remotes,
|
|
1694
|
+
remoteOnlySecrets,
|
|
1695
|
+
// Never spread into an environment: the a2a tools' alone.
|
|
1696
|
+
remoteSecretEnv,
|
|
1659
1697
|
scriptSpecs,
|
|
1660
|
-
|
|
1698
|
+
// A message to an outside agent acts outside the workspace as surely as
|
|
1699
|
+
// an outward tool: a retry after a failed check could have the same work
|
|
1700
|
+
// done twice, reworded past a2a_send's same-request guard. Granted only
|
|
1701
|
+
// (permissionMode: plan withholds the tools), so only then.
|
|
1702
|
+
outward: outward || scriptSpecs.some((sc) => sc.outward) || (remotes.length > 0 && finalAllowed.some((t) => A2A_TOOLS.includes(t))),
|
|
1661
1703
|
searchRoots,
|
|
1662
1704
|
historyDigest,
|
|
1663
1705
|
deskDigest,
|
|
@@ -1704,7 +1746,7 @@ grantsOnly = false) {
|
|
|
1704
1746
|
// missing ones so the step can tell "you never made this" apart from
|
|
1705
1747
|
// "it's right there with a typo in it".
|
|
1706
1748
|
brokenTools: brokenToolReport(tenant, workspace),
|
|
1707
|
-
missingSecrets: [...new Set([...missingDeclared, ...apiTools.missingSecrets])],
|
|
1749
|
+
missingSecrets: [...new Set([...missingDeclared, ...apiTools.missingSecrets, ...remoteSecrets.filter((n) => !(n in secretEnv) && !(n in remoteSecretEnv))])],
|
|
1708
1750
|
};
|
|
1709
1751
|
}
|
|
1710
1752
|
/**
|
|
@@ -2113,7 +2155,7 @@ mayDefer) {
|
|
|
2113
2155
|
// step: the generic HTTP tool still works without the typed ones.
|
|
2114
2156
|
for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant)))
|
|
2115
2157
|
push("error", `openapi: ${w}`);
|
|
2116
|
-
const { front, clockEnv, clock, systemPrompt, promptLayers, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, searchChoice, fetchChoice, actionChoices, web, language, region, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, ask, peers, translator, fallbackTranslator, outward, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
|
|
2158
|
+
const { front, clockEnv, clock, systemPrompt, promptLayers, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, remotes, remoteOnlySecrets, remoteSecretEnv, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, searchChoice, fetchChoice, actionChoices, web, language, region, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, ask, peers, translator, fallbackTranslator, outward, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
|
|
2117
2159
|
disposeRuntime = runtime.dispose;
|
|
2118
2160
|
canActOutward = outward;
|
|
2119
2161
|
// Sent again on every turn; with turns and the tools' result sizes, what
|
|
@@ -2150,7 +2192,52 @@ mayDefer) {
|
|
|
2150
2192
|
// Populate before the first push: everything after this point may quote a
|
|
2151
2193
|
// credential. Short values are skipped — a two-character secret would
|
|
2152
2194
|
// redact half the English language, and anything that short is not one.
|
|
2153
|
-
|
|
2195
|
+
// remotes: — the credentials only they use, made live apart and never
|
|
2196
|
+
// merged into liveSecrets, which the scripts' and the sandbox's
|
|
2197
|
+
// environments are built from.
|
|
2198
|
+
const remoteLive = await materializeSecrets(remoteSecretEnv, { tenant, workspace: path.basename(workspaceRootOf(agentDir)) });
|
|
2199
|
+
const remoteValue = (name) => remoteLive[name] ?? liveSecrets[name];
|
|
2200
|
+
redactions = redactionsFor({ ...liveSecrets, ...remoteLive, ...providerSecrets, ...providerEnv });
|
|
2201
|
+
// remotes: a secret the vault binds to sites of its own is sent only to
|
|
2202
|
+
// them (egress.ts). A remote whose card is on none of them is not sent
|
|
2203
|
+
// it — not called at all — and the trace says why.
|
|
2204
|
+
const remoteBindings = remotes.length
|
|
2205
|
+
? secretSites(tenant, remotes.flatMap((r) => (r.secret ? [r.secret] : [])), path.basename(workspaceRootOf(agentDir)))
|
|
2206
|
+
: {};
|
|
2207
|
+
// And the install's rule for a URL a tenant chose, decided here on the
|
|
2208
|
+
// host, which knows the install (a sandbox does not): https only where
|
|
2209
|
+
// untrusted fetches are guarded (platform.ts checkUntrustedUrl). A card a
|
|
2210
|
+
// hosted install would not call is not called from its sandbox either.
|
|
2211
|
+
const cardProblems = new Map();
|
|
2212
|
+
for (const r of remotes) {
|
|
2213
|
+
const why = await platform.checkUntrustedUrl(r.card);
|
|
2214
|
+
if (why)
|
|
2215
|
+
cardProblems.set(r.name, why);
|
|
2216
|
+
}
|
|
2217
|
+
const stepRemotes = remotes.map((r) => {
|
|
2218
|
+
const refused = cardProblems.get(r.name);
|
|
2219
|
+
if (refused) {
|
|
2220
|
+
const why = `${r.name} cannot be called: ${refused}`;
|
|
2221
|
+
push("error", `remotes: ${why}`);
|
|
2222
|
+
return { ...r, unavailable: why };
|
|
2223
|
+
}
|
|
2224
|
+
const sites = r.secret ? remoteBindings[r.secret] : undefined;
|
|
2225
|
+
let hostname = "";
|
|
2226
|
+
try {
|
|
2227
|
+
hostname = new URL(r.card).hostname;
|
|
2228
|
+
}
|
|
2229
|
+
catch {
|
|
2230
|
+
// parseRemotes kept only readable URLs
|
|
2231
|
+
}
|
|
2232
|
+
if (!sites?.length || sites.some((site) => siteCovers(site, hostname)))
|
|
2233
|
+
return r;
|
|
2234
|
+
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`;
|
|
2235
|
+
push("error", `remotes: ${why}`);
|
|
2236
|
+
return { ...r, unavailable: why };
|
|
2237
|
+
});
|
|
2238
|
+
if (stepRemotes.length) {
|
|
2239
|
+
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`);
|
|
2240
|
+
}
|
|
2154
2241
|
// One line for the clock: which zone this step works to, and which level
|
|
2155
2242
|
// set it. "Why is this dated yesterday" should be answerable from the run.
|
|
2156
2243
|
push("info", `clock: ${clock.timezone} (${clock.source}) — today is ${clockEnv.FOLDRUN_DATE}`);
|
|
@@ -2581,6 +2668,17 @@ mayDefer) {
|
|
|
2581
2668
|
if (c.provider && c.secret in liveSecrets)
|
|
2582
2669
|
addGrant(grant, c.secret, liveSecrets[c.secret], c.host);
|
|
2583
2670
|
}
|
|
2671
|
+
// remotes: each secret granted to its card's host and no other — the
|
|
2672
|
+
// origin the owner wrote. A card cannot move the call elsewhere
|
|
2673
|
+
// (a2a-client.ts validateCard), so the token can only go there.
|
|
2674
|
+
for (const r of stepRemotes) {
|
|
2675
|
+
const host = remoteGrantHost(r);
|
|
2676
|
+
const value = r.secret ? remoteValue(r.secret) : undefined;
|
|
2677
|
+
if (!host || !r.secret || r.unavailable || value === undefined)
|
|
2678
|
+
continue;
|
|
2679
|
+
addGrant(grant, r.secret, value, host);
|
|
2680
|
+
(grant.healthKeys ??= {})[r.secret] = healthKey(r.secret, secretScopes[r.secret] === "workspace" ? "workspace" : "account", path.basename(path.resolve(agentDir, "..", "..")));
|
|
2681
|
+
}
|
|
2584
2682
|
for (const api of apiSpecs) {
|
|
2585
2683
|
const host = hostOf(api.base);
|
|
2586
2684
|
if (!host)
|
|
@@ -2615,6 +2713,8 @@ mayDefer) {
|
|
|
2615
2713
|
...Object.values(mcpServers).map((m) => ("url" in m && typeof m.url === "string" ? hostOf(m.url) : null)).filter((h) => !!h),
|
|
2616
2714
|
...Object.values(grant.secrets).flatMap((g) => g.hosts),
|
|
2617
2715
|
...Object.values(boundSites).flat(),
|
|
2716
|
+
// remotes: each card's own host — never one its card names.
|
|
2717
|
+
...remoteNetworkHosts(stepRemotes),
|
|
2618
2718
|
],
|
|
2619
2719
|
});
|
|
2620
2720
|
grant.network = { level: network.level, hosts: network.hosts };
|
|
@@ -2701,7 +2801,10 @@ mayDefer) {
|
|
|
2701
2801
|
];
|
|
2702
2802
|
const declared = Object.keys(liveSecrets);
|
|
2703
2803
|
// A bound secret is never materialised, whatever the step grants.
|
|
2704
|
-
|
|
2804
|
+
// A secret only remotes: use is the proxy's to fill for the card's
|
|
2805
|
+
// origin, or crosses inside the remote's spec when there is no proxy —
|
|
2806
|
+
// never as an environment variable every program in the sandbox reads.
|
|
2807
|
+
const unbound = declared.filter((name) => !bound.includes(name) && !remoteOnlySecrets.includes(name));
|
|
2705
2808
|
const materialise = (!lease || materialisers.length > 0) && unbound.length > 0;
|
|
2706
2809
|
if (lease) {
|
|
2707
2810
|
const boundNote = bound.length ? `${bound.join(", ")} bound to sites — filled by the egress proxy, never in the sandbox` : "";
|
|
@@ -2741,6 +2844,20 @@ mayDefer) {
|
|
|
2741
2844
|
const identityDeclared = declared.filter((name) => typeof liveSecrets[name] === "string" && isIdentityValue(liveSecrets[name]));
|
|
2742
2845
|
for (const name of identityDeclared)
|
|
2743
2846
|
delete sandboxSecrets[name];
|
|
2847
|
+
for (const name of remoteOnlySecrets)
|
|
2848
|
+
delete sandboxSecrets[name];
|
|
2849
|
+
// The remotes as they cross: with a lease the token is its `${NAME}`,
|
|
2850
|
+
// which the proxy fills for the card's host alone; without one (no
|
|
2851
|
+
// proxy configured) the value rides in the spec, as an API header's
|
|
2852
|
+
// does. A test run with no proxy carries none: it sends nothing.
|
|
2853
|
+
const remoteSpecs = stepRemotes.map((r) => {
|
|
2854
|
+
const value = r.secret ? remoteValue(r.secret) : undefined;
|
|
2855
|
+
if (!r.secret || r.unavailable || value === undefined)
|
|
2856
|
+
return r;
|
|
2857
|
+
if (lease)
|
|
2858
|
+
return { ...r, token: `\${${r.secret}}` };
|
|
2859
|
+
return testRun ? r : { ...r, token: value };
|
|
2860
|
+
});
|
|
2744
2861
|
for (const name of identitiesHeld) {
|
|
2745
2862
|
push("info", `test: ${name} withheld — no identity token on a test run`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
2746
2863
|
}
|
|
@@ -2837,6 +2954,7 @@ mayDefer) {
|
|
|
2837
2954
|
// a bound one goes back to its ${NAME} for the proxy to fill.
|
|
2838
2955
|
mcpServers: bound.length ? unbindMcpHeaders(mcpServers, bound, liveSecrets, grant) : mcpServers,
|
|
2839
2956
|
apis: substitutedApis,
|
|
2957
|
+
...(remoteSpecs.length && allowed.some((t) => A2A_TOOLS.includes(t)) ? { a2a: { remotes: remoteSpecs, runId: runId ?? null, step: stepIndex ?? null } } : {}),
|
|
2840
2958
|
scripts: scriptSpecs,
|
|
2841
2959
|
// The merged spec, not `front.runtime` — see the plan's `runtime`
|
|
2842
2960
|
// field. A tool's own `runtime:` is part of what this step needs.
|
|
@@ -3286,6 +3404,18 @@ mayDefer) {
|
|
|
3286
3404
|
}
|
|
3287
3405
|
// @file secrets become 0600 paths here (host run), cleaned up in the
|
|
3288
3406
|
// finally — once, however many attempts follow.
|
|
3407
|
+
// remotes: the four tools, here in this process. No proxy: the token
|
|
3408
|
+
// goes straight to the card's origin, made live per request.
|
|
3409
|
+
const a2aTools = stepRemotes.length && allowed.some((t) => A2A_TOOLS.includes(t))
|
|
3410
|
+
? buildA2ATools(tenant, stepRemotes, path.basename(workspaceRoot), { env: { ...secretEnv, ...remoteSecretEnv }, missing: [] }, {
|
|
3411
|
+
stateDir: path.join(workspaceRoot, "state", "a2a", step.agent),
|
|
3412
|
+
runId: runId ?? null,
|
|
3413
|
+
step: stepIndex ?? null,
|
|
3414
|
+
test: testRun,
|
|
3415
|
+
keepIn: { dir: agentDir, readable: () => canReadBack(allowed) },
|
|
3416
|
+
emit: (type, text) => push(type, text),
|
|
3417
|
+
})
|
|
3418
|
+
: null;
|
|
3289
3419
|
const mat = materializeFileSecrets(agentDir, stepSecrets);
|
|
3290
3420
|
fileDir = mat.dir;
|
|
3291
3421
|
// install: pip and npm land in a directory of this step's own, gone in
|
|
@@ -3329,6 +3459,7 @@ mayDefer) {
|
|
|
3329
3459
|
...(askTools ? { foldrun_ask: askTools.server } : {}),
|
|
3330
3460
|
...(peerTools ? { foldrun_peers: peerTools.server } : {}),
|
|
3331
3461
|
...(delegateTools?.server ? { foldrun_delegate: delegateTools.server } : {}),
|
|
3462
|
+
...(a2aTools?.server ? { foldrun_a2a: a2aTools.server } : {}),
|
|
3332
3463
|
...(installTools ? { foldrun_install: installTools.server } : {}),
|
|
3333
3464
|
...mcpServers,
|
|
3334
3465
|
},
|
|
@@ -3445,6 +3576,8 @@ mayDefer) {
|
|
|
3445
3576
|
push("info", `api: ${line}`);
|
|
3446
3577
|
for (const line of scriptTools.drainLog())
|
|
3447
3578
|
push("info", `script: ${line}`);
|
|
3579
|
+
for (const line of a2aTools?.drainLog() ?? [])
|
|
3580
|
+
push("info", `a2a: ${line}`);
|
|
3448
3581
|
}
|
|
3449
3582
|
}
|
|
3450
3583
|
catch (err) {
|
|
@@ -3978,7 +4111,10 @@ export function readSchemaFile(workspaceRoot, rel) {
|
|
|
3978
4111
|
}
|
|
3979
4112
|
return null;
|
|
3980
4113
|
}
|
|
3981
|
-
export function createFlowRun(tenant, workspace, steps, flowName, status, tags = [], startedBy = null, opts = {}
|
|
4114
|
+
export function createFlowRun(tenant, workspace, steps, flowName, status, tags = [], startedBy = null, opts = {},
|
|
4115
|
+
/** An A2A task's correlation (foldrun-platform flow-admission.ts) — its
|
|
4116
|
+
* own trailing argument, so the signature only widened. */
|
|
4117
|
+
a2a) {
|
|
3982
4118
|
const run = {
|
|
3983
4119
|
id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
3984
4120
|
flow: flowName,
|
|
@@ -3986,6 +4122,8 @@ export function createFlowRun(tenant, workspace, steps, flowName, status, tags =
|
|
|
3986
4122
|
status,
|
|
3987
4123
|
...(startedBy ? { startedBy: startedBy.trim().toLowerCase() } : {}),
|
|
3988
4124
|
...(opts.test ? { test: true } : {}),
|
|
4125
|
+
// An outside agent asked, over A2A: the task it serves, and whose key.
|
|
4126
|
+
...(a2a ? { trigger: "a2a", a2a: { task: a2a.task, key: a2a.key } } : {}),
|
|
3989
4127
|
startedAt: new Date().toISOString(),
|
|
3990
4128
|
finishedAt: null,
|
|
3991
4129
|
steps: steps.map((s) => ({
|
|
@@ -4205,8 +4343,12 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4205
4343
|
*/
|
|
4206
4344
|
const save = () => {
|
|
4207
4345
|
const disk = run.stopRequested ? null : readRun(tenant, workspace, run.id);
|
|
4208
|
-
if (disk?.stopRequested)
|
|
4346
|
+
if (disk?.stopRequested) {
|
|
4209
4347
|
run.stopRequested = true;
|
|
4348
|
+
// Who stopped it (an A2A caller's cancel, say) travels with the flag.
|
|
4349
|
+
if (disk.stoppedBy)
|
|
4350
|
+
run.stoppedBy ??= disk.stoppedBy;
|
|
4351
|
+
}
|
|
4210
4352
|
if (run.stopRequested) {
|
|
4211
4353
|
if (run.status !== "completed" && run.status !== "failed") {
|
|
4212
4354
|
run.status = "failed";
|
|
@@ -4659,6 +4801,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4659
4801
|
// this is what keeps the *next* group from starting.
|
|
4660
4802
|
const stopCheck = readRun(tenant, workspace, run.id);
|
|
4661
4803
|
if (stopCheck?.stopRequested) {
|
|
4804
|
+
if (stopCheck.stoppedBy)
|
|
4805
|
+
run.stoppedBy ??= stopCheck.stoppedBy;
|
|
4662
4806
|
let skipped = 0;
|
|
4663
4807
|
for (const s of run.steps) {
|
|
4664
4808
|
if (s.status === "pending" || s.status === "running") {
|
|
@@ -4676,7 +4820,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4676
4820
|
witness?.events.push({
|
|
4677
4821
|
t: new Date().toISOString(),
|
|
4678
4822
|
type: "info",
|
|
4679
|
-
text: `run stopped by
|
|
4823
|
+
text: `run stopped by ${stoppedByText(run)} between groups — ${skipped} remaining step${skipped === 1 ? "" : "s"} skipped`,
|
|
4680
4824
|
});
|
|
4681
4825
|
run.status = "failed";
|
|
4682
4826
|
run.stopRequested = true;
|
|
@@ -5136,7 +5280,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
5136
5280
|
if (run.stopRequested) {
|
|
5137
5281
|
step.status = "skipped";
|
|
5138
5282
|
step.skipReason = "run stopped";
|
|
5139
|
-
step.events.push({ t: new Date().toISOString(), type: "info", text:
|
|
5283
|
+
step.events.push({ t: new Date().toISOString(), type: "info", text: `run stopped by ${stoppedByText(run)} while this step waited to start — skipped` });
|
|
5140
5284
|
save();
|
|
5141
5285
|
gate.release(step);
|
|
5142
5286
|
return;
|
|
@@ -5232,7 +5376,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
5232
5376
|
step.events.push({
|
|
5233
5377
|
t: new Date().toISOString(),
|
|
5234
5378
|
type: "info",
|
|
5235
|
-
text:
|
|
5379
|
+
text: `stopped by ${stoppedByText(run)} mid-step — its sandbox was destroyed, which is what ended this attempt; not retried`,
|
|
5236
5380
|
});
|
|
5237
5381
|
save();
|
|
5238
5382
|
break;
|
package/dist/src/scan.js
CHANGED
|
@@ -30,6 +30,9 @@ import matter, { frontmatterErrorLine, frontmatterErrorText, parseLenient, confi
|
|
|
30
30
|
import { refNames } from "./refs.js";
|
|
31
31
|
import { resolveNetwork } from "./network.js";
|
|
32
32
|
import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.js";
|
|
33
|
+
import { parseRemotes } from "./a2a-remotes.js";
|
|
34
|
+
import { isA2aPublished } from "./a2a-publish.js";
|
|
35
|
+
import { parseFlow } from "./store.js";
|
|
33
36
|
export const OWASP_URL = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026";
|
|
34
37
|
export const SCAN_RULES = [
|
|
35
38
|
{
|
|
@@ -81,6 +84,29 @@ export const SCAN_RULES = [
|
|
|
81
84
|
"or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
|
|
82
85
|
owasp: ["ASI01", "ASI02", "ASI03"],
|
|
83
86
|
},
|
|
87
|
+
{
|
|
88
|
+
// 1 Nov 2026, with A2A: a published flow's first input is whatever an
|
|
89
|
+
// outside caller sends. Shipped as a warning, like every new rule.
|
|
90
|
+
id: "security/published-flow-trifecta",
|
|
91
|
+
category: "security",
|
|
92
|
+
level: "warn",
|
|
93
|
+
title: "A flow published over A2A runs an agent that holds private data and can send it out",
|
|
94
|
+
help: "An outside agent's message is untrusted input to every step of a published flow. A step whose agent also holds a secret, " +
|
|
95
|
+
"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 " +
|
|
96
|
+
"where to send what it holds. Take a leg away from that agent (network: { level: trusted, allow: [...] }, split it, a hooks: gate), " +
|
|
97
|
+
"or do not publish the flow.",
|
|
98
|
+
owasp: ["ASI01", "ASI02", "ASI03"],
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "security/published-flow-ungated",
|
|
102
|
+
category: "security",
|
|
103
|
+
level: "warn",
|
|
104
|
+
title: "A flow published over A2A acts outside the workspace with no person in the way",
|
|
105
|
+
help: "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 " +
|
|
106
|
+
"another outside agent, with no approval (`!`) or `ask:` before it or on it, does what any caller asked. Put a gate before the " +
|
|
107
|
+
"first such step — an approval is a person reading what it will do.",
|
|
108
|
+
owasp: ["ASI02", "ASI09"],
|
|
109
|
+
},
|
|
84
110
|
{
|
|
85
111
|
id: "security/subagent-wider",
|
|
86
112
|
category: "security",
|
|
@@ -649,6 +675,7 @@ function docKind(p) {
|
|
|
649
675
|
const WRITE_METHODS = /\b(?:POST|PUT|PATCH|DELETE)\b/i;
|
|
650
676
|
function scanAgents(agents, outward, toolDefs, push, networks) {
|
|
651
677
|
const byName = new Map(agents.map((a) => [a.name, a]));
|
|
678
|
+
const legs = new Map();
|
|
652
679
|
for (const a of agents) {
|
|
653
680
|
const d = a.data;
|
|
654
681
|
const tools = refNames(d.tools);
|
|
@@ -657,6 +684,11 @@ function scanAgents(agents, outward, toolDefs, push, networks) {
|
|
|
657
684
|
// Untrusted: it reads the open web. (A webhook body is the other source;
|
|
658
685
|
// that is a flow's trigger, and the flow-level rule is a later one.)
|
|
659
686
|
const readsWeb = has("web") || tools.some((t) => /browse|fetch|scrape|crawl/i.test(t) && has(t));
|
|
687
|
+
// An outside agent's reply is untrusted too, and a message to one is a
|
|
688
|
+
// way out (remotes:). Its own token is not private data the agent holds:
|
|
689
|
+
// the proxy fills it, for that remote's origin alone.
|
|
690
|
+
const remotes = parseRemotes(d.remotes);
|
|
691
|
+
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 ? ", …" : ""})`] : [])];
|
|
660
692
|
// Private: a secret it spends itself, an API or MCP server it holds.
|
|
661
693
|
const secrets = Array.isArray(d.secrets) ? d.secrets.map(String) : [];
|
|
662
694
|
const apis = Array.isArray(d.apis) ? d.apis : [];
|
|
@@ -669,11 +701,13 @@ function scanAgents(agents, outward, toolDefs, push, networks) {
|
|
|
669
701
|
const openNet = resolveNetwork({ account: networks.account, workspace: networks.workspace, agent: d.network, readsWeb }).level === "open";
|
|
670
702
|
const outwardTools = tools.filter((t) => outward.has(t) && !denied.has(t));
|
|
671
703
|
const writeApis = apis.filter((x) => WRITE_METHODS.test(JSON.stringify(x?.methods ?? x?.method ?? "")));
|
|
672
|
-
const
|
|
704
|
+
const writes = [...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x) => `writes to api ${x?.name ?? "?"}`), ...remotes.map((r) => `outside agent ${r.name}`)];
|
|
705
|
+
const outLeg = [...(openNet ? ["an open network"] : []), ...writes];
|
|
673
706
|
const gated = refNames(d.hooks).length > 0 || d.permissionMode === "plan";
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
707
|
+
legs.set(a.name, { privateLeg, outLeg, writes, gated });
|
|
708
|
+
if (untrusted.length && privateLeg.length && outLeg.length && !gated) {
|
|
709
|
+
push("security/lethal-trifecta", a.file, a.line, `trifecta ${a.name}`, `${a.name} ${untrusted.join(" and ")}, holds ${privateLeg.slice(0, 3).join(", ")}${privateLeg.length > 3 ? ", …" : ""} and can reach ${outLeg.slice(0, 2).join(" and ")} — ` +
|
|
710
|
+
`${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`);
|
|
677
711
|
}
|
|
678
712
|
// Sub-agents declaring tools the parent lacks.
|
|
679
713
|
for (const s of refNames(d.subagents)) {
|
|
@@ -705,6 +739,63 @@ function scanAgents(agents, outward, toolDefs, push, networks) {
|
|
|
705
739
|
push("supply-chain/unpinned-mcp", file, keyLine(lines, "command"), `mcp ${pkg}`, `command runs \`${pkg}\` — whatever was published last; pin a version`);
|
|
706
740
|
runtimePackages(def.runtime, file, lines, push);
|
|
707
741
|
}
|
|
742
|
+
return legs;
|
|
743
|
+
}
|
|
744
|
+
/** The agents a step may run: its own, a rescuer, the ones it delegates to. */
|
|
745
|
+
function stepAgents(s) {
|
|
746
|
+
if (s.subflow)
|
|
747
|
+
return [];
|
|
748
|
+
return [...new Set([s.agent, ...(s.onFail ? [s.onFail] : []), ...(s.delegate ?? [])].filter(Boolean))];
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* A flow with `a2a: {publish: true}` takes its first input from an outside
|
|
752
|
+
* caller. Two findings: a step agent the caller could steer into sending
|
|
753
|
+
* what it holds (the trifecta, with the caller as the untrusted leg), and a
|
|
754
|
+
* step that acts outside the workspace with no person before it.
|
|
755
|
+
*/
|
|
756
|
+
function scanPublishedFlows(flows, legs, push) {
|
|
757
|
+
for (const fl of flows) {
|
|
758
|
+
let info;
|
|
759
|
+
try {
|
|
760
|
+
info = parseFlow(fl.file, fl.content);
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
continue; // a flow that does not parse is check's finding
|
|
764
|
+
}
|
|
765
|
+
if (!isA2aPublished(info.a2a))
|
|
766
|
+
continue;
|
|
767
|
+
const at = keyLine(fl.lines, "a2a");
|
|
768
|
+
const said = new Set();
|
|
769
|
+
for (const step of info.steps) {
|
|
770
|
+
for (const name of stepAgents(step)) {
|
|
771
|
+
const l = legs.get(name);
|
|
772
|
+
if (!l || said.has(name) || l.gated || !l.privateLeg.length || !l.outLeg.length)
|
|
773
|
+
continue;
|
|
774
|
+
said.add(name);
|
|
775
|
+
push("security/published-flow-trifecta", fl.file, step.line ?? at, `published trifecta ${name}`, `${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 ? ", …" : ""} ` +
|
|
776
|
+
`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`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
// Groups in order. A gate (an approval, or ask:, which is one with a
|
|
780
|
+
// question) on any step of a group parks the run before ANY step of that
|
|
781
|
+
// group launches (runner.ts driveRunInner), so it holds its parallel
|
|
782
|
+
// siblings as well as every group after.
|
|
783
|
+
let gated = false;
|
|
784
|
+
for (const g of [...new Set(info.steps.map((s) => s.group))].sort((a, b) => a - b)) {
|
|
785
|
+
const group = info.steps.filter((s) => s.group === g);
|
|
786
|
+
if (group.some((s) => s.approve || s.ask))
|
|
787
|
+
gated = true;
|
|
788
|
+
for (const s of group) {
|
|
789
|
+
if (gated)
|
|
790
|
+
continue;
|
|
791
|
+
const writes = [...new Set(stepAgents(s).flatMap((n) => legs.get(n)?.writes ?? []))];
|
|
792
|
+
if (!writes.length)
|
|
793
|
+
continue;
|
|
794
|
+
push("security/published-flow-ungated", fl.file, s.line ?? at, `published ungated ${g} ${s.agent}`, `step ${g} ([[${s.agent}]]) can act outside the workspace (${writes.slice(0, 2).join(", ")}${writes.length > 2 ? ", …" : ""}) and ${info.name} is published over A2A, ` +
|
|
795
|
+
`with no approval or ask: before it — any caller with a key starts it. Put a gate (\`${g}!.\`) on it or before it`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
708
799
|
}
|
|
709
800
|
function runtimePackages(runtime, file, lines, push) {
|
|
710
801
|
if (!runtime || typeof runtime !== "object")
|
|
@@ -819,6 +910,7 @@ export function scanFiles(files, options = {}) {
|
|
|
819
910
|
};
|
|
820
911
|
const outward = new Set(options.outwardTools ?? []);
|
|
821
912
|
const agents = [];
|
|
913
|
+
const flows = [];
|
|
822
914
|
const toolDefs = new Map();
|
|
823
915
|
for (const f of files.filter(isText)) {
|
|
824
916
|
const lines = f.content.split(/\r?\n/);
|
|
@@ -876,6 +968,10 @@ export function scanFiles(files, options = {}) {
|
|
|
876
968
|
const name = /^agents\/([^/]+)\//.exec(rel)?.[1] ?? rel.replace(/^.*\/|\.md$/g, "");
|
|
877
969
|
agents.push({ name, file: f.path, line: 1, data, lines });
|
|
878
970
|
}
|
|
971
|
+
else if (kind === "flow") {
|
|
972
|
+
if (!f.path.startsWith("library/"))
|
|
973
|
+
flows.push({ file: f.path, content: f.content, lines });
|
|
974
|
+
}
|
|
879
975
|
else if (kind === "tool") {
|
|
880
976
|
const name = typeof data.name === "string" ? data.name : f.path.replace(/^(?:library\/)?tools\//, "").replace(/(?:\/tool)?\.md$/, "");
|
|
881
977
|
if (data.outward === true)
|
|
@@ -885,7 +981,8 @@ export function scanFiles(files, options = {}) {
|
|
|
885
981
|
}
|
|
886
982
|
// project.md is the older name the runtime still reads (Codex review, FOL-58).
|
|
887
983
|
const workspaceMd = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
|
|
888
|
-
scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
|
|
984
|
+
const legs = scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
|
|
985
|
+
scanPublishedFlows(flows, legs, push);
|
|
889
986
|
for (const m of more.values()) {
|
|
890
987
|
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 });
|
|
891
988
|
}
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -44,6 +44,14 @@ interface StoredSecret {
|
|
|
44
44
|
}
|
|
45
45
|
type SecretsFile = Record<string, StoredSecret>;
|
|
46
46
|
declare const writeBound: () => boolean;
|
|
47
|
+
/**
|
|
48
|
+
* An exclusive lock on `file` for a read-change-write, across processes and
|
|
49
|
+
* pods: `<file>.lock` opened O_EXCL (NFS 4.1 and EFS honour it), taken over
|
|
50
|
+
* when older than LOCK_STALE_MS (a holder that died), waited for up to
|
|
51
|
+
* LOCK_WAIT_MS, then `busy` is thrown. Returns the release. The vault's,
|
|
52
|
+
* and any other small state file several runs write (a2a-tools' handles).
|
|
53
|
+
*/
|
|
54
|
+
export declare function lockFileExclusive(file: string, busy?: string): () => void;
|
|
47
55
|
/**
|
|
48
56
|
* Change one vault file safely: `change` gets what is stored now and returns
|
|
49
57
|
* the new contents, or null to leave the file untouched. Returns what is
|
|
@@ -275,6 +283,28 @@ ctx?: {
|
|
|
275
283
|
* the stored value with the new refresh token. */
|
|
276
284
|
onRotate?: (name: string, usedValue: string, next: string) => unknown;
|
|
277
285
|
}): Promise<Record<string, string>>;
|
|
286
|
+
/**
|
|
287
|
+
* materializeSecrets for a tool that holds secrets for a whole step and
|
|
288
|
+
* makes them live per call (the API tools, the a2a tools), on a host with
|
|
289
|
+
* the vault.
|
|
290
|
+
*
|
|
291
|
+
* Two things the step's copy of a recipe cannot do alone. A provider that
|
|
292
|
+
* rotates refresh tokens has spent the refresh token the step started with —
|
|
293
|
+
* the runner materialised it at step start, and any refresh since spent its
|
|
294
|
+
* successor — so an oauth2 recipe is read again from the vault as it is now
|
|
295
|
+
* (and `env` updated in place, for the next call). And it is made live in
|
|
296
|
+
* the account's scope, as the runner and the egress proxy do: the cached
|
|
297
|
+
* access token is keyed by account, and a rotation is written back to the
|
|
298
|
+
* vault by the compare-and-set above. Refreshing the starting recipe outside
|
|
299
|
+
* that scope missed the cache and sent a spent refresh token: invalid_grant.
|
|
300
|
+
*
|
|
301
|
+
* No `scope` (inside a sandbox, where there is no vault and the values
|
|
302
|
+
* crossed already made live): materializeSecrets as it was.
|
|
303
|
+
*/
|
|
304
|
+
export declare function materializeCurrent(env: Record<string, string>, scope?: {
|
|
305
|
+
tenant: string;
|
|
306
|
+
workspace?: string;
|
|
307
|
+
} | null): Promise<Record<string, string>>;
|
|
278
308
|
export interface ServiceAccountConfig {
|
|
279
309
|
token_url: string;
|
|
280
310
|
issuer: string;
|
package/dist/src/secrets.js
CHANGED
|
@@ -221,6 +221,16 @@ function readFile(file) {
|
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
function lockVault(file) {
|
|
224
|
+
return lockFileExclusive(file, "the vault is busy — another change is being saved; try again");
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* An exclusive lock on `file` for a read-change-write, across processes and
|
|
228
|
+
* pods: `<file>.lock` opened O_EXCL (NFS 4.1 and EFS honour it), taken over
|
|
229
|
+
* when older than LOCK_STALE_MS (a holder that died), waited for up to
|
|
230
|
+
* LOCK_WAIT_MS, then `busy` is thrown. Returns the release. The vault's,
|
|
231
|
+
* and any other small state file several runs write (a2a-tools' handles).
|
|
232
|
+
*/
|
|
233
|
+
export function lockFileExclusive(file, busy = `${path.basename(file)} is busy — another writer holds it; try again`) {
|
|
224
234
|
const lock = `${file}.lock`;
|
|
225
235
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
226
236
|
for (;;) {
|
|
@@ -243,7 +253,7 @@ function lockVault(file) {
|
|
|
243
253
|
continue; // released between the open and the stat
|
|
244
254
|
}
|
|
245
255
|
if (Date.now() > deadline)
|
|
246
|
-
throw new Error(
|
|
256
|
+
throw new Error(busy);
|
|
247
257
|
sleep(25);
|
|
248
258
|
}
|
|
249
259
|
}
|
|
@@ -1019,6 +1029,36 @@ ctx) {
|
|
|
1019
1029
|
}
|
|
1020
1030
|
return out;
|
|
1021
1031
|
}
|
|
1032
|
+
/**
|
|
1033
|
+
* materializeSecrets for a tool that holds secrets for a whole step and
|
|
1034
|
+
* makes them live per call (the API tools, the a2a tools), on a host with
|
|
1035
|
+
* the vault.
|
|
1036
|
+
*
|
|
1037
|
+
* Two things the step's copy of a recipe cannot do alone. A provider that
|
|
1038
|
+
* rotates refresh tokens has spent the refresh token the step started with —
|
|
1039
|
+
* the runner materialised it at step start, and any refresh since spent its
|
|
1040
|
+
* successor — so an oauth2 recipe is read again from the vault as it is now
|
|
1041
|
+
* (and `env` updated in place, for the next call). And it is made live in
|
|
1042
|
+
* the account's scope, as the runner and the egress proxy do: the cached
|
|
1043
|
+
* access token is keyed by account, and a rotation is written back to the
|
|
1044
|
+
* vault by the compare-and-set above. Refreshing the starting recipe outside
|
|
1045
|
+
* that scope missed the cache and sent a spent refresh token: invalid_grant.
|
|
1046
|
+
*
|
|
1047
|
+
* No `scope` (inside a sandbox, where there is no vault and the values
|
|
1048
|
+
* crossed already made live): materializeSecrets as it was.
|
|
1049
|
+
*/
|
|
1050
|
+
export async function materializeCurrent(env, scope) {
|
|
1051
|
+
if (!scope?.tenant)
|
|
1052
|
+
return materializeSecrets(env);
|
|
1053
|
+
for (const [name, value] of Object.entries(env)) {
|
|
1054
|
+
if (typeof value !== "string" || !isOAuth2Value(value))
|
|
1055
|
+
continue;
|
|
1056
|
+
const current = getSecret(scope.tenant, name, scope.workspace)?.value;
|
|
1057
|
+
if (current && isOAuth2Value(current))
|
|
1058
|
+
env[name] = current;
|
|
1059
|
+
}
|
|
1060
|
+
return materializeSecrets(env, { tenant: scope.tenant, ...(scope.workspace ? { workspace: scope.workspace } : {}) });
|
|
1061
|
+
}
|
|
1022
1062
|
// ---------------------------------------------------------------- service account
|
|
1023
1063
|
// Signed-key credentials: a Google service account, or anything that mints a
|
|
1024
1064
|
// token by self-signing a JWT with a private key and exchanging it. No user,
|