@foldrun/core 0.6.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 (80) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.js +3 -0
  3. package/dist/src/agent-import.d.ts +21 -0
  4. package/dist/src/agent-import.js +116 -0
  5. package/dist/src/api-tools.js +14 -2
  6. package/dist/src/approvals.d.ts +8 -1
  7. package/dist/src/approvals.js +10 -5
  8. package/dist/src/completions.js +1 -0
  9. package/dist/src/confine.d.ts +1 -0
  10. package/dist/src/confine.js +33 -4
  11. package/dist/src/deploy.d.ts +9 -0
  12. package/dist/src/deploy.js +31 -3
  13. package/dist/src/evals.js +22 -5
  14. package/dist/src/host-env.d.ts +14 -0
  15. package/dist/src/host-env.js +26 -0
  16. package/dist/src/install-helper.d.ts +17 -0
  17. package/dist/src/install-helper.js +134 -0
  18. package/dist/src/install-tool.d.ts +128 -0
  19. package/dist/src/install-tool.js +133 -0
  20. package/dist/src/model-credential.d.ts +16 -0
  21. package/dist/src/model-credential.js +40 -0
  22. package/dist/src/model-env.d.ts +17 -0
  23. package/dist/src/model-env.js +57 -0
  24. package/dist/src/package.d.ts +67 -0
  25. package/dist/src/package.js +594 -0
  26. package/dist/src/platform.d.ts +5 -0
  27. package/dist/src/platform.js +1 -0
  28. package/dist/src/provider-health.js +3 -0
  29. package/dist/src/run-container.d.ts +5 -0
  30. package/dist/src/run-container.js +66 -13
  31. package/dist/src/runner.d.ts +204 -0
  32. package/dist/src/runner.js +257 -36
  33. package/dist/src/runtime.d.ts +77 -0
  34. package/dist/src/runtime.js +230 -5
  35. package/dist/src/script-tools.d.ts +2 -0
  36. package/dist/src/script-tools.js +3 -1
  37. package/dist/src/secrets.d.ts +5 -0
  38. package/dist/src/secrets.js +37 -6
  39. package/dist/src/starter.d.ts +12 -0
  40. package/dist/src/starter.js +36 -4
  41. package/dist/src/step-exec.d.ts +11 -2
  42. package/dist/src/step-exec.js +52 -5
  43. package/dist/src/storage.d.ts +7 -1
  44. package/dist/src/storage.js +14 -1
  45. package/dist/src/store.d.ts +20 -0
  46. package/dist/src/store.js +89 -8
  47. package/dist/src/tool-names.d.ts +3 -1
  48. package/dist/src/tool-names.js +4 -2
  49. package/dist/src/tool-test.js +8 -1
  50. package/dist/src/zip.d.ts +17 -0
  51. package/dist/src/zip.js +184 -0
  52. package/index.ts +3 -0
  53. package/package.json +1 -1
  54. package/src/agent-import.ts +154 -0
  55. package/src/api-tools.ts +16 -1
  56. package/src/approvals.ts +10 -3
  57. package/src/completions.ts +1 -0
  58. package/src/confine.ts +36 -5
  59. package/src/deploy.ts +28 -3
  60. package/src/evals.ts +21 -3
  61. package/src/host-env.ts +27 -0
  62. package/src/install-helper.ts +143 -0
  63. package/src/install-tool.ts +154 -0
  64. package/src/model-credential.ts +41 -0
  65. package/src/model-env.ts +66 -0
  66. package/src/package.ts +664 -0
  67. package/src/platform.ts +6 -0
  68. package/src/provider-health.ts +3 -0
  69. package/src/run-container.ts +65 -12
  70. package/src/runner.ts +264 -36
  71. package/src/runtime.ts +235 -5
  72. package/src/script-tools.ts +5 -1
  73. package/src/secrets.ts +34 -6
  74. package/src/starter.ts +42 -4
  75. package/src/step-exec.ts +56 -8
  76. package/src/storage.ts +13 -0
  77. package/src/store.ts +85 -7
  78. package/src/tool-names.ts +4 -2
  79. package/src/tool-test.ts +8 -1
  80. package/src/zip.ts +189 -0
@@ -43,9 +43,14 @@ import { spawnSync } from "node:child_process";
43
43
  */
44
44
  const PIP_NAME = String.raw `[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9._,-]+\])?`;
45
45
  const PIP_SPECIFIER = String.raw `(==|!=|<=|>=|~=|<|>)[A-Za-z0-9._*+!-]+`;
46
- const PIP_REQUIREMENT = new RegExp(`^${PIP_NAME}(${PIP_SPECIFIER}(,${PIP_SPECIFIER})*)?$`);
46
+ export const PIP_REQUIREMENT = new RegExp(`^${PIP_NAME}(${PIP_SPECIFIER}(,${PIP_SPECIFIER})*)?$`);
47
47
  /** npm's own shape: an optional @scope, then a name, then an optional @range. */
48
- const NPM_REQUIREMENT = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(@[A-Za-z0-9._^~*>=< |-]+)?$/;
48
+ export const NPM_REQUIREMENT = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(@[A-Za-z0-9._^~*>=< |-]+)?$/;
49
+ /** A Debian package name as policy defines it: lowercase letters, digits,
50
+ * `+`, `-` and `.`, starting with a letter or digit, two or more characters.
51
+ * Like the two above, a guard first: it can never begin with a dash, so it
52
+ * can never become an apt option, and it holds no shell characters. */
53
+ export const DEB_PACKAGE = /^[a-z0-9][a-z0-9+.-]{1,62}$/;
49
54
  export function parseRuntime(raw) {
50
55
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
51
56
  return null;
@@ -67,11 +72,14 @@ export function parseRuntime(raw) {
67
72
  node: typeof e.node === "string" || typeof e.node === "boolean" ? e.node : undefined,
68
73
  npm: list(e.npm, NPM_REQUIREMENT),
69
74
  };
75
+ const system = list(e.system ?? e.apt, DEB_PACKAGE);
76
+ if (system.length)
77
+ spec.system = system;
70
78
  if (rejected.length)
71
79
  spec.rejected = rejected;
72
80
  if (Array.isArray(e.ignored) && e.ignored.length)
73
81
  spec.ignored = e.ignored.map(String);
74
- return wantsPython(spec) || wantsNode(spec) ? spec : null;
82
+ return wantsPython(spec) || wantsNode(spec) || wantsSystem(spec) ? spec : null;
75
83
  }
