@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
package/src/runtime.ts CHANGED
@@ -33,6 +33,12 @@ export interface RuntimeSpec {
33
33
  packages: string[]; // pip
34
34
  node?: string | boolean;
35
35
  npm: string[];
36
+ /** Debian packages the step needs from the operating system — a library's
37
+ * headers for a pip package that compiles (`libcairo2-dev`), a C compiler
38
+ * (`build-essential`), a program (`ffmpeg`). Installed with apt by the run
39
+ * pod's root start-up, before anything model-directed runs, from a .deb
40
+ * cache on the runtime volume (SYSTEM_INSTALL_SH); verified here. */
41
+ system?: string[];
36
42
  /** Declared entries that are not valid requirements. Kept so the run can
37
43
  * SAY they were dropped: silently discarding `pandas>=2` and then failing
38
44
  * the script with "no module named pandas" is the least debuggable outcome
@@ -61,11 +67,17 @@ export interface RuntimeSpec {
61
67
  */
62
68
  const PIP_NAME = String.raw`[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9._,-]+\])?`;
63
69
  const PIP_SPECIFIER = String.raw`(==|!=|<=|>=|~=|<|>)[A-Za-z0-9._*+!-]+`;
64
- const PIP_REQUIREMENT = new RegExp(`^${PIP_NAME}(${PIP_SPECIFIER}(,${PIP_SPECIFIER})*)?$`);
70
+ export const PIP_REQUIREMENT = new RegExp(`^${PIP_NAME}(${PIP_SPECIFIER}(,${PIP_SPECIFIER})*)?$`);
65
71
  /** npm's own shape: an optional @scope, then a name, then an optional @range. */
66
- const NPM_REQUIREMENT =
72
+ export const NPM_REQUIREMENT =
67
73
  /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(@[A-Za-z0-9._^~*>=< |-]+)?$/;
68
74
 
75
+ /** A Debian package name as policy defines it: lowercase letters, digits,
76
+ * `+`, `-` and `.`, starting with a letter or digit, two or more characters.
77
+ * Like the two above, a guard first: it can never begin with a dash, so it
78
+ * can never become an apt option, and it holds no shell characters. */
79
+ export const DEB_PACKAGE = /^[a-z0-9][a-z0-9+.-]{1,62}$/;
80
+
69
81
  export function parseRuntime(raw: unknown): RuntimeSpec | null {
70
82
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
71
83
  const e = raw as Record<string, unknown>;
@@ -85,9 +97,11 @@ export function parseRuntime(raw: unknown): RuntimeSpec | null {
85
97
  node: typeof e.node === "string" || typeof e.node === "boolean" ? e.node : undefined,
86
98
  npm: list(e.npm, NPM_REQUIREMENT),
87
99
  };
100
+ const system = list(e.system ?? e.apt, DEB_PACKAGE);
101
+ if (system.length) spec.system = system;
88
102
  if (rejected.length) spec.rejected = rejected;
89
103
  if (Array.isArray(e.ignored) && e.ignored.length) spec.ignored = e.ignored.map(String);
90
- return wantsPython(spec) || wantsNode(spec) ? spec : null;
104
+ return wantsPython(spec) || wantsNode(spec) || wantsSystem(spec) ? spec : null;
91
105
  }
92
106
 
93
107
  /**
@@ -117,6 +131,8 @@ export function mergeRuntimes(...specs: (RuntimeSpec | null | undefined)[]): Run
117
131
  node: pick("node"),
118
132
  npm: uniq(present.flatMap((s) => s.npm)),
119
133
  };
134
+ const system = uniq(present.flatMap((s) => s.system ?? []));
135
+ if (system.length) merged.system = system;
120
136
  const rejected = uniq(present.flatMap((s) => s.rejected ?? []));
121
137
  if (rejected.length) merged.rejected = rejected;
122
138
  const ignored = uniq(present.flatMap((s) => s.ignored ?? []));
@@ -130,6 +146,11 @@ export function fingerprint(spec: RuntimeSpec): string {
130
146
  packages: [...spec.packages].sort(),
131
147
  node: spec.node ?? null,
132
148
  npm: [...spec.npm].sort(),
149
+ // Only when declared: adding the key unconditionally would give every
150
+ // environment already cached a new fingerprint and rebuild them all.
151
+ // Part of it when declared, because a venv whose packages compiled
152
+ // against these libraries is not the venv built without them.
153
+ ...(spec.system?.length ? { system: [...spec.system].sort() } : {}),
133
154
  });
134
155
  return crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 16);
135
156
  }
@@ -244,6 +265,7 @@ function wire(root: string, spec: RuntimeSpec, note: string): PreparedRuntime {
244
265
 
245
266
  // `python: false` says "not this one" — it used to read as a declaration
246
267
  // because it was not undefined, and built a venv nobody asked for.
268
+ const wantsSystem = (s: RuntimeSpec) => (s.system?.length ?? 0) > 0;
247
269
  const wantsPython = (s: RuntimeSpec) => (s.python !== undefined && s.python !== false) || s.packages.length > 0;
248
270
  const wantsNode = (s: RuntimeSpec) => (s.node !== undefined && s.node !== false) || s.npm.length > 0;
249
271
 
@@ -556,8 +578,193 @@ function findUv(): string | null {
556
578
  }
557
579
 
558
580
  // Build (or reuse) the environment for one runtime declaration.
581
+ /**
582
+ * An installer's failure, said so that the first line is the cause.
583
+ *
584
+ * Until 11 Oct 2026 a failure was the installer's last 500 characters, and
585
+ * the run log shows a message's first line: a pycairo build that died for
586
+ * want of a C compiler read, in full, `uv pip install failed: files.` — the
587
+ * tail end of a setuptools deprecation notice. The cause was on the last
588
+ * line of 4 KB nobody saw. So: find the cause and the package it hit, say
589
+ * the fix when it is one we know, then the last lines of the log for the rest.
590
+ */
591
+ export function summarizeInstallFailure(what: string, out: string): string {
592
+ const text = out ?? "";
593
+ const lines = text.split(/\r?\n/).map((l) => l.replace(/\s+$/, "")).filter(Boolean);
594
+ const pkg =
595
+ /Failed to build `([^`]+)`/.exec(text)?.[1] ??
596
+ /Failed building wheel for ([\w.-]+)/.exec(text)?.[1] ??
597
+ /Building wheel for ([\w.-]+) \(.*\) did not run successfully/.exec(text)?.[1] ??
598
+ /Failed to (?:download|prepare|fetch) `([^`]+)`/.exec(text)?.[1] ??
599
+ null;
600
+ const subject = pkg ? `\`${pkg}\`` : "a package";
601
+ let cause: string | null = null;
602
+ let hint = "";
603
+ const header = /fatal error: ([\w./+-]+\.h): No such file/.exec(text);
604
+ const pc = /Package '?([\w.+-]+)'?,? (?:required by [^,]+, )?(?:was )?not found|Dependency "([\w.+-]+)" not found/.exec(text);
605
+ 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)) {
606
+ cause = `${subject} has to be compiled and this runner has no C compiler`;
607
+ 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";
608
+ } else if (/pkg-config(?:: command)? not found|Did not find pkg-config|pkg-config.*No such file/i.test(text)) {
609
+ cause = `${subject} looks for its libraries with pkg-config, which this runner lacks`;
610
+ hint = " — add `system: [pkg-config]` and the library's -dev package to the runtime";
611
+ } else if (header && /^Python\.h$/.test(header[1])) {
612
+ cause = `${subject} builds against Python's own C headers, which this runner lacks`;
613
+ hint = " — add `system: [python3-dev, build-essential]` to the runtime";
614
+ } else if (pc || header) {
615
+ const lib = pc?.[1] ?? pc?.[2] ?? header![1];
616
+ cause = `${subject} needs the system library ${lib} (its headers) to build`;
617
+ hint = ` — add its Debian -dev package to \`runtime: system:\`${pc ? ` (for ${lib}, usually \`lib${lib.replace(/^lib/, "")}-dev\`)` : ""}, plus build-essential and pkg-config`;
618
+ } else if (/E: Unable to locate package (\S+)/.test(text)) {
619
+ cause = `apt has no package \`${/E: Unable to locate package (\S+)/.exec(text)![1]}\``;
620
+ hint = " — check the name on packages.debian.org for trixie";
621
+ } else if (/No solution found when resolving dependencies/.test(text)) {
622
+ const i = lines.findIndex((l) => /No solution found/.test(l));
623
+ cause = `the requirements cannot all be satisfied: ${lines.slice(i + 1, i + 3).map((l) => l.replace(/^[\s╰─▶×]+/, "")).join(" ")}`.trim();
624
+ } 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)) {
625
+ 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)!;
626
+ cause = `no published version of \`${m[1] ?? m[2] ?? m[3]}\` matches`;
627
+ } else if (/npm (?:ERR!|error) code (\S+)/.test(text)) {
628
+ const code = /npm (?:ERR!|error) code (\S+)/.exec(text)![1];
629
+ const detail = lines.find((l) => /npm (?:ERR!|error) (?:404|notarget|E\w+)\b/.test(l) && !/ code /.test(l));
630
+ cause = `npm ${code}${detail ? `: ${detail.replace(/^npm (?:ERR!|error)\s*/, "")}` : ""}`;
631
+ }
632
+ if (!cause) {
633
+ const last = [...lines].reverse().find((l) => /error/i.test(l)) ?? lines[lines.length - 1] ?? "no output";
634
+ cause = (pkg ? `${subject}: ` : "") + last.replace(/^\s*(?:error:|ERROR:|×)\s*/, "");
635
+ }
636
+ const excerpt = lines.slice(-25).join("\n");
637
+ return `${what} failed — ${cause}${hint}\n${excerpt.length > 2000 ? excerpt.slice(-2000) : excerpt}`;
638
+ }
639
+
640
+ /** Sets $FOLDRUN_SYSTEM_PACKAGES from the step's input.json (its merged
641
+ * runtime spec) — the run pod's and the docker entry's first line. */
642
+ 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){}')"`;
643
+
644
+ /** Where a run container's root start-up records the system install. */
645
+ export const SYSTEM_STATUS_FILE = "/opt/runner/job/system-install.json";
646
+ export const SYSTEM_LOG_FILE = "/opt/runner/job/system-install.log";
647
+
648
+ /**
649
+ * The root start-up's half of `runtime: system:` — one shell snippet, used by
650
+ * the k8s run pod, the docker entry and the deploy-time warm pod alike.
651
+ * Reads the package names from $FOLDRUN_SYSTEM_PACKAGES (space-separated,
652
+ * each re-checked against DEB_PACKAGE's shape here too, so nothing else can
653
+ * ride in), installs them with apt as root BEFORE the drop to the agent user,
654
+ * and writes SYSTEM_STATUS_FILE for prepareRuntime to read.
655
+ *
656
+ * The first step to need a package set installs it with apt (the .debs kept
657
+ * in `<cache>/.apt/archives`) and then saves the files those packages added,
658
+ * with their dpkg records, as a snapshot: `<cache>/.apt/snapshots/<key>.tar`.
659
+ * Every later step restores the snapshot instead — measured 11 Oct 2026 in a
660
+ * gVisor run pod on the NFS cache: build-essential, pkg-config, python3-dev,
661
+ * libcairo2-dev and libpango1.0-dev (154 packages, 432 MB) took 23-30 s
662
+ * through apt and 0.76 s from the snapshot, gcc and pkg-config working after.
663
+ * The key is the package set AND a checksum of the image's own dpkg status,
664
+ * so a new runner image never restores files built against the old one. A
665
+ * snapshot is written under a temporary name and renamed, so two pods
666
+ * building it at once cannot leave a torn one; a restore that fails falls
667
+ * back to apt, and prepareRuntime verifies the result either way. Unused for
668
+ * 30 days, one is pruned. Package lists stay per pod: two pods updating one
669
+ * lists directory on a shared volume would corrupt it.
670
+ */
671
+ export function systemInstallSh(cacheDir: string, logDir = "/opt/runner/job"): string {
672
+ return `if [ -n "$FOLDRUN_SYSTEM_PACKAGES" ]; then
673
+ SYS=""; for p in $FOLDRUN_SYSTEM_PACKAGES; do case "$p" in -*|*[!a-z0-9+.-]*) ;; *) SYS="$SYS $p";; esac; done
674
+ D=\${FOLDRUN_DPKG_DIR:-/var/lib/dpkg}; R=\${FOLDRUN_SYS_ROOT:-/}
675
+ C=${cacheDir}/.apt; A=$C/archives; S=$C/snapshots
676
+ mkdir -p "$A/partial" "$S" 2>/dev/null || { A=/var/cache/apt/archives; S=""; }
677
+ KEY=v3-$( { printf '%s\\n' $SYS | sort -u; cksum < "$D/status"; } | cksum | tr ' ' '-')
678
+ T0=$(date +%s); MODE=apt; RC=1
679
+ if [ -n "$S" ] && [ -f "$S/$KEY.tar" ] && tar -xf "$S/$KEY.tar" -C "$R" > ${logDir}/system-install.log 2>&1; then
680
+ MODE=snapshot; RC=0; touch "$S/$KEY.tar" 2>/dev/null
681
+ # apt runs ldconfig through dpkg's trigger; a restore must too, or a
682
+ # library the snapshot brought is missing from /etc/ld.so.cache.
683
+ [ "$R" = / ] && ldconfig 2>/dev/null
684
+ else
685
+ ls "$D"/info/*.list 2>/dev/null | sort > /tmp/.sys-before
686
+ touch /tmp/.sys-mark; sleep 1
687
+ { 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
688
+ RC=$?
689
+ if [ $RC -eq 0 ] && [ -n "$S" ]; then
690
+ ls "$D"/info/*.list 2>/dev/null | sort > /tmp/.sys-after
691
+ comm -13 /tmp/.sys-before /tmp/.sys-after > /tmp/.sys-new
692
+ if [ -s /tmp/.sys-new ]; then
693
+ { cat $(cat /tmp/.sys-new) | sort -u | while read -r f; do if [ -f "$f" ] || [ -L "$f" ]; then echo "$f"; fi; done
694
+ for l in $(cat /tmp/.sys-new); do ls "\${l%.list}".* 2>/dev/null; done
695
+ echo "$D/status"
696
+ # What the packages' install scripts made, which dpkg's lists do not
697
+ # name: update-alternatives links (libblas.so.3 → /etc/alternatives),
698
+ # generated caches and configs. By ctime, not mtime: those links carry
699
+ # the package's own (2025) mtime, and only ctime cannot be backdated.
700
+ 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
701
+ } | sort -u > /tmp/.sys-files
702
+ tar -cf "$S/.$KEY.$$.tar" -T /tmp/.sys-files 2>/dev/null && mv -f "$S/.$KEY.$$.tar" "$S/$KEY.tar" 2>/dev/null
703
+ rm -f "$S/.$KEY.$$.tar" 2>/dev/null
704
+ fi
705
+ find "$S" -name '*.tar' -mtime +30 -delete 2>/dev/null
706
+ fi
707
+ fi
708
+ 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
709
+ rm -rf /var/lib/apt/lists/* 2>/dev/null || true
710
+ fi`;
711
+ }
712
+
713
+ /**
714
+ * Are the declared system packages on this machine? The install happened in
715
+ * the container's root start-up (systemInstallSh); this only reads the
716
+ * result. Without dpkg (a laptop, macOS) there is nothing to check against
717
+ * and nothing this process could install: say so and carry on.
718
+ */
719
+ export function checkSystemPackages(pkgs: string[]): { log: string[]; error: string | null } {
720
+ if (!pkgs.length) return { log: [], error: null };
721
+ const statusFile = process.env.FOLDRUN_SYSTEM_STATUS ?? SYSTEM_STATUS_FILE;
722
+ const logFile = process.env.FOLDRUN_SYSTEM_LOG ?? SYSTEM_LOG_FILE;
723
+ let status: { ok?: boolean; seconds?: number; mode?: string } | null = null;
724
+ try {
725
+ status = JSON.parse(fs.readFileSync(statusFile, "utf8"));
726
+ } catch {
727
+ status = null;
728
+ }
729
+ const q = spawnSync("dpkg-query", ["-W", "-f=${Package} ${db:Status-Status}\n", ...pkgs], { encoding: "utf8" });
730
+ if (q.error) {
731
+ return { log: [`system packages ${pkgs.join(", ")} install only in a run container; on this machine they are assumed present`], error: null };
732
+ }
733
+ const installed = new Set(
734
+ (q.stdout ?? "")
735
+ .split("\n")
736
+ .map((l) => l.trim().split(/\s+/))
737
+ .filter((f) => f[1] === "installed")
738
+ .map((f) => f[0].replace(/:.*$/, "")),
739
+ );
740
+ const missing = pkgs.filter((p) => !installed.has(p));
741
+ if (!missing.length) {
742
+ return { log: [`system packages ready: ${pkgs.join(", ")}${status?.seconds !== undefined ? ` (${status.mode === "snapshot" ? "snapshot" : "apt"}, ${status.seconds}s)` : ""}`], error: null };
743
+ }
744
+ let aptOut = "";
745
+ try {
746
+ aptOut = fs.readFileSync(logFile, "utf8");
747
+ } catch {
748
+ aptOut = "";
749
+ }
750
+ const reason = !status
751
+ ? "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"
752
+ : status.ok
753
+ ? "apt reported success, yet they are not installed"
754
+ : summarizeInstallFailure("apt-get install", aptOut).replace(/^apt-get install failed — /, "apt: ");
755
+ return { log: [], error: `system packages not installed: ${missing.join(", ")} — ${reason}` };
756
+ }
757
+
559
758
  export function prepareRuntime(tenant: string, spec: RuntimeSpec | null): PreparedRuntime {
560
759
  if (!spec) return EMPTY;
760
+ const system = checkSystemPackages(spec.system ?? []);
761
+ if (system.error) return { ...EMPTY, log: system.log, error: system.error };
762
+ if (!wantsPython(spec) && !wantsNode(spec)) return { ...EMPTY, log: system.log };
763
+ const built = prepareLanguageRuntime(tenant, spec);
764
+ return system.log.length ? { ...built, log: [...system.log, ...built.log] } : built;
765
+ }
766
+
767
+ function prepareLanguageRuntime(tenant: string, spec: RuntimeSpec): PreparedRuntime {
561
768
 
562
769
  const fp = fingerprint(spec);
563
770
  const cacheRoot = path.join(dataRoot(), tenant, ".runtimes");
@@ -733,7 +940,7 @@ export function prepareRuntime(tenant: string, spec: RuntimeSpec | null): Prepar
733
940
  ? install(uv, ["pip", "install", "-q", "--python", venvPython, ...spec.packages], root, 300_000, uvEnv)
734
941
  : install(path.join(root, "venv", "bin", "pip"), ["install", "--disable-pip-version-check", "-q", ...spec.packages], root);
735
942
  if (!installed.ok) {
736
- return fail(`${uv ? "uv pip" : "pip"} install failed: ${installed.out.slice(-500)}`);
943
+ return fail(summarizeInstallFailure(`${uv ? "uv pip" : "pip"} install`, installed.out));
737
944
  }
738
945
  built.packages = spec.packages;
739
946
  log.push(`runtime ${fp}: installed ${spec.packages.join(", ")}`);
@@ -766,7 +973,7 @@ export function prepareRuntime(tenant: string, spec: RuntimeSpec | null): Prepar
766
973
  },
