140 lines
6.2 KiB
Rust
140 lines
6.2 KiB
Rust
//! 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<String> {
|
|
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));
|
|
}
|
|
}
|