@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.
@@ -0,0 +1,175 @@
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
+
28
+ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
29
+ import { z } from "zod";
30
+ import type { DelegationResult, OperatorChannel } from "./operator.ts";
31
+ import type { EventExtra } from "./step-exec.ts";
32
+ import { delegateToolName, type DelegateSpec } from "./subagents.ts";
33
+
34
+ export const DELEGATE_SERVER = "foldrun_delegate";
35
+ /** A job handed over is at most this long — the sub-agent's prompt. */
36
+ export const JOB_MAX = 20_000;
37
+ /** What comes back is cut here: the reply is the parent's context. */
38
+ export const REPLY_MAX = 40_000;
39
+
40
+ type Emit = (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
41
+
42
+ /** The granted names, the same on every path. */
43
+ export function delegateToolNames(specs: DelegateSpec[]): string[] {
44
+ return specs.map((s) => `mcp__${DELEGATE_SERVER}__${delegateToolName(s.name)}`);
45
+ }
46
+
47
+ /** The reply as the parent reads it: the sub-agent's words, then what it changed. */
48
+ export function replyText(name: string, r: DelegationResult): string {
49
+ const body = r.result.trim() || "(no reply)";
50
+ const cut = body.length > REPLY_MAX ? `${body.slice(0, REPLY_MAX)}\n…(cut at ${REPLY_MAX} characters)` : body;
51
+ const wrote = r.wrote?.length
52
+ ? `\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")}`
53
+ : "";
54
+ const head = r.status === "completed" ? "" : `${name} did not finish (${r.status}). What it said:\n`;
55
+ return head + cut + wrote;
56
+ }
57
+
58
+ export function buildDelegateTools(opts: {
59
+ specs: DelegateSpec[];
60
+ channel: OperatorChannel | null;
61
+ emit: Emit;
62
+ /** The most one job may run; the step's own timeout bounds it anyway. */
63
+ timeoutSec?: number;
64
+ now?: () => number;
65
+ }) {
66
+ const now = opts.now ?? Date.now;
67
+ const channel = opts.channel;
68
+ if (!opts.specs.length || !channel?.delegate || !channel.delegation) {
69
+ return { server: null, toolNames: [] as string[], calls: {} as Record<string, (job: string) => Promise<unknown>> };
70
+ }
71
+ const text = (t: string, isError = false) => ({ content: [{ type: "text" as const, text: t }], ...(isError ? { isError: true } : {}) });
72
+ const run = async (s: DelegateSpec, rawJob: string) => {
73
+ const job = String(rawJob ?? "").trim().slice(0, JOB_MAX);
74
+ if (!job) return text(`${delegateToolName(s.name)} needs the job, written out in full.`, true);
75
+ let id: string;
76
+ try {
77
+ ({ id } = await channel.delegate!(s.name, job));
78
+ } catch (err) {
79
+ return text(`Could not hand ${s.name} the job: ${err instanceof Error ? err.message : String(err)}`, true);
80
+ }
81
+ opts.emit("info", `delegating to ${s.name} (own sandbox): ${job.split("\n")[0].slice(0, 160)}`, { subagent: s.name });
82
+ const deadline = opts.timeoutSec ? now() + opts.timeoutSec * 1000 : Infinity;
83
+ while (now() < deadline) {
84
+ let got: DelegationResult | null = null;
85
+ try {
86
+ got = await channel.delegation!(id, Math.min(deadline - now(), 25_000));
87
+ } catch {
88
+ // A dropped long-poll is retried; the job goes on meanwhile.
89
+ await new Promise((r) => setTimeout(r, 2_000));
90
+ continue;
91
+ }
92
+ if (got) {
93
+ opts.emit(got.status === "completed" ? "info" : "error", `${s.name} ${got.status}${got.wrote?.length ? ` — changed ${got.wrote.length} file(s)` : ""}`, { subagent: s.name });
94
+ return text(replyText(s.name, got), got.status !== "completed");
95
+ }
96
+ }
97
+ try {
98
+ await channel.cancelDelegation?.(id);
99
+ } catch {
100
+ /* the worker ends it with the step */
101
+ }
102
+ return text(`${s.name} did not finish in time; its job was stopped.`, true);
103
+ };
104
+ const tools = opts.specs.map((s) =>
105
+ tool(
106
+ delegateToolName(s.name),
107
+ `Hand ${s.name} a whole job: ${s.description} It works in its own sandbox with its own tools and returns its reply — ` +
108
+ `it has not read your conversation, so write the job out in full: what to do, what to return, where to put files.`,
109
+ { job: z.string().describe("The complete, self-contained job") },
110
+ async ({ job }) => run(s, job),
111
+ ),
112
+ );
113
+ return {
114
+ server: createSdkMcpServer({ name: DELEGATE_SERVER, version: "1.0.0", tools }),
115
+ toolNames: delegateToolNames(opts.specs),
116
+ /** Each tool's own function, by agent name, for tests. */
117
+ calls: Object.fromEntries(opts.specs.map((s) => [s.name, (job: string) => run(s, job)])),
118
+ };
119
+ }
120
+
121
+ /** The channel for a step that runs in this process (`foldrun run`): no
122
+ * proxy between it and the drive loop, so a job is handed over directly. */
123
+ export function localDelegateChannel(
124
+ run: (agent: string, job: string, cancelled: () => boolean) => Promise<DelegationResult>,
125
+ ): OperatorChannel {
126
+ const jobs = new Map<string, { done: Promise<DelegationResult>; result?: DelegationResult; cancelled: boolean }>();
127
+ let n = 0;
128
+ const none = async () => {
129
+ throw new Error("nobody can answer here");
130
+ };
131
+ return {
132
+ ask: none,
133
+ poll: async () => null,
134
+ inbox: async () => [],
135
+ async delegate(agent, job) {
136
+ const id = `local-${++n}`;
137
+ const entry: { done: Promise<DelegationResult>; result?: DelegationResult; cancelled: boolean } = {
138
+ cancelled: false,
139
+ done: Promise.resolve(),
140
+ } as never;
141
+ entry.done = run(agent, job, () => entry.cancelled)
142
+ .catch((err): DelegationResult => ({ status: "failed", result: err instanceof Error ? err.message : String(err) }))
143
+ .then((r) => (entry.result = r));
144
+ jobs.set(id, entry);
145
+ return { id };
146
+ },
147
+ async delegation(id, maxMs) {
148
+ const entry = jobs.get(id);
149
+ if (!entry) return { status: "skipped", result: "no such job" };
150
+ if (entry.result) return entry.result;
151
+ let timer: ReturnType<typeof setTimeout> | undefined;
152
+ const r = await Promise.race([
153
+ entry.done,
154
+ new Promise<null>((res) => (timer = setTimeout(() => res(null), Math.max(0, maxMs)))),
155
+ ]);
156
+ clearTimeout(timer);
157
+ return r;
158
+ },
159
+ async cancelDelegation(id) {
160
+ const entry = jobs.get(id);
161
+ if (entry) entry.cancelled = true;
162
+ },
163
+ };
164
+ }
165
+
166
+ /** The line the delegating step's prompt carries, so the model knows who
167
+ * works where. */
168
+ export function delegatePromptLine(specs: DelegateSpec[]): string {
169
+ return (
170
+ `<isolated-subagents>\nThese colleagues work in their own sandbox, with their own tools; call the tool to hand one a job ` +
171
+ `and you get back its reply and the paths of any files it changed:\n` +
172
+ specs.map((s) => `- ${delegateToolName(s.name)}: ${s.description}`).join("\n") +
173
+ `\n</isolated-subagents>`
174
+ );
175
+ }
package/src/egress.ts CHANGED
@@ -43,7 +43,7 @@
43
43
  // platform's (it needs a long-lived process and a Service); the pod side is
44
44
  // here in core, because it is what the runner image runs.
45
45
 
46
- import type { PeerInfo } from "./operator.ts";
46
+ import type { DelegationResult, PeerInfo } from "./operator.ts";
47
47
  import { trimSlashes } from "./paths.ts";
48
48
  import type { TestEffect } from "./test-mode.ts";
49
49
  import type { NetworkLevel } from "./network.ts";
@@ -96,6 +96,14 @@ export function hostOf(url: string): string | null {
96
96
  * from the step's declared secrets and the hosts its tools and provider
97
97
  * name; the proxy enforces it per request.
98
98
  */
99
+ /** Which workload a lease is for — the runner's word, never the pod's: what
100
+ * an identity token's subject is built from (oidc-issuer.ts). */
101
+ export interface LeaseWho {
102
+ workspace: string;
103
+ agent: string;
104
+ flow?: string;
105
+ }
106
+
99
107
  export interface EgressGrant {
100
108
  /** Secret name → the hosts that secret may be substituted for. */
101
109
  secrets: Record<string, { value: string; hosts: string[] }>;
@@ -116,6 +124,9 @@ export interface EgressGrant {
116
124
  /** Refuse a site off the list (true), or allow it and name it in the
117
125
  * trace (false: report only). Set by the platform when it leases. */
118
126
  enforce?: boolean;
127
+ /** Identity name → the audience its tokens are for: the only identities
128
+ * this step may ask the proxy's /identity door to mint (oidc-issuer.ts). */
129
+ identities?: Record<string, { audience: string }>;
119
130
  }
120
131
 
121
132
  export interface EgressLease {
@@ -160,13 +171,37 @@ export interface EgressHooks {
160
171
  /** `step` — the step's index in run.steps; keys that step's operator inbox.
161
172
  * `agent` and `peers` — this step's agent and the other steps of its
162
173
  * parallel group (tools: [peers]): the proxy serves the roster and
163
- * addresses a colleague's message to the right inbox. */
164
- lease(args: { tenant: string; runId: string; grant: EgressGrant; test?: boolean; step?: number; agent?: string; peers?: PeerInfo[] }): Promise<EgressLease | null>;
174
+ * addresses a colleague's message to the right inbox.
175
+ * `delegates` — the `sandbox: own` sub-agents this step may hand a job
176
+ * to over the delegate door (delegate-tools.ts); the proxy refuses any
177
+ * other name. */
178
+ lease(args: { tenant: string; runId: string; grant: EgressGrant; test?: boolean; step?: number; agent?: string; peers?: PeerInfo[]; who?: LeaseWho; delegates?: string[] }): Promise<EgressLease | null>;
179
+ /** The run is still being driven: renew its leases' heartbeat. Optional —
180
+ * an install without leases has nothing to renew. */
181
+ renew?(runId: string): Promise<number>;
165
182
  /** The step is over — every attempt, however it ended (tools: [peers]).
166
183
  * A lease comes and goes per attempt, so it cannot say this: a sibling
167
184
  * between retries, or waiting for a slot, holds none and is not done.
168
185
  * Optional: a platform without it keeps every sibling reachable. */
169
186
  stepEnded?(args: { tenant: string; runId: string; step: number }): Promise<void>;
187
+ /** Serve the jobs a step posts to the delegate door while it runs
188
+ * (`sandbox: own` sub-agents): each is handed to `run`, whose result the
189
+ * door then answers the step's long-poll with. `close()` stops serving
190
+ * and settles anything unfinished as skipped. Optional: a platform
191
+ * without it offers no delegate tools, and the runner says so. */
192
+ delegations?(
193
+ args: { tenant: string; runId: string; step: number; resuming?: boolean },
194
+ run: (job: DelegationJob) => Promise<DelegationResult>,
195
+ ): { close(): Promise<void> };
196
+ }
197
+
198
+ /** One job posted to the delegate door. */
199
+ export interface DelegationJob {
200
+ id: string;
201
+ agent: string;
202
+ job: string;
203
+ /** Set when the step cancelled it (the delegate tool timed out). */
204
+ cancelled?: () => boolean;
170
205
  }
171
206
 
172
207
  /** Strip a header set of the proxy's own and hop-by-hop headers. */
@@ -89,6 +89,7 @@ const AGENT: FieldSpec[] = [
89
89
  { key: "mcpServers", type: "object", help: "MCP servers, name → {command | url, args, env, headers}" },
90
90
  { key: "agents", type: ["list", "string"], help: "colleagues it may consult" },
91
91
  { key: "subagents", type: ["list", "string"], help: "colleagues it may delegate to" },
92
+ { 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" },
92
93
  { key: "secrets", type: ["list", "string"], help: "keys it spends itself" },
93
94
  { key: "budget", type: ["number", "string"], help: "USD per run" },
94
95
  { key: "limits", type: "object", validated: true, help: "per-step call limits" },
@@ -240,7 +241,7 @@ const SAID_WHAT_IT_IS: FieldSpec[] = [
240
241
  const JOB_FIELDS: Partial<Record<SchemaKind, Partial<Record<Job, readonly string[]>>>> = {
241
242
  agent: {
242
243
  governance: ["owner", "budget"],
243
- guardrail: ["limits", "network", "hooks", "disallowedTools", "permissionMode", "secrets"],
244
+ guardrail: ["limits", "network", "hooks", "disallowedTools", "permissionMode", "secrets", "sandbox"],
244
245
  },
245
246
  flow: {
246
247
  governance: ["owner", "approvers", "approve_within", "budget", "priority"],
@@ -19,6 +19,12 @@ export interface OAuthPreset {
19
19
  hint: string;
20
20
  }
21
21
 
22
+ /**
23
+ * @deprecated foldrun is a runner and keeps no list of providers: the
24
+ * dashboard and CLI take the provider's own URLs and an `authorize_extra`
25
+ * map (`--authorize-param`) instead. Kept for apps saved from a preset and
26
+ * the CLI's deprecated `--provider`; to be removed in a major.
27
+ */
22
28
  export const OAUTH_PRESETS: Record<string, OAuthPreset> = {
23
29
  google: {
24
30
  authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
@@ -51,4 +57,5 @@ export const OAUTH_PRESETS: Record<string, OAuthPreset> = {
51
57
  },
52
58
  };
53
59
 
60
+ /** @deprecated see OAUTH_PRESETS. */
54
61
  export const OAUTH_PRESET_NAMES = Object.keys(OAUTH_PRESETS);
package/src/operator.ts CHANGED
@@ -99,6 +99,24 @@ export interface OperatorChannel {
99
99
  /** Leave `text` in the inbox of the sibling named `to` (or of every
100
100
  * sibling, `"all"`). Resolves with the agents it reached. */
101
101
  tell?(to: string, text: string): Promise<{ to: string[] }>;
102
+ /** `sandbox: own` sub-agents (delegate-tools.ts): hand `agent` a job. The
103
+ * worker runs it in its own sandbox; only the reply comes back. Optional:
104
+ * an older proxy has no delegate door. */
105
+ delegate?(agent: string, job: string): Promise<{ id: string }>;
106
+ /** Waits up to `maxMs` for that job to finish; null while it still runs. */
107
+ delegation?(id: string, maxMs: number): Promise<DelegationResult | null>;
108
+ /** The parent stopped waiting (its step ended): stop the job. Best effort. */
109
+ cancelDelegation?(id: string): Promise<void>;
110
+ }
111
+
112
+ /** How an isolated sub-agent's job ended, as the delegating step hears it. */
113
+ export interface DelegationResult {
114
+ status: "completed" | "failed" | "skipped";
115
+ /** Its reply — all the parent sees of its work. */
116
+ result: string;
117
+ /** Workspace paths it changed. They reach the workspace when it ends, so
118
+ * the next step sees them; the delegating step's own sandbox does not. */
119
+ wrote?: string[];
102
120
  }
103
121
 
104
122
  export const QUESTION_MAX = 2_000;
@@ -175,6 +193,40 @@ export function httpChannel(egress: string | undefined | null, fetchImpl: typeof
175
193
  );
176
194
  return { to: Array.isArray(body.to) ? (body.to as string[]) : [] };
177
195
  },
196
+ async delegate(agent, job) {
197
+ const body = await json(
198
+ await fetchImpl(`${ep.base}/delegate/${ep.token}`, {
199
+ method: "POST",
200
+ headers: { "content-type": "application/json" },
201
+ body: JSON.stringify({ agent, job }),
202
+ signal: AbortSignal.timeout(15_000),
203
+ }),
204
+ );
205
+ return { id: String(body.id) };
206
+ },
207
+ async delegation(id, maxMs) {
208
+ const wait = Math.max(0, Math.min(maxMs, POLL_MS));
209
+ const body = await json(
210
+ await fetchImpl(`${ep.base}/delegate/${ep.token}/${encodeURIComponent(id)}?wait=${wait}`, {
211
+ signal: AbortSignal.timeout(wait + 15_000),
212
+ }),
213
+ );
214
+ const status = body.status;
215
+ if (status !== "completed" && status !== "failed" && status !== "skipped") return null;
216
+ return {
217
+ status,
218
+ result: typeof body.result === "string" ? body.result : "",
219
+ wrote: Array.isArray(body.wrote) ? (body.wrote as unknown[]).filter((w): w is string => typeof w === "string") : [],
220
+ };
221
+ },
222
+ async cancelDelegation(id) {
223
+ await json(
224
+ await fetchImpl(`${ep.base}/delegate/${ep.token}/${encodeURIComponent(id)}`, {
225
+ method: "DELETE",
226
+ signal: AbortSignal.timeout(5_000),
227
+ }),
228
+ );
229
+ },
178
230
  };
179
231
  }
180
232
 
package/src/platform.ts CHANGED
@@ -107,6 +107,11 @@ export interface PlatformHooks {
107
107
  isolation: Record<string, IsolatedStepRunner>;
108
108
  /** Destroy whatever sandboxes a stopped run still has. Default: nothing to destroy. */
109
109
  killRunSandboxes(runId: string): void;
110
+ /** Destroy ONE step's sandbox, by the ref on its record — a sandbox: own
111
+ * job orphaned by a restart while its parent re-attaches, where killing
112
+ * the whole run's would take the parent too. Optional: an executor that
113
+ * cannot re-attach never needs it (the whole run's are killed instead). */
114
+ killSandbox?(sandbox: { kind: string; ref: string }): void;
110
115
  /** Publish storage/public/ as share links after a run. Default: no links. */
111
116
  syncPublicShares(tenant: string, workspace: string): Promise<{ added: string[] }>;
112
117
  /** This account's own data key, or null to use the install key. */
@@ -35,7 +35,7 @@ import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKE
35
35
  import { egressCaSh, type NetworkLevel } from "./network.ts";
36
36
  import type { ScriptSpec } from "./script-tools.ts";
37
37
  import type { ConsultSpec } from "./agent-tools.ts";
38
- import type { SubagentSpec } from "./subagents.ts";
38
+ import type { DelegateSpec, SubagentSpec } from "./subagents.ts";
39
39
  import { isOperatorEvent, type OperatorEvent } from "./operator.ts";
40
40
  import type { SearchRoot, RunDigest } from "./context-tools.ts";
41
41
  import type { TranslatorSpec } from "./translator.ts";
@@ -66,6 +66,9 @@ export interface ContainerStepInput {
66
66
  consults: ConsultSpec[];
67
67
  /** `subagents:` — built host-side (runner.ts), plain JSON. */
68
68
  subagents?: SubagentSpec[];
69
+ /** `sandbox: own` sub-agents — offered as delegate_<name> tools; their
70
+ * jobs run on the worker in sandboxes of their own (delegate-tools.ts). */
71
+ delegates?: DelegateSpec[];
69
72
  timeoutSec?: number;
70
73
  /** The step's spend ceiling and the model's per-token price — see
71
74
  * step-exec.ts. Values, so the driver can enforce them in the pod. */
@@ -214,6 +217,10 @@ export interface ContainerStepOutcome {
214
217
  /** The pod's reconnects and, when it was lost, what the step had written —
215
218
  * see ExecOutcome.browserPod. */
216
219
  browserPod?: BrowserPodOutcome;
220
+ /** Workspace paths the step's changes were applied to on the host — what
221
+ * applyContainerChanges returned. An isolated sub-agent's reply names
222
+ * them to the step that delegated (delegate-tools.ts). */
223
+ applied?: string[];
217
224
  }
218
225
 
219
226
  /**
@@ -626,6 +633,7 @@ try {
626
633
  const { buildConsultTools } = await import("@foldrun/core/agent-tools");
627
634
  const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import("@foldrun/core/context-tools");
628
635
  const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import("@foldrun/core/operator");
636
+ const { buildDelegateTools } = await import("@foldrun/core/delegate-tools");
629
637
  const { startTranslator } = await import("@foldrun/core/translator");
630
638
  const { prepareRuntime } = await import("@foldrun/core/runtime");
631
639
  const { materializeFileSecrets } = await import("@foldrun/core/secret-files");
@@ -725,6 +733,39 @@ try {
725
733
  const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };
726
734
  const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };
727
735
  const inbox = channel ? inboxReader(channel, emit) : undefined;
736
+ // sandbox: own sub-agents: a tool each, whose job runs in its own sandbox
737
+ // on the worker, reached over the same proxy door.
738
+ const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec }) : { server: null };
739
+
740
+ // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept
741
+ // fresh through the proxy's /identity door, which mints only what this
742
+ // step's lease was granted. Fetched before the step starts, renewed five
743
+ // minutes before it expires (with jitter); a failure is said and retried.
744
+ // Plain JS: this text is the driver, not TypeScript.
745
+ const identityNames = (env.FOLDRUN_IDENTITIES || "").split(",").filter(Boolean);
746
+ if (identityNames.length) {
747
+ // No backslashes: this sits inside a template literal, which eats them.
748
+ const ep = new RegExp("^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$").exec(env.FOLDRUN_EGRESS || "");
749
+ const dir = env.FOLDRUN_IDENTITY_DIR || "/tmp/foldrun-identity";
750
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
751
+ const keepFresh = async (name) => {
752
+ let wait = 30000;
753
+ try {
754
+ if (!ep) throw new Error("no egress proxy to ask");
755
+ const res = await fetch(ep[1] + "/identity/" + ep[2] + "/" + name, { signal: AbortSignal.timeout(15000) });
756
+ const body = await res.json().catch(() => ({}));
757
+ if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || ("proxy answered " + res.status));
758
+ const file = dir + "/" + name.toLowerCase() + ".jwt";
759
+ fs.writeFileSync(file + ".tmp", body.token, { mode: 0o600 });
760
+ fs.renameSync(file + ".tmp", file);
761
+ wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);
762
+ } catch (err) {
763
+ emit("error", "identity " + name + ": " + (err && err.message ? err.message : String(err)) + " — trying again in 30s");
764
+ }
765
+ setTimeout(() => { void keepFresh(name); }, wait).unref();
766
+ };
767
+ await Promise.all(identityNames.map(keepFresh));
768
+ }
728
769
 
729
770
  mark("tools");
730
771
  const outcome = await executeStep({
@@ -746,6 +787,7 @@ try {
746
787
  ...(desks.server ? { foldrun_desks: desks.server } : {}),
747
788
  ...(ask.server ? { foldrun_ask: ask.server } : {}),
748
789
  ...(peers.server ? { foldrun_peers: peers.server } : {}),
790
+ ...(delegate.server ? { foldrun_delegate: delegate.server } : {}),
749
791
  ...(install.server ? { foldrun_install: install.server } : {}),
750
792
  ...input.mcpServers,
751
793
  },
@@ -1687,8 +1729,9 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
1687
1729
  const wsOut = path.join(staging, "out");
1688
1730
  fs.mkdirSync(wsOut);
1689
1731
  const back = spawnSync(cli(), ["cp", `${containerId}:/workspace/.`, wsOut], { encoding: "utf8" });
1732
+ let applied: string[] = [];
1690
1733
  if (back.status === 0) {
1691
- applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert);
1734
+ applied = applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert);
1692
1735
  } else {
1693
1736
  args.emit("error", `copy-out failed — the step's file changes were lost:\n${back.stderr.slice(0, 500)}`);
1694
1737
  }
@@ -1702,7 +1745,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
1702
1745
  phases,
1703
1746
  };
1704
1747
  args.emit("info", timingLine(timing));
1705
- return { ...outcome, timing };
1748
+ return { ...outcome, timing, applied };
1706
1749
  } finally {
1707
1750
  if (containerId) spawnSync(cli(), ["rm", "-f", containerId], { stdio: "ignore" });
1708
1751
  fs.rmSync(staging, { recursive: true, force: true });