@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.
@@ -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
+ }
@@ -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);
@@ -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;
@@ -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
  /**
@@ -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";