@foldrun/core 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- package/templates/hello/tools/wordcount/tool.md +22 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
// Whole-step isolation: the model loop itself inside a container.
|
|
2
|
+
//
|
|
3
|
+
// container.ts isolates *declared script tools* — real code the author chose
|
|
4
|
+
// to ship. This isolates the rest: the agent's built-in Read/Write/Bash,
|
|
5
|
+
// which otherwise run in the server process behind path checks. Path checks
|
|
6
|
+
// are argument inspection; a container is an operating system saying no. The
|
|
7
|
+
// hosted platform runs every step this way (FOLDRUN_RUN_ISOLATION=container);
|
|
8
|
+
// the CLI keeps the in-process path, because on a laptop the "server process"
|
|
9
|
+
// is the user's own shell and there is nothing to protect it from.
|
|
10
|
+
//
|
|
11
|
+
// Shape: copy the workspace in, run the driver, stream events out as JSONL,
|
|
12
|
+
// copy changes back through a filter. Copying, not mounting, for the same
|
|
13
|
+
// reasons as container.ts — and because the copy-out filter is where the
|
|
14
|
+
// spec's write rules become physics: whatever happened inside, knowledge/,
|
|
15
|
+
// secrets and run history cannot come back changed, because they are not
|
|
16
|
+
// copied back at all (secrets and runs/ were never copied in).
|
|
17
|
+
//
|
|
18
|
+
// The runner image bundles @foldrun/core itself, so the driver runs the same
|
|
19
|
+
// executeStep the server would — one implementation, two homes.
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import crypto from "node:crypto";
|
|
24
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
25
|
+
import { isPlatformPath } from "./store.js";
|
|
26
|
+
import { isFileValue, fileContent } from "./secrets.js";
|
|
27
|
+
import { safeTenantSegment } from "./runtime.js";
|
|
28
|
+
import { dataRoot } from "./paths.js";
|
|
29
|
+
/**
|
|
30
|
+
* The one place the timing line is worded, so the docker and k8s traces
|
|
31
|
+
* read the same and a cold-start regression is greppable across both.
|
|
32
|
+
*/
|
|
33
|
+
export function timingLine(t) {
|
|
34
|
+
const s = (ms) => `${(ms / 1000).toFixed(2)}s`;
|
|
35
|
+
const first = t.firstOutputMs === null ? "no output" : `first output +${s(t.firstOutputMs)}`;
|
|
36
|
+
return `timing: sandbox ready in ${s(t.sandboxMs)}, ${first}, ${s(t.totalMs)} total`;
|
|
37
|
+
}
|
|
38
|
+
const cli = () => process.env.FOLDRUN_CONTAINER_CLI ?? "docker";
|
|
39
|
+
// Paths that must never come back from a container, whatever happened in
|
|
40
|
+
// there: everything platform-owned (store.ts#isPlatformPath — also never
|
|
41
|
+
// copied in), plus knowledge/, which goes in for reading and is dropped on
|
|
42
|
+
// the way out, and .git/.
|
|
43
|
+
const DENY_BACK = ["knowledge/", ".git/"];
|
|
44
|
+
// A .git ANYWHERE in the tree, not just at the root. A step that clones a
|
|
45
|
+
// repository into a shared area (files/site, say) was shipping the entire
|
|
46
|
+
// object store back to the workspace, where the harvest then filed thousands
|
|
47
|
+
// of git internals as "files the agents produced". Git metadata is never a
|
|
48
|
+
// reviewable artifact; the clone's content comes back, its plumbing does not.
|
|
49
|
+
const DENY_BACK_GIT_RE = /(^|\/)\.git(\/|$)/;
|
|
50
|
+
// Materialised @file secrets (SSH keys, certs) live under .secret-files/ in
|
|
51
|
+
// an agent's dir while a step runs; they are live credentials and must never
|
|
52
|
+
// come back to the host, whatever the step wrote there.
|
|
53
|
+
const DENY_BACK_SECRET_FILES = /(^|\/)\.secret-files\//;
|
|
54
|
+
// And per-agent knowledge, at any depth under agents/<name>/.
|
|
55
|
+
const DENY_BACK_RE = /^agents\/[^/]+\/knowledge\//;
|
|
56
|
+
// A dependency tree is not workspace content. Nothing authored lives in
|
|
57
|
+
// node_modules, it is enormous, and a runtime linked into a tool's directory
|
|
58
|
+
// so ESM can resolve it would otherwise be copied back file by file.
|
|
59
|
+
const DENY_BACK_NODE_MODULES = /(^|\/)node_modules(\/|$)/;
|
|
60
|
+
/** Should this container-side path be applied back to the host workspace? */
|
|
61
|
+
export function allowedBack(rel) {
|
|
62
|
+
const norm = rel.replaceAll("\\", "/");
|
|
63
|
+
if (norm.includes(".."))
|
|
64
|
+
return false;
|
|
65
|
+
if (isPlatformPath(norm))
|
|
66
|
+
return false;
|
|
67
|
+
if (DENY_BACK.some((d) => norm === d.replace(/\/$/, "") || norm.startsWith(d)))
|
|
68
|
+
return false;
|
|
69
|
+
if (DENY_BACK_RE.test(norm))
|
|
70
|
+
return false;
|
|
71
|
+
if (DENY_BACK_GIT_RE.test(norm))
|
|
72
|
+
return false;
|
|
73
|
+
if (DENY_BACK_SECRET_FILES.test(norm))
|
|
74
|
+
return false;
|
|
75
|
+
if (DENY_BACK_NODE_MODULES.test(norm))
|
|
76
|
+
return false;
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Apply what the STEP changed onto the real workspace — additive and
|
|
81
|
+
* overwriting, never deleting: an agent organising its own files gains
|
|
82
|
+
* nothing from deleting on the host that it can't get by writing, and a bug
|
|
83
|
+
* in a deletion sync destroys authored work.
|
|
84
|
+
*
|
|
85
|
+
* `baseline` is the copy that was handed to the container at the start of the
|
|
86
|
+
* step. Without it this function compares the container against the HOST, and
|
|
87
|
+
* that is a different question with a worse answer: a file the step never
|
|
88
|
+
* touched, edited on the host while the step ran, differs — so it was written
|
|
89
|
+
* back from the container's stale copy and the edit vanished. It happened on
|
|
90
|
+
* 2026-09-03: a tool.md fixed mid-run was reverted eight minutes later by a
|
|
91
|
+
* step that had never read it, and the repository kept the fix while the
|
|
92
|
+
* workspace lost it.
|
|
93
|
+
*
|
|
94
|
+
* With the baseline the rule is simply: identical to what we handed in means
|
|
95
|
+
* the step did not touch it, so it is not ours to write back. Concurrent
|
|
96
|
+
* edits survive, and a step that genuinely changed a file still wins.
|
|
97
|
+
*/
|
|
98
|
+
export function applyContainerChanges(hostWs, containerWs, baseline) {
|
|
99
|
+
const applied = [];
|
|
100
|
+
const walk = (dir) => {
|
|
101
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
102
|
+
const abs = path.join(dir, entry.name);
|
|
103
|
+
const rel = path.relative(containerWs, abs);
|
|
104
|
+
// Never a symlink. docker cp preserves them, and a link the step
|
|
105
|
+
// planted is resolved HERE, on the host, by the read below: `ln -s
|
|
106
|
+
// /proc/self/environ leak.txt` in the sandbox came back as a regular
|
|
107
|
+
// file holding the platform's environment — the install key included.
|
|
108
|
+
// A link to a directory threw EISDIR and aborted the whole copy-out.
|
|
109
|
+
// (kubectl cp already skips symlinks; this makes the two tiers agree.)
|
|
110
|
+
if (entry.isSymbolicLink())
|
|
111
|
+
continue;
|
|
112
|
+
if (entry.isDirectory()) {
|
|
113
|
+
if (allowedBack(rel + "/"))
|
|
114
|
+
walk(abs);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!allowedBack(rel))
|
|
118
|
+
continue;
|
|
119
|
+
const next = fs.readFileSync(abs);
|
|
120
|
+
// Untouched by this step: whatever the host says now is more current
|
|
121
|
+
// than what we handed in, including a change made while it ran.
|
|
122
|
+
if (baseline) {
|
|
123
|
+
const was = path.join(baseline, rel);
|
|
124
|
+
if (fs.existsSync(was) && fs.readFileSync(was).equals(next))
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const target = path.join(hostWs, rel);
|
|
128
|
+
if (fs.existsSync(target) && fs.readFileSync(target).equals(next))
|
|
129
|
+
continue;
|
|
130
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
131
|
+
fs.writeFileSync(target, next);
|
|
132
|
+
applied.push(rel);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
walk(containerWs);
|
|
136
|
+
return applied;
|
|
137
|
+
}
|
|
138
|
+
// ---------- the dependency cache ----------
|
|
139
|
+
/**
|
|
140
|
+
* Where the driver builds venvs and npm prefixes, as an absolute path *inside*
|
|
141
|
+
* the runner. Fixed, and it has to be: a venv bakes its own absolute path into
|
|
142
|
+
* every shebang and into pyvenv.cfg, so a cache reused across containers is
|
|
143
|
+
* only valid if it is mounted at the identical path every time. It is the same
|
|
144
|
+
* path prepareRuntime picks on its own — FOLDRUN_DATA is /home/agent/.foldrun
|
|
145
|
+
* in the driver, and the in-container tenant is "runner" — so mounting here
|
|
146
|
+
* turns the existing cache into a persistent one with no change to the code
|
|
147
|
+
* that fills it.
|
|
148
|
+
*/
|
|
149
|
+
export const RUNTIME_CACHE = "/home/agent/.foldrun/runner/.runtimes";
|
|
150
|
+
/**
|
|
151
|
+
* The per-tenant directory backing that mount, or null when there is none.
|
|
152
|
+
*
|
|
153
|
+
* Per-tenant, not shared. A shared cache would store one copy of pandas
|
|
154
|
+
* instead of one per account, but it would also be mutable state that one
|
|
155
|
+
* tenant's step writes and another tenant's step then *executes* — a poisoned
|
|
156
|
+
* venv arriving as a trusted local file, which is precisely the path gVisor,
|
|
157
|
+
* the capability drop and the egress policy all exist to close. Disk is the
|
|
158
|
+
* cheaper thing to spend. If the duplication ever stops being cheap, the fix
|
|
159
|
+
* is a read-only mount filled by a trusted builder, not a writable shared one.
|
|
160
|
+
*
|
|
161
|
+
* Deliberately NOT under `<tenant>/.runtimes`, which is the *host* cache: the
|
|
162
|
+
* venvs there carry host absolute paths and would be unusable inside a runner.
|
|
163
|
+
* Two roots, so a hit is always a real hit.
|
|
164
|
+
*/
|
|
165
|
+
export function runtimeCacheDir(tenant) {
|
|
166
|
+
if (process.env.FOLDRUN_RUNTIME_CACHE === "off")
|
|
167
|
+
return null;
|
|
168
|
+
const safe = tenant ? safeTenantSegment(tenant) : null;
|
|
169
|
+
if (!safe)
|
|
170
|
+
return null;
|
|
171
|
+
return path.join(dataRoot(), safe, ".runtimes-sandbox");
|
|
172
|
+
}
|
|
173
|
+
// ---------- the runner image ----------
|
|
174
|
+
const DRIVER = `// The runner container's entrypoint. Reads one step, executes it with the
|
|
175
|
+
// same core the server uses, speaks JSONL on stdout, exits.
|
|
176
|
+
import fs from "node:fs";
|
|
177
|
+
const input = JSON.parse(fs.readFileSync("/opt/runner/job/input.json", "utf8"));
|
|
178
|
+
const emit = (type, text, extra) => process.stdout.write(JSON.stringify({ e: "event", type, text, ...extra }) + "\\n");
|
|
179
|
+
// What this sandbox actually touched, read at the last moment from the
|
|
180
|
+
// kernel's own books: cgroup v2 for CPU busy-time and peak memory,
|
|
181
|
+
// /proc/net/dev for bytes on the wire. Every read is best-effort — gVisor
|
|
182
|
+
// exposes some of these files and not others depending on version, and a
|
|
183
|
+
// metric we cannot read is null, never guessed. Reservation is what is
|
|
184
|
+
// billed; these exist so the reservation can be right-sized.
|
|
185
|
+
function readActuals() {
|
|
186
|
+
// fs from the module's own import — the driver is ESM, and a require()
|
|
187
|
+
// here threw on every call, silently nulling every metric it guarded.
|
|
188
|
+
const read = (p) => { try { return fs.readFileSync(p, "utf8"); } catch { return null; } };
|
|
189
|
+
// Two cgroup dialects: v2 (cpu.stat, microseconds; memory.peak) on a
|
|
190
|
+
// plain runtime, v1-style controllers under gVisor (cpuacct.usage,
|
|
191
|
+
// NANOseconds; memory.max_usage_in_bytes) — probed on the real cluster,
|
|
192
|
+
// not assumed. Whichever answers wins; both silent means null.
|
|
193
|
+
const cpuStat = read("/sys/fs/cgroup/cpu.stat");
|
|
194
|
+
const usage = cpuStat?.match(/^usage_usec (\\d+)/m);
|
|
195
|
+
const cpuacctNs = usage ? NaN : Number(read("/sys/fs/cgroup/cpuacct/cpuacct.usage")?.trim());
|
|
196
|
+
const peak =
|
|
197
|
+
read("/sys/fs/cgroup/memory.peak") ??
|
|
198
|
+
read("/sys/fs/cgroup/memory/memory.max_usage_in_bytes") ??
|
|
199
|
+
read("/sys/fs/cgroup/memory/memory.usage_in_bytes");
|
|
200
|
+
let rx = 0, tx = 0, sawNet = false;
|
|
201
|
+
const net = read("/proc/net/dev");
|
|
202
|
+
if (net) {
|
|
203
|
+
for (const line of net.split("\\n").slice(2)) {
|
|
204
|
+
const m = line.trim().match(/^(\\S+):\\s+(\\d+)(?:\\s+\\d+){7}\\s+(\\d+)/);
|
|
205
|
+
if (m && m[1] !== "lo") { rx += Number(m[2]); tx += Number(m[3]); sawNet = true; }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
busyCpuSecs: usage
|
|
210
|
+
? Number(usage[1]) / 1e6
|
|
211
|
+
: Number.isFinite(cpuacctNs) && cpuacctNs > 0
|
|
212
|
+
? cpuacctNs / 1e9
|
|
213
|
+
: null,
|
|
214
|
+
peakMemBytes: peak ? Number(peak.trim()) || null : null,
|
|
215
|
+
rxBytes: sawNet ? rx : null,
|
|
216
|
+
txBytes: sawNet ? tx : null,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
// Runtime state (venvs, npm prefixes) lands in the agent user's real home
|
|
222
|
+
// — writable, inside the container, gone with it.
|
|
223
|
+
process.env.FOLDRUN_DATA = "/home/agent/.foldrun";
|
|
224
|
+
const { executeStep } = await import("@foldrun/core/step-exec");
|
|
225
|
+
const { buildApiTools } = await import("@foldrun/core/api-tools");
|
|
226
|
+
const { buildScriptTools } = await import("@foldrun/core/script-tools");
|
|
227
|
+
const { buildConsultTools } = await import("@foldrun/core/agent-tools");
|
|
228
|
+
const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import("@foldrun/core/context-tools");
|
|
229
|
+
const { startTranslator } = await import("@foldrun/core/translator");
|
|
230
|
+
const { prepareRuntime } = await import("@foldrun/core/runtime");
|
|
231
|
+
const { materializeFileSecrets } = await import("@foldrun/core/secret-files");
|
|
232
|
+
|
|
233
|
+
const agentDir = "/workspace/" + input.agentRel;
|
|
234
|
+
// HOME is where a bare relative path lands when anything resolves one
|
|
235
|
+
// outside cwd — and root's /root is outside the workspace, so such a read
|
|
236
|
+
// was refused by confinement with a path the agent never typed. Its own
|
|
237
|
+
// directory is both the truthful answer and the safe one.
|
|
238
|
+
// @file secrets crossed as content; materialise them to 0600 paths inside
|
|
239
|
+
// the container (a host path from the other side would not exist here).
|
|
240
|
+
const env = materializeFileSecrets(agentDir, { ...process.env, HOME: agentDir }).env;
|
|
241
|
+
|
|
242
|
+
// The declared runtime, built in here — pip and npm have the network, which
|
|
243
|
+
// the host side may not. It lands in .foldrun/runner/.runtimes, which the
|
|
244
|
+
// executors mount from a per-tenant directory (run-container.ts#RUNTIME_CACHE):
|
|
245
|
+
// the first step for a given fingerprint installs, every later one finds
|
|
246
|
+
// .ready and skips. With no mount it still works, just fresh every time —
|
|
247
|
+
// which is what this used to be unconditionally. The log says which happened.
|
|
248
|
+
const runtime = input.runtime
|
|
249
|
+
? prepareRuntime("runner", input.runtime)
|
|
250
|
+
: { interpreters: {}, env: {}, log: [], error: null };
|
|
251
|
+
for (const line of runtime.log) emit("info", "runtime: " + line);
|
|
252
|
+
if (runtime.error) emit("error", "runtime: " + runtime.error);
|
|
253
|
+
|
|
254
|
+
// API and script tools, rebuilt in here from their specs. Secrets were
|
|
255
|
+
// substituted into API headers before the input crossed the boundary, so
|
|
256
|
+
// buildApiTools gets an already-resolved environment. (verify: needs no
|
|
257
|
+
// separate env either — the container's own environment already carries
|
|
258
|
+
// the declared secrets, which is why executeStep gets verifyEnv: {}.)
|
|
259
|
+
const api = buildApiTools("", input.apis, undefined, { env, missing: [] });
|
|
260
|
+
const script = buildScriptTools(
|
|
261
|
+
agentDir,
|
|
262
|
+
input.scripts,
|
|
263
|
+
{ ...env, ...runtime.env },
|
|
264
|
+
"/library/scripts",
|
|
265
|
+
runtime.interpreters,
|
|
266
|
+
);
|
|
267
|
+
// The translator, when the provider speaks Chat Completions: a loopback
|
|
268
|
+
// server for the length of this step. Its env replaces the endpoint the
|
|
269
|
+
// host assembled; the provider's real URL and key stay inside it.
|
|
270
|
+
const translator = input.translator ? await startTranslator(input.translator) : null;
|
|
271
|
+
if (translator) {
|
|
272
|
+
Object.assign(env, translator.env);
|
|
273
|
+
emit("info", "translator: listening on " + translator.baseUrl + " for " + (input.translator.label ?? input.translator.upstreamBase));
|
|
274
|
+
}
|
|
275
|
+
const consult = buildConsultTools(input.consults ?? [], env, emit);
|
|
276
|
+
// The platform's own two groups, rebuilt from values like everything else.
|
|
277
|
+
const search = buildSearchTools(input.search ?? []);
|
|
278
|
+
const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };
|
|
279
|
+
const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };
|
|
280
|
+
|
|
281
|
+
const outcome = await executeStep({
|
|
282
|
+
agentDir,
|
|
283
|
+
workspaceRoot: "/workspace",
|
|
284
|
+
libraryRoot: "/library",
|
|
285
|
+
prompt: input.prompt,
|
|
286
|
+
model: input.model,
|
|
287
|
+
effort: input.effort ?? null,
|
|
288
|
+
systemPrompt: input.systemPrompt,
|
|
289
|
+
allowed: input.allowed,
|
|
290
|
+
mcpNames: input.mcpNames,
|
|
291
|
+
mcpServers: {
|
|
292
|
+
...(api.server ? { foldrun_apis: api.server } : {}),
|
|
293
|
+
...(script.server ? { foldrun_scripts: script.server } : {}),
|
|
294
|
+
...(consult.server ? { foldrun_agents: consult.server } : {}),
|
|
295
|
+
...(search.server ? { foldrun_search: search.server } : {}),
|
|
296
|
+
...(history.server ? { foldrun_history: history.server } : {}),
|
|
297
|
+
...(desks.server ? { foldrun_desks: desks.server } : {}),
|
|
298
|
+
...input.mcpServers,
|
|
299
|
+
},
|
|
300
|
+
env,
|
|
301
|
+
timeoutSec: input.timeoutSec,
|
|
302
|
+
budgetUsd: input.budgetUsd,
|
|
303
|
+
price: input.price,
|
|
304
|
+
verify: input.verify,
|
|
305
|
+
verifyEnv: {},
|
|
306
|
+
output: input.output,
|
|
307
|
+
// The container is the boundary; the SDK's own bash sandbox here would
|
|
308
|
+
// only block declared network use (SSH, curl) for no added safety.
|
|
309
|
+
sandboxBash: false,
|
|
310
|
+
emit,
|
|
311
|
+
});
|
|
312
|
+
for (const line of api.drainLog()) emit("info", "api: " + line);
|
|
313
|
+
for (const line of script.drainLog()) emit("info", "script: " + line);
|
|
314
|
+
if (translator) {
|
|
315
|
+
for (const line of translator.drainLog()) emit("info", line.startsWith("translator") ? line : "translator: " + line);
|
|
316
|
+
await translator.close();
|
|
317
|
+
}
|
|
318
|
+
// A consult's spend belongs to the step that asked.
|
|
319
|
+
const consultCost = consult.drainCost();
|
|
320
|
+
if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;
|
|
321
|
+
process.stdout.write(JSON.stringify({ e: "done", ...outcome, res: readActuals() }) + "\\n");
|
|
322
|
+
process.exit(0);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
emit("error", err instanceof Error ? err.message : String(err));
|
|
325
|
+
// The failure path reports actuals too — a step that died eleven minutes
|
|
326
|
+
// in still held its slice for eleven minutes, and the failed runs are
|
|
327
|
+
// exactly the ones whose resource story gets read.
|
|
328
|
+
let res = null;
|
|
329
|
+
try { res = readActuals(); } catch { /* the reading must never mask the error */ }
|
|
330
|
+
process.stdout.write(JSON.stringify({ e: "done", status: "failed", result: null, costUsd: null, res }) + "\\n");
|
|
331
|
+
process.exit(0);
|
|
332
|
+
}
|
|
333
|
+
`;
|
|
334
|
+
// docker cp writes root-owned files, so the entrypoint starts as root for
|
|
335
|
+
// exactly two commands — chown the copied-in trees, drop to the agent user —
|
|
336
|
+
// and the run flags grant only the three capabilities those two commands
|
|
337
|
+
// need. By the time any model-directed code executes, the process is uid
|
|
338
|
+
// 10001 with no capabilities at all.
|
|
339
|
+
const DOCKERFILE = `FROM node:22-slim
|
|
340
|
+
RUN apt-get update \\
|
|
341
|
+
&& apt-get install -y --no-install-recommends python3 python3-venv ca-certificates bash util-linux tar openssh-client sshpass git curl \\
|
|
342
|
+
&& rm -rf /var/lib/apt/lists/* \\
|
|
343
|
+
&& useradd -m -u 10001 agent
|
|
344
|
+
# Real browsers, because directories and portals increasingly render with
|
|
345
|
+
# JavaScript and WebFetch sees only the empty shell. Chromium is the one the
|
|
346
|
+
# browser tool drives by default; Firefox and WebKit (Safari's engine) are
|
|
347
|
+
# there for an agent asked to see a page the way those render it. Installed
|
|
348
|
+
# to a fixed path the agent user can read (the default cache would be
|
|
349
|
+
# root's HOME). The browsers' own sandboxes are disabled at launch time — in
|
|
350
|
+
# this platform the container/gVisor IS the sandbox, and the two fight.
|
|
351
|
+
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules
|
|
352
|
+
RUN npm install -g playwright@1 >/dev/null \\
|
|
353
|
+
&& playwright install --with-deps chromium firefox webkit >/dev/null \\
|
|
354
|
+
&& chmod -R a+rX /opt/browser
|
|
355
|
+
WORKDIR /opt/runner
|
|
356
|
+
COPY foldrun-core.tgz driver.mjs entry.sh ./
|
|
357
|
+
RUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\
|
|
358
|
+
&& mkdir -p /workspace /library /opt/runner/job ${RUNTIME_CACHE} \\
|
|
359
|
+
&& chown -R agent:agent /workspace /library /opt/runner /home/agent/.foldrun \\
|
|
360
|
+
&& chmod +x /opt/runner/entry.sh
|
|
361
|
+
ENTRYPOINT ["/opt/runner/entry.sh"]
|
|
362
|
+
`;
|
|
363
|
+
const ENTRY = `#!/bin/sh
|
|
364
|
+
set -e
|
|
365
|
+
chown -R agent:agent /workspace /library /opt/runner/job
|
|
366
|
+
# The runtime cache, when one is mounted. NOT recursive: a warm cache is tens
|
|
367
|
+
# of thousands of files and its contents are already agent-owned from the step
|
|
368
|
+
# that built them — only the mount point itself arrives owned by root.
|
|
369
|
+
chown agent:agent ${RUNTIME_CACHE} 2>/dev/null || true
|
|
370
|
+
exec runuser -u agent -- node /opt/runner/driver.mjs
|
|
371
|
+
`;
|
|
372
|
+
function corePackageDir() {
|
|
373
|
+
// This file *is* part of @foldrun/core, so its own package.json is a walk
|
|
374
|
+
// up — from src/ in the repo, from dist/src/ once built. require.resolve
|
|
375
|
+
// can't do it: the exports map rewrites "./package.json" like any subpath.
|
|
376
|
+
let dir = path.dirname(new URL(import.meta.url).pathname);
|
|
377
|
+
for (let i = 0; i < 5; i++) {
|
|
378
|
+
const candidate = path.join(dir, "package.json");
|
|
379
|
+
if (fs.existsSync(candidate)) {
|
|
380
|
+
try {
|
|
381
|
+
if (JSON.parse(fs.readFileSync(candidate, "utf8")).name === "@foldrun/core")
|
|
382
|
+
return dir;
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
// keep walking
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
dir = path.dirname(dir);
|
|
389
|
+
}
|
|
390
|
+
throw new Error("could not locate @foldrun/core's package root");
|
|
391
|
+
}
|
|
392
|
+
export function runnerImageTag() {
|
|
393
|
+
// The fingerprint hashes the *built code*, not the version — a version
|
|
394
|
+
// string that nobody bumped would pin every future run to the runner
|
|
395
|
+
// image of whatever core happened to build first.
|
|
396
|
+
const hash = crypto.createHash("sha256").update(DRIVER + "\n" + DOCKERFILE + "\n" + ENTRY);
|
|
397
|
+
const dist = path.join(corePackageDir(), "dist");
|
|
398
|
+
if (fs.existsSync(dist)) {
|
|
399
|
+
for (const entry of fs.readdirSync(dist, { recursive: true }).sort()) {
|
|
400
|
+
const abs = path.join(dist, String(entry));
|
|
401
|
+
if (fs.statSync(abs).isFile())
|
|
402
|
+
hash.update(String(entry)).update(fs.readFileSync(abs));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return `foldrun-runner:${hash.digest("hex").slice(0, 12)}`;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Build the runner image if this exact (core version, driver, dockerfile)
|
|
409
|
+
* has not been built before. `npm pack` of the installed core, so the image
|
|
410
|
+
* runs the same bytes the server does.
|
|
411
|
+
*/
|
|
412
|
+
export function ensureRunnerImage() {
|
|
413
|
+
const tag = process.env.FOLDRUN_RUNNER_IMAGE ?? runnerImageTag();
|
|
414
|
+
const log = [];
|
|
415
|
+
if (process.env.FOLDRUN_RUNNER_IMAGE)
|
|
416
|
+
return { tag, log };
|
|
417
|
+
const have = spawnSync(cli(), ["image", "inspect", tag], { stdio: "ignore" });
|
|
418
|
+
if (have.status === 0)
|
|
419
|
+
return { tag, log };
|
|
420
|
+
const build = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-runner-build-"));
|
|
421
|
+
try {
|
|
422
|
+
const pack = spawnSync("npm", ["pack", corePackageDir(), "--pack-destination", build], {
|
|
423
|
+
encoding: "utf8",
|
|
424
|
+
});
|
|
425
|
+
if (pack.status !== 0)
|
|
426
|
+
throw new Error(`npm pack failed:\n${pack.stderr}`);
|
|
427
|
+
const tarball = pack.stdout.trim().split("\n").at(-1);
|
|
428
|
+
fs.renameSync(path.join(build, tarball), path.join(build, "foldrun-core.tgz"));
|
|
429
|
+
fs.writeFileSync(path.join(build, "driver.mjs"), DRIVER);
|
|
430
|
+
fs.writeFileSync(path.join(build, "entry.sh"), ENTRY);
|
|
431
|
+
fs.writeFileSync(path.join(build, "Dockerfile"), DOCKERFILE);
|
|
432
|
+
log.push(`building runner image ${tag} (first run only)`);
|
|
433
|
+
const out = spawnSync(cli(), ["build", "-t", tag, build], { encoding: "utf8" });
|
|
434
|
+
if (out.status !== 0) {
|
|
435
|
+
throw new Error(`runner image build failed:\n${(out.stderr || out.stdout).slice(-2000)}`);
|
|
436
|
+
}
|
|
437
|
+
return { tag, log };
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
fs.rmSync(build, { recursive: true, force: true });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// ---------- running one step ----------
|
|
444
|
+
export function parseDriverLine(line) {
|
|
445
|
+
const trimmed = line.trim();
|
|
446
|
+
if (!trimmed.startsWith("{"))
|
|
447
|
+
return null;
|
|
448
|
+
try {
|
|
449
|
+
const parsed = JSON.parse(trimmed);
|
|
450
|
+
if (parsed.e === "event" && typeof parsed.text === "string") {
|
|
451
|
+
return {
|
|
452
|
+
e: "event",
|
|
453
|
+
type: parsed.type,
|
|
454
|
+
text: parsed.text,
|
|
455
|
+
...(typeof parsed.call === "string" ? { call: parsed.call } : {}),
|
|
456
|
+
...(typeof parsed.ms === "number" ? { ms: parsed.ms } : {}),
|
|
457
|
+
...(parsed.err === true ? { err: true } : {}),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
if (parsed.e === "done") {
|
|
461
|
+
return {
|
|
462
|
+
e: "done",
|
|
463
|
+
status: parsed.status === "completed" ? "completed" : "failed",
|
|
464
|
+
result: typeof parsed.result === "string" ? parsed.result : null,
|
|
465
|
+
conclusion: typeof parsed.conclusion === "string" ? parsed.conclusion : null,
|
|
466
|
+
...("data" in parsed ? { data: parsed.data } : {}),
|
|
467
|
+
costUsd: typeof parsed.costUsd === "number" ? parsed.costUsd : null,
|
|
468
|
+
usage: parsed.usage &&
|
|
469
|
+
typeof parsed.usage.inputTokens === "number" &&
|
|
470
|
+
typeof parsed.usage.outputTokens === "number"
|
|
471
|
+
? { inputTokens: parsed.usage.inputTokens, outputTokens: parsed.usage.outputTokens }
|
|
472
|
+
: null,
|
|
473
|
+
res: parsed.res && typeof parsed.res === "object"
|
|
474
|
+
? {
|
|
475
|
+
busyCpuSecs: typeof parsed.res.busyCpuSecs === "number" ? parsed.res.busyCpuSecs : null,
|
|
476
|
+
peakMemBytes: typeof parsed.res.peakMemBytes === "number" ? parsed.res.peakMemBytes : null,
|
|
477
|
+
rxBytes: typeof parsed.res.rxBytes === "number" ? parsed.res.rxBytes : null,
|
|
478
|
+
txBytes: typeof parsed.res.txBytes === "number" ? parsed.res.txBytes : null,
|
|
479
|
+
}
|
|
480
|
+
: null,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
return null; // interleaved non-protocol output (npm, python) is noise
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// The network runner containers join when none is configured: a bridge with
|
|
490
|
+
// inter-container traffic disabled, so two tenants' runs sharing a host
|
|
491
|
+
// cannot see each other's ports — each gets the internet (the model API
|
|
492
|
+
// lives there) and nothing beside it. Created once, reused forever.
|
|
493
|
+
const RUN_NETWORK = "foldrun-runs";
|
|
494
|
+
function ensureRunNetwork() {
|
|
495
|
+
const configured = process.env.FOLDRUN_RUNNER_NETWORK;
|
|
496
|
+
if (configured)
|
|
497
|
+
return configured;
|
|
498
|
+
const have = spawnSync(cli(), ["network", "inspect", RUN_NETWORK], { stdio: "ignore" });
|
|
499
|
+
if (have.status !== 0) {
|
|
500
|
+
spawnSync(cli(), [
|
|
501
|
+
"network", "create",
|
|
502
|
+
"--opt", "com.docker.network.bridge.enable_icc=false",
|
|
503
|
+
RUN_NETWORK,
|
|
504
|
+
], { stdio: "ignore" });
|
|
505
|
+
}
|
|
506
|
+
return RUN_NETWORK;
|
|
507
|
+
}
|
|
508
|
+
/** The limits a size class reserves. Large is the install's configured
|
|
509
|
+
* limits unchanged, so existing installs bill and behave identically;
|
|
510
|
+
* small defaults to a quarter-ish slice and is env-tunable. */
|
|
511
|
+
/**
|
|
512
|
+
* A memory figure Kubernetes will accept.
|
|
513
|
+
*
|
|
514
|
+
* `sizeLimits` speaks Docker, because that is who asked first: `--memory 2g`.
|
|
515
|
+
* Kubernetes wants a quantity — `2Gi` — and rejects the lowercase form with
|
|
516
|
+
* "quantities must match the regular expression", which names neither the
|
|
517
|
+
* field nor the value. The default size returns `2g`, so every default-size
|
|
518
|
+
* step failed to create a pod on any install that had not overridden
|
|
519
|
+
* FOLDRUN_RUNNER_MEMORY. These manifests do override it, which is why this
|
|
520
|
+
* survived: it broke for self-hosters and for nobody testing here.
|
|
521
|
+
*
|
|
522
|
+
* Docker's suffixes are binary, so `g` is `Gi` and not `G`. A value that is
|
|
523
|
+
* already a Kubernetes quantity passes through, and an unrecognised one is
|
|
524
|
+
* handed over unchanged so the cluster's own error is the one you read.
|
|
525
|
+
*
|
|
526
|
+
* Case decides, because one suffix is ambiguous: `k` is a legal Kubernetes
|
|
527
|
+
* decimal suffix AND Docker's kibibytes. Everything this is given comes from
|
|
528
|
+
* sizeLimits, which speaks Docker, so lowercase is read as Docker's — the
|
|
529
|
+
* difference is 2.4% on a memory limit and the alternative is silently
|
|
530
|
+
* misreading the default.
|
|
531
|
+
*/
|
|
532
|
+
export function k8sMemory(value) {
|
|
533
|
+
const s = String(value).trim();
|
|
534
|
+
if (/^\d+(\.\d+)?(Ki|Mi|Gi|Ti|Pi|[KMGTP])?$/.test(s))
|
|
535
|
+
return s;
|
|
536
|
+
const m = s.match(/^(\d+(?:\.\d+)?)([bkmgt])$/);
|
|
537
|
+
if (!m)
|
|
538
|
+
return s;
|
|
539
|
+
return `${m[1]}${{ b: "", k: "Ki", m: "Mi", g: "Gi", t: "Ti" }[m[2]]}`;
|
|
540
|
+
}
|
|
541
|
+
export function sizeLimits(size) {
|
|
542
|
+
if (size === "small") {
|
|
543
|
+
return {
|
|
544
|
+
cpus: process.env.FOLDRUN_RUNNER_CPUS_SMALL ?? "1",
|
|
545
|
+
memory: process.env.FOLDRUN_RUNNER_MEMORY_SMALL ?? "1Gi",
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
if (size === "heavy") {
|
|
549
|
+
// For steps that render a browser or hold real data. The memory figure is
|
|
550
|
+
// a hard ceiling (memory is not compressible — over it, the kernel OOM-kills
|
|
551
|
+
// the pod, which on a single-box install can take neighbours with it). The
|
|
552
|
+
// cpus figure is a billing weight, not a ceiling: on k8s the pod bursts CPU
|
|
553
|
+
// freely, and this is the rate its seconds are priced at.
|
|
554
|
+
return {
|
|
555
|
+
cpus: process.env.FOLDRUN_RUNNER_CPUS_HEAVY ?? "4",
|
|
556
|
+
memory: process.env.FOLDRUN_RUNNER_MEMORY_HEAVY ?? "8Gi",
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
cpus: process.env.FOLDRUN_RUNNER_CPUS ?? "2",
|
|
561
|
+
memory: process.env.FOLDRUN_RUNNER_MEMORY ?? "2g",
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
/** The label both executors stamp, so one name means one thing. */
|
|
565
|
+
export const RUN_LABEL = "foldrun-run-id";
|
|
566
|
+
/**
|
|
567
|
+
* Destroy any sandbox still running for this run. Best effort by design:
|
|
568
|
+
* the step may have finished a moment ago, the daemon may be unreachable,
|
|
569
|
+
* and neither is a reason to refuse to stop a run. Returns how many it
|
|
570
|
+
* removed, which is what the trace reports.
|
|
571
|
+
*/
|
|
572
|
+
export function killRunSandboxes(runId) {
|
|
573
|
+
const ids = spawnSync(cli(), ["ps", "-aq", "--filter", `label=${RUN_LABEL}=${runId}`], {
|
|
574
|
+
encoding: "utf8",
|
|
575
|
+
});
|
|
576
|
+
const list = (ids.stdout ?? "").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
577
|
+
for (const id of list)
|
|
578
|
+
spawnSync(cli(), ["rm", "-f", id], { stdio: "ignore" });
|
|
579
|
+
return list.length;
|
|
580
|
+
}
|
|
581
|
+
export async function runStepInContainer(args) {
|
|
582
|
+
const { tag, log } = ensureRunnerImage();
|
|
583
|
+
for (const line of log)
|
|
584
|
+
args.emit("info", line);
|
|
585
|
+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-run-"));
|
|
586
|
+
let containerId = "";
|
|
587
|
+
try {
|
|
588
|
+
// Stage the workspace copy minus what the platform owns — the vault,
|
|
589
|
+
// hook state, run history. The container gets the trust boundary, not
|
|
590
|
+
// the bookkeeping. One definition of "platform-owned" for this filter,
|
|
591
|
+
// the k8s executor's, and deploys: store.ts#isPlatformPath.
|
|
592
|
+
const wsIn = path.join(staging, "workspace");
|
|
593
|
+
fs.cpSync(args.workspaceRoot, wsIn, {
|
|
594
|
+
recursive: true,
|
|
595
|
+
filter: (src) => !isPlatformPath(path.relative(args.workspaceRoot, src)),
|
|
596
|
+
});
|
|
597
|
+
const libIn = path.join(staging, "library");
|
|
598
|
+
if (fs.existsSync(args.libraryRoot))
|
|
599
|
+
fs.cpSync(args.libraryRoot, libIn, { recursive: true });
|
|
600
|
+
else
|
|
601
|
+
fs.mkdirSync(libIn, { recursive: true });
|
|
602
|
+
const jobIn = path.join(staging, "job");
|
|
603
|
+
fs.mkdirSync(jobIn);
|
|
604
|
+
fs.writeFileSync(path.join(jobIn, "input.json"), JSON.stringify(args.input, null, 2));
|
|
605
|
+
// @file secrets are multi-line (a PEM key, a cert), and a docker
|
|
606
|
+
// --env-file cannot carry a newline. So they are staged as files *now*,
|
|
607
|
+
// into the copied-in workspace, and the env carries the container path —
|
|
608
|
+
// the one place a path is stable across the boundary. Everything else
|
|
609
|
+
// goes through the env file. (Without this the key silently vanished:
|
|
610
|
+
// the old filter dropped any value with a newline, and ssh got an empty
|
|
611
|
+
// -i path.)
|
|
612
|
+
const containerEnv = {};
|
|
613
|
+
for (const [k, v] of Object.entries(args.env)) {
|
|
614
|
+
if (typeof v !== "string")
|
|
615
|
+
continue;
|
|
616
|
+
if (isFileValue(v)) {
|
|
617
|
+
const rel = path.join(args.input.agentRel, ".secret-files", k.toLowerCase());
|
|
618
|
+
const abs = path.join(wsIn, rel);
|
|
619
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o700 });
|
|
620
|
+
fs.writeFileSync(abs, fileContent(v), { mode: 0o600 });
|
|
621
|
+
containerEnv[k] = `/workspace/${rel.replaceAll("\\", "/")}`;
|
|
622
|
+
}
|
|
623
|
+
else if (!v.includes("\n")) {
|
|
624
|
+
containerEnv[k] = v;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const envFile = path.join(staging, "env");
|
|
628
|
+
fs.writeFileSync(envFile, Object.entries(containerEnv).map(([k, v]) => `${k}=${v}`).join("\n"), { mode: 0o600 });
|
|
629
|
+
const flags = [
|
|
630
|
+
"--security-opt", "no-new-privileges",
|
|
631
|
+
...(args.runId ? ["--label", `${RUN_LABEL}=${args.runId}`] : []),
|
|
632
|
+
// Everything dropped except what the entrypoint's chown-and-drop
|
|
633
|
+
// needs; the exec to the agent user sheds these three too.
|
|
634
|
+
"--cap-drop", "ALL",
|
|
635
|
+
"--cap-add", "CHOWN", "--cap-add", "SETUID", "--cap-add", "SETGID",
|
|
636
|
+
"--pids-limit", "512",
|
|
637
|
+
// Memory is a hard ceiling (not compressible — an OOM protects the host).
|
|
638
|
+
// CPU is deliberately NOT capped, matching the k8s path: a cap only
|
|
639
|
+
// throttles work the customer pays for, and the tier's cpu figure is a
|
|
640
|
+
// billing weight, applied in usage.ts, not a docker --cpus limit here.
|
|
641
|
+
"--memory", sizeLimits(args.size).memory,
|
|
642
|
+
"--env-file", envFile,
|
|
643
|
+
];
|
|
644
|
+
// gVisor (or kata) where the host has it: one env var, because the flag
|
|
645
|
+
// is the same docker invocation everywhere else.
|
|
646
|
+
if (process.env.FOLDRUN_RUNNER_RUNTIME) {
|
|
647
|
+
flags.push("--runtime", process.env.FOLDRUN_RUNNER_RUNTIME);
|
|
648
|
+
}
|
|
649
|
+
flags.push("--network", ensureRunNetwork());
|
|
650
|
+
// A name for the host, so the egress proxy the platform runs is reachable
|
|
651
|
+
// from inside a step as host.docker.internal on Linux as it already is on
|
|
652
|
+
// Docker Desktop. The run bridge has inter-container traffic off; the
|
|
653
|
+
// host itself stays reachable, which is the one door this needs.
|
|
654
|
+
flags.push("--add-host", "host.docker.internal:host-gateway");
|
|
655
|
+
// The dependency cache: this tenant's built venvs and npm prefixes,
|
|
656
|
+
// mounted where prepareRuntime already looks. Created host-side rather
|
|
657
|
+
// than left to the daemon so it belongs to the platform user; the
|
|
658
|
+
// entrypoint hands the mount point itself to the agent user.
|
|
659
|
+
const cacheDir = runtimeCacheDir(args.tenant);
|
|
660
|
+
if (cacheDir) {
|
|
661
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
662
|
+
flags.push("-v", `${cacheDir}:${RUNTIME_CACHE}`);
|
|
663
|
+
}
|
|
664
|
+
// The rent clock starts here: staging the workspace is host work, but
|
|
665
|
+
// from `create` to `rm` the sandbox is a resource the platform pays for.
|
|
666
|
+
const t0 = Date.now();
|
|
667
|
+
const create = spawnSync(cli(), ["create", ...flags, tag], { encoding: "utf8" });
|
|
668
|
+
if (create.status !== 0)
|
|
669
|
+
throw new Error(`container create failed:\n${create.stderr}`);
|
|
670
|
+
containerId = create.stdout.trim();
|
|
671
|
+
const sandboxMs = Date.now() - t0;
|
|
672
|
+
const cp = (from, to) => {
|
|
673
|
+
const out = spawnSync(cli(), ["cp", from, to], { encoding: "utf8" });
|
|
674
|
+
if (out.status !== 0)
|
|
675
|
+
throw new Error(`docker cp failed:\n${out.stderr}`);
|
|
676
|
+
};
|
|
677
|
+
cp(`${wsIn}/.`, `${containerId}:/workspace`);
|
|
678
|
+
cp(`${libIn}/.`, `${containerId}:/library`);
|
|
679
|
+
cp(`${jobIn}/input.json`, `${containerId}:/opt/runner/job/input.json`);
|
|
680
|
+
// Start attached and read the driver's JSONL. The step timeout gets a
|
|
681
|
+
// backstop out here — a wedged container is killed, not waited on.
|
|
682
|
+
let firstOutputAt = null;
|
|
683
|
+
const outcome = await new Promise((resolve) => {
|
|
684
|
+
const child = spawn(cli(), ["start", "-a", containerId], { stdio: ["ignore", "pipe", "pipe"] });
|
|
685
|
+
let done = null;
|
|
686
|
+
let buffer = "";
|
|
687
|
+
// Only when the step set a `timeout:` — no platform default. A step
|
|
688
|
+
// that names no limit runs until it finishes.
|
|
689
|
+
const backstopMs = args.input.timeoutSec ? (args.input.timeoutSec + 60) * 1000 : null;
|
|
690
|
+
const backstop = backstopMs
|
|
691
|
+
? setTimeout(() => {
|
|
692
|
+
args.emit("error", `container exceeded its ${Math.round(backstopMs / 1000)}s backstop — killed`);
|
|
693
|
+
spawnSync(cli(), ["kill", containerId], { stdio: "ignore" });
|
|
694
|
+
}, backstopMs)
|
|
695
|
+
: null;
|
|
696
|
+
child.stdout.on("data", (chunk) => {
|
|
697
|
+
buffer += chunk.toString();
|
|
698
|
+
for (;;) {
|
|
699
|
+
const nl = buffer.indexOf("\n");
|
|
700
|
+
if (nl < 0)
|
|
701
|
+
break;
|
|
702
|
+
const line = buffer.slice(0, nl);
|
|
703
|
+
buffer = buffer.slice(nl + 1);
|
|
704
|
+
const parsed = parseDriverLine(line);
|
|
705
|
+
if (!parsed)
|
|
706
|
+
continue;
|
|
707
|
+
firstOutputAt ??= Date.now();
|
|
708
|
+
if (parsed.e === "event") {
|
|
709
|
+
const { e: _e, type, text, ...extra } = parsed;
|
|
710
|
+
args.emit(type, text, extra);
|
|
711
|
+
}
|
|
712
|
+
else
|
|
713
|
+
done = parsed;
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
let stderrTail = "";
|
|
717
|
+
child.stderr.on("data", (c) => {
|
|
718
|
+
stderrTail = (stderrTail + c.toString()).slice(-2000);
|
|
719
|
+
});
|
|
720
|
+
child.on("close", () => {
|
|
721
|
+
if (backstop)
|
|
722
|
+
clearTimeout(backstop);
|
|
723
|
+
if (!done && stderrTail.trim())
|
|
724
|
+
args.emit("error", stderrTail.trim().slice(0, 1000));
|
|
725
|
+
resolve(done ?? { status: "failed", result: null, costUsd: null });
|
|
726
|
+
});
|
|
727
|
+
child.on("error", (err) => {
|
|
728
|
+
if (backstop)
|
|
729
|
+
clearTimeout(backstop);
|
|
730
|
+
args.emit("error", err.message);
|
|
731
|
+
resolve({ status: "failed", result: null, costUsd: null });
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
// What came out, through the filter. On failure too: a failed step's
|
|
735
|
+
// partial outputs are usually the interesting ones.
|
|
736
|
+
const wsOut = path.join(staging, "out");
|
|
737
|
+
fs.mkdirSync(wsOut);
|
|
738
|
+
const back = spawnSync(cli(), ["cp", `${containerId}:/workspace/.`, wsOut], { encoding: "utf8" });
|
|
739
|
+
if (back.status === 0) {
|
|
740
|
+
applyContainerChanges(args.workspaceRoot, wsOut, wsIn);
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
args.emit("error", `copy-out failed — the step's file changes were lost:\n${back.stderr.slice(0, 500)}`);
|
|
744
|
+
}
|
|
745
|
+
// Timed after the copy-out, because the container is still alive for it:
|
|
746
|
+
// what gets billed is the sandbox's whole life, not the model's turn.
|
|
747
|
+
const timing = {
|
|
748
|
+
sandboxMs,
|
|
749
|
+
firstOutputMs: firstOutputAt === null ? null : firstOutputAt - t0 - sandboxMs,
|
|
750
|
+
totalMs: Date.now() - t0,
|
|
751
|
+
};
|
|
752
|
+
args.emit("info", timingLine(timing));
|
|
753
|
+
return { ...outcome, timing };
|
|
754
|
+
}
|
|
755
|
+
finally {
|
|
756
|
+
if (containerId)
|
|
757
|
+
spawnSync(cli(), ["rm", "-f", containerId], { stdio: "ignore" });
|
|
758
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
759
|
+
}
|
|
760
|
+
}
|