@foldrun/core 0.2.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 (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
@@ -0,0 +1,41 @@
1
+ export interface RuntimeSpec {
2
+ python?: string | boolean;
3
+ packages: string[];
4
+ node?: string | boolean;
5
+ npm: string[];
6
+ /** Declared entries that are not valid requirements. Kept so the run can
7
+ * SAY they were dropped: silently discarding `pandas>=2` and then failing
8
+ * the script with "no module named pandas" is the least debuggable outcome
9
+ * available. Never part of the fingerprint — it names nothing installed. */
10
+ rejected?: string[];
11
+ }
12
+ export declare function parseRuntime(raw: unknown): RuntimeSpec | null;
13
+ /**
14
+ * Several declarations, one environment.
15
+ *
16
+ * A tool declares what ITS program needs — `runtime: { packages: [requests] }`
17
+ * in tool.md — because the tool is the unit of code and its dependencies
18
+ * belong beside it, not in every agent that grants it. An agent that grants
19
+ * three Python tools gets one venv holding the union. Version pins are kept
20
+ * verbatim; if two tools pin the same package differently, pip is the one to
21
+ * say so, loudly, at build time — better than one of them silently winning.
22
+ */
23
+ export declare function mergeRuntimes(...specs: (RuntimeSpec | null | undefined)[]): RuntimeSpec | null;
24
+ export declare function fingerprint(spec: RuntimeSpec): string;
25
+ /**
26
+ * A tenant name that is safe as one path segment. Tenants come from account
27
+ * ids rather than user input, but this directory is also handed to `docker -v`
28
+ * and to a k8s `subPath`, where a `..` would escape into another tenant's
29
+ * cache — so it is checked at the boundary rather than assumed upstream.
30
+ */
31
+ export declare function safeTenantSegment(tenant: string): string | null;
32
+ export interface PreparedRuntime {
33
+ /** Interpreter overrides by file extension, e.g. { ".py": "/…/venv/bin/python" }. */
34
+ interpreters: Record<string, string>;
35
+ /** Extra environment for spawned scripts (NODE_PATH, VIRTUAL_ENV, PATH). */
36
+ env: Record<string, string>;
37
+ /** Human-readable lines describing what was built, for the run log. */
38
+ log: string[];
39
+ error: string | null;
40
+ }
41
+ export declare function prepareRuntime(tenant: string, spec: RuntimeSpec | null): PreparedRuntime;
@@ -0,0 +1,357 @@
1
+ // Script runtimes and their dependencies.
2
+ //
3
+ // An agent (or its workspace) declares what its scripts need:
4
+ //
5
+ // runtime:
6
+ // python: "3.12" # optional pin; the host interpreter is used
7
+ // packages: [pandas, requests]
8
+ // node: true
9
+ // npm: [lodash]
10
+ //
11
+ // The platform builds that environment once, keyed by a fingerprint of the
12
+ // declaration, and reuses it for every later run. Python gets a venv; Node
13
+ // gets an npm prefix exposed through NODE_PATH. Nothing is installed into
14
+ // the host's global site-packages, so two agents can want different versions
15
+ // of the same library without colliding.
16
+ //
17
+ // This is dependency *isolation*, not security isolation — scripts still run
18
+ // as the server user. Real isolation needs a container per run; see
19
+ // SPEC.md → Execution environments.
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import { dataRoot } from "./paths.js";
24
+ import crypto from "node:crypto";
25
+ import { spawnSync } from "node:child_process";
26
+ /**
27
+ * What may be handed to `pip install`, and what may be handed to `npm install`.
28
+ *
29
+ * These are argument-injection guards before they are validators. Both
30
+ * installers take options in the same argv as their operands, and the previous
31
+ * pattern (`[\w.@/-]+`) admitted a leading dash — so `packages: ["--index-url",
32
+ * "http://elsewhere/simple", "pandas"]` in an agent's frontmatter was a valid
33
+ * declaration that quietly moved the whole install to another index. Anchored,
34
+ * and a requirement must begin with a letter or a digit.
35
+ *
36
+ * The version half is PEP 440 shaped: a comparator and a version, optionally
37
+ * several comma-separated. The old pattern allowed one comparator CHARACTER,
38
+ * which meant `pandas>2` passed while `pandas>=2` and `pandas==2.1.4` — the
39
+ * two forms anyone actually writes — were dropped without a word.
40
+ */
41
+ const PIP_NAME = String.raw `[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9._,-]+\])?`;
42
+ const PIP_SPECIFIER = String.raw `(==|!=|<=|>=|~=|<|>)[A-Za-z0-9._*+!-]+`;
43
+ const PIP_REQUIREMENT = new RegExp(`^${PIP_NAME}(${PIP_SPECIFIER}(,${PIP_SPECIFIER})*)?$`);
44
+ /** npm's own shape: an optional @scope, then a name, then an optional @range. */
45
+ const NPM_REQUIREMENT = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(@[A-Za-z0-9._^~*>=< |-]+)?$/;
46
+ export function parseRuntime(raw) {
47
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
48
+ return null;
49
+ const e = raw;
50
+ const rejected = [];
51
+ const list = (v, ok) => Array.isArray(v)
52
+ ? v.map(String).filter((entry) => {
53
+ const s = entry.trim();
54
+ if (ok.test(s))
55
+ return true;
56
+ if (s)
57
+ rejected.push(s);
58
+ return false;
59
+ })
60
+ : [];
61
+ const spec = {
62
+ python: typeof e.python === "string" || typeof e.python === "boolean" ? e.python : undefined,
63
+ packages: list(e.packages ?? e.pip, PIP_REQUIREMENT),
64
+ node: typeof e.node === "string" || typeof e.node === "boolean" ? e.node : undefined,
65
+ npm: list(e.npm, NPM_REQUIREMENT),
66
+ };
67
+ if (rejected.length)
68
+ spec.rejected = rejected;
69
+ const wantsSomething = spec.python !== undefined || spec.node !== undefined || spec.packages.length || spec.npm.length;
70
+ return wantsSomething ? spec : null;
71
+ }
72
+ /**
73
+ * Several declarations, one environment.
74
+ *
75
+ * A tool declares what ITS program needs — `runtime: { packages: [requests] }`
76
+ * in tool.md — because the tool is the unit of code and its dependencies
77
+ * belong beside it, not in every agent that grants it. An agent that grants
78
+ * three Python tools gets one venv holding the union. Version pins are kept
79
+ * verbatim; if two tools pin the same package differently, pip is the one to
80
+ * say so, loudly, at build time — better than one of them silently winning.
81
+ */
82
+ export function mergeRuntimes(...specs) {
83
+ const present = specs.filter((s) => Boolean(s));
84
+ if (present.length === 0)
85
+ return null;
86
+ if (present.length === 1)
87
+ return present[0];
88
+ const pick = (key) => {
89
+ // A version pin beats a bare `true`; the first pin wins.
90
+ const pinned = present.map((s) => s[key]).find((v) => typeof v === "string");
91
+ if (pinned !== undefined)
92
+ return pinned;
93
+ return present.some((s) => s[key] === true) ? true : undefined;
94
+ };
95
+ const uniq = (xs) => [...new Set(xs)];
96
+ const merged = {
97
+ python: pick("python"),
98
+ packages: uniq(present.flatMap((s) => s.packages)),
99
+ node: pick("node"),
100
+ npm: uniq(present.flatMap((s) => s.npm)),
101
+ };
102
+ const rejected = uniq(present.flatMap((s) => s.rejected ?? []));
103
+ if (rejected.length)
104
+ merged.rejected = rejected;
105
+ return merged;
106
+ }
107
+ export function fingerprint(spec) {
108
+ const canonical = JSON.stringify({
109
+ python: spec.python ?? null,
110
+ packages: [...spec.packages].sort(),
111
+ node: spec.node ?? null,
112
+ npm: [...spec.npm].sort(),
113
+ });
114
+ return crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 16);
115
+ }
116
+ /**
117
+ * A tenant name that is safe as one path segment. Tenants come from account
118
+ * ids rather than user input, but this directory is also handed to `docker -v`
119
+ * and to a k8s `subPath`, where a `..` would escape into another tenant's
120
+ * cache — so it is checked at the boundary rather than assumed upstream.
121
+ */
122
+ export function safeTenantSegment(tenant) {
123
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(tenant) && tenant !== "." && tenant !== ".."
124
+ ? tenant
125
+ : null;
126
+ }
127
+ const EMPTY = { interpreters: {}, env: {}, log: [], error: null };
128
+ /**
129
+ * Run an installer and always be able to say why it failed.
130
+ *
131
+ * `spawnSync` reports three different failures and only one of them writes to
132
+ * stdout or stderr. A command that cannot be spawned at all — not installed,
133
+ * not on PATH — sets `error` and leaves both streams null; one killed by the
134
+ * timeout sets `signal`; only a command that ran and exited non-zero has
135
+ * output to quote. Reporting the streams alone produced the least useful
136
+ * message a build can give: "npm install failed: " with nothing after it.
137
+ */
138
+ function run(cmd, args, cwd, timeoutMs = 300_000, extraEnv = {}) {
139
+ const res = spawnSync(cmd, args, {
140
+ cwd,
141
+ timeout: timeoutMs,
142
+ encoding: "utf8",
143
+ env: { ...process.env, ...extraEnv },
144
+ });
145
+ const out = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim();
146
+ if (res.status === 0)
147
+ return { ok: true, out };
148
+ const why = res.error
149
+ ? `could not run \`${cmd}\` — ${res.error.message}`
150
+ : res.signal
151
+ ? `\`${cmd}\` was killed by ${res.signal}${res.signal === "SIGTERM" ? ` (the ${Math.round(timeoutMs / 1000)}s limit)` : ""}`
152
+ : `\`${cmd}\` exited ${res.status}`;
153
+ // The reason first, then whatever it managed to say. Never just the output,
154
+ // because the output is empty in exactly the cases that are hardest to
155
+ // diagnose from a distance.
156
+ return { ok: false, out: out ? `${why}\n${out}` : why };
157
+ }
158
+ /** Wire an already-built root up, without installing anything. */
159
+ function wire(root, spec, note) {
160
+ const interpreters = {};
161
+ const env = {};
162
+ const venvPython = path.join(root, "venv", "bin", "python");
163
+ const nodeModules = path.join(root, "node_modules");
164
+ if (wantsPython(spec) && fs.existsSync(venvPython)) {
165
+ interpreters[".py"] = venvPython;
166
+ env.VIRTUAL_ENV = path.join(root, "venv");
167
+ }
168
+ if (wantsNode(spec) && fs.existsSync(nodeModules))
169
+ env.NODE_PATH = nodeModules;
170
+ // A `.ready` root that cannot actually satisfy the declaration is worse than
171
+ // no cache: it reports "cached", wires nothing, and the failure surfaces
172
+ // much later as "Cannot find package 'x'" inside someone's script — which
173
+ // reads as a broken tool, not a broken runtime. Only packages that were
174
+ // asked for by name are checked: `node: true` with no npm list installs
175
+ // nothing and legitimately has no node_modules.
176
+ const missing = [];
177
+ if (spec.packages.length > 0 && !fs.existsSync(venvPython))
178
+ missing.push("the python venv");
179
+ if (spec.npm.length > 0 && !fs.existsSync(nodeModules))
180
+ missing.push("node_modules");
181
+ if (missing.length > 0) {
182
+ return {
183
+ interpreters,
184
+ env,
185
+ log: [note],
186
+ error: `${note.replace(/: .*$/, "")}: marked ready but ${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} missing — the cache entry is incomplete. Delete ${root} to rebuild it.`,
187
+ };
188
+ }
189
+ return { interpreters, env, log: [note], error: null };
190
+ }
191
+ const wantsPython = (s) => s.python !== undefined || s.packages.length > 0;
192
+ const wantsNode = (s) => s.node !== undefined || s.npm.length > 0;
193
+ /** How long a build may hold the claim, and therefore how long another step
194
+ * will wait on it, before it is treated as abandoned. Longer than the 300s
195
+ * install timeout, so a slow-but-live build is never stolen. Env-overridable
196
+ * because an operator who has seen numpy compile knows better than this
197
+ * default does — and because tests cannot spend six minutes proving it. */
198
+ function buildTimeoutMs() {
199
+ const raw = Number(process.env.FOLDRUN_RUNTIME_BUILD_TIMEOUT_MS);
200
+ return Number.isFinite(raw) && raw > 0 ? raw : 6 * 60_000;
201
+ }
202
+ /** Block without spinning. prepareRuntime is synchronous by contract — every
203
+ * caller is mid-spawn — so waiting cannot be done with a promise. */
204
+ function sleepSync(ms) {
205
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
206
+ }
207
+ /**
208
+ * Claim the right to build `root`, or discover someone else holds it.
209
+ *
210
+ * The cache became shared the moment it started surviving the container, and
211
+ * two steps of the same account with the same dependencies now start at the
212
+ * same instant routinely — the platform runs four at a time. Both would run
213
+ * `python -m venv` into one directory and pip into it concurrently, and the
214
+ * loser's half-written venv is not a slow run, it is a corrupt cache that
215
+ * every later step inherits. `mkdir` is the lock because it is atomic on
216
+ * every filesystem this runs on; O_EXCL on a file would do as well.
217
+ */
218
+ function claimBuild(root) {
219
+ const lock = path.join(root, ".building");
220
+ try {
221
+ fs.mkdirSync(lock);
222
+ return true;
223
+ }
224
+ catch {
225
+ // Held — unless whoever held it died. A crashed build leaves the marker
226
+ // behind forever, and without this every later step would wait the full
227
+ // timeout and then build privately, permanently.
228
+ try {
229
+ if (Date.now() - fs.statSync(lock).mtimeMs > buildTimeoutMs()) {
230
+ fs.rmSync(lock, { recursive: true, force: true });
231
+ fs.mkdirSync(lock);
232
+ return true;
233
+ }
234
+ }
235
+ catch {
236
+ // Someone else won the steal. Wait for them like any other holder.
237
+ }
238
+ return false;
239
+ }
240
+ }
241
+ /** Wait for another process's build, up to the point where it is abandoned. */
242
+ function awaitReady(ready) {
243
+ const until = Date.now() + buildTimeoutMs();
244
+ while (Date.now() < until) {
245
+ if (fs.existsSync(ready))
246
+ return true;
247
+ sleepSync(250);
248
+ }
249
+ return false;
250
+ }
251
+ // Build (or reuse) the environment for one runtime declaration.
252
+ export function prepareRuntime(tenant, spec) {
253
+ if (!spec)
254
+ return EMPTY;
255
+ const fp = fingerprint(spec);
256
+ const shared = path.join(dataRoot(), tenant, ".runtimes", fp);
257
+ // Loud, and before anything else: a dropped requirement surfaces later as
258
+ // an import error inside a script, which points at the wrong thing entirely.
259
+ const dropped = spec.rejected?.length
260
+ ? [`runtime ${fp}: ignored invalid requirement(s): ${spec.rejected.join(", ")}`]
261
+ : [];
262
+ // Already built: wire it up and skip the install. The whole point of the
263
+ // cache — on the hosted path this directory is a mounted volume, so the
264
+ // hit rate across a run is close to one.
265
+ if (fs.existsSync(path.join(shared, ".ready"))) {
266
+ const hit = wire(shared, spec, `runtime ${fp}: cached`);
267
+ return { ...hit, log: [...dropped, ...hit.log] };
268
+ }
269
+ fs.mkdirSync(shared, { recursive: true });
270
+ let root = shared;
271
+ const held = claimBuild(shared);
272
+ if (!held) {
273
+ // A concurrent step is building exactly this. Waiting for it beats
274
+ // duplicating it — the work is identical and it is already underway.
275
+ if (awaitReady(path.join(shared, ".ready"))) {
276
+ const hit = wire(shared, spec, `runtime ${fp}: cached (built by a concurrent step)`);
277
+ return { ...hit, log: [...dropped, ...hit.log] };
278
+ }
279
+ // It never finished. Build privately instead: slower and uncached, but a
280
+ // step that runs is worth more than a cache entry, and a wedged lock must
281
+ // never be able to stop work. In the tmpdir, not the cache — a private
282
+ // build is by definition not worth keeping, and leaving these beside the
283
+ // real entries would grow a directory nothing ever prunes.
284
+ root = fs.mkdtempSync(path.join(os.tmpdir(), `foldrun-runtime-${fp}-`));
285
+ }
286
+ const ready = path.join(root, ".ready");
287
+ const interpreters = {};
288
+ const env = {};
289
+ const log = [...dropped];
290
+ const venvPython = path.join(root, "venv", "bin", "python");
291
+ const nodeModules = path.join(root, "node_modules");
292
+ const wantsPy = wantsPython(spec);
293
+ const wantsNd = wantsNode(spec);
294
+ try {
295
+ if (wantsPy) {
296
+ const base = typeof spec.python === "string" ? `python${spec.python}` : "python3";
297
+ const exe = [base, "python3"].find((c) => run("command", ["-v", c], root).ok || run(c, ["--version"], root).ok);
298
+ if (!exe) {
299
+ return { ...EMPTY, error: `python interpreter "${base}" is not available on this host` };
300
+ }
301
+ // A sealed venv, never --system-site-packages. Inheriting the image's
302
+ // packages would make a warm start cheaper, and it was measured on the
303
+ // production box on 2026-08-29: a venv that shadows a baked `pandas` with
304
+ // a pinned one keeps the *system* numpy underneath it, and the two are not
305
+ // ABI-compatible — `pandas<2` on top of numpy 2 dies at import with
306
+ // "numpy.dtype size changed". A pin that silently produces a broken
307
+ // interpreter is worse than any install it saves.
308
+ const made = run(exe, ["-m", "venv", path.join(root, "venv")], root);
309
+ if (!made.ok)
310
+ return { ...EMPTY, error: `failed to create venv: ${made.out.slice(0, 300)}` };
311
+ log.push(`runtime ${fp}: created venv (${exe})`);
312
+ if (spec.packages.length) {
313
+ const pip = path.join(root, "venv", "bin", "pip");
314
+ const installed = run(pip, ["install", "--disable-pip-version-check", "-q", ...spec.packages], root);
315
+ if (!installed.ok) {
316
+ return { ...EMPTY, error: `pip install failed: ${installed.out.slice(-500)}` };
317
+ }
318
+ log.push(`runtime ${fp}: installed ${spec.packages.join(", ")}`);
319
+ }
320
+ interpreters[".py"] = venvPython;
321
+ env.VIRTUAL_ENV = path.join(root, "venv");
322
+ }
323
+ if (wantsNd) {
324
+ if (spec.npm.length) {
325
+ fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: `foldrun-runtime-${fp}`, private: true }, null, 2));
326
+ // Not --silent: it sets npm's loglevel to silent, which suppresses the
327
+ // explanation along with the noise. A build log nobody reads is cheaper
328
+ // than a failure nobody can explain.
329
+ const installed = run("npm", ["install", "--no-fund", "--no-audit", "--no-progress", ...spec.npm], root, 300_000, {
330
+ // npm keeps its cache under $HOME/.npm. A sandbox runs as a user
331
+ // with no home directory, so that resolves to /.npm, which it may
332
+ // not create — and npm fails with ENOENT before it fetches
333
+ // anything. Every tool declaring `npm:` failed this way, on every
334
+ // executor, which is a long time for a feature to be broken
335
+ // quietly. The cache belongs beside the runtime it is building.
336
+ HOME: root,
337
+ npm_config_cache: path.join(root, ".npm-cache"),
338
+ });
339
+ if (!installed.ok) {
340
+ return { ...EMPTY, error: `npm install failed: ${installed.out.slice(-500)}` };
341
+ }
342
+ log.push(`runtime ${fp}: installed ${spec.npm.join(", ")}`);
343
+ }
344
+ if (fs.existsSync(nodeModules))
345
+ env.NODE_PATH = nodeModules;
346
+ }
347
+ fs.writeFileSync(ready, new Date().toISOString());
348
+ return { interpreters, env, log, error: null };
349
+ }
350
+ finally {
351
+ // Whatever happened — built, failed, threw — the claim is released. A
352
+ // failed build leaves no `.ready`, so the next step retries it rather
353
+ // than inheriting a half-built environment.
354
+ if (held)
355
+ fs.rmSync(path.join(shared, ".building"), { recursive: true, force: true });
356
+ }
357
+ }
@@ -0,0 +1,62 @@
1
+ import { z } from "zod";
2
+ import { createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
3
+ export interface ScriptSpec {
4
+ name: string;
5
+ /** Path relative to the agent dir — or empty when `code` carries the
6
+ * script inline. Exactly one of the two is the program. */
7
+ run: string;
8
+ description: string;
9
+ args: Record<string, string>;
10
+ interpreter?: string;
11
+ /** Seconds this script may run — for the crawl that legitimately takes
12
+ * five minutes. Defaults to 120, capped at 600: a limit an author can
13
+ * raise is a budget; one they can remove is a hang. */
14
+ timeout?: number;
15
+ /** The script itself, when the tool is a single markdown file with its
16
+ * code in a fenced block. Materialised to a file at call time — the
17
+ * execution path is the same either way, only where the bytes start
18
+ * differs. */
19
+ code?: string;
20
+ /** Extension for the materialised file, from the fence's language tag —
21
+ * it picks the interpreter the same way a run: path's extension does. */
22
+ codeExt?: string;
23
+ /** `secrets: proxied` — this program sends its secrets through the egress
24
+ * proxy (it reads FOLDRUN_EGRESS and sends ${NAME} placeholders), so the
25
+ * step need not materialise real values into the sandbox for it. The
26
+ * default, `materialised`, is a program that reads values from its
27
+ * environment and needs them real — a browser seeding cookies. */
28
+ secrets?: "proxied" | "materialised";
29
+ }
30
+ export declare function parseScripts(raw: unknown): ScriptSpec[];
31
+ export declare function commandFor(spec: ScriptSpec, abs: string, overrides?: Record<string, string>): {
32
+ cmd: string;
33
+ args: string[];
34
+ };
35
+ export interface ExecutionContext {
36
+ /** "docker" nests each script in its own container; "sandbox" spawns it
37
+ * directly inside the isolation the step already has. */
38
+ executor: "docker" | "sandbox";
39
+ image: string;
40
+ /** Extra read-only mounts for shared/library scripts. */
41
+ mounts: Record<string, string>;
42
+ /** Whether scripts may reach the network. */
43
+ network: boolean;
44
+ }
45
+ export interface ScriptToolResult {
46
+ server: ReturnType<typeof createSdkMcpServer> | null;
47
+ toolNames: string[];
48
+ promptLines: string[];
49
+ drainLog: () => string[];
50
+ }
51
+ export declare function resolveRunPath(agentDir: string, run: string, libraryScripts: string): string;
52
+ /**
53
+ * The schema a script's arguments are offered to the model under. Every
54
+ * argument becomes a command-line flag, so it is text on the way out — but a
55
+ * model reading "depth: how many results (default 20)" sends 10, the number,
56
+ * and the SDK refuses the call before the handler's String() ever runs; on
57
+ * the fast tier the model then announced a retry and ended its turn. A
58
+ * number or a boolean is accepted and written out as the text it would
59
+ * have been.
60
+ */
61
+ export declare function scriptArgShape(args: Record<string, string>): Record<string, z.ZodTypeAny>;
62
+ export declare function buildScriptTools(agentDir: string, scripts: ScriptSpec[], env: Record<string, string>, libraryScripts?: string, interpreters?: Record<string, string>, exec?: ExecutionContext | null): ScriptToolResult;