mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
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:
1 parent
2eab096029
commit
be54e25078
12 files changed
+422
-41
No files matched your search
@@ -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 }); }
|
||||
});
|
||||
@@ -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 };
|
||||
+}
|
||||
+
|
||||
+
|
||||
|
||||
Reference in new issue
Block a user