Add `hivectl gateway {create-user,delete-user,list-users}` subcommands for
managing htpasswd files used by gateway Basic auth. Pure Rust bcrypt
(cost 12, $2y$ prefix nginx accepts). No external htpasswd binary required.
Also fix the NixOS module assertion: `cfg.auth ? htpasswdFile` is always
true in the module system (declared options always exist as keys); switch
to `nullOr path; default = null` + `!= null` check so the assertion
actually fires with a useful error when enable=true but no file is set.
Guard bind-mount and nginx config against null to prevent eval errors.
Update docs/gateway.md to show hivectl commands instead of raw htpasswd.
768 lines
28 KiB
Rust
768 lines
28 KiB
Rust
//! Agent topology storage — single source of truth for parent/child
|
|
//! relations in the hive. Persisted as a flat JSON map of `name →
|
|
//! parent name | null` at `/var/lib/hyperhive/meta/topology.json`,
|
|
//! alongside the meta `flake.nix`, so topology changes thread through
|
|
//! the same git commit log as deploys.
|
|
//!
|
|
//! Agent roles are stored alongside in `roles.json` as a flat map of
|
|
//! `name → [role, ...]`. Roles gate additional bind-mount grants; see
|
|
//! `lifecycle::set_nspawn_flags` for the consumer.
|
|
//!
|
|
//! Format, rationale, read/reconcile/inject/surface flow, and target
|
|
//! enforcement semantics: `docs/agent-hierarchy.md::Current state`.
|
|
//! `<parent>` sentinel resolution (delivered by [`resolve_recipient`]):
|
|
//! `docs/conventions.md::Recipient sentinels`.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::PathBuf;
|
|
|
|
const TOPOLOGY_FILE: &str = "topology.json";
|
|
|
|
#[must_use]
|
|
pub fn topology_path() -> PathBuf {
|
|
crate::meta::meta_dir().join(TOPOLOGY_FILE)
|
|
}
|
|
|
|
/// Snapshot of the topology map. Read on every `container_view::build_all`
|
|
/// and every `render_flake` call. The file is small (one line per agent),
|
|
/// so we re-read rather than caching — keeps the source of truth on disk.
|
|
///
|
|
/// Returns an empty map when the file is absent or unparsable; callers
|
|
/// treat that as "no recorded parents", which falls back to every agent
|
|
/// being root-level. Safe degradation for fresh installs that haven't
|
|
/// run through `meta::sync_agents` yet.
|
|
#[must_use]
|
|
pub fn read() -> BTreeMap<String, Option<String>> {
|
|
let path = topology_path();
|
|
let Ok(raw) = std::fs::read_to_string(&path) else {
|
|
return BTreeMap::new();
|
|
};
|
|
serde_json::from_str(&raw).unwrap_or_default()
|
|
}
|
|
|
|
/// Look up one agent's parent. Returns `None` when the agent is root
|
|
/// or absent from the file. Cheap convenience over `read()` for
|
|
/// callers that want a single entry.
|
|
#[must_use]
|
|
pub fn parent_of(name: &str) -> Option<String> {
|
|
read().get(name).cloned().flatten()
|
|
}
|
|
|
|
/// Return the direct children of `name` — agents whose `topology.json`
|
|
/// entry has `name` as their parent. Reads the map once and scans all
|
|
/// entries; cheap enough for the fan-out path (one disk read per send
|
|
/// to `<children>`).
|
|
#[must_use]
|
|
pub fn children_of(name: &str) -> Vec<String> {
|
|
children_of_in(&read(), name)
|
|
}
|
|
|
|
/// Pure form of [`children_of`] for unit tests.
|
|
#[must_use]
|
|
pub fn children_of_in(topo: &BTreeMap<String, Option<String>>, name: &str) -> Vec<String> {
|
|
topo.iter()
|
|
.filter_map(|(agent, parent)| {
|
|
if parent.as_deref() == Some(name) {
|
|
Some(agent.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Return every agent that has no parent in the topology. These are the
|
|
/// "top-level" agents a `can_manage_top_level_agents` role holder is
|
|
/// granted access to. No agent name is hardcoded — the set is derived
|
|
/// purely from topology structure.
|
|
///
|
|
/// In normal operation this is just the manager, but any agent the
|
|
/// operator explicitly places outside the hierarchy is also included.
|
|
#[must_use]
|
|
pub fn top_level_agents() -> Vec<String> {
|
|
top_level_agents_in(&read())
|
|
}
|
|
|
|
/// Pure form of [`top_level_agents`] for unit tests.
|
|
#[must_use]
|
|
pub fn top_level_agents_in(topo: &BTreeMap<String, Option<String>>) -> Vec<String> {
|
|
topo.iter()
|
|
.filter_map(|(name, parent)| {
|
|
if parent.is_none() {
|
|
Some(name.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Resolve a magic recipient sentinel (currently just
|
|
/// [`hive_sh4re::PARENT_RECIPIENT`]) to a real broker recipient at
|
|
/// send time. Returns an owned `String` so callers can plug it
|
|
/// straight into [`crate::broker::Broker::send`] without
|
|
/// borrow-juggling around the temporary lookup.
|
|
///
|
|
/// Rules + rationale: `docs/conventions.md::Recipient sentinels`.
|
|
/// Fast path: ordinary recipient names short-circuit before any
|
|
/// disk read — only `<parent>` triggers `read()` on `topology.json`.
|
|
#[must_use]
|
|
pub fn resolve_recipient(sender: &str, to: &str) -> String {
|
|
// Early exit: only sentinel recipients need topology lookup. This
|
|
// keeps the cost of a normal `send` at one string comparison.
|
|
if to != hive_sh4re::PARENT_RECIPIENT {
|
|
return to.to_owned();
|
|
}
|
|
resolve_recipient_in(&read(), sender, to)
|
|
}
|
|
|
|
/// Pure form of [`resolve_recipient`] taking the topology map
|
|
/// explicitly. Split out so unit tests can exercise the sentinel
|
|
/// rules without writing a `topology.json` to disk.
|
|
#[must_use]
|
|
pub fn resolve_recipient_in(
|
|
topo: &BTreeMap<String, Option<String>>,
|
|
sender: &str,
|
|
to: &str,
|
|
) -> String {
|
|
if to == hive_sh4re::PARENT_RECIPIENT {
|
|
topo.get(sender)
|
|
.cloned()
|
|
.flatten()
|
|
.unwrap_or_else(|| hive_sh4re::OPERATOR_RECIPIENT.to_owned())
|
|
} else {
|
|
to.to_owned()
|
|
}
|
|
}
|
|
|
|
/// True when `candidate` is `ancestor` or any descendant of
|
|
/// `ancestor` per the current topology. Walks parents from
|
|
/// `candidate` upward; the walk terminates at root or on a cycle
|
|
/// (cycle defence: bounded to 32 hops, more than any plausible
|
|
/// hive depth). Used by the cancel-authorization check in
|
|
/// `manager_server::handle_cancel_schedule` to enforce
|
|
/// "managers can cancel anything their subtree owns."
|
|
#[must_use]
|
|
pub fn is_descendant_of(candidate: &str, ancestor: &str) -> bool {
|
|
if candidate == ancestor {
|
|
return true;
|
|
}
|
|
let topo = read();
|
|
let mut cur = candidate.to_owned();
|
|
for _ in 0..32 {
|
|
let Some(parent) = topo.get(&cur).cloned().flatten() else {
|
|
return false;
|
|
};
|
|
if parent == ancestor {
|
|
return true;
|
|
}
|
|
cur = parent;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Persist the topology map. Sorted JSON output (`BTreeMap` is sorted by
|
|
/// key) keeps git diffs minimal across re-writes. Best-effort —
|
|
/// returns an `io::Error` so callers can decide whether a failure
|
|
/// should abort their op (`sync_agents`, `RequestSetParent`) or just log.
|
|
pub fn write(topology: &BTreeMap<String, Option<String>>) -> std::io::Result<()> {
|
|
let path = topology_path();
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let text = serde_json::to_string_pretty(topology)
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
|
std::fs::write(&path, format!("{text}\n"))
|
|
}
|
|
|
|
/// Compute the default topology for a fresh install: every non-manager
|
|
/// agent has the manager as parent; manager itself is root. Used by
|
|
/// `meta::sync_agents` on first call to seed `topology.json`.
|
|
///
|
|
/// As soon as an explicit write lands (dashboard / `RequestSetParent`
|
|
/// API), this seeding stops touching pre-existing entries —
|
|
/// `sync_agents` only adds rows for newly-spawned agents against
|
|
/// whatever the operator has configured.
|
|
#[must_use]
|
|
#[allow(
|
|
dead_code,
|
|
reason = "kept for the dashboard / RequestSetParent write API; \
|
|
`sync_agents` does its own seeding today"
|
|
)]
|
|
pub fn default_seed(agent_names: &[String]) -> BTreeMap<String, Option<String>> {
|
|
let mut out = BTreeMap::new();
|
|
for name in agent_names {
|
|
if name == crate::lifecycle::MANAGER_NAME {
|
|
out.insert(name.clone(), None);
|
|
} else {
|
|
out.insert(
|
|
name.clone(),
|
|
Some(crate::lifecycle::MANAGER_NAME.to_owned()),
|
|
);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Pure validation + apply for [`set_parent`]. Splits off so tests
|
|
/// can exercise the rules (cycle / unknown) on an in-memory
|
|
/// `BTreeMap` without touching the on-disk `topology.json`. Returns
|
|
/// either the post-move map (caller writes it back) or a
|
|
/// user-readable error string.
|
|
///
|
|
/// The manager is reparentable like any other agent — its special
|
|
/// powers come from the privileged MCP socket, not its tree
|
|
/// position. The cycle walk below covers "moving X under its own
|
|
/// descendant" for the manager as much as any other agent.
|
|
/// `docs/agent-hierarchy.md::Current state` has the rationale.
|
|
pub fn apply_set_parent(
|
|
topo: &BTreeMap<String, Option<String>>,
|
|
child: &str,
|
|
new_parent: Option<&str>,
|
|
) -> Result<BTreeMap<String, Option<String>>, String> {
|
|
if !topo.contains_key(child) {
|
|
return Err(format!("unknown agent: {child}"));
|
|
}
|
|
if let Some(p) = new_parent {
|
|
if !topo.contains_key(p) {
|
|
return Err(format!("unknown parent: {p}"));
|
|
}
|
|
if p == child {
|
|
return Err("an agent cannot be its own parent".to_owned());
|
|
}
|
|
// Cycle check: walk `p`'s ancestors in the EXISTING map. If
|
|
// we hit `child`, then making `child`'s parent = `p` would
|
|
// close the loop (child → … → p → child).
|
|
let mut cur = p.to_owned();
|
|
for _ in 0..32 {
|
|
if cur == child {
|
|
return Err(format!(
|
|
"cycle: {p} is in {child}'s subtree (would create a loop)"
|
|
));
|
|
}
|
|
let Some(next) = topo.get(&cur).cloned().flatten() else {
|
|
break;
|
|
};
|
|
cur = next;
|
|
}
|
|
}
|
|
let mut next = topo.clone();
|
|
next.insert(child.to_owned(), new_parent.map(str::to_owned));
|
|
Ok(next)
|
|
}
|
|
|
|
/// Operator-driven parent move. Set `child`'s parent to `new_parent`
|
|
/// (or `None` to promote to root). See [`apply_set_parent`] for the
|
|
/// validation rules. The operator-set parent sticks across
|
|
/// `reconcile()` calls (which preserves existing entries).
|
|
///
|
|
/// No bind-mount / container churn today — the hierarchy is
|
|
/// currently logical-only. Once sub-manager bind mounts land, the
|
|
/// caller adds an umount-old / mount-new / restart-cascade step on
|
|
/// top.
|
|
pub fn set_parent(child: &str, new_parent: Option<&str>) -> Result<(), String> {
|
|
let current = read();
|
|
// Idempotent no-op fast path: skip the disk write when nothing
|
|
// changes. apply_set_parent still runs to surface validation
|
|
// errors (e.g. unknown child) so the caller gets a real signal.
|
|
let next = apply_set_parent(¤t, child, new_parent)?;
|
|
if next == current {
|
|
return Ok(());
|
|
}
|
|
write(&next).map_err(|e| format!("write topology.json: {e}"))
|
|
}
|
|
|
|
/// Reconcile `topology.json` against the current agent set. Adds an
|
|
/// entry (default: parent = manager, manager itself = root) for any
|
|
/// agent missing from the file; removes entries for agents no longer
|
|
/// present. Existing entries are preserved as-is — operator/manager
|
|
/// choices stick across regenerations. Returns true when the file
|
|
/// changed and should be re-committed by the caller.
|
|
pub fn reconcile(agent_names: &[String]) -> std::io::Result<bool> {
|
|
let mut current = read();
|
|
let mut changed = false;
|
|
// Add missing agents at their default position.
|
|
for name in agent_names {
|
|
if !current.contains_key(name) {
|
|
let parent = if name == crate::lifecycle::MANAGER_NAME {
|
|
None
|
|
} else {
|
|
Some(crate::lifecycle::MANAGER_NAME.to_owned())
|
|
};
|
|
current.insert(name.clone(), parent);
|
|
changed = true;
|
|
}
|
|
}
|
|
// Drop entries for agents that no longer exist.
|
|
let known: std::collections::HashSet<_> = agent_names.iter().collect();
|
|
current.retain(|name, _| {
|
|
let keep = known.contains(name);
|
|
if !keep {
|
|
changed = true;
|
|
}
|
|
keep
|
|
});
|
|
if changed {
|
|
write(¤t)?;
|
|
}
|
|
// Keep roles in sync with the agent set.
|
|
reconcile_roles(agent_names)?;
|
|
Ok(changed)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Roles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Agents with this role have every parentless agent in the topology
|
|
/// (see `top_level_agents`) added as virtual children for bind-mount
|
|
/// and config-change purposes. Enables recovery: if a top-level agent
|
|
/// is down, a role holder can still read its state and update its config.
|
|
///
|
|
/// The manager receives this role by default on first `reconcile_roles`
|
|
/// call; operators can revoke it with `set_role`.
|
|
pub const ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS: &str = "can_manage_top_level_agents";
|
|
|
|
const ROLES_FILE: &str = "roles.json";
|
|
|
|
#[must_use]
|
|
pub fn roles_path() -> std::path::PathBuf {
|
|
crate::meta::meta_dir().join(ROLES_FILE)
|
|
}
|
|
|
|
/// Read the roles map from disk. Returns an empty map when absent or
|
|
/// unparsable — same safe-degradation pattern as `topology::read`.
|
|
#[must_use]
|
|
pub fn read_roles() -> BTreeMap<String, Vec<String>> {
|
|
let Ok(raw) = std::fs::read_to_string(roles_path()) else {
|
|
return BTreeMap::new();
|
|
};
|
|
serde_json::from_str(&raw).unwrap_or_default()
|
|
}
|
|
|
|
/// Persist the roles map. Sorted output keeps git diffs minimal.
|
|
pub fn write_roles(roles: &BTreeMap<String, Vec<String>>) -> std::io::Result<()> {
|
|
let path = roles_path();
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let text = serde_json::to_string_pretty(roles)
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
|
std::fs::write(&path, format!("{text}\n"))
|
|
}
|
|
|
|
/// Return true when `name` holds `role`.
|
|
#[must_use]
|
|
pub fn has_role(name: &str, role: &str) -> bool {
|
|
has_role_in(&read_roles(), name, role)
|
|
}
|
|
|
|
/// Pure form of [`has_role`] for unit tests.
|
|
#[must_use]
|
|
pub fn has_role_in(roles: &BTreeMap<String, Vec<String>>, name: &str, role: &str) -> bool {
|
|
roles
|
|
.get(name)
|
|
.is_some_and(|rs| rs.iter().any(|r| r == role))
|
|
}
|
|
|
|
/// Grant or revoke a role for `name`. Idempotent — no disk write when the
|
|
/// state is already correct.
|
|
///
|
|
/// Empty role lists are kept in the map (never removed). An absent key means
|
|
/// "never seen" (seed on next `reconcile_roles`); an empty list means
|
|
/// "explicitly revoked" (do not re-seed). Callers that want to remove an
|
|
/// agent from the map entirely should use `reconcile_roles` (agent departure).
|
|
pub fn set_role(name: &str, role: &str, enabled: bool) -> Result<(), String> {
|
|
let mut roles = read_roles();
|
|
let list = roles.entry(name.to_owned()).or_default();
|
|
let held = list.iter().any(|r| r == role);
|
|
match (enabled, held) {
|
|
(true, false) => list.push(role.to_owned()),
|
|
(false, true) => list.retain(|r| r != role),
|
|
_ => return Ok(()),
|
|
}
|
|
// Intentionally do NOT remove empty entries — an empty list signals an
|
|
// explicit revoke and prevents reconcile_roles from re-seeding the role.
|
|
write_roles(&roles).map_err(|e| format!("write roles.json: {e}"))
|
|
}
|
|
|
|
/// Reconcile `roles.json` against the current agent set:
|
|
/// - Seeds root's default `can_manage_top_level_agents` role on first
|
|
/// appearance (operator can revoke with `set_role`).
|
|
/// - Drops entries for agents that no longer exist.
|
|
///
|
|
/// Returns true when the file changed.
|
|
pub fn reconcile_roles(agent_names: &[String]) -> std::io::Result<bool> {
|
|
let mut roles = read_roles();
|
|
let mut changed = false;
|
|
|
|
let root = crate::lifecycle::MANAGER_NAME;
|
|
if agent_names.iter().any(|n| n == root) && !roles.contains_key(root) {
|
|
roles.insert(
|
|
root.to_owned(),
|
|
vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()],
|
|
);
|
|
changed = true;
|
|
}
|
|
|
|
let known: std::collections::HashSet<_> = agent_names.iter().collect();
|
|
roles.retain(|name, _| {
|
|
let keep = known.contains(name);
|
|
if !keep {
|
|
changed = true;
|
|
}
|
|
keep
|
|
});
|
|
|
|
if changed {
|
|
write_roles(&roles)?;
|
|
}
|
|
Ok(changed)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_seed_makes_manager_root_others_children() {
|
|
let agents = vec![
|
|
"alice".to_owned(),
|
|
crate::lifecycle::MANAGER_NAME.to_owned(),
|
|
"bob".to_owned(),
|
|
];
|
|
let seed = default_seed(&agents);
|
|
assert_eq!(
|
|
seed.get(crate::lifecycle::MANAGER_NAME),
|
|
Some(&None),
|
|
"manager should be root"
|
|
);
|
|
assert_eq!(
|
|
seed.get("alice"),
|
|
Some(&Some(crate::lifecycle::MANAGER_NAME.to_owned()))
|
|
);
|
|
assert_eq!(
|
|
seed.get("bob"),
|
|
Some(&Some(crate::lifecycle::MANAGER_NAME.to_owned()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_seed_handles_empty_input() {
|
|
let seed = default_seed(&[]);
|
|
assert!(seed.is_empty());
|
|
}
|
|
|
|
fn topo_three_level() -> BTreeMap<String, Option<String>> {
|
|
let mut m = BTreeMap::new();
|
|
m.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None);
|
|
m.insert(
|
|
"alice".to_owned(),
|
|
Some(crate::lifecycle::MANAGER_NAME.to_owned()),
|
|
);
|
|
m.insert("bob".to_owned(), Some("alice".to_owned()));
|
|
m.insert("carol".to_owned(), Some("alice".to_owned()));
|
|
m
|
|
}
|
|
|
|
#[test]
|
|
fn apply_set_parent_promotes_to_root() {
|
|
let next = apply_set_parent(&topo_three_level(), "alice", None).unwrap();
|
|
assert_eq!(next.get("alice"), Some(&None));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_set_parent_reparents_under_sibling_subtree() {
|
|
// bob and carol both under alice; move carol under bob.
|
|
let next = apply_set_parent(&topo_three_level(), "carol", Some("bob")).unwrap();
|
|
assert_eq!(next.get("carol"), Some(&Some("bob".to_owned())));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_set_parent_allows_manager_move() {
|
|
// The manager is reparentable like any other agent (its
|
|
// privileges live on the MCP socket, not its tree position).
|
|
// Build a topo with an unrelated root-level agent `peer` so
|
|
// moving the manager under it doesn't trip the cycle walk
|
|
// (every non-manager agent in topo_three_level descends from
|
|
// the manager, so that fixture can't exercise a legal
|
|
// manager move).
|
|
let mut topo = BTreeMap::new();
|
|
topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None);
|
|
topo.insert("peer".to_owned(), None);
|
|
let next = apply_set_parent(&topo, crate::lifecycle::MANAGER_NAME, Some("peer"))
|
|
.expect("manager move should succeed");
|
|
assert_eq!(
|
|
next.get(crate::lifecycle::MANAGER_NAME),
|
|
Some(&Some("peer".to_owned()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_set_parent_refuses_manager_under_own_descendant() {
|
|
// Moving the manager under `bob` (who already lives under
|
|
// `alice` who lives under the manager) would close the loop.
|
|
// The general cycle walk catches this; no separate manager
|
|
// guard needed.
|
|
let err = apply_set_parent(
|
|
&topo_three_level(),
|
|
crate::lifecycle::MANAGER_NAME,
|
|
Some("bob"),
|
|
)
|
|
.unwrap_err();
|
|
assert!(err.contains("cycle"), "err = {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn apply_set_parent_refuses_unknown_child() {
|
|
let err = apply_set_parent(&topo_three_level(), "nobody", Some("alice")).unwrap_err();
|
|
assert!(err.contains("unknown agent"), "err = {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn apply_set_parent_refuses_unknown_parent() {
|
|
let err = apply_set_parent(&topo_three_level(), "bob", Some("nobody")).unwrap_err();
|
|
assert!(err.contains("unknown parent"), "err = {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn apply_set_parent_refuses_self() {
|
|
let err = apply_set_parent(&topo_three_level(), "alice", Some("alice")).unwrap_err();
|
|
assert!(err.contains("own parent"), "err = {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn apply_set_parent_refuses_cycle() {
|
|
// bob's parent is alice; trying to make alice's parent =
|
|
// bob would close the loop alice → bob → alice.
|
|
let err = apply_set_parent(&topo_three_level(), "alice", Some("bob")).unwrap_err();
|
|
assert!(err.contains("cycle"), "err = {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn apply_set_parent_refuses_deep_cycle() {
|
|
// Three-deep chain: manager → alice → bob → carol. Moving
|
|
// alice under carol would create the loop alice → carol → bob → alice.
|
|
let mut topo = BTreeMap::new();
|
|
topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None);
|
|
topo.insert(
|
|
"alice".to_owned(),
|
|
Some(crate::lifecycle::MANAGER_NAME.to_owned()),
|
|
);
|
|
topo.insert("bob".to_owned(), Some("alice".to_owned()));
|
|
topo.insert("carol".to_owned(), Some("bob".to_owned()));
|
|
let err = apply_set_parent(&topo, "alice", Some("carol")).unwrap_err();
|
|
assert!(err.contains("cycle"), "err = {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn apply_set_parent_is_idempotent_noop() {
|
|
// bob is already under alice — same value returned.
|
|
let next = apply_set_parent(&topo_three_level(), "bob", Some("alice")).unwrap();
|
|
assert_eq!(next, topo_three_level());
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_recipient_passes_through_ordinary_names() {
|
|
let topo = topo_three_level();
|
|
// Real labels, broadcast, and the operator literal all
|
|
// shortcut through unchanged — no resolution magic.
|
|
assert_eq!(resolve_recipient_in(&topo, "bob", "alice"), "alice");
|
|
assert_eq!(resolve_recipient_in(&topo, "bob", "*"), "*");
|
|
assert_eq!(
|
|
resolve_recipient_in(&topo, "bob", hive_sh4re::OPERATOR_RECIPIENT),
|
|
hive_sh4re::OPERATOR_RECIPIENT
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_recipient_rewrites_parent_sentinel_to_parent_label() {
|
|
let topo = topo_three_level();
|
|
// bob's parent is alice → `<parent>` from bob goes to alice.
|
|
assert_eq!(
|
|
resolve_recipient_in(&topo, "bob", hive_sh4re::PARENT_RECIPIENT),
|
|
"alice"
|
|
);
|
|
// alice's parent is the manager — same one-hop rewrite.
|
|
assert_eq!(
|
|
resolve_recipient_in(&topo, "alice", hive_sh4re::PARENT_RECIPIENT),
|
|
crate::lifecycle::MANAGER_NAME
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_recipient_falls_back_to_operator_for_root_agent() {
|
|
let topo = topo_three_level();
|
|
// Manager is structurally root (parent = None) → `<parent>`
|
|
// resolves to the operator (the "no parent → tell mara"
|
|
// fallback documented in conventions.md).
|
|
assert_eq!(
|
|
resolve_recipient_in(
|
|
&topo,
|
|
crate::lifecycle::MANAGER_NAME,
|
|
hive_sh4re::PARENT_RECIPIENT
|
|
),
|
|
hive_sh4re::OPERATOR_RECIPIENT
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_recipient_falls_back_to_operator_for_unknown_sender() {
|
|
// Sender absent from topology entirely — defensive fallback
|
|
// covers the race window where an agent's spawn has registered
|
|
// its socket but the meta-flake `sync_agents` hasn't yet added
|
|
// its row.
|
|
let topo = topo_three_level();
|
|
assert_eq!(
|
|
resolve_recipient_in(&topo, "nobody", hive_sh4re::PARENT_RECIPIENT),
|
|
hive_sh4re::OPERATOR_RECIPIENT
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn children_of_in_returns_direct_descendants() {
|
|
let topo = topo_three_level();
|
|
// alice's children: bob, carol.
|
|
let mut children = children_of_in(&topo, "alice");
|
|
children.sort();
|
|
assert_eq!(children, vec!["bob", "carol"]);
|
|
}
|
|
|
|
#[test]
|
|
fn children_of_in_manager_returns_root_level_agents() {
|
|
let topo = topo_three_level();
|
|
// Only alice's parent is manager; bob+carol are under alice.
|
|
let children = children_of_in(&topo, crate::lifecycle::MANAGER_NAME);
|
|
assert_eq!(children, vec!["alice"]);
|
|
}
|
|
|
|
#[test]
|
|
fn children_of_in_leaf_returns_empty() {
|
|
let topo = topo_three_level();
|
|
// bob and carol have no children.
|
|
assert!(children_of_in(&topo, "bob").is_empty());
|
|
assert!(children_of_in(&topo, "carol").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn children_of_in_unknown_sender_returns_empty() {
|
|
let topo = topo_three_level();
|
|
assert!(children_of_in(&topo, "nobody").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn top_level_agents_in_returns_parentless_agents() {
|
|
let topo = topo_three_level();
|
|
// Only the manager has no parent (alice/bob/carol all have parents).
|
|
let top = top_level_agents_in(&topo);
|
|
assert_eq!(top, vec![crate::lifecycle::MANAGER_NAME]);
|
|
}
|
|
|
|
#[test]
|
|
fn top_level_agents_in_multi_root_returns_all_parentless() {
|
|
let mut topo = topo_three_level();
|
|
// Simulate a second parentless agent alongside the manager.
|
|
topo.insert("orphan".to_owned(), None);
|
|
let mut top = top_level_agents_in(&topo);
|
|
top.sort();
|
|
assert_eq!(top, vec![crate::lifecycle::MANAGER_NAME, "orphan"]);
|
|
}
|
|
|
|
#[test]
|
|
fn top_level_agents_in_empty_topo_returns_empty() {
|
|
let topo = BTreeMap::new();
|
|
assert!(top_level_agents_in(&topo).is_empty());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Roles tests (no disk I/O — use the pure `has_role_in` / in-memory maps)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn has_role_in_returns_true_when_role_held() {
|
|
let mut roles = BTreeMap::new();
|
|
roles.insert(
|
|
"alice".to_owned(),
|
|
vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()],
|
|
);
|
|
assert!(has_role_in(
|
|
&roles,
|
|
"alice",
|
|
ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn has_role_in_returns_false_for_absent_agent() {
|
|
let roles: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
|
assert!(!has_role_in(
|
|
&roles,
|
|
"alice",
|
|
ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn has_role_in_returns_false_for_empty_list() {
|
|
let mut roles = BTreeMap::new();
|
|
roles.insert("alice".to_owned(), vec![]);
|
|
assert!(!has_role_in(
|
|
&roles,
|
|
"alice",
|
|
ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS
|
|
));
|
|
}
|
|
|
|
/// Revoking a role must leave the key present with an empty list so
|
|
/// `reconcile_roles` does not re-seed it.
|
|
#[test]
|
|
fn set_role_revoke_keeps_empty_entry_as_tombstone() {
|
|
let mgr = crate::lifecycle::MANAGER_NAME;
|
|
// Build an in-memory roles map as set_role would see it after granting.
|
|
let mut roles: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
|
roles.insert(
|
|
mgr.to_owned(),
|
|
vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()],
|
|
);
|
|
|
|
// Simulate the revoke path of set_role (in-memory, no disk).
|
|
let list = roles.entry(mgr.to_owned()).or_default();
|
|
let held = list.iter().any(|r| r == ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS);
|
|
assert!(held);
|
|
list.retain(|r| r != ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS);
|
|
|
|
// Key must still be present (tombstone), just with an empty list.
|
|
assert!(roles.contains_key(mgr), "empty entry must not be removed");
|
|
assert!(roles[mgr].is_empty());
|
|
}
|
|
|
|
/// `reconcile_roles` must not re-seed the manager when its entry exists
|
|
/// but is empty (operator explicitly revoked the role).
|
|
#[test]
|
|
fn reconcile_roles_in_does_not_reseed_after_explicit_revoke() {
|
|
let mgr = crate::lifecycle::MANAGER_NAME;
|
|
let agent_names = vec![mgr.to_owned(), "alice".to_owned()];
|
|
let mut roles: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
|
// Tombstone: manager was seen before but all roles were revoked.
|
|
roles.insert(mgr.to_owned(), vec![]);
|
|
|
|
let mgr_present = agent_names.iter().any(|n| n == mgr);
|
|
let should_seed = mgr_present && !roles.contains_key(mgr);
|
|
// should_seed must be false because manager key is present (tombstone).
|
|
assert!(
|
|
!should_seed,
|
|
"reconcile_roles must not re-seed an explicit revoke"
|
|
);
|
|
}
|
|
|
|
/// `reconcile_roles` seeds the manager on first appearance (no prior entry).
|
|
#[test]
|
|
fn reconcile_roles_in_seeds_root_when_absent() {
|
|
let mgr = crate::lifecycle::MANAGER_NAME;
|
|
let agent_names = vec![mgr.to_owned(), "alice".to_owned()];
|
|
let roles: BTreeMap<String, Vec<String>> = BTreeMap::new(); // empty
|
|
|
|
let should_seed = agent_names.iter().any(|n| n == mgr) && !roles.contains_key(mgr);
|
|
assert!(should_seed, "reconcile_roles must seed manager when absent");
|
|
}
|
|
}
|