@foldrun/core 0.3.0 → 0.4.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 (83) hide show
  1. package/README.md +27 -10
  2. package/dist/src/approvals.d.ts +104 -1
  3. package/dist/src/approvals.js +192 -2
  4. package/dist/src/budget.d.ts +32 -0
  5. package/dist/src/budget.js +177 -0
  6. package/dist/src/catalog.js +2 -1
  7. package/dist/src/confine.js +6 -1
  8. package/dist/src/egress.d.ts +19 -2
  9. package/dist/src/egress.js +2 -1
  10. package/dist/src/evals.d.ts +4 -0
  11. package/dist/src/evals.js +20 -11
  12. package/dist/src/flow-lint.d.ts +4 -0
  13. package/dist/src/flow-lint.js +108 -0
  14. package/dist/src/host-env.d.ts +8 -0
  15. package/dist/src/host-env.js +48 -0
  16. package/dist/src/json-schema.d.ts +12 -0
  17. package/dist/src/json-schema.js +202 -0
  18. package/dist/src/library.js +13 -1
  19. package/dist/src/notify.d.ts +79 -1
  20. package/dist/src/notify.js +243 -9
  21. package/dist/src/paths.d.ts +5 -0
  22. package/dist/src/paths.js +32 -0
  23. package/dist/src/platform.d.ts +102 -2
  24. package/dist/src/platform.js +18 -2
  25. package/dist/src/provider-health.d.ts +55 -0
  26. package/dist/src/provider-health.js +232 -0
  27. package/dist/src/run-container.d.ts +57 -4
  28. package/dist/src/run-container.js +147 -41
  29. package/dist/src/runner.d.ts +39 -3
  30. package/dist/src/runner.js +422 -73
  31. package/dist/src/script-tools.d.ts +12 -0
  32. package/dist/src/script-tools.js +10 -2
  33. package/dist/src/secret-health.d.ts +59 -0
  34. package/dist/src/secret-health.js +155 -0
  35. package/dist/src/secrets.d.ts +19 -1
  36. package/dist/src/secrets.js +44 -12
  37. package/dist/src/starter.js +3 -0
  38. package/dist/src/step-exec.d.ts +47 -1
  39. package/dist/src/step-exec.js +155 -60
  40. package/dist/src/store.d.ts +276 -3
  41. package/dist/src/store.js +552 -26
  42. package/dist/src/tar.js +2 -1
  43. package/dist/src/test-mode.d.ts +111 -0
  44. package/dist/src/test-mode.js +285 -0
  45. package/dist/src/tool-test.js +48 -21
  46. package/dist/src/translator.js +2 -1
  47. package/dist/src/trigger-log.d.ts +43 -0
  48. package/dist/src/trigger-log.js +122 -0
  49. package/dist/src/triggers.d.ts +20 -0
  50. package/dist/src/triggers.js +91 -10
  51. package/dist/src/webhook.d.ts +15 -3
  52. package/dist/src/webhook.js +11 -11
  53. package/package.json +4 -4
  54. package/src/approvals.ts +253 -2
  55. package/src/budget.ts +178 -0
  56. package/src/catalog.ts +2 -1
  57. package/src/confine.ts +6 -1
  58. package/src/egress.ts +20 -4
  59. package/src/evals.ts +25 -9
  60. package/src/flow-lint.ts +118 -0
  61. package/src/host-env.ts +51 -0
  62. package/src/json-schema.ts +184 -0
  63. package/src/library.ts +13 -1
  64. package/src/notify.ts +272 -11
  65. package/src/paths.ts +29 -0
  66. package/src/platform.ts +107 -3
  67. package/src/provider-health.ts +274 -0
  68. package/src/run-container.ts +175 -36
  69. package/src/runner.ts +435 -65
  70. package/src/script-tools.ts +18 -2
  71. package/src/secret-health.ts +185 -0
  72. package/src/secrets.ts +44 -10
  73. package/src/starter.ts +3 -0
  74. package/src/step-exec.ts +128 -13
  75. package/src/storage.ts +8 -2
  76. package/src/store.ts +746 -25
  77. package/src/tar.ts +2 -1
  78. package/src/test-mode.ts +311 -0
  79. package/src/tool-test.ts +49 -20
  80. package/src/translator.ts +2 -1
  81. package/src/trigger-log.ts +159 -0
  82. package/src/triggers.ts +92 -10
  83. package/src/webhook.ts +17 -11
