diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index c091c2e9479..d38b69645fe 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -2259,10 +2259,3 @@ export async function probeAcpxClaudeInstallation(model: string): Promise export async function probeAcpxGrokInstallation(model: string): Promise { await verifyQualifiedAcpxInstallation(resolveQualifiedAcpxProfile("grok", model)); } - -/** Verify the pinned Pi closure and snapshot lease without launching a provider. */ -export async function probeAcpxPiInstallation(model: string): Promise { - const installation = await verifyQualifiedAcpxInstallation(resolveQualifiedAcpxProfile("pi", model)); - const lease = await installation.openCommand(); - await lease.close(); -} diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/profile-installation.test.ts index 6270a001e79..14e975d6e2e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-installation.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { assertAcpxProfileEnvironment, classifyAcpxProfileError } from "./profile-installation.js"; +import { describe, expect, it, vi } from "vitest"; +import { assertAcpxProfileEnvironment, classifyAcpxProfileError, probeAcpxPiInstallation } from "./profile-installation.js"; describe("Cursor actionable admission errors", () => { it("requires an explicit bound credential and supports both company-secret names", () => { @@ -24,3 +24,16 @@ describe("Cursor actionable admission errors", () => { expect(classifyAcpxProfileError("codex", new Error("unauthorized"))).toBeNull(); }); }); + +const piProbe = vi.hoisted(() => ({ verify: vi.fn(), open: vi.fn(), close: vi.fn() })); +vi.mock("./pi-installation.js", () => ({ verifyPiInstallation: piProbe.verify })); +it("probes Pi through its pinned distribution and closes the verified snapshot lease", async () => { + piProbe.verify.mockResolvedValue({ openCommand: piProbe.open }); + piProbe.open.mockResolvedValue({ close: piProbe.close }); + await probeAcpxPiInstallation("openrouter/anthropic/claude-sonnet-4.5"); + expect(piProbe.verify).toHaveBeenCalledWith(expect.objectContaining({ agent: "pi", qualificationModel: "openrouter/anthropic/claude-sonnet-4.5" })); + expect(piProbe.close).toHaveBeenCalledTimes(1); + piProbe.verify.mockRejectedValueOnce(new Error("Pi distribution failed verification")); + await expect(probeAcpxPiInstallation("openrouter/anthropic/claude-sonnet-4.5")).rejects.toThrow("failed verification"); + expect(piProbe.close).toHaveBeenCalledTimes(1); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts index c2d7f11ace0..5c07c21aab6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts @@ -65,3 +65,8 @@ export async function probeAcpxCursorInstallation(model: string): Promise const installation = await verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("cursor", model)); await (await installation.openCommand()).close(); } + +export async function probeAcpxPiInstallation(model: string): Promise { + const installation = await verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("pi", model)); + await (await installation.openCommand()).close(); +} diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index a495c0811c1..675b2c6caea 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -91,7 +91,8 @@ export * from "./semantic-tools/index.js"; export * as acceptedCapabilitySemanticTools from "./semantic-tools/index.js"; export * from "./compatibility.js"; -export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxPiInstallation } from "./drivers/acpx/installation-integrity.js"; +export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation } from "./drivers/acpx/installation-integrity.js"; +export { probeAcpxPiInstallation } from "./drivers/acpx/profile-installation.js"; export { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "./live/bundled-remote-provider-pack.js"; export { RunnerdMuseDriver, describeRunnerdMuseDriver, type RunnerdMuseDriverOptions } from "./drivers/muse/runnerd-muse-driver.js"; diff --git a/packages/paperclip-runner/src/live/index.ts b/packages/paperclip-runner/src/live/index.ts index ab80904063e..2f86f2739fb 100644 --- a/packages/paperclip-runner/src/live/index.ts +++ b/packages/paperclip-runner/src/live/index.ts @@ -6,9 +6,9 @@ export * from "./runnerd-codex-transport.js"; export * from "./turn-stream.js"; export * from "./linux-process-start.js"; -export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxPiInstallation } from "../drivers/acpx/installation-integrity.js"; +export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation } from "../drivers/acpx/installation-integrity.js"; export { probeCopilotMetadata, validateCopilotMetadata, type CopilotMetadataResult } from "../drivers/copilot-metadata-probe.js"; -export { probeAcpxCursorInstallation } from "../drivers/acpx/profile-installation.js"; +export { probeAcpxCursorInstallation, probeAcpxPiInstallation } from "../drivers/acpx/profile-installation.js"; export { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "./bundled-remote-provider-pack.js"; diff --git a/ui/src/components/ai-connections/AiConnectionField.test.tsx b/ui/src/components/ai-connections/AiConnectionField.test.tsx index 4930973d465..cc9e6395173 100644 --- a/ui/src/components/ai-connections/AiConnectionField.test.tsx +++ b/ui/src/components/ai-connections/AiConnectionField.test.tsx @@ -161,3 +161,22 @@ it("offers Pi account selection before choosing its required model", async () => await click("Connect another account"); expect(document.body.textContent).toContain("Advanced provider setup"); }); + +it.each(["openai", "anthropic", "google", "openrouter"] as const)("keeps a matching saved Pi %s API-key account selectable", async provider => { + const saved = account({ provider, method: "api_key", name: "Pi provider account", status: "connected" }); + mocks.list.mockResolvedValue({ currentUserId: "owner", connections: [saved], canManageConnections: true }); + flushSync(() => root.render( + + )); + await settle(); + expect(container.textContent).toContain("Pi provider account"); + expect(container.textContent).not.toContain("incompatible"); + expect(container.textContent).not.toContain("does not support"); + const trigger = container.querySelector('[role="combobox"][aria-label="Connection"]')!; + flushSync(() => trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); + await settle(); + const choice = [...document.querySelectorAll('[role="option"]')].find(node => node.textContent?.includes("Pi provider account")); + expect(choice).toBeDefined(); + expect(choice?.getAttribute("aria-disabled")).not.toBe("true"); +}); diff --git a/ui/src/components/ai-connections/AiConnectionField.tsx b/ui/src/components/ai-connections/AiConnectionField.tsx index 8f74e4c15e2..5fcbe5e2757 100644 --- a/ui/src/components/ai-connections/AiConnectionField.tsx +++ b/ui/src/components/ai-connections/AiConnectionField.tsx @@ -145,6 +145,7 @@ export function AiConnectionField({ } {value?.mode !== "router" && c.id === value?.connectionId && c.grantId === value?.grantId, ); + const compatibilityModel = (provider: string) => adapterType === "pi_local" + ? model || `${provider}/` : provider === "openrouter" ? "openrouter/" : undefined; const compatible = connections .filter( (c) => @@ -57,7 +60,7 @@ export function AiConnectionSelect({ isAiConnectionCompatible( c, adapterType, - c.provider === "openrouter" ? "openrouter/" : undefined, + compatibilityModel(c.provider), ), ) .sort( @@ -80,7 +83,7 @@ export function AiConnectionSelect({ mode: "responsible_user", }, adapterType, - requirement.provider === "openrouter" ? "openrouter/" : undefined, + compatibilityModel(requirement.provider), ); const incompatible = selected && !compatible.some((c) => c.id === selected.id);