@foldrun/core 2.14.2 → 3.0.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.
@@ -11,6 +11,9 @@ export interface Roots {
11
11
  workspaceRoot: string;
12
12
  /** The account library: readable, never writable. */
13
13
  libraryRoot: string;
14
+ /** A person approved this step before it ran (an approval gate): its
15
+ * writes to the workspace's configuration and code are allowed. */
16
+ configWrites?: boolean;
14
17
  }
15
18
  export declare const isFilesystemTool: (toolName: string) => boolean;
16
19
  /**
@@ -29,6 +29,7 @@
29
29
  // ordinary deliverable — so the rule names the two directories it applies to.
30
30
  import fs from "node:fs";
31
31
  import path from "node:path";
32
+ import { refusedBack } from "./copy-back.js";
32
33
  /**
33
34
  * A path with its symlinks resolved, as far as it exists.
34
35
  *
@@ -274,6 +275,19 @@ export function checkPaths(toolName, input, roots) {
274
275
  `concept file instead.`,
275
276
  };
276
277
  }
278
+ // Configuration and code are a person's: the same list a sandbox's
279
+ // copy-back refuses (copy-back.ts, FOL-96), so a step that runs on this
280
+ // host, with no sandbox between it and the files, is held to the same
281
+ // rule by its file tools. Its shell is not confined here — isolation is.
282
+ const asConfig = isWrite && !inLibrary && !roots.configWrites ? refusedBack(within) : null;
283
+ if (asConfig) {
284
+ return {
285
+ ok: false,
286
+ reason: `${toolName} was denied: "${raw}" is this workspace's ${asConfig === "config" ? "configuration" : "code"}, ` +
287
+ `which a run does not change. Say what should change and why in your answer — a person ` +
288
+ `applies it (or it becomes a Proposed fix). Your own notes go to outputs/ or workspace/memory/.`,
289
+ };
290
+ }
277
291
  // Hand the real path back to the tool, so the virtual prefix the prompt
278
292
  // advertises is one the agent can actually open — unless the step's
279
293
  // `workspace` link already opens it. Then the path is left as written,
@@ -0,0 +1,17 @@
1
+ import { type Kind } from "./kinds.ts";
2
+ /** A path a step changed that was not taken back, and why. */
3
+ export interface RefusedPath {
4
+ path: string;
5
+ kind: "config" | "code";
6
+ }
7
+ /** The path shapes one kind's files take, inside a workspace. */
8
+ export declare function kindPatterns(kind: Kind): RegExp[];
9
+ /** Whether a step's write to `rel` (workspace-relative) is refused on the way
10
+ * back, and as what; null when it is data and may come back. */
11
+ export declare function refusedBack(rel: string): "config" | "code" | null;
12
+ /** The trail's line for a step's refused writes. */
13
+ /** The trail's line for config and code a person-approved step changed and
14
+ * that were kept (an approval gate before the step: the person read what
15
+ * it would do — the desks' retro applies approved fixes this way). */
16
+ export declare function keptLine(kept: readonly RefusedPath[]): string;
17
+ export declare function refusedLine(refused: readonly RefusedPath[]): string;
@@ -0,0 +1,74 @@
1
+ // What a run's sandbox may hand back to the workspace (FOL-96, 27 Oct 2026).
2
+ //
3
+ // A step's file changes come back from its sandbox (run-container.ts
4
+ // applyContainerChanges — the docker and the k8s runner both), and until now
5
+ // everything came back that was not platform-owned, knowledge or hooks. So a
6
+ // step steered by a page it read could rewrite AGENTS.md, an agent.md or a
7
+ // flow — its own tools:, network:, a provider: block, a new scheduled flow —
8
+ // and every later run, the next step of the same run included, ran on it.
9
+ //
10
+ // Configuration and code are a person's to change. A step that wants a
11
+ // change proposes it (Proposed fixes, the assistant), and a person applies
12
+ // it; a step that writes one anyway has the write refused, and the run's
13
+ // trail names it. What steps legitimately write — state/, memory/, storage/,
14
+ // an agent's own outputs — comes back as before.
15
+ //
16
+ // The list is not written here. It is read off the kinds (kinds.ts), whose
17
+ // every row must say what its files are, so a kind added later is classified
18
+ // the moment it exists — tsc refuses a row without `copyBack`.
19
+ import { KINDS, ALL_KINDS, SUBAGENT_IMPORT_DIRS, WORKSPACE_CONFIG_FILES } from "./kinds.js";
20
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
+ const NAME = "\u0000";
22
+ /** Where an agent's own folder is — the scope agent-level kinds live under. */
23
+ const AGENT_SCOPE = KINDS.agents.file(NAME).split(NAME)[0]; // "agents/"
24
+ /** The path shapes one kind's files take, inside a workspace. */
25
+ export function kindPatterns(kind) {
26
+ const meta = KINDS[kind];
27
+ const file = meta.file(NAME);
28
+ // An agent's folder is a scope as well as a document: everything else in
29
+ // it is the agent's working directory. Its document is the one file.
30
+ if (kind === "agents") {
31
+ const [head, tail] = file.split(NAME);
32
+ return [new RegExp(`^${escape(head)}[^/]+${escape(tail)}$`)];
33
+ }
34
+ // Every other kind owns its top-level folder — a file document's siblings
35
+ // (a tool's script beside its .md) and a folder document's contents alike.
36
+ const root = file.split("/")[0];
37
+ const out = [new RegExp(`^${escape(root)}/`)];
38
+ if (meta.scopes.includes("agent"))
39
+ out.push(new RegExp(`^${escape(AGENT_SCOPE)}[^/]+/${escape(root)}/`));
40
+ return out;
41
+ }
42
+ const RULES = [
43
+ ...WORKSPACE_CONFIG_FILES.map((f) => ({ re: new RegExp(`^${escape(f)}$`), kind: "config" })),
44
+ // Single-file subagents another tool wrote: a deploy imports them as agents.
45
+ ...[...new Set(SUBAGENT_IMPORT_DIRS.map((d) => d.split("/")[0]))].map((d) => ({ re: new RegExp(`^${escape(d)}/`), kind: "config" })),
46
+ ...ALL_KINDS.flatMap((k) => {
47
+ const cls = KINDS[k].copyBack;
48
+ return cls === "data" ? [] : kindPatterns(k).map((re) => ({ re, kind: cls }));
49
+ }),
50
+ ];
51
+ /** Whether a step's write to `rel` (workspace-relative) is refused on the way
52
+ * back, and as what; null when it is data and may come back. */
53
+ export function refusedBack(rel) {
54
+ const norm = rel.replaceAll("\\", "/").replace(/^\.\//, "");
55
+ for (const r of RULES)
56
+ if (r.re.test(norm))
57
+ return r.kind;
58
+ return null;
59
+ }
60
+ /** The trail's line for a step's refused writes. */
61
+ /** The trail's line for config and code a person-approved step changed and
62
+ * that were kept (an approval gate before the step: the person read what
63
+ * it would do — the desks' retro applies approved fixes this way). */
64
+ export function keptLine(kept) {
65
+ const shown = kept.slice(0, 20).map((r) => `${r.path} (${r.kind})`).join(", ");
66
+ const more = kept.length > 20 ? ` and ${kept.length - 20} more` : "";
67
+ return `kept with approval: ${shown}${more} — configuration or code this step changed, kept because a person approved the step before it ran`;
68
+ }
69
+ export function refusedLine(refused) {
70
+ const shown = refused.slice(0, 20).map((r) => `${r.path} (${r.kind})`).join(", ");
71
+ const more = refused.length > 20 ? ` and ${refused.length - 20} more` : "";
72
+ return (`refused path: ${shown}${more} — a step cannot change the workspace's configuration or code; ` +
73
+ `the workspace keeps its own copy. Propose the change instead (Proposed fixes), and a person applies it.`);
74
+ }
@@ -1,4 +1,5 @@
1
1
  import { type DeployFile } from "./store.ts";
2
+ import { SUBAGENT_IMPORT_DIRS } from "./kinds.ts";
2
3
  import { type ScanFinding, type ScanOptions } from "./scan.ts";
3
4
  /**
4
5
  * Is this path, relative to a workspace root, one `readTree` would carry?
@@ -46,7 +47,7 @@ export interface DeployResult extends DeployPlan {
46
47
  * skipped rather than rejected, because a workspace living in a repo alongside
47
48
  * other things is the normal case, not an error.
48
49
  */
49
- export declare const SUBAGENT_IMPORT_DIRS: readonly [".agents/agents", ".claude/agents"];
50
+ export { SUBAGENT_IMPORT_DIRS };
50
51
  /** Bounds for a reader that only looks at the text (the checks, the
51
52
  * assistant): on 20 Oct 2026 the daily rescan read one desk's 1.2 GB of
52
53
  * state/ into a 2 GB heap and crash-looped the worker. A deploy sets none. */
@@ -26,6 +26,7 @@ import { libraryTools } from "./library.js";
26
26
  import { lintFlow, regexProblem } from "./flow-lint.js";
27
27
  import { parseEval } from "./evals.js";
28
28
  import { refNames } from "./refs.js";
29
+ import { SUBAGENT_IMPORT_DIRS } from "./kinds.js";
29
30
  import matter, { configMatter } from "./matter.js";
30
31
  import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.js";
31
32
  import { conformanceIssues } from "./okf.js";
@@ -74,12 +75,9 @@ export function isSourcePath(rel) {
74
75
  * skipped rather than rejected, because a workspace living in a repo alongside
75
76
  * other things is the normal case, not an error.
76
77
  */
77
- // Where single-file subagents authored by other tools are found, in
78
- // precedence order: the vendor-neutral cross-client location first, then any
79
- // tool-specific one for pragmatic compatibility. Extend the tail, never the
80
- // head. Shared by the deploy import (readTree) and the CLI checker so the two
81
- // agree on what counts as an agent.
82
- export const SUBAGENT_IMPORT_DIRS = [".agents/agents", ".claude/agents"];
78
+ // Where single-file subagents authored by other tools are found — declared
79
+ // with the kinds (kinds.ts), which the copy-back rule reads too.
80
+ export { SUBAGENT_IMPORT_DIRS };
83
81
  /** What agents write as they run, not what anyone authored: state/ and
84
82
  * memory/, the workspace's or one agent's. */
85
83
  const DATA_PATH = /^(?:agents\/[^/]+\/)?(?:state|memory)\//;
@@ -116,7 +116,7 @@ const FLOW = [
116
116
  { key: "every", type: ["string", "number"], help: "trigger: watch — how often" },
117
117
  { key: "path", type: "string", help: "trigger: storage — the prefix under storage/" },
118
118
  { key: "filter", type: "object", validated: true, help: "webhook, email, flow or storage trigger: the fields a delivery must have to start a run — e.g. {action: [opened], sender: {type: {not_in: [Bot]}}}" },
119
- { key: "idempotency", type: "string", help: "a header name, or body:<field>, that marks a repeat delivery" },
119
+ { key: "idempotency", type: ["string", "boolean"], help: "a header name, or body:<field>, that marks a repeat delivery; none (or false) to turn off (signature: github defaults to x-github-delivery)" },
120
120
  { key: "debounce", type: ["string", "number"], help: "wait for a burst to end" },
121
121
  { key: "throttle", type: ["string", "number"], help: "at most one run per window" },
122
122
  { key: "sla", type: ["string", "number"], help: "tell someone when a run outlives it" },
@@ -1,6 +1,12 @@
1
1
  /** Where a document can live. Nearest wins at read time. */
2
2
  export type Scope = "account" | "workspace" | "agent";
3
3
  export type Kind = "agents" | "flows" | "evals" | "tools" | "hooks" | "skills" | "memory" | "knowledge" | "scripts";
4
+ /** How a run's write to a kind's files is treated on the way back. */
5
+ export type CopyBackClass = "config" | "code" | "data";
6
+ /** Workspace files that are configuration without being a kind: the shared
7
+ * context and its pre-rename name. */
8
+ export declare const WORKSPACE_CONFIG_FILES: readonly ["AGENTS.md", "project.md"];
9
+ export declare const SUBAGENT_IMPORT_DIRS: readonly [".agents/agents", ".claude/agents"];
4
10
  export interface KindMeta {
5
11
  kind: Kind;
6
12
  /** Singular noun, for buttons and prose. */
@@ -23,6 +29,14 @@ export interface KindMeta {
23
29
  hint: string;
24
30
  /** Path relative to the scope root. */
25
31
  file: (name: string) => string;
32
+ /**
33
+ * What a run's sandbox writing one of these means (copy-back.ts):
34
+ * `config` grants or changes what runs and how, `code` is something a
35
+ * later step executes — neither is ever taken back from a step, which
36
+ * proposes the change instead — and `data` is what steps legitimately
37
+ * write. Required, so a new kind cannot become copy-back-able unnoticed.
38
+ */
39
+ copyBack: CopyBackClass;
26
40
  /** Starting content. */
27
41
  template: (name: string, ctx?: TemplateContext) => string;
28
42
  }
package/dist/src/kinds.js CHANGED
@@ -35,6 +35,15 @@
35
35
  // Everything downstream — creation templates, `foldrun init`, the scaffolder,
36
36
  // the dashboard's New button — reads this table. Adding a kind means adding a
37
37
  // row here and nothing else; that is the point.
38
+ /** Workspace files that are configuration without being a kind: the shared
39
+ * context and its pre-rename name. */
40
+ export const WORKSPACE_CONFIG_FILES = ["AGENTS.md", "project.md"];
41
+ // Where single-file subagents authored by other tools are found, in
42
+ // precedence order: the vendor-neutral cross-client location first, then any
43
+ // tool-specific one for pragmatic compatibility. Extend the tail, never the
44
+ // head. Shared by the deploy import (deploy.ts readTree), the CLI checker
45
+ // and the copy-back rule, so all three agree on what counts as an agent.
46
+ export const SUBAGENT_IMPORT_DIRS = [".agents/agents", ".claude/agents"];
38
47
  const envName = (name) => name.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
39
48
  /**
40
49
  * A slug as a starting display label: `house-style` → `House style`.
@@ -50,6 +59,7 @@ const titleCase = (slug) => {
50
59
  export const KINDS = {
51
60
  agents: {
52
61
  kind: "agents",
62
+ copyBack: "config",
53
63
  one: "agent",
54
64
  docType: null,
55
65
  docKey: null,
@@ -73,6 +83,7 @@ this text is the whole of the agent's instructions.
73
83
  },
74
84
  evals: {
75
85
  kind: "evals",
86
+ copyBack: "config",
76
87
  one: "eval",
77
88
  docType: null,
78
89
  docKey: null,
@@ -95,6 +106,7 @@ expect:
95
106
  },
96
107
  flows: {
97
108
  kind: "flows",
109
+ copyBack: "config",
98
110
  one: "flow",
99
111
  docType: null,
100
112
  docKey: null,
@@ -113,6 +125,7 @@ trigger: manual
113
125
  },
114
126
  tools: {
115
127
  kind: "tools",
128
+ copyBack: "config",
116
129
  one: "tool",
117
130
  docType: null,
118
131
  docKey: null,
@@ -139,6 +152,7 @@ tools: [${name}]
139
152
  },
140
153
  hooks: {
141
154
  kind: "hooks",
155
+ copyBack: "code",
142
156
  one: "hook",
143
157
  docType: null,
144
158
  docKey: null,
@@ -169,6 +183,7 @@ it reads the event as JSON on stdin and prints one JSON answer —
169
183
  // a decision, so the sidebar has no door for it.
170
184
  knowledge: {
171
185
  kind: "knowledge",
186
+ copyBack: "data",
172
187
  one: "knowledge doc",
173
188
  docType: "Reference",
174
189
  // OKF's field and OKF's vocabulary — `Reference` is only the starting point.
@@ -190,6 +205,7 @@ The reference content. Agents read this and never rewrite it.
190
205
  },
191
206
  memory: {
192
207
  kind: "memory",
208
+ copyBack: "data",
193
209
  one: "memory",
194
210
  docType: "Fact",
195
211
  // OKF's field and OKF's vocabulary — `Fact` is only the starting point.
@@ -216,6 +232,7 @@ The fact.
216
232
  },
217
233
  skills: {
218
234
  kind: "skills",
235
+ copyBack: "config",
219
236
  one: "skill",
220
237
  docType: null,
221
238
  docKey: null,
@@ -235,6 +252,7 @@ Step-by-step instructions. Bundle code in this folder's scripts/ if it needs any
235
252
  },
236
253
  scripts: {
237
254
  kind: "scripts",
255
+ copyBack: "code",
238
256
  one: "script",
239
257
  // Code, not a document — no frontmatter to carry either field. A tool
240
258
  // file points at it.
@@ -3,6 +3,7 @@ import type { PeerInfo } from "./operator.ts";
3
3
  import { type HookRecord } from "./hooks.ts";
4
4
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
5
5
  import { type ApiSpec, type Effort, type ModelCall } from "./store.ts";
6
+ import { type RefusedPath } from "./copy-back.ts";
6
7
  import type { CallIo, EventExtra, ExecUsage } from "./step-exec.ts";
7
8
  import type { TokenPrice } from "./catalog.ts";
8
9
  import { type RuntimeSpec } from "./runtime.ts";
@@ -19,6 +20,9 @@ import { type BrowserPodOutcome } from "./browser-pod.ts";
19
20
  * secrets in headers, assembled prompt, serializable MCP configs. */
20
21
  export interface ContainerStepInput {
21
22
  agentRel: string;
23
+ /** A person approved this step before it ran: its file tools may write
24
+ * configuration and code inside the sandbox too (FOL-96). */
25
+ configWrites?: boolean;
22
26
  prompt: string;
23
27
  model: string;
24
28
  effort?: Effort | null;
@@ -195,6 +199,9 @@ export interface ContainerStepOutcome {
195
199
  * applyContainerChanges returned. An isolated sub-agent's reply names
196
200
  * them to the step that delegated (delegate-tools.ts). */
197
201
  applied?: string[];
202
+ /** What the step changed that was not taken back: configuration and code
203
+ * (copy-back.ts). On the step record and in the trail. */
204
+ refused?: RefusedPath[];
198
205
  }
199
206
  /**
200
207
  * The one place the timing line is worded, so the docker and k8s traces
@@ -229,6 +236,20 @@ export declare function allowedBack(rel: string): boolean;
229
236
  * step changed without the staging copy the first driver held in /tmp.
230
237
  */
231
238
  export declare function hashTree(dir: string): Record<string, string>;
239
+ /**
240
+ * Where the host keeps what a sandbox was handed (hashTree), for a driver
241
+ * that inherits the step: under the run's own folder, which is platform-
242
+ * owned — never staged into a sandbox, never copied back, erased with the
243
+ * run (deleteRun). It used to ride into the pod beside the input and be
244
+ * read back from there, where the step could rewrite it (FOL-96).
245
+ */
246
+ export declare function handedInPath(workspaceRoot: string, runId: string, ref: string): string | null;
247
+ /** Note what a sandbox was handed, on the host. */
248
+ export declare function writeHandedIn(workspaceRoot: string, runId: string, ref: string, hashes: Record<string, string>): void;
249
+ /** What the host noted a sandbox was handed, or undefined. */
250
+ export declare function readHandedIn(workspaceRoot: string, runId: string, ref: string): Record<string, string> | undefined;
251
+ /** Forget it once the step's copy-out is done. */
252
+ export declare function dropHandedIn(workspaceRoot: string, runId: string, ref: string): void;
232
253
  /**
233
254
  * Every file's mtime under `dir`, relative path to milliseconds — so a copy
234
255
  * that cannot carry times can have them put back. `kubectl cp` unpacks with
@@ -249,7 +270,21 @@ export declare const APPLY_MTIMES_JS: string;
249
270
  * real conflict, which the caller must not settle by dropping one side.
250
271
  */
251
272
  export declare function mergeAppends(was: Buffer | string | null, host: Buffer, next: Buffer): Buffer | null;
252
- export declare function applyContainerChanges(hostWs: string, containerWs: string, baseline?: string | Record<string, string>, note?: (message: string) => void, divert?: WriteDivert): string[];
273
+ export interface ApplyOptions {
274
+ /** What the host handed back into this sandbox mid-step — a `sandbox: own`
275
+ * job's files (delegate-tools.ts), path → sha256 — as the HOST recorded
276
+ * it (StepRecord.handedBack). The sandbox's own note of the same is only
277
+ * believed where it agrees with this: a step can write that note. */
278
+ handedBack?: Record<string, string | string[]>;
279
+ /** Told of every path the step changed that was not taken back because it
280
+ * is configuration or code (copy-back.ts). */
281
+ onRefused?: (refused: RefusedPath) => void;
282
+ /** A person approved the step before it ran: configuration and code it
283
+ * changed come back, each told to onKept instead of refused. */
284
+ allowConfig?: boolean;
285
+ onKept?: (kept: RefusedPath) => void;
286
+ }
287
+ export declare function applyContainerChanges(hostWs: string, containerWs: string, baseline?: string | Record<string, string>, note?: (message: string) => void, divert?: WriteDivert, opts?: ApplyOptions): string[];
253
288
  /**
254
289
  * Where the driver builds venvs and npm prefixes, as an absolute path *inside*
255
290
  * the runner. Fixed, and it has to be: a venv bakes its own absolute path into
@@ -304,7 +339,7 @@ export declare function runtimeCacheMount(tenant: string | undefined): {
304
339
  target: string;
305
340
  } | null;
306
341
  /** The runner container's entrypoint, as JS text: written into the image as driver.mjs, so it must be plain JS — a TypeScript cast here broke every step on v2026.10.21.5 (tests/run-container.test.ts checks it). */
307
- export declare const DRIVER = "// The runner container's entrypoint. Reads one step, executes it with the\n// same core the server uses, speaks JSONL on stdout, exits.\nimport fs from \"node:fs\";\nconst input = JSON.parse(fs.readFileSync(\"/opt/runner/job/input.json\", \"utf8\"));\n// Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,\n// on the sandbox's clock. A worker that does not know a mark skips the line.\nconst mark = (name) => process.stdout.write(JSON.stringify({ e: \"mark\", name, at: Date.now() }) + \"\\n\");\nmark(\"driver\");\nlet modelSpoke = false;\nconst emit = (type, text, extra) => {\n // The model's first text or tool call is where start-up ends and the\n // step's own work begins \u2014 runtime and tool lines before it are set-up.\n if (!modelSpoke && (type === \"text\" || type === \"tool\")) { modelSpoke = true; mark(\"model\"); }\n process.stdout.write(JSON.stringify({ e: \"event\", type, text, ...extra }) + \"\\n\");\n};\n// What this sandbox actually touched, read at the last moment from the\n// kernel's own books: cgroup v2 for CPU busy-time and peak memory,\n// /proc/net/dev for bytes on the wire. Every read is best-effort \u2014 gVisor\n// exposes some of these files and not others depending on version, and a\n// metric we cannot read is null, never guessed. Reservation is what is\n// billed; these exist so the reservation can be right-sized.\nfunction readActuals() {\n // fs from the module's own import \u2014 the driver is ESM, and a require()\n // here threw on every call, silently nulling every metric it guarded.\n const read = (p) => { try { return fs.readFileSync(p, \"utf8\"); } catch { return null; } };\n // Two cgroup dialects: v2 (cpu.stat, microseconds; memory.peak) on a\n // plain runtime, v1-style controllers under gVisor (cpuacct.usage,\n // NANOseconds; memory.max_usage_in_bytes) \u2014 probed on the real cluster,\n // not assumed. Whichever answers wins; both silent means null.\n const cpuStat = read(\"/sys/fs/cgroup/cpu.stat\");\n const usage = cpuStat?.match(/^usage_usec (\\d+)/m);\n const cpuacctNs = usage ? NaN : Number(read(\"/sys/fs/cgroup/cpuacct/cpuacct.usage\")?.trim());\n // Time the CPU cap held the sandbox back: v2's throttled_usec in the same\n // cpu.stat, v1's throttled_time (nanoseconds) under gVisor. Large against\n // busy time means the step wanted more cores than its class gives.\n const throttledUs = cpuStat?.match(/^throttled_usec (\\d+)/m);\n const throttledNs = throttledUs ? null : read(\"/sys/fs/cgroup/cpu/cpu.stat\")?.match(/^throttled_time (\\d+)/m);\n const peak =\n read(\"/sys/fs/cgroup/memory.peak\") ??\n read(\"/sys/fs/cgroup/memory/memory.max_usage_in_bytes\") ??\n read(\"/sys/fs/cgroup/memory/memory.usage_in_bytes\");\n let rx = 0, tx = 0, sawNet = false;\n const net = read(\"/proc/net/dev\");\n if (net) {\n for (const line of net.split(\"\\n\").slice(2)) {\n const m = line.trim().match(/^(\\S+):\\s+(\\d+)(?:\\s+\\d+){7}\\s+(\\d+)/);\n if (m && m[1] !== \"lo\") { rx += Number(m[2]); tx += Number(m[3]); sawNet = true; }\n }\n }\n return {\n busyCpuSecs: usage\n ? Number(usage[1]) / 1e6\n : Number.isFinite(cpuacctNs) && cpuacctNs > 0\n ? cpuacctNs / 1e9\n : null,\n peakMemBytes: peak ? Number(peak.trim()) || null : null,\n throttledSecs: throttledUs ? Number(throttledUs[1]) / 1e6 : throttledNs ? Number(throttledNs[1]) / 1e9 : null,\n rxBytes: sawNet ? rx : null,\n txBytes: sawNet ? tx : null,\n };\n}\n\ntry {\n // Runtime state (venvs, npm prefixes) lands in the agent user's real home\n // \u2014 writable, inside the container, gone with it.\n process.env.FOLDRUN_DATA = \"/home/agent/.foldrun\";\n const { executeStep } = await import(\"@foldrun/core/step-exec\");\n const { buildApiTools } = await import(\"@foldrun/core/api-tools\");\n const { buildScriptTools } = await import(\"@foldrun/core/script-tools\");\n const { buildConsultTools } = await import(\"@foldrun/core/agent-tools\");\n const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import(\"@foldrun/core/context-tools\");\n const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import(\"@foldrun/core/operator\");\n const { buildDelegateTools } = await import(\"@foldrun/core/delegate-tools\");\n const { startTranslator } = await import(\"@foldrun/core/translator\");\n const { prepareRuntime } = await import(\"@foldrun/core/runtime\");\n const { materializeFileSecrets } = await import(\"@foldrun/core/secret-files\");\n const { paidWebActions } = await import(\"@foldrun/core/browser-pod\");\n mark(\"imports\");\n\n const agentDir = \"/workspace/\" + input.agentRel;\n // HOME is where a bare relative path lands when anything resolves one\n // outside cwd \u2014 and root's /root is outside the workspace, so such a read\n // was refused by confinement with a path the agent never typed. Its own\n // directory is both the truthful answer and the safe one.\n // @file secrets crossed as content; materialise them to 0600 paths inside\n // the container (a host path from the other side would not exist here).\n const env = materializeFileSecrets(agentDir, { ...process.env, HOME: agentDir }).env;\n // Caches go to the sandbox user's own home, not to HOME: the Claude CLI's\n // logs, fontconfig, Mesa and gstreamer all follow XDG_CACHE_HOME, and under\n // agents/<name>/.cache they were copied back after every step \u2014 3,855 files\n // across 179 agent folders on the box by 17 Oct 2026, read by nothing.\n env.XDG_CACHE_HOME = \"/home/agent/.cache\";\n\n // The declared runtime, built in here \u2014 pip and npm have the network, which\n // the host side may not. It lands in .foldrun/runner/.runtimes, which the\n // executors mount from a per-tenant directory (run-container.ts#RUNTIME_CACHE):\n // the first step for a given fingerprint installs, every later one finds\n // .ready and skips. With no mount it still works, just fresh every time \u2014\n // which is what this used to be unconditionally. The log says which happened.\n const runtime = input.runtime\n ? prepareRuntime(\"runner\", input.runtime)\n : { interpreters: {}, env: {}, log: [], error: null };\n for (const line of runtime.log) emit(\"info\", \"runtime: \" + line);\n if (runtime.error) emit(\"error\", \"runtime: \" + runtime.error);\n // The step's own environment gets it too, not only the script tools: an\n // agent that runs \"python3 x.py\" or a shebang script through Bash should\n // meet the packages its runtime declared, not the image's bare python.\n Object.assign(env, runtime.env);\n\n // install: what the agent finds it needs mid-step. apt goes through the\n // root start-up's helper (install-helper.ts); pip and npm land in a\n // directory of this container's own, on the paths from the first turn.\n const { buildInstallTool, INSTALL_TOOL } = await import(\"@foldrun/core/install-tool\");\n const { stepInstallEnv, INSTALL_SOCKET } = await import(\"@foldrun/core/runtime\");\n const installDir = \"/home/agent/.foldrun/step\";\n const installing = (input.allowed ?? []).includes(INSTALL_TOOL);\n if (installing) Object.assign(env, stepInstallEnv(installDir, env));\n const install = installing\n ? buildInstallTool({\n dir: installDir,\n socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,\n cacheRoot: \"/home/agent/.foldrun/runner/.runtimes\",\n python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + \"/bin/python\" : \"python3\",\n env,\n emit,\n })\n : { server: null };\n\n // API and script tools, rebuilt in here from their specs. Secrets were\n // substituted into API headers before the input crossed the boundary, so\n // buildApiTools gets an already-resolved environment. verify: gets the\n // container's own environment, whole: it is the boundary, assembled on\n // the host from an allowlist, and the declared secrets are in it.\n // A long result is kept in a file only when the agent can read it back.\n // (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)\n const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === \"Read\" || t === \"Grep\") };\n // A declared secret named like a model variable crosses as\n // FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own\n // settings, and the agent's API and script tools and verify: get the value\n // back under the name it declared. Built here, after the runtime and any\n // mid-step install have added their paths to env.\n const toolEnv = { ...env };\n for (const [k, v] of Object.entries(env)) if (k.startsWith(\"FOLDRUN_DECLARED_\")) toolEnv[k.slice(\"FOLDRUN_DECLARED_\".length)] = v;\n const api = buildApiTools(\"\", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);\n const script = buildScriptTools(\n agentDir,\n input.scripts,\n toolEnv,\n \"/library/scripts\",\n runtime.interpreters,\n null,\n keepIn.readable,\n );\n // The translator, when the provider speaks Chat Completions: a loopback\n // server for the length of this step. Its env replaces the endpoint the\n // host assembled; the provider's real URL and key stay inside it.\n const translator = input.translator ? await startTranslator(input.translator) : null;\n if (translator) {\n Object.assign(env, translator.env);\n emit(\"info\", \"translator: listening on \" + translator.baseUrl + \" for \" + (input.translator.label ?? input.translator.upstreamBase));\n }\n const consult = buildConsultTools(input.consults ?? [], env, emit);\n // The platform's own two groups, rebuilt from values like everything else.\n const search = buildSearchTools(input.search ?? []);\n const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };\n const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };\n // A person in the loop, over the proxy this step's lease already names:\n // ask_person when tools: [ask], and the run's inbox after every tool call.\n const channel = httpChannel(env.FOLDRUN_EGRESS);\n const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };\n const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };\n const inbox = channel ? inboxReader(channel, emit) : undefined;\n // sandbox: own sub-agents: a tool each, whose job runs in its own sandbox\n // on the worker, reached over the same proxy door.\n const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec, workspaceRoot: \"/workspace\" }) : { server: null };\n\n // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept\n // fresh through the proxy's /identity door, which mints only what this\n // step's lease was granted. Fetched before the step starts, renewed five\n // minutes before it expires (with jitter); a failure is said and retried.\n // Plain JS: this text is the driver, not TypeScript.\n const identityNames = (env.FOLDRUN_IDENTITIES || \"\").split(\",\").filter(Boolean);\n if (identityNames.length) {\n // No backslashes: this sits inside a template literal, which eats them.\n const ep = new RegExp(\"^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$\").exec(env.FOLDRUN_EGRESS || \"\");\n const dir = env.FOLDRUN_IDENTITY_DIR || \"/tmp/foldrun-identity\";\n fs.mkdirSync(dir, { recursive: true, mode: 0o700 });\n const keepFresh = async (name) => {\n let wait = 30000;\n try {\n if (!ep) throw new Error(\"no egress proxy to ask\");\n const res = await fetch(ep[1] + \"/identity/\" + ep[2] + \"/\" + name, { signal: AbortSignal.timeout(15000) });\n const body = await res.json().catch(() => ({}));\n if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || (\"proxy answered \" + res.status));\n const file = dir + \"/\" + name.toLowerCase() + \".jwt\";\n fs.writeFileSync(file + \".tmp\", body.token, { mode: 0o600 });\n fs.renameSync(file + \".tmp\", file);\n wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);\n } catch (err) {\n emit(\"error\", \"identity \" + name + \": \" + (err && err.message ? err.message : String(err)) + \" \u2014 trying again in 30s\");\n }\n setTimeout(() => { void keepFresh(name); }, wait).unref();\n };\n await Promise.all(identityNames.map(keepFresh));\n }\n\n mark(\"tools\");\n const outcome = await executeStep({\n agentDir,\n workspaceRoot: \"/workspace\",\n libraryRoot: \"/library\",\n prompt: input.prompt,\n model: input.model,\n effort: input.effort ?? null,\n systemPrompt: input.systemPrompt,\n allowed: input.allowed,\n mcpNames: input.mcpNames,\n mcpServers: {\n ...(api.server ? { foldrun_apis: api.server } : {}),\n ...(script.server ? { foldrun_scripts: script.server } : {}),\n ...(consult.server ? { foldrun_agents: consult.server } : {}),\n ...(search.server ? { foldrun_search: search.server } : {}),\n ...(history.server ? { foldrun_history: history.server } : {}),\n ...(desks.server ? { foldrun_desks: desks.server } : {}),\n ...(ask.server ? { foldrun_ask: ask.server } : {}),\n ...(peers.server ? { foldrun_peers: peers.server } : {}),\n ...(delegate.server ? { foldrun_delegate: delegate.server } : {}),\n ...(install.server ? { foldrun_install: install.server } : {}),\n ...input.mcpServers,\n },\n ...(inbox ? { inbox } : {}),\n ...(input.subagents?.length ? { subagents: input.subagents } : {}),\n env,\n timeoutSec: input.timeoutSec,\n budgetUsd: input.budgetUsd,\n budgetNote: input.budgetNote,\n price: input.price,\n verify: input.verify,\n verifyEnv: toolEnv,\n ...(typeof input.judgeModel === \"string\" ? { judgeModel: input.judgeModel } : {}),\n ...(typeof input.judgeEffort === \"string\" ? { judgeEffort: input.judgeEffort } : {}),\n output: input.output,\n schema: input.schema,\n maxTurns: input.maxTurns,\n ...(input.capture === \"metadata\" || input.capture === \"full\" ? { capture: input.capture } : {}),\n ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),\n ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),\n // Slim browsing: the platform put this step on the slim image to browse\n // through the account's pod, and named the file the web tool logs its\n // reconnects to. A lost pod stops the step; what it wrote decides what\n // the runner does next (browser-pod.ts).\n ...(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 } : {}) } } : {}),\n // The container is the boundary; the SDK's own bash sandbox here would\n // only block declared network use (SSH, curl) for no added safety.\n sandboxBash: false,\n emit,\n });\n for (const line of api.drainLog()) emit(\"info\", \"api: \" + line);\n for (const line of script.drainLog()) emit(\"info\", \"script: \" + line);\n if (translator) {\n for (const line of translator.drainLog()) emit(\"info\", line.startsWith(\"translator\") ? line : \"translator: \" + line);\n await translator.close();\n }\n // A consult's spend belongs to the step that asked.\n const consultCost = consult.drainCost();\n if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;\n if (consultCost > 0 && typeof outcome.turnsCostUsd === \"number\") outcome.turnsCostUsd += consultCost;\n process.stdout.write(JSON.stringify({ e: \"done\", ...outcome, res: readActuals() }) + \"\\n\");\n process.exit(0);\n} catch (err) {\n emit(\"error\", err instanceof Error ? err.message : String(err));\n // The failure path reports actuals too \u2014 a step that died eleven minutes\n // in still held its slice for eleven minutes, and the failed runs are\n // exactly the ones whose resource story gets read.\n let res = null;\n try { res = readActuals(); } catch { /* the reading must never mask the error */ }\n process.stdout.write(JSON.stringify({ e: \"done\", status: \"failed\", result: null, costUsd: null, res }) + \"\\n\");\n process.exit(0);\n}\n";
342
+ export declare const DRIVER = "// The runner container's entrypoint. Reads one step, executes it with the\n// same core the server uses, speaks JSONL on stdout, exits.\nimport fs from \"node:fs\";\nconst input = JSON.parse(fs.readFileSync(\"/opt/runner/job/input.json\", \"utf8\"));\n// Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,\n// on the sandbox's clock. A worker that does not know a mark skips the line.\nconst mark = (name) => process.stdout.write(JSON.stringify({ e: \"mark\", name, at: Date.now() }) + \"\\n\");\nmark(\"driver\");\nlet modelSpoke = false;\nconst emit = (type, text, extra) => {\n // The model's first text or tool call is where start-up ends and the\n // step's own work begins \u2014 runtime and tool lines before it are set-up.\n if (!modelSpoke && (type === \"text\" || type === \"tool\")) { modelSpoke = true; mark(\"model\"); }\n process.stdout.write(JSON.stringify({ e: \"event\", type, text, ...extra }) + \"\\n\");\n};\n// What this sandbox actually touched, read at the last moment from the\n// kernel's own books: cgroup v2 for CPU busy-time and peak memory,\n// /proc/net/dev for bytes on the wire. Every read is best-effort \u2014 gVisor\n// exposes some of these files and not others depending on version, and a\n// metric we cannot read is null, never guessed. Reservation is what is\n// billed; these exist so the reservation can be right-sized.\nfunction readActuals() {\n // fs from the module's own import \u2014 the driver is ESM, and a require()\n // here threw on every call, silently nulling every metric it guarded.\n const read = (p) => { try { return fs.readFileSync(p, \"utf8\"); } catch { return null; } };\n // Two cgroup dialects: v2 (cpu.stat, microseconds; memory.peak) on a\n // plain runtime, v1-style controllers under gVisor (cpuacct.usage,\n // NANOseconds; memory.max_usage_in_bytes) \u2014 probed on the real cluster,\n // not assumed. Whichever answers wins; both silent means null.\n const cpuStat = read(\"/sys/fs/cgroup/cpu.stat\");\n const usage = cpuStat?.match(/^usage_usec (\\d+)/m);\n const cpuacctNs = usage ? NaN : Number(read(\"/sys/fs/cgroup/cpuacct/cpuacct.usage\")?.trim());\n // Time the CPU cap held the sandbox back: v2's throttled_usec in the same\n // cpu.stat, v1's throttled_time (nanoseconds) under gVisor. Large against\n // busy time means the step wanted more cores than its class gives.\n const throttledUs = cpuStat?.match(/^throttled_usec (\\d+)/m);\n const throttledNs = throttledUs ? null : read(\"/sys/fs/cgroup/cpu/cpu.stat\")?.match(/^throttled_time (\\d+)/m);\n const peak =\n read(\"/sys/fs/cgroup/memory.peak\") ??\n read(\"/sys/fs/cgroup/memory/memory.max_usage_in_bytes\") ??\n read(\"/sys/fs/cgroup/memory/memory.usage_in_bytes\");\n let rx = 0, tx = 0, sawNet = false;\n const net = read(\"/proc/net/dev\");\n if (net) {\n for (const line of net.split(\"\\n\").slice(2)) {\n const m = line.trim().match(/^(\\S+):\\s+(\\d+)(?:\\s+\\d+){7}\\s+(\\d+)/);\n if (m && m[1] !== \"lo\") { rx += Number(m[2]); tx += Number(m[3]); sawNet = true; }\n }\n }\n return {\n busyCpuSecs: usage\n ? Number(usage[1]) / 1e6\n : Number.isFinite(cpuacctNs) && cpuacctNs > 0\n ? cpuacctNs / 1e9\n : null,\n peakMemBytes: peak ? Number(peak.trim()) || null : null,\n throttledSecs: throttledUs ? Number(throttledUs[1]) / 1e6 : throttledNs ? Number(throttledNs[1]) / 1e9 : null,\n rxBytes: sawNet ? rx : null,\n txBytes: sawNet ? tx : null,\n };\n}\n\ntry {\n // Runtime state (venvs, npm prefixes) lands in the agent user's real home\n // \u2014 writable, inside the container, gone with it.\n process.env.FOLDRUN_DATA = \"/home/agent/.foldrun\";\n const { executeStep } = await import(\"@foldrun/core/step-exec\");\n const { buildApiTools } = await import(\"@foldrun/core/api-tools\");\n const { buildScriptTools } = await import(\"@foldrun/core/script-tools\");\n const { buildConsultTools } = await import(\"@foldrun/core/agent-tools\");\n const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import(\"@foldrun/core/context-tools\");\n const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import(\"@foldrun/core/operator\");\n const { buildDelegateTools } = await import(\"@foldrun/core/delegate-tools\");\n const { startTranslator } = await import(\"@foldrun/core/translator\");\n const { prepareRuntime } = await import(\"@foldrun/core/runtime\");\n const { materializeFileSecrets } = await import(\"@foldrun/core/secret-files\");\n const { paidWebActions } = await import(\"@foldrun/core/browser-pod\");\n mark(\"imports\");\n\n const agentDir = \"/workspace/\" + input.agentRel;\n // HOME is where a bare relative path lands when anything resolves one\n // outside cwd \u2014 and root's /root is outside the workspace, so such a read\n // was refused by confinement with a path the agent never typed. Its own\n // directory is both the truthful answer and the safe one.\n // @file secrets crossed as content; materialise them to 0600 paths inside\n // the container (a host path from the other side would not exist here).\n const env = materializeFileSecrets(agentDir, { ...process.env, HOME: agentDir }).env;\n // Caches go to the sandbox user's own home, not to HOME: the Claude CLI's\n // logs, fontconfig, Mesa and gstreamer all follow XDG_CACHE_HOME, and under\n // agents/<name>/.cache they were copied back after every step \u2014 3,855 files\n // across 179 agent folders on the box by 17 Oct 2026, read by nothing.\n env.XDG_CACHE_HOME = \"/home/agent/.cache\";\n\n // The declared runtime, built in here \u2014 pip and npm have the network, which\n // the host side may not. It lands in .foldrun/runner/.runtimes, which the\n // executors mount from a per-tenant directory (run-container.ts#RUNTIME_CACHE):\n // the first step for a given fingerprint installs, every later one finds\n // .ready and skips. With no mount it still works, just fresh every time \u2014\n // which is what this used to be unconditionally. The log says which happened.\n const runtime = input.runtime\n ? prepareRuntime(\"runner\", input.runtime)\n : { interpreters: {}, env: {}, log: [], error: null };\n for (const line of runtime.log) emit(\"info\", \"runtime: \" + line);\n if (runtime.error) emit(\"error\", \"runtime: \" + runtime.error);\n // The step's own environment gets it too, not only the script tools: an\n // agent that runs \"python3 x.py\" or a shebang script through Bash should\n // meet the packages its runtime declared, not the image's bare python.\n Object.assign(env, runtime.env);\n\n // install: what the agent finds it needs mid-step. apt goes through the\n // root start-up's helper (install-helper.ts); pip and npm land in a\n // directory of this container's own, on the paths from the first turn.\n const { buildInstallTool, INSTALL_TOOL } = await import(\"@foldrun/core/install-tool\");\n const { stepInstallEnv, INSTALL_SOCKET } = await import(\"@foldrun/core/runtime\");\n const installDir = \"/home/agent/.foldrun/step\";\n const installing = (input.allowed ?? []).includes(INSTALL_TOOL);\n if (installing) Object.assign(env, stepInstallEnv(installDir, env));\n const install = installing\n ? buildInstallTool({\n dir: installDir,\n socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,\n cacheRoot: \"/home/agent/.foldrun/runner/.runtimes\",\n python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + \"/bin/python\" : \"python3\",\n env,\n emit,\n })\n : { server: null };\n\n // API and script tools, rebuilt in here from their specs. Secrets were\n // substituted into API headers before the input crossed the boundary, so\n // buildApiTools gets an already-resolved environment. verify: gets the\n // container's own environment, whole: it is the boundary, assembled on\n // the host from an allowlist, and the declared secrets are in it.\n // A long result is kept in a file only when the agent can read it back.\n // (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)\n const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === \"Read\" || t === \"Grep\") };\n // A declared secret named like a model variable crosses as\n // FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own\n // settings, and the agent's API and script tools and verify: get the value\n // back under the name it declared. Built here, after the runtime and any\n // mid-step install have added their paths to env.\n const toolEnv = { ...env };\n for (const [k, v] of Object.entries(env)) if (k.startsWith(\"FOLDRUN_DECLARED_\")) toolEnv[k.slice(\"FOLDRUN_DECLARED_\".length)] = v;\n const api = buildApiTools(\"\", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);\n const script = buildScriptTools(\n agentDir,\n input.scripts,\n toolEnv,\n \"/library/scripts\",\n runtime.interpreters,\n null,\n keepIn.readable,\n );\n // The translator, when the provider speaks Chat Completions: a loopback\n // server for the length of this step. Its env replaces the endpoint the\n // host assembled; the provider's real URL and key stay inside it.\n const translator = input.translator ? await startTranslator(input.translator) : null;\n if (translator) {\n Object.assign(env, translator.env);\n emit(\"info\", \"translator: listening on \" + translator.baseUrl + \" for \" + (input.translator.label ?? input.translator.upstreamBase));\n }\n const consult = buildConsultTools(input.consults ?? [], env, emit);\n // The platform's own two groups, rebuilt from values like everything else.\n const search = buildSearchTools(input.search ?? []);\n const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };\n const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };\n // A person in the loop, over the proxy this step's lease already names:\n // ask_person when tools: [ask], and the run's inbox after every tool call.\n const channel = httpChannel(env.FOLDRUN_EGRESS);\n const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };\n const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };\n const inbox = channel ? inboxReader(channel, emit) : undefined;\n // sandbox: own sub-agents: a tool each, whose job runs in its own sandbox\n // on the worker, reached over the same proxy door.\n const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec, workspaceRoot: \"/workspace\" }) : { server: null };\n\n // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept\n // fresh through the proxy's /identity door, which mints only what this\n // step's lease was granted. Fetched before the step starts, renewed five\n // minutes before it expires (with jitter); a failure is said and retried.\n // Plain JS: this text is the driver, not TypeScript.\n const identityNames = (env.FOLDRUN_IDENTITIES || \"\").split(\",\").filter(Boolean);\n if (identityNames.length) {\n // No backslashes: this sits inside a template literal, which eats them.\n const ep = new RegExp(\"^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$\").exec(env.FOLDRUN_EGRESS || \"\");\n const dir = env.FOLDRUN_IDENTITY_DIR || \"/tmp/foldrun-identity\";\n fs.mkdirSync(dir, { recursive: true, mode: 0o700 });\n const keepFresh = async (name) => {\n let wait = 30000;\n try {\n if (!ep) throw new Error(\"no egress proxy to ask\");\n const res = await fetch(ep[1] + \"/identity/\" + ep[2] + \"/\" + name, { signal: AbortSignal.timeout(15000) });\n const body = await res.json().catch(() => ({}));\n if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || (\"proxy answered \" + res.status));\n const file = dir + \"/\" + name.toLowerCase() + \".jwt\";\n fs.writeFileSync(file + \".tmp\", body.token, { mode: 0o600 });\n fs.renameSync(file + \".tmp\", file);\n wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);\n } catch (err) {\n emit(\"error\", \"identity \" + name + \": \" + (err && err.message ? err.message : String(err)) + \" \u2014 trying again in 30s\");\n }\n setTimeout(() => { void keepFresh(name); }, wait).unref();\n };\n await Promise.all(identityNames.map(keepFresh));\n }\n\n mark(\"tools\");\n const outcome = await executeStep({\n agentDir,\n workspaceRoot: \"/workspace\",\n libraryRoot: \"/library\",\n configWrites: input.configWrites === true,\n prompt: input.prompt,\n model: input.model,\n effort: input.effort ?? null,\n systemPrompt: input.systemPrompt,\n allowed: input.allowed,\n mcpNames: input.mcpNames,\n mcpServers: {\n ...(api.server ? { foldrun_apis: api.server } : {}),\n ...(script.server ? { foldrun_scripts: script.server } : {}),\n ...(consult.server ? { foldrun_agents: consult.server } : {}),\n ...(search.server ? { foldrun_search: search.server } : {}),\n ...(history.server ? { foldrun_history: history.server } : {}),\n ...(desks.server ? { foldrun_desks: desks.server } : {}),\n ...(ask.server ? { foldrun_ask: ask.server } : {}),\n ...(peers.server ? { foldrun_peers: peers.server } : {}),\n ...(delegate.server ? { foldrun_delegate: delegate.server } : {}),\n ...(install.server ? { foldrun_install: install.server } : {}),\n ...input.mcpServers,\n },\n ...(inbox ? { inbox } : {}),\n ...(input.subagents?.length ? { subagents: input.subagents } : {}),\n env,\n timeoutSec: input.timeoutSec,\n budgetUsd: input.budgetUsd,\n budgetNote: input.budgetNote,\n price: input.price,\n verify: input.verify,\n verifyEnv: toolEnv,\n ...(typeof input.judgeModel === \"string\" ? { judgeModel: input.judgeModel } : {}),\n ...(typeof input.judgeEffort === \"string\" ? { judgeEffort: input.judgeEffort } : {}),\n output: input.output,\n schema: input.schema,\n maxTurns: input.maxTurns,\n ...(input.capture === \"metadata\" || input.capture === \"full\" ? { capture: input.capture } : {}),\n ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),\n ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),\n // Slim browsing: the platform put this step on the slim image to browse\n // through the account's pod, and named the file the web tool logs its\n // reconnects to. A lost pod stops the step; what it wrote decides what\n // the runner does next (browser-pod.ts).\n ...(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 } : {}) } } : {}),\n // The container is the boundary; the SDK's own bash sandbox here would\n // only block declared network use (SSH, curl) for no added safety.\n sandboxBash: false,\n emit,\n });\n for (const line of api.drainLog()) emit(\"info\", \"api: \" + line);\n for (const line of script.drainLog()) emit(\"info\", \"script: \" + line);\n if (translator) {\n for (const line of translator.drainLog()) emit(\"info\", line.startsWith(\"translator\") ? line : \"translator: \" + line);\n await translator.close();\n }\n // A consult's spend belongs to the step that asked.\n const consultCost = consult.drainCost();\n if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;\n if (consultCost > 0 && typeof outcome.turnsCostUsd === \"number\") outcome.turnsCostUsd += consultCost;\n process.stdout.write(JSON.stringify({ e: \"done\", ...outcome, res: readActuals() }) + \"\\n\");\n process.exit(0);\n} catch (err) {\n emit(\"error\", err instanceof Error ? err.message : String(err));\n // The failure path reports actuals too \u2014 a step that died eleven minutes\n // in still held its slice for eleven minutes, and the failed runs are\n // exactly the ones whose resource story gets read.\n let res = null;\n try { res = readActuals(); } catch { /* the reading must never mask the error */ }\n process.stdout.write(JSON.stringify({ e: \"done\", status: \"failed\", result: null, costUsd: null, res }) + \"\\n\");\n process.exit(0);\n}\n";
308
343
  /** The uv release copied into the image. Pinned: a floating uv is a floating
309
344
  * resolver, and the runtime cache is keyed on the declaration, not on it. */
310
345
  export declare const UV_VERSION = "0.11.25";
@@ -437,6 +472,9 @@ export declare function dockerCpus(cpus: string, available?: number): string | n
437
472
  export declare function heldCores(cpus: string, isolation: string | undefined, available?: number): number;
438
473
  export interface RunInContainerArgs {
439
474
  workspaceRoot: string;
475
+ /** A person approved this step before it ran: configuration and code it
476
+ * changed are kept, not refused (copy-back.ts, FOL-96). */
477
+ configWrites?: boolean;
440
478
  libraryRoot: string;
441
479
  /** The platform's gallery, staged under /library BEFORE the account library so the account's copies win. */
442
480
  galleryRoot?: string;
@@ -495,6 +533,10 @@ export interface RunInContainerArgs {
495
533
  /** A test run: where state/ and storage/ changes go instead of the
496
534
  * workspace, and who to tell about each. See test-mode.ts. */
497
535
  divert?: WriteDivert;
536
+ /** What the host handed back into this sandbox mid-step (StepRecord.
537
+ * handedBack), read at copy-out — kept on the host, never read back from
538
+ * the sandbox alone (FOL-96). */
539
+ handedBack?: () => Record<string, string | string[]> | undefined;
498
540
  /** The run is a test run. An isolated executor labels the sandbox so the
499
541
  * cluster can cut its network down to the egress proxy: a script tool
500
542
  * calls the network directly, not through the proxy where the test-mode
@@ -25,6 +25,7 @@ import crypto from "node:crypto";
25
25
  import { spawn, spawnSync } from "node:child_process";
26
26
  import { CAPTURE_CAP, isPlatformPath } from "./store.js";
27
27
  import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.js";
28
+ import { refusedBack, refusedLine, keptLine } from "./copy-back.js";
28
29
  import { isFileValue, fileContent } from "./secrets.js";
29
30
  import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET } from "./runtime.js";
30
31
  import { egressCaSh } from "./network.js";
@@ -176,6 +177,46 @@ export function hashTree(dir) {
176
177
  walk(dir);
177
178
  return out;
178
179
  }
180
+ /**
181
+ * Where the host keeps what a sandbox was handed (hashTree), for a driver
182
+ * that inherits the step: under the run's own folder, which is platform-
183
+ * owned — never staged into a sandbox, never copied back, erased with the
184
+ * run (deleteRun). It used to ride into the pod beside the input and be
185
+ * read back from there, where the step could rewrite it (FOL-96).
186
+ */
187
+ export function handedInPath(workspaceRoot, runId, ref) {
188
+ const safe = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
189
+ if (!safe.test(runId) || !safe.test(ref))
190
+ return null;
191
+ return path.join(workspaceRoot, "runs", runId, "sandbox", `${ref}.json`);
192
+ }
193
+ /** Note what a sandbox was handed, on the host. */
194
+ export function writeHandedIn(workspaceRoot, runId, ref, hashes) {
195
+ const file = handedInPath(workspaceRoot, runId, ref);
196
+ if (!file)
197
+ return;
198
+ fs.mkdirSync(path.dirname(file), { recursive: true });
199
+ fs.writeFileSync(file, JSON.stringify(hashes));
200
+ }
201
+ /** What the host noted a sandbox was handed, or undefined. */
202
+ export function readHandedIn(workspaceRoot, runId, ref) {
203
+ const file = handedInPath(workspaceRoot, runId, ref);
204
+ if (!file)
205
+ return undefined;
206
+ try {
207
+ const m = JSON.parse(fs.readFileSync(file, "utf8"));
208
+ return m && typeof m === "object" && !Array.isArray(m) ? m : undefined;
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ }
214
+ /** Forget it once the step's copy-out is done. */
215
+ export function dropHandedIn(workspaceRoot, runId, ref) {
216
+ const file = handedInPath(workspaceRoot, runId, ref);
217
+ if (file)
218
+ fs.rmSync(file, { force: true });
219
+ }
179
220
  /**
180
221
  * Every file's mtime under `dir`, relative path to milliseconds — so a copy
181
222
  * that cannot carry times can have them put back. `kubectl cp` unpacks with
@@ -258,13 +299,17 @@ export function mergeAppends(was, host, next) {
258
299
  const joined = hostAdded && !hostAdded.endsWith("\n") ? hostAdded + "\n" : hostAdded;
259
300
  return Buffer.from(h.slice(0, end) + joined + stepAdded, "utf8");
260
301
  }
261
- export function applyContainerChanges(hostWs, containerWs, baseline, note, divert) {
302
+ export function applyContainerChanges(hostWs, containerWs, baseline, note, divert, opts = {}) {
262
303
  const applied = [];
263
304
  // Files a sandbox: own job's reply copied into this sandbox mid-step
264
305
  // (delegate-tools.ts copyBackFiles), path → sha256 of what was written: for
265
306
  // those, that is what the step started from. The job's change is already on
266
307
  // the workspace; read against the older baseline it came back as the
267
308
  // step's own and was merged in a second time (Codex review, 23 Oct 2026).
309
+ // The sandbox notes which it actually wrote (a conflict goes beside the
310
+ // file instead), but the step can write that note too, so an entry counts
311
+ // only where the host handed exactly those bytes back (FOL-96).
312
+ const handedBack = opts.handedBack ?? {};
268
313
  let imported = {};
269
314
  try {
270
315
  // Read here, on the host: a link the step planted is never followed,
@@ -273,8 +318,16 @@ export function applyContainerChanges(hostWs, containerWs, baseline, note, diver
273
318
  const file = path.join(dir, "copied-back.json");
274
319
  if (fs.lstatSync(dir).isDirectory() && fs.lstatSync(file).isFile()) {
275
320
  const m = JSON.parse(fs.readFileSync(file, "utf8"));
276
- if (m && typeof m === "object" && !Array.isArray(m))
277
- imported = m;
321
+ if (m && typeof m === "object" && !Array.isArray(m)) {
322
+ for (const [rel, h] of Object.entries(m)) {
323
+ // Every version the host handed back for the path counts: two jobs
324
+ // can return the same file, and the sandbox may hold the first
325
+ // (the second went beside a parent edit as a conflict copy).
326
+ const ok = handedBack[rel];
327
+ if (typeof h === "string" && (Array.isArray(ok) ? ok.includes(h) : ok === h))
328
+ imported[rel] = h;
329
+ }
330
+ }
278
331
  }
279
332
  }
280
333
  catch {
@@ -322,6 +375,18 @@ export function applyContainerChanges(hostWs, containerWs, baseline, note, diver
322
375
  const hostNow = fs.existsSync(target) ? fs.readFileSync(target) : null;
323
376
  if (hostNow && hostNow.equals(next))
324
377
  continue;
378
+ // Configuration and code are a person's (copy-back.ts): refused here,
379
+ // after "did the step change it" and before any merge, conflict copy
380
+ // or test divert — so none of those can carry it to the workspace,
381
+ // and the next step is staged from the workspace's own copy.
382
+ const refused = refusedBack(rel);
383
+ if (refused && opts.allowConfig) {
384
+ opts.onKept?.({ path: rel.replaceAll("\\", "/"), kind: refused });
385
+ }
386
+ else if (refused) {
387
+ opts.onRefused?.({ path: rel.replaceAll("\\", "/"), kind: refused });
388
+ continue;
389
+ }
325
390
  // The host moved too since the step was handed its copy — another run
326
391
  // or a person wrote it. Writing the step's copy over it erased the
327
392
  // other run's ledger rows on 2026-09-14. Merge appends; keep both
@@ -646,6 +711,7 @@ try {
646
711
  agentDir,
647
712
  workspaceRoot: "/workspace",
648
713
  libraryRoot: "/library",
714
+ configWrites: input.configWrites === true,
649
715
  prompt: input.prompt,
650
716
  model: input.model,
651
717
  effort: input.effort ?? null,
@@ -1643,8 +1709,19 @@ export async function runStepInContainer(args) {
1643
1709
  fs.mkdirSync(wsOut);
1644
1710
  const back = spawnSync(cli(), ["cp", `${containerId}:/workspace/.`, wsOut], { encoding: "utf8" });
1645
1711
  let applied = [];
1712
+ const refused = [];
1713
+ const kept = [];
1646
1714
  if (back.status === 0) {
1647
- applied = applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert);
1715
+ applied = applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert, {
1716
+ handedBack: args.handedBack?.(),
1717
+ onRefused: (r) => refused.push(r),
1718
+ allowConfig: args.configWrites === true,
1719
+ onKept: (r) => kept.push(r),
1720
+ });
1721
+ if (refused.length)
1722
+ args.emit("info", refusedLine(refused));
1723
+ if (kept.length)
1724
+ args.emit("info", keptLine(kept));
1648
1725
  }
1649
1726
  else {
1650
1727
  args.emit("error", `copy-out failed — the step's file changes were lost:\n${back.stderr.slice(0, 500)}`);
@@ -1658,7 +1735,7 @@ export async function runStepInContainer(args) {
1658
1735
  phases,
1659
1736
  };
1660
1737
  args.emit("info", timingLine(timing));
1661
- return { ...outcome, timing, applied };
1738
+ return { ...outcome, timing, applied, ...(refused.length ? { refused } : {}) };
1662
1739
  }
1663
1740
  finally {
1664
1741
  if (containerId)