@foldrun/core 2.8.0 → 2.10.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.
@@ -494,6 +494,11 @@ export declare function actedThenFailedCheck(events: {
494
494
  * happened to create it.
495
495
  */
496
496
  export declare function copyTreeBytes(from: string, to: string): void;
497
+ /** Whether two agents of one workspace call the same model supply: each
498
+ * resolved the way agentContext does — its own provider: block, else the
499
+ * workspace's — and compared in a stable key order. The account key is
500
+ * shared by both, so two that resolve to no block share it (Codex review). */
501
+ export declare function sameSupply(workspaceDir: string, tenant: string, a: string, b: string): boolean;
497
502
  /** A refusal of the credential itself, in any of the wordings Claude Code
498
503
  * has used. "Failed to authenticate: OAuth token revoked" (9 Oct 2026, a
499
504
  * step started one second before the 09:07:52Z refresh) matched none of the
@@ -4,7 +4,7 @@
4
4
  // flow, so there is a single execution path.
5
5
  import { latestRevisionId } from "./history.js";
6
6
  import { recordCallIo, redactionsFor } from "./capture.js";
7
- import { AUTH_REFUSAL } from "./issues.js";
7
+ import { AUTH_REFUSAL, platformCause } from "./issues.js";
8
8
  import { hookNames } from "./hooks.js";
9
9
  import crypto from "node:crypto";
10
10
  import fs from "node:fs";
@@ -34,7 +34,7 @@ const isolatedRun = () => {
34
34
  };
35
35
  import { gatherConsults, buildConsultTools } from "./agent-tools.js";
36
36
  import { DELEGATE_TOOL, gatherSubagents, ownSandbox, subagentPrompt, subagentTools } from "./subagents.js";
37
- import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel } from "./delegate-tools.js";
37
+ import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel, readCopyBack } from "./delegate-tools.js";
38
38
  import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine } from "./operator.js";
39
39
  import { sendPlainNotification } from "./notify.js";
40
40
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
@@ -3510,6 +3510,34 @@ function platformFallbackEnv() {
3510
3510
  */
3511
3511
  const CREDENTIAL_ROTATION_WAIT_MS = 90_000;
3512
3512
  const CREDENTIAL_POLL_MS = 2_000;
3513
+ /** Platform causes a rescuer on the same model supply cannot get past. */
3514
+ const SAME_SUPPLY_WALLS = new Set(["model-cap", "model-auth"]);
3515
+ /** Whether two agents of one workspace call the same model supply: each
3516
+ * resolved the way agentContext does — its own provider: block, else the
3517
+ * workspace's — and compared in a stable key order. The account key is
3518
+ * shared by both, so two that resolve to no block share it (Codex review). */
3519
+ export function sameSupply(workspaceDir, tenant, a, b) {
3520
+ const supplyOf = (agent) => {
3521
+ const agentDir = path.join(workspaceDir, "agents", agent);
3522
+ let own;
3523
+ try {
3524
+ own = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8")).data.provider;
3525
+ }
3526
+ catch {
3527
+ own = undefined;
3528
+ }
3529
+ return stableJson(own ?? workspaceFrontmatter(agentDir, tenant).provider ?? null);
3530
+ };
3531
+ return supplyOf(a) === supplyOf(b);
3532
+ }
3533
+ function stableJson(v) {
3534
+ if (Array.isArray(v))
3535
+ return `[${v.map(stableJson).join(",")}]`;
3536
+ if (v && typeof v === "object") {
3537
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(",")}}`;
3538
+ }
3539
+ return JSON.stringify(v) ?? "null";
3540
+ }
3513
3541
  /** A refusal of the credential itself, in any of the wordings Claude Code
3514
3542
  * has used. "Failed to authenticate: OAuth token revoked" (9 Oct 2026, a
3515
3543
  * step started one second before the 09:07:52Z refresh) matched none of the
@@ -4847,10 +4875,15 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4847
4875
  entry.ms = Date.now() - started;
4848
4876
  save();
4849
4877
  const errors = child.events.filter((e) => e.type === "error").map((e) => e.text).join("\n");
4878
+ // Its files go back with the reply, so the delegating step's
4879
+ // sandbox has them too (delegate-tools.ts copyBackFiles).
4880
+ const back = readCopyBack(pDir, child.wrote ?? []);
4850
4881
  return {
4851
4882
  status,
4852
4883
  result: child.result ?? (errors || "(no reply)"),
4853
4884
  wrote: child.wrote ?? [],
4885
+ files: back.files,
4886
+ filesOmitted: back.omitted,
4854
4887
  };
4855
4888
  };
4856
4889
  // An agent's own `budget:` — the most it may spend in one run — is
@@ -5017,6 +5050,19 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
5017
5050
  save();
5018
5051
  if (run.stopRequested)
5019
5052
  break;
5053
+ // A spent usage window resets in hours or days, never within
5054
+ // the retry waits below: another attempt is refused the same
5055
+ // way (abandon-desk's collector ran twice on 23 Oct 2026).
5056
+ const lastError = [...step.events].reverse().find((e) => e.type === "error")?.text;
5057
+ if (platformCause(lastError)?.key === "model-cap") {
5058
+ step.events.push({
5059
+ t: new Date().toISOString(),
5060
+ type: "info",
5061
+ text: `not retried — the model usage cap is reached and will not reset within a retry's wait (${attempts - attempt} left unused)`,
5062
+ });
5063
+ save();
5064
+ break;
5065
+ }
5020
5066
  // The retry policy. An attempt the cluster ended for want of
