@foldrun/core 0.2.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 (85) hide show
  1. package/README.md +31 -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/deploy.js +4 -2
  9. package/dist/src/egress.d.ts +19 -2
  10. package/dist/src/egress.js +2 -1
  11. package/dist/src/evals.d.ts +4 -0
  12. package/dist/src/evals.js +20 -11
  13. package/dist/src/flow-lint.d.ts +4 -0
  14. package/dist/src/flow-lint.js +108 -0
  15. package/dist/src/host-env.d.ts +8 -0
  16. package/dist/src/host-env.js +48 -0
  17. package/dist/src/json-schema.d.ts +12 -0
  18. package/dist/src/json-schema.js +202 -0
  19. package/dist/src/library.js +13 -1
  20. package/dist/src/notify.d.ts +79 -1
  21. package/dist/src/notify.js +243 -9
  22. package/dist/src/paths.d.ts +5 -0
  23. package/dist/src/paths.js +32 -0
  24. package/dist/src/platform.d.ts +112 -2
  25. package/dist/src/platform.js +20 -2
  26. package/dist/src/provider-health.d.ts +55 -0
  27. package/dist/src/provider-health.js +232 -0
  28. package/dist/src/run-container.d.ts +80 -4
  29. package/dist/src/run-container.js +176 -37
  30. package/dist/src/runner.d.ts +41 -5
  31. package/dist/src/runner.js +513 -81
  32. package/dist/src/script-tools.d.ts +12 -0
  33. package/dist/src/script-tools.js +10 -2
  34. package/dist/src/secret-health.d.ts +59 -0
  35. package/dist/src/secret-health.js +155 -0
  36. package/dist/src/secrets.d.ts +19 -1
  37. package/dist/src/secrets.js +44 -12
  38. package/dist/src/starter.js +4 -0
  39. package/dist/src/step-exec.d.ts +62 -6
  40. package/dist/src/step-exec.js +199 -68
  41. package/dist/src/store.d.ts +300 -3
  42. package/dist/src/store.js +574 -26
  43. package/dist/src/tar.js +2 -1
  44. package/dist/src/test-mode.d.ts +111 -0
  45. package/dist/src/test-mode.js +285 -0
  46. package/dist/src/tool-test.js +48 -21
  47. package/dist/src/translator.js +2 -1
  48. package/dist/src/trigger-log.d.ts +43 -0
  49. package/dist/src/trigger-log.js +122 -0
  50. package/dist/src/triggers.d.ts +20 -0
  51. package/dist/src/triggers.js +91 -10
  52. package/dist/src/webhook.d.ts +15 -3
  53. package/dist/src/webhook.js +11 -11
  54. package/package.json +6 -5
  55. package/src/approvals.ts +253 -2
  56. package/src/budget.ts +178 -0
  57. package/src/catalog.ts +2 -1
  58. package/src/confine.ts +6 -1
  59. package/src/deploy.ts +4 -1
  60. package/src/egress.ts +20 -4
  61. package/src/evals.ts +25 -9
  62. package/src/flow-lint.ts +118 -0
  63. package/src/host-env.ts +51 -0
  64. package/src/json-schema.ts +184 -0
  65. package/src/library.ts +13 -1
  66. package/src/notify.ts +272 -11
  67. package/src/paths.ts +29 -0
  68. package/src/platform.ts +119 -3
  69. package/src/provider-health.ts +274 -0
  70. package/src/run-container.ts +215 -35
  71. package/src/runner.ts +525 -73
  72. package/src/script-tools.ts +18 -2
  73. package/src/secret-health.ts +185 -0
  74. package/src/secrets.ts +44 -10
  75. package/src/starter.ts +4 -0
  76. package/src/step-exec.ts +176 -21
  77. package/src/storage.ts +8 -2
  78. package/src/store.ts +785 -25
  79. package/src/tar.ts +2 -1
  80. package/src/test-mode.ts +311 -0
  81. package/src/tool-test.ts +49 -20
  82. package/src/translator.ts +2 -1
  83. package/src/trigger-log.ts +159 -0
  84. package/src/triggers.ts +92 -10
  85. 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[];
