@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
@@ -1,6 +1,7 @@
1
1
  import { type SubagentSpec } from "./subagents.ts";
2
2
  import { type FlowStep, type RunRecord, type StepRecord } from "./store.ts";
3
3
  import { type Catalog } from "./catalog.ts";
4
+ import { type ScriptSpec } from "./script-tools.ts";
4
5
  import { type RuntimeSpec } from "./runtime.ts";
5
6
  import { type LanguageChoice } from "./language.ts";
6
7
  import { type RegionChoice } from "./locale.ts";
@@ -408,3 +409,37 @@ export declare function waitForRun(tenant: string, workspace: string, runId: str
408
409
  * nothing is what makes `?wait=true` useful rather than merely blocking.
409
410
  */
410
411
  export declare function runResult(run: RunRecord): string | null;
412
+ /**
413
+ * What a Docker-run script sees of the workspace and the account: their
414
+ * scripts/ and their tools/, read-only. A folder tool's code lives in its
415
+ * tools/<name>/ folder, not in scripts/ — without those two mounts every
416
+ * folder tool (the hello template's wordcount among them) was handed a host
417
+ * path the container did not have, and failed on every call. A gallery tool
418
+ * nobody installed runs from the gallery's own shelf, so that is mounted too
419
+ * when there is one. A folder that does not exist is skipped by the copy.
420
+ */
421
+ export declare function scriptMounts(agentDir: string, tenant: string): Record<string, string>;
422
+ /**
423
+ * The skills an agent can see, nearest first, each with the path the agent
424
+ * is told to read it at. Own skills, then the workspace's, then the
425
+ * workspace's `.agents/skills/` (the cross-client convention, scanned after
426
+ * the native location so a foldrun-native skill wins a name clash), then the
427
+ * account library. The nearest definition of a name wins.
428
+ *
429
+ * The workspace's are written `workspace/skills/…`, the one spelling for the
430
+ * workspace root, which the file tools expand themselves. `../../skills/…`
431
+ * left the model to resolve it against a path it had to guess, and on the
432
+ * hello template it climbed one level too far and was refused.
433
+ */
434
+ export declare function skillsInScope(agentDir: string, tenant: string): DiscoveredSkill[];
435
+ /**
436
+ * The interpreters a set of scripts needs, read from what they are: the
437
+ * extension of `run:` (or of inline code), or an explicit `interpreter:`.
438
+ * Docker's script image is built from the runtime spec, and the base one is
439
+ * Python only, so a JavaScript tool that declared no `runtime:` (the demo's
440
+ * inline crm-upload, any folder tool's run.mjs) failed with no node to run
441
+ * it. Python is named too whenever a .py script is present, because asking
442
+ * for node alone builds on node:22-slim, which has no python3. Null when
443
+ * nothing beyond the default is needed.
444
+ */
445
+ export declare function scriptLanguages(scripts: ScriptSpec[]): RuntimeSpec | null;
@@ -12,12 +12,13 @@ import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.js
12
12
  import { eventUrl } from "./webhook.js";
13
13
  import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
14
14
  import { hostSafeEnv } from "./host-env.js";
15
+ import { resolveModelCredential, credentialLine } from "./model-credential.js";
15
16
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
16
17
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
17
18
  import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote } from "./refusal.js";
18
19
  import { platform } from "./platform.js";
19
20
  import { healthKey } from "./secret-health.js";
20
- import { TEST_WRITES_DIR, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.js";
21
+ import { TEST_WRITES_DIR, overlayTestWrites, restoreDivertedDirs, snapshotDivertedDirs, testHeadline, testModeEnv, withholdSecrets } from "./test-mode.js";
21
22
  /** Does this process run steps in a sandbox — the container core ships, or
22
23
  * one the platform registered (a pod)? */
23
24
  const isolatedRun = () => {
@@ -657,8 +658,7 @@ grantsOnly = false) {
657
658
  `\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
658
659
  `\`outputs/\` is yours, and \`workspace/\` is the workspace root, in every tool ` +
659
660
  `and in the shell: the shared file store is \`workspace/storage/\`, what runs ` +
660
- `carry forward is \`workspace/state/\`. (\`../../\` reaches the same root and ` +
661
- `still works, so a path like \`../../knowledge/\` below is \`workspace/knowledge/\`.) ` +
661
+ `carry forward is \`workspace/state/\`, its knowledge \`workspace/knowledge/\`. ` +
662
662
  `Absolute paths are outside the workspace and will be refused.\n\n` +
663
663
  // A flow's whole point is that a later step works on what an earlier one
664
664
  // produced, and each agent writes to its own outputs/. A run showed what
@@ -707,25 +707,7 @@ grantsOnly = false) {
707
707
  // skills for a few tokens each.
708
708
  // Own skills, then the workspace's, then the workspace library — nearest
709
709
  // definition of a name wins, so a team default can be overridden locally.
710
- const skills = [];
711
- const seenSkills = new Set();
712
- const addSkills = (dir, prefix, subdir = "skills") => {
713
- for (const skill of discoverSkills(dir, subdir)) {
714
- if (seenSkills.has(skill.name))
715
- continue;
716
- seenSkills.add(skill.name);
717
- skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
718
- }
719
- };
720
- const workspaceRoot = path.join(agentDir, "..", "..");
721
- addSkills(agentDir, "");
722
- addSkills(workspaceRoot, "../../"); // the workspace's skills/
723
- // The cross-client convention: skills any skills-compatible tool installs
724
- // under .agents/skills/ are visible here too, and vice versa. Scanned after
725
- // the native location so a foldrun-native skill wins a name clash, but both
726
- // are discovered. project scope only — foldrun workspaces are self-contained.
727
- addSkills(workspaceRoot, "../../", ".agents/skills");
728
- addSkills(path.join(libraryDir(tenant)), "../../../../library/"); // the account library
710
+ const skills = skillsInScope(agentDir, tenant);
729
711
  // A skill with `when:` only loads when the run carries a matching tag —
730
712
  // so an agent with 119 skills costs 119 lines of context only when every
731
713
  // one is genuinely relevant.
@@ -804,7 +786,7 @@ grantsOnly = false) {
804
786
  return out.sort();
805
787
  };
806
788
  const own = listDir(path.join(agentDir, "scripts"), "scripts/");
807
- const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "../../scripts/");
789
+ const shared = listDir(path.join(agentDir, "..", "..", "scripts"), "workspace/scripts/");
808
790
  // The library's path depends on where this run executes: isolated runs see
809
791
  // it at /library, host runs at its real location. Printing the host path
810
792
  // into a container's prompt handed the model files it could never open.
