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:
parent
7a3abb88a0
commit
e13856eb37
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue