@foldrun/core 0.5.0 → 0.6.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 (45) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +1 -0
  4. package/dist/src/browser-pod.d.ts +20 -1
  5. package/dist/src/browser-pod.js +58 -9
  6. package/dist/src/container.d.ts +9 -0
  7. package/dist/src/container.js +115 -5
  8. package/dist/src/context-tools.d.ts +1 -1
  9. package/dist/src/evals.js +3 -0
  10. package/dist/src/host-env.js +5 -0
  11. package/dist/src/mail.js +3 -1
  12. package/dist/src/model-credential.d.ts +14 -0
  13. package/dist/src/model-credential.js +58 -0
  14. package/dist/src/run-container.d.ts +1 -1
  15. package/dist/src/run-container.js +27 -7
  16. package/dist/src/runner.d.ts +35 -0
  17. package/dist/src/runner.js +124 -37
  18. package/dist/src/script-tools.d.ts +14 -0
  19. package/dist/src/script-tools.js +47 -7
  20. package/dist/src/step-exec.d.ts +11 -0
  21. package/dist/src/step-exec.js +18 -2
  22. package/dist/src/store.d.ts +4 -0
  23. package/dist/src/store.js +2 -2
  24. package/dist/src/subagents.js +1 -1
  25. package/dist/src/test-mode.d.ts +17 -2
  26. package/dist/src/test-mode.js +53 -13
  27. package/index.ts +1 -0
  28. package/package.json +1 -1
  29. package/src/browser-pod.ts +46 -9
  30. package/src/container.ts +112 -5
  31. package/src/context-tools.ts +1 -1
  32. package/src/evals.ts +3 -0
  33. package/src/host-env.ts +5 -0
  34. package/src/mail.ts +3 -1
  35. package/src/model-credential.ts +71 -0
  36. package/src/run-container.ts +27 -7
  37. package/src/runner.ts +122 -37
  38. package/src/script-tools.ts +43 -5
  39. package/src/step-exec.ts +19 -2
  40. package/src/store.ts +6 -2
  41. package/src/subagents.ts +1 -1
  42. package/src/test-mode.ts +51 -11
  43. package/templates/blog-desk/agents/writer/agent.md +1 -1
  44. package/templates/hello/agents/curator/agent.md +3 -3
  45. package/templates/hello/agents/notetaker/agent.md +2 -2
package/src/runner.ts CHANGED
@@ -15,12 +15,13 @@ import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.ts
15
15
  import { eventUrl } from "./webhook.ts";
16
16
  import { runStepInContainer, sizeLimits, killRunSandboxes, type StepTiming } from "./run-container.ts";
17
17
  import { hostSafeEnv } from "./host-env.ts";
18
+ import { resolveModelCredential, credentialLine } from "./model-credential.ts";
18
19
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
19
20
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
20
21
  import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote, type SupplyState } from "./refusal.ts";
21
22
  import { platform } from "./platform.ts";
22
23
  import { healthKey } from "./secret-health.ts";
