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 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/macOS, fail closed elsewhere, preflight all hunks, and serialize writes with Pi's mutation queue.
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/macOS and fails closed on unsupported platforms.
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=0`.
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 credentials. 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/macOS. 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.
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. It sends only package/version and runtime metadata through `@mocito/install-telemetry`.
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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codex-tools",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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",
@@ -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" : process.platform === "darwin" ? "/dev/fd" : undefined;
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 byPath = new Map<string, PlannedOperation>();
275
- const occupied = new Set<string>();
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 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
- }
299
+ const source = await getVirtualFile(hunk.path);
283
300
 
284
301
  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);
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
- 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);
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 readSecureFile(rootHandle, root, source.absolute, signal);
306
- const moveTo = hunk.moveTo ? (await safePath(hunk.moveTo, root, signal)).absolute : undefined;
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
- 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);
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 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
- );
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 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.`);
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
 
@@ -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(process.env.CI)) return false;
49
- if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(process.env[name]))) return false;
50
- if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
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(join(getAgentDir(), "settings.json")) as SettingsDocument;
54
- return settings.enableInstallTelemetry !== false;
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 {