mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Apply exact host ACP qualification at agent admission and execution
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
97bd3112de
commit
26dde3cfe9
7 files changed
+63
-12
No files matched your search
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in new issue
Block a user