Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion crates/openab-cp/cp.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ max_inflight_delegations = 4096
# enormous budget would otherwise disable saturation entirely.
default_max_delegated_sessions_cap = 16

# Prompt/result excerpts mirrored to observers in cp/event frames are
# truncated to this size (the lobby is an audit surface, not a payload path).
# Validated to 1..=65536: excerpt serialization runs on the delegation path,
# so the cap is a latency budget as well as a content bound.
max_event_excerpt_bytes = 4096

# Ceiling on simultaneously registered observer connections per namespace.
# Observer fan-out does bounded per-observer work inside the delegation
# path's critical section, so the observer population is a configured
# latency budget: a registration beyond the cap is refused with SATURATED.
# Agents (primary/worker) are unaffected.
max_observers_per_namespace = 16

[[agents]]
key = "${CP_KEY_KOUDU}" # per-agent secret, never shared
namespace = "prod"
Expand All @@ -87,8 +100,25 @@ name = "worker-1"
type = "worker"
max_delegated_sessions_cap = 4 # overrides default_max_delegated_sessions_cap

# Read-only lobby client (e.g. the macOS/iOS lobby app). Observers receive
# cp/event notifications and may call cp/list_agents; they can never
# initiate, serve, or cancel delegations — no config can relax this.
[[agents]]
key = "${CP_KEY_LOBBY}"
namespace = "prod"
name = "lobby-app"
type = "observer"