23
- import { TEST_WRITES_DIR, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.ts";
24
+ import { TEST_WRITES_DIR, overlayTestWrites, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.ts";
24
25
 
25
26
  /** Does this process run steps in a sandbox — the container core ships, or
26
27
  * one the platform registered (a pod)? */
@@ -85,7 +86,7 @@ import { attachOperations, prefetchOpenApi } from "./openapi.ts";
85
86
  import { buildSearchTools, buildHistoryTools, buildDeskTools, digestRuns, type SearchRoot, type RunDigest } from "./context-tools.ts";
86
87
  import { startTranslator, translatorSpecFor, type TranslatorSpec } from "./translator.ts";
87
88
  import { providerPreset } from "./providers.ts";
88
- import { buildScriptTools, parseScripts, type ExecutionContext } from "./script-tools.ts";
89
+ import { buildScriptTools, parseScripts, type ExecutionContext, type ScriptSpec } from "./script-tools.ts";
89
90
  import { libraryDir, libraryTools, libraryMemoryIndex } from "./library.ts";
90
91
  import { fingerprint, mergeRuntimes, parseRuntime, prepareRuntime, type PreparedRuntime, type RuntimeSpec } from "./runtime.ts";
91
92
  import { materializeFiles, harvestFiles } from "./storage.ts";
@@ -760,8 +761,7 @@ function agentContext(
760
761
  `\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
761
762
  `\`outputs/\` is yours, and \`workspace/\` is the workspace root, in every tool ` +
762
763
  `and in the shell: the shared file store is \`workspace/storage/\`, what runs ` +
763
- `carry forward is \`workspace/state/\`. (\`../../\` reaches the same root and ` +
764
- `still works, so a path like \`../../knowledge/\` below is \`workspace/knowledge/\`.) ` +
764
+ `carry forward is \`workspace/state/\`, its knowledge \`workspace/knowledge/\`. ` +
765
765
  `Absolute paths are outside the workspace and will be refused.\n\n` +
766
766
  // A flow's whole point is that a later step works on what an earlier one
767
767
  // produced, and each agent writes to its own outputs/. A run showed what
@@ -812,24 +812,7 @@ function agentContext(
812
812
  // skills for a few tokens each.
813
813
  // Own skills, then the workspace's, then the workspace library — nearest
814
814
  // definition of a name wins, so a team default can be overridden locally.
815
- const skills: DiscoveredSkill[] = [];
816
- const seenSkills = new Set<string>();
817
- const addSkills = (dir: string, prefix: string, subdir = "skills") => {
818
- for (const skill of discoverSkills(dir, subdir)) {
819
- if (seenSkills.has(skill.name)) continue;
820
- seenSkills.add(skill.name);
821
- skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
822
- }
823
- };
824
- const workspaceRoot = path.join(agentDir, "..", "..");
825
- addSkills(agentDir, "");
826
- addSkills(workspaceRoot, "../../"); // the workspace's skills/
827
- // The cross-client convention: skills any skills-compatible tool installs
828
- // under .agents/skills/ are visible here too, and vice versa. Scanned after
829
- // the native location so a foldrun-native skill wins a name clash, but both
830
- // are discovered. project scope only — foldrun workspaces are self-contained.
831
- addSkills(workspaceRoot, "../../", ".agents/skills");
832
- addSkills(path.join(libraryDir(tenant)), "../../../../library/"); // the account library
815
+ const skills = skillsInScope(agentDir, tenant);
833
816
 
834
817
  // A skill with `when:` only loads when the run carries a matching tag —
835
818
  // so an agent with 119 skills costs 119 lines of context only when every
@@ -919,7 +902,7 @@ function agentContext(
919
902
  };
920
903
 
921
904
  const own = listDir(path.join(agentDir, "scripts"), "scripts/");
922
- const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "../../scripts/");
905
+ const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "workspace/scripts/");
923
906
  // The library's path depends on where this run executes: isolated runs see
924
907
  // it at /library, host runs at its real location. Printing the host path
925
908
  // into a container's prompt handed the model files it could never open.
@@ -1237,17 +1220,18 @@ function agentContext(
1237
1220
  let runtime: PreparedRuntime = { interpreters: {}, env: {}, log: [], error: null };
1238
1221
 
1239
1222
  if (executor === "docker" && !grantsOnly) {
1240
- const image = ensureImage(runtimeSpec);
1223
+ // The script image also carries what the scripts' own languages need: a
1224
+ // .mjs tool with no runtime: block still needs node in its container.
1225
+ // Here only — the run container and the host have node already, and a
1226
+ // spec there sets off the runtime build for nothing.
1227
+ const image = ensureImage(mergeRuntimes(runtimeSpec, scriptLanguages(scriptSpecs)));
1241
1228
  runtimeLog.push(...image.log);
1242
1229
  runtimeError = image.error;
1243
1230
  if (!image.error) {
1244
1231
  exec = {
1245
1232
  executor: "docker",
1246
1233
  image: image.tag,
1247
- mounts: {
1248
- [path.resolve(agentDir, "..", "..", "scripts")]: "/workspace-scripts",
1249
- [libraryDir(tenant, "scripts")]: "/library-scripts",
1250
- },
1234
+ mounts: scriptMounts(agentDir, tenant),
1251
1235
  // Only agents that granted something reaching out — an `apis:` entry
1252
1236
  // or one of their own tools — get egress.
1253
1237
  network: parseApis(front.apis).length > 0 || ownToolNames(front).length > 0,
@@ -1506,8 +1490,8 @@ function agentContext(
1506
1490
  ? [
1507
1491
  { label: "knowledge/", dir: path.join(agentDir, "knowledge") },
1508
1492
  { label: "memory/", dir: path.join(agentDir, "memory") },
1509
- { label: "../../knowledge/", dir: path.join(wsRoot, "knowledge") },
1510
- { label: "../../memory/", dir: path.join(wsRoot, "memory") },
1493
+ { label: "workspace/knowledge/", dir: path.join(wsRoot, "knowledge") },
1494
+ { label: "workspace/memory/", dir: path.join(wsRoot, "memory") },
1511
1495
  { label: "workspace/state/", dir: path.join(wsRoot, "state") },
1512
1496
  { label: `workspace/${STORAGE_DIR}/`, dir: path.join(wsRoot, STORAGE_DIR) },
1513
1497
  { label: "outputs/", dir: path.join(agentDir, "outputs") },
@@ -1969,6 +1953,16 @@ async function runStep(
1969
1953
  // And one more only when something written could not be read as a zone.
1970
1954
  for (const problem of clock.problems) push("error", problem);
1971
1955
  if (providerLabel) push("info", `provider: ${providerLabel}`);
1956
+ // Which credential the model is called with, on the record: an agent
1957
+ // that names a provider uses its own; otherwise the platform's (in a
1958
+ // run container) or this process's — never a login that just happens
1959
+ // to be on the machine (model-credential.ts).
1960
+ const credential = providerLabel ? null : resolveModelCredential(isolatedRun() ? platformModelEnv() : process.env);
1961
+ step.credential = credential ? credential.label : `provider: ${providerLabel}`;
1962
+ if (credential) {
1963
+ const line = credentialLine(credential);
1964
+ push(line.type, line.text);
1965
+ }
1972
1966
  for (const w of providerWarnings) push("error", w);
1973
1967
  // A level that wrote a language nobody can read was skipped, not obeyed.
1974
1968
  for (const l of language.lines) push("info", l);
@@ -2617,9 +2611,13 @@ async function runStep(
2617
2611
  };
2618
2612
  const again = { ...lastArgs, input: { ...lastArgs.input, image: "full" as const }, resume: null };
2619
2613
  await lease?.commit();
2620
- outcome = await runTracked(again);
2621
- step.image = outcome.image ?? { variant: "full", why: "re-run after the browser pod was lost" };
2614
+ // Before the call, not after: a re-run that throws (the full
2615
+ // sandbox never started) is still the full go on the record — its
2616
+ // failed row read "slim" when this was set only on success.
2617
+ step.image = { variant: "full", why: "re-run after the browser pod was lost" };
2622
2618
  step.browserPod = { ...first.browserPod!, fallback: RERAN_ON_FULL };
2619
+ outcome = await runTracked(again);
2620
+ step.image = outcome.image ?? step.image;
2623
2621
  } else {
2624
2622
  push("error", decision.message);
2625
2623
  step.browserPod = { ...outcome.browserPod, failure: decision.message };
@@ -2692,9 +2690,13 @@ async function runStep(
2692
2690
  push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
2693
2691
  }
2694
2692
  const stepSecrets = held.env;
2693
+ // The model credential for this machine's Claude Code — the agent's
2694
+ // own provider when it names one. The host allowlist drops it, so
2695
+ // without this every in-process step ran on the machine's login.
2696
+ const localModelEnv: Record<string, string> = Object.keys(providerEnv).length ? providerEnv : resolveModelCredential().env;
2695
2697
  const consultTools = buildConsultTools(
2696
2698
  consults,
2697
- { ...hostSafeEnv(), ...stepSecrets, ...providerEnv },
2699
+ { ...hostSafeEnv(), ...stepSecrets, ...localModelEnv },
2698
2700
  (type, text) => push(type, text),
2699
2701
  );
2700
2702
  // On this machine nobody can write in and there is no proxy: the
@@ -2763,6 +2765,10 @@ async function runStep(
2763
2765
  // under the run and the originals come back.
2764
2766
  const attemptThrough = async (modelEnv: Record<string, string | undefined>, spec: TranslatorSpec | null) => {
2765
2767
  const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
2768
+ // What earlier steps of this test run wrote, laid over the real files
2769
+ // for this attempt; restoreDivertedDirs puts the originals back.
2770
+ const handed =
2771
+ before && runId && overlayTestWrites(workspaceRoot, divertFor(runId).to).length ? snapshotDivertedDirs(workspaceRoot) : before;
2766
2772
  const t = spec ? await startTranslator(spec) : null;
2767
2773
  try {
2768
2774
  return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
@@ -2773,12 +2779,12 @@ async function runStep(
2773
2779
  }
2774
2780
  if (before && runId) {
2775
2781
  const d = divertFor(runId);
2776
- restoreDivertedDirs(workspaceRoot, before, d.to, d.note);
2782
+ restoreDivertedDirs(workspaceRoot, before, d.to, d.note, handed ?? before);
2777
2783
  }
2778
2784
  }
2779
2785
  };
2780
2786
  let credentialUsed = platformModelCredential();
2781
- let outcome = await attemptThrough(providerEnv, translator);
2787
+ let outcome = await attemptThrough(localModelEnv, translator);
2782
2788
  // Same rule as the isolated path: a credential that rotated under the
2783
2789
  // step is waited out and retried before anything else is considered.
2784
2790
  if (outcome.status === "failed" && isAuthRefusal(lastRefusal)) {
@@ -2788,7 +2794,7 @@ async function runStep(
2788
2794
  push("info", "the credential rotated under this step; retrying on the new one");
2789
2795
  credentialUsed = rotated;
2790
2796
  lastRefusal = "";
2791
- outcome = await attemptThrough(providerEnv, translator);
2797
+ outcome = await attemptThrough(localModelEnv, translator);
2792
2798
  } else {
2793
2799
  push("info", "the credential did not change — this is the key itself, not a rotation");
2794
2800
  }
@@ -2800,7 +2806,7 @@ async function runStep(
2800
2806
  push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
2801
2807
  await sleep(wait);
2802
2808
  lastRefusal = "";
2803
- outcome = await attemptThrough(providerEnv, translator);
2809
+ outcome = await attemptThrough(localModelEnv, translator);
2804
2810
  }
2805
2811
  // Same rule as the isolated path: one retry, on a refusal, on the
2806
2812
  // declared or platform second supply.
@@ -4824,3 +4830,82 @@ export function runResult(run: RunRecord): string | null {
4824
4830
  }
4825
4831
  return null;
4826
4832
  }
4833
+
4834
+ /**
4835
+ * What a Docker-run script sees of the workspace and the account: their
4836
+ * scripts/ and their tools/, read-only. A folder tool's code lives in its
4837
+ * tools/<name>/ folder, not in scripts/ — without those two mounts every
4838
+ * folder tool (the hello template's wordcount among them) was handed a host
4839
+ * path the container did not have, and failed on every call. A gallery tool
4840
+ * nobody installed runs from the gallery's own shelf, so that is mounted too
4841
+ * when there is one. A folder that does not exist is skipped by the copy.
4842
+ */
4843
+ export function scriptMounts(agentDir: string, tenant: string): Record<string, string> {
4844
+ const workspaceRoot = path.resolve(agentDir, "..", "..");
4845
+ const mounts: Record<string, string> = {
4846
+ [path.join(workspaceRoot, "scripts")]: "/workspace-scripts",
4847
+ [path.join(workspaceRoot, "tools")]: "/workspace-tools",
4848
+ [libraryDir(tenant, "scripts")]: "/library-scripts",
4849
+ [libraryDir(tenant, "tools")]: "/library-tools",
4850
+ };
4851
+ const gallery = platform.galleryDir();
4852
+ if (gallery) mounts[path.join(gallery, "tools")] = "/gallery-tools";
4853
+ return mounts;
4854
+ }
4855
+
4856
+ /**
4857
+ * The skills an agent can see, nearest first, each with the path the agent
4858
+ * is told to read it at. Own skills, then the workspace's, then the
4859
+ * workspace's `.agents/skills/` (the cross-client convention, scanned after
4860
+ * the native location so a foldrun-native skill wins a name clash), then the
4861
+ * account library. The nearest definition of a name wins.
4862
+ *
4863
+ * The workspace's are written `workspace/skills/…`, the one spelling for the
4864
+ * workspace root, which the file tools expand themselves. `../../skills/…`
4865
+ * left the model to resolve it against a path it had to guess, and on the
4866
+ * hello template it climbed one level too far and was refused.
4867
+ */
4868
+ export function skillsInScope(agentDir: string, tenant: string): DiscoveredSkill[] {
4869
+ const skills: DiscoveredSkill[] = [];
4870
+ const seen = new Set<string>();
4871
+ const add = (dir: string, prefix: string, subdir = "skills") => {
4872
+ for (const skill of discoverSkills(dir, subdir)) {
4873
+ if (seen.has(skill.name)) continue;
4874
+ seen.add(skill.name);
4875
+ skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
4876
+ }
4877
+ };
4878
+ const workspaceRoot = path.join(agentDir, "..", "..");
4879
+ add(agentDir, "");
4880
+ add(workspaceRoot, "workspace/");
4881
+ add(workspaceRoot, "workspace/", ".agents/skills");
4882
+ // The account library keeps its relative path: `account/` is expanded by
4883
+ // the file tools only, and a skill's bundled scripts are run from the shell.
4884
+ add(path.join(libraryDir(tenant)), "../../../../library/");
4885
+ return skills;
4886
+ }
4887
+
4888
+ /**
4889
+ * The interpreters a set of scripts needs, read from what they are: the
4890
+ * extension of `run:` (or of inline code), or an explicit `interpreter:`.
4891
+ * Docker's script image is built from the runtime spec, and the base one is
4892
+ * Python only, so a JavaScript tool that declared no `runtime:` (the demo's
4893
+ * inline crm-upload, any folder tool's run.mjs) failed with no node to run
4894
+ * it. Python is named too whenever a .py script is present, because asking
4895
+ * for node alone builds on node:22-slim, which has no python3. Null when
4896
+ * nothing beyond the default is needed.
4897
+ */
4898
+ export function scriptLanguages(scripts: ScriptSpec[]): RuntimeSpec | null {
4899
+ const kind = (sc: ScriptSpec) => {
4900
+ const via = (sc.interpreter ?? "").split(/[\\/]/).pop() ?? "";
4901
+ if (/^node/.test(via)) return "node";
4902
+ if (/^python/.test(via)) return "python";
4903
+ const ext = (sc.run ? path.extname(sc.run) : sc.codeExt ?? "").toLowerCase();
4904
+ if ([".js", ".mjs", ".cjs", ".ts"].includes(ext)) return "node";
4905
+ if (ext === ".py") return "python";
4906
+ return null;
4907
+ };
4908
+ const kinds = new Set(scripts.map(kind));
4909
+ if (!kinds.has("node")) return null;
4910
+ return { node: true, ...(kinds.has("python") ? { python: true } : {}), packages: [], npm: [] };
4911
+ }
@@ -234,12 +234,16 @@ export function runScript(
234
234
  // Container path: same command, executed inside an isolated container
235
235
  // with only the agent's directory (plus declared shared dirs) mounted.
236
236
  if (exec && exec.executor === "docker") {
237
+ // `workspace/…` is the workspace root on the host (the step's link)
238
+ // and in a run container; here there is no link, so each such
239
+ // argument is staged: copied in, pointed at, and copied back after.
240
+ const workspaceRoot = path.resolve(cwd, "..", "..");
241
+ const staged: { host: string; container: string }[] = [];
237
242
  const inContainer = (p: string) => {
238
- if (p.startsWith(path.resolve(cwd))) return p.replace(path.resolve(cwd), "/workspace");
239
- for (const [host, mount] of Object.entries(exec.mounts)) {
240
- if (p.startsWith(host)) return p.replace(host, mount);
241
- }
242
- return p;
243
+ const w = stageWorkspaceArg(p, workspaceRoot);
244
+ if (!w) return containerPath(p, cwd, exec.mounts);
245
+ if (!staged.some((s) => s.host === w.host)) staged.push(w);
246
+ return w.container;
243
247
  };
244
248
  const argv = [
245
249
  // The interpreter inside the image, not the host's venv path.
@@ -251,6 +255,7 @@ export function runScript(
251
255
  runInContainer({
252
256
  agentDir: path.resolve(cwd),
253
257
  readOnly: exec.mounts,
258
+ staged,
254
259
  image: exec.image,
255
260
  argv,
256
261
  env,
@@ -292,6 +297,39 @@ export function runScript(
292
297
  });
293
298
  }
294
299
 
300
+ /**
301
+ * A host path as the script's container sees it: under the agent's own
302
+ * directory it is /workspace, under a mounted folder it is that mount, and
303
+ * anything else (an argument that only looks like a path) is left alone.
304
+ * Containment, not a text prefix: `<ws>/scripts-old/x.py` is not under
305
+ * `<ws>/scripts`. The longest mount wins, so a nested one is not shadowed.
306
+ */
307
+ export function containerPath(p: string, agentDir: string, mounts: Record<string, string>): string {
308
+ if (!path.isAbsolute(p)) return p;
309
+ const under = (root: string) => {
310
+ const rel = path.relative(root, p);
311
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) ? rel : null;
312
+ };
313
+ const own = under(path.resolve(agentDir));
314
+ if (own !== null) return path.posix.join("/workspace", own.split(path.sep).join("/"));
315
+ const hit = Object.entries(mounts)
316
+ .map(([host, mount]) => ({ mount, rel: under(path.resolve(host)), len: host.length }))
317
+ .filter((m) => m.rel !== null)
318
+ .sort((a, b) => b.len - a.len)[0];
319
+ return hit ? path.posix.join(hit.mount, hit.rel!.split(path.sep).join("/")) : p;
320
+ }
321
+
322
+ /** Where a `workspace/…` script argument is staged in a script container,
323
+ * or null when it is not one (or climbs out of the workspace). */
324
+ export function stageWorkspaceArg(arg: string, workspaceRoot: string): { host: string; container: string } | null {
325
+ if (arg !== "workspace" && !arg.startsWith("workspace/")) return null;
326
+ const rel = arg.slice("workspace".length).replace(/^\/+/, "");
327
+ const host = path.resolve(workspaceRoot, rel);
328
+ const within = path.relative(workspaceRoot, host);
329
+ if (within.startsWith("..") || path.isAbsolute(within)) return null;
330
+ return { host, container: path.posix.join("/workspace-root", within.split(path.sep).join("/")) };
331
+ }
332
+
295
333
  export interface ExecutionContext {
296
334
  /** "docker" nests each script in its own container; "sandbox" spawns it
297
335
  * directly inside the isolation the step already has. */
package/src/step-exec.ts CHANGED
@@ -354,8 +354,10 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
354
354
  // With sub-agents, the SDK would also offer its built-in ones
355
355
  // (general-purpose has every tool the session has). Only the declared
356
356
  // ones may run; the guard in the hook below refuses the rest as well.
357
- env: subagents ? { ...(opts.env ?? process.env), CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS: "1" } : opts.env,
357
+ env: sdkEnv(opts.env, subagents ? { CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS: "1" } : {}),
358
358
  mcpServers: opts.mcpServers,
359
+ // Only the servers foldrun passes. See sdkEnv for the claude.ai half.
360
+ strictMcpConfig: true,
359
361
  // canUseTool is only asked when the SDK wants permission, and it never
360
362
  // asks for a read inside the cwd — so `Read workspace/storage/x` went
361
363
  // to `<agentDir>/workspace/storage/x` unchecked and unrewritten. On
@@ -973,7 +975,8 @@ async function judgeReply(
973
975
  systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
974
976
  tools: [],
975
977
  settingSources: [],
976
- env,
978
+ env: sdkEnv(env),
979
+ strictMcpConfig: true,
977
980
  maxTurns: 1,
978
981
  abortController,
979
982
  },
@@ -1043,3 +1046,17 @@ function runVerify(
1043
1046
  child.on("close", (code) => resolve({ code, out }));
1044
1047
  });
1045
1048
  }
1049
+
1050
+ /**
1051
+ * The environment an Agent SDK session starts with: the step's, minus the
1052
+ * connectors of whoever is signed in to claude.ai on this machine. Claude
1053
+ * Code loads an account's claude.ai MCP servers (Docs, Drive, …) unless told
1054
+ * not to, and `settingSources: []` does not reach them — they come with the
1055
+ * login, not from a settings file. A local `foldrun run` handed every agent
1056
+ * the person's own connectors: the hello template's agents reported that
1057
+ * "the only tools I have are the Claude Docs ones". `strictMcpConfig` keeps
1058
+ * the rest of the on-disk MCP configuration out; this keeps the account's.
1059
+ */
1060
+ export function sdkEnv(base: Record<string, string | undefined> | undefined, extra: Record<string, string> = {}): Record<string, string | undefined> {
1061
+ return { ...(base ?? process.env), ...extra, ENABLE_CLAUDEAI_MCP_SERVERS: "false" };
1062
+ }
package/src/store.ts CHANGED
@@ -531,7 +531,7 @@ export function buildMemoryIndex(dir: string, prefix = ""): string | null {
531
531
  // Workspace-scoped shared assets: skills/, memory/, tools/ sit beside agents/
532
532
  // and are available to every agent in that workspace.
533
533
  export function workspaceMemoryIndex(tenant: string, workspace: string): string | null {
534
- return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "../../memory/");
534
+ return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "workspace/memory/");
535
535
  }
536
536
 
537
537
  // Knowledge is what you *gave* an agent — stable reference material an agent
@@ -543,7 +543,7 @@ export function knowledgeIndex(dir: string, prefix = ""): string | null {
543
543
  }
544
544
 
545
545
  export function workspaceKnowledgeIndex(tenant: string, workspace: string): string | null {
546
- return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "../../knowledge/");
546
+ return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "workspace/knowledge/");
547
547
  }
548
548
 
549
549
  // One noun for capability. A tools/<name>.md file declares either an HTTP
@@ -3085,6 +3085,10 @@ export interface StepRecord {
3085
3085
  * through the account's browser pod. The last attempt's; absent where
3086
3086
  * the executor has one image (docker, in-process). */
3087
3087
  image?: { variant: "full" | "slim"; why?: string; pod?: boolean } | null;
3088
+ /** Which model credential the step ran on, by its variable — "API key
3089
+ * (ANTHROPIC_API_KEY)", "Claude login token (CLAUDE_CODE_OAUTH_TOKEN)",
3090
+ * "provider: …" — or "none set" (model-credential.ts). */
3091
+ credential?: string;
3088
3092
  /** Slim browsing through the account's pod (browser-pod.ts): reconnect
3089
3093
  * tries and how many got through; when the pod was lost, why and what
3090
3094
  * the step had written by then; and what happened — `fallback` (the
package/src/subagents.ts CHANGED
@@ -97,7 +97,7 @@ export function subagentPrompt(o: {
97
97
  `# Where you are\n\n` +
98
98
  `You are ${o.name}, a sub-agent started by ${o.parent} to do one job and report back. ` +
99
99
  `You work in ${o.parent}'s directory, \`agents/${o.parent}/\` in the \`${o.workspace}\` workspace: ` +
100
- `\`../../\` is the workspace root, its knowledge is at \`../../knowledge/\`, and absolute paths are refused. ` +
100
+ `\`workspace/\` is the workspace root, its knowledge is at \`workspace/knowledge/\`, and absolute paths are refused. ` +
101
101
  `You have only the tools listed for you. When the job is done, reply with what you found or made — ` +
102
102
  `your reply is all ${o.parent} sees of your work, so put the facts and file paths in it.`,
103
103
  ...(o.shared?.trim() ? [o.shared.trim()] : []),
package/src/test-mode.ts CHANGED
@@ -18,7 +18,9 @@
18
18
  // a test run; the env var says TEST_MODE_WITHHELD instead, so a script
19
19
  // that forgot to check fails at the provider with an auth error
20
20
  // - the write-back: state/ and storage/ go INTO the sandbox as usual and
21
- // come out into runs/<id>/test-writes/ instead of the real directories
21
+ // come out into runs/<id>/test-writes/ instead of the real directories;
22
+ // the next step's copy has those writes laid over it (overlayTestWrites),
23
+ // so the run reads its own work and the workspace never sees it
22
24
  //
23
25
  // Nothing here weakens a live run: every function is a no-op unless the run
24
26
  // says `test: true`.
@@ -270,25 +272,32 @@ export function snapshotDivertedDirs(wsRoot: string): Map<string, Buffer> {
270
272
 
271
273
  /** Undo a step's writes to the diverted directories, keeping each changed
272
274
  * or new file under `to` by its workspace-relative path. Deletions are
273
- * undone too — the original is put back. Returns what was set aside. */
275
+ * undone too — the original is put back. `handed` is what the step was
276
+ * given when that was more than the workspace (the run's earlier test
277
+ * writes, laid over it): only what the step changed from THAT is its
278
+ * write. Returns what was set aside. */
274
279
  export function restoreDivertedDirs(
275
280
  wsRoot: string,
276
281
  before: Map<string, Buffer>,
277
282
  to: string,
278
283
  note: (rel: string, summary: string) => void,
284
+ handed: Map<string, Buffer> = before,
279
285
  ): string[] {
280
286
  const now = snapshotDivertedDirs(wsRoot);
281
287
  const aside: string[] = [];
282
288
  for (const [rel, next] of now) {
283
- const was = before.get(rel) ?? null;
284
- if (was && was.equals(next)) continue;
285
- const dest = path.join(to, rel);
286
- fs.mkdirSync(path.dirname(dest), { recursive: true });
287
- fs.writeFileSync(dest, next);
288
- aside.push(rel);
289
- note(rel, divertedSummary(rel, was, next));
290
- if (was) fs.writeFileSync(path.join(wsRoot, rel), was);
291
- else fs.rmSync(path.join(wsRoot, rel), { force: true });
289
+ const given = handed.get(rel) ?? null;
290
+ if (!(given && given.equals(next))) {
291
+ const dest = path.join(to, rel);
292
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
293
+ fs.writeFileSync(dest, next);
294
+ aside.push(rel);
295
+ note(rel, divertedSummary(rel, before.get(rel) ?? null, next));
296
+ }
297
+ const orig = before.get(rel);
298
+ if (orig) {
299
+ if (!orig.equals(next)) fs.writeFileSync(path.join(wsRoot, rel), orig);
300
+ } else fs.rmSync(path.join(wsRoot, rel), { force: true });
292
301
  }
293
302
  for (const [rel, was] of before) {
294
303
  if (now.has(rel)) continue;
@@ -300,6 +309,37 @@ export function restoreDivertedDirs(
300
309
  return aside;
301
310
  }
302
311
 
312
+ /**
313
+ * A test run's later steps see what its earlier steps wrote. Those writes
314
+ * are kept under runs/<id>/test-writes/, never on the workspace, so each
315
+ * step's copy of the workspace gets them laid over it — otherwise a step
316
+ * reads the real files, and a flow that hands work on through storage/
317
+ * tests something no live run does: on 2026-10-03 a strata-desk test run's
318
+ * compiler wrote today's PASS report, and the mailer was handed the
319
+ * morning's FAIL one and refused. Times are kept, so a verify that asks
320
+ * whether a file is fresh answers for the step that wrote it. Returns the
321
+ * workspace-relative paths laid over.
322
+ */
323
+ export function overlayTestWrites(wsCopy: string, from: string): string[] {
324
+ const laid: string[] = [];
325
+ for (const dir of TEST_DIVERTED_DIRS) {
326
+ const root = path.join(from, dir);
327
+ if (!fs.existsSync(root)) continue;
328
+ for (const entry of fs.readdirSync(root, { recursive: true, withFileTypes: true })) {
329
+ if (!entry.isFile()) continue;
330
+ const abs = path.join(entry.parentPath, entry.name);
331
+ const rel = path.relative(from, abs).replaceAll("\\", "/");
332
+ const dest = path.join(wsCopy, rel);
333
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
334
+ fs.copyFileSync(abs, dest);
335
+ const st = fs.statSync(abs);
336
+ fs.utimesSync(dest, st.atime, st.mtime);
337
+ laid.push(rel);
338
+ }
339
+ }
340
+ return laid;
341
+ }
342
+
303
343
  /** `[test] ` in front of a headline, once. */
304
344
  export function testHeadline(summary: string): string {
305
345
  return summary.startsWith("[test]") ? summary : `[test] ${summary}`;
@@ -2,7 +2,7 @@
2
2
  name: writer
3
3
  description: Turns a brief into a short draft.
4
4
  tools:
5
- - files
5
+ - write
6
6
  ---
7
7
 
8
8
  Write a short draft from the brief you were given. Save it to outputs/draft.md.
@@ -3,12 +3,12 @@ name: curator
3
3
  description: Reviews what the workspace has remembered — stale, duplicated or contradicting facts — and tidies memory without inventing anything.
4
4
  model: default
5
5
  tools:
6
- - files
6
+ - write
7
7
  - search
8
8
  - history
9
9
  ---
10
10
 
11
- You look after this workspace's memory: the files under `../../memory/` and
11
+ You look after this workspace's memory: the files under `workspace/memory/` and
12
12
  under each agent's `memory/`. Agents write there one fact per file, and nobody
13
13
  prunes it, so over months it gains duplicates, facts that later runs
14
14
  contradicted, and notes that stopped being true.
@@ -27,6 +27,6 @@ Do exactly this:
27
27
  move it to `workspace/state/` if it is still useful, else delete it.
28
28
  - Anything you are not sure about: leave it, and list it in your report.
29
29
  3. Never write a fact you did not find in a file or a run. Never touch
30
- `knowledge/` — that is what people gave the agents, and it is theirs.
30
+ `workspace/knowledge/` — that is what people gave the agents, and it is theirs.
31
31
  4. Reply with a one-line headline (how many files reviewed, merged, corrected,
32
32
  removed), then the list of what you changed and what you left for a person.
@@ -3,7 +3,7 @@ name: notetaker
3
3
  description: Reads the house style, writes one short note, checks its length, and records what it learned.
4
4
  model: fast
5
5
  tools:
6
- - files
6
+ - write
7
7
  - wordcount
8
8
  ---
9
9
 
@@ -16,7 +16,7 @@ Do exactly this, in order:
16
16
  2. Draft two sentences about rain gauges, then call the `wordcount` tool on
17
17
  your draft. If it reports more than 2 sentences, cut it down and check
18
18
  again. Save the final text to `outputs/note.md`.
19
- 3. Write one thing you learned to `../../memory/` as a new file — one fact,
19
+ 3. Write one thing you learned to `workspace/memory/` as a new file — one fact,
20
20
  plain markdown, a sentence or two. The platform records who wrote it.
21
21
  4. Update `workspace/state/publishing.md`: increase the note count by one and set
22
22
  the last topic to what you wrote about. Keep it as markdown.