feat(runner): bind Pi agent files and native questions

Normalize pinned Pi file paths before authorization and reject fallback symlink escapes. Add bounded native questions and registered agent-files policy for profile v6.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-29 09:45:30 -05:00
1 parent 2eab096029
commit be54e25078
12 files changed
+422 -41

No files matched your search

+37 -7
View File
@@ -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__<server>__<tool>` 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/<platform>-<arch>` 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
@@ -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");
@@ -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");
@@ -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 {
@@ -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");
}
@@ -24,7 +24,7 @@ function record(value: unknown): Record<string, unknown> {
/** 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");
}
@@ -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<string, (...args: any[]) => 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");
@@ -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<string | undefined>;
select(title: string, options: string[], options_?: { signal?: AbortSignal }): Promise<string | undefined>;
confirm?(title: string, message: string, options?: { signal?: AbortSignal }): Promise<boolean>;
input?(title: string, placeholder?: string, options?: { signal?: AbortSignal }): Promise<string | undefined>;
editor?(title: string, prefill?: string): Promise<string | undefined>;
}
export interface PiExtensionContext { cwd: string; ui: PiUi }
@@ -35,6 +41,8 @@ export interface PiToolDefinition {
toolCallId: string,
arguments_: Record<string, unknown>,
signal?: AbortSignal,
onUpdate?: unknown,
context?: PiExtensionContext,
): Promise<{ content: Array<Record<string, unknown>>; 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<string> {
}
}
/** 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<string | null> {
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<string, unknown>, ui: PiUi, signal?: AbortSignal): Promise<Record<string, unknown>> {
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<string>();
const permissionGrants = new Set<string>();
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<string | null> => {
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<string, Promise<{ content: Array<Record<string, unknown>>; 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}`);
@@ -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 } } });
@@ -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" }] });
@@ -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 }); }
});
+13 -2
View File
@@ -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 };
+}
+
+