feat(runner): declare ACPX driver profile (#12390)

## 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
This commit is contained in:
Dotta 2026-08-30 13:07:45 -05:00 committed by GitHub
parent 7a8c6825a5
commit da6124adf2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 269 additions and 0 deletions

View File

@ -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" }],
});
}
});
});

View File

@ -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<string, unknown> {
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<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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.";
}