@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
@@ -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,11 +37,11 @@ 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";
43
- import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.js";
44
+ import { resolveSecrets, getSecret, materializeSecrets, secretSites, isClientCertValue, clientCertOf, withoutHeldCerts, isIdentityValue, identityOf } from "./secrets.js";
44
45
  import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.js";
45
46
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
46
47
  import { buildApiTools, secretsUsedByApi } from "./api-tools.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).
@@ -1365,7 +1348,8 @@ grantsOnly = false) {
1365
1348
  // the agent, and the date the platform believes it is.
1366
1349
  {
1367
1350
  ...runtime.env,
1368
- ...secretEnv,
1351
+ // An unexported client certificate is the proxy's, never a script's.
1352
+ ...withoutHeldCerts(secretEnv),
1369
1353
  ...(identity.runId ? { FOLDRUN_RUN_ID: identity.runId } : {}),
1370
1354
  ...(identity.agent ? { FOLDRUN_AGENT: identity.agent } : {}),
1371
1355
  // And the workspace: a commit or a record made by a shared library
@@ -1635,6 +1619,9 @@ grantsOnly = false) {
1635
1619
  // asked for by name fails inside the container with "cannot find" while
1636
1620
  // the host path, which uses this same merged spec, works fine.
1637
1621
  runtime: { log: runtimeLog, error: runtimeError, executor, spec: runtimeSpec, dispose: runtime.dispose },
1622
+ // Whole: a bound client certificate goes from here into the egress
1623
+ // grant (runStep), which is where it is presented. Programs get theirs
1624
+ // filtered (withoutHeldCerts) where their environments are built.
1638
1625
  secretEnv: { ...runtime.env, ...secretEnv },
1639
1626
  formatWarning: checkFormatVersion(workspaceFrontmatter(agentDir, tenant).foldrun_version).warning,
1640
1627
  providerEnv,
@@ -1915,6 +1902,15 @@ peerBus) {
1915
1902
  // AGENTS.md — every model call always; each tool call's input and output
1916
1903
  // unless it says `metadata`.
1917
1904
  const capture = workspaceFrontmatter(agentDir, tenant).capture === "metadata" ? "metadata" : "full";
1905
+ // `judge:` (AGENTS.md): the model and effort every `verify: judge:` here
1906
+ // grades with — field by field, the workspace's else the account's.
1907
+ const { judgeModel, judgeEffort } = (() => {
1908
+ const ws = parseJudge(readAgentsMd(workspaceRootOf(agentDir))?.data?.judge);
1909
+ const account = parseJudge(readAgentsMd(accountDir(tenant))?.data?.judge);
1910
+ const model = ws.model ?? account.model;
1911
+ const effort = resolveEffort(ws.effort ?? account.effort ?? "");
1912
+ return { judgeModel: model ? resolveModel(model) : undefined, judgeEffort: effort ?? undefined };
1913
+ })();
1918
1914
  // A step that pours out content stops being recorded past this — its
1919
1915
  // calls keep their names, sizes and times (FOL-54 step 4).
1920
1916
  let capturedChars = 0;
@@ -2498,11 +2494,26 @@ peerBus) {
2498
2494
  // to refuse; the proxy adds the install's own switch and has the last
2499
2495
  // word (egress-proxy.ts), so false here never loosens anything.
2500
2496
  grant.enforce = networkEnforceWanted(readAgentsMd(accountDir(tenant))?.data?.network_enforce, readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce);
2497
+ // Keyless cloud identities the step declared: the proxy may mint tokens
2498
+ // for these audiences only, for this workload (oidc-issuer.ts).
2499
+ // A test run withholds them as it withholds every credential
2500
+ // (test-mode.ts): a token is a credential, however it is delivered.
2501
+ const declaredIdentities = Object.entries(liveSecrets).filter(([, v]) => typeof v === "string" && isIdentityValue(v));
2502
+ const identitiesHeld = testRun
2503
+ ? withholdSecrets(Object.fromEntries(declaredIdentities), {
2504
+ outward: scriptSpecs.some((sc) => sc.outward),
2505
+ allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
2506
+ }).withheld
2507
+ : [];
2508
+ const identities = Object.fromEntries(declaredIdentities.filter(([name]) => !identitiesHeld.includes(name)).map(([name, v]) => [name, { audience: identityOf(v).audience }]));
2509
+ if (Object.keys(identities).length)
2510
+ grant.identities = identities;
2501
2511
  const lease = await platform.egress.lease({
2502
2512
  tenant,
2503
2513
  runId: runId ?? "adhoc",
2504
2514
  grant,
2505
2515
  test: testRun,
2516
+ who: { workspace: workspaceName, agent: step.agent },
2506
2517
  ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
2507
2518
  // tools: [peers] — the roster the proxy serves, and the inboxes a
2508
2519
  // colleague's message may be addressed to. Only when granted: a
@@ -2573,6 +2584,38 @@ peerBus) {
2573
2584
  push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
2574
2585
  }
2575
2586
  }
2587
+ // A keyless identity holds nothing to hand over: the sandbox gets the
2588
+ // path of a token file the driver keeps fresh through the proxy's
2589
+ // /identity door. Without a lease there is no door, and so no token.
2590
+ const identityDeclared = declared.filter((name) => typeof liveSecrets[name] === "string" && isIdentityValue(liveSecrets[name]));
2591
+ for (const name of identityDeclared)
2592
+ delete sandboxSecrets[name];
2593
+ for (const name of identitiesHeld) {
2594
+ push("info", `test: ${name} withheld — no identity token on a test run`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
2595
+ }
2596
+ const identityNames = identityDeclared.filter((name) => !identitiesHeld.includes(name));
2597
+ if (identityNames.length) {
2598
+ if (lease) {
2599
+ const dir = "/tmp/foldrun-identity";
2600
+ sandboxSecrets.FOLDRUN_IDENTITIES = identityNames.join(",");
2601
+ sandboxSecrets.FOLDRUN_IDENTITY_DIR = dir;
2602
+ for (const name of identityNames)
2603
+ sandboxSecrets[`${name}_FILE`] = `${dir}/${name.toLowerCase()}.jwt`;
2604
+ push("info", `identity: ${identityNames.join(", ")} — a short-lived token in $NAME_FILE, renewed by the platform while the step runs`);
2605
+ }
2606
+ else {
2607
+ push("error", `identity: ${identityNames.join(", ")} needs a sandboxed step on the platform — no token here`);
2608
+ }
2609
+ }
2610
+ // A client certificate is the proxy's to present, to the exact sites it
2611
+ // is bound to; never the sandbox's, unless it was saved with `export`.
2612
+ for (const name of declared) {
2613
+ const v = liveSecrets[name];
2614
+ if (typeof v !== "string" || !isClientCertValue(v) || bound.includes(name) || clientCertOf(v).export)
2615
+ continue;
2616
+ delete sandboxSecrets[name];
2617
+ 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`);
2618
+ }
2576
2619
  // On a test run the http tools always go through the proxy as
2577
2620
  // placeholders, even when a script made the step materialise: the
2578
2621
  // proxy is where the outward-write policy lives, and a header
@@ -2651,6 +2694,8 @@ peerBus) {
2651
2694
  budgetNote: stepBudgetNote,
2652
2695
  price: tokenPrice,
2653
2696
  verify: step.verify,
2697
+ ...(judgeModel ? { judgeModel } : {}),
2698
+ ...(judgeEffort ? { judgeEffort } : {}),
2654
2699
  output: step.output,
2655
2700
  schema: resolvedSchema ?? undefined,
2656
2701
  maxTurns: step.maxTurns,
@@ -2705,6 +2750,9 @@ peerBus) {
2705
2750
  emit: pushWatching,
2706
2751
  // Stamps the sandbox, so stopping this run can destroy it.
2707
2752
  runId,
2753
+ // A step still waiting for room has no sandbox to destroy; the
2754
+ // executor asks this before it creates one (pod-room.ts).
2755
+ ...(stopRequested ? { stopRequested } : {}),
2708
2756
  size,
2709
2757
  // The pod's network, for the executor's own wall (the k8s tier's
2710
2758
  // network policy): a filtered pod reaches the proxy and DNS only.
@@ -2969,7 +3017,19 @@ peerBus) {
2969
3017
  for (const name of held.withheld) {
2970
3018
  push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
2971
3019
  }
2972
- const stepSecrets = held.env;
3020
+ // A client certificate not saved with `export` has no proxy here to
3021
+ // present it: it stays out of everything this step runs — tools,
3022
+ // consults, verify commands — not only out of the files.
3023
+ const stepSecrets = Object.fromEntries(Object.entries(held.env).filter(([name, v]) => {
3024
+ if (typeof v === "string" && isIdentityValue(v)) {
3025
+ push("error", `identity: ${name} needs a sandboxed step on the platform — no token here`);
3026
+ return false;
3027
+ }
3028
+ if (typeof v !== "string" || !isClientCertValue(v) || clientCertOf(v).export)
3029
+ return true;
3030
+ push("info", `secrets: ${name} is a client certificate — no egress proxy here to present it; save it with export to use it as files`);
3031
+ return false;
3032
+ }));
2973
3033
  // The model credential for this machine's Claude Code — the agent's
2974
3034
  // own provider when it names one. The host allowlist drops it, so
2975
3035
  // without this every in-process step ran on the machine's login.
@@ -3040,6 +3100,8 @@ peerBus) {
3040
3100
  budgetNote: stepBudgetNote,
3041
3101
  price: tokenPrice,
3042
3102
  verify: step.verify,
3103
+ ...(judgeModel ? { judgeModel } : {}),
3104
+ ...(judgeEffort ? { judgeEffort } : {}),
3043
3105
  output: step.output,
3044
3106
  schema: resolvedSchema ?? undefined,
3045
3107
  maxTurns: step.maxTurns,
@@ -3247,7 +3309,7 @@ export function copyTreeBytes(from, to) {
3247
3309
  function openApiSources(agentDir, tenant) {
3248
3310
  const out = new Set();
3249
3311
  try {
3250
- const { data } = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
3312
+ const { data } = configMatter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
3251
3313
  const front = data;
3252
3314
  for (const api of parseApis(front.apis))
3253
3315
  if (api.openapi?.startsWith("https://"))
@@ -3805,7 +3867,15 @@ async function harvestQuietly(tenant, workspace, run) {
3805
3867
  export const drivingRuns = new Set();
3806
3868
  export function driveRun(tenant, workspace, run, modelOverride, tags = [], opts = {}) {
3807
3869
  drivingRuns.add(run.id);
3870
+ // The run's egress leases are renewed while it is driven, by whichever
3871
+ // door drove it — the queue, or a reconcile that picked it back up — so an
3872
+ // identity token is renewed for a live step however it was started.
3873
+ const renew = platform.egress.renew;
3874
+ const beat = renew ? setInterval(() => void renew(run.id).catch(() => undefined), 5 * 60_000) : null;
3875
+ beat?.unref?.();
3808
3876
  return driveRunInner(tenant, workspace, run, modelOverride, tags, opts).finally(() => {
3877
+ if (beat)
3878
+ clearInterval(beat);
3809
3879
  drivingRuns.delete(run.id);
3810
3880
  });
3811
3881
  }
@@ -3839,10 +3909,31 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3839
3909
  *
3840
3910
  * The flag is one-way: nothing here clears it, so the read-modify-write can
3841
3911
  * only ever turn a stop on, never off.
3912
+ *
3913
+ * And once it is on, this copy may not undo what stopRun wrote: the run
3914
+ * stays failed, and steps that had not started stay skipped. Before
3915
+ * 21 Oct 2026 only the flag came across, so the next save put the record
3916
+ * back to `running` with its steps pending; a second stop then answered
3917
+ * "failed" instead of "already failed", and the group kept starting pods
3918
+ * (medium-desk run-mvho48wp-99e1). A step running in THIS process is left
3919
+ * as it is: its sandbox is gone, it ends on its own, and records how.
3842
3920
  */
3843
3921
  const save = () => {
3844
- if (!run.stopRequested && readRun(tenant, workspace, run.id)?.stopRequested) {
3922
+ const disk = run.stopRequested ? null : readRun(tenant, workspace, run.id);
3923
+ if (disk?.stopRequested)
3845
3924
  run.stopRequested = true;
3925
+ if (run.stopRequested) {
3926
+ if (run.status !== "completed" && run.status !== "failed") {
3927
+ run.status = "failed";
3928
+ run.finishedAt ??= disk?.finishedAt ?? new Date().toISOString();
3929
+ }
3930
+ run.parkedAt = null;
3931
+ for (const s of run.steps) {
3932
+ if (s.status === "pending" || s.status === "awaiting-approval") {
3933
+ s.status = "skipped";
3934
+ s.skipReason ??= "run stopped";
3935
+ }
3936
+ }
3846
3937
  }
3847
3938
  writeRun(tenant, workspace, run);
3848
3939
  };
@@ -4063,7 +4154,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4063
4154
  return groupData[i];
4064
4155
  return undefined;
4065
4156
  };
4066
- const dataOf = (group) => {
4157
+ // A rescue's result already stands on the step it took over, so the
4158
+ // three handoffs below read the flow's steps only: counting the rescue
4159
+ // too handed the next group the same output twice, or a stale one
4160
+ // from an evaluator loop's earlier cycle.
4161
+ const flowStepsOf = (group) => group.filter((s) => !isRescueRecord(s));
4162
+ const dataOf = (all) => {
4163
+ const group = flowStepsOf(all);
4067
4164
  const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
4068
4165
  if (carrying.length === 0)
4069
4166
  return undefined;
@@ -4071,7 +4168,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4071
4168
  return carrying[0].data;
4072
4169
  return carrying.map((s) => ({ agent: s.agent, ...(s.item ? { item: s.item } : {}), data: s.data }));
4073
4170
  };
4074
- const resultsOf = (group) => {
4171
+ const resultsOf = (all) => {
4172
+ const group = flowStepsOf(all);
4075
4173
  const results = group
4076
4174
  .filter((s) => s.status === "completed" && s.result)
4077
4175
  .map((s) => s.item
@@ -4083,8 +4181,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4083
4181
  };
4084
4182
  // What each step concluded — its final turn — for the handoffs that
4085
4183
  // should read a conclusion rather than a transcript (`each: lines`).
4086
- const conclusionsOf = (group) => {
4087
- const out = group
4184
+ const conclusionsOf = (all) => {
4185
+ const out = flowStepsOf(all)
4088
4186
  .filter((s) => s.status === "completed" && (s.conclusion || s.result))
4089
4187
  .map((s) => s.conclusion || s.result);
4090
4188
  return out.length ? out.join("\n") : null;
@@ -4459,7 +4557,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4459
4557
  save();
4460
4558
  if (freshGroup.every((s) => s.status !== "pending")) {
4461
4559
  // everything in this group was rejected
4462
- if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
4560
+ if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s))) {
4463
4561
  run.status = "failed";
4464
4562
  break;
4465
4563
  }
@@ -4472,7 +4570,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4472
4570
  // itself mid-turn at its share, so a fan-out of twenty cannot end
4473
4571
  // twenty steps over — the shares sum to what was left. Between
4474
4572
  // groups the check above still refuses to start the next one.
4475
- const launching = freshGroup.filter((s) => s.status === "pending");
4573
+ // A rescue record is the on-fail of a step in this group, not a step
4574
+ // of the flow: one left pending by a drive that died is picked up by
4575
+ // rescueStep below, never launched alongside the group (21 Oct 2026:
4576
+ // a resumed run launched 77 leftover fixers at once).
4577
+ const launching = freshGroup.filter((s) => s.status === "pending" && !isRescueRecord(s));
4476
4578
  // tools: [peers] — what each step of the group is told about the
4477
4579
  // others, and the bus they share when they run in this process.
4478
4580
  const peerBus = localPeerBus();
@@ -4531,7 +4633,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4531
4633
  // lead's reply or for a slot — must not start it: the
4532
4634
  // between-groups check runs only once this group is over,
4533
4635
  // and save() is what carries the flag in from the record.
4534
- if (lead || pool) {
4636
+ {
4535
4637
  save();
4536
4638
  if (run.stopRequested) {
4537
4639
  step.status = "skipped";
@@ -4694,6 +4796,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4694
4796
  // runs out of cycles below, which used to fail the run without ever
4695
4797
  // reaching the step's on-fail (medium-desk's claim-trimmer was
4696
4798
  // written for exactly that case and could never run).
4799
+ // A rescue hears a stop the way a group step does: its model loop and
4800
+ // its executor's wait for room both poll this (no sandbox exists yet
4801
+ // for a stop to destroy while it waits).
4802
+ const stopPoll = () => {
4803
+ save();
4804
+ return run.stopRequested === true;
4805
+ };
4697
4806
  const rescueStep = async (step) => {
4698
4807
  if (!step.onFail)
4699
4808
  return;
@@ -4706,6 +4815,38 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4706
4815
  });
4707
4816
  return;
4708
4817
  }
4818
+ // One rescue per failure, across drives. A worker that died
4819
+ // mid-rescue left an unfinished record behind it; the drive that
4820
+ // resumes picks that one up instead of splicing another — a crash
4821
+ // loop used to add one per restart (blog-desk run-mvh1zra7-ae4k,
4822
+ // ~80). The leftover carries the same instruction, built from the
4823
+ // same failure, and a running one may still hold a sandbox to
4824
+ // re-attach to, so it is THE rescue; any further unfinished copies
4825
+ // are superseded. A FINISHED rescue is history, never reused: it
4826
+ // answered an earlier failure — an evaluator loop's previous cycle
4827
+ // — not this one.
4828
+ const leftovers = [];
4829
+ for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isRescueRecord(run.steps[j]); j++) {
4830
+ const r = run.steps[j];
4831
+ if (r.agent === step.onFail && (r.rescueOf === undefined || r.rescueOf === step.agent) && (r.status === "pending" || r.status === "running")) {
4832
+ leftovers.push(r);
4833
+ }
4834
+ }
4835
+ const resumed = leftovers[0];
4836
+ for (const r of leftovers.slice(1)) {
4837
+ r.status = "skipped";
4838
+ r.skipReason = "superseded on resume — one rescue per failure";
4839
+ }
4840
+ if (resumed) {
4841
+ step.events.push({ t: new Date().toISOString(), type: "info", text: `resuming the rescue by [[${step.onFail}]] that an earlier drive started` });
4842
+ resumed.rescueOf ??= step.agent;
4843
+ resumed.attempts = Math.max(1, resumed.attempts ?? 0);
4844
+ resumed.status = "running";
4845
+ save();
4846
+ await runStep(rescuerDir, tenant, resumed, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(resumed));
4847
+ settleRescue(step, resumed);
4848
+ return;
4849
+ }
4709
4850
  step.events.push({
4710
4851
  t: new Date().toISOString(),
4711
4852
  type: "info",
@@ -4722,6 +4863,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4722
4863
  const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
4723
4864
  const rescue = {
4724
4865
  agent: step.onFail,
4866
+ rescueOf: step.agent,
4725
4867
  instruction: `${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
4726
4868
  `Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
4727
4869
  ...(step.ask ? { ask: step.ask } : {}),
@@ -4751,7 +4893,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4751
4893
  rescue.attempts = 1;
4752
4894
  rescue.status = "running";
4753
4895
  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));
4896
+ await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(rescue));
4897
+ settleRescue(step, rescue);
4898
+ };
4899
+ // What a rescue's end means for the step it took over.
4900
+ const settleRescue = (step, rescue) => {
4755
4901
  // runStep mutates rescue.status; widen past TS's "running" narrowing.
4756
4902
  const rescueOutcome = rescue.status;
4757
4903
  if (rescueOutcome === "completed") {
@@ -4786,7 +4932,10 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4786
4932
  // here: the person chose to start after it. Counting it stopped
4787
4933
  // fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
4788
4934
  // a second, before the one step it was asked to run.
4789
- const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom);
4935
+ // A rescue record's own failure is already on the step it took over
4936
+ // ("also failed"); counted here it would fail a group whose step a
4937
+ // later rescue completed.
4938
+ const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s));
4790
4939
  if (requiredFailed) {
4791
4940
  run.status = "failed";
4792
4941
  break;
@@ -4862,6 +5011,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4862
5011
  for (const s of [...ordered[gi - 1], ...freshGroup]) {
4863
5012
  if (s.status === "skipped" && s.each)
4864
5013
  continue; // an expanded template stays expanded
5014
+ // A rescue answered its own failure and stays as it ended: reset
5015
+ // to pending it read as an interrupted rescue, and the next
5016
+ // cycle's failure was handed the old one's instructions.
5017
+ if (isRescueRecord(s))
5018
+ continue;
4865
5019
  s.status = "pending";
4866
5020
  s.result = null;
4867
5021
  }
@@ -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
  /**
package/dist/src/scan.js CHANGED
@@ -26,7 +26,7 @@
26
26
  // The reason is required: an ignore without one ignores nothing and is itself
27
27
  // reported, because "why is this allowed?" is the question a reviewer asks.
28
28
  import crypto from "node:crypto";
29
- import matter from "./matter.js";
29
+ import matter, { frontmatterErrorLine, frontmatterErrorText, parseLenient, configMatter } from "./matter.js";
30
30
  import { refNames } from "./refs.js";
31
31
  import { resolveNetwork } from "./network.js";
32
32
  import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.js";
@@ -159,6 +159,32 @@ export const SCAN_RULES = [
159
159
  help: "`effort: extreme`, `size: giant`, `trigger: sometimes`, a cron the scheduler cannot read: each is ignored or falls back, " +
160
160
  "so the agent or flow quietly does something else. The message lists what the field takes.",
161
161
  },
162
+ {
163
+ id: "config/conflict",
164
+ category: "config",
165
+ level: "error",
166
+ title: "A field set twice: at the top and inside its job block",
167
+ help: "`limits:` at the top and `limits:` inside `guardrail:` cannot both be meant. The top one would be read and the other " +
168
+ "quietly ignored, so the file is refused until one is removed.",
169
+ },
170
+ {
171
+ id: "config/misplaced",
172
+ category: "config",
173
+ level: "error",
174
+ title: "A field inside the wrong job block",
175
+ help: "Each of the four blocks (governance, guardrail, observability, evaluation) holds its own fields. A setting written in " +
176
+ "another block is not read — a guardrail under governance: guards nothing — so the file is refused until it is moved " +
177
+ "to the block the message names, or to the top. A word foldrun does not read at all is only a note.",
178
+ },
179
+ {
180
+ id: "config/invalid-frontmatter",
181
+ category: "config",
182
+ level: "warn",
183
+ title: "Frontmatter that YAML cannot read",
184
+ help: "A value with a colon followed by a space (`description: the format (Markdown, ::: directives)`) ends the YAML. " +
185
+ "foldrun reads a skill like that by quoting the value, but the dashboard editor and the source API refuse to save it, " +
186
+ "and other tools that read the file may not. Quote the value: `description: \"…\"`.",
187
+ },
162
188
  {
163
189
  id: "config/missing-field",
164
190
  category: "config",
@@ -544,17 +570,35 @@ export function nearestField(key, known) {
544
570
  return bestD <= allowed ? best : null;
545
571
  }
546
572
  function keyLine(lines, key) {
547
- const re = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`);
548
- const i = lines.findIndex((l) => re.test(l));
549
- return i >= 0 ? i + 1 : undefined;
573
+ const k = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
574
+ const top = new RegExp(`^${k}\\s*:`);
575
+ const i = lines.findIndex((l) => top.test(l));
576
+ if (i >= 0)
577
+ return i + 1;
578
+ // A key inside one of the four job blocks: indented, under its header.
579
+ const inner = new RegExp(`^\\s+${k}\\s*:`);
580
+ let inBlock = false;
581
+ for (let n = 0; n < lines.length; n++) {
582
+ const l = lines[n];
583
+ if (/^(governance|guardrail|observability|evaluation)\s*:\s*(#.*)?$/.test(l))
584
+ inBlock = true;
585
+ else if (/^\S/.test(l))
586
+ inBlock = false;
587
+ else if (inBlock && inner.test(l))
588
+ return n + 1;
589
+ }
590
+ return undefined;
550
591
  }
551
592
  /** Read-only: gray-matter caches by content, so two files with the same
552
593
  * text share one object, and writing to it would leak between them. */
553
- function frontOf(content) {
594
+ function frontOf(content, filePath) {
554
595
  if (!/^?---\r?\n/.test(content))
555
596
  return null;
556
597
  try {
557
- return matter(content).data;
598
+ // An agent, a flow or AGENTS.md reads its job blocks as flat keys, as
599
+ // the runtime does; every other document is read as written.
600
+ const kind = filePath ? schemaKindOf(filePath) : null;
601
+ return (kind === "agent" || kind === "flow" || kind === "agents-md" ? configMatter(content) : matter(content)).data;
558
602
  }
559
603
  catch {
560
604
  return null; // a frontmatter YAML cannot read is check's finding, not ours
@@ -683,6 +727,14 @@ const VENDORED = /(?:^|\/)(?:node_modules|\.venv|venv|site-packages|__pycache__|
683
727
  * since a key in a log here is a key in the workspace (seen live on
684
728
  * 17 Oct 2026: zero-width characters in a CLI MCP log, reported as hidden
685
729
  * text). */
730
+ /** The definition files a deploy refuses outright when their frontmatter does
731
+ * not parse (deploy.ts deployIssues): the workspace's own, by these paths.
732
+ * Everything else the platform reads frontmatter from — skills, and the
733
+ * account library's agents, tools and hooks — is the scanner's to report. */
734
+ export const STRICT_FRONTMATTER_PATH = /^(AGENTS\.md|agents\/[^/]+\/agent\.md|flows\/[^/]+\.md|tools\/[^/]+\.md|tools\/[^/]+\/tool\.md|evals\/[^/]+\.md|hooks\/[^/]+\/hook\.md|\.claude\/agents\/[^/]+\.md)$/;
735
+ /** A skill file in either layout the runtime discovers: <name>/SKILL.md, or
736
+ * the flat <name>.md, under skills/ or .agents/skills/, in the library too. */
737
+ const SKILL_FILE = /(?:^|\/)skills\/(?:[^/]+\/SKILL\.md|[^/]+\.md)$/;
686
738
  const RUNTIME_WRITTEN = /(?:^|\/)(?:\.cache|\.npm|\.local|\.pnpm-store|\.yarn|\.foldrun)\//;
687
739
  /** Text files only — a PNG read as UTF-8 is noise to every rule here. */
688
740
  function isText(f) {
@@ -740,9 +792,35 @@ export function scanFiles(files, options = {}) {
740
792
  scanContent(f, lines, push);
741
793
  scanSupplyChain(f, lines, push);
742
794
  const schema = schemaKindOf(f.path);
795
+ // Frontmatter YAML cannot read. Until 2026-10-21 nothing reported it for
796
+ // a skill or a library file: frontOf below reads it as "no frontmatter"
797
+ // and moves on, and the deploy's own refusal covers only the workspace's
798
+ // definition files. A vaza.ai skill with `description: … (Markdown,
799
+ // frontmatter, ::: directives) …` deployed clean, ran (the runtime quotes
800
+ // the value and retries), and was refused by the source API on its next
801
+ // save.
802
+ if ((schema || SKILL_FILE.test(f.path)) && !STRICT_FRONTMATTER_PATH.test(f.path) && /^\uFEFF?---\r?\n/.test(f.content)) {
803
+ let err = null;
804
+ try {
805
+ matter(f.content);
806
+ }
807
+ catch (e) {
808
+ err = e;
809
+ }
810
+ if (err) {
811
+ const isSkill = schema === "skill" || SKILL_FILE.test(f.path);
812
+ const read = parseLenient(f.content);
813
+ const what = isSkill && !read.failed
814
+ ? "foldrun reads this skill anyway by quoting the value, but the dashboard editor and the source API refuse to save it, and other skills clients may not load it"
815
+ : isSkill
816
+ ? "the runtime reads it as empty, so the skill has no name or description and is never loaded"
817
+ : "the runtime cannot read this file";
818
+ push("config/invalid-frontmatter", f.path, frontmatterErrorLine(err), `config/invalid-frontmatter ${frontmatterErrorText(err)}`, `${frontmatterErrorText(err)} ${what === "the runtime cannot read this file" ? "The runtime cannot read this file" : what.replace(/^foldrun/, "foldrun").replace(/^the/, "The")}.`);
819
+ }
820
+ }
743
821
  // A skill with no frontmatter at all is the clearest one that never
744
822
  // loads: read it as empty, so its required fields are reported.
745
- const data = schema || docKind(f.path) ? (frontOf(f.content) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
823
+ const data = schema || docKind(f.path) ? (frontOf(f.content, f.path) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
746
824
  if (!data)
747
825
  continue;
748
826
  if (schema) {
@@ -770,7 +848,7 @@ export function scanFiles(files, options = {}) {
770
848
  }
771
849
  // project.md is the older name the runtime still reads (Codex review, FOL-58).
772
850
  const workspaceMd = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
773
- scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
851
+ scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
774
852
  for (const m of more.values()) {
775
853
  findings.push({ rule: m.rule, level: m.level ?? scanRule(m.rule)?.level ?? "warn", file: m.file, message: `and ${m.n} more like this in this file, not listed`, fingerprint: m.hash.digest("hex").slice(0, 16), truncated: true });
776
854
  }