@foldrun/core 2.6.0 → 2.8.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/capture.js +39 -1
- package/dist/src/delegate-tools.d.ts +45 -0
- package/dist/src/delegate-tools.js +153 -0
- package/dist/src/egress.d.ts +43 -2
- package/dist/src/frontmatter-fields.js +2 -1
- package/dist/src/oauth-presets.d.ts +7 -0
- package/dist/src/oauth-presets.js +7 -0
- package/dist/src/operator.d.ts +19 -0
- package/dist/src/operator.js +29 -0
- package/dist/src/platform.d.ts +8 -0
- package/dist/src/run-container.d.ts +9 -2
- package/dist/src/run-container.js +38 -2
- package/dist/src/runner.d.ts +21 -1
- package/dist/src/runner.js +362 -14
- package/dist/src/scan.js +17 -1
- package/dist/src/secret-files.js +23 -2
- package/dist/src/secrets.d.ts +62 -2
- package/dist/src/secrets.js +206 -20
- package/dist/src/starter.js +1 -0
- package/dist/src/store.d.ts +34 -0
- package/dist/src/store.js +12 -0
- package/dist/src/subagents.d.ts +14 -0
- package/dist/src/subagents.js +8 -0
- package/package.json +1 -1
- package/src/capture.ts +33 -1
- package/src/delegate-tools.ts +175 -0
- package/src/egress.ts +38 -3
- package/src/frontmatter-fields.ts +2 -1
- package/src/oauth-presets.ts +7 -0
- package/src/operator.ts +52 -0
- package/src/platform.ts +5 -0
- package/src/run-container.ts +46 -3
- package/src/runner.ts +362 -12
- package/src/scan.ts +19 -1
- package/src/secret-files.ts +22 -2
- package/src/secrets.ts +237 -23
- package/src/starter.ts +1 -0
- package/src/store.ts +38 -0
- package/src/subagents.ts +19 -0
package/dist/src/capture.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// secret on the cap's edge is never kept half. Recording never fails the step.
|
|
7
7
|
import { redactText } from "./scan.js";
|
|
8
8
|
import { writeCapturedCall, CAPTURE_CAP } from "./store.js";
|
|
9
|
-
import { isFileValue, fileContent } from "./secrets.js";
|
|
9
|
+
import { isFileValue, fileContent, isApiValue, apiConfigOf, isClientCertValue, clientCertOf } from "./secrets.js";
|
|
10
10
|
/** How much past the cap a step sends, so masking sees a secret whole
|
|
11
11
|
* before the cut (a value longer than this is a file secret, which is also
|
|
12
12
|
* masked line by line). */
|
|
@@ -29,6 +29,44 @@ export function redactionsFor(secrets) {
|
|
|
29
29
|
for (const [name, raw] of Object.entries(secrets)) {
|
|
30
30
|
if (typeof raw !== "string")
|
|
31
31
|
continue;
|
|
32
|
+
// An API connection's header values are each a secret in their own right
|
|
33
|
+
// (a token, Basic credentials) — and a Basic header's password too.
|
|
34
|
+
// An exported client certificate's passphrase and private key reach the
|
|
35
|
+
// sandbox as files: each is masked, whole and line by line.
|
|
36
|
+
if (isClientCertValue(raw)) {
|
|
37
|
+
const c = clientCertOf(raw);
|
|
38
|
+
for (const v of [c.passphrase, c.key]) {
|
|
39
|
+
if (!v)
|
|
40
|
+
continue;
|
|
41
|
+
add(v, name);
|
|
42
|
+
add(v.trim(), name);
|
|
43
|
+
const escaped = JSON.stringify(v).slice(1, -1);
|
|
44
|
+
if (escaped !== v)
|
|
45
|
+
add(escaped, name);
|
|
46
|
+
for (const line of v.split(/\r?\n/))
|
|
47
|
+
if (line.trim().length >= 16)
|
|
48
|
+
add(line.trim(), name);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (isApiValue(raw)) {
|
|
52
|
+
// Each as written and as JSON would escape it, like every value here.
|
|
53
|
+
const both = (v) => {
|
|
54
|
+
add(v, name);
|
|
55
|
+
const escaped = JSON.stringify(v).slice(1, -1);
|
|
56
|
+
if (escaped !== v)
|
|
57
|
+
add(escaped, name);
|
|
58
|
+
};
|
|
59
|
+
for (const v of Object.values(apiConfigOf(raw).headers ?? {})) {
|
|
60
|
+
both(v);
|
|
61
|
+
const basic = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(v.trim());
|
|
62
|
+
if (basic) {
|
|
63
|
+
both(basic[1]);
|
|
64
|
+
const decoded = Buffer.from(basic[1], "base64").toString("utf8");
|
|
65
|
+
if (decoded.includes(":"))
|
|
66
|
+
both(decoded.slice(decoded.indexOf(":") + 1));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
32
70
|
for (const v of isFileValue(raw) ? [raw, fileContent(raw)] : [raw]) {
|
|
33
71
|
add(v, name);
|
|
34
72
|
add(v.trim(), name);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DelegationResult, OperatorChannel } from "./operator.ts";
|
|
2
|
+
import type { EventExtra } from "./step-exec.ts";
|
|
3
|
+
import { type DelegateSpec } from "./subagents.ts";
|
|
4
|
+
export declare const DELEGATE_SERVER = "foldrun_delegate";
|
|
5
|
+
/** A job handed over is at most this long — the sub-agent's prompt. */
|
|
6
|
+
export declare const JOB_MAX = 20000;
|
|
7
|
+
/** What comes back is cut here: the reply is the parent's context. */
|
|
8
|
+
export declare const REPLY_MAX = 40000;
|
|
9
|
+
type Emit = (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
|
|
10
|
+
/** The granted names, the same on every path. */
|
|
11
|
+
export declare function delegateToolNames(specs: DelegateSpec[]): string[];
|
|
12
|
+
/** The reply as the parent reads it: the sub-agent's words, then what it changed. */
|
|
13
|
+
export declare function replyText(name: string, r: DelegationResult): string;
|
|
14
|
+
export declare function buildDelegateTools(opts: {
|
|
15
|
+
specs: DelegateSpec[];
|
|
16
|
+
channel: OperatorChannel | null;
|
|
17
|
+
emit: Emit;
|
|
18
|
+
/** The most one job may run; the step's own timeout bounds it anyway. */
|
|
19
|
+
timeoutSec?: number;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
}): {
|
|
22
|
+
server: null;
|
|
23
|
+
toolNames: string[];
|
|
24
|
+
calls: Record<string, (job: string) => Promise<unknown>>;
|
|
25
|
+
} | {
|
|
26
|
+
server: import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
|
|
27
|
+
toolNames: string[];
|
|
28
|
+
/** Each tool's own function, by agent name, for tests. */
|
|
29
|
+
calls: {
|
|
30
|
+
[k: string]: (job: string) => Promise<{
|
|
31
|
+
content: {
|
|
32
|
+
type: "text";
|
|
33
|
+
text: string;
|
|
34
|
+
}[];
|
|
35
|
+
isError?: boolean | undefined;
|
|
36
|
+
}>;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
/** The channel for a step that runs in this process (`foldrun run`): no
|
|
40
|
+
* proxy between it and the drive loop, so a job is handed over directly. */
|
|
41
|
+
export declare function localDelegateChannel(run: (agent: string, job: string, cancelled: () => boolean) => Promise<DelegationResult>): OperatorChannel;
|
|
42
|
+
/** The line the delegating step's prompt carries, so the model knows who
|
|
43
|
+
* works where. */
|
|
44
|
+
export declare function delegatePromptLine(specs: DelegateSpec[]): string;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Isolated sub-agents: a colleague that does a job in ITS OWN sandbox.
|
|
2
|
+
//
|
|
3
|
+
// subagents: [reader] # on the delegating agent, unchanged
|
|
4
|
+
// sandbox: own # on reader's own agent.md
|
|
5
|
+
//
|
|
6
|
+
// A sub-agent delegated to the ordinary way (subagents.ts) runs inside the
|
|
7
|
+
// parent's SDK session: the parent's pod, secrets, lease and network, its
|
|
8
|
+
// tools cut to the parent's. That protects the parent's context and nothing
|
|
9
|
+
// else — an injected page read by the sub-agent is read in a sandbox the
|
|
10
|
+
// parent's secrets are in.
|
|
11
|
+
//
|
|
12
|
+
// An agent marked `sandbox: own` is instead offered to the parent as a tool,
|
|
13
|
+
// `delegate_<name>(job)`. The call leaves the parent's sandbox through the
|
|
14
|
+
// one door it has (the egress proxy, at its lease — operator.ts), and the
|
|
15
|
+
// worker runs the job as a step of its own: its own pod, its own `tools:`,
|
|
16
|
+
// `secrets:` and `network:` from its own file, NOT cut to the parent's — the
|
|
17
|
+
// parent may hold less. The parent waits and gets the reply text, nothing
|
|
18
|
+
// else. What the sub-agent wrote reaches the workspace when it finishes, so
|
|
19
|
+
// the next step sees it; the reply names the paths.
|
|
20
|
+
//
|
|
21
|
+
// Depth one, as for every sub-agent: the job's own `subagents:` are not
|
|
22
|
+
// offered to it. Its record sits in the run after the parent's, marked
|
|
23
|
+
// `subagentOf` (store.ts), with its own cost and trace.
|
|
24
|
+
//
|
|
25
|
+
// With no channel (a container step on an install without a proxy) the tools
|
|
26
|
+
// are not granted at all; the runner says so on the step.
|
|
27
|
+
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
import { delegateToolName } from "./subagents.js";
|
|
30
|
+
export const DELEGATE_SERVER = "foldrun_delegate";
|
|
31
|
+
/** A job handed over is at most this long — the sub-agent's prompt. */
|
|
32
|
+
export const JOB_MAX = 20_000;
|
|
33
|
+
/** What comes back is cut here: the reply is the parent's context. */
|
|
34
|
+
export const REPLY_MAX = 40_000;
|
|
35
|
+
/** The granted names, the same on every path. */
|
|
36
|
+
export function delegateToolNames(specs) {
|
|
37
|
+
return specs.map((s) => `mcp__${DELEGATE_SERVER}__${delegateToolName(s.name)}`);
|
|
38
|
+
}
|
|
39
|
+
/** The reply as the parent reads it: the sub-agent's words, then what it changed. */
|
|
40
|
+
export function replyText(name, r) {
|
|
41
|
+
const body = r.result.trim() || "(no reply)";
|
|
42
|
+
const cut = body.length > REPLY_MAX ? `${body.slice(0, REPLY_MAX)}\n…(cut at ${REPLY_MAX} characters)` : body;
|
|
43
|
+
const wrote = r.wrote?.length
|
|
44
|
+
? `\n\nFiles ${name} changed (in the workspace from the next step on — not in your sandbox):\n${r.wrote.slice(0, 200).map((w) => `- ${w}`).join("\n")}`
|
|
45
|
+
: "";
|
|
46
|
+
const head = r.status === "completed" ? "" : `${name} did not finish (${r.status}). What it said:\n`;
|
|
47
|
+
return head + cut + wrote;
|
|
48
|
+
}
|
|
49
|
+
export function buildDelegateTools(opts) {
|
|
50
|
+
const now = opts.now ?? Date.now;
|
|
51
|
+
const channel = opts.channel;
|
|
52
|
+
if (!opts.specs.length || !channel?.delegate || !channel.delegation) {
|
|
53
|
+
return { server: null, toolNames: [], calls: {} };
|
|
54
|
+
}
|
|
55
|
+
const text = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) });
|
|
56
|
+
const run = async (s, rawJob) => {
|
|
57
|
+
const job = String(rawJob ?? "").trim().slice(0, JOB_MAX);
|
|
58
|
+
if (!job)
|
|
59
|
+
return text(`${delegateToolName(s.name)} needs the job, written out in full.`, true);
|
|
60
|
+
let id;
|
|
61
|
+
try {
|
|
62
|
+
({ id } = await channel.delegate(s.name, job));
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return text(`Could not hand ${s.name} the job: ${err instanceof Error ? err.message : String(err)}`, true);
|
|
66
|
+
}
|
|
67
|
+
opts.emit("info", `delegating to ${s.name} (own sandbox): ${job.split("\n")[0].slice(0, 160)}`, { subagent: s.name });
|
|
68
|
+
const deadline = opts.timeoutSec ? now() + opts.timeoutSec * 1000 : Infinity;
|
|
69
|
+
while (now() < deadline) {
|
|
70
|
+
let got = null;
|
|
71
|
+
try {
|
|
72
|
+
got = await channel.delegation(id, Math.min(deadline - now(), 25_000));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// A dropped long-poll is retried; the job goes on meanwhile.
|
|
76
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (got) {
|
|
80
|
+
opts.emit(got.status === "completed" ? "info" : "error", `${s.name} ${got.status}${got.wrote?.length ? ` — changed ${got.wrote.length} file(s)` : ""}`, { subagent: s.name });
|
|
81
|
+
return text(replyText(s.name, got), got.status !== "completed");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
await channel.cancelDelegation?.(id);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* the worker ends it with the step */
|
|
89
|
+
}
|
|
90
|
+
return text(`${s.name} did not finish in time; its job was stopped.`, true);
|
|
91
|
+
};
|
|
92
|
+
const tools = opts.specs.map((s) => tool(delegateToolName(s.name), `Hand ${s.name} a whole job: ${s.description} It works in its own sandbox with its own tools and returns its reply — ` +
|
|
93
|
+
`it has not read your conversation, so write the job out in full: what to do, what to return, where to put files.`, { job: z.string().describe("The complete, self-contained job") }, async ({ job }) => run(s, job)));
|
|
94
|
+
return {
|
|
95
|
+
server: createSdkMcpServer({ name: DELEGATE_SERVER, version: "1.0.0", tools }),
|
|
96
|
+
toolNames: delegateToolNames(opts.specs),
|
|
97
|
+
/** Each tool's own function, by agent name, for tests. */
|
|
98
|
+
calls: Object.fromEntries(opts.specs.map((s) => [s.name, (job) => run(s, job)])),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** The channel for a step that runs in this process (`foldrun run`): no
|
|
102
|
+
* proxy between it and the drive loop, so a job is handed over directly. */
|
|
103
|
+
export function localDelegateChannel(run) {
|
|
104
|
+
const jobs = new Map();
|
|
105
|
+
let n = 0;
|
|
106
|
+
const none = async () => {
|
|
107
|
+
throw new Error("nobody can answer here");
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
ask: none,
|
|
111
|
+
poll: async () => null,
|
|
112
|
+
inbox: async () => [],
|
|
113
|
+
async delegate(agent, job) {
|
|
114
|
+
const id = `local-${++n}`;
|
|
115
|
+
const entry = {
|
|
116
|
+
cancelled: false,
|
|
117
|
+
done: Promise.resolve(),
|
|
118
|
+
};
|
|
119
|
+
entry.done = run(agent, job, () => entry.cancelled)
|
|
120
|
+
.catch((err) => ({ status: "failed", result: err instanceof Error ? err.message : String(err) }))
|
|
121
|
+
.then((r) => (entry.result = r));
|
|
122
|
+
jobs.set(id, entry);
|
|
123
|
+
return { id };
|
|
124
|
+
},
|
|
125
|
+
async delegation(id, maxMs) {
|
|
126
|
+
const entry = jobs.get(id);
|
|
127
|
+
if (!entry)
|
|
128
|
+
return { status: "skipped", result: "no such job" };
|
|
129
|
+
if (entry.result)
|
|
130
|
+
return entry.result;
|
|
131
|
+
let timer;
|
|
132
|
+
const r = await Promise.race([
|
|
133
|
+
entry.done,
|
|
134
|
+
new Promise((res) => (timer = setTimeout(() => res(null), Math.max(0, maxMs)))),
|
|
135
|
+
]);
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
return r;
|
|
138
|
+
},
|
|
139
|
+
async cancelDelegation(id) {
|
|
140
|
+
const entry = jobs.get(id);
|
|
141
|
+
if (entry)
|
|
142
|
+
entry.cancelled = true;
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** The line the delegating step's prompt carries, so the model knows who
|
|
147
|
+
* works where. */
|
|
148
|
+
export function delegatePromptLine(specs) {
|
|
149
|
+
return (`<isolated-subagents>\nThese colleagues work in their own sandbox, with their own tools; call the tool to hand one a job ` +
|
|
150
|
+
`and you get back its reply and the paths of any files it changed:\n` +
|
|
151
|
+
specs.map((s) => `- ${delegateToolName(s.name)}: ${s.description}`).join("\n") +
|
|
152
|
+
`\n</isolated-subagents>`);
|
|
153
|
+
}
|
package/dist/src/egress.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PeerInfo } from "./operator.ts";
|
|
1
|
+
import type { DelegationResult, PeerInfo } from "./operator.ts";
|
|
2
2
|
import type { TestEffect } from "./test-mode.ts";
|
|
3
3
|
import type { NetworkLevel } from "./network.ts";
|
|
4
4
|
/** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
|
|
@@ -27,6 +27,13 @@ export declare function hostOf(url: string): string | null;
|
|
|
27
27
|
* from the step's declared secrets and the hosts its tools and provider
|
|
28
28
|
* name; the proxy enforces it per request.
|
|
29
29
|
*/
|
|
30
|
+
/** Which workload a lease is for — the runner's word, never the pod's: what
|
|
31
|
+
* an identity token's subject is built from (oidc-issuer.ts). */
|
|
32
|
+
export interface LeaseWho {
|
|
33
|
+
workspace: string;
|
|
34
|
+
agent: string;
|
|
35
|
+
flow?: string;
|
|
36
|
+
}
|
|
30
37
|
export interface EgressGrant {
|
|
31
38
|
/** Secret name → the hosts that secret may be substituted for. */
|
|
32
39
|
secrets: Record<string, {
|
|
@@ -53,6 +60,11 @@ export interface EgressGrant {
|
|
|
53
60
|
/** Refuse a site off the list (true), or allow it and name it in the
|
|
54
61
|
* trace (false: report only). Set by the platform when it leases. */
|
|
55
62
|
enforce?: boolean;
|
|
63
|
+
/** Identity name → the audience its tokens are for: the only identities
|
|
64
|
+
* this step may ask the proxy's /identity door to mint (oidc-issuer.ts). */
|
|
65
|
+
identities?: Record<string, {
|
|
66
|
+
audience: string;
|
|
67
|
+
}>;
|
|
56
68
|
}
|
|
57
69
|
export interface EgressLease {
|
|
58
70
|
/** The base the pod sends through: `http://…/e/<token>`. */
|
|
@@ -96,7 +108,10 @@ export interface EgressHooks {
|
|
|
96
108
|
/** `step` — the step's index in run.steps; keys that step's operator inbox.
|
|
97
109
|
* `agent` and `peers` — this step's agent and the other steps of its
|
|
98
110
|
* parallel group (tools: [peers]): the proxy serves the roster and
|
|
99
|
-
* addresses a colleague's message to the right inbox.
|
|
111
|
+
* addresses a colleague's message to the right inbox.
|
|
112
|
+
* `delegates` — the `sandbox: own` sub-agents this step may hand a job
|
|
113
|
+
* to over the delegate door (delegate-tools.ts); the proxy refuses any
|
|
114
|
+
* other name. */
|
|
100
115
|
lease(args: {
|
|
101
116
|
tenant: string;
|
|
102
117
|
runId: string;
|
|
@@ -105,7 +120,12 @@ export interface EgressHooks {
|
|
|
105
120
|
step?: number;
|
|
106
121
|
agent?: string;
|
|
107
122
|
peers?: PeerInfo[];
|
|
123
|
+
who?: LeaseWho;
|
|
124
|
+
delegates?: string[];
|
|
108
125
|
}): Promise<EgressLease | null>;
|
|
126
|
+
/** The run is still being driven: renew its leases' heartbeat. Optional —
|
|
127
|
+
* an install without leases has nothing to renew. */
|
|
128
|
+
renew?(runId: string): Promise<number>;
|
|
109
129
|
/** The step is over — every attempt, however it ended (tools: [peers]).
|
|
110
130
|
* A lease comes and goes per attempt, so it cannot say this: a sibling
|
|
111
131
|
* between retries, or waiting for a slot, holds none and is not done.
|
|
@@ -115,6 +135,27 @@ export interface EgressHooks {
|
|
|
115
135
|
runId: string;
|
|
116
136
|
step: number;
|
|
117
137
|
}): Promise<void>;
|
|
138
|
+
/** Serve the jobs a step posts to the delegate door while it runs
|
|
139
|
+
* (`sandbox: own` sub-agents): each is handed to `run`, whose result the
|
|
140
|
+
* door then answers the step's long-poll with. `close()` stops serving
|
|
141
|
+
* and settles anything unfinished as skipped. Optional: a platform
|
|
142
|
+
* without it offers no delegate tools, and the runner says so. */
|
|
143
|
+
delegations?(args: {
|
|
144
|
+
tenant: string;
|
|
145
|
+
runId: string;
|
|
146
|
+
step: number;
|
|
147
|
+
resuming?: boolean;
|
|
148
|
+
}, run: (job: DelegationJob) => Promise<DelegationResult>): {
|
|
149
|
+
close(): Promise<void>;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** One job posted to the delegate door. */
|
|
153
|
+
export interface DelegationJob {
|
|
154
|
+
id: string;
|
|
155
|
+
agent: string;
|
|
156
|
+
job: string;
|
|
157
|
+
/** Set when the step cancelled it (the delegate tool timed out). */
|
|
158
|
+
cancelled?: () => boolean;
|
|
118
159
|
}
|
|
119
160
|
/** Strip a header set of the proxy's own and hop-by-hop headers. */
|
|
120
161
|
export declare const HOP_BY_HOP: Set<string>;
|
|
@@ -44,6 +44,7 @@ const AGENT = [
|
|
|
44
44
|
{ key: "mcpServers", type: "object", help: "MCP servers, name → {command | url, args, env, headers}" },
|
|
45
45
|
{ key: "agents", type: ["list", "string"], help: "colleagues it may consult" },
|
|
46
46
|
{ key: "subagents", type: ["list", "string"], help: "colleagues it may delegate to" },
|
|
47
|
+
{ key: "sandbox", type: "string", values: ["own", "parent"], help: "when delegated to as a sub-agent: own = its own sandbox, tools, secrets and network; parent (default) = inside the delegating step's" },
|
|
47
48
|
{ key: "secrets", type: ["list", "string"], help: "keys it spends itself" },
|
|
48
49
|
{ key: "budget", type: ["number", "string"], help: "USD per run" },
|
|
49
50
|
{ key: "limits", type: "object", validated: true, help: "per-step call limits" },
|
|
@@ -187,7 +188,7 @@ const SAID_WHAT_IT_IS = [
|
|
|
187
188
|
const JOB_FIELDS = {
|
|
188
189
|
agent: {
|
|
189
190
|
governance: ["owner", "budget"],
|
|
190
|
-
guardrail: ["limits", "network", "hooks", "disallowedTools", "permissionMode", "secrets"],
|
|
191
|
+
guardrail: ["limits", "network", "hooks", "disallowedTools", "permissionMode", "secrets", "sandbox"],
|
|
191
192
|
},
|
|
192
193
|
flow: {
|
|
193
194
|
governance: ["owner", "approvers", "approve_within", "budget", "priority"],
|
|
@@ -9,5 +9,12 @@ export interface OAuthPreset {
|
|
|
9
9
|
/** The one thing about this provider that costs an afternoon to learn. */
|
|
10
10
|
hint: string;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* @deprecated foldrun is a runner and keeps no list of providers: the
|
|
14
|
+
* dashboard and CLI take the provider's own URLs and an `authorize_extra`
|
|
15
|
+
* map (`--authorize-param`) instead. Kept for apps saved from a preset and
|
|
16
|
+
* the CLI's deprecated `--provider`; to be removed in a major.
|
|
17
|
+
*/
|
|
12
18
|
export declare const OAUTH_PRESETS: Record<string, OAuthPreset>;
|
|
19
|
+
/** @deprecated see OAUTH_PRESETS. */
|
|
13
20
|
export declare const OAUTH_PRESET_NAMES: string[];
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
// What stays with the account is the OAuth *client* (id + secret): a consent
|
|
7
7
|
// screen belongs to a registered app, so the developer brings those two
|
|
8
8
|
// values from the provider's console and everything else is here.
|
|
9
|
+
/**
|
|
10
|
+
* @deprecated foldrun is a runner and keeps no list of providers: the
|
|
11
|
+
* dashboard and CLI take the provider's own URLs and an `authorize_extra`
|
|
12
|
+
* map (`--authorize-param`) instead. Kept for apps saved from a preset and
|
|
13
|
+
* the CLI's deprecated `--provider`; to be removed in a major.
|
|
14
|
+
*/
|
|
9
15
|
export const OAUTH_PRESETS = {
|
|
10
16
|
google: {
|
|
11
17
|
authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
@@ -36,4 +42,5 @@ export const OAUTH_PRESETS = {
|
|
|
36
42
|
"The redirect URL must match what the app registers exactly, and only localhost may be plain http.",
|
|
37
43
|
},
|
|
38
44
|
};
|
|
45
|
+
/** @deprecated see OAUTH_PRESETS. */
|
|
39
46
|
export const OAUTH_PRESET_NAMES = Object.keys(OAUTH_PRESETS);
|
package/dist/src/operator.d.ts
CHANGED
|
@@ -84,6 +84,25 @@ export interface OperatorChannel {
|
|
|
84
84
|
tell?(to: string, text: string): Promise<{
|
|
85
85
|
to: string[];
|
|
86
86
|
}>;
|
|
87
|
+
/** `sandbox: own` sub-agents (delegate-tools.ts): hand `agent` a job. The
|
|
88
|
+
* worker runs it in its own sandbox; only the reply comes back. Optional:
|
|
89
|
+
* an older proxy has no delegate door. */
|
|
90
|
+
delegate?(agent: string, job: string): Promise<{
|
|
91
|
+
id: string;
|
|
92
|
+
}>;
|
|
93
|
+
/** Waits up to `maxMs` for that job to finish; null while it still runs. */
|
|
94
|
+
delegation?(id: string, maxMs: number): Promise<DelegationResult | null>;
|
|
95
|
+
/** The parent stopped waiting (its step ended): stop the job. Best effort. */
|
|
96
|
+
cancelDelegation?(id: string): Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
/** How an isolated sub-agent's job ended, as the delegating step hears it. */
|
|
99
|
+
export interface DelegationResult {
|
|
100
|
+
status: "completed" | "failed" | "skipped";
|
|
101
|
+
/** Its reply — all the parent sees of its work. */
|
|
102
|
+
result: string;
|
|
103
|
+
/** Workspace paths it changed. They reach the workspace when it ends, so
|
|
104
|
+
* the next step sees them; the delegating step's own sandbox does not. */
|
|
105
|
+
wrote?: string[];
|
|
87
106
|
}
|
|
88
107
|
export declare const QUESTION_MAX = 2000;
|
|
89
108
|
export declare const OPTION_MAX = 200;
|
package/dist/src/operator.js
CHANGED
|
@@ -115,6 +115,35 @@ export function httpChannel(egress, fetchImpl = fetch) {
|
|
|
115
115
|
}));
|
|
116
116
|
return { to: Array.isArray(body.to) ? body.to : [] };
|
|
117
117
|
},
|
|
118
|
+
async delegate(agent, job) {
|
|
119
|
+
const body = await json(await fetchImpl(`${ep.base}/delegate/${ep.token}`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { "content-type": "application/json" },
|
|
122
|
+
body: JSON.stringify({ agent, job }),
|
|
123
|
+
signal: AbortSignal.timeout(15_000),
|
|
124
|
+
}));
|
|
125
|
+
return { id: String(body.id) };
|
|
126
|
+
},
|
|
127
|
+
async delegation(id, maxMs) {
|
|
128
|
+
const wait = Math.max(0, Math.min(maxMs, POLL_MS));
|
|
129
|
+
const body = await json(await fetchImpl(`${ep.base}/delegate/${ep.token}/${encodeURIComponent(id)}?wait=${wait}`, {
|
|
130
|
+
signal: AbortSignal.timeout(wait + 15_000),
|
|
131
|
+
}));
|
|
132
|
+
const status = body.status;
|
|
133
|
+
if (status !== "completed" && status !== "failed" && status !== "skipped")
|
|
134
|
+
return null;
|
|
135
|
+
return {
|
|
136
|
+
status,
|
|
137
|
+
result: typeof body.result === "string" ? body.result : "",
|
|
138
|
+
wrote: Array.isArray(body.wrote) ? body.wrote.filter((w) => typeof w === "string") : [],
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
async cancelDelegation(id) {
|
|
142
|
+
await json(await fetchImpl(`${ep.base}/delegate/${ep.token}/${encodeURIComponent(id)}`, {
|
|
143
|
+
method: "DELETE",
|
|
144
|
+
signal: AbortSignal.timeout(5_000),
|
|
145
|
+
}));
|
|
146
|
+
},
|
|
118
147
|
};
|
|
119
148
|
}
|
|
120
149
|
/**
|
package/dist/src/platform.d.ts
CHANGED
|
@@ -86,6 +86,14 @@ export interface PlatformHooks {
|
|
|
86
86
|
isolation: Record<string, IsolatedStepRunner>;
|
|
87
87
|
/** Destroy whatever sandboxes a stopped run still has. Default: nothing to destroy. */
|
|
88
88
|
killRunSandboxes(runId: string): void;
|
|
89
|
+
/** Destroy ONE step's sandbox, by the ref on its record — a sandbox: own
|
|
90
|
+
* job orphaned by a restart while its parent re-attaches, where killing
|
|
91
|
+
* the whole run's would take the parent too. Optional: an executor that
|
|
92
|
+
* cannot re-attach never needs it (the whole run's are killed instead). */
|
|
93
|
+
killSandbox?(sandbox: {
|
|
94
|
+
kind: string;
|
|
95
|
+
ref: string;
|
|
96
|
+
}): void;
|
|
89
97
|
/** Publish storage/public/ as share links after a run. Default: no links. */
|
|
90
98
|
syncPublicShares(tenant: string, workspace: string): Promise<{
|
|
91
99
|
added: string[];
|
|
@@ -8,7 +8,7 @@ import { type RuntimeSpec } from "./runtime.ts";
|
|
|
8
8
|
import { type NetworkLevel } from "./network.ts";
|
|
9
9
|
import type { ScriptSpec } from "./script-tools.ts";
|
|
10
10
|
import type { ConsultSpec } from "./agent-tools.ts";
|
|
11
|
-
import type { SubagentSpec } from "./subagents.ts";
|
|
11
|
+
import type { DelegateSpec, SubagentSpec } from "./subagents.ts";
|
|
12
12
|
import { type OperatorEvent } from "./operator.ts";
|
|
13
13
|
import type { SearchRoot, RunDigest } from "./context-tools.ts";
|
|
14
14
|
import type { TranslatorSpec } from "./translator.ts";
|
|
@@ -38,6 +38,9 @@ export interface ContainerStepInput {
|
|
|
38
38
|
consults: ConsultSpec[];
|
|
39
39
|
/** `subagents:` — built host-side (runner.ts), plain JSON. */
|
|
40
40
|
subagents?: SubagentSpec[];
|
|
41
|
+
/** `sandbox: own` sub-agents — offered as delegate_<name> tools; their
|
|
42
|
+
* jobs run on the worker in sandboxes of their own (delegate-tools.ts). */
|
|
43
|
+
delegates?: DelegateSpec[];
|
|
41
44
|
timeoutSec?: number;
|
|
42
45
|
/** The step's spend ceiling and the model's per-token price — see
|
|
43
46
|
* step-exec.ts. Values, so the driver can enforce them in the pod. */
|
|
@@ -180,6 +183,10 @@ export interface ContainerStepOutcome {
|
|
|
180
183
|
/** The pod's reconnects and, when it was lost, what the step had written —
|
|
181
184
|
* see ExecOutcome.browserPod. */
|
|
182
185
|
browserPod?: BrowserPodOutcome;
|
|
186
|
+
/** Workspace paths the step's changes were applied to on the host — what
|
|
187
|
+
* applyContainerChanges returned. An isolated sub-agent's reply names
|
|
188
|
+
* them to the step that delegated (delegate-tools.ts). */
|
|
189
|
+
applied?: string[];
|
|
183
190
|
}
|
|
184
191
|
/**
|
|
185
192
|
* The one place the timing line is worded, so the docker and k8s traces
|
|
@@ -289,7 +296,7 @@ export declare function runtimeCacheMount(tenant: string | undefined): {
|
|
|
289
296
|
target: string;
|
|
290
297
|
} | null;
|
|
291
298
|
/** 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). */
|
|
292
|
-
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 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 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 { 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\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 ...(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";
|
|
299
|
+
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 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 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 }) : { 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";
|
|
293
300
|
/** The uv release copied into the image. Pinned: a floating uv is a floating
|
|
294
301
|
* resolver, and the runtime cache is keyed on the declaration, not on it. */
|
|
295
302
|
export declare const UV_VERSION = "0.11.25";
|