5021
5067
  // memory or disk is retried one size class up — the same
5022
5068
  // size would end the same way. Every retry waits: 15 s, 30 s,
@@ -5087,6 +5133,21 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
5087
5133
  });
5088
5134
  return;
5089
5135
  }
5136
+ // A rescuer on the same model supply hits the same wall. On 23 Oct
5137
+ // 2026 every desk's fixer started after a spent weekly window, was
5138
+ // refused the same way, and the issue read "fixer also failed"
5139
+ // instead of the cap. A rescuer on a different supply still tries.
5140
+ const lastError = [...step.events].reverse().find((e) => e.type === "error")?.text;
5141
+ const wall = platformCause(lastError);
5142
+ if (wall && SAME_SUPPLY_WALLS.has(wall.key) && sameSupply(pDir, tenant, step.agent, step.onFail)) {
5143
+ step.events.push({
5144
+ t: new Date().toISOString(),
5145
+ type: "info",
5146
+ text: `not handing to [[${step.onFail}]] (on-fail): ${wall.label.toLowerCase()}, and it runs on the same model supply`,
5147
+ });
5148
+ save();
5149
+ return;
5150
+ }
5090
5151
  // One rescue per failure, across drives. A worker that died
5091
5152
  // mid-rescue left an unfinished record behind it; the drive that
5092
5153
  // resumes picks that one up instead of splicing another — a crash
