pi-codex-tools 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,652 @@
1
+ // Adapted from OpenAI Codex apply-patch grammar/parser behavior; see NOTICE.
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, realpath, unlink } from "node:fs/promises";
4
+ import type { FileHandle } from "node:fs/promises";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
7
+
8
+ export const MAX_PATCH_BYTES = 1_048_576;
9
+ export const MAX_PATCH_HUNKS = 1_000;
10
+ export const MAX_TARGET_FILE_BYTES = 64 * 1024 * 1024;
11
+
12
+ const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
13
+ const SECURE_FD_DIRECTORY = process.platform === "linux" ? "/proc/self/fd" : process.platform === "darwin" ? "/dev/fd" : undefined;
14
+ const SECURE_FILESYSTEM_SUPPORTED = SECURE_FD_DIRECTORY !== undefined && O_NOFOLLOW !== 0;
15
+ const SECURE_DIRECTORY_FLAGS = constants.O_RDONLY | O_NOFOLLOW | (constants.O_DIRECTORY ?? 0) | (constants.O_NONBLOCK ?? 0);
16
+ const SECURE_READ_FLAGS = constants.O_RDONLY | O_NOFOLLOW | (constants.O_NONBLOCK ?? 0);
17
+ const SECURE_UPDATE_FLAGS = constants.O_WRONLY | O_NOFOLLOW | constants.O_TRUNC | (constants.O_NONBLOCK ?? 0);
18
+ const SECURE_CREATE_FLAGS = SECURE_UPDATE_FLAGS | constants.O_CREAT;
19
+ const FILE_READ_CHUNK_BYTES = 64 * 1024;
20
+
21
+ export const APPLY_PATCH_GRAMMAR = `start: begin_patch hunk+ end_patch
22
+ begin_patch: "*** Begin Patch" LF
23
+ end_patch: "*** End Patch" LF?
24
+
25
+ hunk: add_hunk | delete_hunk | update_hunk
26
+ add_hunk: "*** Add File: " filename LF add_line+
27
+ delete_hunk: "*** Delete File: " filename LF
28
+ update_hunk: "*** Update File: " filename LF change_move? change?
29
+ filename: /(.+)/
30
+ add_line: "+" /(.*)/ LF -> line
31
+
32
+ change_move: "*** Move to: " filename LF
33
+ change: (change_context | change_line)+ eof_line?
34
+ change_context: ("@@" | "@@ " /(.+)/) LF
35
+ change_line: ("+" | "-" | " ") /(.*)/ LF
36
+ eof_line: "*** End of File" LF
37
+
38
+ %import common.LF`;
39
+
40
+ export interface UpdateChunk {
41
+ context?: string;
42
+ oldLines: string[];
43
+ newLines: string[];
44
+ endOfFile: boolean;
45
+ }
46
+
47
+ export type ApplyPatchHunk =
48
+ | { kind: "add"; path: string; content: string }
49
+ | { kind: "delete"; path: string }
50
+ | { kind: "update"; path: string; moveTo?: string; chunks: UpdateChunk[] };
51
+
52
+ export interface ApplyPatchResult {
53
+ changes: Array<{ kind: "added" | "updated" | "deleted"; path: string; moveTo?: string }>;
54
+ }
55
+
56
+ export interface ApplyPatchOptions {
57
+ cwd: string;
58
+ signal?: AbortSignal;
59
+ }
60
+
61
+ export function parseApplyPatch(input: string): ApplyPatchHunk[] {
62
+ if (typeof input !== "string") throw new Error("apply_patch input must be a string.");
63
+ if (Buffer.byteLength(input, "utf8") > MAX_PATCH_BYTES) {
64
+ throw new Error(`apply_patch input exceeds the ${MAX_PATCH_BYTES}-byte limit.`);
65
+ }
66
+
67
+ const lines = input.replace(/\r\n?/g, "\n").trim().split("\n");
68
+ if (lines[0]?.trim() !== "*** Begin Patch") {
69
+ throw new Error("The first line of the patch must be '*** Begin Patch'.");
70
+ }
71
+ if (lines.at(-1)?.trim() !== "*** End Patch") {
72
+ throw new Error("The last line of the patch must be '*** End Patch'.");
73
+ }
74
+
75
+ const hunks: ApplyPatchHunk[] = [];
76
+ let index = 1;
77
+ while (index < lines.length - 1) {
78
+ const line = lines[index];
79
+ const trimmed = line.trim();
80
+ if (trimmed === "*** End Patch") break;
81
+ if (trimmed === "") {
82
+ throw new Error(`Unexpected blank line at patch line ${index + 1}.`);
83
+ }
84
+
85
+ if (trimmed.startsWith("*** Add File: ")) {
86
+ const path = headerPath(trimmed, "*** Add File: ", index + 1);
87
+ index++;
88
+ const content: string[] = [];
89
+ while (index < lines.length - 1 && !isHunkHeader(lines[index])) {
90
+ if (!lines[index].startsWith("+")) {
91
+ throw new Error(`Invalid add hunk at patch line ${index + 1}: every line must start with '+'.`);
92
+ }
93
+ content.push(lines[index].slice(1));
94
+ index++;
95
+ }
96
+ if (content.length === 0) {
97
+ throw new Error(`Add hunk for '${path}' must contain at least one line.`);
98
+ }
99
+ hunks.push({ kind: "add", path, content: `${content.join("\n")}\n` });
100
+ continue;
101
+ }
102
+
103
+ if (trimmed.startsWith("*** Delete File: ")) {
104
+ const path = headerPath(trimmed, "*** Delete File: ", index + 1);
105
+ hunks.push({ kind: "delete", path });
106
+ index++;
107
+ continue;
108
+ }
109
+
110
+ if (trimmed.startsWith("*** Update File: ")) {
111
+ const path = headerPath(trimmed, "*** Update File: ", index + 1);
112
+ index++;
113
+ let moveTo: string | undefined;
114
+ if (index < lines.length - 1 && lines[index].startsWith("*** Move to: ")) {
115
+ moveTo = headerPath(lines[index], "*** Move to: ", index + 1);
116
+ index++;
117
+ }
118
+
119
+ const chunks: UpdateChunk[] = [];
120
+ let current: UpdateChunk | undefined;
121
+ while (index < lines.length - 1 && !isUpdateBoundary(lines[index])) {
122
+ const raw = lines[index];
123
+ const currentLine = raw.trimEnd();
124
+
125
+ if (currentLine === "*** End of File") {
126
+ if (!current || (current.oldLines.length === 0 && current.newLines.length === 0)) {
127
+ throw new Error(`Update hunk for '${path}' has no change lines at patch line ${index + 1}.`);
128
+ }
129
+ current.endOfFile = true;
130
+ index++;
131
+ continue;
132
+ }
133
+
134
+ if (currentLine === "@@" || currentLine.startsWith("@@ ")) {
135
+ if (current && current.oldLines.length === 0 && current.newLines.length === 0) {
136
+ throw new Error(`Update hunk for '${path}' has an empty chunk at patch line ${index + 1}.`);
137
+ }
138
+ const context = currentLine === "@@" ? undefined : currentLine.slice(3);
139
+ current = {
140
+ ...(context === undefined ? {} : { context }),
141
+ oldLines: [],
142
+ newLines: [],
143
+ endOfFile: false,
144
+ };
145
+ chunks.push(current);
146
+ index++;
147
+ continue;
148
+ }
149
+
150
+ if (current?.endOfFile && currentLine === "") {
151
+ index++;
152
+ continue;
153
+ }
154
+
155
+ current ??= { oldLines: [], newLines: [], endOfFile: false };
156
+ if (raw === "") {
157
+ current.oldLines.push("");
158
+ current.newLines.push("");
159
+ } else if (raw.startsWith(" ")) {
160
+ const text = raw.slice(1);
161
+ current.oldLines.push(text);
162
+ current.newLines.push(text);
163
+ } else if (raw.startsWith("+")) {
164
+ current.newLines.push(raw.slice(1));
165
+ } else if (raw.startsWith("-")) {
166
+ current.oldLines.push(raw.slice(1));
167
+ } else {
168
+ throw new Error(
169
+ `Unexpected line at patch line ${index + 1}. Every update line must start with ' ', '+' or '-'.`,
170
+ );
171
+ }
172
+ index++;
173
+ }
174
+
175
+ if (chunks.length === 0 || chunks.every((chunk) => chunk.oldLines.length === 0 && chunk.newLines.length === 0)) {
176
+ throw new Error(`Update hunk for '${path}' must contain change lines.`);
177
+ }
178
+ hunks.push({ kind: "update", path, moveTo, chunks });
179
+ continue;
180
+ }
181
+
182
+ throw new Error(`Invalid hunk header at patch line ${index + 1}: '${line}'.`);
183
+ }
184
+
185
+ if (hunks.length === 0) {
186
+ throw new Error("Patch must contain at least one file hunk.");
187
+ }
188
+ if (hunks.length > MAX_PATCH_HUNKS) {
189
+ throw new Error(`Patch contains more than the ${MAX_PATCH_HUNKS}-hunk limit.`);
190
+ }
191
+ return hunks;
192
+ }
193
+
194
+ function isHunkHeader(line: string): boolean {
195
+ const trimmed = line.trim();
196
+ return (
197
+ trimmed.startsWith("*** Add File: ") ||
198
+ trimmed.startsWith("*** Delete File: ") ||
199
+ trimmed.startsWith("*** Update File: ") ||
200
+ trimmed === "*** End Patch"
201
+ );
202
+ }
203
+
204
+ function isUpdateBoundary(line: string): boolean {
205
+ // Keep leading whitespace: one leading space is the update context marker.
206
+ const currentLine = line.trimEnd();
207
+ return (
208
+ currentLine.startsWith("*** Add File: ") ||
209
+ currentLine.startsWith("*** Delete File: ") ||
210
+ currentLine.startsWith("*** Update File: ") ||
211
+ currentLine === "*** End Patch"
212
+ );
213
+ }
214
+
215
+ function headerPath(line: string, marker: string, lineNumber: number): string {
216
+ const path = line.slice(marker.length).trim();
217
+ if (!path) throw new Error(`Missing path at patch line ${lineNumber}.`);
218
+ if (path.includes("\0")) throw new Error(`NUL byte in path at patch line ${lineNumber}.`);
219
+ return path;
220
+ }
221
+
222
+ type PlannedOperation =
223
+ | { kind: "add"; path: string; displayPath: string; content: string }
224
+ | { kind: "delete"; path: string; displayPath: string }
225
+ | { kind: "update"; path: string; displayPath: string; moveTo?: string; moveDisplayPath?: string; chunkGroups: UpdateChunk[][]; content: string };
226
+
227
+ type SafePath = { absolute: string; exists: boolean; isDirectory: boolean; isFile: boolean };
228
+
229
+ export async function applyPatch(input: string, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
230
+ requireSecureFilesystem();
231
+ const hunks = parseApplyPatch(input);
232
+ const root = await realpath(resolve(options.cwd));
233
+ const lockPaths = hunks.flatMap((hunk) => {
234
+ const paths = [resolvePatchPath(hunk.path, root)];
235
+ if (hunk.kind === "update" && hunk.moveTo) paths.push(resolvePatchPath(hunk.moveTo, root));
236
+ return paths;
237
+ });
238
+
239
+ return withMutationLocks(lockPaths, async () => {
240
+ const rootHandle = await openSecureRoot(root, options.signal);
241
+ try {
242
+ const operations = await planOperations(hunks, root, rootHandle, options.signal);
243
+ throwIfAborted(options.signal);
244
+ // ponytail: preflight catches parse/match errors before writes; cross-process failures can still leave a partial multi-file patch.
245
+ for (const operation of operations) {
246
+ throwIfAborted(options.signal);
247
+ if (operation.kind === "add") {
248
+ await writeSecureFile(rootHandle, root, operation.path, operation.content, true, options.signal);
249
+ } else if (operation.kind === "delete") {
250
+ await removeSecureFile(rootHandle, root, operation.path, options.signal);
251
+ } else if (operation.moveTo) {
252
+ await writeSecureFile(rootHandle, root, operation.moveTo, operation.content, true, options.signal);
253
+ await removeSecureFile(rootHandle, root, operation.path, options.signal);
254
+ } else {
255
+ await writeSecureFile(rootHandle, root, operation.path, operation.content, false, options.signal);
256
+ }
257
+ }
258
+
259
+ return {
260
+ changes: operations.map((operation) => ({
261
+ kind: operation.kind === "add" ? "added" : operation.kind === "delete" ? "deleted" : "updated",
262
+ path: operation.displayPath,
263
+ ...(operation.kind === "update" && operation.moveDisplayPath ? { moveTo: operation.moveDisplayPath } : {}),
264
+ })),
265
+ };
266
+ } finally {
267
+ await rootHandle.close();
268
+ }
269
+ });
270
+ }
271
+
272
+ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootHandle: FileHandle, signal?: AbortSignal): Promise<PlannedOperation[]> {
273
+ const operations: PlannedOperation[] = [];
274
+ const byPath = new Map<string, PlannedOperation>();
275
+ const occupied = new Set<string>();
276
+
277
+ for (const hunk of hunks) {
278
+ throwIfAborted(signal);
279
+ const source = await safePath(hunk.path, root, signal);
280
+ if (occupied.has(source.absolute) && !(hunk.kind === "update" && byPath.get(source.absolute)?.kind === "update")) {
281
+ throw new Error(`Patch addresses '${hunk.path}' more than once.`);
282
+ }
283
+
284
+ if (hunk.kind === "add") {
285
+ if (source.isDirectory || (source.exists && !source.isFile)) throw new Error(`Cannot add file over non-file '${hunk.path}'.`);
286
+ const operation: PlannedOperation = { kind: "add", path: source.absolute, displayPath: displayPath(root, source.absolute, hunk.path), content: hunk.content };
287
+ operations.push(operation);
288
+ byPath.set(source.absolute, operation);
289
+ occupied.add(source.absolute);
290
+ continue;
291
+ }
292
+
293
+ if (hunk.kind === "delete") {
294
+ if (!source.exists) throw new Error(`Cannot delete missing file '${hunk.path}'.`);
295
+ if (source.isDirectory || !source.isFile) throw new Error(`Cannot delete non-file '${hunk.path}'.`);
296
+ const operation: PlannedOperation = { kind: "delete", path: source.absolute, displayPath: displayPath(root, source.absolute, hunk.path) };
297
+ operations.push(operation);
298
+ byPath.set(source.absolute, operation);
299
+ occupied.add(source.absolute);
300
+ continue;
301
+ }
302
+
303
+ if (!source.exists) throw new Error(`Cannot update missing file '${hunk.path}'.`);
304
+ if (source.isDirectory || !source.isFile) throw new Error(`Cannot update non-file '${hunk.path}'.`);
305
+ const original = await readSecureFile(rootHandle, root, source.absolute, signal);
306
+ const moveTo = hunk.moveTo ? (await safePath(hunk.moveTo, root, signal)).absolute : undefined;
307
+ if (moveTo === source.absolute) throw new Error(`Cannot move '${hunk.path}' onto itself.`);
308
+ if (moveTo) {
309
+ const destination = await safePath(hunk.moveTo!, root, signal);
310
+ if (destination.isDirectory || (destination.exists && !destination.isFile)) throw new Error(`Cannot move file over non-file '${hunk.moveTo}'.`);
311
+ if (occupied.has(moveTo)) throw new Error(`Patch addresses move destination '${hunk.moveTo}' more than once.`);
312
+ occupied.add(moveTo);
313
+ }
314
+
315
+ const existing = byPath.get(source.absolute);
316
+ if (existing?.kind === "update") {
317
+ if (existing.moveTo || moveTo) throw new Error(`A file can only be moved once in a patch: '${hunk.path}'.`);
318
+ existing.chunkGroups.push(hunk.chunks);
319
+ existing.content = existing.chunkGroups.reduce(
320
+ (content, chunks) => applyUpdateContent(content, chunks, hunk.path),
321
+ original,
322
+ );
323
+ continue;
324
+ }
325
+
326
+ const operation: PlannedOperation = {
327
+ kind: "update",
328
+ path: source.absolute,
329
+ displayPath: displayPath(root, source.absolute, hunk.path),
330
+ moveTo,
331
+ moveDisplayPath: moveTo ? displayPath(root, moveTo, hunk.moveTo!) : undefined,
332
+ chunkGroups: [[...hunk.chunks]],
333
+ content: applyUpdateContent(original, hunk.chunks, hunk.path),
334
+ };
335
+ operations.push(operation);
336
+ byPath.set(source.absolute, operation);
337
+ occupied.add(source.absolute);
338
+ }
339
+
340
+ for (const operation of operations) {
341
+ if (operation.kind === "update" && operation.moveTo && byPath.has(operation.moveTo)) {
342
+ throw new Error(`Move destination '${operation.moveDisplayPath}' is also changed by this patch.`);
343
+ }
344
+ }
345
+ return operations;
346
+ }
347
+
348
+ function resolvePatchPath(rawPath: string, root: string): string {
349
+ const absolute = isAbsolute(rawPath) ? resolve(rawPath) : resolve(root, rawPath);
350
+ if (!isWithin(root, absolute) || absolute === root) {
351
+ throw new Error(`Patch path must stay inside the current working directory: ${rawPath}`);
352
+ }
353
+ return absolute;
354
+ }
355
+
356
+ async function safePath(rawPath: string, root: string, signal?: AbortSignal): Promise<SafePath> {
357
+ throwIfAborted(signal);
358
+ const absolute = resolvePatchPath(rawPath, root);
359
+
360
+ let current = absolute;
361
+ while (true) {
362
+ throwIfAborted(signal);
363
+ try {
364
+ const stats = await lstat(current);
365
+ if (stats.isSymbolicLink()) {
366
+ throw new Error(`Symlink paths are not allowed in apply_patch: ${rawPath}`);
367
+ }
368
+ const resolved = await realpath(current);
369
+ if (!isWithin(root, resolved)) {
370
+ throw new Error(`Patch path escapes the current working directory: ${rawPath}`);
371
+ }
372
+ if (current !== absolute && !stats.isDirectory()) {
373
+ throw new Error(`Parent path is not a directory: ${rawPath}`);
374
+ }
375
+ return {
376
+ absolute,
377
+ exists: current === absolute,
378
+ isDirectory: current === absolute && stats.isDirectory(),
379
+ isFile: current === absolute && stats.isFile(),
380
+ };
381
+ } catch (error) {
382
+ if (!isMissingPathError(error)) throw error;
383
+ const parent = dirname(current);
384
+ if (parent === current) throw new Error(`Cannot resolve patch path: ${rawPath}`);
385
+ current = parent;
386
+ }
387
+ }
388
+ }
389
+
390
+ function requireSecureFilesystem(): void {
391
+ if (!SECURE_FILESYSTEM_SUPPORTED) {
392
+ throw new Error("apply_patch requires a POSIX filesystem with descriptor-based no-follow support.");
393
+ }
394
+ }
395
+
396
+ function secureChildPath(parent: FileHandle, child: string): string {
397
+ if (!SECURE_FD_DIRECTORY) throw new Error("Secure filesystem operations are unavailable on this platform.");
398
+ return join(SECURE_FD_DIRECTORY, String(parent.fd), child);
399
+ }
400
+
401
+ async function openSecureRoot(root: string, signal?: AbortSignal): Promise<FileHandle> {
402
+ requireSecureFilesystem();
403
+ let current = await open(sep, SECURE_DIRECTORY_FLAGS);
404
+ try {
405
+ for (const component of root.split(sep).filter(Boolean)) {
406
+ throwIfAborted(signal);
407
+ const next = await openSecureDirectoryChild(current, component);
408
+ await current.close();
409
+ current = next;
410
+ }
411
+ return current;
412
+ } catch (error) {
413
+ await current.close().catch(() => undefined);
414
+ throw error;
415
+ }
416
+ }
417
+
418
+ async function openSecureDirectoryChild(parent: FileHandle, component: string): Promise<FileHandle> {
419
+ const handle = await open(secureChildPath(parent, component), SECURE_DIRECTORY_FLAGS);
420
+ try {
421
+ if (!(await handle.stat()).isDirectory()) throw new Error(`Secure path component is not a directory: ${component}`);
422
+ return handle;
423
+ } catch (error) {
424
+ await handle.close().catch(() => undefined);
425
+ throw error;
426
+ }
427
+ }
428
+
429
+ type SecureParent = { handle: FileHandle; owned: boolean };
430
+
431
+ async function openSecureParentDirectory(rootHandle: FileHandle, root: string, absolute: string, createParents: boolean, signal?: AbortSignal): Promise<SecureParent> {
432
+ const parentPath = dirname(absolute);
433
+ const relativeParent = relative(root, parentPath);
434
+ const components = relativeParent ? relativeParent.split(sep) : [];
435
+ if (components.some((component) => !component || component === "." || component === "..")) {
436
+ throw new Error(`Patch path must stay inside the current working directory: ${absolute}`);
437
+ }
438
+
439
+ let current = rootHandle;
440
+ let owned = false;
441
+ try {
442
+ for (const component of components) {
443
+ throwIfAborted(signal);
444
+ let next: FileHandle;
445
+ try {
446
+ next = await openSecureDirectoryChild(current, component);
447
+ } catch (error) {
448
+ if (!createParents || !isNoEntryError(error)) throw error;
449
+ try {
450
+ await mkdir(secureChildPath(current, component));
451
+ } catch (mkdirError) {
452
+ if (!isAlreadyExistsError(mkdirError)) throw mkdirError;
453
+ }
454
+ next = await openSecureDirectoryChild(current, component);
455
+ }
456
+ if (owned) await current.close();
457
+ current = next;
458
+ owned = true;
459
+ }
460
+ return { handle: current, owned };
461
+ } catch (error) {
462
+ if (owned) await current.close().catch(() => undefined);
463
+ throw error;
464
+ }
465
+ }
466
+
467
+ async function withSecureFile<T>(
468
+ rootHandle: FileHandle,
469
+ root: string,
470
+ absolute: string,
471
+ flags: number,
472
+ createParents: boolean,
473
+ callback: (file: FileHandle, size: number) => Promise<T>,
474
+ signal?: AbortSignal,
475
+ ): Promise<T> {
476
+ const parent = await openSecureParentDirectory(rootHandle, root, absolute, createParents, signal);
477
+ let file: FileHandle | undefined;
478
+ try {
479
+ file = await open(secureChildPath(parent.handle, basename(absolute)), flags, 0o666);
480
+ const stats = await file.stat();
481
+ if (!stats.isFile()) throw new Error(`Patch target is not a regular file: ${absolute}`);
482
+ return await callback(file, stats.size);
483
+ } finally {
484
+ await file?.close().catch(() => undefined);
485
+ if (parent.owned) await parent.handle.close().catch(() => undefined);
486
+ }
487
+ }
488
+
489
+ async function readSecureFile(rootHandle: FileHandle, root: string, absolute: string, signal?: AbortSignal): Promise<string> {
490
+ return withSecureFile(rootHandle, root, absolute, SECURE_READ_FLAGS, false, async (file, size) => {
491
+ if (size > MAX_TARGET_FILE_BYTES) {
492
+ throw new Error(`Patch target exceeds the ${MAX_TARGET_FILE_BYTES}-byte limit: ${absolute}`);
493
+ }
494
+
495
+ const chunks: Buffer[] = [];
496
+ let total = 0;
497
+ while (true) {
498
+ throwIfAborted(signal);
499
+ const buffer = Buffer.alloc(Math.min(FILE_READ_CHUNK_BYTES, MAX_TARGET_FILE_BYTES + 1 - total));
500
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, null);
501
+ if (bytesRead === 0) break;
502
+ total += bytesRead;
503
+ chunks.push(buffer.subarray(0, bytesRead));
504
+ if (total > MAX_TARGET_FILE_BYTES) {
505
+ throw new Error(`Patch target exceeds the ${MAX_TARGET_FILE_BYTES}-byte limit: ${absolute}`);
506
+ }
507
+ }
508
+ return Buffer.concat(chunks, total).toString("utf8");
509
+ }, signal);
510
+ }
511
+
512
+ async function writeSecureFile(rootHandle: FileHandle, root: string, absolute: string, content: string, createParents: boolean, signal?: AbortSignal): Promise<void> {
513
+ const flags = createParents ? SECURE_CREATE_FLAGS : SECURE_UPDATE_FLAGS;
514
+ await withSecureFile(rootHandle, root, absolute, flags, createParents, async (file) => {
515
+ await file.writeFile(content, "utf8");
516
+ }, signal);
517
+ }
518
+
519
+ async function removeSecureFile(rootHandle: FileHandle, root: string, absolute: string, signal?: AbortSignal): Promise<void> {
520
+ const parent = await openSecureParentDirectory(rootHandle, root, absolute, false, signal);
521
+ try {
522
+ const target = secureChildPath(parent.handle, basename(absolute));
523
+ const stats = await lstat(target);
524
+ if (stats.isSymbolicLink()) throw new Error(`Symlink paths are not allowed in apply_patch: ${absolute}`);
525
+ if (stats.isDirectory()) throw new Error(`Cannot delete directory '${absolute}'.`);
526
+ await unlink(target);
527
+ } finally {
528
+ if (parent.owned) await parent.handle.close().catch(() => undefined);
529
+ }
530
+ }
531
+
532
+ function applyUpdateContent(original: string, chunks: UpdateChunk[], displayPath: string): string {
533
+ const bom = original.startsWith("\uFEFF") ? "\uFEFF" : "";
534
+ const body = bom ? original.slice(1) : original;
535
+ const lineEnding = body.includes("\r\n") ? "\r\n" : "\n";
536
+ const lines = body.replace(/\r\n/g, "\n").split("\n");
537
+ if (lines.at(-1) === "") lines.pop();
538
+
539
+ const replacements: Array<{ start: number; length: number; lines: string[] }> = [];
540
+ let lineIndex = 0;
541
+ for (const chunk of chunks) {
542
+ if (chunk.context !== undefined) {
543
+ const contextIndex = seekSequence(lines, [chunk.context], lineIndex, false);
544
+ if (contextIndex === undefined) throw new Error(`Failed to find context '${chunk.context}' in ${displayPath}.`);
545
+ lineIndex = contextIndex + 1;
546
+ }
547
+
548
+ if (chunk.oldLines.length === 0) {
549
+ replacements.push({ start: lines.length, length: 0, lines: [...chunk.newLines] });
550
+ continue;
551
+ }
552
+
553
+ let pattern = chunk.oldLines;
554
+ let replacementLines = chunk.newLines;
555
+ let found = seekSequence(lines, pattern, lineIndex, chunk.endOfFile);
556
+ if (found === undefined && pattern.at(-1) === "") {
557
+ pattern = pattern.slice(0, -1);
558
+ if (replacementLines.at(-1) === "") replacementLines = replacementLines.slice(0, -1);
559
+ found = seekSequence(lines, pattern, lineIndex, chunk.endOfFile);
560
+ }
561
+ if (found === undefined) {
562
+ throw new Error(`Failed to find expected lines in ${displayPath}:\n${chunk.oldLines.join("\n")}`);
563
+ }
564
+ replacements.push({ start: found, length: pattern.length, lines: [...replacementLines] });
565
+ lineIndex = found + pattern.length;
566
+ }
567
+
568
+ replacements.sort((left, right) => left.start - right.start);
569
+ for (let index = 1; index < replacements.length; index++) {
570
+ const previous = replacements[index - 1];
571
+ const current = replacements[index];
572
+ if (current.start < previous.start + previous.length) {
573
+ throw new Error(`Overlapping update chunks are not allowed in ${displayPath}.`);
574
+ }
575
+ }
576
+
577
+ const updated = [...lines];
578
+ for (const replacement of [...replacements].reverse()) {
579
+ updated.splice(replacement.start, replacement.length, ...replacement.lines);
580
+ }
581
+ if (updated.at(-1) !== "") updated.push("");
582
+ return bom + updated.join(lineEnding);
583
+ }
584
+
585
+ function seekSequence(lines: string[], pattern: string[], start: number, endOfFile: boolean): number | undefined {
586
+ if (pattern.length === 0) return Math.min(start, lines.length);
587
+ if (pattern.length > lines.length) return undefined;
588
+ const first = endOfFile ? Math.max(start, lines.length - pattern.length) : start;
589
+ const last = lines.length - pattern.length;
590
+ if (first > last) return undefined;
591
+
592
+ // KMP keeps each normalization pass linear instead of rescanning the pattern at every line.
593
+ for (const normalize of [(value: string) => value, (value: string) => value.trimEnd(), (value: string) => value.trim(), normalizePunctuation]) {
594
+ const expected = pattern.map(normalize);
595
+ const prefix = buildPrefixTable(expected);
596
+ let matched = 0;
597
+ for (let index = first; index < lines.length; index++) {
598
+ const actual = normalize(lines[index]);
599
+ while (matched > 0 && actual !== expected[matched]) matched = prefix[matched - 1];
600
+ if (actual === expected[matched]) matched++;
601
+ if (matched === expected.length) return index - expected.length + 1;
602
+ }
603
+ }
604
+ return undefined;
605
+ }
606
+
607
+ function buildPrefixTable(pattern: string[]): number[] {
608
+ const prefix = Array<number>(pattern.length).fill(0);
609
+ for (let index = 1, matched = 0; index < pattern.length; index++) {
610
+ while (matched > 0 && pattern[index] !== pattern[matched]) matched = prefix[matched - 1];
611
+ if (pattern[index] === pattern[matched]) matched++;
612
+ prefix[index] = matched;
613
+ }
614
+ return prefix;
615
+ }
616
+
617
+ function normalizePunctuation(value: string): string {
618
+ return value.trim().replace(/[\u2010-\u2015\u2212]/g, "-").replace(/[\u2018-\u201b]/g, "'").replace(/[\u201c-\u201f]/g, '"').replace(/[\u00a0\u2002-\u200a\u202f\u205f\u3000]/g, " ");
619
+ }
620
+
621
+ async function withMutationLocks<T>(paths: string[], callback: () => Promise<T>): Promise<T> {
622
+ const uniquePaths = [...new Set(paths)].sort();
623
+ const acquire = (index: number): Promise<T> =>
624
+ index === uniquePaths.length ? callback() : withFileMutationQueue(uniquePaths[index], () => acquire(index + 1));
625
+ return acquire(0);
626
+ }
627
+
628
+ function displayPath(root: string, absolute: string, fallback: string): string {
629
+ const relativePath = relative(root, absolute);
630
+ return relativePath && !relativePath.startsWith("..") ? relativePath : fallback;
631
+ }
632
+
633
+ function isWithin(root: string, candidate: string): boolean {
634
+ const path = relative(root, candidate);
635
+ return path === "" || (!path.startsWith("..") && !isAbsolute(path));
636
+ }
637
+
638
+ function isMissingPathError(error: unknown): boolean {
639
+ return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
640
+ }
641
+
642
+ function isNoEntryError(error: unknown): boolean {
643
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
644
+ }
645
+
646
+ function isAlreadyExistsError(error: unknown): boolean {
647
+ return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
648
+ }
649
+
650
+ function throwIfAborted(signal: AbortSignal | undefined): void {
651
+ if (signal?.aborted) throw new Error("Operation aborted");
652
+ }
package/src/grammar.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { Type, type TSchema } from "typebox";
2
+
3
+ export interface OpenAIGrammarSampling {
4
+ type: "grammar";
5
+ variants: { openai_lark: string };
6
+ }
7
+
8
+ /** Create the OpenAI grammar transport used by Responses custom tools. */
9
+ export function createOpenAILarkSampling(definition: string): OpenAIGrammarSampling {
10
+ if (definition.trim().length === 0) {
11
+ throw new Error("OpenAI Lark grammar cannot be empty.");
12
+ }
13
+ return { type: "grammar", variants: { openai_lark: definition } };
14
+ }
15
+
16
+ /** Build the one-required-string schema Pi uses to identify raw custom-tool input. */
17
+ export function createFreeformInputSchema(inputProperty: string, description: string): TSchema {
18
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(inputProperty) || inputProperty === "__proto__") {
19
+ throw new Error(`Invalid freeform input property: ${inputProperty}`);
20
+ }
21
+
22
+ const properties: Record<string, TSchema> = Object.create(null) as Record<string, TSchema>;
23
+ properties[inputProperty] = Type.String({ description });
24
+ return Type.Object(properties, { additionalProperties: false });
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { APPLY_PATCH_GRAMMAR, MAX_PATCH_BYTES, MAX_PATCH_HUNKS, MAX_TARGET_FILE_BYTES, applyPatch, parseApplyPatch } from "./apply-patch.js";
2
+ export type { ApplyPatchHunk, ApplyPatchOptions, ApplyPatchResult, UpdateChunk } from "./apply-patch.js";
3
+ export { createFreeformInputSchema, createOpenAILarkSampling } from "./grammar.js";
4
+ export type { OpenAIGrammarSampling } from "./grammar.js";
5
+ export { isOpenAIResponsesApi, supportsOpenAIGrammarTools } from "./model-support.js";