diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index b2324080..34082d72 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -979,7 +979,10 @@ impl AgentServer { #[tool( description = "Restart a direct child sub-agent container (stop + start). \ Only succeeds if `name` is a direct child of this agent in the topology \ - tree — the server enforces this. No approval required." + tree — the server enforces this. No approval required. \ + Agents holding the `infra_admin` capability may also pass a hive \ + infrastructure container name (`hive-ci`, `hive-gateway`, `hive-forge`) \ + to restart it directly via the privileged helper." )] async fn restart(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); @@ -1792,6 +1795,13 @@ fn allowed_capability_tools() -> Vec { let t = token.trim().to_ascii_lowercase(); match t.as_str() { "read_host_journal" => tools.push("get_host_journal".to_owned()), + // infra_admin lets an agent restart hive infrastructure + // containers (hive-ci / hive-gateway / hive-forge) through the + // existing `restart` tool. Unlock it here so agents that hold + // the capability without the full `lifecycle` group can still + // call it; c0re re-checks the capability server-side and only + // honours infra-container names via this path. + "infra_admin" => tools.push("restart".to_owned()), // manage_root_agent / query_agent_state don't expose new MCP // tools: manage_root_agent gates existing lifecycle tools via // topology enforcement; query_agent_state unlocks the `agent` diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index c8842d06..86a405b8 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -391,7 +391,7 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> agent: target, } => handle_reminder_rollup(coord, agent, target.as_deref(), *since_secs), AgentRequest::Start { name } => handle_start_child(coord, agent, name).await, - AgentRequest::Restart { name } => handle_restart_child(coord, agent, name), + AgentRequest::Restart { name } => handle_restart_child(coord, agent, name).await, AgentRequest::Kill { name } => handle_kill_child(coord, agent, name).await, AgentRequest::Update { name } => handle_update_child(coord, agent, name), AgentRequest::ListDescendants => handle_list_descendants(agent).await, @@ -515,7 +515,15 @@ async fn handle_start_child(coord: &Arc, agent: &str, name: &str) - /// `Restart` — enqueue a restart for a direct-child container. /// Topology parenthood is the only authorisation criterion — no /// capability flag needed. -fn handle_restart_child(coord: &Arc, agent: &str, name: &str) -> AgentResponse { +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) 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(agent, name).await; + } if let Some(err) = require_child(agent, name, "restart") { return err; } @@ -531,6 +539,29 @@ fn handle_restart_child(coord: &Arc, agent: &str, name: &str) -> Ag AgentResponse::Ok } +/// 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 `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(agent: &str, container: &str) -> AgentResponse { + if !crate::capabilities::has_cap(agent, hive_sh4re::Capability::InfraAdmin) { + tracing::warn!(%agent, %container, "agent: infra restart denied (no infra_admin capability)"); + return AgentResponse::Err { + message: format!( + "restarting infra container `{container}` requires the `infra_admin` capability" + ), + }; + } + tracing::info!(%agent, %container, "agent: restart infra container"); + match crate::priv_client::restart_infra_container(container).await { + Ok(()) => AgentResponse::Ok, + Err(e) => AgentResponse::Err { + message: format!("{e:#}"), + }, + } +} + /// `Kill` — kill a direct-child container, unregister it, notify the /// manager. async fn handle_kill_child(coord: &Arc, agent: &str, name: &str) -> AgentResponse { diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index bc66b286..432216dc 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -276,6 +276,18 @@ pub async fn restart_matrix_daemon(agent_name: &str) -> Result<()> { .await?) } +/// 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<()> { + ok(call(&PrivRequest::RestartInfraContainer { + container: container.to_owned(), + }) + .await?) +} + fn check(resp: PrivResponse) -> Result<(String, String)> { if resp.ok { Ok((resp.stdout, resp.stderr)) diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 1fa655f6..78be00a1 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -23,7 +23,7 @@ use anyhow::{Context as _, Result, bail}; use hive_sh4re::priv_proto::{ 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; @@ -250,6 +250,10 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String, PrivRequest::RestartMatrixDaemon { ref agent_name } => { restart_matrix_daemon(agent_name).await } + + PrivRequest::RestartInfraContainer { ref container } => { + restart_infra_container(container).await + } } } @@ -376,6 +380,35 @@ async fn restart_matrix_daemon(agent_name: &str) -> Result<(String, String)> { )) } +/// `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 unit = format!("container@{container}.service"); + let out = Command::new("systemctl") + .args(["restart", &unit]) + .output() + .await + .with_context(|| format!("systemctl restart {unit}"))?; + if !out.status.success() { + bail!( + "systemctl restart {unit} exited {}: {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + tracing::info!(target: "infra-restart", "restarted {unit}"); + Ok(( + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + )) +} + /// Shared helper for `WriteAgentForgeToken` and `WriteAgentMatrixToken`. /// Writes `content` to `AGENT_STATE_ROOT//state/`, /// chowns to the agent user (derived from the state dir's existing owner), diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 35c25df3..191677bf 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -1031,6 +1031,13 @@ pub enum Capability { /// available on the agent socket even with this capability — use the /// manager socket for swarm-wide scans. QueryAgentState, + /// Agent can restart hive infrastructure containers (hive-ci, + /// hive-gateway, hive-forge) via the `restart` MCP tool. hive-c0re + /// checks this capability before routing the restart through + /// hive-priv; the concrete service allowlist lives root-side in + /// hive-priv. Deliberately generic ("infra admin") so future + /// privileged infra ops can hang off the same grant. + InfraAdmin, } impl Capability { @@ -1040,6 +1047,7 @@ impl Capability { Self::ManageRootAgent, Self::ReadHostJournal, Self::QueryAgentState, + Self::InfraAdmin, ]; /// Canonical `snake_case` name for this capability (matches serde). @@ -1049,6 +1057,7 @@ impl Capability { Self::ManageRootAgent => "manage_root_agent", Self::ReadHostJournal => "read_host_journal", Self::QueryAgentState => "query_agent_state", + Self::InfraAdmin => "infra_admin", } } @@ -1063,6 +1072,9 @@ impl Capability { Self::QueryAgentState => { "query non-child agents' loose ends and reminder state via get_loose_ends" } + Self::InfraAdmin => { + "restart hive infrastructure containers (hive-ci, hive-gateway, hive-forge) via the restart tool" + } } } } diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index e036d95e..32b9be0d 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -18,6 +18,15 @@ pub const AGENT_PREFIX: &str = "h-"; /// Sibling service containers managed by hive-c0re. pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gateway", "hive-ci"]; +/// 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. pub const META_DIR: &str = "/var/lib/hyperhive/meta"; @@ -287,6 +296,18 @@ pub enum PrivRequest { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, }, + + /// 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 + /// [`RESTARTABLE_INFRA_CONTAINERS`]. + container: String, + }, } /// Response from the privileged helper. @@ -341,3 +362,24 @@ pub enum PrivEvent { /// Terminal event: the operation has finished. Done(PrivResponse), } + +#[cfg(test)] +mod tests { + use super::{RESTARTABLE_INFRA_CONTAINERS, SIBLING_CONTAINERS}; + + #[test] + 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")); + } +}