mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 20:50:08 +02:00
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:
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": {
|
||||
|
||||
Reference in new issue
Block a user