From 26dde3cfe9519ceb4a08fc5538ff1e6f2b360f5b Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 14:39:56 -0500 Subject: [PATCH] Apply exact host ACP qualification at agent admission and execution Co-Authored-By: Paperclip --- server/src/__tests__/adapter-registry.test.ts | 15 +++++++++++++++ server/src/adapters/registry.ts | 9 +++++++++ .../native-runtime/acpx-qualification.test.ts | 15 ++++++++++++++- .../native-runtime/acpx-qualification.ts | 8 ++++---- .../native-session-executor.test.ts | 6 +++--- .../native-runtime/native-session-executor.ts | 5 +++-- .../services/native-runtime/provider-profile.ts | 17 +++++++++++++++-- 7 files changed, 63 insertions(+), 12 deletions(-) diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 39688126530..aaf5f7e7ce4 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -331,6 +331,21 @@ describe("server adapter registry", () => { checks: [{ code: "paperclip_runner_acpx_agent_unavailable" }], }); }); + it("reports qualification-only readiness for an exact host-authorized candidate", async () => { + const key = "PAPERCLIP_RUNNER_ACPX_QUALIFICATION"; + const previous = process.env[key]; + process.env[key] = JSON.stringify([{ agent: "cursor", model: "exact-model" }]); + try { + const result = await requireServerAdapter("paperclip_runner").testEnvironment({ + companyId: "company-1", adapterType: "paperclip_runner", + config: { provider: "acpx", acpxAgent: "cursor", model: "exact-model" }, + }); + expect(result).toMatchObject({ status: "warn", checks: [{ code: "acpx_candidate_qualification_only" }] }); + } finally { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } + }); it("wraps built-in npm runtime installs with the sandbox-aware install helper", () => { const expectedClaudeInstall = `if ! command -v 'claude' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@anthropic-ai/claude-code")}; fi`; const expectedCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex")}; fi`; diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index dd5b1933291..56f0ab6d704 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -404,6 +404,15 @@ const paperclipRunnerAdapter: ServerAdapterModule = { }; } if (profile.provider === "acpx") { + if (["cursor", "copilot", "pi"].includes(profile.acpxAgent)) { + // The profile resolver already validated the isolated host's exact + // qualification pair. Do not report a production readiness pass. + return { + adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(), + checks: [{ code: "acpx_candidate_qualification_only", level: "warn" as const, + message: "This exact candidate and model are admitted for operator-controlled qualification only. Verified runtime installation, bound credentials, and model access are checked before execution; production support remains pending." }], + }; + } try { if (profile.acpxAgent !== "claude") throw new Error("Select Codex to use the native Codex runner."); const target = context.executionTarget; diff --git a/server/src/services/native-runtime/acpx-qualification.test.ts b/server/src/services/native-runtime/acpx-qualification.test.ts index 19c4162ccc1..e8be873d29e 100644 --- a/server/src/services/native-runtime/acpx-qualification.test.ts +++ b/server/src/services/native-runtime/acpx-qualification.test.ts @@ -1,11 +1,24 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { readFileSync } from "node:fs"; import { ACPX_QUALIFICATION_ENV, resolveAcpxQualification } from "./acpx-qualification.js"; import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.js"; +import { resolvePaperclipRunnerProviderProfile, resolvePaperclipRunnerNativeProviderInput } from "./provider-profile.js"; const provider = { kind: "acpx", agent: "cursor", model: "exact-model", permissionMode: "approve-all" } as NativeExecutionInput["provider"]; 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) => { + 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" })); + expect(() => resolvePaperclipRunnerProviderProfile({ ...config, env: authorize([{ agent, model: config.model }]), [ACPX_QUALIFICATION_ENV]: JSON.stringify([{ agent, model: config.model }]) })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_agent_unavailable" })); + vi.stubEnv(ACPX_QUALIFICATION_ENV, JSON.stringify([{ agent, model: config.model }])); + expect(resolvePaperclipRunnerProviderProfile(config)).toMatchObject({ acpxAgent: agent, model: config.model }); + expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config })).toMatchObject({ acpxAgent: agent, model: config.model }); + 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("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/acpx-qualification.ts b/server/src/services/native-runtime/acpx-qualification.ts index 9295d41384d..76eb3cdd36f 100644 --- a/server/src/services/native-runtime/acpx-qualification.ts +++ b/server/src/services/native-runtime/acpx-qualification.ts @@ -2,12 +2,12 @@ import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.j /** Operator-only admission for an exact candidate/model during qualification. */ export const ACPX_QUALIFICATION_ENV = "PAPERCLIP_RUNNER_ACPX_QUALIFICATION"; -type Candidate = "cursor" | "copilot" | "pi"; +export type AcpxQualificationCandidate = "cursor" | "copilot" | "pi"; export function resolveAcpxQualification( - provider: NativeExecutionInput["provider"], + provider: NativeExecutionInput["provider"] | { kind: "acpx"; agent: string; model: string }, hostEnvironment: NodeJS.ProcessEnv, -): Candidate | undefined { +): AcpxQualificationCandidate | undefined { if (provider.kind !== "acpx" || !["cursor", "copilot", "pi"].includes(provider.agent)) return undefined; const encoded = hostEnvironment[ACPX_QUALIFICATION_ENV]; if (!encoded) return undefined; @@ -28,5 +28,5 @@ export function resolveAcpxQualification( if (!entries.some(entry => entry.agent === provider.agent && entry.model === provider.model)) { throw new Error("ACPX qualification requires the explicitly authorized candidate and exact model"); } - return provider.agent as Candidate; + return provider.agent as AcpxQualificationCandidate; } 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 b4667b0e965..9ca2b570efd 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -7639,11 +7639,11 @@ describe("native process ownership", () => { }, ); - it("rejects ACPX Pi before constructing a backend", async () => { + it.each(["pi", "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" }, - provider: { kind: "acpx", agent: "pi", model: "pi-model" }, + provider: { kind: "acpx", agent, model: "pi-model" }, session: { ...execution.session, driverKind: "acpx_runtime" }, } as unknown as NativeExecutionInputV1; state.createBackend.mockClear(); @@ -7654,7 +7654,7 @@ describe("native process ownership", () => { execution: piExecution, runnerInstanceId: "runner", }), - ).rejects.toThrow("descriptor-confined verified launch"); + ).rejects.toThrow("exact host qualification authorization"); expect(state.createBackend).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index be680b6eea3..4e512e6be42 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -7297,10 +7297,11 @@ async function executePaperclipNativeSessionWithinScope( } if ( input.execution.provider.kind === "acpx" && - input.execution.provider.agent === "pi" + ["pi", "cursor", "copilot"].includes(input.execution.provider.agent) && + !resolveAcpxQualification(input.execution.provider, process.env) ) { throw new Error( - "paperclip_runner_provider_unsupported: ACPX Pi is unavailable until descriptor-confined verified launch is implemented", + "paperclip_runner_provider_unsupported: ACPX candidate requires exact host qualification authorization", ); } const preparationSpans = input.preparationSpans ?? []; diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index fb8cf5ed139..5d860b235f4 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -9,6 +9,7 @@ import { AGENTCORE_QUALIFIED_MODEL, CLAUDE_MANAGED_QUALIFIED_MODEL, } from "../provider-profile-qualification.js"; +import { resolveAcpxQualification, type AcpxQualificationCandidate } from "./acpx-qualification.js"; export const QUALIFIED_OPENCODE_RUNNER_VERSION = "1.18.32" as const; export const DEFAULT_OPENCODE_RUNNER_MODEL = @@ -22,6 +23,7 @@ export const QUALIFIED_ACPX_RUNNER_MODELS = { export type QualifiedPaperclipRunnerAcpxAgent = keyof typeof QUALIFIED_ACPX_RUNNER_MODELS; +type AdmittedPaperclipRunnerAcpxAgent = QualifiedPaperclipRunnerAcpxAgent | AcpxQualificationCandidate; export type PaperclipRunnerProviderProfile = | { @@ -52,7 +54,7 @@ export type PaperclipRunnerProviderProfile = provider: "acpx"; backend: "acpx_runtime"; model: string; - acpxAgent: QualifiedPaperclipRunnerAcpxAgent; + acpxAgent: AdmittedPaperclipRunnerAcpxAgent; }; export type PaperclipRunnerNativeProviderInput = @@ -109,7 +111,7 @@ export type PaperclipRunnerNativeProviderInput = | { provider: "acpx"; model: string; - acpxAgent: QualifiedPaperclipRunnerAcpxAgent; + acpxAgent: AdmittedPaperclipRunnerAcpxAgent; acpxPermissionMode: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; }; @@ -408,6 +410,17 @@ export function resolvePaperclipRunnerProviderProfile( const pendingAcpxProfile = PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === acpxAgent && !profile.qualified); if (pendingAcpxProfile) { if (!model) throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", `${pendingAcpxProfile.label} requires an explicit model ID; there is no default model.`); + // Qualification authority belongs to the isolated server operator. Never + // read this allowlist from adapter config, credential refs, or run env. + let qualification: AcpxQualificationCandidate | undefined; + try { + qualification = resolveAcpxQualification({ kind: "acpx", agent: pendingAcpxProfile.value, model }, process.env); + } catch { + throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_qualification_invalid", "ACPX qualification requires an exact host-authorized candidate and model."); + } + if (qualification) { + return { provider: "acpx", backend: "acpx_runtime", model, acpxAgent: qualification }; + } 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") {