diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts index 906863aa1b..9712a48fc9 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { PAPERCLIP_RUNNER_DEFAULT_MODELS, + PAPERCLIP_RUNNER_ACPX_PROFILES, paperclipRunnerTransitionConfig, isPaperclipRunnerProvider, resolvePaperclipRunnerModel, @@ -10,6 +11,13 @@ import { } from "./paperclip-runner-permissions.js"; describe("Paperclip Runner permission defaults", () => { + it("admits Pi while keeping sibling ACP profiles pending", () => { + expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "pi")) + .toMatchObject({ qualified: true, credentialEnvironment: ["OPENROUTER_API_KEY"] }); + for (const agent of ["cursor", "copilot"]) { + expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === agent)?.qualified).toBe(false); + } + }); it("defaults Codex to the only qualified non-interactive mode", () => { expect(resolvePaperclipRunnerPermissionMode("codex", undefined)).toBe( "never", diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.ts b/packages/adapter-utils/src/paperclip-runner-permissions.ts index 2fa8c9730a..b9649ffb8a 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.ts @@ -245,5 +245,5 @@ export const PAPERCLIP_RUNNER_ACPX_PROFILES = Object.freeze([ { value: "claude", label: "Claude", qualified: true, credentialEnvironment: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] }, { value: "cursor", label: "Cursor", qualified: false, credentialEnvironment: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"] }, { value: "copilot", label: "GitHub Copilot", qualified: false, credentialEnvironment: ["COPILOT_GITHUB_TOKEN"] }, - { value: "pi", label: "Pi", qualified: false, credentialEnvironment: ["OPENROUTER_API_KEY"] }, + { value: "pi", label: "Pi", qualified: true, credentialEnvironment: ["OPENROUTER_API_KEY"] }, ] as const); diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts index babdaed2a8..af796fc53b 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -230,9 +230,16 @@ describe("buildPaperclipRunnerConfig", () => { expect(() => buildPaperclipRunnerConfig(makeValues({ model: "", adapterSchemaValues: { provider: "acpx", acpxAgent }, }))).toThrow("requires an explicit provider model"); + const model = acpxAgent === "pi" ? "openrouter/deepseek/deepseek-v4-flash-0731" : "exact-provider-model"; expect(buildPaperclipRunnerConfig(makeValues({ - model: "exact-provider-model", adapterSchemaValues: { provider: "acpx", acpxAgent }, - }))).toMatchObject({ provider: "acpx", acpxAgent, model: "exact-provider-model" }); + model, adapterSchemaValues: { provider: "acpx", acpxAgent }, + }))).toMatchObject({ provider: "acpx", acpxAgent, model }); + }); + + it("rejects a different model when Pi is selected", () => { + expect(() => buildPaperclipRunnerConfig(makeValues({ + model: "claude-sonnet-5", adapterSchemaValues: { provider: "acpx", acpxAgent: "pi" }, + }))).toThrow("Pi requires exact model"); }); it.each([undefined, "agent", "plan", "ask"])("preserves Cursor session mode %s for server admission", acpxSessionMode => { diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index 1a48826c2c..a17c224f7b 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -109,6 +109,10 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record 120 || !/^sha256:[a-f0-9]{64}$/.test(metadata.profileDigest) || !/^sha256:[a-f0-9]{64}$/.test(metadata.closureDigest)) throw new Error("Candidate builder omitted its pinned identity"); candidateProviders[provider] = { version: metadata.version, profileDigest: metadata.profileDigest, - closureDigest: metadata.closureDigest, qualification: "pending", path: assetPath, + closureDigest: metadata.closureDigest, qualification, path: assetPath, sha256: sha256Tree(join(temporaryRoot, assetPath)) }; } @@ -334,7 +334,7 @@ try { codex: "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", }, - ...(candidates.length ? { candidateProviders } : {}), + candidateProviders, artifacts: { grokLauncher: { path: "dist/providers/grok/launcher.cjs", diff --git a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs index 5351f58165..52dd5651af 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs @@ -2,6 +2,17 @@ import { materializePiDistribution } from "./materialize-pi-distribution.mjs"; import { materializePinnedCursorDistribution } from "./materialize-cursor-distribution.mjs"; const CANDIDATES = new Set(["cursor", "copilot", "pi"]); +/** Pi ships by default; the diagnostic option remains compatible with old callers. */ +export function providerPackSelections(candidates) { + if (candidates.some(provider => !CANDIDATES.has(provider)) || new Set(candidates).size !== candidates.length) { + throw new Error("Unknown or duplicate candidate provider"); + } + return [ + { provider: "pi", qualification: "qualified" }, + ...candidates.filter(provider => provider !== "pi").map(provider => ({ provider, qualification: "pending" })), + ]; +} + export function parseProviderPackArguments(args) { let output; const candidates = []; diff --git a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs index a255da1c02..3b0dc5cad9 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs @@ -1,10 +1,18 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { parseProviderPackArguments, materializeCandidateProviderPack } from "./candidate-provider-pack.mjs"; +import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackSelections } from "./candidate-provider-pack.mjs"; -test("candidate builds are explicit and leave qualified-only builds unchanged", () => { +test("candidate options stay explicit and Pi is the only default qualified native asset", () => { assert.deepEqual(parseProviderPackArguments(["--", "/pack"]), { output: "/pack", candidates: [] }); assert.deepEqual(parseProviderPackArguments(["/pack", "--candidate-providers=pi,cursor"]), { output: "/pack", candidates: ["pi", "cursor"] }); + assert.deepEqual(providerPackSelections([]), [{ provider: "pi", qualification: "qualified" }]); + assert.deepEqual(providerPackSelections(["pi", "cursor", "copilot"]), [ + { provider: "pi", qualification: "qualified" }, + { provider: "cursor", qualification: "pending" }, + { provider: "copilot", qualification: "pending" }, + ]); + assert.throws(() => providerPackSelections(["other"]), /Unknown/); + assert.throws(() => providerPackSelections(["pi", "pi"]), /duplicate/); }); test("candidate builder cannot admit unknown providers, options or duplicate assets", async () => { for (const args of [["--candidate-providers=cursor,cursor"], ["--candidate-providers=other"], ["--executable=/tmp/x"], ["/one", "/two"]]) { diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index 2f49015adf..e01e4e3923 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -546,10 +546,19 @@ describe("native backend factory", () => { }, ); - it.each(["pi", "cursor", "copilot"] as const)("rejects unqualified %s direct execution even with an exact persisted profile", agent => { + it("constructs the qualified Pi backend without a diagnostic opt-in or provider launch", async () => { const input = acpxExecution(); if (input.provider.kind !== "acpx") throw new Error("invalid fixture"); - const model = agent === "pi" ? QUALIFIED_ACPX_PROFILES.pi.qualificationModel : "explicit-fixture-model"; + const profile = resolveQualifiedAcpxProfile("pi", QUALIFIED_ACPX_PROFILES.pi.qualificationModel); + Object.assign(input.provider, { agent: "pi", model: profile.qualificationModel, profile }); + const backend = createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime" }); + await expect(backend.descriptor()).resolves.toMatchObject({ name: "acpx_runtime", version: "0.13.1" }); + }); + + it.each(["cursor", "copilot"] as const)("rejects unqualified %s direct execution even with an exact persisted profile", agent => { + const input = acpxExecution(); + if (input.provider.kind !== "acpx") throw new Error("invalid fixture"); + const model = "explicit-fixture-model"; const profile = resolveQualifiedAcpxProfile(agent, model); Object.assign(input.provider, { agent, model, profile }); expect(() => createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime", diff --git a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts index 7d5820bdce..5be946956c 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts @@ -67,16 +67,23 @@ function agentCoreProfile(overrides: Record = {}) { } describe("eval-session request contract", () => { - it.each(["pi", "cursor", "copilot"] as const)("admits %s only with the matching CLI diagnostic opt-in", (agent) => { + it.each(["cursor", "copilot"] as const)("admits %s only with the matching CLI diagnostic opt-in", (agent) => { const value = request({ provider: "acpx", acpxAgent: agent, model: "explicit-provider-model" }); expect(() => parseEvalSessionRequest(value)).toThrow("--candidate-profile"); expect(parseEvalSessionRequest(value, { candidateProfile: agent })).toMatchObject({ acpxAgent: agent, model: "explicit-provider-model" }); - expect(() => parseEvalSessionRequest(value, { candidateProfile: agent === "pi" ? "cursor" : "pi" })).toThrow("must match"); + expect(() => parseEvalSessionRequest(value, { candidateProfile: "pi" })).toThrow("must match"); expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, model: "" }), { candidateProfile: agent })).toThrow("request.model"); expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: "codex", session: { acpxAgent: agent } }))).toThrow("session.acpxAgent must match"); expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, candidateProfile: agent }))).toThrow("--candidate-profile"); }); + it("admits Pi without a diagnostic flag and retains matching historical opt-in", () => { + const value = request({ provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" }); + expect(parseEvalSessionRequest(value)).toMatchObject({ acpxAgent: "pi" }); + expect(parseEvalSessionRequest(value, { candidateProfile: "pi" })).toMatchObject({ acpxAgent: "pi" }); + expect(() => parseEvalSessionRequest(value, { candidateProfile: "cursor" })).toThrow("must match"); + }); + it("accepts only known diagnostic flags and rejects ambiguous repeated arguments", () => { const args = ["--request", "/tmp/request.json", "--output", "/tmp/result.json"]; const expected = resolveQualifiedAcpxProfile("pi", "openrouter/deepseek/deepseek-v4-flash-0731"); @@ -208,11 +215,12 @@ describe("eval-session request contract", () => { })))).toBe("17"); }); - it("requires explicit Pi diagnosis and accepts both qualified remote provider profiles", () => { - expect(() => parseEvalSessionRequest(request({ + it("accepts Pi and both qualified remote provider profiles", () => { + expect(parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: "pi", - }))).toThrow("--candidate-profile"); + model: "openrouter/deepseek/deepseek-v4-flash-0731", + }))).toMatchObject({ provider: "acpx", acpxAgent: "pi" }); expect(parseEvalSessionRequest(request({ provider: "aws_agentcore", driver: "aws_agentcore_harness_api", diff --git a/packages/paperclip-runner/src/cli/eval-session-contract.ts b/packages/paperclip-runner/src/cli/eval-session-contract.ts index d1446cb4ac..7b499a2ae6 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.ts @@ -262,7 +262,7 @@ export function parseEvalSessionRequest( if (options.candidateProfile !== undefined && (provider !== "acpx" || acpxAgent !== options.candidateProfile || !candidate)) { throw new Error("--candidate-profile must match the request's registered candidate ACPX agent"); } - if (candidate && options.candidateProfile !== acpxAgent) { + if (candidate && acpxAgent !== "pi" && options.candidateProfile !== acpxAgent) { throw new Error("Candidate ACPX profiles require an explicit matching --candidate-profile diagnostic flag"); } const managedProfileInput = input.managedProfile === null diff --git a/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts index 45ed92775f..e91425b7b5 100644 --- a/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts @@ -53,7 +53,7 @@ export const ACPX_CAPABILITY_PROFILES: Readonly { model: "openrouter/deepseek/deepseek-v4-flash-0731", }), ).toMatchObject({ - ok: false, - issues: [{ path: "agent", code: "qualification_pending" }], + ok: true, + config: { agent: "pi", permissionMode: "approve-all" }, }); + for (const agent of ["cursor", "copilot"]) { + expect(validateAcpxDriverConfig({ agent, model: "explicit-model" })) + .toMatchObject({ ok: false, issues: [{ path: "agent", code: "qualification_pending" }] }); + } + expect(validateAcpxDriverConfig({ agent: "pi", model: "another-model" })) + .toMatchObject({ ok: false, issues: [{ path: "model", code: "invalid_model" }] }); expect( validateAcpxDriverConfig({ agent: "claude", diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index 237de315b4..a55df5bf27 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -2098,3 +2098,10 @@ 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/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 98e7dc02f9..e8feab5ff5 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -48,7 +48,6 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< acpxVersion: QUALIFIED_ACPX_VERSION, agent: "pi", agentProfileVersion: 11, - qualificationStatus: "pending", agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", agentRuntimePackage: "@earendil-works/pi-coding-agent", diff --git a/packages/paperclip-runner/src/live/index.ts b/packages/paperclip-runner/src/live/index.ts index 22b2bc5e37..d6abeefc91 100644 --- a/packages/paperclip-runner/src/live/index.ts +++ b/packages/paperclip-runner/src/live/index.ts @@ -5,4 +5,4 @@ export * from "./durable-live-session-store.js"; export * from "./runnerd-codex-transport.js"; export * from "./turn-stream.js"; -export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation } from "../drivers/acpx/installation-integrity.js"; +export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxPiInstallation } from "../drivers/acpx/installation-integrity.js"; diff --git a/packages/paperclip-runner/src/live/live-session.test.ts b/packages/paperclip-runner/src/live/live-session.test.ts index bc3195bbcf..bfb415d3fa 100644 --- a/packages/paperclip-runner/src/live/live-session.test.ts +++ b/packages/paperclip-runner/src/live/live-session.test.ts @@ -27,17 +27,30 @@ import { persistedCursorUsageNotice } from "../drivers/acpx/usage-accounting.js" import { captureTurnRejection } from "../../test/capture-turn-rejection.js"; import * as workspaceDiff from "./workspace-diff.js"; -it.each(["pi", "cursor", "copilot"] as const)("requires separately bound evaluation opt-in for %s", async (acpxAgent) => { +it.each(["cursor", "copilot"] as const)("requires separately bound evaluation opt-in for %s", async (acpxAgent) => { const service = new CapabilityLiveSessionService(); await expect(service.create({ provider: "acpx", acpxAgent, requestedModel: "explicit-model" })) .rejects.toThrow("explicit evaluation opt-in"); const mismatched = new CapabilityLiveSessionService({ transportOptions: { - acpxCandidateProfile: acpxAgent === "pi" ? "cursor" : "pi", + acpxCandidateProfile: "pi", } }); await expect(mismatched.create({ provider: "acpx", acpxAgent, requestedModel: "explicit-model" })) .rejects.toThrow("explicit evaluation opt-in"); }); +it("admits Pi live sessions without candidate opt-in while preserving the exact profile", async () => { + const service = new CapabilityLiveSessionService({ transportFactory: fakeTransportFactory(providerState()) }); + const session = await service.create({ provider: "acpx", acpxAgent: "pi", requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731" }); + try { + expect(session.snapshot().config.acpxProfile).toMatchObject({ + agent: "pi", agentProfileVersion: 11, + commandDigest: "sha256:5e276f48c8a87b3e6165369faac62d3925282c84b98934575b1b7b97ad50b309", + }); + await expect(service.create({ provider: "acpx", acpxAgent: "pi", requestedModel: "another-model" })) + .rejects.toThrow("requires exact model"); + } finally { await session.shutdown("test complete"); } +}); + class AsyncNotifications implements AsyncIterable { #values: CodexRpcNotification[] = []; #waiters: Array<(value: IteratorResult) => void> = []; diff --git a/packages/paperclip-runner/src/live/live-session.ts b/packages/paperclip-runner/src/live/live-session.ts index 854048859b..b49a04c543 100644 --- a/packages/paperclip-runner/src/live/live-session.ts +++ b/packages/paperclip-runner/src/live/live-session.ts @@ -902,7 +902,7 @@ export class CapabilityLiveSessionService { async create(input: CreateCapabilityLiveSessionInput = {}): Promise { if (input.provider === "acpx" && input.acpxAgent !== undefined - && ["pi", "cursor", "copilot"].includes(input.acpxAgent) + && ["cursor", "copilot"].includes(input.acpxAgent) && this.#transportOptions.acpxCandidateProfile !== input.acpxAgent) { throw new Error("The candidate ACPX profile requires explicit evaluation opt-in"); } diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 16b283713b..fdeb02451f 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -2016,6 +2016,21 @@ it.each(["opencode", "acpx"] as const)( }, ); +it("admits Pi runnerd transport without candidate opt-in and keeps siblings gated", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-pi-production-admission-")); + const { transport } = createCapabilityRunnerdCodexTransport({ provider: "acpx", acpxAgent: "pi", stateDirectory: root }); + try { + await expect(transport.request("collaborationMode/list", {})).resolves.toMatchObject({ data: [{ mode: "plan" }] }); + for (const acpxAgent of ["cursor", "copilot"] as const) { + expect(() => createCapabilityRunnerdCodexTransport({ provider: "acpx", acpxAgent, stateDirectory: root })) + .toThrow("explicit evaluation opt-in"); + } + } finally { + await transport.close(); + await rm(root, { recursive: true, force: true }); + } +}); + it("allows trusted package-manager runtime roots without exposing HOME paths", () => { expect( trustedRuntimeReadOnlyRoots({ diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 3ffe833d33..58514b613b 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3518,7 +3518,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { throw new Error("native_adopted_runner_state_directory_required"); } if (options.provider === "acpx" && options.acpxAgent !== undefined - && ["pi", "cursor", "copilot"].includes(options.acpxAgent) + && ["cursor", "copilot"].includes(options.acpxAgent) && options.acpxCandidateProfile !== options.acpxAgent) { throw new Error("The candidate ACPX profile requires explicit evaluation opt-in"); } diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 48539de9b4..730717d33f 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -1,4 +1,4 @@ -import { probeAcpxClaudeInstallation } from "@paperclipai/paperclip-runner/live"; +import { probeAcpxClaudeInstallation, probeAcpxPiInstallation } from "@paperclipai/paperclip-runner/live"; import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils"; import type { ServerAdapterModule } from "../adapters/index.js"; @@ -20,6 +20,7 @@ import { vi.mock("@paperclipai/paperclip-runner/live", () => ({ probeAcpxClaudeInstallation: vi.fn(async () => undefined), probeAcpxGrokInstallation: vi.fn(async () => undefined), + probeAcpxPiInstallation: vi.fn(async () => undefined), })); const externalAdapter: ServerAdapterModule = { @@ -318,7 +319,10 @@ describe("server adapter registry", () => { }); }); - it("keeps the ACPX Pi profile unavailable", async () => { + it.each([true, false])("probes the exact Pi installation without qualification opt-in (ready=%s)", async (ready) => { + const probe = vi.mocked(probeAcpxPiInstallation); + if (ready) probe.mockResolvedValueOnce(undefined); + else probe.mockRejectedValueOnce(new Error("Pi closure unavailable")); const result = await requireServerAdapter("paperclip_runner").testEnvironment({ companyId: "company-1", adapterType: "paperclip_runner", @@ -330,9 +334,10 @@ describe("server adapter registry", () => { }); expect(result).toMatchObject({ - status: "fail", - checks: [{ code: "paperclip_runner_acpx_agent_unavailable" }], + status: ready ? "pass" : "fail", + checks: [{ code: ready ? "acpx_runtime_ready" : "acpx_runtime_unavailable" }], }); + expect(probe).toHaveBeenLastCalledWith("openrouter/deepseek/deepseek-v4-flash-0731"); }); it("reports qualification-only readiness for an exact host-authorized candidate", async () => { const key = "PAPERCLIP_RUNNER_ACPX_QUALIFICATION"; diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index da8be96f89..9fe15ad220 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -404,7 +404,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { }; } if (profile.provider === "acpx") { - if (["cursor", "copilot", "pi"].includes(profile.acpxAgent)) { + if (["cursor", "copilot"].includes(profile.acpxAgent)) { // The profile resolver already validated the isolated host's exact // qualification pair. Do not report a production readiness pass. return { @@ -414,7 +414,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { }; } try { - if (profile.acpxAgent !== "claude" && profile.acpxAgent !== "grok") throw new Error("Select Codex to use the native Codex runner."); + if (profile.acpxAgent !== "claude" && profile.acpxAgent !== "grok" && profile.acpxAgent !== "pi") throw new Error("Select Codex to use the native Codex runner."); const target = context.executionTarget; if (target?.kind === "remote") { const probe = await runAdapterExecutionTargetShellCommand( @@ -432,8 +432,9 @@ const paperclipRunnerAdapter: ServerAdapterModule = { message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }], }; } - const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation } = await import("@paperclipai/paperclip-runner/live"); - await (profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : probeAcpxClaudeInstallation)(profile.model); + const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxPiInstallation } = await import("@paperclipai/paperclip-runner/live"); + await (profile.acpxAgent === "pi" ? probeAcpxPiInstallation + : profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : probeAcpxClaudeInstallation)(profile.model); return { adapterType: "paperclip_runner", status: "pass" as const, testedAt: new Date().toISOString(), checks: [{ code: "acpx_runtime_ready", level: "info" as const, message: `ACPX ${profile.acpxAgent} runtime is installed and verified. Model access is checked when it runs.` }], @@ -519,7 +520,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { ) : buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.156.0"), agentConfigurationDoc: - "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build through the Rust Paperclip runner and authenticated PRP transport. Cursor, GitHub Copilot, and Pi are awaiting local and Daytona qualification and are not enabled for production runs. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", + "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build/Pi through the Rust Paperclip runner and authenticated PRP transport. Pi requires the exact openrouter/deepseek/deepseek-v4-flash-0731 model and a bound OPENROUTER_API_KEY. Cursor and GitHub Copilot are awaiting local and Daytona qualification and are not enabled for production runs. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", getConfigSchema: () => ({ fields: [ { diff --git a/server/src/services/heartbeat-runner-provider-config.test.ts b/server/src/services/heartbeat-runner-provider-config.test.ts index 6e84cd2e24..6d5a81c4a9 100644 --- a/server/src/services/heartbeat-runner-provider-config.test.ts +++ b/server/src/services/heartbeat-runner-provider-config.test.ts @@ -447,12 +447,12 @@ describe("Paperclip Runner native provider configuration", () => { ).toThrow("provider changed after this run selected its native backend"); }); - it("rejects Pi before a native descriptor is persisted", () => { + it("rejects an unqualified Pi model before a native descriptor is persisted", () => { expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "pi-model" }, }), - ).toThrow("Pi is awaiting local and Daytona qualification"); + ).toThrow("requires exact model openrouter/deepseek/deepseek-v4-flash-0731"); }); }); diff --git a/server/src/services/native-runtime/acpx-qualification.test.ts b/server/src/services/native-runtime/acpx-qualification.test.ts index e8be873d29..61fa19564b 100644 --- a/server/src/services/native-runtime/acpx-qualification.test.ts +++ b/server/src/services/native-runtime/acpx-qualification.test.ts @@ -8,7 +8,7 @@ const provider = { kind: "acpx", agent: "cursor", model: "exact-model", permissi const authorize = (value: unknown) => ({ [ACPX_QUALIFICATION_ENV]: JSON.stringify(value) }); describe("host ACPX qualification admission", () => { afterEach(() => vi.unstubAllEnvs()); - it.each(["cursor", "copilot", "pi"])("admits %s through agent validation and native input only for the exact host pair", (agent) => { + it.each(["cursor", "copilot"])("admits %s through agent validation and native input only for the exact host pair", (agent) => { const config = { provider: "acpx", acpxAgent: agent, model: "exact-model" }; vi.stubEnv(ACPX_QUALIFICATION_ENV, undefined); expect(() => resolvePaperclipRunnerProviderProfile(config)).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_agent_unavailable" })); @@ -19,6 +19,21 @@ describe("host ACPX qualification admission", () => { expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model: "other-model" })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_qualification_invalid" })); expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model: "" })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_model_required" })); }); + it("admits only the exact Pi model without host authorization and preserves permission modes", () => { + const config = { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" }; + for (const authorization of [undefined, "malformed unrelated candidate authorization"]) { + vi.stubEnv(ACPX_QUALIFICATION_ENV, authorization); + expect(resolvePaperclipRunnerProviderProfile(config)).toMatchObject(config); + for (const acpxPermissionMode of [undefined, "approve-all", "approve-paperclip", "approve-reads", "deny-all"]) { + expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: { ...config, acpxPermissionMode } })) + .toMatchObject({ ...config, acpxPermissionMode: acpxPermissionMode ?? "approve-all" }); + } + for (const model of [undefined, "", "different-model"]) { + expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model })) + .toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_model_unqualified" })); + } + } + }); it("keeps normal candidate execution closed and admits only the exact operator pair", () => { expect(resolveAcpxQualification(provider, {})).toBeUndefined(); expect(resolveAcpxQualification(provider, authorize([{ agent: "cursor", model: "exact-model" }]))).toBe("cursor"); diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 8a19aa0c8d..03be140c4c 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1163,22 +1163,32 @@ describe("remote provider pack manifest", () => { const candidatePath = "provider-assets/pi/linux-x64"; await mkdir(join(root, candidatePath), { recursive: true }); await writeFile(join(root, candidatePath, "runtime"), "pinned runtime"); - const candidates = { pi: { version: "0.0.33", profileDigest: digest("profile"), - closureDigest: digest("closure"), qualification: "pending", path: candidatePath, + const candidates = { pi: { version: "1.0.0", profileDigest: digest("profile"), + closureDigest: digest("closure"), qualification: "qualified", path: candidatePath, sha256: sha256DirectoryTree(join(root, candidatePath)) } }; Object.assign(payload, { candidateProviders: candidates }); await writeManifest(); - expect(readRemoteProviderPackManifest(root).payload.candidateProviders?.pi?.qualification).toBe("pending"); + expect(readRemoteProviderPackManifest(root).payload.candidateProviders?.pi?.qualification).toBe("qualified"); await writeFile(join(root, candidatePath, "runtime"), "substitute runtime"); expect(() => readRemoteProviderPackManifest(root)).toThrow("candidate asset tree digest mismatch"); await writeFile(join(root, candidatePath, "runtime"), "pinned runtime"); - for (const invalid of [{ path: "../outside" }, { qualification: "qualified" }]) { + candidates.pi.qualification = "pending"; + await writeManifest(); + expect(readRemoteProviderPackManifest(root).payload.candidateProviders?.pi?.qualification).toBe("pending"); + candidates.pi.qualification = "qualified"; + for (const invalid of [{ path: "../outside" }, { qualification: "unknown" }]) { const original = { ...candidates.pi }; Object.assign(candidates.pi, invalid); await writeManifest(); expect(() => readRemoteProviderPackManifest(root)).toThrow("invalid candidate identity"); candidates.pi = original; } + for (const provider of ["cursor", "copilot"]) { + Object.assign(candidates, { [provider]: { ...candidates.pi, path: `provider-assets/${provider}/linux-x64` } }); + await writeManifest(); + expect(() => readRemoteProviderPackManifest(root)).toThrow("invalid candidate identity"); + delete (candidates as Record)[provider]; + } await writeManifest(); for (const [artifactName, substituteName] of [ ["nodeCommand", "productionLock"], @@ -8769,6 +8779,11 @@ describe("native process ownership", () => { }, "acpx_runtime", ], + [ + "Pi ACPX without candidate authorization", + { kind: "acpx", agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", permissionMode: "approve-reads" }, + "acpx_runtime", + ], ])( "admits the qualified %s provider", async (_name, provider, driverKind) => { @@ -8807,7 +8822,7 @@ describe("native process ownership", () => { }, ); - it.each(["pi", "cursor", "copilot"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => { + it.each(["cursor", "copilot"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => { const piExecution = { ...execution, binding: { ...execution.binding, runId: "run-acpx-pi-rejected" }, diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 8a5c12c4b5..d4f70ab8c2 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -7562,7 +7562,7 @@ async function executePaperclipNativeSessionWithinScope( } if ( input.execution.provider.kind === "acpx" && - ["pi", "cursor", "copilot"].includes(input.execution.provider.agent) && + ["cursor", "copilot"].includes(input.execution.provider.agent) && !resolveAcpxQualification(input.execution.provider, process.env) ) { throw new Error( @@ -9579,7 +9579,7 @@ type RemoteProviderPackManifest = { bridgeDigest: string; acpxProfileDigests: typeof REMOTE_PROVIDER_PACK_PROFILE_DIGESTS; candidateProviders?: Partial>; artifacts: { @@ -9755,7 +9755,10 @@ export function readRemoteProviderPackManifest( const expectedPath = `provider-assets/${provider}/${payload.target.platform}-${payload.target.architecture}`; if (!["cursor", "copilot", "pi"].includes(provider) || !candidate || Object.keys(candidate).some(key => !["version", "profileDigest", "closureDigest", "qualification", "path", "sha256"].includes(key)) - || candidate.qualification !== "pending" || candidate.path !== expectedPath + // Inventory metadata never grants admission; local profile declarations do. + // Preserve shared packs built before Pi promotion for other qualified providers. + || (candidate.qualification !== "pending" && !(provider === "pi" && candidate.qualification === "qualified")) + || candidate.path !== expectedPath || typeof candidate.version !== "string" || !candidate.version || candidate.version.length > 120 || !/^sha256:[a-f0-9]{64}$/.test(candidate.profileDigest) || !/^sha256:[a-f0-9]{64}$/.test(candidate.closureDigest) @@ -12633,7 +12636,9 @@ async function createRunnerdBackendWithinSessionClaim( ? { acpxAgent: input.execution.provider.agent, // Read only the server operator environment, never agent/runtime env. - acpxCandidateProfile: resolveAcpxQualification(input.execution.provider, process.env), + acpxCandidateProfile: input.execution.provider.agent === "pi" + ? undefined + : resolveAcpxQualification(input.execution.provider, process.env), acpxPermissionMode: input.execution.provider.permissionMode, acpxCursorMode: input.execution.provider.cursorMode, acpxPermissionModePinned: diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index d001bba8f6..757bde1322 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -25,6 +25,7 @@ export const QUALIFIED_ACPX_RUNNER_MODELS = { grok: "grok-4.7", claude: "claude-sonnet-5", codex: "gpt-5.6-sol", + pi: "openrouter/deepseek/deepseek-v4-flash-0731", } as const; export type QualifiedPaperclipRunnerAcpxAgent = @@ -462,14 +463,14 @@ export function resolvePaperclipRunnerProviderProfile( } throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_agent_unavailable", `${pendingAcpxProfile.label} is awaiting local and Daytona qualification. Its profile is not enabled for production runs.`); } - if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok") { + if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "pi") { throw new PaperclipRunnerProviderProfileError( "paperclip_runner_acpx_agent_unavailable", "Paperclip Runner ACPX requires a qualified agent profile.", ); } const qualifiedModel = QUALIFIED_ACPX_RUNNER_MODELS[acpxAgent]; - if (acpxAgent === "codex" && model !== qualifiedModel) { + if ((acpxAgent === "codex" || acpxAgent === "pi") && model !== qualifiedModel) { throw new PaperclipRunnerProviderProfileError( "paperclip_runner_acpx_model_unqualified", `Paperclip Runner ACPX ${acpxAgent} requires exact model ${qualifiedModel}.`, diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index b1c21dfef1..265ca8d8bc 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -34,6 +34,7 @@ const instructionsFileHint = "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime. Note: Codex may still auto-apply repo-scoped AGENTS.md files from the workspace."; const defaultOpenCodeRunnerModel = "openrouter/deepseek/deepseek-v4-flash-0731"; const defaultAcpxClaudeModel = "claude-sonnet-5"; +const defaultAcpxPiModel = "openrouter/deepseek/deepseek-v4-flash-0731"; const defaultClaudeManagedModel = "claude-sonnet-5"; const defaultAwsAgentCoreModel = "global.anthropic.claude-sonnet-4-6"; @@ -242,16 +243,17 @@ export function CodexLocalConfigFields({ )} {runnerManaged && runnerProvider === "acpx" && runnerSchemaValue("acpxAgent", "claude") !== "grok" && ( - +