767
974
  );
768
975
  if (!installed.ok) {
769
- return fail(`npm install failed: ${installed.out.slice(-500)}`);
976
+ return fail(summarizeInstallFailure("npm install", installed.out));
770
977
  }
771
978
  built.npm = spec.npm;
772
979
  log.push(`runtime ${fp}: installed ${spec.npm.join(", ")}`);
@@ -834,3 +1041,26 @@ function touch(file: string): void {
834
1041
  // read-only or gone; neither is a reason to fail the step
835
1042
  }
836
1043
  }
1044
+
1045
+ // ---------- installing mid-step ----------
1046
+
1047
+ /** Where the root start-up's install helper listens, inside a run container. */
1048
+ export const INSTALL_SOCKET = "/opt/runner/job/install.sock";
1049
+
1050
+ /**
1051
+ * What a step installs for itself mid-run (the `install` tool) lands under
1052
+ * one per-step directory: pip packages in `<dir>/py` (`uv pip install
1053
+ * --target`), npm packages in `<dir>/npm` (`npm install --prefix`). Both are
1054
+ * on the step's paths from its first turn, so a tool installed by one call is
1055
+ * found by the next — `ffmpeg`, `import manim`, `require("sharp")`. Inside a
1056
+ * run container the directory is the container's own filesystem and goes
1057
+ * when the container does.
1058
+ */
1059
+ export function stepInstallEnv(dir: string, base: Record<string, string | undefined>): Record<string, string> {
1060
+ return {
1061
+ // After whatever is already there: a declared environment keeps winning.
1062
+ PATH: [base.PATH ?? "", path.join(dir, "py", "bin"), path.join(dir, "npm", "node_modules", ".bin")].filter(Boolean).join(path.delimiter),
1063
+ PYTHONPATH: [base.PYTHONPATH ?? "", path.join(dir, "py")].filter(Boolean).join(path.delimiter),
1064
+ NODE_PATH: [base.NODE_PATH ?? "", path.join(dir, "npm", "node_modules")].filter(Boolean).join(path.delimiter),
1065
+ };
1066
+ }
@@ -38,6 +38,8 @@ export interface ScriptSpec {
38
38
  * script inline. Exactly one of the two is the program. */
39
39
  run: string;
40
40
  description: string;
41
+ /** The tool.md body, for the model: how to call it and what it means. */
42
+ guide?: string;
41
43
  args: Record<string, string>; // arg name → description
42
44
  interpreter?: string; // optional override, e.g. "python3", "bash"
43
45
  /** Seconds this script may run — for the crawl that legitimately takes
@@ -91,6 +93,7 @@ export function parseScripts(raw: unknown): ScriptSpec[] {
91
93
  run: run ?? "",
92
94
  ...(code ? { code, codeExt: typeof e.codeExt === "string" ? e.codeExt : ".mjs" } : {}),
93
95
  description: typeof e.description === "string" ? e.description : "",
96
+ ...(typeof e.guide === "string" && e.guide.trim() ? { guide: e.guide } : {}),
94
97
  args,
95
98
  interpreter: typeof e.interpreter === "string" ? e.interpreter : undefined,
96
99
  secrets: e.secrets === "proxied" ? "proxied" : undefined,
@@ -234,12 +237,16 @@ export function runScript(
234
237
  // Container path: same command, executed inside an isolated container
235
238
  // with only the agent's directory (plus declared shared dirs) mounted.
236
239
  if (exec && exec.executor === "docker") {
240
+ // `workspace/…` is the workspace root on the host (the step's link)
241
+ // and in a run container; here there is no link, so each such
242
+ // argument is staged: copied in, pointed at, and copied back after.
243
+ const workspaceRoot = path.resolve(cwd, "..", "..");
244
+ const staged: { host: string; container: string }[] = [];
237
245
  const inContainer = (p: string) => {
238
- if (p.startsWith(path.resolve(cwd))) return p.replace(path.resolve(cwd), "/workspace");
239
- for (const [host, mount] of Object.entries(exec.mounts)) {
240
- if (p.startsWith(host)) return p.replace(host, mount);
241
- }
242
- return p;
246
+ const w = stageWorkspaceArg(p, workspaceRoot);
247
+ if (!w) return containerPath(p, cwd, exec.mounts);
248
+ if (!staged.some((s) => s.host === w.host)) staged.push(w);
249
+ return w.container;
243
250
  };
244
251
  const argv = [
245
252
  // The interpreter inside the image, not the host's venv path.
@@ -251,6 +258,7 @@ export function runScript(
251
258
  runInContainer({
252
259
  agentDir: path.resolve(cwd),
253
260
  readOnly: exec.mounts,
261
+ staged,
254
262
  image: exec.image,
255
263
  argv,
256
264
  env,
@@ -292,6 +300,39 @@ export function runScript(
292
300
  });
293
301
  }
294
302
 
303
+ /**
304
+ * A host path as the script's container sees it: under the agent's own
305
+ * directory it is /workspace, under a mounted folder it is that mount, and
306
+ * anything else (an argument that only looks like a path) is left alone.
307
+ * Containment, not a text prefix: `<ws>/scripts-old/x.py` is not under
308
+ * `<ws>/scripts`. The longest mount wins, so a nested one is not shadowed.
309
+ */
310
+ export function containerPath(p: string, agentDir: string, mounts: Record<string, string>): string {
311
+ if (!path.isAbsolute(p)) return p;
312
+ const under = (root: string) => {
313
+ const rel = path.relative(root, p);
314
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) ? rel : null;
315
+ };
316
+ const own = under(path.resolve(agentDir));
317
+ if (own !== null) return path.posix.join("/workspace", own.split(path.sep).join("/"));
318
+ const hit = Object.entries(mounts)
319
+ .map(([host, mount]) => ({ mount, rel: under(path.resolve(host)), len: host.length }))
320
+ .filter((m) => m.rel !== null)
321
+ .sort((a, b) => b.len - a.len)[0];
322
+ return hit ? path.posix.join(hit.mount, hit.rel!.split(path.sep).join("/")) : p;
323
+ }
324
+
325
+ /** Where a `workspace/…` script argument is staged in a script container,
326
+ * or null when it is not one (or climbs out of the workspace). */
327
+ export function stageWorkspaceArg(arg: string, workspaceRoot: string): { host: string; container: string } | null {
328
+ if (arg !== "workspace" && !arg.startsWith("workspace/")) return null;
329
+ const rel = arg.slice("workspace".length).replace(/^\/+/, "");
330
+ const host = path.resolve(workspaceRoot, rel);
331
+ const within = path.relative(workspaceRoot, host);
332
+ if (within.startsWith("..") || path.isAbsolute(within)) return null;
333
+ return { host, container: path.posix.join("/workspace-root", within.split(path.sep).join("/")) };
334
+ }
335
+
295
336
  export interface ExecutionContext {
296
337
  /** "docker" nests each script in its own container; "sandbox" spawns it
297
338
  * directly inside the isolation the step already has. */
@@ -389,7 +430,8 @@ export function buildScriptTools(
389
430
  return tool(
390
431
  spec.name,
391
432
  `${spec.description || `Run ${spec.run}.`} Runs the workspace script ${spec.run}; returns its output. ` +
392
- `Credentials it needs are already in its environment.`,
433
+ `Credentials it needs are already in its environment.` +
434
+ (spec.guide ? `\n\nHow to use it (from its tool.md):\n${spec.guide}` : ""),
393
435
  shape,
394
436
  async (args) => {
395
437
  const started = Date.now();
package/src/secrets.ts CHANGED
@@ -505,6 +505,15 @@ export interface OAuth2Config {
505
505
 
506
506
  const OAUTH2_PREFIX = "@oauth2 ";
507
507
 
508
+ /** Tries at a token endpoint before a refresh is called failed: the first,
509
+ * and two more after a short wait, for a network error, a 5xx or a 429. */
510
+ export const OAUTH_REFRESH_TRIES = 3;
511
+ /** Wait before try n (1-based retry): 1s, 3s — FOLDRUN_OAUTH_RETRY_MS scales it for tests. */
512
+ export function oauthRetryDelayMs(n: number): number {
513
+ const base = Number(process.env.FOLDRUN_OAUTH_RETRY_MS ?? 1000);
514
+ return base * (n === 1 ? 1 : 3);
515
+ }
516
+
508
517
  export function setOAuth2Secret(
509
518
  tenant: string,
510
519
  name: string,
@@ -550,12 +559,31 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
550
559
  client_secret: config.client_secret,
551
560
  ...(config.extra ?? {}),
552
561
  });
553
- const res = await fetchUntrusted(config.token_url, {
554
- method: "POST",
555
- headers: { "content-type": "application/x-www-form-urlencoded" },
556
- body: body.toString(),
557
- signal: AbortSignal.timeout(15_000),
558
- }, { sameOrigin: true });
562
+ // A network blip is retried; a provider's answer is not. "fetch failed"
563
+ // (a reset, a DNS hiccup, a timeout) failed whole steps on the first try
564
+ // — gbp-desk's Thursday posts, 7 Oct 2026. A 4xx (revoked, wrong client)
565
+ // will say the same thing again, so it goes straight to the error below
566
+ // and the reconnect mail; a 5xx or 429 is the provider busy, retried.
567
+ let res: Response | undefined;
568
+ let lastNetwork: unknown;
569
+ for (let attempt = 0; attempt < OAUTH_REFRESH_TRIES; attempt++) {
570
+ if (attempt) await new Promise((r) => setTimeout(r, oauthRetryDelayMs(attempt)));
571
+ try {
572
+ res = await fetchUntrusted(config.token_url, {
573
+ method: "POST",
574
+ headers: { "content-type": "application/x-www-form-urlencoded" },
575
+ body: body.toString(),
576
+ signal: AbortSignal.timeout(15_000),
577
+ }, { sameOrigin: true });
578
+ } catch (err) {
579
+ lastNetwork = err;
580
+ res = undefined;
581
+ continue;
582
+ }
583
+ if (res.status === 429 || res.status >= 500) continue;
584
+ break;
585
+ }
586
+ if (!res) throw lastNetwork instanceof Error ? lastNetwork : new Error(String(lastNetwork));
559
587
  const payload = (await res.json().catch(() => ({}))) as {
560
588
  access_token?: string;
561
589
  expires_in?: number;
package/src/starter.ts CHANGED
@@ -47,6 +47,10 @@ foldrun_version: "0.1"
47
47
  # that declares the same key replaces it *whole* — base_url and token never
48
48
  # merge across scopes, so a provider block belongs entirely at one level.
49
49
  #
50
+ # Your own model key needs no block: Settings → Model provider, or
51
+ # \`foldrun account model set anthropic --key …\` (an API key, not a login).
52
+ # A provider: block here or lower wins over it.
53
+ #
50
54
  # provider:
51
55
  # base_url: https://openrouter.ai/api
52
56
  # token: \${OPENROUTER_API_KEY}
@@ -342,13 +346,14 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
342
346
  | \`/runs/<id>/message\` | POST | tell a running step something; \`step\` picks one of a parallel group |
343
347
  | \`/flows\` · \`/flows/<f>\` | GET POST · POST DELETE PATCH | list, create, edit |
344
348
  | \`/flows/<f>/run\` | POST | start a flow |
345
- | \`/agents\` · \`/agents/<a>/run\` | GET POST · POST | list agents, run one |
349
+ | \`/agents\` · \`/agents/<a>/run\` · \`/agents/import\` | GET POST · POST · GET POST | list agents, run one, copy one in from another workspace |
346
350
  | \`/evals\` · \`/evals/<e>/run\` | GET POST · POST | list evals, run one |
347
351
  | \`/tools/<t>/test\` | POST | exercise one tool alone |
348
352
  | \`/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 |
349
353
  | \`/assets\` | POST | upload an asset |
350
354
  | \`/hooks/<f>/rotate\` | POST | new webhook token for a flow |
351
355
  | \`/notify/deliveries\` · \`/notify/deliveries/<id>/redeliver\` | GET · POST | every notify: webhook sent and each attempt; send one again |
356
+ | \`/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) |
352
357
  | \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
353
358
  | \`/vocabulary\` | GET | what this workspace's documents may say |
354
359
 
@@ -458,10 +463,11 @@ subagents: [researcher] # colleagues it may delegate a job to (own context,
458
463
  secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
459
464
  limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
460
465
  timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
461
- provider: {} # BYOK: your own model credential
466
+ provider: {} # BYOK for this agent; account-wide: \`foldrun account model set\`
462
467
  permissionMode: plan # plan first, act once approved
463
468
  runtime: # only if scripts need packages
464
469
  packages: [requests]
470
+ system: [ffmpeg] # Debian packages, apt-installed before the step
465
471
  ---
466
472
 
467
473
  You research a topic and report what you found...
@@ -475,8 +481,12 @@ flow's, else the workspace's AGENTS.md, else the account's, else UTC. It sets
475
481
  \`TZ\` and the date the agent is told, so a Sydney desk stamps the Sydney date.
476
482
 
477
483
  \`runtime:\` installs pip/npm packages in the sandbox each agent runs in. Pin
478
- versions the normal way (\`pandas>=2\`, \`lodash@^4\`). Python and Node are
479
- supported; anything else should ship as a committed binary.
484
+ versions the normal way (\`pandas>=2\`, \`lodash@^4\`). \`system:\` adds Debian
485
+ packages \u{2014} a program (\`ffmpeg\`), a library's headers for a pip package that
486
+ compiles (\`libcairo2-dev\`, \`python3-dev\`), \`build-essential\` \u{2014} installed with
487
+ apt before the step starts and cached, so later runs only unpack them. An agent that
488
+ finds it needs something mid-run installs it itself with the \`install\` tool
489
+ (apt, pip or npm), which every agent has; declaring is only a speed-up.
480
490
 
481
491
  Link documents to each other with \`[[wikilinks]]\` — the name, not the path.
482
492
  `,
@@ -499,3 +509,31 @@ expect:
499
509
  },
500
510
  ];
501
511
  }
512
+
513
+ /**
514
+ * A new workspace with nothing in it to delete: its AGENTS.md, the
515
+ * .gitignore that keeps its secret key out of git, and the CLAUDE.md a coding
516
+ * tool reads. No example agents or flow.
517
+ *
518
+ * The starter's researcher and writer were a demo, and a person making a
519
+ * workspace for real work met them as clutter — "why does a new workspace
520
+ * have agents?" (owner, 2026-10-05). The agents worth having are usually ones
521
+ * already written in another workspace, which `agents/import` copies in; the
522
+ * example is still one flag away (`foldrun new <name> --starter`).
523
+ */
524
+ export function blankWorkspaceFiles(workspace: string): StarterFile[] {
525
+ const keep = new Set(["AGENTS.md", ".gitignore", "CLAUDE.md"]);
526
+ return starterFiles(workspace)
527
+ .filter((f) => keep.has(f.path))
528
+ .map((f) =>
529
+ f.path === "AGENTS.md"
530
+ ? {
531
+ ...f,
532
+ content: f.content.replace(
533
+ "description: A starter workspace — edit the agents, then run the flow.",
534
+ "description: A new workspace — add an agent or import one from another workspace, then write a flow.",
535
+ ),
536
+ }
537
+ : f,
538
+ );
539
+ }