## Permission\-denied file fallbacks

Ada embeds OMP in a host with a real OS permission boundary\.&#32;Native writes to one approved destination are denied inside the agent process\,&#32;but the host has a privileged channel that can perform them\.

Her obstacle is preserving native tool behavior\.&#32;Reimplementing&#32;`write`&#32;or hashline editing would lose snapshots and bookkeeping\.

She chooses a file fallback\,&#32;while leaving permission policy with the host\.

### Milestone\:&#32;delegate exact denied bytes

The public bundle provides an adapter\,&#32;not an elevated writer\.

**Complete public TypeScript source—[file\-fallback\.ts](<https://present-sketch-tp94.here.now/examples/package-lab/advanced/file-fallback.ts>)\:**

~~~ts
import * as path from "node:path";
import type { ExtensionFactory } from "@oh-my-pi/pi-coding-agent";

export interface FieldNotesWriteBroker {
    /** Real host broker: resolve only after exact bytes are durable at the supplied destination. */
    write(destination: string, content: string): Promise<void>;
}

/** Host supplies the already-canonical allowlisted destination and a real broker. No elevated writer is bundled. */
export function createFieldNotesFallback(destination: string, broker: FieldNotesWriteBroker): ExtensionFactory {
    if (!path.isAbsolute(destination)) throw new Error("The host must supply an absolute, canonical destination.");
    return pi => {
        pi.registerFileWriteFallback(async (request, ctx) => {
            if (request.sessionId !== ctx.sessionManager.getSessionId() || request.dst !== destination) return false;
            await broker.write(request.dst, request.content);
            return true;
        });
    };
}
~~~

**Missing prerequisites\:**

- a real broker implementation\;
- a host\-established canonical allowlisted destination\;
- a real permission\-denied native byte\-write scenario\;
- initialized runtime installation of the fallback\.

The adapter checks that the destination is absolute\.&#32;It does&#32;**not**&#32;itself prove canonicalization\;&#32;the host must perform that policy work\.

It has a named factory creator and no default extension export\.&#32;Import it into an SDK host\;&#32;do not launch it directly with&#32;`-e`\.

#### What reaches the write seam\?

The seam handles native ordinary\-file byte writes used by&#32;`write`\,&#32;`edit`&#32;and&#32;`apply_patch`\,&#32;including a hashline move destination\,&#32;after permission errors\:

- `EPERM`\;
- `EACCES`\;
- `EROFS`\.

A special missing\-parent case recovers a denied&#32;`mkdir`&#32;that Bun originally surfaced as&#32;`ENOENT`\.&#32;The broker may need to create that parent\.&#32;A genuinely invalid\/missing path is not automatically a permission fallback\.

Requests contain\:

- `dst`\;
- `content`\;
- `cause`\;
- `sessionId`\,&#32;possibly undefined outside a tool call\.

Handlers run in order\.&#32;The first true result means the native tool may continue as though its byte write succeeded\,&#32;including native snapshot bookkeeping\.

Throwing handlers are logged and skipped\,&#32;including later handlers in the same extension\.&#32;If none succeeds\,&#32;the original error is rethrown\;&#32;a recovered underlying denial may be attached as its cause\.

Returning true before the bytes actually land is a correctness bug\.

#### Path and session policy

`dst`&#32;is the resolved destination the failed write would target\,&#32;including the final component for writes\.&#32;Do not rederive it from a lexically innocent tool path\.

If the destination cannot be resolved safely enough to identify it\,&#32;the fallback is not consulted\.

Fallback registries are&#32;**process\-wide**\.&#32;A handler can be consulted for another session’s denied write\.&#32;Compare request identity to the handler’s session before prompting\:&#32;`ctx.ui`&#32;belongs to the handler’s session\,&#32;not necessarily the originator\.

The public adapter intentionally refuses other sessions\.

Canonical path resolution reduces specific symlink misrouting risks\.&#32;It does not establish a universal sandbox or remove the need for broker\-side path policy and safe filesystem operations\.

### Milestone\:&#32;deletion remains a separate capability

A write fallback must never receive a delete request and interpret missing content as an empty file\.

`registerFileDeleteFallback()`&#32;is separate\.&#32;It covers ordinary native unlink paths such as hashline&#32;`REM`\,&#32;a move’s source unlink and patch deletion\.

A delete request contains\:

- `dst`\;
- `cause`\;
- `confirmedFile`\;
- `sessionId`\.

The final path component is&#32;**not**&#32;resolved through a symlink\:&#32;unlink removes the link itself\.

A broker must use plain unlink semantics\.&#32;It must never fall back to recursive removal or realpath the final component\.

On Darwin\,&#32;unlinking a directory can report&#32;`EPERM`\.&#32;The seam refuses a known directory\,&#32;but inaccessible metadata can leave the target’s type unknown\.&#32;`confirmedFile: false`&#32;can mean either unknown metadata or a symlink—not permission to recursively remove it\.

This complete adapter exercise chooses an even narrower policy\:&#32;only positively identified regular files\.

**Complete TypeScript host\-adapter exercise—not an elevated implementation\:**

~~~ts
import * as path from "node:path";
import type { ExtensionFactory } from "@oh-my-pi/pi-coding-agent";

export interface PlainUnlinkBroker {
    unlink(destination: string): Promise<void>;
}

export function createSingleFileDeleteFallback(
    destination: string,
    broker: PlainUnlinkBroker,
): ExtensionFactory {
    if (!path.isAbsolute(destination)) throw new Error("An absolute policy destination is required.");
    return pi => {
        pi.registerFileDeleteFallback(async (request, ctx) => {
            if (request.sessionId !== ctx.sessionManager.getSessionId()) return false;
            if (request.dst !== destination || !request.confirmedFile) return false;
            await broker.unlink(request.dst);
            return true;
        });
    };
}
~~~

That stricter&#32;`confirmedFile`&#32;choice deliberately declines symlinks and metadata\-hidden targets\.&#32;A different broker policy may support them with safe plain\-unlink operations\,&#32;but recursive deletion is never the fallback contract\.

`ENOENT`&#32;on delete is not diverted\.

### What is not covered

These APIs do not intercept\:

- arbitrary&#32;`Bun.write()`&#32;or&#32;`fs`&#32;calls made by extensions\;
- shell\/subprocess writes\;
- archive\-member rewrites\;
- SQLite row writes\;
- ACP client\-side&#32;`writeTextFile`\;
- the LSP tool’s independent workspace\-edit\/code\-action writes\;
- formatter subprocess mutations\.

Register fallback handlers during factory loading\.&#32;They are installed at runner initialization and disposed on shutdown\.&#32;A first registration after initialization does not install a previously absent seam\.

Each invocation receives a newly built context\,&#32;so current cwd\/UI information is not frozen at installation\.

**Observed scope\:**&#32;existing contract tests exercised real permission\-denied native writes and follow\-up editing\.&#32;The test’s stand\-in broker temporarily changed permissions to place bytes\;&#32;it was not a real privileged broker\.&#32;The workbook’s example scenarios did not exercise elevation\.

**Exercise\:**&#32;the broker succeeded at the move destination but source unlink failed\.&#32;Is the move an atomic success\?

**Answer\:**&#32;no\.&#32;The two primitives have separate outcomes\.&#32;Inspect the actual files before retrying\.

*Source\,&#32;snapshot 2026\-08\-29\:&#32;`packages/coding-agent/src/tools/file-write-fallback.ts`\;&#32;`packages/coding-agent/src/extensibility/extensions/runner.ts`\,&#32;initialization\/disposal\;&#32;`packages/coding-agent/src/extensibility/extensions/wrapper.ts`\,&#32;file\-mutation session attribution\;&#32;`packages/coding-agent/test/sdk-file-write-fallback-extension.test.ts`\.*
