fix(runner): admit Pi MCP names and bound native questions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-29 12:00:42 -05:00
1 parent 97217c5313
commit bb00c85b69
19 files changed
+244 -45

No files matched your search

+27 -4
View File
@@ -1,8 +1,31 @@
# Pi rich ACP runtime
Status: implementation candidate, 2026-09-29. Profile version 6 adds the native
Status: implementation candidate, 2026-09-29. Current profile version 7 repairs
MCP tool-name admission and native question labels. MCP names containing colons
or periods, or exceeding model name limits, receive deterministic collision-checked
aliases of at most 64 characters; authenticated calls retain the exact original
name. Titles and select labels admit at most 1,000 UTF-16 code units, matching
the canonical question contract. Invalid owned arguments fail before a UI promise;
unsupported external UI requests emit an explicit error notice and stop the session.
The editor draft requires the shared `initialText` normalization and editable UI
roundtrip. These repairs change the native closure and require fresh qualification;
no v7 paid Product or Runner proof exists yet. The canonical
[v7 declaration](../../packages/paperclip-runner/test-fixtures/pi-acp/profile-v7-identity.json)
binds the exact wrapper, helper, extension and three target closures.
Credential-free regressions run the pinned SDK with synthetic model streams to
verify all four question methods and original MCP call names behind aliases. The
actual patched wrapper proves visible unsupported-dialog failure; the real form
normalizer verifies the 1,000-unit ASCII, CJK and surrogate-pair boundaries. The
closed snapshot admission test exercises the candidate assets without inference.
Profile version 6 added the native
question tool, the authenticated agent-files root, and matching SDK path
normalization. Its authenticated Product and Runner qualification is pending.
The later v6 Product hello on source `97217c531` passed with one observed attempt
and an exclusive-key cost delta of $0.00065884. Its launcher omitted the explicit
zero-retry CLI flag, so it is retained as `passed_with_retry_policy_gap`, never
as v7 qualification.
The first frozen v6 Product hello attempt fails before provider startup because
the shared native-execution parser still admits only profile versions 1–5.
Both assignment and automatic recovery runs reject the same input; no usage
@@ -135,7 +158,7 @@ lifecycle correlation and stable, disjoint historical replay identities.
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 v6 qualification. The historical v4
evidence of their recorded versions, not v7 qualification. The historical v4
digest is `sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991`.
## Lifetime and recovery
@@ -176,7 +199,7 @@ 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.
qualification requirements for version 7.
## Verified launch and packaging contract
@@ -397,7 +420,7 @@ that those targets have executed successfully.
### Installation authority and token semantics
`verifyPiInstallation(profile)` admits only profile version 6 and the source-owned
`verifyPiInstallation(profile)` admits only profile version 7 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,
@@ -170,7 +170,7 @@ impl AcpxProviderDescriptor {
"0.0.33",
Some("@earendil-works/pi-coding-agent"),
Some("0.84.2"),
"sha256:d2b470e940115a1cd8a31cd5d2ddde2837912e965d3475deede09d67bff6346f",
"sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761",
),
"cursor" => (
self.model.as_str(),
@@ -2740,7 +2740,7 @@ mod tests {
"pi",
"pi-acp",
"0.0.33",
"sha256:d2b470e940115a1cd8a31cd5d2ddde2837912e965d3475deede09d67bff6346f",
"sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761",
Some("@earendil-works/pi-coding-agent"),
Some("0.84.2"),
"openrouter/deepseek/deepseek-v4-flash-0731",
@@ -17,7 +17,7 @@ use sha2::{Digest, Sha256};
const CODEX_ACPX_DIGEST: &str =
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3";
const PI_ACPX_DIGEST: &str =
"sha256:d2b470e940115a1cd8a31cd5d2ddde2837912e965d3475deede09d67bff6346f";
"sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761";
fn temporary_directory(label: &str) -> PathBuf {
let nonce = SystemTime::now()
@@ -19,8 +19,8 @@ const patchPath = join(workspaceRoot, "patches/pi-acp@0.0.33.patch");
const supportedTargets = new Set(["darwin-arm64", "darwin-x64", "linux-x64"]);
export const PI_DISTRIBUTION_PINS = Object.freeze({
wrapper: "0.0.33", runtime: "0.84.2", sdk: "0.26.0", zod: "3.25.76", nodeVersion: PI_NODE_VERSION, undici: "8.10.2", nodeBundledUndici: "7.29.1",
wrapperSha256: "dc4786faff30942e82106c87a8984a75fb979a925ff1d62648500a642b920cac",
helperSha256: "2df24aee67ca6a5cc813e1f87324f51a72f6a104757daed97a09cbbcb63626d4",
wrapperSha256: "2b590e8e12133a77ee71cba2a688bf1e2bc73cfb771235a823ed9cdc3cd8c095",
helperSha256: "153caaa32b93313a81636a28af32ca8dcce295c200853f5897e0387f79a0d92d",
});
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
@@ -296,7 +296,7 @@ describe("NativeExecutionInputV1", () => {
})).toThrow("eventExpiryDays");
});
it.each([1, 2, 3, 4, 5, 6] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => {
it.each([1, 2, 3, 4, 5, 6, 7] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => {
const provider = {
kind: "acpx",
agent: "pi",
@@ -328,7 +328,7 @@ describe("NativeExecutionInputV1", () => {
profile: provider.profile,
});
expect(parseNativeExecutionInput(parsed)).toEqual(parsed);
for (const unsupportedVersion of [0, 7, 1.5, "6", null]) {
for (const unsupportedVersion of [0, 8, 1.5, "7", null]) {
expect(() => parseNativeExecutionInput({
...input,
session: { ...input.session, driverKind: "acpx_runtime" },
@@ -90,7 +90,7 @@ export interface NativeAcpxProfileSnapshot {
protocolVersion: 1;
acpxVersion: "0.13.1";
agent: NativeAcpxAgent;
agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6;
agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7;
agentServerPackage: string;
agentServerVersion: string;
agentRuntimePackage: string | null;
@@ -601,7 +601,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|| profile.protocolVersion !== 1
|| profile.acpxVersion !== "0.13.1"
|| profile.agent !== provider.agent
|| (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6)
|| (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6 && profile.agentProfileVersion !== 7)
) {
throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile");
}
@@ -2,6 +2,7 @@ import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promis
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeAcpFormElicitation } from "./acp-question-adapter.js";
import { createPiLaunchSpec, PiToolIdentities, PiRpcFrames, PiTurnUsage, PiUiBridge } from "./pi-acp-runtime.js";
const temporary: string[] = [];
@@ -68,6 +69,38 @@ describe("Pi ACP bridge", () => {
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q1", ...expected });
});
it("retains blank editor prefill and distinguishes an accepted empty string from cancellation", async () => {
const f = fixture(); f.connection.unstable_createElicitation.mockResolvedValue({ action: "accept", content: { answer: "" } });
await f.bridge.handle({ id: "blank", method: "editor", title: "Draft", prefill: "" });
expect(f.connection.unstable_createElicitation.mock.calls[0]![0]).toMatchObject({ requestedSchema: { properties: { answer: { default: "" } } } });
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "blank", value: "" });
f.connection.unstable_createElicitation.mockResolvedValue({ action: "cancel" });
await f.bridge.handle({ id: "dismissed", method: "editor", title: "Draft", prefill: "" });
expect(f.process.sendExtensionUiResponse).toHaveBeenLastCalledWith({ id: "dismissed", cancelled: true });
});
it.each(["a".repeat(1000), "界".repeat(1000), "🌒".repeat(500)])("passes the canonical character limit through the real form normalizer", async (label) => {
const f = fixture();
f.connection.unstable_createElicitation.mockImplementation(async request => {
const normalized = normalizeAcpFormElicitation(request)!;
expect(normalized.questionSet.title).toBe(label);
expect(normalized.questionSet.questions[0]!.options![0]!.label).toBe(label);
return normalized.accept({ schema: "paperclip.question_response.v1", answers: { [normalized.questionSet.questions[0]!.id]: { selectedOptionIds: ["option-1"] } } });
});
await f.bridge.handle({ id: "boundary", method: "select", title: label, options: [label] });
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledWith({ id: "boundary", value: label });
});
it.each([
{ title: "a".repeat(1001), options: ["ok"] },
{ title: "ok", options: ["界".repeat(1001)] },
])("reports external unsupported questions without presenting or silently cancelling them", async extra => {
const f = fixture();
await expect(f.bridge.handle({ id: "oversize", method: "select", ...extra })).rejects.toThrow("unsupported or invalid");
expect(f.connection.unstable_createElicitation).not.toHaveBeenCalled();
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "oversize", cancelled: true });
});
it("sends only offered native permission choices and keeps questions separate", async () => {
const f = fixture(); f.connection.requestPermission.mockResolvedValue({ outcome: { outcome: "selected", optionId: "allow_always" } });
const event = { id: "p1", method: "select", title: 'paperclip.pi.permission.v1:{"toolCallId":"tool-a","nativeToolCallId":"native-a","modelIteration":1,"toolName":"bash","input":{"command":"pwd"}}' };
@@ -92,10 +125,10 @@ describe("Pi ACP bridge", () => {
f.connection.unstable_createElicitation.mockImplementation(() => new Promise((resolve) => { answer = resolve; }));
const request = f.bridge.handle({ id: "q", method: "select", title: "Question", options: ["A"], timeout: 100 });
await vi.advanceTimersByTimeAsync(100);
answer({ action: "accept", content: { answer: "A" } }); await request;
answer({ action: "accept", content: { answer: "not-an-offered-option" } }); await request;
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q", cancelled: true });
f.connection.unstable_createElicitation.mockResolvedValue({ action: "accept", content: { answer: "other" } });
await f.bridge.handle({ id: "q2", method: "select", title: "Question", options: ["A"] });
await expect(f.bridge.handle({ id: "q2", method: "select", title: "Question", options: ["A"] })).rejects.toThrow("unsupported or invalid");
expect(f.process.sendExtensionUiResponse).toHaveBeenLastCalledWith({ id: "q2", cancelled: true });
});
@@ -227,6 +227,13 @@ export function createPiLaunchSpec(
return { command, args, invocationNamespace, env };
}
// Same UTF-16 character bound as the canonical question-set title/header/label.
export const PI_QUESTION_LABEL_MAX_LENGTH = 1_000;
export function piQuestionLabel(value: unknown): string {
if (typeof value !== "string" || !value.trim() || value.length > PI_QUESTION_LABEL_MAX_LENGTH) throw new Error("Pi question label is invalid or oversized");
return value;
}
interface UiConnection {
requestPermission(params: RecordValue): Promise<unknown>;
unstable_createElicitation(params: RecordValue): Promise<unknown>;
@@ -282,10 +289,11 @@ export class PiUiBridge {
await finish(selected ? { value: selected.name } : { cancelled: true });
return;
}
piQuestionLabel(title);
const property: RecordValue = { type: method === "confirm" ? "boolean" : "string", title };
if (method === "select") {
if (!Array.isArray(event.options) || event.options.length < 1 || event.options.length > 128) throw new Error("Invalid Pi question options");
property.enum = event.options.map((option) => text(option, 4096));
property.enum = event.options.map((option) => piQuestionLabel(option));
if (new Set(property.enum as string[]).size !== event.options.length) throw new Error("Ambiguous Pi question options");
}
if (method === "input" && typeof event.placeholder === "string") property.description = text(event.placeholder);
@@ -305,7 +313,11 @@ export class PiUiBridge {
if (method === "select" && !(property.enum as string[]).includes(value)) throw new Error("Invalid Pi question response");
await finish({ value });
}
} catch { await finish({ cancelled: true }).catch(() => {}); }
} catch {
if (done) return; // An expired request cannot fail a later live session.
await finish({ cancelled: true }).catch(() => {});
throw new Error("Pi structured question is unsupported or invalid");
}
}
}
@@ -6,7 +6,7 @@
* three pins. Never accept a digest supplied only by an installed manifest.
*/
export const PI_DISTRIBUTION_CLOSURE_SHA256 = Object.freeze({
"darwin-arm64": "b2ce6bb2d9b93c4265b2c363cbf1433e06bb3349327f59b6f8eb3fdbd1b03d6a",
"darwin-x64": "a9269f6e0675ad1b50b24e76d47b971d4238ff25ddd57074b707db2a9bc83a74",
"linux-x64": "05eb422d9ce9e32b081cb2d2a3433dc113d66a55f993125b74b1666002cf3b67",
"darwin-arm64": "3703361965d4e0b641fcd5eac4079e9b6c0de47b4b245a5b31b7da7b52c94128",
"darwin-x64": "885d9fde187bd1a877838ac309aa68df4b928e0727ca341b3b8f3b1e7467ad12",
"linux-x64": "425afafe8a1419c86333a6eaa93bf56f0e78a4dc919dcef491f13e451550c5e2",
});
@@ -13,7 +13,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: 6 });
const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 7 });
async function fixture() {
const root = await mkdtemp(join(tmpdir(), "pi-installation-test-")); roots.push(root);
@@ -30,7 +30,7 @@ describe("Pi installation factory", () => {
const hash = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex");
const canonical = (value: any): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
: value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value);
const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v6-identity.json", import.meta.url), "utf8"));
const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v7-identity.json", import.meta.url), "utf8"));
const declaration = identity.declaration;
expect(identity.commandDigest).toBe(`sha256:${hash(canonical(declaration))}`);
expect(QUALIFIED_ACPX_PROFILES.pi.commandDigest).toBe(identity.commandDigest);
@@ -46,18 +46,19 @@ describe("Pi installation factory", () => {
const materializer = await readFile(new URL("../../../scripts/materialize-pi-distribution.mjs", import.meta.url), "utf8");
expect(materializer).toContain(`wrapperSha256: "${declaration.wrapperSha256}"`);
expect(materializer).toContain(`helperSha256: "${declaration.helperSha256}"`);
// This exact patch produced the independently verified v6 wrapper/closure.
// This exact patch produced the independently verified v7 wrapper/closure.
// Pin it separately so wrapper-only edits cannot keep an unchanged declaration.
expect(hash(await readFile(new URL("../../../../../patches/pi-acp@0.0.33.patch", import.meta.url)))).toBe("bae0f2d9c866852a8d72883ddb7cc83af3e157d1638f9aa494670304913f6aea");
expect(hash(await readFile(new URL("../../../../../patches/pi-acp@0.0.33.patch", import.meta.url)))).toBe("4f316d89e63ff47854f93faf5805420cd8b21e730b46a025406db87df86fa5fa");
});
it("rejects legacy profiles and caller-selected identities", () => {
expect(() => assertPiInstallationProfile(candidate())).not.toThrow();
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");
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 7");
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 7");
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 7");
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 7");
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 7");
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 7");
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 !== 6) throw new Error("Pi rich ACP requires profile version 6; reopen the previous session");
if (profile.agentProfileVersion !== 7) throw new Error("Pi rich ACP requires profile version 7; 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");
}
@@ -97,12 +97,44 @@ describe("owned Pi runtime extension", () => {
{ method: "input", title: "Question", command: "touch forbidden" },
{ method: "editor", title: "Question", prefill: "x".repeat(16385) },
{ method: "multi-select", title: "Question" },
{ method: "input", title: "x".repeat(1001) },
{ method: "select", title: "Question", options: [{ id: "a", label: "界".repeat(1001) }] },
];
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("aliases MCP punctuation and long names without changing authenticated call identity", async () => {
const { config } = await workspace(); const h = harness();
const names = ["connection:search", "connection_search", "connection.search", "x".repeat(128)];
const request = vi.fn(async (_server, method) => method === "tools/list"
? { tools: names.map(name => ({ name, inputSchema: { type: "object", properties: {} } })) }
: { content: [{ type: "text", text: "recorded" }] });
const assigned = { ...config, servers: [{ type: "http" as const, name: "paperclip", url: "http://127.0.0.1:1234", headers: [] }] };
await installPiRuntimeExtension(h.api, assigned, request); h.handlers.get("turn_start")!();
expect(h.tools.map(tool => tool.name).every(name => /^[A-Za-z0-9_-]{1,64}$/.test(name))).toBe(true);
expect(new Set(h.tools.map(tool => tool.name)).size).toBe(names.length);
for (const [index, tool] of h.tools.entries()) {
await tool.execute(`call-${index}`, { index });
expect(request).toHaveBeenLastCalledWith(assigned.servers[0], "tools/call", { name: names[index], arguments: { index } }, expect.any(String), undefined);
}
const again = harness(); await installPiRuntimeExtension(again.api, assigned, request);
expect(again.tools.map(tool => tool.name)).toEqual(h.tools.map(tool => tool.name));
const duplicate = harness();
await expect(installPiRuntimeExtension(duplicate.api, assigned, async (_server, method) => method === "tools/list"
? { tools: [names[0], names[0]].map(name => ({ name, inputSchema: { type: "object" } })) } : {})).rejects.toThrow("ambiguous");
expect(duplicate.api.registerCommand).not.toHaveBeenCalled();
});
it.each(["a".repeat(1000), "界".repeat(1000), "🌒".repeat(500)])("admits the exact shared label boundary before native UI", async label => {
const { config } = await workspace(); const h = harness();
await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!();
const select = vi.fn().mockResolvedValue(label);
expect((await h.nativeTools[0]!.execute("boundary", { method: "select", title: label, options: [{ id: "choice", label }] }, undefined, undefined, { cwd: config.workspace, ui: { select } })).details).toEqual({ status: "answered", optionId: "choice" });
expect(select).toHaveBeenCalledWith(label, [label], expect.any(Object));
});
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" }] }] };
@@ -3,7 +3,8 @@
* this immutable module explicitly; project/global extension discovery is off.
* Structural types keep this module independent of Pi's optional UI packages.
*/
import { PiToolIdentities } from "./pi-acp-runtime.js";
import { createHash } from "node:crypto";
import { PI_QUESTION_LABEL_MAX_LENGTH, piQuestionLabel, PiToolIdentities } from "./pi-acp-runtime.js";
import { lstat, realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -205,7 +206,7 @@ async function askPiNativeQuestion(args: Record<string, unknown>, ui: PiUi, sign
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);
const title = piQuestionLabel(args.title);
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"] : [])];
@@ -214,7 +215,7 @@ async function askPiNativeQuestion(args: Record<string, unknown>, ui: PiUi, sign
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);
const option = asRecord(value); const id = boundedText(option.id, 128); const label = piQuestionLabel(option.label);
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 };
});
@@ -370,8 +371,8 @@ export async function installPiRuntimeExtension(
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 } } } },
method: { type: "string", enum: ["select", "confirm", "input", "editor"] }, title: { type: "string", maxLength: PI_QUESTION_LABEL_MAX_LENGTH },
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: PI_QUESTION_LABEL_MAX_LENGTH } } } },
message: { type: "string", maxLength: 16384 }, placeholder: { type: "string", maxLength: 16384 }, prefill: { type: "string", maxLength: 16384 },
} },
async execute(callId, args, signal, _onUpdate, context) {
@@ -389,8 +390,13 @@ export async function installPiRuntimeExtension(
if (!Array.isArray(catalog.tools) || catalog.nextCursor) throw new Error("Pi MCP catalog is incomplete");
for (const rawTool of catalog.tools) {
const tool = asRecord(rawTool);
if (typeof tool.name !== "string" || !/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)) throw new Error("Pi MCP tool name is invalid");
const nativeName = `mcp__${server.name}__${tool.name}`;
if (typeof tool.name !== "string" || !/^[A-Za-z0-9_.:-]{1,128}$/.test(tool.name)) throw new Error("Pi MCP tool name is invalid");
const readableName = `mcp__${server.name}__${tool.name}`;
// MCP names may contain colons/periods and exceed model tool-name limits.
// Preserve the exact source identity only in the authenticated call closure;
// a framed digest avoids lossy replacement collisions (a:b versus a_b).
const nativeName = /^[A-Za-z0-9_-]{1,64}$/.test(readableName) ? readableName
: `mcp__${createHash("sha256").update(JSON.stringify([server.name, tool.name])).digest("hex").slice(0, 59)}`;
if (bridgeTools.has(nativeName) || ++count > MAX_TOOLS) throw new Error("Pi MCP catalog is ambiguous or oversized");
const schema = asRecord(tool.inputSchema);
if (schema.type !== "object") throw new Error("Pi MCP tool parameters must be an object");
@@ -47,14 +47,14 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
acpxVersion: QUALIFIED_ACPX_VERSION,
agent: "pi",
agentProfileVersion: 6,
agentProfileVersion: 7,
qualificationStatus: "pending",
agentServerPackage: "pi-acp",
agentServerVersion: "0.0.33",
agentRuntimePackage: "@earendil-works/pi-coding-agent",
agentRuntimeVersion: "0.84.2",
commandDigest:
"sha256:d2b470e940115a1cd8a31cd5d2ddde2837912e965d3475deede09d67bff6346f",
"sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761",
qualificationModel: "openrouter/deepseek/deepseek-v4-flash-0731",
reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731",
permissionPolicy: "interactive",
@@ -75,6 +75,7 @@ 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 === "oversized-question") { output({ type: "extension_ui_request", id: "oversized", method: "select", title: "Native question", options: ["x".repeat(1001)] }); 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;
@@ -0,0 +1,24 @@
{
"commandDigest": "sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761",
"declaration": {
"schema": "paperclip.acpx_profile_declaration.v1",
"agent": "pi",
"agentProfileVersion": 7,
"acpxVersion": "0.13.1",
"agentServerVersion": "0.0.33",
"agentRuntimeVersion": "0.84.2",
"nodeVersion": "24.21.0",
"closure": {
"darwin-arm64": "3703361965d4e0b641fcd5eac4079e9b6c0de47b4b245a5b31b7da7b52c94128",
"darwin-x64": "885d9fde187bd1a877838ac309aa68df4b928e0727ca341b3b8f3b1e7467ad12",
"linux-x64": "425afafe8a1419c86333a6eaa93bf56f0e78a4dc919dcef491f13e451550c5e2"
},
"wrapperSha256": "2b590e8e12133a77ee71cba2a688bf1e2bc73cfb771235a823ed9cdc3cd8c095",
"helperSha256": "153caaa32b93313a81636a28af32ca8dcce295c200853f5897e0387f79a0d92d",
"extensionSha256": "2b74a4a2e6a42def3d9918a46732e0e1b8c618791d2b22970cfb3e9d285e55ad",
"agentFilesBinding": "registered-runtime-context-v1",
"nativeQuestions": "select-confirm-input-editor-v2",
"pathPolicy": "sdk-normalization-v1",
"mcpToolNames": "provider-safe-exact-source-v1"
}
}
@@ -165,6 +165,13 @@ for (const [method, answer, expected] of [["select", "Blue", { value: "Blue" }],
});
}
test("actual patched ACP reports unsupported external UI as an error and stops the session", async t => {
const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] });
await assert.rejects(f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "oversized-question" }] }), /exited/);
assert.equal(f.requests.length, 0);
assert.ok(f.notifications.some(event => event.params?.update?._meta?.paperclipPi?.notice?.message === "Pi structured question is unsupported or invalid; the session was stopped"));
});
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" }] });
@@ -64,7 +64,7 @@ for (const brokenCatalog of [false, true]) {
response.setHeader("Content-Type", "application/json");
const result = rpc.method === "initialize"
? { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } }
: { tools: [{ name: brokenCatalog ? "invalid/name" : "report_progress", description: "Report progress", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } }] };
: { tools: [{ name: brokenCatalog ? "invalid/name" : "connection:search", description: "Report progress", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } }] };
response.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result }));
});
server.listen(0, "127.0.0.1"); await once(server, "listening");
@@ -220,6 +220,49 @@ test("real pinned Pi executes all four owned native question methods without per
} finally { session?.dispose(); await rm(root, { recursive: true, force: true }); }
});
test("real pinned Pi dispatches aliased MCP tools with their exact original names", { 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-mcp-alias-")));
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 } = await import(pathToFileURL(join(root, "extension.mjs")).href);
const names = ["connection:search", "connection_search", "connection.search", "x".repeat(128)];
const calls = [];
const request = async (_server, method, params) => method === "tools/list"
? { tools: names.map(name => ({ name, description: name, inputSchema: { type: "object", properties: { index: { type: "number" } } } })) }
: method === "tools/call" ? (calls.push(params), { content: [{ type: "text", text: "recorded" }] }) : {};
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: [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1", headers: [] }] }, request)] });
await resourceLoader.reload();
({ session } = await createAgentSession({ cwd: root, agentDir: root, model, modelRuntime, settingsManager: settings, sessionManager: SessionManager.inMemory(root), resourceLoader, noTools: "builtin", thinkingLevel: "off" }));
const errors = []; const results = [];
await session.bindExtensions({ onError: error => errors.push(error.event) });
session.subscribe(event => { if (event.type === "tool_execution_end") results.push(event); });
let streams = 0;
session.agent.streamFunction = (_model, context) => {
const index = streams++; const original = names[index];
const exposed = context.tools.filter(tool => names.includes(tool.description));
assert.equal(exposed.length, names.length);
assert.equal(new Set(exposed.map(tool => tool.name)).size, names.length);
assert.ok(exposed.every(tool => /^[A-Za-z0-9_-]{1,64}$/.test(tool.name)));
const name = exposed.find(tool => tool.description === original)?.name;
const message = { role: "assistant", content: name ? [{ type: "toolCall", id: "call_0", name, arguments: { index } }] : [{ 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: name ? "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 aliases"); await session.agent.waitForIdle();
assert.deepEqual(errors, []); assert.equal(results.length, names.length); assert.ok(results.every(event => !event.isError));
assert.deepEqual(calls, names.map((name, index) => ({ name, arguments: { index } })));
} finally { session?.dispose(); await rm(root, { recursive: true, force: true }); }
});
test("real pinned Pi tool dispatch admits registered agent files and rejects unassigned roots", { 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(
+22 -5
View File
@@ -313,7 +313,7 @@
if (snapshot.oldText === null || newText !== snapshot.oldText) {
hasStructuredDiff = true;
content = [
@@ -1154,14 +1199,11 @@
@@ -1154,14 +1199,16 @@
break;
}
case "extension_ui_request": {
@@ -328,7 +328,12 @@
+ sessionUpdate: "session_info_update",
+ _meta: { paperclipPi: { notice: { message: typeof ev.message === "string" ? ev.message.slice(0, 4096) : "Pi notification", level: ev.notifyType } } }
});
+ void this.uiBridge.handle(ev).catch(() => { this.proc.dispose("SIGKILL"); });
+ void this.uiBridge.handle(ev).catch(() => {
+ this.emit({ sessionUpdate: "session_info_update", _meta: { paperclipPi: { notice: {
+ message: "Pi structured question is unsupported or invalid; the session was stopped", level: "error"
+ } } } });
+ this.proc.dispose("SIGKILL");
+ });
break;
}
case "auto_retry_start": {
@@ -675,7 +680,7 @@
try {
--- /dev/null
+++ b/dist/paperclip-runtime.js
@@ -0,0 +1,382 @@
@@ -0,0 +1,394 @@
+/** 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";
@@ -905,6 +910,13 @@
+ return { command, args, invocationNamespace, env };
+}
+
+// Same UTF-16 character bound as the canonical question-set title/header/label.
+export const PI_QUESTION_LABEL_MAX_LENGTH = 1_000;
+export function piQuestionLabel(value ) {
+ if (typeof value !== "string" || !value.trim() || value.length > PI_QUESTION_LABEL_MAX_LENGTH) throw new Error("Pi question label is invalid or oversized");
+ return value;
+}
+
+
+
+
@@ -960,10 +972,11 @@
+ await finish(selected ? { value: selected.name } : { cancelled: true });
+ return;
+ }
+ piQuestionLabel(title);
+ const property = { type: method === "confirm" ? "boolean" : "string", title };
+ if (method === "select") {
+ if (!Array.isArray(event.options) || event.options.length < 1 || event.options.length > 128) throw new Error("Invalid Pi question options");
+ property.enum = event.options.map((option) => text(option, 4096));
+ property.enum = event.options.map((option) => piQuestionLabel(option));
+ if (new Set(property.enum ).size !== event.options.length) throw new Error("Ambiguous Pi question options");
+ }
+ if (method === "input" && typeof event.placeholder === "string") property.description = text(event.placeholder);
@@ -983,7 +996,11 @@
+ if (method === "select" && !(property.enum ).includes(value)) throw new Error("Invalid Pi question response");
+ await finish({ value });
+ }
+ } catch { await finish({ cancelled: true }).catch(() => {}); }
+ } catch {
+ if (done) return; // An expired request cannot fail a later live session.
+ await finish({ cancelled: true }).catch(() => {});
+ throw new Error("Pi structured question is unsupported or invalid");
+ }
+ }
+}
+