@@ -25,6 +25,7 @@ import crypto from "node:crypto";
25
25
  import { spawn, spawnSync } from "node:child_process";
26
26
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
27
27
  import { isPlatformPath, type ApiSpec, type Effort } from "./store.ts";
28
+ import { divertedSummary, isDivertedPath } from "./test-mode.ts";
28
29
  import type { EventExtra } from "./step-exec.ts";
29
30
  import { isFileValue, fileContent } from "./secrets.ts";
30
31
  import { safeTenantSegment, type RuntimeSpec } from "./runtime.ts";
@@ -61,10 +62,14 @@ export interface ContainerStepInput {
61
62
  /** The step's spend ceiling and the model's per-token price — see
62
63
  * step-exec.ts. Values, so the driver can enforce them in the pod. */
63
64
  budgetUsd?: number | null;
65
+ budgetNote?: string;
64
66
  price?: { input: number; output: number } | null;
65
67
  verify?: string;
66
68
  /** `output: json` — checked inside, where the reply is. */
67
69
  output?: "json";
70
+ /** `schema:` and `max_turns:`, resolved host-side — see ExecOptions. */
71
+ schema?: Record<string, unknown> | boolean;
72
+ maxTurns?: number;
68
73
  /** tools: [search] — the directories to search, as paths INSIDE the
69
74
  * container (/workspace/…, /library/…). */
70
75
  search?: SearchRoot[];
@@ -215,10 +220,45 @@ export function hashTree(dir: string): Record<string, string> {
215
220
  return out;
216
221
  }
217
222
 
223
+ const sha = (b: Buffer) => crypto.createHash("sha256").update(b).digest("hex");
224
+
225
+ /**
226
+ * Both sides changed a file the step was handed. When each is the handed-in
227
+ * content with lines added on the end — a ledger, history.md, a .jsonl — the
228
+ * answer is the base, then the host's additions, then the step's. Only the
229
+ * base's hash may be known, so the base is found as the longest shared
230
+ * leading run of whole lines whose hash matches. Anything else is null: a
231
+ * real conflict, which the caller must not settle by dropping one side.
232
+ */
233
+ export function mergeAppends(was: Buffer | string | null, host: Buffer, next: Buffer): Buffer | null {
234
+ // Both sides created the same new file: no shared base to append to, and
235
+ // joining two JSON documents is corruption, not a merge.
236
+ if (was === null) return null;
237
+ const h = host.toString("utf8");
238
+ const n = next.toString("utf8");
239
+ let common = 0;
240
+ const max = Math.min(h.length, n.length);
241
+ while (common < max && h.charCodeAt(common) === n.charCodeAt(common)) common++;
242
+ for (let end = common; end >= 0; end--) {
243
+ if (end > 0 && h[end - 1] !== "\n") continue;
244
+ const prefix = h.slice(0, end);
245
+ const matches =
246
+ typeof was === "string" ? sha(Buffer.from(prefix, "utf8")) === was : Buffer.from(prefix, "utf8").equals(was);
247
+ if (!matches) continue;
248
+ const hostAdded = h.slice(end);
249
+ const stepAdded = n.slice(end);
250
+ const joined = hostAdded && !hostAdded.endsWith("\n") ? hostAdded + "\n" : hostAdded;
251
+ return Buffer.from(prefix + joined + stepAdded, "utf8");
252
+ }
253
+ return null;
254
+ }
255
+
218
256
  export function applyContainerChanges(
219
257
  hostWs: string,
220
258
  containerWs: string,
221
259
  baseline?: string | Record<string, string>,
260
+ note?: (message: string) => void,
261
+ divert?: WriteDivert,
222
262
  ): string[] {
223
263
  const applied: string[] = [];
224
264
  const walk = (dir: string) => {
@@ -237,18 +277,60 @@ export function applyContainerChanges(
237
277
  continue;
238
278
  }
239
279
  if (!allowedBack(rel)) continue;
240
- const next = fs.readFileSync(abs);
241
- // Untouched by this step: whatever the host says now is more current
242
- // than what we handed in, including a change made while it ran.
280
+ let next: Buffer = fs.readFileSync(abs);
281
+ // What the step was handed: its bytes (a staging dir) or its hash.
282
+ // null when the file did not exist then; undefined when no baseline.
283
+ let was: Buffer | string | null | undefined;
243
284
  if (typeof baseline === "string") {
244
- const was = path.join(baseline, rel);
245
- if (fs.existsSync(was) && fs.readFileSync(was).equals(next)) continue;
285
+ const p = path.join(baseline, rel);
286
+ was = fs.existsSync(p) ? fs.readFileSync(p) : null;
246
287
  } else if (baseline) {
247
- const was = baseline[rel.replaceAll("\\", "/")];
248
- if (was && was === crypto.createHash("sha256").update(next).digest("hex")) continue;
288
+ was = baseline[rel.replaceAll("\\", "/")] ?? null;
249
289
  }
290
+ const same = (b: Buffer) =>
291
+ was === null || was === undefined ? false : typeof was === "string" ? sha(b) === was : b.equals(was);
292
+ // Untouched by this step: whatever the host says now is more current
293
+ // than what we handed in, including a change made while it ran.
294
+ if (same(next)) continue;
250
295
  const target = path.join(hostWs, rel);
251
- if (fs.existsSync(target) && fs.readFileSync(target).equals(next)) continue;
296
+ const hostNow = fs.existsSync(target) ? fs.readFileSync(target) : null;
297
+ if (hostNow && hostNow.equals(next)) continue;
298
+ // The host moved too since the step was handed its copy — another run
299
+ // or a person wrote it. Writing the step's copy over it erased the
300
+ // other run's ledger rows on 2026-09-14. Merge appends; keep both
301
+ // sides of anything else.
302
+ const hostMoved = was !== undefined && hostNow !== null && !same(hostNow);
303
+ if (hostMoved) {
304
+ const merged = mergeAppends(was ?? null, hostNow, next);
305
+ if (merged) {
306
+ next = merged;
307
+ } else {
308
+ const ext = path.extname(rel);
309
+ const stem = rel.slice(0, rel.length - ext.length);
310
+ const conflictRel = `${stem}.conflict-${Date.now().toString(36)}${ext}`;
311
+ const conflictAbs = path.join(hostWs, conflictRel);
312
+ fs.mkdirSync(path.dirname(conflictAbs), { recursive: true });
313
+ fs.writeFileSync(conflictAbs, next);
314
+ applied.push(conflictRel);
315
+ note?.(
316
+ `write-back conflict on ${rel.replaceAll("\\", "/")}: it changed on the workspace while this step ran, ` +
317
+ `and not only by appending — kept the workspace's version; this step's version is ${conflictRel.replaceAll("\\", "/")}`,
318
+ );
319
+ continue;
320
+ }
321
+ }
322
+ // A test run's state/ and storage/ writes land under the run, not on
323
+ // the workspace: the step ran against the real files and its output
324
+ // is kept where a person can read it, but the next real run starts
325
+ // from state a test never touched. Decided after the merge so the
326
+ // diverted copy is what WOULD have been written, conflicts included.
327
+ if (divert && isDivertedPath(rel)) {
328
+ const aside = path.join(divert.to, rel);
329
+ fs.mkdirSync(path.dirname(aside), { recursive: true });
330
+ fs.writeFileSync(aside, next);
331
+ divert.note(rel.replaceAll("\\", "/"), divertedSummary(rel.replaceAll("\\", "/"), hostNow, next));
332
+ continue;
333
+ }
252
334
  fs.mkdirSync(path.dirname(target), { recursive: true });
253
335
  fs.writeFileSync(target, next);
254
336
  applied.push(rel);
@@ -378,9 +460,9 @@ try {
378
460
 
379
461
  // API and script tools, rebuilt in here from their specs. Secrets were
380
462
  // substituted into API headers before the input crossed the boundary, so
381
- // buildApiTools gets an already-resolved environment. (verify: needs no
382
- // separate env either — the container's own environment already carries
383
- // the declared secrets, which is why executeStep gets verifyEnv: {}.)
463
+ // buildApiTools gets an already-resolved environment. verify: gets the
464
+ // container's own environment, whole: it is the boundary, assembled on
465
+ // the host from an allowlist, and the declared secrets are in it.
384
466
  const api = buildApiTools("", input.apis, undefined, { env, missing: [] });
385
467
  const script = buildScriptTools(
386
468
  agentDir,
@@ -425,10 +507,13 @@ try {
425
507
  env,
426
508
  timeoutSec: input.timeoutSec,
427
509
  budgetUsd: input.budgetUsd,
510
+ budgetNote: input.budgetNote,
428
511
  price: input.price,
429
512
  verify: input.verify,
430
- verifyEnv: {},
513
+ verifyEnv: env,
431
514
  output: input.output,
515
+ schema: input.schema,
516
+ maxTurns: input.maxTurns,
432
517
  // The container is the boundary; the SDK's own bash sandbox here would
433
518
  // only block declared network use (SSH, curl) for no added safety.
434
519
  sandboxBash: false,
@@ -536,8 +621,17 @@ export function runnerImageTag(): string {
536
621
  * has not been built before. `npm pack` of the installed core, so the image
537
622
  * runs the same bytes the server does.
538
623
  */
539
- export function ensureRunnerImage(): { tag: string; log: string[] } {
540
- const tag = process.env.FOLDRUN_RUNNER_IMAGE ?? runnerImageTag();
624
+ /**
625
+ * `platform`: build for another architecture — "linux/arm64" on the amd64
626
+ * box, through QEMU — and name the result with the arch appended so it never
627
+ * shadows the native image in the daemon. Only the publisher asks for this;
628
+ * a step always runs the native image. Needs binfmt registered on the host
629
+ * (tonistiigi/binfmt); the publisher does that itself, every time, because
630
+ * the registration does not survive a reboot.
631
+ */
632
+ export function ensureRunnerImage(opts: { platform?: string } = {}): { tag: string; log: string[] } {
633
+ const arch = opts.platform ? `-${opts.platform.split("/").pop()}` : "";
634
+ const tag = process.env.FOLDRUN_RUNNER_IMAGE ?? runnerImageTag() + arch;
541
635
  const log: string[] = [];
542
636
  if (process.env.FOLDRUN_RUNNER_IMAGE) return { tag, log };
543
637
 
@@ -556,7 +650,8 @@ export function ensureRunnerImage(): { tag: string; log: string[] } {
556
650
  fs.writeFileSync(path.join(build, "entry.sh"), ENTRY);
557
651
  fs.writeFileSync(path.join(build, "Dockerfile"), DOCKERFILE);
558
652
  log.push(`building runner image ${tag} (first run only)`);
559
- const out = spawnSync(cli(), ["build", "-t", tag, build], { encoding: "utf8" });
653
+ const args = ["build", ...(opts.platform ? ["--platform", opts.platform] : []), "-t", tag, build];
654
+ const out = spawnSync(cli(), args, { encoding: "utf8" });
560
655
  if (out.status !== 0) {
561
656
  throw new Error(`runner image build failed:\n${(out.stderr || out.stdout).slice(-2000)}`);
562
657
  }
@@ -643,6 +738,8 @@ function ensureRunNetwork(): string {
643
738
  export interface RunInContainerArgs {
644
739
  workspaceRoot: string;
645
740
  libraryRoot: string;
741
+ /** The platform's gallery, staged under /library BEFORE the account library so the account's copies win. */
742
+ galleryRoot?: string;
646
743
  /** The account this step belongs to — the key for its dependency cache.
647
744
  * Optional so an embedder that has no tenancy simply gets no cache. */
648
745
  tenant?: string;
@@ -666,6 +763,66 @@ export interface RunInContainerArgs {
666
763
  * (consumed 0), and before each output line it applies. The runner
667
764
  * records these on the step, so the next driver knows where to attach. */
668
765
  checkpoint?: (ref: string, consumed: number) => void;
766
+ /** A test run: where state/ and storage/ changes go instead of the
767
+ * workspace, and who to tell about each. See test-mode.ts. */
768
+ divert?: WriteDivert;
769
+ /** The run is a test run. An isolated executor labels the sandbox so the
770
+ * cluster can cut its network down to the egress proxy: a script tool
771
+ * calls the network directly, not through the proxy where the test-mode
772
+ * policy lives, so without this a test run's script could still send. */
773
+ test?: boolean;
774
+ }
775
+
776
+ /** What a test run does with a write it must not apply. */
777
+ export interface WriteDivert {
778
+ /** The directory the diverted files are written under, by their
779
+ * workspace-relative path: runs/<id>/test-writes/. */
780
+ to: string;
781
+ note: (rel: string, summary: string) => void;
782
+ }
783
+
784
+ /**
785
+ * What crosses into the container as its environment, and how.
786
+ *
787
+ * A docker --env-file is one `KEY=value` per line and cannot carry a
788
+ * newline. @file secrets are multi-line by nature (a PEM key, a cert), so
789
+ * they are staged as files NOW, into the copied-in workspace, and the env
790
+ * carries the container path — the one place a path is stable across the
791
+ * boundary. A plain secret whose value happens to hold a newline gets the
792
+ * same treatment, and the trace says so: it used to be dropped from the
793
+ * env file without a word, and the step failed later, elsewhere, on a
794
+ * variable that read as unset. Everything else goes through the env file.
795
+ */
796
+ export function stageContainerEnv(
797
+ env: Record<string, string>,
798
+ wsIn: string,
799
+ agentRel: string,
800
+ emit: (type: "info" | "error", text: string) => void,
801
+ ): Record<string, string> {
802
+ const containerEnv: Record<string, string> = {};
803
+ const stage = (k: string, content: string) => {
804
+ const rel = path.join(agentRel, ".secret-files", k.toLowerCase());
805
+ const abs = path.join(wsIn, rel);
806
+ fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o700 });
807
+ fs.writeFileSync(abs, content, { mode: 0o600 });
808
+ return `/workspace/${rel.replaceAll("\\", "/")}`;
809
+ };
810
+ for (const [k, v] of Object.entries(env)) {
811
+ if (typeof v !== "string") continue;
812
+ if (isFileValue(v)) {
813
+ containerEnv[k] = stage(k, fileContent(v));
814
+ } else if (/[\r\n]/.test(v)) {
815
+ containerEnv[k] = stage(k, v);
816
+ emit(
817
+ "info",
818
+ `${k} holds a line break and cannot cross as an environment variable — ` +
819
+ `passed as a file instead; $${k} is its path in the sandbox`,
820
+ );
821
+ } else {
822
+ containerEnv[k] = v;
823
+ }
824
+ }
825
+ return containerEnv;
669
826
  }
670
827
 
671
828
  /** The limits a size class reserves. Large is the install's configured
@@ -759,6 +916,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
759
916
  filter: (src) => !isPlatformPath(path.relative(args.workspaceRoot, src)),
760
917
  });
761
918
  const libIn = path.join(staging, "library");
919
+ if (args.galleryRoot && fs.existsSync(args.galleryRoot)) fs.cpSync(args.galleryRoot, libIn, { recursive: true });
762
920
  if (fs.existsSync(args.libraryRoot)) fs.cpSync(args.libraryRoot, libIn, { recursive: true });
763
921
  else fs.mkdirSync(libIn, { recursive: true });
764
922
 
@@ -766,26 +924,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
766
924
  fs.mkdirSync(jobIn);
767
925
  fs.writeFileSync(path.join(jobIn, "input.json"), JSON.stringify(args.input, null, 2));
768
926
 
769
- // @file secrets are multi-line (a PEM key, a cert), and a docker
770
- // --env-file cannot carry a newline. So they are staged as files *now*,
771
- // into the copied-in workspace, and the env carries the container path —
772
- // the one place a path is stable across the boundary. Everything else
773
- // goes through the env file. (Without this the key silently vanished:
774
- // the old filter dropped any value with a newline, and ssh got an empty
775
- // -i path.)
776
- const containerEnv: Record<string, string> = {};
777
- for (const [k, v] of Object.entries(args.env)) {
778
- if (typeof v !== "string") continue;
779
- if (isFileValue(v)) {
780
- const rel = path.join(args.input.agentRel, ".secret-files", k.toLowerCase());
781
- const abs = path.join(wsIn, rel);
782
- fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o700 });
783
- fs.writeFileSync(abs, fileContent(v), { mode: 0o600 });
784
- containerEnv[k] = `/workspace/${rel.replaceAll("\\", "/")}`;
785
- } else if (!v.includes("\n")) {
786
- containerEnv[k] = v;
787
- }
788
- }
927
+ const containerEnv = stageContainerEnv(args.env, wsIn, args.input.agentRel, args.emit);
789
928
 
790
929
  const envFile = path.join(staging, "env");
791
930
  fs.writeFileSync(
@@ -903,7 +1042,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
903
1042
  fs.mkdirSync(wsOut);
904
1043
  const back = spawnSync(cli(), ["cp", `${containerId}:/workspace/.`, wsOut], { encoding: "utf8" });
905
1044
  if (back.status === 0) {
906
- applyContainerChanges(args.workspaceRoot, wsOut, wsIn);
1045
+ applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert);
907
1046
  } else {
908
1047
  args.emit("error", `copy-out failed — the step's file changes were lost:\n${back.stderr.slice(0, 500)}`);
909
1048
  }