Skip to content
Merged
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 58 additions & 10 deletions crates/openab-cp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 17 additions & 6 deletions crates/openab-cp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}");
}
}

Expand Down
24 changes: 24 additions & 0 deletions crates/openab-cp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading