@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
@@ -25,14 +25,17 @@ import crypto from "node:crypto";
25
25
  import { spawn, spawnSync } from "node:child_process";
26
26
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
27
27
  import { isPlatformPath, type ApiSpec, type Effort } from "./store.ts";
28
+ import { divertedSummary, isDivertedPath } from "./test-mode.ts";
28
29
  import type { EventExtra } from "./step-exec.ts";
29
30
  import { isFileValue, fileContent } from "./secrets.ts";
30
31
  import { safeTenantSegment, type RuntimeSpec } from "./runtime.ts";
31
- import { dataRoot } from "./paths.ts";
32
32
  import type { ScriptSpec } from "./script-tools.ts";
33
33
  import type { ConsultSpec } from "./agent-tools.ts";
34
+ import type { SubagentSpec } from "./subagents.ts";
35
+ import { isOperatorEvent, type OperatorEvent } from "./operator.ts";
34
36
  import type { SearchRoot, RunDigest } from "./context-tools.ts";
35
37
  import type { TranslatorSpec } from "./translator.ts";
38
+ import { parseBrowserPod, type BrowserPodOutcome } from "./browser-pod.ts";
36
39
 
37
40
  /** What crosses the boundary. Everything in here is values — pre-resolved
38
41
  * secrets in headers, assembled prompt, serializable MCP configs. */
@@ -57,14 +60,24 @@ export interface ContainerStepInput {
57
60
  * their agent.md files host-side. The driver rebuilds the consult tools
58
61
  * in here; the callees run toolless, so nothing else need cross. */
59
62
  consults: ConsultSpec[];
63
+ /** `subagents:` — built host-side (runner.ts), plain JSON. */
64
+ subagents?: SubagentSpec[];
60
65
  timeoutSec?: number;
61
66
  /** The step's spend ceiling and the model's per-token price — see
62
67
  * step-exec.ts. Values, so the driver can enforce them in the pod. */
63
68
  budgetUsd?: number | null;
69
+ budgetNote?: string;
64
70
  price?: { input: number; output: number } | null;
65
71
  verify?: string;
66
72
  /** `output: json` — checked inside, where the reply is. */
67
73
  output?: "json";
74
+ /** `schema:` and `max_turns:`, resolved host-side — see ExecOptions. */
75
+ schema?: Record<string, unknown> | boolean;
76
+ maxTurns?: number;
77
+ /** `limits:` resolved host-side, and the SDK-name → foldrun-name map the
78
+ * count reads (limits.ts) — enforced in the pod by the same hook. */
79
+ limits?: Record<string, number>;
80
+ toolOwners?: Record<string, string>;
68
81
  /** tools: [search] — the directories to search, as paths INSIDE the
69
82
  * container (/workspace/…, /library/…). */
70
83
  search?: SearchRoot[];
@@ -72,10 +85,19 @@ export interface ContainerStepInput {
72
85
  history?: RunDigest[];
73
86
  /** tools: [desks] — the account's other workspaces' runs, gathered host-side. */
74
87
  desks?: RunDigest[];
88
+ /** tools: [ask] — ask_person, and how long it may wait for an answer. */
89
+ ask?: { timeoutSec: number };
75
90
  /** A Chat-Completions provider: the driver starts the runtime's translator
76
91
  * on loopback and points the SDK at it. Null or absent: the env's
77
92
  * ANTHROPIC_BASE_URL is spoken to directly. */
78
93
  translator?: TranslatorSpec | null;
94
+ /** A granted tool acts outward (`outward: true`): an executor that could
95
+ * put a browsing step on the slim image keeps this one on the full image,
96
+ * where a browser in the step is always there (browser-pod.ts). */
97
+ outward?: boolean;
98
+ /** "full" — this attempt must have the full image: the re-run after the
99
+ * browser pod was lost. Absent lets the executor choose. */
100
+ image?: "full";
79
101
  }
80
102
 
81
103
  /**
@@ -117,11 +139,21 @@ export interface ContainerStepOutcome {
117
139
  data?: unknown;
118
140
  costUsd: number | null;
119
141
  usage?: { inputTokens: number; outputTokens: number } | null;
142
+ /** Every turn priced at its own model, when sub-agents ran on others —
143
+ * see ExecOutcome.turnsCostUsd. */
144
+ turnsCostUsd?: number;
120
145
  /** What the sandbox actually touched — see the driver's readActuals. */
121
146
  res?: StepActuals | null;
122
147
  /** Set by the isolated executors only — an in-process step rents no
123
148
  * sandbox, so it has no compute seconds to bill. */
124
149
  timing?: StepTiming | null;
150
+ /** Which runner image the sandbox ran, as the executor chose it: `pod`
151
+ * when a browsing step ran on slim and browsed through the account's
152
+ * browser pod. Absent from an executor with one image. */
153
+ image?: { variant: "full" | "slim"; why?: string; pod?: boolean } | null;
154
+ /** The pod's reconnects and, when it was lost, what the step had written —
155
+ * see ExecOutcome.browserPod. */
156
+ browserPod?: BrowserPodOutcome;
125
157
  }
126
158
 
