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);