From e13856eb3722d2d4c793fd4bc61a8e70b6de686f Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:59:14 -0500 Subject: [PATCH] feat(runner): define ACPX sidecar contract (#12386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- packages/paperclip-runner/package.json | 8 +- .../provider-schemas/acpx-sidecar.schema.json | 110 ++++++++++++++++++ .../src/generated_acpx_sidecar_contract.rs | 56 +++++++++ .../scripts/acpx-sidecar-contract.mjs | 33 ++++++ .../generate-acpx-sidecar-contract.mjs | 54 +++++++++ .../acpx/generated-sidecar-contract.ts | 31 +++++ .../test/acpx-sidecar-contract.test.mjs | 95 +++++++++++++++ 7 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json create mode 100644 packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_sidecar_contract.rs create mode 100644 packages/paperclip-runner/scripts/acpx-sidecar-contract.mjs create mode 100644 packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs create mode 100644 packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.ts create mode 100644 packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 06f1c93342..d732e2e42f 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -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", diff --git a/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json b/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json new file mode 100644 index 0000000000..dbbbb53a12 --- /dev/null +++ b/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json @@ -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 + } + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_sidecar_contract.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_sidecar_contract.rs new file mode 100644 index 0000000000..46f8fc0b18 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_sidecar_contract.rs @@ -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, +} diff --git a/packages/paperclip-runner/scripts/acpx-sidecar-contract.mjs b/packages/paperclip-runner/scripts/acpx-sidecar-contract.mjs new file mode 100644 index 0000000000..34388c95f3 --- /dev/null +++ b/packages/paperclip-runner/scripts/acpx-sidecar-contract.mjs @@ -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; +} diff --git a/packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs b/packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs new file mode 100644 index 0000000000..9f3141df9c --- /dev/null +++ b/packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs @@ -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"); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.ts b/packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.ts new file mode 100644 index 0000000000..eed7190584 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.ts @@ -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]; diff --git a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs new file mode 100644 index 0000000000..10695fc9a6 --- /dev/null +++ b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs @@ -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, + }; +}