pi-better-subagents 0.6.0 → 0.7.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/README.md +1 -1
- package/child-incidents.ts +2 -2
- package/docs/failure-observations.md +1 -1
- package/failures.ts +48 -6
- package/incident-model.ts +11 -4
- package/index.ts +47 -18
- package/output-payload.ts +2 -3
- package/package.json +1 -1
- package/permission-policy.ts +9 -3
- package/registry.ts +19 -2
- package/shared-failure-observations.ts +48 -12
- package/shared-log-utils.ts +6 -12
- package/shared-navigator.ts +169 -125
- package/shared-sandbox-core.ts +78 -14
- package/shared-task-apply-patch.ts +409 -0
- package/shared-task-files.ts +69 -7
- package/shared-task-sandbox.ts +11 -0
- package/shared-task-tools.ts +111 -0
- package/subagent-tools.ts +95 -0
- package/task-guard.ts +42 -7
- package/task-policy.ts +28 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Generated from packages/task-sandbox/apply-patch.ts. Do not edit directly.
|
|
2
|
+
/**
|
|
3
|
+
* A guarded `apply_patch`: the Codex patch format, applied only through the
|
|
4
|
+
* task's guarded file operations.
|
|
5
|
+
*
|
|
6
|
+
* Same tool name, parameter schema and patch format as the Codex tool (and
|
|
7
|
+
* `@vanillagreen/pi-codex-minimal-tools`, MIT), so models' habits work. The
|
|
8
|
+
* implementation is this harness's own: it never touches the filesystem
|
|
9
|
+
* directly, so the Project files / Outside project levels, credential rules and
|
|
10
|
+
* protected paths govern it exactly as they govern write, edit and bash.
|
|
11
|
+
*
|
|
12
|
+
* The whole patch is validated first (syntax, file rules, every hunk against
|
|
13
|
+
* the current content). Only then is anything written, one whole file at a
|
|
14
|
+
* time, so no file is ever left half-patched. A failure during the write phase
|
|
15
|
+
* restores what it can and reports exactly what was and was not applied.
|
|
16
|
+
*/
|
|
17
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
export type PatchAction =
|
|
20
|
+
| { kind: "add"; path: string; lines: string[] }
|
|
21
|
+
| { kind: "delete"; path: string }
|
|
22
|
+
| { kind: "update"; path: string; moveTo?: string; hunks: PatchHunk[] };
|
|
23
|
+
|
|
24
|
+
export interface PatchHunk {
|
|
25
|
+
/** Text after `@@ `: a line to find before this hunk's context. */
|
|
26
|
+
anchor?: string;
|
|
27
|
+
/** Lines with their one-character prefix: " " context, "-" removed, "+" added. */
|
|
28
|
+
lines: string[];
|
|
29
|
+
/** `*** End of File`: the hunk's context must end at the file's last line. */
|
|
30
|
+
endOfFile: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const BEGIN = "*** Begin Patch";
|
|
34
|
+
const END = "*** End Patch";
|
|
35
|
+
const ADD = "*** Add File: ";
|
|
36
|
+
const DELETE = "*** Delete File: ";
|
|
37
|
+
const UPDATE = "*** Update File: ";
|
|
38
|
+
const MOVE = "*** Move to: ";
|
|
39
|
+
const EOF_MARKER = "*** End of File";
|
|
40
|
+
|
|
41
|
+
export class PatchError extends Error {
|
|
42
|
+
constructor(message: string) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "PatchError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isHeader(line: string): boolean {
|
|
49
|
+
const t = line.trim();
|
|
50
|
+
return t === END || t.startsWith(ADD) || t.startsWith(DELETE) || t.startsWith(UPDATE);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function headerPath(line: string, prefix: string, at: number): string {
|
|
54
|
+
const path = line.trim().slice(prefix.length).trim();
|
|
55
|
+
if (!path) throw new PatchError(`Line ${at}: '${prefix.trim()}' needs a path.`);
|
|
56
|
+
return path;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Parse Codex patch text. Throws a PatchError naming the offending line. */
|
|
60
|
+
export function parsePatch(input: string): PatchAction[] {
|
|
61
|
+
if (typeof input !== "string" || !input.trim()) throw new PatchError("apply_patch needs a non-empty patch in `input`.");
|
|
62
|
+
const lines = input.replace(/\r\n?/g, "\n").split("\n");
|
|
63
|
+
let i = 0;
|
|
64
|
+
while (i < lines.length && !lines[i]!.trim()) i++;
|
|
65
|
+
if (lines[i]?.trim() !== BEGIN) throw new PatchError(`The patch must start with '${BEGIN}'.`);
|
|
66
|
+
i++;
|
|
67
|
+
const actions: PatchAction[] = [];
|
|
68
|
+
let ended = false;
|
|
69
|
+
while (i < lines.length) {
|
|
70
|
+
const line = lines[i]!;
|
|
71
|
+
const at = i + 1;
|
|
72
|
+
const trimmed = line.trim();
|
|
73
|
+
if (trimmed === END) { ended = true; i++; break; }
|
|
74
|
+
if (!trimmed) { i++; continue; }
|
|
75
|
+
if (trimmed.startsWith(ADD)) {
|
|
76
|
+
const path = headerPath(line, ADD, at);
|
|
77
|
+
const body: string[] = [];
|
|
78
|
+
let bare = 0; // Trailing bare empty lines separate sections; inner ones are empty lines.
|
|
79
|
+
i++;
|
|
80
|
+
while (i < lines.length && !isHeader(lines[i]!)) {
|
|
81
|
+
const bodyLine = lines[i]!;
|
|
82
|
+
if (bodyLine === "") { body.push(""); bare++; i++; continue; }
|
|
83
|
+
if (!bodyLine.startsWith("+")) throw new PatchError(`Line ${i + 1}: every line of an added file starts with '+' (${path}).`);
|
|
84
|
+
body.push(bodyLine.slice(1));
|
|
85
|
+
bare = 0;
|
|
86
|
+
i++;
|
|
87
|
+
}
|
|
88
|
+
body.splice(body.length - bare);
|
|
89
|
+
actions.push({ kind: "add", path, lines: body });
|
|
90
|
+
} else if (trimmed.startsWith(DELETE)) {
|
|
91
|
+
actions.push({ kind: "delete", path: headerPath(line, DELETE, at) });
|
|
92
|
+
i++;
|
|
93
|
+
} else if (trimmed.startsWith(UPDATE)) {
|
|
94
|
+
const path = headerPath(line, UPDATE, at);
|
|
95
|
+
i++;
|
|
96
|
+
let moveTo: string | undefined;
|
|
97
|
+
if (i < lines.length && lines[i]!.trim().startsWith(MOVE)) {
|
|
98
|
+
moveTo = headerPath(lines[i]!, MOVE, i + 1);
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
const hunks: PatchHunk[] = [];
|
|
102
|
+
let current: PatchHunk | undefined;
|
|
103
|
+
let bare = 0; // Trailing bare empty lines: separators unless more hunk lines follow.
|
|
104
|
+
while (i < lines.length && !isHeader(lines[i]!)) {
|
|
105
|
+
const bodyLine = lines[i]!;
|
|
106
|
+
if (bodyLine.startsWith("@@")) {
|
|
107
|
+
if (current) current.lines.splice(current.lines.length - bare);
|
|
108
|
+
bare = 0;
|
|
109
|
+
const anchor = bodyLine.slice(2).trim();
|
|
110
|
+
current = { ...(anchor ? { anchor } : {}), lines: [], endOfFile: false };
|
|
111
|
+
hunks.push(current);
|
|
112
|
+
} else if (bodyLine.trim() === EOF_MARKER) {
|
|
113
|
+
if (!current) throw new PatchError(`Line ${i + 1}: '${EOF_MARKER}' outside a hunk in ${path}.`);
|
|
114
|
+
current.lines.splice(current.lines.length - bare);
|
|
115
|
+
bare = 0;
|
|
116
|
+
current.endOfFile = true;
|
|
117
|
+
} else {
|
|
118
|
+
if (!current) { current = { lines: [], endOfFile: false }; hunks.push(current); }
|
|
119
|
+
else if (current.endOfFile && bodyLine !== "") throw new PatchError(`Line ${i + 1}: lines after '${EOF_MARKER}' in ${path}.`);
|
|
120
|
+
if (bodyLine === "") {
|
|
121
|
+
// A bare empty line is an empty context line (models often drop the space).
|
|
122
|
+
if (!current.endOfFile) { current.lines.push(" "); bare++; }
|
|
123
|
+
} else if (" +-".includes(bodyLine[0]!)) {
|
|
124
|
+
current.lines.push(bodyLine);
|
|
125
|
+
bare = 0;
|
|
126
|
+
} else throw new PatchError(`Line ${i + 1}: hunk lines start with ' ', '+' or '-' (${path}).`);
|
|
127
|
+
}
|
|
128
|
+
i++;
|
|
129
|
+
}
|
|
130
|
+
if (current) current.lines.splice(current.lines.length - bare);
|
|
131
|
+
if (hunks.some((hunk) => hunk.lines.length === 0)) throw new PatchError(`Empty hunk in ${path}.`);
|
|
132
|
+
if (!hunks.length && !moveTo) throw new PatchError(`'${UPDATE.trim()} ${path}' has no changes.`);
|
|
133
|
+
actions.push({ kind: "update", path, ...(moveTo ? { moveTo } : {}), hunks });
|
|
134
|
+
} else {
|
|
135
|
+
throw new PatchError(`Line ${at}: expected '${ADD.trim()}', '${UPDATE.trim()}', '${DELETE.trim()}' or '${END}', got: ${line.slice(0, 80)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!ended) throw new PatchError(`The patch must end with '${END}'.`);
|
|
139
|
+
if (lines.slice(i).some((line) => line.trim())) throw new PatchError(`Text after '${END}'.`);
|
|
140
|
+
if (!actions.length) throw new PatchError("The patch has no file operations.");
|
|
141
|
+
return actions;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- content ----------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
type Text = { lines: string[]; eol: string; finalNewline: boolean };
|
|
147
|
+
|
|
148
|
+
function splitText(content: string): Text {
|
|
149
|
+
const eol = content.includes("\r\n") ? "\r\n" : "\n";
|
|
150
|
+
const lines = content.split(/\r?\n/);
|
|
151
|
+
const finalNewline = lines.length > 1 && lines.at(-1) === "";
|
|
152
|
+
if (finalNewline || content === "") lines.pop();
|
|
153
|
+
return { lines, eol, finalNewline };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function joinText(text: Text): string {
|
|
157
|
+
return text.lines.join(text.eol) + (text.finalNewline && text.lines.length ? text.eol : "");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const normalizers: ((line: string) => string)[] = [
|
|
161
|
+
(line) => line,
|
|
162
|
+
(line) => line.trimEnd(),
|
|
163
|
+
(line) => line.trim(),
|
|
164
|
+
// Typographic punctuation models sometimes substitute for ASCII.
|
|
165
|
+
(line) => line.trim().replace(/[‐-―−]/g, "-").replace(/[‘’‛]/g, "'")
|
|
166
|
+
.replace(/[“”‟]/g, "\"").replace(/[ - ]/g, " "),
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
/** First index at or after `from` where `needle` matches, trying stricter comparisons first. */
|
|
170
|
+
function seek(lines: string[], needle: string[], from: number, endOfFile: boolean): number {
|
|
171
|
+
if (!needle.length) return endOfFile ? lines.length : -1;
|
|
172
|
+
for (const normalize of normalizers) {
|
|
173
|
+
const want = needle.map(normalize);
|
|
174
|
+
const matches = (start: number) => want.every((line, k) => normalize(lines[start + k]!) === line);
|
|
175
|
+
if (endOfFile) {
|
|
176
|
+
const start = lines.length - needle.length;
|
|
177
|
+
if (start >= from && matches(start)) return start;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
for (let start = from; start + needle.length <= lines.length; start++) if (matches(start)) return start;
|
|
181
|
+
}
|
|
182
|
+
return -1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Apply every hunk to `content` in order, or throw naming the hunk that does not fit. */
|
|
186
|
+
export function applyHunks(content: string, hunks: readonly PatchHunk[], path: string): string {
|
|
187
|
+
const text = splitText(content);
|
|
188
|
+
let cursor = 0;
|
|
189
|
+
hunks.forEach((hunk, index) => {
|
|
190
|
+
const label = `hunk ${index + 1} of ${path}`;
|
|
191
|
+
if (hunk.anchor !== undefined) {
|
|
192
|
+
const found = seek(text.lines, [hunk.anchor], cursor, false);
|
|
193
|
+
if (found < 0) throw new PatchError(`${label}: could not find the '@@ ${hunk.anchor}' line.`);
|
|
194
|
+
cursor = found + 1;
|
|
195
|
+
}
|
|
196
|
+
const before = hunk.lines.filter((line) => line[0] !== "+").map((line) => line.slice(1));
|
|
197
|
+
const after = hunk.lines.filter((line) => line[0] !== "-").map((line) => line.slice(1));
|
|
198
|
+
let start: number;
|
|
199
|
+
if (!before.length) {
|
|
200
|
+
// Pure insertion: after the anchor, or at the end of the file.
|
|
201
|
+
start = hunk.anchor !== undefined && !hunk.endOfFile ? cursor : text.lines.length;
|
|
202
|
+
} else {
|
|
203
|
+
start = seek(text.lines, before, cursor, hunk.endOfFile);
|
|
204
|
+
if (start < 0) {
|
|
205
|
+
const preview = before.slice(0, 3).join("\\n");
|
|
206
|
+
throw new PatchError(`${label}: the context/removed lines were not found${hunk.endOfFile ? " at the end of the file" : ""} (starting "${preview.slice(0, 120)}").`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
text.lines.splice(start, before.length, ...after);
|
|
210
|
+
cursor = start + after.length;
|
|
211
|
+
});
|
|
212
|
+
if (!content.length && text.lines.length) text.finalNewline = true;
|
|
213
|
+
return joinText(text);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- planning and applying --------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/** The guarded operations the tool is allowed to use. Nothing else touches the disk. */
|
|
219
|
+
export interface PatchFileOperations {
|
|
220
|
+
/** Rejects with code ENOENT when the file does not exist. */
|
|
221
|
+
readFile(path: string): Promise<Buffer>;
|
|
222
|
+
writeFile(path: string, content: string): Promise<void>;
|
|
223
|
+
mkdir(path: string): Promise<void>;
|
|
224
|
+
remove(path: string): Promise<void>;
|
|
225
|
+
/** Policy pre-checks without I/O: throw the refusal the operation itself would. */
|
|
226
|
+
checkWrite(path: string): void;
|
|
227
|
+
checkRemove(path: string): void;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
type FileState = { original: string | null; current: string | null };
|
|
231
|
+
type Step = { kind: "write" | "remove"; path: string; original: string | null; content?: string };
|
|
232
|
+
|
|
233
|
+
export interface ApplyPatchResult {
|
|
234
|
+
summary: string;
|
|
235
|
+
changes: { kind: "add" | "update" | "delete" | "move"; path: string; moveTo?: string }[];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function stripPathQuotes(path: string): string {
|
|
239
|
+
const quoted = /^(["'`]).*\1$/.test(path) ? path.slice(1, -1) : path;
|
|
240
|
+
return quoted.startsWith("@") ? quoted.slice(1) : quoted;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function resolvePatchPath(cwd: string, path: string): string {
|
|
244
|
+
const cleaned = stripPathQuotes(path.trim());
|
|
245
|
+
return isAbsolute(cleaned) ? resolve(cleaned) : resolve(cwd, cleaned);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function describe(action: PatchAction): string {
|
|
249
|
+
if (action.kind === "add") return `add ${action.path}`;
|
|
250
|
+
if (action.kind === "delete") return `delete ${action.path}`;
|
|
251
|
+
return action.moveTo ? `update ${action.path} → ${action.moveTo}` : `update ${action.path}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Validate the whole patch against the guarded operations and current files,
|
|
256
|
+
* then write it. Throws a PatchError before any write when anything is wrong.
|
|
257
|
+
*/
|
|
258
|
+
export async function applyPatch(input: string, cwd: string, ops: PatchFileOperations): Promise<ApplyPatchResult> {
|
|
259
|
+
const actions = parsePatch(input);
|
|
260
|
+
const files = new Map<string, FileState>();
|
|
261
|
+
const load = async (path: string): Promise<FileState> => {
|
|
262
|
+
let state = files.get(path);
|
|
263
|
+
if (state) return state;
|
|
264
|
+
let original: string | null;
|
|
265
|
+
try {
|
|
266
|
+
original = (await ops.readFile(path)).toString("utf8");
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
269
|
+
original = null;
|
|
270
|
+
}
|
|
271
|
+
state = { original, current: original };
|
|
272
|
+
files.set(path, state);
|
|
273
|
+
return state;
|
|
274
|
+
};
|
|
275
|
+
const fail = (action: PatchAction, error: unknown): never => {
|
|
276
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
277
|
+
throw new PatchError(`apply_patch rejected (${describe(action)}): ${message} Nothing was changed.`);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// 1. Validate everything. No write happens in this phase.
|
|
281
|
+
for (const action of actions) {
|
|
282
|
+
try {
|
|
283
|
+
const path = resolvePatchPath(cwd, action.path);
|
|
284
|
+
if (action.kind === "add") {
|
|
285
|
+
ops.checkWrite(path);
|
|
286
|
+
const state = await load(path);
|
|
287
|
+
state.current = action.lines.length ? action.lines.join("\n") + "\n" : "";
|
|
288
|
+
} else if (action.kind === "delete") {
|
|
289
|
+
ops.checkRemove(path);
|
|
290
|
+
const state = await load(path);
|
|
291
|
+
if (state.current === null) throw new PatchError(`${action.path} does not exist.`);
|
|
292
|
+
state.current = null;
|
|
293
|
+
} else {
|
|
294
|
+
ops.checkWrite(path);
|
|
295
|
+
const state = await load(path);
|
|
296
|
+
if (state.current === null) throw new PatchError(`${action.path} does not exist.`);
|
|
297
|
+
const updated = action.hunks.length ? applyHunks(state.current, action.hunks, action.path) : state.current;
|
|
298
|
+
const destination = action.moveTo ? resolvePatchPath(cwd, action.moveTo) : path;
|
|
299
|
+
if (destination === path) {
|
|
300
|
+
state.current = updated;
|
|
301
|
+
} else {
|
|
302
|
+
ops.checkRemove(path);
|
|
303
|
+
ops.checkWrite(destination);
|
|
304
|
+
const target = await load(destination);
|
|
305
|
+
target.current = updated;
|
|
306
|
+
state.current = null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
} catch (error) {
|
|
310
|
+
fail(action, error);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 2. Write whole files first, removals last: a failure part-way then leaves
|
|
315
|
+
// extra files behind rather than losing content.
|
|
316
|
+
const steps: Step[] = [];
|
|
317
|
+
for (const [path, state] of files) {
|
|
318
|
+
if (state.current === state.original) continue;
|
|
319
|
+
if (state.current !== null) steps.push({ kind: "write", path, original: state.original, content: state.current });
|
|
320
|
+
}
|
|
321
|
+
for (const [path, state] of files) {
|
|
322
|
+
if (state.current === null && state.original !== null) steps.push({ kind: "remove", path, original: state.original });
|
|
323
|
+
}
|
|
324
|
+
const done: Step[] = [];
|
|
325
|
+
try {
|
|
326
|
+
for (const step of steps) {
|
|
327
|
+
if (step.kind === "write") {
|
|
328
|
+
if (step.original === null) await ops.mkdir(dirname(step.path));
|
|
329
|
+
await ops.writeFile(step.path, step.content!);
|
|
330
|
+
} else {
|
|
331
|
+
await ops.remove(step.path);
|
|
332
|
+
}
|
|
333
|
+
done.push(step);
|
|
334
|
+
}
|
|
335
|
+
} catch (error) {
|
|
336
|
+
const failed = steps[done.length]!;
|
|
337
|
+
const restored: string[] = [];
|
|
338
|
+
const left: string[] = [];
|
|
339
|
+
for (const step of [...done].reverse()) {
|
|
340
|
+
try {
|
|
341
|
+
if (step.original === null) await ops.remove(step.path);
|
|
342
|
+
else await ops.writeFile(step.path, step.original);
|
|
343
|
+
restored.push(step.path);
|
|
344
|
+
} catch (undo) {
|
|
345
|
+
left.push(`${step.kind === "write" ? (step.original === null ? "created" : "rewrote") : "removed"} ${step.path} (${undo instanceof Error ? undo.message : String(undo)})`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const pending = steps.slice(done.length + 1).map((step) => `${step.kind} ${step.path}`);
|
|
349
|
+
throw new PatchError([
|
|
350
|
+
`apply_patch failed to ${failed.kind} ${failed.path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
351
|
+
`Applied then restored: ${restored.join(", ") || "none"}.`,
|
|
352
|
+
`Still applied (could not restore): ${left.join("; ") || "none"}.`,
|
|
353
|
+
`Not applied: ${[`${failed.kind} ${failed.path}`, ...pending].join(", ")}.`,
|
|
354
|
+
].join("\n"));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const changes = actions.map((action) => action.kind === "update" && action.moveTo
|
|
358
|
+
? { kind: "move" as const, path: action.path, moveTo: action.moveTo }
|
|
359
|
+
: { kind: action.kind, path: action.path });
|
|
360
|
+
return { summary: `Applied patch: ${actions.map(describe).join(", ")}.`, changes };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---- tool definition --------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
export const APPLY_PATCH_TOOL = "apply_patch";
|
|
366
|
+
|
|
367
|
+
/** Same schema as the Codex tool: one `input` string. */
|
|
368
|
+
export const applyPatchParameters = {
|
|
369
|
+
type: "object",
|
|
370
|
+
additionalProperties: false,
|
|
371
|
+
properties: {
|
|
372
|
+
input: { type: "string", description: "Codex apply_patch text beginning with *** Begin Patch and ending with *** End Patch." },
|
|
373
|
+
},
|
|
374
|
+
required: ["input"],
|
|
375
|
+
} as const;
|
|
376
|
+
|
|
377
|
+
/** Every path a patch reads or changes, for serializing with other file tools. */
|
|
378
|
+
export function patchTargets(input: string, cwd: string): string[] {
|
|
379
|
+
const paths = new Set<string>();
|
|
380
|
+
for (const action of parsePatch(input)) {
|
|
381
|
+
paths.add(resolvePatchPath(cwd, action.path));
|
|
382
|
+
if (action.kind === "update" && action.moveTo) paths.add(resolvePatchPath(cwd, action.moveTo));
|
|
383
|
+
}
|
|
384
|
+
return [...paths].sort();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
type MutationQueue = <T>(path: string, fn: () => Promise<T>) => Promise<T>;
|
|
388
|
+
|
|
389
|
+
export function createApplyPatchToolDefinition(cwd: string, ops: PatchFileOperations, queue?: MutationQueue) {
|
|
390
|
+
return {
|
|
391
|
+
name: APPLY_PATCH_TOOL,
|
|
392
|
+
label: "Apply Patch",
|
|
393
|
+
description: "Apply a Codex-style patch (*** Begin Patch … *** End Patch) with Add File, Update File (optionally *** Move to), and Delete File operations. " +
|
|
394
|
+
"Paths are relative to the working directory. The patch follows the same sandbox file rules as write and edit, is validated in full before anything is written, and never leaves a file half-patched.",
|
|
395
|
+
promptSnippet: "Apply Codex-style multi-file patches using the input argument.",
|
|
396
|
+
promptGuidelines: ["Use apply_patch for multi-file or multi-hunk edits when a Codex-style patch is clearer than separate edit/write calls."],
|
|
397
|
+
parameters: applyPatchParameters,
|
|
398
|
+
async execute(_toolCallId: string, params: { input?: unknown }) {
|
|
399
|
+
if (!params || typeof params.input !== "string") throw new PatchError("apply_patch requires an `input` string.");
|
|
400
|
+
const input = params.input;
|
|
401
|
+
// Hold every target's mutation slot so a parallel edit/write cannot interleave.
|
|
402
|
+
const run = (targets: string[]): Promise<ApplyPatchResult> => queue && targets.length
|
|
403
|
+
? queue(targets[0]!, () => run(targets.slice(1)))
|
|
404
|
+
: applyPatch(input, cwd, ops);
|
|
405
|
+
const result = await run(patchTargets(input, cwd));
|
|
406
|
+
return { content: [{ type: "text" as const, text: `${result.summary}\nFiles changed: ${result.changes.length}` }], details: result };
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
package/shared-task-files.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
/** Kernel-confined operations for Pi's built-in file tools. */
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { constants } from "node:fs";
|
|
6
|
-
import { access, mkdir, open, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { constants, realpathSync } from "node:fs";
|
|
6
|
+
import { access, lstat, mkdir, open, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import { basename, dirname, join } from "node:path";
|
|
7
8
|
import type { ReadOperations, WriteOperations, EditOperations } from "@earendil-works/pi-coding-agent";
|
|
8
9
|
import {
|
|
9
|
-
canonicalizePath, compileWritePolicy, evaluateReadAccess, evaluateWriteAccess, maybeBuildSandboxCommand,
|
|
10
|
-
type SandboxWritePolicy,
|
|
10
|
+
canonicalizePath, compileWritePolicy, evaluateDeleteAccess, evaluateReadAccess, evaluateWriteAccess, maybeBuildSandboxCommand,
|
|
11
|
+
type CompiledSandboxWritePolicy, type SandboxSeams, type SandboxWritePolicy, type WriteAccessDecision,
|
|
11
12
|
} from "./shared-sandbox-core.ts";
|
|
12
13
|
|
|
13
14
|
export interface TaskFileController {
|
|
@@ -73,6 +74,9 @@ process.stdin.on('end', async () => {
|
|
|
73
74
|
if (typeof content !== 'string' || Buffer.byteLength(content, 'utf8') > MAX)
|
|
74
75
|
throw Object.assign(new Error('File exceeds 8 MiB operation limit'), {code:'EFBIG'});
|
|
75
76
|
await fs.writeFile(path, content, 'utf8'); break;
|
|
77
|
+
case 'unlink':
|
|
78
|
+
if ((await fs.lstat(path)).isDirectory()) throw Object.assign(new Error('Refusing to remove a directory'), {code:'EISDIR'});
|
|
79
|
+
await fs.unlink(path); break;
|
|
76
80
|
default: throw new Error('Unknown file operation');
|
|
77
81
|
}
|
|
78
82
|
process.stdout.write(JSON.stringify({ok:true, data}));
|
|
@@ -82,7 +86,48 @@ process.stdin.on('end', async () => {
|
|
|
82
86
|
}
|
|
83
87
|
});`;
|
|
84
88
|
|
|
85
|
-
type Operation = "read" | "mime" | "access-read" | "access-edit" | "mkdir" | "existing-directory" | "write";
|
|
89
|
+
type Operation = "read" | "mime" | "access-read" | "access-edit" | "mkdir" | "existing-directory" | "write" | "unlink";
|
|
90
|
+
|
|
91
|
+
/** Guarded removal of one file (never a directory). Follows the same launch policy as writes. */
|
|
92
|
+
export interface TaskRemoveOperations {
|
|
93
|
+
remove(path: string): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Policy pre-checks with no I/O, so a multi-file tool can validate a whole
|
|
98
|
+
* request before changing anything. Each throws the same refusal the operation
|
|
99
|
+
* itself would; unconfined plans allow everything.
|
|
100
|
+
*/
|
|
101
|
+
export interface TaskAccessChecks {
|
|
102
|
+
write(path: string): void;
|
|
103
|
+
remove(path: string): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function refusal(verb: string, decision: WriteAccessDecision): Error {
|
|
107
|
+
const detail = !decision.allowed && decision.deniedBy ? ` (protected by ${decision.deniedBy})` : "";
|
|
108
|
+
return new Error(`Task sandbox refused to ${verb} ${decision.path}: ${decision.allowed ? "" : decision.reason}${detail}.`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Removing an entry is judged on both its resolved target and its own directory
|
|
113
|
+
* entry, so a link can neither remove a protected file nor hide a protected entry.
|
|
114
|
+
* The entry is evaluated without following its final component: the parent is
|
|
115
|
+
* canonicalized, the leaf is taken literally. Otherwise a link planted in
|
|
116
|
+
* `~/.ssh` or a sibling repository would be judged by its (deletable) target.
|
|
117
|
+
*/
|
|
118
|
+
export function removeDecision(path: string, policy: CompiledSandboxWritePolicy): WriteAccessDecision {
|
|
119
|
+
const target = evaluateDeleteAccess(path, policy);
|
|
120
|
+
if (!target.allowed) return target;
|
|
121
|
+
const entry = join(canonicalizePath(dirname(path)), basename(path));
|
|
122
|
+
if (entry === target.path) return target;
|
|
123
|
+
const literalLeaf: SandboxSeams = {
|
|
124
|
+
canonicalize: (candidate) => {
|
|
125
|
+
if (candidate === entry) throw new Error("the leaf entry is judged literally");
|
|
126
|
+
return realpathSync(candidate);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
return evaluateDeleteAccess(entry, policy, literalLeaf);
|
|
130
|
+
}
|
|
86
131
|
|
|
87
132
|
function mimeType(data: Buffer): string | null {
|
|
88
133
|
const ascii = (at: number, text: string) => data.toString("ascii", at, at + text.length) === text;
|
|
@@ -117,7 +162,7 @@ function mimeType(data: Buffer): string | null {
|
|
|
117
162
|
}
|
|
118
163
|
|
|
119
164
|
export function createTaskFileOperations(controller: TaskFileController): {
|
|
120
|
-
read: ReadOperations; write: WriteOperations; edit: EditOperations;
|
|
165
|
+
read: ReadOperations; write: WriteOperations; edit: EditOperations; remove: TaskRemoveOperations; check: TaskAccessChecks;
|
|
121
166
|
} {
|
|
122
167
|
async function run(operation: Operation, path: string, content?: string): Promise<Buffer | void> {
|
|
123
168
|
const plan = controller.requireLaunchPlan();
|
|
@@ -139,11 +184,18 @@ export function createTaskFileOperations(controller: TaskFileController): {
|
|
|
139
184
|
return;
|
|
140
185
|
case "mkdir": await mkdir(path, { recursive: true }); return;
|
|
141
186
|
case "write": return writeFile(path, content!, "utf8");
|
|
187
|
+
case "unlink":
|
|
188
|
+
if ((await lstat(path)).isDirectory()) throw Object.assign(new Error("Refusing to remove a directory"), { code: "EISDIR" });
|
|
189
|
+
return unlink(path);
|
|
142
190
|
}
|
|
143
191
|
}
|
|
144
192
|
|
|
145
193
|
const policy = compileWritePolicy(plan.policy);
|
|
146
|
-
|
|
194
|
+
if (operation === "unlink") {
|
|
195
|
+
const decision = removeDecision(path, policy);
|
|
196
|
+
if (!decision.allowed) throw refusal("remove", decision);
|
|
197
|
+
}
|
|
198
|
+
const writing = operation === "write" || operation === "mkdir" || operation === "access-edit" || operation === "unlink";
|
|
147
199
|
const decision = writing ? evaluateWriteAccess(path, policy) : evaluateReadAccess(path, policy);
|
|
148
200
|
if (!decision.allowed && operation !== "existing-directory") {
|
|
149
201
|
if (operation === "mkdir" && decision.reason === "permission-denied") {
|
|
@@ -227,7 +279,17 @@ export function createTaskFileOperations(controller: TaskFileController): {
|
|
|
227
279
|
});
|
|
228
280
|
}
|
|
229
281
|
|
|
282
|
+
const check = (verb: "write" | "remove") => (path: string) => {
|
|
283
|
+
const plan = controller.requireLaunchPlan();
|
|
284
|
+
if (!plan.confined) return;
|
|
285
|
+
const policy = compileWritePolicy(plan.policy);
|
|
286
|
+
const decision = verb === "write" ? evaluateWriteAccess(path, policy) : removeDecision(path, policy);
|
|
287
|
+
if (!decision.allowed) throw refusal(verb, decision);
|
|
288
|
+
};
|
|
289
|
+
|
|
230
290
|
return {
|
|
291
|
+
remove: { remove: (path) => run("unlink", path) as Promise<void> },
|
|
292
|
+
check: { write: check("write"), remove: check("remove") },
|
|
231
293
|
read: {
|
|
232
294
|
readFile: (path) => run("read", path) as Promise<Buffer>,
|
|
233
295
|
access: (path) => run("access-read", path) as Promise<void>,
|
package/shared-task-sandbox.ts
CHANGED
|
@@ -11,8 +11,11 @@ import { homedir, tmpdir } from "node:os";
|
|
|
11
11
|
import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
12
12
|
import { canonicalizePath, compileWritePolicy, isRemovableUnderWrite, maybeBuildSandboxCommand, type SandboxPermissions } from "./shared-sandbox-core.ts";
|
|
13
13
|
import { createTaskFileOperations, type TaskFileController } from "./shared-task-files.ts";
|
|
14
|
+
import { APPLY_PATCH_TOOL, createApplyPatchToolDefinition } from "./shared-task-apply-patch.ts";
|
|
14
15
|
|
|
15
16
|
export const TASK_BUILTINS = Object.freeze(["read", "write", "edit", "bash"] as const);
|
|
17
|
+
/** Harness adapters that follow the file rules; each is a task builtin only when the profile enables it. */
|
|
18
|
+
export const GUARDED_TASK_TOOLS = Object.freeze([APPLY_PATCH_TOOL] as const);
|
|
16
19
|
|
|
17
20
|
/** Loaded code and its installed dependencies must remain task-read-only. */
|
|
18
21
|
export function runtimeCodeRoot(path: string): string {
|
|
@@ -165,6 +168,8 @@ export function installTaskTools(pi: ExtensionAPI, options: {
|
|
|
165
168
|
shellPath?: () => string | undefined;
|
|
166
169
|
trustedSources: readonly string[];
|
|
167
170
|
admitExtensionTool?: (name: string, input: unknown, sourcePath: string | undefined) => boolean;
|
|
171
|
+
/** Register the guarded apply_patch adapter as a task builtin. */
|
|
172
|
+
applyPatch?: boolean;
|
|
168
173
|
/** Build the admitted bash definition from the confined operations (default: the SDK bash tool). */
|
|
169
174
|
bashDefinition?: (cwd: string, operations: BashOperations) => ReturnType<typeof createBashToolDefinition>;
|
|
170
175
|
}) {
|
|
@@ -188,6 +193,12 @@ export function installTaskTools(pi: ExtensionAPI, options: {
|
|
|
188
193
|
pi.registerTool(own(createWriteToolDefinition(cwd, { operations: files.write })));
|
|
189
194
|
pi.registerTool(own(createEditToolDefinition(cwd, { operations: files.edit })));
|
|
190
195
|
pi.registerTool(own(options.bashDefinition ? options.bashDefinition(cwd, bash) : createBashToolDefinition(cwd, { operations: bash })));
|
|
196
|
+
if (options.applyPatch) {
|
|
197
|
+
pi.registerTool(own(createApplyPatchToolDefinition(cwd, {
|
|
198
|
+
readFile: files.read.readFile, writeFile: files.write.writeFile, mkdir: files.write.mkdir,
|
|
199
|
+
remove: files.remove.remove, checkWrite: files.check.write, checkRemove: files.check.remove,
|
|
200
|
+
}, PiCodingAgent.withFileMutationQueue)) as any);
|
|
201
|
+
}
|
|
191
202
|
};
|
|
192
203
|
register(options.cwd);
|
|
193
204
|
pi.on("user_bash", () => ({ operations: bash }));
|