From a05c4ee3b08e2b809038b544d485c60888e7b79d Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 17:09:12 -0500 Subject: [PATCH] fix(runner): scope Pi tool identities to native model iterations Preserve exact delivery retries while separating reused native IDs across warm prompts. Add pinned SDK ordering, real semantic bridge, lifecycle and history regressions, and bump the verified Pi profile to v4. Co-Authored-By: Paperclip --- doc/architecture/runner-pi-capabilities.md | 99 +++++-- .../runner-core/src/acpx_provider_backend.rs | 4 +- .../scripts/materialize-pi-distribution.mjs | 6 +- .../src/drivers/acpx/pi-acp-runtime.test.ts | 46 +++- .../src/drivers/acpx/pi-acp-runtime.ts | 96 ++++++- .../src/drivers/acpx/pi-closure-pins.ts | 6 +- .../src/drivers/acpx/pi-installation.test.ts | 9 +- .../src/drivers/acpx/pi-installation.ts | 2 +- .../drivers/acpx/pi-runtime-extension.test.ts | 113 +++++++- .../src/drivers/acpx/pi-runtime-extension.ts | 18 +- .../src/drivers/acpx/qualified-profiles.ts | 4 +- .../test-fixtures/pi-acp/fake-pi.mjs | 34 ++- .../test/pi-acp-package-contract.test.mjs | 55 +++- .../test/pi-native-package-contract.test.mjs | 59 +++- patches/pi-acp@0.0.33.patch | 253 +++++++++++++++--- 15 files changed, 697 insertions(+), 107 deletions(-) diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index 6ba8f0fe445..d707c557862 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -1,11 +1,13 @@ # Pi rich ACP runtime -Status: implementation candidate, 2026-09-28. Profile version 3 passes local -Product file delivery and typed question continuation, plus a native steering, -queued follow-up and denied-write probe. The semantic plan journey found a shared -MCP identity projection defect and remains failed. Version 2 hello completion and -all earlier failures remain retained. Deterministic protocol and immutable launch -checks also pass. +Status: implementation candidate, 2026-09-28. Profile version 4 repairs native +tool ID reuse across model iterations and warm prompts. Its deterministic bridge, +SDK ordering, lifecycle and history regressions pass; authenticated v4 +qualification is pending. Historical version 3 passes cover local Product file +delivery, typed question continuation and native steering, queued follow-up and +denied-write controls. Its semantic plan journey exposed two defects: the shared +MCP display projection, now repaired, followed by a resumed-turn ID collision, +addressed by v4. Version 2 hello completion and every failure remain retained. The wider local and Linux x64 Daytona matrix remains pending; this document does not promote the candidate to a qualified production runtime. @@ -27,20 +29,63 @@ boundaries explicit. | 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. Session grants cover an identical operation and still revalidate paths. 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-bound HTTP MCP catalogs (numeric loopback HTTP or assigned HTTPS gateways) 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. Only the exact session-assigned gateway URL and credential are used, with redirects disabled. Ambient and unassigned MCP servers are not admitted. | +| Semantic tools | Runner-bound HTTP MCP catalogs (numeric loopback HTTP or assigned HTTPS gateways) register under exact `mcp____` names. Calls use an occurrence-scoped delivery ID and preserve the bounded native ID as private ACP-wire provenance, together with the cancellation signal. Common normalized events currently drop that metadata. Authenticated PRP tool handling owns semantic authorization and durable interactions. Only the exact session-assigned gateway URL and credential are used, with redirects disabled. Ambient and unassigned 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, sessionId, kind}` with the matching control kind. | | Usage | Prompt results sum actual assistant message usage receipts across continuations. Input, output, cache reads/writes, total tokens and Pi-reported pricing estimates have provenance. Context-window occupancy is not billed usage. No receipt means no usage assertion; absent cache or cost fields remain unknown. Pi calculates cost from its model catalog rates, so this is not an authoritative provider bill. | | 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. | +| Images | The upstream ACP wrapper accepts image blocks, but `AcpxRuntimeTurnInput` and the common adapter currently forward text only. P1: implement typed image content through the common converter and then qualify the exact model; live testing alone cannot close this implementation gap. | | 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. | +| Fork and clone | Native Pi RPC exposes `fork(entryId)`, `clone`, and `get_fork_messages`; the ACP wrapper and Paperclip controls do not map them. P2: add explicit admitted session operations and verified lineage before exposing controls. | +| HTML export | Native `export_html` is available but unused by this integration. P2: add a bounded workspace artifact export with publication policy before exposing it. | +| Durable goal and native plan | No mapped native surface. Paperclip semantic tools remain the plan path; no Codex parity is claimed. | `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. +## Occurrence identity (profile version 4) + +Pi can reuse a native ID such as `call_0` in a later model iteration, including +within one ACP prompt. The wrapper and immutable extension now derive one live +ID from a private per-child launch namespace, a monotonic model-iteration ordinal, +and the native ID. The native `turn_start` event advances the ordinal; the SDK's +relative `turnIndex` is not authority because it resets on each warm prompt. +Permission requests, streamed tools, lifecycle updates and semantic MCP requests +share that ID. Bounded private ACP-wire provenance retains the original native +ID and iteration. Common ACPX/sidecar normalization drops this `_meta`; these raw +IDs and iteration fields are not currently UI-visible. The normalized delivery ID +itself survives the common tool and permission paths. P2: add an allowlisted +diagnostic provenance projection and UI coverage without granting the raw ID +execution authority. Historical message-index/scope metadata has the same +private-wire-only disposition. + +An exact HTTP retry reuses the delivery ID and shared cached result. Reusing a +native ID for a second invocation in the same iteration, changing its arguments, +or receiving ambiguous iteration boundaries poisons identity state and fails +closed. Shared Runner bridge deduplication and Rust call tombstones are unchanged. +This avoids both replaying a mutation and mistaking a new corrected invocation for +an old cached error. Historical session replay uses a separate stable +`pi-history-` identity from session/message occurrence, explicitly marked +`history-display-only`; it never grants execution authority. + +The maintained `test/pi-native-package-contract.test.mjs` regression exercises +actual pinned Pi Agent/AgentSession dispatch with an in-memory model stream, +empty authentication storage and model networking disabled. Across two warm +prompts it verifies six native iterations, relative indices `[0,1,2,0,1,2]`, +four reused `call_0` executions, extension-before-wrapper ordering, and four +matching distinct delivery/display IDs. The authenticated loopback test in +`pi-runtime-extension.test.ts` uses the real Runner semantic bridge to prove +cached validation errors, concurrent exact retries, changed-payload rejection, +and a corrected invocation in the next iteration. Installed-wrapper tests verify +lifecycle correlation and stable, disjoint historical replay identities. + +The v4 command digest is +`sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991`. +Versions 1, 2 and 3 cannot reopen under this identity. Older paid and image proofs +below remain evidence of their recorded versions, not v4 qualification. + ## Lifetime and recovery A provider UI request is a live Pi RPC promise. Paperclip's durable interaction @@ -109,6 +154,15 @@ part of its verified package and must never be omitted from a copy or hash. ## Verification and maintenance +The v4 focused verification passes 49 TypeScript tests plus the separately enabled +actual-distribution installation test (all five installation cases), 24 installed +wrapper/native-SDK/materializer cases, and 13 Rust native-provider backend tests. +The new macOS arm64 closure was independently reproduced by a fresh locked +public-registry installation. The helper tests cover poisoned malformed inputs, +iteration overflow and the 4,096-entry limit; installed tests cover cancellation +followed by warm native-ID reuse. These checks make no provider inference calls. + + The four 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 @@ -206,7 +260,7 @@ that those targets have executed successfully. ### Installation authority and token semantics -`verifyPiInstallation(profile)` admits only profile version 3 and the source-owned +`verifyPiInstallation(profile)` admits only profile version 4 and the source-owned Pi identity. It resolves `provider-assets/pi/-` inside the verified Runner package, checks the complete runtime against source-pinned closure hashes, and opens a guarded immutable native snapshot. The snapshot bootstrap binds Node, @@ -229,9 +283,9 @@ The first authenticated local Runner snapshot verified the exact configured model ID. It did not settle or produce a terminal usage receipt; complete local and Linux/Daytona receipt qualification remains outstanding. -Profile version 3 declaration digest: `sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472`. It hashes the versioned +Historical profile version 3 declaration digest: `sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472`. The current v4 digest above hashes its versioned profile domain, patched wrapper source and platform closure pins. Every native -closure remains independently checked at launch. Version 1 and 2 warm sessions cannot +closure remains independently checked at launch. Version 1, 2 and 3 warm sessions cannot be reused with this integration. Pi 0.84.2 emits `compaction_start`/`compaction_end`; the wrapper maps those @@ -304,32 +358,39 @@ require a new pack build and source-pinned admission record. The candidate preserves the core request/response paths. It does not preserve every field in Pi's native event stream. These are explicit follow-ups: -- Retry and compaction progress currently becomes assistant text. The wrapper +- **P1 — retry and compaction notices.** Progress currently becomes assistant text. The wrapper does not retain all structured `attempt`, `maxAttempts`, `delayMs`, `errorMessage`, `success`, `finalError`, `reason`, and `willRetry` fields. A follow-up should emit bounded provider notices with source-event provenance and test its metadata. The `auto_retry_end` display distinguishes explicit success, failure, and unknown outcomes. A patched-process regression covers each result and confirms that terminal assistant failure still fails the prompt. -- Native `queue_update` contains steering and follow-up queues. Extension calls +- **P1 — native queue state.** `queue_update` contains steering and follow-up queues. Extension calls acknowledge RPC acceptance, but the wrapper does not project that event into durable queued/delivered state. Do not treat `{accepted: true}` as proof that a later model turn consumed a message. Queue contents are also user content, - so any added projection needs explicit retention rules. -- Extension `setStatus`, `setWidget`, `setTitle`, and `set_editor_text` messages + so any added projection needs explicit retention rules. Next: emit bounded, + occurrence-bound queued/delivered notices and distinguish acceptance from actual + consumption; preserve queue content only under the existing content policy. +- **P2 — extension UI and configuration.** `setStatus`, `setWidget`, `setTitle`, + and `set_editor_text` messages have no Paperclip UI projection. The admitted extension does not use them. `notify` retains a bounded message and severity, while interactive `select`, `confirm`, `input`, and `editor` have the explicit response bridge. Native session-name and thinking-level change events also lack a separate event projection. The wrapper advertises ACP thinking-level configuration, but the current Runner host does not expose `set_mode` or `set_config_option`; no - reasoning-level override was applied during qualification. -- File diffs are bounded text snapshots, not complete binary changes or durable + reasoning-level override was applied during qualification. Next: define bounded + status/widget/title dispositions and explicit typed configuration controls; + require a provider acknowledgement before claiming a setting changed. +- **P2 — files and artifacts; P1 — image input.** File diffs are bounded text snapshots, not complete binary changes or durable artifact publication. Semantic artifact tools remain the supported publication path. Image prompting, model-triggered tools, approvals, typed questions, explicit steering, warm recovery, and live usage need authenticated local and Linux x64 Daytona proof for the exact declared model. The reported model ID - must be checked, rather than inferred from the configuration. + must be checked, rather than inferred from the configuration. Next: implement + image content through the shared text-only converter, qualify actual model + content handling, and separately test diff/artifact publication boundaries. ## Authenticated local qualification progress diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 6145730e2be..7925dbbbde1 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -170,7 +170,7 @@ impl AcpxProviderDescriptor { "0.0.33", Some("@earendil-works/pi-coding-agent"), Some("0.84.2"), - "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991", ), "cursor" => ( self.model.as_str(), @@ -2461,7 +2461,7 @@ mod tests { "pi", "pi-acp", "0.0.33", - "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991", Some("@earendil-works/pi-coding-agent"), Some("0.84.2"), "openrouter/deepseek/deepseek-v4-flash-0731", diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs index 312d4b4ebae..a6d5114fcdd 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs @@ -18,8 +18,8 @@ const patchPath = join(workspaceRoot, "patches/pi-acp@0.0.33.patch"); const supportedTargets = new Set(["darwin-arm64", "darwin-x64", "linux-x64"]); export const PI_DISTRIBUTION_PINS = Object.freeze({ wrapper: "0.0.33", runtime: "0.84.2", sdk: "0.26.0", zod: "3.25.76", nodeVersion: PI_NODE_VERSION, - wrapperSha256: "df6b6270d95154aef058e3dacb766d0f0ca0d26fbfaccc4c804cdb088fd89e1e", - helperSha256: "4333d284ff06ab1195e49630041f0b03a486a92fc678100525ad107e0982dba1", + wrapperSha256: "dc4786faff30942e82106c87a8984a75fb979a925ff1d62648500a642b920cac", + helperSha256: "9e50b60644d0d00b3fb2660eb78ad616dd01395775bcb05974d2bdeadc7f7e57", }); const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); @@ -166,7 +166,7 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np const stripped = stripTypeScriptTypes(helperSource).split("\n").map((line) => line.trimEnd()).join("\n"); if (hash(wrapperBytes) !== PI_DISTRIBUTION_PINS.wrapperSha256 || hash(helperBytes) !== PI_DISTRIBUTION_PINS.helperSha256 || helperBytes.toString("utf8") !== stripped) throw new Error("Pi wrapper patch does not match its qualified source"); await mkdir(join(runtimeRoot, "extensions")); - await writeFile(join(runtimeRoot, "extensions/paperclip.js"), stripTypeScriptTypes(await readFile(join(packageRoot, "src/drivers/acpx/pi-runtime-extension.ts"), "utf8"))); + await writeFile(join(runtimeRoot, "extensions/paperclip.js"), stripTypeScriptTypes(await readFile(join(packageRoot, "src/drivers/acpx/pi-runtime-extension.ts"), "utf8")).replace('from "./pi-acp-runtime.js"', 'from "../node_modules/pi-acp/dist/paperclip-runtime.js"')); await mkdir(join(runtimeRoot, "node/bin"), { recursive: true }); const copiedNode = join(runtimeRoot, "node/bin/node"); await copyFile(node, copiedNode); await chmod(copiedNode, 0o755); 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 index 38381472c3d..2839cc274e3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts @@ -2,7 +2,7 @@ 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"; +import { createPiLaunchSpec, PiToolIdentities, 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 }); }); @@ -12,6 +12,48 @@ function fixture() { return { connection, process, bridge: new PiUiBridge("session-a", connection, process) }; } +describe("Pi occurrence identity", () => { + const namespace = "00000000-0000-4000-8000-000000000000"; + it("aligns independent wrapper and extension counters without resetting warm prompts", () => { + const wrapper = new PiToolIdentities(namespace); const extension = new PiToolIdentities(namespace); + const ids: string[] = []; + for (let iteration = 1; iteration <= 3; iteration++) { + wrapper.normalize({ type: "turn_start" }); extension.begin(); + const id = extension.bind("call_0", "write", { path: "out", content: "x" }, true); + const event = wrapper.normalize({ type: "tool_execution_start", toolCallId: "call_0", toolName: "write", args: { content: "x", path: "out" } }); + expect(event.toolCallId).toBe(id); expect(wrapper.provenance(id)).toMatchObject({ nativeToolCallId: "call_0", modelIteration: iteration }); + expect(wrapper.normalize({ type: "tool_execution_end", toolCallId: "call_0" }).toolCallId).toBe(id); + ids.push(id); wrapper.normalize({ type: "turn_end" }); extension.end(); + } + expect(new Set(ids).size).toBe(3); + }); + it("poisons exhausted iteration and tool bounds", () => { + const exhausted = new PiToolIdentities(namespace); + // Exercise the numerical guard without performing MAX_SAFE_INTEGER turns. + (exhausted as unknown as { ordinal: number }).ordinal = Number.MAX_SAFE_INTEGER; + expect(() => exhausted.begin()).toThrow("ambiguous"); + expect(() => exhausted.identity("call")).toThrow("unavailable"); + const full = new PiToolIdentities(namespace); full.begin(); + for (let index = 0; index < 4096; index++) full.identity(`call_${index}`); + expect(() => full.identity("call_4096")).toThrow("bound"); + expect(() => full.identity("call_0")).toThrow("unavailable"); + }); + it("fails closed on missing/duplicate iteration boundaries and ambiguous duplicate tools", () => { + const missing = new PiToolIdentities(namespace); + expect(() => missing.bind("call", "write", {})).toThrow("iteration"); + expect(() => missing.begin()).toThrow("ambiguous"); + const invalid = new PiToolIdentities(namespace); invalid.begin(); + expect(() => invalid.bind("call", "x".repeat(257), {})).toThrow("invalid"); + expect(() => invalid.bind("valid", "read", {})).toThrow("iteration"); + const duplicate = new PiToolIdentities(namespace); duplicate.begin(); + expect(() => duplicate.begin()).toThrow("ambiguous"); + expect(() => duplicate.identity("call")).toThrow("unavailable"); + const tools = new PiToolIdentities(namespace); tools.begin(); tools.bind("call", "write", {}, true); + expect(() => tools.bind("call", "read", {}, true)).toThrow("conflict"); + expect(() => tools.bind("different", "read", {})).toThrow("iteration"); + }); +}); + describe("Pi ACP bridge", () => { it.each([ ["select", { options: ["Red", "Blue"] }, "Blue", { value: "Blue" }], @@ -28,7 +70,7 @@ describe("Pi ACP bridge", () => { 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"}}' }; + const event = { id: "p1", method: "select", title: 'paperclip.pi.permission.v1:{"toolCallId":"tool-a","nativeToolCallId":"native-a","modelIteration":1,"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" }); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts index 09a8295568a..2eea39ebd72 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts @@ -1,4 +1,5 @@ /** Source for the helper embedded in patches/pi-acp@0.0.33.patch. */ +import { createHash, randomUUID } from "node:crypto"; 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"; @@ -26,6 +27,92 @@ export const PI_ACP_FEATURES = Object.freeze({ pendingRequestRecovery: "live-process-only", }); +/** One trusted Pi model iteration, not one ACP prompt. Pi resets its own turnIndex + * on warm prompts; our ordinal is monotonic for the lifetime of the child. */ +export class PiToolIdentities { + private ordinal = 0; + private active = false; + private poisoned = false; + private entries = new Map(); + private namespace: string; + constructor(namespace: string) { + this.namespace = namespace; + if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(namespace)) throw new Error("Pi invocation namespace is invalid"); + } + private fail(message: string): never { this.poisoned = true; throw new Error(message); } + begin(): void { + if (this.poisoned || this.active || this.ordinal >= Number.MAX_SAFE_INTEGER) this.fail("Pi model iteration identity is ambiguous"); + this.ordinal++; this.active = true; this.entries.clear(); + } + end(): void { + if (this.poisoned || !this.active) this.fail("Pi model iteration identity is unavailable"); + this.active = false; + } + identity(nativeId: unknown): string { + if (this.poisoned || this.ordinal === 0) this.fail("Pi model iteration identity is unavailable"); + let native: string; + try { native = text(nativeId, 256); } catch { return this.fail("Pi native tool identity is invalid"); } + if (!native) this.fail("Pi native tool identity is missing"); + const prior = this.entries.get(native); + if (prior) return prior.id; + if (!this.active || this.entries.size >= 4096) this.fail("Pi tool identity is outside its iteration bound"); + const id = `pi-${createHash("sha256").update(JSON.stringify([this.namespace, this.ordinal, native])).digest("hex")}`; + this.entries.set(native, { id, nativeId: native, claimed: false }); + return id; + } + bind(nativeId: string, name: string, args: unknown, claim = false): string { + if (this.poisoned || !this.active) this.fail("Pi tool invocation has no active model iteration"); + const id = this.identity(nativeId); const entry = this.entries.get(nativeId)!; + const canonical = (value: unknown): unknown => Array.isArray(value) ? value.map(canonical) + : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])) : value; + let encoded: string; + try { encoded = JSON.stringify([text(name, 256), canonical(args)]); } + catch { return this.fail("Pi tool invocation is invalid"); } + if (Buffer.byteLength(encoded) > 1_048_576) this.fail("Pi tool invocation is oversized"); + const fingerprint = createHash("sha256").update(encoded).digest("hex"); + if ((claim && entry.claimed) || (entry.fingerprint !== undefined && entry.fingerprint !== fingerprint)) this.fail("Pi native tool invocation identity conflict"); + entry.fingerprint = fingerprint; if (claim) entry.claimed = true; + return id; + } + provenance(id: string): RecordValue | undefined { + for (const entry of this.entries.values()) if (entry.id === id) return { nativeToolCallId: entry.nativeId, modelIteration: this.ordinal, identityScope: "native-model-iteration" }; + return undefined; + } + normalize(event: RecordValue): RecordValue { + if (event.type === "turn_start") { this.begin(); return event; } + if (event.type === "turn_end") { this.end(); return event; } + if (["tool_execution_start", "tool_execution_update", "tool_execution_end"].includes(String(event.type))) { + const native = text(event.toolCallId, 256); + const id = event.type === "tool_execution_start" ? this.bind(native, text(event.toolName, 256), event.args, true) : this.identity(native); + return { ...event, toolCallId: id }; + } + if (event.type === "message_update" && event.assistantMessageEvent) { + const update = record(event.assistantMessageEvent); + const normalizeTool = (value: unknown, direct = false): unknown => { + if (!value || typeof value !== "object") return value; + const block = record(value); + // Empty IDs during initial streaming do not identify an executable call. + return (direct || block.type === "toolCall") && typeof block.id === "string" && block.id + ? { ...block, id: this.identity(block.id) } : value; + }; + const partial = update.partial && typeof update.partial === "object" ? record(update.partial) : undefined; + return { ...event, assistantMessageEvent: { ...update, + ...(update.toolCall ? { toolCall: normalizeTool(update.toolCall, true) } : {}), + ...(partial && Array.isArray(partial.content) ? { partial: { ...partial, content: partial.content.map((block) => normalizeTool(block)) } } : {}), + } }; + } + return event; + } +} + +/** Session history is presentation-only and must never acquire a live delivery ID. */ +export function piHistoricalToolIdentity(sessionId: string, messageIndex: number, nativeId: string): { id: string; provenance: RecordValue } { + const session = text(sessionId, 1024); const native = text(nativeId, 256); + if (!session || !native || !Number.isSafeInteger(messageIndex) || messageIndex < 0) throw new Error("Pi history tool identity is invalid"); + return { id: `pi-history-${createHash("sha256").update(JSON.stringify([session, messageIndex, native])).digest("hex")}`, + provenance: { nativeToolCallId: native, historyMessageIndex: messageIndex, identityScope: "history-display-only" } }; +} + /** Strict LF framing: Unicode line separators inside JSON are ordinary text. */ export class PiRpcFrames { private decoder = new StringDecoder("utf8"); @@ -96,7 +183,7 @@ export function snapshotPiWorkspaceFile(cwd: string, path: string, environment: export function createPiLaunchSpec( params: { cwd: string; mcpServers?: unknown[]; sessionPath?: string }, environment: NodeJS.ProcessEnv, -): { command: string; args: string[]; env: NodeJS.ProcessEnv } { +): { command: string; args: string[]; env: NodeJS.ProcessEnv; invocationNamespace: string } { 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"); @@ -105,8 +192,9 @@ export function createPiLaunchSpec( const readRoots = pathArray(environment.PAPERCLIP_PI_READ_ROOTS); const protectedRoots = pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS); const workspace = realpathSync(params.cwd); + const invocationNamespace = randomUUID(); const configuration = JSON.stringify({ - workspace, servers: params.mcpServers ?? [], + invocationNamespace, workspace, servers: params.mcpServers ?? [], readOnly: environment.PAPERCLIP_PI_READ_ONLY === "1", readRoots, protectedRoots, instructions: environment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS ?? "", @@ -125,7 +213,7 @@ export function createPiLaunchSpec( 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 } }; + return { command, args, invocationNamespace, env: { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration } }; } interface UiConnection { @@ -175,7 +263,7 @@ export class PiUiBridge { 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 }, + toolCall: { toolCallId, _meta: { paperclipPi: { nativeToolCallId: text(permission.nativeToolCallId, 256), modelIteration: permission.modelIteration } }, 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); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts b/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts index bd19336e41f..101e1c4396b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts @@ -6,7 +6,7 @@ * three pins. Never accept a digest supplied only by an installed manifest. */ export const PI_DISTRIBUTION_CLOSURE_SHA256 = Object.freeze({ - "darwin-arm64": "a523fa338cef7db88a42b81e2dc8a5cc41b78775aed1be0a9439f6622ea8a710", - "darwin-x64": "27988352e6bb214ac1c78faeb3157cf5995cceb12612aef09c44e7f0e76356f6", - "linux-x64": "002812b62463ffae84b8e8b58567a6f4ba5025dd830528c5ed3b4c50c2a0ee29", + "darwin-arm64": "b0e02ed16d27fd1d90a5e91d6309f1c47d6cf55f8937a8f680baeee19646d320", + "darwin-x64": "a79b55d7773f4f67d1c6d87fe67346852df5a23e36e8c55d1e84f80d4b668f22", + "linux-x64": "fc7464cb5ea5af2a2d2519f3a67f7471ca3185110f33eaaea3785043709ad3b2", }); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts index 2ba0af71da3..7af733d642e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -10,7 +10,7 @@ import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified- const roots: string[] = []; afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); -const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 3 }); +const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 4 }); async function fixture() { const root = await mkdtemp(join(tmpdir(), "pi-installation-test-")); roots.push(root); @@ -24,7 +24,7 @@ async function fixture() { describe("Pi installation factory", () => { it("binds the profile declaration to the reviewed patch and platform closure pins", async () => { - const digest = createHash("sha256").update("paperclip.pi.rich-acp.profile.v3\0") + const digest = createHash("sha256").update("paperclip.pi.rich-acp.profile.v4\0") .update(await readFile(new URL("../../../../../patches/pi-acp@0.0.33.patch", import.meta.url))) .update("\0").update(await readFile(new URL("./pi-closure-pins.ts", import.meta.url))).digest("hex"); expect(QUALIFIED_ACPX_PROFILES.pi.commandDigest).toBe(`sha256:${digest}`); @@ -32,8 +32,9 @@ describe("Pi installation factory", () => { it("rejects legacy profiles and caller-selected identities", () => { expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 3"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 3"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 4"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 4"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 4"); for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { reportedModelId: "different" }, { agentRuntimePackage: "ambient-pi" }]) { expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts index a55cbc46dc9..1dcd8cd099e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts @@ -24,7 +24,7 @@ function record(value: unknown): Record { /** Candidate identity is build-owned; model selection cannot substitute a CLI. */ export function assertPiInstallationProfile(profile: QualifiedAcpxProfile): void { const trusted = QUALIFIED_ACPX_PROFILES.pi; - if (profile.agentProfileVersion !== 3) throw new Error("Pi rich ACP requires profile version 3; reopen the previous session"); + if (profile.agentProfileVersion !== 4) throw new Error("Pi rich ACP requires profile version 4; reopen the previous session"); if (profile.agent !== "pi" || profile.driverKind !== trusted.driverKind || profile.protocolVersion !== trusted.protocolVersion || profile.acpxVersion !== trusted.acpxVersion || profile.agentServerPackage !== "pi-acp" || profile.agentServerVersion !== "0.0.33" || profile.agentRuntimePackage !== "@earendil-works/pi-coding-agent" || profile.agentRuntimeVersion !== "0.84.2" || profile.commandDigest !== trusted.commandDigest || profile.permissionPolicy !== "interactive" || profile.qualificationModel !== PI_MODEL || profile.reportedModelId !== PI_MODEL) throw new Error("Pi distribution profile differs from its trusted declaration"); } 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 index 04838466f4f..d5d31715360 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts @@ -1,3 +1,4 @@ +import { startRunnerToolBridge } from "../runner-tool-bridge.js"; import { createServer } from "node:http"; import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -14,6 +15,7 @@ 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: { + invocationNamespace: "00000000-0000-4000-8000-000000000000", workspace: join(root, "workspace"), readOnly: false, readRoots: [join(root, "skills")], protectedRoots: [join(root, "private")], instructions: "Use the assigned Paperclip tools", servers: [], @@ -29,7 +31,7 @@ function harness() { describe("owned Pi runtime extension", () => { it("requires explicit assigned configuration, authenticated HTTPS or numeric loopback HTTP", () => { 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 value = { invocationNamespace: "00000000-0000-4000-8000-000000000000", 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); for (const url of ["http://127.0.0.1.evil.test/mcp", "http://paperclip.example/mcp", "http://localhost/mcp", "http://user:password@127.0.0.1/mcp", "https://user:password@paperclip.example/mcp", "https://paperclip.example/mcp#unbound", "file:///tmp/mcp"]) { @@ -55,7 +57,7 @@ describe("owned Pi runtime extension", () => { : body.method === "tools/call" ? { content: [{ type: "text", text: "recorded" }] } : {}; return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result })); }); - await installPiRuntimeExtension(h.api, assigned, (server, method, params, id, signal) => piMcpRequest(server, method, params, id, signal, fetch_)); + await installPiRuntimeExtension(h.api, assigned, (server, method, params, id, signal) => piMcpRequest(server, method, params, id, signal, fetch_)); h.handlers.get("turn_start")!(); expect(h.tools).toHaveLength(1); expect(h.tools[0]!.name).toBe("mcp__paperclip-assigned__report_progress"); const signal = new AbortController().signal; @@ -65,7 +67,7 @@ describe("owned Pi runtime extension", () => { expect(url).toBe(endpoint); expect(init).toMatchObject({ method: "POST", redirect: "error", headers: { Authorization: authorization } }); } - expect(JSON.parse(String(fetch_.mock.calls.at(-1)![1]!.body))).toEqual({ jsonrpc: "2.0", id: "assigned-call-1", method: "tools/call", params: { name: "report_progress", arguments: { text: "done" } } }); + expect(JSON.parse(String(fetch_.mock.calls.at(-1)![1]!.body))).toEqual({ jsonrpc: "2.0", id: expect.stringMatching(/^pi-[a-f0-9]{64}$/), method: "tools/call", params: { name: "report_progress", arguments: { text: "done" } } }); const context = { cwd: config.workspace, ui: { select: vi.fn() } }; expect(await h.handlers.get("tool_call")!({ toolName: "mcp__unassigned__report_progress", toolCallId: "unassigned", input: {} }, context)).toMatchObject({ block: true }); expect(fetch_).toHaveBeenCalledTimes(3); @@ -74,7 +76,7 @@ describe("owned Pi runtime extension", () => { 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); + await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!(); const select = vi.fn().mockResolvedValue("Allow once"); const context = { cwd: config.workspace, ui: { select } }; const tool = h.handlers.get("tool_call")!; @@ -87,7 +89,7 @@ describe("owned Pi runtime extension", () => { 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); + await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!(); const context = { cwd: config.workspace, ui: { select: vi.fn(async () => { await symlink(join(root, "private"), join(config.workspace, "target")); return "Allow once"; @@ -120,7 +122,7 @@ describe("owned Pi runtime extension", () => { it("limits session grants to identical operations and rechecks their paths", async () => { const { config, root } = await workspace(); const h = harness(); - await installPiRuntimeExtension(h.api, config); + await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!(); const select = vi.fn().mockResolvedValue("Allow for this session"); const context = { cwd: config.workspace, ui: { select } }; const tool = h.handlers.get("tool_call")!; @@ -141,17 +143,108 @@ describe("owned Pi runtime extension", () => { 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); + await installPiRuntimeExtension(h.api, config, request); h.handlers.get("turn_start")!(); 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); + expect(request).toHaveBeenLastCalledWith(config.servers[0], "tools/call", { name: "report_progress", arguments: { text: "done" } }, expect.stringMatching(/^pi-[a-f0-9]{64}$/), 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("separates repeated provider call IDs across native iterations and warm prompts, but preserves exact retries", async () => { + const { config } = await workspace(); const h = harness(); + config.servers = [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234", headers: [] }]; + const calls = new Map }>(); + const request = vi.fn(async (_server, method, params, id) => { + if (method === "tools/list") return { tools: ["paperclip_finish", "get_task_context"].map(name => ({ name, inputSchema: { type: "object" } })) }; + if (method !== "tools/call") return {}; + const fingerprint = JSON.stringify(params); + const previous = calls.get(id); + if (previous && previous.fingerprint !== fingerprint) return { isError: true, content: [{ type: "text", text: "Duplicate call identity conflict." }] }; + if (previous) return previous.result; + const result = params.arguments.contractRevision === "1" + ? { isError: true, content: [{ type: "text", text: "Use contractRevision2 and human_response." }] } + : { content: [{ type: "text", text: "accepted" }] }; + calls.set(id, { fingerprint, result }); return result; + }); + await installPiRuntimeExtension(h.api, config, request); h.handlers.get("turn_start")!(); + const finish = h.tools[0]!; const contextTool = h.tools[1]!; + const context = { cwd: config.workspace, ui: { select: vi.fn() } }; + const invoke = async (tool: PiToolDefinition, args: Record) => { + const blocked = await h.handlers.get("tool_call")!({ toolName: tool.name, toolCallId: "call_0", input: args }, context); + expect(blocked).toBeUndefined(); return tool.execute("call_0", args); + }; + await expect(invoke(finish, { contractRevision: "1" })).rejects.toThrow("human_response"); + h.handlers.get("turn_end")?.(); h.handlers.get("turn_start")?.({ turnIndex: 1 }); + await expect(invoke(finish, { contractRevision: "2" })).resolves.toMatchObject({ content: [{ text: "accepted" }] }); + const correctedId = request.mock.calls.at(-1)![3]; + await expect(finish.execute("call_0", { contractRevision: "2" })).resolves.toMatchObject({ content: [{ text: "accepted" }] }); + expect(request.mock.calls.at(-1)![3]).toBe(correctedId); + h.handlers.get("turn_end")?.(); + // A fresh ACP prompt resets Pi's SDK turnIndex, not the runner's lifetime ordinal. + h.handlers.get("before_agent_start")!({ systemPrompt: "warm continuation" }); + h.handlers.get("turn_start")?.({ turnIndex: 0 }); + await expect(invoke(contextTool, {})).resolves.toMatchObject({ content: [{ text: "accepted" }] }); + expect(calls.size).toBe(3); + await expect(contextTool.execute("call_0", { changed: true })).rejects.toThrow(/identity|invocation/i); + expect(() => h.handlers.get("turn_end")!()).toThrow(/identity|iteration/i); + }); + + it("keeps real authenticated bridge replay authority across corrected calls and warm iterations", async () => { + const { config } = await workspace(); const h = harness(); + const handler = vi.fn(async ({ tool, arguments: args }: { tool: string; arguments: unknown }) => { + if (tool === "paperclip_finish" && (args as any).completionClaim.contractRevision === "1") throw new Error("Use contractRevision2 and human_response."); + return { accepted: tool }; + }); + const bridge = await startRunnerToolBridge({ tools: [{ name: "get_task_context", inputSchema: { type: "object" } }], handler }); + const server = { type: "http" as const, name: "paperclip", url: bridge.url, headers: [{ name: "Authorization", value: `Bearer ${bridge.secret}` }] }; + config.servers = [server]; + const deliveries: Array<{ id: string; params: Record }> = []; + try { + await installPiRuntimeExtension(h.api, config, (assigned, method, params, id, signal) => { + if (method === "tools/call") deliveries.push({ id, params }); + return piMcpRequest(assigned, method, params, id, signal); + }); + const finish = h.tools.find(tool => tool.name.endsWith("__paperclip_finish"))!; + const contextTool = h.tools.find(tool => tool.name.endsWith("__get_task_context"))!; + const result = (revision: string) => ({ reportedWorkDisposition: "done", summary: "Complete", completionClaim: { contractRevision: revision, objectiveSatisfied: true, criteria: [{ criterionId: "human_response", status: "satisfied", evidenceRefs: [] }], remainingWork: [] }, evidence: [], verification: [], attentionRequests: [], artifacts: [] }); + h.handlers.get("turn_start")!(); + await expect(finish.execute("call_0", result("1"))).rejects.toThrow("human_response"); + const stale = deliveries.at(-1)!; + await expect(finish.execute("call_0", result("1"))).rejects.toThrow("human_response"); + expect(handler).toHaveBeenCalledTimes(1); // Cached semantic failure, not another mutation. + expect(deliveries.at(-1)!.id).toBe(stale.id); + expect(await piMcpRequest(server, "tools/call", { name: "paperclip_finish", arguments: result("2") }, stale.id)).toMatchObject({ isError: true, content: [{ text: "Duplicate call identity conflict." }] }); + expect(handler).toHaveBeenCalledTimes(1); + h.handlers.get("turn_end")!(); h.handlers.get("turn_start")!(); + await Promise.all([finish.execute("call_0", result("2")), finish.execute("call_0", result("2"))]); + expect(handler).toHaveBeenCalledTimes(2); // Concurrent retries of one invocation execute once. + expect(deliveries.at(-1)!.id).not.toBe(stale.id); + h.handlers.get("turn_end")!(); h.handlers.get("before_agent_start")!({ systemPrompt: "warm prompt" }); h.handlers.get("turn_start")!(); + await contextTool.execute("call_0", {}); + expect(handler).toHaveBeenCalledTimes(3); + expect(new Set(handler.mock.calls.map(([call]) => (call as any).callId)).size).toBe(3); + } finally { await bridge.close(); } + }); + + it("poisons swallowed iteration errors and rejects ambiguous native invocations before a second tool effect", async () => { + const { config } = await workspace(); const h = harness(); + await installPiRuntimeExtension(h.api, config); + const select = vi.fn().mockResolvedValue("Allow once"); + const context = { cwd: config.workspace, ui: { select } }; + h.handlers.get("turn_start")!(); + const tool = { toolName: "bash", toolCallId: "call_0", input: { command: "pwd" } }; + expect(await h.handlers.get("tool_call")!(tool, context)).toBeUndefined(); + expect(await h.handlers.get("tool_call")!(tool, context)).toMatchObject({ block: true }); + expect(select).toHaveBeenCalledTimes(1); + expect(() => h.handlers.get("turn_start")!()).toThrow(/identity|iteration/i); + expect(await h.handlers.get("tool_call")!({ ...tool, toolCallId: "new" }, context)).toMatchObject({ block: true }); + expect(select).toHaveBeenCalledTimes(1); + }); + it("preserves bounded MCP validation errors as failures and removes bound authentication", async () => { const { config } = await workspace(); const h = harness(); const authorization = "Bearer assigned-mcp-token-123456789"; @@ -159,7 +252,7 @@ describe("owned Pi runtime extension", () => { const request = vi.fn(async (_server, method) => method === "tools/list" ? { tools: [{ name: "paperclip_finish", inputSchema: { type: "object" } }] } : method === "tools/call" ? { isError: true, content: [{ type: "text", text: `completionClaim.contractRevision must equal 1; verification.items[0].status is invalid; Authorization: ${authorization}; token=other-private-value; \"password\":\"another-secret\"` }], structuredContent: { code: "INVALID_VERIFICATION", field: "verification.items[0].status" } } : {}); - await installPiRuntimeExtension(h.api, config, request); + await installPiRuntimeExtension(h.api, config, request); h.handlers.get("turn_start")!(); const error = await h.tools[0]!.execute("original-call", {}).catch(value => value); expect(error).toBeInstanceOf(Error); expect(error.message).toContain("completionClaim.contractRevision must equal 1"); @@ -183,7 +276,7 @@ describe("owned Pi runtime extension", () => { const request = vi.fn(async (_server, method) => method === "tools/list" ? { tools: [{ name: "paperclip_finish", inputSchema: { type: "object" } }] } : method === "tools/call" ? result : {}); - await installPiRuntimeExtension(h.api, config, request); + await installPiRuntimeExtension(h.api, config, request); h.handlers.get("turn_start")!(); const error = await h.tools[0]!.execute("call", {}).catch(value => value); expect(error).toBeInstanceOf(Error); expect(Buffer.byteLength(error.message)).toBeLessThanOrEqual(8192); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts index 530802cf936..f9a5c29b79e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts @@ -3,6 +3,7 @@ * this immutable module explicitly; project/global extension discovery is off. * Structural types keep this module independent of Pi's optional UI packages. */ +import { PiToolIdentities } from "./pi-acp-runtime.js"; import { realpath } from "node:fs/promises"; import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; @@ -40,6 +41,7 @@ export interface PiToolDefinition { export interface PiExtensionApi { registerTool(tool: PiToolDefinition): void; registerCommand(name: string, command: { description: string; handler(): Promise }): void; + on(event: "turn_start" | "turn_end", handler: () => void): void; on(event: "tool_call", handler: ( event: PiToolEvent, context: PiExtensionContext, ) => Promise<{ block: true; reason: string } | undefined>): void; @@ -56,6 +58,7 @@ export interface PiMcpServer { } export interface PiRuntimeConfiguration { + invocationNamespace: string; servers: PiMcpServer[]; workspace: string; readOnly: boolean; @@ -69,6 +72,8 @@ export function readPiRuntimeConfiguration(environment: NodeJS.ProcessEnv): PiRu 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 invocationNamespace = value.invocationNamespace; + if (typeof invocationNamespace !== "string" || !/^[a-f0-9-]{36}$/.test(invocationNamespace)) throw new Error("Pi invocation namespace is invalid"); 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"); @@ -96,7 +101,7 @@ export function readPiRuntimeConfiguration(environment: NodeJS.ProcessEnv): PiRu }); 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, + invocationNamespace, servers, workspace, readOnly: value.readOnly, readRoots: pathList(value.readRoots, "read roots"), protectedRoots: pathList(value.protectedRoots, "protected roots"), instructions: value.instructions, @@ -231,6 +236,9 @@ export async function installPiRuntimeExtension( ): Promise { // Registered names are held in this closure; provider-originated metadata // cannot promote a tool to the authenticated Paperclip bridge. + const identities = new PiToolIdentities(config.invocationNamespace); + pi.on("turn_start", () => identities.begin()); + pi.on("turn_end", () => identities.end()); const bridgeTools = new Set(); const permissionGrants = new Set(); // Assigned skills are a separate immutable lease. A config or executable @@ -249,11 +257,12 @@ export async function installPiRuntimeExtension( 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 toolCallId = identities.bind(event.toolCallId, event.toolName, event.input, true); + if (bridgeTools.has(event.toolName)) return; 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 }); + const detail = JSON.stringify({ toolCallId, ...identities.provenance(toolCallId), toolName: event.toolName, input: event.input }); if (Buffer.byteLength(detail) > 48 * 1024) return { block: true, reason: "Pi permission request is oversized" }; // A session grant covers only the identical operation, never a whole // tool class or a subsequent path. Paths are still revalidated each time. @@ -285,7 +294,8 @@ export async function installPiRuntimeExtension( 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); + const requestId = identities.bind(callId, nativeName, arguments_); + const result = await request(server, "tools/call", { name: sourceName, arguments: arguments_ }, requestId, signal); if (result.isError !== undefined && typeof result.isError !== "boolean") throw new Error("Pi MCP tool error flag is invalid"); if (result.isError === true) throw piMcpToolError(result, config); if (!Array.isArray(result.content)) throw new Error("Pi MCP tool result is invalid"); diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index a7af0f2c1ff..10c8302afcb 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -47,14 +47,14 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, acpxVersion: QUALIFIED_ACPX_VERSION, agent: "pi", - agentProfileVersion: 3, + agentProfileVersion: 4, qualificationStatus: "pending", agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", agentRuntimePackage: "@earendil-works/pi-coding-agent", agentRuntimeVersion: "0.84.2", commandDigest: - "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991", qualificationModel: "openrouter/deepseek/deepseek-v4-flash-0731", reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731", permissionPolicy: "interactive", diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs b/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs index f472f99087e..807dae3197a 100644 --- a/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs +++ b/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs @@ -1,18 +1,24 @@ // Protocol fixture: no provider network calls and no secrets. +import { createHash } from "node:crypto"; 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"); +writeFileSync(sessionFile, JSON.stringify({ type: "session", id: "fixture-session", cwd: process.cwd() }) + "\n"); const output = (value) => process.stdout.write(JSON.stringify(value) + "\n"); let active = false; +let modelIteration = 0; +const invocationNamespace = JSON.parse(process.env.PAPERCLIP_PI_RUNTIME_CONFIGURATION).invocationNamespace; +const toolIdentity = (id) => `pi-${createHash("sha256").update(JSON.stringify([invocationNamespace, modelIteration, id])).digest("hex")}`; +const begin = () => { modelIteration++; output({ type: "turn_start" }); }; let model = "fixture-model"; const compaction = () => ({ firstKeptEntryId: "fixture-entry", tokensBefore: 50, summary: "Fixture compacted", usage: { input: 5, output: 2, cacheRead: 1, cacheWrite: 0, cost: { total: 0.02 } } }); 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: "turn_end" }); output({ type: "agent_end" }); // An agent_end alone must not settle the ACP prompt. setTimeout(() => { active = false; output({ type: "agent_settled" }); }, 15); @@ -24,14 +30,28 @@ createInterface({ input: process.stdin }).on("line", (line) => { 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_messages") { response({ messages: process.env.PI_FIXTURE_HISTORY ? [ + { role: "toolResult", toolName: "mcp__paperclip__paperclip_finish", toolCallId: "call_0", content: [{ type: "text", text: "correct criteria" }], isError: true }, + { role: "toolResult", toolName: "mcp__paperclip__paperclip_finish", toolCallId: "call_0", content: [{ type: "text", text: "accepted" }] }, + ] : [] }); 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 === "compact") { output({ type: "compaction_start", reason: "manual" }); const result = compaction(); output({ type: "compaction_end", reason: "manual", result, aborted: false, willRetry: false }); response(result); return; } - if (request.type === "abort") { response(); if (active) { active = false; output({ type: "agent_settled" }); } return; } + if (request.type === "abort") { response(); if (active) { active = false; output({ type: "turn_end" }); output({ type: "agent_settled" }); } return; } if (request.type === "prompt") { - response({ disposition: "started" }); active = true; output({ type: "agent_start" }); + response({ disposition: "started" }); active = true; output({ type: "agent_start" }); begin(); + if (request.message === "reused-tool-ids") { + for (const [index, toolName] of ["mcp__paperclip__paperclip_finish", "mcp__paperclip__paperclip_finish", "mcp__paperclip__get_task_context"].entries()) { + if (index) { output({ type: "turn_end" }); begin(); } + const args = { revision: index + 1 }; + output({ type: "message_update", assistantMessageEvent: { type: "toolcall_start", contentIndex: 0, partial: { content: [{ type: "toolCall", id: "call_0", name: toolName, arguments: args }] } } }); + output({ type: "tool_execution_start", toolCallId: "call_0", toolName, args }); + output({ type: "tool_execution_update", toolCallId: "call_0", toolName, partialResult: { content: [{ type: "text", text: "working" }] } }); + output({ type: "tool_execution_end", toolCallId: "call_0", toolName, result: { content: [{ type: "text", text: index ? "accepted" : "correct criteria" }] }, isError: index === 0 }); + } + finish("reused IDs handled"); return; + } if (request.message.startsWith("bash-")) { const failure = request.message === "bash-failure"; const oversized = request.message === "bash-oversized"; @@ -49,16 +69,16 @@ createInterface({ input: process.stdin }).on("line", (line) => { output({ type: "auto_retry_end", attempt: 2, ...(request.message === "retry-unknown" ? {} : { success: request.message === "retry-success" }) }); if (request.message === "retry-failure") { output({ type: "message_end", message: { role: "assistant", timestamp: 2, stopReason: "error", usage: { input: 1, output: 0 } } }); - active = false; output({ type: "agent_settled" }); + active = false; output({ type: "turn_end" }); output({ type: "agent_settled" }); } else finish("retry outcome received"); return; } if (request.message === "auto-compact" || request.message === "retry-compact") { output({ type: "compaction_start", reason: "threshold" }); if (request.message === "retry-compact") output({ type: "summarization_retry_scheduled" }); output({ type: "compaction_end", reason: "threshold", result: compaction(), aborted: false, willRetry: true }); finish("compacted"); 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 === "permission") { output({ type: "extension_ui_request", id: "permission-id", method: "select", title: "paperclip.pi.permission.v1:" + JSON.stringify({toolCallId:toolIdentity("tool-1"),nativeToolCallId:"tool-1",modelIteration,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; + active = false; output({ type: "turn_end" }); output({ type: "agent_settled" }); return; } output({ type: "message_update", assistantMessageEvent: { type: "thinking_delta", delta: "fixture reasoning" } }); finish("hello🌒\u2028world"); return; diff --git a/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs index 0dfaed5eaea..d0d7220923f 100644 --- a/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs +++ b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs @@ -91,6 +91,59 @@ test("actual patched ACP process streams thinking and waits for settlement with assert.ok(f.notifications.some((event) => event.params?.update?.content?.text === "hello🌒\u2028world")); }); +test("actual patched ACP process scopes recycled native IDs across iterations and warm prompts", async (t) => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + for (let turn = 0; turn < 2; turn++) await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "reused-tool-ids" }] }); + const updates = f.notifications.map(event => event.params?.update).filter(update => update?.toolCallId); + const starts = updates.filter(update => update.sessionUpdate === "tool_call"); + assert.equal(starts.length, 6); + assert.equal(new Set(starts.map(update => update.toolCallId)).size, 6, "each model iteration has an independent tool identity"); + for (const start of starts) { + assert.match(start.toolCallId, /^pi-[a-f0-9]{64}$/); + const lifecycle = updates.filter(update => update.toolCallId === start.toolCallId); + assert.ok(lifecycle.some(update => update.status === "in_progress")); + assert.equal(lifecycle.filter(update => ["failed", "completed"].includes(update.status)).length, 1); + assert.ok(lifecycle.every(update => update._meta.paperclipPi.nativeToolCallId === "call_0")); + } + assert.deepEqual(starts.map(update => update._meta.paperclipPi.modelIteration), [1, 2, 3, 4, 5, 6]); +}); + +test("cancellation closes the iteration before a warm prompt reuses native IDs", async (t) => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const active = f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "long" }] }); + // The acknowledged native control is a FIFO barrier proving prompt admission. + await f.call("pi/steer", { sessionId: session.sessionId, message: "fixture admission fence" }); + f.notify("session/cancel", { sessionId: session.sessionId }); await active; + const warm = await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "reused-tool-ids" }] }); + assert.equal(warm.stopReason, "end_turn"); + const starts = f.notifications.map(event => event.params?.update).filter(update => update?.sessionUpdate === "tool_call"); + assert.deepEqual(starts.map(update => update._meta.paperclipPi.modelIteration), [2, 3, 4]); + assert.equal(new Set(starts.map(update => update.toolCallId)).size, 3); +}); + +test("warm load uses stable display-only history IDs distinct from live execution", async (t) => { + const f = await fixture(t, { PI_FIXTURE_HISTORY: "1" }); + const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const loads = []; + for (let load = 0; load < 2; load++) { + f.notifications.length = 0; + await f.call("session/load", { sessionId: session.sessionId, cwd: join(f.root, "workspace"), mcpServers: [] }); + const starts = f.notifications.map(event => event.params?.update).filter(update => update?.sessionUpdate === "tool_call"); + assert.equal(starts.length, 2); + assert.notEqual(starts[0].toolCallId, starts[1].toolCallId); + for (const start of starts) { + assert.match(start.toolCallId, /^pi-history-[a-f0-9]{64}$/); + assert.equal(start._meta.paperclipPi.nativeToolCallId, "call_0"); + assert.equal(start._meta.paperclipPi.identityScope, "history-display-only"); + } + loads.push(starts.map(update => update.toolCallId)); + } + assert.deepEqual(loads[0], loads[1]); + f.notifications.length = 0; + await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "reused-tool-ids" }] }); + assert.ok(f.notifications.map(event => event.params?.update).filter(update => update?.toolCallId).every(update => /^pi-[a-f0-9]{64}$/.test(update.toolCallId))); +}); + 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" }] }); @@ -166,7 +219,7 @@ for (const outcome of ["success", "failure", "oversized"]) { 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: `bash-${outcome}` }] }); assert.equal(result.stopReason, "end_turn"); - const updates = f.notifications.map((event) => event.params?.update).filter((update) => update?.toolCallId === "bash-fixture"); + const updates = f.notifications.map((event) => event.params?.update).filter((update) => update?._meta?.paperclipPi?.nativeToolCallId === "bash-fixture"); assert.deepEqual(updates[0].rawInput, { command: outcome === "failure" ? "printf failure >&2; exit 7" : "printf done", timeout: 3 }); assert.deepEqual(updates[1].rawOutput, { content: [{ type: "text", text: "partial" }] }); const terminal = updates.at(-1); diff --git a/packages/paperclip-runner/test/pi-native-package-contract.test.mjs b/packages/paperclip-runner/test/pi-native-package-contract.test.mjs index 60552077056..564cfd08b81 100644 --- a/packages/paperclip-runner/test/pi-native-package-contract.test.mjs +++ b/packages/paperclip-runner/test/pi-native-package-contract.test.mjs @@ -7,7 +7,7 @@ import { stripTypeScriptTypes } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { StringDecoder } from "node:string_decoder"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import test from "node:test"; // This test runs real pinned Pi RPC without issuing a prompt or loading keys. @@ -23,6 +23,7 @@ for (const brokenCatalog of [false, true]) { await mkdir(join(root, "workspace")); await mkdir(join(root, "agent")); const extension = join(root, "extension.mjs"); await writeFile(extension, stripTypeScriptTypes(extensionSource)); + await writeFile(join(root, "pi-acp-runtime.js"), stripTypeScriptTypes(await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8"))); const calls = []; const server = createServer(async (request, response) => { assert.equal(request.headers.authorization, "Bearer 0123456789abcdef0123456789abcdef"); @@ -41,6 +42,7 @@ for (const brokenCatalog of [false, true]) { env: { PATH: process.env.PATH, HOME: root, PI_CODING_AGENT_DIR: join(root, "agent"), PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0", PAPERCLIP_PI_RUNTIME_CONFIGURATION: JSON.stringify({ + invocationNamespace: "00000000-0000-4000-8000-000000000000", workspace: join(root, "workspace"), readOnly: true, readRoots: [], protectedRoots: [join(root, "agent")], instructions: "Use the assigned tools", servers: [{ type: "http", name: "paperclip", url: `http://127.0.0.1:${port}/mcp`, headers: [{ name: "Authorization", value: "Bearer 0123456789abcdef0123456789abcdef" }] }], }), @@ -81,3 +83,58 @@ for (const brokenCatalog of [false, true]) { assert.equal(readiness?.description, "Paperclip runtime gate v1"); }); } + +test("real pinned Pi model iterations align owned identities across warm prompts", { timeout: 20_000 }, async () => { + assert.equal(metadata.version, "0.84.2"); + const load = (name) => import(pathToFileURL(join(packageRoot, `dist/core/${name}.js`)).href); + const [{ createAgentSession }, { DefaultResourceLoader }, { ModelRuntime }, { SessionManager }, { SettingsManager }, { AuthStorage }] = await Promise.all( + ["sdk", "resource-loader", "model-runtime", "session-manager", "settings-manager", "auth-storage"].map(load), + ); + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-native-order-"))); + let session; + try { + await writeFile(join(root, "helper.mjs"), stripTypeScriptTypes(await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8"))); + const { PiToolIdentities } = await import(pathToFileURL(join(root, "helper.mjs")).href); + const namespace = "00000000-0000-4000-8000-000000000000"; + const extensionIds = new PiToolIdentities(namespace); const wrapperIds = new PiToolIdentities(namespace); + const events = []; const delivered = []; const displayed = []; let streams = 0; + const settings = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } }); + const modelRuntime = await ModelRuntime.create({ credentials: AuthStorage.inMemory({}), modelsPath: null, refreshOnCreate: false, allowModelNetwork: false }); + const model = { id: "fixture", name: "Fixture", api: "openai-completions", provider: "fixture", baseUrl: "http://127.0.0.1:1", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 64 }; + const resourceLoader = new DefaultResourceLoader({ cwd: root, agentDir: root, settingsManager: settings, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, extensionFactories: [(pi) => { + pi.on("turn_start", (event) => { extensionIds.begin(); events.push({ surface: "extension", type: event.type, turnIndex: event.turnIndex }); }); + pi.on("turn_end", () => extensionIds.end()); + pi.on("tool_call", (event) => { extensionIds.bind(event.toolCallId, event.toolName, event.input, true); }); + pi.registerTool({ name: "fixture_echo", label: "Fixture echo", description: "No side effects", parameters: { type: "object", properties: {}, additionalProperties: false }, execute: async (nativeId, args) => { + delivered.push(extensionIds.bind(nativeId, "fixture_echo", args)); + return { content: [{ type: "text", text: "fixture result" }] }; + } }); + }] }); + await resourceLoader.reload(); + ({ session } = await createAgentSession({ cwd: root, agentDir: root, model, modelRuntime, settingsManager: settings, sessionManager: SessionManager.inMemory(root), resourceLoader, noTools: "builtin", thinkingLevel: "off" })); + const extensionErrors = []; + await session.bindExtensions({ onError: (error) => extensionErrors.push(error.event) }); + session.subscribe((event) => { + const normalized = wrapperIds.normalize(event); + if (event.type === "turn_start") events.push({ surface: "session", type: event.type }); + if (event.type === "tool_execution_start") displayed.push(normalized.toolCallId); + }); + // Only the model stream is replaced. These are actual pinned Agent loop, + // AgentSession dispatch, extension hooks, and tool execution paths. No key, + // network model lookup, provider request, or inference is involved. + session.agent.streamFunction = () => { + const index = ++streams; const isTool = index % 3 !== 0; + const message = { role: "assistant", content: isTool ? [{ type: "toolCall", id: "call_0", name: "fixture_echo", arguments: {} }] : [{ type: "text", text: "done" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: isTool ? "toolUse" : "stop", timestamp: index }; + return { async *[Symbol.asyncIterator]() { yield { type: "start", partial: message }; yield { type: "done", reason: message.stopReason, message }; }, result: async () => message }; + }; + await session.agent.prompt("fixture first"); await session.agent.waitForIdle(); + await session.agent.prompt("fixture warm"); await session.agent.waitForIdle(); + assert.deepEqual(extensionErrors, []); + assert.equal(streams, 6); assert.equal(delivered.length, 4); + assert.deepEqual(displayed, delivered); assert.equal(new Set(delivered).size, 4); + assert.deepEqual(events.filter((event) => event.surface === "extension").map((event) => event.turnIndex), [0, 1, 2, 0, 1, 2]); + for (let index = 0; index < events.length; index++) if (events[index].surface === "session") assert.equal(events[index - 1]?.surface, "extension"); + } finally { + session?.dispose(); await rm(root, { recursive: true, force: true }); + } +}); diff --git a/patches/pi-acp@0.0.33.patch b/patches/pi-acp@0.0.33.patch index daa07bff7ac..eab557bd01c 100644 --- a/patches/pi-acp@0.0.33.patch +++ b/patches/pi-acp@0.0.33.patch @@ -13,13 +13,17 @@ // 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"; ++import { PI_ACP_FEATURES, PiToolIdentities, piHistoricalToolIdentity, PiRpcFrames, PiUiBridge, PiTurnUsage, createPiLaunchSpec, snapshotPiWorkspaceFile } from "./paperclip-runtime.js"; // src/pi-rpc/command.ts import { platform } from "os"; -@@ -96,49 +97,38 @@ +@@ -94,51 +95,42 @@ + pending = /* @__PURE__ */ new Map(); + eventHandlers = []; preludeLines = []; - constructor(child) { +- constructor(child) { ++ constructor(child, invocationNamespace) { ++ this.toolIdentities = new PiToolIdentities(invocationNamespace); this.child = child; - const rl = readline.createInterface({ input: child.stdout }); - rl.on("line", (line) => { @@ -55,7 +59,8 @@ - } - } - for (const h of this.eventHandlers) h(msg); -+ for (const handler of this.eventHandlers) handler(msg); ++ const event = this.toolIdentities.normalize(msg); ++ for (const handler of this.eventHandlers) handler(event); }); - child.on("exit", (code, signal) => { - const err = new Error(`pi process exited (code=${code}, signal=${signal})`); @@ -90,7 +95,16 @@ }); try { await new Promise((resolve4, reject) => { -@@ -181,21 +171,30 @@ +@@ -172,7 +164,7 @@ + } + child.stderr.on("data", () => { + }); +- const proc = new _PiRpcProcess(child); ++ const proc = new _PiRpcProcess(child, launch.invocationNamespace); + try { + const state = await proc.getState(); + const sessionFile = typeof state?.sessionFile === "string" ? state.sessionFile : null; +@@ -181,21 +173,30 @@ const { dirname: dirname3 } = await import("path"); mkdirSync2(dirname3(sessionFile), { recursive: true }); } @@ -126,7 +140,7 @@ } } /** -@@ -242,7 +241,7 @@ +@@ -242,7 +243,7 @@ if (!res.success) throw new Error(`pi set_steering_mode failed: ${res.error ?? JSON.stringify(res.data)}`); } async compact(customInstructions) { @@ -135,7 +149,7 @@ if (!res.success) throw new Error(`pi compact failed: ${res.error ?? JSON.stringify(res.data)}`); return res.data; } -@@ -283,17 +282,23 @@ +@@ -283,17 +284,23 @@ await this.writeLine(`${JSON.stringify({ type: "extension_ui_response", ...response })} `); } @@ -165,7 +179,7 @@ }); } writeLine(line) { -@@ -337,7 +342,7 @@ +@@ -337,7 +344,7 @@ { authMethods: getAuthMethods() }, @@ -174,7 +188,7 @@ ); } -@@ -534,6 +539,14 @@ +@@ -534,6 +541,14 @@ const record = value; const command = record?.command ?? record?.cmd ?? record?.args?.command ?? record?.args?.cmd ?? record?.input?.command ?? record?.input?.cmd ?? record?.rawInput?.command ?? record?.rawInput?.cmd ?? record?.toolInput?.command ?? record?.toolInput?.cmd ?? record?.details?.command ?? record?.details?.cmd; return typeof command === "string" && command.trim() ? command : void 0; @@ -189,7 +203,7 @@ } function bashResultText(result) { const record = result; -@@ -714,6 +727,7 @@ +@@ -714,6 +729,7 @@ try { proc = await PiRpcProcess.spawn({ cwd: params.cwd, @@ -197,7 +211,7 @@ piCommand: params.piCommand }); } catch (e) { -@@ -808,6 +822,9 @@ +@@ -808,6 +824,9 @@ this.proc = opts.proc; this.conn = opts.conn; this.fileCommands = opts.fileCommands ?? []; @@ -207,7 +221,7 @@ this.proc.onEvent((ev) => this.handlePiEvent(ev)); } setStartupInfo(text) { -@@ -852,6 +869,7 @@ +@@ -852,6 +871,7 @@ } async cancel() { this.cancelRequested = true; @@ -215,7 +229,16 @@ if (this.turnQueue.length) { const queued = this.turnQueue.splice(0, this.turnQueue.length); for (const t of queued) t.resolve("cancelled"); -@@ -890,6 +908,7 @@ +@@ -870,6 +890,8 @@ + return this.cancelRequested; + } + emit(update) { ++ const provenance = typeof update.toolCallId === "string" ? this.proc.toolIdentities.provenance(update.toolCallId) : undefined; ++ if (provenance) update = { ...update, _meta: { ...update._meta, paperclipPi: { ...update._meta?.paperclipPi, ...provenance } } }; + this.lastEmit = this.lastEmit.then( + () => this.conn.sessionUpdate({ + sessionId: this.sessionId, +@@ -890,6 +912,7 @@ kind: "execute", status: params.status, locations: params.locations, @@ -223,7 +246,7 @@ ...params.includeTerminal ? { content: bashTerminalContent(params.toolCallId) } : {}, ...params.includeTerminal ? { _meta: bashTerminalInfoMeta(params.toolCallId, this.cwd) } : {} }); -@@ -903,6 +922,7 @@ +@@ -903,6 +926,7 @@ sessionUpdate: "tool_call_update", toolCallId: params.toolCallId, status: params.status, @@ -231,7 +254,7 @@ _meta: { ...delta ? bashTerminalOutputMeta(params.toolCallId, delta) : {}, ...params.status === "completed" || params.status === "failed" ? bashTerminalExitMeta(params.toolCallId, bashExitCode(params.result, Boolean(params.isError))) : {} -@@ -920,12 +940,16 @@ +@@ -920,12 +944,16 @@ this.cancelRequested = false; this.inAgentLoop = false; this.pendingTurn = { resolve: t.resolve, reject: t.reject }; @@ -248,7 +271,7 @@ const authErr = maybeAuthRequiredError(err); if (authErr) { this.pendingTurn?.reject(authErr); -@@ -946,6 +970,23 @@ +@@ -946,6 +974,23 @@ handlePiEvent(ev) { const type = String(ev.type ?? ""); switch (type) { @@ -272,7 +295,7 @@ case "message_update": { const ame = ev.assistantMessageEvent; if (ame?.type === "text_delta" && typeof ame.delta === "string") { -@@ -1047,7 +1088,7 @@ +@@ -1047,7 +1092,7 @@ if (p) { try { const abs = isAbsolute(p) ? p : resolvePath(this.cwd, p); @@ -281,7 +304,7 @@ this.fileSnapshots.set(toolCallId, { path: p, oldText: snapshotOldText }); if (toolName === "edit") { for (const needle of getEditOldTexts(args)) { -@@ -1125,7 +1166,7 @@ +@@ -1125,7 +1170,7 @@ if (!isError && snapshot) { try { const abs = isAbsolute(snapshot.path) ? snapshot.path : resolvePath(this.cwd, snapshot.path); @@ -290,7 +313,7 @@ if (snapshot.oldText === null || newText !== snapshot.oldText) { hasStructuredDiff = true; content = [ -@@ -1154,14 +1195,11 @@ +@@ -1154,14 +1199,11 @@ break; } case "extension_ui_request": { @@ -309,7 +332,7 @@ break; } case "auto_retry_start": { -@@ -1174,28 +1212,31 @@ +@@ -1174,28 +1216,31 @@ case "auto_retry_end": { this.emit({ sessionUpdate: "agent_message_chunk", @@ -357,7 +380,7 @@ break; } case "agent_start": { -@@ -1210,9 +1251,12 @@ +@@ -1210,9 +1255,12 @@ break; } case "agent_settled": { @@ -371,7 +394,7 @@ this.pendingTurn = null; this.inAgentLoop = false; const next = this.turnQueue.shift(); -@@ -1725,6 +1769,7 @@ +@@ -1725,6 +1773,7 @@ return process.env.PI_CODING_AGENT_DIR ? resolve3(process.env.PI_CODING_AGENT_DIR) : join4(homedir4(), ".pi", "agent"); } function getEnableSkillCommands(cwd) { @@ -379,7 +402,7 @@ const merged = getMergedSettings(cwd); const direct = merged.enableSkillCommands; if (typeof direct === "boolean") return direct; -@@ -1733,6 +1778,7 @@ +@@ -1733,6 +1782,7 @@ return true; } function getQuietStartup(cwd) { @@ -387,7 +410,7 @@ const merged = getMergedSettings(cwd); const direct = merged.quietStartup; if (typeof direct === "boolean") return direct; -@@ -1824,6 +1870,7 @@ +@@ -1824,6 +1874,7 @@ const out = []; const seen = /* @__PURE__ */ new Set(); for (const c of [...a, ...b]) { @@ -395,7 +418,7 @@ if (seen.has(c.name)) continue; seen.add(c.name); out.push(c); -@@ -1884,11 +1931,13 @@ +@@ -1884,11 +1935,13 @@ throw RequestError3.invalidParams(`Unknown sessionId: ${sessionId}`); } const cwd = opts?.cwd ?? stored.cwd; @@ -409,7 +432,7 @@ piCommand: process.env.PI_ACP_PI_COMMAND }); } catch (e) { -@@ -1897,7 +1946,7 @@ +@@ -1897,7 +1950,7 @@ } throw e; } @@ -418,7 +441,7 @@ const session = this.sessions.getOrCreate(sessionId, { cwd, mcpServers: opts?.mcpServers ?? [], -@@ -1917,6 +1966,7 @@ +@@ -1917,6 +1970,7 @@ } } async initialize(params) { @@ -426,7 +449,7 @@ const supportedVersion = 1; const requested = params.protocolVersion; return { -@@ -1933,7 +1983,8 @@ +@@ -1933,7 +1987,8 @@ }), agentCapabilities: { loadSession: true, @@ -436,7 +459,7 @@ promptCapabilities: { image: true, audio: false, -@@ -1942,8 +1993,7 @@ +@@ -1942,8 +1997,7 @@ sessionCapabilities: { // **UNSTABLE** ACP capability used by Zed's codex-acp adapter. // Enables a native session picker in clients that support it. @@ -446,7 +469,7 @@ } } }; -@@ -1953,7 +2003,7 @@ +@@ -1953,7 +2007,7 @@ throw RequestError3.invalidParams(`cwd must be an absolute path: ${params.cwd}`); } this.lastSessionCwd = params.cwd; @@ -455,7 +478,7 @@ const enableSkillCommands = getEnableSkillCommands(params.cwd); const session = await this.sessions.create({ cwd: params.cwd, -@@ -1994,14 +2044,14 @@ +@@ -1994,14 +2048,14 @@ this.cleanupFailedNewSession(session.sessionId, state); throw RequestError3.authRequired( { authMethods: getAuthMethods() }, @@ -472,7 +495,7 @@ ); } const { configOptions, models, modes } = await getSessionConfiguration(session.proc, { -@@ -2064,34 +2114,27 @@ +@@ -2064,34 +2118,27 @@ } async prompt(params) { const session = await this.restoreSession(params.sessionId); @@ -519,7 +542,7 @@ } if (cmd === "session") { const stats = await session.proc.getSessionStats(); -@@ -2432,8 +2475,18 @@ +@@ -2432,8 +2479,18 @@ } } const result = await session.prompt(message, images); @@ -540,7 +563,7 @@ } async cancel(params) { const session = this.sessions.maybeGet(params.sessionId); -@@ -2473,7 +2526,7 @@ +@@ -2473,7 +2530,7 @@ mcpServers: params.mcpServers }); const proc = session.proc; @@ -549,7 +572,61 @@ this.sessions.closeAllExcept?.(session.sessionId); this.store.upsert({ sessionId: params.sessionId, -@@ -2607,6 +2660,7 @@ +@@ -2482,7 +2539,7 @@ + }); + const data = await proc.getMessages(); + const messages = Array.isArray(data?.messages) ? data.messages : []; +- for (const m of messages) { ++ for (const [messageIndex, m] of messages.entries()) { + const role = String(m?.role ?? ""); + if (role === "user") { + const text = normalizePiMessageText(m?.content); +@@ -2510,7 +2567,8 @@ + } + if (role === "toolResult") { + const toolName = String(m?.toolName ?? "tool"); +- const toolCallId = String(m?.toolCallId ?? crypto.randomUUID()); ++ const historical = piHistoricalToolIdentity(params.sessionId, messageIndex, String(m?.toolCallId ?? "")); ++ const toolCallId = historical.id; + const isError = Boolean(m?.isError); + const isBash = isBashTool(toolName); + if (isBash) { +@@ -2524,7 +2582,7 @@ + kind: "execute", + status: "completed", + content: bashTerminalContent(toolCallId), +- _meta: bashTerminalInfoMeta(toolCallId, params.cwd) ++ _meta: { ...bashTerminalInfoMeta(toolCallId, params.cwd), paperclipPi: historical.provenance } + } + }); + await this.conn.sessionUpdate({ +@@ -2535,7 +2593,8 @@ + status: isError ? "failed" : "completed", + _meta: { + ...text2 ? bashTerminalOutputMeta(toolCallId, text2) : {}, +- ...bashTerminalExitMeta(toolCallId, bashExitCode(m, isError)) ++ ...bashTerminalExitMeta(toolCallId, bashExitCode(m, isError)), ++ paperclipPi: historical.provenance + } + } + }); +@@ -2549,6 +2608,7 @@ + title: toolName, + kind: toolName === "read" ? "read" : toolName === "write" || toolName === "edit" ? "edit" : "other", + status: "completed", ++ _meta: { paperclipPi: historical.provenance }, + rawInput: null, + rawOutput: m + } +@@ -2560,6 +2620,7 @@ + sessionUpdate: "tool_call_update", + toolCallId, + status: isError ? "failed" : "completed", ++ _meta: { paperclipPi: historical.provenance }, + content: text ? [{ type: "content", content: { type: "text", text } }] : null, + rawOutput: m + } +@@ -2607,6 +2668,7 @@ return response; } async deleteSession(params) { @@ -557,7 +634,7 @@ const stored = this.store.get(params.sessionId); const piSession = findPiSession(params.sessionId); if (!stored && !piSession) { -@@ -2828,6 +2882,7 @@ +@@ -2828,6 +2890,7 @@ return 0; } function buildUpdateNotice() { @@ -565,7 +642,7 @@ try { const piVersion = spawnSync("pi", ["--version"], { encoding: "utf-8" }); const installed = (String(piVersion.stdout ?? "").trim() || String(piVersion.stderr ?? "").trim()).replace( -@@ -2848,6 +2903,7 @@ +@@ -2848,6 +2911,7 @@ } } function buildStartupInfo(opts) { @@ -573,7 +650,7 @@ void opts.fileCommands; const md = []; try { -@@ -2980,6 +3036,7 @@ +@@ -2980,6 +3044,7 @@ // src/index.ts if (process.argv.includes("--terminal-login")) { @@ -581,7 +658,7 @@ const { spawnSync: spawnSync2 } = await import("child_process"); const cmd = getPiCommand(process.env.PI_ACP_PI_COMMAND); const res = spawnSync2(cmd, [], { -@@ -3019,11 +3076,12 @@ +@@ -3019,11 +3084,12 @@ } }); var stream = ndJsonStream(input, output); @@ -598,8 +675,9 @@ try { --- /dev/null +++ b/dist/paperclip-runtime.js -@@ -0,0 +1,283 @@ +@@ -0,0 +1,371 @@ +/** Source for the helper embedded in patches/pi-acp@0.0.33.patch. */ ++import { createHash, randomUUID } from "node:crypto"; +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"; @@ -627,6 +705,92 @@ + pendingRequestRecovery: "live-process-only", +}); + ++/** One trusted Pi model iteration, not one ACP prompt. Pi resets its own turnIndex ++ * on warm prompts; our ordinal is monotonic for the lifetime of the child. */ ++export class PiToolIdentities { ++ ordinal = 0; ++ active = false; ++ poisoned = false; ++ entries = new Map (); ++ namespace ; ++ constructor(namespace ) { ++ this.namespace = namespace; ++ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(namespace)) throw new Error("Pi invocation namespace is invalid"); ++ } ++ fail(message ) { this.poisoned = true; throw new Error(message); } ++ begin() { ++ if (this.poisoned || this.active || this.ordinal >= Number.MAX_SAFE_INTEGER) this.fail("Pi model iteration identity is ambiguous"); ++ this.ordinal++; this.active = true; this.entries.clear(); ++ } ++ end() { ++ if (this.poisoned || !this.active) this.fail("Pi model iteration identity is unavailable"); ++ this.active = false; ++ } ++ identity(nativeId ) { ++ if (this.poisoned || this.ordinal === 0) this.fail("Pi model iteration identity is unavailable"); ++ let native ; ++ try { native = text(nativeId, 256); } catch { return this.fail("Pi native tool identity is invalid"); } ++ if (!native) this.fail("Pi native tool identity is missing"); ++ const prior = this.entries.get(native); ++ if (prior) return prior.id; ++ if (!this.active || this.entries.size >= 4096) this.fail("Pi tool identity is outside its iteration bound"); ++ const id = `pi-${createHash("sha256").update(JSON.stringify([this.namespace, this.ordinal, native])).digest("hex")}`; ++ this.entries.set(native, { id, nativeId: native, claimed: false }); ++ return id; ++ } ++ bind(nativeId , name , args , claim = false) { ++ if (this.poisoned || !this.active) this.fail("Pi tool invocation has no active model iteration"); ++ const id = this.identity(nativeId); const entry = this.entries.get(nativeId) ; ++ const canonical = (value ) => Array.isArray(value) ? value.map(canonical) ++ : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])) : value; ++ let encoded ; ++ try { encoded = JSON.stringify([text(name, 256), canonical(args)]); } ++ catch { return this.fail("Pi tool invocation is invalid"); } ++ if (Buffer.byteLength(encoded) > 1_048_576) this.fail("Pi tool invocation is oversized"); ++ const fingerprint = createHash("sha256").update(encoded).digest("hex"); ++ if ((claim && entry.claimed) || (entry.fingerprint !== undefined && entry.fingerprint !== fingerprint)) this.fail("Pi native tool invocation identity conflict"); ++ entry.fingerprint = fingerprint; if (claim) entry.claimed = true; ++ return id; ++ } ++ provenance(id ) { ++ for (const entry of this.entries.values()) if (entry.id === id) return { nativeToolCallId: entry.nativeId, modelIteration: this.ordinal, identityScope: "native-model-iteration" }; ++ return undefined; ++ } ++ normalize(event ) { ++ if (event.type === "turn_start") { this.begin(); return event; } ++ if (event.type === "turn_end") { this.end(); return event; } ++ if (["tool_execution_start", "tool_execution_update", "tool_execution_end"].includes(String(event.type))) { ++ const native = text(event.toolCallId, 256); ++ const id = event.type === "tool_execution_start" ? this.bind(native, text(event.toolName, 256), event.args, true) : this.identity(native); ++ return { ...event, toolCallId: id }; ++ } ++ if (event.type === "message_update" && event.assistantMessageEvent) { ++ const update = record(event.assistantMessageEvent); ++ const normalizeTool = (value , direct = false) => { ++ if (!value || typeof value !== "object") return value; ++ const block = record(value); ++ // Empty IDs during initial streaming do not identify an executable call. ++ return (direct || block.type === "toolCall") && typeof block.id === "string" && block.id ++ ? { ...block, id: this.identity(block.id) } : value; ++ }; ++ const partial = update.partial && typeof update.partial === "object" ? record(update.partial) : undefined; ++ return { ...event, assistantMessageEvent: { ...update, ++ ...(update.toolCall ? { toolCall: normalizeTool(update.toolCall, true) } : {}), ++ ...(partial && Array.isArray(partial.content) ? { partial: { ...partial, content: partial.content.map((block) => normalizeTool(block)) } } : {}), ++ } }; ++ } ++ return event; ++ } ++} ++ ++/** Session history is presentation-only and must never acquire a live delivery ID. */ ++export function piHistoricalToolIdentity(sessionId , messageIndex , nativeId ) { ++ const session = text(sessionId, 1024); const native = text(nativeId, 256); ++ if (!session || !native || !Number.isSafeInteger(messageIndex) || messageIndex < 0) throw new Error("Pi history tool identity is invalid"); ++ return { id: `pi-history-${createHash("sha256").update(JSON.stringify([session, messageIndex, native])).digest("hex")}`, ++ provenance: { nativeToolCallId: native, historyMessageIndex: messageIndex, identityScope: "history-display-only" } }; ++} ++ +/** Strict LF framing: Unicode line separators inside JSON are ordinary text. */ +export class PiRpcFrames { + decoder = new StringDecoder("utf8"); @@ -697,7 +861,7 @@ +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"); @@ -706,8 +870,9 @@ + const readRoots = pathArray(environment.PAPERCLIP_PI_READ_ROOTS); + const protectedRoots = pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS); + const workspace = realpathSync(params.cwd); ++ const invocationNamespace = randomUUID(); + const configuration = JSON.stringify({ -+ workspace, servers: params.mcpServers ?? [], ++ invocationNamespace, workspace, servers: params.mcpServers ?? [], + readOnly: environment.PAPERCLIP_PI_READ_ONLY === "1", + readRoots, protectedRoots, + instructions: environment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS ?? "", @@ -726,7 +891,7 @@ + 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 } }; ++ return { command, args, invocationNamespace, env: { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration } }; +} + + @@ -776,7 +941,7 @@ + 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 }, ++ toolCall: { toolCallId, _meta: { paperclipPi: { nativeToolCallId: text(permission.nativeToolCallId, 256), modelIteration: permission.modelIteration } }, 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);