@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
package/src/package.ts CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  import fs from "node:fs";
28
28
  import path from "node:path";
29
- import matter from "./matter.ts";
29
+ import matter, { configMatter } from "./matter.ts";
30
30
  import {
31
31
  assertSafeName,
32
32
  isEditablePath,
@@ -592,7 +592,13 @@ interface AgentFront {
592
592
  }
593
593
 
594
594
  function frontOf(content: string): AgentFront {
595
- const d = safeFront(content);
595
+ // An agent's secrets: and tools: may sit in its guardrail: block.
596
+ let d: Record<string, unknown>;
597
+ try {
598
+ d = configMatter(content).data as Record<string, unknown>;
599
+ } catch {
600
+ d = {};
601
+ }
596
602
  const apis = Array.isArray(d.apis) ? d.apis.map((a) => (a && typeof a === "object" ? String((a as { name?: unknown }).name ?? "") : "")) : [];
597
603
  const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers) : [];
598
604
  const scripts = list(d.scripts);
@@ -21,7 +21,7 @@
21
21
  import { accountModelBlock } from "./model-credential.ts";
22
22
  import fs from "node:fs";
23
23
  import path from "node:path";
24
- import matter from "./matter.ts";
24
+ import { configMatter } from "./matter.ts";
25
25
  import { getSecret } from "./secrets.ts";
26
26
  import { noteSecretUse, healthKey } from "./secret-health.ts";
27
27
  import { accountDir, workspaceDir, listWorkspaces, listAgents, parseProvider, type ProviderSpec } from "./store.ts";
@@ -205,7 +205,7 @@ function cheapestModel(spec: ProviderSpec): string {
205
205
 
206
206
  function frontmatterOf(file: string): Record<string, unknown> {
207
207
  try {
208
- return (matter(fs.readFileSync(file, "utf8")).data ?? {}) as Record<string, unknown>;
208
+ return (configMatter(fs.readFileSync(file, "utf8")).data ?? {}) as Record<string, unknown>;
209
209
  } catch {
210
210
  return {};
211
211
  }
@@ -73,6 +73,9 @@ export interface ContainerStepInput {
73
73
  budgetNote?: string;
74
74
  price?: TokenPrice | null;
75
75
  verify?: string;
76
+ /** The model `verify: judge:` grades with (the workspace's `judge:`). */
77
+ judgeModel?: string;
78
+ judgeEffort?: string;
76
79
  /** `output: json` — checked inside, where the reply is. */
77
80
  output?: "json";
78
81
  /** `schema:` and `max_turns:`, resolved host-side — see ExecOptions. */
@@ -556,7 +559,8 @@ export function runtimeCacheMount(
556
559
 
557
560
  // ---------- the runner image ----------
558
561
 
559
- const DRIVER = `// The runner container's entrypoint. Reads one step, executes it with the
562
+ /** 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). */
563
+ export const DRIVER = `// The runner container's entrypoint. Reads one step, executes it with the
560
564
  // same core the server uses, speaks JSONL on stdout, exits.
561
565
  import fs from "node:fs";
562
566
  const input = JSON.parse(fs.readFileSync("/opt/runner/job/input.json", "utf8"));
@@ -722,6 +726,36 @@ try {
722
726
  const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };
723
727
  const inbox = channel ? inboxReader(channel, emit) : undefined;
724
728
 
729
+ // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept
730
+ // fresh through the proxy's /identity door, which mints only what this
731
+ // step's lease was granted. Fetched before the step starts, renewed five
732
+ // minutes before it expires (with jitter); a failure is said and retried.
733
+ // Plain JS: this text is the driver, not TypeScript.
734
+ const identityNames = (env.FOLDRUN_IDENTITIES || "").split(",").filter(Boolean);
735
+ if (identityNames.length) {
736
+ // No backslashes: this sits inside a template literal, which eats them.
737
+ const ep = new RegExp("^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$").exec(env.FOLDRUN_EGRESS || "");
738
+ const dir = env.FOLDRUN_IDENTITY_DIR || "/tmp/foldrun-identity";
739
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
740
+ const keepFresh = async (name) => {
741
+ let wait = 30000;
742
+ try {
743
+ if (!ep) throw new Error("no egress proxy to ask");
744
+ const res = await fetch(ep[1] + "/identity/" + ep[2] + "/" + name, { signal: AbortSignal.timeout(15000) });
745
+ const body = await res.json().catch(() => ({}));
746
+ if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || ("proxy answered " + res.status));
747
+ const file = dir + "/" + name.toLowerCase() + ".jwt";
748
+ fs.writeFileSync(file + ".tmp", body.token, { mode: 0o600 });
749
+ fs.renameSync(file + ".tmp", file);
750
+ wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);
751
+ } catch (err) {
752
+ emit("error", "identity " + name + ": " + (err && err.message ? err.message : String(err)) + " — trying again in 30s");
753
+ }
754
+ setTimeout(() => { void keepFresh(name); }, wait).unref();
755
+ };
756
+ await Promise.all(identityNames.map(keepFresh));
757
+ }
758
+
725
759
  mark("tools");
726
760
  const outcome = await executeStep({
727
761
  agentDir,
@@ -754,6 +788,8 @@ try {
754
788
  price: input.price,
755
789
  verify: input.verify,
756
790
  verifyEnv: toolEnv,
791
+ ...(typeof input.judgeModel === "string" ? { judgeModel: input.judgeModel } : {}),
792
+ ...(typeof input.judgeEffort === "string" ? { judgeEffort: input.judgeEffort } : {}),
757
793
  output: input.output,
758
794
  schema: input.schema,
759
795
  maxTurns: input.maxTurns,
@@ -1358,6 +1394,10 @@ export interface RunInContainerArgs {
1358
1394
  * without it, "stop" could only mean "stop after this step", and a
1359
1395
  * browser step has fifteen minutes left to spend. */
1360
1396
  runId?: string;
1397
+ /** Whether a person has stopped the run, read from the record. A step that
1398
+ * waits for room before its sandbox exists asks this, since there is no
1399
+ * sandbox yet for the stop to destroy. */
1400
+ stopRequested?: () => boolean;
1361
1401
  /** The reservation class — which limits this step's sandbox holds. */
1362
1402
  size?: "small" | "large" | "heavy";
1363
1403
  /** Re-attach to a sandbox a previous driver started, instead of creating
package/src/runner.ts CHANGED
@@ -10,7 +10,7 @@ import { hookNames } from "./hooks.ts";
10
10
  import fs from "node:fs";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
- import matter from "./matter.ts";
13
+ import matter, { parseLenient, configMatter } from "./matter.ts";
14
14
  import { spawn } from "node:child_process";
15
15
  import {
16
16
  knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor, type EventExtra } from "./step-exec.ts";
@@ -21,6 +21,7 @@ import { eventUrl } from "./webhook.ts";
21
21
  import { runStepInContainer, sizeLimits, killRunSandboxes, type StepTiming } from "./run-container.ts";
22
22
  import { hostSafeEnv, platformApiUrl } from "./host-env.ts";
23
23
  import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken, isModelEnvName, DECLARED_ENV_PREFIX } from "./model-credential.ts";
24
+ import { parseJudge } from "./judge-verdict.ts";
24
25
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
25
26
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
26
27
  import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote, type SupplyState } from "./refusal.ts";
@@ -70,6 +71,7 @@ import {
70
71
  checkFormatVersion,
71
72
  readRun,
72
73
  writeRun,
74
+ isRescueRecord,
73
75
  runSummary,
74
76
  verdictOf,
75
77
  type FlowStep,
@@ -88,7 +90,7 @@ import {
88
90
  import { canReadBack } from "./tool-output.ts";
89
91
  import { PromptLayers, promptCacheKey } from "./prompt-layers.ts";
90
92
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf, type Catalog, type TokenPrice } from "./catalog.ts";
91
- import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.ts";
93
+ import { resolveSecrets, getSecret, materializeSecrets, secretSites, isClientCertValue, clientCertOf, withoutHeldCerts, isIdentityValue, identityOf } from "./secrets.ts";
92
94
  import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
93
95
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.ts";
94
96
  import { buildApiTools, secretsUsedByApi } from "./api-tools.ts";
@@ -335,28 +337,9 @@ const toTags = (v: unknown): string[] =>
335
337
  // rather than dropping the skill, so a cross-client skill still loads here.
336
338
  function safeMatter(raw: string): { data: Record<string, unknown> } {
337
339
  // gray-matter caches a *failed* parse as a hollow success; ./matter.ts
338
- // parses every call, so the throw below is deterministic and the fallback
339
- // fires every time (it used to clear gray-matter's cache here for that).
340
- try {
341
- return matter(raw) as { data: Record<string, unknown> };
342
- } catch {
343
- const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
344
- if (fm) {
345
- const fixed = fm[1].replace(
346
- /^([A-Za-z0-9_-]+):[ \t]+(?!["'>|])(.*:.*)$/gm,
347
- (_m, k: string, v: string) => `${k}: ${JSON.stringify(v)}`,
348
- );
349
- try {
350
- // A function replacer, not a string: a fixed block containing `$&` or
351
- // `$$` (a value with those in it) would otherwise be treated as a
352
- // replacement pattern and splice the whole frontmatter into itself.
353
- return matter(raw.replace(fm[1], () => fixed)) as { data: Record<string, unknown> };
354
- } catch {
355
- /* fall through */
356
- }
357
- }
358
- return { data: {} };
359
- }
340
+ // parses every call, so the strict read throws every time and the lenient
341
+ // one (shared with check, which warns about it) fires every time.
342
+ return { data: parseLenient(raw).data };
360
343
  }
361
344
 
362
345
  // Both layouts: <root>/<name>/SKILL.md (Agent Skills standard) and the flat
@@ -452,7 +435,7 @@ export function readAgentsMd(dir: string): { data: Record<string, unknown>; body
452
435
  const file = path.join(dir, name);
453
436
  if (!fs.existsSync(file)) continue;
454
437
  try {
455
- const parsed = matter(fs.readFileSync(file, "utf8"));
438
+ const parsed = configMatter(fs.readFileSync(file, "utf8"));
456
439
  return { data: parsed.data as Record<string, unknown>, body: withoutAgentRules(parsed.content).trim() };
457
440
  } catch {
458
441
  return null;
@@ -855,7 +838,7 @@ export function agentContext(
855
838
  // agentDir is <data>/<tenant>/projects/<workspace>/agents/<agent>, so the
856
839
  // workspace a secret should resolve against is two levels up.
857
840
  const workspace = path.basename(path.resolve(agentDir, "..", ".."));
858
- const { data: front, content: body } = matter(
841
+ const { data: front, content: body } = configMatter(
859
842
  fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"),
860
843
  );
861
844
  // The prompt's sections, by name and by how often they change. Added in
@@ -1502,7 +1485,8 @@ export function agentContext(
1502
1485
  // the agent, and the date the platform believes it is.
1503
1486
  {
1504
1487
  ...runtime.env,
1505
- ...secretEnv,
1488
+ // An unexported client certificate is the proxy's, never a script's.
1489
+ ...withoutHeldCerts(secretEnv),
1506
1490
  ...(identity.runId ? { FOLDRUN_RUN_ID: identity.runId } : {}),
1507
1491
  ...(identity.agent ? { FOLDRUN_AGENT: identity.agent } : {}),
1508
1492
  // And the workspace: a commit or a record made by a shared library
@@ -1783,6 +1767,9 @@ export function agentContext(
1783
1767
  // asked for by name fails inside the container with "cannot find" while
1784
1768
  // the host path, which uses this same merged spec, works fine.
1785
1769
  runtime: { log: runtimeLog, error: runtimeError, executor, spec: runtimeSpec, dispose: runtime.dispose },
1770
+ // Whole: a bound client certificate goes from here into the egress
1771
+ // grant (runStep), which is where it is presented. Programs get theirs
1772
+ // filtered (withoutHeldCerts) where their environments are built.
1786
1773
  secretEnv: { ...runtime.env, ...secretEnv },
1787
1774
  formatWarning: checkFormatVersion(workspaceFrontmatter(agentDir, tenant).foldrun_version).warning,
1788
1775
  providerEnv,
@@ -2089,6 +2076,15 @@ async function runStep(
2089
2076
  // AGENTS.md — every model call always; each tool call's input and output
2090
2077
  // unless it says `metadata`.
2091
2078
  const capture: "metadata" | "full" = workspaceFrontmatter(agentDir, tenant).capture === "metadata" ? "metadata" : "full";
2079
+ // `judge:` (AGENTS.md): the model and effort every `verify: judge:` here
2080
+ // grades with — field by field, the workspace's else the account's.
2081
+ const { judgeModel, judgeEffort } = (() => {
2082
+ const ws = parseJudge(readAgentsMd(workspaceRootOf(agentDir))?.data?.judge);
2083
+ const account = parseJudge(readAgentsMd(accountDir(tenant))?.data?.judge);
2084
+ const model = ws.model ?? account.model;
2085
+ const effort = resolveEffort(ws.effort ?? account.effort ?? "");
2086
+ return { judgeModel: model ? resolveModel(model) : undefined, judgeEffort: effort ?? undefined };
2087
+ })();
2092
2088
  // A step that pours out content stops being recorded past this — its
2093
2089
  // calls keep their names, sizes and times (FOL-54 step 4).
2094
2090
  let capturedChars = 0;
@@ -2694,11 +2690,27 @@ async function runStep(
2694
2690
  readAgentsMd(accountDir(tenant))?.data?.network_enforce,
2695
2691
  readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce,
2696
2692
  );
2693
+ // Keyless cloud identities the step declared: the proxy may mint tokens
2694
+ // for these audiences only, for this workload (oidc-issuer.ts).
2695
+ // A test run withholds them as it withholds every credential
2696
+ // (test-mode.ts): a token is a credential, however it is delivered.
2697
+ const declaredIdentities = Object.entries(liveSecrets).filter(([, v]) => typeof v === "string" && isIdentityValue(v));
2698
+ const identitiesHeld = testRun
2699
+ ? withholdSecrets(Object.fromEntries(declaredIdentities), {
2700
+ outward: scriptSpecs.some((sc) => sc.outward),
2701
+ allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
2702
+ }).withheld
2703
+ : [];
2704
+ const identities = Object.fromEntries(
2705
+ declaredIdentities.filter(([name]) => !identitiesHeld.includes(name)).map(([name, v]) => [name, { audience: identityOf(v).audience }]),
2706
+ );
2707
+ if (Object.keys(identities).length) grant.identities = identities;
2697
2708
  const lease = await platform.egress.lease({
2698
2709
  tenant,
2699
2710
  runId: runId ?? "adhoc",
2700
2711
  grant,
2701
2712
  test: testRun,
2713
+ who: { workspace: workspaceName, agent: step.agent },
2702
2714
  ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
2703
2715
  // tools: [peers] — the roster the proxy serves, and the inboxes a
2704
2716
  // colleague's message may be addressed to. Only when granted: a
@@ -2771,6 +2783,34 @@ async function runStep(
2771
2783
  push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
2772
2784
  }
2773
2785
  }
2786
+ // A keyless identity holds nothing to hand over: the sandbox gets the
2787
+ // path of a token file the driver keeps fresh through the proxy's
2788
+ // /identity door. Without a lease there is no door, and so no token.
2789
+ const identityDeclared = declared.filter((name) => typeof liveSecrets[name] === "string" && isIdentityValue(liveSecrets[name]!));
2790
+ for (const name of identityDeclared) delete sandboxSecrets[name];
2791
+ for (const name of identitiesHeld) {
2792
+ push("info", `test: ${name} withheld — no identity token on a test run`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
2793
+ }
2794
+ const identityNames = identityDeclared.filter((name) => !identitiesHeld.includes(name));
2795
+ if (identityNames.length) {
2796
+ if (lease) {
2797
+ const dir = "/tmp/foldrun-identity";
2798
+ sandboxSecrets.FOLDRUN_IDENTITIES = identityNames.join(",");
2799
+ sandboxSecrets.FOLDRUN_IDENTITY_DIR = dir;
2800
+ for (const name of identityNames) sandboxSecrets[`${name}_FILE`] = `${dir}/${name.toLowerCase()}.jwt`;
2801
+ push("info", `identity: ${identityNames.join(", ")} — a short-lived token in $NAME_FILE, renewed by the platform while the step runs`);
2802
+ } else {
2803
+ push("error", `identity: ${identityNames.join(", ")} needs a sandboxed step on the platform — no token here`);
2804
+ }
2805
+ }
2806
+ // A client certificate is the proxy's to present, to the exact sites it
2807
+ // is bound to; never the sandbox's, unless it was saved with `export`.
2808
+ for (const name of declared) {
2809
+ const v = liveSecrets[name];
2810
+ if (typeof v !== "string" || !isClientCertValue(v) || bound.includes(name) || clientCertOf(v).export) continue;
2811
+ delete sandboxSecrets[name];
2812
+ push("info", `secrets: ${name} is a client certificate — the egress proxy presents it to the sites it is bound to (\`foldrun secrets sites ${name} <host>\`); unbound, it stays out of the sandbox`);
2813
+ }
2774
2814
  // On a test run the http tools always go through the proxy as
2775
2815
  // placeholders, even when a script made the step materialise: the
2776
2816
  // proxy is where the outward-write policy lives, and a header
@@ -2857,6 +2897,8 @@ async function runStep(
2857
2897
  budgetNote: stepBudgetNote,
2858
2898
  price: tokenPrice,
2859
2899
  verify: step.verify,
2900
+ ...(judgeModel ? { judgeModel } : {}),
2901
+ ...(judgeEffort ? { judgeEffort } : {}),
2860
2902
  output: step.output,
2861
2903
  schema: resolvedSchema ?? undefined,
2862
2904
  maxTurns: step.maxTurns,
@@ -2913,6 +2955,9 @@ async function runStep(
2913
2955
  emit: pushWatching,
2914
2956
  // Stamps the sandbox, so stopping this run can destroy it.
2915
2957
  runId,
2958
+ // A step still waiting for room has no sandbox to destroy; the
2959
+ // executor asks this before it creates one (pod-room.ts).
2960
+ ...(stopRequested ? { stopRequested } : {}),
2916
2961
  size,
2917
2962
  // The pod's network, for the executor's own wall (the k8s tier's
2918
2963
  // network policy): a filtered pod reaches the proxy and DNS only.
@@ -3177,7 +3222,20 @@ async function runStep(
3177
3222
  for (const name of held.withheld) {
3178
3223
  push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
3179
3224
  }
3180
- const stepSecrets = held.env;
3225
+ // A client certificate not saved with `export` has no proxy here to
3226
+ // present it: it stays out of everything this step runs — tools,
3227
+ // consults, verify commands — not only out of the files.
3228
+ const stepSecrets = Object.fromEntries(
3229
+ Object.entries(held.env).filter(([name, v]) => {
3230
+ if (typeof v === "string" && isIdentityValue(v)) {
3231
+ push("error", `identity: ${name} needs a sandboxed step on the platform — no token here`);
3232
+ return false;
3233
+ }
3234
+ if (typeof v !== "string" || !isClientCertValue(v) || clientCertOf(v).export) return true;
3235
+ push("info", `secrets: ${name} is a client certificate — no egress proxy here to present it; save it with export to use it as files`);
3236
+ return false;
3237
+ }),
3238
+ );
3181
3239
  // The model credential for this machine's Claude Code — the agent's
3182
3240
  // own provider when it names one. The host allowlist drops it, so
3183
3241
  // without this every in-process step ran on the machine's login.
@@ -3252,6 +3310,8 @@ async function runStep(
3252
3310
  budgetNote: stepBudgetNote,
3253
3311
  price: tokenPrice,
3254
3312
  verify: step.verify,
3313
+ ...(judgeModel ? { judgeModel } : {}),
3314
+ ...(judgeEffort ? { judgeEffort } : {}),
3255
3315
  output: step.output,
3256
3316
  schema: resolvedSchema ?? undefined,
3257
3317
  maxTurns: step.maxTurns,
@@ -3455,7 +3515,7 @@ export function copyTreeBytes(from: string, to: string) {
3455
3515
  function openApiSources(agentDir: string, tenant: string): string[] {
3456
3516
  const out = new Set<string>();
3457
3517
  try {
3458
- const { data } = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
3518
+ const { data } = configMatter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
3459
3519
  const front = data as { apis?: unknown; tools?: unknown };
3460
3520
  for (const api of parseApis(front.apis)) if (api.openapi?.startsWith("https://")) out.add(api.openapi);
3461
3521
  const workspace = path.basename(path.resolve(agentDir, "..", ".."));
@@ -4039,7 +4099,14 @@ export function driveRun(
4039
4099
  opts: { parkOnApproval?: boolean; effortOverride?: string | null } = {},
4040
4100
  ): Promise<void> {
4041
4101
  drivingRuns.add(run.id);
4102
+ // The run's egress leases are renewed while it is driven, by whichever
4103
+ // door drove it — the queue, or a reconcile that picked it back up — so an
4104
+ // identity token is renewed for a live step however it was started.
4105
+ const renew = platform.egress.renew;
4106
+ const beat = renew ? setInterval(() => void renew(run.id).catch(() => undefined), 5 * 60_000) : null;
4107
+ beat?.unref?.();
4042
4108
  return driveRunInner(tenant, workspace, run, modelOverride, tags, opts).finally(() => {
4109
+ if (beat) clearInterval(beat);
4043
4110
  drivingRuns.delete(run.id);
4044
4111
  });
4045
4112
  }
@@ -4080,10 +4147,30 @@ function driveRunInner(
4080
4147
  *
4081
4148
  * The flag is one-way: nothing here clears it, so the read-modify-write can
4082
4149
  * only ever turn a stop on, never off.
4150
+ *
4151
+ * And once it is on, this copy may not undo what stopRun wrote: the run
4152
+ * stays failed, and steps that had not started stay skipped. Before
4153
+ * 21 Oct 2026 only the flag came across, so the next save put the record
4154
+ * back to `running` with its steps pending; a second stop then answered
4155
+ * "failed" instead of "already failed", and the group kept starting pods
4156
+ * (medium-desk run-mvho48wp-99e1). A step running in THIS process is left
4157
+ * as it is: its sandbox is gone, it ends on its own, and records how.
4083
4158
  */
4084
4159
  const save = () => {
4085
- if (!run.stopRequested && readRun(tenant, workspace, run.id)?.stopRequested) {
4086
- run.stopRequested = true;
4160
+ const disk = run.stopRequested ? null : readRun(tenant, workspace, run.id);
4161
+ if (disk?.stopRequested) run.stopRequested = true;
4162
+ if (run.stopRequested) {
4163
+ if (run.status !== "completed" && run.status !== "failed") {
4164
+ run.status = "failed";
4165
+ run.finishedAt ??= disk?.finishedAt ?? new Date().toISOString();
4166
+ }
4167
+ run.parkedAt = null;
4168
+ for (const s of run.steps) {
4169
+ if (s.status === "pending" || s.status === "awaiting-approval") {
4170
+ s.status = "skipped";
4171
+ s.skipReason ??= "run stopped";
4172
+ }
4173
+ }
4087
4174
  }
4088
4175
  writeRun(tenant, workspace, run);
4089
4176
  };
@@ -4301,13 +4388,20 @@ function driveRunInner(
4301
4388
  for (let i = gi - 1; i >= 0; i--) if (groupData[i] !== undefined) return groupData[i];
4302
4389
  return undefined;
4303
4390
  };
4304
- const dataOf = (group: StepRecord[]): unknown => {
4391
+ // A rescue's result already stands on the step it took over, so the
4392
+ // three handoffs below read the flow's steps only: counting the rescue
4393
+ // too handed the next group the same output twice, or a stale one
4394
+ // from an evaluator loop's earlier cycle.
4395
+ const flowStepsOf = (group: StepRecord[]) => group.filter((s) => !isRescueRecord(s));
4396
+ const dataOf = (all: StepRecord[]): unknown => {
4397
+ const group = flowStepsOf(all);
4305
4398
  const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
4306
4399
  if (carrying.length === 0) return undefined;
4307
4400
  if (carrying.length === 1 && !carrying[0].item) return carrying[0].data;
4308
4401
  return carrying.map((s) => ({ agent: s.agent, ...(s.item ? { item: s.item } : {}), data: s.data }));
4309
4402
  };
4310
- const resultsOf = (group: StepRecord[]): string | null => {
4403
+ const resultsOf = (all: StepRecord[]): string | null => {
4404
+ const group = flowStepsOf(all);
4311
4405
  const results = group
4312
4406
  .filter((s) => s.status === "completed" && s.result)
4313
4407
  .map((s) =>
@@ -4321,8 +4415,8 @@ function driveRunInner(
4321
4415
  };
4322
4416
  // What each step concluded — its final turn — for the handoffs that
4323
4417
  // should read a conclusion rather than a transcript (`each: lines`).
4324
- const conclusionsOf = (group: StepRecord[]): string | null => {
4325
- const out = group
4418
+ const conclusionsOf = (all: StepRecord[]): string | null => {
4419
+ const out = flowStepsOf(all)
4326
4420
  .filter((s) => s.status === "completed" && (s.conclusion || s.result))
4327
4421
  .map((s) => s.conclusion || s.result!);
4328
4422
  return out.length ? out.join("\n") : null;
@@ -4709,7 +4803,7 @@ function driveRunInner(
4709
4803
  save();
4710
4804
  if (freshGroup.every((s) => s.status !== "pending")) {
4711
4805
  // everything in this group was rejected
4712
- if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
4806
+ if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s))) {
4713
4807
  run.status = "failed";
4714
4808
  break;
4715
4809
  }
@@ -4723,7 +4817,11 @@ function driveRunInner(
4723
4817
  // itself mid-turn at its share, so a fan-out of twenty cannot end
4724
4818
  // twenty steps over — the shares sum to what was left. Between
4725
4819
  // groups the check above still refuses to start the next one.
4726
- const launching = freshGroup.filter((s) => s.status === "pending");
4820
+ // A rescue record is the on-fail of a step in this group, not a step
4821
+ // of the flow: one left pending by a drive that died is picked up by
4822
+ // rescueStep below, never launched alongside the group (21 Oct 2026:
4823
+ // a resumed run launched 77 leftover fixers at once).
4824
+ const launching = freshGroup.filter((s) => s.status === "pending" && !isRescueRecord(s));
4727
4825
  // tools: [peers] — what each step of the group is told about the
4728
4826
  // others, and the bus they share when they run in this process.
4729
4827
  const peerBus = localPeerBus();
@@ -4788,7 +4886,7 @@ function driveRunInner(
4788
4886
  // lead's reply or for a slot — must not start it: the
4789
4887
  // between-groups check runs only once this group is over,
4790
4888
  // and save() is what carries the flag in from the record.
4791
- if (lead || pool) {
4889
+ {
4792
4890
  save();
4793
4891
  if (run.stopRequested) {
4794
4892
  step.status = "skipped";
@@ -4968,6 +5066,13 @@ function driveRunInner(
4968
5066
  // runs out of cycles below, which used to fail the run without ever
4969
5067
  // reaching the step's on-fail (medium-desk's claim-trimmer was
4970
5068
  // written for exactly that case and could never run).
5069
+ // A rescue hears a stop the way a group step does: its model loop and
5070
+ // its executor's wait for room both poll this (no sandbox exists yet
5071
+ // for a stop to destroy while it waits).
5072
+ const stopPoll = () => {
5073
+ save();
5074
+ return run.stopRequested === true;
5075
+ };
4971
5076
  const rescueStep = async (step: StepRecord): Promise<void> => {
4972
5077
  if (!step.onFail) return;
4973
5078
  const rescuerDir = path.join(pDir, "agents", step.onFail);
@@ -4979,6 +5084,38 @@ function driveRunInner(
4979
5084
  });
4980
5085
  return;
4981
5086
  }
5087
+ // One rescue per failure, across drives. A worker that died
5088
+ // mid-rescue left an unfinished record behind it; the drive that
5089
+ // resumes picks that one up instead of splicing another — a crash
5090
+ // loop used to add one per restart (blog-desk run-mvh1zra7-ae4k,
5091
+ // ~80). The leftover carries the same instruction, built from the
5092
+ // same failure, and a running one may still hold a sandbox to
5093
+ // re-attach to, so it is THE rescue; any further unfinished copies
5094
+ // are superseded. A FINISHED rescue is history, never reused: it
5095
+ // answered an earlier failure — an evaluator loop's previous cycle
5096
+ // — not this one.
5097
+ const leftovers: StepRecord[] = [];
5098
+ for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isRescueRecord(run.steps[j]); j++) {
5099
+ const r = run.steps[j];
5100
+ if (r.agent === step.onFail && (r.rescueOf === undefined || r.rescueOf === step.agent) && (r.status === "pending" || r.status === "running")) {
5101
+ leftovers.push(r);
5102
+ }
5103
+ }
5104
+ const resumed = leftovers[0];
5105
+ for (const r of leftovers.slice(1)) {
5106
+ r.status = "skipped";
5107
+ r.skipReason = "superseded on resume — one rescue per failure";
5108
+ }
5109
+ if (resumed) {
5110
+ step.events.push({ t: new Date().toISOString(), type: "info", text: `resuming the rescue by [[${step.onFail}]] that an earlier drive started` });
5111
+ resumed.rescueOf ??= step.agent;
5112
+ resumed.attempts = Math.max(1, resumed.attempts ?? 0);
5113
+ resumed.status = "running";
5114
+ save();
5115
+ await runStep(rescuerDir, tenant, resumed, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(resumed));
5116
+ settleRescue(step, resumed);
5117
+ return;
5118
+ }
4982
5119
  step.events.push({
4983
5120
  t: new Date().toISOString(),
4984
5121
  type: "info",
@@ -4995,6 +5132,7 @@ function driveRunInner(
4995
5132
  const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
4996
5133
  const rescue: StepRecord = {
4997
5134
  agent: step.onFail,
5135
+ rescueOf: step.agent,
4998
5136
  instruction:
4999
5137
  `${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
5000
5138
  `Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
@@ -5025,7 +5163,11 @@ function driveRunInner(
5025
5163
  rescue.attempts = 1;
5026
5164
  rescue.status = "running";
5027
5165
  save();
5028
- await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, undefined, flowTimezone, run.steps.indexOf(rescue));
5166
+ await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(rescue));
5167
+ settleRescue(step, rescue);
5168
+ };
5169
+ // What a rescue's end means for the step it took over.
5170
+ const settleRescue = (step: StepRecord, rescue: StepRecord): void => {
5029
5171
  // runStep mutates rescue.status; widen past TS's "running" narrowing.
5030
5172
  const rescueOutcome: string = rescue.status;
5031
5173
  if (rescueOutcome === "completed") {
@@ -5057,7 +5199,10 @@ function driveRunInner(
5057
5199
  // here: the person chose to start after it. Counting it stopped
5058
5200
  // fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
5059
5201
  // a second, before the one step it was asked to run.
5060
- const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom);
5202
+ // A rescue record's own failure is already on the step it took over
5203
+ // ("also failed"); counted here it would fail a group whose step a
5204
+ // later rescue completed.
5205
+ const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s));
5061
5206
  if (requiredFailed) {
5062
5207
  run.status = "failed";
5063
5208
  break;
@@ -5133,6 +5278,10 @@ function driveRunInner(
5133
5278
  });
5134
5279
  for (const s of [...ordered[gi - 1], ...freshGroup]) {
5135
5280
  if (s.status === "skipped" && s.each) continue; // an expanded template stays expanded
5281
+ // A rescue answered its own failure and stays as it ended: reset
5282
+ // to pending it read as an interrupted rescue, and the next
5283
+ // cycle's failure was handed the old one's instructions.
5284
+ if (isRescueRecord(s)) continue;
5136
5285
  s.status = "pending";
5137
5286
  s.result = null;
5138
5287
  }