package/dist/src/scan.js CHANGED
@@ -662,7 +662,7 @@ function scanAgents(agents, outward, toolDefs, push, networks) {
662
662
  const extra = refNames(sub.data.tools).filter((t) => !tools.includes(t));
663
663
  if (sub.data.sandbox === "own") {
664
664
  if (extra.length) {
665
- push("security/subagent-own-sandbox", a.file, keyLine(a.lines, "subagents"), `subagent ${s} own ${extra.join(",")}`, `subagents: [${s}] — ${s} runs in its own sandbox with ${extra.join(", ")}, which ${a.name} does not have; only its reply comes back`);
665
+ push("security/subagent-own-sandbox", a.file, keyLine(a.lines, "subagents"), `subagent ${s} own ${extra.join(",")}`, `subagents: [${s}] — ${s} runs in its own sandbox with ${extra.join(", ")}, which ${a.name} does not have; only its reply and the files it wrote come back`);
666
666
  }
667
667
  continue;
668
668
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -14,9 +14,12 @@
14
14
  // one door it has (the egress proxy, at its lease — operator.ts), and the
15
15
  // worker runs the job as a step of its own: its own pod, its own `tools:`,
16
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.
17
+ // parent may hold less. The parent waits and gets the reply text. What the
18
+ // sub-agent wrote reaches the workspace when it finishes, and comes back with
19
+ // the reply into the parent's sandbox too (copyBackFiles), so the parent's
20
+ // model and its `verify:` see it there: a `verify: file:` on the delegating
21
+ // step used to fail on a file the job had written (owner, 23 Oct 2026). What
22
+ // is too large to send back still reaches the workspace for the next step.
20
23
  //
21
24
  // Depth one, as for every sub-agent: the job's own `subagents:` are not
22
25
  // offered to it. Its record sits in the run after the parent's, marked
@@ -25,6 +28,9 @@
25
28
  // With no channel (a container step on an install without a proxy) the tools
26
29
  // are not granted at all; the runner says so on the step.
27
30
 
31
+ import crypto from "node:crypto";
32
+ import fs from "node:fs";
33
+ import path from "node:path";
28
34
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
29
35
  import { z } from "zod";
30
36
  import type { DelegationResult, OperatorChannel } from "./operator.ts";
@@ -37,6 +43,193 @@ export const JOB_MAX = 20_000;
37
43
  /** What comes back is cut here: the reply is the parent's context. */
38
44
  export const REPLY_MAX = 40_000;
39
45
 
46
+ /** What one job's files may add up to when copied back into the parent's
47
+ * sandbox, and the most files. They travel with the reply through the
48
+ * operator store, so the cap is on the raw bytes before base64. */
49
+ export const COPY_BACK_MAX_BYTES = 4 * 1024 * 1024;
50
+ export const COPY_BACK_MAX_FILES = 100;
51
+
52
+ /** A workspace-relative path that stays inside the workspace. */
53
+ function insideRel(rel: string): string | null {
54
+ const norm = path.posix.normalize(String(rel).replaceAll("\\", "/"));
55
+ if (!norm || norm === "." || norm.startsWith("../") || norm === ".." || path.posix.isAbsolute(norm)) return null;
56
+ return norm;
57
+ }
58
+
59
+ /** Read what a job wrote, on the host, for the trip back to the parent's
60
+ * sandbox: files only (no links), confined to the workspace, until the cap.
61
+ * What does not fit is named, never cut. */
62
+ export function readCopyBack(workspaceDir: string, wrote: string[]): { files: { path: string; base64: string }[]; omitted: string[] } {
63
+ const files: { path: string; base64: string }[] = [];
64
+ const omitted: string[] = [];
65
+ let total = 0;
66
+ const root = path.resolve(workspaceDir);
67
+ let realRoot: string;
68
+ try {
69
+ realRoot = fs.realpathSync(root);
70
+ } catch {
71
+ return { files, omitted };
72
+ }
73
+ for (const w of [...new Set(wrote)]) {
74
+ const rel = insideRel(w);
75
+ if (!rel) continue;
76
+ const abs = path.join(root, rel);
77
+ let st: fs.Stats;
78
+ try {
79
+ st = fs.lstatSync(abs);
80
+ // A directory link on the way must not lead out of the workspace:
81
+ // what is read here goes into another sandbox (Codex review).
82
+ const real = fs.realpathSync(abs);
83
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) continue;
84
+ } catch {
85
+ continue; // written then removed by the job
86
+ }
87
+ if (!st.isFile()) continue;
88
+ if (files.length >= COPY_BACK_MAX_FILES || total + st.size > COPY_BACK_MAX_BYTES) {
89
+ omitted.push(rel);
90
+ continue;
91
+ }
92
+ const buf = fs.readFileSync(abs);
93
+ total += buf.length;
94
+ files.push({ path: rel, base64: buf.toString("base64") });
95
+ }
96
+ return { files, omitted };
97
+ }
98
+
99
+ /** Where a sandbox notes what was copied back into it: path → sha256 of the
100
+ * bytes written. The step's copy-out reads it (run-container.ts
101
+ * applyContainerChanges) as what those files held before the step touched
102
+ * them, so a job's change already on the workspace is not applied twice.
103
+ * Under .foldrun/, which is never copied back as content. */
104
+ export const COPY_BACK_MANIFEST = ".foldrun/copied-back.json";
105
+
106
+ const sha256 = (b: Buffer) => crypto.createHash("sha256").update(b).digest("hex");
107
+
108
+ const exists = (p: string) => {
109
+ try {
110
+ fs.lstatSync(p);
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ };
116
+
117
+ /** The manifest's folder and file, unless either is something other than a
118
+ * real directory and file — a link planted there is never followed. */
119
+ function manifestPath(root: string): string | null {
120
+ const dir = path.join(root, path.dirname(COPY_BACK_MANIFEST));
121
+ const file = path.join(root, COPY_BACK_MANIFEST);
122
+ try {
123
+ if (!fs.lstatSync(dir).isDirectory()) return null;
124
+ } catch {
125
+ return file; // neither exists yet
126
+ }
127
+ try {
128
+ if (!fs.lstatSync(file).isFile()) return null;
129
+ } catch {
130
+ /* new file */
131
+ }
132
+ return file;
133
+ }
134
+
135
+ function writeManifest(root: string, manifest: Record<string, string>): void {
136
+ const file = manifestPath(root);
137
+ if (!file) return;
138
+ fs.mkdirSync(path.dirname(file), { recursive: true });
139
+ fs.writeFileSync(file, JSON.stringify(manifest), { flag: "w" });
140
+ }
141
+
142
+ /** The manifest of a sandbox's workspace, or {}. */
143
+ export function readCopyBackManifest(workspaceRoot: string): Record<string, string> {
144
+ const file = manifestPath(path.resolve(workspaceRoot));
145
+ if (!file) return {};
146
+ try {
147
+ const m = JSON.parse(fs.readFileSync(file, "utf8"));
148
+ return m && typeof m === "object" && !Array.isArray(m) ? (m as Record<string, string>) : {};
149
+ } catch {
150
+ return {};
151
+ }
152
+ }
153
+
154
+ /** Write a job's files into this sandbox's workspace. A path that would leave
155
+ * it, or land through a link, is refused. A file the step itself changed
156
+ * since `since` (its own edit, not one copied back) is not overwritten: the
157
+ * job's version goes beside it as `<name>.from-<agent><ext>`. */
158
+ export function copyBackFiles(
159
+ workspaceRoot: string,
160
+ files: { path: string; base64: string }[],
161
+ opts: { since?: number; from?: string } = {},
162
+ ): { copied: string[]; conflicts: { path: string; copy: string }[] } {
163
+ const root = path.resolve(workspaceRoot);
164
+ const manifest = readCopyBackManifest(root);
165
+ const copied: string[] = [];
166
+ const conflicts: { path: string; copy: string }[] = [];
167
+ // Every directory on the way must be a real one inside the workspace: a
168
+ // link the step planted must not carry the write elsewhere.
169
+ const safeDirs = (abs: string): boolean => {
170
+ let dir = path.dirname(abs);
171
+ const chain: string[] = [];
172
+ while (dir.startsWith(root + path.sep)) {
173
+ chain.unshift(dir);
174
+ dir = path.dirname(dir);
175
+ }
176
+ for (const d of chain) {
177
+ try {
178
+ if (!fs.lstatSync(d).isDirectory()) return false;
179
+ } catch {
180
+ fs.mkdirSync(d);
181
+ }
182
+ }
183
+ return true;
184
+ };
185
+ for (const f of files) {
186
+ const rel = insideRel(f.path);
187
+ if (!rel) continue;
188
+ let target = rel;
189
+ let abs = path.join(root, rel);
190
+ if (!safeDirs(abs)) continue;
191
+ let st: fs.Stats | null = null;
192
+ try {
193
+ st = fs.lstatSync(abs);
194
+ } catch {
195
+ /* new file */
196
+ }
197
+ if (st && !st.isFile()) continue;
198
+ const bytes = Buffer.from(f.base64, "base64");
199
+ if (st) {
200
+ const now = fs.readFileSync(abs);
201
+ if (now.equals(bytes)) {
202
+ manifest[rel] = sha256(bytes);
203
+ copied.push(rel);
204
+ continue;
205
+ }
206
+ const ours = manifest[rel] === sha256(now);
207
+ const editedHere = !ours && opts.since !== undefined && st.mtimeMs > opts.since;
208
+ if (editedHere) {
209
+ // A name nothing has yet: an earlier conflict copy may be the step's
210
+ // own work by now (Codex review).
211
+ const ext = path.posix.extname(rel);
212
+ const stem = `${rel.slice(0, rel.length - ext.length)}.from-${(opts.from ?? "subagent").replace(/[^A-Za-z0-9_-]/g, "-")}`;
213
+ for (let n = 1; ; n++) {
214
+ target = `${stem}${n === 1 ? "" : `-${n}`}${ext}`;
215
+ abs = path.join(root, target);
216
+ if (!exists(abs)) break;
217
+ }
218
+ // Not in the manifest: this copy is not on the workspace yet, and
219
+ // the copy-out must carry it there.
220
+ fs.writeFileSync(abs, bytes);
221
+ conflicts.push({ path: rel, copy: target });
222
+ continue;
223
+ }
224
+ }
225
+ fs.writeFileSync(abs, bytes);
226
+ manifest[rel] = sha256(bytes);
227
+ copied.push(rel);
228
+ }
229
+ if (copied.length || conflicts.length) writeManifest(root, manifest);
230
+ return { copied, conflicts };
231
+ }
232
+
40
233
  type Emit = (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
41
234
 
42
235
  /** The granted names, the same on every path. */
@@ -44,13 +237,23 @@ export function delegateToolNames(specs: DelegateSpec[]): string[] {
44
237
  return specs.map((s) => `mcp__${DELEGATE_SERVER}__${delegateToolName(s.name)}`);
45
238
  }
46
239
 
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 {
240
+ /** The reply as the parent reads it: the sub-agent's words, then what it
241
+ * changed — `copied` are the paths now in this sandbox too. */
242
+ export function replyText(name: string, r: DelegationResult, copied: string[] = [], conflicts: { path: string; copy: string }[] = []): string {
49
243
  const body = r.result.trim() || "(no reply)";
50
244
  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
- : "";
245
+ const here = new Set([...copied, ...conflicts.map((c) => c.path)]);
246
+ const elsewhere = (r.wrote ?? []).filter((w) => !here.has(w));
247
+ const wrote =
248
+ (copied.length
249
+ ? `\n\nFiles ${name} changed, copied into your sandbox at the same paths:\n${copied.slice(0, 200).map((w) => `- ${w}`).join("\n")}`
250
+ : "") +
251
+ (conflicts.length
252
+ ? `\n\nFiles you had changed yourself, which ${name} changed too — yours are kept; its version is beside each:\n${conflicts.slice(0, 200).map((c) => `- ${c.path} → ${c.copy}`).join("\n")}`
253
+ : "") +
254
+ (elsewhere.length
255
+ ? `\n\nFiles ${name} changed that are not in your sandbox (too large to copy back — in the workspace from the next step on):\n${elsewhere.slice(0, 200).map((w) => `- ${w}`).join("\n")}`
256
+ : "");
54
257
  const head = r.status === "completed" ? "" : `${name} did not finish (${r.status}). What it said:\n`;
55
258
  return head + cut + wrote;
56
259
  }
@@ -61,9 +264,14 @@ export function buildDelegateTools(opts: {
61
264
  emit: Emit;
62
265
  /** The most one job may run; the step's own timeout bounds it anyway. */
63
266
  timeoutSec?: number;
267
+ /** This sandbox's workspace: where a job's files are copied back. Absent
268
+ * in-process, where the job wrote the same workspace already. */
269
+ workspaceRoot?: string;
64
270
  now?: () => number;
65
271
  }) {
66
272
  const now = opts.now ?? Date.now;
273
+ // Anything in this sandbox newer than this, the step changed itself.
274
+ const startedAt = Date.now();
67
275
  const channel = opts.channel;
68
276
  if (!opts.specs.length || !channel?.delegate || !channel.delegation) {
69
277
  return { server: null, toolNames: [] as string[], calls: {} as Record<string, (job: string) => Promise<unknown>> };
@@ -90,8 +298,20 @@ export function buildDelegateTools(opts: {
90
298
  continue;
91
299
  }
92
300
  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");
301
+ let copied: string[] = [];
302
+ let conflicts: { path: string; copy: string }[] = [];
303
+ if (opts.workspaceRoot && got.files?.length) {
304
+ try {
305
+ ({ copied, conflicts } = copyBackFiles(opts.workspaceRoot, got.files, { since: startedAt, from: s.name }));
306
+ } catch (err) {
307
+ opts.emit("error", `could not copy ${s.name}'s files into this sandbox: ${err instanceof Error ? err.message : String(err)}`, { subagent: s.name });
308
+ }
309
+ }
310
+ const back = copied.length || conflicts.length
311
+ ? `; ${copied.length} copied into this sandbox${conflicts.length ? `, ${conflicts.length} kept beside the step's own edit` : ""}`
312
+ : "";
313
+ opts.emit(got.status === "completed" ? "info" : "error", `${s.name} ${got.status}${got.wrote?.length ? ` — changed ${got.wrote.length} file(s)${back}` : ""}`, { subagent: s.name });
314
+ return text(replyText(s.name, got, opts.workspaceRoot ? copied : (got.wrote ?? []), conflicts), got.status !== "completed");
95
315
  }
96
316
  }
97
317
  try {
@@ -168,7 +388,7 @@ export function localDelegateChannel(
168
388
  export function delegatePromptLine(specs: DelegateSpec[]): string {
169
389
  return (
170
390
  `<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` +
391
+ `and you get back its reply, with the files it changed copied into your sandbox at the same paths:\n` +
172
392
  specs.map((s) => `- ${delegateToolName(s.name)}: ${s.description}`).join("\n") +
173
393
  `\n</isolated-subagents>`
174
394
  );
package/src/issues.ts CHANGED
@@ -134,7 +134,10 @@ const PLATFORM_CAUSES: (PlatformCause & { test: RegExp })[] = [
134
134
  key: "model-cap",
135
135
  label: "The model usage cap is reached",
136
136
  fix: "runs resume when the cap resets; nothing to change in the desks.",
137
- test: /weekly (usage )?limit|usage limit (reached|exceeded)|reached your (weekly|usage) limit/i,
137
+ // Also the runner's own sentence for a spent subscription window
138
+ // (refusal.ts explainRefusal): on 23 Oct 2026 "the 7-day window is 100%
139
+ // used" filed five desks as their own failures.
140
+ test: /weekly (usage )?limit|usage limit (reached|exceeded)|reached your (weekly|usage) limit|window is (100% used|spent)/i,
138
141
  },
139
142
  {
140
143
  // The provider out of capacity is not the account's cap: it passes in
package/src/operator.ts CHANGED
@@ -117,6 +117,12 @@ export interface DelegationResult {
117
117
  /** Workspace paths it changed. They reach the workspace when it ends, so
118
118
  * the next step sees them; the delegating step's own sandbox does not. */
119
119
  wrote?: string[];
120
+ /** What it wrote, copied back into the delegating step's sandbox, so the
121
+ * parent's model and its `verify:` see it there (copyBackFiles), base64. */
122
+ files?: { path: string; base64: string }[];
123
+ /** Written paths too large to copy back: they reach the workspace when the
124
+ * job ends, so the next step sees them. */
125
+ filesOmitted?: string[];
120
126
  }
121
127
 
122
128
  export const QUESTION_MAX = 2_000;
@@ -217,6 +223,13 @@ export function httpChannel(egress: string | undefined | null, fetchImpl: typeof
217
223
  status,
218
224
  result: typeof body.result === "string" ? body.result : "",
219
225
  wrote: Array.isArray(body.wrote) ? (body.wrote as unknown[]).filter((w): w is string => typeof w === "string") : [],
226
+ files: Array.isArray(body.files)
227
+ ? (body.files as unknown[]).filter(
228
+ (f): f is { path: string; base64: string } =>
229
+ !!f && typeof (f as { path?: unknown }).path === "string" && typeof (f as { base64?: unknown }).base64 === "string",
230
+ )
231
+ : [],
232
+ filesOmitted: Array.isArray(body.filesOmitted) ? (body.filesOmitted as unknown[]).filter((w): w is string => typeof w === "string") : [],
220
233
  };
221
234
  },
222
235
  async cancelDelegation(id) {
@@ -412,6 +412,24 @@ export function applyContainerChanges(
412
412
  divert?: WriteDivert,
413
413
  ): string[] {
414
414
  const applied: string[] = [];
415
+ // Files a sandbox: own job's reply copied into this sandbox mid-step
416
+ // (delegate-tools.ts copyBackFiles), path → sha256 of what was written: for
417
+ // those, that is what the step started from. The job's change is already on
418
+ // the workspace; read against the older baseline it came back as the
419
+ // step's own and was merged in a second time (Codex review, 23 Oct 2026).
420
+ let imported: Record<string, string> = {};
421
+ try {
422
+ // Read here, on the host: a link the step planted is never followed,
423
+ // the same rule as every file below.
424
+ const dir = path.join(containerWs, ".foldrun");
425
+ const file = path.join(dir, "copied-back.json");
426
+ if (fs.lstatSync(dir).isDirectory() && fs.lstatSync(file).isFile()) {
427
+ const m = JSON.parse(fs.readFileSync(file, "utf8"));
428
+ if (m && typeof m === "object" && !Array.isArray(m)) imported = m as Record<string, string>;
429
+ }
430
+ } catch {
431
+ /* none copied back */
432
+ }
415
433
  const walk = (dir: string) => {
416
434
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
417
435
  const abs = path.join(dir, entry.name);
@@ -438,6 +456,8 @@ export function applyContainerChanges(
438
456
  } else if (baseline) {
439
457
  was = baseline[rel.replaceAll("\\", "/")] ?? null;
440
458
  }
459
+ const importedSha = imported[rel.replaceAll("\\", "/")];
460
+ if (typeof importedSha === "string") was = importedSha;
441
461
  const same = (b: Buffer) =>
442
462
  was === null || was === undefined ? false : typeof was === "string" ? sha(b) === was : b.equals(was);
443
463
  // Untouched by this step: whatever the host says now is more current
@@ -735,7 +755,7 @@ try {
735
755
  const inbox = channel ? inboxReader(channel, emit) : undefined;
736
756
  // sandbox: own sub-agents: a tool each, whose job runs in its own sandbox
737
757
  // 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 };
758
+ const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec, workspaceRoot: "/workspace" }) : { server: null };
739
759
 
740
760
  // Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept
741
761
  // fresh through the proxy's /identity door, which mints only what this
@@ -904,8 +924,11 @@ export const RETRY_SH = `retry() { what=$1; shift; n=1; until "$@"; do if [ "$n"
904
924
  * and make one browse call per engine on the full image:
905
925
  * docker buildx imagetools inspect node:22-trixie-slim --format '{{json .Manifest}}' | jq -r .digest
906
926
  * The digest names the multi-arch index, so the one pin serves amd64 and
907
- * arm64 alike. */
908
- export const NODE_BASE = "node:22-trixie-slim@sha256:b26b04c123d9ff8ab646ceb18b9d75a1173acf64b9a401094b906d27b29338d4";
927
+ * arm64 alike.
928
+ *
929
+ * 23 Oct 2026: Node 22.23.4 (the 21 Oct security release: Brotli decoder
930
+ * RCE, TLS hostname bypasses); the pin before carried 22.23.3. */
931
+ export const NODE_BASE = "node:22-trixie-slim@sha256:8eb619e402eaede1748a3c8173bffab391592812e85136452f781741c7242ddf";
909
932
 
910
933
  // Two images from one file, as build targets.
911
934
  //
package/src/runner.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { latestRevisionId } from "./history.ts";
7
7
  import { recordCallIo, redactionsFor } from "./capture.ts";
8
- import { AUTH_REFUSAL } from "./issues.ts";
8
+ import { AUTH_REFUSAL, platformCause } from "./issues.ts";
9
9
  import { hookNames } from "./hooks.ts";
10
10
  import crypto from "node:crypto";
11
11
  import fs from "node:fs";
@@ -38,7 +38,7 @@ const isolatedRun = () => {
38
38
  };
39
39
  import { gatherConsults, buildConsultTools } from "./agent-tools.ts";
40
40
  import { DELEGATE_TOOL, gatherSubagents, ownSandbox, subagentPrompt, subagentTools, type DelegateSpec, type SubagentSpec } from "./subagents.ts";
41
- import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel } from "./delegate-tools.ts";
41
+ import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel, readCopyBack } from "./delegate-tools.ts";
42
42
  import type { DelegationResult } from "./operator.ts";
43
43
  import type { StepDelegation } from "./store.ts";
44
44
  import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine, type PeerInfo } from "./operator.ts";
@@ -3717,6 +3717,35 @@ function platformFallbackEnv(): Record<string, string> | null {
3717
3717
  const CREDENTIAL_ROTATION_WAIT_MS = 90_000;
3718
3718
  const CREDENTIAL_POLL_MS = 2_000;
3719
3719
 
3720
+ /** Platform causes a rescuer on the same model supply cannot get past. */
3721
+ const SAME_SUPPLY_WALLS = new Set(["model-cap", "model-auth"]);
3722
+
3723
+ /** Whether two agents of one workspace call the same model supply: each
3724
+ * resolved the way agentContext does — its own provider: block, else the
3725
+ * workspace's — and compared in a stable key order. The account key is
3726
+ * shared by both, so two that resolve to no block share it (Codex review). */
3727
+ export function sameSupply(workspaceDir: string, tenant: string, a: string, b: string): boolean {
3728
+ const supplyOf = (agent: string): string => {
3729
+ const agentDir = path.join(workspaceDir, "agents", agent);
3730
+ let own: unknown;
3731
+ try {
3732
+ own = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8")).data.provider;
3733
+ } catch {
3734
+ own = undefined;
3735
+ }
3736
+ return stableJson(own ?? workspaceFrontmatter(agentDir, tenant).provider ?? null);
3737
+ };
3738
+ return supplyOf(a) === supplyOf(b);
3739
+ }
3740
+
3741
+ function stableJson(v: unknown): string {
3742
+ if (Array.isArray(v)) return `[${v.map(stableJson).join(",")}]`;
3743
+ if (v && typeof v === "object") {
3744
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson((v as Record<string, unknown>)[k])}`).join(",")}}`;
3745
+ }
3746
+ return JSON.stringify(v) ?? "null";
3747
+ }
3748
+
3720
3749
  /** A refusal of the credential itself, in any of the wordings Claude Code
3721
3750
  * has used. "Failed to authenticate: OAuth token revoked" (9 Oct 2026, a
3722
3751
  * step started one second before the 09:07:52Z refresh) matched none of the
@@ -5101,10 +5130,15 @@ function driveRunInner(
5101
5130
  entry.ms = Date.now() - started;
5102
5131
  save();
5103
5132
  const errors = child.events.filter((e) => e.type === "error").map((e) => e.text).join("\n");
5133
+ // Its files go back with the reply, so the delegating step's
5134
+ // sandbox has them too (delegate-tools.ts copyBackFiles).
5135
+ const back = readCopyBack(pDir, child.wrote ?? []);
5104
5136
  return {
5105
5137
  status,
5106
5138
  result: child.result ?? (errors || "(no reply)"),
5107
5139
  wrote: child.wrote ?? [],
5140
+ files: back.files,
5141
+ filesOmitted: back.omitted,
5108
5142
  };
5109
5143
  };
5110
5144
  // An agent's own `budget:` — the most it may spend in one run — is
@@ -5294,6 +5328,19 @@ function driveRunInner(
5294
5328
  // stop that lands while the outward check was being written.
5295
5329
  save();
5296
5330
  if (run.stopRequested) break;
5331
+ // A spent usage window resets in hours or days, never within
5332
+ // the retry waits below: another attempt is refused the same
5333
+ // way (abandon-desk's collector ran twice on 23 Oct 2026).
5334
+ const lastError = [...step.events].reverse().find((e) => e.type === "error")?.text;
5335
+ if (platformCause(lastError)?.key === "model-cap") {
5336
+ step.events.push({
5337
+ t: new Date().toISOString(),
5338
+ type: "info",
5339
+ text: `not retried — the model usage cap is reached and will not reset within a retry's wait (${attempts - attempt} left unused)`,
5340
+ });
5341
+ save();
5342
+ break;
5343
+ }
5297
5344
  // The retry policy. An attempt the cluster ended for want of
5298
5345
  // memory or disk is retried one size class up — the same
5299
5346
  // size would end the same way. Every retry waits: 15 s, 30 s,
@@ -5363,6 +5410,21 @@ function driveRunInner(
5363
5410
  });
5364
5411
  return;
5365
5412
  }
5413
+ // A rescuer on the same model supply hits the same wall. On 23 Oct
5414
+ // 2026 every desk's fixer started after a spent weekly window, was
5415
+ // refused the same way, and the issue read "fixer also failed"
5416
+ // instead of the cap. A rescuer on a different supply still tries.
5417
+ const lastError = [...step.events].reverse().find((e) => e.type === "error")?.text;
5418
+ const wall = platformCause(lastError);
5419
+ if (wall && SAME_SUPPLY_WALLS.has(wall.key) && sameSupply(pDir, tenant, step.agent, step.onFail)) {
5420
+ step.events.push({
5421
+ t: new Date().toISOString(),
5422
+ type: "info",
5423
+ text: `not handing to [[${step.onFail}]] (on-fail): ${wall.label.toLowerCase()}, and it runs on the same model supply`,
5424
+ });
5425
+ save();
5426
+ return;
5427
+ }
5366
5428
  // One rescue per failure, across drives. A worker that died
5367
5429
  // mid-rescue left an unfinished record behind it; the drive that
5368
5430
  // resumes picks that one up instead of splicing another — a crash
package/src/scan.ts CHANGED
@@ -739,7 +739,7 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
739
739
  if (sub.data.sandbox === "own") {
740
740
  if (extra.length) {
741
741
  push("security/subagent-own-sandbox", a.file, keyLine(a.lines, "subagents"), `subagent ${s} own ${extra.join(",")}`,
742
- `subagents: [${s}] — ${s} runs in its own sandbox with ${extra.join(", ")}, which ${a.name} does not have; only its reply comes back`);
742
+ `subagents: [${s}] — ${s} runs in its own sandbox with ${extra.join(", ")}, which ${a.name} does not have; only its reply and the files it wrote come back`);
743
743
  }
744
744
  continue;
745
745
  }