@foldrun/core 0.6.0 → 1.0.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.
- package/README.md +14 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/agent-import.d.ts +21 -0
- package/dist/src/agent-import.js +116 -0
- package/dist/src/api-tools.js +14 -2
- package/dist/src/approvals.d.ts +8 -1
- package/dist/src/approvals.js +10 -5
- package/dist/src/completions.js +1 -0
- package/dist/src/confine.d.ts +1 -0
- package/dist/src/confine.js +33 -4
- package/dist/src/deploy.d.ts +9 -0
- package/dist/src/deploy.js +31 -3
- package/dist/src/evals.js +22 -5
- package/dist/src/hooks.d.ts +114 -0
- package/dist/src/hooks.js +370 -0
- package/dist/src/host-env.d.ts +14 -0
- package/dist/src/host-env.js +26 -0
- package/dist/src/install-helper.d.ts +17 -0
- package/dist/src/install-helper.js +134 -0
- package/dist/src/install-tool.d.ts +128 -0
- package/dist/src/install-tool.js +133 -0
- package/dist/src/kinds.d.ts +1 -1
- package/dist/src/kinds.js +26 -0
- package/dist/src/library.d.ts +1 -1
- package/dist/src/library.js +17 -1
- package/dist/src/mail.js +1 -1
- package/dist/src/model-credential.d.ts +16 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/model-env.d.ts +17 -0
- package/dist/src/model-env.js +57 -0
- package/dist/src/notify.d.ts +8 -0
- package/dist/src/notify.js +10 -1
- package/dist/src/package.d.ts +67 -0
- package/dist/src/package.js +594 -0
- package/dist/src/platform.d.ts +5 -0
- package/dist/src/platform.js +1 -0
- package/dist/src/provider-health.js +3 -0
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +21 -1
- package/dist/src/run-container.d.ts +10 -0
- package/dist/src/run-container.js +72 -14
- package/dist/src/runner.d.ts +205 -0
- package/dist/src/runner.js +273 -36
- package/dist/src/runtime.d.ts +77 -0
- package/dist/src/runtime.js +230 -5
- package/dist/src/script-tools.d.ts +2 -0
- package/dist/src/script-tools.js +3 -1
- package/dist/src/secrets.d.ts +5 -0
- package/dist/src/secrets.js +37 -6
- package/dist/src/starter.d.ts +12 -0
- package/dist/src/starter.js +38 -4
- package/dist/src/step-exec.d.ts +21 -2
- package/dist/src/step-exec.js +165 -7
- package/dist/src/storage.d.ts +7 -1
- package/dist/src/storage.js +14 -1
- package/dist/src/store.d.ts +31 -1
- package/dist/src/store.js +90 -8
- package/dist/src/tool-names.d.ts +3 -1
- package/dist/src/tool-names.js +4 -2
- package/dist/src/tool-test.js +8 -1
- package/dist/src/zip.d.ts +17 -0
- package/dist/src/zip.js +184 -0
- package/index.ts +3 -0
- package/package.json +18 -1
- package/src/agent-import.ts +154 -0
- package/src/api-tools.ts +16 -1
- package/src/approvals.ts +10 -3
- package/src/completions.ts +1 -0
- package/src/confine.ts +36 -5
- package/src/deploy.ts +28 -3
- package/src/evals.ts +21 -3
- package/src/hooks.ts +406 -0
- package/src/host-env.ts +27 -0
- package/src/install-helper.ts +143 -0
- package/src/install-tool.ts +154 -0
- package/src/kinds.ts +28 -0
- package/src/library.ts +18 -2
- package/src/mail.ts +1 -1
- package/src/model-credential.ts +41 -0
- package/src/model-env.ts +66 -0
- package/src/notify.ts +11 -1
- package/src/package.ts +664 -0
- package/src/platform.ts +6 -0
- package/src/provider-health.ts +3 -0
- package/src/providers.ts +27 -1
- package/src/run-container.ts +75 -14
- package/src/runner.ts +279 -36
- package/src/runtime.ts +235 -5
- package/src/script-tools.ts +5 -1
- package/src/secrets.ts +34 -6
- package/src/starter.ts +44 -4
- package/src/step-exec.ts +171 -11
- package/src/storage.ts +13 -0
- package/src/store.ts +89 -7
- package/src/tool-names.ts +4 -2
- package/src/tool-test.ts +8 -1
- package/src/zip.ts +189 -0
package/dist/src/runner.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// group receives every prior result, labeled per agent. Optional steps ("2?")
|
|
3
3
|
// fail without failing the flow. A direct agent run is a one-step ad-hoc
|
|
4
4
|
// flow, so there is a single execution path.
|
|
5
|
+
import { hookNames } from "./hooks.js";
|
|
5
6
|
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
6
8
|
import path from "node:path";
|
|
7
9
|
import matter from "gray-matter";
|
|
8
10
|
import { knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor } from "./step-exec.js";
|
|
@@ -11,8 +13,8 @@ import { resolveAgentPath } from "./confine.js";
|
|
|
11
13
|
import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.js";
|
|
12
14
|
import { eventUrl } from "./webhook.js";
|
|
13
15
|
import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
|
|
14
|
-
import { hostSafeEnv } from "./host-env.js";
|
|
15
|
-
import { resolveModelCredential, credentialLine } from "./model-credential.js";
|
|
16
|
+
import { hostSafeEnv, platformApiUrl } from "./host-env.js";
|
|
17
|
+
import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken } from "./model-credential.js";
|
|
16
18
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
|
|
17
19
|
import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
|
|
18
20
|
import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote } from "./refusal.js";
|
|
@@ -42,8 +44,10 @@ import { startTranslator, translatorSpecFor } from "./translator.js";
|
|
|
42
44
|
import { providerPreset } from "./providers.js";
|
|
43
45
|
import { buildScriptTools, parseScripts } from "./script-tools.js";
|
|
44
46
|
import { libraryDir, libraryTools, libraryMemoryIndex } from "./library.js";
|
|
45
|
-
import { fingerprint, mergeRuntimes, parseRuntime, prepareRuntime } from "./runtime.js";
|
|
46
|
-
import {
|
|
47
|
+
import { fingerprint, mergeRuntimes, parseRuntime, prepareRuntime, stepInstallEnv } from "./runtime.js";
|
|
48
|
+
import { buildInstallTool, INSTALL_TOOL } from "./install-tool.js";
|
|
49
|
+
import { dataRoot } from "./paths.js";
|
|
50
|
+
import { materializeFiles, harvestFiles, storageBaseline } from "./storage.js";
|
|
47
51
|
import { chooseExecutor, ensureImage } from "./container.js";
|
|
48
52
|
import { stampBundle } from "./okf.js";
|
|
49
53
|
import { readBrowseSettings, readSearchSettings, resolveSearch, searchSettingsEnv, webConfig, browseSessionProblems } from "./providers.js";
|
|
@@ -614,7 +618,7 @@ export function resolveDocLinks(text, workspaceRoot) {
|
|
|
614
618
|
return hit ? `\`${hit}\`` : whole;
|
|
615
619
|
});
|
|
616
620
|
}
|
|
617
|
-
function agentContext(agentDir, tenant, tags = [],
|
|
621
|
+
export function agentContext(agentDir, tenant, tags = [],
|
|
618
622
|
/** Who is running, so scripts can stamp what they write with its provenance. */
|
|
619
623
|
identity = {},
|
|
620
624
|
/** The flow's own `timezone:`, when this step belongs to one. Between the
|
|
@@ -1000,7 +1004,10 @@ grantsOnly = false) {
|
|
|
1000
1004
|
//
|
|
1001
1005
|
// The URL sits in git; the token is a ${SECRET} resolved server-side. An
|
|
1002
1006
|
// agent's own block wins over the workspace's, like everything else here.
|
|
1003
|
-
|
|
1007
|
+
// Then the account's own model key (Settings → Model), when it set one:
|
|
1008
|
+
// an account that brings its key need not write a block anywhere.
|
|
1009
|
+
const fileBlock = front.provider ?? workspaceFrontmatter(agentDir, tenant).provider;
|
|
1010
|
+
const providerBlock = fileBlock ?? accountModelBlock((name) => getSecret(tenant, name)?.value ?? null);
|
|
1004
1011
|
let providerEnv = {};
|
|
1005
1012
|
let providerLabel = null;
|
|
1006
1013
|
// Secret values pulled into the provider block, name → value. Kept apart
|
|
@@ -1151,6 +1158,9 @@ grantsOnly = false) {
|
|
|
1151
1158
|
// one — the `websearch` library tool reads it. Not a secret; rides with
|
|
1152
1159
|
// the clock so it reaches scripts, the sandbox and verify alike.
|
|
1153
1160
|
...(process.env.FOLDRUN_SEARCH_URL ? { FOLDRUN_SEARCH_URL: process.env.FOLDRUN_SEARCH_URL } : {}),
|
|
1161
|
+
// Where this install's own API answers (host-env.ts platformApiUrl), so a
|
|
1162
|
+
// script calls `$FOLDRUN_API_URL/workspaces/…` instead of naming a host.
|
|
1163
|
+
...(platformApiUrl() ? { FOLDRUN_API_URL: platformApiUrl() } : {}),
|
|
1154
1164
|
// Whose index our web search asks when the agent named a search API.
|
|
1155
1165
|
// Unset means the account's own engine. The name, never the key.
|
|
1156
1166
|
...(searchChoice.shape === "direct" && searchChoice.provider
|
|
@@ -1205,6 +1215,7 @@ grantsOnly = false) {
|
|
|
1205
1215
|
...(browseSettings.webgpu ? { FOLDRUN_BROWSER_WEBGPU: "1" } : {}),
|
|
1206
1216
|
...(browseSettings.ignore_https_errors ? { FOLDRUN_BROWSER_IGNORE_HTTPS_ERRORS: "1" } : {}),
|
|
1207
1217
|
...(browseSettings.state_key ? { FOLDRUN_BROWSER_STATE_KEY: browseSettings.state_key } : {}),
|
|
1218
|
+
...(browseSettings.state ? { FOLDRUN_BROWSER_STATE: browseSettings.state } : {}),
|
|
1208
1219
|
...(browseSettings.video ? { FOLDRUN_BROWSER_VIDEO: "1" } : {}),
|
|
1209
1220
|
...(browseSettings.live_view === false ? { FOLDRUN_BROWSER_LIVE_VIEW: "0" } : {}),
|
|
1210
1221
|
};
|
|
@@ -1228,7 +1239,18 @@ grantsOnly = false) {
|
|
|
1228
1239
|
parts.push(`# Clock\n\nToday is ${clockEnv.FOLDRUN_DATE} (${timezone}). Use this date for anything you ` +
|
|
1229
1240
|
`write or check — it is the workspace's calendar, not the container's clock.`);
|
|
1230
1241
|
if (scriptTools.promptLines.length) {
|
|
1231
|
-
parts.push(`# Tools from scripts\n\nCall these directly — they run workspace scripts for you
|
|
1242
|
+
parts.push(`# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
|
|
1243
|
+
`\`mcp__foldrun_scripts__<name>\` (\`web\` is \`mcp__foldrun_scripts__web\`); a bare name is refused:\n\n${scriptTools.promptLines.join("\n")}`);
|
|
1244
|
+
}
|
|
1245
|
+
// The web tool's own description lists every action, so an agent whose
|
|
1246
|
+
// search or fetch a model provider answers called web action=search,
|
|
1247
|
+
// was refused, and only then used WebSearch — eight refusals in one
|
|
1248
|
+
// reddit-desk validator step on 6 Oct 2026. Say it before the first call.
|
|
1249
|
+
if (builtins.length) {
|
|
1250
|
+
const named = builtins.map((b) => b.split("="));
|
|
1251
|
+
parts.push(`# Web search and fetch on this run\n\n` +
|
|
1252
|
+
named.map(([action, tool]) => `- To ${action}, call **${tool}** directly. \`web\` with action=${action} is switched off on this run.`).join("\n") +
|
|
1253
|
+
`\n\nThe web tool's other actions work as usual.`);
|
|
1232
1254
|
}
|
|
1233
1255
|
if (declared.length) {
|
|
1234
1256
|
parts.push(`# Secrets\n\nAvailable to your scripts as environment variables: ${declared.join(", ")}. ` +
|
|
@@ -1237,7 +1259,13 @@ grantsOnly = false) {
|
|
|
1237
1259
|
parts.push("Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
|
|
1238
1260
|
"what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
|
|
1239
1261
|
"for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
|
|
1240
|
-
"directory a tool made outside the workspace, such as a checkout, is reached only through that tool."
|
|
1262
|
+
"directory a tool made outside the workspace, such as a checkout, is reached only through that tool. " +
|
|
1263
|
+
// 45 refusals in 34 runs (6–7 Oct 2026), nearly all an agent rewriting
|
|
1264
|
+
// a plan or draft an earlier run left: the Write tool refuses a file
|
|
1265
|
+
// that exists and has not been Read in this step, before any hook of
|
|
1266
|
+
// ours can see the call. Said once here instead of learned per step.
|
|
1267
|
+
"To replace a file that already exists, Read it first — Write refuses a file you have not Read in this step (a new file needs no Read). " +
|
|
1268
|
+
"Built-in tools are capitalised: Read, Write, Edit, Glob, Grep, Bash.");
|
|
1241
1269
|
// The run's one-line summary is the first meaningful line of the last step
|
|
1242
1270
|
// that produced a result — read, never declared (runSummary in store.ts).
|
|
1243
1271
|
// That convention was load-bearing and unstated: it decides the runs-list
|
|
@@ -1299,6 +1327,13 @@ grantsOnly = false) {
|
|
|
1299
1327
|
// names it (FOLDRUN_WEB_BUILTIN) rather than asking our engine.
|
|
1300
1328
|
if (toolName === "web" && !ref.linked) {
|
|
1301
1329
|
allowed.push(...providerWebTools);
|
|
1330
|
+
// web writes what it found to files and says "Read that file" — a
|
|
1331
|
+
// screenshot, a crawl, a page saved whole. An agent granted web alone
|
|
1332
|
+
// was told to Read and refused it (blog-desk claim-checker, 7 Oct
|
|
1333
|
+
// 2026: "Read is disabled for this session"). Reading is confined to
|
|
1334
|
+
// the workspace like every file tool; disallowedTools still removes it.
|
|
1335
|
+
if (!allowed.includes("Read"))
|
|
1336
|
+
allowed.push("Read");
|
|
1302
1337
|
continue;
|
|
1303
1338
|
}
|
|
1304
1339
|
// Resolution order: built-in group, exact SDK name, then your own tools.
|
|
@@ -1328,6 +1363,9 @@ grantsOnly = false) {
|
|
|
1328
1363
|
if (available[toolName])
|
|
1329
1364
|
shadowed.push(toolName);
|
|
1330
1365
|
}
|
|
1366
|
+
else if (toolName === "install") {
|
|
1367
|
+
// Granted to every agent anyway (below); naming it is harmless.
|
|
1368
|
+
}
|
|
1331
1369
|
else if (BUILTIN_TOOLS.has(toolName)) {
|
|
1332
1370
|
allowed.push(toolName);
|
|
1333
1371
|
if (available[toolName])
|
|
@@ -1391,6 +1429,10 @@ grantsOnly = false) {
|
|
|
1391
1429
|
// what it exposes, which is the unit a person actually reasons about anyway.
|
|
1392
1430
|
for (const name of mcpNames)
|
|
1393
1431
|
allowed.push(`mcp__${name}`, `mcp__${name}__*`);
|
|
1432
|
+
// install — every agent may install what it finds it needs mid-step (apt,
|
|
1433
|
+
// pip, npm), declared or not (install-tool.ts). Granted by default; taken
|
|
1434
|
+
// away by disallowedTools: [install], and absent in plan mode (read-only).
|
|
1435
|
+
allowed.push(INSTALL_TOOL);
|
|
1394
1436
|
// Claude Code's spellings, so an agent written for it mostly runs here.
|
|
1395
1437
|
// `permissionMode: plan` is its read-only mode; ours is the `read` group.
|
|
1396
1438
|
if (front.permissionMode === "plan") {
|
|
@@ -1398,6 +1440,8 @@ grantsOnly = false) {
|
|
|
1398
1440
|
allowed.push(...TOOL_MAP.read, ...apiTools.toolNames, ...scriptTools.toolNames);
|
|
1399
1441
|
}
|
|
1400
1442
|
const denied = refNames(front.disallowedTools);
|
|
1443
|
+
if (denied.includes("install"))
|
|
1444
|
+
denied.push(INSTALL_TOOL);
|
|
1401
1445
|
const finalAllowed = allowed.filter((t) => !denied.includes(t));
|
|
1402
1446
|
return {
|
|
1403
1447
|
front,
|
|
@@ -1600,6 +1644,40 @@ async function terminalAsk(question, options) {
|
|
|
1600
1644
|
rl.close();
|
|
1601
1645
|
}
|
|
1602
1646
|
}
|
|
1647
|
+
/**
|
|
1648
|
+
* What the person at the gate said, framed for the step's prompt — or "".
|
|
1649
|
+
*
|
|
1650
|
+
* The note is the freshest instruction the step has — later than the flow
|
|
1651
|
+
* file, aimed at this exact run. When the gate asked a question (ask:), the
|
|
1652
|
+
* note IS the answer and is framed as one.
|
|
1653
|
+
*
|
|
1654
|
+
* Both halves go through resolveDocLinks for the same reason the
|
|
1655
|
+
* instruction does: this is authored text on its way to becoming a prompt,
|
|
1656
|
+
* and the rule is that ALL of it expands. A person answering "which list?"
|
|
1657
|
+
* with [[dead-ends]] was sending the model two literal brackets — the one
|
|
1658
|
+
* reference syntax the workspace teaches, and the one place it silently did
|
|
1659
|
+
* nothing. The previous step's results are NOT resolved, deliberately: that
|
|
1660
|
+
* is model output, and a model that happens to emit [[brackets]] must not
|
|
1661
|
+
* thereby name a file.
|
|
1662
|
+
*
|
|
1663
|
+
* An ask: gate approved with no note still says so. It used to add nothing,
|
|
1664
|
+
* so the step never learned its question had been answered: gbp-desk's
|
|
1665
|
+
* rivals sender, told "a plain approval means all offices", saw no answer
|
|
1666
|
+
* at all and stopped BLOCKED (run-muu6r18y-417e, 2026-10-05).
|
|
1667
|
+
*/
|
|
1668
|
+
export function operatorBlock(step, workspaceRoot) {
|
|
1669
|
+
const ask = step.ask ? resolveDocLinks(step.ask, workspaceRoot) : "";
|
|
1670
|
+
if (step.approvalNote) {
|
|
1671
|
+
const note = resolveDocLinks(step.approvalNote, workspaceRoot);
|
|
1672
|
+
return step.ask
|
|
1673
|
+
? `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer:\n${note}\n</operator_answer>`
|
|
1674
|
+
: `\n\n<operator_guidance>\nThe human who approved this step added:\n${note}\n</operator_guidance>`;
|
|
1675
|
+
}
|
|
1676
|
+
if (step.ask && step.approvedAt) {
|
|
1677
|
+
return `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer: they approved it with no note — a plain yes.\n</operator_answer>`;
|
|
1678
|
+
}
|
|
1679
|
+
return "";
|
|
1680
|
+
}
|
|
1603
1681
|
async function runStep(agentDir, tenant, step, context, save, modelOverride, tags = [], effortOverride, runId,
|
|
1604
1682
|
/** What the previous group returned as DATA (an `output: json` step's
|
|
1605
1683
|
* value), beside the prose in `context`. Undefined when nothing did. */
|
|
@@ -1641,6 +1719,7 @@ stepIndex) {
|
|
|
1641
1719
|
// cover a script's own stdout.
|
|
1642
1720
|
let redactions = [];
|
|
1643
1721
|
let fileDir = null;
|
|
1722
|
+
let installDir = null;
|
|
1644
1723
|
let disposeRuntime;
|
|
1645
1724
|
// Can this step's tools act outside the workspace? Set once its context is
|
|
1646
1725
|
// read; the verdict at the end of this function reads it.
|
|
@@ -1784,6 +1863,26 @@ stepIndex) {
|
|
|
1784
1863
|
const line = credentialLine(credential);
|
|
1785
1864
|
push(line.type, line.text);
|
|
1786
1865
|
}
|
|
1866
|
+
// A hosted platform runs only its operator's accounts on its own model
|
|
1867
|
+
// credential; every other account brings its own API key. Refused here,
|
|
1868
|
+
// before a sandbox is rented, with what to do about it — not run on the
|
|
1869
|
+
// operator's key, and not failed later by a provider.
|
|
1870
|
+
if (!platform.platformKeyAllowed(tenant)) {
|
|
1871
|
+
const own = Object.values(providerSecrets).concat(Object.values(providerEnv));
|
|
1872
|
+
if (!providerLabel) {
|
|
1873
|
+
push("error", `no model key: this account runs on its own API key, and none is set. Add one in Settings → Model ` +
|
|
1874
|
+
`(or \`foldrun model set <provider> --key …\`), or write a provider: block in AGENTS.md`);
|
|
1875
|
+
step.status = "failed";
|
|
1876
|
+
save();
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
if (own.some((v) => isClaudeLoginToken(v))) {
|
|
1880
|
+
push("error", "model key: a Claude login token (sk-ant-oat…) cannot be used here — use an API key from your provider");
|
|
1881
|
+
step.status = "failed";
|
|
1882
|
+
save();
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1787
1886
|
for (const w of providerWarnings)
|
|
1788
1887
|
push("error", w);
|
|
1789
1888
|
// A level that wrote a language nobody can read was skipped, not obeyed.
|
|
@@ -1958,28 +2057,7 @@ stepIndex) {
|
|
|
1958
2057
|
// happening to contain brackets.
|
|
1959
2058
|
prompt += `\n\n<event>\nThis step waited for an external event. What arrived:\n${step.eventPayload}\n</event>`;
|
|
1960
2059
|
}
|
|
1961
|
-
|
|
1962
|
-
// The person at the gate said something while letting the run through.
|
|
1963
|
-
// That is the freshest instruction the step has — later than the flow
|
|
1964
|
-
// file, aimed at this exact run. When the gate asked a question
|
|
1965
|
-
// (ask:), the note IS the answer and is framed as one.
|
|
1966
|
-
//
|
|
1967
|
-
// Both halves go through resolveDocLinks for the same reason the
|
|
1968
|
-
// instruction above does: this is authored text on its way to becoming
|
|
1969
|
-
// a prompt, and the rule is that ALL of it expands. A person answering
|
|
1970
|
-
// "which list?" with [[dead-ends]] was sending the model two literal
|
|
1971
|
-
// brackets — the one reference syntax the workspace teaches, and the
|
|
1972
|
-
// one place it silently did nothing.
|
|
1973
|
-
//
|
|
1974
|
-
// What is NOT resolved, deliberately: the previous step's results
|
|
1975
|
-
// above. That is model output, not something a person wrote, and a
|
|
1976
|
-
// model that happens to emit [[brackets]] must not thereby name a file.
|
|
1977
|
-
const ask = step.ask ? resolveDocLinks(step.ask, workspaceRoot) : "";
|
|
1978
|
-
const note = resolveDocLinks(step.approvalNote, workspaceRoot);
|
|
1979
|
-
prompt += step.ask
|
|
1980
|
-
? `\n\n<operator_answer>\nThis step asked a human: ${ask}\nTheir answer:\n${note}\n</operator_answer>`
|
|
1981
|
-
: `\n\n<operator_guidance>\nThe human who approved this step added:\n${note}\n</operator_guidance>`;
|
|
1982
|
-
}
|
|
2060
|
+
prompt += operatorBlock(step, workspaceRoot);
|
|
1983
2061
|
if (step.delegate?.length) {
|
|
1984
2062
|
// The chooser's contract. The allowed set comes from the flow file;
|
|
1985
2063
|
// the runner parses exactly this shape back out of the result, so the
|
|
@@ -2023,6 +2101,9 @@ stepIndex) {
|
|
|
2023
2101
|
// watched on both paths, because a refusal reads the same from a pod
|
|
2024
2102
|
// and from this process.
|
|
2025
2103
|
let lastRefusal = "";
|
|
2104
|
+
// The step's model connection was cut (isModelConnectionLost): the
|
|
2105
|
+
// platform's side going away under the step, not the step failing.
|
|
2106
|
+
let lastDrop = "";
|
|
2026
2107
|
// And WHY it refused, in words, when anything on the way saw the
|
|
2027
2108
|
// headers that say so: the translator here, the egress proxy over
|
|
2028
2109
|
// there. Kept apart from `lastRefusal` — that one decides whether to
|
|
@@ -2031,6 +2112,8 @@ stepIndex) {
|
|
|
2031
2112
|
const pushWatching = (type, text, extra) => {
|
|
2032
2113
|
if (type === "error" && isProviderRefusal(text))
|
|
2033
2114
|
lastRefusal = text;
|
|
2115
|
+
if (type === "error" && isModelConnectionLost(text))
|
|
2116
|
+
lastDrop = text;
|
|
2034
2117
|
const why = refusalFromLine(text);
|
|
2035
2118
|
if (why)
|
|
2036
2119
|
refusalWhy = why;
|
|
@@ -2086,6 +2169,18 @@ stepIndex) {
|
|
|
2086
2169
|
const limitOpts = Object.keys(stepLimits).length ? { limits: stepLimits, toolOwners: limitOwners } : {};
|
|
2087
2170
|
if (limitOpts.limits)
|
|
2088
2171
|
push("info", `limits: ${Object.entries(stepLimits).map(([k, n]) => `${k} ${n}`).join(", ")} — per step; a call past one is refused`);
|
|
2172
|
+
// `hooks:` — the account's, the workspace's, then the agent's own, each
|
|
2173
|
+
// once; resolved and run inside the step (hooks.ts, step-exec.ts).
|
|
2174
|
+
const stepHookNames = [...new Set([
|
|
2175
|
+
...hookNames(readAgentsMd(accountDir(tenant))?.data?.hooks),
|
|
2176
|
+
...hookNames(readAgentsMd(workspaceRootOf(agentDir))?.data?.hooks),
|
|
2177
|
+
...hookNames(front.hooks),
|
|
2178
|
+
])];
|
|
2179
|
+
const hookOpts = stepHookNames.length
|
|
2180
|
+
? { hookNames: stepHookNames, hookBase: { run_id: runId ?? null, step: stepIndex ?? null, agent: step.agent, workspace: path.basename(workspaceRootOf(agentDir)) } }
|
|
2181
|
+
: {};
|
|
2182
|
+
if (stepHookNames.length)
|
|
2183
|
+
push("info", `hooks: ${stepHookNames.join(", ")}`);
|
|
2089
2184
|
const isolation = process.env.FOLDRUN_RUN_ISOLATION;
|
|
2090
2185
|
if (isolatedRun()) {
|
|
2091
2186
|
// The isolated path: the whole loop — model, built-in tools, scripts —
|
|
@@ -2244,6 +2339,7 @@ stepIndex) {
|
|
|
2244
2339
|
schema: resolvedSchema ?? undefined,
|
|
2245
2340
|
maxTurns: step.maxTurns,
|
|
2246
2341
|
...limitOpts,
|
|
2342
|
+
...hookOpts,
|
|
2247
2343
|
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
2248
2344
|
// Outward steps keep a browser of their own: an executor that
|
|
2249
2345
|
// could browse from the slim image through the account's pod
|
|
@@ -2317,6 +2413,7 @@ stepIndex) {
|
|
|
2317
2413
|
lastArgs = a;
|
|
2318
2414
|
return runIsolated(a);
|
|
2319
2415
|
};
|
|
2416
|
+
let attemptFrom = step.events.length;
|
|
2320
2417
|
let outcome = await runTracked(firstArgs);
|
|
2321
2418
|
step.sandbox = null; // whatever happens next starts its own
|
|
2322
2419
|
// A 401 from a token that rotated mid-step is not a broken key: the
|
|
@@ -2355,6 +2452,48 @@ stepIndex) {
|
|
|
2355
2452
|
outcome = await runTracked(again);
|
|
2356
2453
|
outcome = withEarlierTiming(outcome, previous);
|
|
2357
2454
|
}
|
|
2455
|
+
// The model connection was cut under the step — the egress proxy or the
|
|
2456
|
+
// worker went away (6 Oct 2026: a worker restarted every 4.5 minutes and
|
|
2457
|
+
// every desk running failed on "Connection refused"). That is the
|
|
2458
|
+
// platform's fault, so the step runs again, whatever its `retry:` —
|
|
2459
|
+
// unless it can act outside the workspace and its tools already ran:
|
|
2460
|
+
// the post may be up, and a second attempt would post it again.
|
|
2461
|
+
for (let n = 1; n <= DROP_RETRIES && outcome.status === "failed" && lastDrop; n++) {
|
|
2462
|
+
if (!dropRetrySafe(canActOutward, step.events.slice(attemptFrom))) {
|
|
2463
|
+
push("info", `the model connection dropped after this step's tools ran — not retried: they can act outside the workspace, and another attempt could repeat what they did`);
|
|
2464
|
+
break;
|
|
2465
|
+
}
|
|
2466
|
+
const wait = Math.round(retryBaseMs() * 2 * n);
|
|
2467
|
+
push("info", `the model connection dropped (${lastDrop.slice(0, 80)}) — the platform's side, not the step's; running the step again in ${Math.round(wait / 1000)}s (${n} of ${DROP_RETRIES})`);
|
|
2468
|
+
await sleep(wait);
|
|
2469
|
+
lastDrop = "";
|
|
2470
|
+
lastRefusal = "";
|
|
2471
|
+
attemptFrom = step.events.length;
|
|
2472
|
+
const previous = outcome.timing;
|
|
2473
|
+
const again = isolatedArgs(platformModelEnv());
|
|
2474
|
+
await lease?.commit();
|
|
2475
|
+
outcome = withEarlierTiming(await runTracked(again), previous);
|
|
2476
|
+
}
|
|
2477
|
+
// The model wrote its tool call out as text — `<invoke name="…">` in
|
|
2478
|
+
// its last message — and stopped. Nothing ran, so nothing was done:
|
|
2479
|
+
// seo-digest's editor three times (27 Sep, 4 and 5 Oct 2026), its own
|
|
2480
|
+
// retry included, because the retry asked the same way. Run it once
|
|
2481
|
+
// more with that said plainly, on the same safety rule as a dropped
|
|
2482
|
+
// connection: never again after an outward tool already ran.
|
|
2483
|
+
for (let n = 1; n <= TEXT_CALL_RETRIES && wroteToolCallAsText(attemptText(outcome.result, step.events.slice(attemptFrom))); n++) {
|
|
2484
|
+
if (!dropRetrySafe(canActOutward, step.events.slice(attemptFrom))) {
|
|
2485
|
+
push("info", "the model wrote a tool call as text instead of making it — not run again: this step's tools can act outside the workspace and already ran");
|
|
2486
|
+
break;
|
|
2487
|
+
}
|
|
2488
|
+
push("info", `the model wrote a tool call as text instead of making it, so nothing ran — running the step again with a note (${n} of ${TEXT_CALL_RETRIES})`);
|
|
2489
|
+
attemptFrom = step.events.length;
|
|
2490
|
+
lastRefusal = "";
|
|
2491
|
+
const previous = outcome.timing;
|
|
2492
|
+
const base = isolatedArgs(platformModelEnv());
|
|
2493
|
+
const again = { ...base, input: { ...base.input, prompt: `${base.input.prompt}\n\n${TEXT_CALL_NOTE}` } };
|
|
2494
|
+
await lease?.commit();
|
|
2495
|
+
outcome = withEarlierTiming(await runTracked(again), previous);
|
|
2496
|
+
}
|
|
2358
2497
|
// The second supply, tried exactly once, and only when the primary
|
|
2359
2498
|
// refused over money/auth/limits rather than the work failing. The
|
|
2360
2499
|
// retry is a fresh sandbox — the failed one is gone, and driveRun's
|
|
@@ -2508,6 +2647,20 @@ stepIndex) {
|
|
|
2508
2647
|
// finally — once, however many attempts follow.
|
|
2509
2648
|
const mat = materializeFileSecrets(agentDir, stepSecrets);
|
|
2510
2649
|
fileDir = mat.dir;
|
|
2650
|
+
// install: pip and npm land in a directory of this step's own, gone in
|
|
2651
|
+
// the finally; apt has no helper on a bare host and says so.
|
|
2652
|
+
installDir = allowed.includes(INSTALL_TOOL) ? fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-step-")) : null;
|
|
2653
|
+
const installEnv = installDir ? stepInstallEnv(installDir, { ...process.env, ...mat.env }) : {};
|
|
2654
|
+
const installTools = installDir
|
|
2655
|
+
? buildInstallTool({
|
|
2656
|
+
dir: installDir,
|
|
2657
|
+
socket: null,
|
|
2658
|
+
cacheRoot: path.join(dataRoot(), tenant, ".runtimes"),
|
|
2659
|
+
python: mat.env.VIRTUAL_ENV ? path.join(mat.env.VIRTUAL_ENV, "bin", "python") : "python3",
|
|
2660
|
+
env: { ...hostSafeEnv(), ...mat.env, ...installEnv },
|
|
2661
|
+
emit: (type, text) => push(type, text),
|
|
2662
|
+
})
|
|
2663
|
+
: null;
|
|
2511
2664
|
const attemptInProcess = (modelEnv) => executeStep({
|
|
2512
2665
|
agentDir,
|
|
2513
2666
|
workspaceRoot,
|
|
@@ -2527,6 +2680,7 @@ stepIndex) {
|
|
|
2527
2680
|
...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
|
|
2528
2681
|
...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
|
|
2529
2682
|
...(askTools ? { foldrun_ask: askTools.server } : {}),
|
|
2683
|
+
...(installTools ? { foldrun_install: installTools.server } : {}),
|
|
2530
2684
|
...mcpServers,
|
|
2531
2685
|
},
|
|
2532
2686
|
// Declared secrets reach the agent's scripts as env vars; the model
|
|
@@ -2534,7 +2688,7 @@ stepIndex) {
|
|
|
2534
2688
|
// the allowlisted host environment, not process.env: the SDK's
|
|
2535
2689
|
// Bash tool runs with this, and on a platform process.env holds
|
|
2536
2690
|
// every tenant's root key and the datastore credentials.
|
|
2537
|
-
env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...modelEnv },
|
|
2691
|
+
env: { ...hostSafeEnv(), ...clockEnv, ...(testRun ? testModeEnv() : {}), ...mat.env, ...installEnv, ...modelEnv },
|
|
2538
2692
|
timeoutSec: step.timeout,
|
|
2539
2693
|
budgetUsd: stepBudgetUsd,
|
|
2540
2694
|
budgetNote: stepBudgetNote,
|
|
@@ -2544,6 +2698,7 @@ stepIndex) {
|
|
|
2544
2698
|
schema: resolvedSchema ?? undefined,
|
|
2545
2699
|
maxTurns: step.maxTurns,
|
|
2546
2700
|
...limitOpts,
|
|
2701
|
+
...hookOpts,
|
|
2547
2702
|
stopRequested,
|
|
2548
2703
|
// What the scripts saw, the verify sees: a flow can then check that
|
|
2549
2704
|
// the proof a step left names THIS run, not one that came before —
|
|
@@ -2647,6 +2802,8 @@ stepIndex) {
|
|
|
2647
2802
|
finally {
|
|
2648
2803
|
try {
|
|
2649
2804
|
cleanupFileSecrets(fileDir);
|
|
2805
|
+
if (installDir)
|
|
2806
|
+
fs.rmSync(installDir, { recursive: true, force: true });
|
|
2650
2807
|
}
|
|
2651
2808
|
catch {
|
|
2652
2809
|
// best-effort — a leftover scratch file is swept with the run's outputs
|
|
@@ -2804,8 +2961,12 @@ function platformFallbackEnv() {
|
|
|
2804
2961
|
*/
|
|
2805
2962
|
const CREDENTIAL_ROTATION_WAIT_MS = 90_000;
|
|
2806
2963
|
const CREDENTIAL_POLL_MS = 2_000;
|
|
2807
|
-
|
|
2808
|
-
|
|
2964
|
+
/** A refusal of the credential itself, in any of the wordings Claude Code
|
|
2965
|
+
* has used. "Failed to authenticate: OAuth token revoked" (9 Oct 2026, a
|
|
2966
|
+
* step started one second before the 09:07:52Z refresh) matched none of the
|
|
2967
|
+
* older two, so the step failed instead of waiting for the new token. */
|
|
2968
|
+
export function isAuthRefusal(text) {
|
|
2969
|
+
return /API Error: 401|access token has (been revoked|expired)|OAuth token (has )?(been )?(revoked|expired)|Failed to authenticate/i.test(text);
|
|
2809
2970
|
}
|
|
2810
2971
|
/**
|
|
2811
2972
|
* Wait, briefly, for the mounted credential to become one we have not
|
|
@@ -2848,6 +3009,44 @@ function isProviderRefusal(text) {
|
|
|
2848
3009
|
// precisely what the fallback provider is for.
|
|
2849
3010
|
return /API Error: (401|402|403|429|5\d\d)|credits?\b|quota|billing|insufficient|overloaded|(weekly|daily|monthly|usage) limit|rate.?limit(ed)?\b.*resets?\b|limit\b.*\bresets?\b/i.test(text);
|
|
2850
3011
|
}
|
|
3012
|
+
/** A step's own model connection cut: the egress proxy or the worker it
|
|
3013
|
+
* runs through went away. Only the run's final error, never a tool's — a
|
|
3014
|
+
* WebFetch that could not connect is the step's business, not ours. */
|
|
3015
|
+
export function isModelConnectionLost(text) {
|
|
3016
|
+
return /^Claude Code returned an error result: API Error: (Connection (refused|dropped|error)|.*\b(ECONNREFUSED|ECONNRESET|socket hang up)\b)/i.test(text);
|
|
3017
|
+
}
|
|
3018
|
+
/** A tool call written out as text rather than made: the model's last
|
|
3019
|
+
* message holds `<invoke name=…>` / `<function_calls>` markup. Only the tail
|
|
3020
|
+
* counts — a reply that quotes such markup mid-way and goes on is fine. */
|
|
3021
|
+
export function wroteToolCallAsText(text) {
|
|
3022
|
+
const tail = text.slice(-1500);
|
|
3023
|
+
const open = tail.search(/<(?:antml:)?(?:invoke|function_calls)\b[^>]*>/);
|
|
3024
|
+
if (open < 0)
|
|
3025
|
+
return false;
|
|
3026
|
+
// Quoted mid-reply and then the reply carries on: not a stalled call.
|
|
3027
|
+
const after = tail.slice(open).replace(/<\/(?:antml:)?(?:invoke|function_calls)>/g, "\u0000");
|
|
3028
|
+
const lastClose = after.lastIndexOf("\u0000");
|
|
3029
|
+
return lastClose < 0 || after.slice(lastClose + 1).trim().length < 200;
|
|
3030
|
+
}
|
|
3031
|
+
/** What an attempt said last: its result, or — when it ended without one —
|
|
3032
|
+
* the error that carried the model's final text. */
|
|
3033
|
+
function attemptText(result, events) {
|
|
3034
|
+
if (result && result.trim())
|
|
3035
|
+
return result;
|
|
3036
|
+
const lastError = [...events].reverse().find((e) => e.type === "error");
|
|
3037
|
+
return lastError?.text ?? "";
|
|
3038
|
+
}
|
|
3039
|
+
/** Runs again after a tool call written as text. */
|
|
3040
|
+
export const TEXT_CALL_RETRIES = 1;
|
|
3041
|
+
export const TEXT_CALL_NOTE = "Note from the platform: your last attempt ended by writing a tool call out as text (an <invoke …> block), so it was not run and nothing happened. " +
|
|
3042
|
+
"Make every tool call as a real tool call — never type its markup into your message.";
|
|
3043
|
+
/** How many times a step whose model connection was cut runs again. */
|
|
3044
|
+
export const DROP_RETRIES = 2;
|
|
3045
|
+
/** May a step whose connection dropped run again? Yes, unless its tools can
|
|
3046
|
+
* act outward and one ran in this attempt (actedThenFailedCheck's rule). */
|
|
3047
|
+
export function dropRetrySafe(canActOutward, attemptEvents) {
|
|
3048
|
+
return !(canActOutward && attemptEvents.some((e) => e.type === "tool"));
|
|
3049
|
+
}
|
|
2851
3050
|
/** How many times a busy provider is given another moment, and the longest
|
|
2852
3051
|
* any one wait may be. Three is the industry's habit and the point where
|
|
2853
3052
|
* waiting longer stops being kinder than failing. */
|
|
@@ -3412,6 +3611,33 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3412
3611
|
//
|
|
3413
3612
|
// Runs on resume too — a parked run's container is long gone, and the
|
|
3414
3613
|
// one it comes back in needs the same files the first one had.
|
|
3614
|
+
//
|
|
3615
|
+
// A run re-driven after its worker died is the exception to "the store
|
|
3616
|
+
// is newer than the disk": a park harvests before it parks, a crash does
|
|
3617
|
+
// not, so what this run's finished steps wrote to storage/ is only on
|
|
3618
|
+
// disk, and materializing first wrote the store's older copy over it.
|
|
3619
|
+
// On 6 Oct 2026 a worker restart put reddit-desk's 2 Oct post back over
|
|
3620
|
+
// that morning's draft, and a rerun from the validator would have
|
|
3621
|
+
// posted it a second time. So a run that has been here before (its
|
|
3622
|
+
// baseline is on disk) stores what it changed first; files it did not
|
|
3623
|
+
// touch still come fresh from the store.
|
|
3624
|
+
const baselineFile = path.join(runRoot, "runs", run.id, "storage-baseline.json");
|
|
3625
|
+
if (fs.existsSync(baselineFile)) {
|
|
3626
|
+
try {
|
|
3627
|
+
const changedFrom = JSON.parse(fs.readFileSync(baselineFile, "utf8"));
|
|
3628
|
+
const { saved } = await harvestFiles(tenant, workspace, `run:${run.id}`, { changedFrom });
|
|
3629
|
+
if (saved.length) {
|
|
3630
|
+
run.steps[0]?.events.push({
|
|
3631
|
+
t: new Date().toISOString(),
|
|
3632
|
+
type: "info",
|
|
3633
|
+
text: `files: kept ${saved.join(", ")} written before the run was picked up again`,
|
|
3634
|
+
});
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
catch {
|
|
3638
|
+
// the materialize below still runs; at worst this is the old behaviour
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3415
3641
|
try {
|
|
3416
3642
|
const brought = await materializeFiles(tenant, workspace);
|
|
3417
3643
|
if (brought.length) {
|
|
@@ -3421,6 +3647,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3421
3647
|
text: `files: ${brought.length} brought into the workspace`,
|
|
3422
3648
|
});
|
|
3423
3649
|
}
|
|
3650
|
+
fs.mkdirSync(path.dirname(baselineFile), { recursive: true });
|
|
3651
|
+
fs.writeFileSync(baselineFile, JSON.stringify(storageBaseline(tenant, workspace)));
|
|
3424
3652
|
}
|
|
3425
3653
|
catch (err) {
|
|
3426
3654
|
// A file store that is down must not take the run with it: most flows
|
|
@@ -3877,7 +4105,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
3877
4105
|
save();
|
|
3878
4106
|
if (freshGroup.every((s) => s.status !== "pending")) {
|
|
3879
4107
|
// everything in this group was rejected
|
|
3880
|
-
if (freshGroup.some((s) => s.status === "failed" && !s.optional)) {
|
|
4108
|
+
if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
|
|
3881
4109
|
run.status = "failed";
|
|
3882
4110
|
break;
|
|
3883
4111
|
}
|
|
@@ -4099,8 +4327,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4099
4327
|
effort: step.effort,
|
|
4100
4328
|
timeout: step.timeout,
|
|
4101
4329
|
// The rescuer inherits the contract, not just the job: a step
|
|
4102
|
-
// that promised data still owes data when someone else does it
|
|
4330
|
+
// that promised data still owes data when someone else does it,
|
|
4331
|
+
// and a step that had to pass a check still has to pass it — a
|
|
4332
|
+
// rescue whose result stood unchecked was a way round the very
|
|
4333
|
+
// check that failed (found 7 Oct 2026, adding rescuers to
|
|
4334
|
+
// medium-desk).
|
|
4103
4335
|
output: step.output,
|
|
4336
|
+
...(step.verify ? { verify: step.verify } : {}),
|
|
4104
4337
|
attempts: 0,
|
|
4105
4338
|
status: "pending",
|
|
4106
4339
|
events: [],
|
|
@@ -4136,7 +4369,11 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4136
4369
|
}
|
|
4137
4370
|
save();
|
|
4138
4371
|
}
|
|
4139
|
-
|
|
4372
|
+
// A step carried into a re-run failed in the run it came from, not
|
|
4373
|
+
// here: the person chose to start after it. Counting it stopped
|
|
4374
|
+
// fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
|
|
4375
|
+
// a second, before the one step it was asked to run.
|
|
4376
|
+
const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom);
|
|
4140
4377
|
if (requiredFailed) {
|
|
4141
4378
|
run.status = "failed";
|
|
4142
4379
|
break;
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ export interface RuntimeSpec {
|
|
|
3
3
|
packages: string[];
|
|
4
4
|
node?: string | boolean;
|
|
5
5
|
npm: string[];
|
|
6
|
+
/** Debian packages the step needs from the operating system — a library's
|
|
7
|
+
* headers for a pip package that compiles (`libcairo2-dev`), a C compiler
|
|
8
|
+
* (`build-essential`), a program (`ffmpeg`). Installed with apt by the run
|
|
9
|
+
* pod's root start-up, before anything model-directed runs, from a .deb
|
|
10
|
+
* cache on the runtime volume (SYSTEM_INSTALL_SH); verified here. */
|
|
11
|
+
system?: string[];
|
|
6
12
|
/** Declared entries that are not valid requirements. Kept so the run can
|
|
7
13
|
* SAY they were dropped: silently discarding `pandas>=2` and then failing
|
|
8
14
|
* the script with "no module named pandas" is the least debuggable outcome
|
|
@@ -13,6 +19,14 @@ export interface RuntimeSpec {
|
|
|
13
19
|
* from the same argv as its packages — and said so in the build log. */
|
|
14
20
|
ignored?: string[];
|
|
15
21
|
}
|
|
22
|
+
export declare const PIP_REQUIREMENT: RegExp;
|
|
23
|
+
/** npm's own shape: an optional @scope, then a name, then an optional @range. */
|
|
24
|
+
export declare const NPM_REQUIREMENT: RegExp;
|
|
25
|
+
/** A Debian package name as policy defines it: lowercase letters, digits,
|
|
26
|
+
* `+`, `-` and `.`, starting with a letter or digit, two or more characters.
|
|
27
|
+
* Like the two above, a guard first: it can never begin with a dash, so it
|
|
28
|
+
* can never become an apt option, and it holds no shell characters. */
|
|
29
|
+
export declare const DEB_PACKAGE: RegExp;
|
|
16
30
|
export declare function parseRuntime(raw: unknown): RuntimeSpec | null;
|
|
17
31
|
/**
|
|
18
32
|
* Several declarations, one environment.
|
|
@@ -90,4 +104,67 @@ export declare function checkEntry(root: string, spec: RuntimeSpec): string | nu
|
|
|
90
104
|
* moment a cache grows — so it costs nothing on the hot path.
|
|
91
105
|
*/
|
|
92
106
|
export declare function pruneRuntimes(cacheRoot: string, keep: string): string[];
|
|
107
|
+
/**
|
|
108
|
+
* An installer's failure, said so that the first line is the cause.
|
|
109
|
+
*
|
|
110
|
+
* Until 11 Oct 2026 a failure was the installer's last 500 characters, and
|
|
111
|
+
* the run log shows a message's first line: a pycairo build that died for
|
|
112
|
+
* want of a C compiler read, in full, `uv pip install failed: files.` — the
|
|
113
|
+
* tail end of a setuptools deprecation notice. The cause was on the last
|
|
114
|
+
* line of 4 KB nobody saw. So: find the cause and the package it hit, say
|
|
115
|
+
* the fix when it is one we know, then the last lines of the log for the rest.
|
|
116
|
+
*/
|
|
117
|
+
export declare function summarizeInstallFailure(what: string, out: string): string;
|
|
118
|
+
/** Sets $FOLDRUN_SYSTEM_PACKAGES from the step's input.json (its merged
|
|
119
|
+
* runtime spec) — the run pod's and the docker entry's first line. */
|
|
120
|
+
export declare 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){}')\"";
|
|
121
|
+
/** Where a run container's root start-up records the system install. */
|
|
122
|
+
export declare const SYSTEM_STATUS_FILE = "/opt/runner/job/system-install.json";
|
|
123
|
+
export declare const SYSTEM_LOG_FILE = "/opt/runner/job/system-install.log";
|
|
124
|
+
/**
|
|
125
|
+
* The root start-up's half of `runtime: system:` — one shell snippet, used by
|
|
126
|
+
* the k8s run pod, the docker entry and the deploy-time warm pod alike.
|
|
127
|
+
* Reads the package names from $FOLDRUN_SYSTEM_PACKAGES (space-separated,
|
|
128
|
+
* each re-checked against DEB_PACKAGE's shape here too, so nothing else can
|
|
129
|
+
* ride in), installs them with apt as root BEFORE the drop to the agent user,
|
|
130
|
+
* and writes SYSTEM_STATUS_FILE for prepareRuntime to read.
|
|
131
|
+
*
|
|
132
|
+
* The first step to need a package set installs it with apt (the .debs kept
|
|
133
|
+
* in `<cache>/.apt/archives`) and then saves the files those packages added,
|
|
134
|
+
* with their dpkg records, as a snapshot: `<cache>/.apt/snapshots/<key>.tar`.
|
|
135
|
+
* Every later step restores the snapshot instead — measured 11 Oct 2026 in a
|
|
136
|
+
* gVisor run pod on the NFS cache: build-essential, pkg-config, python3-dev,
|
|
137
|
+
* libcairo2-dev and libpango1.0-dev (154 packages, 432 MB) took 23-30 s
|
|
138
|
+
* through apt and 0.76 s from the snapshot, gcc and pkg-config working after.
|
|
139
|
+
* The key is the package set AND a checksum of the image's own dpkg status,
|
|
140
|
+
* so a new runner image never restores files built against the old one. A
|
|
141
|
+
* snapshot is written under a temporary name and renamed, so two pods
|
|
142
|
+
* building it at once cannot leave a torn one; a restore that fails falls
|
|
143
|
+
* back to apt, and prepareRuntime verifies the result either way. Unused for
|
|
144
|
+
* 30 days, one is pruned. Package lists stay per pod: two pods updating one
|
|
145
|
+
* lists directory on a shared volume would corrupt it.
|
|
146
|
+
*/
|
|
147
|
+
export declare function systemInstallSh(cacheDir: string, logDir?: string): string;
|
|
148
|
+
/**
|
|
149
|
+
* Are the declared system packages on this machine? The install happened in
|
|
150
|
+
* the container's root start-up (systemInstallSh); this only reads the
|
|
151
|
+
* result. Without dpkg (a laptop, macOS) there is nothing to check against
|
|
152
|
+
* and nothing this process could install: say so and carry on.
|
|
153
|
+
*/
|
|
154
|
+
export declare function checkSystemPackages(pkgs: string[]): {
|
|
155
|
+
log: string[];
|
|
156
|
+
error: string | null;
|
|
157
|
+
};
|
|
93
158
|
export declare function prepareRuntime(tenant: string, spec: RuntimeSpec | null): PreparedRuntime;
|
|
159
|
+
/** Where the root start-up's install helper listens, inside a run container. */
|
|
160
|
+
export declare const INSTALL_SOCKET = "/opt/runner/job/install.sock";
|
|
161
|
+
/**
|
|
162
|
+
* What a step installs for itself mid-run (the `install` tool) lands under
|
|
163
|
+
* one per-step directory: pip packages in `<dir>/py` (`uv pip install
|
|
164
|
+
* --target`), npm packages in `<dir>/npm` (`npm install --prefix`). Both are
|
|
165
|
+
* on the step's paths from its first turn, so a tool installed by one call is
|
|
166
|
+
* found by the next — `ffmpeg`, `import manim`, `require("sharp")`. Inside a
|
|
167
|
+
* run container the directory is the container's own filesystem and goes
|
|
168
|
+
* when the container does.
|
|
169
|
+
*/
|
|
170
|
+
export declare function stepInstallEnv(dir: string, base: Record<string, string | undefined>): Record<string, string>;
|