diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8d65f39e..77bef73a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,48 @@ 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. 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 [ -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="" + 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:$leaked" + exit 1 + fi operator: needs: changes diff --git a/crates/openab-cp/Cargo.toml b/crates/openab-cp/Cargo.toml index 86ca5e30c..4e57f685c 100644 --- a/crates/openab-cp/Cargo.toml +++ b/crates/openab-cp/Cargo.toml @@ -6,19 +6,67 @@ 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). +# +# 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" -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/src/config.rs b/crates/openab-cp/src/config.rs index a7fd7b6f9..f78015256 100644 --- a/crates/openab-cp/src/config.rs +++ b/crates/openab-cp/src/config.rs @@ -636,16 +636,27 @@ 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 max_frame_bytes. + ( + "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/lib.rs b/crates/openab-cp/src/lib.rs index 2ffefece0..d387ba884 100644 --- a/crates/openab-cp/src/lib.rs +++ b/crates/openab-cp/src/lib.rs @@ -5,11 +5,35 @@ //! 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. +//! +//! 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; +#[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;