@foldrun/core 0.4.0 → 0.6.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 +7 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/browser-pod.d.ts +148 -0
- package/dist/src/browser-pod.js +333 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +218 -3
- package/dist/src/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +9 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.js +5 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +30 -3
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +247 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +58 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +53 -7
- package/dist/src/notify.js +194 -126
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +149 -13
- package/dist/src/run-container.js +424 -43
- package/dist/src/runner.d.ts +177 -9
- package/dist/src/runner.js +1146 -154
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +17 -2
- package/dist/src/script-tools.js +82 -10
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +72 -2
- package/dist/src/step-exec.js +431 -37
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +222 -37
- package/dist/src/store.js +349 -62
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +4 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +350 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +14 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +5 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +318 -0
- package/src/model-credential.ts +71 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +460 -44
- package/src/runner.ts +1168 -155
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +81 -10
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +447 -38
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +506 -70
- package/src/subagents.ts +189 -0
- package/src/test-mode.ts +51 -11
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +4 -4
- package/templates/hello/agents/notetaker/agent.md +3 -3
package/src/run-container.ts
CHANGED
|
@@ -25,15 +25,17 @@ import crypto from "node:crypto";
|
|
|
25
25
|
import { spawn, spawnSync } from "node:child_process";
|
|
26
26
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
27
27
|
import { isPlatformPath, type ApiSpec, type Effort } from "./store.ts";
|
|
28
|
-
import { divertedSummary, isDivertedPath } from "./test-mode.ts";
|
|
28
|
+
import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.ts";
|
|
29
29
|
import type { EventExtra } from "./step-exec.ts";
|
|
30
30
|
import { isFileValue, fileContent } from "./secrets.ts";
|
|
31
31
|
import { safeTenantSegment, type RuntimeSpec } from "./runtime.ts";
|
|
32
|
-
import { dataRoot } from "./paths.ts";
|
|
33
32
|
import type { ScriptSpec } from "./script-tools.ts";
|
|
34
33
|
import type { ConsultSpec } from "./agent-tools.ts";
|
|
34
|
+
import type { SubagentSpec } from "./subagents.ts";
|
|
35
|
+
import { isOperatorEvent, type OperatorEvent } from "./operator.ts";
|
|
35
36
|
import type { SearchRoot, RunDigest } from "./context-tools.ts";
|
|
36
37
|
import type { TranslatorSpec } from "./translator.ts";
|
|
38
|
+
import { parseBrowserPod, type BrowserPodOutcome } from "./browser-pod.ts";
|
|
37
39
|
|
|
38
40
|
/** What crosses the boundary. Everything in here is values — pre-resolved
|
|
39
41
|
* secrets in headers, assembled prompt, serializable MCP configs. */
|
|
@@ -58,6 +60,8 @@ export interface ContainerStepInput {
|
|
|
58
60
|
* their agent.md files host-side. The driver rebuilds the consult tools
|
|
59
61
|
* in here; the callees run toolless, so nothing else need cross. */
|
|
60
62
|
consults: ConsultSpec[];
|
|
63
|
+
/** `subagents:` — built host-side (runner.ts), plain JSON. */
|
|
64
|
+
subagents?: SubagentSpec[];
|
|
61
65
|
timeoutSec?: number;
|
|
62
66
|
/** The step's spend ceiling and the model's per-token price — see
|
|
63
67
|
* step-exec.ts. Values, so the driver can enforce them in the pod. */
|
|
@@ -70,6 +74,10 @@ export interface ContainerStepInput {
|
|
|
70
74
|
/** `schema:` and `max_turns:`, resolved host-side — see ExecOptions. */
|
|
71
75
|
schema?: Record<string, unknown> | boolean;
|
|
72
76
|
maxTurns?: number;
|
|
77
|
+
/** `limits:` resolved host-side, and the SDK-name → foldrun-name map the
|
|
78
|
+
* count reads (limits.ts) — enforced in the pod by the same hook. */
|
|
79
|
+
limits?: Record<string, number>;
|
|
80
|
+
toolOwners?: Record<string, string>;
|
|
73
81
|
/** tools: [search] — the directories to search, as paths INSIDE the
|
|
74
82
|
* container (/workspace/…, /library/…). */
|
|
75
83
|
search?: SearchRoot[];
|
|
@@ -77,10 +85,19 @@ export interface ContainerStepInput {
|
|
|
77
85
|
history?: RunDigest[];
|
|
78
86
|
/** tools: [desks] — the account's other workspaces' runs, gathered host-side. */
|
|
79
87
|
desks?: RunDigest[];
|
|
88
|
+
/** tools: [ask] — ask_person, and how long it may wait for an answer. */
|
|
89
|
+
ask?: { timeoutSec: number };
|
|
80
90
|
/** A Chat-Completions provider: the driver starts the runtime's translator
|
|
81
91
|
* on loopback and points the SDK at it. Null or absent: the env's
|
|
82
92
|
* ANTHROPIC_BASE_URL is spoken to directly. */
|
|
83
93
|
translator?: TranslatorSpec | null;
|
|
94
|
+
/** A granted tool acts outward (`outward: true`): an executor that could
|
|
95
|
+
* put a browsing step on the slim image keeps this one on the full image,
|
|
96
|
+
* where a browser in the step is always there (browser-pod.ts). */
|
|
97
|
+
outward?: boolean;
|
|
98
|
+
/** "full" — this attempt must have the full image: the re-run after the
|
|
99
|
+
* browser pod was lost. Absent lets the executor choose. */
|
|
100
|
+
image?: "full";
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
/**
|
|
@@ -122,11 +139,21 @@ export interface ContainerStepOutcome {
|
|
|
122
139
|
data?: unknown;
|
|
123
140
|
costUsd: number | null;
|
|
124
141
|
usage?: { inputTokens: number; outputTokens: number } | null;
|
|
142
|
+
/** Every turn priced at its own model, when sub-agents ran on others —
|
|
143
|
+
* see ExecOutcome.turnsCostUsd. */
|
|
144
|
+
turnsCostUsd?: number;
|
|
125
145
|
/** What the sandbox actually touched — see the driver's readActuals. */
|
|
126
146
|
res?: StepActuals | null;
|
|
127
147
|
/** Set by the isolated executors only — an in-process step rents no
|
|
128
148
|
* sandbox, so it has no compute seconds to bill. */
|
|
129
149
|
timing?: StepTiming | null;
|
|
150
|
+
/** Which runner image the sandbox ran, as the executor chose it: `pod`
|
|
151
|
+
* when a browsing step ran on slim and browsed through the account's
|
|
152
|
+
* browser pod. Absent from an executor with one image. */
|
|
153
|
+
image?: { variant: "full" | "slim"; why?: string; pod?: boolean } | null;
|
|
154
|
+
/** The pod's reconnects and, when it was lost, what the step had written —
|
|
155
|
+
* see ExecOutcome.browserPod. */
|
|
156
|
+
browserPod?: BrowserPodOutcome;
|
|
130
157
|
}
|
|
131
158
|
|
|
132
159
|
/**
|
|
@@ -220,6 +247,33 @@ export function hashTree(dir: string): Record<string, string> {
|
|
|
220
247
|
return out;
|
|
221
248
|
}
|
|
222
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Every file's mtime under `dir`, relative path to milliseconds — so a copy
|
|
252
|
+
* that cannot carry times can have them put back. `kubectl cp` unpacks with
|
|
253
|
+
* `tar -m`, which stamps every file with the moment it landed; on 2026-09-24
|
|
254
|
+
* gbp-desk's reply-sheet check ("pending-replies.json written in the last
|
|
255
|
+
* hour") passed on a file last written on 2026-09-13, and the poster sent
|
|
256
|
+
* nothing. Every freshness check in every flow was answering "yes".
|
|
257
|
+
*/
|
|
258
|
+
export function mtimeManifest(dir: string): Record<string, number> {
|
|
259
|
+
const out: Record<string, number> = {};
|
|
260
|
+
const walk = (d: string) => {
|
|
261
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
262
|
+
const abs = path.join(d, entry.name);
|
|
263
|
+
if (entry.isSymbolicLink()) continue;
|
|
264
|
+
if (entry.isDirectory()) walk(abs);
|
|
265
|
+
else out[path.relative(dir, abs).replaceAll("\\", "/")] = fs.statSync(abs).mtimeMs;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
if (fs.existsSync(dir)) walk(dir);
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The in-pod half of mtimeManifest: a node one-liner, run as root before `go`. */
|
|
273
|
+
export const APPLY_MTIMES_JS =
|
|
274
|
+
`const fs=require("fs"),p=require("path");const m=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));` +
|
|
275
|
+
`for(const [r,t] of Object.entries(m)){try{fs.utimesSync(p.join(process.argv[2],r),t/1000,t/1000)}catch{}}`;
|
|
276
|
+
|
|
223
277
|
const sha = (b: Buffer) => crypto.createHash("sha256").update(b).digest("hex");
|
|
224
278
|
|
|
225
279
|
/**
|
|
@@ -355,25 +409,55 @@ export function applyContainerChanges(
|
|
|
355
409
|
export const RUNTIME_CACHE = "/home/agent/.foldrun/runner/.runtimes";
|
|
356
410
|
|
|
357
411
|
/**
|
|
358
|
-
*
|
|
412
|
+
* Where the platform's shared runtime layer is mounted, read-only, in a run
|
|
413
|
+
* pod — and read-write, one entry at a time, in the pod that builds an entry
|
|
414
|
+
* for it (foldrun-platform runtime-warm.ts). A fixed path for the same reason
|
|
415
|
+
* as RUNTIME_CACHE: a venv hard-codes its own location. Laid out as
|
|
416
|
+
* <FOLDRUN_DATA>/shared/.runtimes so the builder's prepareRuntime("shared", …)
|
|
417
|
+
* lands exactly here with FOLDRUN_DATA=/opt/foldrun-runtimes.
|
|
418
|
+
*/
|
|
419
|
+
export const SHARED_RUNTIMES = "/opt/foldrun-runtimes/shared/.runtimes";
|
|
420
|
+
|
|
421
|
+
/** Docker's own volume-name charset: a leading alphanumeric, then
|
|
422
|
+
* alphanumerics plus `_ . -`. The mount source is handed straight to the
|
|
423
|
+
* daemon, so it is validated against this rather than assumed. */
|
|
424
|
+
const DOCKER_VOLUME_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The per-tenant runtime cache mount — a Docker NAMED volume and the fixed
|
|
428
|
+
* path it mounts at — or null when there is none.
|
|
359
429
|
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
430
|
+
* A named volume, not a host path. The run's `docker create` is issued to the
|
|
431
|
+
* host daemon over the mounted socket, and that daemon cannot resolve a path
|
|
432
|
+
* under the platform's own FOLDRUN_DATA — which under compose is itself a named
|
|
433
|
+
* volume, or a directory Docker Desktop has not shared — so `-v <hostpath>:…`
|
|
434
|
+
* failed with "mounts denied". A named volume is resolved by the daemon, needs
|
|
435
|
+
* no host sharing, and on first use is seeded from the image's mount point,
|
|
436
|
+
* which the runner image creates owned by the agent user (the DOCKERFILE below,
|
|
437
|
+
* `${RUNTIME_CACHE}`) — so the fresh cache is writable without a host-side chown.
|
|
367
438
|
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
439
|
+
* Per-tenant, not shared. A shared cache would store one copy of pandas instead
|
|
440
|
+
* of one per account, but it would also be mutable state that one tenant's step
|
|
441
|
+
* writes and another tenant's step then *executes* — a poisoned venv arriving
|
|
442
|
+
* as a trusted local file, which is precisely the path gVisor, the capability
|
|
443
|
+
* drop and the egress policy all exist to close. Disk is the cheaper thing to
|
|
444
|
+
* spend. One volume per tenant keeps that boundary; each carries a
|
|
445
|
+
* `foldrun.io/tenant` label so it can be found and pruned (self-hosting/README.md).
|
|
446
|
+
*
|
|
447
|
+
* The venvs inside carry the runner's own absolute paths, distinct from the
|
|
448
|
+
* host's `<tenant>/.runtimes` cache — two roots, so a hit is always a real hit.
|
|
371
449
|
*/
|
|
372
|
-
export function
|
|
450
|
+
export function runtimeCacheMount(
|
|
451
|
+
tenant: string | undefined,
|
|
452
|
+
): { source: string; target: string } | null {
|
|
373
453
|
if (process.env.FOLDRUN_RUNTIME_CACHE === "off") return null;
|
|
374
454
|
const safe = tenant ? safeTenantSegment(tenant) : null;
|
|
375
455
|
if (!safe) return null;
|
|
376
|
-
|
|
456
|
+
const source = `foldrun-rt-${safe}`;
|
|
457
|
+
// safeTenantSegment already yields Docker's charset and `foldrun-rt-` is a
|
|
458
|
+
// safe leading token — but the source is what the daemon binds, so prove it.
|
|
459
|
+
if (!DOCKER_VOLUME_NAME.test(source)) return null;
|
|
460
|
+
return { source, target: RUNTIME_CACHE };
|
|
377
461
|
}
|
|
378
462
|
|
|
379
463
|
// ---------- the runner image ----------
|
|
@@ -433,9 +517,11 @@ try {
|
|
|
433
517
|
const { buildScriptTools } = await import("@foldrun/core/script-tools");
|
|
434
518
|
const { buildConsultTools } = await import("@foldrun/core/agent-tools");
|
|
435
519
|
const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import("@foldrun/core/context-tools");
|
|
520
|
+
const { httpChannel, buildAskTool, inboxReader } = await import("@foldrun/core/operator");
|
|
436
521
|
const { startTranslator } = await import("@foldrun/core/translator");
|
|
437
522
|
const { prepareRuntime } = await import("@foldrun/core/runtime");
|
|
438
523
|
const { materializeFileSecrets } = await import("@foldrun/core/secret-files");
|
|
524
|
+
const { paidWebActions } = await import("@foldrun/core/browser-pod");
|
|
439
525
|
|
|
440
526
|
const agentDir = "/workspace/" + input.agentRel;
|
|
441
527
|
// HOME is where a bare relative path lands when anything resolves one
|
|
@@ -457,6 +543,10 @@ try {
|
|
|
457
543
|
: { interpreters: {}, env: {}, log: [], error: null };
|
|
458
544
|
for (const line of runtime.log) emit("info", "runtime: " + line);
|
|
459
545
|
if (runtime.error) emit("error", "runtime: " + runtime.error);
|
|
546
|
+
// The step's own environment gets it too, not only the script tools: an
|
|
547
|
+
// agent that runs "python3 x.py" or a shebang script through Bash should
|
|
548
|
+
// meet the packages its runtime declared, not the image's bare python.
|
|
549
|
+
Object.assign(env, runtime.env);
|
|
460
550
|
|
|
461
551
|
// API and script tools, rebuilt in here from their specs. Secrets were
|
|
462
552
|
// substituted into API headers before the input crossed the boundary, so
|
|
@@ -484,6 +574,11 @@ try {
|
|
|
484
574
|
const search = buildSearchTools(input.search ?? []);
|
|
485
575
|
const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };
|
|
486
576
|
const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };
|
|
577
|
+
// A person in the loop, over the proxy this step's lease already names:
|
|
578
|
+
// ask_person when tools: [ask], and the run's inbox after every tool call.
|
|
579
|
+
const channel = httpChannel(env.FOLDRUN_EGRESS);
|
|
580
|
+
const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };
|
|
581
|
+
const inbox = channel ? inboxReader(channel, emit) : undefined;
|
|
487
582
|
|
|
488
583
|
const outcome = await executeStep({
|
|
489
584
|
agentDir,
|
|
@@ -502,8 +597,11 @@ try {
|
|
|
502
597
|
...(search.server ? { foldrun_search: search.server } : {}),
|
|
503
598
|
...(history.server ? { foldrun_history: history.server } : {}),
|
|
504
599
|
...(desks.server ? { foldrun_desks: desks.server } : {}),
|
|
600
|
+
...(ask.server ? { foldrun_ask: ask.server } : {}),
|
|
505
601
|
...input.mcpServers,
|
|
506
602
|
},
|
|
603
|
+
...(inbox ? { inbox } : {}),
|
|
604
|
+
...(input.subagents?.length ? { subagents: input.subagents } : {}),
|
|
507
605
|
env,
|
|
508
606
|
timeoutSec: input.timeoutSec,
|
|
509
607
|
budgetUsd: input.budgetUsd,
|
|
@@ -514,6 +612,12 @@ try {
|
|
|
514
612
|
output: input.output,
|
|
515
613
|
schema: input.schema,
|
|
516
614
|
maxTurns: input.maxTurns,
|
|
615
|
+
...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),
|
|
616
|
+
// Slim browsing: the platform put this step on the slim image to browse
|
|
617
|
+
// through the account's pod, and named the file the web tool logs its
|
|
618
|
+
// reconnects to. A lost pod stops the step; what it wrote decides what
|
|
619
|
+
// the runner does next (browser-pod.ts).
|
|
620
|
+
...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env), ...(env.FOLDRUN_BROWSER_INIT ? { browseInit: env.FOLDRUN_BROWSER_INIT } : {}) } } : {}),
|
|
517
621
|
// The container is the boundary; the SDK's own bash sandbox here would
|
|
518
622
|
// only block declared network use (SSH, curl) for no added safety.
|
|
519
623
|
sandboxBash: false,
|
|
@@ -528,6 +632,7 @@ try {
|
|
|
528
632
|
// A consult's spend belongs to the step that asked.
|
|
529
633
|
const consultCost = consult.drainCost();
|
|
530
634
|
if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;
|
|
635
|
+
if (consultCost > 0 && typeof outcome.turnsCostUsd === "number") outcome.turnsCostUsd += consultCost;
|
|
531
636
|
process.stdout.write(JSON.stringify({ e: "done", ...outcome, res: readActuals() }) + "\\n");
|
|
532
637
|
process.exit(0);
|
|
533
638
|
} catch (err) {
|
|
@@ -542,35 +647,251 @@ try {
|
|
|
542
647
|
}
|
|
543
648
|
`;
|
|
544
649
|
|
|
650
|
+
// Core itself is installed in a stage of its own (CORE_STAGE) and only the
|
|
651
|
+
// result is copied in: the tarball and the package README — which explains
|
|
652
|
+
// foldrun to a person reading the repo, and which no step reads — stay in
|
|
653
|
+
// that stage and never become a layer of the image. Owner rule, 2026-10-02:
|
|
654
|
+
// foldrun's own docs live in git, not in containers.
|
|
655
|
+
const CORE_STAGE = `FROM base AS core
|
|
656
|
+
WORKDIR /opt/runner
|
|
657
|
+
COPY foldrun-core.tgz ./
|
|
658
|
+
RUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\
|
|
659
|
+
&& npm cache clean --force >/dev/null 2>&1 \\
|
|
660
|
+
&& rm -f foldrun-core.tgz node_modules/@foldrun/core/README.md
|
|
661
|
+
`;
|
|
662
|
+
|
|
545
663
|
// docker cp writes root-owned files, so the entrypoint starts as root for
|
|
546
664
|
// exactly two commands — chown the copied-in trees, drop to the agent user —
|
|
547
665
|
// and the run flags grant only the three capabilities those two commands
|
|
548
666
|
// need. By the time any model-directed code executes, the process is uid
|
|
549
667
|
// 10001 with no capabilities at all.
|
|
550
|
-
const
|
|
668
|
+
const CORE_INSTALL = `WORKDIR /opt/runner
|
|
669
|
+
COPY --from=core --chown=agent:agent /opt/runner/ ./
|
|
670
|
+
COPY --chown=agent:agent driver.mjs entry.sh ./
|
|
671
|
+
RUN mkdir -p /workspace /library /opt/runner/job ${RUNTIME_CACHE} ${SHARED_RUNTIMES} \\
|
|
672
|
+
&& chown agent:agent /opt/runner \\
|
|
673
|
+
&& chown -R agent:agent /workspace /library /opt/runner/job /home/agent/.foldrun /opt/foldrun-runtimes \\
|
|
674
|
+
&& chmod +x /opt/runner/entry.sh
|
|
675
|
+
ENTRYPOINT ["/opt/runner/entry.sh"]
|
|
676
|
+
`;
|
|
677
|
+
|
|
678
|
+
/** The uv release copied into the image. Pinned: a floating uv is a floating
|
|
679
|
+
* resolver, and the runtime cache is keyed on the declaration, not on it. */
|
|
680
|
+
export const UV_VERSION = "0.11.25";
|
|
681
|
+
|
|
682
|
+
/** How many times a best-effort engine download is tried before the build
|
|
683
|
+
* gives up on it, and the wait before the second and third (15s, then 30s).
|
|
684
|
+
* A GitHub release or Google's apt mirror that blinks for a minute used to
|
|
685
|
+
* cost the engine outright; on x86_64 that now fails the build, so a
|
|
686
|
+
* transient blip would fail a deploy. */
|
|
687
|
+
export const ENGINE_DOWNLOAD_ATTEMPTS = 3;
|
|
688
|
+
|
|
689
|
+
/** `retry <engine> <command…>`: one shell function, defined at the top of each
|
|
690
|
+
* RUN that downloads an engine (a RUN is its own shell). Its lines start
|
|
691
|
+
* "runner image:" like every engine line, and go to stderr so a quiet
|
|
692
|
+
* install (>/dev/null) still shows them — the deploy greps them into the
|
|
693
|
+
* step summary (buildFailureSummary). */
|
|
694
|
+
export const RETRY_SH = `retry() { what=$1; shift; n=1; until "$@"; do if [ "$n" -ge ${ENGINE_DOWNLOAD_ATTEMPTS} ]; then echo "runner image: $what - download failed $n times, giving up" >&2; return 1; fi; echo "runner image: $what - download failed (attempt $n of ${ENGINE_DOWNLOAD_ATTEMPTS}), retrying in $((n * 15))s" >&2; sleep $((n * 15)); n=$((n + 1)); done; }`;
|
|
695
|
+
|
|
696
|
+
/** The base both targets start from, pinned by digest. The tag alone floats:
|
|
697
|
+
* `node:22-slim` is re-pushed for every Node patch and Debian point release,
|
|
698
|
+
* and will change Debian release when the Node image decides to, so the
|
|
699
|
+
* same runner tag — a hash of this text — could hold different bytes on two
|
|
700
|
+
* rebuilds. With the digest in the text, new base bytes mean a new tag.
|
|
701
|
+
*
|
|
702
|
+
* Debian 13 (trixie), not the 12 that `node:22-slim` still names: its glibc
|
|
703
|
+
* (2.41) runs Lightpanda past 0.3.6, which wants 2.38; Debian 12 has 2.36.
|
|
704
|
+
* Python is 3.13 here (3.11 on 12): runtime.ts checkEntry rebuilds a cached
|
|
705
|
+
* venv whose interpreter moved, once per declaration.
|
|
706
|
+
*
|
|
707
|
+
* Raise it on purpose (foldrun-infra/README.md, "Runner images"): read the
|
|
708
|
+
* current digest of the tag, put it here, build both targets on both arches
|
|
709
|
+
* and make one browse call per engine on the full image:
|
|
710
|
+
* docker buildx imagetools inspect node:22-trixie-slim --format '{{json .Manifest}}' | jq -r .digest
|
|
711
|
+
* The digest names the multi-arch index, so the one pin serves amd64 and
|
|
712
|
+
* arm64 alike. */
|
|
713
|
+
export const NODE_BASE = "node:22-trixie-slim@sha256:b26b04c123d9ff8ab646ceb18b9d75a1173acf64b9a401094b906d27b29338d4";
|
|
714
|
+
|
|
715
|
+
// Two images from one file, as build targets.
|
|
716
|
+
//
|
|
717
|
+
// full (the default, the last stage) — everything, browsers included. The
|
|
718
|
+
// per-account browser pod runs it, and so does any step that may
|
|
719
|
+
// browse, because the tool falls back to in-pod Chromium whenever
|
|
720
|
+
// the pod is unavailable or the run is a test.
|
|
721
|
+
// slim — the same base, core and Playwright *client*, no browsers. About
|
|
722
|
+
// a quarter of the size: the browsers were 1.4 GB of a 3.1 GB image
|
|
723
|
+
// that every step pulled and every step started, whether or not it
|
|
724
|
+
// ever opened a page. A step that grants no browsing runs this
|
|
725
|
+
// (run-k8s.ts picks), and its declared packages arrive at run time
|
|
726
|
+
// through the runtime cache — uv is in the base for exactly that.
|
|
727
|
+
//
|
|
728
|
+
// The browsers sit BELOW the core install in `full`, as they always have,
|
|
729
|
+
// so a core change rebuilds one thin layer and not 1.4 GB of Chromium.
|
|
730
|
+
export const DOCKERFILE = `FROM ${NODE_BASE} AS base
|
|
551
731
|
RUN apt-get update \\
|
|
552
732
|
&& apt-get install -y --no-install-recommends python3 python3-venv ca-certificates bash util-linux tar openssh-client sshpass git curl \\
|
|
553
733
|
&& rm -rf /var/lib/apt/lists/* \\
|
|
554
734
|
&& useradd -m -u 10001 agent
|
|
735
|
+
# uv builds the declared python environments (runtime.ts): a fraction of
|
|
736
|
+
# pip's time, and it can fetch a pinned python the image does not carry.
|
|
737
|
+
COPY --from=ghcr.io/astral-sh/uv:${UV_VERSION} /uv /uvx /usr/local/bin/
|
|
738
|
+
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules
|
|
739
|
+
|
|
740
|
+
${CORE_STAGE}
|
|
741
|
+
FROM base AS slim
|
|
742
|
+
# Fonts, because a step that draws text without a browser still needs them:
|
|
743
|
+
# the full image gets 51 from Playwright's --with-deps, slim had none, so from
|
|
744
|
+
# 2026-09-29 (when non-browsing steps moved to slim) every resvg/sharp diagram
|
|
745
|
+
# rendered its labels as empty boxes — expert-witness-desk's validator failed
|
|
746
|
+
# four passes on a post image that was "three empty boxes". DejaVu covers
|
|
747
|
+
# Latin, Greek and Cyrillic in a few MB; fontconfig is how renderers find it.
|
|
748
|
+
# DejaVu alone still drew emoji, Chinese, Japanese, Korean, Devanagari and
|
|
749
|
+
# Thai as empty boxes (reproduced 2026-10-01 with resvg-js 2.6 and sharp
|
|
750
|
+
# 0.34), so four more, the smallest set that drew all of them — 8 MB:
|
|
751
|
+
# Symbola emoji and symbols, as outlines. NOT Noto Color Emoji (10 MB):
|
|
752
|
+
# resvg draws nothing from its bitmap glyphs, so a resvg
|
|
753
|
+
# diagram kept its empty boxes.
|
|
754
|
+
# WenQuanYi Micro Hei Chinese, Japanese kana and Korean Hangul, one file.
|
|
755
|
+
# Lohit Devanagari, Loma Hindi and Thai.
|
|
756
|
+
# Here, not in base, so the full image's browser layers stay cached.
|
|
757
|
+
RUN apt-get update \\
|
|
758
|
+
&& apt-get install -y --no-install-recommends fonts-dejavu-core fontconfig fonts-symbola fonts-wqy-microhei fonts-lohit-deva fonts-tlwg-loma-otf \\
|
|
759
|
+
&& rm -rf /var/lib/apt/lists/* \\
|
|
760
|
+
&& fc-cache -f >/dev/null
|
|
761
|
+
# The Playwright client only, the same pinned version as the browser pod's
|
|
762
|
+
# server (the wire protocol is version-locked), so a tool that imports it
|
|
763
|
+
# still loads; there is nothing here for it to launch.
|
|
764
|
+
# npm's download cache is dropped in the same layer: it was 190 MB of every
|
|
765
|
+
# image, read by nothing after the build.
|
|
766
|
+
RUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null && npm cache clean --force >/dev/null 2>&1
|
|
767
|
+
${CORE_INSTALL}
|
|
768
|
+
FROM base AS browsers
|
|
555
769
|
# Real browsers, because directories and portals increasingly render with
|
|
556
|
-
# JavaScript and
|
|
770
|
+
# JavaScript and a plain fetch sees only the empty shell. Chromium is the one the
|
|
557
771
|
# browser tool drives by default; Firefox and WebKit (Safari's engine) are
|
|
558
772
|
# there for an agent asked to see a page the way those render it. Installed
|
|
559
773
|
# to a fixed path the agent user can read (the default cache would be
|
|
560
774
|
# root's HOME). The browsers' own sandboxes are disabled at launch time — in
|
|
561
775
|
# this platform the container/gVisor IS the sandbox, and the two fight.
|
|
562
|
-
|
|
563
|
-
|
|
776
|
+
# Pinned exactly, not @1: web browse's numbered elements use ariaSnapshot's
|
|
777
|
+
# "ai" mode (1.59+), and the per-account browser pod runs this same image, so
|
|
778
|
+
# a floating version would move both under a working tool on any rebuild.
|
|
779
|
+
# Raise it on purpose, with the gallery tests and one real call.
|
|
780
|
+
# axe-core beside it is web browse's mode=a11y: one file the tool injects
|
|
781
|
+
# into the page; without it the mode falls back to its own shorter checks.
|
|
782
|
+
# Xvfb by name: the browser pod starts it at boot and web browse starts one
|
|
783
|
+
# per call for headless: false. It used to arrive only as one of Playwright's
|
|
784
|
+
# --with-deps packages, so a Playwright bump that dropped it would have
|
|
785
|
+
# broken every windowed browser without a word. No xauth: nothing here runs
|
|
786
|
+
# xvfb-run, and Xvfb started without -auth takes the local client as it is.
|
|
787
|
+
RUN apt-get update \\
|
|
788
|
+
&& apt-get install -y --no-install-recommends xvfb \\
|
|
789
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
790
|
+
# Google Chrome and Chrome beta come from Google's apt repository, which
|
|
791
|
+
# Playwright adds; each is tried ENGINE_DOWNLOAD_ATTEMPTS times. Only on
|
|
792
|
+
# x86_64: Google ships no Linux Chrome for arm64, so there it is not tried.
|
|
793
|
+
RUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null \\
|
|
564
794
|
&& playwright install --with-deps chromium firefox webkit >/dev/null \\
|
|
565
|
-
&&
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
&&
|
|
571
|
-
&&
|
|
572
|
-
|
|
573
|
-
|
|
795
|
+
&& ${RETRY_SH} \\
|
|
796
|
+
&& if [ "$(uname -m)" = x86_64 ]; then \\
|
|
797
|
+
retry chrome playwright install --with-deps chrome >/dev/null || echo "runner image: chrome not installed - engine: chrome would fall back to chromium" >&2; \\
|
|
798
|
+
retry chrome-beta playwright install --with-deps chrome-beta >/dev/null || echo "runner image: chrome-beta not installed" >&2; \\
|
|
799
|
+
else echo "runner image: no Google Chrome for $(uname -m) - engine: chrome falls back to chromium"; fi \\
|
|
800
|
+
&& chmod -R a+rX /opt/browser \\
|
|
801
|
+
&& npm cache clean --force >/dev/null 2>&1
|
|
802
|
+
# Lightpanda (engine: lightpanda): a browser that runs the JavaScript and
|
|
803
|
+
# never draws — a fraction of Chromium's memory for reading and filling
|
|
804
|
+
# pages. Its own program, which web browse starts beside a call and drives
|
|
805
|
+
# over the DevTools protocol; shipped unmodified (AGPL-3.0). Pinned by
|
|
806
|
+
# checksum (GitHub's own asset digests, checked again here). 0.4.1 since the
|
|
807
|
+
# move to Debian 13: 0.3.7 on wants glibc 2.38, and Debian 12's 2.36 held it
|
|
808
|
+
# at 0.3.6. An arch without a build leaves the engine out, and the tool says
|
|
809
|
+
# so when asked for it. Each download (with its checksum) is tried
|
|
810
|
+
# ENGINE_DOWNLOAD_ATTEMPTS times before the engine is left out.
|
|
811
|
+
# Raise it on purpose: the new release's two sha256s, then one
|
|
812
|
+
# engine=lightpanda call on each arch.
|
|
813
|
+
RUN arch=$(uname -m) \\
|
|
814
|
+
&& case "$arch" in \\
|
|
815
|
+
x86_64) sum=1d40801e72c0bc61b2cbd3f3562bcfc46de7b79e0568f33f686b64f2e587610a ;; \\
|
|
816
|
+
aarch64) sum=664775c7f5ab69cc3189954c7f9345e25c167cb4dace016173e629f9a5e82c42 ;; \\
|
|
817
|
+
*) sum= ;; \\
|
|
818
|
+
esac \\
|
|
819
|
+
&& mkdir -p /opt/browser/lightpanda \\
|
|
820
|
+
&& ${RETRY_SH} \\
|
|
821
|
+
&& fetch() { curl -fsSL -o /opt/browser/lightpanda/lightpanda "https://github.com/lightpanda-io/browser/releases/download/0.4.1/lightpanda-$arch-linux" \\
|
|
822
|
+
&& echo "$sum /opt/browser/lightpanda/lightpanda" | sha256sum -c - >/dev/null; } \\
|
|
823
|
+
&& if [ -n "$sum" ] && retry lightpanda fetch; then \\
|
|
824
|
+
chmod a+rx /opt/browser/lightpanda/lightpanda && /opt/browser/lightpanda/lightpanda version; \\
|
|
825
|
+
else rm -f /opt/browser/lightpanda/lightpanda; echo "runner image: lightpanda not installed for $arch - engine: lightpanda says so when asked" >&2; fi
|
|
826
|
+
# Obscura (engine: obscura): a headless browser in Rust around V8 that does
|
|
827
|
+
# draw — screenshots, a raster PDF, a screencast — light like Lightpanda.
|
|
828
|
+
# Apache-2.0. The plain build, not -stealth: this platform does not help a
|
|
829
|
+
# page not see automation. Two binaries, obscura and obscura-worker, which
|
|
830
|
+
# must sit side by side. Pinned by checksum; glibc 2.35 is its floor, so
|
|
831
|
+
# Debian 13 (2.41) runs it. As above, a download is tried
|
|
832
|
+
# ENGINE_DOWNLOAD_ATTEMPTS times, then a failed one leaves the engine out.
|
|
833
|
+
RUN arch=$(uname -m) \\
|
|
834
|
+
&& case "$arch" in \\
|
|
835
|
+
x86_64) sum=1534d1e6ddaf3d080ec4091eb41d0a4d8cc042a48b607d3c410fc13b482a9eec ;; \\
|
|
836
|
+
aarch64) sum=5ecf980bca3060236a7a86ec7ed83d943e6598ee87caa46d20325d90bc75f979 ;; \\
|
|
837
|
+
*) sum= ;; \\
|
|
838
|
+
esac \\
|
|
839
|
+
&& mkdir -p /opt/browser/obscura \\
|
|
840
|
+
&& ${RETRY_SH} \\
|
|
841
|
+
&& fetch() { curl -fsSL -o /tmp/obscura.tgz "https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.3/obscura-$arch-linux.tar.gz" \\
|
|
842
|
+
&& echo "$sum /tmp/obscura.tgz" | sha256sum -c - >/dev/null; } \\
|
|
843
|
+
&& if [ -n "$sum" ] && retry obscura fetch \\
|
|
844
|
+
&& tar -xzf /tmp/obscura.tgz -C /opt/browser/obscura obscura obscura-worker; then \\
|
|
845
|
+
chmod a+rx /opt/browser/obscura/obscura /opt/browser/obscura/obscura-worker && /opt/browser/obscura/obscura --version; \\
|
|
846
|
+
else rm -rf /opt/browser/obscura/*; echo "runner image: obscura not installed for $arch - engine: obscura says so when asked" >&2; fi \\
|
|
847
|
+
&& rm -f /tmp/obscura.tgz
|
|
848
|
+
# What this image actually has, written down: /opt/browser/engines.json, one
|
|
849
|
+
# entry per engine (its path and version, or null when the install above
|
|
850
|
+
# left it out) plus Xvfb. Chromium's version is its own --version (dotted,
|
|
851
|
+
# so FOLDRUN_RUNNER_ENGINES carries it: a pinned user_agent on engine
|
|
852
|
+
# chromium is compared with it, not with Google Chrome's) then the build.
|
|
853
|
+
# web browse reads it for its "built without" answer, and the deploy reads
|
|
854
|
+
# it to report the engines beside the version.
|
|
855
|
+
# On x86_64 a missing engine FAILS the build: every engine ships for that
|
|
856
|
+
# arch, so a gap there is a failed download, and an image that quietly lacks
|
|
857
|
+
# the Chrome or Lightpanda a desk names would fail those runs instead. A
|
|
858
|
+
# failed build leaves the previous image running and says why. Elsewhere
|
|
859
|
+
# (arm64 has no Google Chrome) the gap is recorded and the image ships.
|
|
860
|
+
RUN node -e ' \\
|
|
861
|
+
const fs = require("fs"), cp = require("child_process"), pw = require("playwright"); \\
|
|
862
|
+
const at = (p) => (p && fs.existsSync(p) ? p : null); \\
|
|
863
|
+
const ver = (bin, args) => { try { return cp.execFileSync(bin, args, { timeout: 30000, stdio: ["ignore", "pipe", "ignore"] }).toString().trim().split("\\n")[0] || null; } catch { return null; } }; \\
|
|
864
|
+
const one = (p, v) => (p ? { path: p, version: v } : null); \\
|
|
865
|
+
const pwBuild = (p) => (p ? (p.match(/(chromium|firefox|webkit)-(\\d+)/) || [])[0] || null : null); \\
|
|
866
|
+
const lp = at("/opt/browser/lightpanda/lightpanda"); \\
|
|
867
|
+
const cr = at(pw.chromium.executablePath()); \\
|
|
868
|
+
const ob = at("/opt/browser/obscura/obscura-worker") && at("/opt/browser/obscura/obscura"); \\
|
|
869
|
+
const engines = { \\
|
|
870
|
+
chromium: one(cr, cr && [ver(cr, ["--version"]), pwBuild(cr)].filter(Boolean).join(" ")), \\
|
|
871
|
+
chrome: one(at("/opt/google/chrome/chrome"), ver("/opt/google/chrome/chrome", ["--version"])), \\
|
|
872
|
+
"chrome-beta": one(at("/opt/google/chrome-beta/chrome"), ver("/opt/google/chrome-beta/chrome", ["--version"])), \\
|
|
873
|
+
firefox: one(at(pw.firefox.executablePath()), pwBuild(pw.firefox.executablePath())), \\
|
|
874
|
+
webkit: one(at(pw.webkit.executablePath()), pwBuild(pw.webkit.executablePath())), \\
|
|
875
|
+
lightpanda: one(lp, lp && ver(lp, ["version"])), \\
|
|
876
|
+
obscura: one(ob, ob && ver(ob, ["--version"])), \\
|
|
877
|
+
}; \\
|
|
878
|
+
const xvfb = ver("sh", ["-c", "command -v Xvfb"]); \\
|
|
879
|
+
const arch = cp.execFileSync("uname", ["-m"]).toString().trim(); \\
|
|
880
|
+
const manifest = { arch, playwright: require("playwright/package.json").version, engines, xvfb }; \\
|
|
881
|
+
fs.writeFileSync("/opt/browser/engines.json", JSON.stringify(manifest, null, 2) + "\\n"); \\
|
|
882
|
+
console.log(JSON.stringify(manifest)); \\
|
|
883
|
+
const missing = ["chromium", "chrome", "firefox", "webkit", "lightpanda", "obscura"].filter((e) => !engines[e]).concat(xvfb ? [] : ["Xvfb"]); \\
|
|
884
|
+
if (missing.length && arch === "x86_64") { \\
|
|
885
|
+
console.error("runner image: MISSING ENGINES on x86_64: " + missing.join(", ")); \\
|
|
886
|
+
console.error("runner image: every engine ships for x86_64, so a missing one is a failed download (the retry lines above say which) - failing the build"); \\
|
|
887
|
+
process.exit(1); \\
|
|
888
|
+
} \\
|
|
889
|
+
if (missing.length) console.error("runner image: built without " + missing.join(", ") + " on " + arch); \\
|
|
890
|
+
' \\
|
|
891
|
+
&& chmod a+r /opt/browser/engines.json
|
|
892
|
+
|
|
893
|
+
FROM browsers AS full
|
|
894
|
+
${CORE_INSTALL}`;
|
|
574
895
|
|
|
575
896
|
const ENTRY = `#!/bin/sh
|
|
576
897
|
set -e
|
|
@@ -601,7 +922,10 @@ function corePackageDir(): string {
|
|
|
601
922
|
throw new Error("could not locate @foldrun/core's package root");
|
|
602
923
|
}
|
|
603
924
|
|
|
604
|
-
|
|
925
|
+
/** Which build target of the runner image (see DOCKERFILE). */
|
|
926
|
+
export type RunnerVariant = "full" | "slim";
|
|
927
|
+
|
|
928
|
+
export function runnerImageTag(variant: RunnerVariant = "full"): string {
|
|
605
929
|
// The fingerprint hashes the *built code*, not the version — a version
|
|
606
930
|
// string that nobody bumped would pin every future run to the runner
|
|
607
931
|
// image of whatever core happened to build first.
|
|
@@ -613,7 +937,7 @@ export function runnerImageTag(): string {
|
|
|
613
937
|
if (fs.statSync(abs).isFile()) hash.update(String(entry)).update(fs.readFileSync(abs));
|
|
614
938
|
}
|
|
615
939
|
}
|
|
616
|
-
return `foldrun-runner:${hash.digest("hex").slice(0, 12)}`;
|
|
940
|
+
return `foldrun-runner:${hash.digest("hex").slice(0, 12)}${variant === "slim" ? "-slim" : ""}`;
|
|
617
941
|
}
|
|
618
942
|
|
|
619
943
|
/**
|
|
@@ -629,11 +953,31 @@ export function runnerImageTag(): string {
|
|
|
629
953
|
* (tonistiigi/binfmt); the publisher does that itself, every time, because
|
|
630
954
|
* the registration does not survive a reboot.
|
|
631
955
|
*/
|
|
632
|
-
|
|
956
|
+
/**
|
|
957
|
+
* Which image a step runs in: an explicit FOLDRUN_RUNNER_IMAGE when one is
|
|
958
|
+
* set, otherwise the content-hash tag core builds itself. `explicit` is what
|
|
959
|
+
* ensureRunnerImage checks to skip the build — an empty string is *not* an
|
|
960
|
+
* image name (it is `docker build -t ""` → "repository name must have at
|
|
961
|
+
* least one component"), so it falls through to the content-hash tag exactly
|
|
962
|
+
* as an unset value does. Pure, so the resolution is testable without docker.
|
|
963
|
+
*/
|
|
964
|
+
export function runnerImageRef(
|
|
965
|
+
opts: { platform?: string; variant?: RunnerVariant } = {},
|
|
966
|
+
): { tag: string; explicit: boolean } {
|
|
967
|
+
// Each variant has its own override: FOLDRUN_RUNNER_IMAGE names the full
|
|
968
|
+
// image as it always has, FOLDRUN_RUNNER_SLIM_IMAGE the slim one.
|
|
969
|
+
const set = opts.variant === "slim" ? process.env.FOLDRUN_RUNNER_SLIM_IMAGE : process.env.FOLDRUN_RUNNER_IMAGE;
|
|
970
|
+
if (set) return { tag: set, explicit: true };
|
|
633
971
|
const arch = opts.platform ? `-${opts.platform.split("/").pop()}` : "";
|
|
634
|
-
|
|
972
|
+
return { tag: runnerImageTag(opts.variant) + arch, explicit: false };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export function ensureRunnerImage(
|
|
976
|
+
opts: { platform?: string; variant?: RunnerVariant } = {},
|
|
977
|
+
): { tag: string; log: string[] } {
|
|
978
|
+
const { tag, explicit } = runnerImageRef(opts);
|
|
635
979
|
const log: string[] = [];
|
|
636
|
-
if (
|
|
980
|
+
if (explicit) return { tag, log };
|
|
637
981
|
|
|
638
982
|
const have = spawnSync(cli(), ["image", "inspect", tag], { stdio: "ignore" });
|
|
639
983
|
if (have.status === 0) return { tag, log };
|
|
@@ -650,10 +994,23 @@ export function ensureRunnerImage(opts: { platform?: string } = {}): { tag: stri
|
|
|
650
994
|
fs.writeFileSync(path.join(build, "entry.sh"), ENTRY);
|
|
651
995
|
fs.writeFileSync(path.join(build, "Dockerfile"), DOCKERFILE);
|
|
652
996
|
log.push(`building runner image ${tag} (first run only)`);
|
|
653
|
-
const args = [
|
|
997
|
+
const args = [
|
|
998
|
+
"build",
|
|
999
|
+
...(opts.platform ? ["--platform", opts.platform] : []),
|
|
1000
|
+
"--target",
|
|
1001
|
+
opts.variant ?? "full",
|
|
1002
|
+
"-t",
|
|
1003
|
+
tag,
|
|
1004
|
+
build,
|
|
1005
|
+
];
|
|
654
1006
|
const out = spawnSync(cli(), args, { encoding: "utf8" });
|
|
655
1007
|
if (out.status !== 0) {
|
|
656
|
-
|
|
1008
|
+
const full = `${out.stdout ?? ""}\n${out.stderr ?? ""}`;
|
|
1009
|
+
// The whole log beside the summary, for a person on the build machine:
|
|
1010
|
+
// the error names the file; the summary is what CI shows.
|
|
1011
|
+
const logFile = path.join(os.tmpdir(), "foldrun-runner-build.log");
|
|
1012
|
+
try { fs.writeFileSync(logFile, full); } catch { /* the summary still says what failed */ }
|
|
1013
|
+
throw new Error(`runner image build failed (full log: ${logFile}):\n${buildFailureSummary(full)}`);
|
|
657
1014
|
}
|
|
658
1015
|
return { tag, log };
|
|
659
1016
|
} finally {
|
|
@@ -661,11 +1018,43 @@ export function ensureRunnerImage(opts: { platform?: string } = {}): { tag: stri
|
|
|
661
1018
|
}
|
|
662
1019
|
}
|
|
663
1020
|
|
|
1021
|
+
/**
|
|
1022
|
+
* What a failed runner build's error says: the lines that name the failure,
|
|
1023
|
+
* then the end of the log. Only the last 2000 characters used to survive,
|
|
1024
|
+
* and BuildKit ends a failed RUN by echoing the instruction — the engines
|
|
1025
|
+
* probe is a long one — so "chrome missing" scrolled out of the very error
|
|
1026
|
+
* that existed to say it. Every engine line the DOCKERFILE prints starts
|
|
1027
|
+
* "runner image:" (the retries, the giving up, the MISSING ENGINES line);
|
|
1028
|
+
* those are kept, from anywhere in the log, with BuildKit's "#12 3.456 "
|
|
1029
|
+
* prefix taken off, and so are BuildKit's own ERROR lines (each cut short:
|
|
1030
|
+
* they quote the whole instruction). Pure, so it is tested on fixtures.
|
|
1031
|
+
*/
|
|
1032
|
+
export function buildFailureSummary(log: string, opts: { tail?: number; maxLines?: number } = {}): string {
|
|
1033
|
+
const tail = opts.tail ?? 1500;
|
|
1034
|
+
const maxLines = opts.maxLines ?? 40;
|
|
1035
|
+
const seen = new Set<string>();
|
|
1036
|
+
const key: string[] = [];
|
|
1037
|
+
for (const raw of log.split("\n")) {
|
|
1038
|
+
const line = raw.replace(/^#\d+ \d+(\.\d+)? /, "").trim();
|
|
1039
|
+
if (!/^runner image:|^ERROR\b|^error:/i.test(line) && !/did not complete successfully/.test(line)) continue;
|
|
1040
|
+
const cut = line.length > 300 ? line.slice(0, 300) + " …" : line;
|
|
1041
|
+
if (seen.has(cut)) continue;
|
|
1042
|
+
seen.add(cut);
|
|
1043
|
+
key.push(cut);
|
|
1044
|
+
}
|
|
1045
|
+
// The ones that say what is missing first: a reader stops at the top.
|
|
1046
|
+
key.sort((a, b) => Number(/MISSING ENGINES/.test(b)) - Number(/MISSING ENGINES/.test(a)));
|
|
1047
|
+
const head = key.length
|
|
1048
|
+
? key.slice(0, maxLines).map((l) => ` ${l}`).join("\n") + (key.length > maxLines ? `\n … ${key.length - maxLines} more` : "")
|
|
1049
|
+
: " (no engine or ERROR line in the log)";
|
|
1050
|
+
return `${head}\n--- the last ${tail} characters of the build log ---\n${log.slice(-tail)}`;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
664
1053
|
// ---------- running one step ----------
|
|
665
1054
|
|
|
666
1055
|
export function parseDriverLine(
|
|
667
1056
|
line: string,
|
|
668
|
-
): { e: "event"; type: "text" | "tool" | "info" | "error"; text: string; call?: string; ms?: number; err?: boolean } | { e: "done" } & ContainerStepOutcome | null {
|
|
1057
|
+
): { e: "event"; type: "text" | "tool" | "info" | "error"; text: string; call?: string; ms?: number; err?: boolean; operator?: OperatorEvent; subagent?: string; check?: boolean } | { e: "done" } & ContainerStepOutcome | null {
|
|
669
1058
|
const trimmed = line.trim();
|
|
670
1059
|
if (!trimmed.startsWith("{")) return null;
|
|
671
1060
|
try {
|
|
@@ -678,6 +1067,9 @@ export function parseDriverLine(
|
|
|
678
1067
|
...(typeof parsed.call === "string" ? { call: parsed.call } : {}),
|
|
679
1068
|
...(typeof parsed.ms === "number" ? { ms: parsed.ms } : {}),
|
|
680
1069
|
...(parsed.err === true ? { err: true } : {}),
|
|
1070
|
+
...(isOperatorEvent(parsed.operator) ? { operator: parsed.operator } : {}),
|
|
1071
|
+
...(typeof parsed.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(parsed.subagent) ? { subagent: parsed.subagent } : {}),
|
|
1072
|
+
...(parsed.check === true ? { check: true } : {}),
|
|
681
1073
|
};
|
|
682
1074
|
}
|
|
683
1075
|
if (parsed.e === "done") {
|
|
@@ -688,6 +1080,8 @@ export function parseDriverLine(
|
|
|
688
1080
|
conclusion: typeof parsed.conclusion === "string" ? parsed.conclusion : null,
|
|
689
1081
|
...("data" in parsed ? { data: parsed.data } : {}),
|
|
690
1082
|
costUsd: typeof parsed.costUsd === "number" ? parsed.costUsd : null,
|
|
1083
|
+
...(typeof parsed.turnsCostUsd === "number" ? { turnsCostUsd: parsed.turnsCostUsd } : {}),
|
|
1084
|
+
...(parseBrowserPod(parsed.browserPod) ? { browserPod: parseBrowserPod(parsed.browserPod)! } : {}),
|
|
691
1085
|
usage:
|
|
692
1086
|
parsed.usage &&
|
|
693
1087
|
typeof parsed.usage.inputTokens === "number" &&
|
|
@@ -913,8 +1307,18 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
|
|
|
913
1307
|
const wsIn = path.join(staging, "workspace");
|
|
914
1308
|
fs.cpSync(args.workspaceRoot, wsIn, {
|
|
915
1309
|
recursive: true,
|
|
1310
|
+
// Times too: a verify that asks "was this written in the last hour?"
|
|
1311
|
+
// is asking about the step, and a copy stamped "now" answers yes for a
|
|
1312
|
+
// file untouched since last week (see mtimeManifest).
|
|
1313
|
+
preserveTimestamps: true,
|
|
916
1314
|
filter: (src) => !isPlatformPath(path.relative(args.workspaceRoot, src)),
|
|
917
1315
|
});
|
|
1316
|
+
// A test run: what its earlier steps wrote, over the real files (the
|
|
1317
|
+
// copy is the baseline too, so only this step's own writes come back).
|
|
1318
|
+
if (args.divert) {
|
|
1319
|
+
const laid = overlayTestWrites(wsIn, args.divert.to);
|
|
1320
|
+
if (laid.length) args.emit("info", `test: this step sees ${laid.length} file${laid.length === 1 ? "" : "s"} earlier steps of this run wrote`);
|
|
1321
|
+
}
|
|
918
1322
|
const libIn = path.join(staging, "library");
|
|
919
1323
|
if (args.galleryRoot && fs.existsSync(args.galleryRoot)) fs.cpSync(args.galleryRoot, libIn, { recursive: true });
|
|
920
1324
|
if (fs.existsSync(args.libraryRoot)) fs.cpSync(args.libraryRoot, libIn, { recursive: true });
|
|
@@ -960,14 +1364,26 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
|
|
|
960
1364
|
// host itself stays reachable, which is the one door this needs.
|
|
961
1365
|
flags.push("--add-host", "host.docker.internal:host-gateway");
|
|
962
1366
|
|
|
963
|
-
// The dependency cache: this tenant's built venvs and npm prefixes,
|
|
964
|
-
// mounted where prepareRuntime already looks.
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
const
|
|
968
|
-
if (
|
|
969
|
-
|
|
970
|
-
|
|
1367
|
+
// The dependency cache: this tenant's built venvs and npm prefixes, in a
|
|
1368
|
+
// per-tenant Docker named volume mounted where prepareRuntime already looks.
|
|
1369
|
+
// Not a host path — the daemon on the far end of the socket cannot resolve
|
|
1370
|
+
// the platform's FOLDRUN_DATA (runtimeCacheMount explains).
|
|
1371
|
+
const cacheMount = runtimeCacheMount(args.tenant);
|
|
1372
|
+
if (cacheMount) {
|
|
1373
|
+
// Create the volume WITH labels first: `docker create -v` auto-creates it
|
|
1374
|
+
// but cannot label it, and an unlabelled volume is unfindable and
|
|
1375
|
+
// unprunable later. Idempotent — a no-op when it already exists. The
|
|
1376
|
+
// segment is the one runtimeCacheMount already validated, so the
|
|
1377
|
+
// assertions below cannot fire when cacheMount is non-null.
|
|
1378
|
+
const seg = safeTenantSegment(args.tenant!)!;
|
|
1379
|
+
const vol = spawnSync(cli(), [
|
|
1380
|
+
"volume", "create",
|
|
1381
|
+
"--label", "foldrun.io/kind=runtime-cache",
|
|
1382
|
+
"--label", `foldrun.io/tenant=${seg}`,
|
|
1383
|
+
cacheMount.source,
|
|
1384
|
+
], { encoding: "utf8" });
|
|
1385
|
+
if (vol.status !== 0) throw new Error(`docker volume create failed:\n${vol.stderr}`);
|
|
1386
|
+
flags.push("-v", `${cacheMount.source}:${cacheMount.target}`);
|
|
971
1387
|
}
|
|
972
1388
|
|
|
973
1389
|
// The rent clock starts here: staging the workspace is host work, but
|