@foldrun/core 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) 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/completions.js +25 -3
  6. package/dist/src/deploy.js +72 -7
  7. package/dist/src/evals.d.ts +22 -1
  8. package/dist/src/evals.js +43 -14
  9. package/dist/src/flow-lint.d.ts +14 -0
  10. package/dist/src/flow-lint.js +35 -0
  11. package/dist/src/flow-patterns.js +29 -14
  12. package/dist/src/frontmatter-fields.d.ts +27 -0
  13. package/dist/src/frontmatter-fields.js +51 -3
  14. package/dist/src/frontmatter-guard.d.ts +19 -0
  15. package/dist/src/frontmatter-guard.js +67 -0
  16. package/dist/src/frontmatter-schema.d.ts +2 -2
  17. package/dist/src/frontmatter-schema.js +30 -2
  18. package/dist/src/frontmatter-text.d.ts +31 -0
  19. package/dist/src/frontmatter-text.js +102 -0
  20. package/dist/src/judge-verdict.d.ts +19 -0
  21. package/dist/src/judge-verdict.js +54 -0
  22. package/dist/src/matter.d.ts +34 -0
  23. package/dist/src/matter.js +104 -0
  24. package/dist/src/network.d.ts +0 -1
  25. package/dist/src/network.js +10 -7
  26. package/dist/src/package.js +9 -2
  27. package/dist/src/provider-health.js +2 -2
  28. package/dist/src/run-container.d.ts +9 -0
  29. package/dist/src/run-container.js +4 -1
  30. package/dist/src/runner.js +119 -36
  31. package/dist/src/scan.d.ts +11 -0
  32. package/dist/src/scan.js +86 -8
  33. package/dist/src/starter.js +23 -0
  34. package/dist/src/step-exec.d.ts +10 -2
  35. package/dist/src/step-exec.js +12 -9
  36. package/dist/src/store.d.ts +17 -3
  37. package/dist/src/store.js +14 -10
  38. package/package.json +1 -1
  39. package/src/agent-import.ts +2 -2
  40. package/src/agent-tools.ts +2 -2
  41. package/src/agents-md.ts +39 -14
  42. package/src/completions.ts +26 -3
  43. package/src/deploy.ts +59 -7
  44. package/src/evals.ts +45 -13
  45. package/src/flow-lint.ts +36 -0
  46. package/src/flow-patterns.ts +26 -13
  47. package/src/frontmatter-fields.ts +74 -3
  48. package/src/frontmatter-guard.ts +70 -0
  49. package/src/frontmatter-schema.ts +30 -4
  50. package/src/frontmatter-text.ts +109 -0
  51. package/src/judge-verdict.ts +52 -0
  52. package/src/matter.ts +112 -0
  53. package/src/network.ts +10 -6
  54. package/src/package.ts +8 -2
  55. package/src/provider-health.ts +2 -2
  56. package/src/run-container.ts +11 -1
  57. package/src/runner.ts +118 -37
  58. package/src/scan.ts +86 -8
  59. package/src/starter.ts +23 -0
  60. package/src/step-exec.ts +21 -8
  61. package/src/store.ts +23 -10
@@ -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 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"));
@@ -608,6 +609,8 @@ try {
608
609
  price: input.price,
609
610
  verify: input.verify,
610
611
  verifyEnv: toolEnv,
612
+ ...(typeof input.judgeModel === "string" ? { judgeModel: input.judgeModel } : {}),
613
+ ...(typeof input.judgeEffort === "string" ? { judgeEffort: input.judgeEffort } : {}),
611
614
  output: input.output,
612
615
  schema: input.schema,
613
616
  maxTurns: input.maxTurns,
@@ -9,7 +9,7 @@ import { hookNames } from "./hooks.js";
9
9
  import fs from "node:fs";
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
- import matter from "./matter.js";
12
+ import matter, { parseLenient, configMatter } from "./matter.js";
13
13
  import { knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor } from "./step-exec.js";
14
14
  import { cascadeLimits, toolOwners } from "./limits.js";
15
15
  import { resolveAgentPath } from "./confine.js";
@@ -18,6 +18,7 @@ import { eventUrl } from "./webhook.js";
18
18
  import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
19
19
  import { hostSafeEnv, platformApiUrl } from "./host-env.js";
20
20
  import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken, isModelEnvName, DECLARED_ENV_PREFIX } from "./model-credential.js";
