@foldrun/core 0.5.0 → 0.7.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/agent-import.d.ts +21 -0
- package/dist/src/agent-import.js +116 -0
- package/dist/src/api-tools.js +14 -2
- package/dist/src/approvals.d.ts +8 -1
- package/dist/src/approvals.js +10 -5
- package/dist/src/browser-pod.d.ts +20 -1
- package/dist/src/browser-pod.js +58 -9
- package/dist/src/completions.js +1 -0
- package/dist/src/confine.d.ts +1 -0
- package/dist/src/confine.js +33 -4
- package/dist/src/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 +9 -0
- package/dist/src/deploy.js +31 -3
- package/dist/src/evals.js +24 -4
- package/dist/src/host-env.d.ts +14 -0
- package/dist/src/host-env.js +31 -0
- package/dist/src/install-helper.d.ts +17 -0
- package/dist/src/install-helper.js +134 -0
- package/dist/src/install-tool.d.ts +128 -0
- package/dist/src/install-tool.js +133 -0
- package/dist/src/mail.js +3 -1
- package/dist/src/model-credential.d.ts +30 -0
- package/dist/src/model-credential.js +98 -0
- package/dist/src/model-env.d.ts +17 -0
- package/dist/src/model-env.js +57 -0
- package/dist/src/package.d.ts +67 -0
- package/dist/src/package.js +594 -0
- package/dist/src/platform.d.ts +5 -0
- package/dist/src/platform.js +1 -0
- package/dist/src/provider-health.js +3 -0
- package/dist/src/run-container.d.ts +6 -1
- package/dist/src/run-container.js +93 -20
- package/dist/src/runner.d.ts +239 -0
- package/dist/src/runner.js +380 -72
- package/dist/src/runtime.d.ts +77 -0
- package/dist/src/runtime.js +230 -5
- package/dist/src/script-tools.d.ts +16 -0
- package/dist/src/script-tools.js +50 -8
- package/dist/src/secrets.d.ts +5 -0
- package/dist/src/secrets.js +37 -6
- package/dist/src/starter.d.ts +12 -0
- package/dist/src/starter.js +36 -4
- package/dist/src/step-exec.d.ts +22 -2
- package/dist/src/step-exec.js +70 -7
- package/dist/src/storage.d.ts +7 -1
- package/dist/src/storage.js +14 -1
- package/dist/src/store.d.ts +24 -0
- package/dist/src/store.js +91 -10
- 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/dist/src/tool-names.d.ts +3 -1
- package/dist/src/tool-names.js +4 -2
- package/dist/src/tool-test.js +8 -1
- package/dist/src/zip.d.ts +17 -0
- package/dist/src/zip.js +184 -0
- package/index.ts +4 -0
- package/package.json +1 -1
- package/src/agent-import.ts +154 -0
- package/src/api-tools.ts +16 -1
- package/src/approvals.ts +10 -3
- package/src/browser-pod.ts +46 -9
- package/src/completions.ts +1 -0
- package/src/confine.ts +36 -5
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +28 -3
- package/src/evals.ts +23 -2
- package/src/host-env.ts +32 -0
- package/src/install-helper.ts +143 -0
- package/src/install-tool.ts +154 -0
- package/src/mail.ts +3 -1
- package/src/model-credential.ts +112 -0
- package/src/model-env.ts +66 -0
- package/src/package.ts +664 -0
- package/src/platform.ts +6 -0
- package/src/provider-health.ts +3 -0
- package/src/run-container.ts +92 -19
- package/src/runner.ts +385 -72
- package/src/runtime.ts +235 -5
- package/src/script-tools.ts +48 -6
- package/src/secrets.ts +34 -6
- package/src/starter.ts +42 -4
- package/src/step-exec.ts +75 -10
- package/src/storage.ts +13 -0
- package/src/store.ts +91 -9
- package/src/subagents.ts +1 -1
- package/src/test-mode.ts +51 -11
- package/src/tool-names.ts +4 -2
- package/src/tool-test.ts +8 -1
- package/src/zip.ts +189 -0
- 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/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/completions.ts
CHANGED
|
@@ -76,6 +76,7 @@ const TOOL_GROUPS: Completion[] = [
|
|
|
76
76
|
{ label: "code", hint: "Bash — runs anything in the sandbox" },
|
|
77
77
|
{ label: "web", hint: "search, fetch, browse, crawl, map, extract, answer, monitor — providers under web:" },
|
|
78
78
|
{ label: "ask", hint: "ask_person — ask the person running the desk mid-step, and wait for the answer" },
|
|
79
|
+
{ label: "install", hint: "install apt, pip or npm packages mid-step — every agent has it; disallowedTools: [install] removes it" },
|
|
79
80
|
];
|
|
80
81
|
|
|
81
82
|
const SDK_TOOL_NAMES: Completion[] = [
|
package/src/confine.ts
CHANGED
|
@@ -193,7 +193,7 @@ export function isWithin(root: string, abs: string): boolean {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
/** Expand a virtual prefix to a real absolute path, or null if it has none. */
|
|
196
|
-
export function expandVirtual(raw: string, roots: Roots): { abs: string; readOnly: boolean } | null {
|
|
196
|
+
export function expandVirtual(raw: string, roots: Roots): { abs: string; readOnly: boolean; prefix: string } | null {
|
|
197
197
|
// The SDK resolves a relative path against the cwd BEFORE canUseTool sees
|
|
198
198
|
// it, so `workspace/storage/x.json` arrives as
|
|
199
199
|
// `<agentDir>/workspace/storage/x.json` and never matched a prefix. The
|
|
@@ -205,12 +205,23 @@ export function expandVirtual(raw: string, roots: Roots): { abs: string; readOnl
|
|
|
205
205
|
const candidate = rel !== null && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw;
|
|
206
206
|
for (const v of VIRTUAL) {
|
|
207
207
|
if (candidate.startsWith(v.prefix)) {
|
|
208
|
-
return { abs: path.resolve(v.root(roots), candidate.slice(v.prefix.length)), readOnly: v.readOnly };
|
|
208
|
+
return { abs: path.resolve(v.root(roots), candidate.slice(v.prefix.length)), readOnly: v.readOnly, prefix: v.prefix };
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
211
|
return null;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/** Whether `<agentDir>/workspace` is the step's link to the workspace root
|
|
215
|
+
* (linkWorkspace), so a `workspace/…` path opens the right file as written. */
|
|
216
|
+
function linkedWorkspace(roots: Roots): boolean {
|
|
217
|
+
const link = path.join(roots.agentDir, "workspace");
|
|
218
|
+
try {
|
|
219
|
+
return fs.lstatSync(link).isSymbolicLink() && real(link) === real(path.resolve(roots.workspaceRoot));
|
|
220
|
+
} catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
214
225
|
export function checkPaths(
|
|
215
226
|
toolName: string,
|
|
216
227
|
input: Record<string, unknown>,
|
|
@@ -312,8 +323,16 @@ export function checkPaths(
|
|
|
312
323
|
}
|
|
313
324
|
|
|
314
325
|
// Hand the real path back to the tool, so the virtual prefix the prompt
|
|
315
|
-
// advertises is one the agent can actually open
|
|
316
|
-
|
|
326
|
+
// advertises is one the agent can actually open — unless the step's
|
|
327
|
+
// `workspace` link already opens it. Then the path is left as written,
|
|
328
|
+
// because the SDK remembers what was Read under the path the tool ran
|
|
329
|
+
// with but checks an Edit against the one the model wrote, before this
|
|
330
|
+
// hook sees it: Read workspace/x (rewritten) then Edit workspace/x was
|
|
331
|
+
// refused "File has not been read yet" on every try, and blog-desk's
|
|
332
|
+
// fact-checker spent a pass on it (run-muuhguk3-vhrg, 2026-10-05).
|
|
333
|
+
if (virtual && !(virtual.prefix === "workspace/" && linkedWorkspace(roots))) {
|
|
334
|
+
rewritten = { ...(rewritten ?? input), [key]: abs };
|
|
335
|
+
}
|
|
317
336
|
}
|
|
318
337
|
|
|
319
338
|
// Glob searches from `path` (the cwd when absent) and its PATTERN never
|
|
@@ -407,6 +426,17 @@ const PROTECTED_BASH: { re: RegExp; writeOnly: boolean; reason: string }[] = [
|
|
|
407
426
|
},
|
|
408
427
|
];
|
|
409
428
|
|
|
429
|
+
/**
|
|
430
|
+
* A web address, which names a place on a server rather than in the workspace.
|
|
431
|
+
*
|
|
432
|
+
* The protected-path patterns match a bare `runs/`, so an agent calling the
|
|
433
|
+
* platform's own API — `curl https://…/api/workspaces/x/runs/<id>` — was told
|
|
434
|
+
* it was reading the run journal. The weekly retros read their week through
|
|
435
|
+
* that API and were refused on 9 Oct 2026. Only http(s): a `file://` URL is a
|
|
436
|
+
* path, and stays checked.
|
|
437
|
+
*/
|
|
438
|
+
const WEB_URL = /\bhttps?:\/\/[^\s'"`)]+/gi;
|
|
439
|
+
|
|
410
440
|
export function checkBash(command: string): ConfineVerdict {
|
|
411
441
|
for (const re of BASH_ESCAPES) {
|
|
412
442
|
if (re.test(command)) {
|
|
@@ -424,9 +454,10 @@ export function checkBash(command: string): ConfineVerdict {
|
|
|
424
454
|
for (const segment of command.split(/[;|&]+|\n/)) {
|
|
425
455
|
if (!segment.trim()) continue;
|
|
426
456
|
const writes = WRITES_TO_A_FILE.test(segment);
|
|
457
|
+
const paths = segment.replace(WEB_URL, " ");
|
|
427
458
|
for (const rule of PROTECTED_BASH) {
|
|
428
459
|
if (rule.writeOnly && !writes) continue;
|
|
429
|
-
if (rule.re.test(
|
|
460
|
+
if (rule.re.test(paths)) return { ok: false, reason: `Bash was denied: ${rule.reason}` };
|
|
430
461
|
}
|
|
431
462
|
}
|
|
432
463
|
return { ok: true };
|
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/deploy.ts
CHANGED
|
@@ -208,6 +208,25 @@ export function readTree(dir: string): DeployFile[] {
|
|
|
208
208
|
* only the deployed files, so a library or gallery outward tool is not seen
|
|
209
209
|
* here — `foldrun check`, which resolves every scope, remains the real gate.
|
|
210
210
|
* Non-blocking by decision: the deploy proceeds and says so. */
|
|
211
|
+
/**
|
|
212
|
+
* A frontmatter block that lost its opening `---`: `key: value` lines, then a
|
|
213
|
+
* lone `---`, with nothing above them. Nothing is read from it — the parser
|
|
214
|
+
* sees prose — so the agent runs with no tools and the default model, and a
|
|
215
|
+
* model told to call a tool it was never given writes the call out as text.
|
|
216
|
+
* seo-digest's editor did that every Sunday from 20 Sep to 5 Oct 2026.
|
|
217
|
+
* Returns the problem, or null.
|
|
218
|
+
*/
|
|
219
|
+
export function unopenedFrontmatter(raw: string): string | null {
|
|
220
|
+
const text = raw.replace(/^\uFEFF/, "");
|
|
221
|
+
if (/^---\r?\n/.test(text)) return null;
|
|
222
|
+
const lines = text.split(/\r?\n/);
|
|
223
|
+
const close = lines.findIndex((l) => l.trim() === "---");
|
|
224
|
+
if (close < 1) return null;
|
|
225
|
+
const above = lines.slice(0, close).filter((l) => l.trim() && !l.trim().startsWith("#"));
|
|
226
|
+
if (!above.length || !above.every((l) => /^[A-Za-z_][\w-]*:(\s|$)/.test(l) || /^\s+\S/.test(l))) return null;
|
|
227
|
+
return "the frontmatter has no opening `---`, so none of it is read: the agent runs with no tools and the default model. Add `---` as the first line.";
|
|
228
|
+
}
|
|
229
|
+
|
|
211
230
|
export function deployWarnings(files: DeployFile[]): DeployIssue[] {
|
|
212
231
|
const front = (content: string): Record<string, unknown> => {
|
|
213
232
|
try { return matter(content).data as Record<string, unknown>; } catch { return {}; }
|
|
@@ -227,6 +246,9 @@ export function deployWarnings(files: DeployFile[]): DeployIssue[] {
|
|
|
227
246
|
const outwardAgents: string[] = [];
|
|
228
247
|
const agentNames: string[] = [];
|
|
229
248
|
const out: DeployIssue[] = [];
|
|
249
|
+
if (!files.some((f) => /^agents\/[^/]+\/agent\.md$/.test(f.path)) && !files.some((f) => /^\.(claude|agents)\/agents\/[^/]+\.md$/.test(f.path))) {
|
|
250
|
+
out.push({ where: "agents/", message: "no agents yet — add one (agents/<name>/agent.md), or import one from another workspace" });
|
|
251
|
+
}
|
|
230
252
|
// The web: warnings check prints — the old session: key, and a pinned
|
|
231
253
|
// user agent behind the runner image's Chrome, which the platform knows
|
|
232
254
|
// from FOLDRUN_RUNNER_ENGINES (the deploy read it from the image).
|
|
@@ -284,6 +306,8 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
284
306
|
// written, not discovered as a date that is a day out.
|
|
285
307
|
for (const f of files) {
|
|
286
308
|
if (!/^(AGENTS\.md|agents\/[^/]+\/agent\.md)$/.test(f.path)) continue;
|
|
309
|
+
const unopened = unopenedFrontmatter(f.content);
|
|
310
|
+
if (unopened) at(f.path, unopened);
|
|
287
311
|
let front: Record<string, unknown> = {};
|
|
288
312
|
try {
|
|
289
313
|
front = matter(f.content).data as Record<string, unknown>;
|
|
@@ -306,9 +330,10 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
306
330
|
if (lang) at(f.path, lang);
|
|
307
331
|
for (const w of localeProblems(front)) at(f.path, w);
|
|
308
332
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
333
|
+
// No agents is a workspace that has not started yet — "Create workspace"
|
|
334
|
+
// makes exactly that — and deploying it is harmless: a flow that names an
|
|
335
|
+
// agent this push lacks is refused below, step by step. deployWarnings says
|
|
336
|
+
// it, so the empty state is visible without blocking the first push.
|
|
312
337
|
|
|
313
338
|
// Flows, parsed from the incoming text rather than from disk. A step naming
|
|
314
339
|
// an agent that this push does not ship is the single most common way a
|
package/src/evals.ts
CHANGED
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
// they pass — no point paying a model to grade output already known to be
|
|
32
32
|
// wrong.
|
|
33
33
|
|
|
34
|
+
import { accountModelEnv } from "./model-env.ts";
|
|
35
|
+
import { sdkEnv } from "./step-exec.ts";
|
|
34
36
|
import fs from "node:fs";
|
|
35
37
|
import path from "node:path";
|
|
36
38
|
import { deployedCommit } from "./deploy.ts";
|
|
@@ -476,6 +478,7 @@ async function judge(
|
|
|
476
478
|
output: string,
|
|
477
479
|
model: string,
|
|
478
480
|
effort: Effort | null,
|
|
481
|
+
env: Record<string, string | undefined>,
|
|
479
482
|
): Promise<AssertionResult["detail"] & string> {
|
|
480
483
|
const prompt =
|
|
481
484
|
`You are grading one piece of output against one criterion. Answer with exactly ` +
|
|
@@ -491,6 +494,8 @@ async function judge(
|
|
|
491
494
|
tools: [],
|
|
492
495
|
allowedTools: [],
|
|
493
496
|
settingSources: [],
|
|
497
|
+
strictMcpConfig: true,
|
|
498
|
+
env,
|
|
494
499
|
maxTurns: 1,
|
|
495
500
|
},
|
|
496
501
|
});
|
|
@@ -510,6 +515,7 @@ async function checkAssertion(
|
|
|
510
515
|
agentDir: string,
|
|
511
516
|
model: string,
|
|
512
517
|
effort: Effort | null,
|
|
518
|
+
judgeEnv: () => Record<string, string | undefined> = () => sdkEnv(undefined),
|
|
513
519
|
): Promise<AssertionResult> {
|
|
514
520
|
const hay = output.toLowerCase();
|
|
515
521
|
switch (assertion.type) {
|
|
@@ -542,7 +548,15 @@ async function checkAssertion(
|
|
|
542
548
|
return { assertion, passed: code === 0, detail: `exit ${code ?? "error"}${out ? ` — ${out.slice(0, 200)}` : ""}` };
|
|
543
549
|
}
|
|
544
550
|
case "judge": {
|
|
545
|
-
|
|
551
|
+
// The judge runs on the account's own model key, never quietly on the
|
|
552
|
+
// platform's: a customer's eval is not the operator's bill.
|
|
553
|
+
let env: Record<string, string | undefined>;
|
|
554
|
+
try {
|
|
555
|
+
env = judgeEnv();
|
|
556
|
+
} catch (err) {
|
|
557
|
+
return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
|
|
558
|
+
}
|
|
559
|
+
const verdict = await judge(assertion.value, output, model, effort, env);
|
|
546
560
|
const passed = /^\s*PASS\b/i.test(verdict);
|
|
547
561
|
return { assertion, passed, detail: verdict.slice(0, 300) || "judge returned nothing" };
|
|
548
562
|
}
|
|
@@ -625,7 +639,14 @@ export async function runEval(
|
|
|
625
639
|
const judges = testCase.expect.filter((a) => a.type === "judge");
|
|
626
640
|
for (const a of cheap) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
|
|
627
641
|
if (assertions.every((r) => r.passed)) {
|
|
628
|
-
|
|
642
|
+
const judgeEnv = () => {
|
|
643
|
+
const m = accountModelEnv(tenant, workspace, () => sdkEnv(undefined));
|
|
644
|
+
if (m.format !== "anthropic") {
|
|
645
|
+
throw new Error(`this account's provider speaks ${m.format}; a judge needs an Anthropic-format provider`);
|
|
646
|
+
}
|
|
647
|
+
return m.env;
|
|
648
|
+
};
|
|
649
|
+
for (const a of judges) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort, judgeEnv));
|
|
629
650
|
} else {
|
|
630
651
|
for (const a of judges) {
|
|
631
652
|
assertions.push({ assertion: a, passed: false, detail: "not run — an earlier check failed" });
|
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",
|
|
@@ -49,3 +54,30 @@ export function hostSafeEnv(from: NodeJS.ProcessEnv = process.env): NodeJS.Proce
|
|
|
49
54
|
// every spawn. The value is the same either way.
|
|
50
55
|
return out as NodeJS.ProcessEnv;
|
|
51
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where this install's own API answers, for a step to call: `FOLDRUN_API_URL`
|
|
60
|
+
* when the operator set it, else `FOLDRUN_PUBLIC_URL` + `/api`. Undefined on a
|
|
61
|
+
* laptop with neither.
|
|
62
|
+
*
|
|
63
|
+
* A desk that reads its own run history used to write the host into its
|
|
64
|
+
* files (`https://dev.foldrun.io/api`), so the same workspace pointed at the
|
|
65
|
+
* wrong server the day it moved. Now a script reads `$FOLDRUN_API_URL` and an
|
|
66
|
+
* `apis:` entry writes `base: ${FOLDRUN_API_URL}`.
|
|
67
|
+
*/
|
|
68
|
+
export function platformApiUrl(from: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
69
|
+
const explicit = from.FOLDRUN_API_URL?.trim();
|
|
70
|
+
if (explicit) return explicit.replace(/\/+$/, "");
|
|
71
|
+
const origin = from.FOLDRUN_PUBLIC_URL?.trim();
|
|
72
|
+
return origin ? `${origin.replace(/\/+$/, "")}/api` : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The install's own addresses an `apis:` base may name. Not secrets: they
|
|
76
|
+
* are printed in the prompt as the tool's base URL. */
|
|
77
|
+
export function expandPlatformVars(value: string, from: NodeJS.ProcessEnv = process.env): string {
|
|
78
|
+
const vars: Record<string, string | undefined> = {
|
|
79
|
+
FOLDRUN_API_URL: platformApiUrl(from),
|
|
80
|
+
FOLDRUN_PUBLIC_URL: from.FOLDRUN_PUBLIC_URL?.trim().replace(/\/+$/, "") || undefined,
|
|
81
|
+
};
|
|
82
|
+
return value.replace(/\$\{(FOLDRUN_API_URL|FOLDRUN_PUBLIC_URL)\}/g, (whole, name: string) => vars[name] ?? whole);
|
|
83
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The root half of the `install` tool: a tiny server the run container's root
|
|
2
|
+
// start-up leaves running before it drops to the agent user.
|
|
3
|
+
//
|
|
4
|
+
// node install-helper.js <socket> <runtime cache dir>
|
|
5
|
+
//
|
|
6
|
+
// The agent never becomes root. It can only ask this process, over a unix
|
|
7
|
+
// socket, to install Debian packages BY NAME — each name re-checked against
|
|
8
|
+
// Debian's naming rule here, so nothing but a package name reaches apt, and
|
|
9
|
+
// apt is driven by the same snippet the start-up uses (systemInstallSh): the
|
|
10
|
+
// account's .deb cache and snapshots, so a package anyone installed before is
|
|
11
|
+
// restored in about a second. Requests are served one at a time; the process
|
|
12
|
+
// dies with the container.
|
|
13
|
+
//
|
|
14
|
+
// pip and npm need no root and are installed by the tool itself, as the agent.
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import net from "node:net";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import { DEB_PACKAGE, systemInstallSh, summarizeInstallFailure } from "./runtime.ts";
|
|
22
|
+
|
|
23
|
+
export interface InstallRequest {
|
|
24
|
+
manager: "apt";
|
|
25
|
+
packages: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InstallReply {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
/** "snapshot" | "apt" */
|
|
31
|
+
mode?: string;
|
|
32
|
+
seconds?: number;
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Validate one request; null when it may run. Exported for the tests. */
|
|
37
|
+
export function refuse(req: unknown): string | null {
|
|
38
|
+
const r = req as Partial<InstallRequest> | null;
|
|
39
|
+
if (!r || typeof r !== "object") return "not a request";
|
|
40
|
+
if (r.manager !== "apt") return "this helper installs Debian packages (manager: apt) only";
|
|
41
|
+
if (!Array.isArray(r.packages) || r.packages.length === 0) return "no packages named";
|
|
42
|
+
if (r.packages.length > 50) return "at most 50 packages per request";
|
|
43
|
+
const bad = r.packages.filter((p) => typeof p !== "string" || !DEB_PACKAGE.test(p));
|
|
44
|
+
if (bad.length) return `not Debian package names: ${bad.map(String).join(", ")}`;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Install one validated request with the start-up's own snippet. */
|
|
49
|
+
export function installApt(packages: string[], cacheDir: string): Promise<InstallReply> {
|
|
50
|
+
const logDir = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-install-"));
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const child = spawn("sh", ["-c", systemInstallSh(cacheDir, logDir)], {
|
|
53
|
+
env: { ...process.env, FOLDRUN_SYSTEM_PACKAGES: packages.join(" ") },
|
|
54
|
+
stdio: "ignore",
|
|
55
|
+
});
|
|
56
|
+
child.on("close", () => {
|
|
57
|
+
let status: { ok?: boolean; mode?: string; seconds?: number } = {};
|
|
58
|
+
try {
|
|
59
|
+
status = JSON.parse(fs.readFileSync(path.join(logDir, "system-install.json"), "utf8"));
|
|
60
|
+
} catch {
|
|
61
|
+
status = { ok: false };
|
|
62
|
+
}
|
|
63
|
+
let log = "";
|
|
64
|
+
try {
|
|
65
|
+
log = fs.readFileSync(path.join(logDir, "system-install.log"), "utf8");
|
|
66
|
+
} catch {
|
|
67
|
+
log = "";
|
|
68
|
+
}
|
|
69
|
+
fs.rmSync(logDir, { recursive: true, force: true });
|
|
70
|
+
resolve(
|
|
71
|
+
status.ok
|
|
72
|
+
? { ok: true, mode: status.mode, seconds: status.seconds }
|
|
73
|
+
: { ok: false, error: summarizeInstallFailure("apt-get install", log || "no output") },
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
child.on("error", (err) => {
|
|
77
|
+
fs.rmSync(logDir, { recursive: true, force: true });
|
|
78
|
+
resolve({ ok: false, error: `could not run the installer: ${err.message}` });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function serve(socket: string, cacheDir: string): net.Server {
|
|
84
|
+
try {
|
|
85
|
+
fs.rmSync(socket, { force: true });
|
|
86
|
+
} catch {
|
|
87
|
+
/* nothing there */
|
|
88
|
+
}
|
|
89
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
90
|
+
const server = net.createServer((conn) => {
|
|
91
|
+
let buf = "";
|
|
92
|
+
conn.on("data", (chunk) => {
|
|
93
|
+
buf += chunk.toString("utf8");
|
|
94
|
+
if (buf.length > 64_000) {
|
|
95
|
+
conn.end(JSON.stringify({ ok: false, error: "request too large" }) + "\n");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const nl = buf.indexOf("\n");
|
|
99
|
+
if (nl < 0) return;
|
|
100
|
+
const line = buf.slice(0, nl);
|
|
101
|
+
buf = "";
|
|
102
|
+
let req: unknown;
|
|
103
|
+
try {
|
|
104
|
+
req = JSON.parse(line);
|
|
105
|
+
} catch {
|
|
106
|
+
conn.end(JSON.stringify({ ok: false, error: "not JSON" }) + "\n");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const why = refuse(req);
|
|
110
|
+
if (why) {
|
|
111
|
+
conn.end(JSON.stringify({ ok: false, error: why }) + "\n");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// One install at a time: apt holds a lock, and two dpkg runs in one
|
|
115
|
+
// container would fight over it.
|
|
116
|
+
queue = queue.then(async () => {
|
|
117
|
+
const reply = await installApt((req as InstallRequest).packages, cacheDir);
|
|
118
|
+
conn.end(JSON.stringify(reply) + "\n");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
conn.on("error", () => {
|
|
122
|
+
/* the client went away */
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
server.listen(socket, () => {
|
|
126
|
+
// The agent user must be able to connect; nothing else runs in here.
|
|
127
|
+
try {
|
|
128
|
+
fs.chmodSync(socket, 0o666);
|
|
129
|
+
} catch {
|
|
130
|
+
/* best effort */
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
return server;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (process.argv[1] && /install-helper\.(js|ts|mjs)$/.test(process.argv[1])) {
|
|
137
|
+
const [socket, cacheDir] = process.argv.slice(2);
|
|
138
|
+
if (!socket || !cacheDir) {
|
|
139
|
+
console.error("usage: install-helper <socket> <runtime cache dir>");
|
|
140
|
+
process.exit(2);
|
|
141
|
+
}
|
|
142
|
+
serve(socket, cacheDir);
|
|
143
|
+
}
|