@foldrun/core 0.5.0 → 0.6.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 (45) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +1 -0
  4. package/dist/src/browser-pod.d.ts +20 -1
  5. package/dist/src/browser-pod.js +58 -9
  6. package/dist/src/container.d.ts +9 -0
  7. package/dist/src/container.js +115 -5
  8. package/dist/src/context-tools.d.ts +1 -1
  9. package/dist/src/evals.js +3 -0
  10. package/dist/src/host-env.js +5 -0
  11. package/dist/src/mail.js +3 -1
  12. package/dist/src/model-credential.d.ts +14 -0
  13. package/dist/src/model-credential.js +58 -0
  14. package/dist/src/run-container.d.ts +1 -1
  15. package/dist/src/run-container.js +27 -7
  16. package/dist/src/runner.d.ts +35 -0
  17. package/dist/src/runner.js +124 -37
  18. package/dist/src/script-tools.d.ts +14 -0
  19. package/dist/src/script-tools.js +47 -7
  20. package/dist/src/step-exec.d.ts +11 -0
  21. package/dist/src/step-exec.js +18 -2
  22. package/dist/src/store.d.ts +4 -0
  23. package/dist/src/store.js +2 -2
  24. package/dist/src/subagents.js +1 -1
  25. package/dist/src/test-mode.d.ts +17 -2
  26. package/dist/src/test-mode.js +53 -13
  27. package/index.ts +1 -0
  28. package/package.json +1 -1
  29. package/src/browser-pod.ts +46 -9
  30. package/src/container.ts +112 -5
  31. package/src/context-tools.ts +1 -1
  32. package/src/evals.ts +3 -0
  33. package/src/host-env.ts +5 -0
  34. package/src/mail.ts +3 -1
  35. package/src/model-credential.ts +71 -0
  36. package/src/run-container.ts +27 -7
  37. package/src/runner.ts +122 -37
  38. package/src/script-tools.ts +43 -5
  39. package/src/step-exec.ts +19 -2
  40. package/src/store.ts +6 -2
  41. package/src/subagents.ts +1 -1
  42. package/src/test-mode.ts +51 -11
  43. package/templates/blog-desk/agents/writer/agent.md +1 -1
  44. package/templates/hello/agents/curator/agent.md +3 -3
  45. package/templates/hello/agents/notetaker/agent.md +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -48,8 +48,9 @@ export const BROWSE_WRITE_ACTIONS = [
48
48
  ] as const;
49
49
 
50
50
  /** Call arguments of web action=browse that are writes whatever the actions:
51
- * page script, a paid captcha solver, a confirmation of a credential step. */
52
- const BROWSE_WRITE_ARGS = ["js", "captcha", "captcha_solver", "confirm"];
51
+ * page script (js=, or init= scripts run in every page), a paid captcha
52
+ * solver, a confirmation of a credential step. */
53
+ const BROWSE_WRITE_ARGS = ["js", "init", "captcha", "captcha_solver", "confirm"];
53
54
 
54
55
  /** Web actions that only read — when foldrun's own answers them. A paid
55
56
  * provider answering one (FOLDRUN_WEB_<ACTION>_VIA) makes it a write: doing
@@ -72,24 +73,59 @@ export interface ClassifyContext {
72
73
  /** Web actions a paid provider answers this step — from the env's
73
74
  * FOLDRUN_WEB_<ACTION>_VIA. */
74
75
  paidWeb?: Record<string, string>;
76
+ /** Page scripts the agent's web.browse block runs in every page — the
77
+ * env's FOLDRUN_BROWSER_INIT. They make every browse call a write. */
78
+ browseInit?: string;
75
79
  }
76
80
 
