@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/package.json
CHANGED
package/src/browser-pod.ts
CHANGED
|
@@ -48,8 +48,9 @@ export const BROWSE_WRITE_ACTIONS = [
|
|
|
48
48
|
] as const;
|
|
49
49
|
|
|
50
50
|
/** Call arguments of web action=browse that are writes whatever the actions:
|
|
51
|
-
* page script
|
|
52
|
-
|
|
51
|
+
* page script (js=, or init= scripts run in every page), a paid captcha
|
|
52
|
+
* solver, a confirmation of a credential step. */
|
|
53
|
+
const BROWSE_WRITE_ARGS = ["js", "init", "captcha", "captcha_solver", "confirm"];
|
|
53
54
|
|
|
54
55
|
/** Web actions that only read — when foldrun's own answers them. A paid
|
|
55
56
|
* provider answering one (FOLDRUN_WEB_<ACTION>_VIA) makes it a write: doing
|
|
@@ -72,24 +73,59 @@ export interface ClassifyContext {
|
|
|
72
73
|
/** Web actions a paid provider answers this step — from the env's
|
|
73
74
|
* FOLDRUN_WEB_<ACTION>_VIA. */
|
|
74
75
|
paidWeb?: Record<string, string>;
|
|
76
|
+
/** Page scripts the agent's web.browse block runs in every page — the
|
|
77
|
+
* env's FOLDRUN_BROWSER_INIT. They make every browse call a write. */
|
|
78
|
+
browseInit?: string;
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* The browse actions a call asked for, nested `if` branches included.
|
|
83
|
+
*
|
|
84
|
+
* Read the way the web tool reads them (platform gallery.ts, browse.mjs
|
|
85
|
+
* checkSteps): `actions` is an array of steps, a step's action is one of its
|
|
86
|
+
* OWN keys (the first the tool knows), and the only steps inside a step are
|
|
87
|
+
* an `if`'s `then` and `else`. Every own key of a step is returned — the
|
|
88
|
+
* caller keeps the ones it cares about, and a step holding a write key beside
|
|
89
|
+
* a read key counts as the write. What sits inside an option's value — an
|
|
90
|
+
* extract's `fields` (column names), a mock's `json` body, a webmcp `input` —
|
|
91
|
+
* is data, not an action: `{"fields": {"type": "td.kind"}}` is no typing.
|
|
92
|
+
*
|
|
93
|
+
* A shape the tool would refuse (an object where the array belongs, a step
|
|
94
|
+
* that is not an object, `then` that is not a list) is walked key by key at
|
|
95
|
+
* every depth, as before; nesting past MAX_DEPTH is unreadable, so a write
|
|
96
|
+
* buried deep is never taken for a read.
|
|
97
|
+
*/
|
|
78
98
|
export function browseActionNames(actions: unknown): string[] {
|
|
99
|
+
const UNREADABLE = "(unreadable actions)";
|
|
100
|
+
const MAX_DEPTH = 32;
|
|
79
101
|
let steps: unknown = actions;
|
|
80
102
|
if (typeof steps === "string") {
|
|
81
|
-
try { steps = JSON.parse(steps); } catch { return [
|
|
103
|
+
try { steps = JSON.parse(steps); } catch { return [UNREADABLE]; }
|
|
82
104
|
}
|
|
83
105
|
const out: string[] = [];
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
106
|
+
// Unknown shapes: every key anywhere is a candidate action.
|
|
107
|
+
const walkAny = (v: unknown, depth: number) => {
|
|
108
|
+
if (v === null || typeof v !== "object") return;
|
|
109
|
+
if (depth > MAX_DEPTH) { out.push(UNREADABLE); return; }
|
|
110
|
+
if (Array.isArray(v)) { for (const x of v) walkAny(x, depth + 1); return; }
|
|
87
111
|
for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
|
|
88
112
|
out.push(k);
|
|
89
|
-
|
|
113
|
+
walkAny(x, depth + 1);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const walkSteps = (v: unknown, depth: number) => {
|
|
117
|
+
if (v === null || v === undefined) return;
|
|
118
|
+
if (depth > MAX_DEPTH) { out.push(UNREADABLE); return; }
|
|
119
|
+
if (!Array.isArray(v)) { walkAny(v, depth); return; }
|
|
120
|
+
for (const step of v) {
|
|
121
|
+
if (step === null || typeof step !== "object" || Array.isArray(step)) { walkAny(step, depth + 1); continue; }
|
|
122
|
+
for (const [k, x] of Object.entries(step as Record<string, unknown>)) {
|
|
123
|
+
out.push(k);
|
|
124
|
+
if (k === "then" || k === "else") walkSteps(x, depth + 1);
|
|
125
|
+
}
|
|
90
126
|
}
|
|
91
127
|
};
|
|
92
|
-
|
|
128
|
+
walkSteps(steps, 0);
|
|
93
129
|
return out;
|
|
94
130
|
}
|
|
95
131
|
|
|
@@ -132,6 +168,7 @@ export function classifyCall(tool: string, input: Record<string, unknown> | unde
|
|
|
132
168
|
if (action === "browse") {
|
|
133
169
|
const w = browseWrites(args);
|
|
134
170
|
if (w) return write(w);
|
|
171
|
+
if (ctx.browseInit) return write(`browse init scripts (${ctx.browseInit})`);
|
|
135
172
|
}
|
|
136
173
|
return { write: false };
|
|
137
174
|
}
|
package/src/container.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// development keeps working with zero configuration.
|
|
11
11
|
|
|
12
12
|
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
13
14
|
import path from "node:path";
|
|
14
15
|
import { dataRoot } from "./paths.ts";
|
|
15
16
|
import { spawnSync, spawn } from "node:child_process";
|
|
@@ -131,6 +132,10 @@ export interface ContainerRunOptions {
|
|
|
131
132
|
agentDir: string;
|
|
132
133
|
/** Extra read-only mounts: host path → container path. */
|
|
133
134
|
readOnly?: Record<string, string>;
|
|
135
|
+
/** Workspace paths a script was handed as `workspace/…`: copied in at
|
|
136
|
+
* `container` before it runs, and what it created or changed under them
|
|
137
|
+
* copied back to `host` after — the host's workspace link, as a copy. */
|
|
138
|
+
staged?: { host: string; container: string }[];
|
|
134
139
|
image: string;
|
|
135
140
|
argv: string[]; // command inside the container
|
|
136
141
|
env: Record<string, string>;
|
|
@@ -181,15 +186,36 @@ export async function runInContainer(
|
|
|
181
186
|
const cid = (created.stdout ?? "").trim();
|
|
182
187
|
|
|
183
188
|
try {
|
|
184
|
-
// The agent's own directory becomes /workspace…
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
+
// The agent's own directory becomes /workspace… — its entries staged
|
|
190
|
+
// like the workspace paths below (a scratch copy, opened up for the
|
|
191
|
+
// script's user, one tar), minus the step's `workspace` link. A step
|
|
192
|
+
// holds that link (→ ../..) so the shell can say workspace/…, and
|
|
193
|
+
// `docker cp` refuses a link that climbs out of what it copies
|
|
194
|
+
// ("invalid symlink"), so every Docker-run script failed before it
|
|
195
|
+
// started. And `docker cp` lands files as root while scripts run as
|
|
196
|
+
// `agent`, so a script could never write its own outputs/.
|
|
197
|
+
const own = fs
|
|
198
|
+
.readdirSync(opts.agentDir)
|
|
199
|
+
.filter((name) => {
|
|
200
|
+
if (name !== "workspace") return true;
|
|
201
|
+
try {
|
|
202
|
+
return !fs.lstatSync(path.join(opts.agentDir, name)).isSymbolicLink();
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
.map((name) => ({ host: path.join(opts.agentDir, name), container: `/workspace/${name}` }));
|
|
208
|
+
const copiedIn = stageIn(CLI, cid, own, ["/workspace"]);
|
|
209
|
+
if (copiedIn) return { code: null, out: `copy in failed: ${copiedIn}` };
|
|
189
210
|
// …and any shared directories land at their declared paths.
|
|
190
211
|
for (const [host, mount] of Object.entries(opts.readOnly ?? {})) {
|
|
191
212
|
if (fs.existsSync(host)) docker(["cp", `${host}/.`, `${cid}:${mount}`], 120_000);
|
|
192
213
|
}
|
|
214
|
+
const staged = opts.staged ?? [];
|
|
215
|
+
if (staged.length) {
|
|
216
|
+
const failed = stageIn(CLI, cid, staged);
|
|
217
|
+
if (failed) return { code: null, out: `copy in failed: ${failed}` };
|
|
218
|
+
}
|
|
193
219
|
|
|
194
220
|
const result = await new Promise<{ code: number | null; out: string }>((resolve) => {
|
|
195
221
|
const child = spawn(CLI, ["start", "-a", cid], { timeout: opts.timeoutMs });
|
|
@@ -213,8 +239,89 @@ export async function runInContainer(
|
|
|
213
239
|
|
|
214
240
|
// Bring deliverables back so outputs/ behaves the same either way.
|
|
215
241
|
docker(["cp", `${cid}:/workspace/outputs/.`, path.join(opts.agentDir, "outputs")], 120_000);
|
|
242
|
+
for (const s of staged) stageBack(CLI, cid, s);
|
|
216
243
|
return result;
|
|
217
244
|
} finally {
|
|
218
245
|
docker(["rm", "-f", cid], 30_000);
|
|
219
246
|
}
|
|
220
247
|
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Copy the staged workspace paths into a created container: one tar, built
|
|
251
|
+
* in a scratch directory that mirrors the container paths, extracted at /.
|
|
252
|
+
* A path that does not exist yet (an output the script is told to write)
|
|
253
|
+
* gets its parent directory, so the write has somewhere to land.
|
|
254
|
+
*/
|
|
255
|
+
function stageIn(cli: string, cid: string, staged: { host: string; container: string }[], dirs: string[] = []): string | null {
|
|
256
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-stage-"));
|
|
257
|
+
try {
|
|
258
|
+
for (const d of dirs) fs.mkdirSync(path.join(scratch, d), { recursive: true });
|
|
259
|
+
for (const { host, container } of staged) {
|
|
260
|
+
const dest = path.join(scratch, container);
|
|
261
|
+
if (fs.existsSync(host)) {
|
|
262
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
263
|
+
fs.cpSync(host, dest, { recursive: true, dereference: false });
|
|
264
|
+
} else {
|
|
265
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// `docker cp` lands files as root and scripts run as `agent`: these are
|
|
269
|
+
// scratch copies, so open them up for the script to write into.
|
|
270
|
+
openUp(scratch);
|
|
271
|
+
const top = fs.readdirSync(scratch);
|
|
272
|
+
const r = spawnSync(
|
|
273
|
+
"sh",
|
|
274
|
+
["-c", 'dir="$1"; cli="$2"; dest="$3"; shift 3; tar --no-xattrs -C "$dir" -cf - -- "$@" | "$cli" cp - "$dest"', "sh", scratch, cli, `${cid}:/`, ...top],
|
|
275
|
+
{ encoding: "utf8", timeout: 120_000, env: { ...process.env, COPYFILE_DISABLE: "1" } },
|
|
276
|
+
);
|
|
277
|
+
return r.status === 0 ? null : (r.stderr ?? "").slice(-300);
|
|
278
|
+
} finally {
|
|
279
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Copy back what the script created or changed under one staged path. Files
|
|
285
|
+
* whose bytes did not change are left alone, so an input keeps its mtime —
|
|
286
|
+
* freshness checks read it.
|
|
287
|
+
*/
|
|
288
|
+
function stageBack(cli: string, cid: string, s: { host: string; container: string }): void {
|
|
289
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-stage-"));
|
|
290
|
+
try {
|
|
291
|
+
const out = path.join(scratch, "x");
|
|
292
|
+
const got = spawnSync(cli, ["cp", `${cid}:${s.container}`, out], { encoding: "utf8", timeout: 120_000 });
|
|
293
|
+
if (got.status !== 0 || !fs.existsSync(out)) return;
|
|
294
|
+
syncChanged(out, s.host);
|
|
295
|
+
} finally {
|
|
296
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Write `from` over `to` where the bytes differ; recurse into folders. */
|
|
301
|
+
export function syncChanged(from: string, to: string): void {
|
|
302
|
+
const st = fs.lstatSync(from);
|
|
303
|
+
if (st.isSymbolicLink()) return;
|
|
304
|
+
if (st.isDirectory()) {
|
|
305
|
+
fs.mkdirSync(to, { recursive: true });
|
|
306
|
+
for (const name of fs.readdirSync(from)) syncChanged(path.join(from, name), path.join(to, name));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const next = fs.readFileSync(from);
|
|
310
|
+
if (fs.existsSync(to) && fs.statSync(to).isFile() && fs.readFileSync(to).equals(next)) return;
|
|
311
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
312
|
+
fs.writeFileSync(to, next);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Directories 0777, files writable by all (exec bits kept): a scratch tree only. */
|
|
316
|
+
function openUp(dir: string): void {
|
|
317
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
318
|
+
const p = path.join(dir, entry.name);
|
|
319
|
+
if (entry.isSymbolicLink()) continue;
|
|
320
|
+
if (entry.isDirectory()) {
|
|
321
|
+
fs.chmodSync(p, 0o777);
|
|
322
|
+
openUp(p);
|
|
323
|
+
} else {
|
|
324
|
+
fs.chmodSync(p, fs.statSync(p).mode | 0o666);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
package/src/context-tools.ts
CHANGED
|
@@ -32,7 +32,7 @@ import type { RunRecord } from "./store.ts";
|
|
|
32
32
|
// ------------------------------------------------------------------ search
|
|
33
33
|
|
|
34
34
|
export interface SearchRoot {
|
|
35
|
-
/** How the agent should name the place: "knowledge/", "
|
|
35
|
+
/** How the agent should name the place: "knowledge/", "workspace/memory/". */
|
|
36
36
|
label: string;
|
|
37
37
|
/** The directory, absolute. */
|
|
38
38
|
dir: string;
|
package/src/evals.ts
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
// they pass — no point paying a model to grade output already known to be
|
|
32
32
|
// wrong.
|
|
33
33
|
|
|
34
|
+
import { sdkEnv } from "./step-exec.ts";
|
|
34
35
|
import fs from "node:fs";
|
|
35
36
|
import path from "node:path";
|
|
36
37
|
import { deployedCommit } from "./deploy.ts";
|
|
@@ -491,6 +492,8 @@ async function judge(
|
|
|
491
492
|
tools: [],
|
|
492
493
|
allowedTools: [],
|
|
493
494
|
settingSources: [],
|
|
495
|
+
strictMcpConfig: true,
|
|
496
|
+
env: sdkEnv(undefined),
|
|
494
497
|
maxTurns: 1,
|
|
495
498
|
},
|
|
496
499
|
});
|
package/src/host-env.ts
CHANGED
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
const EXACT = new Set([
|
|
21
21
|
"PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE",
|
|
22
22
|
"TZ", "TERM", "SHELL", "USER", "LOGNAME",
|
|
23
|
+
// Where Claude Code / the Agent SDK keeps its config and session
|
|
24
|
+
// transcripts — the same kind of thing as HOME (a path, not a secret), and
|
|
25
|
+
// without it an operator cannot keep them out of the host user's
|
|
26
|
+
// ~/.claude: the box's deploy tests set it to the job's temp (2026-10-02).
|
|
27
|
+
"CLAUDE_CONFIG_DIR",
|
|
23
28
|
// The host's way to the network is the child's way to the network.
|
|
24
29
|
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
|
|
25
30
|
"SSL_CERT_FILE", "SSL_CERT_DIR",
|
package/src/mail.ts
CHANGED
|
@@ -168,7 +168,9 @@ export function readUnsubscribeToken(
|
|
|
168
168
|
const [payload, sig, extra] = token.split(".");
|
|
169
169
|
if (!payload || !sig || extra !== undefined) return { error: "invalid token", status: 401 };
|
|
170
170
|
const want = sign(payload);
|
|
171
|
-
|
|
171
|
+
// Only base64url reaches the compare: a multibyte character would make the
|
|
172
|
+
// byte buffers differ in length, and timingSafeEqual throws on that.
|
|
173
|
+
if (!/^[A-Za-z0-9_-]+$/.test(sig) || sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) {
|
|
172
174
|
return { error: "invalid token", status: 401 };
|
|
173
175
|
}
|
|
174
176
|
let raw: { t?: unknown; e?: unknown; c?: unknown; w?: unknown; x?: unknown };
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
|
|
24
|
+
export type ModelCredentialKind = "api-key" | "auth-token" | "claude-login" | "none";
|
|
25
|
+
|
|
26
|
+
export interface ModelCredential {
|
|
27
|
+
kind: ModelCredentialKind;
|
|
28
|
+
/** What the run record and the CLI say: which credential, by its variable. */
|
|
29
|
+
label: string;
|
|
30
|
+
/** The variables the step's Claude Code is started with. */
|
|
31
|
+
env: Record<string, string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const set = (v: string | undefined) => typeof v === "string" && v.trim() !== "";
|
|
35
|
+
|
|
36
|
+
export function resolveModelCredential(source: Record<string, string | undefined> = process.env): ModelCredential {
|
|
37
|
+
const base: Record<string, string> = set(source.ANTHROPIC_BASE_URL) ? { ANTHROPIC_BASE_URL: source.ANTHROPIC_BASE_URL! } : {};
|
|
38
|
+
if (set(source.ANTHROPIC_API_KEY)) {
|
|
39
|
+
return {
|
|
40
|
+
kind: "api-key",
|
|
41
|
+
label: "API key (ANTHROPIC_API_KEY)",
|
|
42
|
+
env: { ...base, ANTHROPIC_API_KEY: source.ANTHROPIC_API_KEY!, CLAUDE_CODE_OAUTH_TOKEN: "" },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (set(source.ANTHROPIC_AUTH_TOKEN)) {
|
|
46
|
+
return {
|
|
47
|
+
kind: "auth-token",
|
|
48
|
+
label: `bearer token (ANTHROPIC_AUTH_TOKEN)${base.ANTHROPIC_BASE_URL ? ` to ${base.ANTHROPIC_BASE_URL}` : ""}`,
|
|
49
|
+
env: { ...base, ANTHROPIC_AUTH_TOKEN: source.ANTHROPIC_AUTH_TOKEN!, ANTHROPIC_API_KEY: "", CLAUDE_CODE_OAUTH_TOKEN: "" },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (set(source.CLAUDE_CODE_OAUTH_TOKEN)) {
|
|
53
|
+
return {
|
|
54
|
+
kind: "claude-login",
|
|
55
|
+
label: "Claude login token (CLAUDE_CODE_OAUTH_TOKEN)",
|
|
56
|
+
env: { ...base, CLAUDE_CODE_OAUTH_TOKEN: source.CLAUDE_CODE_OAUTH_TOKEN!, ANTHROPIC_API_KEY: "" },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { kind: "none", label: "none set", env: {} };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The line a step's trail carries, and what goes wrong when there is none. */
|
|
63
|
+
export function credentialLine(c: ModelCredential): { type: "info" | "error"; text: string } {
|
|
64
|
+
if (c.kind !== "none") return { type: "info", text: `model credential: ${c.label}` };
|
|
65
|
+
return {
|
|
66
|
+
type: "error",
|
|
67
|
+
text:
|
|
68
|
+
"model credential: none set — Claude Code will fall back to whatever login this machine has, if any. " +
|
|
69
|
+
"Set ANTHROPIC_API_KEY, or CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`",
|
|
70
|
+
};
|
|
71
|
+
}
|
package/src/run-container.ts
CHANGED
|
@@ -25,7 +25,7 @@ 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";
|
|
@@ -617,7 +617,7 @@ try {
|
|
|
617
617
|
// through the account's pod, and named the file the web tool logs its
|
|
618
618
|
// reconnects to. A lost pod stops the step; what it wrote decides what
|
|
619
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) } } : {}),
|
|
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 } : {}) } } : {}),
|
|
621
621
|
// The container is the boundary; the SDK's own bash sandbox here would
|
|
622
622
|
// only block declared network use (SSH, curl) for no added safety.
|
|
623
623
|
sandboxBash: false,
|
|
@@ -647,17 +647,30 @@ try {
|
|
|
647
647
|
}
|
|
648
648
|
`;
|
|
649
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
|
+
|
|
650
663
|
// docker cp writes root-owned files, so the entrypoint starts as root for
|
|
651
664
|
// exactly two commands — chown the copied-in trees, drop to the agent user —
|
|
652
665
|
// and the run flags grant only the three capabilities those two commands
|
|
653
666
|
// need. By the time any model-directed code executes, the process is uid
|
|
654
667
|
// 10001 with no capabilities at all.
|
|
655
668
|
const CORE_INSTALL = `WORKDIR /opt/runner
|
|
656
|
-
COPY
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
&&
|
|
660
|
-
&& chown -R agent:agent /workspace /library /opt/runner /home/agent/.foldrun /opt/foldrun-runtimes \\
|
|
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 \\
|
|
661
674
|
&& chmod +x /opt/runner/entry.sh
|
|
662
675
|
ENTRYPOINT ["/opt/runner/entry.sh"]
|
|
663
676
|
`;
|
|
@@ -724,6 +737,7 @@ RUN apt-get update \\
|
|
|
724
737
|
COPY --from=ghcr.io/astral-sh/uv:${UV_VERSION} /uv /uvx /usr/local/bin/
|
|
725
738
|
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules
|
|
726
739
|
|
|
740
|
+
${CORE_STAGE}
|
|
727
741
|
FROM base AS slim
|
|
728
742
|
# Fonts, because a step that draws text without a browser still needs them:
|
|
729
743
|
# the full image gets 51 from Playwright's --with-deps, slim had none, so from
|
|
@@ -1299,6 +1313,12 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
|
|
|
1299
1313
|
preserveTimestamps: true,
|
|
1300
1314
|
filter: (src) => !isPlatformPath(path.relative(args.workspaceRoot, src)),
|
|
1301
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
|
+
}
|
|
1302
1322
|
const libIn = path.join(staging, "library");
|
|
1303
1323
|
if (args.galleryRoot && fs.existsSync(args.galleryRoot)) fs.cpSync(args.galleryRoot, libIn, { recursive: true });
|
|
1304
1324
|
if (fs.existsSync(args.libraryRoot)) fs.cpSync(args.libraryRoot, libIn, { recursive: true });
|