127
159
  /**
@@ -215,10 +247,72 @@ export function hashTree(dir: string): Record<string, string> {
215
247
  return out;
216
248
  }
217
249
 
250
+ /**
251
+ * Every file's mtime under `dir`, relative path to milliseconds — so a copy
252
+ * that cannot carry times can have them put back. `kubectl cp` unpacks with
253
+ * `tar -m`, which stamps every file with the moment it landed; on 2026-09-24
254
+ * gbp-desk's reply-sheet check ("pending-replies.json written in the last
255
+ * hour") passed on a file last written on 2026-09-13, and the poster sent
256
+ * nothing. Every freshness check in every flow was answering "yes".
257
+ */
258
+ export function mtimeManifest(dir: string): Record<string, number> {
259
+ const out: Record<string, number> = {};
260
+ const walk = (d: string) => {
261
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
262
+ const abs = path.join(d, entry.name);
263
+ if (entry.isSymbolicLink()) continue;
264
+ if (entry.isDirectory()) walk(abs);
265
+ else out[path.relative(dir, abs).replaceAll("\\", "/")] = fs.statSync(abs).mtimeMs;
266
+ }
267
+ };
268
+ if (fs.existsSync(dir)) walk(dir);
269
+ return out;
270
+ }
271
+
272
+ /** The in-pod half of mtimeManifest: a node one-liner, run as root before `go`. */
273
+ export const APPLY_MTIMES_JS =
274
+ `const fs=require("fs"),p=require("path");const m=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));` +
275
+ `for(const [r,t] of Object.entries(m)){try{fs.utimesSync(p.join(process.argv[2],r),t/1000,t/1000)}catch{}}`;
276
+
277
+ const sha = (b: Buffer) => crypto.createHash("sha256").update(b).digest("hex");
278
+
279
+ /**
280
+ * Both sides changed a file the step was handed. When each is the handed-in
281
+ * content with lines added on the end — a ledger, history.md, a .jsonl — the
282
+ * answer is the base, then the host's additions, then the step's. Only the
283
+ * base's hash may be known, so the base is found as the longest shared
284
+ * leading run of whole lines whose hash matches. Anything else is null: a
285
+ * real conflict, which the caller must not settle by dropping one side.
286
+ */
287
+ export function mergeAppends(was: Buffer | string | null, host: Buffer, next: Buffer): Buffer | null {
288
+ // Both sides created the same new file: no shared base to append to, and
289
+ // joining two JSON documents is corruption, not a merge.
290
+ if (was === null) return null;
291
+ const h = host.toString("utf8");
292
+ const n = next.toString("utf8");
293
+ let common = 0;
294
+ const max = Math.min(h.length, n.length);
295
+ while (common < max && h.charCodeAt(common) === n.charCodeAt(common)) common++;
296
+ for (let end = common; end >= 0; end--) {
297
+ if (end > 0 && h[end - 1] !== "\n") continue;
298
+ const prefix = h.slice(0, end);
299
+ const matches =
300
+ typeof was === "string" ? sha(Buffer.from(prefix, "utf8")) === was : Buffer.from(prefix, "utf8").equals(was);
301
+ if (!matches) continue;
302
+ const hostAdded = h.slice(end);
303
+ const stepAdded = n.slice(end);
304
+ const joined = hostAdded && !hostAdded.endsWith("\n") ? hostAdded + "\n" : hostAdded;
305
+ return Buffer.from(prefix + joined + stepAdded, "utf8");
306
+ }
307
+ return null;
308
+ }
309
+
218
310
  export function applyContainerChanges(
219
311
  hostWs: string,
220
312
  containerWs: string,
221
313
  baseline?: string | Record<string, string>,
314
+ note?: (message: string) => void,
315
+ divert?: WriteDivert,
222
316
  ): string[] {
223
317
  const applied: string[] = [];
224
318
  const walk = (dir: string) => {
@@ -237,18 +331,60 @@ export function applyContainerChanges(
237
331
  continue;
238
332
  }
239
333
  if (!allowedBack(rel)) continue;
240
- const next = fs.readFileSync(abs);
241
- // Untouched by this step: whatever the host says now is more current
242
- // than what we handed in, including a change made while it ran.
334
+ let next: Buffer = fs.readFileSync(abs);
335
+ // What the step was handed: its bytes (a staging dir) or its hash.
336
+ // null when the file did not exist then; undefined when no baseline.
337
+ let was: Buffer | string | null | undefined;
243
338
  if (typeof baseline === "string") {
244
- const was = path.join(baseline, rel);
245
- if (fs.existsSync(was) && fs.readFileSync(was).equals(next)) continue;
339
+ const p = path.join(baseline, rel);
340
+ was = fs.existsSync(p) ? fs.readFileSync(p) : null;
246
341
  } else if (baseline) {
247
- const was = baseline[rel.replaceAll("\\", "/")];
248
- if (was && was === crypto.createHash("sha256").update(next).digest("hex")) continue;
342
+ was = baseline[rel.replaceAll("\\", "/")] ?? null;
249
343
  }
344
+ const same = (b: Buffer) =>
345
+ was === null || was === undefined ? false : typeof was === "string" ? sha(b) === was : b.equals(was);
346
+ // Untouched by this step: whatever the host says now is more current
347
+ // than what we handed in, including a change made while it ran.
348
+ if (same(next)) continue;
250
349
  const target = path.join(hostWs, rel);
251
- if (fs.existsSync(target) && fs.readFileSync(target).equals(next)) continue;
350
+ const hostNow = fs.existsSync(target) ? fs.readFileSync(target) : null;
351
+ if (hostNow && hostNow.equals(next)) continue;
352
+ // The host moved too since the step was handed its copy — another run
353
+ // or a person wrote it. Writing the step's copy over it erased the
354
+ // other run's ledger rows on 2026-09-14. Merge appends; keep both
355
+ // sides of anything else.
356
+ const hostMoved = was !== undefined && hostNow !== null && !same(hostNow);
357
+ if (hostMoved) {
358
+ const merged = mergeAppends(was ?? null, hostNow, next);
359
+ if (merged) {
360
+ next = merged;
361
+ } else {
362
+ const ext = path.extname(rel);
363
+ const stem = rel.slice(0, rel.length - ext.length);
364
+ const conflictRel = `${stem}.conflict-${Date.now().toString(36)}${ext}`;
365
+ const conflictAbs = path.join(hostWs, conflictRel);
366
+ fs.mkdirSync(path.dirname(conflictAbs), { recursive: true });
367
+ fs.writeFileSync(conflictAbs, next);
368
+ applied.push(conflictRel);
369
+ note?.(
370
+ `write-back conflict on ${rel.replaceAll("\\", "/")}: it changed on the workspace while this step ran, ` +
371
+ `and not only by appending — kept the workspace's version; this step's version is ${conflictRel.replaceAll("\\", "/")}`,
372
+ );
373
+ continue;
374
+ }
375
+ }
376
+ // A test run's state/ and storage/ writes land under the run, not on
377
+ // the workspace: the step ran against the real files and its output
378
+ // is kept where a person can read it, but the next real run starts
379
+ // from state a test never touched. Decided after the merge so the
380
+ // diverted copy is what WOULD have been written, conflicts included.
381
+ if (divert && isDivertedPath(rel)) {
382
+ const aside = path.join(divert.to, rel);
383
+ fs.mkdirSync(path.dirname(aside), { recursive: true });
384
+ fs.writeFileSync(aside, next);
385
+ divert.note(rel.replaceAll("\\", "/"), divertedSummary(rel.replaceAll("\\", "/"), hostNow, next));
386
+ continue;
387
+ }
252
388
  fs.mkdirSync(path.dirname(target), { recursive: true });
253
389
  fs.writeFileSync(target, next);
254
390
  applied.push(rel);
@@ -273,25 +409,55 @@ export function applyContainerChanges(
273
409
  export const RUNTIME_CACHE = "/home/agent/.foldrun/runner/.runtimes";
274
410
 
275
411
  /**
276
- * The per-tenant directory backing that mount, or null when there is none.
412
+ * Where the platform's shared runtime layer is mounted, read-only, in a run
413
+ * pod — and read-write, one entry at a time, in the pod that builds an entry
414
+ * for it (foldrun-platform runtime-warm.ts). A fixed path for the same reason
415
+ * as RUNTIME_CACHE: a venv hard-codes its own location. Laid out as
416
+ * <FOLDRUN_DATA>/shared/.runtimes so the builder's prepareRuntime("shared", …)
417
+ * lands exactly here with FOLDRUN_DATA=/opt/foldrun-runtimes.
418
+ */
419
+ export const SHARED_RUNTIMES = "/opt/foldrun-runtimes/shared/.runtimes";
420
+
421
+ /** Docker's own volume-name charset: a leading alphanumeric, then
422
+ * alphanumerics plus `_ . -`. The mount source is handed straight to the
423
+ * daemon, so it is validated against this rather than assumed. */
424
+ const DOCKER_VOLUME_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
425
+
426
+ /**
427
+ * The per-tenant runtime cache mount — a Docker NAMED volume and the fixed
428
+ * path it mounts at — or null when there is none.
429
+ *
430
+ * A named volume, not a host path. The run's `docker create` is issued to the
431
+ * host daemon over the mounted socket, and that daemon cannot resolve a path
432
+ * under the platform's own FOLDRUN_DATA — which under compose is itself a named
433
+ * volume, or a directory Docker Desktop has not shared — so `-v <hostpath>:…`
434
+ * failed with "mounts denied". A named volume is resolved by the daemon, needs
435
+ * no host sharing, and on first use is seeded from the image's mount point,
436
+ * which the runner image creates owned by the agent user (the DOCKERFILE below,
437
+ * `${RUNTIME_CACHE}`) — so the fresh cache is writable without a host-side chown.
277
438
  *
278
- * Per-tenant, not shared. A shared cache would store one copy of pandas
279
- * instead of one per account, but it would also be mutable state that one
280
- * tenant's step writes and another tenant's step then *executes* — a poisoned
281
- * venv arriving as a trusted local file, which is precisely the path gVisor,
282
- * the capability drop and the egress policy all exist to close. Disk is the
283
- * cheaper thing to spend. If the duplication ever stops being cheap, the fix
284
- * is a read-only mount filled by a trusted builder, not a writable shared one.
439
+ * Per-tenant, not shared. A shared cache would store one copy of pandas instead
440
+ * of one per account, but it would also be mutable state that one tenant's step
441
+ * writes and another tenant's step then *executes* — a poisoned venv arriving
442
+ * as a trusted local file, which is precisely the path gVisor, the capability
443
+ * drop and the egress policy all exist to close. Disk is the cheaper thing to
444
+ * spend. One volume per tenant keeps that boundary; each carries a
445
+ * `foldrun.io/tenant` label so it can be found and pruned (self-hosting/README.md).
285
446
  *
286
- * Deliberately NOT under `<tenant>/.runtimes`, which is the *host* cache: the
287
- * venvs there carry host absolute paths and would be unusable inside a runner.
288
- * Two roots, so a hit is always a real hit.
447
+ * The venvs inside carry the runner's own absolute paths, distinct from the
448
+ * host's `<tenant>/.runtimes` cache — two roots, so a hit is always a real hit.
289
449
  */
290
- export function runtimeCacheDir(tenant: string | undefined): string | null {
450
+ export function runtimeCacheMount(
451
+ tenant: string | undefined,
452
+ ): { source: string; target: string } | null {
291
453
  if (process.env.FOLDRUN_RUNTIME_CACHE === "off") return null;
292
454
  const safe = tenant ? safeTenantSegment(tenant) : null;
293
455
  if (!safe) return null;
294
- return path.join(dataRoot(), safe, ".runtimes-sandbox");
456
+ const source = `foldrun-rt-${safe}`;
457
+ // safeTenantSegment already yields Docker's charset and `foldrun-rt-` is a
458
+ // safe leading token — but the source is what the daemon binds, so prove it.
459
+ if (!DOCKER_VOLUME_NAME.test(source)) return null;
460
+ return { source, target: RUNTIME_CACHE };
295
461
  }
296
462
 
297
463
  // ---------- the runner image ----------
@@ -351,9 +517,11 @@ try {
351
517
  const { buildScriptTools } = await import("@foldrun/core/script-tools");
352
518
  const { buildConsultTools } = await import("@foldrun/core/agent-tools");
353
519
  const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import("@foldrun/core/context-tools");
520
+ const { httpChannel, buildAskTool, inboxReader } = await import("@foldrun/core/operator");
354
521
  const { startTranslator } = await import("@foldrun/core/translator");
355
522
  const { prepareRuntime } = await import("@foldrun/core/runtime");
356
523
  const { materializeFileSecrets } = await import("@foldrun/core/secret-files");
524
+ const { paidWebActions } = await import("@foldrun/core/browser-pod");
357
525
 
358
526
  const agentDir = "/workspace/" + input.agentRel;
359
527
  // HOME is where a bare relative path lands when anything resolves one
@@ -375,12 +543,16 @@ try {
375
543
  : { interpreters: {}, env: {}, log: [], error: null };
376
544
  for (const line of runtime.log) emit("info", "runtime: " + line);
377
545
  if (runtime.error) emit("error", "runtime: " + runtime.error);
546
+ // The step's own environment gets it too, not only the script tools: an
547
+ // agent that runs "python3 x.py" or a shebang script through Bash should
548
+ // meet the packages its runtime declared, not the image's bare python.
549
+ Object.assign(env, runtime.env);
378
550
 
379
551
  // API and script tools, rebuilt in here from their specs. Secrets were
380
552
  // substituted into API headers before the input crossed the boundary, so
381
- // buildApiTools gets an already-resolved environment. (verify: needs no
382
- // separate env either — the container's own environment already carries
383
- // the declared secrets, which is why executeStep gets verifyEnv: {}.)
553
+ // buildApiTools gets an already-resolved environment. verify: gets the
554
+ // container's own environment, whole: it is the boundary, assembled on
555
+ // the host from an allowlist, and the declared secrets are in it.
384
556
  const api = buildApiTools("", input.apis, undefined, { env, missing: [] });
385
557
  const script = buildScriptTools(
386
558
  agentDir,
@@ -402,6 +574,11 @@ try {
402
574
  const search = buildSearchTools(input.search ?? []);
403
575
  const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };
404
576
  const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };
577
+ // A person in the loop, over the proxy this step's lease already names:
578
+ // ask_person when tools: [ask], and the run's inbox after every tool call.
579
+ const channel = httpChannel(env.FOLDRUN_EGRESS);
580
+ const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };
581
+ const inbox = channel ? inboxReader(channel, emit) : undefined;
405
582
 
406
583
  const outcome = await executeStep({
407
584
  agentDir,
@@ -420,15 +597,27 @@ try {
420
597
  ...(search.server ? { foldrun_search: search.server } : {}),
421
598
  ...(history.server ? { foldrun_history: history.server } : {}),
422
599
  ...(desks.server ? { foldrun_desks: desks.server } : {}),
600
+ ...(ask.server ? { foldrun_ask: ask.server } : {}),
423
601
  ...input.mcpServers,
424
602
  },
603
+ ...(inbox ? { inbox } : {}),
604
+ ...(input.subagents?.length ? { subagents: input.subagents } : {}),
425
605
  env,
426
606
  timeoutSec: input.timeoutSec,
427
607
  budgetUsd: input.budgetUsd,
608
+ budgetNote: input.budgetNote,
428
609
  price: input.price,
429
610
  verify: input.verify,
430
- verifyEnv: {},
611
+ verifyEnv: env,
431
612
  output: input.output,
613
+ schema: input.schema,
614
+ maxTurns: input.maxTurns,
615
+ ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),
616
+ // Slim browsing: the platform put this step on the slim image to browse
617
+ // through the account's pod, and named the file the web tool logs its
618
+ // reconnects to. A lost pod stops the step; what it wrote decides what
619
+ // the runner does next (browser-pod.ts).
620
+ ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env) } } : {}),
432
621
  // The container is the boundary; the SDK's own bash sandbox here would
433
622
  // only block declared network use (SSH, curl) for no added safety.
434
623
  sandboxBash: false,
@@ -443,6 +632,7 @@ try {
443
632
  // A consult's spend belongs to the step that asked.
444
633
  const consultCost = consult.drainCost();
445
634
  if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;
635
+ if (consultCost > 0 && typeof outcome.turnsCostUsd === "number") outcome.turnsCostUsd += consultCost;
446
636
  process.stdout.write(JSON.stringify({ e: "done", ...outcome, res: readActuals() }) + "\\n");
447
637
  process.exit(0);
448
638
  } catch (err) {
@@ -462,30 +652,232 @@ try {
462
652
  // and the run flags grant only the three capabilities those two commands
463
653
  // need. By the time any model-directed code executes, the process is uid
464
654
  // 10001 with no capabilities at all.
465
- const DOCKERFILE = `FROM node:22-slim
655
+ const CORE_INSTALL = `WORKDIR /opt/runner
656
+ COPY foldrun-core.tgz driver.mjs entry.sh ./
657
+ RUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\
658
+ && npm cache clean --force >/dev/null 2>&1 \\
659
+ && mkdir -p /workspace /library /opt/runner/job ${RUNTIME_CACHE} ${SHARED_RUNTIMES} \\
660
+ && chown -R agent:agent /workspace /library /opt/runner /home/agent/.foldrun /opt/foldrun-runtimes \\
661
+ && chmod +x /opt/runner/entry.sh
662
+ ENTRYPOINT ["/opt/runner/entry.sh"]
663
+ `;
664
+
665
+ /** The uv release copied into the image. Pinned: a floating uv is a floating
666
+ * resolver, and the runtime cache is keyed on the declaration, not on it. */
667
+ export const UV_VERSION = "0.11.25";
668
+
669
+ /** How many times a best-effort engine download is tried before the build
670
+ * gives up on it, and the wait before the second and third (15s, then 30s).
671
+ * A GitHub release or Google's apt mirror that blinks for a minute used to
672
+ * cost the engine outright; on x86_64 that now fails the build, so a
673
+ * transient blip would fail a deploy. */
674
+ export const ENGINE_DOWNLOAD_ATTEMPTS = 3;
675
+
676
+ /** `retry <engine> <command…>`: one shell function, defined at the top of each
677
+ * RUN that downloads an engine (a RUN is its own shell). Its lines start
678
+ * "runner image:" like every engine line, and go to stderr so a quiet
679
+ * install (>/dev/null) still shows them — the deploy greps them into the
680
+ * step summary (buildFailureSummary). */
681
+ 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; }`;
682
+
683
+ /** The base both targets start from, pinned by digest. The tag alone floats:
684
+ * `node:22-slim` is re-pushed for every Node patch and Debian point release,
685
+ * and will change Debian release when the Node image decides to, so the
686
+ * same runner tag — a hash of this text — could hold different bytes on two
687
+ * rebuilds. With the digest in the text, new base bytes mean a new tag.
688
+ *
689
+ * Debian 13 (trixie), not the 12 that `node:22-slim` still names: its glibc
690
+ * (2.41) runs Lightpanda past 0.3.6, which wants 2.38; Debian 12 has 2.36.
691
+ * Python is 3.13 here (3.11 on 12): runtime.ts checkEntry rebuilds a cached
692
+ * venv whose interpreter moved, once per declaration.
693
+ *
694
+ * Raise it on purpose (foldrun-infra/README.md, "Runner images"): read the
695
+ * current digest of the tag, put it here, build both targets on both arches
696
+ * and make one browse call per engine on the full image:
697
+ * docker buildx imagetools inspect node:22-trixie-slim --format '{{json .Manifest}}' | jq -r .digest
698
+ * The digest names the multi-arch index, so the one pin serves amd64 and
699
+ * arm64 alike. */
700
+ export const NODE_BASE = "node:22-trixie-slim@sha256:b26b04c123d9ff8ab646ceb18b9d75a1173acf64b9a401094b906d27b29338d4";
701
+
702
+ // Two images from one file, as build targets.
703
+ //
704
+ // full (the default, the last stage) — everything, browsers included. The
705
+ // per-account browser pod runs it, and so does any step that may
706
+ // browse, because the tool falls back to in-pod Chromium whenever
707
+ // the pod is unavailable or the run is a test.
708
+ // slim — the same base, core and Playwright *client*, no browsers. About
709
+ // a quarter of the size: the browsers were 1.4 GB of a 3.1 GB image
710
+ // that every step pulled and every step started, whether or not it
711
+ // ever opened a page. A step that grants no browsing runs this
712
+ // (run-k8s.ts picks), and its declared packages arrive at run time
713
+ // through the runtime cache — uv is in the base for exactly that.
714
+ //
715
+ // The browsers sit BELOW the core install in `full`, as they always have,
716
+ // so a core change rebuilds one thin layer and not 1.4 GB of Chromium.
717
+ export const DOCKERFILE = `FROM ${NODE_BASE} AS base
466
718
  RUN apt-get update \\
467
719
  && apt-get install -y --no-install-recommends python3 python3-venv ca-certificates bash util-linux tar openssh-client sshpass git curl \\
468
720
  && rm -rf /var/lib/apt/lists/* \\
469
721
  && useradd -m -u 10001 agent
722
+ # uv builds the declared python environments (runtime.ts): a fraction of
723
+ # pip's time, and it can fetch a pinned python the image does not carry.
724
+ COPY --from=ghcr.io/astral-sh/uv:${UV_VERSION} /uv /uvx /usr/local/bin/
725
+ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules
726
+
727
+ FROM base AS slim
728
+ # Fonts, because a step that draws text without a browser still needs them:
729
+ # the full image gets 51 from Playwright's --with-deps, slim had none, so from
730
+ # 2026-09-29 (when non-browsing steps moved to slim) every resvg/sharp diagram
731
+ # rendered its labels as empty boxes — expert-witness-desk's validator failed
732
+ # four passes on a post image that was "three empty boxes". DejaVu covers
733
+ # Latin, Greek and Cyrillic in a few MB; fontconfig is how renderers find it.
734
+ # DejaVu alone still drew emoji, Chinese, Japanese, Korean, Devanagari and
735
+ # Thai as empty boxes (reproduced 2026-10-01 with resvg-js 2.6 and sharp
736
+ # 0.34), so four more, the smallest set that drew all of them — 8 MB:
737
+ # Symbola emoji and symbols, as outlines. NOT Noto Color Emoji (10 MB):
738
+ # resvg draws nothing from its bitmap glyphs, so a resvg
739
+ # diagram kept its empty boxes.
740
+ # WenQuanYi Micro Hei Chinese, Japanese kana and Korean Hangul, one file.
741
+ # Lohit Devanagari, Loma Hindi and Thai.
742
+ # Here, not in base, so the full image's browser layers stay cached.
743
+ RUN apt-get update \\
744
+ && apt-get install -y --no-install-recommends fonts-dejavu-core fontconfig fonts-symbola fonts-wqy-microhei fonts-lohit-deva fonts-tlwg-loma-otf \\
745
+ && rm -rf /var/lib/apt/lists/* \\
746
+ && fc-cache -f >/dev/null
747
+ # The Playwright client only, the same pinned version as the browser pod's
748
+ # server (the wire protocol is version-locked), so a tool that imports it
749
+ # still loads; there is nothing here for it to launch.
750
+ # npm's download cache is dropped in the same layer: it was 190 MB of every
751
+ # image, read by nothing after the build.
752
+ RUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null && npm cache clean --force >/dev/null 2>&1
753
+ ${CORE_INSTALL}
754
+ FROM base AS browsers
470
755
  # Real browsers, because directories and portals increasingly render with
471
- # JavaScript and WebFetch sees only the empty shell. Chromium is the one the
756
+ # JavaScript and a plain fetch sees only the empty shell. Chromium is the one the
472
757
  # browser tool drives by default; Firefox and WebKit (Safari's engine) are
473
758
  # there for an agent asked to see a page the way those render it. Installed
474
759
  # to a fixed path the agent user can read (the default cache would be
475
760
  # root's HOME). The browsers' own sandboxes are disabled at launch time — in
476
761
  # this platform the container/gVisor IS the sandbox, and the two fight.
477
- ENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules
478
- RUN npm install -g playwright@1 >/dev/null \\
762
+ # Pinned exactly, not @1: web browse's numbered elements use ariaSnapshot's
763
+ # "ai" mode (1.59+), and the per-account browser pod runs this same image, so
764
+ # a floating version would move both under a working tool on any rebuild.
765
+ # Raise it on purpose, with the gallery tests and one real call.
766
+ # axe-core beside it is web browse's mode=a11y: one file the tool injects
767
+ # into the page; without it the mode falls back to its own shorter checks.
768
+ # Xvfb by name: the browser pod starts it at boot and web browse starts one
769
+ # per call for headless: false. It used to arrive only as one of Playwright's
770
+ # --with-deps packages, so a Playwright bump that dropped it would have
771
+ # broken every windowed browser without a word. No xauth: nothing here runs
772
+ # xvfb-run, and Xvfb started without -auth takes the local client as it is.
773
+ RUN apt-get update \\
774
+ && apt-get install -y --no-install-recommends xvfb \\
775
+ && rm -rf /var/lib/apt/lists/*
776
+ # Google Chrome and Chrome beta come from Google's apt repository, which
777
+ # Playwright adds; each is tried ENGINE_DOWNLOAD_ATTEMPTS times. Only on
778
+ # x86_64: Google ships no Linux Chrome for arm64, so there it is not tried.
779
+ RUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null \\
479
780
  && playwright install --with-deps chromium firefox webkit >/dev/null \\
480
- && chmod -R a+rX /opt/browser
481
- WORKDIR /opt/runner
482
- COPY foldrun-core.tgz driver.mjs entry.sh ./
483
- RUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\
484
- && mkdir -p /workspace /library /opt/runner/job ${RUNTIME_CACHE} \\
485
- && chown -R agent:agent /workspace /library /opt/runner /home/agent/.foldrun \\
486
- && chmod +x /opt/runner/entry.sh
487
- ENTRYPOINT ["/opt/runner/entry.sh"]
488
- `;
781
+ && ${RETRY_SH} \\
782
+ && if [ "$(uname -m)" = x86_64 ]; then \\
783
+ retry chrome playwright install --with-deps chrome >/dev/null || echo "runner image: chrome not installed - engine: chrome would fall back to chromium" >&2; \\
784
+ retry chrome-beta playwright install --with-deps chrome-beta >/dev/null || echo "runner image: chrome-beta not installed" >&2; \\
785
+ else echo "runner image: no Google Chrome for $(uname -m) - engine: chrome falls back to chromium"; fi \\
786
+ && chmod -R a+rX /opt/browser \\
787
+ && npm cache clean --force >/dev/null 2>&1
788
+ # Lightpanda (engine: lightpanda): a browser that runs the JavaScript and
789
+ # never draws — a fraction of Chromium's memory for reading and filling
790
+ # pages. Its own program, which web browse starts beside a call and drives
791
+ # over the DevTools protocol; shipped unmodified (AGPL-3.0). Pinned by
792
+ # checksum (GitHub's own asset digests, checked again here). 0.4.1 since the
793
+ # move to Debian 13: 0.3.7 on wants glibc 2.38, and Debian 12's 2.36 held it
794
+ # at 0.3.6. An arch without a build leaves the engine out, and the tool says
795
+ # so when asked for it. Each download (with its checksum) is tried
796
+ # ENGINE_DOWNLOAD_ATTEMPTS times before the engine is left out.
797
+ # Raise it on purpose: the new release's two sha256s, then one
798
+ # engine=lightpanda call on each arch.
799
+ RUN arch=$(uname -m) \\
800
+ && case "$arch" in \\
801
+ x86_64) sum=1d40801e72c0bc61b2cbd3f3562bcfc46de7b79e0568f33f686b64f2e587610a ;; \\
802
+ aarch64) sum=664775c7f5ab69cc3189954c7f9345e25c167cb4dace016173e629f9a5e82c42 ;; \\
803
+ *) sum= ;; \\
804
+ esac \\
805
+ && mkdir -p /opt/browser/lightpanda \\
806
+ && ${RETRY_SH} \\
807
+ && fetch() { curl -fsSL -o /opt/browser/lightpanda/lightpanda "https://github.com/lightpanda-io/browser/releases/download/0.4.1/lightpanda-$arch-linux" \\
808
+ && echo "$sum /opt/browser/lightpanda/lightpanda" | sha256sum -c - >/dev/null; } \\
809
+ && if [ -n "$sum" ] && retry lightpanda fetch; then \\
810
+ chmod a+rx /opt/browser/lightpanda/lightpanda && /opt/browser/lightpanda/lightpanda version; \\
811
+ else rm -f /opt/browser/lightpanda/lightpanda; echo "runner image: lightpanda not installed for $arch - engine: lightpanda says so when asked" >&2; fi
812
+ # Obscura (engine: obscura): a headless browser in Rust around V8 that does
813
+ # draw — screenshots, a raster PDF, a screencast — light like Lightpanda.
814
+ # Apache-2.0. The plain build, not -stealth: this platform does not help a
815
+ # page not see automation. Two binaries, obscura and obscura-worker, which
816
+ # must sit side by side. Pinned by checksum; glibc 2.35 is its floor, so
817
+ # Debian 13 (2.41) runs it. As above, a download is tried
818
+ # ENGINE_DOWNLOAD_ATTEMPTS times, then a failed one leaves the engine out.
819
+ RUN arch=$(uname -m) \\
820
+ && case "$arch" in \\
821
+ x86_64) sum=1534d1e6ddaf3d080ec4091eb41d0a4d8cc042a48b607d3c410fc13b482a9eec ;; \\
822
+ aarch64) sum=5ecf980bca3060236a7a86ec7ed83d943e6598ee87caa46d20325d90bc75f979 ;; \\
823
+ *) sum= ;; \\
824
+ esac \\
825
+ && mkdir -p /opt/browser/obscura \\
826
+ && ${RETRY_SH} \\
827
+ && fetch() { curl -fsSL -o /tmp/obscura.tgz "https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.3/obscura-$arch-linux.tar.gz" \\
828
+ && echo "$sum /tmp/obscura.tgz" | sha256sum -c - >/dev/null; } \\
829
+ && if [ -n "$sum" ] && retry obscura fetch \\
830
+ && tar -xzf /tmp/obscura.tgz -C /opt/browser/obscura obscura obscura-worker; then \\
831
+ chmod a+rx /opt/browser/obscura/obscura /opt/browser/obscura/obscura-worker && /opt/browser/obscura/obscura --version; \\
832
+ else rm -rf /opt/browser/obscura/*; echo "runner image: obscura not installed for $arch - engine: obscura says so when asked" >&2; fi \\
833
+ && rm -f /tmp/obscura.tgz
834
+ # What this image actually has, written down: /opt/browser/engines.json, one
835
+ # entry per engine (its path and version, or null when the install above
836
+ # left it out) plus Xvfb. Chromium's version is its own --version (dotted,
837
+ # so FOLDRUN_RUNNER_ENGINES carries it: a pinned user_agent on engine
838
+ # chromium is compared with it, not with Google Chrome's) then the build.
839
+ # web browse reads it for its "built without" answer, and the deploy reads
840
+ # it to report the engines beside the version.
841
+ # On x86_64 a missing engine FAILS the build: every engine ships for that
842
+ # arch, so a gap there is a failed download, and an image that quietly lacks
843
+ # the Chrome or Lightpanda a desk names would fail those runs instead. A
844
+ # failed build leaves the previous image running and says why. Elsewhere
845
+ # (arm64 has no Google Chrome) the gap is recorded and the image ships.
846
+ RUN node -e ' \\
847
+ const fs = require("fs"), cp = require("child_process"), pw = require("playwright"); \\
848
+ const at = (p) => (p && fs.existsSync(p) ? p : null); \\
849
+ 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; } }; \\
850
+ const one = (p, v) => (p ? { path: p, version: v } : null); \\
851
+ const pwBuild = (p) => (p ? (p.match(/(chromium|firefox|webkit)-(\\d+)/) || [])[0] || null : null); \\
852
+ const lp = at("/opt/browser/lightpanda/lightpanda"); \\
853
+ const cr = at(pw.chromium.executablePath()); \\
854
+ const ob = at("/opt/browser/obscura/obscura-worker") && at("/opt/browser/obscura/obscura"); \\
855
+ const engines = { \\
856
+ chromium: one(cr, cr && [ver(cr, ["--version"]), pwBuild(cr)].filter(Boolean).join(" ")), \\
857
+ chrome: one(at("/opt/google/chrome/chrome"), ver("/opt/google/chrome/chrome", ["--version"])), \\
858
+ "chrome-beta": one(at("/opt/google/chrome-beta/chrome"), ver("/opt/google/chrome-beta/chrome", ["--version"])), \\
859
+ firefox: one(at(pw.firefox.executablePath()), pwBuild(pw.firefox.executablePath())), \\
860
+ webkit: one(at(pw.webkit.executablePath()), pwBuild(pw.webkit.executablePath())), \\
861
+ lightpanda: one(lp, lp && ver(lp, ["version"])), \\
862
+ obscura: one(ob, ob && ver(ob, ["--version"])), \\
863
+ }; \\
864
+ const xvfb = ver("sh", ["-c", "command -v Xvfb"]); \\
865
+ const arch = cp.execFileSync("uname", ["-m"]).toString().trim(); \\
866
+ const manifest = { arch, playwright: require("playwright/package.json").version, engines, xvfb }; \\
867
+ fs.writeFileSync("/opt/browser/engines.json", JSON.stringify(manifest, null, 2) + "\\n"); \\
868
+ console.log(JSON.stringify(manifest)); \\
869
+ const missing = ["chromium", "chrome", "firefox", "webkit", "lightpanda", "obscura"].filter((e) => !engines[e]).concat(xvfb ? [] : ["Xvfb"]); \\
870
+ if (missing.length && arch === "x86_64") { \\
871
+ console.error("runner image: MISSING ENGINES on x86_64: " + missing.join(", ")); \\
872
+ 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"); \\
873
+ process.exit(1); \\
874
+ } \\
875
+ if (missing.length) console.error("runner image: built without " + missing.join(", ") + " on " + arch); \\
876
+ ' \\
877
+ && chmod a+r /opt/browser/engines.json
878
+
879
+ FROM browsers AS full
880
+ ${CORE_INSTALL}`;
489
881
 
490
882
  const ENTRY = `#!/bin/sh
491
883
  set -e
@@ -516,7 +908,10 @@ function corePackageDir(): string {
516
908
  throw new Error("could not locate @foldrun/core's package root");
517
909
  }
518
910
 
519
- export function runnerImageTag(): string {
911
+ /** Which build target of the runner image (see DOCKERFILE). */
912
+ export type RunnerVariant = "full" | "slim";
913
+
914
+ export function runnerImageTag(variant: RunnerVariant = "full"): string {
520
915
  // The fingerprint hashes the *built code*, not the version — a version
521
916
  // string that nobody bumped would pin every future run to the runner
522
917
  // image of whatever core happened to build first.
@@ -528,7 +923,7 @@ export function runnerImageTag(): string {
528
923
  if (fs.statSync(abs).isFile()) hash.update(String(entry)).update(fs.readFileSync(abs));
529
924
  }
530
925
  }