77
- /** The browse actions a call asked for, nested `if` branches included. */
81
+ /**
82
+ * The browse actions a call asked for, nested `if` branches included.
83
+ *
84
+ * Read the way the web tool reads them (platform gallery.ts, browse.mjs
85
+ * checkSteps): `actions` is an array of steps, a step's action is one of its
86
+ * OWN keys (the first the tool knows), and the only steps inside a step are
87
+ * an `if`'s `then` and `else`. Every own key of a step is returned — the
88
+ * caller keeps the ones it cares about, and a step holding a write key beside
89
+ * a read key counts as the write. What sits inside an option's value — an
90
+ * extract's `fields` (column names), a mock's `json` body, a webmcp `input` —
91
+ * is data, not an action: `{"fields": {"type": "td.kind"}}` is no typing.
92
+ *
93
+ * A shape the tool would refuse (an object where the array belongs, a step
94
+ * that is not an object, `then` that is not a list) is walked key by key at
95
+ * every depth, as before; nesting past MAX_DEPTH is unreadable, so a write
96
+ * buried deep is never taken for a read.
97
+ */
78
98
  export function browseActionNames(actions: unknown): string[] {
99
+ const UNREADABLE = "(unreadable actions)";
100
+ const MAX_DEPTH = 32;
79
101
  let steps: unknown = actions;
80
102
  if (typeof steps === "string") {
81
- try { steps = JSON.parse(steps); } catch { return ["(unreadable actions)"]; }
103
+ try { steps = JSON.parse(steps); } catch { return [UNREADABLE]; }
82
104
  }
83
105
  const out: string[] = [];
84
- const walk = (v: unknown, depth: number) => {
85
- if (depth > 8 || v === null || typeof v !== "object") return;
86
- if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
106
+ // Unknown shapes: every key anywhere is a candidate action.
107
+ const walkAny = (v: unknown, depth: number) => {
108
+ if (v === null || typeof v !== "object") return;
109
+ if (depth > MAX_DEPTH) { out.push(UNREADABLE); return; }
110
+ if (Array.isArray(v)) { for (const x of v) walkAny(x, depth + 1); return; }
87
111
  for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
88
112
  out.push(k);
89
- walk(x, depth + 1);
113
+ walkAny(x, depth + 1);
114
+ }
115
+ };
116
+ const walkSteps = (v: unknown, depth: number) => {
117
+ if (v === null || v === undefined) return;
118
+ if (depth > MAX_DEPTH) { out.push(UNREADABLE); return; }
119
+ if (!Array.isArray(v)) { walkAny(v, depth); return; }
120
+ for (const step of v) {
121
+ if (step === null || typeof step !== "object" || Array.isArray(step)) { walkAny(step, depth + 1); continue; }
122
+ for (const [k, x] of Object.entries(step as Record<string, unknown>)) {
123
+ out.push(k);
124
+ if (k === "then" || k === "else") walkSteps(x, depth + 1);
125
+ }
90
126
  }
91
127
  };
92
- walk(steps, 0);
128
+ walkSteps(steps, 0);
93
129
  return out;
94
130
  }
95
131
 
@@ -132,6 +168,7 @@ export function classifyCall(tool: string, input: Record<string, unknown> | unde
132
168
  if (action === "browse") {
133
169
  const w = browseWrites(args);
134
170
  if (w) return write(w);
171
+ if (ctx.browseInit) return write(`browse init scripts (${ctx.browseInit})`);
135
172
  }
136
173
  return { write: false };
137
174
  }
package/src/container.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  // development keeps working with zero configuration.
11
11
 
12
12
  import fs from "node:fs";
13
+ import os from "node:os";
13
14
  import path from "node:path";
14
15
  import { dataRoot } from "./paths.ts";
15
16
  import { spawnSync, spawn } from "node:child_process";
