@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.
Files changed (99) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.js +4 -0
  4. package/dist/src/agent-import.d.ts +21 -0
  5. package/dist/src/agent-import.js +116 -0
  6. package/dist/src/api-tools.js +14 -2
  7. package/dist/src/approvals.d.ts +8 -1
  8. package/dist/src/approvals.js +10 -5
  9. package/dist/src/browser-pod.d.ts +20 -1
  10. package/dist/src/browser-pod.js +58 -9
  11. package/dist/src/completions.js +1 -0
  12. package/dist/src/confine.d.ts +1 -0
  13. package/dist/src/confine.js +33 -4
  14. package/dist/src/container.d.ts +9 -0
  15. package/dist/src/container.js +115 -5
  16. package/dist/src/context-tools.d.ts +1 -1
  17. package/dist/src/deploy.d.ts +9 -0
  18. package/dist/src/deploy.js +31 -3
  19. package/dist/src/evals.js +24 -4
  20. package/dist/src/host-env.d.ts +14 -0
  21. package/dist/src/host-env.js +31 -0
  22. package/dist/src/install-helper.d.ts +17 -0
  23. package/dist/src/install-helper.js +134 -0
  24. package/dist/src/install-tool.d.ts +128 -0
  25. package/dist/src/install-tool.js +133 -0
  26. package/dist/src/mail.js +3 -1
  27. package/dist/src/model-credential.d.ts +30 -0
  28. package/dist/src/model-credential.js +98 -0
  29. package/dist/src/model-env.d.ts +17 -0
  30. package/dist/src/model-env.js +57 -0
  31. package/dist/src/package.d.ts +67 -0
  32. package/dist/src/package.js +594 -0
  33. package/dist/src/platform.d.ts +5 -0
  34. package/dist/src/platform.js +1 -0
  35. package/dist/src/provider-health.js +3 -0
  36. package/dist/src/run-container.d.ts +6 -1
  37. package/dist/src/run-container.js +93 -20
  38. package/dist/src/runner.d.ts +239 -0
  39. package/dist/src/runner.js +380 -72
  40. package/dist/src/runtime.d.ts +77 -0
  41. package/dist/src/runtime.js +230 -5
  42. package/dist/src/script-tools.d.ts +16 -0
  43. package/dist/src/script-tools.js +50 -8
  44. package/dist/src/secrets.d.ts +5 -0
  45. package/dist/src/secrets.js +37 -6
  46. package/dist/src/starter.d.ts +12 -0
  47. package/dist/src/starter.js +36 -4
  48. package/dist/src/step-exec.d.ts +22 -2
  49. package/dist/src/step-exec.js +70 -7
  50. package/dist/src/storage.d.ts +7 -1
  51. package/dist/src/storage.js +14 -1
  52. package/dist/src/store.d.ts +24 -0
  53. package/dist/src/store.js +91 -10
  54. package/dist/src/subagents.js +1 -1
  55. package/dist/src/test-mode.d.ts +17 -2
  56. package/dist/src/test-mode.js +53 -13
  57. package/dist/src/tool-names.d.ts +3 -1
  58. package/dist/src/tool-names.js +4 -2
  59. package/dist/src/tool-test.js +8 -1
  60. package/dist/src/zip.d.ts +17 -0
  61. package/dist/src/zip.js +184 -0
  62. package/index.ts +4 -0
  63. package/package.json +1 -1
  64. package/src/agent-import.ts +154 -0
  65. package/src/api-tools.ts +16 -1
  66. package/src/approvals.ts +10 -3
  67. package/src/browser-pod.ts +46 -9
  68. package/src/completions.ts +1 -0
  69. package/src/confine.ts +36 -5
  70. package/src/container.ts +112 -5
  71. package/src/context-tools.ts +1 -1
  72. package/src/deploy.ts +28 -3
  73. package/src/evals.ts +23 -2
  74. package/src/host-env.ts +32 -0
  75. package/src/install-helper.ts +143 -0
  76. package/src/install-tool.ts +154 -0
  77. package/src/mail.ts +3 -1
  78. package/src/model-credential.ts +112 -0
  79. package/src/model-env.ts +66 -0
  80. package/src/package.ts +664 -0
  81. package/src/platform.ts +6 -0
  82. package/src/provider-health.ts +3 -0
  83. package/src/run-container.ts +92 -19
  84. package/src/runner.ts +385 -72
  85. package/src/runtime.ts +235 -5
  86. package/src/script-tools.ts +48 -6
  87. package/src/secrets.ts +34 -6
  88. package/src/starter.ts +42 -4
  89. package/src/step-exec.ts +75 -10
  90. package/src/storage.ts +13 -0
  91. package/src/store.ts +91 -9
  92. package/src/subagents.ts +1 -1
  93. package/src/test-mode.ts +51 -11
  94. package/src/tool-names.ts +4 -2
  95. package/src/tool-test.ts +8 -1
  96. package/src/zip.ts +189 -0
  97. package/templates/blog-desk/agents/writer/agent.md +1 -1
  98. package/templates/hello/agents/curator/agent.md +3 -3
  99. package/templates/hello/agents/notetaker/agent.md +2 -2
@@ -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
- const copied = docker(["cp", `${opts.agentDir}/.`, `${cid}:/workspace`], 120_000);
140
- if (copied.status !== 0) {
141
- return { code: null, out: `copy in failed: ${(copied.stderr ?? "").slice(-300)}` };
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/", "../../memory/". */
4
+ /** How the agent should name the place: "knowledge/", "workspace/memory/". */
5
5
  label: string;
6
6
  /** The directory, absolute. */
7
7
  dir: string;
@@ -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
  /**
@@ -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
- if (agents.size === 0) {
289
- at("agents/", "no agents — a workspace needs at least one agents/<name>/agent.md");
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
- const verdict = await judge(assertion.value, output, model, effort);
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) {
@@ -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;
@@ -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 {};