76
84
  /**
77
85
  * Several declarations, one environment.
@@ -103,6 +111,9 @@ export function mergeRuntimes(...specs) {
103
111
  node: pick("node"),
104
112
  npm: uniq(present.flatMap((s) => s.npm)),
105
113
  };
114
+ const system = uniq(present.flatMap((s) => s.system ?? []));
115
+ if (system.length)
116
+ merged.system = system;
106
117
  const rejected = uniq(present.flatMap((s) => s.rejected ?? []));
107
118
  if (rejected.length)
108
119
  merged.rejected = rejected;
@@ -117,6 +128,11 @@ export function fingerprint(spec) {
117
128
  packages: [...spec.packages].sort(),
118
129
  node: spec.node ?? null,
119
130
  npm: [...spec.npm].sort(),
131
+ // Only when declared: adding the key unconditionally would give every
132
+ // environment already cached a new fingerprint and rebuild them all.
133
+ // Part of it when declared, because a venv whose packages compiled
134
+ // against these libraries is not the venv built without them.
135
+ ...(spec.system?.length ? { system: [...spec.system].sort() } : {}),
120
136
  });
121
137
  return crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 16);
122
138
  }
@@ -210,6 +226,7 @@ function wire(root, spec, note) {
210
226
  }
211
227
  // `python: false` says "not this one" — it used to read as a declaration
212
228
  // because it was not undefined, and built a venv nobody asked for.
229
+ const wantsSystem = (s) => (s.system?.length ?? 0) > 0;
213
230
  const wantsPython = (s) => (s.python !== undefined && s.python !== false) || s.packages.length > 0;
214
231
  const wantsNode = (s) => (s.node !== undefined && s.node !== false) || s.npm.length > 0;
215
232
  /** How long a build may hold the claim, and therefore how long another step
@@ -524,9 +541,197 @@ function findUv() {
524
541
  return uvKnown;
525
542
  }
526
543
  // Build (or reuse) the environment for one runtime declaration.
544
+ /**
545
+ * An installer's failure, said so that the first line is the cause.
546
+ *
547
+ * Until 11 Oct 2026 a failure was the installer's last 500 characters, and
548
+ * the run log shows a message's first line: a pycairo build that died for
549
+ * want of a C compiler read, in full, `uv pip install failed: files.` — the
550
+ * tail end of a setuptools deprecation notice. The cause was on the last
551
+ * line of 4 KB nobody saw. So: find the cause and the package it hit, say
552
+ * the fix when it is one we know, then the last lines of the log for the rest.
553
+ */
554
+ export function summarizeInstallFailure(what, out) {
555
+ const text = out ?? "";
556
+ const lines = text.split(/\r?\n/).map((l) => l.replace(/\s+$/, "")).filter(Boolean);
557
+ const pkg = /Failed to build `([^`]+)`/.exec(text)?.[1] ??
558
+ /Failed building wheel for ([\w.-]+)/.exec(text)?.[1] ??
559
+ /Building wheel for ([\w.-]+) \(.*\) did not run successfully/.exec(text)?.[1] ??
560
+ /Failed to (?:download|prepare|fetch) `([^`]+)`/.exec(text)?.[1] ??
561
+ null;
562
+ const subject = pkg ? `\`${pkg}\`` : "a package";
563
+ let cause = null;
564
+ let hint = "";
565
+ const header = /fatal error: ([\w./+-]+\.h): No such file/.exec(text);
566
+ const pc = /Package '?([\w.+-]+)'?,? (?:required by [^,]+, )?(?:was )?not found|Dependency "([\w.+-]+)" not found/.exec(text);
567
+ if (/No such file or directory: '[^']*gcc'|unable to execute '[^']*gcc'|\bgcc: (?:command )?not found|command '[^']*gcc' failed|error: command '[^']*\b(?:cc|gcc|c\+\+|g\+\+)' failed|C compiler cannot create executables|no acceptable C compiler/i.test(text)) {
568
+ cause = `${subject} has to be compiled and this runner has no C compiler`;
569
+ hint = " — add `system: [build-essential]` (and the -dev package of any library it builds against) to the runtime, or pin a version that ships a prebuilt wheel";
570
+ }
571
+ else if (/pkg-config(?:: command)? not found|Did not find pkg-config|pkg-config.*No such file/i.test(text)) {
572
+ cause = `${subject} looks for its libraries with pkg-config, which this runner lacks`;
573
+ hint = " — add `system: [pkg-config]` and the library's -dev package to the runtime";
574
+ }
575
+ else if (header && /^Python\.h$/.test(header[1])) {
576
+ cause = `${subject} builds against Python's own C headers, which this runner lacks`;
577
+ hint = " — add `system: [python3-dev, build-essential]` to the runtime";
578
+ }
579
+ else if (pc || header) {
580
+ const lib = pc?.[1] ?? pc?.[2] ?? header[1];
581
+ cause = `${subject} needs the system library ${lib} (its headers) to build`;
582
+ hint = ` — add its Debian -dev package to \`runtime: system:\`${pc ? ` (for ${lib}, usually \`lib${lib.replace(/^lib/, "")}-dev\`)` : ""}, plus build-essential and pkg-config`;
583
+ }
584
+ else if (/E: Unable to locate package (\S+)/.test(text)) {
585
+ cause = `apt has no package \`${/E: Unable to locate package (\S+)/.exec(text)[1]}\``;
586
+ hint = " — check the name on packages.debian.org for trixie";
587
+ }
588
+ else if (/No solution found when resolving dependencies/.test(text)) {
589
+ const i = lines.findIndex((l) => /No solution found/.test(l));
590
+ cause = `the requirements cannot all be satisfied: ${lines.slice(i + 1, i + 3).map((l) => l.replace(/^[\s╰─▶×]+/, "")).join(" ")}`.trim();
591
+ }
592
+ else if (/No matching distribution found for (\S+)|Could not find a version that satisfies the requirement (\S+)|Because (\S+) was not found in the package registry/.test(text)) {
593
+ const m = /No matching distribution found for (\S+)|Could not find a version that satisfies the requirement (\S+)|Because (\S+) was not found in the package registry/.exec(text);
594
+ cause = `no published version of \`${m[1] ?? m[2] ?? m[3]}\` matches`;
595
+ }
596
+ else if (/npm (?:ERR!|error) code (\S+)/.test(text)) {
597
+ const code = /npm (?:ERR!|error) code (\S+)/.exec(text)[1];
598
+ const detail = lines.find((l) => /npm (?:ERR!|error) (?:404|notarget|E\w+)\b/.test(l) && !/ code /.test(l));
599
+ cause = `npm ${code}${detail ? `: ${detail.replace(/^npm (?:ERR!|error)\s*/, "")}` : ""}`;
600
+ }
601
+ if (!cause) {
602
+ const last = [...lines].reverse().find((l) => /error/i.test(l)) ?? lines[lines.length - 1] ?? "no output";
603
+ cause = (pkg ? `${subject}: ` : "") + last.replace(/^\s*(?:error:|ERROR:|×)\s*/, "");
604
+ }
605
+ const excerpt = lines.slice(-25).join("\n");
606
+ return `${what} failed — ${cause}${hint}\n${excerpt.length > 2000 ? excerpt.slice(-2000) : excerpt}`;
607
+ }
608
+ /** Sets $FOLDRUN_SYSTEM_PACKAGES from the step's input.json (its merged
609
+ * runtime spec) — the run pod's and the docker entry's first line. */
610
+ export const SYSTEM_FROM_INPUT_SH = `FOLDRUN_SYSTEM_PACKAGES="$(node -e 'try{const r=JSON.parse(require("fs").readFileSync("/opt/runner/job/input.json","utf8")).runtime;process.stdout.write(((r&&r.system)||[]).join(" "))}catch(e){}')"`;
611
+ /** Where a run container's root start-up records the system install. */
612
+ export const SYSTEM_STATUS_FILE = "/opt/runner/job/system-install.json";
613
+ export const SYSTEM_LOG_FILE = "/opt/runner/job/system-install.log";
614
+ /**
615
+ * The root start-up's half of `runtime: system:` — one shell snippet, used by
616
+ * the k8s run pod, the docker entry and the deploy-time warm pod alike.
617
+ * Reads the package names from $FOLDRUN_SYSTEM_PACKAGES (space-separated,
618
+ * each re-checked against DEB_PACKAGE's shape here too, so nothing else can
619
+ * ride in), installs them with apt as root BEFORE the drop to the agent user,
620
+ * and writes SYSTEM_STATUS_FILE for prepareRuntime to read.
621
+ *
622
+ * The first step to need a package set installs it with apt (the .debs kept
623
+ * in `<cache>/.apt/archives`) and then saves the files those packages added,
624
+ * with their dpkg records, as a snapshot: `<cache>/.apt/snapshots/<key>.tar`.
625
+ * Every later step restores the snapshot instead — measured 11 Oct 2026 in a
626
+ * gVisor run pod on the NFS cache: build-essential, pkg-config, python3-dev,
627
+ * libcairo2-dev and libpango1.0-dev (154 packages, 432 MB) took 23-30 s
628
+ * through apt and 0.76 s from the snapshot, gcc and pkg-config working after.
629
+ * The key is the package set AND a checksum of the image's own dpkg status,
630
+ * so a new runner image never restores files built against the old one. A
631
+ * snapshot is written under a temporary name and renamed, so two pods
632
+ * building it at once cannot leave a torn one; a restore that fails falls
633
+ * back to apt, and prepareRuntime verifies the result either way. Unused for
634
+ * 30 days, one is pruned. Package lists stay per pod: two pods updating one
635
+ * lists directory on a shared volume would corrupt it.
636
+ */
637
+ export function systemInstallSh(cacheDir, logDir = "/opt/runner/job") {
638
+ return `if [ -n "$FOLDRUN_SYSTEM_PACKAGES" ]; then
639
+ SYS=""; for p in $FOLDRUN_SYSTEM_PACKAGES; do case "$p" in -*|*[!a-z0-9+.-]*) ;; *) SYS="$SYS $p";; esac; done
640
+ D=\${FOLDRUN_DPKG_DIR:-/var/lib/dpkg}; R=\${FOLDRUN_SYS_ROOT:-/}
641
+ C=${cacheDir}/.apt; A=$C/archives; S=$C/snapshots
642
+ mkdir -p "$A/partial" "$S" 2>/dev/null || { A=/var/cache/apt/archives; S=""; }
643
+ KEY=v3-$( { printf '%s\\n' $SYS | sort -u; cksum < "$D/status"; } | cksum | tr ' ' '-')
644
+ T0=$(date +%s); MODE=apt; RC=1
645
+ if [ -n "$S" ] && [ -f "$S/$KEY.tar" ] && tar -xf "$S/$KEY.tar" -C "$R" > ${logDir}/system-install.log 2>&1; then
646
+ MODE=snapshot; RC=0; touch "$S/$KEY.tar" 2>/dev/null
647
+ # apt runs ldconfig through dpkg's trigger; a restore must too, or a
648
+ # library the snapshot brought is missing from /etc/ld.so.cache.
649
+ [ "$R" = / ] && ldconfig 2>/dev/null
650
+ else
651
+ ls "$D"/info/*.list 2>/dev/null | sort > /tmp/.sys-before
652
+ touch /tmp/.sys-mark; sleep 1
653
+ { apt-get -o Acquire::Retries=3 -qq update && DEBIAN_FRONTEND=noninteractive apt-get -o Dir::Cache::Archives="$A" -o Acquire::Retries=3 -o Dpkg::Use-Pty=0 install -y -qq --no-install-recommends $SYS; } > ${logDir}/system-install.log 2>&1
654
+ RC=$?
655
+ if [ $RC -eq 0 ] && [ -n "$S" ]; then
656
+ ls "$D"/info/*.list 2>/dev/null | sort > /tmp/.sys-after
657
+ comm -13 /tmp/.sys-before /tmp/.sys-after > /tmp/.sys-new
658
+ if [ -s /tmp/.sys-new ]; then
659
+ { cat $(cat /tmp/.sys-new) | sort -u | while read -r f; do if [ -f "$f" ] || [ -L "$f" ]; then echo "$f"; fi; done
660
+ for l in $(cat /tmp/.sys-new); do ls "\${l%.list}".* 2>/dev/null; done
661
+ echo "$D/status"
662
+ # What the packages' install scripts made, which dpkg's lists do not
663
+ # name: update-alternatives links (libblas.so.3 → /etc/alternatives),
664
+ # generated caches and configs. By ctime, not mtime: those links carry
665
+ # the package's own (2025) mtime, and only ctime cannot be backdated.
666
+ find \${FOLDRUN_SYS_SCAN:-/etc /usr /var/lib} -xdev \\( -path /var/lib/apt -o -path /var/lib/dpkg/info \\) -prune -o \\( -type f -o -type l \\) -cnewer /tmp/.sys-mark -print 2>/dev/null
667
+ } | sort -u > /tmp/.sys-files
668
+ tar -cf "$S/.$KEY.$$.tar" -T /tmp/.sys-files 2>/dev/null && mv -f "$S/.$KEY.$$.tar" "$S/$KEY.tar" 2>/dev/null
669
+ rm -f "$S/.$KEY.$$.tar" 2>/dev/null
670
+ fi
671
+ find "$S" -name '*.tar' -mtime +30 -delete 2>/dev/null
672
+ fi
673
+ fi
674
+ printf '{"ok": %s, "exit": %s, "seconds": %s, "mode": "%s", "packages": "%s"}\\n' "$([ $RC -eq 0 ] && echo true || echo false)" "$RC" "$(( $(date +%s) - T0 ))" "$MODE" "$SYS" > ${logDir}/system-install.json
675
+ rm -rf /var/lib/apt/lists/* 2>/dev/null || true
676
+ fi`;
677
+ }
678
+ /**
679
+ * Are the declared system packages on this machine? The install happened in
680
+ * the container's root start-up (systemInstallSh); this only reads the
681
+ * result. Without dpkg (a laptop, macOS) there is nothing to check against
682
+ * and nothing this process could install: say so and carry on.
683
+ */
684
+ export function checkSystemPackages(pkgs) {
685
+ if (!pkgs.length)
686
+ return { log: [], error: null };
687
+ const statusFile = process.env.FOLDRUN_SYSTEM_STATUS ?? SYSTEM_STATUS_FILE;
688
+ const logFile = process.env.FOLDRUN_SYSTEM_LOG ?? SYSTEM_LOG_FILE;
689
+ let status = null;
690
+ try {
691
+ status = JSON.parse(fs.readFileSync(statusFile, "utf8"));
692
+ }
693
+ catch {
694
+ status = null;
695
+ }
696
+ const q = spawnSync("dpkg-query", ["-W", "-f=${Package} ${db:Status-Status}\n", ...pkgs], { encoding: "utf8" });
697
+ if (q.error) {
698
+ return { log: [`system packages ${pkgs.join(", ")} install only in a run container; on this machine they are assumed present`], error: null };
699
+ }
700
+ const installed = new Set((q.stdout ?? "")
701
+ .split("\n")
702
+ .map((l) => l.trim().split(/\s+/))
703
+ .filter((f) => f[1] === "installed")
704
+ .map((f) => f[0].replace(/:.*$/, "")));
705
+ const missing = pkgs.filter((p) => !installed.has(p));
706
+ if (!missing.length) {
707
+ return { log: [`system packages ready: ${pkgs.join(", ")}${status?.seconds !== undefined ? ` (${status.mode === "snapshot" ? "snapshot" : "apt"}, ${status.seconds}s)` : ""}`], error: null };
708
+ }
709
+ let aptOut = "";
710
+ try {
711
+ aptOut = fs.readFileSync(logFile, "utf8");
712
+ }
713
+ catch {
714
+ aptOut = "";
715
+ }
716
+ const reason = !status
717
+ ? "this runner did not install them — system packages are installed by a run container's start-up (k8s or docker isolation), not on a bare host"
718
+ : status.ok
719
+ ? "apt reported success, yet they are not installed"
720
+ : summarizeInstallFailure("apt-get install", aptOut).replace(/^apt-get install failed — /, "apt: ");
721
+ return { log: [], error: `system packages not installed: ${missing.join(", ")} — ${reason}` };
722
+ }
527
723
  export function prepareRuntime(tenant, spec) {
528
724
  if (!spec)
529
725
  return EMPTY;
726
+ const system = checkSystemPackages(spec.system ?? []);
727
+ if (system.error)
728
+ return { ...EMPTY, log: system.log, error: system.error };
729
+ if (!wantsPython(spec) && !wantsNode(spec))
730
+ return { ...EMPTY, log: system.log };
731
+ const built = prepareLanguageRuntime(tenant, spec);
732
+ return system.log.length ? { ...built, log: [...system.log, ...built.log] } : built;
733
+ }
734
+ function prepareLanguageRuntime(tenant, spec) {
530
735
  const fp = fingerprint(spec);
531
736
  const cacheRoot = path.join(dataRoot(), tenant, ".runtimes");
532
737
  const shared = path.join(cacheRoot, fp);
@@ -695,7 +900,7 @@ export function prepareRuntime(tenant, spec) {
695
900
  ? install(uv, ["pip", "install", "-q", "--python", venvPython, ...spec.packages], root, 300_000, uvEnv)
696
901
  : install(path.join(root, "venv", "bin", "pip"), ["install", "--disable-pip-version-check", "-q", ...spec.packages], root);
697
902
  if (!installed.ok) {
698
- return fail(`${uv ? "uv pip" : "pip"} install failed: ${installed.out.slice(-500)}`);
903
+ return fail(summarizeInstallFailure(`${uv ? "uv pip" : "pip"} install`, installed.out));
699
904
  }
700
905
  built.packages = spec.packages;
701
906
  log.push(`runtime ${fp}: installed ${spec.packages.join(", ")}`);
@@ -718,7 +923,7 @@ export function prepareRuntime(tenant, spec) {
718
923
  npm_config_cache: path.join(root, ".npm-cache"),
719
924
  });
720
925
  if (!installed.ok) {
721
- return fail(`npm install failed: ${installed.out.slice(-500)}`);
926
+ return fail(summarizeInstallFailure("npm install", installed.out));
722
927
  }
723
928
  built.npm = spec.npm;
724
929
  log.push(`runtime ${fp}: installed ${spec.npm.join(", ")}`);
@@ -790,3 +995,23 @@ function touch(file) {
790
995
  // read-only or gone; neither is a reason to fail the step
791
996
  }
792
997
  }
998
+ // ---------- installing mid-step ----------
999
+ /** Where the root start-up's install helper listens, inside a run container. */
1000
+ export const INSTALL_SOCKET = "/opt/runner/job/install.sock";
1001
+ /**
1002
+ * What a step installs for itself mid-run (the `install` tool) lands under
1003
+ * one per-step directory: pip packages in `<dir>/py` (`uv pip install
1004
+ * --target`), npm packages in `<dir>/npm` (`npm install --prefix`). Both are
1005
+ * on the step's paths from its first turn, so a tool installed by one call is
1006
+ * found by the next — `ffmpeg`, `import manim`, `require("sharp")`. Inside a
1007
+ * run container the directory is the container's own filesystem and goes
1008
+ * when the container does.
1009
+ */
1010
+ export function stepInstallEnv(dir, base) {
1011
+ return {
1012
+ // After whatever is already there: a declared environment keeps winning.
1013
+ PATH: [base.PATH ?? "", path.join(dir, "py", "bin"), path.join(dir, "npm", "node_modules", ".bin")].filter(Boolean).join(path.delimiter),
1014
+ PYTHONPATH: [base.PYTHONPATH ?? "", path.join(dir, "py")].filter(Boolean).join(path.delimiter),
1015
+ NODE_PATH: [base.NODE_PATH ?? "", path.join(dir, "npm", "node_modules")].filter(Boolean).join(path.delimiter),
1016
+ };
1017
+ }
@@ -6,6 +6,8 @@ export interface ScriptSpec {
6
6
  * script inline. Exactly one of the two is the program. */
7
7
  run: string;
8
8
  description: string;
9
+ /** The tool.md body, for the model: how to call it and what it means. */
10
+ guide?: string;
9
11
  args: Record<string, string>;
10
12
  interpreter?: string;
11
13
  /** Seconds this script may run — for the crawl that legitimately takes
@@ -55,6 +55,7 @@ export function parseScripts(raw) {
55
55
  run: run ?? "",
56
56
  ...(code ? { code, codeExt: typeof e.codeExt === "string" ? e.codeExt : ".mjs" } : {}),
57
57
  description: typeof e.description === "string" ? e.description : "",
58
+ ...(typeof e.guide === "string" && e.guide.trim() ? { guide: e.guide } : {}),
58
59
  args,
59
60
  interpreter: typeof e.interpreter === "string" ? e.interpreter : undefined,
60
61
  secrets: e.secrets === "proxied" ? "proxied" : undefined,
@@ -346,7 +347,8 @@ export function buildScriptTools(agentDir, scripts, env, libraryScripts = "", in
346
347
  const tools = scripts.map((spec) => {
347
348
  const shape = scriptArgShape(spec.args);
348
349
  return tool(spec.name, `${spec.description || `Run ${spec.run}.`} Runs the workspace script ${spec.run}; returns its output. ` +
349
- `Credentials it needs are already in its environment.`, shape, async (args) => {
350
+ `Credentials it needs are already in its environment.` +
351
+ (spec.guide ? `\n\nHow to use it (from its tool.md):\n${spec.guide}` : ""), shape, async (args) => {
350
352
  const started = Date.now();
351
353
  const values = Object.fromEntries(Object.entries(args ?? {}).map(([k, v]) => [k, v === undefined ? "" : String(v)]));
352
354
  const { code, out } = await runScript(agentDir, spec, values, env, libraryScripts, interpreters, exec);
@@ -123,6 +123,11 @@ export interface OAuth2Config {
123
123
  /** Extra form fields some providers want (audience, scope, …). */
124
124
  extra?: Record<string, string>;
125
125
  }
126
+ /** Tries at a token endpoint before a refresh is called failed: the first,
127
+ * and two more after a short wait, for a network error, a 5xx or a 429. */
128
+ export declare const OAUTH_REFRESH_TRIES = 3;
129
+ /** Wait before try n (1-based retry): 1s, 3s — FOLDRUN_OAUTH_RETRY_MS scales it for tests. */
130
+ export declare function oauthRetryDelayMs(n: number): number;
126
131
  export declare function setOAuth2Secret(tenant: string, name: string, config: OAuth2Config, workspace?: string): void;
127
132
  export declare function isOAuth2Value(value: string): boolean;
128
133
  /**
@@ -402,6 +402,14 @@ export function rotateMasterKey(tenant, from, to) {
402
402
  return { rewritten, unreadable };
403
403
  }
404
404
  const OAUTH2_PREFIX = "@oauth2 ";
405
+ /** Tries at a token endpoint before a refresh is called failed: the first,
406
+ * and two more after a short wait, for a network error, a 5xx or a 429. */
407
+ export const OAUTH_REFRESH_TRIES = 3;
408
+ /** Wait before try n (1-based retry): 1s, 3s — FOLDRUN_OAUTH_RETRY_MS scales it for tests. */
409
+ export function oauthRetryDelayMs(n) {
410
+ const base = Number(process.env.FOLDRUN_OAUTH_RETRY_MS ?? 1000);
411
+ return base * (n === 1 ? 1 : 3);
412
+ }
405
413
  export function setOAuth2Secret(tenant, name, config, workspace) {
406
414
  for (const field of ["token_url", "client_id", "client_secret"]) {
407
415
  if (!config[field]?.trim())
@@ -440,12 +448,35 @@ async function exchange(config, cacheKey) {
440
448
  client_secret: config.client_secret,
441
449
  ...(config.extra ?? {}),
442
450
  });
443
- const res = await fetchUntrusted(config.token_url, {
444
- method: "POST",
445
- headers: { "content-type": "application/x-www-form-urlencoded" },
446
- body: body.toString(),
447
- signal: AbortSignal.timeout(15_000),
448
- }, { sameOrigin: true });
451
+ // A network blip is retried; a provider's answer is not. "fetch failed"
452
+ // (a reset, a DNS hiccup, a timeout) failed whole steps on the first try
453
+ // — gbp-desk's Thursday posts, 7 Oct 2026. A 4xx (revoked, wrong client)
454
+ // will say the same thing again, so it goes straight to the error below
455
+ // and the reconnect mail; a 5xx or 429 is the provider busy, retried.
456
+ let res;
457
+ let lastNetwork;
458
+ for (let attempt = 0; attempt < OAUTH_REFRESH_TRIES; attempt++) {
459
+ if (attempt)
460
+ await new Promise((r) => setTimeout(r, oauthRetryDelayMs(attempt)));
461
+ try {
462
+ res = await fetchUntrusted(config.token_url, {
463
+ method: "POST",
464
+ headers: { "content-type": "application/x-www-form-urlencoded" },
465
+ body: body.toString(),
466
+ signal: AbortSignal.timeout(15_000),
467
+ }, { sameOrigin: true });
468
+ }
469
+ catch (err) {
470
+ lastNetwork = err;
471
+ res = undefined;
472
+ continue;
473
+ }
474
+ if (res.status === 429 || res.status >= 500)
475
+ continue;
476
+ break;
477
+ }
478
+ if (!res)
479
+ throw lastNetwork instanceof Error ? lastNetwork : new Error(String(lastNetwork));
449
480
  const payload = (await res.json().catch(() => ({})));
450
481
  if (!res.ok || !payload.access_token) {
451
482
  throw new Error(`token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`);
@@ -28,3 +28,15 @@ export declare function accountFiles(account: string): StarterFile[];
28
28
  */
29
29
  export declare function accountGitignore(): StarterFile;
30
30
  export declare function starterFiles(workspace: string): StarterFile[];
31
+ /**
32
+ * A new workspace with nothing in it to delete: its AGENTS.md, the
33
+ * .gitignore that keeps its secret key out of git, and the CLAUDE.md a coding
34
+ * tool reads. No example agents or flow.
35
+ *
36
+ * The starter's researcher and writer were a demo, and a person making a
37
+ * workspace for real work met them as clutter — "why does a new workspace
38
+ * have agents?" (owner, 2026-10-05). The agents worth having are usually ones
39
+ * already written in another workspace, which `agents/import` copies in; the
40
+ * example is still one flag away (`foldrun new <name> --starter`).
41
+ */
42
+ export declare function blankWorkspaceFiles(workspace: string): StarterFile[];
@@ -40,6 +40,10 @@ foldrun_version: "0.1"
40
40
  # that declares the same key replaces it *whole* — base_url and token never
41
41
  # merge across scopes, so a provider block belongs entirely at one level.
42
42
  #
43
+ # Your own model key needs no block: Settings → Model provider, or
44
+ # \`foldrun account model set anthropic --key …\` (an API key, not a login).
45
+ # A provider: block here or lower wins over it.
46
+ #
43
47
  # provider:
44
48
  # base_url: https://openrouter.ai/api
45
49
  # token: \${OPENROUTER_API_KEY}
@@ -326,13 +330,14 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
326
330
  | \`/runs/<id>/message\` | POST | tell a running step something; \`step\` picks one of a parallel group |
327
331
  | \`/flows\` · \`/flows/<f>\` | GET POST · POST DELETE PATCH | list, create, edit |
328
332
  | \`/flows/<f>/run\` | POST | start a flow |
329
- | \`/agents\` · \`/agents/<a>/run\` | GET POST · POST | list agents, run one |
333
+ | \`/agents\` · \`/agents/<a>/run\` · \`/agents/import\` | GET POST · POST · GET POST | list agents, run one, copy one in from another workspace |
330
334
  | \`/evals\` · \`/evals/<e>/run\` | GET POST · POST | list evals, run one |
331
335
  | \`/tools/<t>/test\` | POST | exercise one tool alone |
332
336
  | \`/storage\` · \`/storage/download\` · \`/storage/upload-url\` · \`/storage/preview\` | GET POST PUT DELETE · GET · POST · GET | workspace files, any type; \`preview\` returns one parsed for reading |
333
337
  | \`/assets\` | POST | upload an asset |
334
338
  | \`/hooks/<f>/rotate\` | POST | new webhook token for a flow |
335
339
  | \`/notify/deliveries\` · \`/notify/deliveries/<id>/redeliver\` | GET · POST | every notify: webhook sent and each attempt; send one again |
340
+ | \`/export\` · \`/import\` | GET · POST | the workspace, a flow (\`?kind=flow&name=\`) or an agent as a .zip; take one in (\`?dryRun=1\` first, \`?overwrite=1\` to replace) |
336
341
  | \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
337
342
  | \`/vocabulary\` | GET | what this workspace's documents may say |
338
343
 
@@ -442,10 +447,11 @@ subagents: [researcher] # colleagues it may delegate a job to (own context,
442
447
  secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
443
448
  limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
444
449
  timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
445
- provider: {} # BYOK: your own model credential
450
+ provider: {} # BYOK for this agent; account-wide: \`foldrun account model set\`
446
451
  permissionMode: plan # plan first, act once approved
447
452
  runtime: # only if scripts need packages
448
453
  packages: [requests]
454
+ system: [ffmpeg] # Debian packages, apt-installed before the step
449
455
  ---
450
456
 
451
457
  You research a topic and report what you found...
@@ -459,8 +465,12 @@ flow's, else the workspace's AGENTS.md, else the account's, else UTC. It sets
459
465
  \`TZ\` and the date the agent is told, so a Sydney desk stamps the Sydney date.
460
466
 
461
467
  \`runtime:\` installs pip/npm packages in the sandbox each agent runs in. Pin
462
- versions the normal way (\`pandas>=2\`, \`lodash@^4\`). Python and Node are
463
- supported; anything else should ship as a committed binary.
468
+ versions the normal way (\`pandas>=2\`, \`lodash@^4\`). \`system:\` adds Debian
469
+ packages \u{2014} a program (\`ffmpeg\`), a library's headers for a pip package that
470
+ compiles (\`libcairo2-dev\`, \`python3-dev\`), \`build-essential\` \u{2014} installed with
471
+ apt before the step starts and cached, so later runs only unpack them. An agent that
472
+ finds it needs something mid-run installs it itself with the \`install\` tool
473
+ (apt, pip or npm), which every agent has; declaring is only a speed-up.
464
474
 
465
475
  Link documents to each other with \`[[wikilinks]]\` — the name, not the path.
466
476
  `,
@@ -482,3 +492,25 @@ expect:
482
492
  },
483
493
  ];
484
494
  }