@@ -131,6 +132,10 @@ export interface ContainerRunOptions {
131
132
  agentDir: string;
132
133
  /** Extra read-only mounts: host path → container path. */
133
134
  readOnly?: Record<string, string>;
135
+ /** Workspace paths a script was handed as `workspace/…`: copied in at
136
+ * `container` before it runs, and what it created or changed under them
137
+ * copied back to `host` after — the host's workspace link, as a copy. */
138
+ staged?: { host: string; container: string }[];
134
139
  image: string;
135
140
  argv: string[]; // command inside the container
136
141
  env: Record<string, string>;
@@ -181,15 +186,36 @@ export async function runInContainer(
181
186
  const cid = (created.stdout ?? "").trim();
182
187
 
183
188
  try {
184
- // The agent's own directory becomes /workspace…
185
- const copied = docker(["cp", `${opts.agentDir}/.`, `${cid}:/workspace`], 120_000);
186
- if (copied.status !== 0) {
187
- return { code: null, out: `copy in failed: ${(copied.stderr ?? "").slice(-300)}` };
188
- }
189
+ // The agent's own directory becomes /workspace… — its entries staged
190
+ // like the workspace paths below (a scratch copy, opened up for the
191
+ // script's user, one tar), minus the step's `workspace` link. A step
192
+ // holds that link (→ ../..) so the shell can say workspace/…, and
193
+ // `docker cp` refuses a link that climbs out of what it copies
194
+ // ("invalid symlink"), so every Docker-run script failed before it
195
+ // started. And `docker cp` lands files as root while scripts run as
196
+ // `agent`, so a script could never write its own outputs/.
197
+ const own = fs
198
+ .readdirSync(opts.agentDir)
199
+ .filter((name) => {
200
+ if (name !== "workspace") return true;
201
+ try {
202
+ return !fs.lstatSync(path.join(opts.agentDir, name)).isSymbolicLink();
203
+ } catch {
204
+ return false;
205
+ }
206
+ })
207
+ .map((name) => ({ host: path.join(opts.agentDir, name), container: `/workspace/${name}` }));
208
+ const copiedIn = stageIn(CLI, cid, own, ["/workspace"]);
209
+ if (copiedIn) return { code: null, out: `copy in failed: ${copiedIn}` };
189
210
  // …and any shared directories land at their declared paths.
190
211
  for (const [host, mount] of Object.entries(opts.readOnly ?? {})) {
191
212
  if (fs.existsSync(host)) docker(["cp", `${host}/.`, `${cid}:${mount}`], 120_000);
192
213
  }
214
+ const staged = opts.staged ?? [];
215
+ if (staged.length) {
216
+ const failed = stageIn(CLI, cid, staged);
217
+ if (failed) return { code: null, out: `copy in failed: ${failed}` };
218
+ }
193
219
 
194
220
  const result = await new Promise<{ code: number | null; out: string }>((resolve) => {
195
221
  const child = spawn(CLI, ["start", "-a", cid], { timeout: opts.timeoutMs });
@@ -213,8 +239,89 @@ export async function runInContainer(
213
239
 
214
240
  // Bring deliverables back so outputs/ behaves the same either way.
215
241
  docker(["cp", `${cid}:/workspace/outputs/.`, path.join(opts.agentDir, "outputs")], 120_000);
242
+ for (const s of staged) stageBack(CLI, cid, s);
216
243
  return result;
217
244
  } finally {
218
245
  docker(["rm", "-f", cid], 30_000);
219
246
  }
220
247
  }
248
+
249
+ /**
250
+ * Copy the staged workspace paths into a created container: one tar, built
251
+ * in a scratch directory that mirrors the container paths, extracted at /.
252
+ * A path that does not exist yet (an output the script is told to write)
253
+ * gets its parent directory, so the write has somewhere to land.
254
+ */
255
+ function stageIn(cli: string, cid: string, staged: { host: string; container: string }[], dirs: string[] = []): string | null {
256
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-stage-"));
257
+ try {
258
+ for (const d of dirs) fs.mkdirSync(path.join(scratch, d), { recursive: true });
259
+ for (const { host, container } of staged) {
260
+ const dest = path.join(scratch, container);
261
+ if (fs.existsSync(host)) {
262
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
263
+ fs.cpSync(host, dest, { recursive: true, dereference: false });
264
+ } else {
265
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
266
+ }
267
+ }
268
+ // `docker cp` lands files as root and scripts run as `agent`: these are
269
+ // scratch copies, so open them up for the script to write into.
270
+ openUp(scratch);
271
+ const top = fs.readdirSync(scratch);
272
+ const r = spawnSync(
273
+ "sh",
274
+ ["-c", 'dir="$1"; cli="$2"; dest="$3"; shift 3; tar --no-xattrs -C "$dir" -cf - -- "$@" | "$cli" cp - "$dest"', "sh", scratch, cli, `${cid}:/`, ...top],
275
+ { encoding: "utf8", timeout: 120_000, env: { ...process.env, COPYFILE_DISABLE: "1" } },
276
+ );
277
+ return r.status === 0 ? null : (r.stderr ?? "").slice(-300);
278
+ } finally {
279
+ fs.rmSync(scratch, { recursive: true, force: true });
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Copy back what the script created or changed under one staged path. Files
285
+ * whose bytes did not change are left alone, so an input keeps its mtime —
286
+ * freshness checks read it.
287
+ */
288
+ function stageBack(cli: string, cid: string, s: { host: string; container: string }): void {
289
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-stage-"));
290
+ try {
291
+ const out = path.join(scratch, "x");
292
+ const got = spawnSync(cli, ["cp", `${cid}:${s.container}`, out], { encoding: "utf8", timeout: 120_000 });
293
+ if (got.status !== 0 || !fs.existsSync(out)) return;
294
+ syncChanged(out, s.host);
295
+ } finally {
296
+ fs.rmSync(scratch, { recursive: true, force: true });
297
+ }
298
+ }
299
+
300
+ /** Write `from` over `to` where the bytes differ; recurse into folders. */
301
+ export function syncChanged(from: string, to: string): void {
302
+ const st = fs.lstatSync(from);
303
+ if (st.isSymbolicLink()) return;
304
+ if (st.isDirectory()) {
305
+ fs.mkdirSync(to, { recursive: true });
306
+ for (const name of fs.readdirSync(from)) syncChanged(path.join(from, name), path.join(to, name));
307
+ return;
308
+ }
309
+ const next = fs.readFileSync(from);
310
+ if (fs.existsSync(to) && fs.statSync(to).isFile() && fs.readFileSync(to).equals(next)) return;
311
+ fs.mkdirSync(path.dirname(to), { recursive: true });
312
+ fs.writeFileSync(to, next);
313
+ }
314
+
315
+ /** Directories 0777, files writable by all (exec bits kept): a scratch tree only. */
316
+ function openUp(dir: string): void {
317
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
318
+ const p = path.join(dir, entry.name);
319
+ if (entry.isSymbolicLink()) continue;
320
+ if (entry.isDirectory()) {
321
+ fs.chmodSync(p, 0o777);
322
+ openUp(p);
323
+ } else {
324
+ fs.chmodSync(p, fs.statSync(p).mode | 0o666);
325
+ }
326
+ }
327
+ }
@@ -32,7 +32,7 @@ import type { RunRecord } from "./store.ts";
32
32
  // ------------------------------------------------------------------ search
33
33
 
34
34
  export interface SearchRoot {
35
- /** How the agent should name the place: "knowledge/", "../../memory/". */
35
+ /** How the agent should name the place: "knowledge/", "workspace/memory/". */
36
36
  label: string;
37
37
  /** The directory, absolute. */
38
38
  dir: string;
package/src/evals.ts CHANGED
@@ -31,6 +31,7 @@
31
31
  // they pass — no point paying a model to grade output already known to be
32
32
  // wrong.
33
33
 
34
+ import { sdkEnv } from "./step-exec.ts";
34
35
  import fs from "node:fs";
35
36
  import path from "node:path";
36
37
  import { deployedCommit } from "./deploy.ts";
@@ -491,6 +492,8 @@ async function judge(
491
492
  tools: [],
492
493
  allowedTools: [],
493
494
  settingSources: [],
495
+ strictMcpConfig: true,
496
+ env: sdkEnv(undefined),
494
497
  maxTurns: 1,
495
498
  },
496
499
  });
package/src/host-env.ts CHANGED
@@ -20,6 +20,11 @@
20
20
  const EXACT = new Set([
21
21
  "PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE",
22
22
  "TZ", "TERM", "SHELL", "USER", "LOGNAME",
23
+ // Where Claude Code / the Agent SDK keeps its config and session
24
+ // transcripts — the same kind of thing as HOME (a path, not a secret), and
25
+ // without it an operator cannot keep them out of the host user's
26
+ // ~/.claude: the box's deploy tests set it to the job's temp (2026-10-02).
27
+ "CLAUDE_CONFIG_DIR",
23
28
  // The host's way to the network is the child's way to the network.
24
29
  "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
25
30
  "SSL_CERT_FILE", "SSL_CERT_DIR",
package/src/mail.ts CHANGED
@@ -168,7 +168,9 @@ export function readUnsubscribeToken(
168
168
  const [payload, sig, extra] = token.split(".");
169
169
  if (!payload || !sig || extra !== undefined) return { error: "invalid token", status: 401 };
170
170
  const want = sign(payload);
171
- if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) {
171
+ // Only base64url reaches the compare: a multibyte character would make the
172
+ // byte buffers differ in length, and timingSafeEqual throws on that.
173
+ if (!/^[A-Za-z0-9_-]+$/.test(sig) || sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) {
172
174
  return { error: "invalid token", status: 401 };
173
175
  }
174
176
  let raw: { t?: unknown; e?: unknown; c?: unknown; w?: unknown; x?: unknown };
@@ -0,0 +1,71 @@
1
+ // Which Anthropic credential a step runs on, decided once and said on the
2
+ // record — never whatever happens to be signed in on the machine.
3
+ //
4
+ // In order: an API key (ANTHROPIC_API_KEY), a bearer for a gateway
5
+ // (ANTHROPIC_AUTH_TOKEN), then a Claude login token (CLAUDE_CODE_OAUTH_TOKEN,
6
+ // made by `claude setup-token`). Before this, an in-process step was handed
7
+ // none of them — the host allowlist drops credentials — so Claude Code fell
8
+ // back to the machine's own claude.ai login whatever key was set: a key the
9
+ // CLI insisted on was checked and then ignored.
10
+ //
11
+ // Note on terms: Anthropic does not allow third-party products built on the
12
+ // Agent SDK to offer claude.ai login unless Anthropic has approved it. The
13
+ // login token is accepted here because the account owner chose to support
14
+ // it; offering it to customers needs that approval.
15
+ //
16
+ // What an agent's own shell sees: Claude Code keeps CLAUDE_CODE_OAUTH_TOKEN
17
+ // out of its Bash by itself; an API key is visible there, as it is when you
18
+ // run Claude Code with one in your shell. Its scrub switch
19
+ // (CLAUDE_CODE_SUBPROCESS_ENV_SCRUB) is not used: it also strips the agent's
20
+ // own declared secrets (RESEND_API_KEY, DATAFORSEO_PASSWORD) from Bash, and on
21
+ // Linux it refuses to start without bubblewrap. Script tools and shell
22
+ // `verify:` never see any of these: they start from the host allowlist.
23
+
24
+ export type ModelCredentialKind = "api-key" | "auth-token" | "claude-login" | "none";
25
+
26
+ export interface ModelCredential {
27
+ kind: ModelCredentialKind;
28
+ /** What the run record and the CLI say: which credential, by its variable. */
29
+ label: string;
30
+ /** The variables the step's Claude Code is started with. */
31
+ env: Record<string, string>;
32
+ }
33
+
34
+ const set = (v: string | undefined) => typeof v === "string" && v.trim() !== "";
35
+
36
+ export function resolveModelCredential(source: Record<string, string | undefined> = process.env): ModelCredential {
37
+ const base: Record<string, string> = set(source.ANTHROPIC_BASE_URL) ? { ANTHROPIC_BASE_URL: source.ANTHROPIC_BASE_URL! } : {};
38
+ if (set(source.ANTHROPIC_API_KEY)) {
39
+ return {
40
+ kind: "api-key",
41
+ label: "API key (ANTHROPIC_API_KEY)",
42
+ env: { ...base, ANTHROPIC_API_KEY: source.ANTHROPIC_API_KEY!, CLAUDE_CODE_OAUTH_TOKEN: "" },
43
+ };
44
+ }
45
+ if (set(source.ANTHROPIC_AUTH_TOKEN)) {
46
+ return {
47
+ kind: "auth-token",
48
+ label: `bearer token (ANTHROPIC_AUTH_TOKEN)${base.ANTHROPIC_BASE_URL ? ` to ${base.ANTHROPIC_BASE_URL}` : ""}`,
49
+ env: { ...base, ANTHROPIC_AUTH_TOKEN: source.ANTHROPIC_AUTH_TOKEN!, ANTHROPIC_API_KEY: "", CLAUDE_CODE_OAUTH_TOKEN: "" },
50
+ };
51
+ }
52
+ if (set(source.CLAUDE_CODE_OAUTH_TOKEN)) {
53
+ return {
54
+ kind: "claude-login",
55
+ label: "Claude login token (CLAUDE_CODE_OAUTH_TOKEN)",
56
+ env: { ...base, CLAUDE_CODE_OAUTH_TOKEN: source.CLAUDE_CODE_OAUTH_TOKEN!, ANTHROPIC_API_KEY: "" },
57
+ };
58
+ }
59
+ return { kind: "none", label: "none set", env: {} };
60
+ }
61
+
62
+ /** The line a step's trail carries, and what goes wrong when there is none. */
63
+ export function credentialLine(c: ModelCredential): { type: "info" | "error"; text: string } {
64
+ if (c.kind !== "none") return { type: "info", text: `model credential: ${c.label}` };
65
+ return {
66
+ type: "error",
67
+ text:
68
+ "model credential: none set — Claude Code will fall back to whatever login this machine has, if any. " +
69
+ "Set ANTHROPIC_API_KEY, or CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`",
70
+ };
71
+ }
@@ -25,7 +25,7 @@ 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
+ import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.ts";
29
29
  import type { EventExtra } from "./step-exec.ts";
30
30
  import { isFileValue, fileContent } from "./secrets.ts";
31
31
  import { safeTenantSegment, type RuntimeSpec } from "./runtime.ts";
@@ -617,7 +617,7 @@ try {
617
617
  // through the account's pod, and named the file the web tool logs its
618
618
  // reconnects to. A lost pod stops the step; what it wrote decides what
619
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) } } : {}),
620
+ ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env), ...(env.FOLDRUN_BROWSER_INIT ? { browseInit: env.FOLDRUN_BROWSER_INIT } : {}) } } : {}),
621
621
  // The container is the boundary; the SDK's own bash sandbox here would
