@foldrun/core 0.5.0 → 0.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 (99) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.js +4 -0
  4. package/dist/src/agent-import.d.ts +21 -0
  5. package/dist/src/agent-import.js +116 -0
  6. package/dist/src/api-tools.js +14 -2
  7. package/dist/src/approvals.d.ts +8 -1
  8. package/dist/src/approvals.js +10 -5
  9. package/dist/src/browser-pod.d.ts +20 -1
  10. package/dist/src/browser-pod.js +58 -9
  11. package/dist/src/completions.js +1 -0
  12. package/dist/src/confine.d.ts +1 -0
  13. package/dist/src/confine.js +33 -4
  14. package/dist/src/container.d.ts +9 -0
  15. package/dist/src/container.js +115 -5
  16. package/dist/src/context-tools.d.ts +1 -1
  17. package/dist/src/deploy.d.ts +9 -0
  18. package/dist/src/deploy.js +31 -3
  19. package/dist/src/evals.js +24 -4
  20. package/dist/src/host-env.d.ts +14 -0
  21. package/dist/src/host-env.js +31 -0
  22. package/dist/src/install-helper.d.ts +17 -0
  23. package/dist/src/install-helper.js +134 -0
  24. package/dist/src/install-tool.d.ts +128 -0
  25. package/dist/src/install-tool.js +133 -0
  26. package/dist/src/mail.js +3 -1
  27. package/dist/src/model-credential.d.ts +30 -0
  28. package/dist/src/model-credential.js +98 -0
  29. package/dist/src/model-env.d.ts +17 -0
  30. package/dist/src/model-env.js +57 -0
  31. package/dist/src/package.d.ts +67 -0
  32. package/dist/src/package.js +594 -0
  33. package/dist/src/platform.d.ts +5 -0
  34. package/dist/src/platform.js +1 -0
  35. package/dist/src/provider-health.js +3 -0
  36. package/dist/src/run-container.d.ts +6 -1
  37. package/dist/src/run-container.js +93 -20
  38. package/dist/src/runner.d.ts +239 -0
  39. package/dist/src/runner.js +380 -72
  40. package/dist/src/runtime.d.ts +77 -0
  41. package/dist/src/runtime.js +230 -5
  42. package/dist/src/script-tools.d.ts +16 -0
  43. package/dist/src/script-tools.js +50 -8
  44. package/dist/src/secrets.d.ts +5 -0
  45. package/dist/src/secrets.js +37 -6
  46. package/dist/src/starter.d.ts +12 -0
  47. package/dist/src/starter.js +36 -4
  48. package/dist/src/step-exec.d.ts +22 -2
  49. package/dist/src/step-exec.js +70 -7
  50. package/dist/src/storage.d.ts +7 -1
  51. package/dist/src/storage.js +14 -1
  52. package/dist/src/store.d.ts +24 -0
  53. package/dist/src/store.js +91 -10
  54. package/dist/src/subagents.js +1 -1
  55. package/dist/src/test-mode.d.ts +17 -2
  56. package/dist/src/test-mode.js +53 -13
  57. package/dist/src/tool-names.d.ts +3 -1
  58. package/dist/src/tool-names.js +4 -2
  59. package/dist/src/tool-test.js +8 -1
  60. package/dist/src/zip.d.ts +17 -0
  61. package/dist/src/zip.js +184 -0
  62. package/index.ts +4 -0
  63. package/package.json +1 -1
  64. package/src/agent-import.ts +154 -0
  65. package/src/api-tools.ts +16 -1
  66. package/src/approvals.ts +10 -3
  67. package/src/browser-pod.ts +46 -9
  68. package/src/completions.ts +1 -0
  69. package/src/confine.ts +36 -5
  70. package/src/container.ts +112 -5
  71. package/src/context-tools.ts +1 -1
  72. package/src/deploy.ts +28 -3
  73. package/src/evals.ts +23 -2
  74. package/src/host-env.ts +32 -0
  75. package/src/install-helper.ts +143 -0
  76. package/src/install-tool.ts +154 -0
  77. package/src/mail.ts +3 -1
  78. package/src/model-credential.ts +112 -0
  79. package/src/model-env.ts +66 -0
  80. package/src/package.ts +664 -0
  81. package/src/platform.ts +6 -0
  82. package/src/provider-health.ts +3 -0
  83. package/src/run-container.ts +92 -19
  84. package/src/runner.ts +385 -72
  85. package/src/runtime.ts +235 -5
  86. package/src/script-tools.ts +48 -6
  87. package/src/secrets.ts +34 -6
  88. package/src/starter.ts +42 -4
  89. package/src/step-exec.ts +75 -10
  90. package/src/storage.ts +13 -0
  91. package/src/store.ts +91 -9
  92. package/src/subagents.ts +1 -1
  93. package/src/test-mode.ts +51 -11
  94. package/src/tool-names.ts +4 -2
  95. package/src/tool-test.ts +8 -1
  96. package/src/zip.ts +189 -0
  97. package/templates/blog-desk/agents/writer/agent.md +1 -1
  98. package/templates/hello/agents/curator/agent.md +3 -3
  99. package/templates/hello/agents/notetaker/agent.md +2 -2
@@ -3,6 +3,7 @@
3
3
  // fail without failing the flow. A direct agent run is a one-step ad-hoc
4
4
  // flow, so there is a single execution path.
5
5
  import fs from "node:fs";
6
+ import os from "node:os";
6
7
  import path from "node:path";
7
8
  import matter from "gray-matter";
8
9
  import { knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor } from "./step-exec.js";
@@ -11,13 +12,14 @@ import { resolveAgentPath } from "./confine.js";
11
12
  import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.js";
12
13
  import { eventUrl } from "./webhook.js";
13
14
  import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
14
- import { hostSafeEnv } from "./host-env.js";
15
+ import { hostSafeEnv, platformApiUrl } from "./host-env.js";
16
+ import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken } from "./model-credential.js";
15
17
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
16
18
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
17
19
  import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote } from "./refusal.js";
18
20
  import { platform } from "./platform.js";
19
21
  import { healthKey } from "./secret-health.js";
20
- import { TEST_WRITES_DIR, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.js";
22
+ import { TEST_WRITES_DIR, overlayTestWrites, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.js";
21
23
  /** Does this process run steps in a sandbox — the container core ships, or
22
24
  * one the platform registered (a pod)? */
23
25
  const isolatedRun = () => {
@@ -41,8 +43,10 @@ import { startTranslator, translatorSpecFor } from "./translator.js";
41
43
  import { providerPreset } from "./providers.js";
42
44
  import { buildScriptTools, parseScripts } from "./script-tools.js";
43
45
  import { libraryDir, libraryTools, libraryMemoryIndex } from "./library.js";
44
- import { fingerprint, mergeRuntimes, parseRuntime, prepareRuntime } from "./runtime.js";
45
- import { materializeFiles, harvestFiles } from "./storage.js";
46
+ import { fingerprint, mergeRuntimes, parseRuntime, prepareRuntime, stepInstallEnv } from "./runtime.js";
47
+ import { buildInstallTool, INSTALL_TOOL } from "./install-tool.js";
48
+ import { dataRoot } from "./paths.js";
49
+ import { materializeFiles, harvestFiles, storageBaseline } from "./storage.js";
46
50
  import { chooseExecutor, ensureImage } from "./container.js";
47
51
  import { stampBundle } from "./okf.js";
48
52
  import { readBrowseSettings, readSearchSettings, resolveSearch, searchSettingsEnv, webConfig, browseSessionProblems } from "./providers.js";
@@ -613,7 +617,7 @@ export function resolveDocLinks(text, workspaceRoot) {
613
617
  return hit ? `\`${hit}\`` : whole;
614
618
  });
615
619
  }
616
- function agentContext(agentDir, tenant, tags = [],
620
+ export function agentContext(agentDir, tenant, tags = [],
617
621
  /** Who is running, so scripts can stamp what they write with its provenance. */
618
622
  identity = {},
619
623
  /** The flow's own `timezone:`, when this step belongs to one. Between the
@@ -657,8 +661,7 @@ grantsOnly = false) {
657
661
  `\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
658
662
  `\`outputs/\` is yours, and \`workspace/\` is the workspace root, in every tool ` +
659
663
  `and in the shell: the shared file store is \`workspace/storage/\`, what runs ` +
660
- `carry forward is \`workspace/state/\`. (\`../../\` reaches the same root and ` +
661
- `still works, so a path like \`../../knowledge/\` below is \`workspace/knowledge/\`.) ` +
664
+ `carry forward is \`workspace/state/\`, its knowledge \`workspace/knowledge/\`. ` +
662
665
  `Absolute paths are outside the workspace and will be refused.\n\n` +
663
666
  // A flow's whole point is that a later step works on what an earlier one
664
667
  // produced, and each agent writes to its own outputs/. A run showed what
@@ -707,25 +710,7 @@ grantsOnly = false) {
707
710
  // skills for a few tokens each.
708
711
  // Own skills, then the workspace's, then the workspace library — nearest
709
712
  // definition of a name wins, so a team default can be overridden locally.
710
- const skills = [];
711
- const seenSkills = new Set();
712
- const addSkills = (dir, prefix, subdir = "skills") => {
713
- for (const skill of discoverSkills(dir, subdir)) {
714
- if (seenSkills.has(skill.name))
715
- continue;
716
- seenSkills.add(skill.name);
717
- skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
718
- }
719
- };
720
- const workspaceRoot = path.join(agentDir, "..", "..");
721
- addSkills(agentDir, "");
722
- addSkills(workspaceRoot, "../../"); // the workspace's skills/
723
- // The cross-client convention: skills any skills-compatible tool installs
724
- // under .agents/skills/ are visible here too, and vice versa. Scanned after
725
- // the native location so a foldrun-native skill wins a name clash, but both
726
- // are discovered. project scope only — foldrun workspaces are self-contained.
727
- addSkills(workspaceRoot, "../../", ".agents/skills");
728
- addSkills(path.join(libraryDir(tenant)), "../../../../library/"); // the account library
713
+ const skills = skillsInScope(agentDir, tenant);
729
714
  // A skill with `when:` only loads when the run carries a matching tag —
730
715
  // so an agent with 119 skills costs 119 lines of context only when every
731
716
  // one is genuinely relevant.
@@ -804,7 +789,7 @@ grantsOnly = false) {
804
789
  return out.sort();
805
790
  };
806
791
  const own = listDir(path.join(agentDir, "scripts"), "scripts/");
807
- const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "../../scripts/");
792
+ const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "workspace/scripts/");
808
793
  // The library's path depends on where this run executes: isolated runs see
809
794
  // it at /library, host runs at its real location. Printing the host path
810
795
  // into a container's prompt handed the model files it could never open.
@@ -1018,7 +1003,10 @@ grantsOnly = false) {
1018
1003
  //
1019
1004
  // The URL sits in git; the token is a ${SECRET} resolved server-side. An
1020
1005
  // agent's own block wins over the workspace's, like everything else here.
1021
- const providerBlock = front.provider ?? workspaceFrontmatter(agentDir, tenant).provider;
1006
+ // Then the account's own model key (Settings → Model), when it set one:
1007
+ // an account that brings its key need not write a block anywhere.
1008
+ const fileBlock = front.provider ?? workspaceFrontmatter(agentDir, tenant).provider;
1009
+ const providerBlock = fileBlock ?? accountModelBlock((name) => getSecret(tenant, name)?.value ?? null);
1022
1010
  let providerEnv = {};
1023
1011
  let providerLabel = null;
1024
1012
  // Secret values pulled into the provider block, name → value. Kept apart
@@ -1110,17 +1098,18 @@ grantsOnly = false) {
1110
1098
  let exec = null;
1111
1099
  let runtime = { interpreters: {}, env: {}, log: [], error: null };
1112
1100
  if (executor === "docker" && !grantsOnly) {
1113
- const image = ensureImage(runtimeSpec);
1101
+ // The script image also carries what the scripts' own languages need: a
1102
+ // .mjs tool with no runtime: block still needs node in its container.
1103
+ // Here only — the run container and the host have node already, and a
1104
+ // spec there sets off the runtime build for nothing.
1105
+ const image = ensureImage(mergeRuntimes(runtimeSpec, scriptLanguages(scriptSpecs)));
1114
1106
  runtimeLog.push(...image.log);
1115
1107
  runtimeError = image.error;
1116
1108
  if (!image.error) {
1117
1109
  exec = {
1118
1110
  executor: "docker",
1119
1111
  image: image.tag,
1120
- mounts: {
1121
- [path.resolve(agentDir, "..", "..", "scripts")]: "/workspace-scripts",
1122
- [libraryDir(tenant, "scripts")]: "/library-scripts",
1123
- },
1112
+ mounts: scriptMounts(agentDir, tenant),
1124
1113
  // Only agents that granted something reaching out — an `apis:` entry
1125
1114
  // or one of their own tools — get egress.
1126
1115
  network: parseApis(front.apis).length > 0 || ownToolNames(front).length > 0,
@@ -1168,6 +1157,9 @@ grantsOnly = false) {
1168
1157
  // one — the `websearch` library tool reads it. Not a secret; rides with
1169
1158
  // the clock so it reaches scripts, the sandbox and verify alike.
1170
1159
  ...(process.env.FOLDRUN_SEARCH_URL ? { FOLDRUN_SEARCH_URL: process.env.FOLDRUN_SEARCH_URL } : {}),
1160
+ // Where this install's own API answers (host-env.ts platformApiUrl), so a
1161
+ // script calls `$FOLDRUN_API_URL/workspaces/…` instead of naming a host.
1162
+ ...(platformApiUrl() ? { FOLDRUN_API_URL: platformApiUrl() } : {}),
1171
1163
  // Whose index our web search asks when the agent named a search API.
1172
1164
  // Unset means the account's own engine. The name, never the key.
1173
1165
  ...(searchChoice.shape === "direct" && searchChoice.provider
@@ -1245,7 +1237,18 @@ grantsOnly = false) {
1245
1237
  parts.push(`# Clock\n\nToday is ${clockEnv.FOLDRUN_DATE} (${timezone}). Use this date for anything you ` +
1246
1238
  `write or check — it is the workspace's calendar, not the container's clock.`);
1247
1239
  if (scriptTools.promptLines.length) {
1248
- parts.push(`# Tools from scripts\n\nCall these directly — they run workspace scripts for you:\n\n${scriptTools.promptLines.join("\n")}`);
1240
+ parts.push(`# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
1241
+ `\`mcp__foldrun_scripts__<name>\` (\`web\` is \`mcp__foldrun_scripts__web\`); a bare name is refused:\n\n${scriptTools.promptLines.join("\n")}`);
1242
+ }
1243
+ // The web tool's own description lists every action, so an agent whose
1244
+ // search or fetch a model provider answers called web action=search,
1245
+ // was refused, and only then used WebSearch — eight refusals in one
1246
+ // reddit-desk validator step on 6 Oct 2026. Say it before the first call.
1247
+ if (builtins.length) {
1248
+ const named = builtins.map((b) => b.split("="));
1249
+ parts.push(`# Web search and fetch on this run\n\n` +
1250
+ named.map(([action, tool]) => `- To ${action}, call **${tool}** directly. \`web\` with action=${action} is switched off on this run.`).join("\n") +
1251
+ `\n\nThe web tool's other actions work as usual.`);
1249
1252
  }
1250
1253
  if (declared.length) {
1251
1254
  parts.push(`# Secrets\n\nAvailable to your scripts as environment variables: ${declared.join(", ")}. ` +
@@ -1254,7 +1257,13 @@ grantsOnly = false) {
1254
1257
  parts.push("Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
1255
1258
  "what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
1256
1259
  "for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
1257
- "directory a tool made outside the workspace, such as a checkout, is reached only through that tool.");
1260
+ "directory a tool made outside the workspace, such as a checkout, is reached only through that tool. " +
1261
+ // 45 refusals in 34 runs (6–7 Oct 2026), nearly all an agent rewriting
1262
+ // a plan or draft an earlier run left: the Write tool refuses a file
1263
+ // that exists and has not been Read in this step, before any hook of
1264
+ // ours can see the call. Said once here instead of learned per step.
1265
+ "To replace a file that already exists, Read it first — Write refuses a file you have not Read in this step (a new file needs no Read). " +
1266
+ "Built-in tools are capitalised: Read, Write, Edit, Glob, Grep, Bash.");
1258
1267
  // The run's one-line summary is the first meaningful line of the last step
1259
1268
  // that produced a result — read, never declared (runSummary in store.ts).
1260
1269
  // That convention was load-bearing and unstated: it decides the runs-list
@@ -1316,6 +1325,13 @@ grantsOnly = false) {
1316
1325
  // names it (FOLDRUN_WEB_BUILTIN) rather than asking our engine.
1317
1326
  if (toolName === "web" && !ref.linked) {
1318
1327
  allowed.push(...providerWebTools);
1328
+ // web writes what it found to files and says "Read that file" — a
1329
+ // screenshot, a crawl, a page saved whole. An agent granted web alone
1330
+ // was told to Read and refused it (blog-desk claim-checker, 7 Oct
1331
+ // 2026: "Read is disabled for this session"). Reading is confined to
1332
+ // the workspace like every file tool; disallowedTools still removes it.
1333
+ if (!allowed.includes("Read"))
1334
+ allowed.push("Read");
1319
1335
  continue;
1320
1336
  }
1321
1337
  // Resolution order: built-in group, exact SDK name, then your own tools.
@@ -1345,6 +1361,9 @@ grantsOnly = false) {
1345
1361
  if (available[toolName])
1346
1362
  shadowed.push(toolName);
1347
1363
  }
1364
+ else if (toolName === "install") {
1365
+ // Granted to every agent anyway (below); naming it is harmless.
1366
+ }
1348
1367
  else if (BUILTIN_TOOLS.has(toolName)) {
1349
1368
  allowed.push(toolName);
1350
1369
  if (available[toolName])
@@ -1367,8 +1386,8 @@ grantsOnly = false) {
1367
1386
  ? [
1368
1387
  { label: "knowledge/", dir: path.join(agentDir, "knowledge") },
1369
1388
  { label: "memory/", dir: path.join(agentDir, "memory") },
1370
- { label: "../../knowledge/", dir: path.join(wsRoot, "knowledge") },
1371
- { label: "../../memory/", dir: path.join(wsRoot, "memory") },
1389
+ { label: "workspace/knowledge/", dir: path.join(wsRoot, "knowledge") },
1390
+ { label: "workspace/memory/", dir: path.join(wsRoot, "memory") },
1372
1391
  { label: "workspace/state/", dir: path.join(wsRoot, "state") },
1373
1392
  { label: `workspace/${STORAGE_DIR}/`, dir: path.join(wsRoot, STORAGE_DIR) },
1374
1393
  { label: "outputs/", dir: path.join(agentDir, "outputs") },
@@ -1408,6 +1427,10 @@ grantsOnly = false) {
1408
1427
  // what it exposes, which is the unit a person actually reasons about anyway.
1409
1428
  for (const name of mcpNames)
1410
1429
  allowed.push(`mcp__${name}`, `mcp__${name}__*`);
1430
+ // install — every agent may install what it finds it needs mid-step (apt,
1431
+ // pip, npm), declared or not (install-tool.ts). Granted by default; taken
1432
+ // away by disallowedTools: [install], and absent in plan mode (read-only).
1433
+ allowed.push(INSTALL_TOOL);
1411
1434
  // Claude Code's spellings, so an agent written for it mostly runs here.
1412
1435
  // `permissionMode: plan` is its read-only mode; ours is the `read` group.
1413
1436
  if (front.permissionMode === "plan") {
@@ -1415,6 +1438,8 @@ grantsOnly = false) {
1415
1438
  allowed.push(...TOOL_MAP.read, ...apiTools.toolNames, ...scriptTools.toolNames);
1416
1439
  }
1417
1440
  const denied = refNames(front.disallowedTools);
1441
+ if (denied.includes("install"))
1442
+ denied.push(INSTALL_TOOL);
1418
1443
  const finalAllowed = allowed.filter((t) => !denied.includes(t));
1419
1444
  return {
1420
1445
  front,
@@ -1617,6 +1642,40 @@ async function terminalAsk(question, options) {
1617
1642
  rl.close();
1618
1643
  }
1619
1644
  }
1645
+ /**
1646
+ * What the person at the gate said, framed for the step's prompt — or "".
1647
+ *
1648
+ * The note is the freshest instruction the step has — later than the flow
1649
+ * file, aimed at this exact run. When the gate asked a question (ask:), the
1650
+ * note IS the answer and is framed as one.
1651
+ *
1652
+ * Both halves go through resolveDocLinks for the same reason the
1653
+ * instruction does: this is authored text on its way to becoming a prompt,
1654
+ * and the rule is that ALL of it expands. A person answering "which list?"
1655
+ * with [[dead-ends]] was sending the model two literal brackets — the one
1656
+ * reference syntax the workspace teaches, and the one place it silently did
1657
+ * nothing. The previous step's results are NOT resolved, deliberately: that
1658
+ * is model output, and a model that happens to emit [[brackets]] must not
1659
+ * thereby name a file.
1660
+ *
1661
+ * An ask: gate approved with no note still says so. It used to add nothing,
1662
+ * so the step never learned its question had been answered: gbp-desk's
1663
+ * rivals sender, told "a plain approval means all offices", saw no answer
1664
+ * at all and stopped BLOCKED (run-muu6r18y-417e, 2026-10-05).
1665
+ */
1666
+ export function operatorBlock(step, workspaceRoot) {
1667
+ const ask = step.ask ? resolveDocLinks(step.ask, workspaceRoot) : "";
1668
+ if (step.approvalNote) {
1669
+ const note = resolveDocLinks(step.approvalNote, workspaceRoot);
1670
+ return step.ask
1671
+ ? `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer:\n${note}\n</operator_answer>`
1672
+ : `\n\n<operator_guidance>\nThe human who approved this step added:\n${note}\n</operator_guidance>`;
1673
+ }
1674
+ if (step.ask && step.approvedAt) {
1675
+ return `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer: they approved it with no note — a plain yes.\n</operator_answer>`;
1676
+ }
1677
+ return "";
1678
+ }
1620
1679
  async function runStep(agentDir, tenant, step, context, save, modelOverride, tags = [], effortOverride, runId,
1621
1680
  /** What the previous group returned as DATA (an `output: json` step's
1622
1681
  * value), beside the prose in `context`. Undefined when nothing did. */
@@ -1658,6 +1717,7 @@ stepIndex) {
1658
1717
  // cover a script's own stdout.
1659
1718
  let redactions = [];
1660
1719
  let fileDir = null;
1720
+ let installDir = null;
1661
1721
  let disposeRuntime;
1662
1722
  // Can this step's tools act outside the workspace? Set once its context is
1663
1723
  // read; the verdict at the end of this function reads it.
@@ -1791,6 +1851,36 @@ stepIndex) {
1791
1851
  push("error", problem);
1792
1852
  if (providerLabel)
1793
1853
  push("info", `provider: ${providerLabel}`);
1854
+ // Which credential the model is called with, on the record: an agent
1855
+ // that names a provider uses its own; otherwise the platform's (in a
1856
+ // run container) or this process's — never a login that just happens
1857
+ // to be on the machine (model-credential.ts).
1858
+ const credential = providerLabel ? null : resolveModelCredential(isolatedRun() ? platformModelEnv() : process.env);
1859
+ step.credential = credential ? credential.label : `provider: ${providerLabel}`;
1860
+ if (credential) {
1861
+ const line = credentialLine(credential);
1862
+ push(line.type, line.text);
1863
+ }
1864
+ // A hosted platform runs only its operator's accounts on its own model
1865
+ // credential; every other account brings its own API key. Refused here,
1866
+ // before a sandbox is rented, with what to do about it — not run on the
1867
+ // operator's key, and not failed later by a provider.
1868
+ if (!platform.platformKeyAllowed(tenant)) {
1869
+ const own = Object.values(providerSecrets).concat(Object.values(providerEnv));
1870
+ if (!providerLabel) {
1871
+ push("error", `no model key: this account runs on its own API key, and none is set. Add one in Settings → Model ` +
1872
+ `(or \`foldrun model set <provider> --key …\`), or write a provider: block in AGENTS.md`);
1873
+ step.status = "failed";
1874
+ save();
1875
+ return;
1876
+ }
1877
+ if (own.some((v) => isClaudeLoginToken(v))) {
1878
+ push("error", "model key: a Claude login token (sk-ant-oat…) cannot be used here — use an API key from your provider");
1879
+ step.status = "failed";
1880
+ save();
1881
+ return;
1882
+ }
1883
+ }
1794
1884
  for (const w of providerWarnings)
1795
1885
  push("error", w);
1796
1886
  // A level that wrote a language nobody can read was skipped, not obeyed.
@@ -1965,28 +2055,7 @@ stepIndex) {
1965
2055
  // happening to contain brackets.
1966
2056
  prompt += `\n\n<event>\nThis step waited for an external event. What arrived:\n${step.eventPayload}\n</event>`;
1967
2057
  }
1968
- if (step.approvalNote) {
1969
- // The person at the gate said something while letting the run through.
1970
- // That is the freshest instruction the step has — later than the flow
1971
- // file, aimed at this exact run. When the gate asked a question
1972
- // (ask:), the note IS the answer and is framed as one.
1973
- //
1974
- // Both halves go through resolveDocLinks for the same reason the
1975
- // instruction above does: this is authored text on its way to becoming
1976
- // a prompt, and the rule is that ALL of it expands. A person answering
1977
- // "which list?" with [[dead-ends]] was sending the model two literal
1978
- // brackets — the one reference syntax the workspace teaches, and the
1979
- // one place it silently did nothing.
1980
- //
1981
- // What is NOT resolved, deliberately: the previous step's results
1982
- // above. That is model output, not something a person wrote, and a
1983
- // model that happens to emit [[brackets]] must not thereby name a file.
1984
- const ask = step.ask ? resolveDocLinks(step.ask, workspaceRoot) : "";
1985
- const note = resolveDocLinks(step.approvalNote, workspaceRoot);
1986
- prompt += step.ask
1987
- ? `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer:\n${note}\n</operator_answer>`
1988
- : `\n\n<operator_guidance>\nThe human who approved this step added:\n${note}\n</operator_guidance>`;
1989
- }
2058
+ prompt += operatorBlock(step, workspaceRoot);
1990
2059
  if (step.delegate?.length) {
1991
2060
  // The chooser's contract. The allowed set comes from the flow file;
1992
2061
  // the runner parses exactly this shape back out of the result, so the
@@ -2030,6 +2099,9 @@ stepIndex) {
2030
2099
  // watched on both paths, because a refusal reads the same from a pod
2031
2100
  // and from this process.
2032
2101
  let lastRefusal = "";
2102
+ // The step's model connection was cut (isModelConnectionLost): the
2103
+ // platform's side going away under the step, not the step failing.
2104
+ let lastDrop = "";
2033
2105
  // And WHY it refused, in words, when anything on the way saw the
2034
2106
  // headers that say so: the translator here, the egress proxy over
2035
2107
  // there. Kept apart from `lastRefusal` — that one decides whether to
@@ -2038,6 +2110,8 @@ stepIndex) {
2038
2110
  const pushWatching = (type, text, extra) => {
2039
2111
  if (type === "error" && isProviderRefusal(text))
2040
2112
  lastRefusal = text;
2113
+ if (type === "error" && isModelConnectionLost(text))
2114
+ lastDrop = text;
2041
2115
  const why = refusalFromLine(text);
2042
2116
  if (why)
2043
2117
  refusalWhy = why;
@@ -2324,6 +2398,7 @@ stepIndex) {
2324
2398
  lastArgs = a;
2325
2399
  return runIsolated(a);
2326
2400
  };
2401
+ let attemptFrom = step.events.length;
2327
2402
  let outcome = await runTracked(firstArgs);
2328
2403
  step.sandbox = null; // whatever happens next starts its own
2329
2404
  // A 401 from a token that rotated mid-step is not a broken key: the
@@ -2362,6 +2437,48 @@ stepIndex) {
2362
2437
  outcome = await runTracked(again);
2363
2438
  outcome = withEarlierTiming(outcome, previous);
2364
2439
  }
2440
+ // The model connection was cut under the step — the egress proxy or the
2441
+ // worker went away (6 Oct 2026: a worker restarted every 4.5 minutes and
2442
+ // every desk running failed on "Connection refused"). That is the
2443
+ // platform's fault, so the step runs again, whatever its `retry:` —
2444
+ // unless it can act outside the workspace and its tools already ran:
2445
+ // the post may be up, and a second attempt would post it again.
2446
+ for (let n = 1; n <= DROP_RETRIES && outcome.status === "failed" && lastDrop; n++) {
2447
+ if (!dropRetrySafe(canActOutward, step.events.slice(attemptFrom))) {
2448
+ push("info", `the model connection dropped after this step's tools ran — not retried: they can act outside the workspace, and another attempt could repeat what they did`);
2449
+ break;
2450
+ }
2451
+ const wait = Math.round(retryBaseMs() * 2 * n);
2452
+ push("info", `the model connection dropped (${lastDrop.slice(0, 80)}) — the platform's side, not the step's; running the step again in ${Math.round(wait / 1000)}s (${n} of ${DROP_RETRIES})`);
2453
+ await sleep(wait);
2454
+ lastDrop = "";
2455
+ lastRefusal = "";
2456
+ attemptFrom = step.events.length;
2457
+ const previous = outcome.timing;
2458
+ const again = isolatedArgs(platformModelEnv());
2459
+ await lease?.commit();
2460
+ outcome = withEarlierTiming(await runTracked(again), previous);
2461
+ }
2462
+ // The model wrote its tool call out as text — `<invoke name="…">` in
2463
+ // its last message — and stopped. Nothing ran, so nothing was done:
2464
+ // seo-digest's editor three times (27 Sep, 4 and 5 Oct 2026), its own
2465
+ // retry included, because the retry asked the same way. Run it once
2466
+ // more with that said plainly, on the same safety rule as a dropped
2467
+ // connection: never again after an outward tool already ran.
2468
+ for (let n = 1; n <= TEXT_CALL_RETRIES && wroteToolCallAsText(attemptText(outcome.result, step.events.slice(attemptFrom))); n++) {
2469
+ if (!dropRetrySafe(canActOutward, step.events.slice(attemptFrom))) {
2470
+ push("info", "the model wrote a tool call as text instead of making it — not run again: this step's tools can act outside the workspace and already ran");
2471
+ break;
2472
+ }
2473
+ push("info", `the model wrote a tool call as text instead of making it, so nothing ran — running the step again with a note (${n} of ${TEXT_CALL_RETRIES})`);
2474
+ attemptFrom = step.events.length;
2475
+ lastRefusal = "";
2476
+ const previous = outcome.timing;
2477
+ const base = isolatedArgs(platformModelEnv());
2478
+ const again = { ...base, input: { ...base.input, prompt: `${base.input.prompt}\n\n${TEXT_CALL_NOTE}` } };
2479
+ await lease?.commit();
2480
+ outcome = withEarlierTiming(await runTracked(again), previous);
2481
+ }
2365
2482
  // The second supply, tried exactly once, and only when the primary
2366
2483
  // refused over money/auth/limits rather than the work failing. The
2367
2484
  // retry is a fresh sandbox — the failed one is gone, and driveRun's
@@ -2420,9 +2537,13 @@ stepIndex) {
2420
2537
  };
2421
2538
  const again = { ...lastArgs, input: { ...lastArgs.input, image: "full" }, resume: null };
2422
2539
  await lease?.commit();
2423
- outcome = await runTracked(again);
2424
- step.image = outcome.image ?? { variant: "full", why: "re-run after the browser pod was lost" };
2540
+ // Before the call, not after: a re-run that throws (the full
2541
+ // sandbox never started) is still the full go on the record — its
2542
+ // failed row read "slim" when this was set only on success.
2543
+ step.image = { variant: "full", why: "re-run after the browser pod was lost" };
2425
2544
  step.browserPod = { ...first.browserPod, fallback: RERAN_ON_FULL };
2545
+ outcome = await runTracked(again);
2546
+ step.image = outcome.image ?? step.image;
2426
2547
  }
2427
2548
  else {
2428
2549
  push("error", decision.message);
@@ -2499,7 +2620,11 @@ stepIndex) {
2499
2620
  push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
2500
2621
  }
2501
2622
  const stepSecrets = held.env;
2502
- const consultTools = buildConsultTools(consults, { ...hostSafeEnv(), ...stepSecrets, ...providerEnv }, (type, text) => push(type, text));
2623
+ // The model credential for this machine's Claude Code — the agent's
2624
+ // own provider when it names one. The host allowlist drops it, so
2625
+ // without this every in-process step ran on the machine's login.
2626
+ const localModelEnv = Object.keys(providerEnv).length ? providerEnv : resolveModelCredential().env;
2627
+ const consultTools = buildConsultTools(consults, { ...hostSafeEnv(), ...stepSecrets, ...localModelEnv }, (type, text) => push(type, text));
2503
2628
  // On this machine nobody can write in and there is no proxy: the
2504
2629
  // question goes to the terminal when there is one, else unanswered.
2505
2630
  const askTools = ask ? buildAskTool({ channel: null, timeoutSec: ask.timeoutSec, emit: push, local: terminalAsk }) : null;
@@ -2507,6 +2632,20 @@ stepIndex) {
2507
2632
  // finally — once, however many attempts follow.
2508
2633
  const mat = materializeFileSecrets(agentDir, stepSecrets);
2509
2634
  fileDir = mat.dir;
2635
+ // install: pip and npm land in a directory of this step's own, gone in
2636
+ // the finally; apt has no helper on a bare host and says so.
2637
+ installDir = allowed.includes(INSTALL_TOOL) ? fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-step-")) : null;
2638
+ const installEnv = installDir ? stepInstallEnv(installDir, { ...process.env, ...mat.env }) : {};
2639
+ const installTools = installDir
2640
+ ? buildInstallTool({
2641
+ dir: installDir,
2642
+ socket: null,
2643
+ cacheRoot: path.join(dataRoot(), tenant, ".runtimes"),
2644
+ python: mat.env.VIRTUAL_ENV ? path.join(mat.env.VIRTUAL_ENV, "bin", "python") : "python3",
2645
+ env: { ...hostSafeEnv(), ...mat.env, ...installEnv },
2646
+ emit: (type, text) => push(type, text),
2647
+ })
2648
+ : null;
2510
2649
  const attemptInProcess = (modelEnv) => executeStep({
2511
2650
  agentDir,
2512
2651
  workspaceRoot,
@@ -2526,6 +2665,7 @@ stepIndex) {
2526
2665
  ...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
2527
2666
  ...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
2528
2667
  ...(askTools ? { foldrun_ask: askTools.server } : {}),
2668
+ ...(installTools ? { foldrun_install: installTools.server } : {}),
2529
2669
  ...mcpServers,
2530
2670
  },
2531
2671
  // Declared secrets reach the agent's scripts as env vars; the model
@@ -2533,7 +2673,7 @@ stepIndex) {
2533
2673
  // the allowlisted host environment, not process.env: the SDK's
2534
2674
  // Bash tool runs with this, and on a platform process.env holds
2535
2675
  // every tenant's root key and the datastore credentials.
2536
- env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...modelEnv },
2676
+ env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...installEnv, ...modelEnv },
2537
2677
  timeoutSec: step.timeout,
2538
2678
  budgetUsd: stepBudgetUsd,
2539
2679
  budgetNote: stepBudgetNote,
@@ -2566,6 +2706,9 @@ stepIndex) {
2566
2706
  // under the run and the originals come back.
2567
2707
  const attemptThrough = async (modelEnv, spec) => {
2568
2708
  const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
2709
+ // What earlier steps of this test run wrote, laid over the real files
2710
+ // for this attempt; restoreDivertedDirs puts the originals back.
2711
+ const handed = before && runId && overlayTestWrites(workspaceRoot, divertFor(runId).to).length ? snapshotDivertedDirs(workspaceRoot) : before;
2569
2712
  const t = spec ? await startTranslator(spec) : null;
2570
2713
  try {
2571
2714
  return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
@@ -2578,12 +2721,12 @@ stepIndex) {
2578
2721
  }
2579
2722
  if (before && runId) {
2580
2723
  const d = divertFor(runId);
2581
- restoreDivertedDirs(workspaceRoot, before, d.to, d.note);
2724
+ restoreDivertedDirs(workspaceRoot, before, d.to, d.note, handed ?? before);
2582
2725
  }
2583
2726
  }
2584
2727
  };
2585
2728
  let credentialUsed = platformModelCredential();
2586
- let outcome = await attemptThrough(providerEnv, translator);
2729
+ let outcome = await attemptThrough(localModelEnv, translator);
2587
2730
  // Same rule as the isolated path: a credential that rotated under the
