From 93c2bf6143dc6970f562379c4ac4f67a625df56b Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 30 Oct 2026 06:40:09 -0400 Subject: [PATCH 1/8] refactor(cp): make openab-cp proto usable without the server Split openab-cp into an always-compiled `proto` module and a default `server` feature (config, registry, policy, router, events, WS server, binary), so runtime clients can depend on the wire contract with default-features = false without linking axum/tokio. Adds proto::MIN_RUNTIME_FRAME_BYTES and rejects CP configs whose max_frame_bytes is below it. Carried from #1528 (commit 9455b50). Also fixes #1528 round-3 finding 7 (bail! string had a literal run of spaces; test now asserts the exact message) and gates tests/ws_lifecycle.rs on `server` so `cargo test --no-default-features` runs the proto tests. Part 1 of 3 re-landing #1528; see openabdev/openab-pty#77. Verified on macmini (M4, rustc 1.99.1), clean copy of this tree: - cargo metadata --locked: ok; Cargo.lock unchanged vs origin/main - cargo build -p openab-cp --locked: ok - cargo test -p openab-cp --locked: 142 unit + 9 ws_lifecycle passed - cargo check -p openab-cp --no-default-features --locked: ok - cargo test -p openab-cp --no-default-features --locked: 16 proto passed - cargo tree -p openab-cp --no-default-features -e normal: no axum/tokio/hyper - cargo clippy -p openab-cp --all-targets -- -D warnings: clean (default and --no-default-features) - cargo fmt -p openab-cp --check: clean - Docker dummy-layer sim (manifests + empty lib.rs, no openab-cp main.rs): cargo metadata --locked and root cargo check --locked ok - CI parity: cargo check --workspace, clippy --workspace [--features unified] -D warnings, test -p openab-gateway --features acp (417), test -p openab-core --features acp-mcp acp_mcp:: (5) / acp::pool:: (24), test --features acp (42), build --features unified: all ok - cargo test --workspace: all pass except openab-core secrets::tests::resolve_exec_nonzero_exit, which spawns /bin/false; macOS has only /usr/bin/false. openab-core is untouched here; CI is Linux. --- crates/openab-cp/Cargo.toml | 61 ++++++++++++++++++++++++++------ crates/openab-cp/cp.toml.example | 2 ++ crates/openab-cp/src/config.rs | 23 ++++++++++++ crates/openab-cp/src/lib.rs | 19 ++++++++++ crates/openab-cp/src/proto.rs | 4 +++ 5 files changed, 99 insertions(+), 10 deletions(-) diff --git a/crates/openab-cp/Cargo.toml b/crates/openab-cp/Cargo.toml index 86ca5e30c..17ad2aa08 100644 --- a/crates/openab-cp/Cargo.toml +++ b/crates/openab-cp/Cargo.toml @@ -6,19 +6,60 @@ license = "MIT" description = "OpenAB Agent Control Plane — registry, router, and policy for direct inter-agent delegation" [dependencies] -tokio = { version = "1", features = ["full"] } -axum = { version = "0.8", features = ["ws"] } -futures-util = "0.3" +# --- wire types (always compiled) --- +# `proto` is the shared contract between the CP and every runtime that speaks +# to it, so it must be usable WITHOUT the server: runtime clients (the OAB +# runtime in `openab-core`, from the follow-up runtime-client PR) depend on +# this crate with `default-features = false` purely for these types, and must +# not inherit axum/tokio/hyper through that edge (the runtime image would +# otherwise grow a whole HTTP server it never binds). serde = { version = "1", features = ["derive"] } serde_json = "1" -toml = "0.8" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -anyhow = "1" chrono = { version = "0.4", features = ["serde"] } -parking_lot = "0.12" -clap = { version = "4", features = ["derive"] } -subtle = "2" + +# --- server-only (feature `server`, on by default) --- +tokio = { version = "1", features = ["full"], optional = true } +axum = { version = "0.8", features = ["ws"], optional = true } +futures-util = { version = "0.3", optional = true } +toml = { version = "0.8", optional = true } +tracing = { version = "0.1", optional = true } +tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } +anyhow = { version = "1", optional = true } +parking_lot = { version = "0.12", optional = true } +clap = { version = "4", features = ["derive"], optional = true } +subtle = { version = "2", optional = true } + +[features] +# Default: the full control plane (config, registry, policy, router, events, +# WebSocket server) plus the `openab-cp` binary. Turning it off leaves only +# `proto` — the wire contract — which is what runtime clients need. +default = ["server"] +server = [ + "dep:tokio", + "dep:axum", + "dep:futures-util", + "dep:toml", + "dep:tracing", + "dep:tracing-subscriber", + "dep:anyhow", + "dep:parking_lot", + "dep:clap", + "dep:subtle", +] + +# The binary IS the server; without the feature there is nothing for it to +# start, so it is skipped instead of failing to compile. +[[bin]] +name = "openab-cp" +path = "src/main.rs" +required-features = ["server"] + +# The lifecycle tests drive the real WebSocket server, so they only exist +# with it; without this, `cargo test --no-default-features` cannot build. +[[test]] +name = "ws_lifecycle" +path = "tests/ws_lifecycle.rs" +required-features = ["server"] [dev-dependencies] # WebSocket client for the end-to-end admission/lifecycle tests. Same version diff --git a/crates/openab-cp/cp.toml.example b/crates/openab-cp/cp.toml.example index f4ea04ddc..cb8fdfa54 100644 --- a/crates/openab-cp/cp.toml.example +++ b/crates/openab-cp/cp.toml.example @@ -23,6 +23,8 @@ max_deadline_secs = 1800 max_result_bytes = 262144 # Transport-level cap on inbound WebSocket messages (enforced pre-parse). +# Must remain at least 1048576 bytes for the bundled runtime client; larger +# values are allowed, while smaller values are rejected during validation. max_frame_bytes = 1048576 # Delegation prompts larger than this are rejected. diff --git a/crates/openab-cp/src/config.rs b/crates/openab-cp/src/config.rs index a7fd7b6f9..be4d27088 100644 --- a/crates/openab-cp/src/config.rs +++ b/crates/openab-cp/src/config.rs @@ -307,6 +307,14 @@ impl CpConfig { if self.write_timeout_secs == 0 { bail!("write_timeout_secs must be greater than 0"); } + if self.max_frame_bytes < crate::proto::MIN_RUNTIME_FRAME_BYTES { + bail!( + "max_frame_bytes ({}) is below the bundled runtime minimum ({}) — \ + raise it or use a protocol-compatible client with a smaller negotiated ceiling", + self.max_frame_bytes, + crate::proto::MIN_RUNTIME_FRAME_BYTES + ); + } // A budget that cannot hold one maximum-size frame would refuse every // enqueue and disconnect every peer the moment a large frame is routed // to it. @@ -614,6 +622,21 @@ lease_expiry_secs = 30 } } + #[test] + fn frame_limit_cannot_undercut_the_bundled_runtime_contract() { + let cfg: CpConfig = toml::from_str("max_frame_bytes = 1048575").unwrap(); + let err = cfg.validate().unwrap_err().to_string(); + assert_eq!( + err, + concat!( + "max_frame_bytes (1048575) is below the bundled runtime minimum (1048576) — ", + "raise it or use a protocol-compatible client with a smaller negotiated ceiling" + ) + ); + let cfg: CpConfig = toml::from_str("max_frame_bytes = 1048576").unwrap(); + cfg.validate().unwrap(); + } + #[test] fn resource_bounds_default_and_are_validated() { // The byte budget, the global in-flight ceiling, and the default diff --git a/crates/openab-cp/src/lib.rs b/crates/openab-cp/src/lib.rs index 2ffefece0..48baade90 100644 --- a/crates/openab-cp/src/lib.rs +++ b/crates/openab-cp/src/lib.rs @@ -5,11 +5,30 @@ //! register over WebSocket; the CP authenticates them against config-bound //! identities, enforces delegation policy authoritatively, and routes //! `cp/delegate` / `cp/delegate_result` frames between them. +//! +//! ## Crate layout: `proto` vs the `server` feature +//! +//! [`proto`] is the wire contract and is ALWAYS compiled. Everything else — +//! config, registry, policy, router, observer fan-out, and the WebSocket +//! server — sits behind the default `server` feature, together with the axum +//! and tokio dependencies it needs. +//! +//! That split exists so the OAB runtime (`openab-core`) can depend on this +//! crate with `default-features = false` to speak the protocol without +//! linking an HTTP server it never binds. Anything a runtime client needs +//! belongs in `proto`; anything that only the hub does must stay behind the +//! feature. +#[cfg(feature = "server")] pub mod config; +#[cfg(feature = "server")] pub mod events; +#[cfg(feature = "server")] pub mod policy; pub mod proto; +#[cfg(feature = "server")] pub mod registry; +#[cfg(feature = "server")] pub mod router; +#[cfg(feature = "server")] pub mod server; diff --git a/crates/openab-cp/src/proto.rs b/crates/openab-cp/src/proto.rs index 5e70b984b..acf57b97b 100644 --- a/crates/openab-cp/src/proto.rs +++ b/crates/openab-cp/src/proto.rs @@ -37,6 +37,10 @@ use serde_json::Value; /// Wire protocol version. Carried in `cp/register`; the CP rejects /// registrations with a version it does not support. pub const PROTOCOL_VERSION: u32 = 1; +/// Minimum frame ceiling supported by the bundled runtime client. The server +/// may configure a larger value, but a smaller one would let an otherwise +/// valid runtime result disconnect the whole worker connection. +pub const MIN_RUNTIME_FRAME_BYTES: usize = 1024 * 1024; // --- JSON-RPC envelopes --- From 3fbe4022ecea5e69c45eb3802971b8d9af20c3d9 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 30 Oct 2026 06:49:48 -0400 Subject: [PATCH 2/8] test(cp): exercise the queue-vs-frame rule above the frame floor Review opus5-F1: the max_frame_bytes floor ran before the max_outbound_queue_bytes <= max_frame_bytes check, so the case queue=4096/frame=8192 was rejected by the floor and the strictly-below branch of the queue rule went untested. Replace it with queue=1048576/frame=2097152 and assert the expected error text for every rejection case instead of a bare is_err(). Review opus5-F2: proto.rs and cp.toml.example now say the runtime client lands in a follow-up PR instead of describing it as already bundled. Verified on macmini (M4), clean copy of this tree: - cargo fmt -p openab-cp --check: clean - cargo build -p openab-cp --locked: ok - cargo test -p openab-cp --locked: 142 unit + 9 ws_lifecycle passed - cargo check -p openab-cp --no-default-features --locked: ok - cargo test -p openab-cp --no-default-features --locked: 16 proto passed - cargo clippy -p openab-cp --all-targets -- -D warnings: clean (default and --no-default-features) - cargo clippy --workspace -- -D warnings, cargo check --workspace: ok - Cargo.lock unchanged vs origin/main --- crates/openab-cp/cp.toml.example | 5 +++-- crates/openab-cp/src/config.rs | 24 ++++++++++++++++++------ crates/openab-cp/src/proto.rs | 7 ++++--- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/openab-cp/cp.toml.example b/crates/openab-cp/cp.toml.example index cb8fdfa54..baa22dec0 100644 --- a/crates/openab-cp/cp.toml.example +++ b/crates/openab-cp/cp.toml.example @@ -23,8 +23,9 @@ max_deadline_secs = 1800 max_result_bytes = 262144 # Transport-level cap on inbound WebSocket messages (enforced pre-parse). -# Must remain at least 1048576 bytes for the bundled runtime client; larger -# values are allowed, while smaller values are rejected during validation. +# Must be at least 1048576 bytes, the minimum the runtime client (landing in +# a follow-up PR) requires; larger values are allowed, smaller values are +# rejected during validation. max_frame_bytes = 1048576 # Delegation prompts larger than this are rejected. diff --git a/crates/openab-cp/src/config.rs b/crates/openab-cp/src/config.rs index be4d27088..4499cead5 100644 --- a/crates/openab-cp/src/config.rs +++ b/crates/openab-cp/src/config.rs @@ -659,16 +659,28 @@ default_max_delegated_sessions_cap = 3", assert_eq!(explicit.max_inflight_delegations, 32); assert_eq!(explicit.default_max_delegated_sessions_cap, 3); - for bad in [ - "max_inflight_delegations = 0", - "default_max_delegated_sessions_cap = 0", + for (bad, why) in [ + ("max_inflight_delegations = 0", "max_inflight_delegations"), + ( + "default_max_delegated_sessions_cap = 0", + "default_max_delegated_sessions_cap", + ), // Equal to max_frame_bytes is not enough: one maximum-size frame // must fit, or the connection is unusable. - "max_outbound_queue_bytes = 1048576", - "max_outbound_queue_bytes = 4096\nmax_frame_bytes = 8192", + ( + "max_outbound_queue_bytes = 1048576", + "must exceed max_frame_bytes", + ), + // Strictly below, with a frame ceiling above the runtime floor so + // the queue rule (not the floor) is what rejects it. + ( + "max_outbound_queue_bytes = 1048576\nmax_frame_bytes = 2097152", + "must exceed max_frame_bytes", + ), ] { let cfg: CpConfig = toml::from_str(bad).unwrap(); - assert!(cfg.validate().is_err(), "{bad} must be rejected"); + let err = cfg.validate().expect_err(bad).to_string(); + assert!(err.contains(why), "{bad}: unexpected error {err:?}"); } } diff --git a/crates/openab-cp/src/proto.rs b/crates/openab-cp/src/proto.rs index acf57b97b..1802c95bc 100644 --- a/crates/openab-cp/src/proto.rs +++ b/crates/openab-cp/src/proto.rs @@ -37,9 +37,10 @@ use serde_json::Value; /// Wire protocol version. Carried in `cp/register`; the CP rejects /// registrations with a version it does not support. pub const PROTOCOL_VERSION: u32 = 1; -/// Minimum frame ceiling supported by the bundled runtime client. The server -/// may configure a larger value, but a smaller one would let an otherwise -/// valid runtime result disconnect the whole worker connection. +/// Minimum frame ceiling the runtime client (landing in the follow-up PR) +/// will require. The server may configure a larger value, but a smaller one +/// would let an otherwise valid runtime result disconnect the whole worker +/// connection. pub const MIN_RUNTIME_FRAME_BYTES: usize = 1024 * 1024; // --- JSON-RPC envelopes --- From 98626dcda8f518d9f64f6aa0d3e992fe73c7e0b0 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 30 Oct 2026 11:02:30 +0000 Subject: [PATCH 3/8] fix(review): defer max_frame_bytes floor enforcement to the runtime-client PR Keep MIN_RUNTIME_FRAME_BYTES in proto as the shared constant, but drop the CpConfig::validate() rejection and its test: nothing in this PR consumes the floor, and the error text promised a frame-size negotiation the protocol does not have. The check lands with the runtime client in PR B. --- crates/openab-cp/cp.toml.example | 3 --- crates/openab-cp/src/config.rs | 26 +------------------------- crates/openab-cp/src/proto.rs | 4 +++- 3 files changed, 4 insertions(+), 29 deletions(-) diff --git a/crates/openab-cp/cp.toml.example b/crates/openab-cp/cp.toml.example index baa22dec0..f4ea04ddc 100644 --- a/crates/openab-cp/cp.toml.example +++ b/crates/openab-cp/cp.toml.example @@ -23,9 +23,6 @@ max_deadline_secs = 1800 max_result_bytes = 262144 # Transport-level cap on inbound WebSocket messages (enforced pre-parse). -# Must be at least 1048576 bytes, the minimum the runtime client (landing in -# a follow-up PR) requires; larger values are allowed, smaller values are -# rejected during validation. max_frame_bytes = 1048576 # Delegation prompts larger than this are rejected. diff --git a/crates/openab-cp/src/config.rs b/crates/openab-cp/src/config.rs index 4499cead5..f78015256 100644 --- a/crates/openab-cp/src/config.rs +++ b/crates/openab-cp/src/config.rs @@ -307,14 +307,6 @@ impl CpConfig { if self.write_timeout_secs == 0 { bail!("write_timeout_secs must be greater than 0"); } - if self.max_frame_bytes < crate::proto::MIN_RUNTIME_FRAME_BYTES { - bail!( - "max_frame_bytes ({}) is below the bundled runtime minimum ({}) — \ - raise it or use a protocol-compatible client with a smaller negotiated ceiling", - self.max_frame_bytes, - crate::proto::MIN_RUNTIME_FRAME_BYTES - ); - } // A budget that cannot hold one maximum-size frame would refuse every // enqueue and disconnect every peer the moment a large frame is routed // to it. @@ -622,21 +614,6 @@ lease_expiry_secs = 30 } } - #[test] - fn frame_limit_cannot_undercut_the_bundled_runtime_contract() { - let cfg: CpConfig = toml::from_str("max_frame_bytes = 1048575").unwrap(); - let err = cfg.validate().unwrap_err().to_string(); - assert_eq!( - err, - concat!( - "max_frame_bytes (1048575) is below the bundled runtime minimum (1048576) — ", - "raise it or use a protocol-compatible client with a smaller negotiated ceiling" - ) - ); - let cfg: CpConfig = toml::from_str("max_frame_bytes = 1048576").unwrap(); - cfg.validate().unwrap(); - } - #[test] fn resource_bounds_default_and_are_validated() { // The byte budget, the global in-flight ceiling, and the default @@ -671,8 +648,7 @@ default_max_delegated_sessions_cap = 3", "max_outbound_queue_bytes = 1048576", "must exceed max_frame_bytes", ), - // Strictly below, with a frame ceiling above the runtime floor so - // the queue rule (not the floor) is what rejects it. + // Strictly below max_frame_bytes. ( "max_outbound_queue_bytes = 1048576\nmax_frame_bytes = 2097152", "must exceed max_frame_bytes", diff --git a/crates/openab-cp/src/proto.rs b/crates/openab-cp/src/proto.rs index 1802c95bc..2e9454838 100644 --- a/crates/openab-cp/src/proto.rs +++ b/crates/openab-cp/src/proto.rs @@ -40,7 +40,9 @@ pub const PROTOCOL_VERSION: u32 = 1; /// Minimum frame ceiling the runtime client (landing in the follow-up PR) /// will require. The server may configure a larger value, but a smaller one /// would let an otherwise valid runtime result disconnect the whole worker -/// connection. +/// connection. Not enforced yet: CP config validation starts rejecting +/// smaller `max_frame_bytes` values in the same follow-up PR, together with +/// the client that relies on it. The protocol has no frame-size negotiation. pub const MIN_RUNTIME_FRAME_BYTES: usize = 1024 * 1024; // --- JSON-RPC envelopes --- From 1a3171280b0d112001a6768f8ec6dba4ce864a1e Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:05:34 +0000 Subject: [PATCH 4/8] fix(review): document Cargo feature unification for the openab-cp proto split The no-server guarantee holds only when nothing else in the same build enables the server feature; cargo build --workspace does, through the openab-cp binary. The tokio-tungstenite dev-dependency is test-only and never reaches downstream crates. --- crates/openab-cp/Cargo.toml | 7 +++++++ crates/openab-cp/src/lib.rs | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/crates/openab-cp/Cargo.toml b/crates/openab-cp/Cargo.toml index 17ad2aa08..4e57f685c 100644 --- a/crates/openab-cp/Cargo.toml +++ b/crates/openab-cp/Cargo.toml @@ -13,6 +13,13 @@ description = "OpenAB Agent Control Plane — registry, router, and policy for d # this crate with `default-features = false` purely for these types, and must # not inherit axum/tokio/hyper through that edge (the runtime image would # otherwise grow a whole HTTP server it never binds). +# +# Caveat: Cargo unifies features per build. If anything else in the same build +# enables `server` (for example `cargo build --workspace`, which also builds +# the `openab-cp` binary), the runtime is compiled against the server deps +# too. The guarantee holds when the runtime package is built on its own. +# `tokio-tungstenite` under [dev-dependencies] is test-only and never reaches +# downstream crates. serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/openab-cp/src/lib.rs b/crates/openab-cp/src/lib.rs index 48baade90..99efdbb7c 100644 --- a/crates/openab-cp/src/lib.rs +++ b/crates/openab-cp/src/lib.rs @@ -18,6 +18,11 @@ //! linking an HTTP server it never binds. Anything a runtime client needs //! belongs in `proto`; anything that only the hub does must stay behind the //! feature. +//! +//! Cargo unifies features per build, so this only keeps the server out of a +//! build that does not also enable `server` somewhere else (a +//! `cargo build --workspace` does, through the `openab-cp` binary). +//! Build the runtime package on its own to get the server-free graph. #[cfg(feature = "server")] pub mod config; From 9c1166270f1563d681c6bf4a8147db64b705a26f Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:05:42 +0000 Subject: [PATCH 5/8] fix(review): drop trailing whitespace in openab-cp lib docs --- crates/openab-cp/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openab-cp/src/lib.rs b/crates/openab-cp/src/lib.rs index 99efdbb7c..d387ba884 100644 --- a/crates/openab-cp/src/lib.rs +++ b/crates/openab-cp/src/lib.rs @@ -21,7 +21,7 @@ //! //! Cargo unifies features per build, so this only keeps the server out of a //! build that does not also enable `server` somewhere else (a -//! `cargo build --workspace` does, through the `openab-cp` binary). +//! `cargo build --workspace` does, through the `openab-cp` binary). //! Build the runtime package on its own to get the server-free graph. #[cfg(feature = "server")] From 0916de60b02ad5bb82d37f4ece05f113f43300a2 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:14:53 +0000 Subject: [PATCH 6/8] fix(review): CI-check the openab-cp proto-only build --workspace always enables openab-cp's default server feature, so no CI step built the configuration runtime clients depend on. Add clippy and test for openab-cp with --no-default-features, and fail if a server dependency appears in its normal dependency graph (-e normal leaves out the test-only tokio-tungstenite dev-dependency). --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8d65f39e..91fa56616 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,22 @@ jobs: run: cargo test --features acp - name: cargo build (unified) run: cargo build --features unified + # `--workspace` always enables openab-cp's default `server` feature, so nothing above ever + # builds the proto-only configuration that runtime clients depend on. Build, lint and test + # it on its own, then fail if a server dependency leaks into its normal dependency graph. + # `-e normal` leaves out the test-only `tokio-tungstenite` dev-dependency. + - name: openab-cp (proto only, no default features) + run: | + set -euo pipefail + cargo clippy -p openab-cp --no-default-features --all-targets -- -D warnings + cargo test -p openab-cp --no-default-features + leaked=$(cargo tree -p openab-cp --no-default-features -e normal --prefix none \ + | grep -E '^(tokio|axum|hyper|tower|tracing|clap|toml) ' || true) + if [ -n "$leaked" ]; then + echo "::error::openab-cp proto-only build pulls in server dependencies:" + echo "$leaked" + exit 1 + fi operator: needs: changes From 9837a1a0ccff02bcb65c4ebdc6ee291ec705feb3 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:21:15 -0400 Subject: [PATCH 7/8] fix(review): make the openab-cp proto-only leak check fail closed - cargo tree's output is captured on its own, so a failing cargo tree aborts the step under set -e, and an output without the openab-cp root line fails explicitly instead of reading as "no leak" (opus5-F2). - The denylist is now the ten optional deps the `server` feature gates plus axum's hyper/tower transitives (opus5-F3). Verified on macmini (scratch copy, rust stable): - the extracted CI step script (clippy -D warnings, cargo test --no-default-features: 16 passed, tree check): exit 0 - pattern against the default-features tree matches all ten server deps - cargo tree on a bad package aborts the step before the grep --- .github/workflows/ci.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91fa56616..6caeffcf7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,14 +96,21 @@ jobs: # `--workspace` always enables openab-cp's default `server` feature, so nothing above ever # builds the proto-only configuration that runtime clients depend on. Build, lint and test # it on its own, then fail if a server dependency leaks into its normal dependency graph. - # `-e normal` leaves out the test-only `tokio-tungstenite` dev-dependency. + # `-e normal` leaves out the test-only `tokio-tungstenite` dev-dependency. The pattern is + # the `server` feature's optional deps (crates/openab-cp/Cargo.toml) plus axum's hyper/tower. - name: openab-cp (proto only, no default features) run: | set -euo pipefail cargo clippy -p openab-cp --no-default-features --all-targets -- -D warnings cargo test -p openab-cp --no-default-features - leaked=$(cargo tree -p openab-cp --no-default-features -e normal --prefix none \ - | grep -E '^(tokio|axum|hyper|tower|tracing|clap|toml) ' || true) + tree=$(cargo tree -p openab-cp --no-default-features -e normal --prefix none) + if ! printf '%s\n' "$tree" | grep -q '^openab-cp '; then + echo "::error::cargo tree did not list openab-cp; the leak check cannot run" + exit 1 + fi + leaked=$(printf '%s\n' "$tree" \ + | grep -E '^(tokio|axum|futures-util|toml|tracing|tracing-subscriber|anyhow|parking_lot|clap|subtle|hyper|tower) ' \ + || true) if [ -n "$leaked" ]; then echo "::error::openab-cp proto-only build pulls in server dependencies:" echo "$leaked" From f0b33a9a07a4efdd3864b98cf33f377a0a47fa1d Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 30 Oct 2026 14:53:21 -0400 Subject: [PATCH 8/8] fix(cp): fail the proto leak check closed and drop the unused frame floor constant - ci.yml: read the server-only denylist from the optional deps in crates/openab-cp/Cargo.toml instead of a hand-written list. Fail when that list is empty, when cargo tree errors or prints nothing, and when openab-cp is missing from the tree. The step has no || true. - proto.rs: remove MIN_RUNTIME_FRAME_BYTES. Nothing consumes it in this PR (review NIT F2). The constant and its max_frame_bytes floor move to the runtime-client PR. Checks on macmini, scratch copy of this commit (--locked): - CI step script run verbatim: clippy -D warnings, cargo test --no-default-features (16 proto tests), leak check exit 0. - Leak check against a fake cargo: leaked futures-util -> exit 1; empty tree -> exit 1; failing cargo tree -> exit 101; clean tree -> exit 0. - Default: clippy --all-targets -D warnings clean; cargo test (141 unit, 9 integration) passed. - cargo fmt -p openab-cp --check clean. --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++-------- crates/openab-cp/src/proto.rs | 7 ------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6caeffcf7..77bef73a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,24 +96,43 @@ jobs: # `--workspace` always enables openab-cp's default `server` feature, so nothing above ever # builds the proto-only configuration that runtime clients depend on. Build, lint and test # it on its own, then fail if a server dependency leaks into its normal dependency graph. - # `-e normal` leaves out the test-only `tokio-tungstenite` dev-dependency. The pattern is - # the `server` feature's optional deps (crates/openab-cp/Cargo.toml) plus axum's hyper/tower. + # `-e normal` leaves out the test-only `tokio-tungstenite` dev-dependency. The denylist is + # read from the optional dependencies in crates/openab-cp/Cargo.toml (every dep that the + # `server` feature turns on), so it cannot drift from the manifest. Any failure stops the step. - name: openab-cp (proto only, no default features) run: | set -euo pipefail cargo clippy -p openab-cp --no-default-features --all-targets -- -D warnings cargo test -p openab-cp --no-default-features + server_deps=$(python3 -c ' + import tomllib + with open("crates/openab-cp/Cargo.toml", "rb") as f: + manifest = tomllib.load(f) + for name, spec in manifest.get("dependencies", {}).items(): + if isinstance(spec, dict) and spec.get("optional"): + print(name) + ') + if [ -z "$server_deps" ]; then + echo "::error::Cargo.toml lists no optional server dependencies; the leak check would be empty" + exit 1 + fi tree=$(cargo tree -p openab-cp --no-default-features -e normal --prefix none) - if ! printf '%s\n' "$tree" | grep -q '^openab-cp '; then + if [ -z "$tree" ]; then + echo "::error::cargo tree printed nothing; the leak check cannot run" + exit 1 + fi + if ! grep -q '^openab-cp ' <<< "$tree"; then echo "::error::cargo tree did not list openab-cp; the leak check cannot run" exit 1 fi - leaked=$(printf '%s\n' "$tree" \ - | grep -E '^(tokio|axum|futures-util|toml|tracing|tracing-subscriber|anyhow|parking_lot|clap|subtle|hyper|tower) ' \ - || true) + leaked="" + while read -r dep; do + if grep -q "^${dep} " <<< "$tree"; then + leaked="$leaked $dep" + fi + done <<< "$server_deps" if [ -n "$leaked" ]; then - echo "::error::openab-cp proto-only build pulls in server dependencies:" - echo "$leaked" + echo "::error::openab-cp proto-only build pulls in server dependencies:$leaked" exit 1 fi diff --git a/crates/openab-cp/src/proto.rs b/crates/openab-cp/src/proto.rs index 2e9454838..5e70b984b 100644 --- a/crates/openab-cp/src/proto.rs +++ b/crates/openab-cp/src/proto.rs @@ -37,13 +37,6 @@ use serde_json::Value; /// Wire protocol version. Carried in `cp/register`; the CP rejects /// registrations with a version it does not support. pub const PROTOCOL_VERSION: u32 = 1; -/// Minimum frame ceiling the runtime client (landing in the follow-up PR) -/// will require. The server may configure a larger value, but a smaller one -/// would let an otherwise valid runtime result disconnect the whole worker -/// connection. Not enforced yet: CP config validation starts rejecting -/// smaller `max_frame_bytes` values in the same follow-up PR, together with -/// the client that relies on it. The protocol has no frame-size negotiation. -pub const MIN_RUNTIME_FRAME_BYTES: usize = 1024 * 1024; // --- JSON-RPC envelopes ---