@foldrun/core 0.7.0 → 1.1.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 (49) hide show
  1. package/README.md +14 -1
  2. package/dist/src/completions.js +1 -0
  3. package/dist/src/deploy.js +5 -0
  4. package/dist/src/egress.d.ts +21 -0
  5. package/dist/src/egress.js +8 -0
  6. package/dist/src/hooks.d.ts +114 -0
  7. package/dist/src/hooks.js +370 -0
  8. package/dist/src/kinds.d.ts +1 -1
  9. package/dist/src/kinds.js +26 -0
  10. package/dist/src/library.d.ts +1 -1
  11. package/dist/src/library.js +17 -1
  12. package/dist/src/mail.js +1 -1
  13. package/dist/src/network.d.ts +120 -0
  14. package/dist/src/network.js +360 -0
  15. package/dist/src/notify.d.ts +8 -0
  16. package/dist/src/notify.js +10 -1
  17. package/dist/src/providers.d.ts +5 -0
  18. package/dist/src/providers.js +21 -1
  19. package/dist/src/run-container.d.ts +13 -0
  20. package/dist/src/run-container.js +8 -1
  21. package/dist/src/runner.d.ts +1 -0
  22. package/dist/src/runner.js +144 -16
  23. package/dist/src/secrets.d.ts +20 -0
  24. package/dist/src/secrets.js +65 -1
  25. package/dist/src/starter.js +9 -1
  26. package/dist/src/step-exec.d.ts +10 -0
  27. package/dist/src/step-exec.js +113 -2
  28. package/dist/src/store.d.ts +23 -1
  29. package/dist/src/store.js +33 -1
  30. package/dist/src/tool-loop.d.ts +45 -0
  31. package/dist/src/tool-loop.js +133 -0
  32. package/package.json +19 -1
  33. package/src/completions.ts +1 -0
  34. package/src/deploy.ts +4 -0
  35. package/src/egress.ts +26 -0
  36. package/src/hooks.ts +406 -0
  37. package/src/kinds.ts +28 -0
  38. package/src/library.ts +18 -2
  39. package/src/mail.ts +1 -1
  40. package/src/network.ts +390 -0
  41. package/src/notify.ts +11 -1
  42. package/src/providers.ts +27 -1
  43. package/src/run-container.ts +16 -2
  44. package/src/runner.ts +145 -14
  45. package/src/secrets.ts +69 -1
  46. package/src/starter.ts +9 -1
  47. package/src/step-exec.ts +115 -3
  48. package/src/store.ts +39 -1
  49. package/src/tool-loop.ts +176 -0
@@ -1,7 +1,9 @@
1
+ import { type HookRecord } from "./hooks.ts";
1
2
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
2
3
  import { type ApiSpec, type Effort } from "./store.ts";
3
4
  import type { EventExtra } from "./step-exec.ts";
4
5
  import { type RuntimeSpec } from "./runtime.ts";
6
+ import { type NetworkLevel } from "./network.ts";
5
7
  import type { ScriptSpec } from "./script-tools.ts";
6
8
  import type { ConsultSpec } from "./agent-tools.ts";
7
9
  import type { SubagentSpec } from "./subagents.ts";
