diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index ba88f49cabb..34a80759661 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -1,6 +1,9 @@ # Pi rich ACP runtime -Status: implementation candidate, 2026-09-28. Profile version 5 repairs the +Status: implementation candidate, 2026-09-29. Profile version 6 adds the native +question tool, the authenticated agent-files root, and matching SDK path +normalization. Its authenticated Product and Runner qualification is pending. +Historical profile version 5 repairs the Undici dependency and bundled Node runtime affected by GHSA-3wwx-pv8p-q78v. Full authenticated v5 qualification is pending; its bounded native controls probe passes. Historical version 4 repairs native tool ID reuse across model iterations and warm prompts; its native steering, @@ -29,7 +32,8 @@ boundaries explicit. | Text and reasoning | Upstream streams text and thought chunks. The common runner's private reasoning policy still applies; provider support does not authorize retention or display. | | Native tools | `read`, `grep`, `find`, `ls`, `write`, `edit`, and `bash` pass the immutable extension gate before execution. File roots and read-only mode are checked before and after a permission wait. The host sandbox remains authoritative for shell commands and races. | | Permissions | Native tool approval uses ACP `session/request_permission`, with allow once, allow for the session, and deny. Only offered options are accepted. 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. | +| Questions | The owned `paperclip_native_question` tool exposes Pi `select`, `confirm`, `input`, and `editor` through ACP form elicitation. Select is single-choice with stable option IDs. Pi returns `false` for both No and dismissal of confirm, so the tool reports `negative_or_cancelled`; it never invents a confirmed answer. Other methods return explicit cancellation. Permissions and semantic Plan approval remain separate. | +| Agent files | Only the authenticated execution's registered `agent_files` working copy can become `AGENT_HOME`. The Pi wrapper takes `PAPERCLIP_PI_AGENT_HOME` from the runner's bound launch, ignores ambient `AGENT_HOME`, and admits that canonical directory alongside the task workspace. Protected overlap, directory replacement and symlink escape fail closed. Read-only policy still forbids writes. `PI_CODING_AGENT_DIR` remains private provider state. | | 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. | @@ -82,10 +86,10 @@ 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 current v5 command digest is +The historical v5 command digest is `sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803`. Versions 1–4 cannot reopen under this identity. Paid and image proofs below remain -evidence of their recorded versions, not v5 qualification. The historical v4 +evidence of their recorded versions, not v6 qualification. The historical v4 digest is `sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991`. ## Lifetime and recovery @@ -102,6 +106,32 @@ The wrapper attaches a per-turn generation to settlement work, fails queued turns on provider death, and bounds RPC request waits. This does not constitute a claim of generic provider-process handoff or exactly-once external side effects. +## Version 6 native boundaries + +The native file gate now applies Pi 0.84.2's path interpretation before checking +roots: `@` prefix removal, home expansion, file URLs and Unicode spaces. Native +read also tries macOS screenshot spacing, decomposed Unicode and curly-quote +filenames. Every possible fallback is checked, including its physical symlink +target, both before and after approval. The prior raw-path check could authorize +a workspace-relative spelling that the SDK subsequently interpreted outside the +workspace. The actual pinned SDK regression verifies the interpreted path and +rejects aliases to private state. + +The native question tool accepts `{method,title}` plus the method's own fields: +`options: [{id,label}]` for select, `message` for confirm, `placeholder` for input, +and `prefill` for editor. There are at most 128 unique IDs and unique labels; +titles, input fields and answers have UTF-8 bounds. Unsupported fields, unoffered +answers and the reserved permission-title prefix are rejected. Exact retries of +one native occurrence reuse its pending or completed result. This tool cannot +approve a native operation or a Paperclip Plan. Durable semantic questions and +revision-bound Plan approval continue to use assigned Paperclip tools. + +Credential-free tests exercise all four methods through the actual pinned Pi +AgentSession tool dispatcher and separately through the installed ACP wrapper's +form transport. They do not establish browser persistence, reconnect delivery, +provider-death expiry, or live model behavior. Those remain distinct Product +qualification requirements for version 6. + ## Verified launch and packaging contract The wrapper rejects ambient launch unless `PAPERCLIP_ACPX_ISOLATED_CONTEXT=1`. @@ -321,7 +351,7 @@ that those targets have executed successfully. ### Installation authority and token semantics -`verifyPiInstallation(profile)` admits only profile version 4 and the source-owned +`verifyPiInstallation(profile)` admits only profile version 6 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, @@ -344,9 +374,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. -Historical profile version 3 declaration digest: `sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472`. The current v5 digest above hashes its versioned +Historical profile version 3 declaration digest: `sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472`. Each profile digest hashes its versioned profile domain, patched wrapper source and platform closure pins. Every native -closure remains independently checked at launch. Version 1, 2 and 3 warm sessions cannot +closure remains independently checked at launch. Version 1 through 5 warm sessions cannot be reused with this integration. Pi 0.84.2 emits `compaction_start`/`compaction_end`; the wrapper maps those diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs index 82873e8b13b..cbdd9303fbb 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs @@ -20,7 +20,7 @@ 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, undici: "8.10.2", nodeBundledUndici: "7.29.1", wrapperSha256: "dc4786faff30942e82106c87a8984a75fb979a925ff1d62648500a642b920cac", - helperSha256: "9e50b60644d0d00b3fb2660eb78ad616dd01395775bcb05974d2bdeadc7f7e57", + helperSha256: "2df24aee67ca6a5cc813e1f87324f51a72f6a104757daed97a09cbbcb63626d4", }); const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); 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 2839cc274e3..082fb35ec07 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 @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, realpath, 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"; @@ -155,6 +155,14 @@ describe("Pi ACP bridge", () => { const launch = createPiLaunchSpec({ cwd: root, mcpServers: [] }, environment); expect(launch.command).toBe(join(root, "node")); expect(launch.args).toEqual(expect.arrayContaining(["--no-extensions", "--no-approve", "--no-skills", "--offline"])); + const ambient = createPiLaunchSpec({ cwd: root }, { ...environment, AGENT_HOME: "/ambient/unregistered" }); + expect(ambient.env.AGENT_HOME).toBeUndefined(); + expect(JSON.parse(ambient.env.PAPERCLIP_PI_RUNTIME_CONFIGURATION!).agentHome).toBeUndefined(); + const agentHome = await realpath(root); + const bound = createPiLaunchSpec({ cwd: root }, { ...environment, AGENT_HOME: "/ambient/unregistered", PAPERCLIP_PI_AGENT_HOME: agentHome }); + expect(bound.env.AGENT_HOME).toBe(agentHome); + expect(JSON.parse(bound.env.PAPERCLIP_PI_RUNTIME_CONFIGURATION!).agentHome).toBe(agentHome); + for (const path of ["/", "relative", join(root, "cli.js")]) expect(() => createPiLaunchSpec({ cwd: root }, { ...environment, PAPERCLIP_PI_AGENT_HOME: path })).toThrow("registered directory"); expect(() => createPiLaunchSpec({ cwd: root }, { ...environment, PAPERCLIP_PI_NODE_EXECUTABLE: "pi" })).toThrow("binding"); await symlink(join(root, "cli.js"), join(root, "sessions", "escape.jsonl")); expect(() => createPiLaunchSpec({ cwd: root, sessionPath: join(root, "sessions", "escape.jsonl") }, environment)).toThrow("escaped"); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts index 2eea39ebd72..3431b40c961 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts @@ -192,11 +192,18 @@ 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 suppliedAgentHome = environment.PAPERCLIP_PI_AGENT_HOME; + let agentHome: string | undefined; + if (suppliedAgentHome !== undefined) { + if (!isAbsolute(suppliedAgentHome) || /[\0\r\n]/.test(suppliedAgentHome) || suppliedAgentHome === "/" || !lstatSync(suppliedAgentHome).isDirectory() || realpathSync(suppliedAgentHome) !== suppliedAgentHome) throw new Error("Pi agent files require a canonical registered directory"); + agentHome = suppliedAgentHome; + } const invocationNamespace = randomUUID(); const configuration = JSON.stringify({ invocationNamespace, workspace, servers: params.mcpServers ?? [], readOnly: environment.PAPERCLIP_PI_READ_ONLY === "1", readRoots, protectedRoots, + ...(agentHome ? { agentHome } : {}), instructions: environment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS ?? "", }); if (Buffer.byteLength(configuration) > 64 * 1024) throw new Error("Pi launch configuration exceeds its bound"); @@ -213,7 +220,11 @@ 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, invocationNamespace, env: { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration } }; + const env: NodeJS.ProcessEnv = { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration }; + // Ambient AGENT_HOME never grants filesystem authority. Only the runner's + // authenticated working-copy binding reaches both the model and tool gate. + if (agentHome) env.AGENT_HOME = agentHome; else delete env.AGENT_HOME; + return { command, args, invocationNamespace, env }; } interface UiConnection { 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 12849bf512d..d9aa850f131 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: 5 }); +const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 6 }); 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.v5\0") + const digest = createHash("sha256").update("paperclip.pi.rich-acp.profile.v6\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,10 +32,11 @@ describe("Pi installation factory", () => { it("rejects legacy profiles and caller-selected identities", () => { expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 5"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 5"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 5"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 5"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 6"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 6"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 6"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 6"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 6"); 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 a9baabd8c7d..dcd33907707 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 !== 5) throw new Error("Pi rich ACP requires profile version 5; reopen the previous session"); + if (profile.agentProfileVersion !== 6) throw new Error("Pi rich ACP requires profile version 6; 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 d5d31715360..90ba3e49fc4 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,12 +1,12 @@ import { startRunnerToolBridge } from "../runner-tool-bridge.js"; import { createServer } from "node:http"; -import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, realpath, rename, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { checkPiNativeTool, installPiRuntimeExtension, piMcpRequest, readPiRuntimeConfiguration, - PI_PERMISSION_TITLE_PREFIX, type PiExtensionApi, type PiRuntimeConfiguration, type PiToolDefinition, + PI_NATIVE_QUESTION_TOOL, PI_PERMISSION_TITLE_PREFIX, type PiExtensionApi, type PiRuntimeConfiguration, type PiToolDefinition, } from "./pi-runtime-extension.js"; const directories: string[] = []; @@ -24,11 +24,85 @@ async function workspace() { function harness() { const handlers = new Map any>(); const tools: PiToolDefinition[] = []; - const api = { on: (event: string, handler: (...args: any[]) => any) => handlers.set(event, handler), registerTool: (tool: PiToolDefinition) => tools.push(tool), registerCommand: vi.fn() } as unknown as PiExtensionApi; - return { api, handlers, tools }; + const nativeTools: PiToolDefinition[] = []; + const api = { on: (event: string, handler: (...args: any[]) => any) => handlers.set(event, handler), registerTool: (tool: PiToolDefinition) => (tool.name === PI_NATIVE_QUESTION_TOOL ? nativeTools : tools).push(tool), registerCommand: vi.fn() } as unknown as PiExtensionApi; + return { api, handlers, tools, nativeTools }; } describe("owned Pi runtime extension", () => { + it("admits only the canonical registered agent-files root and revalidates it after approval", async () => { + const { config, root } = await workspace(); const h = harness(); + const agentHome = join(await realpath(root), "agent-files"); await mkdir(agentHome); + const assigned = { ...config, agentHome }; + await installPiRuntimeExtension(h.api, assigned); h.handlers.get("turn_start")!(); + const select = vi.fn().mockResolvedValue("Allow once"); const context = { cwd: config.workspace, ui: { select } }; + const event = { toolName: "write", toolCallId: "memory", input: { path: join(agentHome, "MEMORY.md") } }; + expect(await h.handlers.get("tool_call")!(event, context)).toBeUndefined(); + expect(await checkPiNativeTool(event, context, config)).toMatch("outside"); + assigned.readOnly = true; + expect(await checkPiNativeTool(event, context, assigned)).toMatch("reading only"); + expect(await checkPiNativeTool({ ...event, toolName: "read" }, context, assigned)).toBeNull(); + assigned.readOnly = false; + await symlink(join(root, "private"), join(agentHome, "escape")); + expect(await checkPiNativeTool({ ...event, input: { path: join(agentHome, "escape/secret") } }, context, assigned)).toMatch("protected"); + select.mockImplementation(async () => { + await rename(agentHome, `${agentHome}-old`); await symlink(join(root, "private"), agentHome); return "Allow once"; + }); + expect(await h.handlers.get("tool_call")!({ ...event, toolCallId: "late-memory" }, context)).toMatchObject({ block: true }); + await expect(installPiRuntimeExtension(harness().api, assigned)).rejects.toThrow("canonical"); + await expect(installPiRuntimeExtension(harness().api, { ...config, agentHome: await realpath(join(root, "private")) })).rejects.toThrow("overlap"); + }); + + it("pins the agent-files directory identity across same-path replacement", async () => { + const { config, root } = await workspace(); const h = harness(); + const agentHome = join(await realpath(root), "agent-files"); await mkdir(agentHome); + await installPiRuntimeExtension(h.api, { ...config, agentHome }); h.handlers.get("turn_start")!(); + const select = vi.fn().mockImplementation(async () => { + await rename(agentHome, `${agentHome}-old`); await mkdir(agentHome); return "Allow once"; + }); + expect(await h.handlers.get("tool_call")!({ toolName: "write", toolCallId: "memory", input: { path: join(agentHome, "MEMORY.md") } }, { cwd: config.workspace, ui: { select } })).toMatchObject({ block: true, reason: "Pi agent files were replaced after admission" }); + }); + + it.each([ + ["select", { options: [{ id: "blue", label: "Blue" }, { id: "red", label: "Red" }] }, "Blue", { status: "answered", optionId: "blue" }], + ["confirm", { message: "Continue?" }, true, { status: "answered", confirmed: true }], + ["confirm", { message: "Continue?" }, false, { status: "negative_or_cancelled", confirmed: false }], + ["input", { placeholder: "Name" }, "Ada", { status: "answered", value: "Ada" }], + ["editor", { prefill: "Old\ntext" }, "New\ntext", { status: "answered", value: "New\ntext" }], + ["input", {}, undefined, { status: "cancelled" }], + ])("exposes native %s questions with bounded typed answers and exact retry identity", async (method, extra, answer, expected) => { + const { config } = await workspace(); const h = harness(); + await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!(); + const ui = { select: vi.fn(), confirm: vi.fn(), input: vi.fn(), editor: vi.fn() }; + ui[method as keyof typeof ui].mockResolvedValue(answer); + const context = { cwd: config.workspace, ui }; const args = { method, title: "Question", ...extra }; + const tool = h.nativeTools[0]!; + expect(await h.handlers.get("tool_call")!({ toolName: tool.name, toolCallId: "question", input: args }, context)).toBeUndefined(); + const result = await tool.execute("question", args, undefined, undefined, context); + expect(result.details).toEqual(expected); + expect(await tool.execute("question", args, undefined, undefined, context)).toEqual(result); + expect(ui[method as keyof typeof ui]).toHaveBeenCalledTimes(1); + if (method !== "select") expect(ui.select).not.toHaveBeenCalled(); + }); + + it("rejects malformed native questions and never routes them as permissions", async () => { + const { config } = await workspace(); const h = harness(); + await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!(); + const ui = { select: vi.fn(), confirm: vi.fn(), input: vi.fn(), editor: vi.fn() }; + const context = { cwd: config.workspace, ui }; + const invalid = [ + { method: "input", title: `${PI_PERMISSION_TITLE_PREFIX}{}` }, + { method: "select", title: "Question", options: [{ id: "a", label: "Same" }, { id: "b", label: "Same" }] }, + { method: "select", title: "Question", options: [{ id: "a", label: "One" }, { id: "a", label: "Two" }] }, + { method: "input", title: "Question", command: "touch forbidden" }, + { method: "editor", title: "Question", prefill: "x".repeat(16385) }, + { method: "multi-select", title: "Question" }, + ]; + for (const [index, args] of invalid.entries()) await expect(h.nativeTools[0]!.execute(`invalid-${index}`, args, undefined, undefined, context)).rejects.toThrow(); + expect(Object.values(ui).every(fn => fn.mock.calls.length === 0)).toBe(true); + const abort = new AbortController(); abort.abort(); + expect((await h.nativeTools[0]!.execute("cancelled", { method: "input", title: "Question" }, abort.signal, undefined, context)).details).toEqual({ status: "cancelled" }); + }); it("requires explicit assigned configuration, authenticated HTTPS or numeric loopback HTTP", () => { expect(() => readPiRuntimeConfiguration({})).toThrow("missing"); 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" }] }] }; @@ -100,6 +174,49 @@ describe("owned Pi runtime extension", () => { expect(await h.handlers.get("tool_call")!({ toolName: "bash", toolCallId: "b", input: { command: "pwd" } }, context)).toMatchObject({ block: true }); }); + it("checks Pi path expansion against workspace and protected roots before approval", async () => { + const { config, root } = await workspace(); const h = harness(); + await mkdir(join(config.workspace, "protected")); + config.protectedRoots.push(join(config.workspace, "protected")); + await mkdir(join(config.workspace, "space name")); + await symlink(join(root, "private"), join(config.workspace, "space name", "escape")); + 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 paths = [ + `@${root}/private/secret`, `file://${root}/private/secret`, "~/secret", "~", "@~/secret", + `@${config.workspace}/protected/secret`, `file://${config.workspace}/protected/secret`, + "space\u00a0name/escape/secret", "space\u202fname/escape/secret", + "file:///tmp/invalid%00name", + ]; + for (const [index, path] of paths.entries()) { + expect(await h.handlers.get("tool_call")!({ toolName: "write", toolCallId: `expanded-${index}`, input: { path } }, context), path).toMatchObject({ block: true }); + } + expect(select).not.toHaveBeenCalled(); + for (const [index, path] of [`@${config.workspace}/safe`, `file://${config.workspace}/safe`, "space\u00a0name/safe"].entries()) { + expect(await h.handlers.get("tool_call")!({ toolName: "write", toolCallId: `safe-${index}`, input: { path } }, context)).toBeUndefined(); + } + expect(select).toHaveBeenCalledTimes(3); + }); + + it("rejects read fallback aliases to private state before and after a permission wait", async () => { + const { config, root } = await workspace(); const h = harness(); + const aliases = [["capture 1 PM.png", "capture 1\u202fPM.png"], ["caf\u00e9", "cafe\u0301"], ["a'b", "a\u2019b"], ["caf\u00e9'b", "cafe\u0301\u2019b"]]; + for (const [, alternate] of aliases) await symlink(join(root, "private"), join(config.workspace, alternate!)); + await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!(); + const select = vi.fn().mockResolvedValue("Allow once"); + const context = { cwd: config.workspace, ui: { select } }; + for (const [index, [path]] of aliases.entries()) { + expect(await h.handlers.get("tool_call")!({ toolName: "read", toolCallId: `alternate-${index}`, input: { path } }, context)).toMatchObject({ block: true }); + } + expect(select).not.toHaveBeenCalled(); + select.mockImplementation(async () => { + await symlink(join(root, "private"), join(config.workspace, "late\u2019alias")); + return "Allow once"; + }); + expect(await h.handlers.get("tool_call")!({ toolName: "read", toolCallId: "late", input: { path: "late'alias" } }, context)).toMatchObject({ block: true }); + }); + it("makes assigned skills readable but never writable", async () => { const { config } = await workspace(); await writeFile(join(config.readRoots[0]!, "SKILL.md"), "assigned"); 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 f9a5c29b79e..6c78f9d4d4d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts @@ -4,11 +4,14 @@ * 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"; +import { lstat, realpath } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; export const PI_PERMISSION_TITLE_PREFIX = "paperclip.pi.permission.v1:"; export const PI_PERMISSION_OPTIONS = ["Allow once", "Allow for this session", "Deny"] as const; +export const PI_NATIVE_QUESTION_TOOL = "paperclip_native_question"; const MAX_CONFIGURATION_BYTES = 64 * 1024; const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; const MAX_TOOLS = 512; @@ -16,7 +19,10 @@ const MAX_TOOLS = 512; const MAX_ERROR_BYTES = 8_192; interface PiUi { - select(title: string, options: string[]): Promise; + select(title: string, options: string[], options_?: { signal?: AbortSignal }): Promise; + confirm?(title: string, message: string, options?: { signal?: AbortSignal }): Promise; + input?(title: string, placeholder?: string, options?: { signal?: AbortSignal }): Promise; + editor?(title: string, prefill?: string): Promise; } export interface PiExtensionContext { cwd: string; ui: PiUi } @@ -35,6 +41,8 @@ export interface PiToolDefinition { toolCallId: string, arguments_: Record, signal?: AbortSignal, + onUpdate?: unknown, + context?: PiExtensionContext, ): Promise<{ content: Array>; details?: unknown }>; } @@ -61,6 +69,8 @@ export interface PiRuntimeConfiguration { invocationNamespace: string; servers: PiMcpServer[]; workspace: string; + /** Canonical server-registered agent_files working copy, never ambient HOME. */ + agentHome?: string; readOnly: boolean; readRoots: string[]; protectedRoots: string[]; @@ -102,6 +112,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 { invocationNamespace, servers, workspace, readOnly: value.readOnly, + ...(value.agentHome === undefined ? {} : { agentHome: absolutePath(value.agentHome, "agent home") }), readRoots: pathList(value.readRoots, "read roots"), protectedRoots: pathList(value.protectedRoots, "protected roots"), instructions: value.instructions, @@ -137,10 +148,28 @@ async function physicalPath(path: string): Promise { } } +/** Match the pinned Pi 0.84.2 tools/path-utils.js before authorizing a path. + * Read may select any of its filename fallbacks; validate all of them so an + * alternate spelling cannot select an unchecked symlink after approval. + */ +export function piNativeToolPaths(value: string, workspace: string, read: boolean): string[] { + let normalized = value.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " "); + if (normalized.startsWith("@")) normalized = normalized.slice(1); + if (normalized === "~") normalized = homedir(); + else if (normalized.startsWith("~/")) normalized = join(homedir(), normalized.slice(2)); + else if (normalized.startsWith("file://")) normalized = fileURLToPath(normalized); + if (/[\0\r\n]/.test(normalized)) throw new Error("Pi tool path is invalid"); + const path = resolve(workspace, normalized); + if (!read) return [path]; + const nfd = path.normalize("NFD"); + return [...new Set([path, path.replace(/ (AM|PM)\./gi, "\u202F$1."), nfd, path.replace(/'/g, "\u2019"), nfd.replace(/'/g, "\u2019")])]; +} + export async function checkPiNativeTool( event: PiToolEvent, context: PiExtensionContext, config: PiRuntimeConfiguration, ): Promise { if (await realpath(context.cwd) !== await realpath(config.workspace)) return "Pi workspace changed after admission"; + if (config.agentHome && await realpath(config.agentHome) !== config.agentHome) return "Pi agent files changed after admission"; const readTools = new Set(["read", "grep", "find", "ls"]); const fileTools = new Set([...readTools, "edit", "write"]); if (!fileTools.has(event.toolName) && event.toolName !== "bash") return "Unregistered Pi tool is not admitted"; @@ -150,21 +179,66 @@ export async function checkPiNativeTool( if (event.toolName === "bash") return null; const pathValue = event.input.path ?? (readTools.has(event.toolName) ? "." : undefined); if (typeof pathValue !== "string" || /[\0\r\n]/.test(pathValue)) return "Pi tool path is invalid"; - const logical = resolve(config.workspace, pathValue); - const target = await physicalPath(logical); - if (!readTools.has(event.toolName)) { - for (const root of config.readRoots) { - if (inside(root, logical) || inside(await realpath(root), target)) return "Assigned Pi skills are read-only"; + for (const logical of piNativeToolPaths(pathValue, config.workspace, event.toolName === "read")) { + const target = await physicalPath(logical); + if (!readTools.has(event.toolName)) { + for (const root of config.readRoots) { + if (inside(root, logical) || inside(await realpath(root), target)) return "Assigned Pi skills are read-only"; + } } + for (const root of config.protectedRoots) { + if (inside(root, logical) || inside(await physicalPath(root), target)) return "Pi tool targets protected runtime state"; + } + const writableRoots = [config.workspace, ...(config.agentHome ? [config.agentHome] : [])]; + const roots = readTools.has(event.toolName) ? [...writableRoots, ...config.readRoots] : writableRoots; + let admitted = false; + for (const root of roots) { + if (inside(root, logical) && inside(await realpath(root), target)) { admitted = true; break; } + } + if (!admitted) return "Pi tool path is outside its assigned workspace and agent files"; } - for (const root of config.protectedRoots) { - if (inside(root, logical) || inside(await physicalPath(root), target)) return "Pi tool targets protected runtime state"; + return null; +} + +async function askPiNativeQuestion(args: Record, ui: PiUi, signal?: AbortSignal): Promise> { + const boundedText = (value: unknown, limit = 16_384): string => { + if (typeof value !== "string" || Buffer.byteLength(value) > limit) throw new Error("Pi question text is invalid or oversized"); + return value; + }; + const title = boundedText(args.title, 4096); + if (!title.trim() || title.startsWith(PI_PERMISSION_TITLE_PREFIX)) throw new Error("Pi question title is invalid"); + const method = args.method; + const permitted = ["method", "title", ...(method === "select" ? ["options"] : method === "confirm" ? ["message"] : method === "input" ? ["placeholder"] : method === "editor" ? ["prefill"] : [])]; + if (Object.keys(args).some(key => !permitted.includes(key))) throw new Error("Pi question has unsupported fields"); + if (signal?.aborted) return { status: "cancelled" }; + if (method === "select") { + if (!Array.isArray(args.options) || args.options.length < 1 || args.options.length > 128) throw new Error("Pi question options are invalid"); + const options = args.options.map(value => { + const option = asRecord(value); const id = boundedText(option.id, 128); const label = boundedText(option.label, 4096); + if (!/^[A-Za-z0-9_-]+$/.test(id) || !label.trim() || Object.keys(option).some(key => !["id", "label"].includes(key))) throw new Error("Pi question option is invalid"); + return { id, label }; + }); + if (new Set(options.map(option => option.id)).size !== options.length || new Set(options.map(option => option.label)).size !== options.length) throw new Error("Pi question options are ambiguous"); + const answer = await ui.select(title, options.map(option => option.label), { signal }); + if (signal?.aborted || answer === undefined) return { status: "cancelled" }; + const selected = options.find(option => option.label === answer); + if (!selected) throw new Error("Pi question returned an unoffered option"); + return { status: "answered", optionId: selected.id }; } - const roots = readTools.has(event.toolName) ? [config.workspace, ...config.readRoots] : [config.workspace]; - for (const root of roots) { - if (inside(root, logical) && inside(await realpath(root), target)) return null; + if (method === "confirm") { + if (!ui.confirm) throw new Error("Pi native confirmation is unavailable"); + const answer = await ui.confirm(title, boundedText(args.message), { signal }); + if (signal?.aborted) return { status: "cancelled" }; + if (typeof answer !== "boolean") throw new Error("Pi confirmation response is invalid"); + // Native Pi returns false for both No and dismissal. Never invent an answer. + return { status: answer ? "answered" : "negative_or_cancelled", confirmed: answer }; } - return "Pi tool path is outside its assigned workspace"; + if (method !== "input" && method !== "editor") throw new Error("Pi question method is unsupported"); + if (method === "input" && !ui.input || method === "editor" && !ui.editor) throw new Error("Pi native text question is unavailable"); + const value = method === "input" + ? await ui.input!(title, args.placeholder === undefined ? undefined : boundedText(args.placeholder), { signal }) + : await ui.editor!(title, args.prefill === undefined ? undefined : boundedText(args.prefill)); + return signal?.aborted || value === undefined ? { status: "cancelled" } : { status: "answered", value: boundedText(value, 65_536) }; } /** Keep authenticated tool validation useful without exposing transport credentials. */ @@ -241,6 +315,21 @@ export async function installPiRuntimeExtension( pi.on("turn_end", () => identities.end()); const bridgeTools = new Set(); const permissionGrants = new Set(); + const agentHomeIdentity = config.agentHome ? await lstat(config.agentHome, { bigint: true }) : undefined; + if (config.agentHome) { + if (!agentHomeIdentity!.isDirectory() || await realpath(config.agentHome) !== config.agentHome) throw new Error("Pi agent files are not a canonical registered directory"); + for (const root of config.protectedRoots) { + const physical = await physicalPath(root); + if (inside(config.agentHome, physical) || inside(physical, config.agentHome)) throw new Error("Pi agent files overlap protected runtime state"); + } + } + const checkPolicy = async (event: PiToolEvent, context: PiExtensionContext): Promise => { + if (config.agentHome && agentHomeIdentity) { + const named = await lstat(config.agentHome, { bigint: true }); + if (!named.isDirectory() || named.dev !== agentHomeIdentity.dev || named.ino !== agentHomeIdentity.ino) return "Pi agent files were replaced after admission"; + } + return checkPiNativeTool(event, context, config); + }; // Assigned skills are a separate immutable lease. A config or executable // directory must never gain readability by being presented as a skill root. for (const root of config.readRoots) { @@ -259,8 +348,8 @@ export async function installPiRuntimeExtension( pi.on("tool_call", async (event, context) => { 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 (bridgeTools.has(event.toolName) || event.toolName === PI_NATIVE_QUESTION_TOOL) return; + const denial = await checkPolicy(event, context); if (denial) return { block: true, reason: denial }; 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" }; @@ -271,11 +360,28 @@ export async function installPiRuntimeExtension( : await context.ui.select(`${PI_PERMISSION_TITLE_PREFIX}${detail}`, [...PI_PERMISSION_OPTIONS]); if (selection !== "Allow once" && selection !== "Allow for this session") return { block: true, reason: "Pi operation was denied or cancelled" }; // Re-check file bindings after a human wait; approval never freezes paths. - const changed = await checkPiNativeTool(event, context, config); + const changed = await checkPolicy(event, context); if (!changed && selection === "Allow for this session" && permissionGrants.size < 4096) permissionGrants.add(grantKey); return changed ? { block: true, reason: changed } : undefined; } catch { return { block: true, reason: "Pi permission boundary is unavailable" }; } }); + const questions = new Map>; details: unknown }>>(); + pi.registerTool({ + name: PI_NATIVE_QUESTION_TOOL, label: "Ask a native question", + description: "Ask the human a native Pi select, confirm, input, or editor question. Select is single-choice and returns the supplied stable option ID. This cannot approve tools or a Paperclip Plan. For durable task questions or Plan approval use the assigned Paperclip semantic tools. Cancellation is not an answer; confirm false means No or dismissal.", + parameters: { type: "object", additionalProperties: false, required: ["method", "title"], properties: { + method: { type: "string", enum: ["select", "confirm", "input", "editor"] }, title: { type: "string", maxLength: 4096 }, + options: { type: "array", minItems: 1, maxItems: 128, items: { type: "object", additionalProperties: false, required: ["id", "label"], properties: { id: { type: "string", pattern: "^[A-Za-z0-9_-]{1,128}$" }, label: { type: "string", maxLength: 4096 } } } }, + message: { type: "string", maxLength: 16384 }, placeholder: { type: "string", maxLength: 16384 }, prefill: { type: "string", maxLength: 16384 }, + } }, + async execute(callId, args, signal, _onUpdate, context) { + const id = identities.bind(callId, PI_NATIVE_QUESTION_TOOL, args); + const previous = questions.get(id); if (previous) return previous; + if (!context?.ui || questions.size >= 4096) throw new Error("Pi native question context is unavailable or exhausted"); + const pending = askPiNativeQuestion(args, context.ui, signal).then(result => ({ content: [{ type: "text", text: JSON.stringify(result) }], details: result })); + questions.set(id, pending); return pending; + }, + }); let count = 0; for (const server of config.servers) { await request(server, "initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "paperclip-pi", version: "1" } }, `pi-init-${server.name}`); 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 807dae3197a..0fda812f6be 100644 --- a/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs +++ b/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs @@ -75,6 +75,10 @@ createInterface({ input: process.stdin }).on("line", (line) => { } 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.startsWith("native-question-")) { + const method = request.message.slice("native-question-".length); + output({ type: "extension_ui_request", id: `native-${method}`, method, title: "Native question", options: ["Red", "Blue"], message: "Continue?", placeholder: "Name", prefill: "Old\ntext" }); 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 } } }); 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 d0d7220923f..fb4bc556210 100644 --- a/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs +++ b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs @@ -153,6 +153,18 @@ test("actual patched ACP process keeps text questions separate from native permi assert.equal(f.requests[1].params.options[1].kind, "allow_always"); }); +for (const [method, answer, expected] of [["select", "Blue", { value: "Blue" }], ["confirm", false, { confirmed: false }], ["input", "Ada", { value: "Ada" }], ["editor", "New\ntext", { value: "New\ntext" }]]) { + test(`actual patched ACP ${method} question round-trips through form elicitation`, async t => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + f.setAnswer(async () => ({ action: "accept", content: { answer } })); + await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: `native-question-${method}` }] }); + assert.equal(f.requests.length, 1); assert.notEqual(f.requests[0].method, "session/request_permission"); + assert.equal(f.requests[0].params._meta.paperclipPi.method, method); + const response = f.notifications.map(event => event.params?.update?.content?.text).find(text => text?.includes('"extension_ui_response"')); + assert.deepEqual(JSON.parse(response), { type: "extension_ui_response", id: `native-${method}`, ...expected }); + }); +} + test("native steering is explicit and does not replace the active ACP turn", async (t) => { const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); const prompt = f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "long" }] }); 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 564cfd08b81..32096a917ff 100644 --- a/packages/paperclip-runner/test/pi-native-package-contract.test.mjs +++ b/packages/paperclip-runner/test/pi-native-package-contract.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { createServer } from "node:http"; -import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { stripTypeScriptTypes } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -16,6 +16,38 @@ const packageRoot = process.env.PAPERCLIP_TEST_PI_RUNTIME_ROOT const metadata = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")); const extensionSource = await readFile(new URL("../src/drivers/acpx/pi-runtime-extension.ts", import.meta.url), "utf8"); +test("owned gate authorizes the actual pinned SDK path expansions and read fallbacks", async () => { + assert.equal(metadata.version, "0.84.2"); + const { resolveToCwd, resolveReadPathAsync } = await import(pathToFileURL(join(packageRoot, "dist/core/tools/path-utils.js")).href); + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-native-paths-"))); + try { + const workspace = join(root, "workspace"); const privateRoot = join(root, "private"); + await mkdir(workspace); await mkdir(privateRoot); + const secret = join(privateRoot, "sentinel"); await writeFile(secret, "private fixture"); + await writeFile(join(root, "extension.mjs"), 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 { piNativeToolPaths, checkPiNativeTool } = await import(pathToFileURL(join(root, "extension.mjs")).href); + const config = { workspace, readOnly: false, readRoots: [], protectedRoots: [privateRoot] }; + const context = { cwd: workspace }; + for (const path of [`@${secret}`, `file://${secret}`, "~", "~/sentinel", "@~/sentinel", "~//sentinel", "space\u00a0name/sentinel", "space\u202fname/sentinel", "@safe", "ordinary"]) { + assert.equal(piNativeToolPaths(path, workspace, false)[0], resolveToCwd(path, workspace), path); + } + for (const path of [`@${secret}`, `file://${secret}`, "~", "~/sentinel", "@~/sentinel"]) { + assert.notEqual(await checkPiNativeTool({ toolName: "read", input: { path } }, context, config), null, path); + } + for (const [path, alternate] of [["capture 1 PM.png", "capture 1\u202fPM.png"], ["a'b", "a\u2019b"], ["caf\u00e9'b", "cafe\u0301\u2019b"]]) { + await symlink(secret, join(workspace, alternate)); + const actual = await resolveReadPathAsync(path, workspace); + assert.equal(await realpath(actual), secret); + assert.ok(piNativeToolPaths(path, workspace, true).includes(actual)); + assert.notEqual(await checkPiNativeTool({ toolName: "read", input: { path } }, context, config), null, path); + } + await mkdir(join(workspace, "space name")); await symlink(secret, join(workspace, "space name/sentinel")); + assert.equal(await realpath(resolveToCwd("space\u00a0name/sentinel", workspace)), secret); + assert.notEqual(await checkPiNativeTool({ toolName: "read", input: { path: "space\u00a0name/sentinel" } }, context, config), null); + } finally { await rm(root, { recursive: true, force: true }); } +}); + for (const brokenCatalog of [false, true]) { test(`real pinned Pi ${brokenCatalog ? "withholds" : "registers"} readiness after MCP initialization`, { timeout: 20_000 }, async (t) => { assert.equal(metadata.version, "0.84.2"); @@ -138,3 +170,52 @@ test("real pinned Pi model iterations align owned identities across warm prompts session?.dispose(); await rm(root, { recursive: true, force: true }); } }); + +test("real pinned Pi executes all four owned native question methods without permission authority", { timeout: 20_000 }, async () => { + 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-questions-"))); + let session; + try { + await writeFile(join(root, "extension.mjs"), 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 { installPiRuntimeExtension, PI_NATIVE_QUESTION_TOOL } = await import(pathToFileURL(join(root, "extension.mjs")).href); + 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 => installPiRuntimeExtension(pi, { invocationNamespace: "00000000-0000-4000-8000-000000000000", workspace: root, readOnly: true, readRoots: [], protectedRoots: [], instructions: "", servers: [] })] }); + await resourceLoader.reload(); + ({ session } = await createAgentSession({ cwd: root, agentDir: root, model, modelRuntime, settingsManager: settings, sessionManager: SessionManager.inMemory(root), resourceLoader, noTools: "builtin", thinkingLevel: "off" })); + const dialogs = []; const results = []; const errors = []; + const uiContext = { + select: async (title, options) => { dialogs.push({ method: "select", title, options }); return "Blue"; }, + confirm: async (title, message) => { dialogs.push({ method: "confirm", title, message }); return false; }, + input: async (title, placeholder) => { dialogs.push({ method: "input", title, placeholder }); return "Ada"; }, + editor: async (title, prefill) => { dialogs.push({ method: "editor", title, prefill }); return "New\ntext"; }, + notify() {}, setStatus() {}, setWidget() {}, setTitle() {}, setEditorText() {}, getEditorText: () => "", setWorkingMessage() {}, + }; + await session.bindExtensions({ uiContext, onError: error => errors.push(error.event) }); + session.subscribe(event => { if (event.type === "tool_execution_end") results.push(event); }); + const requests = [ + { method: "select", title: "Color", options: [{ id: "blue", label: "Blue" }, { id: "red", label: "Red" }] }, + { method: "confirm", title: "Continue", message: "Continue editing?" }, + { method: "input", title: "Name", placeholder: "Your name" }, + { method: "editor", title: "Draft", prefill: "Old\ntext" }, + ]; + let streams = 0; + session.agent.streamFunction = () => { + const args = requests[streams++]; + const message = { role: "assistant", content: args ? [{ type: "toolCall", id: "call_0", name: PI_NATIVE_QUESTION_TOOL, arguments: args }] : [{ 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: args ? "toolUse" : "stop", timestamp: streams }; + return { async *[Symbol.asyncIterator]() { yield { type: "start", partial: message }; yield { type: "done", reason: message.stopReason, message }; }, result: async () => message }; + }; + await session.agent.prompt("fixture native questions"); await session.agent.waitForIdle(); + assert.deepEqual(errors, []); assert.equal(results.length, 4); + assert.ok(results.every(event => !event.isError)); + assert.deepEqual(dialogs.map(dialog => dialog.method), ["select", "confirm", "input", "editor"]); + assert.ok(dialogs.every(dialog => !dialog.title.startsWith("paperclip.pi.permission.v1:"))); + assert.deepEqual(results.map(event => event.result.details), [{ status: "answered", optionId: "blue" }, { status: "negative_or_cancelled", confirmed: false }, { status: "answered", value: "Ada" }, { status: "answered", value: "New\ntext" }]); + } 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 eab557bd01c..e64a2b0ab8b 100644 --- a/patches/pi-acp@0.0.33.patch +++ b/patches/pi-acp@0.0.33.patch @@ -675,7 +675,7 @@ try { --- /dev/null +++ b/dist/paperclip-runtime.js -@@ -0,0 +1,371 @@ +@@ -0,0 +1,382 @@ +/** 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"; @@ -870,11 +870,18 @@ + const readRoots = pathArray(environment.PAPERCLIP_PI_READ_ROOTS); + const protectedRoots = pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS); + const workspace = realpathSync(params.cwd); ++ const suppliedAgentHome = environment.PAPERCLIP_PI_AGENT_HOME; ++ let agentHome ; ++ if (suppliedAgentHome !== undefined) { ++ if (!isAbsolute(suppliedAgentHome) || /[\0\r\n]/.test(suppliedAgentHome) || suppliedAgentHome === "/" || !lstatSync(suppliedAgentHome).isDirectory() || realpathSync(suppliedAgentHome) !== suppliedAgentHome) throw new Error("Pi agent files require a canonical registered directory"); ++ agentHome = suppliedAgentHome; ++ } + const invocationNamespace = randomUUID(); + const configuration = JSON.stringify({ + invocationNamespace, workspace, servers: params.mcpServers ?? [], + readOnly: environment.PAPERCLIP_PI_READ_ONLY === "1", + readRoots, protectedRoots, ++ ...(agentHome ? { agentHome } : {}), + instructions: environment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS ?? "", + }); + if (Buffer.byteLength(configuration) > 64 * 1024) throw new Error("Pi launch configuration exceeds its bound"); @@ -891,7 +898,11 @@ + 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, invocationNamespace, env: { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration } }; ++ const env = { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration }; ++ // Ambient AGENT_HOME never grants filesystem authority. Only the runner's ++ // authenticated working-copy binding reaches both the model and tool gate. ++ if (agentHome) env.AGENT_HOME = agentHome; else delete env.AGENT_HOME; ++ return { command, args, invocationNamespace, env }; +} + +