@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
@@ -18,6 +18,7 @@
18
18
  // It reads the same provider block a run reads, resolved the same way, so a
19
19
  // pass here means a run would reach the same place with the same key.
20
20
 
21
+ import { accountModelBlock } from "./model-credential.ts";
21
22
  import fs from "node:fs";
22
23
  import path from "node:path";
23
24
  import matter from "gray-matter";
@@ -236,6 +237,8 @@ export function providersInUse(tenant: string): ProviderInUse[] {
236
237
  };
237
238
 
238
239
  add(frontmatterOf(path.join(accountDir(tenant), "AGENTS.md")).provider, null, "account");
240
+ // The account's own key from Settings → Model, which needs no block.
241
+ add(accountModelBlock((name) => getSecret(tenant, name)?.value ?? null), null, "account (Settings → Model)");
239
242
  for (const ws of safeList(() => listWorkspaces(tenant))) {
240
243
  const dir = workspaceDir(tenant, ws.name);
241
244
  add(frontmatterOf(path.join(dir, "AGENTS.md")).provider, ws.name, ws.name);
@@ -25,10 +25,10 @@ import crypto from "node:crypto";
25
25
  import { spawn, spawnSync } from "node:child_process";
26
26
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
27
27
  import { isPlatformPath, type ApiSpec, type Effort } from "./store.ts";
28
- import { divertedSummary, isDivertedPath } from "./test-mode.ts";
28
+ import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.ts";
29
29
  import type { EventExtra } from "./step-exec.ts";
30
30
  import { isFileValue, fileContent } from "./secrets.ts";
31
- import { safeTenantSegment, type RuntimeSpec } from "./runtime.ts";
31
+ import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET, type RuntimeSpec } from "./runtime.ts";
32
32
  import type { ScriptSpec } from "./script-tools.ts";
33
33
  import type { ConsultSpec } from "./agent-tools.ts";
34
34
  import type { SubagentSpec } from "./subagents.ts";
