@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/api-tools.js
CHANGED
|
@@ -17,7 +17,7 @@ import { EGRESS_ENV, viaEgress } from "./egress.js";
|
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
19
19
|
import { previewOrKeep, RESULT_KEEP_CHARS } from "./tool-output.js";
|
|
20
|
-
import { resolveSecrets,
|
|
20
|
+
import { resolveSecrets, materializeCurrent } from "./secrets.js";
|
|
21
21
|
// Collected whole up to this; the model sees a preview and where the rest is
|
|
22
22
|
// kept (tool-output.ts), not a cut with the rest gone.
|
|
23
23
|
const MAX_BODY_CHARS = RESULT_KEEP_CHARS;
|
|
@@ -97,7 +97,7 @@ export function typedToolName(apiName, opId) {
|
|
|
97
97
|
*/
|
|
98
98
|
async function performRequest(api, env, bucket, log, req,
|
|
99
99
|
/** Where a long body is kept, when the agent can read it back (tool-output.ts). */
|
|
100
|
-
keepIn) {
|
|
100
|
+
keepIn, scope = null) {
|
|
101
101
|
const started = Date.now();
|
|
102
102
|
try {
|
|
103
103
|
const waited = bucket ? await bucket.take() : 0;
|
|
@@ -105,8 +105,11 @@ keepIn) {
|
|
|
105
105
|
// Per call, not per build: an oauth2 secret's recipe becomes a
|
|
106
106
|
// live access token here, so a step that runs for an hour keeps
|
|
107
107
|
// getting fresh tokens (the exchange is cached until near expiry)
|
|
108
|
-
// — and a recipe can never end up inside a header.
|
|
109
|
-
|
|
108
|
+
// — and a recipe can never end up inside a header. On a host, the
|
|
109
|
+
// vault's recipe as it is now, made live in the account's scope: a
|
|
110
|
+
// rotating refresh token the runner (or an earlier call) spent is never
|
|
111
|
+
// sent again (secrets.ts materializeCurrent).
|
|
112
|
+
const live = await materializeCurrent(env, scope);
|
|
110
113
|
const path = req.path.startsWith("/") ? req.path : `/${req.path}`;
|
|
111
114
|
const url = new URL(api.base + path);
|
|
112
115
|
// Base-URL confinement: a path can't escape the declared host.
|
|
@@ -183,7 +186,7 @@ function argKeys(op) {
|
|
|
183
186
|
}
|
|
184
187
|
return out;
|
|
185
188
|
}
|
|
186
|
-
function typedTool(api, op, env, bucket, log, keepIn) {
|
|
189
|
+
function typedTool(api, op, env, bucket, log, keepIn, scope = null) {
|
|
187
190
|
const keys = argKeys(op);
|
|
188
191
|
const shape = {};
|
|
189
192
|
for (const [key, p] of keys)
|
|
@@ -235,10 +238,10 @@ function typedTool(api, op, env, bucket, log, keepIn) {
|
|
|
235
238
|
query,
|
|
236
239
|
headers,
|
|
237
240
|
body: typeof values.body === "string" ? values.body : undefined,
|
|
238
|
-
}, keepIn);
|
|
241
|
+
}, keepIn, scope);
|
|
239
242
|
});
|
|
240
243
|
}
|
|
241
|
-
function genericTool(api, env, bucket, log, keepIn) {
|
|
244
|
+
function genericTool(api, env, bucket, log, keepIn, scope = null) {
|
|
242
245
|
const typed = api.resolvedOperations?.length
|
|
243
246
|
? ` Prefer the typed ${api.name}_* tools; this one is the escape hatch for operations they do not cover.`
|
|
244
247
|
: "";
|
|
@@ -258,7 +261,7 @@ function genericTool(api, env, bucket, log, keepIn) {
|
|
|
258
261
|
query: args.query ?? {},
|
|
259
262
|
headers: {},
|
|
260
263
|
body: args.body,
|
|
261
|
-
}, keepIn));
|
|
264
|
+
}, keepIn, scope));
|
|
262
265
|
}
|
|
263
266
|
export function buildApiTools(tenant, apis, workspace,
|
|
264
267
|
// The runner container passes this: secrets were resolved before the specs
|
|
@@ -277,6 +280,8 @@ keepIn) {
|
|
|
277
280
|
missing: resolved.missing,
|
|
278
281
|
}
|
|
279
282
|
: resolveSecrets(tenant, neededSecrets, workspace);
|
|
283
|
+
// Read from the vault here: made live per call in its account's scope.
|
|
284
|
+
const scope = !resolved && tenant ? { tenant, ...(workspace ? { workspace } : {}) } : null;
|
|
280
285
|
const log = [];
|
|
281
286
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the SDK types its own list this way
|
|
282
287
|
const tools = [];
|
|
@@ -311,12 +316,12 @@ keepIn) {
|
|
|
311
316
|
}
|
|
312
317
|
const typedNames = [];
|
|
313
318
|
for (const op of ops) {
|
|
314
|
-
tools.push(typedTool(api, op, env, bucket, log, keepIn));
|
|
319
|
+
tools.push(typedTool(api, op, env, bucket, log, keepIn, scope));
|
|
315
320
|
typedNames.push(typedToolName(api.name, op.id));
|
|
316
321
|
methods[`mcp__foldrun_apis__${typedToolName(api.name, op.id)}`] = String(op.method ?? "").toUpperCase();
|
|
317
322
|
}
|
|
318
323
|
if (withGeneric)
|
|
319
|
-
tools.push(genericTool(api, env, bucket, log, keepIn));
|
|
324
|
+
tools.push(genericTool(api, env, bucket, log, keepIn, scope));
|
|
320
325
|
const names = [...typedNames, ...(withGeneric ? [`call_${api.name}`] : [])];
|
|
321
326
|
for (const n of names) {
|
|
322
327
|
toolNames.push(`mcp__foldrun_apis__${n}`);
|
package/dist/src/browser-pod.js
CHANGED
|
@@ -173,6 +173,10 @@ export function classifyCall(tool, input, ctx = {}) {
|
|
|
173
173
|
// colleague is said, and a re-run would say it twice.
|
|
174
174
|
if (server === "foldrun_peers")
|
|
175
175
|
return name === "peers_list" ? { write: false } : write("a message to a colleague");
|
|
176
|
+
// remotes: — reading where a request got to changes nothing; a send, a
|
|
177
|
+
// reply or a cancel acts on an outside agent's system.
|
|
178
|
+
if (server === "foldrun_a2a")
|
|
179
|
+
return name === "a2a_status" ? { write: false } : write(`${name.replace(/^a2a_/, "")} to an outside agent`);
|
|
176
180
|
if (server === "foldrun_scripts") {
|
|
177
181
|
if (name !== "web")
|
|
178
182
|
return write(`script ${name}`);
|
package/dist/src/completions.js
CHANGED
|
@@ -331,9 +331,9 @@ export function completionsAt(path, text, cursor, vocab) {
|
|
|
331
331
|
// that field's files and nothing else. `[[search]]` in tools: is how a
|
|
332
332
|
// tool named after a runtime group gets granted; offering the group
|
|
333
333
|
// here would defeat the point of the brackets.
|
|
334
|
-
const inlineOwner = line.match(/^\s*([a-zA-Z_]
|
|
334
|
+
const inlineOwner = line.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*):\s*(?:\[.*)?\[\[[^\]]*$/)?.[1];
|
|
335
335
|
const blockOwner = /^\s+-\s*\[\[/.test(line)
|
|
336
|
-
? before.slice(0, lineStart).split("\n").reverse().find((l) => /^\s*[a-zA-Z_]
|
|
336
|
+
? before.slice(0, lineStart).split("\n").reverse().find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))?.trim().replace(":", "")
|
|
337
337
|
: undefined;
|
|
338
338
|
const owner = inlineOwner ?? blockOwner;
|
|
339
339
|
const byField = {
|
|
@@ -391,7 +391,7 @@ export function completionsAt(path, text, cursor, vocab) {
|
|
|
391
391
|
// been chosen yet, one at a time. Fixed combinations were the wrong
|
|
392
392
|
// model: nobody wants a menu of every permutation, they want to add the
|
|
393
393
|
// next verb.
|
|
394
|
-
const inlineList = line.match(/^(\s*)([a-zA-Z_]
|
|
394
|
+
const inlineList = line.match(/^(\s*)([a-zA-Z_][a-zA-Z0-9_]*):\s*\[([^\]]*)$/);
|
|
395
395
|
if (inlineList) {
|
|
396
396
|
const [, , key, inside] = inlineList;
|
|
397
397
|
// The inline and block forms of a list are the same field — `tools: [a, b]`
|
|
@@ -435,7 +435,7 @@ export function completionsAt(path, text, cursor, vocab) {
|
|
|
435
435
|
}
|
|
436
436
|
}
|
|
437
437
|
// 4. `key: value` — the value side of a known field.
|
|
438
|
-
const kv = line.match(/^(\s*)([a-zA-Z_]
|
|
438
|
+
const kv = line.match(/^(\s*)([a-zA-Z_][a-zA-Z0-9_]*):\s*(\S*)$/);
|
|
439
439
|
if (kv) {
|
|
440
440
|
const [, , key, partial] = kv;
|
|
441
441
|
const from = cursor - partial.length;
|
|
@@ -517,7 +517,7 @@ export function completionsAt(path, text, cursor, vocab) {
|
|
|
517
517
|
const partial = listItem[2];
|
|
518
518
|
const from = cursor - partial.length;
|
|
519
519
|
const priorLines = before.slice(0, lineStart).split("\n").reverse();
|
|
520
|
-
const owner = priorLines.find((l) => /^\s*[a-zA-Z_]
|
|
520
|
+
const owner = priorLines.find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))?.trim().replace(":", "");
|
|
521
521
|
const byOwner = {
|
|
522
522
|
// One list now: built-ins and your own tools are granted the same way.
|
|
523
523
|
// Groups first (they cover most needs), then your own tools — the ones
|
|
@@ -558,7 +558,7 @@ export function completionsAt(path, text, cursor, vocab) {
|
|
|
558
558
|
const partial = mapping[2] ?? "";
|
|
559
559
|
const priorLines = before.slice(0, lineStart).split("\n").reverse();
|
|
560
560
|
const owner = priorLines
|
|
561
|
-
.find((l) => /^\s*[a-zA-Z_]
|
|
561
|
+
.find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))
|
|
562
562
|
?.trim()
|
|
563
563
|
.replace(":", "");
|
|
564
564
|
const byOwner = {
|
|
@@ -593,7 +593,7 @@ export function completionsAt(path, text, cursor, vocab) {
|
|
|
593
593
|
// kind of file — the block takes nothing else.
|
|
594
594
|
const fmClose = text.indexOf("\n---", text.indexOf("---") + 3);
|
|
595
595
|
const insideFrontmatter = text.startsWith("---") && (fmClose === -1 || cursor <= fmClose);
|
|
596
|
-
const nested = line.match(/^(\s+)([a-zA-Z_]
|
|
596
|
+
const nested = line.match(/^(\s+)([a-zA-Z_][a-zA-Z0-9_]*|)$/);
|
|
597
597
|
if (insideFrontmatter && nested) {
|
|
598
598
|
const header = before.slice(0, lineStart).split("\n").reverse().find((l) => /^\S/.test(l));
|
|
599
599
|
const job = JOBS.find((j) => new RegExp(`^${j.name}\\s*:\\s*$`).test(header ?? ""));
|
|
@@ -624,7 +624,7 @@ export function completionsAt(path, text, cursor, vocab) {
|
|
|
624
624
|
// this kind of file. This is where you learn the format by writing it.
|
|
625
625
|
const fmEnd = text.indexOf("\n---", text.indexOf("---") + 3);
|
|
626
626
|
const inFrontmatter = text.startsWith("---") && (fmEnd === -1 || cursor <= fmEnd);
|
|
627
|
-
if (inFrontmatter && /^[a-zA-Z_][a-zA-
|
|
627
|
+
if (inFrontmatter && /^[a-zA-Z_][a-zA-Z0-9_-]*$|^$/.test(line)) {
|
|
628
628
|
const from = cursor - line.length;
|
|
629
629
|
return { from, query: line, items: filter(fieldsFor(path, kind), line), title: `${kind} fields` };
|
|
630
630
|
}
|
|
@@ -43,6 +43,7 @@ const AGENT = [
|
|
|
43
43
|
{ key: "scripts", type: "list", help: "programs in scripts/, each a tool" },
|
|
44
44
|
{ key: "apis", type: "list", help: "an HTTP API as a tool" },
|
|
45
45
|
{ key: "mcpServers", type: "object", help: "MCP servers, name → {command | url, args, env, headers}" },
|
|
46
|
+
{ key: "remotes", type: "list", help: "outside A2A agents it may call — [{name, card: the https URL of its agent card, secret?: a vault NAME sent as a bearer token, description?}]" },
|
|
46
47
|
{ key: "agents", type: ["list", "string"], help: "colleagues it may consult" },
|
|
47
48
|
{ key: "subagents", type: ["list", "string"], help: "colleagues it may delegate to" },
|
|
48
49
|
{ key: "sandbox", type: "string", values: ["own", "parent"], help: "when delegated to as a sub-agent: own = its own sandbox, tools, secrets and network; parent (default) = inside the delegating step's" },
|
|
@@ -127,6 +128,7 @@ const FLOW = [
|
|
|
127
128
|
{ key: "budget", type: ["number", "string"], help: "USD per run" },
|
|
128
129
|
{ key: "compute", type: "object", help: "{vcpu, memory, disk} one run's steps may hold together at once — only narrows the workspace's and account's" },
|
|
129
130
|
{ key: "live", type: "boolean", help: "true: steps may act for real in a test run" },
|
|
131
|
+
{ key: "a2a", type: "object", help: "serve this flow to outside agents over A2A 1.0 — {publish: true, name?, description?, examples?, public_card?, input?}; changing it needs workspace:manage" },
|
|
130
132
|
];
|
|
131
133
|
const TOOL = [
|
|
132
134
|
{ key: "name", type: "string", help: "defaults to the file or folder name" },
|
|
@@ -9,6 +9,8 @@ import { cronProblem, parseCron, cronCanFire } from "./cron.js";
|
|
|
9
9
|
import { SECRET_NAME } from "./secrets.js";
|
|
10
10
|
import { modelSecretCollision } from "./model-credential.js";
|
|
11
11
|
import { resourcesProblem, computeProblem } from "./resources.js";
|
|
12
|
+
import { remotesProblem } from "./a2a-remotes.js";
|
|
13
|
+
import { a2aPublishProblem } from "./a2a-publish.js";
|
|
12
14
|
export { FRONTMATTER, schemaKindOf } from "./frontmatter-fields.js";
|
|
13
15
|
/** A value for a message: a scalar as written, anything else by its shape.
|
|
14
16
|
* Never String() of a map — its `toString` is whatever the YAML says. */
|
|
@@ -31,6 +33,7 @@ const CHECKS = {
|
|
|
31
33
|
"agent": {
|
|
32
34
|
effort: (v) => (resolveEffort(v) ? null : `"${String(v)}" is not an effort — low, medium, high, xhigh or max; the step runs at the model's default`),
|
|
33
35
|
apis: apisProblem,
|
|
36
|
+
remotes: remotesProblem,
|
|
34
37
|
budget: (v) => budgetProblem(v, ["run"]),
|
|
35
38
|
ask: (v) => askProblem(v),
|
|
36
39
|
resources: resourcesProblem,
|
|
@@ -55,6 +58,7 @@ const CHECKS = {
|
|
|
55
58
|
pause_when: (v) => (String(v).length <= 60 ? null : "is longer than 60 characters"),
|
|
56
59
|
budget: (v) => budgetProblem(v, ["run"]),
|
|
57
60
|
compute: computeProblem,
|
|
61
|
+
a2a: a2aPublishProblem,
|
|
58
62
|
},
|
|
59
63
|
"tool": {
|
|
60
64
|
transport: (v) => (["http", "script", "mcp"].includes(String(v).toLowerCase()) ? null : `"${String(v)}" is not a transport — http, script or mcp`),
|
package/dist/src/limits.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// web.search: 40 # one action of the web tool
|
|
6
6
|
// web: 150 # every web action together
|
|
7
7
|
// crm: 20 # an API, script or MCP tool, by the name it is granted as
|
|
8
|
+
// a2a: 10 # messages to outside agents (a2a_send and a2a_reply)
|
|
9
|
+
// a2a.status: 30 # polls of them, apart from the sends
|
|
8
10
|
// calls: 300 # every tool call in the step, whatever the tool
|
|
9
11
|
// repeat: 5 # the same call — one tool, the same input — at most this often
|
|
10
12
|
//
|
|
@@ -21,6 +23,7 @@
|
|
|
21
23
|
import crypto from "node:crypto";
|
|
22
24
|
import { WEB_ACTIONS } from "./providers.js";
|
|
23
25
|
import { DELEGATE_TOOLS } from "./subagents.js";
|
|
26
|
+
import { A2A_SERVER, A2A_LIMIT_KEY, A2A_LIMIT_ACTIONS } from "./a2a-remotes.js";
|
|
24
27
|
/** The key that caps every call in the step. */
|
|
25
28
|
export const TOTAL_KEY = "calls";
|
|
26
29
|
/** The key that caps one identical call: a model asking the same tool the
|
|
@@ -96,8 +99,11 @@ export function readLimits(raw, where = "limits") {
|
|
|
96
99
|
}
|
|
97
100
|
if (k.includes(".")) {
|
|
98
101
|
const [head, action] = k.split(".");
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
const web = head === "web" && WEB_ACTIONS.includes(action);
|
|
103
|
+
const a2a = head === A2A_LIMIT_KEY && A2A_LIMIT_ACTIONS.includes(action);
|
|
104
|
+
if (!web && !a2a) {
|
|
105
|
+
problems.push(`${where}: "${k}" is not a key — the dotted keys are web.<action>: ${WEB_ACTIONS.map((a) => `web.${a}`).join(", ")}, ` +
|
|
106
|
+
`and ${A2A_LIMIT_ACTIONS.map((a) => `${A2A_LIMIT_KEY}.${a}`).join(", ")}.`);
|
|
101
107
|
continue;
|
|
102
108
|
}
|
|
103
109
|
}
|
|
@@ -166,6 +172,16 @@ export function limitKeysFor(toolName, input, owners = {}) {
|
|
|
166
172
|
}
|
|
167
173
|
if (DELEGATE_TOOLS.includes(toolName))
|
|
168
174
|
return keys;
|
|
175
|
+
// remotes: — a message to an outside agent (a2a_send, a2a_reply) counts
|
|
176
|
+
// under `a2a`; every tool also under its own `a2a.<action>`, so polling
|
|
177
|
+
// (`a2a.status`) is capped apart from sending.
|
|
178
|
+
const a2a = new RegExp(`^mcp__${A2A_SERVER}__a2a_([a-z]+)$`).exec(toolName);
|
|
179
|
+
if (a2a) {
|
|
180
|
+
if (a2a[1] === "send" || a2a[1] === "reply")
|
|
181
|
+
keys.push(A2A_LIMIT_KEY);
|
|
182
|
+
keys.push(`${A2A_LIMIT_KEY}.${a2a[1]}`);
|
|
183
|
+
return keys;
|
|
184
|
+
}
|
|
169
185
|
const owner = owners[toolName];
|
|
170
186
|
const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
|
|
171
187
|
const name = owner
|
|
@@ -263,7 +279,7 @@ export class CallCounter {
|
|
|
263
279
|
*/
|
|
264
280
|
export function limitKeyProblems(limits, ctx) {
|
|
265
281
|
const out = [];
|
|
266
|
-
const builtinKeys = ["read", "write", "code", ...Object.keys(BUILTIN_GROUP), "TodoWrite", "search", "history", "desks", "ask", "web"];
|
|
282
|
+
const builtinKeys = ["read", "write", "code", ...Object.keys(BUILTIN_GROUP), "TodoWrite", "search", "history", "desks", "ask", "web", A2A_LIMIT_KEY];
|
|
267
283
|
const known = new Set([...builtinKeys, ...ctx.known].map(normKey));
|
|
268
284
|
const granted = new Set([...ctx.granted].flatMap((g) => {
|
|
269
285
|
const n = normKey(g);
|
|
@@ -281,10 +297,12 @@ export function limitKeyProblems(limits, ctx) {
|
|
|
281
297
|
for (const key of Object.keys(limits)) {
|
|
282
298
|
if (key === TOTAL_KEY || key === REPEAT_KEY)
|
|
283
299
|
continue;
|
|
284
|
-
const base = key.startsWith("web.") ? "web" : key;
|
|
300
|
+
const base = key.startsWith("web.") ? "web" : key.startsWith(`${A2A_LIMIT_KEY}.`) ? A2A_LIMIT_KEY : key;
|
|
285
301
|
const n = normKey(base);
|
|
286
302
|
if (!known.has(n)) {
|
|
287
303
|
const valid = [TOTAL_KEY, "web", ...WEB_ACTIONS.map((a) => `web.${a}`), ...[...new Set([...ctx.granted].map(String))].filter((g) => g !== "web")];
|
|
304
|
+
if ([...ctx.granted].map(String).includes(A2A_LIMIT_KEY))
|
|
305
|
+
valid.push(...A2A_LIMIT_ACTIONS.map((a) => `${A2A_LIMIT_KEY}.${a}`));
|
|
288
306
|
out.push({ level: "error", message: `limits: ${key} — not a tool this agent could call. The keys: ${[...new Set(valid)].join(", ")}.` });
|
|
289
307
|
}
|
|
290
308
|
else if (!granted.has(n)) {
|
|
@@ -16,6 +16,7 @@ import { type OperatorEvent } from "./operator.ts";
|
|
|
16
16
|
import type { SearchRoot, RunDigest } from "./context-tools.ts";
|
|
17
17
|
import type { TranslatorSpec } from "./translator.ts";
|
|
18
18
|
import { type BrowserPodOutcome } from "./browser-pod.ts";
|
|
19
|
+
import type { RemoteSpec } from "./a2a-remotes.ts";
|
|
19
20
|
/** What crosses the boundary. Everything in here is values — pre-resolved
|
|
20
21
|
* secrets in headers, assembled prompt, serializable MCP configs. */
|
|
21
22
|
export interface ContainerStepInput {
|
|
@@ -32,6 +33,14 @@ export interface ContainerStepInput {
|
|
|
32
33
|
/** http/stdio configs only — in-process servers are rebuilt inside. */
|
|
33
34
|
mcpServers: Record<string, McpServerConfig>;
|
|
34
35
|
apis: ApiSpec[];
|
|
36
|
+
/** `remotes:` — outside A2A agents, each spec's `token` filled host-side
|
|
37
|
+
* (`${NAME}` for the egress proxy, or the value with no proxy), and the
|
|
38
|
+
* run and step its handles are kept under (a2a-tools.ts). */
|
|
39
|
+
a2a?: {
|
|
40
|
+
remotes: RemoteSpec[];
|
|
41
|
+
runId: string | null;
|
|
42
|
+
step: number | null;
|
|
43
|
+
};
|
|
35
44
|
scripts: ScriptSpec[];
|
|
36
45
|
/** The agent's `runtime:` declaration — the driver builds the venv/npm
|
|
37
46
|
* prefix inside the container, where the network is. Without this,
|
|
@@ -339,7 +348,7 @@ export declare function runtimeCacheMount(tenant: string | undefined): {
|
|
|
339
348
|
target: string;
|
|
340
349
|
} | null;
|
|
341
350
|
/** The runner container's entrypoint, as JS text: written into the image as driver.mjs, so it must be plain JS — a TypeScript cast here broke every step on v2026.10.21.5 (tests/run-container.test.ts checks it). */
|
|
342
|
-
export declare const DRIVER = "// The runner container's entrypoint. Reads one step, executes it with the\n// same core the server uses, speaks JSONL on stdout, exits.\nimport fs from \"node:fs\";\nconst input = JSON.parse(fs.readFileSync(\"/opt/runner/job/input.json\", \"utf8\"));\n// Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,\n// on the sandbox's clock. A worker that does not know a mark skips the line.\nconst mark = (name) => process.stdout.write(JSON.stringify({ e: \"mark\", name, at: Date.now() }) + \"\\n\");\nmark(\"driver\");\nlet modelSpoke = false;\nconst emit = (type, text, extra) => {\n // The model's first text or tool call is where start-up ends and the\n // step's own work begins \u2014 runtime and tool lines before it are set-up.\n if (!modelSpoke && (type === \"text\" || type === \"tool\")) { modelSpoke = true; mark(\"model\"); }\n process.stdout.write(JSON.stringify({ e: \"event\", type, text, ...extra }) + \"\\n\");\n};\n// What this sandbox actually touched, read at the last moment from the\n// kernel's own books: cgroup v2 for CPU busy-time and peak memory,\n// /proc/net/dev for bytes on the wire. Every read is best-effort \u2014 gVisor\n// exposes some of these files and not others depending on version, and a\n// metric we cannot read is null, never guessed. Reservation is what is\n// billed; these exist so the reservation can be right-sized.\nfunction readActuals() {\n // fs from the module's own import \u2014 the driver is ESM, and a require()\n // here threw on every call, silently nulling every metric it guarded.\n const read = (p) => { try { return fs.readFileSync(p, \"utf8\"); } catch { return null; } };\n // Two cgroup dialects: v2 (cpu.stat, microseconds; memory.peak) on a\n // plain runtime, v1-style controllers under gVisor (cpuacct.usage,\n // NANOseconds; memory.max_usage_in_bytes) \u2014 probed on the real cluster,\n // not assumed. Whichever answers wins; both silent means null.\n const cpuStat = read(\"/sys/fs/cgroup/cpu.stat\");\n const usage = cpuStat?.match(/^usage_usec (\\d+)/m);\n const cpuacctNs = usage ? NaN : Number(read(\"/sys/fs/cgroup/cpuacct/cpuacct.usage\")?.trim());\n // Time the CPU cap held the sandbox back: v2's throttled_usec in the same\n // cpu.stat, v1's throttled_time (nanoseconds) under gVisor. Large against\n // busy time means the step wanted more cores than its class gives.\n const throttledUs = cpuStat?.match(/^throttled_usec (\\d+)/m);\n const throttledNs = throttledUs ? null : read(\"/sys/fs/cgroup/cpu/cpu.stat\")?.match(/^throttled_time (\\d+)/m);\n const peak =\n read(\"/sys/fs/cgroup/memory.peak\") ??\n read(\"/sys/fs/cgroup/memory/memory.max_usage_in_bytes\") ??\n read(\"/sys/fs/cgroup/memory/memory.usage_in_bytes\");\n let rx = 0, tx = 0, sawNet = false;\n const net = read(\"/proc/net/dev\");\n if (net) {\n for (const line of net.split(\"\\n\").slice(2)) {\n const m = line.trim().match(/^(\\S+):\\s+(\\d+)(?:\\s+\\d+){7}\\s+(\\d+)/);\n if (m && m[1] !== \"lo\") { rx += Number(m[2]); tx += Number(m[3]); sawNet = true; }\n }\n }\n return {\n busyCpuSecs: usage\n ? Number(usage[1]) / 1e6\n : Number.isFinite(cpuacctNs) && cpuacctNs > 0\n ? cpuacctNs / 1e9\n : null,\n peakMemBytes: peak ? Number(peak.trim()) || null : null,\n throttledSecs: throttledUs ? Number(throttledUs[1]) / 1e6 : throttledNs ? Number(throttledNs[1]) / 1e9 : null,\n rxBytes: sawNet ? rx : null,\n txBytes: sawNet ? tx : null,\n };\n}\n\ntry {\n // Runtime state (venvs, npm prefixes) lands in the agent user's real home\n // \u2014 writable, inside the container, gone with it.\n process.env.FOLDRUN_DATA = \"/home/agent/.foldrun\";\n const { executeStep } = await import(\"@foldrun/core/step-exec\");\n const { buildApiTools } = await import(\"@foldrun/core/api-tools\");\n const { buildScriptTools } = await import(\"@foldrun/core/script-tools\");\n const { buildConsultTools } = await import(\"@foldrun/core/agent-tools\");\n const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import(\"@foldrun/core/context-tools\");\n const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import(\"@foldrun/core/operator\");\n const { buildDelegateTools } = await import(\"@foldrun/core/delegate-tools\");\n const { startTranslator } = await import(\"@foldrun/core/translator\");\n const { prepareRuntime } = await import(\"@foldrun/core/runtime\");\n const { materializeFileSecrets } = await import(\"@foldrun/core/secret-files\");\n const { paidWebActions } = await import(\"@foldrun/core/browser-pod\");\n mark(\"imports\");\n\n const agentDir = \"/workspace/\" + input.agentRel;\n // HOME is where a bare relative path lands when anything resolves one\n // outside cwd \u2014 and root's /root is outside the workspace, so such a read\n // was refused by confinement with a path the agent never typed. Its own\n // directory is both the truthful answer and the safe one.\n // @file secrets crossed as content; materialise them to 0600 paths inside\n // the container (a host path from the other side would not exist here).\n const env = materializeFileSecrets(agentDir, { ...process.env, HOME: agentDir }).env;\n // Caches go to the sandbox user's own home, not to HOME: the Claude CLI's\n // logs, fontconfig, Mesa and gstreamer all follow XDG_CACHE_HOME, and under\n // agents/<name>/.cache they were copied back after every step \u2014 3,855 files\n // across 179 agent folders on the box by 17 Oct 2026, read by nothing.\n env.XDG_CACHE_HOME = \"/home/agent/.cache\";\n\n // The declared runtime, built in here \u2014 pip and npm have the network, which\n // the host side may not. It lands in .foldrun/runner/.runtimes, which the\n // executors mount from a per-tenant directory (run-container.ts#RUNTIME_CACHE):\n // the first step for a given fingerprint installs, every later one finds\n // .ready and skips. With no mount it still works, just fresh every time \u2014\n // which is what this used to be unconditionally. The log says which happened.\n const runtime = input.runtime\n ? prepareRuntime(\"runner\", input.runtime)\n : { interpreters: {}, env: {}, log: [], error: null };\n for (const line of runtime.log) emit(\"info\", \"runtime: \" + line);\n if (runtime.error) emit(\"error\", \"runtime: \" + runtime.error);\n // The step's own environment gets it too, not only the script tools: an\n // agent that runs \"python3 x.py\" or a shebang script through Bash should\n // meet the packages its runtime declared, not the image's bare python.\n Object.assign(env, runtime.env);\n\n // install: what the agent finds it needs mid-step. apt goes through the\n // root start-up's helper (install-helper.ts); pip and npm land in a\n // directory of this container's own, on the paths from the first turn.\n const { buildInstallTool, INSTALL_TOOL } = await import(\"@foldrun/core/install-tool\");\n const { stepInstallEnv, INSTALL_SOCKET } = await import(\"@foldrun/core/runtime\");\n const installDir = \"/home/agent/.foldrun/step\";\n const installing = (input.allowed ?? []).includes(INSTALL_TOOL);\n if (installing) Object.assign(env, stepInstallEnv(installDir, env));\n const install = installing\n ? buildInstallTool({\n dir: installDir,\n socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,\n cacheRoot: \"/home/agent/.foldrun/runner/.runtimes\",\n python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + \"/bin/python\" : \"python3\",\n env,\n emit,\n })\n : { server: null };\n\n // API and script tools, rebuilt in here from their specs. Secrets were\n // substituted into API headers before the input crossed the boundary, so\n // buildApiTools gets an already-resolved environment. verify: gets the\n // container's own environment, whole: it is the boundary, assembled on\n // the host from an allowlist, and the declared secrets are in it.\n // A long result is kept in a file only when the agent can read it back.\n // (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)\n const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === \"Read\" || t === \"Grep\") };\n // A declared secret named like a model variable crosses as\n // FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own\n // settings, and the agent's API and script tools and verify: get the value\n // back under the name it declared. Built here, after the runtime and any\n // mid-step install have added their paths to env.\n const toolEnv = { ...env };\n for (const [k, v] of Object.entries(env)) if (k.startsWith(\"FOLDRUN_DECLARED_\")) toolEnv[k.slice(\"FOLDRUN_DECLARED_\".length)] = v;\n const api = buildApiTools(\"\", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);\n const script = buildScriptTools(\n agentDir,\n input.scripts,\n toolEnv,\n \"/library/scripts\",\n runtime.interpreters,\n null,\n keepIn.readable,\n );\n // The translator, when the provider speaks Chat Completions: a loopback\n // server for the length of this step. Its env replaces the endpoint the\n // host assembled; the provider's real URL and key stay inside it.\n const translator = input.translator ? await startTranslator(input.translator) : null;\n if (translator) {\n Object.assign(env, translator.env);\n emit(\"info\", \"translator: listening on \" + translator.baseUrl + \" for \" + (input.translator.label ?? input.translator.upstreamBase));\n }\n const consult = buildConsultTools(input.consults ?? [], env, emit);\n // The platform's own two groups, rebuilt from values like everything else.\n const search = buildSearchTools(input.search ?? []);\n const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };\n const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };\n // A person in the loop, over the proxy this step's lease already names:\n // ask_person when tools: [ask], and the run's inbox after every tool call.\n const channel = httpChannel(env.FOLDRUN_EGRESS);\n const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };\n const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };\n const inbox = channel ? inboxReader(channel, emit) : undefined;\n // sandbox: own sub-agents: a tool each, whose job runs in its own sandbox\n // on the worker, reached over the same proxy door.\n const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec, workspaceRoot: \"/workspace\" }) : { server: null };\n\n // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept\n // fresh through the proxy's /identity door, which mints only what this\n // step's lease was granted. Fetched before the step starts, renewed five\n // minutes before it expires (with jitter); a failure is said and retried.\n // Plain JS: this text is the driver, not TypeScript.\n const identityNames = (env.FOLDRUN_IDENTITIES || \"\").split(\",\").filter(Boolean);\n if (identityNames.length) {\n // No backslashes: this sits inside a template literal, which eats them.\n const ep = new RegExp(\"^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$\").exec(env.FOLDRUN_EGRESS || \"\");\n const dir = env.FOLDRUN_IDENTITY_DIR || \"/tmp/foldrun-identity\";\n fs.mkdirSync(dir, { recursive: true, mode: 0o700 });\n const keepFresh = async (name) => {\n let wait = 30000;\n try {\n if (!ep) throw new Error(\"no egress proxy to ask\");\n const res = await fetch(ep[1] + \"/identity/\" + ep[2] + \"/\" + name, { signal: AbortSignal.timeout(15000) });\n const body = await res.json().catch(() => ({}));\n if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || (\"proxy answered \" + res.status));\n const file = dir + \"/\" + name.toLowerCase() + \".jwt\";\n fs.writeFileSync(file + \".tmp\", body.token, { mode: 0o600 });\n fs.renameSync(file + \".tmp\", file);\n wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);\n } catch (err) {\n emit(\"error\", \"identity \" + name + \": \" + (err && err.message ? err.message : String(err)) + \" \u2014 trying again in 30s\");\n }\n setTimeout(() => { void keepFresh(name); }, wait).unref();\n };\n await Promise.all(identityNames.map(keepFresh));\n }\n\n mark(\"tools\");\n const outcome = await executeStep({\n agentDir,\n workspaceRoot: \"/workspace\",\n libraryRoot: \"/library\",\n configWrites: input.configWrites === true,\n prompt: input.prompt,\n model: input.model,\n effort: input.effort ?? null,\n systemPrompt: input.systemPrompt,\n allowed: input.allowed,\n mcpNames: input.mcpNames,\n mcpServers: {\n ...(api.server ? { foldrun_apis: api.server } : {}),\n ...(script.server ? { foldrun_scripts: script.server } : {}),\n ...(consult.server ? { foldrun_agents: consult.server } : {}),\n ...(search.server ? { foldrun_search: search.server } : {}),\n ...(history.server ? { foldrun_history: history.server } : {}),\n ...(desks.server ? { foldrun_desks: desks.server } : {}),\n ...(ask.server ? { foldrun_ask: ask.server } : {}),\n ...(peers.server ? { foldrun_peers: peers.server } : {}),\n ...(delegate.server ? { foldrun_delegate: delegate.server } : {}),\n ...(install.server ? { foldrun_install: install.server } : {}),\n ...input.mcpServers,\n },\n ...(inbox ? { inbox } : {}),\n ...(input.subagents?.length ? { subagents: input.subagents } : {}),\n env,\n timeoutSec: input.timeoutSec,\n budgetUsd: input.budgetUsd,\n budgetNote: input.budgetNote,\n price: input.price,\n verify: input.verify,\n verifyEnv: toolEnv,\n ...(typeof input.judgeModel === \"string\" ? { judgeModel: input.judgeModel } : {}),\n ...(typeof input.judgeEffort === \"string\" ? { judgeEffort: input.judgeEffort } : {}),\n output: input.output,\n schema: input.schema,\n maxTurns: input.maxTurns,\n ...(input.capture === \"metadata\" || input.capture === \"full\" ? { capture: input.capture } : {}),\n ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),\n ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),\n // Slim browsing: the platform put this step on the slim image to browse\n // through the account's pod, and named the file the web tool logs its\n // reconnects to. A lost pod stops the step; what it wrote decides what\n // the runner does next (browser-pod.ts).\n ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env), ...(env.FOLDRUN_BROWSER_INIT ? { browseInit: env.FOLDRUN_BROWSER_INIT } : {}) } } : {}),\n // The container is the boundary; the SDK's own bash sandbox here would\n // only block declared network use (SSH, curl) for no added safety.\n sandboxBash: false,\n emit,\n });\n for (const line of api.drainLog()) emit(\"info\", \"api: \" + line);\n for (const line of script.drainLog()) emit(\"info\", \"script: \" + line);\n if (translator) {\n for (const line of translator.drainLog()) emit(\"info\", line.startsWith(\"translator\") ? line : \"translator: \" + line);\n await translator.close();\n }\n // A consult's spend belongs to the step that asked.\n const consultCost = consult.drainCost();\n if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;\n if (consultCost > 0 && typeof outcome.turnsCostUsd === \"number\") outcome.turnsCostUsd += consultCost;\n process.stdout.write(JSON.stringify({ e: \"done\", ...outcome, res: readActuals() }) + \"\\n\");\n process.exit(0);\n} catch (err) {\n emit(\"error\", err instanceof Error ? err.message : String(err));\n // The failure path reports actuals too \u2014 a step that died eleven minutes\n // in still held its slice for eleven minutes, and the failed runs are\n // exactly the ones whose resource story gets read.\n let res = null;\n try { res = readActuals(); } catch { /* the reading must never mask the error */ }\n process.stdout.write(JSON.stringify({ e: \"done\", status: \"failed\", result: null, costUsd: null, res }) + \"\\n\");\n process.exit(0);\n}\n";
|
|
351
|
+
export declare const DRIVER = "// The runner container's entrypoint. Reads one step, executes it with the\n// same core the server uses, speaks JSONL on stdout, exits.\nimport fs from \"node:fs\";\nconst input = JSON.parse(fs.readFileSync(\"/opt/runner/job/input.json\", \"utf8\"));\n// Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,\n// on the sandbox's clock. A worker that does not know a mark skips the line.\nconst mark = (name) => process.stdout.write(JSON.stringify({ e: \"mark\", name, at: Date.now() }) + \"\\n\");\nmark(\"driver\");\nlet modelSpoke = false;\nconst emit = (type, text, extra) => {\n // The model's first text or tool call is where start-up ends and the\n // step's own work begins \u2014 runtime and tool lines before it are set-up.\n if (!modelSpoke && (type === \"text\" || type === \"tool\")) { modelSpoke = true; mark(\"model\"); }\n process.stdout.write(JSON.stringify({ e: \"event\", type, text, ...extra }) + \"\\n\");\n};\n// What this sandbox actually touched, read at the last moment from the\n// kernel's own books: cgroup v2 for CPU busy-time and peak memory,\n// /proc/net/dev for bytes on the wire. Every read is best-effort \u2014 gVisor\n// exposes some of these files and not others depending on version, and a\n// metric we cannot read is null, never guessed. Reservation is what is\n// billed; these exist so the reservation can be right-sized.\nfunction readActuals() {\n // fs from the module's own import \u2014 the driver is ESM, and a require()\n // here threw on every call, silently nulling every metric it guarded.\n const read = (p) => { try { return fs.readFileSync(p, \"utf8\"); } catch { return null; } };\n // Two cgroup dialects: v2 (cpu.stat, microseconds; memory.peak) on a\n // plain runtime, v1-style controllers under gVisor (cpuacct.usage,\n // NANOseconds; memory.max_usage_in_bytes) \u2014 probed on the real cluster,\n // not assumed. Whichever answers wins; both silent means null.\n const cpuStat = read(\"/sys/fs/cgroup/cpu.stat\");\n const usage = cpuStat?.match(/^usage_usec (\\d+)/m);\n const cpuacctNs = usage ? NaN : Number(read(\"/sys/fs/cgroup/cpuacct/cpuacct.usage\")?.trim());\n // Time the CPU cap held the sandbox back: v2's throttled_usec in the same\n // cpu.stat, v1's throttled_time (nanoseconds) under gVisor. Large against\n // busy time means the step wanted more cores than its class gives.\n const throttledUs = cpuStat?.match(/^throttled_usec (\\d+)/m);\n const throttledNs = throttledUs ? null : read(\"/sys/fs/cgroup/cpu/cpu.stat\")?.match(/^throttled_time (\\d+)/m);\n const peak =\n read(\"/sys/fs/cgroup/memory.peak\") ??\n read(\"/sys/fs/cgroup/memory/memory.max_usage_in_bytes\") ??\n read(\"/sys/fs/cgroup/memory/memory.usage_in_bytes\");\n let rx = 0, tx = 0, sawNet = false;\n const net = read(\"/proc/net/dev\");\n if (net) {\n for (const line of net.split(\"\\n\").slice(2)) {\n const m = line.trim().match(/^(\\S+):\\s+(\\d+)(?:\\s+\\d+){7}\\s+(\\d+)/);\n if (m && m[1] !== \"lo\") { rx += Number(m[2]); tx += Number(m[3]); sawNet = true; }\n }\n }\n return {\n busyCpuSecs: usage\n ? Number(usage[1]) / 1e6\n : Number.isFinite(cpuacctNs) && cpuacctNs > 0\n ? cpuacctNs / 1e9\n : null,\n peakMemBytes: peak ? Number(peak.trim()) || null : null,\n throttledSecs: throttledUs ? Number(throttledUs[1]) / 1e6 : throttledNs ? Number(throttledNs[1]) / 1e9 : null,\n rxBytes: sawNet ? rx : null,\n txBytes: sawNet ? tx : null,\n };\n}\n\ntry {\n // Runtime state (venvs, npm prefixes) lands in the agent user's real home\n // \u2014 writable, inside the container, gone with it.\n process.env.FOLDRUN_DATA = \"/home/agent/.foldrun\";\n const { executeStep } = await import(\"@foldrun/core/step-exec\");\n const { buildApiTools } = await import(\"@foldrun/core/api-tools\");\n const { buildA2ATools } = await import(\"@foldrun/core/a2a-tools\");\n const { buildScriptTools } = await import(\"@foldrun/core/script-tools\");\n const { buildConsultTools } = await import(\"@foldrun/core/agent-tools\");\n const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import(\"@foldrun/core/context-tools\");\n const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import(\"@foldrun/core/operator\");\n const { buildDelegateTools } = await import(\"@foldrun/core/delegate-tools\");\n const { startTranslator } = await import(\"@foldrun/core/translator\");\n const { prepareRuntime } = await import(\"@foldrun/core/runtime\");\n const { materializeFileSecrets } = await import(\"@foldrun/core/secret-files\");\n const { paidWebActions } = await import(\"@foldrun/core/browser-pod\");\n mark(\"imports\");\n\n const agentDir = \"/workspace/\" + input.agentRel;\n // HOME is where a bare relative path lands when anything resolves one\n // outside cwd \u2014 and root's /root is outside the workspace, so such a read\n // was refused by confinement with a path the agent never typed. Its own\n // directory is both the truthful answer and the safe one.\n // @file secrets crossed as content; materialise them to 0600 paths inside\n // the container (a host path from the other side would not exist here).\n const env = materializeFileSecrets(agentDir, { ...process.env, HOME: agentDir }).env;\n // Caches go to the sandbox user's own home, not to HOME: the Claude CLI's\n // logs, fontconfig, Mesa and gstreamer all follow XDG_CACHE_HOME, and under\n // agents/<name>/.cache they were copied back after every step \u2014 3,855 files\n // across 179 agent folders on the box by 17 Oct 2026, read by nothing.\n env.XDG_CACHE_HOME = \"/home/agent/.cache\";\n\n // The declared runtime, built in here \u2014 pip and npm have the network, which\n // the host side may not. It lands in .foldrun/runner/.runtimes, which the\n // executors mount from a per-tenant directory (run-container.ts#RUNTIME_CACHE):\n // the first step for a given fingerprint installs, every later one finds\n // .ready and skips. With no mount it still works, just fresh every time \u2014\n // which is what this used to be unconditionally. The log says which happened.\n const runtime = input.runtime\n ? prepareRuntime(\"runner\", input.runtime)\n : { interpreters: {}, env: {}, log: [], error: null };\n for (const line of runtime.log) emit(\"info\", \"runtime: \" + line);\n if (runtime.error) emit(\"error\", \"runtime: \" + runtime.error);\n // The step's own environment gets it too, not only the script tools: an\n // agent that runs \"python3 x.py\" or a shebang script through Bash should\n // meet the packages its runtime declared, not the image's bare python.\n Object.assign(env, runtime.env);\n\n // install: what the agent finds it needs mid-step. apt goes through the\n // root start-up's helper (install-helper.ts); pip and npm land in a\n // directory of this container's own, on the paths from the first turn.\n const { buildInstallTool, INSTALL_TOOL } = await import(\"@foldrun/core/install-tool\");\n const { stepInstallEnv, INSTALL_SOCKET } = await import(\"@foldrun/core/runtime\");\n const installDir = \"/home/agent/.foldrun/step\";\n const installing = (input.allowed ?? []).includes(INSTALL_TOOL);\n if (installing) Object.assign(env, stepInstallEnv(installDir, env));\n const install = installing\n ? buildInstallTool({\n dir: installDir,\n socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,\n cacheRoot: \"/home/agent/.foldrun/runner/.runtimes\",\n python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + \"/bin/python\" : \"python3\",\n env,\n emit,\n })\n : { server: null };\n\n // API and script tools, rebuilt in here from their specs. Secrets were\n // substituted into API headers before the input crossed the boundary, so\n // buildApiTools gets an already-resolved environment. verify: gets the\n // container's own environment, whole: it is the boundary, assembled on\n // the host from an allowlist, and the declared secrets are in it.\n // A long result is kept in a file only when the agent can read it back.\n // (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)\n const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === \"Read\" || t === \"Grep\") };\n // A declared secret named like a model variable crosses as\n // FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own\n // settings, and the agent's API and script tools and verify: get the value\n // back under the name it declared. Built here, after the runtime and any\n // mid-step install have added their paths to env.\n const toolEnv = { ...env };\n for (const [k, v] of Object.entries(env)) if (k.startsWith(\"FOLDRUN_DECLARED_\")) toolEnv[k.slice(\"FOLDRUN_DECLARED_\".length)] = v;\n const api = buildApiTools(\"\", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);\n // remotes: \u2014 the four a2a tools, rebuilt from values. Each token crossed in\n // its spec (a placeholder the proxy fills for the card's origin alone), so\n // nothing here asks a vault; handles go under the workspace's state/.\n const a2a = input.a2a && Array.isArray(input.a2a.remotes) && input.a2a.remotes.length\n ? buildA2ATools(\"\", input.a2a.remotes, undefined, { env: {}, missing: [] }, {\n stateDir: \"/workspace/state/a2a/\" + input.agentRel.split(\"/\").pop(),\n runId: input.a2a.runId,\n step: input.a2a.step,\n egress: env.FOLDRUN_EGRESS || null,\n test: env.FOLDRUN_TEST_MODE === \"1\",\n keepIn,\n emit,\n })\n : { server: null, drainLog: () => [] };\n const script = buildScriptTools(\n agentDir,\n input.scripts,\n toolEnv,\n \"/library/scripts\",\n runtime.interpreters,\n null,\n keepIn.readable,\n );\n // The translator, when the provider speaks Chat Completions: a loopback\n // server for the length of this step. Its env replaces the endpoint the\n // host assembled; the provider's real URL and key stay inside it.\n const translator = input.translator ? await startTranslator(input.translator) : null;\n if (translator) {\n Object.assign(env, translator.env);\n emit(\"info\", \"translator: listening on \" + translator.baseUrl + \" for \" + (input.translator.label ?? input.translator.upstreamBase));\n }\n const consult = buildConsultTools(input.consults ?? [], env, emit);\n // The platform's own two groups, rebuilt from values like everything else.\n const search = buildSearchTools(input.search ?? []);\n const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };\n const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };\n // A person in the loop, over the proxy this step's lease already names:\n // ask_person when tools: [ask], and the run's inbox after every tool call.\n const channel = httpChannel(env.FOLDRUN_EGRESS);\n const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };\n const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };\n const inbox = channel ? inboxReader(channel, emit) : undefined;\n // sandbox: own sub-agents: a tool each, whose job runs in its own sandbox\n // on the worker, reached over the same proxy door.\n const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec, workspaceRoot: \"/workspace\" }) : { server: null };\n\n // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept\n // fresh through the proxy's /identity door, which mints only what this\n // step's lease was granted. Fetched before the step starts, renewed five\n // minutes before it expires (with jitter); a failure is said and retried.\n // Plain JS: this text is the driver, not TypeScript.\n const identityNames = (env.FOLDRUN_IDENTITIES || \"\").split(\",\").filter(Boolean);\n if (identityNames.length) {\n // No backslashes: this sits inside a template literal, which eats them.\n const ep = new RegExp(\"^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$\").exec(env.FOLDRUN_EGRESS || \"\");\n const dir = env.FOLDRUN_IDENTITY_DIR || \"/tmp/foldrun-identity\";\n fs.mkdirSync(dir, { recursive: true, mode: 0o700 });\n const keepFresh = async (name) => {\n let wait = 30000;\n try {\n if (!ep) throw new Error(\"no egress proxy to ask\");\n const res = await fetch(ep[1] + \"/identity/\" + ep[2] + \"/\" + name, { signal: AbortSignal.timeout(15000) });\n const body = await res.json().catch(() => ({}));\n if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || (\"proxy answered \" + res.status));\n const file = dir + \"/\" + name.toLowerCase() + \".jwt\";\n fs.writeFileSync(file + \".tmp\", body.token, { mode: 0o600 });\n fs.renameSync(file + \".tmp\", file);\n wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);\n } catch (err) {\n emit(\"error\", \"identity \" + name + \": \" + (err && err.message ? err.message : String(err)) + \" \u2014 trying again in 30s\");\n }\n setTimeout(() => { void keepFresh(name); }, wait).unref();\n };\n await Promise.all(identityNames.map(keepFresh));\n }\n\n mark(\"tools\");\n const outcome = await executeStep({\n agentDir,\n workspaceRoot: \"/workspace\",\n libraryRoot: \"/library\",\n configWrites: input.configWrites === true,\n prompt: input.prompt,\n model: input.model,\n effort: input.effort ?? null,\n systemPrompt: input.systemPrompt,\n allowed: input.allowed,\n mcpNames: input.mcpNames,\n mcpServers: {\n ...(api.server ? { foldrun_apis: api.server } : {}),\n ...(a2a.server ? { foldrun_a2a: a2a.server } : {}),\n ...(script.server ? { foldrun_scripts: script.server } : {}),\n ...(consult.server ? { foldrun_agents: consult.server } : {}),\n ...(search.server ? { foldrun_search: search.server } : {}),\n ...(history.server ? { foldrun_history: history.server } : {}),\n ...(desks.server ? { foldrun_desks: desks.server } : {}),\n ...(ask.server ? { foldrun_ask: ask.server } : {}),\n ...(peers.server ? { foldrun_peers: peers.server } : {}),\n ...(delegate.server ? { foldrun_delegate: delegate.server } : {}),\n ...(install.server ? { foldrun_install: install.server } : {}),\n ...input.mcpServers,\n },\n ...(inbox ? { inbox } : {}),\n ...(input.subagents?.length ? { subagents: input.subagents } : {}),\n env,\n timeoutSec: input.timeoutSec,\n budgetUsd: input.budgetUsd,\n budgetNote: input.budgetNote,\n price: input.price,\n verify: input.verify,\n verifyEnv: toolEnv,\n ...(typeof input.judgeModel === \"string\" ? { judgeModel: input.judgeModel } : {}),\n ...(typeof input.judgeEffort === \"string\" ? { judgeEffort: input.judgeEffort } : {}),\n output: input.output,\n schema: input.schema,\n maxTurns: input.maxTurns,\n ...(input.capture === \"metadata\" || input.capture === \"full\" ? { capture: input.capture } : {}),\n ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),\n ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),\n // Slim browsing: the platform put this step on the slim image to browse\n // through the account's pod, and named the file the web tool logs its\n // reconnects to. A lost pod stops the step; what it wrote decides what\n // the runner does next (browser-pod.ts).\n ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env), ...(env.FOLDRUN_BROWSER_INIT ? { browseInit: env.FOLDRUN_BROWSER_INIT } : {}) } } : {}),\n // The container is the boundary; the SDK's own bash sandbox here would\n // only block declared network use (SSH, curl) for no added safety.\n sandboxBash: false,\n emit,\n });\n for (const line of api.drainLog()) emit(\"info\", \"api: \" + line);\n for (const line of a2a.drainLog()) emit(\"info\", \"a2a: \" + line);\n for (const line of script.drainLog()) emit(\"info\", \"script: \" + line);\n if (translator) {\n for (const line of translator.drainLog()) emit(\"info\", line.startsWith(\"translator\") ? line : \"translator: \" + line);\n await translator.close();\n }\n // A consult's spend belongs to the step that asked.\n const consultCost = consult.drainCost();\n if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;\n if (consultCost > 0 && typeof outcome.turnsCostUsd === \"number\") outcome.turnsCostUsd += consultCost;\n process.stdout.write(JSON.stringify({ e: \"done\", ...outcome, res: readActuals() }) + \"\\n\");\n process.exit(0);\n} catch (err) {\n emit(\"error\", err instanceof Error ? err.message : String(err));\n // The failure path reports actuals too \u2014 a step that died eleven minutes\n // in still held its slice for eleven minutes, and the failed runs are\n // exactly the ones whose resource story gets read.\n let res = null;\n try { res = readActuals(); } catch { /* the reading must never mask the error */ }\n process.stdout.write(JSON.stringify({ e: \"done\", status: \"failed\", result: null, costUsd: null, res }) + \"\\n\");\n process.exit(0);\n}\n";
|
|
343
352
|
/** The uv release copied into the image. Pinned: a floating uv is a floating
|
|
344
353
|
* resolver, and the runtime cache is keyed on the declaration, not on it. */
|
|
345
354
|
export declare const UV_VERSION = "0.11.25";
|
|
@@ -569,6 +569,7 @@ try {
|
|
|
569
569
|
process.env.FOLDRUN_DATA = "/home/agent/.foldrun";
|
|
570
570
|
const { executeStep } = await import("@foldrun/core/step-exec");
|
|
571
571
|
const { buildApiTools } = await import("@foldrun/core/api-tools");
|
|
572
|
+
const { buildA2ATools } = await import("@foldrun/core/a2a-tools");
|
|
572
573
|
const { buildScriptTools } = await import("@foldrun/core/script-tools");
|
|
573
574
|
const { buildConsultTools } = await import("@foldrun/core/agent-tools");
|
|
574
575
|
const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import("@foldrun/core/context-tools");
|
|
@@ -645,6 +646,20 @@ try {
|
|
|
645
646
|
const toolEnv = { ...env };
|
|
646
647
|
for (const [k, v] of Object.entries(env)) if (k.startsWith("FOLDRUN_DECLARED_")) toolEnv[k.slice("FOLDRUN_DECLARED_".length)] = v;
|
|
647
648
|
const api = buildApiTools("", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);
|
|
649
|
+
// remotes: — the four a2a tools, rebuilt from values. Each token crossed in
|
|
650
|
+
// its spec (a placeholder the proxy fills for the card's origin alone), so
|
|
651
|
+
// nothing here asks a vault; handles go under the workspace's state/.
|
|
652
|
+
const a2a = input.a2a && Array.isArray(input.a2a.remotes) && input.a2a.remotes.length
|
|
653
|
+
? buildA2ATools("", input.a2a.remotes, undefined, { env: {}, missing: [] }, {
|
|
654
|
+
stateDir: "/workspace/state/a2a/" + input.agentRel.split("/").pop(),
|
|
655
|
+
runId: input.a2a.runId,
|
|
656
|
+
step: input.a2a.step,
|
|
657
|
+
egress: env.FOLDRUN_EGRESS || null,
|
|
658
|
+
test: env.FOLDRUN_TEST_MODE === "1",
|
|
659
|
+
keepIn,
|
|
660
|
+
emit,
|
|
661
|
+
})
|
|
662
|
+
: { server: null, drainLog: () => [] };
|
|
648
663
|
const script = buildScriptTools(
|
|
649
664
|
agentDir,
|
|
650
665
|
input.scripts,
|
|
@@ -721,6 +736,7 @@ try {
|
|
|
721
736
|
mcpNames: input.mcpNames,
|
|
722
737
|
mcpServers: {
|
|
723
738
|
...(api.server ? { foldrun_apis: api.server } : {}),
|
|
739
|
+
...(a2a.server ? { foldrun_a2a: a2a.server } : {}),
|
|
724
740
|
...(script.server ? { foldrun_scripts: script.server } : {}),
|
|
725
741
|
...(consult.server ? { foldrun_agents: consult.server } : {}),
|
|
726
742
|
...(search.server ? { foldrun_search: search.server } : {}),
|
|
@@ -760,6 +776,7 @@ try {
|
|
|
760
776
|
emit,
|
|
761
777
|
});
|
|
762
778
|
for (const line of api.drainLog()) emit("info", "api: " + line);
|
|
779
|
+
for (const line of a2a.drainLog()) emit("info", "a2a: " + line);
|
|
763
780
|
for (const line of script.drainLog()) emit("info", "script: " + line);
|
|
764
781
|
if (translator) {
|
|
765
782
|
for (const line of translator.drainLog()) emit("info", line.startsWith("translator") ? line : "translator: " + line);
|
package/dist/src/runner.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type DelegateSpec, type SubagentSpec } from "./subagents.ts";
|
|
2
2
|
import { type FlowStep, type RunRecord, type StepRecord } from "./store.ts";
|
|
3
3
|
import { type Catalog } from "./catalog.ts";
|
|
4
|
+
import { type RemoteSpec } from "./a2a-remotes.ts";
|
|
4
5
|
import { type SearchRoot, type RunDigest } from "./context-tools.ts";
|
|
5
6
|
import { type ScriptSpec } from "./script-tools.ts";
|
|
6
7
|
import { type RuntimeSpec } from "./runtime.ts";
|
|
@@ -331,6 +332,9 @@ grantsOnly?: boolean): {
|
|
|
331
332
|
apiTools: import("./api-tools.ts").ApiToolResult;
|
|
332
333
|
scriptTools: import("./script-tools.ts").ScriptToolResult;
|
|
333
334
|
apiSpecs: import("./store.ts").ApiSpec[];
|
|
335
|
+
remotes: RemoteSpec[];
|
|
336
|
+
remoteOnlySecrets: string[];
|
|
337
|
+
remoteSecretEnv: Record<string, string>;
|
|
334
338
|
scriptSpecs: ScriptSpec[];
|
|
335
339
|
outward: boolean;
|
|
336
340
|
searchRoots: SearchRoot[];
|
|
@@ -579,6 +583,12 @@ export declare function platformModelEnv(): Record<string, string | undefined>;
|
|
|
579
583
|
export declare function readSchemaFile(workspaceRoot: string, rel: string): Record<string, unknown> | boolean | null;
|
|
580
584
|
export declare function createFlowRun(tenant: string, workspace: string, steps: FlowStep[], flowName: string, status: "queued" | "running", tags?: string[], startedBy?: string | null, opts?: {
|
|
581
585
|
test?: boolean;
|
|
586
|
+
},
|
|
587
|
+
/** An A2A task's correlation (foldrun-platform flow-admission.ts) — its
|
|
588
|
+
* own trailing argument, so the signature only widened. */
|
|
589
|
+
a2a?: {
|
|
590
|
+
task: string;
|
|
591
|
+
key: string;
|
|
582
592
|
}): RunRecord;
|
|
583
593
|
export declare function startFlowRun(tenant: string, workspace: string, steps: FlowStep[], flowName: string, modelOverride?: string | null, tags?: string[], effortOverride?: string | null, startedBy?: string | null, opts?: {
|
|
584
594
|
test?: boolean;
|