diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index eb942af2a6a..fa1f8af3754 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -23,7 +23,7 @@ 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-owned loopback HTTP MCP catalogs register under exact `mcp____` names. Calls retain the native tool call ID and cancellation signal. Authenticated PRP tool handling owns semantic authorization and durable interactions. Ambient MCP and external MCP servers are not admitted. | +| 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. | | Plans and artifacts | Pi has no native structured plan or artifact channel. Paperclip plan and artifact semantic tools remain available through the MCP bridge; native file edits retain bounded, workspace-confined ACP diff projection. Tool text/image results are preserved, and resource blocks are recorded without following URLs. | | Steering | Capability-negotiated `pi/steer` issues native RPC `steer` during an active turn. `pi/follow_up` explicitly queues native RPC `follow_up`. Neither is inferred from a second ACP prompt. Each takes `{sessionId, message}` and returns `{accepted: true}`. | | Usage | Prompt results sum actual assistant message usage receipts across continuations. Input, output, cache reads/writes, total tokens and 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. | @@ -224,7 +224,7 @@ 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. -Profile version 2 declaration digest: `sha256:5ed73f923b24bf203cf39d6c5dedd5dfd21e94efcb2c2580b4adf53f1b91fd2f`. It hashes the versioned +Profile version 2 declaration digest: `sha256:c577a771778febe24b920388da77f6143971b8eec29e1ba9a433dac5b76de3cd`. It hashes the versioned profile domain, patched wrapper source and platform closure pins. Every native closure remains independently checked at launch. Version 1 warm sessions cannot be reused with this integration. 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 ec52956356e..724143fea40 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:5ed73f923b24bf203cf39d6c5dedd5dfd21e94efcb2c2580b4adf53f1b91fd2f", + "sha256:c577a771778febe24b920388da77f6143971b8eec29e1ba9a433dac5b76de3cd", ), "cursor" => ( self.model.as_str(), @@ -2461,7 +2461,7 @@ mod tests { "pi", "pi-acp", "0.0.33", - "sha256:5ed73f923b24bf203cf39d6c5dedd5dfd21e94efcb2c2580b4adf53f1b91fd2f", + "sha256:c577a771778febe24b920388da77f6143971b8eec29e1ba9a433dac5b76de3cd", Some("@earendil-works/pi-coding-agent"), Some("0.84.2"), "openrouter/deepseek/deepseek-v4-flash-0731", 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 5fb2ee3e35c..afa503deb8d 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": "c79fa33fd134e860fd777cd467873438b17b80b0b462a89e95cc63fbaa46bada", - "darwin-x64": "73624d1f59d55f7654bbe014ca30b55f5d0b3fbb3343467e71105414743a5c1c", - "linux-x64": "2f8ae83b42b12e6d4a99e347c245c274f67a2b180ab411441e8fe747fa84d72d", + "darwin-arm64": "a26e98fe3f4d3d93c13beffdb4e8b778d438dd1b3bca8a9b1610154342382681", + "darwin-x64": "9423812469c8aedba369c581b23a4302e4d86aac06e98e3d14d95d5e875597f0", + "linux-x64": "892085f1f22c0813e4d722c37ddaa343a01aecfeff360d8ca6d5b201dd3f01f5", }); 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 6497c9e6660..63d81cea6b2 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 @@ -27,15 +27,48 @@ function harness() { } describe("owned Pi runtime extension", () => { - it("requires explicit isolated configuration and loopback authentication", () => { + 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 parse = () => readPiRuntimeConfiguration({ PAPERCLIP_PI_RUNTIME_CONFIGURATION: JSON.stringify(value) }); expect(parse().servers).toHaveLength(1); - value.servers[0]!.url = "https://127.0.0.1.evil.test/mcp"; - expect(parse).toThrow("loopback"); - value.servers[0]!.url = "http://user:password@127.0.0.1/mcp"; - expect(parse).toThrow("loopback"); + 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"]) { + value.servers[0]!.url = url; + expect(parse).toThrow("requires HTTPS or numeric loopback HTTP"); + } + value.servers[0]!.url = "https://paperclip.example/mcp/gateway"; + value.servers[0]!.headers = []; + expect(parse).toThrow("authentication"); + }); + + it("initializes and executes only the exact assigned HTTPS gateway with its bound credential", async () => { + const { config } = await workspace(); const h = harness(); + const endpoint = "https://paperclip.example/api/mcp/gateway/assigned"; + const authorization = "Bearer 1234567890123456"; + const assigned = readPiRuntimeConfiguration({ PAPERCLIP_PI_RUNTIME_CONFIGURATION: JSON.stringify({ + ...config, servers: [{ type: "http", name: "paperclip-assigned", url: endpoint, headers: [{ name: "Authorization", value: authorization }] }], + }) }); + const fetch_ = vi.fn(async (_url, init) => { + const body = JSON.parse(String(init!.body)); + const result = body.method === "tools/list" + ? { tools: [{ name: "report_progress", description: "Report progress", inputSchema: { type: "object", properties: {} } }] } + : 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_)); + expect(h.tools).toHaveLength(1); + expect(h.tools[0]!.name).toBe("mcp__paperclip-assigned__report_progress"); + const signal = new AbortController().signal; + await expect(h.tools[0]!.execute("assigned-call-1", { text: "done" }, signal)).resolves.toMatchObject({ content: [{ text: "recorded" }] }); + expect(fetch_.mock.calls.map(([, init]) => JSON.parse(String(init!.body)).method)).toEqual(["initialize", "tools/list", "tools/call"]); + for (const [url, init] of fetch_.mock.calls) { + 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" } } }); + 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); }); it("denies escapes and read-only mutations before requesting provider permission", async () => { 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 2a1e0754f54..6a7a6b667d8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts @@ -79,8 +79,11 @@ export function readPiRuntimeConfiguration(environment: NodeJS.ProcessEnv): PiRu names.add(server.name); if (typeof server.url !== "string") throw new Error("Pi MCP endpoint is invalid"); const url = new URL(server.url); - if (!['http:', 'https:'].includes(url.protocol) || !['127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password || url.hash) { - throw new Error("Pi MCP endpoint must be a runner-owned loopback endpoint"); + // Only session-bound gateways reach this configuration. Match the host's + // assigned transport policy: HTTPS remotely, cleartext only on loopback. + const cleartextLoopback = url.protocol === "http:" && ["127.0.0.1", "[::1]"].includes(url.hostname); + if ((url.protocol !== "https:" && !cleartextLoopback) || url.username || url.password || url.hash) { + throw new Error("Pi assigned MCP endpoint requires HTTPS or numeric loopback HTTP"); } if (!Array.isArray(server.headers) || server.headers.length !== 1) throw new Error("Pi MCP authentication is invalid"); const header = asRecord(server.headers[0]); diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 34513ff91ae..2d3975cb358 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -54,7 +54,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< agentRuntimePackage: "@earendil-works/pi-coding-agent", agentRuntimeVersion: "0.84.2", commandDigest: - "sha256:5ed73f923b24bf203cf39d6c5dedd5dfd21e94efcb2c2580b4adf53f1b91fd2f", + "sha256:c577a771778febe24b920388da77f6143971b8eec29e1ba9a433dac5b76de3cd", qualificationModel: "openrouter/deepseek/deepseek-v4-flash-0731", reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731", permissionPolicy: "interactive",