531
- return `foldrun-runner:${hash.digest("hex").slice(0, 12)}`;
926
+ return `foldrun-runner:${hash.digest("hex").slice(0, 12)}${variant === "slim" ? "-slim" : ""}`;
532
927
  }
533
928
 
534
929
  /**
@@ -536,10 +931,39 @@ export function runnerImageTag(): string {
536
931
  * has not been built before. `npm pack` of the installed core, so the image
537
932
  * runs the same bytes the server does.
538
933
  */
539
- export function ensureRunnerImage(): { tag: string; log: string[] } {
540
- const tag = process.env.FOLDRUN_RUNNER_IMAGE ?? runnerImageTag();
934
+ /**
935
+ * `platform`: build for another architecture — "linux/arm64" on the amd64
936
+ * box, through QEMU — and name the result with the arch appended so it never
937
+ * shadows the native image in the daemon. Only the publisher asks for this;
938
+ * a step always runs the native image. Needs binfmt registered on the host
939
+ * (tonistiigi/binfmt); the publisher does that itself, every time, because
940
+ * the registration does not survive a reboot.
941
+ */
942
+ /**
943
+ * Which image a step runs in: an explicit FOLDRUN_RUNNER_IMAGE when one is
944
+ * set, otherwise the content-hash tag core builds itself. `explicit` is what
945
+ * ensureRunnerImage checks to skip the build — an empty string is *not* an
946
+ * image name (it is `docker build -t ""` → "repository name must have at
947
+ * least one component"), so it falls through to the content-hash tag exactly
948
+ * as an unset value does. Pure, so the resolution is testable without docker.
949
+ */
950
+ export function runnerImageRef(
951
+ opts: { platform?: string; variant?: RunnerVariant } = {},
952
+ ): { tag: string; explicit: boolean } {
953
+ // Each variant has its own override: FOLDRUN_RUNNER_IMAGE names the full
954
+ // image as it always has, FOLDRUN_RUNNER_SLIM_IMAGE the slim one.
955
+ const set = opts.variant === "slim" ? process.env.FOLDRUN_RUNNER_SLIM_IMAGE : process.env.FOLDRUN_RUNNER_IMAGE;
956
+ if (set) return { tag: set, explicit: true };
957
+ const arch = opts.platform ? `-${opts.platform.split("/").pop()}` : "";
958
+ return { tag: runnerImageTag(opts.variant) + arch, explicit: false };
959
+ }
960
+
961
+ export function ensureRunnerImage(
962
+ opts: { platform?: string; variant?: RunnerVariant } = {},
963
+ ): { tag: string; log: string[] } {
964
+ const { tag, explicit } = runnerImageRef(opts);
541
965
  const log: string[] = [];
542
- if (process.env.FOLDRUN_RUNNER_IMAGE) return { tag, log };
966
+ if (explicit) return { tag, log };
543
967
 
544
968
  const have = spawnSync(cli(), ["image", "inspect", tag], { stdio: "ignore" });
545
969
  if (have.status === 0) return { tag, log };
@@ -556,9 +980,23 @@ export function ensureRunnerImage(): { tag: string; log: string[] } {
556
980
  fs.writeFileSync(path.join(build, "entry.sh"), ENTRY);
557
981
  fs.writeFileSync(path.join(build, "Dockerfile"), DOCKERFILE);
558
982
  log.push(`building runner image ${tag} (first run only)`);
559
- const out = spawnSync(cli(), ["build", "-t", tag, build], { encoding: "utf8" });
983
+ const args = [
984
+ "build",
985
+ ...(opts.platform ? ["--platform", opts.platform] : []),
986
+ "--target",
987
+ opts.variant ?? "full",
988
+ "-t",
989
+ tag,
990
+ build,
991
+ ];
992
+ const out = spawnSync(cli(), args, { encoding: "utf8" });
560
993
  if (out.status !== 0) {
561
- throw new Error(`runner image build failed:\n${(out.stderr || out.stdout).slice(-2000)}`);
994
+ const full = `${out.stdout ?? ""}\n${out.stderr ?? ""}`;
995
+ // The whole log beside the summary, for a person on the build machine:
996
+ // the error names the file; the summary is what CI shows.
997
+ const logFile = path.join(os.tmpdir(), "foldrun-runner-build.log");
998
+ try { fs.writeFileSync(logFile, full); } catch { /* the summary still says what failed */ }
999
+ throw new Error(`runner image build failed (full log: ${logFile}):\n${buildFailureSummary(full)}`);
562
1000
  }
563
1001
  return { tag, log };
564
1002
  } finally {
@@ -566,11 +1004,43 @@ export function ensureRunnerImage(): { tag: string; log: string[] } {
566
1004
  }
567
1005
  }
568
1006
 
1007
+ /**
1008
+ * What a failed runner build's error says: the lines that name the failure,
1009
+ * then the end of the log. Only the last 2000 characters used to survive,
1010
+ * and BuildKit ends a failed RUN by echoing the instruction — the engines
1011
+ * probe is a long one — so "chrome missing" scrolled out of the very error
1012
+ * that existed to say it. Every engine line the DOCKERFILE prints starts
1013
+ * "runner image:" (the retries, the giving up, the MISSING ENGINES line);
1014
+ * those are kept, from anywhere in the log, with BuildKit's "#12 3.456 "
1015
+ * prefix taken off, and so are BuildKit's own ERROR lines (each cut short:
1016
+ * they quote the whole instruction). Pure, so it is tested on fixtures.
1017
+ */
1018
+ export function buildFailureSummary(log: string, opts: { tail?: number; maxLines?: number } = {}): string {
1019
+ const tail = opts.tail ?? 1500;
1020
+ const maxLines = opts.maxLines ?? 40;
1021
+ const seen = new Set<string>();
1022
+ const key: string[] = [];
1023
+ for (const raw of log.split("\n")) {
1024
+ const line = raw.replace(/^#\d+ \d+(\.\d+)? /, "").trim();
1025
+ if (!/^runner image:|^ERROR\b|^error:/i.test(line) && !/did not complete successfully/.test(line)) continue;
1026
+ const cut = line.length > 300 ? line.slice(0, 300) + " …" : line;
1027
+ if (seen.has(cut)) continue;
1028
+ seen.add(cut);
1029
+ key.push(cut);
1030
+ }
1031
+ // The ones that say what is missing first: a reader stops at the top.
1032
+ key.sort((a, b) => Number(/MISSING ENGINES/.test(b)) - Number(/MISSING ENGINES/.test(a)));
1033
+ const head = key.length
1034
+ ? key.slice(0, maxLines).map((l) => ` ${l}`).join("\n") + (key.length > maxLines ? `\n … ${key.length - maxLines} more` : "")
1035
+ : " (no engine or ERROR line in the log)";
1036
+ return `${head}\n--- the last ${tail} characters of the build log ---\n${log.slice(-tail)}`;
1037
+ }
1038
+
569
1039
  // ---------- running one step ----------
570
1040
 
571
1041
  export function parseDriverLine(
572
1042
  line: string,
573
- ): { e: "event"; type: "text" | "tool" | "info" | "error"; text: string; call?: string; ms?: number; err?: boolean } | { e: "done" } & ContainerStepOutcome | null {
1043
+ ): { e: "event"; type: "text" | "tool" | "info" | "error"; text: string; call?: string; ms?: number; err?: boolean; operator?: OperatorEvent; subagent?: string; check?: boolean } | { e: "done" } & ContainerStepOutcome | null {
574
1044
  const trimmed = line.trim();
575
1045
  if (!trimmed.startsWith("{")) return null;
576
1046
  try {
@@ -583,6 +1053,9 @@ export function parseDriverLine(
583
1053
  ...(typeof parsed.call === "string" ? { call: parsed.call } : {}),
584
1054
  ...(typeof parsed.ms === "number" ? { ms: parsed.ms } : {}),
585
1055
  ...(parsed.err === true ? { err: true } : {}),
1056
+ ...(isOperatorEvent(parsed.operator) ? { operator: parsed.operator } : {}),
1057
+ ...(typeof parsed.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(parsed.subagent) ? { subagent: parsed.subagent } : {}),
1058
+ ...(parsed.check === true ? { check: true } : {}),
586
1059
  };
587
1060
  }
588
1061
  if (parsed.e === "done") {
@@ -593,6 +1066,8 @@ export function parseDriverLine(
593
1066
  conclusion: typeof parsed.conclusion === "string" ? parsed.conclusion : null,
594
1067
  ...("data" in parsed ? { data: parsed.data } : {}),
595
1068
  costUsd: typeof parsed.costUsd === "number" ? parsed.costUsd : null,
1069
+ ...(typeof parsed.turnsCostUsd === "number" ? { turnsCostUsd: parsed.turnsCostUsd } : {}),
1070
+ ...(parseBrowserPod(parsed.browserPod) ? { browserPod: parseBrowserPod(parsed.browserPod)! } : {}),
596
1071
  usage:
597
1072
  parsed.usage &&
598
1073
  typeof parsed.usage.inputTokens === "number" &&
@@ -643,6 +1118,8 @@ function ensureRunNetwork(): string {
643
1118
  export interface RunInContainerArgs {
644
1119
  workspaceRoot: string;
645
1120
  libraryRoot: string;
1121
+ /** The platform's gallery, staged under /library BEFORE the account library so the account's copies win. */
1122
+ galleryRoot?: string;
646
1123
  /** The account this step belongs to — the key for its dependency cache.
647
1124
  * Optional so an embedder that has no tenancy simply gets no cache. */
648
1125
  tenant?: string;
@@ -666,6 +1143,66 @@ export interface RunInContainerArgs {
666
1143
  * (consumed 0), and before each output line it applies. The runner
667
1144
  * records these on the step, so the next driver knows where to attach. */
668
1145
  checkpoint?: (ref: string, consumed: number) => void;
1146
+ /** A test run: where state/ and storage/ changes go instead of the
1147
+ * workspace, and who to tell about each. See test-mode.ts. */
1148
+ divert?: WriteDivert;
1149
+ /** The run is a test run. An isolated executor labels the sandbox so the
1150
+ * cluster can cut its network down to the egress proxy: a script tool
1151
+ * calls the network directly, not through the proxy where the test-mode
1152
+ * policy lives, so without this a test run's script could still send. */
1153
+ test?: boolean;
1154
+ }
1155
+
1156
+ /** What a test run does with a write it must not apply. */
1157
+ export interface WriteDivert {
1158
+ /** The directory the diverted files are written under, by their
1159
+ * workspace-relative path: runs/<id>/test-writes/. */
1160
+ to: string;
1161
+ note: (rel: string, summary: string) => void;
1162
+ }
1163
+
1164
+ /**
1165
+ * What crosses into the container as its environment, and how.
1166
+ *
1167
+ * A docker --env-file is one `KEY=value` per line and cannot carry a
1168
+ * newline. @file secrets are multi-line by nature (a PEM key, a cert), so
1169
+ * they are staged as files NOW, into the copied-in workspace, and the env
1170
+ * carries the container path — the one place a path is stable across the
1171
+ * boundary. A plain secret whose value happens to hold a newline gets the
1172
+ * same treatment, and the trace says so: it used to be dropped from the
1173
+ * env file without a word, and the step failed later, elsewhere, on a
1174
+ * variable that read as unset. Everything else goes through the env file.
1175
+ */
1176
+ export function stageContainerEnv(
1177
+ env: Record<string, string>,
1178
+ wsIn: string,
1179
+ agentRel: string,
1180
+ emit: (type: "info" | "error", text: string) => void,
1181
+ ): Record<string, string> {
1182
+ const containerEnv: Record<string, string> = {};
1183
+ const stage = (k: string, content: string) => {
1184
+ const rel = path.join(agentRel, ".secret-files", k.toLowerCase());
1185
+ const abs = path.join(wsIn, rel);
1186
+ fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o700 });
1187
+ fs.writeFileSync(abs, content, { mode: 0o600 });
1188
+ return `/workspace/${rel.replaceAll("\\", "/")}`;
1189
+ };
1190
+ for (const [k, v] of Object.entries(env)) {
1191
+ if (typeof v !== "string") continue;
1192
+ if (isFileValue(v)) {
1193
+ containerEnv[k] = stage(k, fileContent(v));
1194
+ } else if (/[\r\n]/.test(v)) {
1195
+ containerEnv[k] = stage(k, v);
1196
+ emit(
1197
+ "info",
1198
+ `${k} holds a line break and cannot cross as an environment variable — ` +
1199
+ `passed as a file instead; $${k} is its path in the sandbox`,
1200
+ );
1201
+ } else {
1202
+ containerEnv[k] = v;
1203
+ }
1204
+ }
1205
+ return containerEnv;
669
1206
  }
670
1207
 
671
1208
  /** The limits a size class reserves. Large is the install's configured
@@ -756,9 +1293,14 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
756
1293
  const wsIn = path.join(staging, "workspace");
757
1294
  fs.cpSync(args.workspaceRoot, wsIn, {
758
1295
  recursive: true,
1296
+ // Times too: a verify that asks "was this written in the last hour?"
1297
+ // is asking about the step, and a copy stamped "now" answers yes for a
1298
+ // file untouched since last week (see mtimeManifest).
1299
+ preserveTimestamps: true,
759
1300
  filter: (src) => !isPlatformPath(path.relative(args.workspaceRoot, src)),
760
1301
  });
761
1302
  const libIn = path.join(staging, "library");
1303
+ if (args.galleryRoot && fs.existsSync(args.galleryRoot)) fs.cpSync(args.galleryRoot, libIn, { recursive: true });
762
1304
  if (fs.existsSync(args.libraryRoot)) fs.cpSync(args.libraryRoot, libIn, { recursive: true });
763
1305
  else fs.mkdirSync(libIn, { recursive: true });
764
1306
 
@@ -766,26 +1308,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
766
1308
  fs.mkdirSync(jobIn);
767
1309
  fs.writeFileSync(path.join(jobIn, "input.json"), JSON.stringify(args.input, null, 2));
768
1310
 
769
- // @file secrets are multi-line (a PEM key, a cert), and a docker
770
- // --env-file cannot carry a newline. So they are staged as files *now*,
771
- // into the copied-in workspace, and the env carries the container path —
772
- // the one place a path is stable across the boundary. Everything else
773
- // goes through the env file. (Without this the key silently vanished:
774
- // the old filter dropped any value with a newline, and ssh got an empty
775
- // -i path.)
776
- const containerEnv: Record<string, string> = {};
777
- for (const [k, v] of Object.entries(args.env)) {
778
- if (typeof v !== "string") continue;
779
- if (isFileValue(v)) {
780
- const rel = path.join(args.input.agentRel, ".secret-files", k.toLowerCase());
781
- const abs = path.join(wsIn, rel);
782
- fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o700 });
783
- fs.writeFileSync(abs, fileContent(v), { mode: 0o600 });
784
- containerEnv[k] = `/workspace/${rel.replaceAll("\\", "/")}`;
785
- } else if (!v.includes("\n")) {
786
- containerEnv[k] = v;
787
- }
788
- }
1311
+ const containerEnv = stageContainerEnv(args.env, wsIn, args.input.agentRel, args.emit);
789
1312
 
790
1313
  const envFile = path.join(staging, "env");
791
1314
  fs.writeFileSync(
@@ -821,14 +1344,26 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
821
1344
  // host itself stays reachable, which is the one door this needs.
822
1345
  flags.push("--add-host", "host.docker.internal:host-gateway");
823
1346
 
824
- // The dependency cache: this tenant's built venvs and npm prefixes,
825
- // mounted where prepareRuntime already looks. Created host-side rather
826
- // than left to the daemon so it belongs to the platform user; the
827
- // entrypoint hands the mount point itself to the agent user.
828
- const cacheDir = runtimeCacheDir(args.tenant);
829
- if (cacheDir) {
830
- fs.mkdirSync(cacheDir, { recursive: true });
831
- flags.push("-v", `${cacheDir}:${RUNTIME_CACHE}`);
1347
+ // The dependency cache: this tenant's built venvs and npm prefixes, in a
1348
+ // per-tenant Docker named volume mounted where prepareRuntime already looks.
1349
+ // Not a host path — the daemon on the far end of the socket cannot resolve
1350
+ // the platform's FOLDRUN_DATA (runtimeCacheMount explains).
1351
+ const cacheMount = runtimeCacheMount(args.tenant);
1352
+ if (cacheMount) {
1353
+ // Create the volume WITH labels first: `docker create -v` auto-creates it
1354
+ // but cannot label it, and an unlabelled volume is unfindable and
1355
+ // unprunable later. Idempotent — a no-op when it already exists. The
1356
+ // segment is the one runtimeCacheMount already validated, so the
1357
+ // assertions below cannot fire when cacheMount is non-null.
1358
+ const seg = safeTenantSegment(args.tenant!)!;
1359
+ const vol = spawnSync(cli(), [
1360
+ "volume", "create",
1361
+ "--label", "foldrun.io/kind=runtime-cache",
1362
+ "--label", `foldrun.io/tenant=${seg}`,
1363
+ cacheMount.source,
1364
+ ], { encoding: "utf8" });
1365
+ if (vol.status !== 0) throw new Error(`docker volume create failed:\n${vol.stderr}`);
1366
+ flags.push("-v", `${cacheMount.source}:${cacheMount.target}`);
832
1367
  }
833
1368
 
834
1369
  // The rent clock starts here: staging the workspace is host work, but
@@ -903,7 +1438,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
903
1438
  fs.mkdirSync(wsOut);
904
1439
  const back = spawnSync(cli(), ["cp", `${containerId}:/workspace/.`, wsOut], { encoding: "utf8" });
905
1440
  if (back.status === 0) {
906
- applyContainerChanges(args.workspaceRoot, wsOut, wsIn);
1441
+ applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert);
907
1442
  } else {
908
1443
  args.emit("error", `copy-out failed — the step's file changes were lost:\n${back.stderr.slice(0, 500)}`);
909
1444
  }