2588
2731
  // step is waited out and retried before anything else is considered.
2589
2732
  if (outcome.status === "failed" && isAuthRefusal(lastRefusal)) {
@@ -2593,7 +2736,7 @@ stepIndex) {
2593
2736
  push("info", "the credential rotated under this step; retrying on the new one");
2594
2737
  credentialUsed = rotated;
2595
2738
  lastRefusal = "";
2596
- outcome = await attemptThrough(providerEnv, translator);
2739
+ outcome = await attemptThrough(localModelEnv, translator);
2597
2740
  }
2598
2741
  else {
2599
2742
  push("info", "the credential did not change — this is the key itself, not a rotation");
@@ -2606,7 +2749,7 @@ stepIndex) {
2606
2749
  push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
2607
2750
  await sleep(wait);
2608
2751
  lastRefusal = "";
2609
- outcome = await attemptThrough(providerEnv, translator);
2752
+ outcome = await attemptThrough(localModelEnv, translator);
2610
2753
  }
2611
2754
  // Same rule as the isolated path: one retry, on a refusal, on the
2612
2755
  // declared or platform second supply.
@@ -2643,6 +2786,8 @@ stepIndex) {
2643
2786
  finally {
2644
2787
  try {
2645
2788
  cleanupFileSecrets(fileDir);
2789
+ if (installDir)
2790
+ fs.rmSync(installDir, { recursive: true, force: true });
2646
2791
  }
2647
2792
  catch {
2648
2793
  // best-effort — a leftover scratch file is swept with the run's outputs
@@ -2800,8 +2945,12 @@ function platformFallbackEnv() {
2800
2945
  */
2801
2946
  const CREDENTIAL_ROTATION_WAIT_MS = 90_000;
2802
2947
  const CREDENTIAL_POLL_MS = 2_000;
2803
- function isAuthRefusal(text) {
2804
- return /API Error: 401|access token has (been revoked|expired)/i.test(text);
2948
+ /** A refusal of the credential itself, in any of the wordings Claude Code
2949
+ * has used. "Failed to authenticate: OAuth token revoked" (9 Oct 2026, a
2950
+ * step started one second before the 09:07:52Z refresh) matched none of the
2951
+ * older two, so the step failed instead of waiting for the new token. */
2952
+ export function isAuthRefusal(text) {
2953
+ return /API Error: 401|access token has (been revoked|expired)|OAuth token (has )?(been )?(revoked|expired)|Failed to authenticate/i.test(text);
2805
2954
  }
2806
2955
  /**
2807
2956
  * Wait, briefly, for the mounted credential to become one we have not
@@ -2844,6 +2993,44 @@ function isProviderRefusal(text) {
2844
2993
  // precisely what the fallback provider is for.
2845
2994
  return /API Error: (401|402|403|429|5\d\d)|credits?\b|quota|billing|insufficient|overloaded|(weekly|daily|monthly|usage) limit|rate.?limit(ed)?\b.*resets?\b|limit\b.*\bresets?\b/i.test(text);
2846
2995
  }
2996
+ /** A step's own model connection cut: the egress proxy or the worker it
2997
+ * runs through went away. Only the run's final error, never a tool's — a
2998
+ * WebFetch that could not connect is the step's business, not ours. */
2999
+ export function isModelConnectionLost(text) {
3000
+ return /^Claude Code returned an error result: API Error: (Connection (refused|dropped|error)|.*\b(ECONNREFUSED|ECONNRESET|socket hang up)\b)/i.test(text);
3001
+ }
3002
+ /** A tool call written out as text rather than made: the model's last
3003
+ * message holds `<invoke name=…>` / `<function_calls>` markup. Only the tail
3004
+ * counts — a reply that quotes such markup mid-way and goes on is fine. */
3005
+ export function wroteToolCallAsText(text) {
3006
+ const tail = text.slice(-1500);
3007
+ const open = tail.search(/<(?:antml:)?(?:invoke|function_calls)\b[^>]*>/);
3008
+ if (open < 0)
3009
+ return false;
3010
+ // Quoted mid-reply and then the reply carries on: not a stalled call.
3011
+ const after = tail.slice(open).replace(/<\/(?:antml:)?(?:invoke|function_calls)>/g, "\u0000");
3012
+ const lastClose = after.lastIndexOf("\u0000");
3013
+ return lastClose < 0 || after.slice(lastClose + 1).trim().length < 200;
3014
+ }
3015
+ /** What an attempt said last: its result, or — when it ended without one —
3016
+ * the error that carried the model's final text. */
3017
+ function attemptText(result, events) {
3018
+ if (result && result.trim())
3019
+ return result;
3020
+ const lastError = [...events].reverse().find((e) => e.type === "error");
3021
+ return lastError?.text ?? "";
3022
+ }
3023
+ /** Runs again after a tool call written as text. */
3024
+ export const TEXT_CALL_RETRIES = 1;
3025
+ export const TEXT_CALL_NOTE = "Note from the platform: your last attempt ended by writing a tool call out as text (an <invoke …> block), so it was not run and nothing happened. " +
3026
+ "Make every tool call as a real tool call — never type its markup into your message.";
3027
+ /** How many times a step whose model connection was cut runs again. */
3028
+ export const DROP_RETRIES = 2;
3029
+ /** May a step whose connection dropped run again? Yes, unless its tools can
3030
+ * act outward and one ran in this attempt (actedThenFailedCheck's rule). */
3031
+ export function dropRetrySafe(canActOutward, attemptEvents) {
3032
+ return !(canActOutward && attemptEvents.some((e) => e.type === "tool"));
3033
+ }
2847
3034
  /** How many times a busy provider is given another moment, and the longest
2848
3035
  * any one wait may be. Three is the industry's habit and the point where
2849
3036
  * waiting longer stops being kinder than failing. */
@@ -3408,6 +3595,33 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3408
3595
  //
3409
3596
  // Runs on resume too — a parked run's container is long gone, and the
3410
3597
  // one it comes back in needs the same files the first one had.
3598
+ //
3599
+ // A run re-driven after its worker died is the exception to "the store
3600
+ // is newer than the disk": a park harvests before it parks, a crash does
3601
+ // not, so what this run's finished steps wrote to storage/ is only on
3602
+ // disk, and materializing first wrote the store's older copy over it.
3603
+ // On 6 Oct 2026 a worker restart put reddit-desk's 2 Oct post back over
3604
+ // that morning's draft, and a rerun from the validator would have
3605
+ // posted it a second time. So a run that has been here before (its
3606
+ // baseline is on disk) stores what it changed first; files it did not
3607
+ // touch still come fresh from the store.
3608
+ const baselineFile = path.join(runRoot, "runs", run.id, "storage-baseline.json");
3609
+ if (fs.existsSync(baselineFile)) {
3610
+ try {
3611
+ const changedFrom = JSON.parse(fs.readFileSync(baselineFile, "utf8"));
3612
+ const { saved } = await harvestFiles(tenant, workspace, `run:${run.id}`, { changedFrom });
3613
+ if (saved.length) {
3614
+ run.steps[0]?.events.push({
3615
+ t: new Date().toISOString(),
3616
+ type: "info",
3617
+ text: `files: kept ${saved.join(", ")} written before the run was picked up again`,
3618
+ });
3619
+ }
3620
+ }
3621
+ catch {
3622
+ // the materialize below still runs; at worst this is the old behaviour
3623
+ }
3624
+ }
3411
3625
  try {
3412
3626
  const brought = await materializeFiles(tenant, workspace);
3413
3627
  if (brought.length) {
@@ -3417,6 +3631,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3417
3631
  text: `files: ${brought.length} brought into the workspace`,
3418
3632
  });
3419
3633
  }
3634
+ fs.mkdirSync(path.dirname(baselineFile), { recursive: true });
3635
+ fs.writeFileSync(baselineFile, JSON.stringify(storageBaseline(tenant, workspace)));
3420
3636
  }
3421
3637
  catch (err) {
3422
3638
  // A file store that is down must not take the run with it: most flows
@@ -3873,7 +4089,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3873
4089
  save();
3874
4090
  if (freshGroup.every((s) => s.status !== "pending")) {
3875
4091
  // everything in this group was rejected
3876
- if (freshGroup.some((s) => s.status === "failed" && !s.optional)) {
4092
+ if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
3877
4093
  run.status = "failed";
3878
4094
  break;
3879
4095
  }
@@ -4095,8 +4311,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4095
4311
  effort: step.effort,
4096
4312
  timeout: step.timeout,
4097
4313
  // The rescuer inherits the contract, not just the job: a step
4098
- // that promised data still owes data when someone else does it.
4314
+ // that promised data still owes data when someone else does it,
4315
+ // and a step that had to pass a check still has to pass it — a
4316
+ // rescue whose result stood unchecked was a way round the very
4317
+ // check that failed (found 7 Oct 2026, adding rescuers to
4318
+ // medium-desk).
4099
4319
  output: step.output,
4320
+ ...(step.verify ? { verify: step.verify } : {}),
4100
4321
  attempts: 0,
4101
4322
  status: "pending",
4102
4323
  events: [],
@@ -4132,7 +4353,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4132
4353
  }
4133
4354
  save();
4134
4355
  }
4135
- const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional);
4356
+ // A step carried into a re-run failed in the run it came from, not
4357
+ // here: the person chose to start after it. Counting it stopped
4358
+ // fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
4359
+ // a second, before the one step it was asked to run.
4360
+ const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom);
4136
4361
  if (requiredFailed) {
4137
4362
  run.status = "failed";
4138
4363
  break;
@@ -4550,3 +4775,86 @@ export function runResult(run) {
4550
4775
  }
4551
4776
  return null;
4552
4777
  }
4778
+ /**
4779
+ * What a Docker-run script sees of the workspace and the account: their
4780
+ * scripts/ and their tools/, read-only. A folder tool's code lives in its
4781
+ * tools/<name>/ folder, not in scripts/ — without those two mounts every
4782
+ * folder tool (the hello template's wordcount among them) was handed a host
4783
+ * path the container did not have, and failed on every call. A gallery tool
4784
+ * nobody installed runs from the gallery's own shelf, so that is mounted too
4785
+ * when there is one. A folder that does not exist is skipped by the copy.
4786
+ */
4787
+ export function scriptMounts(agentDir, tenant) {
4788
+ const workspaceRoot = path.resolve(agentDir, "..", "..");
4789
+ const mounts = {
4790
+ [path.join(workspaceRoot, "scripts")]: "/workspace-scripts",
4791
+ [path.join(workspaceRoot, "tools")]: "/workspace-tools",
4792
+ [libraryDir(tenant, "scripts")]: "/library-scripts",
4793
+ [libraryDir(tenant, "tools")]: "/library-tools",
4794
+ };
4795
+ const gallery = platform.galleryDir();
4796
+ if (gallery)
4797
+ mounts[path.join(gallery, "tools")] = "/gallery-tools";
4798
+ return mounts;
4799
+ }
4800
+ /**
4801
+ * The skills an agent can see, nearest first, each with the path the agent
4802
+ * is told to read it at. Own skills, then the workspace's, then the
4803
+ * workspace's `.agents/skills/` (the cross-client convention, scanned after
4804
+ * the native location so a foldrun-native skill wins a name clash), then the
4805
+ * account library. The nearest definition of a name wins.
4806
+ *
4807
+ * The workspace's are written `workspace/skills/…`, the one spelling for the
4808
+ * workspace root, which the file tools expand themselves. `../../skills/…`
4809
+ * left the model to resolve it against a path it had to guess, and on the
4810
+ * hello template it climbed one level too far and was refused.
4811
+ */
4812
+ export function skillsInScope(agentDir, tenant) {
4813
+ const skills = [];
4814
+ const seen = new Set();
4815
+ const add = (dir, prefix, subdir = "skills") => {
4816
+ for (const skill of discoverSkills(dir, subdir)) {
4817
+ if (seen.has(skill.name))
4818
+ continue;
4819
+ seen.add(skill.name);
4820
+ skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
4821
+ }
4822
+ };
4823
+ const workspaceRoot = path.join(agentDir, "..", "..");
4824
+ add(agentDir, "");
4825
+ add(workspaceRoot, "workspace/");
4826
+ add(workspaceRoot, "workspace/", ".agents/skills");
4827
+ // The account library keeps its relative path: `account/` is expanded by
4828
+ // the file tools only, and a skill's bundled scripts are run from the shell.
4829
+ add(path.join(libraryDir(tenant)), "../../../../library/");
4830
+ return skills;
4831
+ }
4832
+ /**
4833
+ * The interpreters a set of scripts needs, read from what they are: the
4834
+ * extension of `run:` (or of inline code), or an explicit `interpreter:`.
4835
+ * Docker's script image is built from the runtime spec, and the base one is
4836
+ * Python only, so a JavaScript tool that declared no `runtime:` (the demo's
4837
+ * inline crm-upload, any folder tool's run.mjs) failed with no node to run
4838
+ * it. Python is named too whenever a .py script is present, because asking
4839
+ * for node alone builds on node:22-slim, which has no python3. Null when
4840
+ * nothing beyond the default is needed.
4841
+ */
4842
+ export function scriptLanguages(scripts) {
4843
+ const kind = (sc) => {
4844
+ const via = (sc.interpreter ?? "").split(/[\\/]/).pop() ?? "";
4845
+ if (/^node/.test(via))
4846
+ return "node";
4847
+ if (/^python/.test(via))
4848
+ return "python";
4849
+ const ext = (sc.run ? path.extname(sc.run) : sc.codeExt ?? "").toLowerCase();
4850
+ if ([".js", ".mjs", ".cjs", ".ts"].includes(ext))
4851
+ return "node";
4852
+ if (ext === ".py")
4853
+ return "python";
4854
+ return null;
4855
+ };
4856
+ const kinds = new Set(scripts.map(kind));
4857
+ if (!kinds.has("node"))
4858
+ return null;
4859
+ return { node: true, ...(kinds.has("python") ? { python: true } : {}), packages: [], npm: [] };
4860
+ }