622
622
  // only block declared network use (SSH, curl) for no added safety.
623
623
  sandboxBash: false,
@@ -647,17 +647,30 @@ try {
647
647
  }
648
648
  `;
649
649
 
650
+ // Core itself is installed in a stage of its own (CORE_STAGE) and only the
651
+ // result is copied in: the tarball and the package README — which explains
652
+ // foldrun to a person reading the repo, and which no step reads — stay in
653
+ // that stage and never become a layer of the image. Owner rule, 2026-10-02:
654
+ // foldrun's own docs live in git, not in containers.
655
+ const CORE_STAGE = `FROM base AS core
656
+ WORKDIR /opt/runner
657
+ COPY foldrun-core.tgz ./
658
+ RUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\
659
+ && npm cache clean --force >/dev/null 2>&1 \\
660
+ && rm -f foldrun-core.tgz node_modules/@foldrun/core/README.md
661
+ `;
662
+
650
663
  // docker cp writes root-owned files, so the entrypoint starts as root for
651
664
  // exactly two commands — chown the copied-in trees, drop to the agent user —
652
665
  // and the run flags grant only the three capabilities those two commands
653
666
  // need. By the time any model-directed code executes, the process is uid
654
667
  // 10001 with no capabilities at all.
655
668
  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 \\
669
+ COPY --from=core --chown=agent:agent /opt/runner/ ./
670
+ COPY --chown=agent:agent driver.mjs entry.sh ./
671
+ RUN mkdir -p /workspace /library /opt/runner/job ${RUNTIME_CACHE} ${SHARED_RUNTIMES} \\
672
+ && chown agent:agent /opt/runner \\
673
+ && chown -R agent:agent /workspace /library /opt/runner/job /home/agent/.foldrun /opt/foldrun-runtimes \\
661
674
  && chmod +x /opt/runner/entry.sh
662
675
  ENTRYPOINT ["/opt/runner/entry.sh"]
663
676
  `;