@@ -106,6 +111,10 @@ export interface StepActuals {
106
111
  export interface ContainerStepOutcome {
107
112
  status: "completed" | "failed";
108
113
  result: string | null;
114
+ /** Why the sandbox ended when the step did not: what the cluster said
115
+ * (OOMKilled, Evicted, the pod gone). Absent when the driver finished
116
+ * and said so itself. A retry policy reads this. */
117
+ reason?: string | null;
109
118
  /** The model's final text block — see StepRecord.conclusion. Crosses the
110
119
  * boundary with the rest of the outcome; the driver spreads it. */
111
120
  conclusion?: string | null;
@@ -186,10 +195,70 @@ export function allowedBack(rel: string): boolean {
186
195
  * the step did not touch it, so it is not ours to write back. Concurrent
187
196
  * edits survive, and a step that genuinely changed a file still wins.
188
197
  */
198
+ /**
199
+ * What a workspace copy looked like when it went in: the sha256 of every
200
+ * file the step could hand back. Small enough to ride into the sandbox
201
+ * beside the input, so a driver that inherits the step can tell what the
202
+ * step changed without the staging copy the first driver held in /tmp.
203
+ */
204
+ export function hashTree(dir: string): Record<string, string> {
205
+ const out: Record<string, string> = {};
206
+ const walk = (d: string) => {
207
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
208
+ const abs = path.join(d, entry.name);
209
+ const rel = path.relative(dir, abs).replaceAll("\\", "/");
210
+ if (entry.isSymbolicLink()) continue;
211
+ if (entry.isDirectory()) {
212
+ if (allowedBack(rel + "/")) walk(abs);
213
+ continue;
214
+ }
215
+ if (!allowedBack(rel)) continue;
216
+ out[rel] = crypto.createHash("sha256").update(fs.readFileSync(abs)).digest("hex");
217
+ }
218
+ };
219
+ if (fs.existsSync(dir)) walk(dir);
220
+ return out;
221
+ }
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
+
189
256
  export function applyContainerChanges(
190
257
  hostWs: string,
191
258
  containerWs: string,
192
- baseline?: string,
259
+ baseline?: string | Record<string, string>,
260
+ note?: (message: string) => void,
261
+ divert?: WriteDivert,
193
262
  ): string[] {
194
263
  const applied: string[] = [];
195
264
  const walk = (dir: string) => {
@@ -208,15 +277,60 @@ export function applyContainerChanges(
208
277
  continue;
209
278
  }
210
279
  if (!allowedBack(rel)) continue;
211
- const next = fs.readFileSync(abs);
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;
284
+ if (typeof baseline === "string") {
285
+ const p = path.join(baseline, rel);
286
+ was = fs.existsSync(p) ? fs.readFileSync(p) : null;
287
+ } else if (baseline) {
288
+ was = baseline[rel.replaceAll("\\", "/")] ?? null;
289
+ }
290
+ const same = (b: Buffer) =>
291
+ was === null || was === undefined ? false : typeof was === "string" ? sha(b) === was : b.equals(was);
212
292
  // Untouched by this step: whatever the host says now is more current
213
293
  // than what we handed in, including a change made while it ran.
214
- if (baseline) {
215
- const was = path.join(baseline, rel);
216
- if (fs.existsSync(was) && fs.readFileSync(was).equals(next)) continue;
217
- }
294
+ if (same(next)) continue;
218
295
  const target = path.join(hostWs, rel);
219
- 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
+ }
220
334
  fs.mkdirSync(path.dirname(target), { recursive: true });
221
335
  fs.writeFileSync(target, next);
222
336
  applied.push(rel);
@@ -346,9 +460,9 @@ try {
346
460
 
347
461
  // API and script tools, rebuilt in here from their specs. Secrets were
348
462
  // substituted into API headers before the input crossed the boundary, so
349
- // buildApiTools gets an already-resolved environment. (verify: needs no
350
- // separate env either — the container's own environment already carries
351
- // 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.
352
466
  const api = buildApiTools("", input.apis, undefined, { env, missing: [] });
353
467
  const script = buildScriptTools(
354
468
  agentDir,
@@ -393,10 +507,13 @@ try {
393
507
  env,
394
508
  timeoutSec: input.timeoutSec,
395
509
  budgetUsd: input.budgetUsd,
510
+ budgetNote: input.budgetNote,
396
511
  price: input.price,
397
512
  verify: input.verify,
398
- verifyEnv: {},
513
+ verifyEnv: env,
399
514
  output: input.output,
515
+ schema: input.schema,
516
+ maxTurns: input.maxTurns,
400
517
  // The container is the boundary; the SDK's own bash sandbox here would
401
518
  // only block declared network use (SSH, curl) for no added safety.
402
519
  sandboxBash: false,
@@ -504,8 +621,17 @@ export function runnerImageTag(): string {
504
621
  * has not been built before. `npm pack` of the installed core, so the image
505
622
  * runs the same bytes the server does.
506
623
  */
507
- export function ensureRunnerImage(): { tag: string; log: string[] } {
508
- 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;
509
635
  const log: string[] = [];
510
636
  if (process.env.FOLDRUN_RUNNER_IMAGE) return { tag, log };
511
637
 
@@ -524,7 +650,8 @@ export function ensureRunnerImage(): { tag: string; log: string[] } {
524
650
  fs.writeFileSync(path.join(build, "entry.sh"), ENTRY);
525
651
  fs.writeFileSync(path.join(build, "Dockerfile"), DOCKERFILE);
526
652
  log.push(`building runner image ${tag} (first run only)`);
527
- 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" });
528
655
  if (out.status !== 0) {
529
656
  throw new Error(`runner image build failed:\n${(out.stderr || out.stdout).slice(-2000)}`);
530
657
  }
@@ -611,6 +738,8 @@ function ensureRunNetwork(): string {
611
738
  export interface RunInContainerArgs {
612
739
  workspaceRoot: string;
613
740
  libraryRoot: string;
741
+ /** The platform's gallery, staged under /library BEFORE the account library so the account's copies win. */
742
+ galleryRoot?: string;
614
743
  /** The account this step belongs to — the key for its dependency cache.
615
744
  * Optional so an embedder that has no tenancy simply gets no cache. */
616
745
  tenant?: string;
@@ -625,6 +754,75 @@ export interface RunInContainerArgs {
625
754
  runId?: string;
626
755
  /** The reservation class — which limits this step's sandbox holds. */
627
756
  size?: "small" | "large" | "heavy";
757
+ /** Re-attach to a sandbox a previous driver started, instead of creating
758
+ * one: its handle, and how many output lines that driver had applied.
759
+ * The executor replays nothing before `consumed`, and reads the step's
760
+ * files back from the sandbox's own copy-in snapshot. */
761
+ resume?: { ref: string; consumed: number } | null;
762
+ /** Called by an executor that can be resumed: when its sandbox exists
763
+ * (consumed 0), and before each output line it applies. The runner
764
+ * records these on the step, so the next driver knows where to attach. */
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;
628
826
  }
629
827
 
630
828
  /** The limits a size class reserves. Large is the install's configured
@@ -718,6 +916,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
718
916
  filter: (src) => !isPlatformPath(path.relative(args.workspaceRoot, src)),
719
917
  });
720
918
  const libIn = path.join(staging, "library");
919
+ if (args.galleryRoot && fs.existsSync(args.galleryRoot)) fs.cpSync(args.galleryRoot, libIn, { recursive: true });
721
920
  if (fs.existsSync(args.libraryRoot)) fs.cpSync(args.libraryRoot, libIn, { recursive: true });
722
921
  else fs.mkdirSync(libIn, { recursive: true });
723
922
 
@@ -725,26 +924,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
725
924
  fs.mkdirSync(jobIn);
726
925
  fs.writeFileSync(path.join(jobIn, "input.json"), JSON.stringify(args.input, null, 2));
727
926
 
728
- // @file secrets are multi-line (a PEM key, a cert), and a docker
729
- // --env-file cannot carry a newline. So they are staged as files *now*,
730
- // into the copied-in workspace, and the env carries the container path —
731
- // the one place a path is stable across the boundary. Everything else
732
- // goes through the env file. (Without this the key silently vanished:
733
- // the old filter dropped any value with a newline, and ssh got an empty
734
- // -i path.)
735
- const containerEnv: Record<string, string> = {};
736
- for (const [k, v] of Object.entries(args.env)) {
737
- if (typeof v !== "string") continue;
738
- if (isFileValue(v)) {
739
- const rel = path.join(args.input.agentRel, ".secret-files", k.toLowerCase());
740
- const abs = path.join(wsIn, rel);
741
- fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o700 });
742
- fs.writeFileSync(abs, fileContent(v), { mode: 0o600 });
743
- containerEnv[k] = `/workspace/${rel.replaceAll("\\", "/")}`;
744
- } else if (!v.includes("\n")) {
745
- containerEnv[k] = v;
746
- }
747
- }
927
+ const containerEnv = stageContainerEnv(args.env, wsIn, args.input.agentRel, args.emit);
748
928
 
749
929
  const envFile = path.join(staging, "env");
750
930
  fs.writeFileSync(
@@ -862,7 +1042,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
862
1042
  fs.mkdirSync(wsOut);
863
1043
  const back = spawnSync(cli(), ["cp", `${containerId}:/workspace/.`, wsOut], { encoding: "utf8" });
864
1044
  if (back.status === 0) {
865
- applyContainerChanges(args.workspaceRoot, wsOut, wsIn);
1045
+ applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert);
866
1046
  } else {
867
1047
  args.emit("error", `copy-out failed — the step's file changes were lost:\n${back.stderr.slice(0, 500)}`);
868
1048
  }