@foldrun/core 2.5.0 → 2.7.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 (70) hide show
  1. package/dist/src/agent-import.js +2 -2
  2. package/dist/src/agent-tools.js +2 -2
  3. package/dist/src/agents-md.d.ts +5 -1
  4. package/dist/src/agents-md.js +42 -18
  5. package/dist/src/capture.js +39 -1
  6. package/dist/src/completions.js +25 -3
  7. package/dist/src/deploy.js +72 -7
  8. package/dist/src/egress.d.ts +16 -0
  9. package/dist/src/evals.d.ts +22 -1
  10. package/dist/src/evals.js +43 -14
  11. package/dist/src/flow-lint.d.ts +14 -0
  12. package/dist/src/flow-lint.js +35 -0
  13. package/dist/src/flow-patterns.js +29 -14
  14. package/dist/src/frontmatter-fields.d.ts +27 -0
  15. package/dist/src/frontmatter-fields.js +51 -3
  16. package/dist/src/frontmatter-guard.d.ts +19 -0
  17. package/dist/src/frontmatter-guard.js +67 -0
  18. package/dist/src/frontmatter-schema.d.ts +2 -2
  19. package/dist/src/frontmatter-schema.js +30 -2
  20. package/dist/src/frontmatter-text.d.ts +31 -0
  21. package/dist/src/frontmatter-text.js +102 -0
  22. package/dist/src/judge-verdict.d.ts +19 -0
  23. package/dist/src/judge-verdict.js +54 -0
  24. package/dist/src/matter.d.ts +34 -0
  25. package/dist/src/matter.js +104 -0
  26. package/dist/src/network.d.ts +0 -1
  27. package/dist/src/network.js +10 -7
  28. package/dist/src/package.js +9 -2
  29. package/dist/src/provider-health.js +2 -2
  30. package/dist/src/run-container.d.ts +9 -0
  31. package/dist/src/run-container.js +34 -1
  32. package/dist/src/runner.js +193 -39
  33. package/dist/src/scan.d.ts +11 -0
  34. package/dist/src/scan.js +86 -8
  35. package/dist/src/secret-files.js +23 -2
  36. package/dist/src/secrets.d.ts +62 -2
  37. package/dist/src/secrets.js +206 -20
  38. package/dist/src/starter.js +23 -0
  39. package/dist/src/step-exec.d.ts +10 -2
  40. package/dist/src/step-exec.js +12 -9
  41. package/dist/src/store.d.ts +17 -3
  42. package/dist/src/store.js +14 -10
  43. package/package.json +1 -1
  44. package/src/agent-import.ts +2 -2
  45. package/src/agent-tools.ts +2 -2
  46. package/src/agents-md.ts +39 -14
  47. package/src/capture.ts +33 -1
  48. package/src/completions.ts +26 -3
  49. package/src/deploy.ts +59 -7
  50. package/src/egress.ts +15 -1
  51. package/src/evals.ts +45 -13
  52. package/src/flow-lint.ts +36 -0
  53. package/src/flow-patterns.ts +26 -13
  54. package/src/frontmatter-fields.ts +74 -3
  55. package/src/frontmatter-guard.ts +70 -0
  56. package/src/frontmatter-schema.ts +30 -4
  57. package/src/frontmatter-text.ts +109 -0
  58. package/src/judge-verdict.ts +52 -0
  59. package/src/matter.ts +112 -0
  60. package/src/network.ts +10 -6
  61. package/src/package.ts +8 -2
  62. package/src/provider-health.ts +2 -2
  63. package/src/run-container.ts +41 -1
  64. package/src/runner.ts +189 -40
  65. package/src/scan.ts +86 -8
  66. package/src/secret-files.ts +22 -2
  67. package/src/secrets.ts +237 -23
  68. package/src/starter.ts +23 -0
  69. package/src/step-exec.ts +21 -8
  70. package/src/store.ts +23 -10
@@ -20,9 +20,113 @@
20
20
  // tests/matter.test.ts and foldrun-web's consistency test hold every repo to
21
21
  // it.
22
22
  import grayMatter from "gray-matter";
