@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/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/deploy.d.ts
CHANGED
|
@@ -60,6 +60,15 @@ export declare function readTree(dir: string): DeployFile[];
|
|
|
60
60
|
* only the deployed files, so a library or gallery outward tool is not seen
|
|
61
61
|
* here — `foldrun check`, which resolves every scope, remains the real gate.
|
|
62
62
|
* Non-blocking by decision: the deploy proceeds and says so. */
|
|
63
|
+
/**
|
|
64
|
+
* A frontmatter block that lost its opening `---`: `key: value` lines, then a
|
|
65
|
+
* lone `---`, with nothing above them. Nothing is read from it — the parser
|
|
66
|
+
* sees prose — so the agent runs with no tools and the default model, and a
|
|
67
|
+
* model told to call a tool it was never given writes the call out as text.
|
|
68
|
+
* seo-digest's editor did that every Sunday from 20 Sep to 5 Oct 2026.
|
|
69
|
+
* Returns the problem, or null.
|
|
70
|
+
*/
|
|
71
|
+
export declare function unopenedFrontmatter(raw: string): string | null;
|
|
63
72
|
export declare function deployWarnings(files: DeployFile[]): DeployIssue[];
|
|
64
73
|
export declare function deployIssues(files: DeployFile[]): DeployIssue[];
|
|
65
74
|
/**
|
package/dist/src/deploy.js
CHANGED
|
@@ -164,6 +164,27 @@ export function readTree(dir) {
|
|
|
164
164
|
* only the deployed files, so a library or gallery outward tool is not seen
|
|
165
165
|
* here — `foldrun check`, which resolves every scope, remains the real gate.
|
|
166
166
|
* Non-blocking by decision: the deploy proceeds and says so. */
|
|
167
|
+
/**
|
|
168
|
+
* A frontmatter block that lost its opening `---`: `key: value` lines, then a
|
|
169
|
+
* lone `---`, with nothing above them. Nothing is read from it — the parser
|
|
170
|
+
* sees prose — so the agent runs with no tools and the default model, and a
|
|
171
|
+
* model told to call a tool it was never given writes the call out as text.
|
|
172
|
+
* seo-digest's editor did that every Sunday from 20 Sep to 5 Oct 2026.
|
|
173
|
+
* Returns the problem, or null.
|
|
174
|
+
*/
|
|
175
|
+
export function unopenedFrontmatter(raw) {
|
|
176
|
+
const text = raw.replace(/^\uFEFF/, "");
|
|
177
|
+
if (/^---\r?\n/.test(text))
|
|
178
|
+
return null;
|
|
179
|
+
const lines = text.split(/\r?\n/);
|
|
180
|
+
const close = lines.findIndex((l) => l.trim() === "---");
|
|
181
|
+
if (close < 1)
|
|
182
|
+
return null;
|
|
183
|
+
const above = lines.slice(0, close).filter((l) => l.trim() && !l.trim().startsWith("#"));
|
|
184
|
+
if (!above.length || !above.every((l) => /^[A-Za-z_][\w-]*:(\s|$)/.test(l) || /^\s+\S/.test(l)))
|
|
185
|
+
return null;
|
|
186
|
+
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.";
|
|
187
|
+
}
|
|
167
188
|
export function deployWarnings(files) {
|
|
168
189
|
const front = (content) => {
|
|
169
190
|
try {
|
|
@@ -190,6 +211,9 @@ export function deployWarnings(files) {
|
|
|
190
211
|
const outwardAgents = [];
|
|
191
212
|
const agentNames = [];
|
|
192
213
|
const out = [];
|
|
214
|
+
if (!files.some((f) => /^agents\/[^/]+\/agent\.md$/.test(f.path)) && !files.some((f) => /^\.(claude|agents)\/agents\/[^/]+\.md$/.test(f.path))) {
|
|
215
|
+
out.push({ where: "agents/", message: "no agents yet — add one (agents/<name>/agent.md), or import one from another workspace" });
|
|
216
|
+
}
|
|
193
217
|
// The web: warnings check prints — the old session: key, and a pinned
|
|
194
218
|
// user agent behind the runner image's Chrome, which the platform knows
|
|
195
219
|
// from FOLDRUN_RUNNER_ENGINES (the deploy read it from the image).
|
|
@@ -257,6 +281,9 @@ export function deployIssues(files) {
|
|
|
257
281
|
for (const f of files) {
|
|
258
282
|
if (!/^(AGENTS\.md|agents\/[^/]+\/agent\.md)$/.test(f.path))
|
|
259
283
|
continue;
|
|
284
|
+
const unopened = unopenedFrontmatter(f.content);
|
|
285
|
+
if (unopened)
|
|
286
|
+
at(f.path, unopened);
|
|
260
287
|
let front = {};
|
|
261
288
|
try {
|
|
262
289
|
front = matter(f.content).data;
|
|
@@ -285,9 +312,10 @@ export function deployIssues(files) {
|
|
|
285
312
|
for (const w of localeProblems(front))
|
|
286
313
|
at(f.path, w);
|
|
287
314
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
315
|
+
// No agents is a workspace that has not started yet — "Create workspace"
|
|
316
|
+
// makes exactly that — and deploying it is harmless: a flow that names an
|
|
317
|
+
// agent this push lacks is refused below, step by step. deployWarnings says
|
|
318
|
+
// it, so the empty state is visible without blocking the first push.
|
|
291
319
|
// Flows, parsed from the incoming text rather than from disk. A step naming
|
|
292
320
|
// an agent that this push does not ship is the single most common way a
|
|
293
321
|
// deploy breaks a schedule, and it is free to catch here.
|
package/dist/src/evals.js
CHANGED
|
@@ -30,6 +30,8 @@
|
|
|
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 { accountModelEnv } from "./model-env.js";
|
|
34
|
+
import { sdkEnv } from "./step-exec.js";
|
|
33
35
|
import fs from "node:fs";
|
|
34
36
|
import path from "node:path";
|
|
35
37
|
import { deployedCommit } from "./deploy.js";
|
|
@@ -352,7 +354,7 @@ function shell(command, cwd) {
|
|
|
352
354
|
// The judge gets the rubric and the output, and must answer with one word.
|
|
353
355
|
// Constrained output beats asking for a score: "PASS" or "FAIL" can't be
|
|
354
356
|
// misread, where "7/10" needs a threshold nobody agreed on.
|
|
355
|
-
async function judge(rubric, output, model, effort) {
|
|
357
|
+
async function judge(rubric, output, model, effort, env) {
|
|
356
358
|
const prompt = `You are grading one piece of output against one criterion. Answer with exactly ` +
|
|
357
359
|
`PASS or FAIL on the first line, then one short sentence of reason.\n\n` +
|
|
358
360
|
`Criterion: ${rubric}\n\n<output>\n${output.slice(0, 20000)}\n</output>`;
|
|
@@ -365,6 +367,8 @@ async function judge(rubric, output, model, effort) {
|
|
|
365
367
|
tools: [],
|
|
366
368
|
allowedTools: [],
|
|
367
369
|
settingSources: [],
|
|
370
|
+
strictMcpConfig: true,
|
|
371
|
+
env,
|
|
368
372
|
maxTurns: 1,
|
|
369
373
|
},
|
|
370
374
|
});
|
|
@@ -378,7 +382,7 @@ async function judge(rubric, output, model, effort) {
|
|
|
378
382
|
}
|
|
379
383
|
return text.trim();
|
|
380
384
|
}
|
|
381
|
-
async function checkAssertion(assertion, output, agentDir, model, effort) {
|
|
385
|
+
async function checkAssertion(assertion, output, agentDir, model, effort, judgeEnv = () => sdkEnv(undefined)) {
|
|
382
386
|
const hay = output.toLowerCase();
|
|
383
387
|
switch (assertion.type) {
|
|
384
388
|
case "contains": {
|
|
@@ -411,7 +415,16 @@ async function checkAssertion(assertion, output, agentDir, model, effort) {
|
|
|
411
415
|
return { assertion, passed: code === 0, detail: `exit ${code ?? "error"}${out ? ` — ${out.slice(0, 200)}` : ""}` };
|
|
412
416
|
}
|
|
413
417
|
case "judge": {
|
|
414
|
-
|
|
418
|
+
// The judge runs on the account's own model key, never quietly on the
|
|
419
|
+
// platform's: a customer's eval is not the operator's bill.
|
|
420
|
+
let env;
|
|
421
|
+
try {
|
|
422
|
+
env = judgeEnv();
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
|
|
426
|
+
}
|
|
427
|
+
const verdict = await judge(assertion.value, output, model, effort, env);
|
|
415
428
|
const passed = /^\s*PASS\b/i.test(verdict);
|
|
416
429
|
return { assertion, passed, detail: verdict.slice(0, 300) || "judge returned nothing" };
|
|
417
430
|
}
|
|
@@ -482,8 +495,15 @@ export async function runEval(tenant, workspace, info) {
|
|
|
482
495
|
for (const a of cheap)
|
|
483
496
|
assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
|
|
484
497
|
if (assertions.every((r) => r.passed)) {
|
|
498
|
+
const judgeEnv = () => {
|
|
499
|
+
const m = accountModelEnv(tenant, workspace, () => sdkEnv(undefined));
|
|
500
|
+
if (m.format !== "anthropic") {
|
|
501
|
+
throw new Error(`this account's provider speaks ${m.format}; a judge needs an Anthropic-format provider`);
|
|
502
|
+
}
|
|
503
|
+
return m.env;
|
|
504
|
+
};
|
|
485
505
|
for (const a of judges)
|
|
486
|
-
assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
|
|
506
|
+
assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort, judgeEnv));
|
|
487
507
|
}
|
|
488
508
|
else {
|
|
489
509
|
for (const a of judges) {
|
package/dist/src/host-env.d.ts
CHANGED
|
@@ -6,3 +6,17 @@ export declare function hostEnvAllowed(name: string): boolean;
|
|
|
6
6
|
* caller that layers secrets on top never writes into the host's own env.
|
|
7
7
|
*/
|
|
8
8
|
export declare function hostSafeEnv(from?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
9
|
+
/**
|
|
10
|
+
* Where this install's own API answers, for a step to call: `FOLDRUN_API_URL`
|
|
11
|
+
* when the operator set it, else `FOLDRUN_PUBLIC_URL` + `/api`. Undefined on a
|
|
12
|
+
* laptop with neither.
|
|
13
|
+
*
|
|
14
|
+
* A desk that reads its own run history used to write the host into its
|
|
15
|
+
* files (`https://dev.foldrun.io/api`), so the same workspace pointed at the
|
|
16
|
+
* wrong server the day it moved. Now a script reads `$FOLDRUN_API_URL` and an
|
|
17
|
+
* `apis:` entry writes `base: ${FOLDRUN_API_URL}`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function platformApiUrl(from?: NodeJS.ProcessEnv): string | undefined;
|
|
20
|
+
/** The install's own addresses an `apis:` base may name. Not secrets: they
|
|
21
|
+
* are printed in the prompt as the tool's base URL. */
|
|
22
|
+
export declare function expandPlatformVars(value: string, from?: NodeJS.ProcessEnv): string;
|
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",
|
|
@@ -46,3 +51,29 @@ export function hostSafeEnv(from = process.env) {
|
|
|
46
51
|
// every spawn. The value is the same either way.
|
|
47
52
|
return out;
|
|
48
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Where this install's own API answers, for a step to call: `FOLDRUN_API_URL`
|
|
56
|
+
* when the operator set it, else `FOLDRUN_PUBLIC_URL` + `/api`. Undefined on a
|
|
57
|
+
* laptop with neither.
|
|
58
|
+
*
|
|
59
|
+
* A desk that reads its own run history used to write the host into its
|
|
60
|
+
* files (`https://dev.foldrun.io/api`), so the same workspace pointed at the
|
|
61
|
+
* wrong server the day it moved. Now a script reads `$FOLDRUN_API_URL` and an
|
|
62
|
+
* `apis:` entry writes `base: ${FOLDRUN_API_URL}`.
|
|
63
|
+
*/
|
|
64
|
+
export function platformApiUrl(from = process.env) {
|
|
65
|
+
const explicit = from.FOLDRUN_API_URL?.trim();
|
|
66
|
+
if (explicit)
|
|
67
|
+
return explicit.replace(/\/+$/, "");
|
|
68
|
+
const origin = from.FOLDRUN_PUBLIC_URL?.trim();
|
|
69
|
+
return origin ? `${origin.replace(/\/+$/, "")}/api` : undefined;
|
|
70
|
+
}
|
|
71
|
+
/** The install's own addresses an `apis:` base may name. Not secrets: they
|
|
72
|
+
* are printed in the prompt as the tool's base URL. */
|
|
73
|
+
export function expandPlatformVars(value, from = process.env) {
|
|
74
|
+
const vars = {
|
|
75
|
+
FOLDRUN_API_URL: platformApiUrl(from),
|
|
76
|
+
FOLDRUN_PUBLIC_URL: from.FOLDRUN_PUBLIC_URL?.trim().replace(/\/+$/, "") || undefined,
|
|
77
|
+
};
|
|
78
|
+
return value.replace(/\$\{(FOLDRUN_API_URL|FOLDRUN_PUBLIC_URL)\}/g, (whole, name) => vars[name] ?? whole);
|
|
79
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
export interface InstallRequest {
|
|
3
|
+
manager: "apt";
|
|
4
|
+
packages: string[];
|
|
5
|
+
}
|
|
6
|
+
export interface InstallReply {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
/** "snapshot" | "apt" */
|
|
9
|
+
mode?: string;
|
|
10
|
+
seconds?: number;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Validate one request; null when it may run. Exported for the tests. */
|
|
14
|
+
export declare function refuse(req: unknown): string | null;
|
|
15
|
+
/** Install one validated request with the start-up's own snippet. */
|
|
16
|
+
export declare function installApt(packages: string[], cacheDir: string): Promise<InstallReply>;
|
|
17
|
+
export declare function serve(socket: string, cacheDir: string): net.Server;
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
import fs from "node:fs";
|
|
16
|
+
import net from "node:net";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { DEB_PACKAGE, systemInstallSh, summarizeInstallFailure } from "./runtime.js";
|
|
21
|
+
/** Validate one request; null when it may run. Exported for the tests. */
|
|
22
|
+
export function refuse(req) {
|
|
23
|
+
const r = req;
|
|
24
|
+
if (!r || typeof r !== "object")
|
|
25
|
+
return "not a request";
|
|
26
|
+
if (r.manager !== "apt")
|
|
27
|
+
return "this helper installs Debian packages (manager: apt) only";
|
|
28
|
+
if (!Array.isArray(r.packages) || r.packages.length === 0)
|
|
29
|
+
return "no packages named";
|
|
30
|
+
if (r.packages.length > 50)
|
|
31
|
+
return "at most 50 packages per request";
|
|
32
|
+
const bad = r.packages.filter((p) => typeof p !== "string" || !DEB_PACKAGE.test(p));
|
|
33
|
+
if (bad.length)
|
|
34
|
+
return `not Debian package names: ${bad.map(String).join(", ")}`;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/** Install one validated request with the start-up's own snippet. */
|
|
38
|
+
export function installApt(packages, cacheDir) {
|
|
39
|
+
const logDir = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-install-"));
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const child = spawn("sh", ["-c", systemInstallSh(cacheDir, logDir)], {
|
|
42
|
+
env: { ...process.env, FOLDRUN_SYSTEM_PACKAGES: packages.join(" ") },
|
|
43
|
+
stdio: "ignore",
|
|
44
|
+
});
|
|
45
|
+
child.on("close", () => {
|
|
46
|
+
let status = {};
|
|
47
|
+
try {
|
|
48
|
+
status = JSON.parse(fs.readFileSync(path.join(logDir, "system-install.json"), "utf8"));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
status = { ok: false };
|
|
52
|
+
}
|
|
53
|
+
let log = "";
|
|
54
|
+
try {
|
|
55
|
+
log = fs.readFileSync(path.join(logDir, "system-install.log"), "utf8");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
log = "";
|
|
59
|
+
}
|
|
60
|
+
fs.rmSync(logDir, { recursive: true, force: true });
|
|
61
|
+
resolve(status.ok
|
|
62
|
+
? { ok: true, mode: status.mode, seconds: status.seconds }
|
|
63
|
+
: { ok: false, error: summarizeInstallFailure("apt-get install", log || "no output") });
|
|
64
|
+
});
|
|
65
|
+
child.on("error", (err) => {
|
|
66
|
+
fs.rmSync(logDir, { recursive: true, force: true });
|
|
67
|
+
resolve({ ok: false, error: `could not run the installer: ${err.message}` });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export function serve(socket, cacheDir) {
|
|
72
|
+
try {
|
|
73
|
+
fs.rmSync(socket, { force: true });
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* nothing there */
|
|
77
|
+
}
|
|
78
|
+
let queue = Promise.resolve();
|
|
79
|
+
const server = net.createServer((conn) => {
|
|
80
|
+
let buf = "";
|
|
81
|
+
conn.on("data", (chunk) => {
|
|
82
|
+
buf += chunk.toString("utf8");
|
|
83
|
+
if (buf.length > 64_000) {
|
|
84
|
+
conn.end(JSON.stringify({ ok: false, error: "request too large" }) + "\n");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const nl = buf.indexOf("\n");
|
|
88
|
+
if (nl < 0)
|
|
89
|
+
return;
|
|
90
|
+
const line = buf.slice(0, nl);
|
|
91
|
+
buf = "";
|
|
92
|
+
let req;
|
|
93
|
+
try {
|
|
94
|
+
req = JSON.parse(line);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
conn.end(JSON.stringify({ ok: false, error: "not JSON" }) + "\n");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const why = refuse(req);
|
|
101
|
+
if (why) {
|
|
102
|
+
conn.end(JSON.stringify({ ok: false, error: why }) + "\n");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// One install at a time: apt holds a lock, and two dpkg runs in one
|
|
106
|
+
// container would fight over it.
|
|
107
|
+
queue = queue.then(async () => {
|
|
108
|
+
const reply = await installApt(req.packages, cacheDir);
|
|
109
|
+
conn.end(JSON.stringify(reply) + "\n");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
conn.on("error", () => {
|
|
113
|
+
/* the client went away */
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
server.listen(socket, () => {
|
|
117
|
+
// The agent user must be able to connect; nothing else runs in here.
|
|
118
|
+
try {
|
|
119
|
+
fs.chmodSync(socket, 0o666);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* best effort */
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return server;
|
|
126
|
+
}
|
|
127
|
+
if (process.argv[1] && /install-helper\.(js|ts|mjs)$/.test(process.argv[1])) {
|
|
128
|
+
const [socket, cacheDir] = process.argv.slice(2);
|
|
129
|
+
if (!socket || !cacheDir) {
|
|
130
|
+
console.error("usage: install-helper <socket> <runtime cache dir>");
|
|
131
|
+
process.exit(2);
|
|
132
|
+
}
|
|
133
|
+
serve(socket, cacheDir);
|
|
134
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
type Emit = (type: "text" | "tool" | "info" | "error", text: string) => void;
|
|
2
|
+
export declare const INSTALL_TOOL = "mcp__foldrun_install__install";
|
|
3
|
+
/** Ask the root helper; resolves with its reply or an error. */
|
|
4
|
+
export declare function askHelper(socket: string, packages: string[], timeoutMs?: number): Promise<{
|
|
5
|
+
ok: boolean;
|
|
6
|
+
mode?: string;
|
|
7
|
+
seconds?: number;
|
|
8
|
+
error?: string;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function buildInstallTool(opts: {
|
|
11
|
+
/** The per-step directory pip and npm install into (stepInstallEnv's). */
|
|
12
|
+
dir: string;
|
|
13
|
+
/** The root helper's socket; null where there is none (a laptop). */
|
|
14
|
+
socket: string | null;
|
|
15
|
+
/** The account's runtime cache, for uv's and npm's download caches. */
|
|
16
|
+
cacheRoot: string;
|
|
17
|
+
/** The interpreter pip installs for: the declared venv's, else python3. */
|
|
18
|
+
python: string;
|
|
19
|
+
env: Record<string, string | undefined>;
|
|
20
|
+
emit: Emit;
|
|
21
|
+
}): {
|
|
22
|
+
server: import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
|
|
23
|
+
toolName: string;
|
|
24
|
+
/** What this step installed, `manager:name`, for the run record. */
|
|
25
|
+
installed: () => string[];
|
|
26
|
+
/** The handler, for tests. */
|
|
27
|
+
call: (args: {
|
|
28
|
+
manager: "apt" | "pip" | "npm";
|
|
29
|
+
packages: string[];
|
|
30
|
+
}) => Promise<{
|
|
31
|
+
[x: string]: unknown;
|
|
32
|
+
_meta?: {
|
|
33
|
+
[x: string]: unknown;
|
|
34
|
+
progressToken?: string | number | undefined;
|
|
35
|
+
"io.modelcontextprotocol/related-task"?: {
|
|
36
|
+
taskId: string;
|
|
37
|
+
} | undefined;
|
|
38
|
+
} | undefined;
|
|
39
|
+
content: ({
|
|
40
|
+
type: "text";
|
|
41
|
+
text: string;
|
|
42
|
+
annotations?: {
|
|
43
|
+
audience?: ("assistant" | "user")[] | undefined;
|
|
44
|
+
priority?: number | undefined;
|
|
45
|
+
lastModified?: string | undefined;
|
|
46
|
+
} | undefined;
|
|
47
|
+
_meta?: {
|
|
48
|
+
[x: string]: unknown;
|
|
49
|
+
} | undefined;
|
|
50
|
+
} | {
|
|
51
|
+
type: "image";
|
|
52
|
+
data: string;
|
|
53
|
+
mimeType: string;
|
|
54
|
+
annotations?: {
|
|
55
|
+
audience?: ("assistant" | "user")[] | undefined;
|
|
56
|
+
priority?: number | undefined;
|
|
57
|
+
lastModified?: string | undefined;
|
|
58
|
+
} | undefined;
|
|
59
|
+
_meta?: {
|
|
60
|
+
[x: string]: unknown;
|
|
61
|
+
} | undefined;
|
|
62
|
+
} | {
|
|
63
|
+
type: "audio";
|
|
64
|
+
data: string;
|
|
65
|
+
mimeType: string;
|
|
66
|
+
annotations?: {
|
|
67
|
+
audience?: ("assistant" | "user")[] | undefined;
|
|
68
|
+
priority?: number | undefined;
|
|
69
|
+
lastModified?: string | undefined;
|
|
70
|
+
} | undefined;
|
|
71
|
+
_meta?: {
|
|
72
|
+
[x: string]: unknown;
|
|
73
|
+
} | undefined;
|
|
74
|
+
} | {
|
|
75
|
+
uri: string;
|
|
76
|
+
description?: string | undefined;
|
|
77
|
+
mimeType?: string | undefined;
|
|
78
|
+
size?: number | undefined;
|
|
79
|
+
annotations?: {
|
|
80
|
+
audience?: ("assistant" | "user")[] | undefined;
|
|
81
|
+
priority?: number | undefined;
|
|
82
|
+
lastModified?: string | undefined;
|
|
83
|
+
} | undefined;
|
|
84
|
+
_meta?: {
|
|
85
|
+
[x: string]: unknown;
|
|
86
|
+
} | undefined;
|
|
87
|
+
icons?: {
|
|
88
|
+
src: string;
|
|
89
|
+
mimeType?: string | undefined;
|
|
90
|
+
sizes?: string[] | undefined;
|
|
91
|
+
theme?: "dark" | "light" | undefined;
|
|
92
|
+
}[] | undefined;
|
|
93
|
+
name: string;
|
|
94
|
+
title?: string | undefined;
|
|
95
|
+
type: "resource_link";
|
|
96
|
+
} | {
|
|
97
|
+
type: "resource";
|
|
98
|
+
resource: {
|
|
99
|
+
uri: string;
|
|
100
|
+
mimeType?: string | undefined;
|
|
101
|
+
_meta?: {
|
|
102
|
+
[x: string]: unknown;
|
|
103
|
+
} | undefined;
|
|
104
|
+
text: string;
|
|
105
|
+
} | {
|
|
106
|
+
uri: string;
|
|
107
|
+
mimeType?: string | undefined;
|
|
108
|
+
_meta?: {
|
|
109
|
+
[x: string]: unknown;
|
|
110
|
+
} | undefined;
|
|
111
|
+
blob: string;
|
|
112
|
+
};
|
|
113
|
+
annotations?: {
|
|
114
|
+
audience?: ("assistant" | "user")[] | undefined;
|
|
115
|
+
priority?: number | undefined;
|
|
116
|
+
lastModified?: string | undefined;
|
|
117
|
+
} | undefined;
|
|
118
|
+
_meta?: {
|
|
119
|
+
[x: string]: unknown;
|
|
120
|
+
} | undefined;
|
|
121
|
+
})[];
|
|
122
|
+
structuredContent?: {
|
|
123
|
+
[x: string]: unknown;
|
|
124
|
+
} | undefined;
|
|
125
|
+
isError?: boolean | undefined;
|
|
126
|
+
}>;
|
|
127
|
+
};
|
|
128
|
+
export {};
|