Declare rich ACP profile capabilities and qualification gates

Keep Cursor, Copilot and Pi unavailable until qualification; require explicit new-provider models without substitution and bind new native candidates to versioned profiles.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-28 11:14:45 -05:00
1 parent af61365ea5
commit 384ea84546
9 files changed
+113 -22

No files matched your search

@@ -125,7 +125,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = {
value: "approve-reads",
label: "Allow Paperclip reads",
description:
"Automatically allow assigned Paperclip read tools. Other operations stop with an approval-required message because this runner has no interactive approval handler.",
"Automatically allow assigned Paperclip read tools. Other operations request a supported permission decision. Company permissions and execution boundaries still apply.",
},
{
value: "deny-all",
@@ -65,7 +65,7 @@
{
"path": "schemas/provider-descriptor.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json",
"sha256": "a56c0c8501171c34bb72f6fba08d3872839a8b45bbc10daa93620afc9dc2b669"
"sha256": "cb0f96f789536bb330aa6a773d8e5726fbbb844e6d5e2bad918ea9a5c18ff024"
},
{
"path": "schemas/provider-event.schema.json",
@@ -18,7 +18,7 @@
"endpointQualifier": { "type": "string", "minLength": 1, "maxLength": 240 },
"memoryId": { "type": "string", "minLength": 1, "maxLength": 240 },
"eventExpiryDays": { "const": 90 },
"agent": { "enum": ["pi", "claude", "codex"] },
"agent": { "enum": ["pi", "claude", "codex", "cursor", "copilot"] },
"requestedModel": { "type": "string", "minLength": 1, "maxLength": 240 },
"acpProtocolVersion": { "const": 1 },
"agentServerPackage": { "type": "string", "minLength": 1, "maxLength": 240 },
@@ -77,7 +77,7 @@ export interface NativeAwsAgentCoreProfileSnapshot {
eventExpiryDays: 90;
}
export type NativeAcpxAgent = "pi" | "claude" | "codex";
export type NativeAcpxAgent = "pi" | "claude" | "codex" | "cursor" | "copilot";
export type NativeCodexApprovalPolicy = "never" | "on-request" | "untrusted";
export type NativeOpenCodePermissionMode = "allow" | "ask" | "deny";
export type NativeAcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
@@ -87,7 +87,7 @@ export interface NativeAcpxProfileSnapshot {
protocolVersion: 1;
acpxVersion: "0.13.1";
agent: NativeAcpxAgent;
agentProfileVersion: 1;
agentProfileVersion: 1 | 2;
agentServerPackage: string;
agentServerVersion: string;
agentRuntimePackage: string | null;
@@ -530,8 +530,8 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
if (providerModel === null) {
throw new NativeExecutionInputError("input.provider.model is required for acpx");
}
if (provider.agent !== "pi" && provider.agent !== "claude" && provider.agent !== "codex") {
throw new NativeExecutionInputError("input.provider.agent must be pi, claude, or codex");
if (provider.agent !== "pi" && provider.agent !== "claude" && provider.agent !== "codex" && provider.agent !== "cursor" && provider.agent !== "copilot") {
throw new NativeExecutionInputError("input.provider.agent must be pi, claude, codex, cursor, or copilot");
}
if (isV4) {
if (provider.permissionMode !== "approve-all" && provider.permissionMode !== "approve-paperclip" && provider.permissionMode !== "approve-reads" && provider.permissionMode !== "deny-all") {
@@ -558,7 +558,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|| profile.protocolVersion !== 1
|| profile.acpxVersion !== "0.13.1"
|| profile.agent !== provider.agent
|| profile.agentProfileVersion !== 1
|| (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2)
) {
throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile");
}
@@ -579,7 +579,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
protocolVersion: 1,
acpxVersion: "0.13.1",
agent: provider.agent,
agentProfileVersion: 1,
agentProfileVersion: profile.agentProfileVersion,
agentServerPackage: text(profile.agentServerPackage, "input.provider.profile.agentServerPackage"),
agentServerVersion: text(profile.agentServerVersion, "input.provider.profile.agentServerVersion"),
agentRuntimePackage: runtimePackage,
@@ -0,0 +1,61 @@
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
export interface AcpxCapabilityProfile {
readonly displayName: string;
readonly qualification: "qualified" | "pending";
readonly models: "explicit-provider-verified" | "exact-qualified";
readonly permissions: "runner-policy" | "interactive";
readonly questions: "form" | "cursor-extension" | "not-exposed";
readonly plans: "native" | "cursor-decision" | "semantic-only";
readonly tools: "authenticated-mcp" | "owned-extension";
readonly recovery: "session-load" | "unverified";
readonly usage: "reported" | "unverified";
readonly steering: "unsupported" | "owned-extension-pending";
readonly followUp: "controller-queue" | "owned-extension-pending";
readonly artifacts: "policy_disabled" | "references-pending";
readonly extensionRequests: readonly string[];
readonly extensionNotifications: readonly string[];
}
/** These are runner integration claims, not a proxy for everything a harness can do. */
export const ACPX_CAPABILITY_PROFILES: Readonly<Record<QualifiedAcpxAgent, AcpxCapabilityProfile>> = {
claude: {
displayName: "Claude", qualification: "qualified", models: "explicit-provider-verified",
permissions: "interactive", questions: "form", plans: "native", tools: "authenticated-mcp",
recovery: "session-load", usage: "reported", steering: "unsupported", followUp: "controller-queue",
artifacts: "policy_disabled", extensionRequests: [], extensionNotifications: [],
},
codex: {
displayName: "Codex", qualification: "qualified", models: "exact-qualified",
permissions: "runner-policy", questions: "form", plans: "native", tools: "authenticated-mcp",
recovery: "session-load", usage: "reported", steering: "unsupported", followUp: "controller-queue",
artifacts: "policy_disabled", extensionRequests: [], extensionNotifications: [],
},
cursor: {
displayName: "Cursor", qualification: "pending", models: "explicit-provider-verified",
permissions: "interactive", questions: "cursor-extension", plans: "cursor-decision", tools: "authenticated-mcp",
recovery: "session-load", usage: "unverified", steering: "unsupported", followUp: "controller-queue",
artifacts: "references-pending",
extensionRequests: ["cursor/ask_question", "cursor/create_plan", "cursor/update_todos", "cursor/task", "cursor/generate_image"],
extensionNotifications: ["cursor/update_todos", "cursor/task", "cursor/generate_image"],
},
copilot: {
displayName: "GitHub Copilot", qualification: "pending", models: "explicit-provider-verified",
permissions: "interactive", questions: "not-exposed", plans: "native", tools: "authenticated-mcp",
recovery: "session-load", usage: "reported", steering: "unsupported", followUp: "controller-queue",
artifacts: "references-pending", extensionRequests: [],
extensionNotifications: ["github.com/copilot/sessionEvent"],
},
pi: {
displayName: "Pi", qualification: "pending", models: "exact-qualified",
permissions: "interactive", questions: "form", plans: "semantic-only", tools: "owned-extension",
recovery: "session-load", usage: "reported", steering: "owned-extension-pending", followUp: "owned-extension-pending",
artifacts: "references-pending", extensionRequests: [], extensionNotifications: [],
},
};
for (const profile of Object.values(ACPX_CAPABILITY_PROFILES)) {
Object.freeze(profile.extensionRequests);
Object.freeze(profile.extensionNotifications);
Object.freeze(profile);
}
Object.freeze(ACPX_CAPABILITY_PROFILES);
@@ -84,7 +84,7 @@ describe("ACPX driver profile", () => {
}),
).toMatchObject({
ok: false,
issues: [{ path: "agent", code: "invalid_agent" }],
issues: [{ path: "agent", code: "qualification_pending" }],
});
expect(
validateAcpxDriverConfig({
@@ -12,7 +12,9 @@ import {
type QualifiedAcpxAgent,
} from "./qualified-profiles.js";
const ACPX_AGENTS = ["claude", "codex"] as const;
import { ACPX_CAPABILITY_PROFILES } from "./capability-profiles.js";
const ACPX_AGENTS = ["claude", "codex", "pi", "cursor", "copilot"] as const;
const ACPX_PERMISSION_MODES = [
"approve-all",
"approve-paperclip",
@@ -30,11 +32,12 @@ export interface ValidatedAcpxDriverConfig extends Record<string, unknown> {
export function acpxCapabilities(
agent: QualifiedAcpxAgent,
): NativeSessionCapabilities {
const profile = ACPX_CAPABILITY_PROFILES[agent];
return {
resume: true,
resume: profile.recovery === "session-load",
typedEvents: true,
typedEventFamilies: providerFamilyCapabilities({
plan: agent === "pi" ? "unsupported" : "available",
plan: profile.plans === "semantic-only" ? "unsupported" : "available",
tool_execution: "available",
model_identity: "available",
review: "available",
@@ -46,9 +49,9 @@ export function acpxCapabilities(
structuredResult: true,
read: true,
reconciliation: true,
usage: true,
usage: profile.usage === "reported",
dynamicTools: true,
runtimeRequestResolution: true,
runtimeRequestResolution: profile.permissions === "interactive" || profile.questions === "form",
runtimeRequestHandoff: true,
goals: false,
threadLineage: false,
@@ -96,9 +99,12 @@ export function validateAcpxDriverConfig(
return invalid(
"agent",
"invalid_agent",
"ACPX agent must be claude or codex.",
"ACPX agent must be claude, codex, cursor, copilot, or pi.",
);
}
if (ACPX_CAPABILITY_PROFILES[agent].qualification !== "qualified") {
return invalid("agent", "qualification_pending", `${ACPX_CAPABILITY_PROFILES[agent].displayName} requires local and Daytona qualification before use.`);
}
const model = text(config.model);
try {
resolveQualifiedAcpxProfile(agent, model);
@@ -144,9 +150,7 @@ function isPermissionMode(value: string): value is NativeAcpxPermissionMode {
}
function displayAgent(agent: QualifiedAcpxAgent): string {
if (agent === "pi") return "Pi";
if (agent === "claude") return "Claude";
return "Codex";
return ACPX_CAPABILITY_PROFILES[agent].displayName;
}
function record(value: unknown): Record<string, unknown> | null {
@@ -11,7 +11,9 @@ export interface QualifiedAcpxProfile {
readonly protocolVersion: typeof ACPX_DRIVER_PROTOCOL_VERSION;
readonly acpxVersion: typeof QUALIFIED_ACPX_VERSION;
readonly agent: QualifiedAcpxAgent;
readonly agentProfileVersion: 1;
readonly agentProfileVersion: 1 | 2;
readonly qualificationStatus?: "pending";
readonly modelPolicy?: "explicit-provider-verified";
readonly agentServerPackage: string;
readonly agentServerVersion: string;
readonly agentRuntimePackage: string | null;
@@ -47,6 +49,28 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731",
permissionPolicy: "interactive",
},
cursor: {
driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
acpxVersion: QUALIFIED_ACPX_VERSION, agent: "cursor", agentProfileVersion: 2,
agentServerPackage: "cursor-agent", agentServerVersion: "2026.09.26-dd393fe",
agentRuntimePackage: null, agentRuntimeVersion: null,
commandDigest: "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899",
// Authenticated discovery has not established a qualification model. Never
// turn this empty declaration into a default; callers must select an ID.
qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive",
modelPolicy: "explicit-provider-verified", qualificationStatus: "pending",
},
copilot: {
driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 2,
agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88",
agentRuntimePackage: null, agentRuntimeVersion: null,
commandDigest: "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457",
// Authenticated discovery has not established a qualification model. Never
// turn this empty declaration into a default; callers must select an ID.
qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive",
modelPolicy: "explicit-provider-verified", qualificationStatus: "pending",
},
claude: {
driverKind: ACPX_DRIVER_KIND,
protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
@@ -87,7 +111,7 @@ export function resolveQualifiedAcpxProfile(
): QualifiedAcpxProfile {
const profile = QUALIFIED_ACPX_PROFILES[agent];
if (!requestedModel.trim()) throw new Error("ACPX model must not be empty");
if (agent !== "claude" && requestedModel !== profile.qualificationModel) {
if (agent !== "claude" && profile.modelPolicy !== "explicit-provider-verified" && requestedModel !== profile.qualificationModel) {
throw new Error(
`ACPX ${agent} profile requires exact model ${profile.qualificationModel}; received ${requestedModel}`,
);
@@ -650,7 +650,9 @@ export const providerDescriptorSchema = {
"enum": [
"pi",
"claude",
"codex"
"codex",
"cursor",
"copilot"
]
},
"requestedModel": {