@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.
- package/dist/src/delegate-tools.d.ts +49 -2
- package/dist/src/delegate-tools.js +236 -11
- package/dist/src/issues.js +4 -1
- package/dist/src/operator.d.ts +9 -0
- package/dist/src/operator.js +4 -0
- package/dist/src/run-container.d.ts +7 -4
- package/dist/src/run-container.js +29 -3
- package/dist/src/runner.d.ts +5 -0
- package/dist/src/runner.js +63 -2
- package/dist/src/scan.js +1 -1
- package/package.json +1 -1
- package/src/delegate-tools.ts +231 -11
- package/src/issues.ts +4 -1
- package/src/operator.ts +13 -0
- package/src/run-container.ts +26 -3
- package/src/runner.ts +64 -2
- package/src/scan.ts +1 -1
package/dist/src/runner.d.ts
CHANGED
|
@@ -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
|
package/dist/src/runner.js
CHANGED
|
@@ -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
|
|
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
package/src/delegate-tools.ts
CHANGED
|
@@ -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
|
|
18
|
-
//
|
|
19
|
-
// the
|
|
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
|
|
48
|
-
|
|
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
|
|
52
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
package/src/run-container.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|