21
+ import { parseJudge } from "./judge-verdict.js";
21
22
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
22
23
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
23
24
  import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote } from "./refusal.js";
@@ -36,7 +37,7 @@ import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec,
36
37
  import { sendPlainNotification } from "./notify.js";
37
38
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
38
39
  import { refNames } from "./refs.js";
39
- import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
40
+ import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isRescueRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
40
41
  import { canReadBack } from "./tool-output.js";
41
42
  import { PromptLayers, promptCacheKey } from "./prompt-layers.js";
42
43
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf } from "./catalog.js";
@@ -266,27 +267,9 @@ const toTags = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" &
266
267
  // rather than dropping the skill, so a cross-client skill still loads here.
267
268
  function safeMatter(raw) {
268
269
  // gray-matter caches a *failed* parse as a hollow success; ./matter.ts
269
- // parses every call, so the throw below is deterministic and the fallback
270
- // fires every time (it used to clear gray-matter's cache here for that).
271
- try {
272
- return matter(raw);
273
- }
274
- catch {
275
- const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
276
- if (fm) {
277
- const fixed = fm[1].replace(/^([A-Za-z0-9_-]+):[ \t]+(?!["'>|])(.*:.*)$/gm, (_m, k, v) => `${k}: ${JSON.stringify(v)}`);
278
- try {
279
- // A function replacer, not a string: a fixed block containing `$&` or
280
- // `$$` (a value with those in it) would otherwise be treated as a
281
- // replacement pattern and splice the whole frontmatter into itself.
282
- return matter(raw.replace(fm[1], () => fixed));
283
- }
284
- catch {
285
- /* fall through */
286
- }
287
- }
288
- return { data: {} };
289
- }
270
+ // parses every call, so the strict read throws every time and the lenient
271
+ // one (shared with check, which warns about it) fires every time.
272
+ return { data: parseLenient(raw).data };
290
273
  }
291
274
  // Both layouts: <root>/<name>/SKILL.md (Agent Skills standard) and the flat
292
275
  // <root>/<name>.md convenience form. `subdir` is the skills root under the
@@ -385,7 +368,7 @@ export function readAgentsMd(dir) {
385
368
  if (!fs.existsSync(file))
386
369
  continue;
387
370
  try {
388
- const parsed = matter(fs.readFileSync(file, "utf8"));
371
+ const parsed = configMatter(fs.readFileSync(file, "utf8"));
389
372
  return { data: parsed.data, body: withoutAgentRules(parsed.content).trim() };
390
373
  }
391
374
  catch {
@@ -753,7 +736,7 @@ grantsOnly = false) {
753
736
  // agentDir is <data>/<tenant>/projects/<workspace>/agents/<agent>, so the
754
737
  // workspace a secret should resolve against is two levels up.
755
738
  const workspace = path.basename(path.resolve(agentDir, "..", ".."));
756
- const { data: front, content: body } = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
739
+ const { data: front, content: body } = configMatter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
757
740
  // The prompt's sections, by name and by how often they change. Added in
758
741
  // the order they are computed; assembled stable first, so the model's
759
742
  // cache keeps the agent's half across steps and runs (prompt-layers.ts).
@@ -1915,6 +1898,15 @@ peerBus) {
1915
1898
  // AGENTS.md — every model call always; each tool call's input and output
1916
1899
  // unless it says `metadata`.
1917
1900
  const capture = workspaceFrontmatter(agentDir, tenant).capture === "metadata" ? "metadata" : "full";
1901
+ // `judge:` (AGENTS.md): the model and effort every `verify: judge:` here
1902
+ // grades with — field by field, the workspace's else the account's.
1903
+ const { judgeModel, judgeEffort } = (() => {
1904
+ const ws = parseJudge(readAgentsMd(workspaceRootOf(agentDir))?.data?.judge);
1905
+ const account = parseJudge(readAgentsMd(accountDir(tenant))?.data?.judge);
1906
+ const model = ws.model ?? account.model;
1907
+ const effort = resolveEffort(ws.effort ?? account.effort ?? "");
1908
+ return { judgeModel: model ? resolveModel(model) : undefined, judgeEffort: effort ?? undefined };
1909
+ })();
1918
1910
  // A step that pours out content stops being recorded past this — its
1919
1911
  // calls keep their names, sizes and times (FOL-54 step 4).
1920
1912
  let capturedChars = 0;
@@ -2651,6 +2643,8 @@ peerBus) {
2651
2643
  budgetNote: stepBudgetNote,
2652
2644
  price: tokenPrice,
2653
2645
  verify: step.verify,
2646
+ ...(judgeModel ? { judgeModel } : {}),
2647
+ ...(judgeEffort ? { judgeEffort } : {}),
2654
2648
  output: step.output,
2655
2649
  schema: resolvedSchema ?? undefined,
2656
2650
  maxTurns: step.maxTurns,
@@ -2705,6 +2699,9 @@ peerBus) {
2705
2699
  emit: pushWatching,
2706
2700
  // Stamps the sandbox, so stopping this run can destroy it.
2707
2701
  runId,
2702
+ // A step still waiting for room has no sandbox to destroy; the
2703
+ // executor asks this before it creates one (pod-room.ts).
2704
+ ...(stopRequested ? { stopRequested } : {}),
2708
2705
  size,
2709
2706
  // The pod's network, for the executor's own wall (the k8s tier's
2710
2707
  // network policy): a filtered pod reaches the proxy and DNS only.
@@ -3040,6 +3037,8 @@ peerBus) {
3040
3037
  budgetNote: stepBudgetNote,
3041
3038
  price: tokenPrice,
3042
3039
  verify: step.verify,
3040
+ ...(judgeModel ? { judgeModel } : {}),
3041
+ ...(judgeEffort ? { judgeEffort } : {}),
3043
3042
  output: step.output,
3044
3043
  schema: resolvedSchema ?? undefined,
3045
3044
  maxTurns: step.maxTurns,
@@ -3247,7 +3246,7 @@ export function copyTreeBytes(from, to) {
3247
3246
  function openApiSources(agentDir, tenant) {
3248
3247
  const out = new Set();
3249
3248
  try {
3250
- const { data } = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
3249
+ const { data } = configMatter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
3251
3250
  const front = data;
3252
3251
  for (const api of parseApis(front.apis))
3253
3252
  if (api.openapi?.startsWith("https://"))
@@ -3839,10 +3838,31 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3839
3838
  *
3840
3839
  * The flag is one-way: nothing here clears it, so the read-modify-write can
3841
3840
  * only ever turn a stop on, never off.
3841
+ *
3842
+ * And once it is on, this copy may not undo what stopRun wrote: the run
3843
+ * stays failed, and steps that had not started stay skipped. Before
3844
+ * 21 Oct 2026 only the flag came across, so the next save put the record
3845
+ * back to `running` with its steps pending; a second stop then answered
3846
+ * "failed" instead of "already failed", and the group kept starting pods
3847
+ * (medium-desk run-mvho48wp-99e1). A step running in THIS process is left
3848
+ * as it is: its sandbox is gone, it ends on its own, and records how.
3842
3849
  */
3843
3850
  const save = () => {
3844
- if (!run.stopRequested && readRun(tenant, workspace, run.id)?.stopRequested) {
3851
+ const disk = run.stopRequested ? null : readRun(tenant, workspace, run.id);
3852
+ if (disk?.stopRequested)
3845
3853
  run.stopRequested = true;
3854
+ if (run.stopRequested) {
3855
+ if (run.status !== "completed" && run.status !== "failed") {
3856
+ run.status = "failed";
3857
+ run.finishedAt ??= disk?.finishedAt ?? new Date().toISOString();
3858
+ }
3859
+ run.parkedAt = null;
3860
+ for (const s of run.steps) {
3861
+ if (s.status === "pending" || s.status === "awaiting-approval") {
3862
+ s.status = "skipped";
3863
+ s.skipReason ??= "run stopped";
3864
+ }
3865
+ }
3846
3866
  }
3847
3867
  writeRun(tenant, workspace, run);
3848
3868
  };
@@ -4063,7 +4083,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4063
4083
  return groupData[i];
4064
4084
  return undefined;
4065
4085
  };
4066
- const dataOf = (group) => {
4086
+ // A rescue's result already stands on the step it took over, so the
4087
+ // three handoffs below read the flow's steps only: counting the rescue
4088
+ // too handed the next group the same output twice, or a stale one
4089
+ // from an evaluator loop's earlier cycle.
4090
+ const flowStepsOf = (group) => group.filter((s) => !isRescueRecord(s));
4091
+ const dataOf = (all) => {
4092
+ const group = flowStepsOf(all);
4067
4093
  const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
4068
4094
  if (carrying.length === 0)
4069
4095
  return undefined;
@@ -4071,7 +4097,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4071
4097
  return carrying[0].data;
4072
4098
  return carrying.map((s) => ({ agent: s.agent, ...(s.item ? { item: s.item } : {}), data: s.data }));
4073
4099
  };
4074
- const resultsOf = (group) => {
4100
+ const resultsOf = (all) => {
4101
+ const group = flowStepsOf(all);
4075
4102
  const results = group
4076
4103
  .filter((s) => s.status === "completed" && s.result)
4077
4104
  .map((s) => s.item
@@ -4083,8 +4110,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4083
4110
  };
4084
4111
  // What each step concluded — its final turn — for the handoffs that
4085
4112
  // should read a conclusion rather than a transcript (`each: lines`).
4086
- const conclusionsOf = (group) => {
4087
- const out = group
4113
+ const conclusionsOf = (all) => {
4114
+ const out = flowStepsOf(all)
4088
4115
  .filter((s) => s.status === "completed" && (s.conclusion || s.result))
4089
4116
  .map((s) => s.conclusion || s.result);
4090
4117
  return out.length ? out.join("\n") : null;
@@ -4459,7 +4486,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4459
4486
  save();
4460
4487
  if (freshGroup.every((s) => s.status !== "pending")) {
4461
4488
  // everything in this group was rejected
4462
- if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
4489
+ if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s))) {
4463
4490
  run.status = "failed";
4464
4491
  break;
4465
4492
  }
@@ -4472,7 +4499,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4472
4499
  // itself mid-turn at its share, so a fan-out of twenty cannot end
4473
4500
  // twenty steps over — the shares sum to what was left. Between
4474
4501
  // groups the check above still refuses to start the next one.
4475
- const launching = freshGroup.filter((s) => s.status === "pending");
4502
+ // A rescue record is the on-fail of a step in this group, not a step
4503
+ // of the flow: one left pending by a drive that died is picked up by
4504
+ // rescueStep below, never launched alongside the group (21 Oct 2026:
4505
+ // a resumed run launched 77 leftover fixers at once).
4506
+ const launching = freshGroup.filter((s) => s.status === "pending" && !isRescueRecord(s));
4476
4507
  // tools: [peers] — what each step of the group is told about the
4477
4508
  // others, and the bus they share when they run in this process.
4478
4509
  const peerBus = localPeerBus();
@@ -4531,7 +4562,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4531
4562
  // lead's reply or for a slot — must not start it: the
4532
4563
  // between-groups check runs only once this group is over,
4533
4564
  // and save() is what carries the flag in from the record.
4534
- if (lead || pool) {
4565
+ {
4535
4566
  save();
4536
4567
  if (run.stopRequested) {
4537
4568
  step.status = "skipped";
@@ -4694,6 +4725,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4694
4725
  // runs out of cycles below, which used to fail the run without ever
4695
4726
  // reaching the step's on-fail (medium-desk's claim-trimmer was
4696
4727
  // written for exactly that case and could never run).
4728
+ // A rescue hears a stop the way a group step does: its model loop and
4729
+ // its executor's wait for room both poll this (no sandbox exists yet
4730
+ // for a stop to destroy while it waits).
4731
+ const stopPoll = () => {
4732
+ save();
4733
+ return run.stopRequested === true;
4734
+ };
4697
4735
  const rescueStep = async (step) => {
4698
4736
  if (!step.onFail)
4699
4737
  return;
@@ -4706,6 +4744,38 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4706
4744
  });
4707
4745
  return;
4708
4746
  }
4747
+ // One rescue per failure, across drives. A worker that died
4748
+ // mid-rescue left an unfinished record behind it; the drive that
4749
+ // resumes picks that one up instead of splicing another — a crash
4750
+ // loop used to add one per restart (blog-desk run-mvh1zra7-ae4k,
4751
+ // ~80). The leftover carries the same instruction, built from the
4752
+ // same failure, and a running one may still hold a sandbox to
4753
+ // re-attach to, so it is THE rescue; any further unfinished copies
4754
+ // are superseded. A FINISHED rescue is history, never reused: it
4755
+ // answered an earlier failure — an evaluator loop's previous cycle
4756
+ // — not this one.
4757
+ const leftovers = [];
4758
+ for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isRescueRecord(run.steps[j]); j++) {
4759
+ const r = run.steps[j];
4760
+ if (r.agent === step.onFail && (r.rescueOf === undefined || r.rescueOf === step.agent) && (r.status === "pending" || r.status === "running")) {
4761
+ leftovers.push(r);
4762
+ }
4763
+ }
4764
+ const resumed = leftovers[0];
4765
+ for (const r of leftovers.slice(1)) {
4766
+ r.status = "skipped";
4767
+ r.skipReason = "superseded on resume — one rescue per failure";
4768
+ }
4769
+ if (resumed) {
4770
+ step.events.push({ t: new Date().toISOString(), type: "info", text: `resuming the rescue by [[${step.onFail}]] that an earlier drive started` });
4771
+ resumed.rescueOf ??= step.agent;
4772
+ resumed.attempts = Math.max(1, resumed.attempts ?? 0);
4773
+ resumed.status = "running";
4774
+ save();
4775
+ await runStep(rescuerDir, tenant, resumed, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(resumed));
4776
+ settleRescue(step, resumed);
4777
+ return;
4778
+ }
4709
4779
  step.events.push({
4710
4780
  t: new Date().toISOString(),
4711
4781
  type: "info",
@@ -4722,6 +4792,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4722
4792
  const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
4723
4793
  const rescue = {
4724
4794
  agent: step.onFail,
4795
+ rescueOf: step.agent,
4725
4796
  instruction: `${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
4726
4797
  `Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
4727
4798
  ...(step.ask ? { ask: step.ask } : {}),
@@ -4751,7 +4822,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4751
4822
  rescue.attempts = 1;
4752
4823
  rescue.status = "running";
4753
4824
  save();
4754
- await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, undefined, flowTimezone, run.steps.indexOf(rescue));
4825
+ await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(rescue));
4826
+ settleRescue(step, rescue);
4827
+ };
4828
+ // What a rescue's end means for the step it took over.
4829
+ const settleRescue = (step, rescue) => {
4755
4830
  // runStep mutates rescue.status; widen past TS's "running" narrowing.
4756
4831
  const rescueOutcome = rescue.status;
4757
4832
  if (rescueOutcome === "completed") {
@@ -4786,7 +4861,10 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4786
4861
  // here: the person chose to start after it. Counting it stopped
4787
4862
  // fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
4788
4863
  // a second, before the one step it was asked to run.
4789
- const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom);
4864
+ // A rescue record's own failure is already on the step it took over
4865
+ // ("also failed"); counted here it would fail a group whose step a
4866
+ // later rescue completed.
4867
+ const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s));
4790
4868
  if (requiredFailed) {
4791
4869
  run.status = "failed";
4792
4870
  break;
@@ -4862,6 +4940,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4862
4940
  for (const s of [...ordered[gi - 1], ...freshGroup]) {
4863
4941
  if (s.status === "skipped" && s.each)
4864
4942
  continue; // an expanded template stays expanded
4943
+ // A rescue answered its own failure and stays as it ended: reset
4944
+ // to pending it read as an interrupted rescue, and the next
4945
+ // cycle's failure was handed the old one's instructions.
4946
+ if (isRescueRecord(s))
4947
+ continue;
4865
4948
  s.status = "pending";
4866
4949
  s.result = null;
4867
4950
  }
@@ -83,6 +83,17 @@ export declare function untrustedTextFindings(text: string): {
83
83
  }[];
84
84
  /** The known field a key was probably meant to be, or null. */
85
85
  export declare function nearestField(key: string, known: readonly string[]): string | null;
86
+ /** What a run leaves in an agent's folder — the Claude CLI's `.cache/` logs,
87
+ * package caches — and copies back with it. Nobody wrote these, so they are
88
+ * not checked as prompts or code; they are still searched for credentials,
89
+ * since a key in a log here is a key in the workspace (seen live on
90
+ * 17 Oct 2026: zero-width characters in a CLI MCP log, reported as hidden
91
+ * text). */
92
+ /** The definition files a deploy refuses outright when their frontmatter does
93
+ * not parse (deploy.ts deployIssues): the workspace's own, by these paths.
94
+ * Everything else the platform reads frontmatter from — skills, and the
95
+ * account library's agents, tools and hooks — is the scanner's to report. */
96
+ export declare const STRICT_FRONTMATTER_PATH: RegExp;
86
97
  /** How many times one rule is listed for one file before the rest are counted. */
87
98
  export declare const PER_FILE_RULE = 100;
88
99
  /**