diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index 958e475a071..4d28ab150d6 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -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/-` 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, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index eb31864386e..b8617c48155 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -170,7 +170,7 @@ impl AcpxProviderDescriptor { "0.0.33", Some("@earendil-works/pi-coding-agent"), Some("0.84.2"), - "sha256: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", diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index 9a423a29f02..aa380c50cdb 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -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() diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs index cbdd9303fbb..a685c29bb5a 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs @@ -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"); diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 0e756aeb122..46f2d858bbd 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -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" }, diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index cf60a156215..25b6f160875 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -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"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts index 082fb35ec07..e4999afa43d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts @@ -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 }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts index 3431b40c961..7c730e10c19 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts @@ -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; unstable_createElicitation(params: RecordValue): Promise; @@ -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"); + } } } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts b/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts index 6b5065bd88d..3e91ab7554d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts @@ -6,7 +6,7 @@ * three pins. Never accept a digest supplied only by an installed manifest. */ export const PI_DISTRIBUTION_CLOSURE_SHA256 = Object.freeze({ - "darwin-arm64": "b2ce6bb2d9b93c4265b2c363cbf1433e06bb3349327f59b6f8eb3fdbd1b03d6a", - "darwin-x64": "a9269f6e0675ad1b50b24e76d47b971d4238ff25ddd57074b707db2a9bc83a74", - "linux-x64": "05eb422d9ce9e32b081cb2d2a3433dc113d66a55f993125b74b1666002cf3b67", + "darwin-arm64": "3703361965d4e0b641fcd5eac4079e9b6c0de47b4b245a5b31b7da7b52c94128", + "darwin-x64": "885d9fde187bd1a877838ac309aa68df4b928e0727ca341b3b8f3b1e7467ad12", + "linux-x64": "425afafe8a1419c86333a6eaa93bf56f0e78a4dc919dcef491f13e451550c5e2", }); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts index 10dd9da4d26..9450ca61579 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -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"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts index dcd33907707..8b675eea8a0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts @@ -24,7 +24,7 @@ function record(value: unknown): Record { /** Candidate identity is build-owned; model selection cannot substitute a CLI. */ export function assertPiInstallationProfile(profile: QualifiedAcpxProfile): void { const trusted = QUALIFIED_ACPX_PROFILES.pi; - if (profile.agentProfileVersion !== 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"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts index 90ba3e49fc4..bd0880cc2e6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts @@ -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" }] }] }; diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts index 6c78f9d4d4d..fbceca7501a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts @@ -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, 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, 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"); diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 9a9691e2eab..e524acacad7 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -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", diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs b/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs index 0fda812f6be..bcb67a4e98e 100644 --- a/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs +++ b/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs @@ -75,6 +75,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; diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v7-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v7-identity.json new file mode 100644 index 00000000000..70719d93d95 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v7-identity.json @@ -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" + } +} diff --git a/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs index fb4bc556210..bec4c288c68 100644 --- a/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs +++ b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs @@ -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" }] }); diff --git a/packages/paperclip-runner/test/pi-native-package-contract.test.mjs b/packages/paperclip-runner/test/pi-native-package-contract.test.mjs index 0d77a39788d..5018fd2fe19 100644 --- a/packages/paperclip-runner/test/pi-native-package-contract.test.mjs +++ b/packages/paperclip-runner/test/pi-native-package-contract.test.mjs @@ -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( diff --git a/patches/pi-acp@0.0.33.patch b/patches/pi-acp@0.0.33.patch index e64a2b0ab8b..49a29facfdf 100644 --- a/patches/pi-acp@0.0.33.patch +++ b/patches/pi-acp@0.0.33.patch @@ -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"); ++ } + } +} +