@foldrun/core 2.14.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/confine.d.ts +3 -0
- package/dist/src/confine.js +14 -0
- package/dist/src/copy-back.d.ts +17 -0
- package/dist/src/copy-back.js +74 -0
- package/dist/src/deploy.d.ts +2 -1
- package/dist/src/deploy.js +4 -6
- package/dist/src/install-tool.js +15 -6
- package/dist/src/kinds.d.ts +14 -0
- package/dist/src/kinds.js +18 -0
- package/dist/src/run-container.d.ts +44 -2
- package/dist/src/run-container.js +82 -5
- package/dist/src/runner.js +32 -0
- package/dist/src/step-exec.d.ts +3 -0
- package/dist/src/step-exec.js +3 -3
- package/dist/src/store.d.ts +11 -0
- package/package.json +1 -1
- package/src/confine.ts +19 -0
- package/src/copy-back.ts +87 -0
- package/src/deploy.ts +4 -6
- package/src/install-tool.ts +15 -6
- package/src/kinds.ts +31 -0
- package/src/run-container.ts +105 -3
- package/src/runner.ts +30 -0
- package/src/step-exec.ts +6 -3
- package/src/store.ts +8 -0
package/dist/src/confine.d.ts
CHANGED
|
@@ -11,6 +11,9 @@ export interface Roots {
|
|
|
11
11
|
workspaceRoot: string;
|
|
12
12
|
/** The account library: readable, never writable. */
|
|
13
13
|
libraryRoot: string;
|
|
14
|
+
/** A person approved this step before it ran (an approval gate): its
|
|
15
|
+
* writes to the workspace's configuration and code are allowed. */
|
|
16
|
+
configWrites?: boolean;
|
|
14
17
|
}
|
|
15
18
|
export declare const isFilesystemTool: (toolName: string) => boolean;
|
|
16
19
|
/**
|
package/dist/src/confine.js
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
// ordinary deliverable — so the rule names the two directories it applies to.
|
|
30
30
|
import fs from "node:fs";
|
|
31
31
|
import path from "node:path";
|
|
32
|
+
import { refusedBack } from "./copy-back.js";
|
|
32
33
|
/**
|
|
33
34
|
* A path with its symlinks resolved, as far as it exists.
|
|
34
35
|
*
|
|
@@ -274,6 +275,19 @@ export function checkPaths(toolName, input, roots) {
|
|
|
274
275
|
`concept file instead.`,
|
|
275
276
|
};
|
|
276
277
|
}
|
|
278
|
+
// Configuration and code are a person's: the same list a sandbox's
|
|
279
|
+
// copy-back refuses (copy-back.ts, FOL-96), so a step that runs on this
|
|
280
|
+
// host, with no sandbox between it and the files, is held to the same
|
|
281
|
+
// rule by its file tools. Its shell is not confined here — isolation is.
|
|
282
|
+
const asConfig = isWrite && !inLibrary && !roots.configWrites ? refusedBack(within) : null;
|
|
283
|
+
if (asConfig) {
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
reason: `${toolName} was denied: "${raw}" is this workspace's ${asConfig === "config" ? "configuration" : "code"}, ` +
|
|
287
|
+
`which a run does not change. Say what should change and why in your answer — a person ` +
|
|
288
|
+
`applies it (or it becomes a Proposed fix). Your own notes go to outputs/ or workspace/memory/.`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
277
291
|
// Hand the real path back to the tool, so the virtual prefix the prompt
|
|
278
292
|
// advertises is one the agent can actually open — unless the step's
|
|
279
293
|
// `workspace` link already opens it. Then the path is left as written,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Kind } from "./kinds.ts";
|
|
2
|
+
/** A path a step changed that was not taken back, and why. */
|
|
3
|
+
export interface RefusedPath {
|
|
4
|
+
path: string;
|
|
5
|
+
kind: "config" | "code";
|
|
6
|
+
}
|
|
7
|
+
/** The path shapes one kind's files take, inside a workspace. */
|
|
8
|
+
export declare function kindPatterns(kind: Kind): RegExp[];
|
|
9
|
+
/** Whether a step's write to `rel` (workspace-relative) is refused on the way
|
|
10
|
+
* back, and as what; null when it is data and may come back. */
|
|
11
|
+
export declare function refusedBack(rel: string): "config" | "code" | null;
|
|
12
|
+
/** The trail's line for a step's refused writes. */
|
|
13
|
+
/** The trail's line for config and code a person-approved step changed and
|
|
14
|
+
* that were kept (an approval gate before the step: the person read what
|
|
15
|
+
* it would do — the desks' retro applies approved fixes this way). */
|
|
16
|
+
export declare function keptLine(kept: readonly RefusedPath[]): string;
|
|
17
|
+
export declare function refusedLine(refused: readonly RefusedPath[]): string;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// What a run's sandbox may hand back to the workspace (FOL-96, 27 Oct 2026).
|
|
2
|
+
//
|
|
3
|
+
// A step's file changes come back from its sandbox (run-container.ts
|
|
4
|
+
// applyContainerChanges — the docker and the k8s runner both), and until now
|
|
5
|
+
// everything came back that was not platform-owned, knowledge or hooks. So a
|
|
6
|
+
// step steered by a page it read could rewrite AGENTS.md, an agent.md or a
|
|
7
|
+
// flow — its own tools:, network:, a provider: block, a new scheduled flow —
|
|
8
|
+
// and every later run, the next step of the same run included, ran on it.
|
|
9
|
+
//
|
|
10
|
+
// Configuration and code are a person's to change. A step that wants a
|
|
11
|
+
// change proposes it (Proposed fixes, the assistant), and a person applies
|
|
12
|
+
// it; a step that writes one anyway has the write refused, and the run's
|
|
13
|
+
// trail names it. What steps legitimately write — state/, memory/, storage/,
|
|
14
|
+
// an agent's own outputs — comes back as before.
|
|
15
|
+
//
|
|
16
|
+
// The list is not written here. It is read off the kinds (kinds.ts), whose
|
|
17
|
+
// every row must say what its files are, so a kind added later is classified
|
|
18
|
+
// the moment it exists — tsc refuses a row without `copyBack`.
|
|
19
|
+
import { KINDS, ALL_KINDS, SUBAGENT_IMPORT_DIRS, WORKSPACE_CONFIG_FILES } from "./kinds.js";
|
|
20
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
21
|
+
const NAME = "\u0000";
|
|
22
|
+
/** Where an agent's own folder is — the scope agent-level kinds live under. */
|
|
23
|
+
const AGENT_SCOPE = KINDS.agents.file(NAME).split(NAME)[0]; // "agents/"
|
|
24
|
+
/** The path shapes one kind's files take, inside a workspace. */
|
|
25
|
+
export function kindPatterns(kind) {
|
|
26
|
+
const meta = KINDS[kind];
|
|
27
|
+
const file = meta.file(NAME);
|
|
28
|
+
// An agent's folder is a scope as well as a document: everything else in
|
|
29
|
+
// it is the agent's working directory. Its document is the one file.
|
|
30
|
+
if (kind === "agents") {
|
|
31
|
+
const [head, tail] = file.split(NAME);
|
|
32
|
+
return [new RegExp(`^${escape(head)}[^/]+${escape(tail)}$`)];
|
|
33
|
+
}
|
|
34
|
+
// Every other kind owns its top-level folder — a file document's siblings
|
|
35
|
+
// (a tool's script beside its .md) and a folder document's contents alike.
|
|
36
|
+
const root = file.split("/")[0];
|
|
37
|
+
const out = [new RegExp(`^${escape(root)}/`)];
|
|
38
|
+
if (meta.scopes.includes("agent"))
|
|
39
|
+
out.push(new RegExp(`^${escape(AGENT_SCOPE)}[^/]+/${escape(root)}/`));
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
const RULES = [
|
|
43
|
+
...WORKSPACE_CONFIG_FILES.map((f) => ({ re: new RegExp(`^${escape(f)}$`), kind: "config" })),
|
|
44
|
+
// Single-file subagents another tool wrote: a deploy imports them as agents.
|
|
45
|
+
...[...new Set(SUBAGENT_IMPORT_DIRS.map((d) => d.split("/")[0]))].map((d) => ({ re: new RegExp(`^${escape(d)}/`), kind: "config" })),
|
|
46
|
+
...ALL_KINDS.flatMap((k) => {
|
|
47
|
+
const cls = KINDS[k].copyBack;
|
|
48
|
+
return cls === "data" ? [] : kindPatterns(k).map((re) => ({ re, kind: cls }));
|
|
49
|
+
}),
|
|
50
|
+
];
|
|
51
|
+
/** Whether a step's write to `rel` (workspace-relative) is refused on the way
|
|
52
|
+
* back, and as what; null when it is data and may come back. */
|
|
53
|
+
export function refusedBack(rel) {
|
|
54
|
+
const norm = rel.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
55
|
+
for (const r of RULES)
|
|
56
|
+
if (r.re.test(norm))
|
|
57
|
+
return r.kind;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
/** The trail's line for a step's refused writes. */
|
|
61
|
+
/** The trail's line for config and code a person-approved step changed and
|
|
62
|
+
* that were kept (an approval gate before the step: the person read what
|
|
63
|
+
* it would do — the desks' retro applies approved fixes this way). */
|
|
64
|
+
export function keptLine(kept) {
|
|
65
|
+
const shown = kept.slice(0, 20).map((r) => `${r.path} (${r.kind})`).join(", ");
|
|
66
|
+
const more = kept.length > 20 ? ` and ${kept.length - 20} more` : "";
|
|
67
|
+
return `kept with approval: ${shown}${more} — configuration or code this step changed, kept because a person approved the step before it ran`;
|
|
68
|
+
}
|
|
69
|
+
export function refusedLine(refused) {
|
|
70
|
+
const shown = refused.slice(0, 20).map((r) => `${r.path} (${r.kind})`).join(", ");
|
|
71
|
+
const more = refused.length > 20 ? ` and ${refused.length - 20} more` : "";
|
|
72
|
+
return (`refused path: ${shown}${more} — a step cannot change the workspace's configuration or code; ` +
|
|
73
|
+
`the workspace keeps its own copy. Propose the change instead (Proposed fixes), and a person applies it.`);
|
|
74
|
+
}
|
package/dist/src/deploy.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type DeployFile } from "./store.ts";
|
|
2
|
+
import { SUBAGENT_IMPORT_DIRS } from "./kinds.ts";
|
|
2
3
|
import { type ScanFinding, type ScanOptions } from "./scan.ts";
|
|
3
4
|
/**
|
|
4
5
|
* Is this path, relative to a workspace root, one `readTree` would carry?
|
|
@@ -46,7 +47,7 @@ export interface DeployResult extends DeployPlan {
|
|
|
46
47
|
* skipped rather than rejected, because a workspace living in a repo alongside
|
|
47
48
|
* other things is the normal case, not an error.
|
|
48
49
|
*/
|
|
49
|
-
export
|
|
50
|
+
export { SUBAGENT_IMPORT_DIRS };
|
|
50
51
|
/** Bounds for a reader that only looks at the text (the checks, the
|
|
51
52
|
* assistant): on 20 Oct 2026 the daily rescan read one desk's 1.2 GB of
|
|
52
53
|
* state/ into a 2 GB heap and crash-looped the worker. A deploy sets none. */
|
package/dist/src/deploy.js
CHANGED
|
@@ -26,6 +26,7 @@ import { libraryTools } from "./library.js";
|
|
|
26
26
|
import { lintFlow, regexProblem } from "./flow-lint.js";
|
|
27
27
|
import { parseEval } from "./evals.js";
|
|
28
28
|
import { refNames } from "./refs.js";
|
|
29
|
+
import { SUBAGENT_IMPORT_DIRS } from "./kinds.js";
|
|
29
30
|
import matter, { configMatter } from "./matter.js";
|
|
30
31
|
import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.js";
|
|
31
32
|
import { conformanceIssues } from "./okf.js";
|
|
@@ -74,12 +75,9 @@ export function isSourcePath(rel) {
|
|
|
74
75
|
* skipped rather than rejected, because a workspace living in a repo alongside
|
|
75
76
|
* other things is the normal case, not an error.
|
|
76
77
|
*/
|
|
77
|
-
// Where single-file subagents authored by other tools are found
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
// head. Shared by the deploy import (readTree) and the CLI checker so the two
|
|
81
|
-
// agree on what counts as an agent.
|
|
82
|
-
export const SUBAGENT_IMPORT_DIRS = [".agents/agents", ".claude/agents"];
|
|
78
|
+
// Where single-file subagents authored by other tools are found — declared
|
|
79
|
+
// with the kinds (kinds.ts), which the copy-back rule reads too.
|
|
80
|
+
export { SUBAGENT_IMPORT_DIRS };
|
|
83
81
|
/** What agents write as they run, not what anyone authored: state/ and
|
|
84
82
|
* memory/, the workspace's or one agent's. */
|
|
85
83
|
const DATA_PATH = /^(?:agents\/[^/]+\/)?(?:state|memory)\//;
|
package/dist/src/install-tool.js
CHANGED
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
// pip and npm need no root and run here, as the agent, into a per-step
|
|
13
13
|
// directory already on PATH / PYTHONPATH / NODE_PATH (stepInstallEnv), so the
|
|
14
14
|
// next command finds what this one installed. Inside a run container all of
|
|
15
|
-
// it goes when the container does.
|
|
15
|
+
// it goes when the container does.
|
|
16
|
+
//
|
|
17
|
+
// No package runs its own code on the way in (FOL-98, 28 Oct 2026): npm
|
|
18
|
+
// installs with --ignore-scripts (no preinstall/postinstall), and pip takes
|
|
19
|
+
// built wheels only (--only-binary :all:) — an sdist's setup.py is code. A
|
|
20
|
+
// package that has no wheel says so, and `runtime: packages:` builds it
|
|
21
|
+
// before the step instead, where the build is declared, cached and seen. Every install is logged with the line to
|
|
16
22
|
// add to the agent's `runtime:` to have it before the step starts next time.
|
|
17
23
|
import fs from "node:fs";
|
|
18
24
|
import net from "node:net";
|
|
@@ -86,27 +92,30 @@ export function buildInstallTool(opts) {
|
|
|
86
92
|
fs.mkdirSync(target, { recursive: true });
|
|
87
93
|
const uv = findUv();
|
|
88
94
|
const res = uv
|
|
89
|
-
? spawnSync(uv, ["pip", "install", "-q", "--python", opts.python, "--target", target, ...names], {
|
|
95
|
+
? spawnSync(uv, ["pip", "install", "-q", "--only-binary", ":all:", "--python", opts.python, "--target", target, ...names], {
|
|
90
96
|
env: { ...opts.env, UV_CACHE_DIR: path.join(opts.cacheRoot, ".uv-cache"), UV_LINK_MODE: "copy", UV_NO_PROGRESS: "1" },
|
|
91
97
|
encoding: "utf8",
|
|
92
98
|
timeout: 600_000,
|
|
93
99
|
})
|
|
94
|
-
: spawnSync(opts.python, ["-m", "pip", "install", "-q", "--disable-pip-version-check", "--target", target, ...names], {
|
|
100
|
+
: spawnSync(opts.python, ["-m", "pip", "install", "-q", "--disable-pip-version-check", "--only-binary", ":all:", "--target", target, ...names], {
|
|
95
101
|
env: opts.env,
|
|
96
102
|
encoding: "utf8",
|
|
97
103
|
timeout: 600_000,
|
|
98
104
|
});
|
|
99
105
|
if (res.status !== 0) {
|
|
100
|
-
const
|
|
106
|
+
const raw = `${res.stdout ?? ""}${res.stderr ?? ""}${res.error ? res.error.message : ""}`;
|
|
107
|
+
const msg = summarizeInstallFailure(uv ? "uv pip install" : "pip install", raw);
|
|
101
108
|
opts.emit("error", `install: pip ${names.join(" ")} — ${msg.split("\n")[0]}`);
|
|
102
|
-
|
|
109
|
+
// Wheels only: one that exists only as source is not built here.
|
|
110
|
+
const noWheel = /no matching distribution|only-binary|no wheels? (?:with|for)|binary.*not available|doesn't have a wheel|has no wheel/i.test(raw);
|
|
111
|
+
return text(noWheel ? `${msg}\n\nOnly built wheels are installed mid-step — a source package's build runs its own code. Declare it in the agent's runtime: packages: [${names.join(", ")}] to have it built before the step.` : msg, true);
|
|
103
112
|
}
|
|
104
113
|
how = `pip, ${Math.round((Date.now() - t0) / 1000)}s`;
|
|
105
114
|
}
|
|
106
115
|
else {
|
|
107
116
|
const prefix = path.join(opts.dir, "npm");
|
|
108
117
|
fs.mkdirSync(prefix, { recursive: true });
|
|
109
|
-
const res = spawnSync("npm", ["install", "--no-fund", "--no-audit", "--no-progress", "--prefix", prefix, ...names], {
|
|
118
|
+
const res = spawnSync("npm", ["install", "--ignore-scripts", "--no-fund", "--no-audit", "--no-progress", "--prefix", prefix, ...names], {
|
|
110
119
|
env: { ...opts.env, npm_config_cache: path.join(opts.cacheRoot, ".npm-cache") },
|
|
111
120
|
encoding: "utf8",
|
|
112
121
|
timeout: 600_000,
|
package/dist/src/kinds.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/** Where a document can live. Nearest wins at read time. */
|
|
2
2
|
export type Scope = "account" | "workspace" | "agent";
|
|
3
3
|
export type Kind = "agents" | "flows" | "evals" | "tools" | "hooks" | "skills" | "memory" | "knowledge" | "scripts";
|
|
4
|
+
/** How a run's write to a kind's files is treated on the way back. */
|
|
5
|
+
export type CopyBackClass = "config" | "code" | "data";
|
|
6
|
+
/** Workspace files that are configuration without being a kind: the shared
|
|
7
|
+
* context and its pre-rename name. */
|
|
8
|
+
export declare const WORKSPACE_CONFIG_FILES: readonly ["AGENTS.md", "project.md"];
|
|
9
|
+
export declare const SUBAGENT_IMPORT_DIRS: readonly [".agents/agents", ".claude/agents"];
|
|
4
10
|
export interface KindMeta {
|
|
5
11
|
kind: Kind;
|
|
6
12
|
/** Singular noun, for buttons and prose. */
|
|
@@ -23,6 +29,14 @@ export interface KindMeta {
|
|
|
23
29
|
hint: string;
|
|
24
30
|
/** Path relative to the scope root. */
|
|
25
31
|
file: (name: string) => string;
|
|
32
|
+
/**
|
|
33
|
+
* What a run's sandbox writing one of these means (copy-back.ts):
|
|
34
|
+
* `config` grants or changes what runs and how, `code` is something a
|
|
35
|
+
* later step executes — neither is ever taken back from a step, which
|
|
36
|
+
* proposes the change instead — and `data` is what steps legitimately
|
|
37
|
+
* write. Required, so a new kind cannot become copy-back-able unnoticed.
|
|
38
|
+
*/
|
|
39
|
+
copyBack: CopyBackClass;
|
|
26
40
|
/** Starting content. */
|
|
27
41
|
template: (name: string, ctx?: TemplateContext) => string;
|
|
28
42
|
}
|
package/dist/src/kinds.js
CHANGED
|
@@ -35,6 +35,15 @@
|
|
|
35
35
|
// Everything downstream — creation templates, `foldrun init`, the scaffolder,
|
|
36
36
|
// the dashboard's New button — reads this table. Adding a kind means adding a
|
|
37
37
|
// row here and nothing else; that is the point.
|
|
38
|
+
/** Workspace files that are configuration without being a kind: the shared
|
|
39
|
+
* context and its pre-rename name. */
|
|
40
|
+
export const WORKSPACE_CONFIG_FILES = ["AGENTS.md", "project.md"];
|
|
41
|
+
// Where single-file subagents authored by other tools are found, in
|
|
42
|
+
// precedence order: the vendor-neutral cross-client location first, then any
|
|
43
|
+
// tool-specific one for pragmatic compatibility. Extend the tail, never the
|
|
44
|
+
// head. Shared by the deploy import (deploy.ts readTree), the CLI checker
|
|
45
|
+
// and the copy-back rule, so all three agree on what counts as an agent.
|
|
46
|
+
export const SUBAGENT_IMPORT_DIRS = [".agents/agents", ".claude/agents"];
|
|
38
47
|
const envName = (name) => name.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
|
|
39
48
|
/**
|
|
40
49
|
* A slug as a starting display label: `house-style` → `House style`.
|
|
@@ -50,6 +59,7 @@ const titleCase = (slug) => {
|
|
|
50
59
|
export const KINDS = {
|
|
51
60
|
agents: {
|
|
52
61
|
kind: "agents",
|
|
62
|
+
copyBack: "config",
|
|
53
63
|
one: "agent",
|
|
54
64
|
docType: null,
|
|
55
65
|
docKey: null,
|
|
@@ -73,6 +83,7 @@ this text is the whole of the agent's instructions.
|
|
|
73
83
|
},
|
|
74
84
|
evals: {
|
|
75
85
|
kind: "evals",
|
|
86
|
+
copyBack: "config",
|
|
76
87
|
one: "eval",
|
|
77
88
|
docType: null,
|
|
78
89
|
docKey: null,
|
|
@@ -95,6 +106,7 @@ expect:
|
|
|
95
106
|
},
|
|
96
107
|
flows: {
|
|
97
108
|
kind: "flows",
|
|
109
|
+
copyBack: "config",
|
|
98
110
|
one: "flow",
|
|
99
111
|
docType: null,
|
|
100
112
|
docKey: null,
|
|
@@ -113,6 +125,7 @@ trigger: manual
|
|
|
113
125
|
},
|
|
114
126
|
tools: {
|
|
115
127
|
kind: "tools",
|
|
128
|
+
copyBack: "config",
|
|
116
129
|
one: "tool",
|
|
117
130
|
docType: null,
|
|
118
131
|
docKey: null,
|
|
@@ -139,6 +152,7 @@ tools: [${name}]
|
|
|
139
152
|
},
|
|
140
153
|
hooks: {
|
|
141
154
|
kind: "hooks",
|
|
155
|
+
copyBack: "code",
|
|
142
156
|
one: "hook",
|
|
143
157
|
docType: null,
|
|
144
158
|
docKey: null,
|
|
@@ -169,6 +183,7 @@ it reads the event as JSON on stdin and prints one JSON answer —
|
|
|
169
183
|
// a decision, so the sidebar has no door for it.
|
|
170
184
|
knowledge: {
|
|
171
185
|
kind: "knowledge",
|
|
186
|
+
copyBack: "data",
|
|
172
187
|
one: "knowledge doc",
|
|
173
188
|
docType: "Reference",
|
|
174
189
|
// OKF's field and OKF's vocabulary — `Reference` is only the starting point.
|
|
@@ -190,6 +205,7 @@ The reference content. Agents read this and never rewrite it.
|
|
|
190
205
|
},
|
|
191
206
|
memory: {
|
|
192
207
|
kind: "memory",
|
|
208
|
+
copyBack: "data",
|
|
193
209
|
one: "memory",
|
|
194
210
|
docType: "Fact",
|
|
195
211
|
// OKF's field and OKF's vocabulary — `Fact` is only the starting point.
|
|
@@ -216,6 +232,7 @@ The fact.
|
|
|
216
232
|
},
|
|
217
233
|
skills: {
|
|
218
234
|
kind: "skills",
|
|
235
|
+
copyBack: "config",
|
|
219
236
|
one: "skill",
|
|
220
237
|
docType: null,
|
|
221
238
|
docKey: null,
|
|
@@ -235,6 +252,7 @@ Step-by-step instructions. Bundle code in this folder's scripts/ if it needs any
|
|
|
235
252
|
},
|
|
236
253
|
scripts: {
|
|
237
254
|
kind: "scripts",
|
|
255
|
+
copyBack: "code",
|
|
238
256
|
one: "script",
|
|
239
257
|
// Code, not a document — no frontmatter to carry either field. A tool
|
|
240
258
|
// file points at it.
|
|
@@ -3,6 +3,7 @@ import type { PeerInfo } from "./operator.ts";
|
|
|
3
3
|
import { type HookRecord } from "./hooks.ts";
|
|
4
4
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
5
5
|
import { type ApiSpec, type Effort, type ModelCall } from "./store.ts";
|
|
6
|
+
import { type RefusedPath } from "./copy-back.ts";
|
|
6
7
|
import type { CallIo, EventExtra, ExecUsage } from "./step-exec.ts";
|
|
7
8
|
import type { TokenPrice } from "./catalog.ts";
|
|
8
9
|
import { type RuntimeSpec } from "./runtime.ts";
|
|
@@ -19,6 +20,9 @@ import { type BrowserPodOutcome } from "./browser-pod.ts";
|
|
|
19
20
|
* secrets in headers, assembled prompt, serializable MCP configs. */
|
|
20
21
|
export interface ContainerStepInput {
|
|
21
22
|
agentRel: string;
|
|
23
|
+
/** A person approved this step before it ran: its file tools may write
|
|
24
|
+
* configuration and code inside the sandbox too (FOL-96). */
|
|
25
|
+
configWrites?: boolean;
|
|
22
26
|
prompt: string;
|
|
23
27
|
model: string;
|
|
24
28
|
effort?: Effort | null;
|
|
@@ -195,6 +199,9 @@ export interface ContainerStepOutcome {
|
|
|
195
199
|
* applyContainerChanges returned. An isolated sub-agent's reply names
|
|
196
200
|
* them to the step that delegated (delegate-tools.ts). */
|
|
197
201
|
applied?: string[];
|
|
202
|
+
/** What the step changed that was not taken back: configuration and code
|
|
203
|
+
* (copy-back.ts). On the step record and in the trail. */
|
|
204
|
+
refused?: RefusedPath[];
|
|
198
205
|
}
|
|
199
206
|
/**
|
|
200
207
|
* The one place the timing line is worded, so the docker and k8s traces
|
|
@@ -229,6 +236,20 @@ export declare function allowedBack(rel: string): boolean;
|
|
|
229
236
|
* step changed without the staging copy the first driver held in /tmp.
|
|
230
237
|
*/
|
|
231
238
|
export declare function hashTree(dir: string): Record<string, string>;
|
|
239
|
+
/**
|
|
240
|
+
* Where the host keeps what a sandbox was handed (hashTree), for a driver
|
|
241
|
+
* that inherits the step: under the run's own folder, which is platform-
|
|
242
|
+
* owned — never staged into a sandbox, never copied back, erased with the
|
|
243
|
+
* run (deleteRun). It used to ride into the pod beside the input and be
|
|
244
|
+
* read back from there, where the step could rewrite it (FOL-96).
|
|
245
|
+
*/
|
|
246
|
+
export declare function handedInPath(workspaceRoot: string, runId: string, ref: string): string | null;
|
|
247
|
+
/** Note what a sandbox was handed, on the host. */
|
|
248
|
+
export declare function writeHandedIn(workspaceRoot: string, runId: string, ref: string, hashes: Record<string, string>): void;
|
|
249
|
+
/** What the host noted a sandbox was handed, or undefined. */
|
|
250
|
+
export declare function readHandedIn(workspaceRoot: string, runId: string, ref: string): Record<string, string> | undefined;
|
|
251
|
+
/** Forget it once the step's copy-out is done. */
|
|
252
|
+
export declare function dropHandedIn(workspaceRoot: string, runId: string, ref: string): void;
|
|
232
253
|
/**
|
|
233
254
|
* Every file's mtime under `dir`, relative path to milliseconds — so a copy
|
|
234
255
|
* that cannot carry times can have them put back. `kubectl cp` unpacks with
|
|
@@ -249,7 +270,21 @@ export declare const APPLY_MTIMES_JS: string;
|
|
|
249
270
|
* real conflict, which the caller must not settle by dropping one side.
|
|
250
271
|
*/
|
|
251
272
|
export declare function mergeAppends(was: Buffer | string | null, host: Buffer, next: Buffer): Buffer | null;
|
|
252
|
-
export
|
|
273
|
+
export interface ApplyOptions {
|
|
274
|
+
/** What the host handed back into this sandbox mid-step — a `sandbox: own`
|
|
275
|
+
* job's files (delegate-tools.ts), path → sha256 — as the HOST recorded
|
|
276
|
+
* it (StepRecord.handedBack). The sandbox's own note of the same is only
|
|
277
|
+
* believed where it agrees with this: a step can write that note. */
|
|
278
|
+
handedBack?: Record<string, string | string[]>;
|
|
279
|
+
/** Told of every path the step changed that was not taken back because it
|
|
280
|
+
* is configuration or code (copy-back.ts). */
|
|
281
|
+
onRefused?: (refused: RefusedPath) => void;
|
|
282
|
+
/** A person approved the step before it ran: configuration and code it
|
|
283
|
+
* changed come back, each told to onKept instead of refused. */
|
|
284
|
+
allowConfig?: boolean;
|
|
285
|
+
onKept?: (kept: RefusedPath) => void;
|
|
286
|
+
}
|
|
287
|
+
export declare function applyContainerChanges(hostWs: string, containerWs: string, baseline?: string | Record<string, string>, note?: (message: string) => void, divert?: WriteDivert, opts?: ApplyOptions): string[];
|
|
253
288
|
/**
|
|
254
289
|
* Where the driver builds venvs and npm prefixes, as an absolute path *inside*
|
|
255
290
|
* the runner. Fixed, and it has to be: a venv bakes its own absolute path into
|
|
@@ -304,7 +339,7 @@ export declare function runtimeCacheMount(tenant: string | undefined): {
|
|
|
304
339
|
target: string;
|
|
305
340
|
} | null;
|
|
306
341
|
/** The runner container's entrypoint, as JS text: written into the image as driver.mjs, so it must be plain JS — a TypeScript cast here broke every step on v2026.10.21.5 (tests/run-container.test.ts checks it). */
|
|
307
|
-
export declare const DRIVER = "// The runner container's entrypoint. Reads one step, executes it with the\n// same core the server uses, speaks JSONL on stdout, exits.\nimport fs from \"node:fs\";\nconst input = JSON.parse(fs.readFileSync(\"/opt/runner/job/input.json\", \"utf8\"));\n// Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,\n// on the sandbox's clock. A worker that does not know a mark skips the line.\nconst mark = (name) => process.stdout.write(JSON.stringify({ e: \"mark\", name, at: Date.now() }) + \"\\n\");\nmark(\"driver\");\nlet modelSpoke = false;\nconst emit = (type, text, extra) => {\n // The model's first text or tool call is where start-up ends and the\n // step's own work begins \u2014 runtime and tool lines before it are set-up.\n if (!modelSpoke && (type === \"text\" || type === \"tool\")) { modelSpoke = true; mark(\"model\"); }\n process.stdout.write(JSON.stringify({ e: \"event\", type, text, ...extra }) + \"\\n\");\n};\n// What this sandbox actually touched, read at the last moment from the\n// kernel's own books: cgroup v2 for CPU busy-time and peak memory,\n// /proc/net/dev for bytes on the wire. Every read is best-effort \u2014 gVisor\n// exposes some of these files and not others depending on version, and a\n// metric we cannot read is null, never guessed. Reservation is what is\n// billed; these exist so the reservation can be right-sized.\nfunction readActuals() {\n // fs from the module's own import \u2014 the driver is ESM, and a require()\n // here threw on every call, silently nulling every metric it guarded.\n const read = (p) => { try { return fs.readFileSync(p, \"utf8\"); } catch { return null; } };\n // Two cgroup dialects: v2 (cpu.stat, microseconds; memory.peak) on a\n // plain runtime, v1-style controllers under gVisor (cpuacct.usage,\n // NANOseconds; memory.max_usage_in_bytes) \u2014 probed on the real cluster,\n // not assumed. Whichever answers wins; both silent means null.\n const cpuStat = read(\"/sys/fs/cgroup/cpu.stat\");\n const usage = cpuStat?.match(/^usage_usec (\\d+)/m);\n const cpuacctNs = usage ? NaN : Number(read(\"/sys/fs/cgroup/cpuacct/cpuacct.usage\")?.trim());\n // Time the CPU cap held the sandbox back: v2's throttled_usec in the same\n // cpu.stat, v1's throttled_time (nanoseconds) under gVisor. Large against\n // busy time means the step wanted more cores than its class gives.\n const throttledUs = cpuStat?.match(/^throttled_usec (\\d+)/m);\n const throttledNs = throttledUs ? null : read(\"/sys/fs/cgroup/cpu/cpu.stat\")?.match(/^throttled_time (\\d+)/m);\n const peak =\n read(\"/sys/fs/cgroup/memory.peak\") ??\n read(\"/sys/fs/cgroup/memory/memory.max_usage_in_bytes\") ??\n read(\"/sys/fs/cgroup/memory/memory.usage_in_bytes\");\n let rx = 0, tx = 0, sawNet = false;\n const net = read(\"/proc/net/dev\");\n if (net) {\n for (const line of net.split(\"\\n\").slice(2)) {\n const m = line.trim().match(/^(\\S+):\\s+(\\d+)(?:\\s+\\d+){7}\\s+(\\d+)/);\n if (m && m[1] !== \"lo\") { rx += Number(m[2]); tx += Number(m[3]); sawNet = true; }\n }\n }\n return {\n busyCpuSecs: usage\n ? Number(usage[1]) / 1e6\n : Number.isFinite(cpuacctNs) && cpuacctNs > 0\n ? cpuacctNs / 1e9\n : null,\n peakMemBytes: peak ? Number(peak.trim()) || null : null,\n throttledSecs: throttledUs ? Number(throttledUs[1]) / 1e6 : throttledNs ? Number(throttledNs[1]) / 1e9 : null,\n rxBytes: sawNet ? rx : null,\n txBytes: sawNet ? tx : null,\n };\n}\n\ntry {\n // Runtime state (venvs, npm prefixes) lands in the agent user's real home\n // \u2014 writable, inside the container, gone with it.\n process.env.FOLDRUN_DATA = \"/home/agent/.foldrun\";\n const { executeStep } = await import(\"@foldrun/core/step-exec\");\n const { buildApiTools } = await import(\"@foldrun/core/api-tools\");\n const { buildScriptTools } = await import(\"@foldrun/core/script-tools\");\n const { buildConsultTools } = await import(\"@foldrun/core/agent-tools\");\n const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import(\"@foldrun/core/context-tools\");\n const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import(\"@foldrun/core/operator\");\n const { buildDelegateTools } = await import(\"@foldrun/core/delegate-tools\");\n const { startTranslator } = await import(\"@foldrun/core/translator\");\n const { prepareRuntime } = await import(\"@foldrun/core/runtime\");\n const { materializeFileSecrets } = await import(\"@foldrun/core/secret-files\");\n const { paidWebActions } = await import(\"@foldrun/core/browser-pod\");\n mark(\"imports\");\n\n const agentDir = \"/workspace/\" + input.agentRel;\n // HOME is where a bare relative path lands when anything resolves one\n // outside cwd \u2014 and root's /root is outside the workspace, so such a read\n // was refused by confinement with a path the agent never typed. Its own\n // directory is both the truthful answer and the safe one.\n // @file secrets crossed as content; materialise them to 0600 paths inside\n // the container (a host path from the other side would not exist here).\n const env = materializeFileSecrets(agentDir, { ...process.env, HOME: agentDir }).env;\n // Caches go to the sandbox user's own home, not to HOME: the Claude CLI's\n // logs, fontconfig, Mesa and gstreamer all follow XDG_CACHE_HOME, and under\n // agents/<name>/.cache they were copied back after every step \u2014 3,855 files\n // across 179 agent folders on the box by 17 Oct 2026, read by nothing.\n env.XDG_CACHE_HOME = \"/home/agent/.cache\";\n\n // The declared runtime, built in here \u2014 pip and npm have the network, which\n // the host side may not. It lands in .foldrun/runner/.runtimes, which the\n // executors mount from a per-tenant directory (run-container.ts#RUNTIME_CACHE):\n // the first step for a given fingerprint installs, every later one finds\n // .ready and skips. With no mount it still works, just fresh every time \u2014\n // which is what this used to be unconditionally. The log says which happened.\n const runtime = input.runtime\n ? prepareRuntime(\"runner\", input.runtime)\n : { interpreters: {}, env: {}, log: [], error: null };\n for (const line of runtime.log) emit(\"info\", \"runtime: \" + line);\n if (runtime.error) emit(\"error\", \"runtime: \" + runtime.error);\n // The step's own environment gets it too, not only the script tools: an\n // agent that runs \"python3 x.py\" or a shebang script through Bash should\n // meet the packages its runtime declared, not the image's bare python.\n Object.assign(env, runtime.env);\n\n // install: what the agent finds it needs mid-step. apt goes through the\n // root start-up's helper (install-helper.ts); pip and npm land in a\n // directory of this container's own, on the paths from the first turn.\n const { buildInstallTool, INSTALL_TOOL } = await import(\"@foldrun/core/install-tool\");\n const { stepInstallEnv, INSTALL_SOCKET } = await import(\"@foldrun/core/runtime\");\n const installDir = \"/home/agent/.foldrun/step\";\n const installing = (input.allowed ?? []).includes(INSTALL_TOOL);\n if (installing) Object.assign(env, stepInstallEnv(installDir, env));\n const install = installing\n ? buildInstallTool({\n dir: installDir,\n socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,\n cacheRoot: \"/home/agent/.foldrun/runner/.runtimes\",\n python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + \"/bin/python\" : \"python3\",\n env,\n emit,\n })\n : { server: null };\n\n // API and script tools, rebuilt in here from their specs. Secrets were\n // substituted into API headers before the input crossed the boundary, so\n // buildApiTools gets an already-resolved environment. verify: gets the\n // container's own environment, whole: it is the boundary, assembled on\n // the host from an allowlist, and the declared secrets are in it.\n // A long result is kept in a file only when the agent can read it back.\n // (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)\n const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === \"Read\" || t === \"Grep\") };\n // A declared secret named like a model variable crosses as\n // FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own\n // settings, and the agent's API and script tools and verify: get the value\n // back under the name it declared. Built here, after the runtime and any\n // mid-step install have added their paths to env.\n const toolEnv = { ...env };\n for (const [k, v] of Object.entries(env)) if (k.startsWith(\"FOLDRUN_DECLARED_\")) toolEnv[k.slice(\"FOLDRUN_DECLARED_\".length)] = v;\n const api = buildApiTools(\"\", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);\n const script = buildScriptTools(\n agentDir,\n input.scripts,\n toolEnv,\n \"/library/scripts\",\n runtime.interpreters,\n null,\n keepIn.readable,\n );\n // The translator, when the provider speaks Chat Completions: a loopback\n // server for the length of this step. Its env replaces the endpoint the\n // host assembled; the provider's real URL and key stay inside it.\n const translator = input.translator ? await startTranslator(input.translator) : null;\n if (translator) {\n Object.assign(env, translator.env);\n emit(\"info\", \"translator: listening on \" + translator.baseUrl + \" for \" + (input.translator.label ?? input.translator.upstreamBase));\n }\n const consult = buildConsultTools(input.consults ?? [], env, emit);\n // The platform's own two groups, rebuilt from values like everything else.\n const search = buildSearchTools(input.search ?? []);\n const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };\n const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };\n // A person in the loop, over the proxy this step's lease already names:\n // ask_person when tools: [ask], and the run's inbox after every tool call.\n const channel = httpChannel(env.FOLDRUN_EGRESS);\n const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };\n const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };\n const inbox = channel ? inboxReader(channel, emit) : undefined;\n // sandbox: own sub-agents: a tool each, whose job runs in its own sandbox\n // on the worker, reached over the same proxy door.\n const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec, workspaceRoot: \"/workspace\" }) : { server: null };\n\n // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept\n // fresh through the proxy's /identity door, which mints only what this\n // step's lease was granted. Fetched before the step starts, renewed five\n // minutes before it expires (with jitter); a failure is said and retried.\n // Plain JS: this text is the driver, not TypeScript.\n const identityNames = (env.FOLDRUN_IDENTITIES || \"\").split(\",\").filter(Boolean);\n if (identityNames.length) {\n // No backslashes: this sits inside a template literal, which eats them.\n const ep = new RegExp(\"^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$\").exec(env.FOLDRUN_EGRESS || \"\");\n const dir = env.FOLDRUN_IDENTITY_DIR || \"/tmp/foldrun-identity\";\n fs.mkdirSync(dir, { recursive: true, mode: 0o700 });\n const keepFresh = async (name) => {\n let wait = 30000;\n try {\n if (!ep) throw new Error(\"no egress proxy to ask\");\n const res = await fetch(ep[1] + \"/identity/\" + ep[2] + \"/\" + name, { signal: AbortSignal.timeout(15000) });\n const body = await res.json().catch(() => ({}));\n if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || (\"proxy answered \" + res.status));\n const file = dir + \"/\" + name.toLowerCase() + \".jwt\";\n fs.writeFileSync(file + \".tmp\", body.token, { mode: 0o600 });\n fs.renameSync(file + \".tmp\", file);\n wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);\n } catch (err) {\n emit(\"error\", \"identity \" + name + \": \" + (err && err.message ? err.message : String(err)) + \" \u2014 trying again in 30s\");\n }\n setTimeout(() => { void keepFresh(name); }, wait).unref();\n };\n await Promise.all(identityNames.map(keepFresh));\n }\n\n mark(\"tools\");\n const outcome = await executeStep({\n agentDir,\n workspaceRoot: \"/workspace\",\n libraryRoot: \"/library\",\n prompt: input.prompt,\n model: input.model,\n effort: input.effort ?? null,\n systemPrompt: input.systemPrompt,\n allowed: input.allowed,\n mcpNames: input.mcpNames,\n mcpServers: {\n ...(api.server ? { foldrun_apis: api.server } : {}),\n ...(script.server ? { foldrun_scripts: script.server } : {}),\n ...(consult.server ? { foldrun_agents: consult.server } : {}),\n ...(search.server ? { foldrun_search: search.server } : {}),\n ...(history.server ? { foldrun_history: history.server } : {}),\n ...(desks.server ? { foldrun_desks: desks.server } : {}),\n ...(ask.server ? { foldrun_ask: ask.server } : {}),\n ...(peers.server ? { foldrun_peers: peers.server } : {}),\n ...(delegate.server ? { foldrun_delegate: delegate.server } : {}),\n ...(install.server ? { foldrun_install: install.server } : {}),\n ...input.mcpServers,\n },\n ...(inbox ? { inbox } : {}),\n ...(input.subagents?.length ? { subagents: input.subagents } : {}),\n env,\n timeoutSec: input.timeoutSec,\n budgetUsd: input.budgetUsd,\n budgetNote: input.budgetNote,\n price: input.price,\n verify: input.verify,\n verifyEnv: toolEnv,\n ...(typeof input.judgeModel === \"string\" ? { judgeModel: input.judgeModel } : {}),\n ...(typeof input.judgeEffort === \"string\" ? { judgeEffort: input.judgeEffort } : {}),\n output: input.output,\n schema: input.schema,\n maxTurns: input.maxTurns,\n ...(input.capture === \"metadata\" || input.capture === \"full\" ? { capture: input.capture } : {}),\n ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),\n ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),\n // Slim browsing: the platform put this step on the slim image to browse\n // through the account's pod, and named the file the web tool logs its\n // reconnects to. A lost pod stops the step; what it wrote decides what\n // the runner does next (browser-pod.ts).\n ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env), ...(env.FOLDRUN_BROWSER_INIT ? { browseInit: env.FOLDRUN_BROWSER_INIT } : {}) } } : {}),\n // The container is the boundary; the SDK's own bash sandbox here would\n // only block declared network use (SSH, curl) for no added safety.\n sandboxBash: false,\n emit,\n });\n for (const line of api.drainLog()) emit(\"info\", \"api: \" + line);\n for (const line of script.drainLog()) emit(\"info\", \"script: \" + line);\n if (translator) {\n for (const line of translator.drainLog()) emit(\"info\", line.startsWith(\"translator\") ? line : \"translator: \" + line);\n await translator.close();\n }\n // A consult's spend belongs to the step that asked.\n const consultCost = consult.drainCost();\n if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;\n if (consultCost > 0 && typeof outcome.turnsCostUsd === \"number\") outcome.turnsCostUsd += consultCost;\n process.stdout.write(JSON.stringify({ e: \"done\", ...outcome, res: readActuals() }) + \"\\n\");\n process.exit(0);\n} catch (err) {\n emit(\"error\", err instanceof Error ? err.message : String(err));\n // The failure path reports actuals too \u2014 a step that died eleven minutes\n // in still held its slice for eleven minutes, and the failed runs are\n // exactly the ones whose resource story gets read.\n let res = null;\n try { res = readActuals(); } catch { /* the reading must never mask the error */ }\n process.stdout.write(JSON.stringify({ e: \"done\", status: \"failed\", result: null, costUsd: null, res }) + \"\\n\");\n process.exit(0);\n}\n";
|
|
342
|
+
export declare const DRIVER = "// The runner container's entrypoint. Reads one step, executes it with the\n// same core the server uses, speaks JSONL on stdout, exits.\nimport fs from \"node:fs\";\nconst input = JSON.parse(fs.readFileSync(\"/opt/runner/job/input.json\", \"utf8\"));\n// Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,\n// on the sandbox's clock. A worker that does not know a mark skips the line.\nconst mark = (name) => process.stdout.write(JSON.stringify({ e: \"mark\", name, at: Date.now() }) + \"\\n\");\nmark(\"driver\");\nlet modelSpoke = false;\nconst emit = (type, text, extra) => {\n // The model's first text or tool call is where start-up ends and the\n // step's own work begins \u2014 runtime and tool lines before it are set-up.\n if (!modelSpoke && (type === \"text\" || type === \"tool\")) { modelSpoke = true; mark(\"model\"); }\n process.stdout.write(JSON.stringify({ e: \"event\", type, text, ...extra }) + \"\\n\");\n};\n// What this sandbox actually touched, read at the last moment from the\n// kernel's own books: cgroup v2 for CPU busy-time and peak memory,\n// /proc/net/dev for bytes on the wire. Every read is best-effort \u2014 gVisor\n// exposes some of these files and not others depending on version, and a\n// metric we cannot read is null, never guessed. Reservation is what is\n// billed; these exist so the reservation can be right-sized.\nfunction readActuals() {\n // fs from the module's own import \u2014 the driver is ESM, and a require()\n // here threw on every call, silently nulling every metric it guarded.\n const read = (p) => { try { return fs.readFileSync(p, \"utf8\"); } catch { return null; } };\n // Two cgroup dialects: v2 (cpu.stat, microseconds; memory.peak) on a\n // plain runtime, v1-style controllers under gVisor (cpuacct.usage,\n // NANOseconds; memory.max_usage_in_bytes) \u2014 probed on the real cluster,\n // not assumed. Whichever answers wins; both silent means null.\n const cpuStat = read(\"/sys/fs/cgroup/cpu.stat\");\n const usage = cpuStat?.match(/^usage_usec (\\d+)/m);\n const cpuacctNs = usage ? NaN : Number(read(\"/sys/fs/cgroup/cpuacct/cpuacct.usage\")?.trim());\n // Time the CPU cap held the sandbox back: v2's throttled_usec in the same\n // cpu.stat, v1's throttled_time (nanoseconds) under gVisor. Large against\n // busy time means the step wanted more cores than its class gives.\n const throttledUs = cpuStat?.match(/^throttled_usec (\\d+)/m);\n const throttledNs = throttledUs ? null : read(\"/sys/fs/cgroup/cpu/cpu.stat\")?.match(/^throttled_time (\\d+)/m);\n const peak =\n read(\"/sys/fs/cgroup/memory.peak\") ??\n read(\"/sys/fs/cgroup/memory/memory.max_usage_in_bytes\") ??\n read(\"/sys/fs/cgroup/memory/memory.usage_in_bytes\");\n let rx = 0, tx = 0, sawNet = false;\n const net = read(\"/proc/net/dev\");\n if (net) {\n for (const line of net.split(\"\\n\").slice(2)) {\n const m = line.trim().match(/^(\\S+):\\s+(\\d+)(?:\\s+\\d+){7}\\s+(\\d+)/);\n if (m && m[1] !== \"lo\") { rx += Number(m[2]); tx += Number(m[3]); sawNet = true; }\n }\n }\n return {\n busyCpuSecs: usage\n ? Number(usage[1]) / 1e6\n : Number.isFinite(cpuacctNs) && cpuacctNs > 0\n ? cpuacctNs / 1e9\n : null,\n peakMemBytes: peak ? Number(peak.trim()) || null : null,\n throttledSecs: throttledUs ? Number(throttledUs[1]) / 1e6 : throttledNs ? Number(throttledNs[1]) / 1e9 : null,\n rxBytes: sawNet ? rx : null,\n txBytes: sawNet ? tx : null,\n };\n}\n\ntry {\n // Runtime state (venvs, npm prefixes) lands in the agent user's real home\n // \u2014 writable, inside the container, gone with it.\n process.env.FOLDRUN_DATA = \"/home/agent/.foldrun\";\n const { executeStep } = await import(\"@foldrun/core/step-exec\");\n const { buildApiTools } = await import(\"@foldrun/core/api-tools\");\n const { buildScriptTools } = await import(\"@foldrun/core/script-tools\");\n const { buildConsultTools } = await import(\"@foldrun/core/agent-tools\");\n const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import(\"@foldrun/core/context-tools\");\n const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import(\"@foldrun/core/operator\");\n const { buildDelegateTools } = await import(\"@foldrun/core/delegate-tools\");\n const { startTranslator } = await import(\"@foldrun/core/translator\");\n const { prepareRuntime } = await import(\"@foldrun/core/runtime\");\n const { materializeFileSecrets } = await import(\"@foldrun/core/secret-files\");\n const { paidWebActions } = await import(\"@foldrun/core/browser-pod\");\n mark(\"imports\");\n\n const agentDir = \"/workspace/\" + input.agentRel;\n // HOME is where a bare relative path lands when anything resolves one\n // outside cwd \u2014 and root's /root is outside the workspace, so such a read\n // was refused by confinement with a path the agent never typed. Its own\n // directory is both the truthful answer and the safe one.\n // @file secrets crossed as content; materialise them to 0600 paths inside\n // the container (a host path from the other side would not exist here).\n const env = materializeFileSecrets(agentDir, { ...process.env, HOME: agentDir }).env;\n // Caches go to the sandbox user's own home, not to HOME: the Claude CLI's\n // logs, fontconfig, Mesa and gstreamer all follow XDG_CACHE_HOME, and under\n // agents/<name>/.cache they were copied back after every step \u2014 3,855 files\n // across 179 agent folders on the box by 17 Oct 2026, read by nothing.\n env.XDG_CACHE_HOME = \"/home/agent/.cache\";\n\n // The declared runtime, built in here \u2014 pip and npm have the network, which\n // the host side may not. It lands in .foldrun/runner/.runtimes, which the\n // executors mount from a per-tenant directory (run-container.ts#RUNTIME_CACHE):\n // the first step for a given fingerprint installs, every later one finds\n // .ready and skips. With no mount it still works, just fresh every time \u2014\n // which is what this used to be unconditionally. The log says which happened.\n const runtime = input.runtime\n ? prepareRuntime(\"runner\", input.runtime)\n : { interpreters: {}, env: {}, log: [], error: null };\n for (const line of runtime.log) emit(\"info\", \"runtime: \" + line);\n if (runtime.error) emit(\"error\", \"runtime: \" + runtime.error);\n // The step's own environment gets it too, not only the script tools: an\n // agent that runs \"python3 x.py\" or a shebang script through Bash should\n // meet the packages its runtime declared, not the image's bare python.\n Object.assign(env, runtime.env);\n\n // install: what the agent finds it needs mid-step. apt goes through the\n // root start-up's helper (install-helper.ts); pip and npm land in a\n // directory of this container's own, on the paths from the first turn.\n const { buildInstallTool, INSTALL_TOOL } = await import(\"@foldrun/core/install-tool\");\n const { stepInstallEnv, INSTALL_SOCKET } = await import(\"@foldrun/core/runtime\");\n const installDir = \"/home/agent/.foldrun/step\";\n const installing = (input.allowed ?? []).includes(INSTALL_TOOL);\n if (installing) Object.assign(env, stepInstallEnv(installDir, env));\n const install = installing\n ? buildInstallTool({\n dir: installDir,\n socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,\n cacheRoot: \"/home/agent/.foldrun/runner/.runtimes\",\n python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + \"/bin/python\" : \"python3\",\n env,\n emit,\n })\n : { server: null };\n\n // API and script tools, rebuilt in here from their specs. Secrets were\n // substituted into API headers before the input crossed the boundary, so\n // buildApiTools gets an already-resolved environment. verify: gets the\n // container's own environment, whole: it is the boundary, assembled on\n // the host from an allowlist, and the declared secrets are in it.\n // A long result is kept in a file only when the agent can read it back.\n // (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)\n const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === \"Read\" || t === \"Grep\") };\n // A declared secret named like a model variable crosses as\n // FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own\n // settings, and the agent's API and script tools and verify: get the value\n // back under the name it declared. Built here, after the runtime and any\n // mid-step install have added their paths to env.\n const toolEnv = { ...env };\n for (const [k, v] of Object.entries(env)) if (k.startsWith(\"FOLDRUN_DECLARED_\")) toolEnv[k.slice(\"FOLDRUN_DECLARED_\".length)] = v;\n const api = buildApiTools(\"\", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);\n const script = buildScriptTools(\n agentDir,\n input.scripts,\n toolEnv,\n \"/library/scripts\",\n runtime.interpreters,\n null,\n keepIn.readable,\n );\n // The translator, when the provider speaks Chat Completions: a loopback\n // server for the length of this step. Its env replaces the endpoint the\n // host assembled; the provider's real URL and key stay inside it.\n const translator = input.translator ? await startTranslator(input.translator) : null;\n if (translator) {\n Object.assign(env, translator.env);\n emit(\"info\", \"translator: listening on \" + translator.baseUrl + \" for \" + (input.translator.label ?? input.translator.upstreamBase));\n }\n const consult = buildConsultTools(input.consults ?? [], env, emit);\n // The platform's own two groups, rebuilt from values like everything else.\n const search = buildSearchTools(input.search ?? []);\n const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };\n const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };\n // A person in the loop, over the proxy this step's lease already names:\n // ask_person when tools: [ask], and the run's inbox after every tool call.\n const channel = httpChannel(env.FOLDRUN_EGRESS);\n const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };\n const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };\n const inbox = channel ? inboxReader(channel, emit) : undefined;\n // sandbox: own sub-agents: a tool each, whose job runs in its own sandbox\n // on the worker, reached over the same proxy door.\n const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec, workspaceRoot: \"/workspace\" }) : { server: null };\n\n // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept\n // fresh through the proxy's /identity door, which mints only what this\n // step's lease was granted. Fetched before the step starts, renewed five\n // minutes before it expires (with jitter); a failure is said and retried.\n // Plain JS: this text is the driver, not TypeScript.\n const identityNames = (env.FOLDRUN_IDENTITIES || \"\").split(\",\").filter(Boolean);\n if (identityNames.length) {\n // No backslashes: this sits inside a template literal, which eats them.\n const ep = new RegExp(\"^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$\").exec(env.FOLDRUN_EGRESS || \"\");\n const dir = env.FOLDRUN_IDENTITY_DIR || \"/tmp/foldrun-identity\";\n fs.mkdirSync(dir, { recursive: true, mode: 0o700 });\n const keepFresh = async (name) => {\n let wait = 30000;\n try {\n if (!ep) throw new Error(\"no egress proxy to ask\");\n const res = await fetch(ep[1] + \"/identity/\" + ep[2] + \"/\" + name, { signal: AbortSignal.timeout(15000) });\n const body = await res.json().catch(() => ({}));\n if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || (\"proxy answered \" + res.status));\n const file = dir + \"/\" + name.toLowerCase() + \".jwt\";\n fs.writeFileSync(file + \".tmp\", body.token, { mode: 0o600 });\n fs.renameSync(file + \".tmp\", file);\n wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);\n } catch (err) {\n emit(\"error\", \"identity \" + name + \": \" + (err && err.message ? err.message : String(err)) + \" \u2014 trying again in 30s\");\n }\n setTimeout(() => { void keepFresh(name); }, wait).unref();\n };\n await Promise.all(identityNames.map(keepFresh));\n }\n\n mark(\"tools\");\n const outcome = await executeStep({\n agentDir,\n workspaceRoot: \"/workspace\",\n libraryRoot: \"/library\",\n configWrites: input.configWrites === true,\n prompt: input.prompt,\n model: input.model,\n effort: input.effort ?? null,\n systemPrompt: input.systemPrompt,\n allowed: input.allowed,\n mcpNames: input.mcpNames,\n mcpServers: {\n ...(api.server ? { foldrun_apis: api.server } : {}),\n ...(script.server ? { foldrun_scripts: script.server } : {}),\n ...(consult.server ? { foldrun_agents: consult.server } : {}),\n ...(search.server ? { foldrun_search: search.server } : {}),\n ...(history.server ? { foldrun_history: history.server } : {}),\n ...(desks.server ? { foldrun_desks: desks.server } : {}),\n ...(ask.server ? { foldrun_ask: ask.server } : {}),\n ...(peers.server ? { foldrun_peers: peers.server } : {}),\n ...(delegate.server ? { foldrun_delegate: delegate.server } : {}),\n ...(install.server ? { foldrun_install: install.server } : {}),\n ...input.mcpServers,\n },\n ...(inbox ? { inbox } : {}),\n ...(input.subagents?.length ? { subagents: input.subagents } : {}),\n env,\n timeoutSec: input.timeoutSec,\n budgetUsd: input.budgetUsd,\n budgetNote: input.budgetNote,\n price: input.price,\n verify: input.verify,\n verifyEnv: toolEnv,\n ...(typeof input.judgeModel === \"string\" ? { judgeModel: input.judgeModel } : {}),\n ...(typeof input.judgeEffort === \"string\" ? { judgeEffort: input.judgeEffort } : {}),\n output: input.output,\n schema: input.schema,\n maxTurns: input.maxTurns,\n ...(input.capture === \"metadata\" || input.capture === \"full\" ? { capture: input.capture } : {}),\n ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),\n ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),\n // Slim browsing: the platform put this step on the slim image to browse\n // through the account's pod, and named the file the web tool logs its\n // reconnects to. A lost pod stops the step; what it wrote decides what\n // the runner does next (browser-pod.ts).\n ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env), ...(env.FOLDRUN_BROWSER_INIT ? { browseInit: env.FOLDRUN_BROWSER_INIT } : {}) } } : {}),\n // The container is the boundary; the SDK's own bash sandbox here would\n // only block declared network use (SSH, curl) for no added safety.\n sandboxBash: false,\n emit,\n });\n for (const line of api.drainLog()) emit(\"info\", \"api: \" + line);\n for (const line of script.drainLog()) emit(\"info\", \"script: \" + line);\n if (translator) {\n for (const line of translator.drainLog()) emit(\"info\", line.startsWith(\"translator\") ? line : \"translator: \" + line);\n await translator.close();\n }\n // A consult's spend belongs to the step that asked.\n const consultCost = consult.drainCost();\n if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;\n if (consultCost > 0 && typeof outcome.turnsCostUsd === \"number\") outcome.turnsCostUsd += consultCost;\n process.stdout.write(JSON.stringify({ e: \"done\", ...outcome, res: readActuals() }) + \"\\n\");\n process.exit(0);\n} catch (err) {\n emit(\"error\", err instanceof Error ? err.message : String(err));\n // The failure path reports actuals too \u2014 a step that died eleven minutes\n // in still held its slice for eleven minutes, and the failed runs are\n // exactly the ones whose resource story gets read.\n let res = null;\n try { res = readActuals(); } catch { /* the reading must never mask the error */ }\n process.stdout.write(JSON.stringify({ e: \"done\", status: \"failed\", result: null, costUsd: null, res }) + \"\\n\");\n process.exit(0);\n}\n";
|
|
308
343
|
/** The uv release copied into the image. Pinned: a floating uv is a floating
|
|
309
344
|
* resolver, and the runtime cache is keyed on the declaration, not on it. */
|
|
310
345
|
export declare const UV_VERSION = "0.11.25";
|
|
@@ -437,6 +472,9 @@ export declare function dockerCpus(cpus: string, available?: number): string | n
|
|
|
437
472
|
export declare function heldCores(cpus: string, isolation: string | undefined, available?: number): number;
|
|
438
473
|
export interface RunInContainerArgs {
|
|
439
474
|
workspaceRoot: string;
|
|
475
|
+
/** A person approved this step before it ran: configuration and code it
|
|
476
|
+
* changed are kept, not refused (copy-back.ts, FOL-96). */
|
|
477
|
+
configWrites?: boolean;
|
|
440
478
|
libraryRoot: string;
|
|
441
479
|
/** The platform's gallery, staged under /library BEFORE the account library so the account's copies win. */
|
|
442
480
|
galleryRoot?: string;
|
|
@@ -495,6 +533,10 @@ export interface RunInContainerArgs {
|
|
|
495
533
|
/** A test run: where state/ and storage/ changes go instead of the
|
|
496
534
|
* workspace, and who to tell about each. See test-mode.ts. */
|
|
497
535
|
divert?: WriteDivert;
|
|
536
|
+
/** What the host handed back into this sandbox mid-step (StepRecord.
|
|
537
|
+
* handedBack), read at copy-out — kept on the host, never read back from
|
|
538
|
+
* the sandbox alone (FOL-96). */
|
|
539
|
+
handedBack?: () => Record<string, string | string[]> | undefined;
|
|
498
540
|
/** The run is a test run. An isolated executor labels the sandbox so the
|
|
499
541
|
* cluster can cut its network down to the egress proxy: a script tool
|
|
500
542
|
* calls the network directly, not through the proxy where the test-mode
|