@foldrun/core 0.5.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 +1 -0
- package/dist/index.js +1 -0
- package/dist/src/browser-pod.d.ts +20 -1
- package/dist/src/browser-pod.js +58 -9
- 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/evals.js +3 -0
- package/dist/src/host-env.js +5 -0
- package/dist/src/mail.js +3 -1
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +58 -0
- package/dist/src/run-container.d.ts +1 -1
- package/dist/src/run-container.js +27 -7
- package/dist/src/runner.d.ts +35 -0
- package/dist/src/runner.js +124 -37
- package/dist/src/script-tools.d.ts +14 -0
- package/dist/src/script-tools.js +47 -7
- package/dist/src/step-exec.d.ts +11 -0
- package/dist/src/step-exec.js +18 -2
- package/dist/src/store.d.ts +4 -0
- package/dist/src/store.js +2 -2
- package/dist/src/subagents.js +1 -1
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/browser-pod.ts +46 -9
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/evals.ts +3 -0
- package/src/host-env.ts +5 -0
- package/src/mail.ts +3 -1
- package/src/model-credential.ts +71 -0
- package/src/run-container.ts +27 -7
- package/src/runner.ts +122 -37
- package/src/script-tools.ts +43 -5
- package/src/step-exec.ts +19 -2
- package/src/store.ts +6 -2
- package/src/subagents.ts +1 -1
- package/src/test-mode.ts +51 -11
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +3 -3
- package/templates/hello/agents/notetaker/agent.md +2 -2
package/README.md
CHANGED
|
@@ -208,16 +208,19 @@ To *run* an agent you need one of:
|
|
|
208
208
|
|
|
209
209
|
- **`ANTHROPIC_API_KEY`** — an API key from
|
|
210
210
|
[console.anthropic.com](https://console.anthropic.com).
|
|
211
|
+
- **`CLAUDE_CODE_OAUTH_TOKEN`** — a Claude login token, from
|
|
212
|
+
`claude setup-token`. Used when no API key is set.
|
|
211
213
|
- **Your own provider.** An agent that names a `provider:` needs no
|
|
212
214
|
Anthropic key: 29 endpoints are known by name, including `ollama`,
|
|
213
215
|
`lmstudio` and `vllm`, so a local model is a complete answer. Anything
|
|
214
216
|
not in that list works by spelling out `base_url:`, `format:` and
|
|
215
217
|
`auth:`. See [providers](https://docs.foldrun.io/providers).
|
|
216
218
|
|
|
217
|
-
|
|
218
|
-
the
|
|
219
|
-
its Agent SDK to
|
|
220
|
-
|
|
219
|
+
The first one set is used, and every step records which. The login Claude
|
|
220
|
+
Code keeps on the machine is never picked up by itself. Anthropic does not
|
|
221
|
+
allow products built on its Agent SDK to offer claude.ai login unless
|
|
222
|
+
Anthropic has approved them, so check that the terms cover your use before
|
|
223
|
+
you rely on a login token.
|
|
221
224
|
|
|
222
225
|
## Status
|
|
223
226
|
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -30,8 +30,27 @@ export interface ClassifyContext {
|
|
|
30
30
|
/** Web actions a paid provider answers this step — from the env's
|
|
31
31
|
* FOLDRUN_WEB_<ACTION>_VIA. */
|
|
32
32
|
paidWeb?: Record<string, string>;
|
|
33
|
+
/** Page scripts the agent's web.browse block runs in every page — the
|
|
34
|
+
* env's FOLDRUN_BROWSER_INIT. They make every browse call a write. */
|
|
35
|
+
browseInit?: string;
|
|
33
36
|
}
|
|
34
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* The browse actions a call asked for, nested `if` branches included.
|
|
39
|
+
*
|
|
40
|
+
* Read the way the web tool reads them (platform gallery.ts, browse.mjs
|
|
41
|
+
* checkSteps): `actions` is an array of steps, a step's action is one of its
|
|
42
|
+
* OWN keys (the first the tool knows), and the only steps inside a step are
|
|
43
|
+
* an `if`'s `then` and `else`. Every own key of a step is returned — the
|
|
44
|
+
* caller keeps the ones it cares about, and a step holding a write key beside
|
|
45
|
+
* a read key counts as the write. What sits inside an option's value — an
|
|
46
|
+
* extract's `fields` (column names), a mock's `json` body, a webmcp `input` —
|
|
47
|
+
* is data, not an action: `{"fields": {"type": "td.kind"}}` is no typing.
|
|
48
|
+
*
|
|
49
|
+
* A shape the tool would refuse (an object where the array belongs, a step
|
|
50
|
+
* that is not an object, `then` that is not a list) is walked key by key at
|
|
51
|
+
* every depth, as before; nesting past MAX_DEPTH is unreadable, so a write
|
|
52
|
+
* buried deep is never taken for a read.
|
|
53
|
+
*/
|
|
35
54
|
export declare function browseActionNames(actions: unknown): string[];
|
|
36
55
|
/** What a browse call's arguments would write, or null when it only reads. */
|
|
37
56
|
export declare function browseWrites(input: Record<string, unknown>): string | null;
|
package/dist/src/browser-pod.js
CHANGED
|
@@ -45,8 +45,9 @@ export const BROWSE_WRITE_ACTIONS = [
|
|
|
45
45
|
"select", "check", "uncheck", "upload", "dialog", "login", "eval", "webmcp", "solve",
|
|
46
46
|
];
|
|
47
47
|
/** Call arguments of web action=browse that are writes whatever the actions:
|
|
48
|
-
* page script
|
|
49
|
-
|
|
48
|
+
* page script (js=, or init= scripts run in every page), a paid captcha
|
|
49
|
+
* solver, a confirmation of a credential step. */
|
|
50
|
+
const BROWSE_WRITE_ARGS = ["js", "init", "captcha", "captcha_solver", "confirm"];
|
|
50
51
|
/** Web actions that only read — when foldrun's own answers them. A paid
|
|
51
52
|
* provider answering one (FOLDRUN_WEB_<ACTION>_VIA) makes it a write: doing
|
|
52
53
|
* it twice is paying twice. `monitor` keeps state, so it always writes. */
|
|
@@ -57,32 +58,78 @@ const READ_BUILTINS = new Set(["Read", "Glob", "Grep", "WebSearch", "WebFetch",
|
|
|
57
58
|
* answer; a delegation's own calls come through on their own. */
|
|
58
59
|
const READ_SERVERS = new Set(["foldrun_search", "foldrun_history", "foldrun_desks", "foldrun_agents"]);
|
|
59
60
|
const DELEGATE = new Set(["Agent", "Task"]);
|
|
60
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* The browse actions a call asked for, nested `if` branches included.
|
|
63
|
+
*
|
|
64
|
+
* Read the way the web tool reads them (platform gallery.ts, browse.mjs
|
|
65
|
+
* checkSteps): `actions` is an array of steps, a step's action is one of its
|
|
66
|
+
* OWN keys (the first the tool knows), and the only steps inside a step are
|
|
67
|
+
* an `if`'s `then` and `else`. Every own key of a step is returned — the
|
|
68
|
+
* caller keeps the ones it cares about, and a step holding a write key beside
|
|
69
|
+
* a read key counts as the write. What sits inside an option's value — an
|
|
70
|
+
* extract's `fields` (column names), a mock's `json` body, a webmcp `input` —
|
|
71
|
+
* is data, not an action: `{"fields": {"type": "td.kind"}}` is no typing.
|
|
72
|
+
*
|
|
73
|
+
* A shape the tool would refuse (an object where the array belongs, a step
|
|
74
|
+
* that is not an object, `then` that is not a list) is walked key by key at
|
|
75
|
+
* every depth, as before; nesting past MAX_DEPTH is unreadable, so a write
|
|
76
|
+
* buried deep is never taken for a read.
|
|
77
|
+
*/
|
|
61
78
|
export function browseActionNames(actions) {
|
|
79
|
+
const UNREADABLE = "(unreadable actions)";
|
|
80
|
+
const MAX_DEPTH = 32;
|
|
62
81
|
let steps = actions;
|
|
63
82
|
if (typeof steps === "string") {
|
|
64
83
|
try {
|
|
65
84
|
steps = JSON.parse(steps);
|
|
66
85
|
}
|
|
67
86
|
catch {
|
|
68
|
-
return [
|
|
87
|
+
return [UNREADABLE];
|
|
69
88
|
}
|
|
70
89
|
}
|
|
71
90
|
const out = [];
|
|
72
|
-
|
|
73
|
-
|
|
91
|
+
// Unknown shapes: every key anywhere is a candidate action.
|
|
92
|
+
const walkAny = (v, depth) => {
|
|
93
|
+
if (v === null || typeof v !== "object")
|
|
94
|
+
return;
|
|
95
|
+
if (depth > MAX_DEPTH) {
|
|
96
|
+
out.push(UNREADABLE);
|
|
74
97
|
return;
|
|
98
|
+
}
|
|
75
99
|
if (Array.isArray(v)) {
|
|
76
100
|
for (const x of v)
|
|
77
|
-
|
|
101
|
+
walkAny(x, depth + 1);
|
|
78
102
|
return;
|
|
79
103
|
}
|
|
80
104
|
for (const [k, x] of Object.entries(v)) {
|
|
81
105
|
out.push(k);
|
|
82
|
-
|
|
106
|
+
walkAny(x, depth + 1);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const walkSteps = (v, depth) => {
|
|
110
|
+
if (v === null || v === undefined)
|
|
111
|
+
return;
|
|
112
|
+
if (depth > MAX_DEPTH) {
|
|
113
|
+
out.push(UNREADABLE);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(v)) {
|
|
117
|
+
walkAny(v, depth);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (const step of v) {
|
|
121
|
+
if (step === null || typeof step !== "object" || Array.isArray(step)) {
|
|
122
|
+
walkAny(step, depth + 1);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
for (const [k, x] of Object.entries(step)) {
|
|
126
|
+
out.push(k);
|
|
127
|
+
if (k === "then" || k === "else")
|
|
128
|
+
walkSteps(x, depth + 1);
|
|
129
|
+
}
|
|
83
130
|
}
|
|
84
131
|
};
|
|
85
|
-
|
|
132
|
+
walkSteps(steps, 0);
|
|
86
133
|
return out;
|
|
87
134
|
}
|
|
88
135
|
/** What a browse call's arguments would write, or null when it only reads. */
|
|
@@ -135,6 +182,8 @@ export function classifyCall(tool, input, ctx = {}) {
|
|
|
135
182
|
const w = browseWrites(args);
|
|
136
183
|
if (w)
|
|
137
184
|
return write(w);
|
|
185
|
+
if (ctx.browseInit)
|
|
186
|
+
return write(`browse init scripts (${ctx.browseInit})`);
|
|
138
187
|
}
|
|
139
188
|
return { write: false };
|
|
140
189
|
}
|
package/dist/src/container.d.ts
CHANGED
|
@@ -15,6 +15,13 @@ export interface ContainerRunOptions {
|
|
|
15
15
|
agentDir: string;
|
|
16
16
|
/** Extra read-only mounts: host path → container path. */
|
|
17
17
|
readOnly?: Record<string, string>;
|
|
18
|
+
/** Workspace paths a script was handed as `workspace/…`: copied in at
|
|
19
|
+
* `container` before it runs, and what it created or changed under them
|
|
20
|
+
* copied back to `host` after — the host's workspace link, as a copy. */
|
|
21
|
+
staged?: {
|
|
22
|
+
host: string;
|
|
23
|
+
container: string;
|
|
24
|
+
}[];
|
|
18
25
|
image: string;
|
|
19
26
|
argv: string[];
|
|
20
27
|
env: Record<string, string>;
|
|
@@ -28,3 +35,5 @@ export declare function runInContainer(opts: ContainerRunOptions): Promise<{
|
|
|
28
35
|
code: number | null;
|
|
29
36
|
out: string;
|
|
30
37
|
}>;
|
|
38
|
+
/** Write `from` over `to` where the bytes differ; recurse into folders. */
|
|
39
|
+
export declare function syncChanged(from: string, to: string): void;
|
package/dist/src/container.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// Falls back to host execution when Docker isn't available, so local
|
|
10
10
|
// development keeps working with zero configuration.
|
|
11
11
|
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
12
13
|
import path from "node:path";
|
|
13
14
|
import { dataRoot } from "./paths.js";
|
|
14
15
|
import { spawnSync, spawn } from "node:child_process";
|
|
@@ -135,16 +136,41 @@ export async function runInContainer(opts) {
|
|
|
135
136
|
}
|
|
136
137
|
const cid = (created.stdout ?? "").trim();
|
|
137
138
|
try {
|
|
138
|
-
// The agent's own directory becomes /workspace…
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
139
|
+
// The agent's own directory becomes /workspace… — its entries staged
|
|
140
|
+
// like the workspace paths below (a scratch copy, opened up for the
|
|
141
|
+
// script's user, one tar), minus the step's `workspace` link. A step
|
|
142
|
+
// holds that link (→ ../..) so the shell can say workspace/…, and
|
|
143
|
+
// `docker cp` refuses a link that climbs out of what it copies
|
|
144
|
+
// ("invalid symlink"), so every Docker-run script failed before it
|
|
145
|
+
// started. And `docker cp` lands files as root while scripts run as
|
|
146
|
+
// `agent`, so a script could never write its own outputs/.
|
|
147
|
+
const own = fs
|
|
148
|
+
.readdirSync(opts.agentDir)
|
|
149
|
+
.filter((name) => {
|
|
150
|
+
if (name !== "workspace")
|
|
151
|
+
return true;
|
|
152
|
+
try {
|
|
153
|
+
return !fs.lstatSync(path.join(opts.agentDir, name)).isSymbolicLink();
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
.map((name) => ({ host: path.join(opts.agentDir, name), container: `/workspace/${name}` }));
|
|
160
|
+
const copiedIn = stageIn(CLI, cid, own, ["/workspace"]);
|
|
161
|
+
if (copiedIn)
|
|
162
|
+
return { code: null, out: `copy in failed: ${copiedIn}` };
|
|
143
163
|
// …and any shared directories land at their declared paths.
|
|
144
164
|
for (const [host, mount] of Object.entries(opts.readOnly ?? {})) {
|
|
145
165
|
if (fs.existsSync(host))
|
|
146
166
|
docker(["cp", `${host}/.`, `${cid}:${mount}`], 120_000);
|
|
147
167
|
}
|
|
168
|
+
const staged = opts.staged ?? [];
|
|
169
|
+
if (staged.length) {
|
|
170
|
+
const failed = stageIn(CLI, cid, staged);
|
|
171
|
+
if (failed)
|
|
172
|
+
return { code: null, out: `copy in failed: ${failed}` };
|
|
173
|
+
}
|
|
148
174
|
const result = await new Promise((resolve) => {
|
|
149
175
|
const child = spawn(CLI, ["start", "-a", cid], { timeout: opts.timeoutMs });
|
|
150
176
|
let out = "";
|
|
@@ -164,9 +190,93 @@ export async function runInContainer(opts) {
|
|
|
164
190
|
});
|
|
165
191
|
// Bring deliverables back so outputs/ behaves the same either way.
|
|
166
192
|
docker(["cp", `${cid}:/workspace/outputs/.`, path.join(opts.agentDir, "outputs")], 120_000);
|
|
193
|
+
for (const s of staged)
|
|
194
|
+
stageBack(CLI, cid, s);
|
|
167
195
|
return result;
|
|
168
196
|
}
|
|
169
197
|
finally {
|
|
170
198
|
docker(["rm", "-f", cid], 30_000);
|
|
171
199
|
}
|
|
172
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Copy the staged workspace paths into a created container: one tar, built
|
|
203
|
+
* in a scratch directory that mirrors the container paths, extracted at /.
|
|
204
|
+
* A path that does not exist yet (an output the script is told to write)
|
|
205
|
+
* gets its parent directory, so the write has somewhere to land.
|
|
206
|
+
*/
|
|
207
|
+
function stageIn(cli, cid, staged, dirs = []) {
|
|
208
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-stage-"));
|
|
209
|
+
try {
|
|
210
|
+
for (const d of dirs)
|
|
211
|
+
fs.mkdirSync(path.join(scratch, d), { recursive: true });
|
|
212
|
+
for (const { host, container } of staged) {
|
|
213
|
+
const dest = path.join(scratch, container);
|
|
214
|
+
if (fs.existsSync(host)) {
|
|
215
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
216
|
+
fs.cpSync(host, dest, { recursive: true, dereference: false });
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// `docker cp` lands files as root and scripts run as `agent`: these are
|
|
223
|
+
// scratch copies, so open them up for the script to write into.
|
|
224
|
+
openUp(scratch);
|
|
225
|
+
const top = fs.readdirSync(scratch);
|
|
226
|
+
const r = spawnSync("sh", ["-c", 'dir="$1"; cli="$2"; dest="$3"; shift 3; tar --no-xattrs -C "$dir" -cf - -- "$@" | "$cli" cp - "$dest"', "sh", scratch, cli, `${cid}:/`, ...top], { encoding: "utf8", timeout: 120_000, env: { ...process.env, COPYFILE_DISABLE: "1" } });
|
|
227
|
+
return r.status === 0 ? null : (r.stderr ?? "").slice(-300);
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Copy back what the script created or changed under one staged path. Files
|
|
235
|
+
* whose bytes did not change are left alone, so an input keeps its mtime —
|
|
236
|
+
* freshness checks read it.
|
|
237
|
+
*/
|
|
238
|
+
function stageBack(cli, cid, s) {
|
|
239
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-stage-"));
|
|
240
|
+
try {
|
|
241
|
+
const out = path.join(scratch, "x");
|
|
242
|
+
const got = spawnSync(cli, ["cp", `${cid}:${s.container}`, out], { encoding: "utf8", timeout: 120_000 });
|
|
243
|
+
if (got.status !== 0 || !fs.existsSync(out))
|
|
244
|
+
return;
|
|
245
|
+
syncChanged(out, s.host);
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** Write `from` over `to` where the bytes differ; recurse into folders. */
|
|
252
|
+
export function syncChanged(from, to) {
|
|
253
|
+
const st = fs.lstatSync(from);
|
|
254
|
+
if (st.isSymbolicLink())
|
|
255
|
+
return;
|
|
256
|
+
if (st.isDirectory()) {
|
|
257
|
+
fs.mkdirSync(to, { recursive: true });
|
|
258
|
+
for (const name of fs.readdirSync(from))
|
|
259
|
+
syncChanged(path.join(from, name), path.join(to, name));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const next = fs.readFileSync(from);
|
|
263
|
+
if (fs.existsSync(to) && fs.statSync(to).isFile() && fs.readFileSync(to).equals(next))
|
|
264
|
+
return;
|
|
265
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
266
|
+
fs.writeFileSync(to, next);
|
|
267
|
+
}
|
|
268
|
+
/** Directories 0777, files writable by all (exec bits kept): a scratch tree only. */
|
|
269
|
+
function openUp(dir) {
|
|
270
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
271
|
+
const p = path.join(dir, entry.name);
|
|
272
|
+
if (entry.isSymbolicLink())
|
|
273
|
+
continue;
|
|
274
|
+
if (entry.isDirectory()) {
|
|
275
|
+
fs.chmodSync(p, 0o777);
|
|
276
|
+
openUp(p);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
fs.chmodSync(p, fs.statSync(p).mode | 0o666);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
import type { RunRecord } from "./store.ts";
|
|
3
3
|
export interface SearchRoot {
|
|
4
|
-
/** How the agent should name the place: "knowledge/", "
|
|
4
|
+
/** How the agent should name the place: "knowledge/", "workspace/memory/". */
|
|
5
5
|
label: string;
|
|
6
6
|
/** The directory, absolute. */
|
|
7
7
|
dir: string;
|
package/dist/src/evals.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// Deterministic checks run first and cost nothing. The judge only runs if
|
|
31
31
|
// they pass — no point paying a model to grade output already known to be
|
|
32
32
|
// wrong.
|
|
33
|
+
import { sdkEnv } from "./step-exec.js";
|
|
33
34
|
import fs from "node:fs";
|
|
34
35
|
import path from "node:path";
|
|
35
36
|
import { deployedCommit } from "./deploy.js";
|
|
@@ -365,6 +366,8 @@ async function judge(rubric, output, model, effort) {
|
|
|
365
366
|
tools: [],
|
|
366
367
|
allowedTools: [],
|
|
367
368
|
settingSources: [],
|
|
369
|
+
strictMcpConfig: true,
|
|
370
|
+
env: sdkEnv(undefined),
|
|
368
371
|
maxTurns: 1,
|
|
369
372
|
},
|
|
370
373
|
});
|
package/dist/src/host-env.js
CHANGED
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
const EXACT = new Set([
|
|
20
20
|
"PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE",
|
|
21
21
|
"TZ", "TERM", "SHELL", "USER", "LOGNAME",
|
|
22
|
+
// Where Claude Code / the Agent SDK keeps its config and session
|
|
23
|
+
// transcripts — the same kind of thing as HOME (a path, not a secret), and
|
|
24
|
+
// without it an operator cannot keep them out of the host user's
|
|
25
|
+
// ~/.claude: the box's deploy tests set it to the job's temp (2026-10-02).
|
|
26
|
+
"CLAUDE_CONFIG_DIR",
|
|
22
27
|
// The host's way to the network is the child's way to the network.
|
|
23
28
|
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
|
|
24
29
|
"SSL_CERT_FILE", "SSL_CERT_DIR",
|
package/dist/src/mail.js
CHANGED
|
@@ -125,7 +125,9 @@ export function readUnsubscribeToken(token, now = Date.now()) {
|
|
|
125
125
|
if (!payload || !sig || extra !== undefined)
|
|
126
126
|
return { error: "invalid token", status: 401 };
|
|
127
127
|
const want = sign(payload);
|
|
128
|
-
|
|
128
|
+
// Only base64url reaches the compare: a multibyte character would make the
|
|
129
|
+
// byte buffers differ in length, and timingSafeEqual throws on that.
|
|
130
|
+
if (!/^[A-Za-z0-9_-]+$/.test(sig) || sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) {
|
|
129
131
|
return { error: "invalid token", status: 401 };
|
|
130
132
|
}
|
|
131
133
|
let raw;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type ModelCredentialKind = "api-key" | "auth-token" | "claude-login" | "none";
|
|
2
|
+
export interface ModelCredential {
|
|
3
|
+
kind: ModelCredentialKind;
|
|
4
|
+
/** What the run record and the CLI say: which credential, by its variable. */
|
|
5
|
+
label: string;
|
|
6
|
+
/** The variables the step's Claude Code is started with. */
|
|
7
|
+
env: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
export declare function resolveModelCredential(source?: Record<string, string | undefined>): ModelCredential;
|
|
10
|
+
/** The line a step's trail carries, and what goes wrong when there is none. */
|
|
11
|
+
export declare function credentialLine(c: ModelCredential): {
|
|
12
|
+
type: "info" | "error";
|
|
13
|
+
text: string;
|
|
14
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Which Anthropic credential a step runs on, decided once and said on the
|
|
2
|
+
// record — never whatever happens to be signed in on the machine.
|
|
3
|
+
//
|
|
4
|
+
// In order: an API key (ANTHROPIC_API_KEY), a bearer for a gateway
|
|
5
|
+
// (ANTHROPIC_AUTH_TOKEN), then a Claude login token (CLAUDE_CODE_OAUTH_TOKEN,
|
|
6
|
+
// made by `claude setup-token`). Before this, an in-process step was handed
|
|
7
|
+
// none of them — the host allowlist drops credentials — so Claude Code fell
|
|
8
|
+
// back to the machine's own claude.ai login whatever key was set: a key the
|
|
9
|
+
// CLI insisted on was checked and then ignored.
|
|
10
|
+
//
|
|
11
|
+
// Note on terms: Anthropic does not allow third-party products built on the
|
|
12
|
+
// Agent SDK to offer claude.ai login unless Anthropic has approved it. The
|
|
13
|
+
// login token is accepted here because the account owner chose to support
|
|
14
|
+
// it; offering it to customers needs that approval.
|
|
15
|
+
//
|
|
16
|
+
// What an agent's own shell sees: Claude Code keeps CLAUDE_CODE_OAUTH_TOKEN
|
|
17
|
+
// out of its Bash by itself; an API key is visible there, as it is when you
|
|
18
|
+
// run Claude Code with one in your shell. Its scrub switch
|
|
19
|
+
// (CLAUDE_CODE_SUBPROCESS_ENV_SCRUB) is not used: it also strips the agent's
|
|
20
|
+
// own declared secrets (RESEND_API_KEY, DATAFORSEO_PASSWORD) from Bash, and on
|
|
21
|
+
// Linux it refuses to start without bubblewrap. Script tools and shell
|
|
22
|
+
// `verify:` never see any of these: they start from the host allowlist.
|
|
23
|
+
const set = (v) => typeof v === "string" && v.trim() !== "";
|
|
24
|
+
export function resolveModelCredential(source = process.env) {
|
|
25
|
+
const base = set(source.ANTHROPIC_BASE_URL) ? { ANTHROPIC_BASE_URL: source.ANTHROPIC_BASE_URL } : {};
|
|
26
|
+
if (set(source.ANTHROPIC_API_KEY)) {
|
|
27
|
+
return {
|
|
28
|
+
kind: "api-key",
|
|
29
|
+
label: "API key (ANTHROPIC_API_KEY)",
|
|
30
|
+
env: { ...base, ANTHROPIC_API_KEY: source.ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN: "" },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (set(source.ANTHROPIC_AUTH_TOKEN)) {
|
|
34
|
+
return {
|
|
35
|
+
kind: "auth-token",
|
|
36
|
+
label: `bearer token (ANTHROPIC_AUTH_TOKEN)${base.ANTHROPIC_BASE_URL ? ` to ${base.ANTHROPIC_BASE_URL}` : ""}`,
|
|
37
|
+
env: { ...base, ANTHROPIC_AUTH_TOKEN: source.ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY: "", CLAUDE_CODE_OAUTH_TOKEN: "" },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (set(source.CLAUDE_CODE_OAUTH_TOKEN)) {
|
|
41
|
+
return {
|
|
42
|
+
kind: "claude-login",
|
|
43
|
+
label: "Claude login token (CLAUDE_CODE_OAUTH_TOKEN)",
|
|
44
|
+
env: { ...base, CLAUDE_CODE_OAUTH_TOKEN: source.CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY: "" },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return { kind: "none", label: "none set", env: {} };
|
|
48
|
+
}
|
|
49
|
+
/** The line a step's trail carries, and what goes wrong when there is none. */
|
|
50
|
+
export function credentialLine(c) {
|
|
51
|
+
if (c.kind !== "none")
|
|
52
|
+
return { type: "info", text: `model credential: ${c.label}` };
|
|
53
|
+
return {
|
|
54
|
+
type: "error",
|
|
55
|
+
text: "model credential: none set — Claude Code will fall back to whatever login this machine has, if any. " +
|
|
56
|
+
"Set ANTHROPIC_API_KEY, or CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -271,7 +271,7 @@ export declare const RETRY_SH = "retry() { what=$1; shift; n=1; until \"$@\"; do
|
|
|
271
271
|
* The digest names the multi-arch index, so the one pin serves amd64 and
|
|
272
272
|
* arm64 alike. */
|
|
273
273
|
export declare const NODE_BASE = "node:22-trixie-slim@sha256:b26b04c123d9ff8ab646ceb18b9d75a1173acf64b9a401094b906d27b29338d4";
|
|
274
|
-
export declare const DOCKERFILE = "FROM node:22-trixie-slim@sha256:b26b04c123d9ff8ab646ceb18b9d75a1173acf64b9a401094b906d27b29338d4 AS base\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends python3 python3-venv ca-certificates bash util-linux tar openssh-client sshpass git curl \\\n && rm -rf /var/lib/apt/lists/* \\\n && useradd -m -u 10001 agent\n# uv builds the declared python environments (runtime.ts): a fraction of\n# pip's time, and it can fetch a pinned python the image does not carry.\nCOPY --from=ghcr.io/astral-sh/uv:0.11.25 /uv /uvx /usr/local/bin/\nENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules\n\nFROM base AS slim\n# Fonts, because a step that draws text without a browser still needs them:\n# the full image gets 51 from Playwright's --with-deps, slim had none, so from\n# 2026-09-29 (when non-browsing steps moved to slim) every resvg/sharp diagram\n# rendered its labels as empty boxes \u2014 expert-witness-desk's validator failed\n# four passes on a post image that was \"three empty boxes\". DejaVu covers\n# Latin, Greek and Cyrillic in a few MB; fontconfig is how renderers find it.\n# DejaVu alone still drew emoji, Chinese, Japanese, Korean, Devanagari and\n# Thai as empty boxes (reproduced 2026-10-01 with resvg-js 2.6 and sharp\n# 0.34), so four more, the smallest set that drew all of them \u2014 8 MB:\n# Symbola emoji and symbols, as outlines. NOT Noto Color Emoji (10 MB):\n# resvg draws nothing from its bitmap glyphs, so a resvg\n# diagram kept its empty boxes.\n# WenQuanYi Micro Hei Chinese, Japanese kana and Korean Hangul, one file.\n# Lohit Devanagari, Loma Hindi and Thai.\n# Here, not in base, so the full image's browser layers stay cached.\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends fonts-dejavu-core fontconfig fonts-symbola fonts-wqy-microhei fonts-lohit-deva fonts-tlwg-loma-otf \\\n && rm -rf /var/lib/apt/lists/* \\\n && fc-cache -f >/dev/null\n# The Playwright client only, the same pinned version as the browser pod's\n# server (the wire protocol is version-locked), so a tool that imports it\n# still loads; there is nothing here for it to launch.\n# npm's download cache is dropped in the same layer: it was 190 MB of every\n# image, read by nothing after the build.\nRUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null && npm cache clean --force >/dev/null 2>&1\nWORKDIR /opt/runner\nCOPY foldrun-core.tgz driver.mjs entry.sh ./\nRUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\\n && npm cache clean --force >/dev/null 2>&1 \\\n && mkdir -p /workspace /library /opt/runner/job /home/agent/.foldrun/runner/.runtimes /opt/foldrun-runtimes/shared/.runtimes \\\n && chown -R agent:agent /workspace /library /opt/runner /home/agent/.foldrun /opt/foldrun-runtimes \\\n && chmod +x /opt/runner/entry.sh\nENTRYPOINT [\"/opt/runner/entry.sh\"]\n\nFROM base AS browsers\n# Real browsers, because directories and portals increasingly render with\n# JavaScript and a plain fetch sees only the empty shell. Chromium is the one the\n# browser tool drives by default; Firefox and WebKit (Safari's engine) are\n# there for an agent asked to see a page the way those render it. Installed\n# to a fixed path the agent user can read (the default cache would be\n# root's HOME). The browsers' own sandboxes are disabled at launch time \u2014 in\n# this platform the container/gVisor IS the sandbox, and the two fight.\n# Pinned exactly, not @1: web browse's numbered elements use ariaSnapshot's\n# \"ai\" mode (1.59+), and the per-account browser pod runs this same image, so\n# a floating version would move both under a working tool on any rebuild.\n# Raise it on purpose, with the gallery tests and one real call.\n# axe-core beside it is web browse's mode=a11y: one file the tool injects\n# into the page; without it the mode falls back to its own shorter checks.\n# Xvfb by name: the browser pod starts it at boot and web browse starts one\n# per call for headless: false. It used to arrive only as one of Playwright's\n# --with-deps packages, so a Playwright bump that dropped it would have\n# broken every windowed browser without a word. No xauth: nothing here runs\n# xvfb-run, and Xvfb started without -auth takes the local client as it is.\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends xvfb \\\n && rm -rf /var/lib/apt/lists/*\n# Google Chrome and Chrome beta come from Google's apt repository, which\n# Playwright adds; each is tried ENGINE_DOWNLOAD_ATTEMPTS times. Only on\n# x86_64: Google ships no Linux Chrome for arm64, so there it is not tried.\nRUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null \\\n && playwright install --with-deps chromium firefox webkit >/dev/null \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && if [ \"$(uname -m)\" = x86_64 ]; then \\\n retry chrome playwright install --with-deps chrome >/dev/null || echo \"runner image: chrome not installed - engine: chrome would fall back to chromium\" >&2; \\\n retry chrome-beta playwright install --with-deps chrome-beta >/dev/null || echo \"runner image: chrome-beta not installed\" >&2; \\\n else echo \"runner image: no Google Chrome for $(uname -m) - engine: chrome falls back to chromium\"; fi \\\n && chmod -R a+rX /opt/browser \\\n && npm cache clean --force >/dev/null 2>&1\n# Lightpanda (engine: lightpanda): a browser that runs the JavaScript and\n# never draws \u2014 a fraction of Chromium's memory for reading and filling\n# pages. Its own program, which web browse starts beside a call and drives\n# over the DevTools protocol; shipped unmodified (AGPL-3.0). Pinned by\n# checksum (GitHub's own asset digests, checked again here). 0.4.1 since the\n# move to Debian 13: 0.3.7 on wants glibc 2.38, and Debian 12's 2.36 held it\n# at 0.3.6. An arch without a build leaves the engine out, and the tool says\n# so when asked for it. Each download (with its checksum) is tried\n# ENGINE_DOWNLOAD_ATTEMPTS times before the engine is left out.\n# Raise it on purpose: the new release's two sha256s, then one\n# engine=lightpanda call on each arch.\nRUN arch=$(uname -m) \\\n && case \"$arch\" in \\\n x86_64) sum=1d40801e72c0bc61b2cbd3f3562bcfc46de7b79e0568f33f686b64f2e587610a ;; \\\n aarch64) sum=664775c7f5ab69cc3189954c7f9345e25c167cb4dace016173e629f9a5e82c42 ;; \\\n *) sum= ;; \\\n esac \\\n && mkdir -p /opt/browser/lightpanda \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && fetch() { curl -fsSL -o /opt/browser/lightpanda/lightpanda \"https://github.com/lightpanda-io/browser/releases/download/0.4.1/lightpanda-$arch-linux\" \\\n && echo \"$sum /opt/browser/lightpanda/lightpanda\" | sha256sum -c - >/dev/null; } \\\n && if [ -n \"$sum\" ] && retry lightpanda fetch; then \\\n chmod a+rx /opt/browser/lightpanda/lightpanda && /opt/browser/lightpanda/lightpanda version; \\\n else rm -f /opt/browser/lightpanda/lightpanda; echo \"runner image: lightpanda not installed for $arch - engine: lightpanda says so when asked\" >&2; fi\n# Obscura (engine: obscura): a headless browser in Rust around V8 that does\n# draw \u2014 screenshots, a raster PDF, a screencast \u2014 light like Lightpanda.\n# Apache-2.0. The plain build, not -stealth: this platform does not help a\n# page not see automation. Two binaries, obscura and obscura-worker, which\n# must sit side by side. Pinned by checksum; glibc 2.35 is its floor, so\n# Debian 13 (2.41) runs it. As above, a download is tried\n# ENGINE_DOWNLOAD_ATTEMPTS times, then a failed one leaves the engine out.\nRUN arch=$(uname -m) \\\n && case \"$arch\" in \\\n x86_64) sum=1534d1e6ddaf3d080ec4091eb41d0a4d8cc042a48b607d3c410fc13b482a9eec ;; \\\n aarch64) sum=5ecf980bca3060236a7a86ec7ed83d943e6598ee87caa46d20325d90bc75f979 ;; \\\n *) sum= ;; \\\n esac \\\n && mkdir -p /opt/browser/obscura \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && fetch() { curl -fsSL -o /tmp/obscura.tgz \"https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.3/obscura-$arch-linux.tar.gz\" \\\n && echo \"$sum /tmp/obscura.tgz\" | sha256sum -c - >/dev/null; } \\\n && if [ -n \"$sum\" ] && retry obscura fetch \\\n && tar -xzf /tmp/obscura.tgz -C /opt/browser/obscura obscura obscura-worker; then \\\n chmod a+rx /opt/browser/obscura/obscura /opt/browser/obscura/obscura-worker && /opt/browser/obscura/obscura --version; \\\n else rm -rf /opt/browser/obscura/*; echo \"runner image: obscura not installed for $arch - engine: obscura says so when asked\" >&2; fi \\\n && rm -f /tmp/obscura.tgz\n# What this image actually has, written down: /opt/browser/engines.json, one\n# entry per engine (its path and version, or null when the install above\n# left it out) plus Xvfb. Chromium's version is its own --version (dotted,\n# so FOLDRUN_RUNNER_ENGINES carries it: a pinned user_agent on engine\n# chromium is compared with it, not with Google Chrome's) then the build.\n# web browse reads it for its \"built without\" answer, and the deploy reads\n# it to report the engines beside the version.\n# On x86_64 a missing engine FAILS the build: every engine ships for that\n# arch, so a gap there is a failed download, and an image that quietly lacks\n# the Chrome or Lightpanda a desk names would fail those runs instead. A\n# failed build leaves the previous image running and says why. Elsewhere\n# (arm64 has no Google Chrome) the gap is recorded and the image ships.\nRUN node -e ' \\\n const fs = require(\"fs\"), cp = require(\"child_process\"), pw = require(\"playwright\"); \\\n const at = (p) => (p && fs.existsSync(p) ? p : null); \\\n 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; } }; \\\n const one = (p, v) => (p ? { path: p, version: v } : null); \\\n const pwBuild = (p) => (p ? (p.match(/(chromium|firefox|webkit)-(\\d+)/) || [])[0] || null : null); \\\n const lp = at(\"/opt/browser/lightpanda/lightpanda\"); \\\n const cr = at(pw.chromium.executablePath()); \\\n const ob = at(\"/opt/browser/obscura/obscura-worker\") && at(\"/opt/browser/obscura/obscura\"); \\\n const engines = { \\\n chromium: one(cr, cr && [ver(cr, [\"--version\"]), pwBuild(cr)].filter(Boolean).join(\" \")), \\\n chrome: one(at(\"/opt/google/chrome/chrome\"), ver(\"/opt/google/chrome/chrome\", [\"--version\"])), \\\n \"chrome-beta\": one(at(\"/opt/google/chrome-beta/chrome\"), ver(\"/opt/google/chrome-beta/chrome\", [\"--version\"])), \\\n firefox: one(at(pw.firefox.executablePath()), pwBuild(pw.firefox.executablePath())), \\\n webkit: one(at(pw.webkit.executablePath()), pwBuild(pw.webkit.executablePath())), \\\n lightpanda: one(lp, lp && ver(lp, [\"version\"])), \\\n obscura: one(ob, ob && ver(ob, [\"--version\"])), \\\n }; \\\n const xvfb = ver(\"sh\", [\"-c\", \"command -v Xvfb\"]); \\\n const arch = cp.execFileSync(\"uname\", [\"-m\"]).toString().trim(); \\\n const manifest = { arch, playwright: require(\"playwright/package.json\").version, engines, xvfb }; \\\n fs.writeFileSync(\"/opt/browser/engines.json\", JSON.stringify(manifest, null, 2) + \"\\n\"); \\\n console.log(JSON.stringify(manifest)); \\\n const missing = [\"chromium\", \"chrome\", \"firefox\", \"webkit\", \"lightpanda\", \"obscura\"].filter((e) => !engines[e]).concat(xvfb ? [] : [\"Xvfb\"]); \\\n if (missing.length && arch === \"x86_64\") { \\\n console.error(\"runner image: MISSING ENGINES on x86_64: \" + missing.join(\", \")); \\\n 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\"); \\\n process.exit(1); \\\n } \\\n if (missing.length) console.error(\"runner image: built without \" + missing.join(\", \") + \" on \" + arch); \\\n ' \\\n && chmod a+r /opt/browser/engines.json\n\nFROM browsers AS full\nWORKDIR /opt/runner\nCOPY foldrun-core.tgz driver.mjs entry.sh ./\nRUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\\n && npm cache clean --force >/dev/null 2>&1 \\\n && mkdir -p /workspace /library /opt/runner/job /home/agent/.foldrun/runner/.runtimes /opt/foldrun-runtimes/shared/.runtimes \\\n && chown -R agent:agent /workspace /library /opt/runner /home/agent/.foldrun /opt/foldrun-runtimes \\\n && chmod +x /opt/runner/entry.sh\nENTRYPOINT [\"/opt/runner/entry.sh\"]\n";
|
|
274
|
+
export declare const DOCKERFILE = "FROM node:22-trixie-slim@sha256:b26b04c123d9ff8ab646ceb18b9d75a1173acf64b9a401094b906d27b29338d4 AS base\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends python3 python3-venv ca-certificates bash util-linux tar openssh-client sshpass git curl \\\n && rm -rf /var/lib/apt/lists/* \\\n && useradd -m -u 10001 agent\n# uv builds the declared python environments (runtime.ts): a fraction of\n# pip's time, and it can fetch a pinned python the image does not carry.\nCOPY --from=ghcr.io/astral-sh/uv:0.11.25 /uv /uvx /usr/local/bin/\nENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules\n\nFROM base AS core\nWORKDIR /opt/runner\nCOPY foldrun-core.tgz ./\nRUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\\n && npm cache clean --force >/dev/null 2>&1 \\\n && rm -f foldrun-core.tgz node_modules/@foldrun/core/README.md\n\nFROM base AS slim\n# Fonts, because a step that draws text without a browser still needs them:\n# the full image gets 51 from Playwright's --with-deps, slim had none, so from\n# 2026-09-29 (when non-browsing steps moved to slim) every resvg/sharp diagram\n# rendered its labels as empty boxes \u2014 expert-witness-desk's validator failed\n# four passes on a post image that was \"three empty boxes\". DejaVu covers\n# Latin, Greek and Cyrillic in a few MB; fontconfig is how renderers find it.\n# DejaVu alone still drew emoji, Chinese, Japanese, Korean, Devanagari and\n# Thai as empty boxes (reproduced 2026-10-01 with resvg-js 2.6 and sharp\n# 0.34), so four more, the smallest set that drew all of them \u2014 8 MB:\n# Symbola emoji and symbols, as outlines. NOT Noto Color Emoji (10 MB):\n# resvg draws nothing from its bitmap glyphs, so a resvg\n# diagram kept its empty boxes.\n# WenQuanYi Micro Hei Chinese, Japanese kana and Korean Hangul, one file.\n# Lohit Devanagari, Loma Hindi and Thai.\n# Here, not in base, so the full image's browser layers stay cached.\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends fonts-dejavu-core fontconfig fonts-symbola fonts-wqy-microhei fonts-lohit-deva fonts-tlwg-loma-otf \\\n && rm -rf /var/lib/apt/lists/* \\\n && fc-cache -f >/dev/null\n# The Playwright client only, the same pinned version as the browser pod's\n# server (the wire protocol is version-locked), so a tool that imports it\n# still loads; there is nothing here for it to launch.\n# npm's download cache is dropped in the same layer: it was 190 MB of every\n# image, read by nothing after the build.\nRUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null && npm cache clean --force >/dev/null 2>&1\nWORKDIR /opt/runner\nCOPY --from=core --chown=agent:agent /opt/runner/ ./\nCOPY --chown=agent:agent driver.mjs entry.sh ./\nRUN mkdir -p /workspace /library /opt/runner/job /home/agent/.foldrun/runner/.runtimes /opt/foldrun-runtimes/shared/.runtimes \\\n && chown agent:agent /opt/runner \\\n && chown -R agent:agent /workspace /library /opt/runner/job /home/agent/.foldrun /opt/foldrun-runtimes \\\n && chmod +x /opt/runner/entry.sh\nENTRYPOINT [\"/opt/runner/entry.sh\"]\n\nFROM base AS browsers\n# Real browsers, because directories and portals increasingly render with\n# JavaScript and a plain fetch sees only the empty shell. Chromium is the one the\n# browser tool drives by default; Firefox and WebKit (Safari's engine) are\n# there for an agent asked to see a page the way those render it. Installed\n# to a fixed path the agent user can read (the default cache would be\n# root's HOME). The browsers' own sandboxes are disabled at launch time \u2014 in\n# this platform the container/gVisor IS the sandbox, and the two fight.\n# Pinned exactly, not @1: web browse's numbered elements use ariaSnapshot's\n# \"ai\" mode (1.59+), and the per-account browser pod runs this same image, so\n# a floating version would move both under a working tool on any rebuild.\n# Raise it on purpose, with the gallery tests and one real call.\n# axe-core beside it is web browse's mode=a11y: one file the tool injects\n# into the page; without it the mode falls back to its own shorter checks.\n# Xvfb by name: the browser pod starts it at boot and web browse starts one\n# per call for headless: false. It used to arrive only as one of Playwright's\n# --with-deps packages, so a Playwright bump that dropped it would have\n# broken every windowed browser without a word. No xauth: nothing here runs\n# xvfb-run, and Xvfb started without -auth takes the local client as it is.\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends xvfb \\\n && rm -rf /var/lib/apt/lists/*\n# Google Chrome and Chrome beta come from Google's apt repository, which\n# Playwright adds; each is tried ENGINE_DOWNLOAD_ATTEMPTS times. Only on\n# x86_64: Google ships no Linux Chrome for arm64, so there it is not tried.\nRUN npm install -g playwright@1.63.0 axe-core@4.13.0 >/dev/null \\\n && playwright install --with-deps chromium firefox webkit >/dev/null \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && if [ \"$(uname -m)\" = x86_64 ]; then \\\n retry chrome playwright install --with-deps chrome >/dev/null || echo \"runner image: chrome not installed - engine: chrome would fall back to chromium\" >&2; \\\n retry chrome-beta playwright install --with-deps chrome-beta >/dev/null || echo \"runner image: chrome-beta not installed\" >&2; \\\n else echo \"runner image: no Google Chrome for $(uname -m) - engine: chrome falls back to chromium\"; fi \\\n && chmod -R a+rX /opt/browser \\\n && npm cache clean --force >/dev/null 2>&1\n# Lightpanda (engine: lightpanda): a browser that runs the JavaScript and\n# never draws \u2014 a fraction of Chromium's memory for reading and filling\n# pages. Its own program, which web browse starts beside a call and drives\n# over the DevTools protocol; shipped unmodified (AGPL-3.0). Pinned by\n# checksum (GitHub's own asset digests, checked again here). 0.4.1 since the\n# move to Debian 13: 0.3.7 on wants glibc 2.38, and Debian 12's 2.36 held it\n# at 0.3.6. An arch without a build leaves the engine out, and the tool says\n# so when asked for it. Each download (with its checksum) is tried\n# ENGINE_DOWNLOAD_ATTEMPTS times before the engine is left out.\n# Raise it on purpose: the new release's two sha256s, then one\n# engine=lightpanda call on each arch.\nRUN arch=$(uname -m) \\\n && case \"$arch\" in \\\n x86_64) sum=1d40801e72c0bc61b2cbd3f3562bcfc46de7b79e0568f33f686b64f2e587610a ;; \\\n aarch64) sum=664775c7f5ab69cc3189954c7f9345e25c167cb4dace016173e629f9a5e82c42 ;; \\\n *) sum= ;; \\\n esac \\\n && mkdir -p /opt/browser/lightpanda \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && fetch() { curl -fsSL -o /opt/browser/lightpanda/lightpanda \"https://github.com/lightpanda-io/browser/releases/download/0.4.1/lightpanda-$arch-linux\" \\\n && echo \"$sum /opt/browser/lightpanda/lightpanda\" | sha256sum -c - >/dev/null; } \\\n && if [ -n \"$sum\" ] && retry lightpanda fetch; then \\\n chmod a+rx /opt/browser/lightpanda/lightpanda && /opt/browser/lightpanda/lightpanda version; \\\n else rm -f /opt/browser/lightpanda/lightpanda; echo \"runner image: lightpanda not installed for $arch - engine: lightpanda says so when asked\" >&2; fi\n# Obscura (engine: obscura): a headless browser in Rust around V8 that does\n# draw \u2014 screenshots, a raster PDF, a screencast \u2014 light like Lightpanda.\n# Apache-2.0. The plain build, not -stealth: this platform does not help a\n# page not see automation. Two binaries, obscura and obscura-worker, which\n# must sit side by side. Pinned by checksum; glibc 2.35 is its floor, so\n# Debian 13 (2.41) runs it. As above, a download is tried\n# ENGINE_DOWNLOAD_ATTEMPTS times, then a failed one leaves the engine out.\nRUN arch=$(uname -m) \\\n && case \"$arch\" in \\\n x86_64) sum=1534d1e6ddaf3d080ec4091eb41d0a4d8cc042a48b607d3c410fc13b482a9eec ;; \\\n aarch64) sum=5ecf980bca3060236a7a86ec7ed83d943e6598ee87caa46d20325d90bc75f979 ;; \\\n *) sum= ;; \\\n esac \\\n && mkdir -p /opt/browser/obscura \\\n && retry() { what=$1; shift; n=1; until \"$@\"; do if [ \"$n\" -ge 3 ]; then echo \"runner image: $what - download failed $n times, giving up\" >&2; return 1; fi; echo \"runner image: $what - download failed (attempt $n of 3), retrying in $((n * 15))s\" >&2; sleep $((n * 15)); n=$((n + 1)); done; } \\\n && fetch() { curl -fsSL -o /tmp/obscura.tgz \"https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.3/obscura-$arch-linux.tar.gz\" \\\n && echo \"$sum /tmp/obscura.tgz\" | sha256sum -c - >/dev/null; } \\\n && if [ -n \"$sum\" ] && retry obscura fetch \\\n && tar -xzf /tmp/obscura.tgz -C /opt/browser/obscura obscura obscura-worker; then \\\n chmod a+rx /opt/browser/obscura/obscura /opt/browser/obscura/obscura-worker && /opt/browser/obscura/obscura --version; \\\n else rm -rf /opt/browser/obscura/*; echo \"runner image: obscura not installed for $arch - engine: obscura says so when asked\" >&2; fi \\\n && rm -f /tmp/obscura.tgz\n# What this image actually has, written down: /opt/browser/engines.json, one\n# entry per engine (its path and version, or null when the install above\n# left it out) plus Xvfb. Chromium's version is its own --version (dotted,\n# so FOLDRUN_RUNNER_ENGINES carries it: a pinned user_agent on engine\n# chromium is compared with it, not with Google Chrome's) then the build.\n# web browse reads it for its \"built without\" answer, and the deploy reads\n# it to report the engines beside the version.\n# On x86_64 a missing engine FAILS the build: every engine ships for that\n# arch, so a gap there is a failed download, and an image that quietly lacks\n# the Chrome or Lightpanda a desk names would fail those runs instead. A\n# failed build leaves the previous image running and says why. Elsewhere\n# (arm64 has no Google Chrome) the gap is recorded and the image ships.\nRUN node -e ' \\\n const fs = require(\"fs\"), cp = require(\"child_process\"), pw = require(\"playwright\"); \\\n const at = (p) => (p && fs.existsSync(p) ? p : null); \\\n 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; } }; \\\n const one = (p, v) => (p ? { path: p, version: v } : null); \\\n const pwBuild = (p) => (p ? (p.match(/(chromium|firefox|webkit)-(\\d+)/) || [])[0] || null : null); \\\n const lp = at(\"/opt/browser/lightpanda/lightpanda\"); \\\n const cr = at(pw.chromium.executablePath()); \\\n const ob = at(\"/opt/browser/obscura/obscura-worker\") && at(\"/opt/browser/obscura/obscura\"); \\\n const engines = { \\\n chromium: one(cr, cr && [ver(cr, [\"--version\"]), pwBuild(cr)].filter(Boolean).join(\" \")), \\\n chrome: one(at(\"/opt/google/chrome/chrome\"), ver(\"/opt/google/chrome/chrome\", [\"--version\"])), \\\n \"chrome-beta\": one(at(\"/opt/google/chrome-beta/chrome\"), ver(\"/opt/google/chrome-beta/chrome\", [\"--version\"])), \\\n firefox: one(at(pw.firefox.executablePath()), pwBuild(pw.firefox.executablePath())), \\\n webkit: one(at(pw.webkit.executablePath()), pwBuild(pw.webkit.executablePath())), \\\n lightpanda: one(lp, lp && ver(lp, [\"version\"])), \\\n obscura: one(ob, ob && ver(ob, [\"--version\"])), \\\n }; \\\n const xvfb = ver(\"sh\", [\"-c\", \"command -v Xvfb\"]); \\\n const arch = cp.execFileSync(\"uname\", [\"-m\"]).toString().trim(); \\\n const manifest = { arch, playwright: require(\"playwright/package.json\").version, engines, xvfb }; \\\n fs.writeFileSync(\"/opt/browser/engines.json\", JSON.stringify(manifest, null, 2) + \"\\n\"); \\\n console.log(JSON.stringify(manifest)); \\\n const missing = [\"chromium\", \"chrome\", \"firefox\", \"webkit\", \"lightpanda\", \"obscura\"].filter((e) => !engines[e]).concat(xvfb ? [] : [\"Xvfb\"]); \\\n if (missing.length && arch === \"x86_64\") { \\\n console.error(\"runner image: MISSING ENGINES on x86_64: \" + missing.join(\", \")); \\\n 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\"); \\\n process.exit(1); \\\n } \\\n if (missing.length) console.error(\"runner image: built without \" + missing.join(\", \") + \" on \" + arch); \\\n ' \\\n && chmod a+r /opt/browser/engines.json\n\nFROM browsers AS full\nWORKDIR /opt/runner\nCOPY --from=core --chown=agent:agent /opt/runner/ ./\nCOPY --chown=agent:agent driver.mjs entry.sh ./\nRUN mkdir -p /workspace /library /opt/runner/job /home/agent/.foldrun/runner/.runtimes /opt/foldrun-runtimes/shared/.runtimes \\\n && chown agent:agent /opt/runner \\\n && chown -R agent:agent /workspace /library /opt/runner/job /home/agent/.foldrun /opt/foldrun-runtimes \\\n && chmod +x /opt/runner/entry.sh\nENTRYPOINT [\"/opt/runner/entry.sh\"]\n";
|
|
275
275
|
/** Which build target of the runner image (see DOCKERFILE). */
|
|
276
276
|
export type RunnerVariant = "full" | "slim";
|
|
277
277
|
export declare function runnerImageTag(variant?: RunnerVariant): string;
|
|
@@ -23,7 +23,7 @@ import path from "node:path";
|
|
|
23
23
|
import crypto from "node:crypto";
|
|
24
24
|
import { spawn, spawnSync } from "node:child_process";
|
|
25
25
|
import { isPlatformPath } from "./store.js";
|
|
26
|
-
import { divertedSummary, isDivertedPath } from "./test-mode.js";
|
|
26
|
+
import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.js";
|
|
27
27
|
import { isFileValue, fileContent } from "./secrets.js";
|
|
28
28
|
import { safeTenantSegment } from "./runtime.js";
|
|
29
29
|
import { isOperatorEvent } from "./operator.js";
|
|
@@ -489,7 +489,7 @@ try {
|
|
|
489
489
|
// through the account's pod, and named the file the web tool logs its
|
|
490
490
|
// reconnects to. A lost pod stops the step; what it wrote decides what
|
|
491
491
|
// the runner does next (browser-pod.ts).
|
|
492
|
-
...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env) } } : {}),
|
|
492
|
+
...(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 } : {}) } } : {}),
|
|
493
493
|
// The container is the boundary; the SDK's own bash sandbox here would
|
|
494
494
|
// only block declared network use (SSH, curl) for no added safety.
|
|
495
495
|
sandboxBash: false,
|
|
@@ -518,17 +518,29 @@ try {
|
|
|
518
518
|
process.exit(0);
|
|
519
519
|
}
|
|
520
520
|
`;
|
|
521
|
+
// Core itself is installed in a stage of its own (CORE_STAGE) and only the
|
|
522
|
+
// result is copied in: the tarball and the package README — which explains
|
|
523
|
+
// foldrun to a person reading the repo, and which no step reads — stay in
|
|
524
|
+
// that stage and never become a layer of the image. Owner rule, 2026-10-02:
|
|
525
|
+
// foldrun's own docs live in git, not in containers.
|
|
526
|
+
const CORE_STAGE = `FROM base AS core
|
|
527
|
+
WORKDIR /opt/runner
|
|
528
|
+
COPY foldrun-core.tgz ./
|
|
529
|
+
RUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\
|
|
530
|
+
&& npm cache clean --force >/dev/null 2>&1 \\
|
|
531
|
+
&& rm -f foldrun-core.tgz node_modules/@foldrun/core/README.md
|
|
532
|
+
`;
|
|
521
533
|
// docker cp writes root-owned files, so the entrypoint starts as root for
|
|
522
534
|
// exactly two commands — chown the copied-in trees, drop to the agent user —
|
|
523
535
|
// and the run flags grant only the three capabilities those two commands
|
|
524
536
|
// need. By the time any model-directed code executes, the process is uid
|
|
525
537
|
// 10001 with no capabilities at all.
|
|
526
538
|
const CORE_INSTALL = `WORKDIR /opt/runner
|
|
527
|
-
COPY
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
&&
|
|
531
|
-
&& chown -R agent:agent /workspace /library /opt/runner /home/agent/.foldrun /opt/foldrun-runtimes \\
|
|
539
|
+
COPY --from=core --chown=agent:agent /opt/runner/ ./
|
|
540
|
+
COPY --chown=agent:agent driver.mjs entry.sh ./
|
|
541
|
+
RUN mkdir -p /workspace /library /opt/runner/job ${RUNTIME_CACHE} ${SHARED_RUNTIMES} \\
|
|
542
|
+
&& chown agent:agent /opt/runner \\
|
|
543
|
+
&& chown -R agent:agent /workspace /library /opt/runner/job /home/agent/.foldrun /opt/foldrun-runtimes \\
|
|
532
544
|
&& chmod +x /opt/runner/entry.sh
|
|
533
545
|
ENTRYPOINT ["/opt/runner/entry.sh"]
|
|
534
546
|
`;
|
|
@@ -590,6 +602,7 @@ RUN apt-get update \\
|
|
|
590
602
|
COPY --from=ghcr.io/astral-sh/uv:${UV_VERSION} /uv /uvx /usr/local/bin/
|
|
591
603
|
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules
|
|
592
604
|
|
|
605
|
+
${CORE_STAGE}
|
|
593
606
|
FROM base AS slim
|
|
594
607
|
# Fonts, because a step that draws text without a browser still needs them:
|
|
595
608
|
# the full image gets 51 from Playwright's --with-deps, slim had none, so from
|
|
@@ -1100,6 +1113,13 @@ export async function runStepInContainer(args) {
|
|
|
1100
1113
|
preserveTimestamps: true,
|
|
1101
1114
|
filter: (src) => !isPlatformPath(path.relative(args.workspaceRoot, src)),
|
|
1102
1115
|
});
|
|
1116
|
+
// A test run: what its earlier steps wrote, over the real files (the
|
|
1117
|
+
// copy is the baseline too, so only this step's own writes come back).
|
|
1118
|
+
if (args.divert) {
|
|
1119
|
+
const laid = overlayTestWrites(wsIn, args.divert.to);
|
|
1120
|
+
if (laid.length)
|
|
1121
|
+
args.emit("info", `test: this step sees ${laid.length} file${laid.length === 1 ? "" : "s"} earlier steps of this run wrote`);
|
|
1122
|
+
}
|
|
1103
1123
|
const libIn = path.join(staging, "library");
|
|
1104
1124
|
if (args.galleryRoot && fs.existsSync(args.galleryRoot))
|
|
1105
1125
|
fs.cpSync(args.galleryRoot, libIn, { recursive: true });
|