23
+ import { JOBS, FOLDABLE } from "./frontmatter-fields.js";
24
+ /** Where the four job blocks put things, on the parsed data — out of sight
25
+ * of every reader that spreads it or writes it out as JSON. */
26
+ const FOLD = Symbol.for("foldrun.jobBlocks");
27
+ /** What the fold did to this data, or null when it had no blocks. */
28
+ export function jobFold(data) {
29
+ return data && typeof data === "object" ? (data[FOLD] ?? null) : null;
30
+ }
31
+ /**
32
+ * The four job blocks (`governance:`, `guardrail:`, `observability:`,
33
+ * `evaluation:`) read as the flat keys they hold (frontmatter-fields.ts
34
+ * JOB_FIELDS). Only a key some config kind lets that block hold is lifted;
35
+ * anything else stays in the block for check to report. A key set both ways
36
+ * keeps its top-level value and is recorded. Nothing parsed is changed: new
37
+ * objects are built (YAML aliases share nodes — deleting from one would
38
+ * delete from the other).
39
+ */
40
+ function foldJobs(data) {
41
+ let fold = null;
42
+ let out = null;
43
+ for (const { name } of JOBS) {
44
+ const block = data[name];
45
+ if (!block || typeof block !== "object" || Array.isArray(block) || block instanceof Date)
46
+ continue;
47
+ const inner = block;
48
+ const lift = Object.keys(inner).filter((k) => FOLDABLE[name].has(k));
49
+ if (!lift.length)
50
+ continue;
51
+ out ??= { ...data };
52
+ fold ??= { folded: new Map(), conflicts: new Map() };
53
+ const rest = {};
54
+ for (const [k, v] of Object.entries(inner))
55
+ if (!lift.includes(k))
56
+ rest[k] = v;
57
+ for (const k of lift) {
58
+ if (Object.prototype.hasOwnProperty.call(data, k))
59
+ fold.conflicts.set(k, name);
60
+ else {
61
+ out[k] = inner[k];
62
+ fold.folded.set(k, name);
63
+ }
64
+ }
65
+ if (Object.keys(rest).length)
66
+ out[name] = rest;
67
+ else
68
+ delete out[name];
69
+ }
70
+ if (!out || !fold)
71
+ return data;
72
+ Object.defineProperty(out, FOLD, { value: fold, enumerable: false });
73
+ return out;
74
+ }
23
75
  function matter(input, options) {
24
76
  return grayMatter(input, options ?? {});
25
77
  }
26
78
  matter.stringify = grayMatter.stringify;
27
79
  matter.test = grayMatter.test;
80
+ /**
81
+ * matter(), for the three files that hold configuration — an agent, a flow,
82
+ * AGENTS.md — with the four job blocks read as flat keys. Every reader of
83
+ * those files goes through this (tests/jobs.test.ts holds them to it);
84
+ * knowledge, memory, skills and every other document keep matter(), so a
85
+ * note's own `governance:` is its own (Codex debate, 21 Oct 2026).
86
+ */
87
+ export function configMatter(input, options) {
88
+ const file = grayMatter(input, options ?? {});
89
+ if (file.data && typeof file.data === "object" && !Array.isArray(file.data)) {
90
+ file.data = foldJobs(file.data);
91
+ }
92
+ return file;
93
+ }
28
94
  export default matter;
