@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,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
+ }