@foldrun/core 2.9.0 → 2.10.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.
@@ -6,17 +6,64 @@ export declare const DELEGATE_SERVER = "foldrun_delegate";
6
6
  export declare const JOB_MAX = 20000;
7
7
  /** What comes back is cut here: the reply is the parent's context. */
8
8
  export declare const REPLY_MAX = 40000;
9
+ /** What one job's files may add up to when copied back into the parent's
10
+ * sandbox, and the most files. They travel with the reply through the
11
+ * operator store, so the cap is on the raw bytes before base64. */
12
+ export declare const COPY_BACK_MAX_BYTES: number;
13
+ export declare const COPY_BACK_MAX_FILES = 100;
14
+ /** Read what a job wrote, on the host, for the trip back to the parent's
15
+ * sandbox: files only (no links), confined to the workspace, until the cap.
16
+ * What does not fit is named, never cut. */
17
+ export declare function readCopyBack(workspaceDir: string, wrote: string[]): {
18
+ files: {
19
+ path: string;
20
+ base64: string;
21
+ }[];
22
+ omitted: string[];
23
+ };
24
+ /** Where a sandbox notes what was copied back into it: path → sha256 of the
25
+ * bytes written. The step's copy-out reads it (run-container.ts
26
+ * applyContainerChanges) as what those files held before the step touched
27
+ * them, so a job's change already on the workspace is not applied twice.
28
+ * Under .foldrun/, which is never copied back as content. */
29
+ export declare const COPY_BACK_MANIFEST = ".foldrun/copied-back.json";
30
+ /** The manifest of a sandbox's workspace, or {}. */
31
+ export declare function readCopyBackManifest(workspaceRoot: string): Record<string, string>;
32
+ /** Write a job's files into this sandbox's workspace. A path that would leave
33
+ * it, or land through a link, is refused. A file the step itself changed
34
+ * since `since` (its own edit, not one copied back) is not overwritten: the
35
+ * job's version goes beside it as `<name>.from-<agent><ext>`. */
36
+ export declare function copyBackFiles(workspaceRoot: string, files: {
37
+ path: string;
38
+ base64: string;
39
+ }[], opts?: {
40
+ since?: number;
41
+ from?: string;
42
+ }): {
43
+ copied: string[];
44
+ conflicts: {
45
+ path: string;
46
+ copy: string;
47
+ }[];
48
+ };
9
49
  type Emit = (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
10
50
  /** The granted names, the same on every path. */
11
51
  export declare function delegateToolNames(specs: DelegateSpec[]): string[];
12
- /** The reply as the parent reads it: the sub-agent's words, then what it changed. */
13
- export declare function replyText(name: string, r: DelegationResult): string;
52
+ /** The reply as the parent reads it: the sub-agent's words, then what it
53
+ * changed — `copied` are the paths now in this sandbox too. */
54
+ export declare function replyText(name: string, r: DelegationResult, copied?: string[], conflicts?: {
55
+ path: string;
56
+ copy: string;
57
+ }[]): string;
14
58
  export declare function buildDelegateTools(opts: {
15
59
  specs: DelegateSpec[];
16
60
  channel: OperatorChannel | null;
17
61
  emit: Emit;
18
62
  /** The most one job may run; the step's own timeout bounds it anyway. */
19
63
  timeoutSec?: number;
64
+ /** This sandbox's workspace: where a job's files are copied back. Absent
65
+ * in-process, where the job wrote the same workspace already. */
66
+ workspaceRoot?: string;
20
67
  now?: () => number;
21
68
  }): {
22
69
  server: null;
@@ -14,9 +14,12 @@
14
14
  // one door it has (the egress proxy, at its lease — operator.ts), and the
15
15
  // worker runs the job as a step of its own: its own pod, its own `tools:`,
16
16
  // `secrets:` and `network:` from its own file, NOT cut to the parent's — the
17
- // parent may hold less. The parent waits and gets the reply text, nothing
18
- // else. What the sub-agent wrote reaches the workspace when it finishes, so
19
- // the next step sees it; the reply names the paths.
17
+ // parent may hold less. The parent waits and gets the reply text. What the
18
+ // sub-agent wrote reaches the workspace when it finishes, and comes back with
19
+ // the reply into the parent's sandbox too (copyBackFiles), so the parent's
20
+ // model and its `verify:` see it there: a `verify: file:` on the delegating
21
+ // step used to fail on a file the job had written (owner, 23 Oct 2026). What
22
+ // is too large to send back still reaches the workspace for the next step.
20
23
  //
21
24
  // Depth one, as for every sub-agent: the job's own `subagents:` are not
22
25
  // offered to it. Its record sits in the run after the parent's, marked
@@ -24,6 +27,9 @@
24
27
  //
25
28
  // With no channel (a container step on an install without a proxy) the tools
26
29
  // are not granted at all; the runner says so on the step.
30
+ import crypto from "node:crypto";
31
+ import fs from "node:fs";
32
+ import path from "node:path";
27
33
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
28
34
  import { z } from "zod";
29
35
  import { delegateToolName } from "./subagents.js";
@@ -32,22 +38,228 @@ export const DELEGATE_SERVER = "foldrun_delegate";
32
38
  export const JOB_MAX = 20_000;
33
39
  /** What comes back is cut here: the reply is the parent's context. */
34
40
  export const REPLY_MAX = 40_000;
41
+ /** What one job's files may add up to when copied back into the parent's
42
+ * sandbox, and the most files. They travel with the reply through the
43
+ * operator store, so the cap is on the raw bytes before base64. */
44
+ export const COPY_BACK_MAX_BYTES = 4 * 1024 * 1024;
45
+ export const COPY_BACK_MAX_FILES = 100;
46
+ /** A workspace-relative path that stays inside the workspace. */
47
+ function insideRel(rel) {
48
+ const norm = path.posix.normalize(String(rel).replaceAll("\\", "/"));
49
+ if (!norm || norm === "." || norm.startsWith("../") || norm === ".." || path.posix.isAbsolute(norm))
50
+ return null;
51
+ return norm;
52
+ }
53
+ /** Read what a job wrote, on the host, for the trip back to the parent's
54
+ * sandbox: files only (no links), confined to the workspace, until the cap.
55
+ * What does not fit is named, never cut. */
56
+ export function readCopyBack(workspaceDir, wrote) {
57
+ const files = [];
58
+ const omitted = [];
59
+ let total = 0;
60
+ const root = path.resolve(workspaceDir);
61
+ let realRoot;
62
+ try {
63
+ realRoot = fs.realpathSync(root);
64
+ }
65
+ catch {
66
+ return { files, omitted };
67
+ }
68
+ for (const w of [...new Set(wrote)]) {
69
+ const rel = insideRel(w);
70
+ if (!rel)
71
+ continue;
72
+ const abs = path.join(root, rel);
73
+ let st;
74
+ try {
75
+ st = fs.lstatSync(abs);
76
+ // A directory link on the way must not lead out of the workspace:
77
+ // what is read here goes into another sandbox (Codex review).
78
+ const real = fs.realpathSync(abs);
79
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep))
80
+ continue;
81
+ }
82
+ catch {
83
+ continue; // written then removed by the job
84
+ }
85
+ if (!st.isFile())
86
+ continue;
87
+ if (files.length >= COPY_BACK_MAX_FILES || total + st.size > COPY_BACK_MAX_BYTES) {
88
+ omitted.push(rel);
89
+ continue;
90
+ }
91
+ const buf = fs.readFileSync(abs);
92
+ total += buf.length;
93
+ files.push({ path: rel, base64: buf.toString("base64") });
94
+ }
95
+ return { files, omitted };
96
+ }
97
+ /** Where a sandbox notes what was copied back into it: path → sha256 of the
98
+ * bytes written. The step's copy-out reads it (run-container.ts
99
+ * applyContainerChanges) as what those files held before the step touched
100
+ * them, so a job's change already on the workspace is not applied twice.
101
+ * Under .foldrun/, which is never copied back as content. */
102
+ export const COPY_BACK_MANIFEST = ".foldrun/copied-back.json";
103
+ const sha256 = (b) => crypto.createHash("sha256").update(b).digest("hex");
104
+ const exists = (p) => {
105
+ try {
106
+ fs.lstatSync(p);
107
+ return true;
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ };
113
+ /** The manifest's folder and file, unless either is something other than a
114
+ * real directory and file — a link planted there is never followed. */
115
+ function manifestPath(root) {
116
+ const dir = path.join(root, path.dirname(COPY_BACK_MANIFEST));
117
+ const file = path.join(root, COPY_BACK_MANIFEST);
118
+ try {
119
+ if (!fs.lstatSync(dir).isDirectory())
120
+ return null;
121
+ }
122
+ catch {
123
+ return file; // neither exists yet
124
+ }
125
+ try {
126
+ if (!fs.lstatSync(file).isFile())
127
+ return null;
128
+ }
129
+ catch {
130
+ /* new file */
131
+ }
132
+ return file;
133
+ }
134
+ function writeManifest(root, manifest) {
135
+ const file = manifestPath(root);
136
+ if (!file)
137
+ return;
138
+ fs.mkdirSync(path.dirname(file), { recursive: true });
139
+ fs.writeFileSync(file, JSON.stringify(manifest), { flag: "w" });
140
+ }
141
+ /** The manifest of a sandbox's workspace, or {}. */
142
+ export function readCopyBackManifest(workspaceRoot) {
143
+ const file = manifestPath(path.resolve(workspaceRoot));
144
+ if (!file)
145
+ return {};
146
+ try {
147
+ const m = JSON.parse(fs.readFileSync(file, "utf8"));
148
+ return m && typeof m === "object" && !Array.isArray(m) ? m : {};
149
+ }
150
+ catch {
151
+ return {};
152
+ }
153
+ }
154
+ /** Write a job's files into this sandbox's workspace. A path that would leave
155
+ * it, or land through a link, is refused. A file the step itself changed
156
+ * since `since` (its own edit, not one copied back) is not overwritten: the
157
+ * job's version goes beside it as `<name>.from-<agent><ext>`. */
158
+ export function copyBackFiles(workspaceRoot, files, opts = {}) {
159
+ const root = path.resolve(workspaceRoot);
160
+ const manifest = readCopyBackManifest(root);
161
+ const copied = [];
162
+ const conflicts = [];
163
+ // Every directory on the way must be a real one inside the workspace: a
164
+ // link the step planted must not carry the write elsewhere.
165
+ const safeDirs = (abs) => {
166
+ let dir = path.dirname(abs);
167
+ const chain = [];
168
+ while (dir.startsWith(root + path.sep)) {
169
+ chain.unshift(dir);
170
+ dir = path.dirname(dir);
171
+ }
172
+ for (const d of chain) {
173
+ try {
174
+ if (!fs.lstatSync(d).isDirectory())
175
+ return false;
176
+ }
177
+ catch {
178
+ fs.mkdirSync(d);
179
+ }
180
+ }
181
+ return true;
182
+ };
183
+ for (const f of files) {
184
+ const rel = insideRel(f.path);
185
+ if (!rel)
186
+ continue;
187
+ let target = rel;
188
+ let abs = path.join(root, rel);
189
+ if (!safeDirs(abs))
190
+ continue;
191
+ let st = null;
192
+ try {
193
+ st = fs.lstatSync(abs);
194
+ }
195
+ catch {
196
+ /* new file */
197
+ }
198
+ if (st && !st.isFile())
199
+ continue;
200
+ const bytes = Buffer.from(f.base64, "base64");
201
+ if (st) {
202
+ const now = fs.readFileSync(abs);
203
+ if (now.equals(bytes)) {
204
+ manifest[rel] = sha256(bytes);
205
+ copied.push(rel);
206
+ continue;
207
+ }
208
+ const ours = manifest[rel] === sha256(now);
209
+ const editedHere = !ours && opts.since !== undefined && st.mtimeMs > opts.since;
210
+ if (editedHere) {
211
+ // A name nothing has yet: an earlier conflict copy may be the step's
212
+ // own work by now (Codex review).
213
+ const ext = path.posix.extname(rel);
214
+ const stem = `${rel.slice(0, rel.length - ext.length)}.from-${(opts.from ?? "subagent").replace(/[^A-Za-z0-9_-]/g, "-")}`;
215
+ for (let n = 1;; n++) {
216
+ target = `${stem}${n === 1 ? "" : `-${n}`}${ext}`;
217
+ abs = path.join(root, target);
218
+ if (!exists(abs))
219
+ break;
220
+ }
221
+ // Not in the manifest: this copy is not on the workspace yet, and
222
+ // the copy-out must carry it there.
223
+ fs.writeFileSync(abs, bytes);
224
+ conflicts.push({ path: rel, copy: target });
225
+ continue;
226
+ }
227
+ }
228
+ fs.writeFileSync(abs, bytes);
229
+ manifest[rel] = sha256(bytes);
230
+ copied.push(rel);
231
+ }
232
+ if (copied.length || conflicts.length)
233
+ writeManifest(root, manifest);
234
+ return { copied, conflicts };
235
+ }
35
236
  /** The granted names, the same on every path. */
36
237
  export function delegateToolNames(specs) {
37
238
  return specs.map((s) => `mcp__${DELEGATE_SERVER}__${delegateToolName(s.name)}`);
38
239
  }
39
- /** The reply as the parent reads it: the sub-agent's words, then what it changed. */
40
- export function replyText(name, r) {
240
+ /** The reply as the parent reads it: the sub-agent's words, then what it
241
+ * changed — `copied` are the paths now in this sandbox too. */
242
+ export function replyText(name, r, copied = [], conflicts = []) {
41
243
  const body = r.result.trim() || "(no reply)";
42
244
  const cut = body.length > REPLY_MAX ? `${body.slice(0, REPLY_MAX)}\n…(cut at ${REPLY_MAX} characters)` : body;
43
- const wrote = r.wrote?.length
44
- ? `\n\nFiles ${name} changed (in the workspace from the next step on — not in your sandbox):\n${r.wrote.slice(0, 200).map((w) => `- ${w}`).join("\n")}`
45
- : "";
245
+ const here = new Set([...copied, ...conflicts.map((c) => c.path)]);
246
+ const elsewhere = (r.wrote ?? []).filter((w) => !here.has(w));
247
+ const wrote = (copied.length
248
+ ? `\n\nFiles ${name} changed, copied into your sandbox at the same paths:\n${copied.slice(0, 200).map((w) => `- ${w}`).join("\n")}`
249
+ : "") +
250
+ (conflicts.length
251
+ ? `\n\nFiles you had changed yourself, which ${name} changed too — yours are kept; its version is beside each:\n${conflicts.slice(0, 200).map((c) => `- ${c.path} → ${c.copy}`).join("\n")}`
252
+ : "") +
253
+ (elsewhere.length
254
+ ? `\n\nFiles ${name} changed that are not in your sandbox (too large to copy back — in the workspace from the next step on):\n${elsewhere.slice(0, 200).map((w) => `- ${w}`).join("\n")}`
255
+ : "");
46
256
  const head = r.status === "completed" ? "" : `${name} did not finish (${r.status}). What it said:\n`;
47
257
  return head + cut + wrote;
48
258
  }
49
259
  export function buildDelegateTools(opts) {
50
260
  const now = opts.now ?? Date.now;
261
+ // Anything in this sandbox newer than this, the step changed itself.
262
+ const startedAt = Date.now();
51
263
  const channel = opts.channel;
52
264
  if (!opts.specs.length || !channel?.delegate || !channel.delegation) {
53
265
  return { server: null, toolNames: [], calls: {} };
@@ -77,8 +289,21 @@ export function buildDelegateTools(opts) {
77
289
  continue;
78
290
  }
79
291
  if (got) {
80
- opts.emit(got.status === "completed" ? "info" : "error", `${s.name} ${got.status}${got.wrote?.length ? ` — changed ${got.wrote.length} file(s)` : ""}`, { subagent: s.name });
81
- return text(replyText(s.name, got), got.status !== "completed");
292
+ let copied = [];
293
+ let conflicts = [];
294
+ if (opts.workspaceRoot && got.files?.length) {
295
+ try {
296
+ ({ copied, conflicts } = copyBackFiles(opts.workspaceRoot, got.files, { since: startedAt, from: s.name }));
297
+ }
298
+ catch (err) {
299
+ opts.emit("error", `could not copy ${s.name}'s files into this sandbox: ${err instanceof Error ? err.message : String(err)}`, { subagent: s.name });
300
+ }
301
+ }
302
+ const back = copied.length || conflicts.length
303
+ ? `; ${copied.length} copied into this sandbox${conflicts.length ? `, ${conflicts.length} kept beside the step's own edit` : ""}`
304
+ : "";
305
+ opts.emit(got.status === "completed" ? "info" : "error", `${s.name} ${got.status}${got.wrote?.length ? ` — changed ${got.wrote.length} file(s)${back}` : ""}`, { subagent: s.name });
306
+ return text(replyText(s.name, got, opts.workspaceRoot ? copied : (got.wrote ?? []), conflicts), got.status !== "completed");
82
307
  }
83
308
  }
84
309
  try {
@@ -147,7 +372,7 @@ export function localDelegateChannel(run) {
147
372
  * works where. */
148
373
  export function delegatePromptLine(specs) {
149
374
  return (`<isolated-subagents>\nThese colleagues work in their own sandbox, with their own tools; call the tool to hand one a job ` +
150
- `and you get back its reply and the paths of any files it changed:\n` +
375
+ `and you get back its reply, with the files it changed copied into your sandbox at the same paths:\n` +
151
376
  specs.map((s) => `- ${delegateToolName(s.name)}: ${s.description}`).join("\n") +
152
377
  `\n</isolated-subagents>`);
153
378
  }
@@ -103,6 +103,15 @@ export interface DelegationResult {
103
103
  /** Workspace paths it changed. They reach the workspace when it ends, so
104
104
  * the next step sees them; the delegating step's own sandbox does not. */
105
105
  wrote?: string[];
106
+ /** What it wrote, copied back into the delegating step's sandbox, so the
107
+ * parent's model and its `verify:` see it there (copyBackFiles), base64. */
108
+ files?: {
109
+ path: string;
110
+ base64: string;
111
+ }[];
112
+ /** Written paths too large to copy back: they reach the workspace when the
113
+ * job ends, so the next step sees them. */
114
+ filesOmitted?: string[];
106
115
  }
107
116
  export declare const QUESTION_MAX = 2000;
108
117
  export declare const OPTION_MAX = 200;
@@ -136,6 +136,10 @@ export function httpChannel(egress, fetchImpl = fetch) {
136
136
  status,
137
137
  result: typeof body.result === "string" ? body.result : "",
138
138
  wrote: Array.isArray(body.wrote) ? body.wrote.filter((w) => typeof w === "string") : [],
139
+ files: Array.isArray(body.files)
140
+ ? body.files.filter((f) => !!f && typeof f.path === "string" && typeof f.base64 === "string")
141
+ : [],
142
+ filesOmitted: Array.isArray(body.filesOmitted) ? body.filesOmitted.filter((w) => typeof w === "string") : [],
139
143
  };
140
144
  },
141
145
  async cancelDelegation(id) {
@@ -296,7 +296,7 @@ export declare function runtimeCacheMount(tenant: string | undefined): {
296
296
  target: string;
297
297
  } | null;
298
298
  /** 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). */
299
- 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 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 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 }) : { 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 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";
299
+ 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 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 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 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";
300
300
  /** The uv release copied into the image. Pinned: a floating uv is a floating
301
301
  * resolver, and the runtime cache is keyed on the declaration, not on it. */
302
302
  export declare const UV_VERSION = "0.11.25";
@@ -259,6 +259,26 @@ export function mergeAppends(was, host, next) {
259
259
  }
260
260
  export function applyContainerChanges(hostWs, containerWs, baseline, note, divert) {
261
261
  const applied = [];
262
+ // Files a sandbox: own job's reply copied into this sandbox mid-step
263
+ // (delegate-tools.ts copyBackFiles), path → sha256 of what was written: for
264
+ // those, that is what the step started from. The job's change is already on
265
+ // the workspace; read against the older baseline it came back as the
266
+ // step's own and was merged in a second time (Codex review, 23 Oct 2026).
267
+ let imported = {};
268
+ try {
269
+ // Read here, on the host: a link the step planted is never followed,
270
+ // the same rule as every file below.
271
+ const dir = path.join(containerWs, ".foldrun");
272
+ const file = path.join(dir, "copied-back.json");
273
+ if (fs.lstatSync(dir).isDirectory() && fs.lstatSync(file).isFile()) {
274
+ const m = JSON.parse(fs.readFileSync(file, "utf8"));
275
+ if (m && typeof m === "object" && !Array.isArray(m))
276
+ imported = m;
277
+ }
278
+ }
279
+ catch {
280
+ /* none copied back */
281
+ }
262
282
  const walk = (dir) => {
263
283
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
264
284
  const abs = path.join(dir, entry.name);
@@ -289,6 +309,9 @@ export function applyContainerChanges(hostWs, containerWs, baseline, note, diver
289
309
  else if (baseline) {
290
310
  was = baseline[rel.replaceAll("\\", "/")] ?? null;
291
311
  }
312
+ const importedSha = imported[rel.replaceAll("\\", "/")];
313
+ if (typeof importedSha === "string")
314
+ was = importedSha;
292
315
  const same = (b) => was === null || was === undefined ? false : typeof was === "string" ? sha(b) === was : b.equals(was);
293
316
  // Untouched by this step: whatever the host says now is more current
294
317
  // than what we handed in, including a change made while it ran.
@@ -579,7 +602,7 @@ try {
579
602
  const inbox = channel ? inboxReader(channel, emit) : undefined;
580
603
  // sandbox: own sub-agents: a tool each, whose job runs in its own sandbox
581
604
  // on the worker, reached over the same proxy door.
582
- const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec }) : { server: null };
605
+ const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec, workspaceRoot: "/workspace" }) : { server: null };
583
606
 
584
607
  // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept
585
608
  // fresh through the proxy's /identity door, which mints only what this
@@ -34,7 +34,7 @@ const isolatedRun = () => {
34
34
  };
35
35
  import { gatherConsults, buildConsultTools } from "./agent-tools.js";
36
36
  import { DELEGATE_TOOL, gatherSubagents, ownSandbox, subagentPrompt, subagentTools } from "./subagents.js";
37
- import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel } from "./delegate-tools.js";
37
+ import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel, readCopyBack } from "./delegate-tools.js";
38
38
  import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine } from "./operator.js";
39
39
  import { sendPlainNotification } from "./notify.js";
40
40
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
@@ -4875,10 +4875,15 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4875
4875
  entry.ms = Date.now() - started;
4876
4876
  save();
4877
4877
  const errors = child.events.filter((e) => e.type === "error").map((e) => e.text).join("\n");
4878
+ // Its files go back with the reply, so the delegating step's
4879
+ // sandbox has them too (delegate-tools.ts copyBackFiles).
4880
+ const back = readCopyBack(pDir, child.wrote ?? []);
4878
4881
  return {
4879
4882
  status,
4880
4883
  result: child.result ?? (errors || "(no reply)"),
4881
4884
  wrote: child.wrote ?? [],
4885
+ files: back.files,
4886
+ filesOmitted: back.omitted,
4882
4887
  };
4883
4888
  };