@@ -724,6 +737,7 @@ RUN apt-get update \\
724
737
  COPY --from=ghcr.io/astral-sh/uv:${UV_VERSION} /uv /uvx /usr/local/bin/
725
738
  ENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules
726
739
 
740
+ ${CORE_STAGE}
727
741
  FROM base AS slim
728
742
  # Fonts, because a step that draws text without a browser still needs them:
729
743
  # the full image gets 51 from Playwright's --with-deps, slim had none, so from
@@ -1299,6 +1313,12 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
1299
1313
  preserveTimestamps: true,
1300
1314
  filter: (src) => !isPlatformPath(path.relative(args.workspaceRoot, src)),
1301
1315
  });
1316
+ // A test run: what its earlier steps wrote, over the real files (the
1317
+ // copy is the baseline too, so only this step's own writes come back).
1318
+ if (args.divert) {
1319
+ const laid = overlayTestWrites(wsIn, args.divert.to);
1320
+ if (laid.length) args.emit("info", `test: this step sees ${laid.length} file${laid.length === 1 ? "" : "s"} earlier steps of this run wrote`);
1321
+ }
1302
1322
  const libIn = path.join(staging, "library");
1303
1323
  if (args.galleryRoot && fs.existsSync(args.galleryRoot)) fs.cpSync(args.galleryRoot, libIn, { recursive: true });
1304
1324
  if (fs.existsSync(args.libraryRoot)) fs.cpSync(args.libraryRoot, libIn, { recursive: true });