From da6124adf253f0048826e3f2003e8afab037b8e1 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:07:45 -0500 Subject: [PATCH] feat(runner): declare ACPX driver profile (#12390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A native driver must declare its supported behavior before the coordinator can select it. > - ACP-compatible agents do not all expose the same event families. > - A loose config could also select an unqualified model, permission policy, or executable field. > - This pull request defines the ACPX descriptor, capability matrix, and strict configuration parser. > - The benefit is one reviewable admission contract before any ACPX runtime is wired. ## Linked Issues or Issue Description **Agent or provider** Qualified Pi, Claude, and Codex ACP servers through the internal ACPX driver. **Why this adapter is useful** The runner needs a truthful capability descriptor and a closed configuration boundary before it can create an ACPX session. The boundary must reject arbitrary commands and unqualified models. **How the agent is invoked** A later pull request will implement the private runtime behind this descriptor. This pull request does not launch a process, add a dependency, register an adapter, or make ACPX selectable. **Additional context** This pull request is stacked on #12389. Pi reports plan events as unsupported. Claude and Codex report structured plan support. ## What Changed - Add the ACPX driver descriptor and native runtime-context capability declaration. - Add an agent-specific typed event capability matrix. - Add strict config validation for agent, exact qualified model, and permission mode. - Default the permission mode to `approve-all` when the field is absent. - Reject non-object config, unknown fields, unqualified models, and unsupported permission values. - Add table-driven tests for all qualified agents and failure cases. ## Verification - Runner TypeScript typecheck — passed. - Runner TypeScript tests — passed, including 8 new driver-profile assertions. - `pnpm -r typecheck` — passed for all applicable workspaces. - `pnpm build` — passed, including runner binary, server, UI, and workspace packages. - Prettier and `git diff --check` — passed. - The diff contains 2 files and does not change `pnpm-lock.yaml`, a workflow, a dependency, a public export, server selection, or UI behavior. ## Risks The main risk is advertising a capability that a qualified ACP server cannot supply. The matrix is explicit per agent, and tests cover the one current difference. Config validation rejects unknown fields so a caller cannot smuggle an executable or an unsupported runtime setting into this boundary. ## Model Used OpenAI Codex with GPT-5 and repository tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked an existing public item or described the issue in this PR - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal task identifier - [x] I have run the affected tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have documented the compatibility and admission boundary - [ ] All applicable GitHub Actions are green - [ ] Greptile is 5/5 with every actionable comment resolved - [x] I will address all review findings before requesting merge --- .../src/drivers/acpx/driver-profile.test.ts | 104 +++++++++++ .../src/drivers/acpx/driver-profile.ts | 165 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/driver-profile.ts diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts new file mode 100644 index 0000000000..44c3ded2b2 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { + acpxCapabilities, + acpxDriverDescriptor, + validateAcpxDriverConfig, +} from "./driver-profile.js"; + +describe("ACPX driver profile", () => { + it.each([ + ["codex", "available"], + ["claude", "available"], + ["pi", "unsupported"], + ] as const)( + "advertises structured plans for %s as %s", + (agent, availability) => { + const plan = acpxCapabilities(agent).typedEventFamilies?.find( + (family) => family.family === "plan", + ); + expect(plan).toMatchObject({ + availability, + detailLevel: availability === "available" ? "structured" : "summary", + }); + }, + ); + + it("describes only implemented ACPX capability boundaries", () => { + expect(acpxDriverDescriptor("claude")).toMatchObject({ + kind: "acpx_runtime", + displayName: "Claude via ACPX", + version: "0.13.1", + protocolVersion: "acp/v1", + runtimeContextCapabilities: { + instructions: "native", + skills: "native", + mcp: "native", + }, + capabilities: { + resume: true, + steering: false, + interruption: true, + dynamicTools: true, + runtimeRequestResolution: true, + }, + }); + }); + + it.each([ + ["pi", "openrouter/deepseek/deepseek-v4-flash-0731"], + ["claude", "claude-sonnet-5"], + ["codex", "gpt-5.6-sol"], + ] as const)("accepts the exact qualified %s model", (agent, model) => { + expect(validateAcpxDriverConfig({ agent, model })).toEqual({ + ok: true, + config: { agent, model, permissionMode: "approve-all" }, + issues: [], + }); + }); + + it("fails closed for unknown fields and unqualified settings", () => { + expect(validateAcpxDriverConfig(null)).toMatchObject({ + ok: false, + issues: [{ code: "invalid_config" }], + }); + expect( + validateAcpxDriverConfig({ + agent: "codex", + model: "gpt-5.6-sol", + command: "/tmp/arbitrary-provider", + }), + ).toMatchObject({ + ok: false, + issues: [{ path: "command", code: "unknown_field" }], + }); + expect( + validateAcpxDriverConfig({ agent: "codex", model: "other" }), + ).toMatchObject({ + ok: false, + issues: [{ path: "model", code: "invalid_model" }], + }); + expect( + validateAcpxDriverConfig({ + agent: "claude", + model: "claude-sonnet-5", + permissionMode: "unrestricted", + }), + ).toMatchObject({ + ok: false, + issues: [{ path: "permissionMode", code: "invalid_permission_mode" }], + }); + for (const permissionMode of ["", 42, undefined]) { + expect( + validateAcpxDriverConfig({ + agent: "claude", + model: "claude-sonnet-5", + permissionMode, + }), + ).toMatchObject({ + ok: false, + issues: [{ path: "permissionMode", code: "invalid_permission_mode" }], + }); + } + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts new file mode 100644 index 0000000000..98abd458dc --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts @@ -0,0 +1,165 @@ +import type { + HarnessDriverConfigValidation, + HarnessDriverDescriptor, +} from "../../contracts/harness-driver.js"; +import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js"; +import type { NativeSessionCapabilities } from "../../contracts/types.js"; +import { providerFamilyCapabilities } from "../../provider-events.js"; +import { + ACPX_DRIVER_KIND, + QUALIFIED_ACPX_VERSION, + resolveQualifiedAcpxProfile, + type QualifiedAcpxAgent, +} from "./qualified-profiles.js"; + +const ACPX_AGENTS = ["pi", "claude", "codex"] as const; +const ACPX_PERMISSION_MODES = [ + "approve-all", + "approve-reads", + "deny-all", +] as const; +const ACPX_CONFIG_FIELDS = new Set(["agent", "model", "permissionMode"]); + +export interface ValidatedAcpxDriverConfig extends Record { + agent: QualifiedAcpxAgent; + model: string; + permissionMode: NativeAcpxPermissionMode; +} + +export function acpxCapabilities( + agent: QualifiedAcpxAgent, +): NativeSessionCapabilities { + return { + resume: true, + typedEvents: true, + typedEventFamilies: providerFamilyCapabilities({ + plan: agent === "pi" ? "unsupported" : "available", + tool_execution: "available", + model_identity: "available", + review: "available", + provider_notice: "available", + artifact: "policy_disabled", + }), + steering: false, + interruption: true, + structuredResult: true, + read: true, + reconciliation: true, + usage: true, + dynamicTools: true, + runtimeRequestResolution: true, + runtimeRequestHandoff: true, + goals: false, + threadLineage: false, + unsupported: ["steering", "goals", "threadLineage"], + }; +} + +export function acpxDriverDescriptor( + agent: QualifiedAcpxAgent, +): HarnessDriverDescriptor { + return { + kind: ACPX_DRIVER_KIND, + displayName: `${displayAgent(agent)} via ACPX`, + version: QUALIFIED_ACPX_VERSION, + protocolVersion: "acp/v1", + runtimeContextCapabilities: { + instructions: "native", + skills: "native", + mcp: "native", + }, + capabilities: acpxCapabilities(agent), + }; +} + +export function validateAcpxDriverConfig( + value: unknown, +): HarnessDriverConfigValidation { + const config = record(value); + if (config === null) { + return invalid("", "invalid_config", "ACPX config must be an object."); + } + const unknownField = Object.keys(config).find( + (field) => !ACPX_CONFIG_FIELDS.has(field), + ); + if (unknownField !== undefined) { + return invalid( + unknownField, + "unknown_field", + `ACPX config does not support ${unknownField}.`, + ); + } + + const agent = text(config.agent); + if (!isAcpxAgent(agent)) { + return invalid( + "agent", + "invalid_agent", + "ACPX agent must be pi, claude, or codex.", + ); + } + const model = text(config.model); + try { + resolveQualifiedAcpxProfile(agent, model); + } catch (error) { + return invalid("model", "invalid_model", safeErrorMessage(error)); + } + const permissionMode = Object.prototype.hasOwnProperty.call( + config, + "permissionMode", + ) + ? text(config.permissionMode) + : "approve-all"; + if (!isPermissionMode(permissionMode)) { + return invalid( + "permissionMode", + "invalid_permission_mode", + "ACPX permission mode must be approve-all, approve-reads, or deny-all.", + ); + } + + const validated: ValidatedAcpxDriverConfig = { + agent, + model, + permissionMode, + }; + return { ok: true, config: validated, issues: [] }; +} + +function invalid( + path: string, + code: string, + message: string, +): HarnessDriverConfigValidation { + return { ok: false, config: null, issues: [{ path, code, message }] }; +} + +function isAcpxAgent(value: string): value is QualifiedAcpxAgent { + return (ACPX_AGENTS as readonly string[]).includes(value); +} + +function isPermissionMode(value: string): value is NativeAcpxPermissionMode { + return (ACPX_PERMISSION_MODES as readonly string[]).includes(value); +} + +function displayAgent(agent: QualifiedAcpxAgent): string { + if (agent === "pi") return "Pi"; + if (agent === "claude") return "Claude"; + return "Codex"; +} + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function text(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function safeErrorMessage(error: unknown): string { + return error instanceof Error + ? error.message.slice(0, 1_000) + : "Invalid model."; +}