95
+ /** The first line of a YAML parse error, with where it is: what a person
96
+ * needs to fix the file, without the parser's excerpt. */
97
+ export function frontmatterErrorText(err) {
98
+ const msg = err instanceof Error ? err.message : String(err);
99
+ return `frontmatter does not parse — ${msg.split("\n")[0].trim().replace(/:$/, "")}. Quote a value that contains a colon followed by a space.`;
100
+ }
101
+ /** The 1-based file line a YAML parse error points at, when it says. The
102
+ * text gray-matter hands the parser starts with the newline after the
103
+ * opening `---`, so the parser's 0-based line is the file's 1-based one. */
104
+ export function frontmatterErrorLine(err) {
105
+ const line = err?.mark?.line;
106
+ return typeof line === "number" ? line + 1 : undefined;
107
+ }
108
+ /** Frontmatter as the runtime reads a skill: strictly first, then once more
109
+ * with every unquoted value that holds a colon quoted (the commonest way a
110
+ * hand-written SKILL.md breaks, and one other skills clients forgive), and
111
+ * empty when even that fails. `lenient` says the second read was needed. */
112
+ export function parseLenient(raw) {
113
+ try {
114
+ return { data: matter(raw).data, lenient: false, failed: false };
115
+ }
116
+ catch {
117
+ const fm = raw.match(/^\uFEFF?---\r?\n([\s\S]*?)\r?\n---/);
118
+ if (fm) {
119
+ const fixed = fm[1].replace(/^([A-Za-z0-9_-]+):[ \t]+(?!["'>|])(.*:.*)$/gm, (_m, k, v) => `${k}: ${JSON.stringify(v)}`);
120
+ try {
121
+ // A function replacer, not a string: a fixed block containing `$&` or
122
+ // `$$` (a value with those in it) would otherwise be treated as a
123
+ // replacement pattern and splice the whole frontmatter into itself.
124
+ return { data: matter(raw.replace(fm[1], () => fixed)).data, lenient: true, failed: false };
125
+ }
126
+ catch {
127
+ /* fall through */
128
+ }
129
+ }
130
+ return { data: {}, lenient: false, failed: true };
131
+ }
132
+ }
@@ -1,4 +1,3 @@
1
- /** How wide a step's network is. */
2
1
  export type NetworkLevel = "open" | "trusted" | "none";
3
2
  export declare const NETWORK_LEVELS: readonly NetworkLevel[];
4
3
  /** One level's `network:` line, read. Absent fields inherit. */
@@ -26,6 +26,8 @@
26
26
  // proxy and DNS and nothing else, so a program that ignores the proxy
27
27
  // variables fails instead of going round. This file is the vocabulary; the
28
28
  // proxy (foldrun-platform egress-proxy.ts) holds the rule per request.
29
+ /** How wide a step's network is. */
30
+ import { keySpan, dropEmptyBlock } from "./frontmatter-text.js";
29
31
  export const NETWORK_LEVELS = ["open", "trusted", "none"];
30
32
  /**
31
33
  * The trusted list: the domains Anthropic's Claude Code cloud environments
@@ -370,15 +372,16 @@ export function setNetworkSetting(raw, setting) {
370
372
  const close = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
371
373
  if (close < 0)
372
374
  return raw;
373
- const at = lines.findIndex((l, i) => i > 0 && i < close && /^network\s*:/.test(l));
374
- if (at < 0) {
375
+ // Where network: is written — at the top or inside `guardrail:` — is
376
+ // where it is set (frontmatter-text.ts); written nowhere, it goes last.
377
+ const where = keySpan(lines.slice(1, close), "network");
378
+ if (!where) {
375
379
  if (line)
376
380
  lines.splice(close, 0, line);
377
381
  return lines.join(eol);
378
382
  }
379
- let end = at + 1;
380
- while (end < close && (/^\s+\S/.test(lines[end]) || /^\s*-\s/.test(lines[end])))
381
- end++;
382
- lines.splice(at, end - at, ...(line ? [line] : []));
383
- return lines.join(eol);
383
+ const front = lines.slice(1, close);
384
+ front.splice(where.at, where.end - where.at, ...(line ? [`${where.indent}${line}`] : []));
385
+ const edited = !line && where.block !== null ? dropEmptyBlock(front, where.block) : front;
386
+ return [lines[0], ...edited, ...lines.slice(close)].join(eol);
384
387
  }
@@ -25,7 +25,7 @@
25
25
  // claim, the files are what will run.
26
26
  import fs from "node:fs";
27
27
  import path from "node:path";
28
- import matter from "./matter.js";
28
+ import matter, { configMatter } from "./matter.js";
29
29
  import { assertSafeName, isEditablePath, listWorkspaceFiles, listWorkspaces, listFlows, parseFlow, readWorkspaceFile, saveWorkspace, blankTemplateFiles, syncBundleFor, notifyWorkspaceChanged, workspaceDir, } from "./store.js";
30
30
  import { recordRevision } from "./history.js";
31
31
  import { libraryDir, libraryTools } from "./library.js";
@@ -522,7 +522,14 @@ function safeFront(content) {
522
522
  }
523
523
  }
524
524
  function frontOf(content) {
525
- const d = safeFront(content);
525
+ // An agent's secrets: and tools: may sit in its guardrail: block.
526
+ let d;
527
+ try {
528
+ d = configMatter(content).data;
529
+ }
530
+ catch {
531
+ d = {};
532
+ }
526
533
  const apis = Array.isArray(d.apis) ? d.apis.map((a) => (a && typeof a === "object" ? String(a.name ?? "") : "")) : [];
527
534
  const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers) : [];
528
535
  const scripts = list(d.scripts);
@@ -20,7 +20,7 @@
20
20
  import { accountModelBlock } from "./model-credential.js";
21
21
  import fs from "node:fs";
22
22
  import path from "node:path";
23
- import matter from "./matter.js";
23
+ import { configMatter } from "./matter.js";
24
24
  import { getSecret } from "./secrets.js";
25
25
  import { noteSecretUse, healthKey } from "./secret-health.js";
26
26
  import { accountDir, workspaceDir, listWorkspaces, listAgents, parseProvider } from "./store.js";
@@ -168,7 +168,7 @@ function cheapestModel(spec) {
168
168
  }
169
169
  function frontmatterOf(file) {
170
170
  try {
171
- return (matter(fs.readFileSync(file, "utf8")).data ?? {});
171
+ return (configMatter(fs.readFileSync(file, "utf8")).data ?? {});
172
172
  }
173
173
  catch {
174
174
  return {};
@@ -45,6 +45,9 @@ export interface ContainerStepInput {
45
45
  budgetNote?: string;
46
46
  price?: TokenPrice | null;
47
47
  verify?: string;
48
+ /** The model `verify: judge:` grades with (the workspace's `judge:`). */
49
+ judgeModel?: string;
50
+ judgeEffort?: string;
48
51
  /** `output: json` — checked inside, where the reply is. */
49
52
  output?: "json";
50
53
  /** `schema:` and `max_turns:`, resolved host-side — see ExecOptions. */
@@ -285,6 +288,8 @@ export declare function runtimeCacheMount(tenant: string | undefined): {
285
288
  source: string;
286
289
  target: string;
287
290
  } | null;
291
+ /** 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). */
292
+ 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 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 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 { 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\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 ...(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";
288
293
  /** The uv release copied into the image. Pinned: a floating uv is a floating
289
294
  * resolver, and the runtime cache is keyed on the declaration, not on it. */
290
295
  export declare const UV_VERSION = "0.11.25";
@@ -415,6 +420,10 @@ export interface RunInContainerArgs {
415
420
  * without it, "stop" could only mean "stop after this step", and a
416
421
  * browser step has fifteen minutes left to spend. */
417
422
  runId?: string;
423
+ /** Whether a person has stopped the run, read from the record. A step that
424
+ * waits for room before its sandbox exists asks this, since there is no
425
+ * sandbox yet for the stop to destroy. */
426
+ stopRequested?: () => boolean;
418
427
  /** The reservation class — which limits this step's sandbox holds. */
419
428
  size?: "small" | "large" | "heavy";
420
429
  /** Re-attach to a sandbox a previous driver started, instead of creating
@@ -410,7 +410,8 @@ export function runtimeCacheMount(tenant) {
410
410
  return { source, target: RUNTIME_CACHE };
411
411
  }
412
412
  // ---------- the runner image ----------
413
- const DRIVER = `// The runner container's entrypoint. Reads one step, executes it with the
413
+ /** 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). */
414
+ export const DRIVER = `// The runner container's entrypoint. Reads one step, executes it with the
414
415
  // same core the server uses, speaks JSONL on stdout, exits.
415
416
  import fs from "node:fs";
416
417
  const input = JSON.parse(fs.readFileSync("/opt/runner/job/input.json", "utf8"));
@@ -576,6 +577,36 @@ try {
576
577
  const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };
577
578
  const inbox = channel ? inboxReader(channel, emit) : undefined;
578
579
 
580
+ // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept
581
+ // fresh through the proxy's /identity door, which mints only what this
582
+ // step's lease was granted. Fetched before the step starts, renewed five
583
+ // minutes before it expires (with jitter); a failure is said and retried.
584
+ // Plain JS: this text is the driver, not TypeScript.
585
+ const identityNames = (env.FOLDRUN_IDENTITIES || "").split(",").filter(Boolean);
586
+ if (identityNames.length) {
587
+ // No backslashes: this sits inside a template literal, which eats them.
588
+ const ep = new RegExp("^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$").exec(env.FOLDRUN_EGRESS || "");
589
+ const dir = env.FOLDRUN_IDENTITY_DIR || "/tmp/foldrun-identity";
590
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
591
+ const keepFresh = async (name) => {
592
+ let wait = 30000;
593
+ try {
594
+ if (!ep) throw new Error("no egress proxy to ask");
595
+ const res = await fetch(ep[1] + "/identity/" + ep[2] + "/" + name, { signal: AbortSignal.timeout(15000) });
596
+ const body = await res.json().catch(() => ({}));
597
+ if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || ("proxy answered " + res.status));
598
+ const file = dir + "/" + name.toLowerCase() + ".jwt";
599
+ fs.writeFileSync(file + ".tmp", body.token, { mode: 0o600 });
600
+ fs.renameSync(file + ".tmp", file);
601
+ wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);
602
+ } catch (err) {
603
+ emit("error", "identity " + name + ": " + (err && err.message ? err.message : String(err)) + " — trying again in 30s");
604
+ }
605
+ setTimeout(() => { void keepFresh(name); }, wait).unref();
606
+ };
607
+ await Promise.all(identityNames.map(keepFresh));
608
+ }
609
+
579
610
  mark("tools");
580
611
  const outcome = await executeStep({
581
612
  agentDir,
@@ -608,6 +639,8 @@ try {
608
639
  price: input.price,
609
640
  verify: input.verify,
610
641
  verifyEnv: toolEnv,
642
+ ...(typeof input.judgeModel === "string" ? { judgeModel: input.judgeModel } : {}),
643
+ ...(typeof input.judgeEffort === "string" ? { judgeEffort: input.judgeEffort } : {}),
611
644
  output: input.output,
612
645
  schema: input.schema,
613
646
  maxTurns: input.maxTurns,