495
+ /**
496
+ * A new workspace with nothing in it to delete: its AGENTS.md, the
497
+ * .gitignore that keeps its secret key out of git, and the CLAUDE.md a coding
498
+ * tool reads. No example agents or flow.
499
+ *
500
+ * The starter's researcher and writer were a demo, and a person making a
501
+ * workspace for real work met them as clutter — "why does a new workspace
502
+ * have agents?" (owner, 2026-10-05). The agents worth having are usually ones
503
+ * already written in another workspace, which `agents/import` copies in; the
504
+ * example is still one flag away (`foldrun new <name> --starter`).
505
+ */
506
+ export function blankWorkspaceFiles(workspace) {
507
+ const keep = new Set(["AGENTS.md", ".gitignore", "CLAUDE.md"]);
508
+ return starterFiles(workspace)
509
+ .filter((f) => keep.has(f.path))
510
+ .map((f) => f.path === "AGENTS.md"
511
+ ? {
512
+ ...f,
513
+ content: f.content.replace("description: A starter workspace — edit the agents, then run the flow.", "description: A new workspace — add an agent or import one from another workspace, then write a flow."),
514
+ }
515
+ : f);
516
+ }
@@ -238,8 +238,8 @@ export declare function checkVerify(agentDir: string, verify: string, ctx: {
238
238
  * and `matches:` test this, because it is also what the run's headline
239
239
  * is read from: a reporter that narrated between tool calls ("Now let
240
240
  * me write the report…") failed `matches: ^BAD\b` with a correct
241
- * headline while the check read every turn joined. `judge:` still grades
242
- * the whole result. */
241
+ * headline while the check read every turn joined. `judge:` grades this
242
+ * too, as "the reply", with the whole result beside it as the work. */
243
243
  conclusion?: string | null;
244
244
  data?: unknown;
245
245
  /** The step's own model environment, for `judge:` — it grades on the
@@ -249,6 +249,12 @@ export declare function checkVerify(agentDir: string, verify: string, ctx: {
249
249
  * person asked the run to stop. */
250
250
  signal?: AbortSignal;
251
251
  }): Promise<VerifyVerdict>;
252
+ /**
253
+ * A toolless, fast-tier grading call: does the reply satisfy the sentence?
254
+ * Same shape as the eval judge, and answered with one word first so the
255
+ * verdict is a prefix test rather than a reading.
256
+ */
257
+ export declare function judgePrompt(rubric: string, reply: string, work: string): string;
252
258
  /**
253
259
  * The environment an Agent SDK session starts with: the step's, minus the
254
260
  * connectors of whoever is signed in to claude.ai on this machine. Claude
@@ -260,3 +266,6 @@ export declare function checkVerify(agentDir: string, verify: string, ctx: {
260
266
  * the rest of the on-disk MCP configuration out; this keeps the account's.
261
267
  */
262
268
  export declare function sdkEnv(base: Record<string, string | undefined> | undefined, extra?: Record<string, string>): Record<string, string | undefined>;
269
+ /** The text of a tool result, whichever shape it came in: a string, or a
270
+ * list of content blocks of which only the text ones say anything. */
271
+ export declare function toolResultText(content: unknown): string;
@@ -188,6 +188,13 @@ async function executeStepInner(opts, runQuery) {
188
188
  // declares no tools gets none, instead of seeing the full Claude
189
189
  // Code toolset and burning turns on denied calls.
190
190
  tools: opts.allowed,
191
+ // Stated, never left to the SDK. Unset, it chose its own mode for the
192
+ // login it ran under, and in auto mode its classifier refused a granted
193
+ // script before canUseTool saw it: "denied by the Claude Code auto mode
194
+ // classifier. Reason: [Irreversible Local Destruction]" for backlink-
195
+ // desk's outreach_reset, which only marks old bodies STALE (2026-10-04
196
+ // and 05). The decision is ours: canUseTool and the PreToolUse hook.
197
+ permissionMode: "default",
191
198
  // Deliberately NOT allowedTools: listing them there auto-approves and
192
199
  // skips canUseTool entirely, which is where confinement happens. The
193
200
  // toolset is still restricted by `tools` above.
@@ -511,12 +518,25 @@ async function executeStepInner(opts, runQuery) {
511
518
  ...(block.is_error ? { err: true } : {}),
512
519
  ...(open.subagent ? { subagent: open.subagent } : {}),
513
520
  });
521
+ // Why it failed, on the trail. The span above carried only `err`,
522
+ // so a refused call left the agent's own guess as the only account
523
+ // of it: backlink-desk's outreach_reset "denied by the permission
524
+ // classifier" (run-muufboma-2t6l, 2026-10-05) could not be checked.
525
+ if (block.is_error) {
526
+ const why = toolResultText(block.content).replace(/\s+/g, " ").trim().slice(0, 300);
527
+ if (why)
528
+ emit("error", `${open.name}: ${why}`, { call: block.tool_use_id, ...(open.subagent ? { subagent: open.subagent } : {}) });
529
+ }
514
530
  readPod(block.tool_use_id);
515
531
  }
