pi-codex-tools 0.1.0 → 0.1.1
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 +10 -0
- package/README.md +5 -3
- package/SECURITY.md +2 -2
- package/extensions/index.ts +3 -0
- package/package.json +1 -1
- package/src/apply-patch.ts +83 -51
- package/src/install-telemetry.ts +8 -7
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,16 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.1] - 2026-08-04
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Guard `apply_patch` execution when the active model does not advertise grammar-tool support.
|
|
14
|
+
- Require the Pi runtime grammar-tool contract and fail closed on unsupported filesystem platforms.
|
|
15
|
+
- Preflight repeated file hunks sequentially without rejecting valid Codex patches.
|
|
16
|
+
- Respect `enableInstallTelemetry: false` even when `PI_TELEMETRY` is enabled.
|
|
17
|
+
- Document credential-containing target reads, telemetry controls, runtime requirements, and line-oriented compatibility.
|
|
18
|
+
|
|
9
19
|
## [0.1.0] - 2026-08-03
|
|
10
20
|
|
|
11
21
|
### Added
|
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, stay under Pi's current working directory, reject symlink escapes, use descriptor-anchored no-follow operations on Linux
|
|
9
|
+
- **Safe local mutation** — patches are limited to 1 MiB, target files to 64 MiB, stay under Pi's current working directory, reject symlink escapes, use descriptor-anchored no-follow operations on Linux, 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 and disables provider-side parallel tool calls when the patch tool is active.
|
|
12
12
|
|
|
@@ -24,7 +24,7 @@ pi -e /path/to/pi-mono/packages/pi-codex-tools
|
|
|
24
24
|
|
|
25
25
|
## Scope decisions
|
|
26
26
|
|
|
27
|
-
The current Codex source does not define separate `read_file` or `write_file` tools: file inspection is normally done through shell commands and file mutation through `apply_patch`. This package keeps Pi's bounded `read` and `bash` tools, and uses `apply_patch` in place of Pi's `edit` and `write` tools for supported models. Because Pi does not provide Codex's OS-level filesystem sandbox, `apply_patch` runs only on Linux
|
|
27
|
+
The current Codex source does not define separate `read_file` or `write_file` tools: file inspection is normally done through shell commands and file mutation through `apply_patch`. This package keeps Pi's bounded `read` and `bash` tools, and uses `apply_patch` in place of Pi's `edit` and `write` tools for supported models. Because Pi does not provide Codex's OS-level filesystem sandbox, `apply_patch` runs only on Linux and fails closed on unsupported platforms. It also requires a Pi model runtime that advertises `compat.supportsOpenAIGrammarTools`; older runtimes leave the tool inactive.
|
|
28
28
|
|
|
29
29
|
| Codex surface | Decision |
|
|
30
30
|
| --- | --- |
|
|
@@ -48,6 +48,8 @@ These choices are based on the Codex tool specifications in `codex-rs/core/src/t
|
|
|
48
48
|
|
|
49
49
|
These behaviors intentionally match Codex `apply_patch`.
|
|
50
50
|
|
|
51
|
+
The provider contract is runtime-specific: use Pi 0.83.0 or newer for OpenAI grammar-tool support. For a manual smoke test, start Pi with this extension and a model that advertises `supportsOpenAIGrammarTools`, then verify that a file change appears as an `apply_patch` call and not as `edit`, `write`, or `bash`.
|
|
52
|
+
|
|
51
53
|
## Development
|
|
52
54
|
|
|
53
55
|
```bash
|
|
@@ -56,7 +58,7 @@ npm test -w packages/pi-codex-tools
|
|
|
56
58
|
npm run -w packages/pi-codex-tools pack:dry-run
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
Install/update telemetry can be disabled with `PI_OFFLINE=1` or `PI_TELEMETRY=
|
|
61
|
+
Install/update telemetry is disabled in CI and can be disabled with `PI_OFFLINE=1`, `PI_TELEMETRY=0` or `PI_TELEMETRY=false`, or Pi's `enableInstallTelemetry: false` setting. See [SECURITY.md](./SECURITY.md).
|
|
60
62
|
|
|
61
63
|
## License
|
|
62
64
|
|
package/SECURITY.md
CHANGED
|
@@ -19,8 +19,8 @@ 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
|
|
22
|
+
`apply_patch` does not access the network or credential APIs. It can read credential-containing files when a patch targets them. It validates paths beneath the current working directory, 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 uses root-anchored descriptor-based no-follow operations on Linux. It fails closed on unsupported platforms because Pi does not provide Codex's OS-level filesystem sandbox. 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 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
|
|
|
26
|
-
Install/update telemetry is best effort and can be disabled with `PI_OFFLINE=1`, `PI_TELEMETRY=0`, CI detection, or Pi's `enableInstallTelemetry: false` setting.
|
|
26
|
+
Install/update telemetry is best effort and can be disabled with `PI_OFFLINE=1`, `PI_TELEMETRY=0`, `PI_TELEMETRY=false`, CI detection, or Pi's `enableInstallTelemetry: false` setting. Through `@mocito/install-telemetry`, it sends the package name and version as HTTPS URL query parameters and adds `process.platform`, the runtime name/version, and `process.arch` to the `User-Agent`. These fields are not intended to identify a host, user, repository, or path; no prompts, patches, file contents, credentials, auth headers, or provider responses are sent.
|
package/extensions/index.ts
CHANGED
|
@@ -36,6 +36,9 @@ export default function piCodexTools(pi: ExtensionAPI): void {
|
|
|
36
36
|
constrainedSampling: createOpenAILarkSampling(APPLY_PATCH_GRAMMAR),
|
|
37
37
|
executionMode: "sequential",
|
|
38
38
|
async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
|
|
39
|
+
if (!supportsOpenAIGrammarTools(ctx.model)) {
|
|
40
|
+
throw new Error("apply_patch is only available for OpenAI models that advertise grammar-tool support.");
|
|
41
|
+
}
|
|
39
42
|
const patch = (rawParams as { patch?: unknown }).patch;
|
|
40
43
|
if (typeof patch !== "string") throw new Error("apply_patch requires raw patch text.");
|
|
41
44
|
const result = await applyPatch(patch, { cwd: ctx.cwd, signal });
|
package/package.json
CHANGED
package/src/apply-patch.ts
CHANGED
|
@@ -10,7 +10,7 @@ export const MAX_PATCH_HUNKS = 1_000;
|
|
|
10
10
|
export const MAX_TARGET_FILE_BYTES = 64 * 1024 * 1024;
|
|
11
11
|
|
|
12
12
|
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
13
|
-
const SECURE_FD_DIRECTORY = process.platform === "linux" ? "/proc/self/fd" :
|
|
13
|
+
const SECURE_FD_DIRECTORY = process.platform === "linux" ? "/proc/self/fd" : undefined;
|
|
14
14
|
const SECURE_FILESYSTEM_SUPPORTED = SECURE_FD_DIRECTORY !== undefined && O_NOFOLLOW !== 0;
|
|
15
15
|
const SECURE_DIRECTORY_FLAGS = constants.O_RDONLY | O_NOFOLLOW | (constants.O_DIRECTORY ?? 0) | (constants.O_NONBLOCK ?? 0);
|
|
16
16
|
const SECURE_READ_FLAGS = constants.O_RDONLY | O_NOFOLLOW | (constants.O_NONBLOCK ?? 0);
|
|
@@ -225,6 +225,7 @@ type PlannedOperation =
|
|
|
225
225
|
| { kind: "update"; path: string; displayPath: string; moveTo?: string; moveDisplayPath?: string; chunkGroups: UpdateChunk[][]; content: string };
|
|
226
226
|
|
|
227
227
|
type SafePath = { absolute: string; exists: boolean; isDirectory: boolean; isFile: boolean };
|
|
228
|
+
type VirtualFile = Omit<SafePath, "absolute"> & { content?: string };
|
|
228
229
|
|
|
229
230
|
export async function applyPatch(input: string, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
|
|
230
231
|
requireSecureFilesystem();
|
|
@@ -271,77 +272,108 @@ export async function applyPatch(input: string, options: ApplyPatchOptions): Pro
|
|
|
271
272
|
|
|
272
273
|
async function planOperations(hunks: ApplyPatchHunk[], root: string, rootHandle: FileHandle, signal?: AbortSignal): Promise<PlannedOperation[]> {
|
|
273
274
|
const operations: PlannedOperation[] = [];
|
|
274
|
-
const
|
|
275
|
-
|
|
275
|
+
const virtualFiles = new Map<string, VirtualFile>();
|
|
276
|
+
|
|
277
|
+
const getVirtualFile = async (rawPath: string): Promise<{ absolute: string; file: VirtualFile }> => {
|
|
278
|
+
const absolute = resolvePatchPath(rawPath, root);
|
|
279
|
+
const existing = virtualFiles.get(absolute);
|
|
280
|
+
if (existing) return { absolute, file: existing };
|
|
281
|
+
const safe = await safePath(rawPath, root, signal);
|
|
282
|
+
const file: VirtualFile = {
|
|
283
|
+
exists: safe.exists,
|
|
284
|
+
isDirectory: safe.isDirectory,
|
|
285
|
+
isFile: safe.isFile,
|
|
286
|
+
};
|
|
287
|
+
virtualFiles.set(absolute, file);
|
|
288
|
+
return { absolute, file };
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const getVirtualContent = async (absolute: string, file: VirtualFile): Promise<string> => {
|
|
292
|
+
if (!file.exists || !file.isFile) throw new Error(`Cannot read non-file '${absolute}'.`);
|
|
293
|
+
if (file.content === undefined) file.content = await readSecureFile(rootHandle, root, absolute, signal);
|
|
294
|
+
return file.content;
|
|
295
|
+
};
|
|
276
296
|
|
|
277
297
|
for (const hunk of hunks) {
|
|
278
298
|
throwIfAborted(signal);
|
|
279
|
-
const source = await
|
|
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
|
-
}
|
|
299
|
+
const source = await getVirtualFile(hunk.path);
|
|
283
300
|
|
|
284
301
|
if (hunk.kind === "add") {
|
|
285
|
-
if (source.isDirectory || (source.exists && !source.isFile))
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
302
|
+
if (source.file.isDirectory || (source.file.exists && !source.file.isFile)) {
|
|
303
|
+
throw new Error(`Cannot add file over non-file '${hunk.path}'.`);
|
|
304
|
+
}
|
|
305
|
+
source.file.exists = true;
|
|
306
|
+
source.file.isDirectory = false;
|
|
307
|
+
source.file.isFile = true;
|
|
308
|
+
source.file.content = hunk.content;
|
|
309
|
+
operations.push({
|
|
310
|
+
kind: "add",
|
|
311
|
+
path: source.absolute,
|
|
312
|
+
displayPath: displayPath(root, source.absolute, hunk.path),
|
|
313
|
+
content: hunk.content,
|
|
314
|
+
});
|
|
290
315
|
continue;
|
|
291
316
|
}
|
|
292
317
|
|
|
293
318
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
occupied.add(source.absolute);
|
|
319
|
+
if (!source.file.exists) throw new Error(`Cannot delete missing file '${hunk.path}'.`);
|
|
320
|
+
if (source.file.isDirectory || !source.file.isFile) throw new Error(`Cannot delete non-file '${hunk.path}'.`);
|
|
321
|
+
operations.push({ kind: "delete", path: source.absolute, displayPath: displayPath(root, source.absolute, hunk.path) });
|
|
322
|
+
source.file.exists = false;
|
|
323
|
+
source.file.content = undefined;
|
|
300
324
|
continue;
|
|
301
325
|
}
|
|
302
326
|
|
|
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
|
|
306
|
-
const moveTo = hunk.moveTo ? (
|
|
327
|
+
if (!source.file.exists) throw new Error(`Cannot update missing file '${hunk.path}'.`);
|
|
328
|
+
if (source.file.isDirectory || !source.file.isFile) throw new Error(`Cannot update non-file '${hunk.path}'.`);
|
|
329
|
+
const original = await getVirtualContent(source.absolute, source.file);
|
|
330
|
+
const moveTo = hunk.moveTo ? resolvePatchPath(hunk.moveTo, root) : undefined;
|
|
307
331
|
if (moveTo === source.absolute) throw new Error(`Cannot move '${hunk.path}' onto itself.`);
|
|
332
|
+
|
|
333
|
+
let destination: { absolute: string; file: VirtualFile } | undefined;
|
|
308
334
|
if (moveTo) {
|
|
309
|
-
|
|
310
|
-
if (destination.isDirectory || (destination.exists && !destination.isFile))
|
|
311
|
-
|
|
312
|
-
|
|
335
|
+
destination = await getVirtualFile(hunk.moveTo!);
|
|
336
|
+
if (destination.file.isDirectory || (destination.file.exists && !destination.file.isFile)) {
|
|
337
|
+
throw new Error(`Cannot move file over non-file '${hunk.moveTo}'.`);
|
|
338
|
+
}
|
|
313
339
|
}
|
|
314
340
|
|
|
315
|
-
const
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
341
|
+
const content = applyUpdateContent(original, hunk.chunks, hunk.path);
|
|
342
|
+
if (destination) {
|
|
343
|
+
operations.push({
|
|
344
|
+
kind: "update",
|
|
345
|
+
path: source.absolute,
|
|
346
|
+
displayPath: displayPath(root, source.absolute, hunk.path),
|
|
347
|
+
moveTo: moveTo!,
|
|
348
|
+
moveDisplayPath: displayPath(root, moveTo!, hunk.moveTo!),
|
|
349
|
+
chunkGroups: [[...hunk.chunks]],
|
|
350
|
+
content,
|
|
351
|
+
});
|
|
352
|
+
source.file.exists = false;
|
|
353
|
+
source.file.content = undefined;
|
|
354
|
+
destination.file.exists = true;
|
|
355
|
+
destination.file.isDirectory = false;
|
|
356
|
+
destination.file.isFile = true;
|
|
357
|
+
destination.file.content = content;
|
|
323
358
|
continue;
|
|
324
359
|
}
|
|
325
360
|
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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.`);
|
|
361
|
+
const previous = operations.at(-1);
|
|
362
|
+
if (previous?.kind === "update" && previous.path === source.absolute && !previous.moveTo) {
|
|
363
|
+
previous.content = content;
|
|
364
|
+
previous.chunkGroups.push([...hunk.chunks]);
|
|
365
|
+
} else {
|
|
366
|
+
operations.push({
|
|
367
|
+
kind: "update",
|
|
368
|
+
path: source.absolute,
|
|
369
|
+
displayPath: displayPath(root, source.absolute, hunk.path),
|
|
370
|
+
chunkGroups: [[...hunk.chunks]],
|
|
371
|
+
content,
|
|
372
|
+
});
|
|
343
373
|
}
|
|
374
|
+
source.file.content = content;
|
|
344
375
|
}
|
|
376
|
+
|
|
345
377
|
return operations;
|
|
346
378
|
}
|
|
347
379
|
|
package/src/install-telemetry.ts
CHANGED
|
@@ -44,14 +44,15 @@ function isPresentEnvFlag(value: string | undefined): boolean {
|
|
|
44
44
|
return normalized !== "0" && normalized !== "false" && normalized !== "no";
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function isInstallTelemetryEnabled(): boolean {
|
|
48
|
-
if (isTruthyEnvFlag(
|
|
49
|
-
if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(
|
|
50
|
-
if (isTruthyEnvFlag(
|
|
51
|
-
if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
|
|
47
|
+
export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
|
|
48
|
+
if (isTruthyEnvFlag(env.CI)) return false;
|
|
49
|
+
if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
|
|
50
|
+
if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
|
|
52
51
|
|
|
53
|
-
const settings = readJsonFile(
|
|
54
|
-
|
|
52
|
+
const settings = readJsonFile(settingsPath) as SettingsDocument;
|
|
53
|
+
if (settings.enableInstallTelemetry === false) return false;
|
|
54
|
+
if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
|
|
55
|
+
return true;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
function getPackageVersion(): string {
|