diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 19dd5673..62e230f4 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -21,8 +21,6 @@ This document contains the help content for the `hivectl` command-line program. * [`hivectl agents restart`↴](#hivectl-agents-restart) * [`hivectl agents restart-all`↴](#hivectl-agents-restart-all) * [`hivectl choom`↴](#hivectl-choom) -* [`hivectl stop`↴](#hivectl-stop) -* [`hivectl start`↴](#hivectl-start) ## `hivectl` @@ -37,8 +35,6 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that * `gateway` — Gateway htpasswd user management. Add, remove, or list users in an htpasswd file used by the gateway's HTTP Basic auth (`services.hyperhive.gateway.auth`). Credentials are stored as `BCrypt` hashes — no extra service or PAM required * `agents` — Agent container management. Requires the hive-c0re daemon to be running (connects to the host admin socket) * `choom` — Open an interactive Claude session inside an agent container -* `stop` — Stop containers hive-wide in one operator action. Bare `hivectl stop` stops **everything** — all sub-agents plus the ci, forge, gateway, and matrix infra containers. Narrow it with scope flags: `--agents` (all sub-agents), `--ci` / `--forge` / `--gateway` / `--matrix` (named infra), and `--agent ` (repeatable) for specific sub-agents. Flags are additive (e.g. `--agents --matrix`). Requires the hive-c0re daemon (connects to the host admin socket). hive-c0re itself is never stopped — it services the request -* `start` — Start containers hive-wide — the inverse of `hivectl stop`. Bare `hivectl start` starts everything back up; the same scope flags as `stop` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, `--matrix`, `--agent `). Requires the hive-c0re daemon @@ -301,49 +297,6 @@ Pass `--fresh` to start a new Claude session instead of continuing the most rece -## `hivectl stop` - -Stop containers hive-wide in one operator action. Bare `hivectl stop` stops **everything** — all sub-agents plus the ci, forge, gateway, and matrix infra containers. Narrow it with scope flags: `--agents` (all sub-agents), `--ci` / `--forge` / `--gateway` / `--matrix` (named infra), and `--agent ` (repeatable) for specific sub-agents. Flags are additive (e.g. `--agents --matrix`). Requires the hive-c0re daemon (connects to the host admin socket). hive-c0re itself is never stopped — it services the request - -**Usage:** `hivectl stop [OPTIONS]` - -###### **Options:** - -* `--agents` — All sub-agent containers -* `--agent ` — A specific sub-agent by name. Repeatable: `--agent a --agent b` -* `--ci` — The CI runner container (`hive-ci`) -* `--forge` — The forge container (`hive-forge`) -* `--gateway` — The gateway container (`hive-gateway`) -* `--matrix` — The matrix container (`hive-matrix`) -* `--graceful` — Gracefully quiesce each agent (finish the current turn, drain the inbox) before stopping, instead of a hard stop. - - NOTE: not yet effective — the per-agent quiesce is still being implemented (see the graceful-agent-stop tracker), so today this falls through to a hard stop. The flag is accepted now so the wire/CLI shape is stable when the quiesce lands. -* `--socket ` — Path to the hive-c0re host admin socket - - Default value: `/run/hyperhive/host.sock` - - - -## `hivectl start` - -Start containers hive-wide — the inverse of `hivectl stop`. Bare `hivectl start` starts everything back up; the same scope flags as `stop` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, `--matrix`, `--agent `). Requires the hive-c0re daemon - -**Usage:** `hivectl start [OPTIONS]` - -###### **Options:** - -* `--agents` — All sub-agent containers -* `--agent ` — A specific sub-agent by name. Repeatable: `--agent a --agent b` -* `--ci` — The CI runner container (`hive-ci`) -* `--forge` — The forge container (`hive-forge`) -* `--gateway` — The gateway container (`hive-gateway`) -* `--matrix` — The matrix container (`hive-matrix`) -* `--socket ` — Path to the hive-c0re host admin socket - - Default value: `/run/hyperhive/host.sock` - - -
diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 2808fe70..68d00ccd 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -518,10 +518,10 @@ async fn handle_start_child(coord: &Arc, agent: &str, name: &str) - async fn handle_restart_child(coord: &Arc, agent: &str, name: &str) -> AgentResponse { // Infra-container restart: an agent holding the `infra_admin` // capability can restart a hive infrastructure container (hive-ci / - // hive-gateway / hive-forge / hive-matrix) by passing its name to the - // same restart tool. These names are never agent children, so this - // branch is disjoint from the child-restart path below. - if hive_sh4re::priv_proto::SIBLING_CONTAINERS.contains(&name) { + // hive-gateway / hive-forge) by passing its name to the same restart + // tool. These names are never agent children, so this branch is + // disjoint from the child-restart path below. + if hive_sh4re::priv_proto::RESTARTABLE_INFRA_CONTAINERS.contains(&name) { return handle_restart_infra(coord, agent, name).await; } if let Some(err) = require_child(agent, name, "restart") { @@ -541,7 +541,7 @@ async fn handle_restart_child(coord: &Arc, agent: &str, name: &str) /// Restart a hive infrastructure container on behalf of an agent that /// holds the `infra_admin` capability. The container name is already -/// known to be in `SIBLING_CONTAINERS`; this gates on the +/// known to be in `RESTARTABLE_INFRA_CONTAINERS`; this gates on the /// capability and routes the systemctl restart through hive-priv (which /// re-validates the name root-side). Direct, not approval-gated. async fn handle_restart_infra( diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index 1fe74ed1..9374c004 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -19,7 +19,7 @@ use std::os::unix::process::CommandExt as _; use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; -use clap::{Args, Parser, Subcommand}; +use clap::{Parser, Subcommand}; use hive_c0re::coordinator::Coordinator; #[derive(Parser)] @@ -100,41 +100,6 @@ enum Cmd { #[arg(long)] fresh: bool, }, - /// Stop containers hive-wide in one operator action. Bare `hivectl - /// stop` stops **everything** — all sub-agents plus the ci, forge, - /// gateway, and matrix infra containers. Narrow it with scope flags: - /// `--agents` (all sub-agents), `--ci` / `--forge` / `--gateway` / - /// `--matrix` (named infra), and `--agent ` (repeatable) for - /// specific sub-agents. Flags are additive (e.g. `--agents --matrix`). - /// Requires the hive-c0re daemon (connects to the host admin socket). - /// hive-c0re itself is never stopped — it services the request. - Stop { - #[command(flatten)] - scope: ScopeArgs, - /// Gracefully quiesce each agent (finish the current turn, drain - /// the inbox) before stopping, instead of a hard stop. - /// - /// NOTE: not yet effective — the per-agent quiesce is still being - /// implemented (see the graceful-agent-stop tracker), so today - /// this falls through to a hard stop. The flag is accepted now so - /// the wire/CLI shape is stable when the quiesce lands. - #[arg(long)] - graceful: bool, - /// Path to the hive-c0re host admin socket. - #[arg(long, default_value = DEFAULT_HOST_SOCKET)] - socket: PathBuf, - }, - /// Start containers hive-wide — the inverse of `hivectl stop`. Bare - /// `hivectl start` starts everything back up; the same scope flags as - /// `stop` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, - /// `--matrix`, `--agent `). Requires the hive-c0re daemon. - Start { - #[command(flatten)] - scope: ScopeArgs, - /// Path to the hive-c0re host admin socket. - #[arg(long, default_value = DEFAULT_HOST_SOCKET)] - socket: PathBuf, - }, /// Emit the full CLI reference as `CommonMark` to stdout. /// /// Hidden tooling command (not part of day-to-day operator admin): @@ -146,49 +111,6 @@ enum Cmd { MarkdownDocs, } -/// Shared scope flags for `hivectl stop` / `hivectl start`. With no flag -/// set the verb targets **everything** (all sub-agents + every controllable -/// infra container). Setting any flag restricts to the selected classes, -/// additively. -// One bool per selectable container class, each mapping 1:1 to a clap flag; -// orthogonal toggles, not a state machine — hence the bools allow (mirrors -// `hive_sh4re::LifecycleScope`). -#[allow(clippy::struct_excessive_bools)] -#[derive(Args)] -struct ScopeArgs { - /// All sub-agent containers. - #[arg(long)] - agents: bool, - /// A specific sub-agent by name. Repeatable: `--agent a --agent b`. - #[arg(long = "agent", value_name = "NAME")] - agent: Vec, - /// The CI runner container (`hive-ci`). - #[arg(long)] - ci: bool, - /// The forge container (`hive-forge`). - #[arg(long)] - forge: bool, - /// The gateway container (`hive-gateway`). - #[arg(long)] - gateway: bool, - /// The matrix container (`hive-matrix`). - #[arg(long)] - matrix: bool, -} - -impl ScopeArgs { - fn to_scope(&self) -> hive_sh4re::LifecycleScope { - hive_sh4re::LifecycleScope { - agents: self.agents, - agent_names: self.agent.clone(), - ci: self.ci, - forge: self.forge, - gateway: self.gateway, - matrix: self.matrix, - } - } -} - #[derive(Subcommand)] enum ForgeCmd { /// Create or refresh the Forgejo account + token for ``. @@ -426,12 +348,6 @@ async fn main() -> Result<()> { AgentsCmd::Restart { name, socket } => agents_restart(&socket, &name).await, AgentsCmd::RestartAll { socket } => agents_restart_all(&socket).await, }, - Cmd::Stop { - scope, - graceful, - socket, - } => stop(&socket, scope.to_scope(), graceful).await, - Cmd::Start { scope, socket } => start(&socket, scope.to_scope()).await, Cmd::Choom { name, fresh } => choom(&name, fresh), Cmd::MarkdownDocs => { print!("{}", clap_markdown::help_markdown::()); @@ -888,43 +804,6 @@ async fn agents_restart_all(socket: &Path) -> Result<()> { Ok(()) } -async fn stop(socket: &Path, scope: hive_sh4re::LifecycleScope, graceful: bool) -> Result<()> { - let resp = - hive_c0re::client::request(socket, hive_sh4re::HostRequest::Stop { scope, graceful }) - .await - .with_context(|| format!("connect to daemon socket {}", socket.display()))?; - render_lifecycle(&resp, "stopped") -} - -async fn start(socket: &Path, scope: hive_sh4re::LifecycleScope) -> Result<()> { - let resp = hive_c0re::client::request(socket, hive_sh4re::HostRequest::Start { scope }) - .await - .with_context(|| format!("connect to daemon socket {}", socket.display()))?; - render_lifecycle(&resp, "started") -} - -/// Render a hive-wide stop/start response: one `: ` line per -/// touched container, then surface any aggregated per-target failure as a -/// non-zero exit. `verb` is the past-tense word printed per item -/// (`stopped` / `started`). -fn render_lifecycle(resp: &hive_sh4re::HostResponse, verb: &str) -> Result<()> { - let items = resp.agents.as_deref().unwrap_or(&[]); - if items.is_empty() { - println!("{verb}: nothing matched the requested scope"); - } else { - for item in items { - println!("{verb}: {item}"); - } - } - if !resp.ok { - bail!( - "{verb}: {}", - resp.error.as_deref().unwrap_or("unknown error") - ); - } - Ok(()) -} - /// Reject usernames containing `:` (field separator) or control chars /// that would corrupt the htpasswd file format. fn validate_htpasswd_username(username: &str) -> Result<()> { diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index b6a25b5f..6f38a131 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -8,8 +8,8 @@ use anyhow::{Context as _, Result, bail}; use hive_sh4re::priv_proto::{ - BindMount, InfraAction, JournalQuery, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, - PrivResponse, PrivStream, + BindMount, JournalQuery, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, + PrivStream, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; @@ -283,24 +283,14 @@ pub async fn restart_matrix_daemon(agent_name: &str) -> Result<()> { .await?) } -/// Restart a hive infrastructure container on the host (thin wrapper over -/// [`control_infra_container`] with `action = Restart`). hive-priv -/// re-validates `container` against its root-side allowlist; callers must -/// already have checked the requesting agent holds the `infra_admin` -/// capability. +/// Restart a hive infrastructure container (hive-ci / hive-gateway / +/// hive-forge) on the host via `systemctl restart +/// container@.service`. hive-priv re-validates `container` +/// against its root-side allowlist; callers must already have checked +/// the requesting agent holds the `infra_admin` capability. pub async fn restart_infra_container(container: &str) -> Result<()> { - control_infra_container(container, InfraAction::Restart).await -} - -/// Start / stop / restart a hive infrastructure container (`hive-ci`, -/// `hive-gateway`, `hive-forge`, `hive-matrix`) on the host via `systemctl -/// container@.service`. hive-priv re-validates -/// `container` against its root-side allowlist (`SIBLING_CONTAINERS`). Used -/// by the hive-wide `hivectl stop` / `hivectl start` flow. -pub async fn control_infra_container(container: &str, action: InfraAction) -> Result<()> { - ok(call(&PrivRequest::ControlInfraContainer { + ok(call(&PrivRequest::RestartInfraContainer { container: container.to_owned(), - action, }) .await?) } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 2e2a041a..69e643d6 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -2,8 +2,7 @@ use std::path::Path; use std::sync::Arc; use anyhow::{Context, Result}; -use hive_sh4re::priv_proto::InfraAction; -use hive_sh4re::{HostRequest, HostResponse, LifecycleScope}; +use hive_sh4re::{HostRequest, HostResponse}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; @@ -94,20 +93,6 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { HostResponse::success() } HostRequest::RestartAll => handle_restart_all().await?, - HostRequest::Stop { scope, graceful } => { - // Resolve the scope to explicit container names at the entry - // point, then operate on names — never pass the bare "all - // agents" flag deeper (it'd force every consumer, incl. the - // graceful-stop queue, to re-expand it). - let agents = scoped_agents(scope).await?; - let infra = scoped_infra(scope); - handle_stop(&agents, &infra, *graceful).await? - } - HostRequest::Start { scope } => { - let agents = scoped_agents(scope).await?; - let infra = scoped_infra(scope); - handle_start(&agents, &infra).await? - } HostRequest::Destroy { name, purge } => { actions::destroy(&coord, name, *purge).await?; HostResponse::success() @@ -214,136 +199,6 @@ async fn handle_restart_all() -> Result { } } -/// Stop the given `agents` (resolved logical names) then `infra` containers -/// (`hivectl stop`). Agents go down before infra so they're not mid-request -/// against a forge/matrix that's already gone. Per-target failures are -/// aggregated rather than aborting on the first error, mirroring -/// `handle_restart_all`. Callers resolve the [`LifecycleScope`] to these -/// explicit name lists up front — this never sees the "all" flag. -async fn handle_stop(agents: &[String], infra: &[&str], graceful: bool) -> Result { - tracing::info!(?agents, ?infra, graceful, "stop"); - let mut ok_items: Vec = Vec::new(); - let mut errors: Vec = Vec::new(); - - for agent in agents { - // TODO(graceful agent stop): when `graceful`, run the per-agent - // quiesce (turn-end → drain → reject new messages) before the kill. - // The flag is threaded through the wire now; the quiesce itself - // lands with the graceful-agent-stop work. - let _ = graceful; - match lifecycle::kill(agent).await { - Ok(()) => ok_items.push(agent.clone()), - Err(e) => { - tracing::warn!(%agent, error = ?e, "stop: agent kill failed"); - errors.push(format!("{agent}: {e:#}")); - } - } - } - - for &container in infra { - match crate::priv_client::control_infra_container(container, InfraAction::Stop).await { - Ok(()) => ok_items.push(container.to_owned()), - Err(e) => { - tracing::warn!(%container, error = ?e, "stop: infra stop failed"); - errors.push(format!("{container}: {e:#}")); - } - } - } - - Ok(finish_lifecycle(ok_items, &errors)) -} - -/// Start the given `infra` containers then `agents` (`hivectl start`) — the -/// inverse of [`handle_stop`]. Infra comes up before agents so the agents -/// find forge/matrix/gateway ready. Per-target failures aggregated. Callers -/// resolve the [`LifecycleScope`] to these explicit name lists up front. -async fn handle_start(agents: &[String], infra: &[&str]) -> Result { - tracing::info!(?agents, ?infra, "start"); - let mut ok_items: Vec = Vec::new(); - let mut errors: Vec = Vec::new(); - - for &container in infra { - match crate::priv_client::control_infra_container(container, InfraAction::Start).await { - Ok(()) => ok_items.push(container.to_owned()), - Err(e) => { - tracing::warn!(%container, error = ?e, "start: infra start failed"); - errors.push(format!("{container}: {e:#}")); - } - } - } - - for agent in agents { - match lifecycle::start(agent).await { - Ok(()) => ok_items.push(agent.clone()), - Err(e) => { - tracing::warn!(%agent, error = ?e, "start: agent start failed"); - errors.push(format!("{agent}: {e:#}")); - } - } - } - - Ok(finish_lifecycle(ok_items, &errors)) -} - -/// Resolve which sub-agent logical names a scope targets: every live -/// container (from `lifecycle::list`) when `agents` is set or the scope is -/// "everything", plus any explicit `agent_names`. Returns de-duplicated -/// logical names with the `h-` container prefix stripped. -async fn scoped_agents(scope: &LifecycleScope) -> Result> { - use std::collections::BTreeSet; - let mut set: BTreeSet = BTreeSet::new(); - if scope.agents || scope.is_everything() { - for c in lifecycle::list().await? { - let logical = c - .strip_prefix(lifecycle::AGENT_PREFIX) - .unwrap_or(&c) - .to_owned(); - set.insert(logical); - } - } - for n in &scope.agent_names { - set.insert(n.clone()); - } - Ok(set.into_iter().collect()) -} - -/// Resolve which infra container names a scope targets. An "everything" -/// scope (no flags set) selects all controllable infra; otherwise each set -/// flag maps to its container. Fixed order for deterministic output. -fn scoped_infra(scope: &LifecycleScope) -> Vec<&'static str> { - let everything = scope.is_everything(); - let mut out = Vec::new(); - if everything || scope.ci { - out.push("hive-ci"); - } - if everything || scope.forge { - out.push("hive-forge"); - } - if everything || scope.gateway { - out.push("hive-gateway"); - } - if everything || scope.matrix { - out.push("hive-matrix"); - } - out -} - -/// Build the aggregated lifecycle response: `ok` with the touched names when -/// every target succeeded, otherwise `ok: false` with the joined errors and -/// the partial success list (matches `handle_restart_all`). -fn finish_lifecycle(ok_items: Vec, errors: &[String]) -> HostResponse { - if errors.is_empty() { - HostResponse::list(ok_items) - } else { - HostResponse { - ok: false, - error: Some(errors.join("; ")), - agents: Some(ok_items), - approvals: None, - } - } -} - /// Rebuild `name`'s container, notifying the manager of the outcome /// (success or failure) and kicking the agent's next turn on success. async fn handle_rebuild(coord: &Arc, name: &str) -> Result { diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 47e9cce1..55c69f94 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -21,9 +21,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use hive_sh4re::priv_proto::{ - AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, InfraAction, JournalQuery, MANAGER_NAME, META_DIR, + AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, JournalQuery, MANAGER_NAME, META_DIR, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, PrivStreamLine, - SIBLING_CONTAINERS, + RESTARTABLE_INFRA_CONTAINERS, SIBLING_CONTAINERS, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::unix::OwnedWriteHalf; @@ -265,10 +265,9 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String, restart_matrix_daemon(agent_name).await } - PrivRequest::ControlInfraContainer { - ref container, - action, - } => control_infra_container(container, action).await, + PrivRequest::RestartInfraContainer { ref container } => { + restart_infra_container(container).await + } } } @@ -395,32 +394,29 @@ async fn restart_matrix_daemon(agent_name: &str) -> Result<(String, String)> { )) } -/// `ControlInfraContainer` — start/stop/restart a hive infrastructure -/// container via `systemctl container@.service`. The -/// `container` is validated against `SIBLING_CONTAINERS` here, root-side; -/// this is the authoritative allowlist (hive-c0re is never in it, so a -/// stop can't sever the daemon socket the request arrived on). Serves both -/// the hive-wide `hivectl stop`/`start` flow and an `infra_admin` agent's -/// `restart` (action = Restart). -async fn control_infra_container(container: &str, action: InfraAction) -> Result<(String, String)> { - if !SIBLING_CONTAINERS.contains(&container) { - bail!("container {container:?} is not a controllable hive infra container"); +/// `RestartInfraContainer` — restart a hive infrastructure container on +/// the host via `systemctl restart container@.service`. The +/// `container` is validated against `RESTARTABLE_INFRA_CONTAINERS` here, +/// root-side, so this is the authoritative allowlist even though +/// hive-c0re also gates on the caller's `infra_admin` capability. +async fn restart_infra_container(container: &str) -> Result<(String, String)> { + if !RESTARTABLE_INFRA_CONTAINERS.contains(&container) { + bail!("container {container:?} is not a restartable hive infra container"); } - let verb = action.systemctl_verb(); let unit = format!("container@{container}.service"); let out = Command::new("systemctl") - .args([verb, &unit]) + .args(["restart", &unit]) .output() .await - .with_context(|| format!("systemctl {verb} {unit}"))?; + .with_context(|| format!("systemctl restart {unit}"))?; if !out.status.success() { bail!( - "systemctl {verb} {unit} exited {}: {}", + "systemctl restart {unit} exited {}: {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } - tracing::info!(target: "infra-control", "{verb} {unit}"); + tracing::info!(target: "infra-restart", "restarted {unit}"); Ok(( String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned(), diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index ba3789a7..7308761e 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -58,68 +58,6 @@ pub enum HostRequest { child: String, new_parent: Option, }, - /// Stop managed containers hive-wide in one operator action - /// (`hivectl stop`): agents plus the selected infra containers. `scope` - /// selects which classes; an all-false scope means **everything** (the - /// bare `hivectl stop`). `graceful` runs the per-agent quiesce (graceful - /// agent stop, issue tracker `graceful agent stop`) instead of a hard - /// stop. Agents stop via the lifecycle path; infra containers via the - /// host `container@` units. - Stop { - #[serde(default)] - scope: LifecycleScope, - #[serde(default)] - graceful: bool, - }, - /// Start managed containers hive-wide — the inverse of `Stop` - /// (`hivectl start`). Same `scope` semantics (all-false = everything); - /// no graceful flag (start is unconditional). - Start { - #[serde(default)] - scope: LifecycleScope, - }, -} - -/// Selects which container classes a hive-wide [`HostRequest::Stop`] / -/// [`HostRequest::Start`] touches. An all-false scope means **everything** -/// (the bare `hivectl stop` / `start`); set individual fields to restrict -/// (e.g. only `agents` → just the sub-agent containers). `agents` covers -/// every managed sub-agent container; the rest are the named infra -/// containers (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`). -// -// A flat bag of independent flag toggles — one bool per selectable -// container class — is exactly the right shape here: each maps 1:1 to a -// `hivectl` `--ci` / `--forge` / `--gateway` / `--matrix` flag, and they're -// orthogonal (any subset is valid), so a state machine or two-variant enums -// would only obscure the mapping. Hence the `struct_excessive_bools` allow. -#[allow(clippy::struct_excessive_bools)] -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct LifecycleScope { - /// All sub-agent containers (`--agents`). - #[serde(default)] - pub agents: bool, - /// Specific sub-agents by logical name (`--agent `, repeatable). - /// Additive with the rest of the scope; redundant when `agents` is set - /// (which already covers every sub-agent). - #[serde(default)] - pub agent_names: Vec, - #[serde(default)] - pub ci: bool, - #[serde(default)] - pub forge: bool, - #[serde(default)] - pub gateway: bool, - #[serde(default)] - pub matrix: bool, -} - -impl LifecycleScope { - /// True when nothing is explicitly selected — interpreted as "all - /// classes" (the bare `hivectl stop` / `start` with no scope flags). - pub fn is_everything(&self) -> bool { - !(self.agents || self.ci || self.forge || self.gateway || self.matrix) - && self.agent_names.is_empty() - } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 1b4a3e38..b23fca72 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -15,36 +15,17 @@ pub const MANAGER_NAME: &str = "ruth"; /// Sub-agent container prefix. System container name = `h-`. pub const AGENT_PREFIX: &str = "h-"; -/// Sibling service containers managed by hive-c0re. This doubles as the -/// authoritative allowlist for infra lifecycle ops -/// ([`PrivRequest::ControlInfraContainer`]): any of these four may be -/// started / stopped / restarted (by the hive-wide `hivectl stop`/`start` -/// flow or an `infra_admin` agent's `restart`). `hive-c0re` is deliberately -/// absent — stopping it would sever the very socket the request arrived on. -/// hive-priv re-validates against this list root-side, so it's authoritative -/// regardless of what the caller sends. +/// Sibling service containers managed by hive-c0re. pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gateway", "hive-ci"]; -/// Lifecycle verb for [`PrivRequest::ControlInfraContainer`]. Maps directly -/// to `systemctl container@.service`. -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum InfraAction { - Start, - Stop, - Restart, -} - -impl InfraAction { - /// The `systemctl` subcommand this action maps to. - pub fn systemctl_verb(self) -> &'static str { - match self { - InfraAction::Start => "start", - InfraAction::Stop => "stop", - InfraAction::Restart => "restart", - } - } -} +/// Infra containers an agent holding the `infra_admin` capability may +/// restart via the `restart` MCP tool. A deliberate subset of +/// [`SIBLING_CONTAINERS`]: hive-matrix is excluded (kicking the matrix +/// backend mid-sync is its own concern) and hive-c0re is excluded +/// entirely (a self-restart would sever the very socket the request +/// arrived on). hive-priv re-validates against this list root-side, so +/// it is the authoritative allowlist regardless of what the caller sends. +pub const RESTARTABLE_INFRA_CONTAINERS: &[&str] = &["hive-ci", "hive-gateway", "hive-forge"]; /// Host path of the meta flake. The flake ref for agent `` is /// `{META_DIR}#{name}`, derived by `hive-priv` — never passed over the wire. @@ -324,17 +305,16 @@ pub enum PrivRequest { agent_name: String, }, - /// Start / stop / restart a hive infrastructure container on the host - /// via `systemctl container@.service`. hive-priv - /// validates `container` against [`SIBLING_CONTAINERS`] root-side (the - /// authoritative allowlist; `hive-c0re` is never in it). Serves both the - /// hive-wide `hivectl stop` / `hivectl start` flow and an `infra_admin` - /// agent's `restart` (with `action = Restart`). - ControlInfraContainer { + /// Restart a hive infrastructure container on the host via + /// `systemctl restart container@.service`. hive-priv + /// validates `container` against [`RESTARTABLE_INFRA_CONTAINERS`] + /// before acting — the root-side allowlist is authoritative. Used + /// by hive-c0re to service a `restart` request from an agent that + /// holds the `infra_admin` capability. + RestartInfraContainer { /// Infra container name (e.g. `hive-ci`); must be in - /// [`SIBLING_CONTAINERS`]. + /// [`RESTARTABLE_INFRA_CONTAINERS`]. container: String, - action: InfraAction, }, } @@ -393,15 +373,21 @@ pub enum PrivEvent { #[cfg(test)] mod tests { - use super::SIBLING_CONTAINERS; + use super::{RESTARTABLE_INFRA_CONTAINERS, SIBLING_CONTAINERS}; #[test] - fn infra_control_allowlist_excludes_c0re_includes_matrix() { - // SIBLING_CONTAINERS is the authoritative allowlist for infra - // lifecycle ops. hive-c0re must NEVER be in it — stopping the daemon - // would sever the socket the request arrived on. - assert!(!SIBLING_CONTAINERS.contains(&"hive-c0re")); - // hive-matrix IS controllable (operator can stop/start/restart it). - assert!(SIBLING_CONTAINERS.contains(&"hive-matrix")); + fn restartable_infra_is_a_safe_subset_of_siblings() { + // Every restartable infra container must be a known sibling. + for c in RESTARTABLE_INFRA_CONTAINERS { + assert!( + SIBLING_CONTAINERS.contains(c), + "{c} is not a managed sibling container" + ); + } + // hive-matrix and hive-c0re are deliberately excluded: kicking the + // matrix backend mid-sync is its own concern, and a self-restart of + // c0re would sever the request socket. + assert!(!RESTARTABLE_INFRA_CONTAINERS.contains(&"hive-matrix")); + assert!(!RESTARTABLE_INFRA_CONTAINERS.contains(&"hive-c0re")); } }