@foldrun/core 0.6.0 → 1.0.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 (98) hide show
  1. package/README.md +14 -1
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -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/completions.js +1 -0
  10. package/dist/src/confine.d.ts +1 -0
  11. package/dist/src/confine.js +33 -4
  12. package/dist/src/deploy.d.ts +9 -0
  13. package/dist/src/deploy.js +31 -3
  14. package/dist/src/evals.js +22 -5
  15. package/dist/src/hooks.d.ts +114 -0
  16. package/dist/src/hooks.js +370 -0
  17. package/dist/src/host-env.d.ts +14 -0
  18. package/dist/src/host-env.js +26 -0
  19. package/dist/src/install-helper.d.ts +17 -0
  20. package/dist/src/install-helper.js +134 -0
  21. package/dist/src/install-tool.d.ts +128 -0
  22. package/dist/src/install-tool.js +133 -0
  23. package/dist/src/kinds.d.ts +1 -1
  24. package/dist/src/kinds.js +26 -0
  25. package/dist/src/library.d.ts +1 -1
  26. package/dist/src/library.js +17 -1
  27. package/dist/src/mail.js +1 -1
  28. package/dist/src/model-credential.d.ts +16 -0
  29. package/dist/src/model-credential.js +40 -0
  30. package/dist/src/model-env.d.ts +17 -0
  31. package/dist/src/model-env.js +57 -0
  32. package/dist/src/notify.d.ts +8 -0
  33. package/dist/src/notify.js +10 -1
  34. package/dist/src/package.d.ts +67 -0
  35. package/dist/src/package.js +594 -0
  36. package/dist/src/platform.d.ts +5 -0
  37. package/dist/src/platform.js +1 -0
  38. package/dist/src/provider-health.js +3 -0
  39. package/dist/src/providers.d.ts +5 -0
  40. package/dist/src/providers.js +21 -1
  41. package/dist/src/run-container.d.ts +10 -0
  42. package/dist/src/run-container.js +72 -14
  43. package/dist/src/runner.d.ts +205 -0
  44. package/dist/src/runner.js +273 -36
  45. package/dist/src/runtime.d.ts +77 -0
  46. package/dist/src/runtime.js +230 -5
  47. package/dist/src/script-tools.d.ts +2 -0
  48. package/dist/src/script-tools.js +3 -1
  49. package/dist/src/secrets.d.ts +5 -0
  50. package/dist/src/secrets.js +37 -6
  51. package/dist/src/starter.d.ts +12 -0
  52. package/dist/src/starter.js +38 -4
  53. package/dist/src/step-exec.d.ts +21 -2
  54. package/dist/src/step-exec.js +165 -7
  55. package/dist/src/storage.d.ts +7 -1
  56. package/dist/src/storage.js +14 -1
  57. package/dist/src/store.d.ts +31 -1
  58. package/dist/src/store.js +90 -8
  59. package/dist/src/tool-names.d.ts +3 -1
  60. package/dist/src/tool-names.js +4 -2
  61. package/dist/src/tool-test.js +8 -1
  62. package/dist/src/zip.d.ts +17 -0
  63. package/dist/src/zip.js +184 -0
  64. package/index.ts +3 -0
  65. package/package.json +18 -1
  66. package/src/agent-import.ts +154 -0
  67. package/src/api-tools.ts +16 -1
  68. package/src/approvals.ts +10 -3
  69. package/src/completions.ts +1 -0
  70. package/src/confine.ts +36 -5
  71. package/src/deploy.ts +28 -3
  72. package/src/evals.ts +21 -3
  73. package/src/hooks.ts +406 -0
  74. package/src/host-env.ts +27 -0
  75. package/src/install-helper.ts +143 -0
  76. package/src/install-tool.ts +154 -0
  77. package/src/kinds.ts +28 -0
  78. package/src/library.ts +18 -2
  79. package/src/mail.ts +1 -1
  80. package/src/model-credential.ts +41 -0
  81. package/src/model-env.ts +66 -0
  82. package/src/notify.ts +11 -1
  83. package/src/package.ts +664 -0
  84. package/src/platform.ts +6 -0
  85. package/src/provider-health.ts +3 -0
  86. package/src/providers.ts +27 -1
  87. package/src/run-container.ts +75 -14
  88. package/src/runner.ts +279 -36
  89. package/src/runtime.ts +235 -5
  90. package/src/script-tools.ts +5 -1
  91. package/src/secrets.ts +34 -6
  92. package/src/starter.ts +44 -4
  93. package/src/step-exec.ts +171 -11
  94. package/src/storage.ts +13 -0
  95. package/src/store.ts +89 -7
  96. package/src/tool-names.ts +4 -2
  97. package/src/tool-test.ts +8 -1
  98. package/src/zip.ts +189 -0
@@ -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);
package/src/providers.ts CHANGED
@@ -591,6 +591,11 @@ export interface BrowseSettings {
591
591
  ignore_https_errors?: boolean;
592
592
  /** The NAME of a vault secret whose value encrypts state= logins at rest. */
593
593
  state_key?: string;
594
+ /** A saved login every call uses by default: loaded from
595
+ * workspace/state/browser/<name>.json and saved back when the call ends.
596
+ * Beside `cookies:` (with `state_key:`), the secret seeds it once and the
597
+ * session the site keeps rotating is what later runs load. */
598
+ state?: string;
594
599
  /** true records every call, so the run page always has the recording to play. */
595
600
  video?: boolean;
596
601
  /** false stops the tool streaming its page to the run page while the step
@@ -685,7 +690,7 @@ export function browseEngineRefusal(engine: string, what: string): string | null
685
690
  }
686
691
  const BROWSE_SETTING_KEYS = [
687
692
  "engine", "user_agent", "device", "locale", "timezone", "cookies", "cookie_domain", "storage", "storage_origin", "identities", "headless", "version", "live",
688
- "allowed_domains", "deny", "boundaries", "init", "extensions", "webgpu", "ignore_https_errors", "state_key",
693
+ "allowed_domains", "deny", "boundaries", "init", "extensions", "webgpu", "ignore_https_errors", "state_key", "state",
689
694
  "video", "live_view", "vendor_session",
690
695
  // Deprecated spelling of vendor_session (read, and warned about).
691
696
  "session",
@@ -964,6 +969,11 @@ export function readBrowseSettings(raw: unknown): { settings: BrowseSettings; re
964
969
  if (list.length) settings[k] = list;
965
970
  continue;
966
971
  }
972
+ if (k === "state") {
973
+ if (typeof v !== "string" || !/^[A-Za-z0-9._-]{1,64}$/.test(v)) return { settings, rest: left(rest), error: "web.browse.state is a plain name (letters, digits, dot, dash, underscore), like youtube — the login is kept in workspace/state/browser/<name>.json." };
974
+ settings.state = v;
975
+ continue;
976
+ }
967
977
  if (k === "state_key") {
968
978
  if (typeof v !== "string" || !/^[A-Z][A-Z0-9_]{0,63}$/.test(v)) return { settings, rest: left(rest), error: "web.browse.state_key must be the NAME of a vault secret (CAPITALS), never the key — store it with `foldrun secrets set NAME` and declare it under secrets:." };
969
979
  settings.state_key = v;
@@ -1033,6 +1043,22 @@ export function readBrowseSettings(raw: unknown): { settings: BrowseSettings; re
1033
1043
  };
1034
1044
  }
1035
1045
 
1046
+ // A secret's cookies seeding a saved login: the login then sits in state/,
1047
+ // a workspace file, so it must be encrypted with a key from the vault.
1048
+ if (settings.state && settings.cookies && !settings.state_key) {
1049
+ return {
1050
+ settings,
1051
+ rest: left(rest),
1052
+ error:
1053
+ `web.browse.state beside cookies needs web.browse.state_key — the secret's session would be saved ` +
1054
+ `to state/ where any step can read it. Store a key (\`foldrun secrets set BROWSER_STATE_KEY\`), ` +
1055
+ `write \`state_key: BROWSER_STATE_KEY\` and declare it under secrets:.`,
1056
+ };
1057
+ }
1058
+ if (settings.state && settings.storage) {
1059
+ return { settings, rest: left(rest), error: "web.browse.state cannot be combined with storage: a secret's tokens would be written to state/." };
1060
+ }
1061
+
1036
1062
  // Storage is per origin, and seeding it into the wrong one would hand a
1037
1063
  // site another site's signed-in state — the same mistake `cookie_domain`
1038
1064
  // exists to prevent, one storage area over.
@@ -18,6 +18,7 @@
18
18
  // The runner image bundles @foldrun/core itself, so the driver runs the same
19
19
  // executeStep the server would — one implementation, two homes.
20
20
 
21
+ import { isHookRecord, type HookRecord } from "./hooks.ts";
21
22
  import fs from "node:fs";
22
23
  import os from "node:os";
23
24
  import path from "node:path";
@@ -28,7 +29,7 @@ import { isPlatformPath, type ApiSpec, type Effort } from "./store.ts";
28
29
  import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.ts";
29
30
  import type { EventExtra } from "./step-exec.ts";
30
31
  import { isFileValue, fileContent } from "./secrets.ts";
31
- import { safeTenantSegment, type RuntimeSpec } from "./runtime.ts";
32
+ import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET, type RuntimeSpec } from "./runtime.ts";
32
33
  import type { ScriptSpec } from "./script-tools.ts";
33
34
  import type { ConsultSpec } from "./agent-tools.ts";
34
35
  import type { SubagentSpec } from "./subagents.ts";
@@ -77,6 +78,9 @@ export interface ContainerStepInput {
77
78
  /** `limits:` resolved host-side, and the SDK-name → foldrun-name map the
78
79
  * count reads (limits.ts) — enforced in the pod by the same hook. */
79
80
  limits?: Record<string, number>;
81
+ /** `hooks:` names, resolved inside the sandbox against its own roots. */
82
+ hookNames?: string[];
83
+ hookBase?: Record<string, unknown>;
80
84
  toolOwners?: Record<string, string>;
81
85
  /** tools: [search] — the directories to search, as paths INSIDE the
82
86
  * container (/workspace/…, /library/…). */
@@ -172,7 +176,9 @@ const cli = () => process.env.FOLDRUN_CONTAINER_CLI ?? "docker";
172
176
  // there: everything platform-owned (store.ts#isPlatformPath — also never
173
177
  // copied in), plus knowledge/, which goes in for reading and is dropped on
174
178
  // the way out, and .git/.
175
- const DENY_BACK = ["knowledge/", ".git/"];
179
+ // hooks/: a step must not rewrite the code that guards it — for this run (the
180
+ // code is read into memory before the model starts, hooks.ts) or the next.
181
+ const DENY_BACK = ["knowledge/", "hooks/", ".git/"];
176
182
  // A .git ANYWHERE in the tree, not just at the root. A step that clones a
177
183
  // repository into a shared area (files/site, say) was shipping the entire
178
184
  // object store back to the workspace, where the harvest then filed thousands
@@ -293,18 +299,37 @@ export function mergeAppends(was: Buffer | string | null, host: Buffer, next: Bu
293
299
  let common = 0;
294
300
  const max = Math.min(h.length, n.length);
295
301
  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");
302
+ // One pass over the shared prefix. Rebuilding and hashing the prefix at
303
+ // every line boundary, from the longest down, was quadratic: on 6 Oct 2026
304
+ // a conflicted file of a few MB held the worker's event loop for minutes,
305
+ // the liveness probe killed it every 4.5 minutes, and every step on the box
306
+ // lost its model connection.
307
+ let end = -1;
308
+ if (typeof was === "string") {
309
+ const run = crypto.createHash("sha256");
310
+ if (run.copy().digest("hex") === was) end = 0;
311
+ let from = 0;
312
+ for (let nl = h.indexOf("\n"); nl !== -1 && nl < common; nl = h.indexOf("\n", nl + 1)) {
313
+ run.update(h.slice(from, nl + 1), "utf8");
314
+ from = nl + 1;
315
+ if (run.copy().digest("hex") === was) end = from;
316
+ }
317
+ } else {
318
+ // The base's bytes are known, so it can only end at their length.
319
+ const base = was.toString("utf8");
320
+ if (
321
+ base.length <= common &&
322
+ (base.length === 0 || base.endsWith("\n")) &&
323
+ h.startsWith(base) &&
324
+ Buffer.from(base, "utf8").equals(was)
325
+ )
326
+ end = base.length;
306
327
  }
307
- return null;
328
+ if (end < 0) return null;
329
+ const hostAdded = h.slice(end);
330
+ const stepAdded = n.slice(end);
331
+ const joined = hostAdded && !hostAdded.endsWith("\n") ? hostAdded + "\n" : hostAdded;
332
+ return Buffer.from(h.slice(0, end) + joined + stepAdded, "utf8");
308
333
  }
309
334
 
310
335
  export function applyContainerChanges(
@@ -408,6 +433,13 @@ export function applyContainerChanges(
408
433
  */
409
434
  export const RUNTIME_CACHE = "/home/agent/.foldrun/runner/.runtimes";
410
435
 
436
+ /** Starts the install helper as root, in the background, before the drop to
437
+ * the agent user: the root half of the `install` tool (install-helper.ts).
438
+ * The agent reaches it only through the socket; it installs only validated
439
+ * Debian package names, and dies with the container. */
440
+ export const INSTALL_HELPER_SH = `FOLDRUN_INSTALL_SOCKET=${INSTALL_SOCKET}; export FOLDRUN_INSTALL_SOCKET
441
+ node /opt/runner/node_modules/@foldrun/core/dist/src/install-helper.js "$FOLDRUN_INSTALL_SOCKET" ${RUNTIME_CACHE} >/dev/null 2>&1 &`;
442
+
411
443
  /**
412
444
  * Where the platform's shared runtime layer is mounted, read-only, in a run
413
445
  * pod — and read-write, one entry at a time, in the pod that builds an entry
@@ -548,6 +580,25 @@ try {
548
580
  // meet the packages its runtime declared, not the image's bare python.
549
581
  Object.assign(env, runtime.env);
550
582
 
583
+ // install: what the agent finds it needs mid-step. apt goes through the
584
+ // root start-up's helper (install-helper.ts); pip and npm land in a
585
+ // directory of this container's own, on the paths from the first turn.
586
+ const { buildInstallTool, INSTALL_TOOL } = await import("@foldrun/core/install-tool");
587
+ const { stepInstallEnv, INSTALL_SOCKET } = await import("@foldrun/core/runtime");
588
+ const installDir = "/home/agent/.foldrun/step";
589
+ const installing = (input.allowed ?? []).includes(INSTALL_TOOL);
590
+ if (installing) Object.assign(env, stepInstallEnv(installDir, env));
591
+ const install = installing
592
+ ? buildInstallTool({
593
+ dir: installDir,
594
+ socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,
595
+ cacheRoot: "${RUNTIME_CACHE}",
596
+ python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + "/bin/python" : "python3",
597
+ env,
598
+ emit,
599
+ })
600
+ : { server: null };
601
+
551
602
  // API and script tools, rebuilt in here from their specs. Secrets were
552
603
  // substituted into API headers before the input crossed the boundary, so
553
604
  // buildApiTools gets an already-resolved environment. verify: gets the
@@ -598,6 +649,7 @@ try {
598
649
  ...(history.server ? { foldrun_history: history.server } : {}),
599
650
  ...(desks.server ? { foldrun_desks: desks.server } : {}),
600
651
  ...(ask.server ? { foldrun_ask: ask.server } : {}),
652
+ ...(install.server ? { foldrun_install: install.server } : {}),
601
653
  ...input.mcpServers,
602
654
  },
603
655
  ...(inbox ? { inbox } : {}),
@@ -613,6 +665,7 @@ try {
613
665
  schema: input.schema,
614
666
  maxTurns: input.maxTurns,
615
667
  ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),
668
+ ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),
616
669
  // Slim browsing: the platform put this step on the slim image to browse
617
670
  // through the account's pod, and named the file the web tool logs its
618
671
  // reconnects to. A lost pod stops the step; what it wrote decides what
@@ -893,7 +946,14 @@ RUN node -e ' \\
893
946
  FROM browsers AS full
894
947
  ${CORE_INSTALL}`;
895
948
 
949
+ // runtime: system: — apt as root, before the drop (runtime.ts systemInstallSh).
950
+ // set -e is off around it: a failed install is reported by the driver, which
951
+ // reads the status file and fails the step with apt's own words.
896
952
  const ENTRY = `#!/bin/sh
953
+ set +e
954
+ ${SYSTEM_FROM_INPUT_SH}
955
+ ${systemInstallSh(RUNTIME_CACHE)}
956
+ ${INSTALL_HELPER_SH}
897
957
  set -e
898
958
  chown -R agent:agent /workspace /library /opt/runner/job
899
959
  # The runtime cache, when one is mounted. NOT recursive: a warm cache is tens
@@ -1054,7 +1114,7 @@ export function buildFailureSummary(log: string, opts: { tail?: number; maxLines
1054
1114
 
1055
1115
  export function parseDriverLine(
1056
1116
  line: string,
1057
- ): { e: "event"; type: "text" | "tool" | "info" | "error"; text: string; call?: string; ms?: number; err?: boolean; operator?: OperatorEvent; subagent?: string; check?: boolean } | { e: "done" } & ContainerStepOutcome | null {
1117
+ ): { e: "event"; type: "text" | "tool" | "info" | "error"; text: string; call?: string; ms?: number; err?: boolean; operator?: OperatorEvent; subagent?: string; check?: boolean; hook?: HookRecord } | { e: "done" } & ContainerStepOutcome | null {
1058
1118
  const trimmed = line.trim();
1059
1119
  if (!trimmed.startsWith("{")) return null;
1060
1120
  try {
@@ -1070,6 +1130,7 @@ export function parseDriverLine(
1070
1130
  ...(isOperatorEvent(parsed.operator) ? { operator: parsed.operator } : {}),
1071
1131
  ...(typeof parsed.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(parsed.subagent) ? { subagent: parsed.subagent } : {}),
1072
1132
  ...(parsed.check === true ? { check: true } : {}),
1133
+ ...(isHookRecord(parsed.hook) ? { hook: parsed.hook } : {}),
1073
1134
  };
1074
1135
  }
1075
1136
  if (parsed.e === "done") {