@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.
- package/dist/src/delegate-tools.d.ts +49 -2
- package/dist/src/delegate-tools.js +236 -11
- package/dist/src/operator.d.ts +9 -0
- package/dist/src/operator.js +4 -0
- package/dist/src/run-container.d.ts +1 -1
- package/dist/src/run-container.js +24 -1
- package/dist/src/runner.js +6 -1
- package/dist/src/scan.js +1 -1
- package/package.json +1 -1
- package/src/delegate-tools.ts +231 -11
- package/src/operator.ts +13 -0
- package/src/run-container.ts +21 -1
- package/src/runner.ts +6 -1
- package/src/scan.ts +1 -1
|
@@ -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
|
|
13
|
-
|
|
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
|
|
18
|
-
//
|
|
19
|
-
// the
|
|
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
|
|
40
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
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
|
}
|
package/dist/src/operator.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/operator.js
CHANGED
|
@@ -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
|
package/dist/src/runner.js
CHANGED
|
@@ -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
|
|
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
package/src/delegate-tools.ts
CHANGED
|
@@ -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
|
|
18
|
-
//
|
|
19
|
-
// the
|
|
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
|
|
48
|
-
|
|
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
|
|
52
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
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) {
|
package/src/run-container.ts
CHANGED
|
@@ -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
|
|
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
|
}
|