From 742f9bef156a56886d86c7ca1eb0f3e32832f06d Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 11:43:45 -0500 Subject: [PATCH] Bind Pi launches to verified immutable provider assets Co-Authored-By: Paperclip --- doc/architecture/runner-pi-capabilities.md | 24 +++++ .../scripts/materialize-pi-distribution.mjs | 34 ++++-- .../src/drivers/acpx/pi-closure-pins.ts | 6 +- .../src/drivers/acpx/pi-installation.test.ts | 91 ++++++++++++++++ .../src/drivers/acpx/pi-installation.ts | 94 ++++++++++++++++ .../test/pi-acp-package-contract.test.mjs | 1 + patches/pi-acp@0.0.33.patch | 100 +++++++++++++----- 7 files changed, 307 insertions(+), 43 deletions(-) create mode 100644 packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/pi-installation.ts diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index 6992a30ed79..5ac568312ed 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -190,3 +190,27 @@ archive hashes were checked against the [official Node release checksums](https: The x64 closure pins combine the identical locked package/resource graph with each verified official x64 interpreter. They are candidate artifact identity, not proof that those targets have executed successfully. + +### Installation authority and token semantics + +`verifyPiInstallation(profile)` admits only profile version 2 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, +Pi, and the extension relative to itself, overriding inherited launch paths. +Unconfigured installations return an explicit bound-credential error and expose +no terminal login option. The materializer returns the runtime `version`, +`profileDigest`, and complete `closureDigest` for the provider-pack manifest. + +For the exact OpenRouter qualification model, Pi AI 0.84.2's +`dist/providers/data/openrouter.json` selects `openai-completions`. Its +`dist/api/openai-completions.js` assigns `usage.output` from `completion_tokens`, +which already includes reasoning tokens; `usage.reasoning` is a subset. PRP must +therefore use zero additional thought tokens for this pinned profile, rather than +counting that subset twice. Missing cache categories remain unknown at the wrapper +receipt boundary. `usage.cost` is a Pi catalog pricing estimate, never an invoice. + +The closed snapshot has been exercised against actual pinned Pi through ACP +initialization and the missing-credential admission path without a model call. +The exact reported `currentModelId` and billable receipt still need credentialed +local and Linux/Daytona qualification. diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs index 2fd8e016024..a31291f2eff 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs @@ -7,6 +7,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "../src/drivers/acpx/pi-closure-pins.ts"; import { PI_NODE_DISTRIBUTIONS, PI_NODE_VERSION } from "../src/drivers/acpx/pi-node-pins.ts"; +import { QUALIFIED_ACPX_PROFILES } from "../src/drivers/acpx/qualified-profiles.ts"; import { inventoryPiRuntimeFiles, verifyPiRuntimeManifest } from "../src/drivers/acpx/pi-verified-runtime.ts"; const run = promisify(execFile); @@ -17,7 +18,7 @@ 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: "d2a2cf67bebedb7cff46b2cebdc98dda8e91f3e64b15f26c660715e5145c7cc6", + wrapperSha256: "18375d30e7eca9fddf6c024f49899d6da38c064d8587aa1678fd4c2d70308fbc", helperSha256: "57dd03e3d1e59d0191f0dd66f5a809e6c825de3c4b6660bff697686c160a659f", }); const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); @@ -93,6 +94,20 @@ export async function writePiDistributionManifest(runtimeRoot) { return { manifest, ...verified }; } +export function piDistributionBootstrapSource() { + return [ + 'const path=require("node:path");', + 'process.env.PAPERCLIP_PI_NODE_EXECUTABLE=path.join(__dirname,"node/bin/node");', + 'process.env.PAPERCLIP_PI_ENTRYPOINT=path.join(__dirname,"node_modules/@earendil-works/pi-coding-agent/dist/cli.js");', + 'process.env.PAPERCLIP_PI_EXTENSION_PATH=path.join(__dirname,"extensions/paperclip.js");', + 'process.env.PAPERCLIP_PI_MODULE_GUARD_PATH=path.join(__dirname,".paperclip-native-module-guard.cjs");', + 'const protectedRoots=JSON.parse(process.env.PAPERCLIP_PI_PROTECTED_ROOTS??"[]");if(!Array.isArray(protectedRoots))throw new Error("Invalid Pi protected roots");', + 'process.env.PAPERCLIP_PI_PROTECTED_ROOTS=JSON.stringify([...protectedRoots,__dirname]);', + 'import(require("node:url").pathToFileURL(path.join(__dirname,"node_modules/pi-acp/dist/index.js")).href).catch(()=>{process.exitCode=1;});', + '', + ].join("\n"); +} + /** Build on the target platform. No lifecycle scripts, model requests, or auth. */ export async function materializePiDistribution({ outputRoot, nodeExecutable, npmExecutable = "npm" }) { if (typeof outputRoot !== "string" || !outputRoot || !isAbsolute(outputRoot)) throw new Error("Pi distribution output must be absolute"); @@ -170,15 +185,7 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np } } await rm(join(runtimeRoot, "node_modules/.package-lock.json"), { force: true }); - await writeFile(join(runtimeRoot, "pi-entry.cjs"), [ - 'const path=require("node:path");', - 'process.env.PAPERCLIP_PI_NODE_EXECUTABLE=path.join(__dirname,"node/bin/node");', - 'process.env.PAPERCLIP_PI_ENTRYPOINT=path.join(__dirname,"node_modules/@earendil-works/pi-coding-agent/dist/cli.js");', - 'process.env.PAPERCLIP_PI_EXTENSION_PATH=path.join(__dirname,"extensions/paperclip.js");', - 'process.env.PAPERCLIP_PI_MODULE_GUARD_PATH=path.join(__dirname,".paperclip-native-module-guard.cjs");', - 'import(require("node:url").pathToFileURL(path.join(__dirname,"node_modules/pi-acp/dist/index.js")).href).catch(()=>{process.exitCode=1;});', - '', - ].join("\n")); + await writeFile(join(runtimeRoot, "pi-entry.cjs"), piDistributionBootstrapSource()); for (const item of await readdir(staging)) if (item !== "runtime") await rm(join(staging, item), { recursive: true, force: true }); const { manifest, manifestDigest } = await writePiDistributionManifest(runtimeRoot); const entries = await Promise.all(manifest.files.map(async (file) => { @@ -200,7 +207,12 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np await rename(staging, output); const finalRoot = join(output, "runtime"); const binding = await verifyPiRuntimeManifest(finalRoot, manifest); - return { outputRoot: output, runtimeRoot: finalRoot, manifestPath: join(output, "pi-distribution.json"), metadata, ...binding }; + return { + version: PI_DISTRIBUTION_PINS.runtime, + profileDigest: QUALIFIED_ACPX_PROFILES.pi.commandDigest, + closureDigest: `sha256:${nativeClosureSha256}`, + outputRoot: output, runtimeRoot: finalRoot, manifestPath: join(output, "pi-distribution.json"), metadata, ...binding, + }; } catch (error) { await rm(staging, { recursive: true, force: true }); throw error; } } 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 715315b1129..298e00f63a6 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": "277568e3adb63faefafb1aedd8e8d33441608e94d9b764fbfd980e390ff07c8c", - "darwin-x64": "f8f271ce09284ef0bf52a99adaa280798f2135b8f8a6ee628394324b923b5d7f", - "linux-x64": "b0d71abf0cfe464c147fcc5bfd875e349f94773d84e46a1bdcbadfc0184f8690", + "darwin-arm64": "f2c95f532e4b56a57e938ceb62b5ea1a86bc721cba29b41de6270afe3fe0041f", + "darwin-x64": "93f3c06c91fc9e06084dff083f3b43ac100dee3ac2c5f5981e3d281f1e561a20", + "linux-x64": "99707d2c78656bebdf5df4b85e4674a72d5d30a3a0c730536246251a2889d0a8", }); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts new file mode 100644 index 00000000000..04990d95f60 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -0,0 +1,91 @@ +import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { once } from "node:events"; +import { StringDecoder } from "node:string_decoder"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { assertPiInstallationProfile, verifyPiInstallation } from "./pi-installation.js"; +import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js"; + +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: 2 }); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "pi-installation-test-")); roots.push(root); + await writeFile(join(root, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" })); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", root); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(root, "package.json")); + const assets = join(root, "provider-assets/pi", `${process.platform}-${process.arch}`); + await mkdir(join(assets, "runtime"), { recursive: true }); + return { root, assets }; +} + +describe("Pi installation factory", () => { + it("rejects legacy profiles and caller-selected identities", () => { + expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 2"); + for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { reportedModelId: "different" }, { agentRuntimePackage: "ambient-pi" }]) { + expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration"); + } + }); + it("never takes closure authority from installed metadata", async () => { + const { assets } = await fixture(); + await writeFile(join(assets, "pi-distribution.json"), JSON.stringify({ + schema: "paperclip.pi-distribution.v1", runtimeRoot: "runtime", nativeClosureSha256: "0".repeat(64), + target: { platform: process.platform, architecture: process.arch, nodeVersion: "24.19.0" }, + pins: { nodeVersion: "24.19.0", wrapper: "0.0.33", runtime: "0.84.2", sdk: "0.26.0", zod: "3.25.76" }, + })); + await expect(verifyPiInstallation(candidate())).rejects.toThrow("trusted target pin"); + }); + it.runIf(process.env.PAPERCLIP_TEST_PI_DISTRIBUTION_ROOT)("launches the actual closed Pi snapshot with bound subprocess paths", async () => { + const { root, assets } = await fixture(); + await cp(process.env.PAPERCLIP_TEST_PI_DISTRIBUTION_ROOT!, assets, { recursive: true }); + const workspace = join(root, "workspace"); const agentHome = join(root, "agent"); + await mkdir(workspace); await mkdir(agentHome); + const installation = await verifyPiInstallation(candidate()); + const command = await installation.openCommand(); + const child = command.spawn([], { cwd: workspace, env: { + PATH: "/usr/bin:/bin", HOME: agentHome, PI_CODING_AGENT_DIR: agentHome, + PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_PI_READ_ONLY: "1", + PAPERCLIP_PI_READ_ROOTS: "[]", PAPERCLIP_PI_PROTECTED_ROOTS: JSON.stringify([agentHome]), + PAPERCLIP_PI_ENTRYPOINT: "/unverified/ignored.js", PI_TELEMETRY: "0", + } }); + let stderr = ""; child.stderr!.on("data", chunk => { stderr += String(chunk); }); + const pending = new Map(); + const decoder = new StringDecoder("utf8"); let buffer = ""; let id = 0; + child.stdout!.on("data", chunk => { + buffer += decoder.write(chunk); + for (;;) { + const at = buffer.indexOf("\n"); if (at < 0) break; + const line = buffer.slice(0, at); buffer = buffer.slice(at + 1); if (!line.trim()) continue; + const value = JSON.parse(line); const waiter = pending.get(value.id); + if (waiter) { pending.delete(value.id); if (value.error) waiter.reject(new Error(JSON.stringify(value.error))); else waiter.resolve(value.result); } + } + }); + child.once("exit", () => { for (const waiter of pending.values()) waiter.reject(new Error(`Pi exited: ${stderr}`)); pending.clear(); }); + const call = (method: string, params: unknown) => new Promise((resolve, reject) => { + const requestId = ++id; + const timer = setTimeout(() => reject(new Error(`Pi snapshot admission timed out: ${stderr}`)), 15_000); + pending.set(requestId, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } }); + child.stdin!.write(`${JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params })}\n`); + }); + try { + const initialized = await call("initialize", { protocolVersion: 1, clientCapabilities: { elicitation: { form: {} } } }); + expect(initialized.agentCapabilities._meta.paperclipPi.steering).toBe(true); + expect(initialized.authMethods).toEqual([]); + await expect(call("session/new", { cwd: workspace, mcpServers: [] })).rejects.toThrow("Bind the configured OpenRouter API credential"); + } finally { + child.stdin!.end(); + if (child.exitCode === null) { const timer = setTimeout(() => child.kill("SIGKILL"), 3000); await once(child, "close"); clearTimeout(timer); } + await command.close(); + } + }, 90_000); + + it("refuses symlinked target assets and metadata", async () => { + const { root, assets } = await fixture(); + await rm(assets, { recursive: true }); + await symlink(root, assets); + await expect(verifyPiInstallation(candidate())).rejects.toThrow("fixed asset directory"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts new file mode 100644 index 00000000000..c2e6fb768b5 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts @@ -0,0 +1,94 @@ +import { constants } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { verifyNativeAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js"; +import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "./pi-closure-pins.js"; +import { PI_NODE_VERSION } from "./pi-node-pins.js"; +import { verifyPiRuntimeManifest, type PiRuntimeManifest } from "./pi-verified-runtime.js"; +import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js"; +import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js"; + +const MAX_DISTRIBUTION_METADATA_BYTES = 4 * 1024 * 1024; +const PI_MODEL = "openrouter/deepseek/deepseek-v4-flash-0731"; +const FIXED_PATHS = Object.freeze({ + node: "node/bin/node", + piEntrypoint: "node_modules/@earendil-works/pi-coding-agent/dist/cli.js", + extension: "extensions/paperclip.js", + wrapperEntrypoint: "node_modules/pi-acp/dist/index.js", +}); +function record(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Pi distribution metadata is invalid"); + return value as 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 !== 2) throw new Error("Pi rich ACP requires profile version 2; 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"); +} + +async function readDistributionMetadata(path: string): Promise> { + const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = await file.stat({ bigint: true }); + if (!before.isFile() || before.nlink !== 1n || before.size < 1n || before.size > BigInt(MAX_DISTRIBUTION_METADATA_BYTES)) throw new Error("Pi distribution metadata is not a bounded private file"); + const bytes = await file.readFile(); + const after = await file.stat({ bigint: true }); + const named = await lstat(path, { bigint: true }); + if (bytes.length !== Number(before.size) || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || named.dev !== before.dev || named.ino !== before.ino || named.isSymbolicLink()) throw new Error("Pi distribution metadata changed while read"); + return record(JSON.parse(bytes.toString("utf8"))); + } finally { await file.close(); } +} + +/** + * Resolve only fixed runner package assets. The independently source-pinned + * native closure supplies the snapshot and provider-group lifetime guardian. + * Qualification status remains the caller's production admission gate. + */ +export async function verifyPiInstallation(profile: QualifiedAcpxProfile): Promise { + assertPiInstallationProfile(profile); + const target = `${process.platform}-${process.arch}`; + if (!Object.hasOwn(PI_DISTRIBUTION_CLOSURE_SHA256, target)) throw new Error("Pi distribution target is unsupported"); + const expectedClosure = PI_DISTRIBUTION_CLOSURE_SHA256[target as keyof typeof PI_DISTRIBUTION_CLOSURE_SHA256]; + const assets = join(resolveRunnerProviderAssetsRoot(import.meta.url, "pi"), target); + const runtimeRoot = join(assets, "runtime"); + const assertDirectories = async (): Promise => { + for (const path of [assets, runtimeRoot]) { + const stat = await lstat(path); + if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(path) !== path) throw new Error("Pi distribution escaped its fixed asset directory"); + } + }; + await assertDirectories(); + const metadataPath = join(assets, "pi-distribution.json"); + const metadata = await readDistributionMetadata(metadataPath); + const targetMetadata = record(metadata.target); + const pins = record(metadata.pins); + if (metadata.schema !== "paperclip.pi-distribution.v1" || metadata.runtimeRoot !== "runtime" || metadata.nativeClosureSha256 !== expectedClosure || targetMetadata.platform !== process.platform || targetMetadata.architecture !== process.arch || targetMetadata.nodeVersion !== PI_NODE_VERSION || pins.nodeVersion !== PI_NODE_VERSION || pins.wrapper !== "0.0.33" || pins.runtime !== "0.84.2" || pins.sdk !== "0.26.0" || pins.zod !== "3.25.76") throw new Error("Pi distribution metadata does not match its trusted target pin"); + const manifest = record(metadata.manifest) as unknown as PiRuntimeManifest; + for (const [key, value] of Object.entries(FIXED_PATHS)) { + if (manifest[key as keyof typeof FIXED_PATHS] !== value) throw new Error("Pi distribution changed a fixed launch path"); + } + const verified = await verifyPiRuntimeManifest(runtimeRoot, manifest); + if (verified.manifestDigest !== metadata.manifestDigest) throw new Error("Pi runtime manifest digest does not match its distribution"); + const native = await verifyNativeAcpxInstallation({ + distributionRoot: runtimeRoot, + manifestPath: join(assets, "native-closure.json"), + expectedClosureSha256: expectedClosure, + executable: FIXED_PATHS.node, + entrypoint: "pi-entry.cjs", + fixedArguments: [], + }); + return Object.freeze({ + commandDigest: profile.commandDigest, + agentServerPackageJsonPath: join(runtimeRoot, "node_modules/pi-acp/package.json"), + agentRuntimePackageJsonPath: join(runtimeRoot, "node_modules/@earendil-works/pi-coding-agent/package.json"), + async openCommand() { + await assertDirectories(); + // The native primitive reads every admitted file through held descriptors + // into a new immutable snapshot. The bootstrap derives its own launch + // environment from that snapshot, never these mutable installation paths. + return native.openCommand(); + }, + }); +} 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 a578a811052..f6243380e44 100644 --- a/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs +++ b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs @@ -83,6 +83,7 @@ async function fixture(t, extra = {}) { test("actual patched ACP process streams thinking and waits for settlement with usage", async (t) => { const f = await fixture(t); assert.equal(f.initialized.agentCapabilities._meta.paperclipPi.steering, true); + assert.deepEqual(f.initialized.authMethods, []); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); const result = await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }); assert.equal(result.stopReason, "end_turn"); assert.equal(result.usage.inputTokens, 11); assert.equal(result.usage.totalTokens, 16); diff --git a/patches/pi-acp@0.0.33.patch b/patches/pi-acp@0.0.33.patch index a91c777ffea..826cf5915cc 100644 --- a/patches/pi-acp@0.0.33.patch +++ b/patches/pi-acp@0.0.33.patch @@ -1,7 +1,15 @@ diff --git a/dist/index.js b/dist/index.js --- a/dist/index.js +++ b/dist/index.js -@@ -53,7 +53,7 @@ +@@ -11,6 +11,7 @@ + // src/acp/auth.ts + var PI_SETUP_METHOD_ID = "pi_terminal_login"; + function getAuthMethods(opts) { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return []; + const supportsTerminalAuthMeta = opts?.supportsTerminalAuthMeta ?? true; + const method = { + id: PI_SETUP_METHOD_ID, +@@ -53,7 +54,7 @@ // src/pi-rpc/process.ts import { spawn } from "child_process"; @@ -10,7 +18,7 @@ diff --git a/dist/index.js b/dist/index.js // src/pi-rpc/command.ts import { platform } from "os"; -@@ -96,49 +96,38 @@ +@@ -96,49 +97,38 @@ preludeLines = []; constructor(child) { this.child = child; @@ -83,7 +91,7 @@ diff --git a/dist/index.js b/dist/index.js }); try { await new Promise((resolve4, reject) => { -@@ -181,21 +170,30 @@ +@@ -181,21 +171,30 @@ const { dirname: dirname3 } = await import("path"); mkdirSync2(dirname3(sessionFile), { recursive: true }); } @@ -119,7 +127,7 @@ diff --git a/dist/index.js b/dist/index.js } } /** -@@ -288,12 +286,17 @@ +@@ -288,12 +287,17 @@ const withId = { ...cmd, id }; const line = `${JSON.stringify(withId)} `; @@ -142,7 +150,16 @@ diff --git a/dist/index.js b/dist/index.js }); } writeLine(line) { -@@ -714,6 +717,7 @@ +@@ -337,7 +341,7 @@ + { + authMethods: getAuthMethods() + }, +- "Configure an API key or log in with an OAuth provider." ++ "Bind the configured OpenRouter API credential to this Paperclip runtime." + ); + } + +@@ -714,6 +718,7 @@ try { proc = await PiRpcProcess.spawn({ cwd: params.cwd, @@ -150,7 +167,7 @@ diff --git a/dist/index.js b/dist/index.js piCommand: params.piCommand }); } catch (e) { -@@ -808,6 +812,9 @@ +@@ -808,6 +813,9 @@ this.proc = opts.proc; this.conn = opts.conn; this.fileCommands = opts.fileCommands ?? []; @@ -160,7 +177,7 @@ diff --git a/dist/index.js b/dist/index.js this.proc.onEvent((ev) => this.handlePiEvent(ev)); } setStartupInfo(text) { -@@ -852,6 +859,7 @@ +@@ -852,6 +860,7 @@ } async cancel() { this.cancelRequested = true; @@ -168,7 +185,7 @@ diff --git a/dist/index.js b/dist/index.js if (this.turnQueue.length) { const queued = this.turnQueue.splice(0, this.turnQueue.length); for (const t of queued) t.resolve("cancelled"); -@@ -920,12 +928,16 @@ +@@ -920,12 +929,16 @@ this.cancelRequested = false; this.inAgentLoop = false; this.pendingTurn = { resolve: t.resolve, reject: t.reject }; @@ -185,7 +202,7 @@ diff --git a/dist/index.js b/dist/index.js const authErr = maybeAuthRequiredError(err); if (authErr) { this.pendingTurn?.reject(authErr); -@@ -946,6 +958,23 @@ +@@ -946,6 +959,23 @@ handlePiEvent(ev) { const type = String(ev.type ?? ""); switch (type) { @@ -209,7 +226,7 @@ diff --git a/dist/index.js b/dist/index.js case "message_update": { const ame = ev.assistantMessageEvent; if (ame?.type === "text_delta" && typeof ame.delta === "string") { -@@ -1047,7 +1076,7 @@ +@@ -1047,7 +1077,7 @@ if (p) { try { const abs = isAbsolute(p) ? p : resolvePath(this.cwd, p); @@ -218,7 +235,7 @@ diff --git a/dist/index.js b/dist/index.js this.fileSnapshots.set(toolCallId, { path: p, oldText: snapshotOldText }); if (toolName === "edit") { for (const needle of getEditOldTexts(args)) { -@@ -1125,7 +1154,7 @@ +@@ -1125,7 +1155,7 @@ if (!isError && snapshot) { try { const abs = isAbsolute(snapshot.path) ? snapshot.path : resolvePath(this.cwd, snapshot.path); @@ -227,7 +244,7 @@ diff --git a/dist/index.js b/dist/index.js if (snapshot.oldText === null || newText !== snapshot.oldText) { hasStructuredDiff = true; content = [ -@@ -1154,14 +1183,11 @@ +@@ -1154,14 +1184,11 @@ break; } case "extension_ui_request": { @@ -246,7 +263,7 @@ diff --git a/dist/index.js b/dist/index.js break; } case "auto_retry_start": { -@@ -1210,9 +1236,12 @@ +@@ -1210,9 +1237,12 @@ break; } case "agent_settled": { @@ -260,7 +277,7 @@ diff --git a/dist/index.js b/dist/index.js this.pendingTurn = null; this.inAgentLoop = false; const next = this.turnQueue.shift(); -@@ -1725,6 +1754,7 @@ +@@ -1725,6 +1755,7 @@ return process.env.PI_CODING_AGENT_DIR ? resolve3(process.env.PI_CODING_AGENT_DIR) : join4(homedir4(), ".pi", "agent"); } function getEnableSkillCommands(cwd) { @@ -268,7 +285,7 @@ diff --git a/dist/index.js b/dist/index.js const merged = getMergedSettings(cwd); const direct = merged.enableSkillCommands; if (typeof direct === "boolean") return direct; -@@ -1733,6 +1763,7 @@ +@@ -1733,6 +1764,7 @@ return true; } function getQuietStartup(cwd) { @@ -276,7 +293,15 @@ diff --git a/dist/index.js b/dist/index.js const merged = getMergedSettings(cwd); const direct = merged.quietStartup; if (typeof direct === "boolean") return direct; -@@ -1884,11 +1915,13 @@ +@@ -1824,6 +1856,7 @@ + const out = []; + const seen = /* @__PURE__ */ new Set(); + for (const c of [...a, ...b]) { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && !["compact", "session", "autocompact"].includes(c.name)) continue; + if (seen.has(c.name)) continue; + seen.add(c.name); + out.push(c); +@@ -1884,11 +1917,13 @@ throw RequestError3.invalidParams(`Unknown sessionId: ${sessionId}`); } const cwd = opts?.cwd ?? stored.cwd; @@ -290,7 +315,7 @@ diff --git a/dist/index.js b/dist/index.js piCommand: process.env.PI_ACP_PI_COMMAND }); } catch (e) { -@@ -1897,7 +1930,7 @@ +@@ -1897,7 +1932,7 @@ } throw e; } @@ -299,7 +324,7 @@ diff --git a/dist/index.js b/dist/index.js const session = this.sessions.getOrCreate(sessionId, { cwd, mcpServers: opts?.mcpServers ?? [], -@@ -1917,6 +1950,7 @@ +@@ -1917,6 +1952,7 @@ } } async initialize(params) { @@ -307,7 +332,7 @@ diff --git a/dist/index.js b/dist/index.js const supportedVersion = 1; const requested = params.protocolVersion; return { -@@ -1933,7 +1967,8 @@ +@@ -1933,7 +1969,8 @@ }), agentCapabilities: { loadSession: true, @@ -317,7 +342,7 @@ diff --git a/dist/index.js b/dist/index.js promptCapabilities: { image: true, audio: false, -@@ -1942,8 +1977,7 @@ +@@ -1942,8 +1979,7 @@ sessionCapabilities: { // **UNSTABLE** ACP capability used by Zed's codex-acp adapter. // Enables a native session picker in clients that support it. @@ -327,7 +352,7 @@ diff --git a/dist/index.js b/dist/index.js } } }; -@@ -1953,7 +1987,7 @@ +@@ -1953,7 +1989,7 @@ throw RequestError3.invalidParams(`cwd must be an absolute path: ${params.cwd}`); } this.lastSessionCwd = params.cwd; @@ -336,7 +361,24 @@ diff --git a/dist/index.js b/dist/index.js const enableSkillCommands = getEnableSkillCommands(params.cwd); const session = await this.sessions.create({ cwd: params.cwd, -@@ -2066,6 +2100,8 @@ +@@ -1994,14 +2030,14 @@ + this.cleanupFailedNewSession(session.sessionId, state); + throw RequestError3.authRequired( + { authMethods: getAuthMethods() }, +- "Configure an API key or log in with an OAuth provider." ++ "Bind the configured OpenRouter API credential to this Paperclip runtime." + ); + } + if (stateErr && maybeAuthRequiredError(stateErr)) { + this.cleanupFailedNewSession(session.sessionId, state); + throw RequestError3.authRequired( + { authMethods: getAuthMethods() }, +- "Configure an API key or log in with an OAuth provider." ++ "Bind the configured OpenRouter API credential to this Paperclip runtime." + ); + } + const { configOptions, models, modes } = await getSessionConfiguration(session.proc, { +@@ -2066,6 +2102,8 @@ const session = await this.restoreSession(params.sessionId); const { message, images } = promptToPiMessage(params.prompt); if (images.length === 0 && message.trimStart().startsWith("/")) { @@ -345,7 +387,7 @@ diff --git a/dist/index.js b/dist/index.js const trimmed = message.trim(); const space = trimmed.indexOf(" "); const cmd = space === -1 ? trimmed.slice(1) : trimmed.slice(1, space); -@@ -2432,8 +2468,18 @@ +@@ -2432,8 +2470,18 @@ } } const result = await session.prompt(message, images); @@ -366,7 +408,7 @@ diff --git a/dist/index.js b/dist/index.js } async cancel(params) { const session = this.sessions.maybeGet(params.sessionId); -@@ -2473,7 +2519,7 @@ +@@ -2473,7 +2521,7 @@ mcpServers: params.mcpServers }); const proc = session.proc; @@ -375,7 +417,7 @@ diff --git a/dist/index.js b/dist/index.js this.sessions.closeAllExcept?.(session.sessionId); this.store.upsert({ sessionId: params.sessionId, -@@ -2607,6 +2653,7 @@ +@@ -2607,6 +2655,7 @@ return response; } async deleteSession(params) { @@ -383,7 +425,7 @@ diff --git a/dist/index.js b/dist/index.js const stored = this.store.get(params.sessionId); const piSession = findPiSession(params.sessionId); if (!stored && !piSession) { -@@ -2828,6 +2875,7 @@ +@@ -2828,6 +2877,7 @@ return 0; } function buildUpdateNotice() { @@ -391,7 +433,7 @@ diff --git a/dist/index.js b/dist/index.js try { const piVersion = spawnSync("pi", ["--version"], { encoding: "utf-8" }); const installed = (String(piVersion.stdout ?? "").trim() || String(piVersion.stderr ?? "").trim()).replace( -@@ -2848,6 +2896,7 @@ +@@ -2848,6 +2898,7 @@ } } function buildStartupInfo(opts) { @@ -399,7 +441,7 @@ diff --git a/dist/index.js b/dist/index.js void opts.fileCommands; const md = []; try { -@@ -2980,6 +3029,7 @@ +@@ -2980,6 +3031,7 @@ // src/index.ts if (process.argv.includes("--terminal-login")) { @@ -407,7 +449,7 @@ diff --git a/dist/index.js b/dist/index.js const { spawnSync: spawnSync2 } = await import("child_process"); const cmd = getPiCommand(process.env.PI_ACP_PI_COMMAND); const res = spawnSync2(cmd, [], { -@@ -3019,11 +3069,12 @@ +@@ -3019,11 +3071,12 @@ } }); var stream = ndJsonStream(input, output);