@foldrun/core 2.8.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/issues.js +4 -1
- package/dist/src/operator.d.ts +9 -0
- package/dist/src/operator.js +4 -0
- package/dist/src/run-container.d.ts +7 -4
- package/dist/src/run-container.js +29 -3
- package/dist/src/runner.d.ts +5 -0
- package/dist/src/runner.js +63 -2
- package/dist/src/scan.js +1 -1
- package/package.json +1 -1
- package/src/delegate-tools.ts +231 -11
- package/src/issues.ts +4 -1
- package/src/operator.ts +13 -0
- package/src/run-container.ts +26 -3
- package/src/runner.ts +64 -2
- 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/issues.js
CHANGED
|
@@ -41,7 +41,10 @@ const PLATFORM_CAUSES = [
|
|
|
41
41
|
key: "model-cap",
|
|
42
42
|
label: "The model usage cap is reached",
|
|
43
43
|
fix: "runs resume when the cap resets; nothing to change in the desks.",
|
|
44
|
-
|
|
44
|
+
// Also the runner's own sentence for a spent subscription window
|
|
45
|
+
// (refusal.ts explainRefusal): on 23 Oct 2026 "the 7-day window is 100%
|
|
46
|
+
// used" filed five desks as their own failures.
|
|
47
|
+
test: /weekly (usage )?limit|usage limit (reached|exceeded)|reached your (weekly|usage) limit|window is (100% used|spent)/i,
|
|
45
48
|
},
|
|
46
49
|
{
|
|
47
50
|
// The provider out of capacity is not the account's cap: it passes in
|
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";
|
|
@@ -328,9 +328,12 @@ export declare const RETRY_SH = "retry() { what=$1; shift; n=1; until \"$@\"; do
|
|
|
328
328
|
* and make one browse call per engine on the full image:
|
|
329
329
|
* docker buildx imagetools inspect node:22-trixie-slim --format '{{json .Manifest}}' | jq -r .digest
|
|
330
330
|
* The digest names the multi-arch index, so the one pin serves amd64 and
|
|
331
|
-
* arm64 alike.
|
|
332
|
-
|
|
333
|
-
export declare const DOCKERFILE = "FROM node:22-trixie-slim@sha256:b26b04c123d9ff8ab646ceb18b9d75a1173acf64b9a401094b906d27b29338d4 AS base\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends python3 python3-venv ca-certificates bash util-linux tar openssh-client sshpass git curl \\\n && rm -rf /var/lib/apt/lists/* \\\n && useradd -m -u 10001 agent\n# uv builds the declared python environments (runtime.ts): a fraction of\n# pip's time, and it can fetch a pinned python the image does not carry.\nCOPY --from=ghcr.io/astral-sh/uv:0.11.25 /uv /uvx /usr/local/bin/\nENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules\n\nFROM base AS core\nWORKDIR /opt/runner\nCOPY foldrun-core.tgz ./\nRUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\\n && npm cache clean --force >/dev/null 2>&1 \\\n && rm -f foldrun-core.tgz node_modules/@foldrun/core/README.md\n\nFROM base AS slim\n# Fonts, because a step that draws text without a browser still needs them:\n# the full image gets 51 from Playwright's --with-deps, slim had none, so from\n# 2026-09-29 (when non-browsing steps moved to slim) every resvg/sharp diagram\n# rendered its labels as empty boxes \u2014 expert-witness-desk's validator failed\n# four passes on a post image that was \"three empty boxes\". DejaVu covers\n# Latin, Greek and Cyrillic in a few MB; fontconfig is how renderers find it.\n# DejaVu alone still drew emoji, Chinese, Japanese, Korean, Devanagari and\n# Thai as empty boxes (reproduced 2026-10-01 with resvg-js 2.6 and sharp\n# 0.34), so four more, the smallest set that drew all of them \u2014 8 MB:\n# Symbola emoji and symbols, as outlines. NOT Noto Color Emoji (10 MB):\n# resvg draws nothing from its bitmap glyphs, so a resvg\n# diagram kept its empty boxes.\n# WenQuanYi Micro Hei Chinese, Japanese kana and Korean Hangul, one file.\n# Lohit Devanagari, Loma Hindi and Thai.\n# Here, not in base, so the full image's browser layers stay cached.\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends fonts-dejavu-core fontconfig fonts-symbola fonts-wqy-microhei fonts-lohit-deva fonts-tlwg-loma-otf \\\n && rm -rf /var/lib/apt/lists/* \\\n && fc-cache -f >/dev/null\n# The Playwright client only, the same pinned version as the browser pod's\n# server (the wire protocol is version-locked), so a tool that imports it\n# still loads; there is nothing here for it to launch.\n# npm's download cache is dropped in the same layer: it was 190 MB of every\n# image, read by nothing after the build.\nRUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null && npm cache clean --force >/dev/null 2>&1\nWORKDIR /opt/runner\nCOPY --from=core --chown=agent:agent /opt/runner/ ./\nCOPY --chown=agent:agent driver.mjs entry.sh ./\nRUN mkdir -p /workspace /library /opt/runner/job /home/agent/.foldrun/runner/.runtimes /opt/foldrun-runtimes/shared/.runtimes \\\n && chown agent:agent /opt/runner \\\n && chown -R agent:agent /workspace /library /opt/runner/job /home/agent/.foldrun /opt/foldrun-runtimes \\\n && chmod +x /opt/runner/entry.sh\nENTRYPOINT [\"/opt/runner/entry.sh\"]\n\nFROM base AS browsers\n# Real browsers, because directories and portals increasingly render with\n# JavaScript and a plain fetch sees only the empty shell. Chromium is the one the\n# browser tool drives by default; Firefox and WebKit (Safari's engine) are\n# there for an agent asked to see a page the way those render it. Installed\n# to a fixed path the agent user can read (the default cache would be\n# root's HOME). The browsers' own sandboxes are disabled at launch time \u2014 in\n# this platform the container/gVisor IS the sandbox, and the two fight.\n# Pinned exactly, not @1: web browse's numbered elements use ariaSnapshot's\n# \"ai\" mode (1.59+), and the per-account browser pod runs this same image, so\n# a floating version would move both under a working tool on any rebuild.\n# Raise it on purpose, with the gallery tests and one real call.\n# axe-core beside it is web browse's mode=a11y: one file the tool injects\n# into the page; without it the mode falls back to its own shorter checks.\n# Xvfb by name: the browser pod starts it at boot and web browse starts one\n# per call for headless: false. It used to arrive only as one of Playwright's\n# --with-deps packages, so a Playwright bump that dropped it would have\n# broken every windowed browser without a word. No xauth: nothing here runs\n# xvfb-run, and Xvfb started without -auth takes the local client as it is.\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends xvfb \\\n && rm -rf /var/lib/apt/lists/*\n# Google Chrome and Chrome beta come from Google's apt repository, which\n# Playwright adds; each is tried ENGINE_DOWNLOAD_ATTEMPTS times. Only on\n# x86_64: Google ships no Linux Chrome for arm64, so there it is not tried.\nRUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null \\\n && playwright install --with-deps chromium firefox webkit >/dev/null \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && if [ \"$(uname -m)\" = x86_64 ]; then \\\n retry chrome playwright install --with-deps chrome >/dev/null || echo \"runner image: chrome not installed - engine: chrome would fall back to chromium\" >&2; \\\n retry chrome-beta playwright install --with-deps chrome-beta >/dev/null || echo \"runner image: chrome-beta not installed\" >&2; \\\n else echo \"runner image: no Google Chrome for $(uname -m) - engine: chrome falls back to chromium\"; fi \\\n && chmod -R a+rX /opt/browser \\\n && npm cache clean --force >/dev/null 2>&1\n# Lightpanda (engine: lightpanda): a browser that runs the JavaScript and\n# never draws \u2014 a fraction of Chromium's memory for reading and filling\n# pages. Its own program, which web browse starts beside a call and drives\n# over the DevTools protocol; shipped unmodified (AGPL-3.0). Pinned by\n# checksum (GitHub's own asset digests, checked again here). 0.4.1 since the\n# move to Debian 13: 0.3.7 on wants glibc 2.38, and Debian 12's 2.36 held it\n# at 0.3.6. An arch without a build leaves the engine out, and the tool says\n# so when asked for it. Each download (with its checksum) is tried\n# ENGINE_DOWNLOAD_ATTEMPTS times before the engine is left out.\n# Raise it on purpose: the new release's two sha256s, then one\n# engine=lightpanda call on each arch.\nRUN arch=$(uname -m) \\\n && case \"$arch\" in \\\n x86_64) sum=1d40801e72c0bc61b2cbd3f3562bcfc46de7b79e0568f33f686b64f2e587610a ;; \\\n aarch64) sum=664775c7f5ab69cc3189954c7f9345e25c167cb4dace016173e629f9a5e82c42 ;; \\\n *) sum= ;; \\\n esac \\\n && mkdir -p /opt/browser/lightpanda \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && fetch() { curl -fsSL -o /opt/browser/lightpanda/lightpanda \"https://github.com/lightpanda-io/browser/releases/download/0.4.1/lightpanda-$arch-linux\" \\\n && echo \"$sum /opt/browser/lightpanda/lightpanda\" | sha256sum -c - >/dev/null; } \\\n && if [ -n \"$sum\" ] && retry lightpanda fetch; then \\\n chmod a+rx /opt/browser/lightpanda/lightpanda && /opt/browser/lightpanda/lightpanda version; \\\n else rm -f /opt/browser/lightpanda/lightpanda; echo \"runner image: lightpanda not installed for $arch - engine: lightpanda says so when asked\" >&2; fi\n# Obscura (engine: obscura): a headless browser in Rust around V8 that does\n# draw \u2014 screenshots, a raster PDF, a screencast \u2014 light like Lightpanda.\n# Apache-2.0. The plain build, not -stealth: this platform does not help a\n# page not see automation. Two binaries, obscura and obscura-worker, which\n# must sit side by side. Pinned by checksum; glibc 2.35 is its floor, so\n# Debian 13 (2.41) runs it. As above, a download is tried\n# ENGINE_DOWNLOAD_ATTEMPTS times, then a failed one leaves the engine out.\nRUN arch=$(uname -m) \\\n && case \"$arch\" in \\\n x86_64) sum=1534d1e6ddaf3d080ec4091eb41d0a4d8cc042a48b607d3c410fc13b482a9eec ;; \\\n aarch64) sum=5ecf980bca3060236a7a86ec7ed83d943e6598ee87caa46d20325d90bc75f979 ;; \\\n *) sum= ;; \\\n esac \\\n && mkdir -p /opt/browser/obscura \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && fetch() { curl -fsSL -o /tmp/obscura.tgz \"https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.3/obscura-$arch-linux.tar.gz\" \\\n && echo \"$sum /tmp/obscura.tgz\" | sha256sum -c - >/dev/null; } \\\n && if [ -n \"$sum\" ] && retry obscura fetch \\\n && tar -xzf /tmp/obscura.tgz -C /opt/browser/obscura obscura obscura-worker; then \\\n chmod a+rx /opt/browser/obscura/obscura /opt/browser/obscura/obscura-worker && /opt/browser/obscura/obscura --version; \\\n else rm -rf /opt/browser/obscura/*; echo \"runner image: obscura not installed for $arch - engine: obscura says so when asked\" >&2; fi \\\n && rm -f /tmp/obscura.tgz\n# What this image actually has, written down: /opt/browser/engines.json, one\n# entry per engine (its path and version, or null when the install above\n# left it out) plus Xvfb. Chromium's version is its own --version (dotted,\n# so FOLDRUN_RUNNER_ENGINES carries it: a pinned user_agent on engine\n# chromium is compared with it, not with Google Chrome's) then the build.\n# web browse reads it for its \"built without\" answer, and the deploy reads\n# it to report the engines beside the version.\n# On x86_64 a missing engine FAILS the build: every engine ships for that\n# arch, so a gap there is a failed download, and an image that quietly lacks\n# the Chrome or Lightpanda a desk names would fail those runs instead. A\n# failed build leaves the previous image running and says why. Elsewhere\n# (arm64 has no Google Chrome) the gap is recorded and the image ships.\nRUN node -e ' \\\n const fs = require(\"fs\"), cp = require(\"child_process\"), pw = require(\"playwright\"); \\\n const at = (p) => (p && fs.existsSync(p) ? p : null); \\\n const ver = (bin, args) => { try { return cp.execFileSync(bin, args, { timeout: 30000, stdio: [\"ignore\", \"pipe\", \"ignore\"] }).toString().trim().split(\"\\n\")[0] || null; } catch { return null; } }; \\\n const one = (p, v) => (p ? { path: p, version: v } : null); \\\n const pwBuild = (p) => (p ? (p.match(/(chromium|firefox|webkit)-(\\d+)/) || [])[0] || null : null); \\\n const lp = at(\"/opt/browser/lightpanda/lightpanda\"); \\\n const cr = at(pw.chromium.executablePath()); \\\n const ob = at(\"/opt/browser/obscura/obscura-worker\") && at(\"/opt/browser/obscura/obscura\"); \\\n const engines = { \\\n chromium: one(cr, cr && [ver(cr, [\"--version\"]), pwBuild(cr)].filter(Boolean).join(\" \")), \\\n chrome: one(at(\"/opt/google/chrome/chrome\"), ver(\"/opt/google/chrome/chrome\", [\"--version\"])), \\\n \"chrome-beta\": one(at(\"/opt/google/chrome-beta/chrome\"), ver(\"/opt/google/chrome-beta/chrome\", [\"--version\"])), \\\n firefox: one(at(pw.firefox.executablePath()), pwBuild(pw.firefox.executablePath())), \\\n webkit: one(at(pw.webkit.executablePath()), pwBuild(pw.webkit.executablePath())), \\\n lightpanda: one(lp, lp && ver(lp, [\"version\"])), \\\n obscura: one(ob, ob && ver(ob, [\"--version\"])), \\\n }; \\\n const xvfb = ver(\"sh\", [\"-c\", \"command -v Xvfb\"]); \\\n const arch = cp.execFileSync(\"uname\", [\"-m\"]).toString().trim(); \\\n const manifest = { arch, playwright: require(\"playwright/package.json\").version, engines, xvfb }; \\\n fs.writeFileSync(\"/opt/browser/engines.json\", JSON.stringify(manifest, null, 2) + \"\\n\"); \\\n console.log(JSON.stringify(manifest)); \\\n const missing = [\"chromium\", \"chrome\", \"firefox\", \"webkit\", \"lightpanda\", \"obscura\"].filter((e) => !engines[e]).concat(xvfb ? [] : [\"Xvfb\"]); \\\n if (missing.length && arch === \"x86_64\") { \\\n console.error(\"runner image: MISSING ENGINES on x86_64: \" + missing.join(\", \")); \\\n console.error(\"runner image: every engine ships for x86_64, so a missing one is a failed download (the retry lines above say which) - failing the build\"); \\\n process.exit(1); \\\n } \\\n if (missing.length) console.error(\"runner image: built without \" + missing.join(\", \") + \" on \" + arch); \\\n ' \\\n && chmod a+r /opt/browser/engines.json\n\nFROM browsers AS full\nWORKDIR /opt/runner\nCOPY --from=core --chown=agent:agent /opt/runner/ ./\nCOPY --chown=agent:agent driver.mjs entry.sh ./\nRUN mkdir -p /workspace /library /opt/runner/job /home/agent/.foldrun/runner/.runtimes /opt/foldrun-runtimes/shared/.runtimes \\\n && chown agent:agent /opt/runner \\\n && chown -R agent:agent /workspace /library /opt/runner/job /home/agent/.foldrun /opt/foldrun-runtimes \\\n && chmod +x /opt/runner/entry.sh\nENTRYPOINT [\"/opt/runner/entry.sh\"]\n";
|
|
331
|
+
* arm64 alike.
|
|
332
|
+
*
|
|
333
|
+
* 23 Oct 2026: Node 22.23.4 (the 21 Oct security release: Brotli decoder
|
|
334
|
+
* RCE, TLS hostname bypasses); the pin before carried 22.23.3. */
|
|
335
|
+
export declare const NODE_BASE = "node:22-trixie-slim@sha256:8eb619e402eaede1748a3c8173bffab391592812e85136452f781741c7242ddf";
|
|
336
|
+
export declare const DOCKERFILE = "FROM node:22-trixie-slim@sha256:8eb619e402eaede1748a3c8173bffab391592812e85136452f781741c7242ddf AS base\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends python3 python3-venv ca-certificates bash util-linux tar openssh-client sshpass git curl \\\n && rm -rf /var/lib/apt/lists/* \\\n && useradd -m -u 10001 agent\n# uv builds the declared python environments (runtime.ts): a fraction of\n# pip's time, and it can fetch a pinned python the image does not carry.\nCOPY --from=ghcr.io/astral-sh/uv:0.11.25 /uv /uvx /usr/local/bin/\nENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules\n\nFROM base AS core\nWORKDIR /opt/runner\nCOPY foldrun-core.tgz ./\nRUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\\n && npm cache clean --force >/dev/null 2>&1 \\\n && rm -f foldrun-core.tgz node_modules/@foldrun/core/README.md\n\nFROM base AS slim\n# Fonts, because a step that draws text without a browser still needs them:\n# the full image gets 51 from Playwright's --with-deps, slim had none, so from\n# 2026-09-29 (when non-browsing steps moved to slim) every resvg/sharp diagram\n# rendered its labels as empty boxes \u2014 expert-witness-desk's validator failed\n# four passes on a post image that was \"three empty boxes\". DejaVu covers\n# Latin, Greek and Cyrillic in a few MB; fontconfig is how renderers find it.\n# DejaVu alone still drew emoji, Chinese, Japanese, Korean, Devanagari and\n# Thai as empty boxes (reproduced 2026-10-01 with resvg-js 2.6 and sharp\n# 0.34), so four more, the smallest set that drew all of them \u2014 8 MB:\n# Symbola emoji and symbols, as outlines. NOT Noto Color Emoji (10 MB):\n# resvg draws nothing from its bitmap glyphs, so a resvg\n# diagram kept its empty boxes.\n# WenQuanYi Micro Hei Chinese, Japanese kana and Korean Hangul, one file.\n# Lohit Devanagari, Loma Hindi and Thai.\n# Here, not in base, so the full image's browser layers stay cached.\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends fonts-dejavu-core fontconfig fonts-symbola fonts-wqy-microhei fonts-lohit-deva fonts-tlwg-loma-otf \\\n && rm -rf /var/lib/apt/lists/* \\\n && fc-cache -f >/dev/null\n# The Playwright client only, the same pinned version as the browser pod's\n# server (the wire protocol is version-locked), so a tool that imports it\n# still loads; there is nothing here for it to launch.\n# npm's download cache is dropped in the same layer: it was 190 MB of every\n# image, read by nothing after the build.\nRUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null && npm cache clean --force >/dev/null 2>&1\nWORKDIR /opt/runner\nCOPY --from=core --chown=agent:agent /opt/runner/ ./\nCOPY --chown=agent:agent driver.mjs entry.sh ./\nRUN mkdir -p /workspace /library /opt/runner/job /home/agent/.foldrun/runner/.runtimes /opt/foldrun-runtimes/shared/.runtimes \\\n && chown agent:agent /opt/runner \\\n && chown -R agent:agent /workspace /library /opt/runner/job /home/agent/.foldrun /opt/foldrun-runtimes \\\n && chmod +x /opt/runner/entry.sh\nENTRYPOINT [\"/opt/runner/entry.sh\"]\n\nFROM base AS browsers\n# Real browsers, because directories and portals increasingly render with\n# JavaScript and a plain fetch sees only the empty shell. Chromium is the one the\n# browser tool drives by default; Firefox and WebKit (Safari's engine) are\n# there for an agent asked to see a page the way those render it. Installed\n# to a fixed path the agent user can read (the default cache would be\n# root's HOME). The browsers' own sandboxes are disabled at launch time \u2014 in\n# this platform the container/gVisor IS the sandbox, and the two fight.\n# Pinned exactly, not @1: web browse's numbered elements use ariaSnapshot's\n# \"ai\" mode (1.59+), and the per-account browser pod runs this same image, so\n# a floating version would move both under a working tool on any rebuild.\n# Raise it on purpose, with the gallery tests and one real call.\n# axe-core beside it is web browse's mode=a11y: one file the tool injects\n# into the page; without it the mode falls back to its own shorter checks.\n# Xvfb by name: the browser pod starts it at boot and web browse starts one\n# per call for headless: false. It used to arrive only as one of Playwright's\n# --with-deps packages, so a Playwright bump that dropped it would have\n# broken every windowed browser without a word. No xauth: nothing here runs\n# xvfb-run, and Xvfb started without -auth takes the local client as it is.\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends xvfb \\\n && rm -rf /var/lib/apt/lists/*\n# Google Chrome and Chrome beta come from Google's apt repository, which\n# Playwright adds; each is tried ENGINE_DOWNLOAD_ATTEMPTS times. Only on\n# x86_64: Google ships no Linux Chrome for arm64, so there it is not tried.\nRUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null \\\n && playwright install --with-deps chromium firefox webkit >/dev/null \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && if [ \"$(uname -m)\" = x86_64 ]; then \\\n retry chrome playwright install --with-deps chrome >/dev/null || echo \"runner image: chrome not installed - engine: chrome would fall back to chromium\" >&2; \\\n retry chrome-beta playwright install --with-deps chrome-beta >/dev/null || echo \"runner image: chrome-beta not installed\" >&2; \\\n else echo \"runner image: no Google Chrome for $(uname -m) - engine: chrome falls back to chromium\"; fi \\\n && chmod -R a+rX /opt/browser \\\n && npm cache clean --force >/dev/null 2>&1\n# Lightpanda (engine: lightpanda): a browser that runs the JavaScript and\n# never draws \u2014 a fraction of Chromium's memory for reading and filling\n# pages. Its own program, which web browse starts beside a call and drives\n# over the DevTools protocol; shipped unmodified (AGPL-3.0). Pinned by\n# checksum (GitHub's own asset digests, checked again here). 0.4.1 since the\n# move to Debian 13: 0.3.7 on wants glibc 2.38, and Debian 12's 2.36 held it\n# at 0.3.6. An arch without a build leaves the engine out, and the tool says\n# so when asked for it. Each download (with its checksum) is tried\n# ENGINE_DOWNLOAD_ATTEMPTS times before the engine is left out.\n# Raise it on purpose: the new release's two sha256s, then one\n# engine=lightpanda call on each arch.\nRUN arch=$(uname -m) \\\n && case \"$arch\" in \\\n x86_64) sum=1d40801e72c0bc61b2cbd3f3562bcfc46de7b79e0568f33f686b64f2e587610a ;; \\\n aarch64) sum=664775c7f5ab69cc3189954c7f9345e25c167cb4dace016173e629f9a5e82c42 ;; \\\n *) sum= ;; \\\n esac \\\n && mkdir -p /opt/browser/lightpanda \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && fetch() { curl -fsSL -o /opt/browser/lightpanda/lightpanda \"https://github.com/lightpanda-io/browser/releases/download/0.4.1/lightpanda-$arch-linux\" \\\n && echo \"$sum /opt/browser/lightpanda/lightpanda\" | sha256sum -c - >/dev/null; } \\\n && if [ -n \"$sum\" ] && retry lightpanda fetch; then \\\n chmod a+rx /opt/browser/lightpanda/lightpanda && /opt/browser/lightpanda/lightpanda version; \\\n else rm -f /opt/browser/lightpanda/lightpanda; echo \"runner image: lightpanda not installed for $arch - engine: lightpanda says so when asked\" >&2; fi\n# Obscura (engine: obscura): a headless browser in Rust around V8 that does\n# draw \u2014 screenshots, a raster PDF, a screencast \u2014 light like Lightpanda.\n# Apache-2.0. The plain build, not -stealth: this platform does not help a\n# page not see automation. Two binaries, obscura and obscura-worker, which\n# must sit side by side. Pinned by checksum; glibc 2.35 is its floor, so\n# Debian 13 (2.41) runs it. As above, a download is tried\n# ENGINE_DOWNLOAD_ATTEMPTS times, then a failed one leaves the engine out.\nRUN arch=$(uname -m) \\\n && case \"$arch\" in \\\n x86_64) sum=1534d1e6ddaf3d080ec4091eb41d0a4d8cc042a48b607d3c410fc13b482a9eec ;; \\\n aarch64) sum=5ecf980bca3060236a7a86ec7ed83d943e6598ee87caa46d20325d90bc75f979 ;; \\\n *) sum= ;; \\\n esac \\\n && mkdir -p /opt/browser/obscura \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && fetch() { curl -fsSL -o /tmp/obscura.tgz \"https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.3/obscura-$arch-linux.tar.gz\" \\\n && echo \"$sum /tmp/obscura.tgz\" | sha256sum -c - >/dev/null; } \\\n && if [ -n \"$sum\" ] && retry obscura fetch \\\n && tar -xzf /tmp/obscura.tgz -C /opt/browser/obscura obscura obscura-worker; then \\\n chmod a+rx /opt/browser/obscura/obscura /opt/browser/obscura/obscura-worker && /opt/browser/obscura/obscura --version; \\\n else rm -rf /opt/browser/obscura/*; echo \"runner image: obscura not installed for $arch - engine: obscura says so when asked\" >&2; fi \\\n && rm -f /tmp/obscura.tgz\n# What this image actually has, written down: /opt/browser/engines.json, one\n# entry per engine (its path and version, or null when the install above\n# left it out) plus Xvfb. Chromium's version is its own --version (dotted,\n# so FOLDRUN_RUNNER_ENGINES carries it: a pinned user_agent on engine\n# chromium is compared with it, not with Google Chrome's) then the build.\n# web browse reads it for its \"built without\" answer, and the deploy reads\n# it to report the engines beside the version.\n# On x86_64 a missing engine FAILS the build: every engine ships for that\n# arch, so a gap there is a failed download, and an image that quietly lacks\n# the Chrome or Lightpanda a desk names would fail those runs instead. A\n# failed build leaves the previous image running and says why. Elsewhere\n# (arm64 has no Google Chrome) the gap is recorded and the image ships.\nRUN node -e ' \\\n const fs = require(\"fs\"), cp = require(\"child_process\"), pw = require(\"playwright\"); \\\n const at = (p) => (p && fs.existsSync(p) ? p : null); \\\n const ver = (bin, args) => { try { return cp.execFileSync(bin, args, { timeout: 30000, stdio: [\"ignore\", \"pipe\", \"ignore\"] }).toString().trim().split(\"\\n\")[0] || null; } catch { return null; } }; \\\n const one = (p, v) => (p ? { path: p, version: v } : null); \\\n const pwBuild = (p) => (p ? (p.match(/(chromium|firefox|webkit)-(\\d+)/) || [])[0] || null : null); \\\n const lp = at(\"/opt/browser/lightpanda/lightpanda\"); \\\n const cr = at(pw.chromium.executablePath()); \\\n const ob = at(\"/opt/browser/obscura/obscura-worker\") && at(\"/opt/browser/obscura/obscura\"); \\\n const engines = { \\\n chromium: one(cr, cr && [ver(cr, [\"--version\"]), pwBuild(cr)].filter(Boolean).join(\" \")), \\\n chrome: one(at(\"/opt/google/chrome/chrome\"), ver(\"/opt/google/chrome/chrome\", [\"--version\"])), \\\n \"chrome-beta\": one(at(\"/opt/google/chrome-beta/chrome\"), ver(\"/opt/google/chrome-beta/chrome\", [\"--version\"])), \\\n firefox: one(at(pw.firefox.executablePath()), pwBuild(pw.firefox.executablePath())), \\\n webkit: one(at(pw.webkit.executablePath()), pwBuild(pw.webkit.executablePath())), \\\n lightpanda: one(lp, lp && ver(lp, [\"version\"])), \\\n obscura: one(ob, ob && ver(ob, [\"--version\"])), \\\n }; \\\n const xvfb = ver(\"sh\", [\"-c\", \"command -v Xvfb\"]); \\\n const arch = cp.execFileSync(\"uname\", [\"-m\"]).toString().trim(); \\\n const manifest = { arch, playwright: require(\"playwright/package.json\").version, engines, xvfb }; \\\n fs.writeFileSync(\"/opt/browser/engines.json\", JSON.stringify(manifest, null, 2) + \"\\n\"); \\\n console.log(JSON.stringify(manifest)); \\\n const missing = [\"chromium\", \"chrome\", \"firefox\", \"webkit\", \"lightpanda\", \"obscura\"].filter((e) => !engines[e]).concat(xvfb ? [] : [\"Xvfb\"]); \\\n if (missing.length && arch === \"x86_64\") { \\\n console.error(\"runner image: MISSING ENGINES on x86_64: \" + missing.join(\", \")); \\\n console.error(\"runner image: every engine ships for x86_64, so a missing one is a failed download (the retry lines above say which) - failing the build\"); \\\n process.exit(1); \\\n } \\\n if (missing.length) console.error(\"runner image: built without \" + missing.join(\", \") + \" on \" + arch); \\\n ' \\\n && chmod a+r /opt/browser/engines.json\n\nFROM browsers AS full\nWORKDIR /opt/runner\nCOPY --from=core --chown=agent:agent /opt/runner/ ./\nCOPY --chown=agent:agent driver.mjs entry.sh ./\nRUN mkdir -p /workspace /library /opt/runner/job /home/agent/.foldrun/runner/.runtimes /opt/foldrun-runtimes/shared/.runtimes \\\n && chown agent:agent /opt/runner \\\n && chown -R agent:agent /workspace /library /opt/runner/job /home/agent/.foldrun /opt/foldrun-runtimes \\\n && chmod +x /opt/runner/entry.sh\nENTRYPOINT [\"/opt/runner/entry.sh\"]\n";
|
|
334
337
|
/** Which build target of the runner image (see DOCKERFILE). */
|
|
335
338
|
export type RunnerVariant = "full" | "slim";
|
|
336
339
|
export declare function runnerImageTag(variant?: RunnerVariant): string;
|
|
@@ -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
|
|
@@ -742,8 +765,11 @@ export const RETRY_SH = `retry() { what=$1; shift; n=1; until "$@"; do if [ "$n"
|
|
|
742
765
|
* and make one browse call per engine on the full image:
|
|
743
766
|
* docker buildx imagetools inspect node:22-trixie-slim --format '{{json .Manifest}}' | jq -r .digest
|
|
744
767
|
* The digest names the multi-arch index, so the one pin serves amd64 and
|
|
745
|
-
* arm64 alike.
|
|
746
|
-
|
|
768
|
+
* arm64 alike.
|
|
769
|
+
*
|
|
770
|
+
* 23 Oct 2026: Node 22.23.4 (the 21 Oct security release: Brotli decoder
|
|
771
|
+
* RCE, TLS hostname bypasses); the pin before carried 22.23.3. */
|
|
772
|
+
export const NODE_BASE = "node:22-trixie-slim@sha256:8eb619e402eaede1748a3c8173bffab391592812e85136452f781741c7242ddf";
|
|
747
773
|
// Two images from one file, as build targets.
|
|
748
774
|
//
|
|
749
775
|
// full (the default, the last stage) — everything, browsers included. The
|