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