516
532
  }
517
533
  }
518
534
  else if (message.type === "result") {
519
535
  status = message.subtype === "success" ? "completed" : "failed";
536
+ // Calls the SDK itself refused, which canUseTool never saw.
537
+ for (const d of ("permission_denials" in message && Array.isArray(message.permission_denials) ? message.permission_denials : [])) {
538
+ emit("error", `refused before it ran: ${String(d.tool_name ?? "a tool")}`);
539
+ }
520
540
  if (message.subtype === "error_max_turns") {
521
541
  emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
522
542
  }
@@ -801,7 +821,13 @@ export async function checkVerify(agentDir, verify, ctx) {
801
821
  return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
802
822
  }
803
823
  case "judge": {
804
- const verdict = await judgeReply(value, output, ctx.modelEnv ?? {}, ctx.signal);
824
+ // The reply is the final turn, as everywhere else; every turn goes
825
+ // beside it as the work, so a claim about what the step did can still
826
+ // be checked. Both as one <reply> failed `judge: the reply opens with
827
+ // GOOD` on a reply that did (fix-desk, run-muwevg72-7mza): the joined
828
+ // text opened with the narration before it.
829
+ const reply = ctx.conclusion?.trim() ? ctx.conclusion : output;
830
+ const verdict = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal);
805
831
  const ok = /^\s*PASS\b/i.test(verdict);
806
832
  return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
807
833
  }
