//! Manager socket — `/run/hyperhive/manager/mcp.sock` on the host, //! bind-mounted into the manager container at `/run/hive/mcp.sock`. //! Reserved recipient names/senders and the out-of-band `HelperEvent` //! payload hive-c0re pushes into the manager's inbox. use hive_types::Ident; use serde::{Deserialize, Serialize}; /// Logical name the broker uses for the manager. pub const MANAGER_AGENT: &str = "ruth"; /// Logical name of the human operator: the sender of operator-authored /// messages. Not a recipient — `send` to it fails with /// [`OPERATOR_SEND_REFUSAL`]. pub const OPERATOR_RECIPIENT: &str = "operator"; /// The error a `send` to [`OPERATOR_RECIPIENT`] returns. One text for every /// agent, so it covers both an agent with the matrix MCP tools and one /// without them. pub const OPERATOR_SEND_REFUSAL: &str = "send failed: `operator` is not a send recipient. \ If you have the matrix MCP tools, reach the operator over matrix \ (`mcp__matrix__send_dm`, or `mcp__matrix__send_message` to a room the operator is in). \ Without them there is no direct channel to the operator: they read your `set_status` \ line and turn output on the dashboard, and your logs."; /// Sender hive-c0re uses for events it pushes into the manager's inbox. /// Manager harness recognises this and parses the body as a `HelperEvent`. pub const SYSTEM_SENDER: &str = "system"; /// Parse `s` as a [`Ident`] for use as `Message.from`, falling back to /// [`SYSTEM_SENDER`] on the (should-be-unreachable) case that `s` isn't /// ident-shaped. `Message.from` is always either a fixed sentinel literal /// (`SYSTEM_SENDER`, `OPERATOR_RECIPIENT`, `"scheduled"`, …) or an /// already-registered agent's own name reaching this point through /// hive-c0re's internal dispatch — never arbitrary external input — so /// this is a defensive fallback for a programming-bug case, not a /// validation gate. /// /// # Panics /// /// Never, unless [`SYSTEM_SENDER`] itself stops being ident-shaped (which /// would also be a programming bug, caught by `hive-types`' own tests). #[must_use] pub fn trusted_sender(s: &str) -> Ident { Ident::parse(s) .unwrap_or_else(|_| Ident::parse(SYSTEM_SENDER).expect("SYSTEM_SENDER is a valid Ident")) } /// Out-of-band events the host-side daemon pushes to the manager's inbox. /// Serialised as JSON in `Message::body` (sender = `SYSTEM_SENDER`). /// Per-variant triggers + the optional `sha`/`tag` semantics live in /// `docs/agent-lifecycle/config-changes.md::Helper events`. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "event", rename_all = "snake_case")] pub enum HelperEvent { /// A sub-agent's recorded flake rev is stale relative to hyperhive. NeedsUpdate { agent: String }, } #[cfg(test)] mod reserved_name_tests { use super::{MANAGER_AGENT, OPERATOR_RECIPIENT, SYSTEM_SENDER}; use hive_types::{Ident, RESERVED_NAMES_ENV, is_reserved_name}; /// The blacklist as nix rendered it for this test run. /// /// **Panics when the variable is absent, deliberately.** The list lives /// in `nix/reserved-names.nix` now, so this test can no longer read it /// from the Rust tree; `nix/checks.nix` and `nix/devshell.nix` both /// export it. Skipping instead would turn "nobody wired the variable" /// into a green run — the same shape as a checker that reports clean /// because it crashed, and the whole point of a drift test is that it /// is the thing that notices. fn reserved() -> Vec { let raw = hive_types::reserved_names_raw().unwrap_or_else(|| { panic!( "{RESERVED_NAMES_ENV} is unset or blank, so the drift test cannot run. \ nix/checks.nix and nix/devshell.nix are supposed to export it from \ nix/reserved-names.nix — fix the plumbing rather than this test." ) }); hive_types::parse_reserved_names(&raw) .into_iter() .map(str::to_owned) .collect() } /// The sentinels declared here and the blacklist nix owns are two /// spellings of one fact, in places that cannot import each other's /// intent. This pins them together: adding a sentinel without reserving /// it now fails here rather than years later, when an agent takes the /// name. #[test] fn ident_shaped_sentinels_are_reserved() { let owned = reserved(); let reserved: Vec<&str> = owned.iter().map(String::as_str).collect(); for sentinel in [OPERATOR_RECIPIENT, SYSTEM_SENDER] { assert!( is_reserved_name(sentinel, &reserved), "{sentinel:?} is a sentinel an agent could be named — it must be in \ nix/reserved-names.nix" ); } } /// Every entry nix hands us must be a name an agent could actually have /// been given. One that `Ident::parse` rejects is dead weight — nothing /// could ever have been created with it, so listing it implies a guard /// doing nothing. `graceful-stop` is what makes this worth asserting: it /// is hyphenated, and a charset tightening would silently retire it. /// /// This assertion used to live beside the list in `hive-types`; it moved /// here because here is where the real list is readable. #[test] fn every_reserved_name_is_a_valid_ident() { let owned = reserved(); assert!( !owned.is_empty(), "the blacklist rendered empty — an empty list makes every assertion below vacuous" ); for name in &owned { assert!( Ident::parse(name).is_ok(), "{name:?} is reserved but not a parseable ident — one of the two is wrong" ); } } /// `ruth` is a real agent, not a protocol literal, so it is not /// reserved: a second agent wanting the name is a *taken* name, which /// the roster check answers. Recorded as a test so the distinction is /// enforced rather than remembered. #[test] fn manager_name_is_taken_not_reserved() { let owned = reserved(); let reserved: Vec<&str> = owned.iter().map(String::as_str).collect(); assert!(Ident::parse(MANAGER_AGENT).is_ok()); assert!(!is_reserved_name(MANAGER_AGENT, &reserved)); } }