@foldrun/core 2.14.3 → 4.0.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/confine.d.ts +3 -0
- package/dist/src/confine.js +14 -0
- package/dist/src/copy-back.d.ts +17 -0
- package/dist/src/copy-back.js +74 -0
- package/dist/src/deploy.d.ts +2 -1
- package/dist/src/deploy.js +4 -6
- package/dist/src/install-tool.js +15 -6
- package/dist/src/kinds.d.ts +14 -0
- package/dist/src/kinds.js +18 -0
- package/dist/src/run-container.d.ts +44 -2
- package/dist/src/run-container.js +82 -5
- package/dist/src/runner.js +32 -0
- package/dist/src/step-exec.d.ts +3 -0
- package/dist/src/step-exec.js +3 -3
- package/dist/src/store.d.ts +11 -0
- package/package.json +1 -1
- package/src/confine.ts +19 -0
- package/src/copy-back.ts +87 -0
- package/src/deploy.ts +4 -6
- package/src/install-tool.ts +15 -6
- package/src/kinds.ts +31 -0
- package/src/run-container.ts +105 -3
- package/src/runner.ts +30 -0
- package/src/step-exec.ts +6 -3
- package/src/store.ts +8 -0
|
@@ -25,6 +25,7 @@ import crypto from "node:crypto";
|
|
|
25
25
|
import { spawn, spawnSync } from "node:child_process";
|
|
26
26
|
import { CAPTURE_CAP, isPlatformPath } from "./store.js";
|
|
27
27
|
import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.js";
|
|
28
|
+
import { refusedBack, refusedLine, keptLine } from "./copy-back.js";
|
|
28
29
|
import { isFileValue, fileContent } from "./secrets.js";
|
|
29
30
|
import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET } from "./runtime.js";
|
|
30
31
|
import { egressCaSh } from "./network.js";
|
|
@@ -176,6 +177,46 @@ export function hashTree(dir) {
|
|
|
176
177
|
walk(dir);
|
|
177
178
|
return out;
|
|
178
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Where the host keeps what a sandbox was handed (hashTree), for a driver
|
|
182
|
+
* that inherits the step: under the run's own folder, which is platform-
|
|
183
|
+
* owned — never staged into a sandbox, never copied back, erased with the
|
|
184
|
+
* run (deleteRun). It used to ride into the pod beside the input and be
|
|
185
|
+
* read back from there, where the step could rewrite it (FOL-96).
|
|
186
|
+
*/
|
|
187
|
+
export function handedInPath(workspaceRoot, runId, ref) {
|
|
188
|
+
const safe = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
|
|
189
|
+
if (!safe.test(runId) || !safe.test(ref))
|
|
190
|
+
return null;
|
|
191
|
+
return path.join(workspaceRoot, "runs", runId, "sandbox", `${ref}.json`);
|
|
192
|
+
}
|
|
193
|
+
/** Note what a sandbox was handed, on the host. */
|
|
194
|
+
export function writeHandedIn(workspaceRoot, runId, ref, hashes) {
|
|
195
|
+
const file = handedInPath(workspaceRoot, runId, ref);
|
|
196
|
+
if (!file)
|
|
197
|
+
return;
|
|
198
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
199
|
+
fs.writeFileSync(file, JSON.stringify(hashes));
|
|
200
|
+
}
|
|
201
|
+
/** What the host noted a sandbox was handed, or undefined. */
|
|
202
|
+
export function readHandedIn(workspaceRoot, runId, ref) {
|
|
203
|
+
const file = handedInPath(workspaceRoot, runId, ref);
|
|
204
|
+
if (!file)
|
|
205
|
+
return undefined;
|
|
206
|
+
try {
|
|
207
|
+
const m = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
208
|
+
return m && typeof m === "object" && !Array.isArray(m) ? m : undefined;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Forget it once the step's copy-out is done. */
|
|
215
|
+
export function dropHandedIn(workspaceRoot, runId, ref) {
|
|
216
|
+
const file = handedInPath(workspaceRoot, runId, ref);
|
|
217
|
+
if (file)
|
|
218
|
+
fs.rmSync(file, { force: true });
|
|
219
|
+
}
|
|
179
220
|
/**
|
|
180
221
|
* Every file's mtime under `dir`, relative path to milliseconds — so a copy
|
|
181
222
|
* that cannot carry times can have them put back. `kubectl cp` unpacks with
|
|
@@ -258,13 +299,17 @@ export function mergeAppends(was, host, next) {
|
|
|
258
299
|
const joined = hostAdded && !hostAdded.endsWith("\n") ? hostAdded + "\n" : hostAdded;
|
|
259
300
|
return Buffer.from(h.slice(0, end) + joined + stepAdded, "utf8");
|
|
260
301
|
}
|
|
261
|
-
export function applyContainerChanges(hostWs, containerWs, baseline, note, divert) {
|
|
302
|
+
export function applyContainerChanges(hostWs, containerWs, baseline, note, divert, opts = {}) {
|
|
262
303
|
const applied = [];
|
|
263
304
|
// Files a sandbox: own job's reply copied into this sandbox mid-step
|
|
264
305
|
// (delegate-tools.ts copyBackFiles), path → sha256 of what was written: for
|
|
265
306
|
// those, that is what the step started from. The job's change is already on
|
|
266
307
|
// the workspace; read against the older baseline it came back as the
|
|
267
308
|
// step's own and was merged in a second time (Codex review, 23 Oct 2026).
|
|
309
|
+
// The sandbox notes which it actually wrote (a conflict goes beside the
|
|
310
|
+
// file instead), but the step can write that note too, so an entry counts
|
|
311
|
+
// only where the host handed exactly those bytes back (FOL-96).
|
|
312
|
+
const handedBack = opts.handedBack ?? {};
|
|
268
313
|
let imported = {};
|
|
269
314
|
try {
|
|
270
315
|
// Read here, on the host: a link the step planted is never followed,
|
|
@@ -273,8 +318,16 @@ export function applyContainerChanges(hostWs, containerWs, baseline, note, diver
|
|
|
273
318
|
const file = path.join(dir, "copied-back.json");
|
|
274
319
|
if (fs.lstatSync(dir).isDirectory() && fs.lstatSync(file).isFile()) {
|
|
275
320
|
const m = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
276
|
-
if (m && typeof m === "object" && !Array.isArray(m))
|
|
277
|
-
|
|
321
|
+
if (m && typeof m === "object" && !Array.isArray(m)) {
|
|
322
|
+
for (const [rel, h] of Object.entries(m)) {
|
|
323
|
+
// Every version the host handed back for the path counts: two jobs
|
|
324
|
+
// can return the same file, and the sandbox may hold the first
|
|
325
|
+
// (the second went beside a parent edit as a conflict copy).
|
|
326
|
+
const ok = handedBack[rel];
|
|
327
|
+
if (typeof h === "string" && (Array.isArray(ok) ? ok.includes(h) : ok === h))
|
|
328
|
+
imported[rel] = h;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
278
331
|
}
|
|
279
332
|
}
|
|
280
333
|
catch {
|
|
@@ -322,6 +375,18 @@ export function applyContainerChanges(hostWs, containerWs, baseline, note, diver
|
|
|
322
375
|
const hostNow = fs.existsSync(target) ? fs.readFileSync(target) : null;
|
|
323
376
|
if (hostNow && hostNow.equals(next))
|
|
324
377
|
continue;
|
|
378
|
+
// Configuration and code are a person's (copy-back.ts): refused here,
|
|
379
|
+
// after "did the step change it" and before any merge, conflict copy
|
|
380
|
+
// or test divert — so none of those can carry it to the workspace,
|
|
381
|
+
// and the next step is staged from the workspace's own copy.
|
|
382
|
+
const refused = refusedBack(rel);
|
|
383
|
+
if (refused && opts.allowConfig) {
|
|
384
|
+
opts.onKept?.({ path: rel.replaceAll("\\", "/"), kind: refused });
|
|
385
|
+
}
|
|
386
|
+
else if (refused) {
|
|
387
|
+
opts.onRefused?.({ path: rel.replaceAll("\\", "/"), kind: refused });
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
325
390
|
// The host moved too since the step was handed its copy — another run
|
|
326
391
|
// or a person wrote it. Writing the step's copy over it erased the
|
|
327
392
|
// other run's ledger rows on 2026-09-14. Merge appends; keep both
|
|
@@ -646,6 +711,7 @@ try {
|
|
|
646
711
|
agentDir,
|
|
647
712
|
workspaceRoot: "/workspace",
|
|
648
713
|
libraryRoot: "/library",
|
|
714
|
+
configWrites: input.configWrites === true,
|
|
649
715
|
prompt: input.prompt,
|
|
650
716
|
model: input.model,
|
|
651
717
|
effort: input.effort ?? null,
|
|
@@ -1643,8 +1709,19 @@ export async function runStepInContainer(args) {
|
|
|
1643
1709
|
fs.mkdirSync(wsOut);
|
|
1644
1710
|
const back = spawnSync(cli(), ["cp", `${containerId}:/workspace/.`, wsOut], { encoding: "utf8" });
|
|
1645
1711
|
let applied = [];
|
|
1712
|
+
const refused = [];
|
|
1713
|
+
const kept = [];
|
|
1646
1714
|
if (back.status === 0) {
|
|
1647
|
-
applied = applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert
|
|
1715
|
+
applied = applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert, {
|
|
1716
|
+
handedBack: args.handedBack?.(),
|
|
1717
|
+
onRefused: (r) => refused.push(r),
|
|
1718
|
+
allowConfig: args.configWrites === true,
|
|
1719
|
+
onKept: (r) => kept.push(r),
|
|
1720
|
+
});
|
|
1721
|
+
if (refused.length)
|
|
1722
|
+
args.emit("info", refusedLine(refused));
|
|
1723
|
+
if (kept.length)
|
|
1724
|
+
args.emit("info", keptLine(kept));
|
|
1648
1725
|
}
|
|
1649
1726
|
else {
|
|
1650
1727
|
args.emit("error", `copy-out failed — the step's file changes were lost:\n${back.stderr.slice(0, 500)}`);
|
|
@@ -1658,7 +1735,7 @@ export async function runStepInContainer(args) {
|
|
|
1658
1735
|
phases,
|
|
1659
1736
|
};
|
|
1660
1737
|
args.emit("info", timingLine(timing));
|
|
1661
|
-
return { ...outcome, timing, applied };
|
|
1738
|
+
return { ...outcome, timing, applied, ...(refused.length ? { refused } : {}) };
|
|
1662
1739
|
}
|
|
1663
1740
|
finally {
|
|
1664
1741
|
if (containerId)
|
package/dist/src/runner.js
CHANGED
|
@@ -2824,6 +2824,8 @@ mayDefer) {
|
|
|
2824
2824
|
tenant,
|
|
2825
2825
|
input: {
|
|
2826
2826
|
agentRel: path.relative(workspaceRoot, agentDir).replaceAll("\\", "/"),
|
|
2827
|
+
// Its file tools inside the sandbox get the same allowance (FOL-96).
|
|
2828
|
+
configWrites: step.approve === true && Boolean(step.approvedAt),
|
|
2827
2829
|
prompt,
|
|
2828
2830
|
model,
|
|
2829
2831
|
effort,
|
|
@@ -2913,6 +2915,11 @@ mayDefer) {
|
|
|
2913
2915
|
network: { level: network.level, enforced: forward?.enforce === true },
|
|
2914
2916
|
// A test run's state/ and storage/ changes come out under the run.
|
|
2915
2917
|
divert: testRun && runId ? divertFor(runId) : undefined,
|
|
2918
|
+
// Read at copy-out: what the host handed back into the sandbox.
|
|
2919
|
+
handedBack: () => step.handedBack,
|
|
2920
|
+
// A person approved this step before it ran: configuration and code
|
|
2921
|
+
// it changes are kept, listed on the trail (FOL-96).
|
|
2922
|
+
configWrites: step.approve === true && Boolean(step.approvedAt),
|
|
2916
2923
|
test: testRun,
|
|
2917
2924
|
// Resume: a sandbox a previous driver left running, recorded on the
|
|
2918
2925
|
// step. Consumed once — a fresh attempt after this one (a retry, a
|
|
@@ -3120,6 +3127,12 @@ mayDefer) {
|
|
|
3120
3127
|
step.browserPod = outcome.browserPod ? { ...outcome.browserPod } : null;
|
|
3121
3128
|
if (step.subagentOf && outcome.applied?.length)
|
|
3122
3129
|
step.wrote = [...new Set([...(step.wrote ?? []), ...outcome.applied])];
|
|
3130
|
+
if (outcome.refused?.length) {
|
|
3131
|
+
const seen = new Map((step.refused ?? []).map((r) => [r.path, r]));
|
|
3132
|
+
for (const r of outcome.refused)
|
|
3133
|
+
seen.set(r.path, r);
|
|
3134
|
+
step.refused = [...seen.values()];
|
|
3135
|
+
}
|
|
3123
3136
|
if (outcome.status === "failed" && outcome.browserPod?.lost) {
|
|
3124
3137
|
const decision = podLossDecision(outcome.browserPod);
|
|
3125
3138
|
if (decision.rerun) {
|
|
@@ -3291,6 +3304,9 @@ mayDefer) {
|
|
|
3291
3304
|
const attemptInProcess = (modelEnv) => executeStep({
|
|
3292
3305
|
agentDir,
|
|
3293
3306
|
workspaceRoot,
|
|
3307
|
+
// A person approved this step (its gate) before it ran: it may change
|
|
3308
|
+
// configuration and code (FOL-96) — the desks' retro applies fixes so.
|
|
3309
|
+
configWrites: step.approve === true && Boolean(step.approvedAt),
|
|
3294
3310
|
libraryRoot: libraryDir(tenant),
|
|
3295
3311
|
...(subagents.length ? { subagents } : {}),
|
|
3296
3312
|
// The colleagues' inbox, read after every tool call — the same hook
|
|
@@ -4991,6 +5007,22 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4991
5007
|
// Its files go back with the reply, so the delegating step's
|
|
4992
5008
|
// sandbox has them too (delegate-tools.ts copyBackFiles).
|
|
4993
5009
|
const back = readCopyBack(pDir, child.wrote ?? []);
|
|
5010
|
+
// What goes back into the parent's sandbox, as the host sends it:
|
|
5011
|
+
// its copy-out reads this, not the sandbox's own note (FOL-96).
|
|
5012
|
+
if (back.files.length) {
|
|
5013
|
+
parent.handedBack = { ...(parent.handedBack ?? {}) };
|
|
5014
|
+
for (const f of back.files) {
|
|
5015
|
+
const h = crypto.createHash("sha256").update(Buffer.from(f.base64, "base64")).digest("hex");
|
|
5016
|
+
// Kept beside any earlier job's for the same path, never over
|
|
5017
|
+
// it: either may be what the sandbox ends up holding.
|
|
5018
|
+
const prev = parent.handedBack[f.path];
|
|
5019
|
+
const all = prev === undefined ? [] : Array.isArray(prev) ? prev : [prev];
|
|
5020
|
+
if (!all.includes(h))
|
|
5021
|
+
all.push(h);
|
|
5022
|
+
parent.handedBack[f.path] = all.length === 1 ? all[0] : all;
|
|
5023
|
+
}
|
|
5024
|
+
save();
|
|
5025
|
+
}
|
|
4994
5026
|
return {
|
|
4995
5027
|
status,
|
|
4996
5028
|
result: child.result ?? (errors || "(no reply)"),
|
package/dist/src/step-exec.d.ts
CHANGED
|
@@ -57,6 +57,9 @@ export interface ExecOutcome {
|
|
|
57
57
|
browserPod?: BrowserPodOutcome;
|
|
58
58
|
}
|
|
59
59
|
export interface ExecOptions {
|
|
60
|
+
/** A person approved this step before it ran: its file tools may write
|
|
61
|
+
* the workspace's configuration and code (confine.ts, FOL-96). */
|
|
62
|
+
configWrites?: boolean;
|
|
60
63
|
/** What the step reports beyond its events (FOL-54 step 4): `metadata`
|
|
61
64
|
* sends each model call (`turn`); `full` also sends each tool call's input
|
|
62
65
|
* and output (`io`). Unset sends neither — the consult, the probe and any
|
package/dist/src/step-exec.js
CHANGED
|
@@ -315,7 +315,7 @@ async function executeStepInner(opts, runQuery) {
|
|
|
315
315
|
}
|
|
316
316
|
}
|
|
317
317
|
const verdict = isFilesystemTool(hookInput.tool_name)
|
|
318
|
-
? checkPaths(hookInput.tool_name, hookInput.tool_input, { agentDir, workspaceRoot, libraryRoot })
|
|
318
|
+
? checkPaths(hookInput.tool_name, hookInput.tool_input, { agentDir, workspaceRoot, libraryRoot, configWrites: opts.configWrites })
|
|
319
319
|
: null;
|
|
320
320
|
if (verdict && !verdict.ok) {
|
|
321
321
|
emit("error", verdict.reason, via ? { subagent: via } : undefined);
|
|
@@ -357,7 +357,7 @@ async function executeStepInner(opts, runQuery) {
|
|
|
357
357
|
const again = hookInput.tool_name === "Bash"
|
|
358
358
|
? checkBash(String(v.input.command ?? ""))
|
|
359
359
|
: isFilesystemTool(hookInput.tool_name)
|
|
360
|
-
? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot })
|
|
360
|
+
? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot, configWrites: opts.configWrites })
|
|
361
361
|
: { ok: true };
|
|
362
362
|
const granted = opts.allowed.includes(hookInput.tool_name) || opts.mcpNames.some((n) => hookInput.tool_name.startsWith(`mcp__${n}__`));
|
|
363
363
|
// The sub-agent guard read the ORIGINAL input: a rewrite of a
|
|
@@ -432,7 +432,7 @@ async function executeStepInner(opts, runQuery) {
|
|
|
432
432
|
const verdict = toolName === "Bash"
|
|
433
433
|
? checkBash(String(input.command ?? ""))
|
|
434
434
|
: isFilesystemTool(toolName)
|
|
435
|
-
? checkPaths(toolName, input, { agentDir, workspaceRoot, libraryRoot })
|
|
435
|
+
? checkPaths(toolName, input, { agentDir, workspaceRoot, libraryRoot, configWrites: opts.configWrites })
|
|
436
436
|
: { ok: true };
|
|
437
437
|
if (!verdict.ok) {
|
|
438
438
|
refund();
|
package/dist/src/store.d.ts
CHANGED
|
@@ -1294,6 +1294,17 @@ export interface StepRecord {
|
|
|
1294
1294
|
/** On an isolated sub-agent's run: the workspace paths its sandbox's
|
|
1295
1295
|
* changes were applied to — what its delegating step is told it wrote. */
|
|
1296
1296
|
wrote?: string[];
|
|
1297
|
+
/** What this step's sandbox changed that was not taken back to the
|
|
1298
|
+
* workspace: configuration and code, which a step proposes and a person
|
|
1299
|
+
* applies (copy-back.ts, FOL-96). */
|
|
1300
|
+
refused?: {
|
|
1301
|
+
path: string;
|
|
1302
|
+
kind: "config" | "code";
|
|
1303
|
+
}[];
|
|
1304
|
+
/** What the host handed back into this step's sandbox mid-step — its
|
|
1305
|
+
* `sandbox: own` jobs' files, path → sha256 — recorded HERE, on the host,
|
|
1306
|
+
* so the copy-out never has to take the sandbox's word for it (FOL-96). */
|
|
1307
|
+
handedBack?: Record<string, string | string[]>;
|
|
1297
1308
|
/**
|
|
1298
1309
|
* Sandbox seconds this step rented, and how many of them were cold start.
|
|
1299
1310
|
* Set only on the isolated path — an in-process step rents nothing, so
|
package/package.json
CHANGED
package/src/confine.ts
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
import fs from "node:fs";
|
|
32
32
|
import path from "node:path";
|
|
33
|
+
import { refusedBack } from "./copy-back.ts";
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* A path with its symlinks resolved, as far as it exists.
|
|
@@ -97,6 +98,9 @@ export interface Roots {
|
|
|
97
98
|
workspaceRoot: string;
|
|
98
99
|
/** The account library: readable, never writable. */
|
|
99
100
|
libraryRoot: string;
|
|
101
|
+
/** A person approved this step before it ran (an approval gate): its
|
|
102
|
+
* writes to the workspace's configuration and code are allowed. */
|
|
103
|
+
configWrites?: boolean;
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
// Skills are announced to the agent with virtual prefixes — `workspace/skills/…`
|
|
@@ -322,6 +326,21 @@ export function checkPaths(
|
|
|
322
326
|
};
|
|
323
327
|
}
|
|
324
328
|
|
|
329
|
+
// Configuration and code are a person's: the same list a sandbox's
|
|
330
|
+
// copy-back refuses (copy-back.ts, FOL-96), so a step that runs on this
|
|
331
|
+
// host, with no sandbox between it and the files, is held to the same
|
|
332
|
+
// rule by its file tools. Its shell is not confined here — isolation is.
|
|
333
|
+
const asConfig = isWrite && !inLibrary && !roots.configWrites ? refusedBack(within) : null;
|
|
334
|
+
if (asConfig) {
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
reason:
|
|
338
|
+
`${toolName} was denied: "${raw}" is this workspace's ${asConfig === "config" ? "configuration" : "code"}, ` +
|
|
339
|
+
`which a run does not change. Say what should change and why in your answer — a person ` +
|
|
340
|
+
`applies it (or it becomes a Proposed fix). Your own notes go to outputs/ or workspace/memory/.`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
325
344
|
// Hand the real path back to the tool, so the virtual prefix the prompt
|
|
326
345
|
// advertises is one the agent can actually open — unless the step's
|
|
327
346
|
// `workspace` link already opens it. Then the path is left as written,
|
package/src/copy-back.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// What a run's sandbox may hand back to the workspace (FOL-96, 27 Oct 2026).
|
|
2
|
+
//
|
|
3
|
+
// A step's file changes come back from its sandbox (run-container.ts
|
|
4
|
+
// applyContainerChanges — the docker and the k8s runner both), and until now
|
|
5
|
+
// everything came back that was not platform-owned, knowledge or hooks. So a
|
|
6
|
+
// step steered by a page it read could rewrite AGENTS.md, an agent.md or a
|
|
7
|
+
// flow — its own tools:, network:, a provider: block, a new scheduled flow —
|
|
8
|
+
// and every later run, the next step of the same run included, ran on it.
|
|
9
|
+
//
|
|
10
|
+
// Configuration and code are a person's to change. A step that wants a
|
|
11
|
+
// change proposes it (Proposed fixes, the assistant), and a person applies
|
|
12
|
+
// it; a step that writes one anyway has the write refused, and the run's
|
|
13
|
+
// trail names it. What steps legitimately write — state/, memory/, storage/,
|
|
14
|
+
// an agent's own outputs — comes back as before.
|
|
15
|
+
//
|
|
16
|
+
// The list is not written here. It is read off the kinds (kinds.ts), whose
|
|
17
|
+
// every row must say what its files are, so a kind added later is classified
|
|
18
|
+
// the moment it exists — tsc refuses a row without `copyBack`.
|
|
19
|
+
|
|
20
|
+
import { KINDS, ALL_KINDS, SUBAGENT_IMPORT_DIRS, WORKSPACE_CONFIG_FILES, type CopyBackClass, type Kind } from "./kinds.ts";
|
|
21
|
+
|
|
22
|
+
/** A path a step changed that was not taken back, and why. */
|
|
23
|
+
export interface RefusedPath {
|
|
24
|
+
path: string;
|
|
25
|
+
kind: "config" | "code";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
|
+
const NAME = "\u0000";
|
|
30
|
+
|
|
31
|
+
/** Where an agent's own folder is — the scope agent-level kinds live under. */
|
|
32
|
+
const AGENT_SCOPE = KINDS.agents.file(NAME).split(NAME)[0]; // "agents/"
|
|
33
|
+
|
|
34
|
+
/** The path shapes one kind's files take, inside a workspace. */
|
|
35
|
+
export function kindPatterns(kind: Kind): RegExp[] {
|
|
36
|
+
const meta = KINDS[kind];
|
|
37
|
+
const file = meta.file(NAME);
|
|
38
|
+
// An agent's folder is a scope as well as a document: everything else in
|
|
39
|
+
// it is the agent's working directory. Its document is the one file.
|
|
40
|
+
if (kind === "agents") {
|
|
41
|
+
const [head, tail] = file.split(NAME);
|
|
42
|
+
return [new RegExp(`^${escape(head)}[^/]+${escape(tail)}$`)];
|
|
43
|
+
}
|
|
44
|
+
// Every other kind owns its top-level folder — a file document's siblings
|
|
45
|
+
// (a tool's script beside its .md) and a folder document's contents alike.
|
|
46
|
+
const root = file.split("/")[0];
|
|
47
|
+
const out = [new RegExp(`^${escape(root)}/`)];
|
|
48
|
+
if (meta.scopes.includes("agent")) out.push(new RegExp(`^${escape(AGENT_SCOPE)}[^/]+/${escape(root)}/`));
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const RULES: { re: RegExp; kind: "config" | "code" }[] = [
|
|
53
|
+
...WORKSPACE_CONFIG_FILES.map((f) => ({ re: new RegExp(`^${escape(f)}$`), kind: "config" as const })),
|
|
54
|
+
// Single-file subagents another tool wrote: a deploy imports them as agents.
|
|
55
|
+
...[...new Set(SUBAGENT_IMPORT_DIRS.map((d) => d.split("/")[0]))].map((d) => ({ re: new RegExp(`^${escape(d)}/`), kind: "config" as const })),
|
|
56
|
+
...ALL_KINDS.flatMap((k) => {
|
|
57
|
+
const cls: CopyBackClass = KINDS[k].copyBack;
|
|
58
|
+
return cls === "data" ? [] : kindPatterns(k).map((re) => ({ re, kind: cls }));
|
|
59
|
+
}),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Whether a step's write to `rel` (workspace-relative) is refused on the way
|
|
63
|
+
* back, and as what; null when it is data and may come back. */
|
|
64
|
+
export function refusedBack(rel: string): "config" | "code" | null {
|
|
65
|
+
const norm = rel.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
66
|
+
for (const r of RULES) if (r.re.test(norm)) return r.kind;
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The trail's line for a step's refused writes. */
|
|
71
|
+
/** The trail's line for config and code a person-approved step changed and
|
|
72
|
+
* that were kept (an approval gate before the step: the person read what
|
|
73
|
+
* it would do — the desks' retro applies approved fixes this way). */
|
|
74
|
+
export function keptLine(kept: readonly RefusedPath[]): string {
|
|
75
|
+
const shown = kept.slice(0, 20).map((r) => `${r.path} (${r.kind})`).join(", ");
|
|
76
|
+
const more = kept.length > 20 ? ` and ${kept.length - 20} more` : "";
|
|
77
|
+
return `kept with approval: ${shown}${more} — configuration or code this step changed, kept because a person approved the step before it ran`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function refusedLine(refused: readonly RefusedPath[]): string {
|
|
81
|
+
const shown = refused.slice(0, 20).map((r) => `${r.path} (${r.kind})`).join(", ");
|
|
82
|
+
const more = refused.length > 20 ? ` and ${refused.length - 20} more` : "";
|
|
83
|
+
return (
|
|
84
|
+
`refused path: ${shown}${more} — a step cannot change the workspace's configuration or code; ` +
|
|
85
|
+
`the workspace keeps its own copy. Propose the change instead (Proposed fixes), and a person applies it.`
|
|
86
|
+
);
|
|
87
|
+
}
|
package/src/deploy.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { libraryTools } from "./library.ts";
|
|
|
41
41
|
import { lintFlow, regexProblem } from "./flow-lint.ts";
|
|
42
42
|
import { parseEval } from "./evals.ts";
|
|
43
43
|
import { refNames } from "./refs.ts";
|
|
44
|
+
import { SUBAGENT_IMPORT_DIRS } from "./kinds.ts";
|
|
44
45
|
import matter, { configMatter } from "./matter.ts";
|
|
45
46
|
import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
|
|
46
47
|
import { conformanceIssues } from "./okf.ts";
|
|
@@ -123,12 +124,9 @@ export interface DeployResult extends DeployPlan {
|
|
|
123
124
|
* skipped rather than rejected, because a workspace living in a repo alongside
|
|
124
125
|
* other things is the normal case, not an error.
|
|
125
126
|
*/
|
|
126
|
-
// Where single-file subagents authored by other tools are found
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
// head. Shared by the deploy import (readTree) and the CLI checker so the two
|
|
130
|
-
// agree on what counts as an agent.
|
|
131
|
-
export const SUBAGENT_IMPORT_DIRS = [".agents/agents", ".claude/agents"] as const;
|
|
127
|
+
// Where single-file subagents authored by other tools are found — declared
|
|
128
|
+
// with the kinds (kinds.ts), which the copy-back rule reads too.
|
|
129
|
+
export { SUBAGENT_IMPORT_DIRS };
|
|
132
130
|
|
|
133
131
|
/** What agents write as they run, not what anyone authored: state/ and
|
|
134
132
|
* memory/, the workspace's or one agent's. */
|
package/src/install-tool.ts
CHANGED
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
// pip and npm need no root and run here, as the agent, into a per-step
|
|
13
13
|
// directory already on PATH / PYTHONPATH / NODE_PATH (stepInstallEnv), so the
|
|
14
14
|
// next command finds what this one installed. Inside a run container all of
|
|
15
|
-
// it goes when the container does.
|
|
15
|
+
// it goes when the container does.
|
|
16
|
+
//
|
|
17
|
+
// No package runs its own code on the way in (FOL-98, 28 Oct 2026): npm
|
|
18
|
+
// installs with --ignore-scripts (no preinstall/postinstall), and pip takes
|
|
19
|
+
// built wheels only (--only-binary :all:) — an sdist's setup.py is code. A
|
|
20
|
+
// package that has no wheel says so, and `runtime: packages:` builds it
|
|
21
|
+
// before the step instead, where the build is declared, cached and seen. Every install is logged with the line to
|
|
16
22
|
// add to the agent's `runtime:` to have it before the step starts next time.
|
|
17
23
|
|
|
18
24
|
import fs from "node:fs";
|
|
@@ -106,26 +112,29 @@ export function buildInstallTool(opts: {
|
|
|
106
112
|
fs.mkdirSync(target, { recursive: true });
|
|
107
113
|
const uv = findUv();
|
|
108
114
|
const res = uv
|
|
109
|
-
? spawnSync(uv, ["pip", "install", "-q", "--python", opts.python, "--target", target, ...names], {
|
|
115
|
+
? spawnSync(uv, ["pip", "install", "-q", "--only-binary", ":all:", "--python", opts.python, "--target", target, ...names], {
|
|
110
116
|
env: { ...opts.env, UV_CACHE_DIR: path.join(opts.cacheRoot, ".uv-cache"), UV_LINK_MODE: "copy", UV_NO_PROGRESS: "1" } as unknown as NodeJS.ProcessEnv,
|
|
111
117
|
encoding: "utf8",
|
|
112
118
|
timeout: 600_000,
|
|
113
119
|
})
|
|
114
|
-
: spawnSync(opts.python, ["-m", "pip", "install", "-q", "--disable-pip-version-check", "--target", target, ...names], {
|
|
120
|
+
: spawnSync(opts.python, ["-m", "pip", "install", "-q", "--disable-pip-version-check", "--only-binary", ":all:", "--target", target, ...names], {
|
|
115
121
|
env: opts.env as unknown as NodeJS.ProcessEnv,
|
|
116
122
|
encoding: "utf8",
|
|
117
123
|
timeout: 600_000,
|
|
118
124
|
});
|
|
119
125
|
if (res.status !== 0) {
|
|
120
|
-
const
|
|
126
|
+
const raw = `${res.stdout ?? ""}${res.stderr ?? ""}${res.error ? res.error.message : ""}`;
|
|
127
|
+
const msg = summarizeInstallFailure(uv ? "uv pip install" : "pip install", raw);
|
|
121
128
|
opts.emit("error", `install: pip ${names.join(" ")} — ${msg.split("\n")[0]}`);
|
|
122
|
-
|
|
129
|
+
// Wheels only: one that exists only as source is not built here.
|
|
130
|
+
const noWheel = /no matching distribution|only-binary|no wheels? (?:with|for)|binary.*not available|doesn't have a wheel|has no wheel/i.test(raw);
|
|
131
|
+
return text(noWheel ? `${msg}\n\nOnly built wheels are installed mid-step — a source package's build runs its own code. Declare it in the agent's runtime: packages: [${names.join(", ")}] to have it built before the step.` : msg, true);
|
|
123
132
|
}
|
|
124
133
|
how = `pip, ${Math.round((Date.now() - t0) / 1000)}s`;
|
|
125
134
|
} else {
|
|
126
135
|
const prefix = path.join(opts.dir, "npm");
|
|
127
136
|
fs.mkdirSync(prefix, { recursive: true });
|
|
128
|
-
const res = spawnSync("npm", ["install", "--no-fund", "--no-audit", "--no-progress", "--prefix", prefix, ...names], {
|
|
137
|
+
const res = spawnSync("npm", ["install", "--ignore-scripts", "--no-fund", "--no-audit", "--no-progress", "--prefix", prefix, ...names], {
|
|
129
138
|
env: { ...opts.env, npm_config_cache: path.join(opts.cacheRoot, ".npm-cache") } as unknown as NodeJS.ProcessEnv,
|
|
130
139
|
encoding: "utf8",
|
|
131
140
|
timeout: 600_000,
|
package/src/kinds.ts
CHANGED
|
@@ -55,6 +55,20 @@ export type Kind =
|
|
|
55
55
|
// question each answers rather than the alphabet, and a consistency test
|
|
56
56
|
// holds the sidebar and the asset pages to it.
|
|
57
57
|
|
|
58
|
+
/** How a run's write to a kind's files is treated on the way back. */
|
|
59
|
+
export type CopyBackClass = "config" | "code" | "data";
|
|
60
|
+
|
|
61
|
+
/** Workspace files that are configuration without being a kind: the shared
|
|
62
|
+
* context and its pre-rename name. */
|
|
63
|
+
export const WORKSPACE_CONFIG_FILES = ["AGENTS.md", "project.md"] as const;
|
|
64
|
+
|
|
65
|
+
// Where single-file subagents authored by other tools are found, in
|
|
66
|
+
// precedence order: the vendor-neutral cross-client location first, then any
|
|
67
|
+
// tool-specific one for pragmatic compatibility. Extend the tail, never the
|
|
68
|
+
// head. Shared by the deploy import (deploy.ts readTree), the CLI checker
|
|
69
|
+
// and the copy-back rule, so all three agree on what counts as an agent.
|
|
70
|
+
export const SUBAGENT_IMPORT_DIRS = [".agents/agents", ".claude/agents"] as const;
|
|
71
|
+
|
|
58
72
|
export interface KindMeta {
|
|
59
73
|
kind: Kind;
|
|
60
74
|
/** Singular noun, for buttons and prose. */
|
|
@@ -77,6 +91,14 @@ export interface KindMeta {
|
|
|
77
91
|
hint: string;
|
|
78
92
|
/** Path relative to the scope root. */
|
|
79
93
|
file: (name: string) => string;
|
|
94
|
+
/**
|
|
95
|
+
* What a run's sandbox writing one of these means (copy-back.ts):
|
|
96
|
+
* `config` grants or changes what runs and how, `code` is something a
|
|
97
|
+
* later step executes — neither is ever taken back from a step, which
|
|
98
|
+
* proposes the change instead — and `data` is what steps legitimately
|
|
99
|
+
* write. Required, so a new kind cannot become copy-back-able unnoticed.
|
|
100
|
+
*/
|
|
101
|
+
copyBack: CopyBackClass;
|
|
80
102
|
/** Starting content. */
|
|
81
103
|
template: (name: string, ctx?: TemplateContext) => string;
|
|
82
104
|
}
|
|
@@ -105,6 +127,7 @@ const titleCase = (slug: string) => {
|
|
|
105
127
|
export const KINDS: Record<Kind, KindMeta> = {
|
|
106
128
|
agents: {
|
|
107
129
|
kind: "agents",
|
|
130
|
+
copyBack: "config",
|
|
108
131
|
one: "agent",
|
|
109
132
|
docType: null,
|
|
110
133
|
docKey: null,
|
|
@@ -129,6 +152,7 @@ this text is the whole of the agent's instructions.
|
|
|
129
152
|
|
|
130
153
|
evals: {
|
|
131
154
|
kind: "evals",
|
|
155
|
+
copyBack: "config",
|
|
132
156
|
one: "eval",
|
|
133
157
|
docType: null,
|
|
134
158
|
docKey: null,
|
|
@@ -152,6 +176,7 @@ expect:
|
|
|
152
176
|
|
|
153
177
|
flows: {
|
|
154
178
|
kind: "flows",
|
|
179
|
+
copyBack: "config",
|
|
155
180
|
one: "flow",
|
|
156
181
|
docType: null,
|
|
157
182
|
docKey: null,
|
|
@@ -171,6 +196,7 @@ trigger: manual
|
|
|
171
196
|
|
|
172
197
|
tools: {
|
|
173
198
|
kind: "tools",
|
|
199
|
+
copyBack: "config",
|
|
174
200
|
one: "tool",
|
|
175
201
|
docType: null,
|
|
176
202
|
docKey: null,
|
|
@@ -198,6 +224,7 @@ tools: [${name}]
|
|
|
198
224
|
|
|
199
225
|
hooks: {
|
|
200
226
|
kind: "hooks",
|
|
227
|
+
copyBack: "code",
|
|
201
228
|
one: "hook",
|
|
202
229
|
docType: null,
|
|
203
230
|
docKey: null,
|
|
@@ -228,6 +255,7 @@ it reads the event as JSON on stdin and prints one JSON answer —
|
|
|
228
255
|
// a decision, so the sidebar has no door for it.
|
|
229
256
|
knowledge: {
|
|
230
257
|
kind: "knowledge",
|
|
258
|
+
copyBack: "data",
|
|
231
259
|
one: "knowledge doc",
|
|
232
260
|
docType: "Reference",
|
|
233
261
|
// OKF's field and OKF's vocabulary — `Reference` is only the starting point.
|
|
@@ -250,6 +278,7 @@ The reference content. Agents read this and never rewrite it.
|
|
|
250
278
|
|
|
251
279
|
memory: {
|
|
252
280
|
kind: "memory",
|
|
281
|
+
copyBack: "data",
|
|
253
282
|
one: "memory",
|
|
254
283
|
docType: "Fact",
|
|
255
284
|
// OKF's field and OKF's vocabulary — `Fact` is only the starting point.
|
|
@@ -277,6 +306,7 @@ The fact.
|
|
|
277
306
|
|
|
278
307
|
skills: {
|
|
279
308
|
kind: "skills",
|
|
309
|
+
copyBack: "config",
|
|
280
310
|
one: "skill",
|
|
281
311
|
docType: null,
|
|
282
312
|
docKey: null,
|
|
@@ -297,6 +327,7 @@ Step-by-step instructions. Bundle code in this folder's scripts/ if it needs any
|
|
|
297
327
|
|
|
298
328
|
scripts: {
|
|
299
329
|
kind: "scripts",
|
|
330
|
+
copyBack: "code",
|
|
300
331
|
one: "script",
|
|
301
332
|
// Code, not a document — no frontmatter to carry either field. A tool
|
|
302
333
|
// file points at it.
|