@@ -813,7 +839,18 @@ export async function checkVerify(agentDir, verify, ctx) {
813
839
  * Same shape as the eval judge, and answered with one word first so the
814
840
  * verdict is a prefix test rather than a reading.
815
841
  */
816
- async function judgeReply(rubric, output, env, signal) {
842
+ export function judgePrompt(rubric, reply, work) {
843
+ const shown = reply.slice(0, 20_000);
844
+ const context = work.trim() && work.trim() !== reply.trim()
845
+ ? `Everything the step wrote while working, for context. "The reply" means only the reply block below, ` +
846
+ `never this:\n<work>\n${work.slice(-20_000)}\n</work>\n\n`
847
+ : "";
848
+ return (`You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
849
+ `first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
850
+ context +
851
+ `<reply>\n${shown}\n</reply>`);
852
+ }
853
+ async function judgeReply(rubric, reply, work, env, signal) {
817
854
  const texts = [];
818
855
  const abortController = new AbortController();
819
856
  if (signal?.aborted)
@@ -821,9 +858,7 @@ async function judgeReply(rubric, output, env, signal) {
821
858
  signal?.addEventListener("abort", () => abortController.abort(), { once: true });
822
859
  try {
823
860
  const q = query({
824
- prompt: `You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
825
- `first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
826
- `<reply>\n${output.slice(0, 40_000)}\n</reply>`,
861
+ prompt: judgePrompt(rubric, reply, work),
827
862
  options: {
828
863
  model: "haiku",
829
864
  systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
@@ -914,3 +949,15 @@ function runVerify(agentDir, command, env, data, signal) {
914
949
  export function sdkEnv(base, extra = {}) {
915
950
  return { ...(base ?? process.env), ...extra, ENABLE_CLAUDEAI_MCP_SERVERS: "false" };
916
951
  }
952
+ /** The text of a tool result, whichever shape it came in: a string, or a
953
+ * list of content blocks of which only the text ones say anything. */
954
+ export function toolResultText(content) {
955
+ if (typeof content === "string")
956
+ return content;
957
+ if (!Array.isArray(content))
958
+ return "";
959
+ return content
960
+ .filter((b) => !!b && b.type === "text" && typeof b.text === "string")
961
+ .map((b) => b.text)
962
+ .join(" ");
963
+ }