# Per-namespace policy. Absent namespaces use the conservative defaults:
# max_depth = 1, allow_worker_initiation = false.
# max_depth = 1, allow_worker_initiation = false, metadata_only = false.
[namespaces.prod]
max_depth = 1
allow_worker_initiation = false
# Withhold agent-supplied content from cp/event: prompt/result excerpts,
# worker-reported error text, and initiator cancel reasons are all
# suppressed. Observers still see every event with full attribution
# (from/to/chain), timing, and status, and CP-synthesized diagnostics
# (timeout / disconnect reasons) remain visible by design — the stream is
# metadata-complete but content-free. Default false (excerpts included,
# bounded by max_event_excerpt_bytes).
# metadata_only = true
114 changes: 114 additions & 0 deletions crates/openab-cp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ pub struct CpConfig {
/// short enough that a dead one frees its quota promptly.
#[serde(default = "default_write_timeout_secs")]
pub write_timeout_secs: u64,
/// Maximum size of prompt/result excerpts mirrored to observers in
/// `cp/event` frames. The lobby is an audit surface, not a second
/// delivery path: excerpts are truncated with a marker, never rejected.
#[serde(default = "default_max_event_excerpt_bytes")]
pub max_event_excerpt_bytes: usize,
/// Ceiling on simultaneously registered observer connections per
/// namespace. Observer fan-out does bounded per-observer work inside the
/// delegation path's in-flight critical section (see the router's lock
/// hierarchy note), so the total is deliberately capped: this knob is
/// what makes "bounded" a guarantee instead of an operational hope. A
/// registration that would exceed it is refused; agents (primary/worker)
/// are unaffected.
#[serde(default = "default_max_observers_per_namespace")]
pub max_observers_per_namespace: usize,

/// Memory ceiling for ONE connection's outbound queue, in bytes.
///
Expand Down Expand Up @@ -180,6 +194,14 @@ fn default_max_delegated_sessions_cap() -> u32 {
16
}

fn default_max_event_excerpt_bytes() -> usize {
4 * 1024
}

fn default_max_observers_per_namespace() -> usize {
16
}

/// Immutable identity claims bound to one auth key.
#[derive(Debug, Clone, Deserialize)]
pub struct AgentIdentity {
Expand Down Expand Up @@ -209,6 +231,16 @@ pub struct NamespacePolicy {
/// Whether workers may initiate delegations (depth still applies).
#[serde(default)]
pub allow_worker_initiation: bool,
/// Withhold agent-supplied content from `cp/event`: prompt excerpts,
/// result excerpts, worker-reported error text, and initiator-supplied
/// cancel reasons are all suppressed (their keys absent). Observers still
/// see every event with full attribution (`from`/`to`/`chain`), timing,
/// status, and CP-synthesized diagnostics — timeout and disconnect
/// reasons are metadata the CP composed, not agent content, so they
/// survive this knob by design. Defaults to false (excerpts included,
/// bounded by `max_event_excerpt_bytes`) — the pre-existing behavior.
#[serde(default)]
pub metadata_only: bool,
}

fn default_depth() -> u32 {
Expand All @@ -220,6 +252,7 @@ impl Default for NamespacePolicy {
Self {
max_depth: default_depth(),
allow_worker_initiation: false,
metadata_only: false,
}
}
}
Expand Down Expand Up @@ -294,6 +327,33 @@ impl CpConfig {
if self.default_max_delegated_sessions_cap == 0 {
bail!("default_max_delegated_sessions_cap must be at least 1");
}
// Zero silently reduces every excerpt to the bare truncation marker
// (a content-free lobby that LOOKS configured); an oversized cap
// multiplies the per-observer work the delegation path performs
// inside its in-flight critical section. Bound it on both sides.
if self.max_event_excerpt_bytes == 0 {
bail!(
"max_event_excerpt_bytes must be at least 1 (0 would silently \
replace every excerpt with the bare truncation marker; use \
[namespaces.X] metadata_only = true to suppress content)"
);
}
if self.max_event_excerpt_bytes > 64 * 1024 {
bail!(
"max_event_excerpt_bytes ({}) exceeds the 65536-byte ceiling — \
excerpts are serialized on the delegation path, and the lobby \
is an audit surface, not a delivery path",
self.max_event_excerpt_bytes
);
}
// Zero observers would refuse every lobby client while the surface
// is configured; the ceiling itself is what bounds fan-out work.
if self.max_observers_per_namespace == 0 {
bail!(
"max_observers_per_namespace must be at least 1 (omit observer \
identities from [[agents]] to disable the lobby instead)"
);
}
// Bearer keys over cleartext TCP must never reach an untrusted
// network: non-loopback binds require the explicit override.
if !self.allow_insecure_bind && !is_loopback(&self.listen) {
Expand Down Expand Up @@ -419,6 +479,31 @@ allow_worker_initiation = false
assert!(!d.allow_worker_initiation);
}

#[test]
fn metadata_only_defaults_off_and_is_per_namespace() {
let cfg: CpConfig = toml::from_str(base_toml()).unwrap();
assert!(
!cfg.policy_for("prod").metadata_only,
"backward-compatible default: excerpts are included"
);
assert!(!cfg.policy_for("unlisted").metadata_only);

let cfg: CpConfig = toml::from_str(
r#"
[namespaces.prod]
metadata_only = true

[namespaces.dev]
max_depth = 3
"#,
)
.unwrap();
assert!(cfg.policy_for("prod").metadata_only);
// Unrelated knobs keep their defaults when only metadata_only is set.
assert_eq!(cfg.policy_for("prod").max_depth, 1);
assert!(!cfg.policy_for("dev").metadata_only);
}

#[test]
fn identity_lookup_binds_key_to_claims() {
let cfg: CpConfig = toml::from_str(base_toml()).unwrap();
Expand Down Expand Up @@ -457,6 +542,35 @@ lease_expiry_secs = 30
assert!(cfg.validate().is_err());
}

#[test]
fn event_excerpt_bytes_bounded_on_both_sides() {
// Review rounds 4/10 (R4-F17 + F51): zero silently reduced every
// excerpt to the bare truncation marker, and an oversized cap
// multiplies work done on the delegation path. Both refused now.
let cfg: CpConfig = toml::from_str("max_event_excerpt_bytes = 0").unwrap();
assert!(cfg.validate().is_err(), "zero excerpt cap must be refused");
let cfg: CpConfig = toml::from_str("max_event_excerpt_bytes = 65537").unwrap();
assert!(cfg.validate().is_err(), "oversized excerpt cap refused");
let cfg: CpConfig = toml::from_str("max_event_excerpt_bytes = 65536").unwrap();
cfg.validate().unwrap();
// Default remains valid and unchanged.
let cfg: CpConfig = toml::from_str(base_toml()).unwrap();
cfg.validate().unwrap();
assert_eq!(cfg.max_event_excerpt_bytes, 4096);
}

#[test]
fn observer_cap_defaults_and_zero_is_refused() {
// Review round-10 F51 (facet d): the observer population is a
// configured latency budget; zero would refuse every lobby client
// while the surface is configured.
let cfg: CpConfig = toml::from_str(base_toml()).unwrap();
cfg.validate().unwrap();
assert_eq!(cfg.max_observers_per_namespace, 16);
let cfg: CpConfig = toml::from_str("max_observers_per_namespace = 0").unwrap();
assert!(cfg.validate().is_err());
}

#[test]
fn non_loopback_bind_requires_override() {
let cfg: CpConfig = toml::from_str("listen = \"0.0.0.0:9800\"").unwrap();
Expand Down
Loading
Loading