@@ -1110,17 +1092,18 @@ grantsOnly = false) {
1110
1092
  let exec = null;
1111
1093
  let runtime = { interpreters: {}, env: {}, log: [], error: null };
1112
1094
  if (executor === "docker" && !grantsOnly) {
1113
- const image = ensureImage(runtimeSpec);
1095
+ // The script image also carries what the scripts' own languages need: a
1096
+ // .mjs tool with no runtime: block still needs node in its container.
1097
+ // Here only — the run container and the host have node already, and a
1098
+ // spec there sets off the runtime build for nothing.
1099
+ const image = ensureImage(mergeRuntimes(runtimeSpec, scriptLanguages(scriptSpecs)));
1114
1100
  runtimeLog.push(...image.log);
1115
1101
  runtimeError = image.error;
1116
1102
  if (!image.error) {
1117
1103
  exec = {
1118
1104
  executor: "docker",
1119
1105
  image: image.tag,
1120
- mounts: {
1121
- [path.resolve(agentDir, "..", "..", "scripts")]: "/workspace-scripts",
1122
- [libraryDir(tenant, "scripts")]: "/library-scripts",
1123
- },
1106
+ mounts: scriptMounts(agentDir, tenant),
1124
1107
  // Only agents that granted something reaching out — an `apis:` entry
1125
1108
  // or one of their own tools — get egress.
1126
1109
  network: parseApis(front.apis).length > 0 || ownToolNames(front).length > 0,
@@ -1367,8 +1350,8 @@ grantsOnly = false) {
1367
1350
  ? [
1368
1351
  { label: "knowledge/", dir: path.join(agentDir, "knowledge") },
1369
1352
  { label: "memory/", dir: path.join(agentDir, "memory") },
1370
- { label: "../../knowledge/", dir: path.join(wsRoot, "knowledge") },
1371
- { label: "../../memory/", dir: path.join(wsRoot, "memory") },
1353
+ { label: "workspace/knowledge/", dir: path.join(wsRoot, "knowledge") },
1354
+ { label: "workspace/memory/", dir: path.join(wsRoot, "memory") },
1372
1355
  { label: "workspace/state/", dir: path.join(wsRoot, "state") },
1373
1356
  { label: `workspace/${STORAGE_DIR}/`, dir: path.join(wsRoot, STORAGE_DIR) },
1374
1357
  { label: "outputs/", dir: path.join(agentDir, "outputs") },
@@ -1791,6 +1774,16 @@ stepIndex) {
1791
1774
  push("error", problem);
1792
1775
  if (providerLabel)
1793
1776
  push("info", `provider: ${providerLabel}`);
1777
+ // Which credential the model is called with, on the record: an agent
1778
+ // that names a provider uses its own; otherwise the platform's (in a
1779
+ // run container) or this process's — never a login that just happens
1780
+ // to be on the machine (model-credential.ts).
1781
+ const credential = providerLabel ? null : resolveModelCredential(isolatedRun() ? platformModelEnv() : process.env);
1782
+ step.credential = credential ? credential.label : `provider: ${providerLabel}`;
1783
+ if (credential) {
1784
+ const line = credentialLine(credential);
1785
+ push(line.type, line.text);
1786
+ }
1794
1787
  for (const w of providerWarnings)
1795
1788
  push("error", w);
1796
1789
  // A level that wrote a language nobody can read was skipped, not obeyed.
@@ -2420,9 +2413,13 @@ stepIndex) {
2420
2413
  };
2421
2414
  const again = { ...lastArgs, input: { ...lastArgs.input, image: "full" }, resume: null };
2422
2415
  await lease?.commit();
2423
- outcome = await runTracked(again);
2424
- step.image = outcome.image ?? { variant: "full", why: "re-run after the browser pod was lost" };
2416
+ // Before the call, not after: a re-run that throws (the full
2417
+ // sandbox never started) is still the full go on the record — its
2418
+ // failed row read "slim" when this was set only on success.
2419
+ step.image = { variant: "full", why: "re-run after the browser pod was lost" };
2425
2420
  step.browserPod = { ...first.browserPod, fallback: RERAN_ON_FULL };
2421
+ outcome = await runTracked(again);
2422
+ step.image = outcome.image ?? step.image;
2426
2423
  }
2427
2424
  else {
2428
2425
  push("error", decision.message);
@@ -2499,7 +2496,11 @@ stepIndex) {
2499
2496
  push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
2500
2497
  }
2501
2498
  const stepSecrets = held.env;
2502
- const consultTools = buildConsultTools(consults, { ...hostSafeEnv(), ...stepSecrets, ...providerEnv }, (type, text) => push(type, text));
2499
+ // The model credential for this machine's Claude Code — the agent's
2500
+ // own provider when it names one. The host allowlist drops it, so
2501
+ // without this every in-process step ran on the machine's login.
2502
+ const localModelEnv = Object.keys(providerEnv).length ? providerEnv : resolveModelCredential().env;
2503
+ const consultTools = buildConsultTools(consults, { ...hostSafeEnv(), ...stepSecrets, ...localModelEnv }, (type, text) => push(type, text));
2503
2504
  // On this machine nobody can write in and there is no proxy: the
2504
2505
  // question goes to the terminal when there is one, else unanswered.
2505
2506
  const askTools = ask ? buildAskTool({ channel: null, timeoutSec: ask.timeoutSec, emit: push, local: terminalAsk }) : null;
@@ -2566,6 +2567,9 @@ stepIndex) {
2566
2567
  // under the run and the originals come back.
2567
2568
  const attemptThrough = async (modelEnv, spec) => {
2568
2569
  const before = testRun ? snapshotDivertedDirs(workspaceRoot) : null;
2570
+ // What earlier steps of this test run wrote, laid over the real files
2571
+ // for this attempt; restoreDivertedDirs puts the originals back.
2572
+ const handed = before && runId && overlayTestWrites(workspaceRoot, divertFor(runId).to).length ? snapshotDivertedDirs(workspaceRoot) : before;
2569
2573
  const t = spec ? await startTranslator(spec) : null;
2570
2574
  try {
2571
2575
  return await attemptInProcess(t ? { ...modelEnv, ...t.env } : modelEnv);
@@ -2578,12 +2582,12 @@ stepIndex) {
2578
2582
  }
2579
2583
  if (before && runId) {
2580
2584
  const d = divertFor(runId);
2581
- restoreDivertedDirs(workspaceRoot, before, d.to, d.note);
2585
+ restoreDivertedDirs(workspaceRoot, before, d.to, d.note, handed ?? before);
2582
2586
  }
2583
2587
  }
2584
2588
  };
2585
2589
  let credentialUsed = platformModelCredential();
2586
- let outcome = await attemptThrough(providerEnv, translator);
2590
+ let outcome = await attemptThrough(localModelEnv, translator);
2587
2591
  // Same rule as the isolated path: a credential that rotated under the
2588
2592
  // step is waited out and retried before anything else is considered.
2589
2593
  if (outcome.status === "failed" && isAuthRefusal(lastRefusal)) {
@@ -2593,7 +2597,7 @@ stepIndex) {
2593
2597
  push("info", "the credential rotated under this step; retrying on the new one");
2594
2598
  credentialUsed = rotated;
2595
2599
  lastRefusal = "";
2596
- outcome = await attemptThrough(providerEnv, translator);
2600
+ outcome = await attemptThrough(localModelEnv, translator);
2597
2601
  }
2598
2602
  else {
2599
2603
  push("info", "the credential did not change — this is the key itself, not a rotation");
@@ -2606,7 +2610,7 @@ stepIndex) {
2606
2610
  push("info", `provider busy (${lastRefusal.slice(0, 60)}) — waiting ${(wait / 1000).toFixed(1)}s, attempt ${n} of ${OVERLOAD_RETRIES}`);
2607
2611
  await sleep(wait);
2608
2612
  lastRefusal = "";
2609
- outcome = await attemptThrough(providerEnv, translator);
2613
+ outcome = await attemptThrough(localModelEnv, translator);
2610
2614
  }
2611
2615
  // Same rule as the isolated path: one retry, on a refusal, on the
2612
2616
  // declared or platform second supply.
@@ -4550,3 +4554,86 @@ export function runResult(run) {
4550
4554
  }
4551
4555
  return null;
4552
4556
  }
4557
+ /**
4558
+ * What a Docker-run script sees of the workspace and the account: their
4559
+ * scripts/ and their tools/, read-only. A folder tool's code lives in its
4560
+ * tools/<name>/ folder, not in scripts/ — without those two mounts every
4561
+ * folder tool (the hello template's wordcount among them) was handed a host
4562
+ * path the container did not have, and failed on every call. A gallery tool
4563
+ * nobody installed runs from the gallery's own shelf, so that is mounted too
4564
+ * when there is one. A folder that does not exist is skipped by the copy.
4565
+ */
4566
+ export function scriptMounts(agentDir, tenant) {
4567
+ const workspaceRoot = path.resolve(agentDir, "..", "..");
4568
+ const mounts = {
4569
+ [path.join(workspaceRoot, "scripts")]: "/workspace-scripts",
4570
+ [path.join(workspaceRoot, "tools")]: "/workspace-tools",
4571
+ [libraryDir(tenant, "scripts")]: "/library-scripts",
4572
+ [libraryDir(tenant, "tools")]: "/library-tools",
4573
+ };
4574
+ const gallery = platform.galleryDir();
4575
+ if (gallery)
4576
+ mounts[path.join(gallery, "tools")] = "/gallery-tools";
4577
+ return mounts;
4578
+ }
4579
+ /**
4580
+ * The skills an agent can see, nearest first, each with the path the agent
4581
+ * is told to read it at. Own skills, then the workspace's, then the
4582
+ * workspace's `.agents/skills/` (the cross-client convention, scanned after
4583
+ * the native location so a foldrun-native skill wins a name clash), then the
4584
+ * account library. The nearest definition of a name wins.
4585
+ *
4586
+ * The workspace's are written `workspace/skills/…`, the one spelling for the
4587
+ * workspace root, which the file tools expand themselves. `../../skills/…`
4588
+ * left the model to resolve it against a path it had to guess, and on the
4589
+ * hello template it climbed one level too far and was refused.
4590
+ */
4591
+ export function skillsInScope(agentDir, tenant) {
4592
+ const skills = [];
4593
+ const seen = new Set();
4594
+ const add = (dir, prefix, subdir = "skills") => {
4595
+ for (const skill of discoverSkills(dir, subdir)) {
4596
+ if (seen.has(skill.name))
4597
+ continue;
4598
+ seen.add(skill.name);
4599
+ skills.push({ ...skill, path: `${prefix}${skill.path}`, dir: skill.dir ? `${prefix}${skill.dir}` : null });
4600
+ }
4601
+ };
4602
+ const workspaceRoot = path.join(agentDir, "..", "..");
4603
+ add(agentDir, "");
4604
+ add(workspaceRoot, "workspace/");
4605
+ add(workspaceRoot, "workspace/", ".agents/skills");
4606
+ // The account library keeps its relative path: `account/` is expanded by
4607
+ // the file tools only, and a skill's bundled scripts are run from the shell.
4608
+ add(path.join(libraryDir(tenant)), "../../../../library/");
4609
+ return skills;
4610
+ }
4611
+ /**
4612
+ * The interpreters a set of scripts needs, read from what they are: the
4613
+ * extension of `run:` (or of inline code), or an explicit `interpreter:`.
4614
+ * Docker's script image is built from the runtime spec, and the base one is
4615
+ * Python only, so a JavaScript tool that declared no `runtime:` (the demo's
4616
+ * inline crm-upload, any folder tool's run.mjs) failed with no node to run
4617
+ * it. Python is named too whenever a .py script is present, because asking
4618
+ * for node alone builds on node:22-slim, which has no python3. Null when
4619
+ * nothing beyond the default is needed.
4620
+ */
4621
+ export function scriptLanguages(scripts) {
4622
+ const kind = (sc) => {
4623
+ const via = (sc.interpreter ?? "").split(/[\\/]/).pop() ?? "";
4624
+ if (/^node/.test(via))
4625
+ return "node";
4626
+ if (/^python/.test(via))
4627
+ return "python";
4628
+ const ext = (sc.run ? path.extname(sc.run) : sc.codeExt ?? "").toLowerCase();
4629
+ if ([".js", ".mjs", ".cjs", ".ts"].includes(ext))
4630
+ return "node";
4631
+ if (ext === ".py")
4632
+ return "python";
4633
+ return null;
4634
+ };
4635
+ const kinds = new Set(scripts.map(kind));
4636
+ if (!kinds.has("node"))
4637
+ return null;
4638
+ return { node: true, ...(kinds.has("python") ? { python: true } : {}), packages: [], npm: [] };
4639
+ }
@@ -45,6 +45,20 @@ export declare function runScript(cwd: string, spec: ScriptSpec, values: Record<
45
45
  code: number | null;
46
46
  out: string;
47
47
  }>;
48
+ /**
49
+ * A host path as the script's container sees it: under the agent's own
50
+ * directory it is /workspace, under a mounted folder it is that mount, and
51
+ * anything else (an argument that only looks like a path) is left alone.
52
+ * Containment, not a text prefix: `<ws>/scripts-old/x.py` is not under
53
+ * `<ws>/scripts`. The longest mount wins, so a nested one is not shadowed.
54
+ */
55
+ export declare function containerPath(p: string, agentDir: string, mounts: Record<string, string>): string;
56
+ /** Where a `workspace/…` script argument is staged in a script container,
57
+ * or null when it is not one (or climbs out of the workspace). */
58
+ export declare function stageWorkspaceArg(arg: string, workspaceRoot: string): {
59
+ host: string;
60
+ container: string;
61
+ } | null;
48
62
  export interface ExecutionContext {
49
63
  /** "docker" nests each script in its own container; "sandbox" spawns it
50
64
  * directly inside the isolation the step already has. */
@@ -183,14 +183,18 @@ export function runScript(cwd, spec, values, env, libraryScripts, interpreters,
183
183
  // Container path: same command, executed inside an isolated container
184
184
  // with only the agent's directory (plus declared shared dirs) mounted.
185
185
  if (exec && exec.executor === "docker") {
186
+ // `workspace/…` is the workspace root on the host (the step's link)
187
+ // and in a run container; here there is no link, so each such
188
+ // argument is staged: copied in, pointed at, and copied back after.
189
+ const workspaceRoot = path.resolve(cwd, "..", "..");
190
+ const staged = [];
186
191
  const inContainer = (p) => {
187
- if (p.startsWith(path.resolve(cwd)))
188
- return p.replace(path.resolve(cwd), "/workspace");
189
- for (const [host, mount] of Object.entries(exec.mounts)) {
190
- if (p.startsWith(host))
191
- return p.replace(host, mount);
192
- }
193
- return p;
192
+ const w = stageWorkspaceArg(p, workspaceRoot);
193
+ if (!w)
194
+ return containerPath(p, cwd, exec.mounts);
195
+ if (!staged.some((s) => s.host === w.host))
196
+ staged.push(w);
197
+ return w.container;
194
198
  };
195
199
  const argv = [
196
200
  // The interpreter inside the image, not the host's venv path.
@@ -203,6 +207,7 @@ export function runScript(cwd, spec, values, env, libraryScripts, interpreters,
203
207
  runInContainer({
204
208
  agentDir: path.resolve(cwd),
205
209
  readOnly: exec.mounts,
210
+ staged,
206
211
  image: exec.image,
207
212
  argv,
208
213
  env,
@@ -238,6 +243,41 @@ export function runScript(cwd, spec, values, env, libraryScripts, interpreters,
238
243
  }));
239
244
  });
240
245
  }
246
+ /**
247
+ * A host path as the script's container sees it: under the agent's own
248
+ * directory it is /workspace, under a mounted folder it is that mount, and
249
+ * anything else (an argument that only looks like a path) is left alone.
250
+ * Containment, not a text prefix: `<ws>/scripts-old/x.py` is not under
251
+ * `<ws>/scripts`. The longest mount wins, so a nested one is not shadowed.
252
+ */
253
+ export function containerPath(p, agentDir, mounts) {
254
+ if (!path.isAbsolute(p))
255
+ return p;
256
+ const under = (root) => {
257
+ const rel = path.relative(root, p);
258
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) ? rel : null;
259
+ };
260
+ const own = under(path.resolve(agentDir));
261
+ if (own !== null)
262
+ return path.posix.join("/workspace", own.split(path.sep).join("/"));
263
+ const hit = Object.entries(mounts)
264
+ .map(([host, mount]) => ({ mount, rel: under(path.resolve(host)), len: host.length }))
265
+ .filter((m) => m.rel !== null)
266
+ .sort((a, b) => b.len - a.len)[0];
267
+ return hit ? path.posix.join(hit.mount, hit.rel.split(path.sep).join("/")) : p;
268
+ }
269
+ /** Where a `workspace/…` script argument is staged in a script container,
270
+ * or null when it is not one (or climbs out of the workspace). */
271
+ export function stageWorkspaceArg(arg, workspaceRoot) {
272
+ if (arg !== "workspace" && !arg.startsWith("workspace/"))
273
+ return null;
274
+ const rel = arg.slice("workspace".length).replace(/^\/+/, "");
275
+ const host = path.resolve(workspaceRoot, rel);
276
+ const within = path.relative(workspaceRoot, host);
277
+ if (within.startsWith("..") || path.isAbsolute(within))
278
+ return null;
279
+ return { host, container: path.posix.join("/workspace-root", within.split(path.sep).join("/")) };
280
+ }
241
281
  // `run:` accepts:
242
282
  // scripts/x.py this agent's own script
243
283
  // workspace/tools/<t>/x.py a folder tool's own code, in this workspace
@@ -249,3 +249,14 @@ export declare function checkVerify(agentDir: string, verify: string, ctx: {
249
249
  * person asked the run to stop. */
250
250
  signal?: AbortSignal;
251
251
  }): Promise<VerifyVerdict>;
252
+ /**
253
+ * The environment an Agent SDK session starts with: the step's, minus the
254
+ * connectors of whoever is signed in to claude.ai on this machine. Claude
255
+ * Code loads an account's claude.ai MCP servers (Docs, Drive, …) unless told
256
+ * not to, and `settingSources: []` does not reach them — they come with the
257
+ * login, not from a settings file. A local `foldrun run` handed every agent
258
+ * the person's own connectors: the hello template's agents reported that
259
+ * "the only tools I have are the Claude Docs ones". `strictMcpConfig` keeps
260
+ * the rest of the on-disk MCP configuration out; this keeps the account's.
261
+ */
262
+ export declare function sdkEnv(base: Record<string, string | undefined> | undefined, extra?: Record<string, string>): Record<string, string | undefined>;
@@ -213,8 +213,10 @@ async function executeStepInner(opts, runQuery) {
213
213
  // With sub-agents, the SDK would also offer its built-in ones
214
214
  // (general-purpose has every tool the session has). Only the declared
215
215
  // ones may run; the guard in the hook below refuses the rest as well.
216
- env: subagents ? { ...(opts.env ?? process.env), CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS: "1" } : opts.env,
216
+ env: sdkEnv(opts.env, subagents ? { CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS: "1" } : {}),
217
217
  mcpServers: opts.mcpServers,
218
+ // Only the servers foldrun passes. See sdkEnv for the claude.ai half.
219
+ strictMcpConfig: true,
218
220
  // canUseTool is only asked when the SDK wants permission, and it never
219
221
  // asks for a read inside the cwd — so `Read workspace/storage/x` went
220
222
  // to `<agentDir>/workspace/storage/x` unchecked and unrewritten. On
@@ -827,7 +829,8 @@ async function judgeReply(rubric, output, env, signal) {
827
829
  systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
828
830
  tools: [],
829
831
  settingSources: [],
830
- env,
832
+ env: sdkEnv(env),
833
+ strictMcpConfig: true,
831
834
  maxTurns: 1,
832
835
  abortController,
833
836
  },
@@ -898,3 +901,16 @@ function runVerify(agentDir, command, env, data, signal) {
898
901
  child.on("close", (code) => resolve({ code, out }));
899
902
  });
900
903
  }
904
+ /**
905
+ * The environment an Agent SDK session starts with: the step's, minus the
906
+ * connectors of whoever is signed in to claude.ai on this machine. Claude
907
+ * Code loads an account's claude.ai MCP servers (Docs, Drive, …) unless told
908
+ * not to, and `settingSources: []` does not reach them — they come with the
909
+ * login, not from a settings file. A local `foldrun run` handed every agent
910
+ * the person's own connectors: the hello template's agents reported that
911
+ * "the only tools I have are the Claude Docs ones". `strictMcpConfig` keeps
912
+ * the rest of the on-disk MCP configuration out; this keeps the account's.
913
+ */
914
+ export function sdkEnv(base, extra = {}) {
915
+ return { ...(base ?? process.env), ...extra, ENABLE_CLAUDEAI_MCP_SERVERS: "false" };
916
+ }
@@ -992,6 +992,10 @@ export interface StepRecord {
992
992
  why?: string;
993
993
  pod?: boolean;
994
994
  } | null;
995
+ /** Which model credential the step ran on, by its variable — "API key
996
+ * (ANTHROPIC_API_KEY)", "Claude login token (CLAUDE_CODE_OAUTH_TOKEN)",
997
+ * "provider: …" — or "none set" (model-credential.ts). */
998
+ credential?: string;
995
999
  /** Slim browsing through the account's pod (browser-pod.ts): reconnect
996
1000
  * tries and how many got through; when the pod was lost, why and what
997
1001
  * the step had written by then; and what happened — `fallback` (the
package/dist/src/store.js CHANGED
@@ -483,7 +483,7 @@ export function buildMemoryIndex(dir, prefix = "") {
483
483
  // Workspace-scoped shared assets: skills/, memory/, tools/ sit beside agents/
484
484
  // and are available to every agent in that workspace.
485
485
  export function workspaceMemoryIndex(tenant, workspace) {
486
- return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "../../memory/");
486
+ return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "workspace/memory/");
487
487
  }
488
488
  // Knowledge is what you *gave* an agent — stable reference material an agent
489
489
  // reads and never rewrites. Memory is what it *learned* and authors itself.
@@ -493,7 +493,7 @@ export function knowledgeIndex(dir, prefix = "") {
493
493
  return buildMemoryIndex(dir, prefix);
494
494
  }
495
495
  export function workspaceKnowledgeIndex(tenant, workspace) {
496
- return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "../../knowledge/");
496
+ return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "workspace/knowledge/");
497
497
  }
498
498
  // One noun for capability. A tools/<name>.md file declares either an HTTP
499
499
  // endpoint or a script — `type:` says which, and it's inferred when omitted
@@ -71,7 +71,7 @@ export function subagentPrompt(o) {
71
71
  `# Where you are\n\n` +
72
72
  `You are ${o.name}, a sub-agent started by ${o.parent} to do one job and report back. ` +
73
73
  `You work in ${o.parent}'s directory, \`agents/${o.parent}/\` in the \`${o.workspace}\` workspace: ` +
74
- `\`../../\` is the workspace root, its knowledge is at \`../../knowledge/\`, and absolute paths are refused. ` +
74
+ `\`workspace/\` is the workspace root, its knowledge is at \`workspace/knowledge/\`, and absolute paths are refused. ` +
75
75
  `You have only the tools listed for you. When the job is done, reply with what you found or made — ` +
76
76
  `your reply is all ${o.parent} sees of your work, so put the facts and file paths in it.`,
77
77
  ...(o.shared?.trim() ? [o.shared.trim()] : []),
@@ -103,8 +103,23 @@ export declare function divertedSummary(rel: string, was: Buffer | null, next: B
103
103
  export declare function snapshotDivertedDirs(wsRoot: string): Map<string, Buffer>;
104
104
  /** Undo a step's writes to the diverted directories, keeping each changed
105
105
  * or new file under `to` by its workspace-relative path. Deletions are
106
- * undone too — the original is put back. Returns what was set aside. */
107
- export declare function restoreDivertedDirs(wsRoot: string, before: Map<string, Buffer>, to: string, note: (rel: string, summary: string) => void): string[];
106
+ * undone too — the original is put back. `handed` is what the step was
107
+ * given when that was more than the workspace (the run's earlier test
108
+ * writes, laid over it): only what the step changed from THAT is its
109
+ * write. Returns what was set aside. */
110
+ export declare function restoreDivertedDirs(wsRoot: string, before: Map<string, Buffer>, to: string, note: (rel: string, summary: string) => void, handed?: Map<string, Buffer>): string[];
111
+ /**
112
+ * A test run's later steps see what its earlier steps wrote. Those writes
113
+ * are kept under runs/<id>/test-writes/, never on the workspace, so each
114
+ * step's copy of the workspace gets them laid over it — otherwise a step
115
+ * reads the real files, and a flow that hands work on through storage/
116
+ * tests something no live run does: on 2026-10-03 a strata-desk test run's
117
+ * compiler wrote today's PASS report, and the mailer was handed the
118
+ * morning's FAIL one and refused. Times are kept, so a verify that asks
119
+ * whether a file is fresh answers for the step that wrote it. Returns the
120
+ * workspace-relative paths laid over.
121
+ */
122
+ export declare function overlayTestWrites(wsCopy: string, from: string): string[];
108
123
  /** `[test] ` in front of a headline, once. */
109
124
  export declare function testHeadline(summary: string): string;
110
125
  /** The env every test-run sandbox gets, beside its secrets. */
@@ -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`.
@@ -247,21 +249,27 @@ export function snapshotDivertedDirs(wsRoot) {
247
249
  }
248
250
  /** Undo a step's writes to the diverted directories, keeping each changed
249
251
  * or new file under `to` by its workspace-relative path. Deletions are
250
- * undone too — the original is put back. Returns what was set aside. */
251
- export function restoreDivertedDirs(wsRoot, before, to, note) {
252
+ * undone too — the original is put back. `handed` is what the step was
253
+ * given when that was more than the workspace (the run's earlier test
254
+ * writes, laid over it): only what the step changed from THAT is its
255
+ * write. Returns what was set aside. */
256
+ export function restoreDivertedDirs(wsRoot, before, to, note, handed = before) {
252
257
  const now = snapshotDivertedDirs(wsRoot);
253
258
  const aside = [];
254
259
  for (const [rel, next] of now) {
255
- const was = before.get(rel) ?? null;
256
- if (was && was.equals(next))
257
- continue;
258
- const dest = path.join(to, rel);
259
- fs.mkdirSync(path.dirname(dest), { recursive: true });
260
- fs.writeFileSync(dest, next);
261
- aside.push(rel);
262
- note(rel, divertedSummary(rel, was, next));
263
- if (was)
264
- fs.writeFileSync(path.join(wsRoot, rel), was);
260
+ const given = handed.get(rel) ?? null;
261
+ if (!(given && given.equals(next))) {
262
+ const dest = path.join(to, rel);
263
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
264
+ fs.writeFileSync(dest, next);
265
+ aside.push(rel);
266
+ note(rel, divertedSummary(rel, before.get(rel) ?? null, next));
267
+ }
268
+ const orig = before.get(rel);
269
+ if (orig) {
270
+ if (!orig.equals(next))
271
+ fs.writeFileSync(path.join(wsRoot, rel), orig);
272
+ }
265
273
  else
266
274
  fs.rmSync(path.join(wsRoot, rel), { force: true });
267
275
  }
@@ -275,6 +283,38 @@ export function restoreDivertedDirs(wsRoot, before, to, note) {
275
283
  }
276
284
  return aside;
277
285
  }
286
+ /**
287
+ * A test run's later steps see what its earlier steps wrote. Those writes
288
+ * are kept under runs/<id>/test-writes/, never on the workspace, so each
289
+ * step's copy of the workspace gets them laid over it — otherwise a step
290
+ * reads the real files, and a flow that hands work on through storage/
291
+ * tests something no live run does: on 2026-10-03 a strata-desk test run's
292
+ * compiler wrote today's PASS report, and the mailer was handed the
293
+ * morning's FAIL one and refused. Times are kept, so a verify that asks
294
+ * whether a file is fresh answers for the step that wrote it. Returns the
295
+ * workspace-relative paths laid over.
296
+ */
297
+ export function overlayTestWrites(wsCopy, from) {
298
+ const laid = [];
299
+ for (const dir of TEST_DIVERTED_DIRS) {
300
+ const root = path.join(from, dir);
301
+ if (!fs.existsSync(root))
302
+ continue;
303
+ for (const entry of fs.readdirSync(root, { recursive: true, withFileTypes: true })) {
304
+ if (!entry.isFile())
305
+ continue;
306
+ const abs = path.join(entry.parentPath, entry.name);
307
+ const rel = path.relative(from, abs).replaceAll("\\", "/");
308
+ const dest = path.join(wsCopy, rel);
309
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
310
+ fs.copyFileSync(abs, dest);
311
+ const st = fs.statSync(abs);
312
+ fs.utimesSync(dest, st.atime, st.mtime);
313
+ laid.push(rel);
314
+ }
315
+ }
316
+ return laid;
317
+ }
278
318
  /** `[test] ` in front of a headline, once. */
279
319
  export function testHeadline(summary) {
280
320
  return summary.startsWith("[test]") ? summary : `[test] ${summary}`;
package/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  export * from "./src/layout.ts";
14
14
  export * from "./src/paths.ts";
15
15
  export * from "./src/store.ts";
16
+ export * from "./src/model-credential.ts";
16
17
  export * from "./src/starter.ts";
17
18
  export * from "./src/runner.ts";
18
19
  export * from "./src/okf.ts";