@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.
- package/README.md +7 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/src/browser-pod.d.ts +20 -1
- package/dist/src/browser-pod.js +58 -9
- package/dist/src/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- package/dist/src/evals.js +3 -0
- package/dist/src/host-env.js +5 -0
- package/dist/src/mail.js +3 -1
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +58 -0
- package/dist/src/run-container.d.ts +1 -1
- package/dist/src/run-container.js +27 -7
- package/dist/src/runner.d.ts +35 -0
- package/dist/src/runner.js +124 -37
- package/dist/src/script-tools.d.ts +14 -0
- package/dist/src/script-tools.js +47 -7
- package/dist/src/step-exec.d.ts +11 -0
- package/dist/src/step-exec.js +18 -2
- package/dist/src/store.d.ts +4 -0
- package/dist/src/store.js +2 -2
- package/dist/src/subagents.js +1 -1
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/browser-pod.ts +46 -9
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/evals.ts +3 -0
- package/src/host-env.ts +5 -0
- package/src/mail.ts +3 -1
- package/src/model-credential.ts +71 -0
- package/src/run-container.ts +27 -7
- package/src/runner.ts +122 -37
- package/src/script-tools.ts +43 -5
- package/src/step-exec.ts +19 -2
- package/src/store.ts +6 -2
- package/src/subagents.ts +1 -1
- package/src/test-mode.ts +51 -11
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +3 -3
- 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
|
|
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
|
|
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"), "
|
|
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
|
-
|
|
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: "
|
|
1510
|
-
{ label: "
|
|
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
|
-
|
|
2621
|
-
|
|
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, ...
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/src/script-tools.ts
CHANGED
|
@@ -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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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:
|
|
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"), "
|
|
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"), "
|
|
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
|
-
|
|
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.
|
|
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
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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}`;
|
|
@@ -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
|
-
-
|
|
6
|
+
- write
|
|
7
7
|
- search
|
|
8
8
|
- history
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
You look after this workspace's memory: the files under
|
|
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
|
-
-
|
|
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
|
|
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.
|