@@ -52,6 +54,9 @@ export interface ContainerStepInput {
52
54
  /** `limits:` resolved host-side, and the SDK-name → foldrun-name map the
53
55
  * count reads (limits.ts) — enforced in the pod by the same hook. */
54
56
  limits?: Record<string, number>;
57
+ /** `hooks:` names, resolved inside the sandbox against its own roots. */
58
+ hookNames?: string[];
59
+ hookBase?: Record<string, unknown>;
55
60
  toolOwners?: Record<string, string>;
56
61
  /** tools: [search] — the directories to search, as paths INSIDE the
57
62
  * container (/workspace/…, /library/…). */
@@ -340,6 +345,7 @@ export declare function parseDriverLine(line: string): {
340
345
  operator?: OperatorEvent;
341
346
  subagent?: string;
342
347
  check?: boolean;
348
+ hook?: HookRecord;
343
349
  } | ({
344
350
  e: "done";
345
351
  } & ContainerStepOutcome) | null;
@@ -354,6 +360,13 @@ export interface RunInContainerArgs {
354
360
  input: ContainerStepInput;
355
361
  /** Secret + provider env — crosses as an env file, never argv. */
356
362
  env: Record<string, string>;
363
+ /** The step's network (network.ts), for an executor that can wall the
364
+ * sandbox in itself: the k8s tier labels a filtered pod so its policy
365
+ * admits the proxy and DNS alone. `enforced` false is report-only. */
366
+ network?: {
367
+ level: NetworkLevel;
368
+ enforced: boolean;
369
+ };
357
370
  emit: (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
358
371
  /** The run this step belongs to. Stamped on the sandbox as a label so a
359
372
  * person stopping the run can reach the thing actually burning money —
@@ -17,6 +17,7 @@
17
17
  //
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
+ import { isHookRecord } from "./hooks.js";
20
21
  import fs from "node:fs";
21
22
  import os from "node:os";
22
23
  import path from "node:path";
@@ -26,6 +27,7 @@ import { isPlatformPath } from "./store.js";
26
27
  import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.js";
27
28
  import { isFileValue, fileContent } from "./secrets.js";
28
29
  import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET } from "./runtime.js";
30
+ import { egressCaSh } from "./network.js";
29
31
  import { isOperatorEvent } from "./operator.js";
30
32
  import { parseBrowserPod } from "./browser-pod.js";
31
33
  /**
@@ -42,7 +44,9 @@ const cli = () => process.env.FOLDRUN_CONTAINER_CLI ?? "docker";
42
44
  // there: everything platform-owned (store.ts#isPlatformPath — also never
43
45
  // copied in), plus knowledge/, which goes in for reading and is dropped on
44
46
  // the way out, and .git/.
45
- const DENY_BACK = ["knowledge/", ".git/"];
47
+ // hooks/: a step must not rewrite the code that guards it — for this run (the
48
+ // code is read into memory before the model starts, hooks.ts) or the next.
49
+ const DENY_BACK = ["knowledge/", "hooks/", ".git/"];
46
50
  // A .git ANYWHERE in the tree, not just at the root. A step that clones a
47
51
  // repository into a shared area (files/site, say) was shipping the entire
48
52
  // object store back to the workspace, where the harvest then filed thousands
@@ -531,6 +535,7 @@ try {
531
535
  schema: input.schema,
532
536
  maxTurns: input.maxTurns,
533
537
  ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),
538
+ ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),
534
539
  // Slim browsing: the platform put this step on the slim image to browse
535
540
  // through the account's pod, and named the file the web tool logs its
536
541
  // reconnects to. A lost pod stops the step; what it wrote decides what
@@ -808,6 +813,7 @@ ${CORE_INSTALL}`;
808
813
  // reads the status file and fails the step with apt's own words.
809
814
  const ENTRY = `#!/bin/sh
810
815
  set +e
816
+ ${egressCaSh("/opt/runner/job")}
811
817
  ${SYSTEM_FROM_INPUT_SH}
812
818
  ${systemInstallSh(RUNTIME_CACHE)}
813
819
  ${INSTALL_HELPER_SH}
@@ -983,6 +989,7 @@ export function parseDriverLine(line) {
983
989
  ...(isOperatorEvent(parsed.operator) ? { operator: parsed.operator } : {}),
984
990
  ...(typeof parsed.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(parsed.subagent) ? { subagent: parsed.subagent } : {}),
985
991
  ...(parsed.check === true ? { check: true } : {}),
992
+ ...(isHookRecord(parsed.hook) ? { hook: parsed.hook } : {}),
986
993
  };
987
994
  }
988
995
  if (parsed.e === "done") {
@@ -250,6 +250,7 @@ grantsOnly?: boolean): {
250
250
  FOLDRUN_BROWSER_WEBGPU?: string | undefined;
251
251
  FOLDRUN_BROWSER_IGNORE_HTTPS_ERRORS?: string | undefined;
252
252
  FOLDRUN_BROWSER_STATE_KEY?: string | undefined;
253
+ FOLDRUN_BROWSER_STATE?: string | undefined;
253
254
  FOLDRUN_BROWSER_VIDEO?: string | undefined;
254
255
  FOLDRUN_BROWSER_LIVE_VIEW?: string | undefined;
255
256
  };
@@ -2,6 +2,7 @@
2
2
  // group receives every prior result, labeled per agent. Optional steps ("2?")
3
3
  // fail without failing the flow. A direct agent run is a one-step ad-hoc
4
4
  // flow, so there is a single execution path.
5
+ import { hookNames } from "./hooks.js";
5
6
  import fs from "node:fs";
6
7
  import os from "node:os";
7
8
  import path from "node:path";
@@ -34,7 +35,8 @@ import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacy
34
35
  import { refNames } from "./refs.js";
35
36
  import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
36
37
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel } from "./catalog.js";
37
- import { resolveSecrets, getSecret, materializeSecrets } from "./secrets.js";
38
+ import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.js";
39
+ import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.js";
38
40
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
39
41
  import { buildApiTools, secretsUsedByApi } from "./api-tools.js";
40
42
  import { attachOperations, prefetchOpenApi } from "./openapi.js";
@@ -59,6 +61,24 @@ export { localDate } from "./clock.js";
59
61
  // An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
60
62
  // in env and headers resolve server-side, so a credential reaches the server
61
63
  // process without ever entering the model's context.
64
+ /** Remote MCP servers with each bound secret's value put back as its
65
+ * `${NAME}`, granted to that server's host — the sandbox then holds the
66
+ * name and the proxy fills it, as for an http tool. */
67
+ function unbindMcpHeaders(servers, bound, values, grant) {
68
+ const known = Object.fromEntries(bound.filter((n) => n in values).map((n) => [n, values[n]]));
69
+ const out = {};
70
+ for (const [name, server] of Object.entries(servers)) {
71
+ if (server.type !== "http" || !server.headers) {
72
+ out[name] = server;
73
+ continue;
74
+ }
75
+ const host = hostOf(server.url);
76
+ out[name] = host
77
+ ? { ...server, headers: Object.fromEntries(Object.entries(server.headers).map(([k, v]) => [k, unsubstitute(v, known, host, grant)])) }
78
+ : server;
79
+ }
80
+ return out;
81
+ }
62
82
  function mcpConfig(spec, tenant, workspace) {
63
83
  const fill = (values) => {
64
84
  const out = {};
@@ -1214,6 +1234,7 @@ grantsOnly = false) {
1214
1234
  ...(browseSettings.webgpu ? { FOLDRUN_BROWSER_WEBGPU: "1" } : {}),
1215
1235
  ...(browseSettings.ignore_https_errors ? { FOLDRUN_BROWSER_IGNORE_HTTPS_ERRORS: "1" } : {}),
1216
1236
  ...(browseSettings.state_key ? { FOLDRUN_BROWSER_STATE_KEY: browseSettings.state_key } : {}),
1237
+ ...(browseSettings.state ? { FOLDRUN_BROWSER_STATE: browseSettings.state } : {}),
1217
1238
  ...(browseSettings.video ? { FOLDRUN_BROWSER_VIDEO: "1" } : {}),
1218
1239
  ...(browseSettings.live_view === false ? { FOLDRUN_BROWSER_LIVE_VIEW: "0" } : {}),
1219
1240
  };
@@ -2167,6 +2188,18 @@ stepIndex) {
2167
2188
  const limitOpts = Object.keys(stepLimits).length ? { limits: stepLimits, toolOwners: limitOwners } : {};
2168
2189
  if (limitOpts.limits)
2169
2190
  push("info", `limits: ${Object.entries(stepLimits).map(([k, n]) => `${k} ${n}`).join(", ")} — per step; a call past one is refused`);
2191
+ // `hooks:` — the account's, the workspace's, then the agent's own, each
2192
+ // once; resolved and run inside the step (hooks.ts, step-exec.ts).
2193
+ const stepHookNames = [...new Set([
2194
+ ...hookNames(readAgentsMd(accountDir(tenant))?.data?.hooks),
2195
+ ...hookNames(readAgentsMd(workspaceRootOf(agentDir))?.data?.hooks),
2196
+ ...hookNames(front.hooks),
2197
+ ])];
2198
+ const hookOpts = stepHookNames.length
2199
+ ? { hookNames: stepHookNames, hookBase: { run_id: runId ?? null, step: stepIndex ?? null, agent: step.agent, workspace: path.basename(workspaceRootOf(agentDir)) } }
2200
+ : {};
2201
+ if (stepHookNames.length)
2202
+ push("info", `hooks: ${stepHookNames.join(", ")}`);
2170
2203
  const isolation = process.env.FOLDRUN_RUN_ISOLATION;
2171
2204
  if (isolatedRun()) {
2172
2205
  // The isolated path: the whole loop — model, built-in tools, scripts —
@@ -2217,7 +2250,46 @@ stepIndex) {
2217
2250
  }
2218
2251
  }
2219
2252
  }
2253
+ // `network:` — which sites the sandbox may reach (network.ts). Resolved
2254
+ // before the lease, because the proxy holds the list per request. A
2255
+ // step that may fetch or browse the open web defaults to open; every
2256
+ // other to the trusted list plus what it declares.
2257
+ const workspaceName = path.basename(workspaceRootOf(agentDir));
2258
+ const readsWeb = scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.some((a) => a === "fetch" || a === "browse"));
2259
+ // Secrets bound to sites in the vault: filled by the proxy for those
2260
+ // sites alone, never put in the sandbox (egress.ts).
2261
+ const boundSites = secretSites(tenant, Object.keys(liveSecrets), workspaceName);
2262
+ const network = resolveNetwork({
2263
+ account: readAgentsMd(accountDir(tenant))?.data?.network,
2264
+ workspace: readAgentsMd(workspaceRootOf(agentDir))?.data?.network,
2265
+ agent: front.network,
2266
+ readsWeb,
2267
+ implied: [
2268
+ ...apiSpecs.map((a) => hostOf(a.base)).filter((h) => !!h),
2269
+ ...Object.values(mcpServers).map((m) => ("url" in m && typeof m.url === "string" ? hostOf(m.url) : null)).filter((h) => !!h),
2270
+ ...Object.values(grant.secrets).flatMap((g) => g.hosts),
2271
+ ...Object.values(boundSites).flat(),
2272
+ ],
2273
+ });
2274
+ grant.network = { level: network.level, hosts: network.hosts };
2220
2275
  const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun, ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}) });
2276
+ // The forward door: every program in the sandbox sends through the
2277
+ // proxy, which holds the site list and fills bound secrets. A proxy
2278
+ // without the door (an older platform) leaves programs going direct.
2279
+ // A step that may browse gets no proxy variables: the browser in it
2280
+ // reads HTTPS_PROXY too, and Chromium cannot send credentials written
2281
+ // into a proxy URL — every page failed ERR_INVALID_AUTH_CREDENTIALS
2282
+ // (14 Oct 2026, the reel posters). Such a step is open by default,
2283
+ // and its bound secrets materialise as they did before the door.
2284
+ const browses = scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse"));
2285
+ const forward = lease?.proxy && !browses ? lease : null;
2286
+ push("info", networkLine(network, forward ? forward.enforce === true : null));
2287
+ const bound = forward ? Object.keys(boundSites).filter((name) => name in liveSecrets) : [];
2288
+ for (const name of bound) {
2289
+ for (const site of boundSites[name])
2290
+ addGrant(grant, name, liveSecrets[name], site);
2291
+ (grant.healthKeys ??= {})[name] = healthKey(name, secretScopes[name] === "workspace" ? "workspace" : "account", workspaceName);
2292
+ }
2221
2293
  // Which secrets must still be real inside the sandbox: a script tool
2222
2294
  // reads them from its environment, and unless it says `secrets:
2223
2295
  // proxied` it needs the value, not the name. bash is a script by
@@ -2229,19 +2301,24 @@ stepIndex) {
2229
2301
  ...(scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse")) ? ["web (browse)"] : []),
2230
2302
  ...(allowed.includes("Bash") ? ["bash"] : []),
2231
2303
  ];
2232
- const materialise = !lease || materialisers.length > 0;
2233
2304
  const declared = Object.keys(liveSecrets);
2305
+ // A bound secret is never materialised, whatever the step grants.
2306
+ const unbound = declared.filter((name) => !bound.includes(name));
2307
+ const materialise = (!lease || materialisers.length > 0) && unbound.length > 0;
2234
2308
  if (lease) {
2309
+ const boundNote = bound.length ? `${bound.join(", ")} bound to sites — filled by the egress proxy, never in the sandbox` : "";
2235
2310
  push("info", declared.length
2236
2311
  ? materialise
2237
- ? `secrets: ${declared.join(", ")} materialised in the sandbox for ${materialisers.join(", ")}; api tools and the model key go through the egress proxy`
2238
- : `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names`
2312
+ ? `secrets: ${unbound.join(", ")} materialised in the sandbox for ${materialisers.join(", ")}${boundNote ? `; ${boundNote}` : ""}; api tools and the model key go through the egress proxy`
2313
+ : `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names${boundNote ? ` (${boundNote})` : ""}`
2239
2314
  : "secrets: none declared; the model key goes through the egress proxy");
2240
2315
  }
2241
2316
  // Sandbox env for the declared secrets: the value, or its own name as
2242
2317
  // a placeholder that the proxy fills on the way out.
2243
2318
  const placeholders = Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
2244
- let sandboxSecrets = materialise ? liveSecrets : placeholders;
2319
+ let sandboxSecrets = materialise
2320
+ ? { ...liveSecrets, ...Object.fromEntries(bound.map((name) => [name, placeholders[name]])) }
2321
+ : placeholders;
2245
2322
  // A test run hands the sandbox no send-capable value. The env var is
2246
2323
  // still there, reading TEST_MODE_WITHHELD, so a script that never
2247
2324
  // checked FOLDRUN_TEST_MODE fails at the provider's door with an auth
@@ -2249,11 +2326,13 @@ stepIndex) {
2249
2326
  // has a declared sender (everything withheld) or a declared reader
2250
2327
  // (nothing withheld); see test-mode.ts for the order.
2251
2328
  if (testRun && materialise) {
2252
- const held = withholdSecrets(liveSecrets, {
2329
+ const held = withholdSecrets(Object.fromEntries(unbound.map((name) => [name, liveSecrets[name]])), {
2253
2330
  outward: scriptSpecs.some((sc) => sc.outward),
2254
2331
  allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
2255
2332
  });
2256
- sandboxSecrets = held.env;
2333
+ // Bound secrets stay placeholders: the proxy, which holds the test
2334
+ // policy, is the only party that ever fills them.
2335
+ sandboxSecrets = { ...held.env, ...Object.fromEntries(bound.map((name) => [name, placeholders[name]])) };
2257
2336
  for (const name of held.withheld) {
2258
2337
  push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
2259
2338
  }
@@ -2308,7 +2387,9 @@ stepIndex) {
2308
2387
  systemPrompt,
2309
2388
  allowed,
2310
2389
  mcpNames: consultNames,
2311
- mcpServers,
2390
+ // A remote MCP server's headers had their secrets filled host-side;
2391
+ // a bound one goes back to its ${NAME} for the proxy to fill.
2392
+ mcpServers: bound.length ? unbindMcpHeaders(mcpServers, bound, liveSecrets, grant) : mcpServers,
2312
2393
  apis: substitutedApis,
2313
2394
  scripts: scriptSpecs,
2314
2395
  // The merged spec, not `front.runtime` — see the plan's `runtime`
@@ -2325,6 +2406,7 @@ stepIndex) {
2325
2406
  schema: resolvedSchema ?? undefined,
2326
2407
  maxTurns: step.maxTurns,
2327
2408
  ...limitOpts,
2409
+ ...hookOpts,
2328
2410
  translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
2329
2411
  // Outward steps keep a browser of their own: an executor that
2330
2412
  // could browse from the slim image through the account's pod
@@ -2350,6 +2432,14 @@ stepIndex) {
2350
2432
  // after and carries the agent's chosen endpoint + token.
2351
2433
  ...proxied(Object.keys(providerEnv).length === 0 ? modelEnv : providerEnv, keyName),
2352
2434
  ...(lease ? { [EGRESS_ENV]: lease.url } : {}),
2435
+ // Every program's HTTP(S) through the proxy's forward door, and
2436
+ // the CA it signs bound sites with, for the sandbox to trust.
2437
+ ...(forward ? proxyEnv(forward.proxy) : {}),
2438
+ // Only a step with a bound secret has a site the proxy
2439
+ // intercepts, so only that step's TLS clients are pointed at a
2440
+ // bundle with the egress CA in it; every other step's are as
2441
+ // they always were.
2442
+ ...(forward?.ca && bound.length ? { [EGRESS_CA_ENV]: Buffer.from(forward.ca).toString("base64") } : {}),
2353
2443
  ...clockEnv,
2354
2444
  // Who is running: the in-process path stamps scripts with these
2355
2445
  // host-side, but a container rebuilds its tools from its own
@@ -2366,6 +2456,9 @@ stepIndex) {
2366
2456
  // Stamps the sandbox, so stopping this run can destroy it.
2367
2457
  runId,
2368
2458
  size,
2459
+ // The pod's network, for the executor's own wall (the k8s tier's
2460
+ // network policy): a filtered pod reaches the proxy and DNS only.
2461
+ network: { level: network.level, enforced: forward?.enforce === true },
2369
2462
  // A test run's state/ and storage/ changes come out under the run.
2370
2463
  divert: testRun && runId ? divertFor(runId) : undefined,
2371
2464
  test: testRun,
@@ -2683,6 +2776,7 @@ stepIndex) {
2683
2776
  schema: resolvedSchema ?? undefined,
2684
2777
  maxTurns: step.maxTurns,
2685
2778
  ...limitOpts,
2779
+ ...hookOpts,
2686
2780
  stopRequested,
2687
2781
  // What the scripts saw, the verify sees: a flow can then check that
2688
2782
  // the proof a step left names THIS run, not one that came before —
@@ -4281,11 +4375,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4281
4375
  // Not after a stop: the failure a stop leaves behind is not one a
4282
4376
  // rescuer should take over. The merge in save() surfaces the flag.
4283
4377
  save();
4284
- for (const step of freshGroup) {
4285
- if (run.stopRequested)
4286
- break;
4287
- if (step.status !== "failed" || !step.onFail)
4288
- continue;
4378
+ // One rescue of one failed step. Also used when an evaluator loop
4379
+ // runs out of cycles below, which used to fail the run without ever
4380
+ // reaching the step's on-fail (medium-desk's claim-trimmer was
4381
+ // written for exactly that case and could never run).
4382
+ const rescueStep = async (step) => {
4383
+ if (!step.onFail)
4384
+ return;
4289
4385
  const rescuerDir = path.join(pDir, "agents", step.onFail);
4290
4386
  if (!fs.existsSync(path.join(rescuerDir, "agent.md"))) {
4291
4387
  step.events.push({
@@ -4293,7 +4389,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4293
4389
  type: "error",
4294
4390
  text: `on-fail: "${step.onFail}" is not an agent in this workspace — no rescue`,
4295
4391
  });
4296
- continue;
4392
+ return;
4297
4393
  }
4298
4394
  step.events.push({
4299
4395
  t: new Date().toISOString(),
@@ -4301,10 +4397,21 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4301
4397
  text: `failed — handing to [[${step.onFail}]] (on-fail)`,
4302
4398
  });
4303
4399
  const failure = (step.result ?? step.events.filter((e) => e.type === "error").map((e) => e.text).join("\n")).slice(0, 4000);
4400
+ // A gated step's approval travels with it: the rescuer acts inside
4401
+ // the same answer the failed agent had, and must be told what it
4402
+ // was. Fixers used to dig it out of the run record, and stop when
4403
+ // they could not (15 Oct 2026).
4404
+ const approved = step.approvedAt
4405
+ ? `\nA person approved this step before it ran${step.approvalNote ? " — their answer is below" : ", with no note: a plain yes"}. Act within that approval and nothing wider.`
4406
+ : "";
4407
+ const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
4304
4408
  const rescue = {
4305
4409
  agent: step.onFail,
4306
4410
  instruction: `${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
4307
- `Its failure, for whatever it is worth:\n${failure}\n</rescue>`,
4411
+ `Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
4412
+ ...(step.ask ? { ask: step.ask } : {}),
4413
+ ...(step.approvedAt ? { approvedAt: step.approvedAt } : {}),
4414
+ ...(step.approvalNote ? { approvalNote: step.approvalNote } : {}),
4308
4415
  group: step.group,
4309
4416
  optional: false,
4310
4417
  model: step.model,
@@ -4352,6 +4459,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4352
4459
  });
4353
4460
  }
4354
4461
  save();
4462
+ };
4463
+ for (const step of freshGroup) {
4464
+ if (run.stopRequested)
4465
+ break;
4466
+ if (step.status !== "failed" || !step.onFail)
4467
+ continue;
4468
+ await rescueStep(step);
4355
4469
  }
4356
4470
  // A step carried into a re-run failed in the run it came from, not
4357
4471
  // here: the person chose to start after it. Counting it stopped
@@ -4451,7 +4565,21 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4451
4565
  text: `loop exhausted: after ${looper.loop} cycle${looper.loop === 1 ? "" : "s"} the result still does not say "${looper.until}"`,
4452
4566
  });
4453
4567
  save();
4454
- if (!looper.optional) {
4568
+ if (looper.onFail && !run.stopRequested) {
4569
+ await rescueStep(looper);
4570
+ // The rescuer owes what the loop owed: its marker.
4571
+ // rescueStep mutates the status; widen past TS's "failed" narrowing.
4572
+ if (looper.status === "completed" && !saysUntilMarker(looper.result ?? null, looper.until)) {
4573
+ looper.status = "failed";
4574
+ looper.events.push({
4575
+ t: new Date().toISOString(),
4576
+ type: "error",
4577
+ text: `on-fail: [[${looper.onFail}]]'s result does not say "${looper.until}" either`,
4578
+ });
4579
+ save();
4580
+ }
4581
+ }
4582
+ if (looper.status === "failed" && !looper.optional) {
4455
4583
  run.status = "failed";
4456
4584
  break;
4457
4585
  }
@@ -26,6 +26,11 @@ interface StoredSecret {
26
26
  * record written before per-account keys existed is encrypted with — so an
27
27
  * old vault stays readable without a migration having to run first. */
28
28
  k?: "tenant";
29
+ /** The sites this secret is bound to (network.ts SITE shape). A bound
30
+ * secret never enters a sandbox: the egress proxy fills its `${NAME}` for
31
+ * these sites alone. Metadata, not a value, so it is stored in the clear
32
+ * beside the ciphertext and survives the value being set again. */
33
+ sites?: string[];
29
34
  }
30
35
  type SecretsFile = Record<string, StoredSecret>;
31
36
  /**
@@ -36,6 +41,18 @@ type SecretsFile = Record<string, StoredSecret>;
36
41
  export declare function mutateVaultFile(file: string, change: (all: SecretsFile) => SecretsFile | null): SecretsFile;
37
42
  export declare const SECRET_NAME: RegExp;
38
43
  export declare function setSecret(tenant: string, name: string, value: string, workspace?: string, kind?: SecretKind): void;
44
+ /**
45
+ * Bind a secret to sites, or unbind it (an empty list). The secret must
46
+ * exist in that scope. Sites are `api.example.com` or `*.example.com` — no
47
+ * scheme, path or port. Returns the list as stored.
48
+ */
49
+ export declare function setSecretSites(tenant: string, name: string, sites: string[], workspace?: string): string[];
50
+ /**
51
+ * The sites each named secret is bound to, nearest scope first — the same
52
+ * record getSecret would read the value from. A name with no binding (or
53
+ * no secret) is absent from the answer.
54
+ */
55
+ export declare function secretSites(tenant: string, names: string[], workspace?: string): Record<string, string[]>;
39
56
  /** Encrypt one value for an account — the same envelope secrets use, exported
40
57
  * so sibling stores (OAuth clients) don't invent a second crypto path. */
41
58
  export declare function encryptValue(tenant: string, value: string): {
@@ -65,6 +82,9 @@ export interface SecretEntry {
65
82
  /** How this credential works: auto-refreshing oauth2, a signed service
66
83
  * account, a file/ssh/api materialised into runs — absent for static values. */
67
84
  kind?: SecretKind;
85
+ /** The sites it is bound to — the proxy fills it for these and no other,
86
+ * and it never enters a sandbox. Absent when unbound. */
87
+ sites?: string[];
68
88
  }
69
89
  export declare function listSecrets(tenant: string, workspace?: string): SecretEntry[];
70
90
  export declare function resolveSecrets(tenant: string, names: string[], workspace?: string): {
@@ -39,6 +39,7 @@ const tenantKey = (tenant) => platform.tenantKey(tenant);
39
39
  import crypto from "node:crypto";
40
40
  import { assertSafeName } from "./store.js";
41
41
  import { trimSlashes } from "./paths.js";
42
+ import { SITE } from "./network.js";
42
43
  const keyFile = () => path.join(dataRoot(), ".secret-key");
43
44
  /**
44
45
  * The install's root secret, as text: FOLDRUN_SECRET_KEY when set, else the
@@ -163,14 +164,70 @@ export function setSecret(tenant, name, value, workspace, kind) {
163
164
  throw new Error("secret too long");
164
165
  const sealed = encryptValue(tenant, value);
165
166
  mutateVaultFile(secretsFile(tenant, workspace), (all) => {
167
+ // A new value keeps the sites the old one was bound to: binding is a
168
+ // property of the credential's use, not of its current value, and a
169
+ // rotation that silently unbound it would put the new value in pods.
170
+ const sites = all[name]?.sites;
166
171
  all[name] = {
167
172
  ...sealed,
168
173
  updatedAt: new Date().toISOString(),
169
174
  ...(kind ? { kind } : {}),
175
+ ...(sites?.length ? { sites } : {}),
170
176
  };
171
177
  return all;
172
178
  });
173
179
  }
180
+ /**
181
+ * Bind a secret to sites, or unbind it (an empty list). The secret must
182
+ * exist in that scope. Sites are `api.example.com` or `*.example.com` — no
183
+ * scheme, path or port. Returns the list as stored.
184
+ */
185
+ export function setSecretSites(tenant, name, sites, workspace) {
186
+ const clean = [...new Set(sites.map((x) => x.trim().toLowerCase()).filter(Boolean))];
187
+ for (const site of clean) {
188
+ if (!SITE.test(site))
189
+ throw new Error(`"${site}" is not a site — a domain like api.example.com or *.example.com, no scheme, path or port`);
190
+ }
191
+ let found = false;
192
+ mutateVaultFile(secretsFile(tenant, workspace), (all) => {
193
+ const rec = all[name];
194
+ if (!rec)
195
+ return null;
196
+ found = true;
197
+ // A file, an ssh key or an @api header set is not a value that travels
198
+ // in a header — it is unpacked inside the sandbox, so there is nothing
199
+ // for the proxy to fill.
200
+ if (clean.length && (rec.kind === "file" || rec.kind === "ssh" || rec.kind === "api")) {
201
+ throw new Error(`${name} is a ${rec.kind} secret, unpacked inside the sandbox — only a value sent in a request can be bound to sites`);
202
+ }
203
+ if (clean.length)
204
+ rec.sites = clean;
205
+ else
206
+ delete rec.sites;
207
+ return all;
208
+ });
209
+ if (!found)
210
+ throw new Error(`no secret ${name}${workspace ? ` in workspace ${workspace}` : " in the account"}`);
211
+ return clean;
212
+ }
213
+ /**
214
+ * The sites each named secret is bound to, nearest scope first — the same
215
+ * record getSecret would read the value from. A name with no binding (or
216
+ * no secret) is absent from the answer.
217
+ */
218
+ export function secretSites(tenant, names, workspace) {
219
+ const out = {};
220
+ const own = workspace ? read(tenant, workspace) : {};
221
+ const source = workspace ? platform.previewSourceOf(tenant, workspace) : null;
222
+ const inherited = source ? read(tenant, source) : {};
223
+ const account = read(tenant);
224
+ for (const name of names) {
225
+ const rec = own[name] ?? inherited[name] ?? account[name];
226
+ if (rec?.sites?.length)
227
+ out[name] = [...rec.sites];
228
+ }
229
+ return out;
230
+ }
174
231
  /**
175
232
  * Which key writes this account's records.
176
233
  *
@@ -271,6 +328,7 @@ export function listSecrets(tenant, workspace) {
271
328
  updatedAt: rec.updatedAt,
272
329
  scope: "account",
273
330
  ...(rec.kind ? { kind: rec.kind } : {}),
331
+ ...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
274
332
  }));
275
333
  if (!workspace)
276
334
  return account.sort((a, b) => a.name.localeCompare(b.name));
@@ -280,6 +338,7 @@ export function listSecrets(tenant, workspace) {
280
338
  scope: "workspace",
281
339
  shadowed: account.some((a) => a.name === name),
282
340
  ...(rec.kind ? { kind: rec.kind } : {}),
341
+ ...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
283
342
  }));
284
343
  const ownNames = new Set(own.map((s) => s.name));
285
344
  return [...own, ...account.filter((a) => !ownNames.has(a.name))].sort((a, b) => a.name.localeCompare(b.name));
@@ -748,7 +807,12 @@ export function rewrapTenantSecrets(tenant) {
748
807
  return null;
749
808
  const next = { ...all };
750
809
  for (const [name, value] of plain) {
751
- next[name] = { ...encryptValue(tenant, value), updatedAt: all[name].updatedAt, ...(all[name].kind ? { kind: all[name].kind } : {}) };
810
+ next[name] = {
811
+ ...encryptValue(tenant, value),
812
+ updatedAt: all[name].updatedAt,
813
+ ...(all[name].kind ? { kind: all[name].kind } : {}),
814
+ ...(all[name].sites?.length ? { sites: all[name].sites } : {}),
815
+ };
752
816
  }
753
817
  moved += plain.size;
754
818
  return next;
@@ -263,6 +263,7 @@ only when they asked for exactly that deletion.
263
263
  | \`memory/\` | what past runs learned — agents write here themselves |
264
264
  | \`evals/\` | tests for agents, same idea as unit tests |
265
265
  | \`skills/\` | a procedure an agent can follow, named in \`skills:\` |
266
+ | \`hooks/\` | your code at every tool call, step start or step end (\`hook.md\` + \`run.mjs\`), turned on with \`hooks:\` — refuse, rewrite, add context; same on every model |
266
267
  | \`tools/\` | a folder tool: its definition and its code together |
267
268
  | \`scripts/\` | code the agents call, referenced by \`scripts:\` |
268
269
  | \`state/\` | what runs accumulate. Yours to read, never deployed over |
@@ -294,7 +295,11 @@ memory an agent wrote that your push does not mention. Those belong to the platf
294
295
  reverted what an agent learned would make every run a little dumber.
295
296
 
296
297
  Secrets never go in these files. \`foldrun secrets\` puts them in the vault, and
297
- agents reference them by name.
298
+ agents reference them by name. Bind one to the sites it is for
299
+ (\`foldrun secrets sites NAME api.example.com\`) and it never enters a sandbox:
300
+ the egress proxy fills \`\${NAME}\` in requests to those sites alone. Which sites
301
+ a sandbox may reach at all is \`network:\` — open for an agent that reads the web,
302
+ the trusted list plus what it declares for every other (\`foldrun agent network\`).
298
303
 
299
304
  ## Talking to a deployed workspace
300
305
 
@@ -340,6 +345,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
340
345
  | \`/export\` · \`/import\` | GET · POST | the workspace, a flow (\`?kind=flow&name=\`) or an agent as a .zip; take one in (\`?dryRun=1\` first, \`?overwrite=1\` to replace) |
341
346
  | \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
342
347
  | \`/vocabulary\` | GET | what this workspace's documents may say |
348
+ | \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
343
349
 
344
350
  Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
345
351
  \`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
@@ -439,6 +445,7 @@ size: large # small | large | heavy — the sandbox it rents
439
445
  tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
440
446
  disallowedTools: [Bash] # subtract from what it would otherwise have
441
447
  skills: [house-style] # from skills/
448
+ hooks: [no-force-push] # from hooks/ or the library: code at every tool call
442
449
  scripts: [summarise.py] # from scripts/, each becomes a callable tool
443
450
  apis: [{ name: crm }] # an HTTP API, as a tool
444
451
  mcpServers: {} # an MCP server, as a tool
@@ -446,6 +453,7 @@ agents: [writer] # colleagues this one may consult (an answer, no too
446
453
  subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
447
454
  secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
448
455
  limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
456
+ network: [api.example.com] # sites its sandbox may reach, added to the trusted list; open | trusted | none
449
457
  timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
450
458
  provider: {} # BYOK for this agent; account-wide: \`foldrun account model set\`
451
459
  permissionMode: plan # plan first, act once approved
@@ -1,3 +1,4 @@
1
+ import { type HookDef, type HookRecord } from "./hooks.ts";
1
2
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
2
3
  import type { Effort } from "./store.ts";
3
4
  import type { TestEffect } from "./test-mode.ts";
@@ -40,6 +41,14 @@ export interface ExecOutcome {
40
41
  browserPod?: BrowserPodOutcome;
41
42
  }
42
43
  export interface ExecOptions {
44
+ /** `hooks:` resolved host-side (hooks.ts): run at this step's own dispatch,
45
+ * inside its sandbox. `hookBase` is what every hook event carries. */
46
+ hooks?: HookDef[];
47
+ /** Or the names (`hooks:` from the account, workspace and agent), resolved
48
+ * here against this step's own roots — in a sandbox those are not the
49
+ * host's paths. A name that resolves to nothing refuses the step. */
50
+ hookNames?: string[];
51
+ hookBase?: Record<string, unknown>;
43
52
  agentDir: string;
44
53
  workspaceRoot: string;
45
54
  libraryRoot: string;
@@ -151,6 +160,7 @@ export type EventExtra = {
151
160
  operator?: OperatorEvent;
152
161
  subagent?: string;
153
162
  check?: boolean;
163
+ hook?: HookRecord;
154
164
  };
155
165
  /** Conservative per-token rates for a model nothing else can price — an
156
166
  * unknown id on a gateway with no catalogue. Opus-class, so a ceiling