feat(runner): define ACPX sidecar contract (#12386)

## Thinking Path

> - Paperclip Runner now has a complete guarded Codex vertical slice.
> - The next provider series must not start by importing a provider
implementation or dependency bundle.
> - ACPX needs one bounded, versioned process boundary shared by
TypeScript and Rust.
> - A schema is the authority; checked-in generated inventories keep
both languages in lockstep.
> - Unknown versions, commands, event types, and properties must fail
closed.
> - This pull request therefore lands only the sidecar wire contract and
its drift gate.
> - No ACPX runtime, dependency, executable, package export, or
production selection is added.

## Linked Issues or Issue Description

This is the first package-local unit in the post-Codex provider series.

**What happened?**

The integration branch contains an ACPX provider, but its TypeScript
sidecar and Rust client need a small shared authority before either
implementation can be reviewed safely. Importing the final integration
implementation directly would mix the protocol, runtime, third-party
dependencies, and production wiring.

**Expected behavior**

The schema defines every ACPX sidecar request, response, event, command,
event type, and protocol version. Generated TypeScript and Rust
inventories must drift-check against that schema. No runtime can select
or execute ACPX yet.

**Steps to reproduce**

1. Change the protocol version, command inventory, or event inventory in
the schema.
2. Run the runner protocol type check without regenerating the language
inventories.
3. Observe the drift gate fail.

**Paperclip version or commit**

Stacked on `runner-server-semantic-codex` at `ebd7f9df7`.

## What Changed

- Add the internal ACPX sidecar v2 JSON Schema outside the public PRP v1
schema catalog.
- Generate one TypeScript inventory and one Rust inventory from that
schema.
- Add generate and check hooks to the existing runner protocol-type
workflow.
- Add fail-closed AJV tests for all three message families, version
drift, unknown commands, and extra properties.
- Keep the generated Rust module unregistered until the Rust ACPX
transport exists.

## Compatibility Boundary

- Codex remains the only production runner provider.
- `paperclip_runner` selection and the default-off rollout flag are
unchanged.
- No ACPX package, patch, lockfile, binary entry point, root export,
server file, UI file, workflow, or dependency is added.
- The schema is shipped with the existing `protocol` directory but is
not added to the public PRP manifest.
- Existing direct adapters continue through their current paths.
- Diff against the actual stacked base: 6 files.

## Verification

- Runner TypeScript typecheck and both generated-contract drift gates —
passed.
- Runner TypeScript tests — 37 files and 355 Vitest tests passed; 11
Node contract tests passed.
- Rust provider-bridge regression suite after restacking — 14/14 passed.
- `pnpm -r typecheck` — passed for all applicable workspaces.
- `pnpm build` — passed, including runner binary, server, UI, and
workspace packages.
- `pnpm test:run` — attempted; the local host reproduced unrelated
workspace/Postgres and port-exposure failures in unchanged server
suites. The changed runner contract suites pass, and the repository's
serialized/sharded GitHub checks remain authoritative for those
host-sensitive suites.
- Prettier, rustfmt, generated-source drift checks, and `git diff
--check` — passed.
- `pnpm-lock.yaml` is unchanged.

## Risks

The main risk is allowing schema and generated language inventories to
diverge. Build and typecheck now fail on any drift. The sidecar
implementation and third-party ACPX packages are deliberately absent, so
this PR cannot alter runtime behavior or expand the production attack
surface.

## 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
- [x] I have described the issue and expected behavior 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 security 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 11:59:14 -05:00 committed by GitHub
parent 7a3abb88a0
commit e13856eb37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 383 additions and 4 deletions

View File

@ -29,17 +29,17 @@
"build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins",
"build:binary": "cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/stage-runner-binary.mjs",
"typecheck": "pnpm run typecheck:typescript && pnpm run typecheck:rust",
"typecheck:typescript": "node --check scripts/protocol-contract.mjs && node --check scripts/generate-protocol-manifest.mjs && node --check scripts/generate-protocol-schema-module.mjs && node --check scripts/generate-replay-goldens.mjs && node --check scripts/generate-semantic-action-catalog.mjs && pnpm run check:protocol-types && tsc -p tsconfig.json --noEmit",
"typecheck:typescript": "node --check scripts/protocol-contract.mjs && node --check scripts/generate-protocol-manifest.mjs && node --check scripts/generate-protocol-schema-module.mjs && node --check scripts/generate-acpx-sidecar-contract.mjs && node --check scripts/generate-replay-goldens.mjs && node --check scripts/generate-semantic-action-catalog.mjs && pnpm run check:protocol-types && tsc -p tsconfig.json --noEmit",
"typecheck:rust": "cargo fmt --manifest-path runner/Cargo.toml --all -- --check && cargo check --manifest-path runner/Cargo.toml --locked --workspace",
"test": "pnpm run test:typescript && pnpm run test:rust",
"test:typescript": "node --test test/protocol-contract.test.mjs && vitest run",
"test:typescript": "node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs && vitest run",
"test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace",
"test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider",
"test:durable": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core durable::",
"generate:protocol-manifest": "node scripts/generate-protocol-manifest.mjs",
"check:protocol-manifest": "node scripts/generate-protocol-manifest.mjs --check",
"generate:protocol-types": "node scripts/generate-protocol-schema-module.mjs",
"check:protocol-types": "node scripts/generate-protocol-schema-module.mjs --check",
"generate:protocol-types": "node scripts/generate-protocol-schema-module.mjs && node scripts/generate-acpx-sidecar-contract.mjs",
"check:protocol-types": "node scripts/generate-protocol-schema-module.mjs --check && node scripts/generate-acpx-sidecar-contract.mjs --check",
"generate:replay-goldens": "pnpm run build:typescript && node scripts/generate-replay-goldens.mjs",
"check:replay-goldens": "pnpm run build:typescript && node scripts/generate-replay-goldens.mjs --check",
"generate:semantic-action-catalog": "pnpm run build:typescript && node scripts/generate-semantic-action-catalog.mjs",

View File

@ -0,0 +1,110 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/acpx-sidecar/v2/message.schema.json",
"title": "Paperclip ACPX sidecar message",
"oneOf": [
{ "$ref": "#/$defs/request" },
{ "$ref": "#/$defs/response" },
{ "$ref": "#/$defs/event" }
],
"$defs": {
"protocolVersion": { "const": 2 },
"command": {
"enum": [
"initialize",
"session.open",
"run.attach",
"turn.start",
"turn.cancel",
"permission.resolve",
"input.resolve",
"tool.resolve",
"session.read",
"session.snapshot",
"session.suspend",
"session.close"
]
},
"eventType": {
"enum": [
"runtime.event",
"runtime.permission_requested",
"runtime.input_requested",
"runtime.tool_called",
"runtime.turn_terminal",
"runtime.process",
"runtime.diagnostic"
]
},
"request": {
"type": "object",
"required": ["protocolVersion", "id", "command", "params"],
"properties": {
"protocolVersion": { "$ref": "#/$defs/protocolVersion" },
"id": { "type": "integer", "minimum": 1 },
"command": { "$ref": "#/$defs/command" },
"params": { "type": "object" }
},
"additionalProperties": false
},
"response": {
"type": "object",
"required": ["protocolVersion", "id", "ok"],
"oneOf": [
{
"properties": {
"ok": { "const": true },
"result": { "type": "object" },
"error": false
},
"required": ["result"]
},
{
"properties": {
"ok": { "const": false },
"result": false,
"error": { "type": "object" }
},
"required": ["error"]
}
],
"properties": {
"protocolVersion": { "$ref": "#/$defs/protocolVersion" },
"id": { "type": "integer", "minimum": 1 },
"ok": { "type": "boolean" },
"result": { "type": "object" },
"error": {
"type": "object",
"required": ["code", "message", "retryable"],
"properties": {
"code": { "type": "string", "minLength": 1, "maxLength": 160 },
"message": { "type": "string", "maxLength": 8192 },
"retryable": { "type": "boolean" }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"event": {
"type": "object",
"required": [
"protocolVersion",
"sequence",
"eventType",
"runId",
"turnId",
"payload"
],
"properties": {
"protocolVersion": { "$ref": "#/$defs/protocolVersion" },
"sequence": { "type": "integer", "minimum": 1 },
"eventType": { "$ref": "#/$defs/eventType" },
"runId": { "type": ["string", "null"], "maxLength": 160 },
"turnId": { "type": ["string", "null"], "maxLength": 160 },
"payload": { "type": "object" }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,56 @@
// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.
pub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = 2;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GeneratedAcpxSidecarCommand {
Initialize,
SessionOpen,
RunAttach,
TurnStart,
TurnCancel,
PermissionResolve,
InputResolve,
ToolResolve,
SessionRead,
SessionSnapshot,
SessionSuspend,
SessionClose,
}
impl GeneratedAcpxSidecarCommand {
pub const fn as_str(self) -> &'static str {
match self {
Self::Initialize => "initialize",
Self::SessionOpen => "session.open",
Self::RunAttach => "run.attach",
Self::TurnStart => "turn.start",
Self::TurnCancel => "turn.cancel",
Self::PermissionResolve => "permission.resolve",
Self::InputResolve => "input.resolve",
Self::ToolResolve => "tool.resolve",
Self::SessionRead => "session.read",
Self::SessionSnapshot => "session.snapshot",
Self::SessionSuspend => "session.suspend",
Self::SessionClose => "session.close",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]
pub enum GeneratedAcpxSidecarEventType {
#[serde(rename = "runtime.event")]
RuntimeEvent,
#[serde(rename = "runtime.permission_requested")]
RuntimePermissionRequested,
#[serde(rename = "runtime.input_requested")]
RuntimeInputRequested,
#[serde(rename = "runtime.tool_called")]
RuntimeToolCalled,
#[serde(rename = "runtime.turn_terminal")]
RuntimeTurnTerminal,
#[serde(rename = "runtime.process")]
RuntimeProcess,
#[serde(rename = "runtime.diagnostic")]
RuntimeDiagnostic,
}

View File

@ -0,0 +1,33 @@
const SCHEMA_ID_PATTERN =
/^https:\/\/paperclip\.dev\/schemas\/acpx-sidecar\/v([1-9]\d*)\/message\.schema\.json$/;
const PROTOCOL_VERSION_REF = "#/$defs/protocolVersion";
export function readAcpxSidecarProtocolVersion(schema) {
const schemaIdMatch = SCHEMA_ID_PATTERN.exec(schema?.$id ?? "");
const protocolVersion = Number(schemaIdMatch?.[1]);
if (!Number.isSafeInteger(protocolVersion)) {
throw new Error(
"ACPX sidecar schema $id must declare a positive safe-integer protocol version",
);
}
if (schema?.$defs?.protocolVersion?.const !== protocolVersion) {
throw new Error(
"ACPX sidecar protocol version must match its authoritative schema $id",
);
}
for (const family of ["request", "response", "event"]) {
const versionSchema = schema?.$defs?.[family]?.properties?.protocolVersion;
if (
Object.keys(versionSchema ?? {}).length !== 1 ||
versionSchema?.$ref !== PROTOCOL_VERSION_REF
) {
throw new Error(
`ACPX ${family} schema must use the shared protocol version`,
);
}
}
return protocolVersion;
}

View File

@ -0,0 +1,54 @@
import { readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { readAcpxSidecarProtocolVersion } from "./acpx-sidecar-contract.mjs";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const schema = JSON.parse(
await readFile(
resolve(root, "protocol/provider-schemas/acpx-sidecar.schema.json"),
"utf8",
),
);
const commands = schema.$defs.command.enum;
const events = schema.$defs.eventType.enum;
const protocolVersion = readAcpxSidecarProtocolVersion(schema);
const quote = (value) => JSON.stringify(value);
const rustVariant = (value) =>
value
.split(/[._-]/)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join("");
const typescript = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\nexport const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION = ${protocolVersion} as const;\nexport const GENERATED_ACPX_SIDECAR_COMMANDS = [\n${commands.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarCommand =\n (typeof GENERATED_ACPX_SIDECAR_COMMANDS)[number];\n\nexport const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [\n${events.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarEventType =\n (typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number];\n`;
const rust = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\npub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = ${protocolVersion};\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub enum GeneratedAcpxSidecarCommand {\n${commands.map((value) => ` ${rustVariant(value)},`).join("\n")}\n}\n\nimpl GeneratedAcpxSidecarCommand {\n pub const fn as_str(self) -> &'static str {\n match self {\n${commands.map((value) => ` Self::${rustVariant(value)} => ${quote(value)},`).join("\n")}\n }\n }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]\npub enum GeneratedAcpxSidecarEventType {\n${events.map((value) => ` #[serde(rename = ${quote(value)})]\n ${rustVariant(value)},`).join("\n")}\n}\n`;
const outputs = [
[resolve(root, "src/drivers/acpx/generated-sidecar-contract.ts"), typescript],
[
resolve(
root,
"runner/crates/runner-core/src/generated_acpx_sidecar_contract.rs",
),
rust,
],
];
if (process.argv.includes("--check")) {
const stale = [];
for (const [path, content] of outputs)
if ((await readFile(path, "utf8").catch(() => "")) !== content)
stale.push(path);
if (stale.length > 0) {
process.stderr.write(
`Generated ACPX sidecar contracts are stale: ${stale.join(", ")}\n`,
);
process.exitCode = 1;
} else
process.stdout.write(
"Generated TypeScript/Rust ACPX sidecar contracts match the schema.\n",
);
} else {
await Promise.all(outputs.map(([path, content]) => writeFile(path, content)));
process.stdout.write("Generated TypeScript/Rust ACPX sidecar contracts.\n");
}

View File

@ -0,0 +1,31 @@
// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.
export const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION = 2 as const;
export const GENERATED_ACPX_SIDECAR_COMMANDS = [
"initialize",
"session.open",
"run.attach",
"turn.start",
"turn.cancel",
"permission.resolve",
"input.resolve",
"tool.resolve",
"session.read",
"session.snapshot",
"session.suspend",
"session.close",
] as const;
export type GeneratedAcpxSidecarCommand =
(typeof GENERATED_ACPX_SIDECAR_COMMANDS)[number];
export const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [
"runtime.event",
"runtime.permission_requested",
"runtime.input_requested",
"runtime.tool_called",
"runtime.turn_terminal",
"runtime.process",
"runtime.diagnostic",
] as const;
export type GeneratedAcpxSidecarEventType =
(typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number];

View File

@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import Ajv2020 from "ajv/dist/2020.js";
import { readAcpxSidecarProtocolVersion } from "../scripts/acpx-sidecar-contract.mjs";
const schema = JSON.parse(
await readFile(
new URL(
"../protocol/provider-schemas/acpx-sidecar.schema.json",
import.meta.url,
),
"utf8",
),
);
const validate = new Ajv2020({ allErrors: true, strict: true }).compile(schema);
const protocolVersion = readAcpxSidecarProtocolVersion(schema);
const messages = [
{
protocolVersion,
id: 1,
command: "initialize",
params: {},
},
{
protocolVersion,
id: 1,
ok: true,
result: {},
},
{
protocolVersion,
sequence: 1,
eventType: "runtime.event",
runId: "run-1",
turnId: "turn-1",
payload: {},
},
];
test("the ACPX sidecar schema accepts each versioned message family", () => {
for (const message of messages) {
assert.equal(validate(message), true, JSON.stringify(validate.errors));
}
});
test("the ACPX sidecar schema fails closed on drift", () => {
for (const message of [
{ ...messages[0], protocolVersion: protocolVersion + 1 },
{ ...messages[0], command: "session.destroy" },
{ protocolVersion, id: 1, ok: true, result: {}, error: error() },
{ protocolVersion, id: 1, ok: true },
{ protocolVersion, id: 1, ok: false },
{ protocolVersion, id: 1, ok: false, result: {}, error: error() },
{ ...messages[2], unexpected: true },
]) {
assert.equal(validate(message), false);
}
});
test("every ACPX sidecar message family uses the shared version", () => {
for (const family of ["request", "response", "event"]) {
assert.deepEqual(schema.$defs[family].properties.protocolVersion, {
$ref: "#/$defs/protocolVersion",
});
}
});
test("the ACPX sidecar schema id carries the declared protocol version", () => {
assert.equal(
schema.$id,
`https://paperclip.dev/schemas/acpx-sidecar/v${protocolVersion}/message.schema.json`,
);
});
test("a coordinated family-version upgrade cannot outpace the schema id", () => {
const driftedSchema = structuredClone(schema);
driftedSchema.$defs.protocolVersion.const = protocolVersion + 1;
assert.throws(
() => readAcpxSidecarProtocolVersion(driftedSchema),
/must match its authoritative schema \$id/,
);
});
function error() {
return {
code: "runtime_failed",
message: "The runtime failed.",
retryable: false,
};
}