From d975d4ceb7219d49737d8c946bbbaa038c14b7a8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 11:14:39 -0500 Subject: [PATCH] Add pinned Pi rich ACP wrapper and owned runtime extension Co-Authored-By: Paperclip --- doc/architecture/runner-pi-capabilities.md | 133 ++++ .../src/drivers/acpx/pi-acp-runtime.test.ts | 96 +++ .../src/drivers/acpx/pi-acp-runtime.ts | 248 +++++++ .../drivers/acpx/pi-runtime-extension.test.ts | 116 +++ .../src/drivers/acpx/pi-runtime-extension.ts | 267 +++++++ .../drivers/acpx/pi-verified-runtime.test.ts | 46 ++ .../src/drivers/acpx/pi-verified-runtime.ts | 114 +++ .../test-fixtures/pi-acp/fake-pi.mjs | 45 ++ .../test/pi-acp-package-contract.test.mjs | 123 ++++ patches/pi-acp@0.0.33.patch | 677 ++++++++++++++++++ 10 files changed, 1865 insertions(+) create mode 100644 doc/architecture/runner-pi-capabilities.md create mode 100644 packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.ts create mode 100644 packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs create mode 100644 packages/paperclip-runner/test/pi-acp-package-contract.test.mjs create mode 100644 patches/pi-acp@0.0.33.patch diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md new file mode 100644 index 00000000000..f27b4897495 --- /dev/null +++ b/doc/architecture/runner-pi-capabilities.md @@ -0,0 +1,133 @@ +# Pi rich ACP runtime + +Status: implementation candidate, 2026-09-28. Deterministic protocol tests and an +uncredentialed admission probe pass. Paid local inference and Linux x64 Daytona +qualification are pending; this document does not promote the candidate to a +qualified production runtime. + +The runner pins `pi-acp@0.0.33` and +`@earendil-works/pi-coding-agent@0.84.2`. The candidate model is +`openrouter/deepseek/deepseek-v4-flash-0731`; only an explicitly bound OpenRouter +credential may reach this profile. `patches/pi-acp@0.0.33.patch` repairs the ACP +wrapper. `pi-runtime-extension.ts` supplies the runner-owned semantic bridge and +pre-execution native tool policy. This keeps the upstream session, model, +streaming, diff, retry, and compaction implementation while making its missing +boundaries explicit. + +## Capabilities and differences from Codex app-server + +| Surface | Pi implementation and boundary | +| --- | --- | +| Sessions | Native Pi JSONL create/load; resume is confined to the execution's private session home and original workspace. Cold `prompt` cannot implicitly load an arbitrary session. | +| Text and reasoning | Upstream streams text and thought chunks. The common runner's private reasoning policy still applies; provider support does not authorize retention or display. | +| Native tools | `read`, `grep`, `find`, `ls`, `write`, `edit`, and `bash` pass the immutable extension gate before execution. File roots and read-only mode are checked before and after a permission wait. The host sandbox remains authoritative for shell commands and races. | +| Permissions | Native tool approval uses ACP `session/request_permission`, with allow once, allow for the session, and deny. Only offered options are accepted. Questions never become approvals. | +| Questions | Pi `select`, `confirm`, `input`, and `editor` map to ACP form elicitation with typed schemas. Decline, cancellation, timeout, malformed replies, and duplicate/late replies cannot become accepted answers. | +| Semantic tools | Runner-owned loopback HTTP MCP catalogs register under exact `mcp____` names. Calls retain the native tool call ID and cancellation signal. Authenticated PRP tool handling owns semantic authorization and durable interactions. Ambient MCP and external MCP servers are not admitted. | +| Plans and artifacts | Pi has no native structured plan or artifact channel. Paperclip plan and artifact semantic tools remain available through the MCP bridge; native file edits retain bounded, workspace-confined ACP diff projection. Tool text/image results are preserved, and resource blocks are recorded without following URLs. | +| Steering | Capability-negotiated `pi/steer` issues native RPC `steer` during an active turn. `pi/follow_up` explicitly queues native RPC `follow_up`. Neither is inferred from a second ACP prompt. Each takes `{sessionId, message}` and returns `{accepted: true}`. | +| Usage | Prompt results sum actual assistant message usage receipts across continuations. Input, output, cache reads/writes, total tokens and provider-reported cost have provenance. Context-window occupancy is not billed usage. No receipt means no usage assertion. | +| Retry and compaction | Upstream retry/compaction notices are retained. `agent_settled`, rather than a transient `agent_end`, settles a prompt. A final provider error remains a failed prompt and does not become successful completion. | +| Images | Upstream ACP image prompt blocks are passed to native Pi RPC. Model-specific image support still requires live qualification. | +| Cancellation and death | Cancellation expires live UI waits and calls native abort. Pi process exit rejects pending RPC requests and all active/queued turns. Partial RPC frames, oversized frames, and malformed JSON fail closed. | +| Fork, durable goal, native plan | Unavailable. Do not advertise Codex parity for these features. | + +`initialize` advertises `_meta.paperclipPi.version = 1`, `steering`, +`queuedFollowUp`, the four question methods, `nativePermissions`, `promptUsage`, +`nativePlan: false`, and `pendingRequestRecovery: "live-process-only"`. The common +host must inspect this advertisement before sending provider extension requests. + +## Lifetime and recovery + +A provider UI request is a live Pi RPC promise. Paperclip's durable interaction +can survive a control-plane connection loss while the runner, wrapper, and Pi +process remain alive. A response is forwarded once to that exact pending request. +If Pi or the wrapper dies, the interaction must expire; neither a new process nor +a loaded JSONL session has the original promise. Do not replay an approval into a +replacement process. Normal conversation continuation can load the private JSONL +session after a separately admitted restart. + +The wrapper attaches a per-turn generation to settlement work, fails queued +turns on provider death, and bounds RPC request waits. This does not constitute a +claim of generic provider-process handoff or exactly-once external side effects. + +## Verified launch and packaging contract + +The wrapper rejects ambient launch unless `PAPERCLIP_ACPX_ISOLATED_CONTEXT=1`. +It executes an absolute bound Node executable with an absolute bound Pi CLI path, +without a shell or PATH lookup. The common command lease must supply: + +- `PAPERCLIP_PI_NODE_EXECUTABLE`, `PAPERCLIP_PI_ENTRYPOINT`, and + `PAPERCLIP_PI_EXTENSION_PATH`: files in the immutable private provider snapshot. +- `PI_CODING_AGENT_DIR`: isolated persisted session/auth/settings home. +- `PAPERCLIP_PI_READ_ONLY`: exactly `0` or `1`. +- `PAPERCLIP_PI_READ_ROOTS` and `PAPERCLIP_PI_PROTECTED_ROOTS`: JSON arrays of + absolute allowed skill/read roots and protected runtime/config roots. +- `PAPERCLIP_PI_SYSTEM_INSTRUCTIONS`: admitted instructions, bounded to 32 KiB. + +The wrapper synthesizes `PAPERCLIP_PI_RUNTIME_CONFIGURATION` for its child, +including only session-bound MCP servers. The owned extension captures and deletes +this configuration environment variable before model shell execution. Credentials +remain accessible only through the deliberately admitted runtime mechanisms. + +Pi starts with `--no-extensions --no-skills --no-prompt-templates --no-themes +--no-approve --offline`, followed by the explicit immutable extension and admitted +skill paths. Project trust is denied; user-bash RPC and terminal login are disabled. +Only the controlled `/compact`, `/session`, and `/autocompact` slash commands are +accepted. Startup package update checks are disabled. + +Pi can continue after a broken extension. Therefore the wrapper probes +`get_commands` and requires the exact readiness sentinel +`paperclip-runtime-ready-v1` / `Paperclip runtime gate v1`. The extension registers +this command only after gates and every MCP catalog finish initialization. A +missing sentinel kills the subprocess before the first model prompt. + +`pi-verified-runtime.ts` inventories and checks the complete graph, including +Node, wrapper helper, extension, package metadata, native libraries, WASM and +resources. The manifest schema is `paperclip.pi-runtime-files.v1`, with relative +`node`, `piEntrypoint`, `extension`, `wrapperEntrypoint` fields and a complete +`files` array of SHA-256-bound regular files or contained relative symlinks. +Hardlinked files, escaping links, changed files, and undeclared files fail +admission. Its verifier returns the three launch environment bindings and a +manifest digest. Admission verification alone is not a command lease: the shared +host must retain its immutable snapshot and process guardian through termination. + +The published Pi package has a shrinkwrap and a nested dependency graph. Provider +pack creation must copy the complete installed graph and use the same pinned Pi +family dependencies, rather than reconstructing the graph from `cli.js` imports. +The emitted `dist/drivers/acpx/pi-runtime-extension.js` must be included in every +macOS arm64/x64 and Linux x64 pack. The wrapper's `dist/paperclip-runtime.js` is +part of its verified package and must never be omitted from a copy or hash. + +## Verification and maintenance + +The three colocated Vitest suites exercise questions, permissions, timeouts, +framing, usage, file policies, MCP behavior, and graph verification. The Node test +`test/pi-acp-package-contract.test.mjs` launches the actual patched npm wrapper +against a deterministic RPC fixture. It proves initialization, streaming, +terminal usage, question versus permission routing, explicit steering, provider +exit, required sentinel, and typed failure settlement. It requires the exact +installed pinned package; an absent or unpatched dependency is a failure. +`PAPERCLIP_TEST_PI_ACP_PACKAGE` can select a separately installed pinned fixture. +These fixtures invoke no model or paid API. + +A no-key probe against the real Pi 0.84.2 CLI loaded the compiled owned extension +and returned the exact readiness command. This checks the extension ABI and +explicit loading path. It does not prove authenticated inference, native tool +execution, MCP model invocation, credential renewal, or remote sandbox behavior. +Those remain required local and Daytona qualification evidence under the shared +live-spend cap. No live inference spend was incurred by these deterministic tests. + +The helper in `patches/pi-acp@0.0.33.patch` is generated from +`src/drivers/acpx/pi-acp-runtime.ts` with Node `stripTypeScriptTypes`, trimming +trailing whitespace. The package contract checks that these bytes match. When +updating the upstream version, rebase the wrapper patch and rerun the actual +package tests; do not preserve an old qualified command hash after changing bytes. + +Primary references reviewed: + +- [Harness priorities report](https://pages.paperclip.ing/2026-09-25-harness-priorities/report.md) +- [pi-acp source](https://github.com/svkozak/pi-acp) +- [Pi RPC protocol](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md) +- [Pi extensions](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md) +- [ACP protocol and SDK](https://github.com/agentclientprotocol/typescript-sdk) diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts new file mode 100644 index 00000000000..365b26e62c2 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts @@ -0,0 +1,96 @@ +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPiLaunchSpec, PiRpcFrames, PiTurnUsage, PiUiBridge } from "./pi-acp-runtime.js"; + +const temporary: string[] = []; +afterEach(async () => { vi.useRealTimers(); for (const path of temporary.splice(0)) await rm(path, { recursive: true, force: true }); }); +function fixture() { + const connection = { requestPermission: vi.fn(), unstable_createElicitation: vi.fn() }; + const process = { sendExtensionUiResponse: vi.fn().mockResolvedValue(undefined) }; + return { connection, process, bridge: new PiUiBridge("session-a", connection, process) }; +} + +describe("Pi ACP bridge", () => { + it.each([ + ["select", { options: ["Red", "Blue"] }, "Blue", { value: "Blue" }], + ["confirm", { message: "Proceed?" }, false, { confirmed: false }], + ["input", { placeholder: "Name" }, "Ada", { value: "Ada" }], + ["editor", { prefill: "Old\nText" }, "New\nText", { value: "New\nText" }], + ])("round-trips %s as questions independently of permission auto-approval", async (method, extra, answer, expected) => { + const f = fixture(); f.connection.unstable_createElicitation.mockResolvedValue({ action: "accept", content: { answer } }); + await f.bridge.handle({ id: "q1", method, title: "Choice", ...extra }); + expect(f.connection.requestPermission).not.toHaveBeenCalled(); + expect(f.connection.unstable_createElicitation).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "session-a", mode: "form", requestedSchema: expect.objectContaining({ required: ["answer"] }) })); + expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q1", ...expected }); + }); + + it("sends only offered native permission choices and keeps questions separate", async () => { + const f = fixture(); f.connection.requestPermission.mockResolvedValue({ outcome: { outcome: "selected", optionId: "allow_always" } }); + const event = { id: "p1", method: "select", title: 'paperclip.pi.permission.v1:{"toolCallId":"tool-a","toolName":"bash","input":{"command":"pwd"}}' }; + await f.bridge.handle(event); + expect(f.connection.unstable_createElicitation).not.toHaveBeenCalled(); + expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "p1", value: "Allow for this session" }); + expect(f.connection.requestPermission.mock.calls[0]![0]).toMatchObject({ toolCall: { toolCallId: "tool-a", kind: "execute" }, options: [{ kind: "allow_once" }, { kind: "allow_always" }, { kind: "reject_once" }] }); + await f.bridge.handle(event); expect(f.connection.requestPermission).toHaveBeenCalledTimes(1); + }); + + it("expires pending requests and discards a late answer exactly once", async () => { + const f = fixture(); let answer!: (value: unknown) => void; + f.connection.unstable_createElicitation.mockImplementation(() => new Promise((resolve) => { answer = resolve; })); + const request = f.bridge.handle({ id: "q", method: "input", title: "Question" }); + f.bridge.cancelAll(); f.bridge.cancelAll(); + answer({ action: "accept", content: { answer: "stale" } }); await request; + expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q", cancelled: true }); + }); + + it("honors Pi dialog deadlines and rejects invalid answers", async () => { + vi.useFakeTimers(); const f = fixture(); let answer!: (value: unknown) => void; + f.connection.unstable_createElicitation.mockImplementation(() => new Promise((resolve) => { answer = resolve; })); + const request = f.bridge.handle({ id: "q", method: "select", title: "Question", options: ["A"], timeout: 100 }); + await vi.advanceTimersByTimeAsync(100); + answer({ action: "accept", content: { answer: "A" } }); await request; + expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q", cancelled: true }); + f.connection.unstable_createElicitation.mockResolvedValue({ action: "accept", content: { answer: "other" } }); + await f.bridge.handle({ id: "q2", method: "select", title: "Question", options: ["A"] }); + expect(f.process.sendExtensionUiResponse).toHaveBeenLastCalledWith({ id: "q2", cancelled: true }); + }); + + it("uses LF framing across UTF-8 chunks without splitting Unicode separators", () => { + const seen: unknown[] = []; const frames = new PiRpcFrames((value) => seen.push(value)); + const bytes = Buffer.from(JSON.stringify({ text: "๐ŸŒ’\u2028\u2029" }) + "\r\n"); + for (const byte of bytes) frames.write(Uint8Array.of(byte)); frames.end(); + expect(seen).toEqual([{ text: "๐ŸŒ’\u2028\u2029" }]); + expect(() => new PiRpcFrames(() => {}, 2).write(Buffer.from("xxx"))).toThrow("bound"); + const truncated = new PiRpcFrames(() => {}); truncated.write(Buffer.from('{"x":1}')); + expect(() => truncated.end()).toThrow("inside"); + }); + + it("reports actual incremental usage and resets the failure after a successful retry", () => { + const usage = new PiTurnUsage(); const failed = { role: "assistant", timestamp: 1, stopReason: "error", usage: { input: 4, output: 1, cacheRead: 2, cacheWrite: 3, cost: { total: 0.01 } } }; + usage.accept(failed); usage.accept(failed); + expect(usage.response()).toMatchObject({ usage: { totalTokens: 10 }, _meta: { jetbrains: { air: { sessionFailure: { severity: "error" } } } } }); + usage.accept({ ...failed, timestamp: 2, stopReason: "stop" }); + expect(usage.response()).toMatchObject({ usage: { inputTokens: 8, totalTokens: 20 } }); + expect(usage.response()._meta).toBeUndefined(); + usage.reset(); expect(usage.response()).toEqual({}); + }); + + it("launches verified paths only and rejects session escapes", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-pi-launch-")); temporary.push(root); + await mkdir(join(root, "sessions")); + for (const name of ["node", "cli.js", "extension.js"]) await writeFile(join(root, name), "verified"); + const environment = { + PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_PI_READ_ONLY: "0", + PAPERCLIP_PI_NODE_EXECUTABLE: join(root, "node"), PAPERCLIP_PI_ENTRYPOINT: join(root, "cli.js"), PAPERCLIP_PI_EXTENSION_PATH: join(root, "extension.js"), + PI_CODING_AGENT_DIR: root, + }; + const launch = createPiLaunchSpec({ cwd: root, mcpServers: [] }, environment); + expect(launch.command).toBe(join(root, "node")); + expect(launch.args).toEqual(expect.arrayContaining(["--no-extensions", "--no-approve", "--no-skills", "--offline"])); + expect(() => createPiLaunchSpec({ cwd: root }, { ...environment, PAPERCLIP_PI_NODE_EXECUTABLE: "pi" })).toThrow("binding"); + await symlink(join(root, "cli.js"), join(root, "sessions", "escape.jsonl")); + expect(() => createPiLaunchSpec({ cwd: root, sessionPath: join(root, "sessions", "escape.jsonl") }, environment)).toThrow("escaped"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts new file mode 100644 index 00000000000..d8f6922bbbd --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts @@ -0,0 +1,248 @@ +/** Source for the helper embedded in patches/pi-acp@0.0.33.patch. */ +import { StringDecoder } from "node:string_decoder"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from "node:fs"; + +const PERMISSION_PREFIX = "paperclip.pi.permission.v1:"; +const PERMISSION_CHOICES = [ + { optionId: "allow_once", name: "Allow once", kind: "allow_once" }, + { optionId: "allow_always", name: "Allow for this session", kind: "allow_always" }, + { optionId: "reject_once", name: "Deny", kind: "reject_once" }, +]; +type RecordValue = Record; +function record(value: unknown): RecordValue { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Pi runtime record"); + return value as RecordValue; +} +function text(value: unknown, max = 16_384): string { + if (typeof value !== "string" || Buffer.byteLength(value) > max) throw new Error("Invalid Pi runtime text"); + return value; +} + +export const PI_ACP_FEATURES = Object.freeze({ + version: 1, steering: true, queuedFollowUp: true, + questions: ["select", "confirm", "input", "editor"], + nativePermissions: true, promptUsage: true, nativePlan: false, + pendingRequestRecovery: "live-process-only", +}); + +/** Strict LF framing: Unicode line separators inside JSON are ordinary text. */ +export class PiRpcFrames { + private decoder = new StringDecoder("utf8"); + private pending = ""; + private readonly receive: (value: RecordValue) => void; + private readonly limit: number; + constructor(receive: (value: RecordValue) => void, limit = 4 * 1024 * 1024) { this.receive = receive; this.limit = limit; } + write(chunk: Uint8Array): void { + this.pending += this.decoder.write(Buffer.from(chunk)); + for (;;) { + const index = this.pending.indexOf("\n"); + if (index < 0) break; + const line = this.pending.slice(0, index).replace(/\r$/, ""); + this.pending = this.pending.slice(index + 1); + if (Buffer.byteLength(line) > this.limit) throw new Error("Pi RPC frame exceeds its bound"); + if (line.trim()) this.receive(record(JSON.parse(line))); + } + if (Buffer.byteLength(this.pending) > this.limit) throw new Error("Pi RPC frame exceeds its bound"); + } + end(): void { + this.pending += this.decoder.end(); + if (this.pending.trim()) throw new Error("Pi RPC stream ended inside a frame"); + } +} + +function requiredFile(environment: NodeJS.ProcessEnv, name: string): string { + const path = environment[name]; + if (!path || !isAbsolute(path) || /[\0\r\n]/.test(path)) throw new Error(`Missing verified Pi launch binding: ${name}`); + // This checks the handoff shape. The runner command lease verifies all bytes, + // retains their directory identity, and owns the subprocess lifetime. + if (!lstatSync(path).isFile()) throw new Error(`Invalid verified Pi launch binding: ${name}`); + return path; +} +function pathArray(raw: string | undefined): string[] { + const paths: unknown = JSON.parse(raw ?? "[]"); + if (!Array.isArray(paths) || paths.length > 128 || paths.some((p) => typeof p !== "string" || !isAbsolute(p) || /[\0\r\n]/.test(p))) throw new Error("Invalid Pi runtime paths"); + return paths; +} + +/** Provider tool events are untrusted; diff projection cannot read host files. */ +export function snapshotPiWorkspaceFile(cwd: string, path: string, environment: NodeJS.ProcessEnv = process.env): string { + const root = realpathSync(cwd); + const logical = resolve(root, path); + const physical = realpathSync(logical); + const within = (base: string, target: string): boolean => { + const suffix = relative(base, target); + return !isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`); + }; + if (!within(root, logical) || !within(root, physical)) throw new Error("Pi diff escaped its workspace"); + for (const protectedPath of pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS)) { + if (within(protectedPath, logical)) throw new Error("Pi diff targets protected state"); + let protectedPhysical: string; + try { protectedPhysical = realpathSync(protectedPath); } catch { continue; } + if (within(protectedPhysical, physical)) throw new Error("Pi diff targets protected state"); + } + const fd = openSync(physical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const before = fstatSync(fd, { bigint: true }); + if (!before.isFile() || before.nlink !== 1n || before.size > 4n * 1024n * 1024n) throw new Error("Pi diff file is unsupported"); + const result = readFileSync(fd, "utf8"); + const after = fstatSync(fd, { bigint: true }); + const named = lstatSync(physical, { bigint: true }); + if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.ctimeNs !== after.ctimeNs || named.ino !== before.ino || named.dev !== before.dev || realpathSync(logical) !== physical) throw new Error("Pi diff file changed during capture"); + return result; + } finally { closeSync(fd); } +} + +export function createPiLaunchSpec( + params: { cwd: string; mcpServers?: unknown[]; sessionPath?: string }, + environment: NodeJS.ProcessEnv, +): { command: string; args: string[]; env: NodeJS.ProcessEnv } { + if (environment.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") throw new Error("Pi requires an isolated runner launch"); + const command = requiredFile(environment, "PAPERCLIP_PI_NODE_EXECUTABLE"); + const entrypoint = requiredFile(environment, "PAPERCLIP_PI_ENTRYPOINT"); + const extension = requiredFile(environment, "PAPERCLIP_PI_EXTENSION_PATH"); + if (environment.PAPERCLIP_PI_READ_ONLY !== "0" && environment.PAPERCLIP_PI_READ_ONLY !== "1") throw new Error("Pi requires an explicit read-only policy"); + const readRoots = pathArray(environment.PAPERCLIP_PI_READ_ROOTS); + const protectedRoots = pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS); + const workspace = realpathSync(params.cwd); + const configuration = JSON.stringify({ + workspace, servers: params.mcpServers ?? [], + readOnly: environment.PAPERCLIP_PI_READ_ONLY === "1", + readRoots, protectedRoots, + instructions: environment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS ?? "", + }); + if (Buffer.byteLength(configuration) > 64 * 1024) throw new Error("Pi launch configuration exceeds its bound"); + const args = [entrypoint, "--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-approve", "--offline", "-e", extension]; + for (const root of readRoots) args.push("--skill", root); + if (params.sessionPath) { + const home = environment.PI_CODING_AGENT_DIR; + if (!home) throw new Error("Pi session home is missing"); + const sessionRoot = realpathSync(resolve(home, "sessions")); + const sessionPath = realpathSync(params.sessionPath); + const suffix = relative(sessionRoot, sessionPath); + if (!suffix || isAbsolute(suffix) || suffix === ".." || suffix.startsWith(`..${sep}`) || !lstatSync(params.sessionPath).isFile()) throw new Error("Pi session escaped its private home"); + args.push("--session", sessionPath); + } + return { command, args, env: { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration } }; +} + +interface UiConnection { + requestPermission(params: RecordValue): Promise; + unstable_createElicitation(params: RecordValue): Promise; +} +interface UiProcess { sendExtensionUiResponse(response: RecordValue): Promise } + +/** Live promises never become authority to replay an answer after provider death. */ +export class PiUiBridge { + private readonly pending = new Map(); + private readonly seen = new Set(); + private readonly sessionId: string; + private readonly connection: UiConnection; + private readonly process: UiProcess; + constructor(sessionId: string, connection: UiConnection, process: UiProcess) { this.sessionId = sessionId; this.connection = connection; this.process = process; } + + cancelAll(): void { for (const value of this.pending.values()) value.cancel(); } + + async handle(event: RecordValue): Promise { + const id = text(event.id, 256); + const method = text(event.method, 64); + if (!["select", "confirm", "input", "editor"].includes(method)) return; + if (this.seen.has(id)) return; + if (this.seen.size >= 4096) throw new Error("Pi UI request history exceeds its bound"); + this.seen.add(id); + let done = false; + let timeout: ReturnType | undefined; + const finish = async (response: RecordValue): Promise => { + if (done) return; + done = true; + if (timeout) clearTimeout(timeout); + this.pending.delete(id); + await this.process.sendExtensionUiResponse({ id, ...response }); + }; + this.pending.set(id, { cancel: () => { void finish({ cancelled: true }).catch(() => {}); } }); + if (typeof event.timeout === "number" && Number.isFinite(event.timeout)) { + timeout = setTimeout(() => { void finish({ cancelled: true }).catch(() => {}); }, Math.max(0, Math.min(event.timeout, 2_147_483_647))); + timeout.unref?.(); + } + try { + const title = text(event.title ?? "Additional information needed", 65_536); + if (method === "select" && title.startsWith(PERMISSION_PREFIX)) { + const permission = record(JSON.parse(title.slice(PERMISSION_PREFIX.length))); + const toolCallId = text(permission.toolCallId, 256); + const toolName = text(permission.toolName, 128); + const input = record(permission.input); + const result = record(await this.connection.requestPermission({ + sessionId: this.sessionId, + toolCall: { toolCallId, title: `Pi ${toolName}`, kind: toolName === "bash" ? "execute" : ["write", "edit"].includes(toolName) ? "edit" : "read", status: "pending", rawInput: input }, + options: PERMISSION_CHOICES, + })); + const outcome = record(result.outcome); + const selected = outcome.outcome === "selected" ? PERMISSION_CHOICES.find((option) => option.optionId === outcome.optionId) : undefined; + await finish(selected ? { value: selected.name } : { cancelled: true }); + return; + } + const property: RecordValue = { type: method === "confirm" ? "boolean" : "string", title }; + if (method === "select") { + if (!Array.isArray(event.options) || event.options.length < 1 || event.options.length > 128) throw new Error("Invalid Pi question options"); + property.enum = event.options.map((option) => text(option, 4096)); + if (new Set(property.enum as string[]).size !== event.options.length) throw new Error("Ambiguous Pi question options"); + } + if (method === "input" && typeof event.placeholder === "string") property.description = text(event.placeholder); + if (method === "editor" && typeof event.prefill === "string") property.default = text(event.prefill); + const result = record(await this.connection.unstable_createElicitation({ + sessionId: this.sessionId, mode: "form", message: typeof event.message === "string" ? text(event.message) : title, + requestedSchema: { type: "object", title, properties: { answer: property }, required: ["answer"] }, + _meta: { paperclipPi: { version: 1, requestId: id, method } }, + })); + if (result.action !== "accept") { await finish({ cancelled: true }); return; } + const answer = record(result.content).answer; + if (method === "confirm") { + if (typeof answer !== "boolean") throw new Error("Invalid Pi confirmation response"); + await finish({ confirmed: answer }); + } else { + const value = text(answer, 65_536); + if (method === "select" && !(property.enum as string[]).includes(value)) throw new Error("Invalid Pi question response"); + await finish({ value }); + } + } catch { await finish({ cancelled: true }).catch(() => {}); } + } +} + +/** Sum actual assistant receipts, never context occupancy or estimated cost. */ +export class PiTurnUsage { + private seen = new Set(); + private total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; + private cost = 0; + private failed = false; + private observed = false; + reset(): void { this.seen.clear(); this.total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; this.cost = 0; this.failed = false; this.observed = false; } + accept(value: unknown): void { + const message = record(value); + if (message.role !== "assistant") return; + this.failed = message.stopReason === "error"; + if (!message.usage || typeof message.usage !== "object") return; + const key = JSON.stringify([message.timestamp, message.id, message.usage, message.content]); + if (this.seen.has(key)) return; + if (this.seen.size >= 8192) throw new Error("Pi usage receipts exceed their bound"); + this.seen.add(key); + const usage = record(message.usage); + const numbers = ["input", "output", "cacheRead", "cacheWrite"].map((name) => usage[name] ?? 0); + if (numbers.some((value) => typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) return; + this.observed = true; + for (const [index, field] of ["inputTokens", "outputTokens", "cachedReadTokens", "cachedWriteTokens"].entries()) { + const name = field as keyof typeof this.total; + this.total[name] += numbers[index] as number; + } + this.total.totalTokens = this.total.inputTokens + this.total.outputTokens + this.total.cachedReadTokens + this.total.cachedWriteTokens; + if (usage.cost && typeof usage.cost === "object") { + const cost = record(usage.cost).total; + if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0) this.cost += cost; + } + } + response(): RecordValue { + return { + ...(this.observed ? { usage: { ...this.total, _meta: { paperclipPi: { provenance: "assistant_message_receipts", costUsd: this.cost } } } } : {}), + ...(this.failed ? { _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "service", title: "Pi provider request failed" } } } } } : {}), + }; + } +} diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts new file mode 100644 index 00000000000..3f3d86cf8a5 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts @@ -0,0 +1,116 @@ +import { createServer } from "node:http"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + checkPiNativeTool, installPiRuntimeExtension, piMcpRequest, readPiRuntimeConfiguration, + PI_PERMISSION_TITLE_PREFIX, type PiExtensionApi, type PiRuntimeConfiguration, type PiToolDefinition, +} from "./pi-runtime-extension.js"; + +const directories: string[] = []; +afterEach(async () => { for (const path of directories.splice(0)) await rm(path, { recursive: true, force: true }); }); +async function workspace() { + const root = await mkdtemp(join(tmpdir(), "paperclip-pi-extension-")); directories.push(root); + await mkdir(join(root, "workspace")); await mkdir(join(root, "skills")); await mkdir(join(root, "private")); + return { root, config: { + workspace: join(root, "workspace"), readOnly: false, + readRoots: [join(root, "skills")], protectedRoots: [join(root, "private")], + instructions: "Use the assigned Paperclip tools", servers: [], + } satisfies PiRuntimeConfiguration }; +} +function harness() { + const handlers = new Map any>(); + const tools: PiToolDefinition[] = []; + const api = { on: (event: string, handler: (...args: any[]) => any) => handlers.set(event, handler), registerTool: (tool: PiToolDefinition) => tools.push(tool), registerCommand: vi.fn() } as unknown as PiExtensionApi; + return { api, handlers, tools }; +} + +describe("owned Pi runtime extension", () => { + it("requires explicit isolated configuration and loopback authentication", () => { + expect(() => readPiRuntimeConfiguration({})).toThrow("missing"); + const value = { workspace: "/work/project", readOnly: false, readRoots: [], protectedRoots: [], instructions: "", servers: [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234/mcp", headers: [{ name: "Authorization", value: "Bearer 1234567890123456" }] }] }; + const parse = () => readPiRuntimeConfiguration({ PAPERCLIP_PI_RUNTIME_CONFIGURATION: JSON.stringify(value) }); + expect(parse().servers).toHaveLength(1); + value.servers[0]!.url = "https://127.0.0.1.evil.test/mcp"; + expect(parse).toThrow("loopback"); + value.servers[0]!.url = "http://user:password@127.0.0.1/mcp"; + expect(parse).toThrow("loopback"); + }); + + it("denies escapes and read-only mutations before requesting provider permission", async () => { + const { root, config } = await workspace(); const h = harness(); + await symlink(join(root, "private"), join(config.workspace, "escape")); + await installPiRuntimeExtension(h.api, config); + const select = vi.fn().mockResolvedValue("Allow once"); + const context = { cwd: config.workspace, ui: { select } }; + const tool = h.handlers.get("tool_call")!; + expect(await tool({ toolName: "write", toolCallId: "t1", input: { path: "escape/new-file" } }, context)).toMatchObject({ block: true }); + expect(await tool({ toolName: "read", toolCallId: "t2", input: { path: "../../outside" } }, context)).toMatchObject({ block: true }); + config.readOnly = true; + expect(await tool({ toolName: "bash", toolCallId: "t3", input: { command: "touch result" } }, context)).toMatchObject({ block: true }); + expect(select).not.toHaveBeenCalled(); + }); + + it("revalidates paths after a human wait and never treats cancellation as approval", async () => { + const { root, config } = await workspace(); const h = harness(); + await installPiRuntimeExtension(h.api, config); + const context = { cwd: config.workspace, ui: { select: vi.fn(async () => { + await symlink(join(root, "private"), join(config.workspace, "target")); + return "Allow once"; + }) } }; + expect(await h.handlers.get("tool_call")!({ toolName: "write", toolCallId: "t", input: { path: "target/new" } }, context)).toMatchObject({ block: true }); + expect(context.ui.select.mock.calls[0]![0]).toMatch(PI_PERMISSION_TITLE_PREFIX); + context.ui.select = vi.fn().mockResolvedValue(undefined); + expect(await h.handlers.get("tool_call")!({ toolName: "bash", toolCallId: "b", input: { command: "pwd" } }, context)).toMatchObject({ block: true }); + }); + + it("makes assigned skills readable but never writable", async () => { + const { config } = await workspace(); + await writeFile(join(config.readRoots[0]!, "SKILL.md"), "assigned"); + const context = { cwd: config.workspace, ui: { select: vi.fn() } }; + expect(await checkPiNativeTool({ toolName: "read", toolCallId: "r", input: { path: join(config.readRoots[0]!, "SKILL.md") } }, context, config)).toBeNull(); + expect(await checkPiNativeTool({ toolName: "write", toolCallId: "w", input: { path: join(config.readRoots[0]!, "SKILL.md") } }, context, config)).toMatch("outside"); + }); + + it("registers exact authenticated MCP tools with stable idempotency and abort signal", async () => { + const { config } = await workspace(); const h = harness(); + config.servers = [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234", headers: [] }]; + const request = vi.fn(async (_server, method) => method === "tools/list" + ? { tools: [{ name: "report_progress", description: "Report progress", inputSchema: { type: "object", properties: {} } }] } + : method === "tools/call" ? { content: [{ type: "text", text: "recorded" }] } : {}); + await installPiRuntimeExtension(h.api, config, request); + const tool = h.tools[0]!; expect(tool.name).toBe("mcp__paperclip__report_progress"); + const signal = new AbortController().signal; + await expect(tool.execute("call-1", { text: "done" }, signal)).resolves.toMatchObject({ content: [{ text: "recorded" }] }); + expect(request).toHaveBeenLastCalledWith(config.servers[0], "tools/call", { name: "report_progress", arguments: { text: "done" } }, "call-1", signal); + const select = vi.fn(); const context = { cwd: config.workspace, ui: { select } }; + expect(await h.handlers.get("tool_call")!({ toolName: tool.name, toolCallId: "m", input: {} }, context)).toBeUndefined(); + expect(await h.handlers.get("tool_call")!({ toolName: "mcp__paperclip__invented", toolCallId: "i", input: {} }, context)).toMatchObject({ block: true }); + expect(select).not.toHaveBeenCalled(); + }); + + it("rejects incomplete catalogs and tool-result errors without exposing auth", async () => { + const { config } = await workspace(); const h = harness(); + config.servers = [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234", headers: [] }]; + const request = vi.fn(async (_server, method) => method === "tools/list" ? { tools: [], nextCursor: "more" } : {}); + await expect(installPiRuntimeExtension(h.api, config, request)).rejects.toThrow("incomplete"); + }); + + it("does not follow MCP redirects or forward credentials to another origin", async () => { + const server = createServer((_req, res) => { res.writeHead(302, { Location: "http://127.0.0.1:1/steal" }); res.end(); }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as { port: number }; + try { + await expect(piMcpRequest({ type: "http", name: "paperclip", url: `http://127.0.0.1:${address.port}`, headers: [{ name: "Authorization", value: "Bearer secret" }] }, "tools/list", {}, "1")).rejects.toThrow(); + } finally { server.closeAllConnections(); await new Promise((resolve) => server.close(() => resolve())); } + }); + + it("validates response IDs and bounds tool output", async () => { + const server = { type: "http" as const, name: "paperclip", url: "http://127.0.0.1:1", headers: [] }; + const fetch_ = vi.fn(async () => new Response(JSON.stringify({ jsonrpc: "2.0", id: "other", result: {} }))); + await expect(piMcpRequest(server, "tools/list", {}, "1", undefined, fetch_)).rejects.toThrow("identity"); + fetch_.mockImplementation(async () => new Response("x".repeat(4 * 1024 * 1024 + 1))); + await expect(piMcpRequest(server, "tools/list", {}, "1", undefined, fetch_)).rejects.toThrow("oversized"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts new file mode 100644 index 00000000000..4965e1b2d42 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts @@ -0,0 +1,267 @@ +/** + * The only extension admitted to the qualified Pi process. The launcher loads + * this immutable module explicitly; project/global extension discovery is off. + * Structural types keep this module independent of Pi's optional UI packages. + */ +import { realpath } from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; + +export const PI_PERMISSION_TITLE_PREFIX = "paperclip.pi.permission.v1:"; +export const PI_PERMISSION_OPTIONS = ["Allow once", "Allow for this session", "Deny"] as const; +const MAX_CONFIGURATION_BYTES = 64 * 1024; +const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +const MAX_TOOLS = 512; + +interface PiUi { + select(title: string, options: string[]): Promise; +} + +export interface PiExtensionContext { cwd: string; ui: PiUi } +export interface PiToolEvent { + toolName: string; + toolCallId: string; + input: Record; +} + +export interface PiToolDefinition { + name: string; + label: string; + description: string; + parameters: Record; + execute( + toolCallId: string, + arguments_: Record, + signal?: AbortSignal, + ): Promise<{ content: Array>; details?: unknown }>; +} + +export interface PiExtensionApi { + registerTool(tool: PiToolDefinition): void; + registerCommand(name: string, command: { description: string; handler(): Promise }): void; + on(event: "tool_call", handler: ( + event: PiToolEvent, context: PiExtensionContext, + ) => Promise<{ block: true; reason: string } | undefined>): void; + on(event: "user_bash", handler: () => { result: { output: string; exitCode: number; cancelled: boolean; truncated: boolean } }): void; + on(event: "project_trust", handler: () => { trusted: "no"; remember: false }): void; + on(event: "before_agent_start", handler: (event: { systemPrompt: string }) => { systemPrompt: string } | undefined): void; +} + +export interface PiMcpServer { + type: "http"; + name: string; + url: string; + headers: Array<{ name: string; value: string }>; +} + +export interface PiRuntimeConfiguration { + servers: PiMcpServer[]; + workspace: string; + readOnly: boolean; + readRoots: string[]; + protectedRoots: string[]; + instructions: string; +} + +/** This environment is synthesized by the verified wrapper, never the model. */ +export function readPiRuntimeConfiguration(environment: NodeJS.ProcessEnv): PiRuntimeConfiguration { + const raw = environment.PAPERCLIP_PI_RUNTIME_CONFIGURATION; + if (!raw || Buffer.byteLength(raw) > MAX_CONFIGURATION_BYTES) throw new Error("Pi runtime configuration is missing or oversized"); + const value = asRecord(JSON.parse(raw)); + const workspace = absolutePath(value.workspace, "workspace"); + if (typeof value.readOnly !== "boolean") throw new Error("Pi read-only policy is missing"); + if (!Array.isArray(value.servers) || value.servers.length > 16) throw new Error("Pi MCP configuration is invalid"); + const names = new Set(); + const servers = value.servers.map((entry): PiMcpServer => { + const server = asRecord(entry); + if (server.type !== "http" || typeof server.name !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(server.name) || names.has(server.name)) { + throw new Error("Pi MCP server identity is invalid"); + } + names.add(server.name); + if (typeof server.url !== "string") throw new Error("Pi MCP endpoint is invalid"); + const url = new URL(server.url); + if (!['http:', 'https:'].includes(url.protocol) || !['127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password || url.hash) { + throw new Error("Pi MCP endpoint must be a runner-owned loopback endpoint"); + } + if (!Array.isArray(server.headers) || server.headers.length !== 1) throw new Error("Pi MCP authentication is invalid"); + const header = asRecord(server.headers[0]); + if (header.name !== "Authorization" || typeof header.value !== "string" || !/^Bearer [A-Za-z0-9._~-]{16,4096}$/.test(header.value)) { + throw new Error("Pi MCP authentication is invalid"); + } + return { type: "http", name: server.name, url: url.href, headers: [{ name: "Authorization", value: header.value }] }; + }); + if (typeof value.instructions !== "string" || Buffer.byteLength(value.instructions) > 32 * 1024) throw new Error("Pi runtime instructions are invalid"); + return { + servers, workspace, readOnly: value.readOnly, + readRoots: pathList(value.readRoots, "read roots"), + protectedRoots: pathList(value.protectedRoots, "protected roots"), + instructions: value.instructions, + }; +} + +function pathList(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.length > 128) throw new Error(`Pi ${label} are invalid`); + return value.map((path) => absolutePath(path, label)); +} + +function absolutePath(value: unknown, label: string): string { + if (typeof value !== "string" || !isAbsolute(value) || /[\0\r\n]/.test(value) || value === "/") throw new Error(`Pi ${label} path is invalid`); + return resolve(value); +} + +function asRecord(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Pi runtime record is invalid"); + return value as Record; +} + +function inside(root: string, target: string): boolean { + const suffix = relative(root, target); + return suffix === "" || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)); +} + +/** Resolve every existing ancestor, including symlinks, before creating files. */ +async function physicalPath(path: string): Promise { + try { return await realpath(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(path) === path) throw error; + return resolve(await physicalPath(dirname(path)), relative(dirname(path), path)); + } +} + +export async function checkPiNativeTool( + event: PiToolEvent, context: PiExtensionContext, config: PiRuntimeConfiguration, +): Promise { + if (await realpath(context.cwd) !== await realpath(config.workspace)) return "Pi workspace changed after admission"; + const readTools = new Set(["read", "grep", "find", "ls"]); + const fileTools = new Set([...readTools, "edit", "write"]); + if (!fileTools.has(event.toolName) && event.toolName !== "bash") return "Unregistered Pi tool is not admitted"; + if (config.readOnly && !readTools.has(event.toolName)) return "This execution permits reading only"; + // Shell syntax is not a filesystem authorization language. The host sandbox + // remains authoritative for commands; every command still crosses ACP policy. + if (event.toolName === "bash") return null; + const pathValue = event.input.path ?? (readTools.has(event.toolName) ? "." : undefined); + if (typeof pathValue !== "string" || /[\0\r\n]/.test(pathValue)) return "Pi tool path is invalid"; + const logical = resolve(config.workspace, pathValue); + const target = await physicalPath(logical); + for (const root of config.protectedRoots) { + if (inside(root, logical) || inside(await physicalPath(root), target)) return "Pi tool targets protected runtime state"; + } + const roots = readTools.has(event.toolName) ? [config.workspace, ...config.readRoots] : [config.workspace]; + for (const root of roots) { + if (inside(root, logical) && inside(await realpath(root), target)) return null; + } + return "Pi tool path is outside its assigned workspace"; +} + +/** Bounded JSON MCP client for runner-owned HTTP bridges. No redirects or files. */ +export async function piMcpRequest( + server: PiMcpServer, + method: string, + params: Record, + id: string, + signal?: AbortSignal, + fetch_: typeof fetch = fetch, +): Promise> { + const request = JSON.stringify({ jsonrpc: "2.0", id, method, params }); + if (Buffer.byteLength(request) > 1_048_576) throw new Error("Pi MCP request is oversized"); + const timeout = AbortSignal.timeout(method === "tools/call" ? 24 * 60 * 60 * 1000 : 30_000); + const response = await fetch_(server.url, { + method: "POST", redirect: "error", + headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...Object.fromEntries(server.headers.map((h) => [h.name, h.value])) }, + body: request, signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + if (!response.ok || !response.body) throw new Error(`Pi MCP ${method} failed (HTTP ${response.status})`); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + for (;;) { + const item = await reader.read(); + if (item.done) break; + length += item.value.byteLength; + if (length > MAX_RESPONSE_BYTES) throw new Error("Pi MCP response is oversized"); + chunks.push(item.value); + } + } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } + const message = asRecord(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + if (message.jsonrpc !== "2.0" || message.id !== id) throw new Error("Pi MCP response identity mismatch"); + if (message.error) throw new Error(`Pi MCP ${method} was rejected`); + return asRecord(message.result); +} + +export async function installPiRuntimeExtension( + pi: PiExtensionApi, + config: PiRuntimeConfiguration, + request: typeof piMcpRequest = piMcpRequest, +): Promise { + // Registered names are held in this closure; provider-originated metadata + // cannot promote a tool to the authenticated Paperclip bridge. + const bridgeTools = new Set(); + pi.on("project_trust", () => ({ trusted: "no", remember: false })); + pi.on("user_bash", () => ({ result: { output: "Interactive shell commands are disabled in Paperclip Runner", exitCode: 1, cancelled: false, truncated: false } })); + pi.on("before_agent_start", (event) => config.instructions + ? { systemPrompt: `${event.systemPrompt}\n\n${config.instructions}` } : undefined); + pi.on("tool_call", async (event, context) => { + if (bridgeTools.has(event.toolName)) return; + try { + const denial = await checkPiNativeTool(event, context, config); + if (denial) return { block: true, reason: denial }; + const detail = JSON.stringify({ toolCallId: event.toolCallId, toolName: event.toolName, input: event.input }); + if (Buffer.byteLength(detail) > 48 * 1024) return { block: true, reason: "Pi permission request is oversized" }; + const selection = await context.ui.select(`${PI_PERMISSION_TITLE_PREFIX}${detail}`, [...PI_PERMISSION_OPTIONS]); + if (selection !== "Allow once" && selection !== "Allow for this session") return { block: true, reason: "Pi operation was denied or cancelled" }; + // Re-check file bindings after a human wait; approval never freezes paths. + const changed = await checkPiNativeTool(event, context, config); + return changed ? { block: true, reason: changed } : undefined; + } catch { return { block: true, reason: "Pi permission boundary is unavailable" }; } + }); + let count = 0; + for (const server of config.servers) { + await request(server, "initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "paperclip-pi", version: "1" } }, `pi-init-${server.name}`); + const catalog = await request(server, "tools/list", {}, `pi-tools-${server.name}`); + if (!Array.isArray(catalog.tools) || catalog.nextCursor) throw new Error("Pi MCP catalog is incomplete"); + for (const rawTool of catalog.tools) { + const tool = asRecord(rawTool); + if (typeof tool.name !== "string" || !/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)) throw new Error("Pi MCP tool name is invalid"); + const nativeName = `mcp__${server.name}__${tool.name}`; + if (bridgeTools.has(nativeName) || ++count > MAX_TOOLS) throw new Error("Pi MCP catalog is ambiguous or oversized"); + const schema = asRecord(tool.inputSchema); + if (schema.type !== "object") throw new Error("Pi MCP tool parameters must be an object"); + const sourceName = tool.name; + const definition: PiToolDefinition = { + name: nativeName, label: `${server.name}: ${sourceName}`, + description: typeof tool.description === "string" ? tool.description : sourceName, + parameters: structuredClone(schema), + async execute(callId, arguments_, signal) { + const result = await request(server, "tools/call", { name: sourceName, arguments: arguments_ }, callId, signal); + if (result.isError === true) throw new Error("Paperclip tool request was rejected"); + if (!Array.isArray(result.content)) throw new Error("Pi MCP tool result is invalid"); + const content = result.content.map((item) => { + const block = asRecord(item); + if (block.type === "text" && typeof block.text === "string") return { type: "text", text: block.text }; + if (block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") return { type: "image", data: block.data, mimeType: block.mimeType }; + // Resource links and structured outputs stay visible as data. They + // never cause the extension to fetch a URL or open a returned path. + return { type: "text", text: JSON.stringify(block) }; + }); + return { content, ...(result.structuredContent === undefined ? {} : { details: result.structuredContent }) }; + }, + }; + pi.registerTool(definition); + bridgeTools.add(nativeName); + } + } + // Pi reports extension-load failures and may continue. The wrapper requires + // this exact sentinel before admitting prompts, so a failed extension cannot + // silently leave native tools unguarded. Register only after every MCP binds. + pi.registerCommand("paperclip-runtime-ready-v1", { + description: "Paperclip runtime gate v1", + async handler() {}, + }); +} + +export default async function paperclipPiExtension(pi: PiExtensionApi): Promise { + const configuration = readPiRuntimeConfiguration(process.env); + // Do not let the model's shell tool inherit authenticated MCP credentials. + delete process.env.PAPERCLIP_PI_RUNTIME_CONFIGURATION; + await installPiRuntimeExtension(pi, configuration); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.test.ts new file mode 100644 index 00000000000..bb396840dee --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.test.ts @@ -0,0 +1,46 @@ +import { link, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { inventoryPiRuntimeFiles, PI_RUNTIME_MANIFEST_SCHEMA, verifyPiRuntimeManifest, type PiRuntimeManifest } from "./pi-verified-runtime.js"; + +const temporary: string[] = []; +afterEach(async () => { for (const root of temporary.splice(0)) await rm(root, { recursive: true, force: true }); }); +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "paperclip-pi-verified-")); temporary.push(root); + await mkdir(join(root, "assets")); + for (const name of ["node", "pi.js", "extension.js", "wrapper.js", "assets/native.node", "assets/image.wasm"]) await writeFile(join(root, name), `pinned:${name}`); + const manifest: PiRuntimeManifest = { schema: PI_RUNTIME_MANIFEST_SCHEMA, node: "node", piEntrypoint: "pi.js", extension: "extension.js", wrapperEntrypoint: "wrapper.js", files: await inventoryPiRuntimeFiles(root) }; + return { root, manifest }; +} + +describe("Pi complete runtime manifest", () => { + it("binds interpreter, Pi, extension, wrapper, native modules and resources", async () => { + const { root, manifest } = await fixture(); + const result = await verifyPiRuntimeManifest(root, manifest); + expect(result.environment.PAPERCLIP_PI_EXTENSION_PATH).toBe(await realpath(join(root, "extension.js"))); + expect(result.manifestDigest).toMatch(/^sha256:[a-f0-9]{64}$/); + await writeFile(join(root, "assets/image.wasm"), "replacement"); + await expect(verifyPiRuntimeManifest(root, manifest)).rejects.toThrow("package graph"); + }); + + it("refuses unrecorded resources and duplicate manifest entries", async () => { + const { root, manifest } = await fixture(); + await writeFile(join(root, "extra.js"), "untrusted"); + await expect(verifyPiRuntimeManifest(root, manifest)).rejects.toThrow("package graph"); + manifest.files.push(manifest.files[0]!); + await expect(verifyPiRuntimeManifest(root, manifest)).rejects.toThrow("entry"); + }); + + it("allows only in-pack relative links and rejects hardlinked mutable files", async () => { + const { root, manifest } = await fixture(); + await symlink("assets", join(root, "resources")); + manifest.files = await inventoryPiRuntimeFiles(root); + await expect(verifyPiRuntimeManifest(root, manifest)).resolves.toHaveProperty("manifestDigest"); + await symlink(tmpdir(), join(root, "escape")); + await expect(inventoryPiRuntimeFiles(root)).rejects.toThrow("escapes"); + await rm(join(root, "escape")); + await link(join(root, "pi.js"), join(root, "hardlink.js")); + await expect(inventoryPiRuntimeFiles(root)).rejects.toThrow("writable name"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.ts b/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.ts new file mode 100644 index 00000000000..5a446e0e138 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.ts @@ -0,0 +1,114 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, open, readdir, readlink, realpath } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; + +export const PI_RUNTIME_MANIFEST_SCHEMA = "paperclip.pi-runtime-files.v1" as const; +export interface PiRuntimeFile { + path: string; + kind: "file" | "symlink"; + sha256: string; + /** A link target is data in the manifest and must remain inside the pack. */ + target?: string; +} +export interface PiRuntimeManifest { + schema: typeof PI_RUNTIME_MANIFEST_SCHEMA; + node: string; + piEntrypoint: string; + extension: string; + wrapperEntrypoint: string; + files: PiRuntimeFile[]; +} + +function contained(root: string, path: string): boolean { + const suffix = relative(root, path); + return suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix); +} +function safeRelative(path: string): boolean { + return path.length > 0 && !isAbsolute(path) && !/[\0\r\n\\]/.test(path) && path.split("/").every((part) => part !== "" && part !== "." && part !== ".."); +} +function hash(bytes: Uint8Array | string): string { return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; } + +/** + * Inventory a complete provider pack, not just Pi's cli.js. Native modules, + * WASM, data, package metadata, and the owned extension all participate. + * Used while building a pack; the caller records/signs this immutable result. + */ +export async function inventoryPiRuntimeFiles(root: string): Promise { + const physicalRoot = await realpath(root); + if ((await lstat(root)).isSymbolicLink()) throw new Error("Pi runtime root must not be a symlink"); + const files: PiRuntimeFile[] = []; + const visit = async (directory: string): Promise => { + const entries = (await readdir(directory)).sort(); + for (const name of entries) { + const path = join(directory, name); + const rel = relative(physicalRoot, path).split(sep).join("/"); + if (!safeRelative(rel)) throw new Error("Pi runtime contains an invalid filename"); + const stat = await lstat(path); + if (stat.isDirectory()) { await visit(path); continue; } + if (files.length >= 100_000) throw new Error("Pi runtime file inventory exceeds its bound"); + if (stat.isSymbolicLink()) { + const target = await readlink(path); + if (isAbsolute(target) || !contained(physicalRoot, resolve(dirname(path), target)) || !contained(physicalRoot, await realpath(path))) throw new Error("Pi runtime link escapes its pack"); + files.push({ path: rel, kind: "symlink", target, sha256: hash(target) }); + } else if (stat.isFile()) { + if (stat.nlink !== 1) throw new Error("Pi runtime file has another writable name"); + files.push({ path: rel, kind: "file", sha256: await hashFile(path) }); + } else throw new Error("Pi runtime contains a non-file resource"); + } + }; + await visit(physicalRoot); + return files; +} + +async function hashFile(path: string): Promise { + const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const before = await file.stat({ bigint: true }); + if (!before.isFile() || before.nlink !== 1n || before.size > 512n * 1024n * 1024n) throw new Error("Pi runtime file identity is invalid"); + const digest = createHash("sha256"); + for await (const chunk of file.createReadStream({ autoClose: false })) digest.update(chunk); + const after = await file.stat({ bigint: true }); + if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) throw new Error("Pi runtime file changed during verification"); + return `sha256:${digest.digest("hex")}`; + } finally { await file.close(); } +} + +/** + * Verify an immutable private snapshot at admission. This returns only launch + * paths; the common command lease must continue holding its snapshot and + * process guardian. This function alone is deliberately not a command lease. + */ +export async function verifyPiRuntimeManifest( + root: string, manifest: PiRuntimeManifest, +): Promise<{ environment: Record; manifestDigest: string }> { + if (manifest.schema !== PI_RUNTIME_MANIFEST_SCHEMA || !Array.isArray(manifest.files) || manifest.files.length === 0 || manifest.files.length > 100_000) throw new Error("Pi runtime manifest is invalid"); + const unique = new Set(); + for (const entry of manifest.files) { + if (!safeRelative(entry.path) || unique.has(entry.path) || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256) || !["file", "symlink"].includes(entry.kind)) throw new Error("Pi runtime manifest entry is invalid"); + if (entry.kind === "symlink" ? typeof entry.target !== "string" : entry.target !== undefined) throw new Error("Pi runtime manifest link is invalid"); + unique.add(entry.path); + } + const sorted = manifest.files.map((entry): PiRuntimeFile => entry.kind === "file" + ? { path: entry.path, kind: entry.kind, sha256: entry.sha256 } + : { path: entry.path, kind: entry.kind, target: entry.target, sha256: entry.sha256 }) + .sort((a, b) => a.path.localeCompare(b.path, "en")); + const actual = (await inventoryPiRuntimeFiles(root)).sort((a, b) => a.path.localeCompare(b.path, "en")); + if (JSON.stringify(actual) !== JSON.stringify(sorted)) throw new Error("Pi runtime package graph differs from its qualified manifest"); + const physicalRoot = await realpath(root); + const boundPath = async (path: string): Promise => { + if (!safeRelative(path) || !unique.has(path)) throw new Error("Pi executable is absent from its manifest"); + const value = await realpath(join(physicalRoot, path)); + if (!contained(physicalRoot, value) || !(await lstat(value)).isFile()) throw new Error("Pi executable escaped its verified snapshot"); + return value; + }; + await boundPath(manifest.wrapperEntrypoint); + return { + environment: { + PAPERCLIP_PI_NODE_EXECUTABLE: await boundPath(manifest.node), + PAPERCLIP_PI_ENTRYPOINT: await boundPath(manifest.piEntrypoint), + PAPERCLIP_PI_EXTENSION_PATH: await boundPath(manifest.extension), + }, + manifestDigest: hash(JSON.stringify({ ...manifest, files: sorted })), + }; +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs b/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs new file mode 100644 index 00000000000..838898c56ab --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs @@ -0,0 +1,45 @@ +// Protocol fixture: no provider network calls and no secrets. +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +const home = process.env.PI_CODING_AGENT_DIR; +mkdirSync(join(home, "sessions"), { recursive: true }); +const sessionFile = join(home, "sessions", "fixture.jsonl"); +writeFileSync(sessionFile, JSON.stringify({ type: "session", cwd: process.cwd() }) + "\n"); +const output = (value) => process.stdout.write(JSON.stringify(value) + "\n"); +let active = false; +let model = "fixture-model"; +const finish = (answer = "done") => { + output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: answer } }); + output({ type: "message_end", message: { role: "assistant", timestamp: Date.now(), stopReason: "stop", usage: { input: 11, output: 3, cacheRead: 2, cacheWrite: 0, cost: { total: 0.01 } } } }); + output({ type: "agent_end" }); + // An agent_end alone must not settle the ACP prompt. + setTimeout(() => { active = false; output({ type: "agent_settled" }); }, 15); +}; +createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + const response = (data = {}) => output({ type: "response", id: request.id, command: request.type, success: true, data }); + if (request.type === "extension_ui_response") { finish(JSON.stringify(request)); return; } + if (request.type === "get_state") { response({ sessionId: "fixture-session", sessionFile, model: { provider: "openrouter", id: model }, thinkingLevel: "off" }); return; } + if (request.type === "get_available_models") { response({ models: [{ provider: "openrouter", id: "fixture-model", name: "Fixture" }] }); return; } + if (request.type === "set_model") { model = request.modelId; response({ model: { provider: request.provider, id: model } }); return; } + if (request.type === "get_messages") { response({ messages: [] }); return; } + if (request.type === "get_commands") { response({ commands: process.env.PI_FIXTURE_EXTENSION_FAIL ? [] : [{ name: "paperclip-runtime-ready-v1", description: "Paperclip runtime gate v1", source: "extension" }] }); return; } + if (request.type === "steer") { response(); output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: `steered:${request.message}` } }); return; } + if (request.type === "follow_up") { response(); finish(`follow-up:${request.message}`); return; } + if (request.type === "abort") { response(); if (active) { active = false; output({ type: "agent_settled" }); } return; } + if (request.type === "prompt") { + response({ disposition: "started" }); active = true; output({ type: "agent_start" }); + if (request.message === "die") { process.exit(4); } + if (request.message === "long") return; + if (request.message === "question") { output({ type: "extension_ui_request", id: "question-id", method: "input", title: "Project name", placeholder: "Name" }); return; } + if (request.message === "permission") { output({ type: "extension_ui_request", id: "permission-id", method: "select", title: 'paperclip.pi.permission.v1:{"toolCallId":"tool-1","toolName":"bash","input":{"command":"pwd"}}', options: ["Allow once", "Allow for this session", "Deny"] }); return; } + if (request.message === "failure") { + output({ type: "message_end", message: { role: "assistant", timestamp: 1, stopReason: "error", usage: { input: 1, output: 0 } } }); + active = false; output({ type: "agent_settled" }); return; + } + output({ type: "message_update", assistantMessageEvent: { type: "thinking_delta", delta: "fixture reasoning" } }); + finish("hello๐ŸŒ’\u2028world"); return; + } + response(); +}).on("close", () => process.exit(0)); diff --git a/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs new file mode 100644 index 00000000000..a578a811052 --- /dev/null +++ b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { createRequire, stripTypeScriptTypes } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { StringDecoder } from "node:string_decoder"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +// Optional test-only package location permits the isolated Pi branch to test +// before the integration owner updates package.json and the shared lockfile. +const packageJson = process.env.PAPERCLIP_TEST_PI_ACP_PACKAGE + ?? createRequire(import.meta.url).resolve("pi-acp/package.json"); +const packageRoot = dirname(packageJson); +const packageMetadata = JSON.parse(await readFile(packageJson, "utf8")); +const packageSource = await readFile(join(packageRoot, "dist/index.js"), "utf8"); +const helperSource = await readFile(join(packageRoot, "dist/paperclip-runtime.js"), "utf8"); +const localHelper = await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8"); +const normalize = (source) => source.split("\n").map((line) => line.trimEnd()).join("\n"); + +test("Pi patch embeds the reviewed helper and pins exact upstream version", () => { + assert.equal(packageMetadata.version, "0.0.33"); + assert.equal(normalize(helperSource), normalize(stripTypeScriptTypes(localHelper))); + assert.match(packageSource, /createPiLaunchSpec\(params, process\.env\)/); + assert.match(packageSource, /shell: false/); + assert.match(packageSource, /snapshotPiWorkspaceFile\(this\.cwd, abs\)/); +}); + +async function fixture(t, extra = {}) { + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-acp-contract-"))); + await mkdir(join(root, "workspace")); await mkdir(join(root, "agent")); + await writeFile(join(root, "extension.js"), "export default function() {}\n"); + const fakePi = fileURLToPath(new URL("../test-fixtures/pi-acp/fake-pi.mjs", import.meta.url)); + const child = spawn(process.execPath, [join(packageRoot, "dist/index.js")], { + cwd: join(root, "workspace"), stdio: "pipe", + env: { + PATH: process.env.PATH, HOME: root, PI_CODING_AGENT_DIR: join(root, "agent"), + PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_PI_READ_ONLY: "0", + PAPERCLIP_PI_NODE_EXECUTABLE: await realpath(process.execPath), + PAPERCLIP_PI_ENTRYPOINT: fakePi, PAPERCLIP_PI_EXTENSION_PATH: join(root, "extension.js"), + ...extra, + }, + }); + let stderr = ""; child.stderr.on("data", (chunk) => { stderr += chunk; }); + const pending = new Map(); const notifications = []; const requests = []; + let id = 0; let answer = async (request) => request.method === "session/request_permission" + ? { outcome: { outcome: "selected", optionId: "allow_once" } } + : { action: "accept", content: { answer: "Paperclip" } }; + const send = (value) => child.stdin.write(JSON.stringify(value) + "\n"); + const receive = (line) => { + const message = JSON.parse(line); + if (message.method && Object.hasOwn(message, "id")) { + requests.push(message); + void answer(message).then((result) => send({ jsonrpc: "2.0", id: message.id, result })); + } else if (Object.hasOwn(message, "id")) { + const waiter = pending.get(message.id); pending.delete(message.id); + if (message.error) waiter?.reject(new Error(JSON.stringify(message.error))); else waiter?.resolve(message.result); + } else notifications.push(message); + }; + const decoder = new StringDecoder("utf8"); let buffered = ""; + child.stdout.on("data", (chunk) => { + buffered += decoder.write(chunk); + for (;;) { const at = buffered.indexOf("\n"); if (at < 0) break; const line = buffered.slice(0, at); buffered = buffered.slice(at + 1); if (line.trim()) receive(line); } + }); + child.once("exit", () => { for (const waiter of pending.values()) waiter.reject(new Error(`wrapper exited: ${stderr}`)); pending.clear(); }); + const call = (method, params) => new Promise((resolve_, reject) => { + const requestId = ++id; + const timer = setTimeout(() => { pending.delete(requestId); reject(new Error(`timeout: ${method}: ${stderr}`)); }, 5000); + pending.set(requestId, { resolve: (value) => { clearTimeout(timer); resolve_(value); }, reject: (error) => { clearTimeout(timer); reject(error); } }); + send({ jsonrpc: "2.0", id: requestId, method, params }); + }); + t.after(async () => { + child.stdin.end(); + if (child.exitCode === null) { const timer = setTimeout(() => child.kill("SIGKILL"), 3000); await once(child, "exit"); clearTimeout(timer); } + await rm(root, { recursive: true, force: true }); + }); + const initialized = await call("initialize", { protocolVersion: 1, clientCapabilities: { elicitation: { form: {} } } }); + return { call, initialized, root, notifications, requests, setAnswer(value) { answer = value; } }; +} + +test("actual patched ACP process streams thinking and waits for settlement with usage", async (t) => { + const f = await fixture(t); + assert.equal(f.initialized.agentCapabilities._meta.paperclipPi.steering, true); + const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const result = await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }); + assert.equal(result.stopReason, "end_turn"); assert.equal(result.usage.inputTokens, 11); assert.equal(result.usage.totalTokens, 16); + assert.ok(f.notifications.some((event) => event.params?.update?.sessionUpdate === "agent_thought_chunk")); + assert.ok(f.notifications.some((event) => event.params?.update?.content?.text === "hello๐ŸŒ’\u2028world")); +}); + +test("actual patched ACP process keeps text questions separate from native permissions", async (t) => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "question" }] }); + assert.equal(f.requests.length, 1); assert.notEqual(f.requests[0].method, "session/request_permission"); + await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "permission" }] }); + assert.equal(f.requests[1].method, "session/request_permission"); + assert.equal(f.requests[1].params.options[1].kind, "allow_always"); +}); + +test("native steering is explicit and does not replace the active ACP turn", async (t) => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const prompt = f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "long" }] }); + await f.call("pi/steer", { sessionId: session.sessionId, message: "focus" }); + await f.call("pi/follow_up", { sessionId: session.sessionId, message: "then finish" }); + assert.equal((await prompt).stopReason, "end_turn"); + await assert.rejects(f.call("pi/steer", { sessionId: session.sessionId, message: "too late" }), /active turn/); +}); + +test("provider exit fails promptly and missing owned extension fails admission", async (t) => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + await assert.rejects(f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "die" }] }), /exited/); + const missing = await fixture(t, { PI_FIXTURE_EXTENSION_FAIL: "1" }); + await assert.rejects(missing.call("session/new", { cwd: join(missing.root, "workspace"), mcpServers: [] }), /failed admission/); +}); + +test("provider failure is typed and unadmitted slash commands cannot bypass controls", async (t) => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const result = await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "failure" }] }); + assert.equal(result._meta.jetbrains.air.sessionFailure.severity, "error"); + await assert.rejects(f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "/export /outside/report.html" }] }), /not admitted/); +}); diff --git a/patches/pi-acp@0.0.33.patch b/patches/pi-acp@0.0.33.patch new file mode 100644 index 00000000000..23379c1f6b3 --- /dev/null +++ b/patches/pi-acp@0.0.33.patch @@ -0,0 +1,677 @@ +diff --git a/dist/index.js b/dist/index.js +--- a/dist/index.js ++++ b/dist/index.js +@@ -53,7 +53,7 @@ + + // src/pi-rpc/process.ts + import { spawn } from "child_process"; +-import * as readline from "readline"; ++import { PI_ACP_FEATURES, PiRpcFrames, PiUiBridge, PiTurnUsage, createPiLaunchSpec, snapshotPiWorkspaceFile } from "./paperclip-runtime.js"; + + // src/pi-rpc/command.ts + import { platform } from "os"; +@@ -96,49 +96,38 @@ + preludeLines = []; + constructor(child) { + this.child = child; +- const rl = readline.createInterface({ input: child.stdout }); +- rl.on("line", (line) => { +- if (!line.trim()) return; +- let msg; +- try { +- msg = JSON.parse(line); +- } catch { +- const cleaned = stripAnsi(String(line)).trimEnd(); +- if (cleaned) this.preludeLines.push(cleaned); ++ this.exited = null; ++ const fail = (error) => { ++ if (this.exited) return; ++ this.exited = error; ++ for (const [, pending] of this.pending) pending.reject(error); ++ this.pending.clear(); ++ for (const handler of this.eventHandlers) handler({ type: "paperclip_process_exit" }); ++ }; ++ const frames = new PiRpcFrames((msg) => { ++ if (msg.type === "response") { ++ if (typeof msg.id === "string") this.pending.get(msg.id)?.resolve(msg); + return; + } +- if (msg?.type === "response") { +- const id = typeof msg.id === "string" ? msg.id : void 0; +- if (id) { +- const pending = this.pending.get(id); +- if (pending) { +- this.pending.delete(id); +- pending.resolve(msg); +- return; +- } +- } +- } +- for (const h of this.eventHandlers) h(msg); ++ for (const handler of this.eventHandlers) handler(msg); + }); +- child.on("exit", (code, signal) => { +- const err = new Error(`pi process exited (code=${code}, signal=${signal})`); +- for (const [, p] of this.pending) p.reject(err); +- this.pending.clear(); ++ child.stdout.on("data", (chunk) => { ++ try { frames.write(chunk); } catch (error) { fail(error); this.dispose("SIGKILL"); } + }); +- child.on("error", (err) => { +- for (const [, p] of this.pending) p.reject(err); +- this.pending.clear(); ++ child.stdout.on("end", () => { ++ try { frames.end(); } catch (error) { fail(error); } + }); ++ child.on("exit", () => fail(new Error("Pi process exited before its next request"))); ++ child.on("error", fail); + } + static async spawn(params) { +- const cmd = getPiCommand(params.piCommand); +- const args = ["--mode", "rpc", "--no-themes"]; +- if (params.sessionPath) args.push("--session", params.sessionPath); +- const child = spawn(cmd, args, { ++ const launch = createPiLaunchSpec(params, process.env); ++ const cmd = launch.command; ++ const child = spawn(cmd, launch.args, { + cwd: params.cwd, + stdio: "pipe", +- env: process.env, +- shell: shouldUseShellForPiCommand(cmd) ++ env: launch.env, ++ shell: false + }); + try { + await new Promise((resolve4, reject) => { +@@ -181,21 +170,30 @@ + const { dirname: dirname3 } = await import("path"); + mkdirSync2(dirname3(sessionFile), { recursive: true }); + } +- } catch { ++ const commands = await proc.getCommands(); ++ if (!Array.isArray(commands?.commands) || !commands.commands.some((command) => command.name === "paperclip-runtime-ready-v1" && command.description === "Paperclip runtime gate v1")) throw new Error("Pi owned runtime extension did not initialize"); ++ } catch (error) { ++ proc.dispose("SIGKILL"); ++ throw new PiRpcSpawnError("Pi owned runtime extension failed admission", { cause: error }); + } + return proc; + } + onEvent(handler) { + this.eventHandlers.push(handler); ++ if (this.exited) queueMicrotask(() => handler({ type: "paperclip_process_exit" })); + return () => { + this.eventHandlers = this.eventHandlers.filter((h) => h !== handler); + }; + } + dispose(signal = "SIGTERM") { +- if (this.child.killed) return; +- try { +- this.child.kill(signal); +- } catch { ++ if (this.child.exitCode !== null || this.child.signalCode !== null) return; ++ try { this.child.stdin.end(); this.child.kill(signal); } catch {} ++ if (signal !== "SIGKILL") { ++ const timer = setTimeout(() => { ++ if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill("SIGKILL"); ++ }, 2000); ++ timer.unref(); ++ this.child.once("exit", () => clearTimeout(timer)); + } + } + /** +@@ -288,12 +286,17 @@ + const withId = { ...cmd, id }; + const line = `${JSON.stringify(withId)} + `; ++ if (this.exited) return Promise.reject(this.exited); + return new Promise((resolve4, reject) => { +- this.pending.set(id, { resolve: resolve4, reject }); +- void this.writeLine(line).catch((error) => { +- this.pending.delete(id); +- reject(error); +- }); ++ const timer = setTimeout(() => finish(new Error("Pi RPC request timed out")), 30000); ++ timer.unref(); ++ const finish = (error, value) => { ++ if (!this.pending.delete(id)) return; ++ clearTimeout(timer); ++ if (error) reject(error); else resolve4(value); ++ }; ++ this.pending.set(id, { resolve: (value) => finish(null, value), reject: (error) => finish(error) }); ++ void this.writeLine(line).catch((error) => finish(error)); + }); + } + writeLine(line) { +@@ -714,6 +717,7 @@ + try { + proc = await PiRpcProcess.spawn({ + cwd: params.cwd, ++ mcpServers: params.mcpServers, + piCommand: params.piCommand + }); + } catch (e) { +@@ -808,6 +812,9 @@ + this.proc = opts.proc; + this.conn = opts.conn; + this.fileCommands = opts.fileCommands ?? []; ++ this.uiBridge = new PiUiBridge(this.sessionId, this.conn, this.proc); ++ this.turnUsage = new PiTurnUsage(); ++ this.turnEpoch = 0; + this.proc.onEvent((ev) => this.handlePiEvent(ev)); + } + setStartupInfo(text) { +@@ -852,6 +859,7 @@ + } + async cancel() { + this.cancelRequested = true; ++ this.uiBridge.cancelAll(); + if (this.turnQueue.length) { + const queued = this.turnQueue.splice(0, this.turnQueue.length); + for (const t of queued) t.resolve("cancelled"); +@@ -920,12 +928,16 @@ + this.cancelRequested = false; + this.inAgentLoop = false; + this.pendingTurn = { resolve: t.resolve, reject: t.reject }; ++ const epoch = ++this.turnEpoch; ++ this.turnUsage.reset(); + this.emit({ + sessionUpdate: "session_info_update", + _meta: { piAcp: { queueDepth: this.turnQueue.length, running: true } } + }); + this.proc.prompt(t.message, t.images).catch((err) => { + void this.flushEmits().finally(() => { ++ if (epoch !== this.turnEpoch || !this.pendingTurn) return; ++ this.uiBridge.cancelAll(); + const authErr = maybeAuthRequiredError(err); + if (authErr) { + this.pendingTurn?.reject(authErr); +@@ -946,6 +958,23 @@ + handlePiEvent(ev) { + const type = String(ev.type ?? ""); + switch (type) { ++ case "paperclip_process_exit": { ++ this.uiBridge.cancelAll(); ++ this.pendingTurn?.reject(new Error("Pi provider process exited")); ++ this.pendingTurn = null; ++ for (const turn of this.turnQueue.splice(0)) turn.reject(new Error("Pi provider process exited")); ++ this.turnEpoch += 1; ++ break; ++ } ++ case "message_end": { ++ try { this.turnUsage.accept(ev.message); } catch { ++ this.pendingTurn?.reject(new Error("Pi usage receipt is invalid")); ++ this.pendingTurn = null; ++ this.uiBridge.cancelAll(); ++ this.proc.dispose("SIGKILL"); ++ } ++ break; ++ } + case "message_update": { + const ame = ev.assistantMessageEvent; + if (ame?.type === "text_delta" && typeof ame.delta === "string") { +@@ -1047,7 +1076,7 @@ + if (p) { + try { + const abs = isAbsolute(p) ? p : resolvePath(this.cwd, p); +- snapshotOldText = readFileSync3(abs, "utf8"); ++ snapshotOldText = snapshotPiWorkspaceFile(this.cwd, abs); + this.fileSnapshots.set(toolCallId, { path: p, oldText: snapshotOldText }); + if (toolName === "edit") { + for (const needle of getEditOldTexts(args)) { +@@ -1125,7 +1154,7 @@ + if (!isError && snapshot) { + try { + const abs = isAbsolute(snapshot.path) ? snapshot.path : resolvePath(this.cwd, snapshot.path); +- const newText = readFileSync3(abs, "utf8"); ++ const newText = snapshotPiWorkspaceFile(this.cwd, abs); + if (snapshot.oldText === null || newText !== snapshot.oldText) { + hasStructuredDiff = true; + content = [ +@@ -1154,14 +1183,11 @@ + break; + } + case "extension_ui_request": { +- void this.handleExtensionUiRequest(ev).catch(() => { +- const id = stringProp(ev, "id"); +- if (!id) { +- return; +- } +- void this.proc.sendExtensionUiResponse({ id, cancelled: true }).catch(() => { +- }); ++ if (ev.method === "notify") this.emit({ ++ sessionUpdate: "session_info_update", ++ _meta: { paperclipPi: { notice: { message: typeof ev.message === "string" ? ev.message.slice(0, 4096) : "Pi notification", level: ev.notifyType } } } + }); ++ void this.uiBridge.handle(ev).catch(() => { this.proc.dispose("SIGKILL"); }); + break; + } + case "auto_retry_start": { +@@ -1210,9 +1236,12 @@ + break; + } + case "agent_settled": { ++ const epoch = this.turnEpoch; + void this.flushEmits().finally(() => { ++ if (epoch !== this.turnEpoch || !this.pendingTurn) return; ++ this.uiBridge.cancelAll(); + const reason = this.cancelRequested ? "cancelled" : "end_turn"; +- this.pendingTurn?.resolve(reason); ++ this.pendingTurn?.resolve({ stopReason: reason, ...this.turnUsage.response() }); + this.pendingTurn = null; + this.inAgentLoop = false; + const next = this.turnQueue.shift(); +@@ -1725,6 +1754,7 @@ + return process.env.PI_CODING_AGENT_DIR ? resolve3(process.env.PI_CODING_AGENT_DIR) : join4(homedir4(), ".pi", "agent"); + } + function getEnableSkillCommands(cwd) { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return true; + const merged = getMergedSettings(cwd); + const direct = merged.enableSkillCommands; + if (typeof direct === "boolean") return direct; +@@ -1733,6 +1763,7 @@ + return true; + } + function getQuietStartup(cwd) { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return true; + const merged = getMergedSettings(cwd); + const direct = merged.quietStartup; + if (typeof direct === "boolean") return direct; +@@ -1884,11 +1915,13 @@ + throw RequestError3.invalidParams(`Unknown sessionId: ${sessionId}`); + } + const cwd = opts?.cwd ?? stored.cwd; ++ if (cwd !== stored.cwd || !opts) throw RequestError3.invalidParams("Pi recovery requires session/load in the original workspace"); + let proc; + try { + proc = await PiRpcProcess.spawn({ + cwd, + sessionPath: stored.sessionFile, ++ mcpServers: opts?.mcpServers ?? [], + piCommand: process.env.PI_ACP_PI_COMMAND + }); + } catch (e) { +@@ -1897,7 +1930,7 @@ + } + throw e; + } +- const fileCommands = loadSlashCommands(cwd); ++ const fileCommands = []; + const session = this.sessions.getOrCreate(sessionId, { + cwd, + mcpServers: opts?.mcpServers ?? [], +@@ -1917,6 +1950,7 @@ + } + } + async initialize(params) { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") throw RequestError3.invalidParams("Pi requires a qualified Paperclip launch"); + const supportedVersion = 1; + const requested = params.protocolVersion; + return { +@@ -1933,7 +1967,8 @@ + }), + agentCapabilities: { + loadSession: true, +- mcpCapabilities: { http: false, sse: false }, ++ mcpCapabilities: { http: true, sse: false }, ++ _meta: { paperclipPi: PI_ACP_FEATURES }, + promptCapabilities: { + image: true, + audio: false, +@@ -1942,8 +1977,7 @@ + sessionCapabilities: { + // **UNSTABLE** ACP capability used by Zed's codex-acp adapter. + // Enables a native session picker in clients that support it. +- list: {}, +- delete: {} ++ list: {} + } + } + }; +@@ -1953,7 +1987,7 @@ + throw RequestError3.invalidParams(`cwd must be an absolute path: ${params.cwd}`); + } + this.lastSessionCwd = params.cwd; +- const fileCommands = loadSlashCommands(params.cwd); ++ const fileCommands = []; + const enableSkillCommands = getEnableSkillCommands(params.cwd); + const session = await this.sessions.create({ + cwd: params.cwd, +@@ -2066,6 +2100,8 @@ + const session = await this.restoreSession(params.sessionId); + const { message, images } = promptToPiMessage(params.prompt); + if (images.length === 0 && message.trimStart().startsWith("/")) { ++ const command = message.trim().split(/\s+/)[0]; ++ if (!["/compact", "/session", "/autocompact"].includes(command)) throw RequestError3.invalidParams("Pi slash command is not admitted by Paperclip Runner"); + const trimmed = message.trim(); + const space = trimmed.indexOf(" "); + const cmd = space === -1 ? trimmed.slice(1) : trimmed.slice(1, space); +@@ -2432,8 +2468,18 @@ + } + } + const result = await session.prompt(message, images); +- const stopReason = result === "error" ? session.wasCancelRequested() ? "cancelled" : "end_turn" : result; +- return { stopReason }; ++ if (result && typeof result === "object") return result; ++ if (result === "error") throw RequestError3.internalError({}, "Pi prompt failed"); ++ return { stopReason: result }; ++ } ++ async extMethod(method, params) { ++ if (method !== "pi/steer" && method !== "pi/follow_up") throw RequestError3.methodNotFound(method); ++ if (typeof params.sessionId !== "string" || typeof params.message !== "string" || !params.message.trim() || Buffer.byteLength(params.message) > 65536) throw RequestError3.invalidParams("Invalid Pi continuation"); ++ const session = this.sessions.maybeGet(params.sessionId); ++ if (!session?.pendingTurn || session.cancelRequested) throw RequestError3.invalidParams("Pi continuation requires an active turn"); ++ const response = await session.proc.request({ type: method === "pi/steer" ? "steer" : "follow_up", message: params.message }); ++ if (!response.success) throw RequestError3.internalError({}, "Pi continuation was rejected"); ++ return { accepted: true, sessionId: session.sessionId, kind: method === "pi/steer" ? "steer" : "follow_up" }; + } + async cancel(params) { + const session = this.sessions.maybeGet(params.sessionId); +@@ -2473,7 +2519,7 @@ + mcpServers: params.mcpServers + }); + const proc = session.proc; +- const fileCommands = loadSlashCommands(params.cwd); ++ const fileCommands = []; + this.sessions.closeAllExcept?.(session.sessionId); + this.store.upsert({ + sessionId: params.sessionId, +@@ -2607,6 +2653,7 @@ + return response; + } + async deleteSession(params) { ++ throw RequestError3.methodNotFound("session/delete"); + const stored = this.store.get(params.sessionId); + const piSession = findPiSession(params.sessionId); + if (!stored && !piSession) { +@@ -2828,6 +2875,7 @@ + return 0; + } + function buildUpdateNotice() { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return null; + try { + const piVersion = spawnSync("pi", ["--version"], { encoding: "utf-8" }); + const installed = (String(piVersion.stdout ?? "").trim() || String(piVersion.stderr ?? "").trim()).replace( +@@ -2848,6 +2896,7 @@ + } + } + function buildStartupInfo(opts) { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return ""; + void opts.fileCommands; + const md = []; + try { +@@ -2980,6 +3029,7 @@ + + // src/index.ts + if (process.argv.includes("--terminal-login")) { ++ throw new Error("Interactive Pi authentication is not available in Paperclip Runner"); + const { spawnSync: spawnSync2 } = await import("child_process"); + const cmd = getPiCommand(process.env.PI_ACP_PI_COMMAND); + const res = spawnSync2(cmd, [], { +@@ -3019,11 +3069,12 @@ + } + }); + var stream = ndJsonStream(input, output); +-var agent = new AgentSideConnection((conn) => new PiAcpAgent(conn), stream); ++var ownedPiAgent; ++var agent = new AgentSideConnection((conn) => (ownedPiAgent = new PiAcpAgent(conn)), stream); + function shutdown() { + try { + ; +- agent?.agent?.dispose?.(); ++ ownedPiAgent?.dispose?.(); + } catch { + } + try { +diff --git a/dist/paperclip-runtime.js b/dist/paperclip-runtime.js +new file mode 100644 +--- /dev/null ++++ b/dist/paperclip-runtime.js +@@ -0,0 +1,248 @@ ++/** Source for the helper embedded in patches/pi-acp@0.0.33.patch. */ ++import { StringDecoder } from "node:string_decoder"; ++import { isAbsolute, relative, resolve, sep } from "node:path"; ++import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from "node:fs"; ++ ++const PERMISSION_PREFIX = "paperclip.pi.permission.v1:"; ++const PERMISSION_CHOICES = [ ++ { optionId: "allow_once", name: "Allow once", kind: "allow_once" }, ++ { optionId: "allow_always", name: "Allow for this session", kind: "allow_always" }, ++ { optionId: "reject_once", name: "Deny", kind: "reject_once" }, ++]; ++ ++function record(value ) { ++ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Pi runtime record"); ++ return value ; ++} ++function text(value , max = 16_384) { ++ if (typeof value !== "string" || Buffer.byteLength(value) > max) throw new Error("Invalid Pi runtime text"); ++ return value; ++} ++ ++export const PI_ACP_FEATURES = Object.freeze({ ++ version: 1, steering: true, queuedFollowUp: true, ++ questions: ["select", "confirm", "input", "editor"], ++ nativePermissions: true, promptUsage: true, nativePlan: false, ++ pendingRequestRecovery: "live-process-only", ++}); ++ ++/** Strict LF framing: Unicode line separators inside JSON are ordinary text. */ ++export class PiRpcFrames { ++ decoder = new StringDecoder("utf8"); ++ pending = ""; ++ receive ; ++ limit ; ++ constructor(receive , limit = 4 * 1024 * 1024) { this.receive = receive; this.limit = limit; } ++ write(chunk ) { ++ this.pending += this.decoder.write(Buffer.from(chunk)); ++ for (;;) { ++ const index = this.pending.indexOf("\n"); ++ if (index < 0) break; ++ const line = this.pending.slice(0, index).replace(/\r$/, ""); ++ this.pending = this.pending.slice(index + 1); ++ if (Buffer.byteLength(line) > this.limit) throw new Error("Pi RPC frame exceeds its bound"); ++ if (line.trim()) this.receive(record(JSON.parse(line))); ++ } ++ if (Buffer.byteLength(this.pending) > this.limit) throw new Error("Pi RPC frame exceeds its bound"); ++ } ++ end() { ++ this.pending += this.decoder.end(); ++ if (this.pending.trim()) throw new Error("Pi RPC stream ended inside a frame"); ++ } ++} ++ ++function requiredFile(environment , name ) { ++ const path = environment[name]; ++ if (!path || !isAbsolute(path) || /[\0\r\n]/.test(path)) throw new Error(`Missing verified Pi launch binding: ${name}`); ++ // This checks the handoff shape. The runner command lease verifies all bytes, ++ // retains their directory identity, and owns the subprocess lifetime. ++ if (!lstatSync(path).isFile()) throw new Error(`Invalid verified Pi launch binding: ${name}`); ++ return path; ++} ++function pathArray(raw ) { ++ const paths = JSON.parse(raw ?? "[]"); ++ if (!Array.isArray(paths) || paths.length > 128 || paths.some((p) => typeof p !== "string" || !isAbsolute(p) || /[\0\r\n]/.test(p))) throw new Error("Invalid Pi runtime paths"); ++ return paths; ++} ++ ++/** Provider tool events are untrusted; diff projection cannot read host files. */ ++export function snapshotPiWorkspaceFile(cwd , path , environment = process.env) { ++ const root = realpathSync(cwd); ++ const logical = resolve(root, path); ++ const physical = realpathSync(logical); ++ const within = (base , target ) => { ++ const suffix = relative(base, target); ++ return !isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`); ++ }; ++ if (!within(root, logical) || !within(root, physical)) throw new Error("Pi diff escaped its workspace"); ++ for (const protectedPath of pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS)) { ++ if (within(protectedPath, logical)) throw new Error("Pi diff targets protected state"); ++ let protectedPhysical ; ++ try { protectedPhysical = realpathSync(protectedPath); } catch { continue; } ++ if (within(protectedPhysical, physical)) throw new Error("Pi diff targets protected state"); ++ } ++ const fd = openSync(physical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); ++ try { ++ const before = fstatSync(fd, { bigint: true }); ++ if (!before.isFile() || before.nlink !== 1n || before.size > 4n * 1024n * 1024n) throw new Error("Pi diff file is unsupported"); ++ const result = readFileSync(fd, "utf8"); ++ const after = fstatSync(fd, { bigint: true }); ++ const named = lstatSync(physical, { bigint: true }); ++ if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.ctimeNs !== after.ctimeNs || named.ino !== before.ino || named.dev !== before.dev || realpathSync(logical) !== physical) throw new Error("Pi diff file changed during capture"); ++ return result; ++ } finally { closeSync(fd); } ++} ++ ++export function createPiLaunchSpec( ++ params , ++ environment , ++) { ++ if (environment.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") throw new Error("Pi requires an isolated runner launch"); ++ const command = requiredFile(environment, "PAPERCLIP_PI_NODE_EXECUTABLE"); ++ const entrypoint = requiredFile(environment, "PAPERCLIP_PI_ENTRYPOINT"); ++ const extension = requiredFile(environment, "PAPERCLIP_PI_EXTENSION_PATH"); ++ if (environment.PAPERCLIP_PI_READ_ONLY !== "0" && environment.PAPERCLIP_PI_READ_ONLY !== "1") throw new Error("Pi requires an explicit read-only policy"); ++ const readRoots = pathArray(environment.PAPERCLIP_PI_READ_ROOTS); ++ const protectedRoots = pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS); ++ const workspace = realpathSync(params.cwd); ++ const configuration = JSON.stringify({ ++ workspace, servers: params.mcpServers ?? [], ++ readOnly: environment.PAPERCLIP_PI_READ_ONLY === "1", ++ readRoots, protectedRoots, ++ instructions: environment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS ?? "", ++ }); ++ if (Buffer.byteLength(configuration) > 64 * 1024) throw new Error("Pi launch configuration exceeds its bound"); ++ const args = [entrypoint, "--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-approve", "--offline", "-e", extension]; ++ for (const root of readRoots) args.push("--skill", root); ++ if (params.sessionPath) { ++ const home = environment.PI_CODING_AGENT_DIR; ++ if (!home) throw new Error("Pi session home is missing"); ++ const sessionRoot = realpathSync(resolve(home, "sessions")); ++ const sessionPath = realpathSync(params.sessionPath); ++ const suffix = relative(sessionRoot, sessionPath); ++ if (!suffix || isAbsolute(suffix) || suffix === ".." || suffix.startsWith(`..${sep}`) || !lstatSync(params.sessionPath).isFile()) throw new Error("Pi session escaped its private home"); ++ args.push("--session", sessionPath); ++ } ++ return { command, args, env: { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration } }; ++} ++ ++ ++ ++ ++ ++ ++ ++/** Live promises never become authority to replay an answer after provider death. */ ++export class PiUiBridge { ++ pending = new Map (); ++ seen = new Set (); ++ sessionId ; ++ connection ; ++ process ; ++ constructor(sessionId , connection , process ) { this.sessionId = sessionId; this.connection = connection; this.process = process; } ++ ++ cancelAll() { for (const value of this.pending.values()) value.cancel(); } ++ ++ async handle(event ) { ++ const id = text(event.id, 256); ++ const method = text(event.method, 64); ++ if (!["select", "confirm", "input", "editor"].includes(method)) return; ++ if (this.seen.has(id)) return; ++ if (this.seen.size >= 4096) throw new Error("Pi UI request history exceeds its bound"); ++ this.seen.add(id); ++ let done = false; ++ let timeout ; ++ const finish = async (response ) => { ++ if (done) return; ++ done = true; ++ if (timeout) clearTimeout(timeout); ++ this.pending.delete(id); ++ await this.process.sendExtensionUiResponse({ id, ...response }); ++ }; ++ this.pending.set(id, { cancel: () => { void finish({ cancelled: true }).catch(() => {}); } }); ++ if (typeof event.timeout === "number" && Number.isFinite(event.timeout)) { ++ timeout = setTimeout(() => { void finish({ cancelled: true }).catch(() => {}); }, Math.max(0, Math.min(event.timeout, 2_147_483_647))); ++ timeout.unref?.(); ++ } ++ try { ++ const title = text(event.title ?? "Additional information needed", 65_536); ++ if (method === "select" && title.startsWith(PERMISSION_PREFIX)) { ++ const permission = record(JSON.parse(title.slice(PERMISSION_PREFIX.length))); ++ const toolCallId = text(permission.toolCallId, 256); ++ const toolName = text(permission.toolName, 128); ++ const input = record(permission.input); ++ const result = record(await this.connection.requestPermission({ ++ sessionId: this.sessionId, ++ toolCall: { toolCallId, title: `Pi ${toolName}`, kind: toolName === "bash" ? "execute" : ["write", "edit"].includes(toolName) ? "edit" : "read", status: "pending", rawInput: input }, ++ options: PERMISSION_CHOICES, ++ })); ++ const outcome = record(result.outcome); ++ const selected = outcome.outcome === "selected" ? PERMISSION_CHOICES.find((option) => option.optionId === outcome.optionId) : undefined; ++ await finish(selected ? { value: selected.name } : { cancelled: true }); ++ return; ++ } ++ const property = { type: method === "confirm" ? "boolean" : "string", title }; ++ if (method === "select") { ++ if (!Array.isArray(event.options) || event.options.length < 1 || event.options.length > 128) throw new Error("Invalid Pi question options"); ++ property.enum = event.options.map((option) => text(option, 4096)); ++ if (new Set(property.enum ).size !== event.options.length) throw new Error("Ambiguous Pi question options"); ++ } ++ if (method === "input" && typeof event.placeholder === "string") property.description = text(event.placeholder); ++ if (method === "editor" && typeof event.prefill === "string") property.default = text(event.prefill); ++ const result = record(await this.connection.unstable_createElicitation({ ++ sessionId: this.sessionId, mode: "form", message: typeof event.message === "string" ? text(event.message) : title, ++ requestedSchema: { type: "object", title, properties: { answer: property }, required: ["answer"] }, ++ _meta: { paperclipPi: { version: 1, requestId: id, method } }, ++ })); ++ if (result.action !== "accept") { await finish({ cancelled: true }); return; } ++ const answer = record(result.content).answer; ++ if (method === "confirm") { ++ if (typeof answer !== "boolean") throw new Error("Invalid Pi confirmation response"); ++ await finish({ confirmed: answer }); ++ } else { ++ const value = text(answer, 65_536); ++ if (method === "select" && !(property.enum ).includes(value)) throw new Error("Invalid Pi question response"); ++ await finish({ value }); ++ } ++ } catch { await finish({ cancelled: true }).catch(() => {}); } ++ } ++} ++ ++/** Sum actual assistant receipts, never context occupancy or estimated cost. */ ++export class PiTurnUsage { ++ seen = new Set (); ++ total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; ++ cost = 0; ++ failed = false; ++ observed = false; ++ reset() { this.seen.clear(); this.total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; this.cost = 0; this.failed = false; this.observed = false; } ++ accept(value ) { ++ const message = record(value); ++ if (message.role !== "assistant") return; ++ this.failed = message.stopReason === "error"; ++ if (!message.usage || typeof message.usage !== "object") return; ++ const key = JSON.stringify([message.timestamp, message.id, message.usage, message.content]); ++ if (this.seen.has(key)) return; ++ if (this.seen.size >= 8192) throw new Error("Pi usage receipts exceed their bound"); ++ this.seen.add(key); ++ const usage = record(message.usage); ++ const numbers = ["input", "output", "cacheRead", "cacheWrite"].map((name) => usage[name] ?? 0); ++ if (numbers.some((value) => typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) return; ++ this.observed = true; ++ for (const [index, field] of ["inputTokens", "outputTokens", "cachedReadTokens", "cachedWriteTokens"].entries()) { ++ const name = field ; ++ this.total[name] += numbers[index] ; ++ } ++ this.total.totalTokens = this.total.inputTokens + this.total.outputTokens + this.total.cachedReadTokens + this.total.cachedWriteTokens; ++ if (usage.cost && typeof usage.cost === "object") { ++ const cost = record(usage.cost).total; ++ if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0) this.cost += cost; ++ } ++ } ++ response() { ++ return { ++ ...(this.observed ? { usage: { ...this.total, _meta: { paperclipPi: { provenance: "assistant_message_receipts", costUsd: this.cost } } } } : {}), ++ ...(this.failed ? { _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "service", title: "Pi provider request failed" } } } } } : {}), ++ }; ++ } ++}