@foldrun/core 0.3.0 → 0.5.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 (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
@@ -23,9 +23,11 @@ import path from "node:path";
23
23
  import crypto from "node:crypto";
24
24
  import { spawn, spawnSync } from "node:child_process";
25
25
  import { isPlatformPath } from "./store.js";
26
+ import { divertedSummary, isDivertedPath } from "./test-mode.js";
26
27
  import { isFileValue, fileContent } from "./secrets.js";
27
28
  import { safeTenantSegment } from "./runtime.js";
28
- import { dataRoot } from "./paths.js";
29
+ import { isOperatorEvent } from "./operator.js";
30
+ import { parseBrowserPod } from "./browser-pod.js";
29
31
  /**
30
32
  * The one place the timing line is worded, so the docker and k8s traces
31
33
  * read the same and a cold-start regression is greppable across both.
@@ -123,7 +125,69 @@ export function hashTree(dir) {
123
125
  walk(dir);
124
126
  return out;
125
127
  }
126
- export function applyContainerChanges(hostWs, containerWs, baseline) {
128
+ /**
129
+ * Every file's mtime under `dir`, relative path to milliseconds — so a copy
130
+ * that cannot carry times can have them put back. `kubectl cp` unpacks with
131
+ * `tar -m`, which stamps every file with the moment it landed; on 2026-09-24
132
+ * gbp-desk's reply-sheet check ("pending-replies.json written in the last
133
+ * hour") passed on a file last written on 2026-09-13, and the poster sent
134
+ * nothing. Every freshness check in every flow was answering "yes".
135
+ */
136
+ export function mtimeManifest(dir) {
137
+ const out = {};
138
+ const walk = (d) => {
139
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
140
+ const abs = path.join(d, entry.name);
141
+ if (entry.isSymbolicLink())
142
+ continue;
143
+ if (entry.isDirectory())
144
+ walk(abs);
145
+ else
146
+ out[path.relative(dir, abs).replaceAll("\\", "/")] = fs.statSync(abs).mtimeMs;
147
+ }
148
+ };
149
+ if (fs.existsSync(dir))
150
+ walk(dir);
151
+ return out;
152
+ }
153
+ /** The in-pod half of mtimeManifest: a node one-liner, run as root before `go`. */
154
+ export const APPLY_MTIMES_JS = `const fs=require("fs"),p=require("path");const m=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));` +
155
+ `for(const [r,t] of Object.entries(m)){try{fs.utimesSync(p.join(process.argv[2],r),t/1000,t/1000)}catch{}}`;
156
+ const sha = (b) => crypto.createHash("sha256").update(b).digest("hex");
157
+ /**
158
+ * Both sides changed a file the step was handed. When each is the handed-in
159
+ * content with lines added on the end — a ledger, history.md, a .jsonl — the
160
+ * answer is the base, then the host's additions, then the step's. Only the
161
+ * base's hash may be known, so the base is found as the longest shared
162
+ * leading run of whole lines whose hash matches. Anything else is null: a
163
+ * real conflict, which the caller must not settle by dropping one side.
164
+ */
165
+ export function mergeAppends(was, host, next) {
166
+ // Both sides created the same new file: no shared base to append to, and
167
+ // joining two JSON documents is corruption, not a merge.
168
+ if (was === null)
169
+ return null;
170
+ const h = host.toString("utf8");
171
+ const n = next.toString("utf8");
172
+ let common = 0;
173
+ const max = Math.min(h.length, n.length);
174
+ while (common < max && h.charCodeAt(common) === n.charCodeAt(common))
175
+ common++;
176
+ for (let end = common; end >= 0; end--) {
177
+ if (end > 0 && h[end - 1] !== "\n")
178
+ continue;
179
+ const prefix = h.slice(0, end);
180
+ const matches = typeof was === "string" ? sha(Buffer.from(prefix, "utf8")) === was : Buffer.from(prefix, "utf8").equals(was);
181
+ if (!matches)
182
+ continue;
183
+ const hostAdded = h.slice(end);
184
+ const stepAdded = n.slice(end);
185
+ const joined = hostAdded && !hostAdded.endsWith("\n") ? hostAdded + "\n" : hostAdded;
186
+ return Buffer.from(prefix + joined + stepAdded, "utf8");
187
+ }
188
+ return null;
189
+ }
190
+ export function applyContainerChanges(hostWs, containerWs, baseline, note, divert) {
127
191
  const applied = [];
128
192
  const walk = (dir) => {
129
193
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -144,22 +208,61 @@ export function applyContainerChanges(hostWs, containerWs, baseline) {
144
208
  }
145
209
  if (!allowedBack(rel))
146
210
  continue;
147
- const next = fs.readFileSync(abs);
148
- // Untouched by this step: whatever the host says now is more current
149
- // than what we handed in, including a change made while it ran.
211
+ let next = fs.readFileSync(abs);
212
+ // What the step was handed: its bytes (a staging dir) or its hash.
213
+ // null when the file did not exist then; undefined when no baseline.
214
+ let was;
150
215
  if (typeof baseline === "string") {
151
- const was = path.join(baseline, rel);
152
- if (fs.existsSync(was) && fs.readFileSync(was).equals(next))
153
- continue;
216
+ const p = path.join(baseline, rel);
217
+ was = fs.existsSync(p) ? fs.readFileSync(p) : null;
154
218
  }
155
219
  else if (baseline) {
156
- const was = baseline[rel.replaceAll("\\", "/")];
157
- if (was && was === crypto.createHash("sha256").update(next).digest("hex"))
158
- continue;
220
+ was = baseline[rel.replaceAll("\\", "/")] ?? null;
159
221
  }
222
+ const same = (b) => was === null || was === undefined ? false : typeof was === "string" ? sha(b) === was : b.equals(was);
223
+ // Untouched by this step: whatever the host says now is more current
224
+ // than what we handed in, including a change made while it ran.
225
+ if (same(next))
226
+ continue;
160
227
  const target = path.join(hostWs, rel);
161
- if (fs.existsSync(target) && fs.readFileSync(target).equals(next))
228
+ const hostNow = fs.existsSync(target) ? fs.readFileSync(target) : null;
229
+ if (hostNow && hostNow.equals(next))
162
230
  continue;
231
+ // The host moved too since the step was handed its copy — another run
232
+ // or a person wrote it. Writing the step's copy over it erased the
233
+ // other run's ledger rows on 2026-09-14. Merge appends; keep both
234
+ // sides of anything else.
235
+ const hostMoved = was !== undefined && hostNow !== null && !same(hostNow);
236
+ if (hostMoved) {
237
+ const merged = mergeAppends(was ?? null, hostNow, next);
238
+ if (merged) {
239
+ next = merged;
240
+ }
241
+ else {
242
+ const ext = path.extname(rel);
243
+ const stem = rel.slice(0, rel.length - ext.length);
244
+ const conflictRel = `${stem}.conflict-${Date.now().toString(36)}${ext}`;
245
+ const conflictAbs = path.join(hostWs, conflictRel);
246
+ fs.mkdirSync(path.dirname(conflictAbs), { recursive: true });
247
+ fs.writeFileSync(conflictAbs, next);
248
+ applied.push(conflictRel);
249
+ note?.(`write-back conflict on ${rel.replaceAll("\\", "/")}: it changed on the workspace while this step ran, ` +
250
+ `and not only by appending — kept the workspace's version; this step's version is ${conflictRel.replaceAll("\\", "/")}`);
251
+ continue;
252
+ }
253
+ }
254
+ // A test run's state/ and storage/ writes land under the run, not on
255
+ // the workspace: the step ran against the real files and its output
256
+ // is kept where a person can read it, but the next real run starts
257
+ // from state a test never touched. Decided after the merge so the
258
+ // diverted copy is what WOULD have been written, conflicts included.
259
+ if (divert && isDivertedPath(rel)) {
260
+ const aside = path.join(divert.to, rel);
261
+ fs.mkdirSync(path.dirname(aside), { recursive: true });
262
+ fs.writeFileSync(aside, next);
263
+ divert.note(rel.replaceAll("\\", "/"), divertedSummary(rel.replaceAll("\\", "/"), hostNow, next));
264
+ continue;
265
+ }
163
266
  fs.mkdirSync(path.dirname(target), { recursive: true });
164
267
  fs.writeFileSync(target, next);
165
268
  applied.push(rel);
@@ -181,27 +284,54 @@ export function applyContainerChanges(hostWs, containerWs, baseline) {
181
284
  */
182
285
  export const RUNTIME_CACHE = "/home/agent/.foldrun/runner/.runtimes";
183
286
  /**
184
- * The per-tenant directory backing that mount, or null when there is none.
287
+ * Where the platform's shared runtime layer is mounted, read-only, in a run
288
+ * pod — and read-write, one entry at a time, in the pod that builds an entry
289
+ * for it (foldrun-platform runtime-warm.ts). A fixed path for the same reason
290
+ * as RUNTIME_CACHE: a venv hard-codes its own location. Laid out as
291
+ * <FOLDRUN_DATA>/shared/.runtimes so the builder's prepareRuntime("shared", …)
292
+ * lands exactly here with FOLDRUN_DATA=/opt/foldrun-runtimes.
293
+ */
294
+ export const SHARED_RUNTIMES = "/opt/foldrun-runtimes/shared/.runtimes";
295
+ /** Docker's own volume-name charset: a leading alphanumeric, then
296
+ * alphanumerics plus `_ . -`. The mount source is handed straight to the
297
+ * daemon, so it is validated against this rather than assumed. */
298
+ const DOCKER_VOLUME_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
299
+ /**
300
+ * The per-tenant runtime cache mount — a Docker NAMED volume and the fixed
301
+ * path it mounts at — or null when there is none.
302
+ *
303
+ * A named volume, not a host path. The run's `docker create` is issued to the
304
+ * host daemon over the mounted socket, and that daemon cannot resolve a path
305
+ * under the platform's own FOLDRUN_DATA — which under compose is itself a named
306
+ * volume, or a directory Docker Desktop has not shared — so `-v <hostpath>:…`
307
+ * failed with "mounts denied". A named volume is resolved by the daemon, needs
308
+ * no host sharing, and on first use is seeded from the image's mount point,
309
+ * which the runner image creates owned by the agent user (the DOCKERFILE below,
310
+ * `${RUNTIME_CACHE}`) — so the fresh cache is writable without a host-side chown.
185
311
  *
186
- * Per-tenant, not shared. A shared cache would store one copy of pandas
187
- * instead of one per account, but it would also be mutable state that one
188
- * tenant's step writes and another tenant's step then *executes* — a poisoned
189
- * venv arriving as a trusted local file, which is precisely the path gVisor,
190
- * the capability drop and the egress policy all exist to close. Disk is the
191
- * cheaper thing to spend. If the duplication ever stops being cheap, the fix
192
- * is a read-only mount filled by a trusted builder, not a writable shared one.
312
+ * Per-tenant, not shared. A shared cache would store one copy of pandas instead
313
+ * of one per account, but it would also be mutable state that one tenant's step
314
+ * writes and another tenant's step then *executes* — a poisoned venv arriving
315
+ * as a trusted local file, which is precisely the path gVisor, the capability
316
+ * drop and the egress policy all exist to close. Disk is the cheaper thing to
317
+ * spend. One volume per tenant keeps that boundary; each carries a
318
+ * `foldrun.io/tenant` label so it can be found and pruned (self-hosting/README.md).
193
319
  *
194
- * Deliberately NOT under `<tenant>/.runtimes`, which is the *host* cache: the
195
- * venvs there carry host absolute paths and would be unusable inside a runner.
196
- * Two roots, so a hit is always a real hit.
320
+ * The venvs inside carry the runner's own absolute paths, distinct from the
321
+ * host's `<tenant>/.runtimes` cache — two roots, so a hit is always a real hit.
197
322
  */
198
- export function runtimeCacheDir(tenant) {
323
+ export function runtimeCacheMount(tenant) {
199
324
  if (process.env.FOLDRUN_RUNTIME_CACHE === "off")
200
325
  return null;
201
326
  const safe = tenant ? safeTenantSegment(tenant) : null;
202
327
  if (!safe)
203
328
  return null;
204
- return path.join(dataRoot(), safe, ".runtimes-sandbox");
329
+ const source = `foldrun-rt-${safe}`;
330
+ // safeTenantSegment already yields Docker's charset and `foldrun-rt-` is a
331
+ // safe leading token — but the source is what the daemon binds, so prove it.
332
+ if (!DOCKER_VOLUME_NAME.test(source))
333
+ return null;
334
+ return { source, target: RUNTIME_CACHE };
205
335
  }
206
336
  // ---------- the runner image ----------
207
337
  const DRIVER = `// The runner container's entrypoint. Reads one step, executes it with the
@@ -259,9 +389,11 @@ try {
259
389
  const { buildScriptTools } = await import("@foldrun/core/script-tools");
260
390
  const { buildConsultTools } = await import("@foldrun/core/agent-tools");
261
391
  const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import("@foldrun/core/context-tools");
392
+ const { httpChannel, buildAskTool, inboxReader } = await import("@foldrun/core/operator");
262
393
  const { startTranslator } = await import("@foldrun/core/translator");
263
394
  const { prepareRuntime } = await import("@foldrun/core/runtime");
264
395
  const { materializeFileSecrets } = await import("@foldrun/core/secret-files");
396
+ const { paidWebActions } = await import("@foldrun/core/browser-pod");
265
397
 
266
398
  const agentDir = "/workspace/" + input.agentRel;
267
399
  // HOME is where a bare relative path lands when anything resolves one
@@ -283,12 +415,16 @@ try {
283
415
  : { interpreters: {}, env: {}, log: [], error: null };
284
416
  for (const line of runtime.log) emit("info", "runtime: " + line);
285
417
  if (runtime.error) emit("error", "runtime: " + runtime.error);
418
+ // The step's own environment gets it too, not only the script tools: an
419
+ // agent that runs "python3 x.py" or a shebang script through Bash should
420
+ // meet the packages its runtime declared, not the image's bare python.
421
+ Object.assign(env, runtime.env);
286
422
 
287
423
  // API and script tools, rebuilt in here from their specs. Secrets were
288
424
  // substituted into API headers before the input crossed the boundary, so
289
- // buildApiTools gets an already-resolved environment. (verify: needs no
290
- // separate env either — the container's own environment already carries
291
- // the declared secrets, which is why executeStep gets verifyEnv: {}.)
425
+ // buildApiTools gets an already-resolved environment. verify: gets the
426
+ // container's own environment, whole: it is the boundary, assembled on
427
+ // the host from an allowlist, and the declared secrets are in it.
292
428
  const api = buildApiTools("", input.apis, undefined, { env, missing: [] });
293
429
  const script = buildScriptTools(
294
430
  agentDir,
@@ -310,6 +446,11 @@ try {
310
446
  const search = buildSearchTools(input.search ?? []);
311
447
  const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };
312
448
  const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };
449
+ // A person in the loop, over the proxy this step's lease already names:
450
+ // ask_person when tools: [ask], and the run's inbox after every tool call.
451
+ const channel = httpChannel(env.FOLDRUN_EGRESS);
452
+ const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };
453
+ const inbox = channel ? inboxReader(channel, emit) : undefined;
313
454
 
314
455
  const outcome = await executeStep({
315
456
  agentDir,
@@ -328,15 +469,27 @@ try {
328
469
  ...(search.server ? { foldrun_search: search.server } : {}),
329
470
  ...(history.server ? { foldrun_history: history.server } : {}),
330
471
  ...(desks.server ? { foldrun_desks: desks.server } : {}),
472
+ ...(ask.server ? { foldrun_ask: ask.server } : {}),
331
473
  ...input.mcpServers,
332
474
  },
475
+ ...(inbox ? { inbox } : {}),
476
+ ...(input.subagents?.length ? { subagents: input.subagents } : {}),
333
477
  env,
334
478
  timeoutSec: input.timeoutSec,
335
479
  budgetUsd: input.budgetUsd,
480
+ budgetNote: input.budgetNote,
336
481
  price: input.price,
337
482
  verify: input.verify,
338
- verifyEnv: {},
483
+ verifyEnv: env,
339
484
  output: input.output,
485
+ schema: input.schema,
486
+ maxTurns: input.maxTurns,
487
+ ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),
488
+ // Slim browsing: the platform put this step on the slim image to browse
489
+ // through the account's pod, and named the file the web tool logs its
490
+ // reconnects to. A lost pod stops the step; what it wrote decides what
491
+ // the runner does next (browser-pod.ts).
492
+ ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env) } } : {}),
340
493
  // The container is the boundary; the SDK's own bash sandbox here would
341
494
  // only block declared network use (SSH, curl) for no added safety.
342
495
  sandboxBash: false,
@@ -351,6 +504,7 @@ try {
351
504
  // A consult's spend belongs to the step that asked.
352
505
  const consultCost = consult.drainCost();
353
506
  if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;
507
+ if (consultCost > 0 && typeof outcome.turnsCostUsd === "number") outcome.turnsCostUsd += consultCost;
354
508
  process.stdout.write(JSON.stringify({ e: "done", ...outcome, res: readActuals() }) + "\\n");
355
509
  process.exit(0);
356
510
  } catch (err) {
@@ -369,30 +523,227 @@ try {
369
523
  // and the run flags grant only the three capabilities those two commands
370
524
  // need. By the time any model-directed code executes, the process is uid
371
525
  // 10001 with no capabilities at all.
372
- const DOCKERFILE = `FROM node:22-slim
526
+ const CORE_INSTALL = `WORKDIR /opt/runner
527
+ COPY foldrun-core.tgz driver.mjs entry.sh ./
528
+ RUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\
529
+ && npm cache clean --force >/dev/null 2>&1 \\
530
+ && mkdir -p /workspace /library /opt/runner/job ${RUNTIME_CACHE} ${SHARED_RUNTIMES} \\
531
+ && chown -R agent:agent /workspace /library /opt/runner /home/agent/.foldrun /opt/foldrun-runtimes \\
532
+ && chmod +x /opt/runner/entry.sh
533
+ ENTRYPOINT ["/opt/runner/entry.sh"]
534
+ `;
535
+ /** The uv release copied into the image. Pinned: a floating uv is a floating
536
+ * resolver, and the runtime cache is keyed on the declaration, not on it. */
537
+ export const UV_VERSION = "0.11.25";
538
+ /** How many times a best-effort engine download is tried before the build
539
+ * gives up on it, and the wait before the second and third (15s, then 30s).
540
+ * A GitHub release or Google's apt mirror that blinks for a minute used to
541
+ * cost the engine outright; on x86_64 that now fails the build, so a
542
+ * transient blip would fail a deploy. */
543
+ export const ENGINE_DOWNLOAD_ATTEMPTS = 3;
544
+ /** `retry <engine> <command…>`: one shell function, defined at the top of each
545
+ * RUN that downloads an engine (a RUN is its own shell). Its lines start
546
+ * "runner image:" like every engine line, and go to stderr so a quiet
547
+ * install (>/dev/null) still shows them — the deploy greps them into the
548
+ * step summary (buildFailureSummary). */
549
+ export const RETRY_SH = `retry() { what=$1; shift; n=1; until "$@"; do if [ "$n" -ge ${ENGINE_DOWNLOAD_ATTEMPTS} ]; then echo "runner image: $what - download failed $n times, giving up" >&2; return 1; fi; echo "runner image: $what - download failed (attempt $n of ${ENGINE_DOWNLOAD_ATTEMPTS}), retrying in $((n * 15))s" >&2; sleep $((n * 15)); n=$((n + 1)); done; }`;
550
+ /** The base both targets start from, pinned by digest. The tag alone floats:
551
+ * `node:22-slim` is re-pushed for every Node patch and Debian point release,
552
+ * and will change Debian release when the Node image decides to, so the
553
+ * same runner tag — a hash of this text — could hold different bytes on two
554
+ * rebuilds. With the digest in the text, new base bytes mean a new tag.
555
+ *
556
+ * Debian 13 (trixie), not the 12 that `node:22-slim` still names: its glibc
557
+ * (2.41) runs Lightpanda past 0.3.6, which wants 2.38; Debian 12 has 2.36.
558
+ * Python is 3.13 here (3.11 on 12): runtime.ts checkEntry rebuilds a cached
559
+ * venv whose interpreter moved, once per declaration.
560
+ *
561
+ * Raise it on purpose (foldrun-infra/README.md, "Runner images"): read the
562
+ * current digest of the tag, put it here, build both targets on both arches
563
+ * and make one browse call per engine on the full image:
564
+ * docker buildx imagetools inspect node:22-trixie-slim --format '{{json .Manifest}}' | jq -r .digest
565
+ * The digest names the multi-arch index, so the one pin serves amd64 and
566
+ * arm64 alike. */
567
+ export const NODE_BASE = "node:22-trixie-slim@sha256:b26b04c123d9ff8ab646ceb18b9d75a1173acf64b9a401094b906d27b29338d4";
568
+ // Two images from one file, as build targets.
569
+ //
570
+ // full (the default, the last stage) — everything, browsers included. The
571
+ // per-account browser pod runs it, and so does any step that may
572
+ // browse, because the tool falls back to in-pod Chromium whenever
573
+ // the pod is unavailable or the run is a test.
574
+ // slim — the same base, core and Playwright *client*, no browsers. About
575
+ // a quarter of the size: the browsers were 1.4 GB of a 3.1 GB image
576
+ // that every step pulled and every step started, whether or not it
577
+ // ever opened a page. A step that grants no browsing runs this
578
+ // (run-k8s.ts picks), and its declared packages arrive at run time
579
+ // through the runtime cache — uv is in the base for exactly that.
580
+ //
581
+ // The browsers sit BELOW the core install in `full`, as they always have,
582
+ // so a core change rebuilds one thin layer and not 1.4 GB of Chromium.
583
+ export const DOCKERFILE = `FROM ${NODE_BASE} AS base
373
584
  RUN apt-get update \\
374
585
  && apt-get install -y --no-install-recommends python3 python3-venv ca-certificates bash util-linux tar openssh-client sshpass git curl \\
375
586
  && rm -rf /var/lib/apt/lists/* \\
376
587
  && useradd -m -u 10001 agent
588
+ # uv builds the declared python environments (runtime.ts): a fraction of
589
+ # pip's time, and it can fetch a pinned python the image does not carry.
590
+ COPY --from=ghcr.io/astral-sh/uv:${UV_VERSION} /uv /uvx /usr/local/bin/
591
+ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules
592
+
593
+ FROM base AS slim
594
+ # Fonts, because a step that draws text without a browser still needs them:
595
+ # the full image gets 51 from Playwright's --with-deps, slim had none, so from
596
+ # 2026-09-29 (when non-browsing steps moved to slim) every resvg/sharp diagram
597
+ # rendered its labels as empty boxes — expert-witness-desk's validator failed
598
+ # four passes on a post image that was "three empty boxes". DejaVu covers
599
+ # Latin, Greek and Cyrillic in a few MB; fontconfig is how renderers find it.
600
+ # DejaVu alone still drew emoji, Chinese, Japanese, Korean, Devanagari and
601
+ # Thai as empty boxes (reproduced 2026-10-01 with resvg-js 2.6 and sharp
602
+ # 0.34), so four more, the smallest set that drew all of them — 8 MB:
603
+ # Symbola emoji and symbols, as outlines. NOT Noto Color Emoji (10 MB):
604
+ # resvg draws nothing from its bitmap glyphs, so a resvg
605
+ # diagram kept its empty boxes.
606
+ # WenQuanYi Micro Hei Chinese, Japanese kana and Korean Hangul, one file.
607
+ # Lohit Devanagari, Loma Hindi and Thai.
608
+ # Here, not in base, so the full image's browser layers stay cached.
609
+ RUN apt-get update \\
610
+ && apt-get install -y --no-install-recommends fonts-dejavu-core fontconfig fonts-symbola fonts-wqy-microhei fonts-lohit-deva fonts-tlwg-loma-otf \\
611
+ && rm -rf /var/lib/apt/lists/* \\
612
+ && fc-cache -f >/dev/null
613
+ # The Playwright client only, the same pinned version as the browser pod's
614
+ # server (the wire protocol is version-locked), so a tool that imports it
615
+ # still loads; there is nothing here for it to launch.
616
+ # npm's download cache is dropped in the same layer: it was 190 MB of every
617
+ # image, read by nothing after the build.
618
+ RUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null && npm cache clean --force >/dev/null 2>&1
619
+ ${CORE_INSTALL}
620
+ FROM base AS browsers
377
621
  # Real browsers, because directories and portals increasingly render with
378
- # JavaScript and WebFetch sees only the empty shell. Chromium is the one the
622
+ # JavaScript and a plain fetch sees only the empty shell. Chromium is the one the
379
623
  # browser tool drives by default; Firefox and WebKit (Safari's engine) are
380
624
  # there for an agent asked to see a page the way those render it. Installed
381
625
  # to a fixed path the agent user can read (the default cache would be
382
626
  # root's HOME). The browsers' own sandboxes are disabled at launch time — in
383
627
  # this platform the container/gVisor IS the sandbox, and the two fight.
384
- ENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules
385
- RUN npm install -g playwright@1 >/dev/null \\
628
+ # Pinned exactly, not @1: web browse's numbered elements use ariaSnapshot's
629
+ # "ai" mode (1.59+), and the per-account browser pod runs this same image, so
630
+ # a floating version would move both under a working tool on any rebuild.
631
+ # Raise it on purpose, with the gallery tests and one real call.
632
+ # axe-core beside it is web browse's mode=a11y: one file the tool injects
633
+ # into the page; without it the mode falls back to its own shorter checks.
634
+ # Xvfb by name: the browser pod starts it at boot and web browse starts one
635
+ # per call for headless: false. It used to arrive only as one of Playwright's
636
+ # --with-deps packages, so a Playwright bump that dropped it would have
637
+ # broken every windowed browser without a word. No xauth: nothing here runs
638
+ # xvfb-run, and Xvfb started without -auth takes the local client as it is.
639
+ RUN apt-get update \\
640
+ && apt-get install -y --no-install-recommends xvfb \\
641
+ && rm -rf /var/lib/apt/lists/*
642
+ # Google Chrome and Chrome beta come from Google's apt repository, which
643
+ # Playwright adds; each is tried ENGINE_DOWNLOAD_ATTEMPTS times. Only on
644
+ # x86_64: Google ships no Linux Chrome for arm64, so there it is not tried.
645
+ RUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null \\
386
646
  && playwright install --with-deps chromium firefox webkit >/dev/null \\
387
- && chmod -R a+rX /opt/browser
388
- WORKDIR /opt/runner
389
- COPY foldrun-core.tgz driver.mjs entry.sh ./
390
- RUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\
391
- && mkdir -p /workspace /library /opt/runner/job ${RUNTIME_CACHE} \\
392
- && chown -R agent:agent /workspace /library /opt/runner /home/agent/.foldrun \\
393
- && chmod +x /opt/runner/entry.sh
394
- ENTRYPOINT ["/opt/runner/entry.sh"]
395
- `;
647
+ && ${RETRY_SH} \\
648
+ && if [ "$(uname -m)" = x86_64 ]; then \\
649
+ retry chrome playwright install --with-deps chrome >/dev/null || echo "runner image: chrome not installed - engine: chrome would fall back to chromium" >&2; \\
650
+ retry chrome-beta playwright install --with-deps chrome-beta >/dev/null || echo "runner image: chrome-beta not installed" >&2; \\
651
+ else echo "runner image: no Google Chrome for $(uname -m) - engine: chrome falls back to chromium"; fi \\
652
+ && chmod -R a+rX /opt/browser \\
653
+ && npm cache clean --force >/dev/null 2>&1
654
+ # Lightpanda (engine: lightpanda): a browser that runs the JavaScript and
655
+ # never draws — a fraction of Chromium's memory for reading and filling
656
+ # pages. Its own program, which web browse starts beside a call and drives
657
+ # over the DevTools protocol; shipped unmodified (AGPL-3.0). Pinned by
658
+ # checksum (GitHub's own asset digests, checked again here). 0.4.1 since the
659
+ # move to Debian 13: 0.3.7 on wants glibc 2.38, and Debian 12's 2.36 held it
660
+ # at 0.3.6. An arch without a build leaves the engine out, and the tool says
661
+ # so when asked for it. Each download (with its checksum) is tried
662
+ # ENGINE_DOWNLOAD_ATTEMPTS times before the engine is left out.
663
+ # Raise it on purpose: the new release's two sha256s, then one
664
+ # engine=lightpanda call on each arch.
665
+ RUN arch=$(uname -m) \\
666
+ && case "$arch" in \\
667
+ x86_64) sum=1d40801e72c0bc61b2cbd3f3562bcfc46de7b79e0568f33f686b64f2e587610a ;; \\
668
+ aarch64) sum=664775c7f5ab69cc3189954c7f9345e25c167cb4dace016173e629f9a5e82c42 ;; \\
669
+ *) sum= ;; \\
670
+ esac \\
671
+ && mkdir -p /opt/browser/lightpanda \\
672
+ && ${RETRY_SH} \\
673
+ && fetch() { curl -fsSL -o /opt/browser/lightpanda/lightpanda "https://github.com/lightpanda-io/browser/releases/download/0.4.1/lightpanda-$arch-linux" \\
674
+ && echo "$sum /opt/browser/lightpanda/lightpanda" | sha256sum -c - >/dev/null; } \\
675
+ && if [ -n "$sum" ] && retry lightpanda fetch; then \\
676
+ chmod a+rx /opt/browser/lightpanda/lightpanda && /opt/browser/lightpanda/lightpanda version; \\
677
+ else rm -f /opt/browser/lightpanda/lightpanda; echo "runner image: lightpanda not installed for $arch - engine: lightpanda says so when asked" >&2; fi
678
+ # Obscura (engine: obscura): a headless browser in Rust around V8 that does
679
+ # draw — screenshots, a raster PDF, a screencast — light like Lightpanda.
680
+ # Apache-2.0. The plain build, not -stealth: this platform does not help a
681
+ # page not see automation. Two binaries, obscura and obscura-worker, which
682
+ # must sit side by side. Pinned by checksum; glibc 2.35 is its floor, so
683
+ # Debian 13 (2.41) runs it. As above, a download is tried
684
+ # ENGINE_DOWNLOAD_ATTEMPTS times, then a failed one leaves the engine out.
685
+ RUN arch=$(uname -m) \\
686
+ && case "$arch" in \\
687
+ x86_64) sum=1534d1e6ddaf3d080ec4091eb41d0a4d8cc042a48b607d3c410fc13b482a9eec ;; \\
688
+ aarch64) sum=5ecf980bca3060236a7a86ec7ed83d943e6598ee87caa46d20325d90bc75f979 ;; \\
689
+ *) sum= ;; \\
690
+ esac \\
691
+ && mkdir -p /opt/browser/obscura \\
692
+ && ${RETRY_SH} \\
693
+ && fetch() { curl -fsSL -o /tmp/obscura.tgz "https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.3/obscura-$arch-linux.tar.gz" \\
694
+ && echo "$sum /tmp/obscura.tgz" | sha256sum -c - >/dev/null; } \\
695
+ && if [ -n "$sum" ] && retry obscura fetch \\
696
+ && tar -xzf /tmp/obscura.tgz -C /opt/browser/obscura obscura obscura-worker; then \\
697
+ chmod a+rx /opt/browser/obscura/obscura /opt/browser/obscura/obscura-worker && /opt/browser/obscura/obscura --version; \\
698
+ else rm -rf /opt/browser/obscura/*; echo "runner image: obscura not installed for $arch - engine: obscura says so when asked" >&2; fi \\
699
+ && rm -f /tmp/obscura.tgz
700
+ # What this image actually has, written down: /opt/browser/engines.json, one
701
+ # entry per engine (its path and version, or null when the install above
702
+ # left it out) plus Xvfb. Chromium's version is its own --version (dotted,
703
+ # so FOLDRUN_RUNNER_ENGINES carries it: a pinned user_agent on engine
704
+ # chromium is compared with it, not with Google Chrome's) then the build.
705
+ # web browse reads it for its "built without" answer, and the deploy reads
706
+ # it to report the engines beside the version.
707
+ # On x86_64 a missing engine FAILS the build: every engine ships for that
708
+ # arch, so a gap there is a failed download, and an image that quietly lacks
709
+ # the Chrome or Lightpanda a desk names would fail those runs instead. A
710
+ # failed build leaves the previous image running and says why. Elsewhere
711
+ # (arm64 has no Google Chrome) the gap is recorded and the image ships.
712
+ RUN node -e ' \\
713
+ const fs = require("fs"), cp = require("child_process"), pw = require("playwright"); \\
714
+ const at = (p) => (p && fs.existsSync(p) ? p : null); \\
715
+ const ver = (bin, args) => { try { return cp.execFileSync(bin, args, { timeout: 30000, stdio: ["ignore", "pipe", "ignore"] }).toString().trim().split("\\n")[0] || null; } catch { return null; } }; \\
716
+ const one = (p, v) => (p ? { path: p, version: v } : null); \\
717
+ const pwBuild = (p) => (p ? (p.match(/(chromium|firefox|webkit)-(\\d+)/) || [])[0] || null : null); \\
718
+ const lp = at("/opt/browser/lightpanda/lightpanda"); \\
719
+ const cr = at(pw.chromium.executablePath()); \\
720
+ const ob = at("/opt/browser/obscura/obscura-worker") && at("/opt/browser/obscura/obscura"); \\
721
+ const engines = { \\
722
+ chromium: one(cr, cr && [ver(cr, ["--version"]), pwBuild(cr)].filter(Boolean).join(" ")), \\
723
+ chrome: one(at("/opt/google/chrome/chrome"), ver("/opt/google/chrome/chrome", ["--version"])), \\
724
+ "chrome-beta": one(at("/opt/google/chrome-beta/chrome"), ver("/opt/google/chrome-beta/chrome", ["--version"])), \\
725
+ firefox: one(at(pw.firefox.executablePath()), pwBuild(pw.firefox.executablePath())), \\
726
+ webkit: one(at(pw.webkit.executablePath()), pwBuild(pw.webkit.executablePath())), \\
727
+ lightpanda: one(lp, lp && ver(lp, ["version"])), \\
728
+ obscura: one(ob, ob && ver(ob, ["--version"])), \\
729
+ }; \\
730
+ const xvfb = ver("sh", ["-c", "command -v Xvfb"]); \\
731
+ const arch = cp.execFileSync("uname", ["-m"]).toString().trim(); \\
732
+ const manifest = { arch, playwright: require("playwright/package.json").version, engines, xvfb }; \\
733
+ fs.writeFileSync("/opt/browser/engines.json", JSON.stringify(manifest, null, 2) + "\\n"); \\
734
+ console.log(JSON.stringify(manifest)); \\
735
+ const missing = ["chromium", "chrome", "firefox", "webkit", "lightpanda", "obscura"].filter((e) => !engines[e]).concat(xvfb ? [] : ["Xvfb"]); \\
736
+ if (missing.length && arch === "x86_64") { \\
737
+ console.error("runner image: MISSING ENGINES on x86_64: " + missing.join(", ")); \\
738
+ console.error("runner image: every engine ships for x86_64, so a missing one is a failed download (the retry lines above say which) - failing the build"); \\
739
+ process.exit(1); \\
740
+ } \\
741
+ if (missing.length) console.error("runner image: built without " + missing.join(", ") + " on " + arch); \\
742
+ ' \\
743
+ && chmod a+r /opt/browser/engines.json
744
+
745
+ FROM browsers AS full
746
+ ${CORE_INSTALL}`;
396
747
  const ENTRY = `#!/bin/sh
397
748
  set -e
398
749
  chown -R agent:agent /workspace /library /opt/runner/job
@@ -422,7 +773,7 @@ function corePackageDir() {
422
773
  }
423
774
  throw new Error("could not locate @foldrun/core's package root");
424
775
  }
425
- export function runnerImageTag() {
776
+ export function runnerImageTag(variant = "full") {
426
777
  // The fingerprint hashes the *built code*, not the version — a version
427
778
  // string that nobody bumped would pin every future run to the runner
428
779
  // image of whatever core happened to build first.
@@ -435,17 +786,42 @@ export function runnerImageTag() {
435
786
  hash.update(String(entry)).update(fs.readFileSync(abs));
436
787
  }
437
788
  }
438
- return `foldrun-runner:${hash.digest("hex").slice(0, 12)}`;
789
+ return `foldrun-runner:${hash.digest("hex").slice(0, 12)}${variant === "slim" ? "-slim" : ""}`;
439
790
  }
440
791
  /**
441
792
  * Build the runner image if this exact (core version, driver, dockerfile)
442
793
  * has not been built before. `npm pack` of the installed core, so the image
443
794
  * runs the same bytes the server does.
444
795
  */
