pi-codex-tools 0.2.2 → 0.2.4
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/CHANGELOG.md +12 -0
- package/README.md +6 -1
- package/SECURITY.md +1 -1
- package/extensions/index.ts +15 -1
- package/package.json +5 -5
- package/src/apply-patch.ts +32 -40
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,18 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.4] - 2026-09-11
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Supply owned apply_patch grammar metadata to pi-codex-compaction through Pi's public event bus.
|
|
14
|
+
|
|
15
|
+
## [0.2.3] - 2026-08-11
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Accept relative paths outside the current working directory and absolute paths, matching Pi's native `edit` and `write` tools while preserving no-follow filesystem operations.
|
|
20
|
+
|
|
9
21
|
## [0.2.2] - 2026-08-10
|
|
10
22
|
|
|
11
23
|
### Changed
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Give grammar-capable OpenAI/Codex models the Codex `apply_patch` tool in Pi with
|
|
|
6
6
|
|
|
7
7
|
- **Raw `apply_patch`** — sends Codex's Lark grammar as an OpenAI custom tool, so patches are not JSON-wrapped.
|
|
8
8
|
- **Capability-based activation** — requires `openai-codex-responses` or `openai-responses` plus `model.compat.supportsOpenAIGrammarTools === true`; model names alone are never enough.
|
|
9
|
-
- **Safe local mutation** — patches are limited to 1 MiB, target files to 64 MiB,
|
|
9
|
+
- **Safe local mutation** — patches are limited to 1 MiB, target files to 64 MiB, accept relative or absolute paths like Pi's native file tools, reject symlink paths, use descriptor-anchored no-follow operations on Linux and macOS, fail closed elsewhere, preflight all hunks, and serialize writes with Pi's mutation queue.
|
|
10
10
|
- **Model switching** — supported models replace Pi's `edit` and `write` tools with `apply_patch`; other active tools are preserved. Switching back restores only the file tools that were active before the switch.
|
|
11
11
|
- **Sequential patch calls** — the extension marks patch execution sequential while leaving provider-side parallel tool calls enabled.
|
|
12
12
|
- **Streaming progress** — while a patch is generated, the TUI shows a live, color-coded glimpse of the content being written (new-file content, or `+`/`-` lines for updates) plus a running `+added -removed` tally and a per-file roster for multi-file patches. It reuses Pi's shared diff rendering and mirrors the built-in `write`/`edit` previews; patch execution is unchanged.
|
|
@@ -40,6 +40,11 @@ These choices are based on the Codex tool specifications in `codex-rs/core/src/t
|
|
|
40
40
|
|
|
41
41
|
## Compatibility notes
|
|
42
42
|
|
|
43
|
+
With an updated `pi-codex-compaction` installed, the package supplies its owned
|
|
44
|
+
grammar metadata through Pi's public event bus. This keeps raw `apply_patch`
|
|
45
|
+
calls and results intact in direct Codex compaction requests, including Astra.
|
|
46
|
+
No private Pi registry is patched.
|
|
47
|
+
|
|
43
48
|
`apply_patch` is line-oriented rather than byte-oriented:
|
|
44
49
|
|
|
45
50
|
- `*** Add File` requires at least one `+` line and writes a trailing newline. A `+`-only hunk creates a one-newline file, not a zero-byte file.
|
package/SECURITY.md
CHANGED
|
@@ -19,7 +19,7 @@ Report privately through [GitHub Security Advisories](https://github.com/jvm/pi-
|
|
|
19
19
|
|
|
20
20
|
Pi extensions execute with the same permissions as the local user running Pi. Review installed extensions and only install packages from sources you trust.
|
|
21
21
|
|
|
22
|
-
`apply_patch` does not access the network or credential APIs. It can read credential-containing files when a patch targets them. It
|
|
22
|
+
`apply_patch` does not access the network or credential APIs. Like Pi's native `edit` and `write` tools, it accepts relative or absolute paths and can modify files outside the current working directory with the local user's permissions. It can read credential-containing files when a patch targets them. It rejects symlink paths and symlinked parents, limits patch input to 1 MiB and target-file reads to 64 MiB, preflights file changes before writing, and performs a filesystem-root-anchored descriptor-based no-follow directory walk so a path component swapped to a symlink between check and use cannot redirect a mutation. On Linux the walk re-opens each component via `/proc/self/fd`; on macOS it uses a bundled `openat`/`mkdirat`/`unlinkat` N-API binding (committed prebuilds for Apple silicon and Intel, loaded via `node-gyp-build`) because Node does not expose `openat` and macOS lacks procfs. The binding is darwin-only, exposes only those POSIX calls, and is loaded best-effort: on platforms without it `apply_patch` fails closed and Pi keeps its native `edit`/`write` tools. A failure during a multi-file write can still leave earlier files changed; callers should use version control and review the resulting diff.
|
|
23
23
|
|
|
24
24
|
The package strips untrusted C0/C1 control bytes and terminal escape sequences from `apply_patch` preview text and paths before handing them to the TUI. The package reads the current provider/model capability flags only to select tools. It does not log prompts, patches, file contents, credentials, auth headers, or provider responses.
|
|
25
25
|
|
package/extensions/index.ts
CHANGED
|
@@ -44,7 +44,7 @@ export default function piCodexTools(pi: ExtensionAPI): void {
|
|
|
44
44
|
promptGuidelines: [
|
|
45
45
|
"Use apply_patch for file changes when it is available.",
|
|
46
46
|
"Send the patch body directly; do not wrap it in JSON or add a shell heredoc.",
|
|
47
|
-
`Patch paths
|
|
47
|
+
`Patch paths may be relative to the current working directory or absolute, and patches are limited to ${MAX_PATCH_BYTES} bytes.`,
|
|
48
48
|
],
|
|
49
49
|
parameters: APPLY_PATCH_PARAMETERS,
|
|
50
50
|
constrainedSampling: createOpenAILarkSampling(APPLY_PATCH_GRAMMAR),
|
|
@@ -90,6 +90,20 @@ export default function piCodexTools(pi: ExtensionAPI): void {
|
|
|
90
90
|
|
|
91
91
|
let replacedToolsWasActive: Record<ReplacedTool, boolean> | undefined;
|
|
92
92
|
|
|
93
|
+
pi.events?.on("pi-codex-compaction:tools:v1", (value) => {
|
|
94
|
+
const data = value as {
|
|
95
|
+
model?: ExtensionContext["model"];
|
|
96
|
+
tools?: Array<{ name: string; parameters: unknown; constrainedSampling?: OpenAIGrammarSampling }>;
|
|
97
|
+
} | undefined;
|
|
98
|
+
if (!data || !supportsOpenAIGrammarTools(data.model) || !Array.isArray(data.tools)) return;
|
|
99
|
+
for (const tool of data.tools) {
|
|
100
|
+
// Do not attach our grammar to another extension's apply_patch override.
|
|
101
|
+
if (tool.name === APPLY_PATCH && tool.parameters === APPLY_PATCH_PARAMETERS) {
|
|
102
|
+
tool.constrainedSampling = createOpenAILarkSampling(APPLY_PATCH_GRAMMAR);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
93
107
|
function synchronizeTools(ctx: ExtensionContext): void {
|
|
94
108
|
if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
|
|
95
109
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-codex-tools",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Codex-compatible apply_patch tooling for Pi's grammar-capable OpenAI models.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -60,10 +60,10 @@
|
|
|
60
60
|
"typebox": "*"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@earendil-works/pi-ai": "^0.
|
|
64
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
65
|
-
"@earendil-works/pi-tui": "^0.
|
|
66
|
-
"@types/node": "^26.
|
|
63
|
+
"@earendil-works/pi-ai": "^0.85.1",
|
|
64
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
65
|
+
"@earendil-works/pi-tui": "^0.85.1",
|
|
66
|
+
"@types/node": "^26.2.0",
|
|
67
67
|
"prebuildify": "^6.0.1",
|
|
68
68
|
"tsx": "^4.23.5",
|
|
69
69
|
"typebox": "^1.3.10",
|
package/src/apply-patch.ts
CHANGED
|
@@ -298,17 +298,18 @@ type VirtualFile = Omit<SafePath, "absolute"> & { content?: string };
|
|
|
298
298
|
export async function applyPatch(input: string, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
|
|
299
299
|
requireSecureFilesystem();
|
|
300
300
|
const hunks = parseApplyPatch(input);
|
|
301
|
-
const
|
|
301
|
+
const cwd = await realpath(resolve(options.cwd));
|
|
302
|
+
const root = sep;
|
|
302
303
|
const lockPaths = hunks.flatMap((hunk) => {
|
|
303
|
-
const paths = [resolvePatchPath(hunk.path,
|
|
304
|
-
if (hunk.kind === "update" && hunk.moveTo) paths.push(resolvePatchPath(hunk.moveTo,
|
|
304
|
+
const paths = [resolvePatchPath(hunk.path, cwd)];
|
|
305
|
+
if (hunk.kind === "update" && hunk.moveTo) paths.push(resolvePatchPath(hunk.moveTo, cwd));
|
|
305
306
|
return paths;
|
|
306
307
|
});
|
|
307
308
|
|
|
308
309
|
return withMutationLocks(lockPaths, async () => {
|
|
309
310
|
const rootFd = await openSecureRoot(root, options.signal);
|
|
310
311
|
try {
|
|
311
|
-
const operations = await planOperations(hunks, root, rootFd, options.signal);
|
|
312
|
+
const operations = await planOperations(hunks, cwd, root, rootFd, options.signal);
|
|
312
313
|
throwIfAborted(options.signal);
|
|
313
314
|
// ponytail: preflight catches parse/match errors before writes; cross-process failures can still leave a partial multi-file patch.
|
|
314
315
|
for (const operation of operations) {
|
|
@@ -338,15 +339,21 @@ export async function applyPatch(input: string, options: ApplyPatchOptions): Pro
|
|
|
338
339
|
});
|
|
339
340
|
}
|
|
340
341
|
|
|
341
|
-
async function planOperations(
|
|
342
|
+
async function planOperations(
|
|
343
|
+
hunks: ApplyPatchHunk[],
|
|
344
|
+
cwd: string,
|
|
345
|
+
root: string,
|
|
346
|
+
rootFd: number,
|
|
347
|
+
signal?: AbortSignal,
|
|
348
|
+
): Promise<PlannedOperation[]> {
|
|
342
349
|
const operations: PlannedOperation[] = [];
|
|
343
350
|
const virtualFiles = new Map<string, VirtualFile>();
|
|
344
351
|
|
|
345
352
|
const getVirtualFile = async (rawPath: string): Promise<{ absolute: string; file: VirtualFile }> => {
|
|
346
|
-
const absolute = resolvePatchPath(rawPath,
|
|
353
|
+
const absolute = resolvePatchPath(rawPath, cwd);
|
|
347
354
|
const existing = virtualFiles.get(absolute);
|
|
348
355
|
if (existing) return { absolute, file: existing };
|
|
349
|
-
const safe = await safePath(rawPath,
|
|
356
|
+
const safe = await safePath(rawPath, cwd, signal);
|
|
350
357
|
const file: VirtualFile = {
|
|
351
358
|
exists: safe.exists,
|
|
352
359
|
isDirectory: safe.isDirectory,
|
|
@@ -377,7 +384,7 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
|
|
|
377
384
|
operations.push({
|
|
378
385
|
kind: "add",
|
|
379
386
|
path: source.absolute,
|
|
380
|
-
displayPath: displayPath(
|
|
387
|
+
displayPath: displayPath(cwd, source.absolute, hunk.path),
|
|
381
388
|
content: hunk.content,
|
|
382
389
|
});
|
|
383
390
|
continue;
|
|
@@ -386,7 +393,7 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
|
|
|
386
393
|
if (hunk.kind === "delete") {
|
|
387
394
|
if (!source.file.exists) throw new Error(`Cannot delete missing file '${hunk.path}'.`);
|
|
388
395
|
if (source.file.isDirectory || !source.file.isFile) throw new Error(`Cannot delete non-file '${hunk.path}'.`);
|
|
389
|
-
operations.push({ kind: "delete", path: source.absolute, displayPath: displayPath(
|
|
396
|
+
operations.push({ kind: "delete", path: source.absolute, displayPath: displayPath(cwd, source.absolute, hunk.path) });
|
|
390
397
|
source.file.exists = false;
|
|
391
398
|
source.file.content = undefined;
|
|
392
399
|
continue;
|
|
@@ -395,7 +402,7 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
|
|
|
395
402
|
if (!source.file.exists) throw new Error(`Cannot update missing file '${hunk.path}'.`);
|
|
396
403
|
if (source.file.isDirectory || !source.file.isFile) throw new Error(`Cannot update non-file '${hunk.path}'.`);
|
|
397
404
|
const original = await getVirtualContent(source.absolute, source.file);
|
|
398
|
-
const moveTo = hunk.moveTo ? resolvePatchPath(hunk.moveTo,
|
|
405
|
+
const moveTo = hunk.moveTo ? resolvePatchPath(hunk.moveTo, cwd) : undefined;
|
|
399
406
|
if (moveTo === source.absolute) throw new Error(`Cannot move '${hunk.path}' onto itself.`);
|
|
400
407
|
|
|
401
408
|
let destination: { absolute: string; file: VirtualFile } | undefined;
|
|
@@ -411,9 +418,9 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
|
|
|
411
418
|
operations.push({
|
|
412
419
|
kind: "update",
|
|
413
420
|
path: source.absolute,
|
|
414
|
-
displayPath: displayPath(
|
|
421
|
+
displayPath: displayPath(cwd, source.absolute, hunk.path),
|
|
415
422
|
moveTo: moveTo!,
|
|
416
|
-
moveDisplayPath: displayPath(
|
|
423
|
+
moveDisplayPath: displayPath(cwd, moveTo!, hunk.moveTo!),
|
|
417
424
|
chunkGroups: [[...hunk.chunks]],
|
|
418
425
|
content,
|
|
419
426
|
});
|
|
@@ -434,7 +441,7 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
|
|
|
434
441
|
operations.push({
|
|
435
442
|
kind: "update",
|
|
436
443
|
path: source.absolute,
|
|
437
|
-
displayPath: displayPath(
|
|
444
|
+
displayPath: displayPath(cwd, source.absolute, hunk.path),
|
|
438
445
|
chunkGroups: [[...hunk.chunks]],
|
|
439
446
|
content,
|
|
440
447
|
});
|
|
@@ -445,19 +452,16 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
|
|
|
445
452
|
return operations;
|
|
446
453
|
}
|
|
447
454
|
|
|
448
|
-
function resolvePatchPath(rawPath: string,
|
|
449
|
-
|
|
450
|
-
if (!isWithin(root, absolute) || absolute === root) {
|
|
451
|
-
throw new Error(`Patch path must stay inside the current working directory: ${rawPath}`);
|
|
452
|
-
}
|
|
453
|
-
return absolute;
|
|
455
|
+
function resolvePatchPath(rawPath: string, cwd: string): string {
|
|
456
|
+
return isAbsolute(rawPath) ? resolve(rawPath) : resolve(cwd, rawPath);
|
|
454
457
|
}
|
|
455
458
|
|
|
456
|
-
async function safePath(rawPath: string,
|
|
459
|
+
async function safePath(rawPath: string, cwd: string, signal?: AbortSignal): Promise<SafePath> {
|
|
457
460
|
throwIfAborted(signal);
|
|
458
|
-
const absolute = resolvePatchPath(rawPath,
|
|
461
|
+
const absolute = resolvePatchPath(rawPath, cwd);
|
|
459
462
|
|
|
460
463
|
let current = absolute;
|
|
464
|
+
let target: Omit<SafePath, "absolute"> = { exists: false, isDirectory: false, isFile: false };
|
|
461
465
|
while (true) {
|
|
462
466
|
throwIfAborted(signal);
|
|
463
467
|
try {
|
|
@@ -465,25 +469,18 @@ async function safePath(rawPath: string, root: string, signal?: AbortSignal): Pr
|
|
|
465
469
|
if (stats.isSymbolicLink()) {
|
|
466
470
|
throw new Error(`Symlink paths are not allowed in apply_patch: ${rawPath}`);
|
|
467
471
|
}
|
|
468
|
-
const resolved = await realpath(current);
|
|
469
|
-
if (!isWithin(root, resolved)) {
|
|
470
|
-
throw new Error(`Patch path escapes the current working directory: ${rawPath}`);
|
|
471
|
-
}
|
|
472
472
|
if (current !== absolute && !stats.isDirectory()) {
|
|
473
473
|
throw new Error(`Parent path is not a directory: ${rawPath}`);
|
|
474
474
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
isDirectory: current === absolute && stats.isDirectory(),
|
|
479
|
-
isFile: current === absolute && stats.isFile(),
|
|
480
|
-
};
|
|
475
|
+
if (current === absolute) {
|
|
476
|
+
target = { exists: true, isDirectory: stats.isDirectory(), isFile: stats.isFile() };
|
|
477
|
+
}
|
|
481
478
|
} catch (error) {
|
|
482
479
|
if (!isMissingPathError(error)) throw error;
|
|
483
|
-
const parent = dirname(current);
|
|
484
|
-
if (parent === current) throw new Error(`Cannot resolve patch path: ${rawPath}`);
|
|
485
|
-
current = parent;
|
|
486
480
|
}
|
|
481
|
+
const parent = dirname(current);
|
|
482
|
+
if (parent === current) return { absolute, ...target };
|
|
483
|
+
current = parent;
|
|
487
484
|
}
|
|
488
485
|
}
|
|
489
486
|
|
|
@@ -528,7 +525,7 @@ async function openSecureParentDirectory(rootFd: number, root: string, absolute:
|
|
|
528
525
|
const relativeParent = relative(root, parentPath);
|
|
529
526
|
const components = relativeParent ? relativeParent.split(sep) : [];
|
|
530
527
|
if (components.some((component) => !component || component === "." || component === "..")) {
|
|
531
|
-
throw new Error(`Patch path
|
|
528
|
+
throw new Error(`Patch path cannot be traversed securely: ${absolute}`);
|
|
532
529
|
}
|
|
533
530
|
|
|
534
531
|
let current = rootFd;
|
|
@@ -727,11 +724,6 @@ function displayPath(root: string, absolute: string, fallback: string): string {
|
|
|
727
724
|
return relativePath && !relativePath.startsWith("..") ? relativePath : fallback;
|
|
728
725
|
}
|
|
729
726
|
|
|
730
|
-
function isWithin(root: string, candidate: string): boolean {
|
|
731
|
-
const path = relative(root, candidate);
|
|
732
|
-
return path === "" || (!path.startsWith("..") && !isAbsolute(path));
|
|
733
|
-
}
|
|
734
|
-
|
|
735
727
|
function isMissingPathError(error: unknown): boolean {
|
|
736
728
|
return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
737
729
|
}
|