@@ -293,18 +293,37 @@ export function mergeAppends(was: Buffer | string | null, host: Buffer, next: Bu
293
293
  let common = 0;
294
294
  const max = Math.min(h.length, n.length);
295
295
  while (common < max && h.charCodeAt(common) === n.charCodeAt(common)) common++;
296
- for (let end = common; end >= 0; end--) {
297
- if (end > 0 && h[end - 1] !== "\n") continue;
298
- const prefix = h.slice(0, end);
299
- const matches =
300
- typeof was === "string" ? sha(Buffer.from(prefix, "utf8")) === was : Buffer.from(prefix, "utf8").equals(was);
301
- if (!matches) continue;
302
- const hostAdded = h.slice(end);
303
- const stepAdded = n.slice(end);
304
- const joined = hostAdded && !hostAdded.endsWith("\n") ? hostAdded + "\n" : hostAdded;
305
- return Buffer.from(prefix + joined + stepAdded, "utf8");
296
+ // One pass over the shared prefix. Rebuilding and hashing the prefix at
297
+ // every line boundary, from the longest down, was quadratic: on 6 Oct 2026
298
+ // a conflicted file of a few MB held the worker's event loop for minutes,
299
+ // the liveness probe killed it every 4.5 minutes, and every step on the box
300
+ // lost its model connection.
301
+ let end = -1;
302
+ if (typeof was === "string") {
303
+ const run = crypto.createHash("sha256");
304
+ if (run.copy().digest("hex") === was) end = 0;
305
+ let from = 0;
306
+ for (let nl = h.indexOf("\n"); nl !== -1 && nl < common; nl = h.indexOf("\n", nl + 1)) {
307
+ run.update(h.slice(from, nl + 1), "utf8");
308
+ from = nl + 1;
309
+ if (run.copy().digest("hex") === was) end = from;
310
+ }
311
+ } else {
312
+ // The base's bytes are known, so it can only end at their length.
313
+ const base = was.toString("utf8");
314
+ if (
315
+ base.length <= common &&
316
+ (base.length === 0 || base.endsWith("\n")) &&
317
+ h.startsWith(base) &&
318
+ Buffer.from(base, "utf8").equals(was)
319
+ )
320
+ end = base.length;
306
321
  }
307
- return null;
322
+ if (end < 0) return null;
323
+ const hostAdded = h.slice(end);
324
+ const stepAdded = n.slice(end);
325
+ const joined = hostAdded && !hostAdded.endsWith("\n") ? hostAdded + "\n" : hostAdded;
326
+ return Buffer.from(h.slice(0, end) + joined + stepAdded, "utf8");
308
327
  }
309
328
 
310
329
  export function applyContainerChanges(
@@ -408,6 +427,13 @@ export function applyContainerChanges(
408
427
  */
409
428
  export const RUNTIME_CACHE = "/home/agent/.foldrun/runner/.runtimes";
410
429
 
430
+ /** Starts the install helper as root, in the background, before the drop to
431
+ * the agent user: the root half of the `install` tool (install-helper.ts).
432
+ * The agent reaches it only through the socket; it installs only validated
433
+ * Debian package names, and dies with the container. */
434
+ export const INSTALL_HELPER_SH = `FOLDRUN_INSTALL_SOCKET=${INSTALL_SOCKET}; export FOLDRUN_INSTALL_SOCKET
435
+ node /opt/runner/node_modules/@foldrun/core/dist/src/install-helper.js "$FOLDRUN_INSTALL_SOCKET" ${RUNTIME_CACHE} >/dev/null 2>&1 &`;
436
+
411
437
  /**
412
438
  * Where the platform's shared runtime layer is mounted, read-only, in a run
413
439
  * pod — and read-write, one entry at a time, in the pod that builds an entry
@@ -548,6 +574,25 @@ try {
548
574
  // meet the packages its runtime declared, not the image's bare python.
549
575
  Object.assign(env, runtime.env);
550
576
 
577
+ // install: what the agent finds it needs mid-step. apt goes through the
578
+ // root start-up's helper (install-helper.ts); pip and npm land in a
579
+ // directory of this container's own, on the paths from the first turn.
580
+ const { buildInstallTool, INSTALL_TOOL } = await import("@foldrun/core/install-tool");
581
+ const { stepInstallEnv, INSTALL_SOCKET } = await import("@foldrun/core/runtime");
582
+ const installDir = "/home/agent/.foldrun/step";
583
+ const installing = (input.allowed ?? []).includes(INSTALL_TOOL);
584
+ if (installing) Object.assign(env, stepInstallEnv(installDir, env));
585
+ const install = installing
586
+ ? buildInstallTool({
587
+ dir: installDir,
588
+ socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,
589
+ cacheRoot: "${RUNTIME_CACHE}",
590
+ python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + "/bin/python" : "python3",
591
+ env,
592
+ emit,
593
+ })
594
+ : { server: null };
595
+
551
596
  // API and script tools, rebuilt in here from their specs. Secrets were
552
597
  // substituted into API headers before the input crossed the boundary, so
553
598
  // buildApiTools gets an already-resolved environment. verify: gets the
@@ -598,6 +643,7 @@ try {
598
643
  ...(history.server ? { foldrun_history: history.server } : {}),
599
644
  ...(desks.server ? { foldrun_desks: desks.server } : {}),
600
645
  ...(ask.server ? { foldrun_ask: ask.server } : {}),
646
+ ...(install.server ? { foldrun_install: install.server } : {}),
601
647
  ...input.mcpServers,
602
648
  },
603
649
  ...(inbox ? { inbox } : {}),
@@ -617,7 +663,7 @@ try {
617
663
  // through the account's pod, and named the file the web tool logs its
618
664
  // reconnects to. A lost pod stops the step; what it wrote decides what
619
665
  // the runner does next (browser-pod.ts).
620
- ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env) } } : {}),
666
+ ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env), ...(env.FOLDRUN_BROWSER_INIT ? { browseInit: env.FOLDRUN_BROWSER_INIT } : {}) } } : {}),
621
667
  // The container is the boundary; the SDK's own bash sandbox here would
622
668
  // only block declared network use (SSH, curl) for no added safety.
623
669
  sandboxBash: false,
@@ -647,17 +693,30 @@ try {
647
693
  }
648
694
  `;
649
695
 
696
+ // Core itself is installed in a stage of its own (CORE_STAGE) and only the
697
+ // result is copied in: the tarball and the package README — which explains
698
+ // foldrun to a person reading the repo, and which no step reads — stay in
699
+ // that stage and never become a layer of the image. Owner rule, 2026-10-02:
700
+ // foldrun's own docs live in git, not in containers.
701
+ const CORE_STAGE = `FROM base AS core
702
+ WORKDIR /opt/runner
703
+ COPY foldrun-core.tgz ./
704
+ RUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\
705
+ && npm cache clean --force >/dev/null 2>&1 \\
706
+ && rm -f foldrun-core.tgz node_modules/@foldrun/core/README.md
707
+ `;
708
+
650
709
  // docker cp writes root-owned files, so the entrypoint starts as root for
651
710
  // exactly two commands — chown the copied-in trees, drop to the agent user —
652
711
  // and the run flags grant only the three capabilities those two commands
653
712
  // need. By the time any model-directed code executes, the process is uid
654
713
  // 10001 with no capabilities at all.
655
714
  const CORE_INSTALL = `WORKDIR /opt/runner
656
- COPY foldrun-core.tgz driver.mjs entry.sh ./
657
- RUN npm init -y >/dev/null && npm install ./foldrun-core.tgz --omit=dev \\
658
- && npm cache clean --force >/dev/null 2>&1 \\
659
- && mkdir -p /workspace /library /opt/runner/job ${RUNTIME_CACHE} ${SHARED_RUNTIMES} \\
660
- && chown -R agent:agent /workspace /library /opt/runner /home/agent/.foldrun /opt/foldrun-runtimes \\
715
+ COPY --from=core --chown=agent:agent /opt/runner/ ./
716
+ COPY --chown=agent:agent driver.mjs entry.sh ./
717
+ RUN mkdir -p /workspace /library /opt/runner/job ${RUNTIME_CACHE} ${SHARED_RUNTIMES} \\
718
+ && chown agent:agent /opt/runner \\
719
+ && chown -R agent:agent /workspace /library /opt/runner/job /home/agent/.foldrun /opt/foldrun-runtimes \\
661
720
  && chmod +x /opt/runner/entry.sh
662
721
  ENTRYPOINT ["/opt/runner/entry.sh"]
663
722
  `;
@@ -724,6 +783,7 @@ RUN apt-get update \\
724
783
  COPY --from=ghcr.io/astral-sh/uv:${UV_VERSION} /uv /uvx /usr/local/bin/
725
784
  ENV PLAYWRIGHT_BROWSERS_PATH=/opt/browser NODE_PATH=/usr/local/lib/node_modules
726
785
 
786
+ ${CORE_STAGE}
727
787
  FROM base AS slim
728
788
  # Fonts, because a step that draws text without a browser still needs them:
729
789
  # the full image gets 51 from Playwright's --with-deps, slim had none, so from
@@ -879,7 +939,14 @@ RUN node -e ' \\
879
939
  FROM browsers AS full
880
940
  ${CORE_INSTALL}`;
881
941
 
942
+ // runtime: system: — apt as root, before the drop (runtime.ts systemInstallSh).
943
+ // set -e is off around it: a failed install is reported by the driver, which
944
+ // reads the status file and fails the step with apt's own words.
882
945
  const ENTRY = `#!/bin/sh
946
+ set +e
947
+ ${SYSTEM_FROM_INPUT_SH}
948
+ ${systemInstallSh(RUNTIME_CACHE)}
949
+ ${INSTALL_HELPER_SH}
883
950
  set -e
884
951
  chown -R agent:agent /workspace /library /opt/runner/job
885
952
  # The runtime cache, when one is mounted. NOT recursive: a warm cache is tens
@@ -1299,6 +1366,12 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
1299
1366
  preserveTimestamps: true,
1300
1367
  filter: (src) => !isPlatformPath(path.relative(args.workspaceRoot, src)),
1301
1368
  });
1369
+ // A test run: what its earlier steps wrote, over the real files (the
1370
+ // copy is the baseline too, so only this step's own writes come back).
1371
+ if (args.divert) {
1372
+ const laid = overlayTestWrites(wsIn, args.divert.to);
1373
+ if (laid.length) args.emit("info", `test: this step sees ${laid.length} file${laid.length === 1 ? "" : "s"} earlier steps of this run wrote`);
1374
+ }
1302
1375
  const libIn = path.join(staging, "library");
1303
1376
  if (args.galleryRoot && fs.existsSync(args.galleryRoot)) fs.cpSync(args.galleryRoot, libIn, { recursive: true });
1304
1377
  if (fs.existsSync(args.libraryRoot)) fs.cpSync(args.libraryRoot, libIn, { recursive: true });