4884
4889
  // An agent's own `budget:` — the most it may spend in one run — is
package/dist/src/scan.js CHANGED
@@ -662,7 +662,7 @@ function scanAgents(agents, outward, toolDefs, push, networks) {
662
662
  const extra = refNames(sub.data.tools).filter((t) => !tools.includes(t));
663
663
  if (sub.data.sandbox === "own") {
664
664
  if (extra.length) {
665
- push("security/subagent-own-sandbox", a.file, keyLine(a.lines, "subagents"), `subagent ${s} own ${extra.join(",")}`, `subagents: [${s}] — ${s} runs in its own sandbox with ${extra.join(", ")}, which ${a.name} does not have; only its reply comes back`);
665
+ push("security/subagent-own-sandbox", a.file, keyLine(a.lines, "subagents"), `subagent ${s} own ${extra.join(",")}`, `subagents: [${s}] — ${s} runs in its own sandbox with ${extra.join(", ")}, which ${a.name} does not have; only its reply and the files it wrote come back`);
666
666
  }
667
667
  continue;
668
668
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -14,9 +14,12 @@
14
14
  // one door it has (the egress proxy, at its lease — operator.ts), and the
15
15
  // worker runs the job as a step of its own: its own pod, its own `tools:`,
16
16
  // `secrets:` and `network:` from its own file, NOT cut to the parent's — the
17
- // parent may hold less. The parent waits and gets the reply text, nothing
18
- // else. What the sub-agent wrote reaches the workspace when it finishes, so
19
- // the next step sees it; the reply names the paths.
17
+ // parent may hold less. The parent waits and gets the reply text. What the
18
+ // sub-agent wrote reaches the workspace when it finishes, and comes back with
19
+ // the reply into the parent's sandbox too (copyBackFiles), so the parent's
20
+ // model and its `verify:` see it there: a `verify: file:` on the delegating
21
+ // step used to fail on a file the job had written (owner, 23 Oct 2026). What
22
+ // is too large to send back still reaches the workspace for the next step.
20
23
  //
21
24
  // Depth one, as for every sub-agent: the job's own `subagents:` are not
22
25
  // offered to it. Its record sits in the run after the parent's, marked
@@ -25,6 +28,9 @@
25
28
  // With no channel (a container step on an install without a proxy) the tools
26
29
  // are not granted at all; the runner says so on the step.
27
30
 
31
+ import crypto from "node:crypto";
32
+ import fs from "node:fs";
33
+ import path from "node:path";
28
34
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
29
35
  import { z } from "zod";
30
36
  import type { DelegationResult, OperatorChannel } from "./operator.ts";
@@ -37,6 +43,193 @@ export const JOB_MAX = 20_000;
37
43
  /** What comes back is cut here: the reply is the parent's context. */
38
44
  export const REPLY_MAX = 40_000;
39
45
 
46
+ /** What one job's files may add up to when copied back into the parent's
47
+ * sandbox, and the most files. They travel with the reply through the
48
+ * operator store, so the cap is on the raw bytes before base64. */
49
+ export const COPY_BACK_MAX_BYTES = 4 * 1024 * 1024;
50
+ export const COPY_BACK_MAX_FILES = 100;
51
+
52
+ /** A workspace-relative path that stays inside the workspace. */
53
+ function insideRel(rel: string): string | null {
54
+ const norm = path.posix.normalize(String(rel).replaceAll("\\", "/"));
55
+ if (!norm || norm === "." || norm.startsWith("../") || norm === ".." || path.posix.isAbsolute(norm)) return null;
56
+ return norm;
57
+ }
58
+
59
+ /** Read what a job wrote, on the host, for the trip back to the parent's
60
+ * sandbox: files only (no links), confined to the workspace, until the cap.
61
+ * What does not fit is named, never cut. */
62
+ export function readCopyBack(workspaceDir: string, wrote: string[]): { files: { path: string; base64: string }[]; omitted: string[] } {
63
+ const files: { path: string; base64: string }[] = [];
64
+ const omitted: string[] = [];
65
+ let total = 0;
66
+ const root = path.resolve(workspaceDir);
67
+ let realRoot: string;
68
+ try {
69
+ realRoot = fs.realpathSync(root);
70
+ } catch {
71
+ return { files, omitted };
72
+ }
73
+ for (const w of [...new Set(wrote)]) {
74
+ const rel = insideRel(w);
75
+ if (!rel) continue;
76
+ const abs = path.join(root, rel);
77
+ let st: fs.Stats;
78
+ try {
79
+ st = fs.lstatSync(abs);
80
+ // A directory link on the way must not lead out of the workspace:
81
+ // what is read here goes into another sandbox (Codex review).
82
+ const real = fs.realpathSync(abs);
83
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) continue;
84
+ } catch {
85
+ continue; // written then removed by the job
86
+ }
87
+ if (!st.isFile()) continue;
88
+ if (files.length >= COPY_BACK_MAX_FILES || total + st.size > COPY_BACK_MAX_BYTES) {
89
+ omitted.push(rel);
90
+ continue;
91
+ }
92
+ const buf = fs.readFileSync(abs);
93
+ total += buf.length;
94
+ files.push({ path: rel, base64: buf.toString("base64") });
95
+ }
96
+ return { files, omitted };
97
+ }
98
+
99
+ /** Where a sandbox notes what was copied back into it: path → sha256 of the
100
+ * bytes written. The step's copy-out reads it (run-container.ts
101
+ * applyContainerChanges) as what those files held before the step touched
102
+ * them, so a job's change already on the workspace is not applied twice.
103
+ * Under .foldrun/, which is never copied back as content. */
104
+ export const COPY_BACK_MANIFEST = ".foldrun/copied-back.json";
105
+
106
+ const sha256 = (b: Buffer) => crypto.createHash("sha256").update(b).digest("hex");
107
+
108
+ const exists = (p: string) => {
109
+ try {
110
+ fs.lstatSync(p);
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ };
116
+
117
+ /** The manifest's folder and file, unless either is something other than a
118
+ * real directory and file — a link planted there is never followed. */
119
+ function manifestPath(root: string): string | null {
120
+ const dir = path.join(root, path.dirname(COPY_BACK_MANIFEST));
121
+ const file = path.join(root, COPY_BACK_MANIFEST);
122
+ try {
123
+ if (!fs.lstatSync(dir).isDirectory()) return null;
124
+ } catch {
125
+ return file; // neither exists yet
126
+ }
127
+ try {
128
+ if (!fs.lstatSync(file).isFile()) return null;
129
+ } catch {
130
+ /* new file */
131
+ }
132
+ return file;
133
+ }
134
+
135
+ function writeManifest(root: string, manifest: Record<string, string>): void {
136
+ const file = manifestPath(root);
137
+ if (!file) return;
138
+ fs.mkdirSync(path.dirname(file), { recursive: true });
139
+ fs.writeFileSync(file, JSON.stringify(manifest), { flag: "w" });
140
+ }
141
+
142
+ /** The manifest of a sandbox's workspace, or {}. */
143
+ export function readCopyBackManifest(workspaceRoot: string): Record<string, string> {
144
+ const file = manifestPath(path.resolve(workspaceRoot));
145
+ if (!file) return {};
146
+ try {
147
+ const m = JSON.parse(fs.readFileSync(file, "utf8"));
148
+ return m && typeof m === "object" && !Array.isArray(m) ? (m as Record<string, string>) : {};
149
+ } catch {
150
+ return {};
151
+ }
152
+ }
153
+
154
+ /** Write a job's files into this sandbox's workspace. A path that would leave
155
+ * it, or land through a link, is refused. A file the step itself changed
156
+ * since `since` (its own edit, not one copied back) is not overwritten: the
157
+ * job's version goes beside it as `<name>.from-<agent><ext>`. */
158
+ export function copyBackFiles(
159
+ workspaceRoot: string,
160
+ files: { path: string; base64: string }[],
161
+ opts: { since?: number; from?: string } = {},
162
+ ): { copied: string[]; conflicts: { path: string; copy: string }[] } {
163
+ const root = path.resolve(workspaceRoot);
164
+ const manifest = readCopyBackManifest(root);
165
+ const copied: string[] = [];
166
+ const conflicts: { path: string; copy: string }[] = [];
167
+ // Every directory on the way must be a real one inside the workspace: a
168
+ // link the step planted must not carry the write elsewhere.
169
+ const safeDirs = (abs: string): boolean => {
170
+ let dir = path.dirname(abs);
171
+ const chain: string[] = [];
172
+ while (dir.startsWith(root + path.sep)) {
173
+ chain.unshift(dir);
174
+ dir = path.dirname(dir);
175
+ }
176
+ for (const d of chain) {
177
+ try {
178
+ if (!fs.lstatSync(d).isDirectory()) return false;
179
+ } catch {
180
+ fs.mkdirSync(d);
181
+ }
182
+ }
183
+ return true;
184
+ };
185
+ for (const f of files) {
186
+ const rel = insideRel(f.path);
187
+ if (!rel) continue;
188
+ let target = rel;
189
+ let abs = path.join(root, rel);
190
+ if (!safeDirs(abs)) continue;
191
+ let st: fs.Stats | null = null;
192
+ try {
193
+ st = fs.lstatSync(abs);
194
+ } catch {
195
+ /* new file */
196
+ }
197
+ if (st && !st.isFile()) continue;
198
+ const bytes = Buffer.from(f.base64, "base64");
199
+ if (st) {
200
+ const now = fs.readFileSync(abs);
201
+ if (now.equals(bytes)) {
202
+ manifest[rel] = sha256(bytes);
203
+ copied.push(rel);
204
+ continue;
205
+ }
206
+ const ours = manifest[rel] === sha256(now);
207
+ const editedHere = !ours && opts.since !== undefined && st.mtimeMs > opts.since;
208
+ if (editedHere) {
209
+ // A name nothing has yet: an earlier conflict copy may be the step's
210
+ // own work by now (Codex review).
211
+ const ext = path.posix.extname(rel);
212
+ const stem = `${rel.slice(0, rel.length - ext.length)}.from-${(opts.from ?? "subagent").replace(/[^A-Za-z0-9_-]/g, "-")}`;
213
+ for (let n = 1; ; n++) {
214
+ target = `${stem}${n === 1 ? "" : `-${n}`}${ext}`;
215
+ abs = path.join(root, target);
216
+ if (!exists(abs)) break;
217
+ }
218
+ // Not in the manifest: this copy is not on the workspace yet, and
219
+ // the copy-out must carry it there.
220
+ fs.writeFileSync(abs, bytes);
221
+ conflicts.push({ path: rel, copy: target });
222
+ continue;
223
+ }
224
+ }
225
+ fs.writeFileSync(abs, bytes);
226
+ manifest[rel] = sha256(bytes);
227
+ copied.push(rel);
228
+ }
229
+ if (copied.length || conflicts.length) writeManifest(root, manifest);
230
+ return { copied, conflicts };
231
+ }
232
+
40
233
  type Emit = (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
41
234
 
42
235
  /** The granted names, the same on every path. */
@@ -44,13 +237,23 @@ export function delegateToolNames(specs: DelegateSpec[]): string[] {
44
237
  return specs.map((s) => `mcp__${DELEGATE_SERVER}__${delegateToolName(s.name)}`);
45
238
  }
46
239
 
47
- /** The reply as the parent reads it: the sub-agent's words, then what it changed. */
48
- export function replyText(name: string, r: DelegationResult): string {
240
+ /** The reply as the parent reads it: the sub-agent's words, then what it
241
+ * changed — `copied` are the paths now in this sandbox too. */
242
+ export function replyText(name: string, r: DelegationResult, copied: string[] = [], conflicts: { path: string; copy: string }[] = []): string {
49
243
  const body = r.result.trim() || "(no reply)";
50
244
  const cut = body.length > REPLY_MAX ? `${body.slice(0, REPLY_MAX)}\n…(cut at ${REPLY_MAX} characters)` : body;
51
- const wrote = r.wrote?.length
52
- ? `\n\nFiles ${name} changed (in the workspace from the next step on — not in your sandbox):\n${r.wrote.slice(0, 200).map((w) => `- ${w}`).join("\n")}`
53
- : "";
245
+ const here = new Set([...copied, ...conflicts.map((c) => c.path)]);
246
+ const elsewhere = (r.wrote ?? []).filter((w) => !here.has(w));
247
+ const wrote =
248
+ (copied.length
249
+ ? `\n\nFiles ${name} changed, copied into your sandbox at the same paths:\n${copied.slice(0, 200).map((w) => `- ${w}`).join("\n")}`
250
+ : "") +
251
+ (conflicts.length
252
+ ? `\n\nFiles you had changed yourself, which ${name} changed too — yours are kept; its version is beside each:\n${conflicts.slice(0, 200).map((c) => `- ${c.path} → ${c.copy}`).join("\n")}`
253
+ : "") +
254
+ (elsewhere.length
255
+ ? `\n\nFiles ${name} changed that are not in your sandbox (too large to copy back — in the workspace from the next step on):\n${elsewhere.slice(0, 200).map((w) => `- ${w}`).join("\n")}`
256
+ : "");
54
257
  const head = r.status === "completed" ? "" : `${name} did not finish (${r.status}). What it said:\n`;
55
258
  return head + cut + wrote;
56
259
  }
@@ -61,9 +264,14 @@ export function buildDelegateTools(opts: {
61
264
  emit: Emit;
62
265
  /** The most one job may run; the step's own timeout bounds it anyway. */
63
266
  timeoutSec?: number;
267
+ /** This sandbox's workspace: where a job's files are copied back. Absent
268
+ * in-process, where the job wrote the same workspace already. */
269
+ workspaceRoot?: string;
64
270
  now?: () => number;
65
271
  }) {
66
272
  const now = opts.now ?? Date.now;
273
+ // Anything in this sandbox newer than this, the step changed itself.
274
+ const startedAt = Date.now();
67
275
  const channel = opts.channel;
68
276
  if (!opts.specs.length || !channel?.delegate || !channel.delegation) {
69
277
  return { server: null, toolNames: [] as string[], calls: {} as Record<string, (job: string) => Promise<unknown>> };
@@ -90,8 +298,20 @@ export function buildDelegateTools(opts: {
90
298
  continue;
91
299
  }
92
300
  if (got) {
93
- opts.emit(got.status === "completed" ? "info" : "error", `${s.name} ${got.status}${got.wrote?.length ? ` — changed ${got.wrote.length} file(s)` : ""}`, { subagent: s.name });
94
- return text(replyText(s.name, got), got.status !== "completed");
301
+ let copied: string[] = [];
302
+ let conflicts: { path: string; copy: string }[] = [];
303
+ if (opts.workspaceRoot && got.files?.length) {
304
+ try {
305
+ ({ copied, conflicts } = copyBackFiles(opts.workspaceRoot, got.files, { since: startedAt, from: s.name }));
306
+ } catch (err) {
307
+ opts.emit("error", `could not copy ${s.name}'s files into this sandbox: ${err instanceof Error ? err.message : String(err)}`, { subagent: s.name });
308
+ }
309
+ }
310
+ const back = copied.length || conflicts.length
311
+ ? `; ${copied.length} copied into this sandbox${conflicts.length ? `, ${conflicts.length} kept beside the step's own edit` : ""}`
312
+ : "";
313
+ opts.emit(got.status === "completed" ? "info" : "error", `${s.name} ${got.status}${got.wrote?.length ? ` — changed ${got.wrote.length} file(s)${back}` : ""}`, { subagent: s.name });
314
+ return text(replyText(s.name, got, opts.workspaceRoot ? copied : (got.wrote ?? []), conflicts), got.status !== "completed");
95
315
  }
96
316
  }
97
317
  try {
@@ -168,7 +388,7 @@ export function localDelegateChannel(
168
388
  export function delegatePromptLine(specs: DelegateSpec[]): string {
169
389
  return (
170
390
  `<isolated-subagents>\nThese colleagues work in their own sandbox, with their own tools; call the tool to hand one a job ` +
171
- `and you get back its reply and the paths of any files it changed:\n` +
391
+ `and you get back its reply, with the files it changed copied into your sandbox at the same paths:\n` +
172
392
  specs.map((s) => `- ${delegateToolName(s.name)}: ${s.description}`).join("\n") +
173
393
  `\n</isolated-subagents>`
174
394
  );
package/src/operator.ts CHANGED
@@ -117,6 +117,12 @@ export interface DelegationResult {
117
117
  /** Workspace paths it changed. They reach the workspace when it ends, so
118
118
  * the next step sees them; the delegating step's own sandbox does not. */
119
119
  wrote?: string[];
120
+ /** What it wrote, copied back into the delegating step's sandbox, so the
121
+ * parent's model and its `verify:` see it there (copyBackFiles), base64. */
122
+ files?: { path: string; base64: string }[];
123
+ /** Written paths too large to copy back: they reach the workspace when the
124
+ * job ends, so the next step sees them. */
125
+ filesOmitted?: string[];
120
126
  }
121
127
 
122
128
  export const QUESTION_MAX = 2_000;
@@ -217,6 +223,13 @@ export function httpChannel(egress: string | undefined | null, fetchImpl: typeof
217
223
  status,
218
224
  result: typeof body.result === "string" ? body.result : "",
219
225
  wrote: Array.isArray(body.wrote) ? (body.wrote as unknown[]).filter((w): w is string => typeof w === "string") : [],
226
+ files: Array.isArray(body.files)
227
+ ? (body.files as unknown[]).filter(
228
+ (f): f is { path: string; base64: string } =>
229
+ !!f && typeof (f as { path?: unknown }).path === "string" && typeof (f as { base64?: unknown }).base64 === "string",
230
+ )
231
+ : [],
232
+ filesOmitted: Array.isArray(body.filesOmitted) ? (body.filesOmitted as unknown[]).filter((w): w is string => typeof w === "string") : [],
220
233
  };
221
234
  },
222
235
  async cancelDelegation(id) {
@@ -412,6 +412,24 @@ export function applyContainerChanges(
412
412
  divert?: WriteDivert,
413
413
  ): string[] {
414
414
  const applied: string[] = [];
415
+ // Files a sandbox: own job's reply copied into this sandbox mid-step
416
+ // (delegate-tools.ts copyBackFiles), path → sha256 of what was written: for
417
+ // those, that is what the step started from. The job's change is already on
418
+ // the workspace; read against the older baseline it came back as the
419
+ // step's own and was merged in a second time (Codex review, 23 Oct 2026).
420
+ let imported: Record<string, string> = {};
421
+ try {
422
+ // Read here, on the host: a link the step planted is never followed,
423
+ // the same rule as every file below.
424
+ const dir = path.join(containerWs, ".foldrun");
425
+ const file = path.join(dir, "copied-back.json");
426
+ if (fs.lstatSync(dir).isDirectory() && fs.lstatSync(file).isFile()) {
427
+ const m = JSON.parse(fs.readFileSync(file, "utf8"));
428
+ if (m && typeof m === "object" && !Array.isArray(m)) imported = m as Record<string, string>;
429
+ }
430
+ } catch {
431
+ /* none copied back */
432
+ }
415
433
  const walk = (dir: string) => {
416
434
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
417
435
  const abs = path.join(dir, entry.name);
@@ -438,6 +456,8 @@ export function applyContainerChanges(
438
456
  } else if (baseline) {
439
457
  was = baseline[rel.replaceAll("\\", "/")] ?? null;
440
458
  }
459
+ const importedSha = imported[rel.replaceAll("\\", "/")];
460
+ if (typeof importedSha === "string") was = importedSha;
441
461
  const same = (b: Buffer) =>
442
462
  was === null || was === undefined ? false : typeof was === "string" ? sha(b) === was : b.equals(was);
443
463
  // Untouched by this step: whatever the host says now is more current
@@ -735,7 +755,7 @@ try {
735
755
  const inbox = channel ? inboxReader(channel, emit) : undefined;
736
756
  // sandbox: own sub-agents: a tool each, whose job runs in its own sandbox
737
757
  // on the worker, reached over the same proxy door.
738
- const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec }) : { server: null };
758
+ const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec, workspaceRoot: "/workspace" }) : { server: null };
739
759
 
740
760
  // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept
741
761
  // fresh through the proxy's /identity door, which mints only what this
package/src/runner.ts CHANGED
@@ -38,7 +38,7 @@ const isolatedRun = () => {
38
38
  };
39
39
  import { gatherConsults, buildConsultTools } from "./agent-tools.ts";
40
40
  import { DELEGATE_TOOL, gatherSubagents, ownSandbox, subagentPrompt, subagentTools, type DelegateSpec, type SubagentSpec } from "./subagents.ts";
41
- import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel } from "./delegate-tools.ts";
41
+ import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel, readCopyBack } from "./delegate-tools.ts";
42
42
  import type { DelegationResult } from "./operator.ts";
43
43
  import type { StepDelegation } from "./store.ts";
44
44
  import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine, type PeerInfo } from "./operator.ts";
@@ -5130,10 +5130,15 @@ function driveRunInner(
5130
5130
  entry.ms = Date.now() - started;
5131
5131
  save();
5132
5132
  const errors = child.events.filter((e) => e.type === "error").map((e) => e.text).join("\n");
5133
+ // Its files go back with the reply, so the delegating step's
5134
+ // sandbox has them too (delegate-tools.ts copyBackFiles).
5135
+ const back = readCopyBack(pDir, child.wrote ?? []);
5133
5136
  return {
5134
5137
  status,
5135
5138
  result: child.result ?? (errors || "(no reply)"),
5136
5139
  wrote: child.wrote ?? [],
5140
+ files: back.files,
5141
+ filesOmitted: back.omitted,
5137
5142
  };
5138
5143
  };
5139
5144
  // An agent's own `budget:` — the most it may spend in one run — is
package/src/scan.ts CHANGED
@@ -739,7 +739,7 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
739
739
  if (sub.data.sandbox === "own") {
740
740
  if (extra.length) {
741
741
  push("security/subagent-own-sandbox", a.file, keyLine(a.lines, "subagents"), `subagent ${s} own ${extra.join(",")}`,
742
- `subagents: [${s}] — ${s} runs in its own sandbox with ${extra.join(", ")}, which ${a.name} does not have; only its reply comes back`);
742
+ `subagents: [${s}] — ${s} runs in its own sandbox with ${extra.join(", ")}, which ${a.name} does not have; only its reply and the files it wrote come back`);
743
743
  }
744
744
  continue;
745
745
  }