445
- export function ensureRunnerImage() {
446
- const tag = process.env.FOLDRUN_RUNNER_IMAGE ?? runnerImageTag();
796
+ /**
797
+ * `platform`: build for another architecture — "linux/arm64" on the amd64
798
+ * box, through QEMU — and name the result with the arch appended so it never
799
+ * shadows the native image in the daemon. Only the publisher asks for this;
800
+ * a step always runs the native image. Needs binfmt registered on the host
801
+ * (tonistiigi/binfmt); the publisher does that itself, every time, because
802
+ * the registration does not survive a reboot.
803
+ */
804
+ /**
805
+ * Which image a step runs in: an explicit FOLDRUN_RUNNER_IMAGE when one is
806
+ * set, otherwise the content-hash tag core builds itself. `explicit` is what
807
+ * ensureRunnerImage checks to skip the build — an empty string is *not* an
808
+ * image name (it is `docker build -t ""` → "repository name must have at
809
+ * least one component"), so it falls through to the content-hash tag exactly
810
+ * as an unset value does. Pure, so the resolution is testable without docker.
811
+ */
812
+ export function runnerImageRef(opts = {}) {
813
+ // Each variant has its own override: FOLDRUN_RUNNER_IMAGE names the full
814
+ // image as it always has, FOLDRUN_RUNNER_SLIM_IMAGE the slim one.
815
+ const set = opts.variant === "slim" ? process.env.FOLDRUN_RUNNER_SLIM_IMAGE : process.env.FOLDRUN_RUNNER_IMAGE;
816
+ if (set)
817
+ return { tag: set, explicit: true };
818
+ const arch = opts.platform ? `-${opts.platform.split("/").pop()}` : "";
819
+ return { tag: runnerImageTag(opts.variant) + arch, explicit: false };
820
+ }
821
+ export function ensureRunnerImage(opts = {}) {
822
+ const { tag, explicit } = runnerImageRef(opts);
447
823
  const log = [];
448
- if (process.env.FOLDRUN_RUNNER_IMAGE)
824
+ if (explicit)
449
825
  return { tag, log };
450
826
  const have = spawnSync(cli(), ["image", "inspect", tag], { stdio: "ignore" });
451
827
  if (have.status === 0)
@@ -463,9 +839,26 @@ export function ensureRunnerImage() {
463
839
  fs.writeFileSync(path.join(build, "entry.sh"), ENTRY);
464
840
  fs.writeFileSync(path.join(build, "Dockerfile"), DOCKERFILE);
465
841
  log.push(`building runner image ${tag} (first run only)`);
466
- const out = spawnSync(cli(), ["build", "-t", tag, build], { encoding: "utf8" });
842
+ const args = [
843
+ "build",
844
+ ...(opts.platform ? ["--platform", opts.platform] : []),
845
+ "--target",
846
+ opts.variant ?? "full",
847
+ "-t",
848
+ tag,
849
+ build,
850
+ ];
851
+ const out = spawnSync(cli(), args, { encoding: "utf8" });
467
852
  if (out.status !== 0) {
468
- throw new Error(`runner image build failed:\n${(out.stderr || out.stdout).slice(-2000)}`);
853
+ const full = `${out.stdout ?? ""}\n${out.stderr ?? ""}`;
854
+ // The whole log beside the summary, for a person on the build machine:
855
+ // the error names the file; the summary is what CI shows.
856
+ const logFile = path.join(os.tmpdir(), "foldrun-runner-build.log");
857
+ try {
858
+ fs.writeFileSync(logFile, full);
859
+ }
860
+ catch { /* the summary still says what failed */ }
861
+ throw new Error(`runner image build failed (full log: ${logFile}):\n${buildFailureSummary(full)}`);
469
862
  }
470
863
  return { tag, log };
471
864
  }
@@ -473,6 +866,39 @@ export function ensureRunnerImage() {
473
866
  fs.rmSync(build, { recursive: true, force: true });
474
867
  }
475
868
  }
869
+ /**
870
+ * What a failed runner build's error says: the lines that name the failure,
871
+ * then the end of the log. Only the last 2000 characters used to survive,
872
+ * and BuildKit ends a failed RUN by echoing the instruction — the engines
873
+ * probe is a long one — so "chrome missing" scrolled out of the very error
874
+ * that existed to say it. Every engine line the DOCKERFILE prints starts
875
+ * "runner image:" (the retries, the giving up, the MISSING ENGINES line);
876
+ * those are kept, from anywhere in the log, with BuildKit's "#12 3.456 "
877
+ * prefix taken off, and so are BuildKit's own ERROR lines (each cut short:
878
+ * they quote the whole instruction). Pure, so it is tested on fixtures.
879
+ */
880
+ export function buildFailureSummary(log, opts = {}) {
881
+ const tail = opts.tail ?? 1500;
882
+ const maxLines = opts.maxLines ?? 40;
883
+ const seen = new Set();
884
+ const key = [];
885
+ for (const raw of log.split("\n")) {
886
+ const line = raw.replace(/^#\d+ \d+(\.\d+)? /, "").trim();
887
+ if (!/^runner image:|^ERROR\b|^error:/i.test(line) && !/did not complete successfully/.test(line))
888
+ continue;
889
+ const cut = line.length > 300 ? line.slice(0, 300) + " …" : line;
890
+ if (seen.has(cut))
891
+ continue;
892
+ seen.add(cut);
893
+ key.push(cut);
894
+ }
895
+ // The ones that say what is missing first: a reader stops at the top.
896
+ key.sort((a, b) => Number(/MISSING ENGINES/.test(b)) - Number(/MISSING ENGINES/.test(a)));
897
+ const head = key.length
898
+ ? key.slice(0, maxLines).map((l) => ` ${l}`).join("\n") + (key.length > maxLines ? `\n … ${key.length - maxLines} more` : "")
899
+ : " (no engine or ERROR line in the log)";
900
+ return `${head}\n--- the last ${tail} characters of the build log ---\n${log.slice(-tail)}`;
901
+ }
476
902
  // ---------- running one step ----------
477
903
  export function parseDriverLine(line) {
478
904
  const trimmed = line.trim();
@@ -488,6 +914,9 @@ export function parseDriverLine(line) {
488
914
  ...(typeof parsed.call === "string" ? { call: parsed.call } : {}),
489
915
  ...(typeof parsed.ms === "number" ? { ms: parsed.ms } : {}),
490
916
  ...(parsed.err === true ? { err: true } : {}),
917
+ ...(isOperatorEvent(parsed.operator) ? { operator: parsed.operator } : {}),
918
+ ...(typeof parsed.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(parsed.subagent) ? { subagent: parsed.subagent } : {}),
919
+ ...(parsed.check === true ? { check: true } : {}),
491
920
  };
492
921
  }
493
922
  if (parsed.e === "done") {
@@ -498,6 +927,8 @@ export function parseDriverLine(line) {
498
927
  conclusion: typeof parsed.conclusion === "string" ? parsed.conclusion : null,
499
928
  ...("data" in parsed ? { data: parsed.data } : {}),
500
929
  costUsd: typeof parsed.costUsd === "number" ? parsed.costUsd : null,
930
+ ...(typeof parsed.turnsCostUsd === "number" ? { turnsCostUsd: parsed.turnsCostUsd } : {}),
931
+ ...(parseBrowserPod(parsed.browserPod) ? { browserPod: parseBrowserPod(parsed.browserPod) } : {}),
501
932
  usage: parsed.usage &&
502
933
  typeof parsed.usage.inputTokens === "number" &&
503
934
  typeof parsed.usage.outputTokens === "number"
@@ -538,6 +969,44 @@ function ensureRunNetwork() {
538
969
  }
539
970
  return RUN_NETWORK;
540
971
  }
972
+ /**
973
+ * What crosses into the container as its environment, and how.
974
+ *
975
+ * A docker --env-file is one `KEY=value` per line and cannot carry a
976
+ * newline. @file secrets are multi-line by nature (a PEM key, a cert), so
977
+ * they are staged as files NOW, into the copied-in workspace, and the env
978
+ * carries the container path — the one place a path is stable across the
979
+ * boundary. A plain secret whose value happens to hold a newline gets the
980
+ * same treatment, and the trace says so: it used to be dropped from the
981
+ * env file without a word, and the step failed later, elsewhere, on a
982
+ * variable that read as unset. Everything else goes through the env file.
983
+ */
984
+ export function stageContainerEnv(env, wsIn, agentRel, emit) {
985
+ const containerEnv = {};
986
+ const stage = (k, content) => {
987
+ const rel = path.join(agentRel, ".secret-files", k.toLowerCase());
988
+ const abs = path.join(wsIn, rel);
989
+ fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o700 });
990
+ fs.writeFileSync(abs, content, { mode: 0o600 });
991
+ return `/workspace/${rel.replaceAll("\\", "/")}`;
992
+ };
993
+ for (const [k, v] of Object.entries(env)) {
994
+ if (typeof v !== "string")
995
+ continue;
996
+ if (isFileValue(v)) {
997
+ containerEnv[k] = stage(k, fileContent(v));
998
+ }
999
+ else if (/[\r\n]/.test(v)) {
1000
+ containerEnv[k] = stage(k, v);
1001
+ emit("info", `${k} holds a line break and cannot cross as an environment variable — ` +
1002
+ `passed as a file instead; $${k} is its path in the sandbox`);
1003
+ }
1004
+ else {
1005
+ containerEnv[k] = v;
1006
+ }
1007
+ }
1008
+ return containerEnv;
1009
+ }
541
1010
  /** The limits a size class reserves. Large is the install's configured
542
1011
  * limits unchanged, so existing installs bill and behave identically;
543
1012
  * small defaults to a quarter-ish slice and is env-tunable. */
@@ -625,9 +1094,15 @@ export async function runStepInContainer(args) {
625
1094
  const wsIn = path.join(staging, "workspace");
626
1095
  fs.cpSync(args.workspaceRoot, wsIn, {
627
1096
  recursive: true,
1097
+ // Times too: a verify that asks "was this written in the last hour?"
1098
+ // is asking about the step, and a copy stamped "now" answers yes for a
1099
+ // file untouched since last week (see mtimeManifest).
1100
+ preserveTimestamps: true,
628
1101
  filter: (src) => !isPlatformPath(path.relative(args.workspaceRoot, src)),
629
1102
  });
630
1103
  const libIn = path.join(staging, "library");
1104
+ if (args.galleryRoot && fs.existsSync(args.galleryRoot))
1105
+ fs.cpSync(args.galleryRoot, libIn, { recursive: true });
631
1106
  if (fs.existsSync(args.libraryRoot))
632
1107
  fs.cpSync(args.libraryRoot, libIn, { recursive: true });
633
1108
  else
@@ -635,28 +1110,7 @@ export async function runStepInContainer(args) {
635
1110
  const jobIn = path.join(staging, "job");
636
1111
  fs.mkdirSync(jobIn);
637
1112
  fs.writeFileSync(path.join(jobIn, "input.json"), JSON.stringify(args.input, null, 2));
638
- // @file secrets are multi-line (a PEM key, a cert), and a docker
639
- // --env-file cannot carry a newline. So they are staged as files *now*,
640
- // into the copied-in workspace, and the env carries the container path —
641
- // the one place a path is stable across the boundary. Everything else
642
- // goes through the env file. (Without this the key silently vanished:
643
- // the old filter dropped any value with a newline, and ssh got an empty
644
- // -i path.)
645
- const containerEnv = {};
646
- for (const [k, v] of Object.entries(args.env)) {
647
- if (typeof v !== "string")
648
- continue;
649
- if (isFileValue(v)) {
650
- const rel = path.join(args.input.agentRel, ".secret-files", k.toLowerCase());
651
- const abs = path.join(wsIn, rel);
652
- fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o700 });
653
- fs.writeFileSync(abs, fileContent(v), { mode: 0o600 });
654
- containerEnv[k] = `/workspace/${rel.replaceAll("\\", "/")}`;
655
- }
656
- else if (!v.includes("\n")) {
657
- containerEnv[k] = v;
658
- }
659
- }
1113
+ const containerEnv = stageContainerEnv(args.env, wsIn, args.input.agentRel, args.emit);
660
1114
  const envFile = path.join(staging, "env");
661
1115
  fs.writeFileSync(envFile, Object.entries(containerEnv).map(([k, v]) => `${k}=${v}`).join("\n"), { mode: 0o600 });
662
1116
  const flags = [
@@ -685,14 +1139,27 @@ export async function runStepInContainer(args) {
685
1139
  // Docker Desktop. The run bridge has inter-container traffic off; the
686
1140
  // host itself stays reachable, which is the one door this needs.
687
1141
  flags.push("--add-host", "host.docker.internal:host-gateway");
688
- // The dependency cache: this tenant's built venvs and npm prefixes,
689
- // mounted where prepareRuntime already looks. Created host-side rather
690
- // than left to the daemon so it belongs to the platform user; the
691
- // entrypoint hands the mount point itself to the agent user.
692
- const cacheDir = runtimeCacheDir(args.tenant);
693
- if (cacheDir) {
694
- fs.mkdirSync(cacheDir, { recursive: true });
695
- flags.push("-v", `${cacheDir}:${RUNTIME_CACHE}`);
1142
+ // The dependency cache: this tenant's built venvs and npm prefixes, in a
1143
+ // per-tenant Docker named volume mounted where prepareRuntime already looks.
1144
+ // Not a host path — the daemon on the far end of the socket cannot resolve
1145
+ // the platform's FOLDRUN_DATA (runtimeCacheMount explains).
1146
+ const cacheMount = runtimeCacheMount(args.tenant);
1147
+ if (cacheMount) {
1148
+ // Create the volume WITH labels first: `docker create -v` auto-creates it
1149
+ // but cannot label it, and an unlabelled volume is unfindable and
1150
+ // unprunable later. Idempotent — a no-op when it already exists. The
1151
+ // segment is the one runtimeCacheMount already validated, so the
1152
+ // assertions below cannot fire when cacheMount is non-null.
1153
+ const seg = safeTenantSegment(args.tenant);
1154
+ const vol = spawnSync(cli(), [
1155
+ "volume", "create",
1156
+ "--label", "foldrun.io/kind=runtime-cache",
1157
+ "--label", `foldrun.io/tenant=${seg}`,
1158
+ cacheMount.source,
1159
+ ], { encoding: "utf8" });
1160
+ if (vol.status !== 0)
1161
+ throw new Error(`docker volume create failed:\n${vol.stderr}`);
1162
+ flags.push("-v", `${cacheMount.source}:${cacheMount.target}`);
696
1163
  }
697
1164
  // The rent clock starts here: staging the workspace is host work, but
698
1165
  // from `create` to `rm` the sandbox is a resource the platform pays for.
@@ -770,7 +1237,7 @@ export async function runStepInContainer(args) {
770
1237
  fs.mkdirSync(wsOut);
771
1238
  const back = spawnSync(cli(), ["cp", `${containerId}:/workspace/.`, wsOut], { encoding: "utf8" });
772
1239
  if (back.status === 0) {
773
- applyContainerChanges(args.workspaceRoot, wsOut, wsIn);
1240
+ applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert);
774
1241
  }
775
1242
  else {
776
1243
  args.emit("error", `copy-out failed — the step's file changes were lost:\n${back.stderr.slice(0, 500)}`);