remove hive-level infra-container restart from web ui and agents

This commit is contained in:
damocles 2026-08-30 22:12:04 +02:00 committed by mara
commit 7516a4e10e
17 changed files with 112 additions and 272 deletions

View file

@ -365,7 +365,6 @@ that allows the underlying resource access.
| `manage_root_agent` | may lifecycle-manage the root/manager agent via `kill`/`start`/`restart` |
| `read_host_journal` | `get_host_journal` MCP tool is registered + `GET /journal-host` requests are served |
| `query_agent_state` | may call `get_loose_ends` / `CountPendingReminders` targeting non-child agents |
| `infra_admin` | may call `restart(name)` on hive infrastructure containers (`hive-ci`, `hive-forge`, `hive-matrix`**not** `hive-gateway`, which is the host's nginx and is operator-only); each restart is logged to the dashboard AUDIT trail |
**Config storage** — per-agent capabilities live in
`/var/lib/hyperhive/meta/capabilities.json` alongside `tool-groups.json`.

View file

@ -159,24 +159,17 @@ omitted — agents share the host netns, so there is no per-container net
counter (per-agent network needs the netns-isolation roadmap in
`docs/network.md`).
**1NFR4** — start / stop / restart the four hive infrastructure services
(`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`) directly from the
dashboard, without needing an `infra_admin` agent's `restart` MCP tool.
Three are containers; `hive-gateway` is the host's `nginx.service`, and is
the one an agent may **not** restart — this panel is the way it gets
bounced. One row per service: name, a `badge-ok`/`badge-fail`
running/stopped dot, and `↺ R3ST4RT` + `■ ST0P` (running) or `▶ ST4RT`
(stopped) buttons, same themed-confirm pattern as the K3PT ST4T3
tombstone actions. Backed by
`POST /api/infra-container/{name}/{action}` (`action`
`start|stop|restart`), which calls the same
`priv_client::control_infra_container` helper the agent-facing
`infra_admin` path uses — no new privileged-helper surface, no
capability check (the dashboard is already operator-authenticated).
**1NFR4** — start / stop the four hive infrastructure services (`hive-ci`,
`hive-forge`, `hive-gateway`, `hive-matrix`) directly from the dashboard —
operator-only, no agent-facing equivalent. One row per service: name, a
`badge-ok`/`badge-fail` running/stopped dot, and `■ ST0P` (running) or
`▶ ST4RT` (stopped) buttons, same themed-confirm pattern as the K3PT ST4T3
tombstone actions. Backed by `POST /api/infra-container/{name}/{action}`
(`action``start|stop`), which calls `priv_client::control_infra_container`
— no capability check (the dashboard is already operator-authenticated).
Every attempt is written to the audit log (actor `"operator"`, action
`start_infra`/`stop_infra`/`restart_infra`) alongside agent-driven infra
restarts. Status rows ride the `infra_containers` field on
`GET /api/state`'s `StateSnapshot` (`{name, running}`, live
`start_infra`/`stop_infra`). Status rows ride the `infra_containers` field
on `GET /api/state`'s `StateSnapshot` (`{name, running}`, live
`systemctl is-active container@<unit>.service` read); `core.js` polls
`/api/state` every 5 s only while the 1NFR4 sub-tab is active, same
cadence/lifecycle as C0NT41N3R L04D's polling.
@ -440,7 +433,6 @@ The current capabilities are:
| `manage_root_agent` | allows the `set_status` / lifecycle tools on the root agent |
| `read_host_journal` | unlocks `get_host_journal` to read journald from inside a container |
| `query_agent_state` | allows `get_loose_ends(agent: "<name>")` calls targeting other agents |
| `infra_admin` | allows `restart` on hive infrastructure containers (`hive-ci`, `hive-forge`, `hive-matrix`; the gateway is operator-only); each restart is logged to the AUDIT trail |
Each row is one agent. Columns are the capability names returned by
`GET /api/capabilities` as `caps: Vec<String>`. Checking or unchecking
@ -754,17 +746,20 @@ chip ticks every 30 s. Available to the operator unconditionally (not
capability-gated — the endpoint lives on the hive-c0re dashboard,
behind the gateway).
**AUDIT sub-tab** — operator-visible trail of agent-initiated
privileged actions (e.g. infra-container restarts via `infra_admin`).
Lazy-fetched on tab show (like SYSTEM) from `GET /api/audit-log`, which
returns `{ entries, total }``entries` newest-first, server-clamped to
the latest 500; `total` drives a "latest 500 of N" count so the clamp is
never silent. Rendered as a filterable table (when / agent / action /
target / outcome / detail); the filter box is a client-side substring
match over the cached rows. The outcome badge colours `ok` green and
`err` red, with an `err` whose `detail` starts `denied:` (a capability
refusal) shown amber and labelled `denied` so it reads apart from an
execution failure. `ts_unix` is an RFC 3339 string; a 30 s ticker keeps the
**AUDIT sub-tab** — operator-visible trail of privileged actions worth a
durable who/what/when record (currently: infra-container start/stop from
the 1NFR4 panel — see `hive-c0re/src/stores/audit_log.rs`'s doc comment
for what's in scope). Lazy-fetched on tab show (like SYSTEM) from
`GET /api/audit-log`, which returns `{ entries, total }``entries`
newest-first, server-clamped to the latest 500; `total` drives a "latest
500 of N" count so the clamp is never silent. Rendered as a filterable
table (when / agent / action / target / outcome / detail); the filter box
is a client-side substring match over the cached rows. The outcome badge
colours `ok` green and `err` red, with an `err` whose `detail` starts
`denied:` (a capability refusal) shown amber and labelled `denied` so it
reads apart from an execution failure — generic styling for whichever
future privileged action writes that prefix, nothing currently produces
it. `ts_unix` is an RFC 3339 string; a 30 s ticker keeps the
relative "ago" column honest while the tab is in view. The backing
`audit_log` store records every privileged-action attempt (ok / err /
denied). New entries live-append without a refresh: an `audit_entry_added`
@ -1254,16 +1249,14 @@ below — some endpoints aren't in it yet.
a background `du -sxb` of the agent's state dir + container writable
rootfs every ~5 min, `-x` excluding the shared read-only nix store.
`null` until the first sample lands.
- `POST /api/infra-container/{name}/{action}` — start / stop / restart a
hive infra service (C0R3 1NFR4 panel). `name` parses into the
- `POST /api/infra-container/{name}/{action}` — start / stop a hive infra
service (C0R3 1NFR4 panel, operator-only). `name` parses into the
`InfraContainer` allowlist (`hive-ci`/`hive-forge`/`hive-gateway`/
`hive-matrix`, 400 on unknown), and the variant decides the unit —
`container@<name>.service`, or `nginx.service` for the gateway.
`action``start|stop|restart`. Calls
the same `priv_client::control_infra_container` helper the
`infra_admin` agent path uses; records an `audit_log` entry
(`start_infra`/`stop_infra`/`restart_infra`, actor `"operator"`) either
way.
`action``start|stop`. Calls `priv_client::control_infra_container`;
records an `audit_log` entry (`start_infra`/`stop_infra`, actor
`"operator"`) either way.
- `POST /api/cancel-reminder/{id}` — hard-delete a pending reminder.
- `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery
failed (clears the failure state so the scheduler retries).

View file

@ -55,11 +55,11 @@
</div>
</section>
<!-- 1NFR4: start / stop / restart the four hive infrastructure
containers (hive-ci, hive-forge, hive-gateway, hive-matrix)
directly from the dashboard, mirroring what an infra_admin
agent's `restart` tool already reaches. Status polled every 5s
while this sub-tab is open, same cadence as C0NT41N3R L04D. -->
<!-- 1NFR4: start / stop the four hive infrastructure containers
(hive-ci, hive-forge, hive-gateway, hive-matrix) directly from
the dashboard — operator-only, no agent-facing equivalent.
Status polled every 5s while this sub-tab is open, same cadence
as C0NT41N3R L04D. -->
<section class="core-pane" id="core-pane-infra" data-tab-pane="infra"
role="tabpanel" aria-labelledby="core-tab-infra">
<p class="meta">hive infrastructure containers — ci, forge, gateway, matrix. actions are logged to the AUDIT log same as agent-driven restarts.</p>

View file

@ -381,7 +381,7 @@ function stopContainerLoadPolling() {
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; }
}
// ─── infra containers (start/stop/restart the 4 hive infra containers) ────
// ─── infra containers (start/stop the 4 hive infra containers) ────────────
let infraTimer = null;
function renderInfraContainers(rows) {
@ -406,8 +406,6 @@ function renderInfraContainers(rows) {
const actions = el('div', { class: 'actions' });
const base = '/api/infra-container/' + encodeURIComponent(c.name) + '/';
if (c.running) {
actions.append(form(base + 'restart', 'btn-restart', '↺ R3ST4RT',
'restart ' + c.name + '?'));
actions.append(form(base + 'stop', 'btn-stop', '■ ST0P',
'stop ' + c.name + '?'));
} else {

View file

@ -564,11 +564,7 @@ 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. \
Agents holding the `infra_admin` capability may also pass a hive \
infrastructure container name (`hive-ci`, `hive-forge`, `hive-matrix`) \
to restart it directly via the privileged helper. The gateway is \
not restartable by an agent ask the operator."
tree the server enforces this. No approval required."
)]
async fn restart(&self, Parameters(args): Parameters<RestartArgs>) -> String {
let log = format!("{args:?}");

View file

@ -59,13 +59,6 @@ fn allowed_capability_tools() -> Vec<String> {
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-forge / hive-matrix — not the
// gateway) 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`

View file

@ -482,8 +482,8 @@ impl Coordinator {
crate::build_logs::install(build_logs.clone());
// Audit log shares the same db dir; install its process-wide
// handle so privileged-action recording sites (e.g.
// `socket_server::handle_restart_infra`) write without threading an
// `Arc<AuditLog>` through the agent-request surface.
// `dashboard::infra_containers::post_infra_container`) write
// without threading an `Arc<AuditLog>` through the surface.
let audit_log =
Arc::new(crate::audit_log::AuditLog::open(build_logs_dir).context("open audit_log")?);
crate::audit_log::install(audit_log.clone());

View file

@ -1,13 +1,8 @@
//! Dashboard endpoint for operator-driven infra lifecycle (start / stop /
//! restart on `hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`).
//! Parallels the `infra_admin`-gated agent path in
//! `socket_server/lifecycle_handlers.rs::handle_restart_infra`, but this one
//! is reached from the dashboard — already fully operator-authenticated —
//! so no capability check is needed here, just the same audit trail.
//!
//! The two surfaces cover different sets: this endpoint takes all four,
//! while the agent path refuses the gateway — nginx on the host fronts
//! every hive service, so bouncing it is the operator's call.
//! Dashboard endpoint for operator-driven infra lifecycle (start / stop on
//! `hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`). Operator-only —
//! there is no agent-facing equivalent for either action.
//! Already fully operator-authenticated by the time a request reaches here,
//! so no capability check is needed, just the audit trail.
use axum::{
extract::{Path as AxumPath, State},
@ -18,21 +13,18 @@ use hive_priv_sock::{InfraAction, InfraContainer};
use super::{AppState, error_response};
/// Start / stop / restart a
/// hive infrastructure container from the dashboard.
/// Start / stop a hive infrastructure container from the dashboard.
///
/// `name` parses into [`InfraContainer`] (the allowlist; unrecognised
/// names 400), `action` into `start` / `stop` / `restart`. Every attempt
/// lands in the audit log (actor `"operator"`, action `start_infra` /
/// `stop_infra` / `restart_infra`) and streams as an `AuditEntryAdded`
/// event, so operator-driven and agent-driven (`infra_admin`) infra
/// actions show up in the same AUDIT view.
/// names 400), `action` into `start` / `stop`. Every attempt lands in the
/// audit log (actor `"operator"`, action `start_infra` / `stop_infra`) and
/// streams as an `AuditEntryAdded` event.
#[utoipa::path(
post,
path = "/api/infra-container/{name}/{action}",
params(
("name" = String, Path, description = "infra service name (hive-ci/hive-forge/hive-gateway/hive-matrix)"),
("action" = String, Path, description = "start | stop | restart"),
("action" = String, Path, description = "start | stop"),
),
responses(
(status = 200, description = "action completed", body = String),
@ -50,11 +42,8 @@ pub(super) async fn post_infra_container(
let (infra_action, action_label) = match action.as_str() {
"start" => (InfraAction::Start, "start_infra"),
"stop" => (InfraAction::Stop, "stop_infra"),
"restart" => (InfraAction::Restart, "restart_infra"),
other => {
return error_response(&format!(
"unknown action: {other} (want start|stop|restart)"
));
return error_response(&format!("unknown action: {other} (want start|stop)"));
}
};
let target = container.name();

View file

@ -38,7 +38,7 @@ use crate::lifecycle;
(name = "approvals", description = "approve/deny pending approval rows"),
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
(name = "extra_forges", description = "external (non-internal) forge account provisioning"),
(name = "infra_containers", description = "start/stop/restart of hive infrastructure containers"),
(name = "infra_containers", description = "start/stop of hive infrastructure containers"),
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "matrix_accounts", description = "matrix + github account provisioning for agents"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),

View file

@ -119,8 +119,8 @@ pub(super) struct StateSnapshot {
server_warnings: Vec<crate::host_stats::ServerWarning>,
/// Live running/stopped status for the four hive infra containers
/// (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`). Feeds the
/// C0R3 page's 1NFR4 sub-tab so the operator can start/stop/restart
/// them without an `infra_admin` agent's `restart` tool.
/// C0R3 page's 1NFR4 sub-tab, the operator-only surface for starting
/// and stopping them.
infra_containers: Vec<InfraContainerView>,
}

View file

@ -390,8 +390,8 @@ mod tests {
entry: crate::audit_log::AuditEntry {
id: 1,
ts_unix: hive_sh4re::wire_time::from_secs(0),
agent: "atlas".into(),
action: "restart_infra".into(),
agent: "operator".into(),
action: "stop_infra".into(),
target: "hive-ci".into(),
outcome: "ok".into(),
detail: None,
@ -419,21 +419,21 @@ mod tests {
entry: crate::audit_log::AuditEntry {
id: 42,
ts_unix: hive_sh4re::wire_time::from_secs(1_700_000_000),
agent: "atlas".into(),
action: "restart_infra".into(),
agent: "operator".into(),
action: "stop_infra".into(),
target: "hive-gateway".into(),
outcome: "err".into(),
detail: Some("denied: missing infra_admin capability".into()),
detail: Some("systemctl stop failed".into()),
},
};
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise");
assert_eq!(v["kind"], "audit_entry_added");
assert_eq!(v["seq"], 7);
assert_eq!(v["id"], 42);
assert_eq!(v["agent"], "atlas");
assert_eq!(v["agent"], "operator");
assert_eq!(v["target"], "hive-gateway");
assert_eq!(v["outcome"], "err");
assert_eq!(v["detail"], "denied: missing infra_admin capability");
assert_eq!(v["detail"], "systemctl stop failed");
// Not nested — there must be no `entry` sub-object.
assert!(v.get("entry").is_none());
}

View file

@ -477,21 +477,13 @@ pub async fn register_ci_runner(token: &str) -> Result<()> {
.await?)
}
/// Restart a hive infrastructure service on the host (thin wrapper over
/// [`control_infra_container`] with `action = Restart`). Callers must
/// already have checked that the requesting agent holds the `infra_admin`
/// capability *and* that the target is
/// [`agent_restartable`](InfraContainer::agent_restartable).
pub async fn restart_infra_container(container: InfraContainer) -> Result<()> {
control_infra_container(container, InfraAction::Restart).await
}
/// Start / stop / restart a hive infrastructure service (`hive-ci`,
/// `hive-gateway`, `hive-forge`, `hive-matrix`) on the host via `systemctl
/// <action> <unit>`, where the unit is derived root-side from the variant
/// Start / stop a hive infrastructure service (`hive-ci`, `hive-gateway`,
/// `hive-forge`, `hive-matrix`) on the host via `systemctl <action> <unit>`,
/// where the unit is derived root-side from the variant
/// (`container@<name>.service`, or `nginx.service` for the gateway). The
/// [`InfraContainer`] enum is the allowlist — hive-priv needs no name
/// re-validation. Used by the hive-wide `hivectl stop` / `start` flow.
/// re-validation. Used by the hive-wide `hivectl stop` / `start` flow and
/// the dashboard's operator-only infra panel. No agent-facing path exists.
pub async fn control_infra_container(container: InfraContainer, action: InfraAction) -> Result<()> {
ok(call(&PrivRequest::ControlInfraContainer { container, action }).await?)
}

View file

@ -1,6 +1,5 @@
//! Container-lifecycle request handlers (`Start` / `Restart` / `Kill` /
//! `Update` / `ListDescendants`), including the capability-gated
//! infra-container restart path. All are topology-guarded via
//! `Update` / `ListDescendants`). All are topology-guarded via
//! `super::require_descendant`.
use std::sync::Arc;
@ -27,20 +26,10 @@ pub(super) async fn handle_start(coord: &Arc<Coordinator>, agent: &str, name: &s
}
/// `Restart` — enqueue a restart for a container. The caller must be an
/// ancestor of `name` in the topology. The infra-container branch is
/// orthogonal: it is gated on the `infra_admin` capability and audited, so it
/// stays ahead of the topology guard.
/// ancestor of `name` in the topology. Agents have no infra-container
/// restart path: an infra name here just falls through to the topology
/// guard like any other non-descendant name.
pub(super) async fn handle_restart(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Response {
// Infra restart: an agent holding the `infra_admin` capability can
// restart a hive infrastructure service (hive-ci / hive-forge /
// hive-matrix) by passing its name to the same restart tool. The
// `InfraContainer` enum parse both recognises these (never agent
// children, so disjoint from the child path below) and yields the typed
// value the restart path needs. It recognises `hive-gateway` too, which
// is then refused — a name the agent surface knows but may not act on.
if let Ok(container) = name.parse::<hive_priv_sock::InfraContainer>() {
return handle_restart_infra(coord, agent, container).await;
}
if let Some(err) = require_descendant(agent, name, "restart") {
return err;
}
@ -51,73 +40,6 @@ pub(super) async fn handle_restart(coord: &Arc<Coordinator>, agent: &str, name:
Response::Ok
}
/// Restart a hive infrastructure container on behalf of an agent that
/// holds the `infra_admin` capability. The `container` is already a valid
/// [`hive_priv_sock::InfraContainer`] (the caller parsed it); this gates on the capability
/// and routes the systemctl restart through hive-priv. Direct, not
/// approval-gated.
async fn handle_restart_infra(
coord: &Arc<Coordinator>,
agent: &str,
container: hive_priv_sock::InfraContainer,
) -> Response {
let name = container.name();
// Record the attempt in the operator-visible privileged-action audit
// trail, then emit a live `AuditEntryAdded` so the dashboard audit view
// appends it off `/dashboard/stream`. Best-effort: `record` returns the
// canonical row (or `None` on a sqlite blip), and we stream exactly that
// row so the stored + streamed views can't drift. `action` is stable so
// the dashboard can group/filter.
let audit = |outcome: crate::audit_log::AuditOutcome, detail: Option<&str>| {
if let Some(entry) = coord
.audit_log
.record(agent, "restart_infra", name, outcome, detail)
{
coord.emit_audit_entry(entry);
}
};
// Some targets are off-limits to agents regardless of capability — the
// gateway, because nginx fronts every hive service from the host and an
// agent bouncing it takes out the forge, the dashboard and matrix at
// once, including the route its own fix would have to travel. Checked
// before the capability so the refusal doesn't read as "ask for
// infra_admin"; no capability grants this.
if !container.agent_restartable() {
tracing::warn!(%agent, %name, "agent: infra restart denied (not agent-restartable)");
audit(
crate::audit_log::AuditOutcome::Err,
Some("denied: target is not agent-restartable"),
);
return Response::Err {
message: format!("`{name}` cannot be restarted by an agent; ask the operator"),
};
}
if !crate::capabilities::has_cap(agent, hive_sh4re::permissions::Capability::InfraAdmin) {
tracing::warn!(%agent, %name, "agent: infra restart denied (no infra_admin capability)");
audit(
crate::audit_log::AuditOutcome::Err,
Some("denied: missing infra_admin capability"),
);
return Response::Err {
message: format!(
"restarting infra container `{name}` requires the `infra_admin` capability"
),
};
}
tracing::info!(%agent, %name, "agent: restart infra container");
match crate::priv_client::restart_infra_container(container).await {
Ok(()) => {
audit(crate::audit_log::AuditOutcome::Ok, None);
Response::Ok
}
Err(e) => {
let msg = format!("{e:#}");
audit(crate::audit_log::AuditOutcome::Err, Some(&msg));
Response::Err { message: msg }
}
}
}
/// `Kill` — kill a container, unregister it, notify the swarm. The caller
/// must be an ancestor of `name` in the topology.
pub(super) async fn handle_kill(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Response {

View file

@ -1,23 +1,20 @@
//! Sqlite-backed audit trail of agent-initiated privileged actions.
//! Sqlite-backed audit trail of privileged actions worth a durable,
//! operator-visible who/what/when record beyond hive-priv's low-level
//! journal trace — currently the dashboard's operator-driven infra
//! container start/stop (`dashboard::infra_containers::post_infra_container`).
//!
//! Surfaces, durably and operator-visibly, the privileged operations
//! hive-c0re performs *on behalf of an agent* — the ones that cross the
//! agent/operator trust boundary and so warrant a who/what/when record
//! beyond hive-priv's low-level journal trace. First entry: infra
//! container restarts via the `infra_admin`-gated `restart` tool (the
//! follow-up audit trail for that capability).
//!
//! Deliberately scoped to *agent-initiated* privileged actions. The bulk
//! of `PrivRequest` traffic (token writes, nspawn-flag edits) fires
//! constantly during normal lifecycle and is hive-c0re's own bookkeeping,
//! not an agent crossing the boundary — logging all of it would drown the
//! signal the operator actually wants.
//! Deliberately narrow: the bulk of `PrivRequest` traffic (token writes,
//! nspawn-flag edits) fires constantly during normal lifecycle and is
//! hive-c0re's own bookkeeping, not a privileged action worth a standalone
//! record — logging all of it would drown the signal the operator
//! actually wants. Nothing agent-initiated lands here today; the module
//! stays generic for whatever privileged action needs this record next.
//!
//! Same process-singleton handle pattern as `build_logs`: installed once
//! at `Coordinator::open`, and fetched by recording sites (e.g.
//! `socket_server::handle_restart_infra`) so they don't have to thread an
//! `Arc<AuditLog>` through every call path. Recording is best-effort: a
//! sqlite blip must never fail the underlying privileged action.
//! at `Coordinator::open`, and fetched by recording sites so they don't
//! have to thread an `Arc<AuditLog>` through every call path. Recording is
//! best-effort: a sqlite blip must never fail the underlying privileged
//! action.
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
@ -85,9 +82,10 @@ impl AuditOutcome {
pub struct AuditEntry {
pub id: i64,
pub ts_unix: DateTime<Utc>,
/// Agent on whose behalf the action was taken.
/// Actor who took the action (e.g. `"operator"`, or an agent name for
/// a future agent-initiated entry).
pub agent: String,
/// What was done (e.g. `restart_infra`).
/// What was done (e.g. `stop_infra`).
pub action: String,
/// What it acted on (e.g. `hive-ci`).
pub target: String,
@ -267,15 +265,15 @@ mod tests {
let (_d, db) = tmpdb();
// record() returns the canonical inserted row (id + ts assigned).
let entry = db
.record("atlas", "restart_infra", "hive-ci", AuditOutcome::Ok, None)
.record("operator", "stop_infra", "hive-ci", AuditOutcome::Ok, None)
.expect("record returns the inserted entry");
assert!(entry.id > 0);
assert_eq!(entry.target, "hive-ci");
assert_eq!(entry.outcome, "ok");
assert!(entry.detail.is_none());
let _ = db.record(
"atlas",
"restart_infra",
"operator",
"stop_infra",
"hive-gateway",
AuditOutcome::Err,
Some("systemctl failed"),
@ -289,8 +287,8 @@ mod tests {
assert_eq!(rows[1].target, "hive-ci");
assert_eq!(rows[1].outcome, "ok");
assert!(rows[1].detail.is_none());
assert_eq!(rows[0].agent, "atlas");
assert_eq!(rows[0].action, "restart_infra");
assert_eq!(rows[0].agent, "operator");
assert_eq!(rows[0].action, "stop_infra");
assert_eq!(db.count_total().expect("count"), 2);
}
@ -305,7 +303,7 @@ mod tests {
#[test]
fn vacuum_drops_only_old_rows() {
let (_d, db) = tmpdb();
let _ = db.record("a", "restart_infra", "hive-ci", AuditOutcome::Ok, None);
let _ = db.record("operator", "stop_infra", "hive-ci", AuditOutcome::Ok, None);
// Backdate it past the retention window.
{
let conn = db.conn.lock().unwrap();
@ -315,7 +313,13 @@ mod tests {
)
.unwrap();
}
let _ = db.record("a", "restart_infra", "hive-forge", AuditOutcome::Ok, None);
let _ = db.record(
"operator",
"stop_infra",
"hive-forge",
AuditOutcome::Ok,
None,
);
let removed = db.vacuum().expect("vacuum");
assert_eq!(removed, 1, "only the backdated row should be reaped");
let rows = db.list_recent(10).expect("list");

View file

@ -56,7 +56,6 @@ pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-ci"
pub enum InfraAction {
Start,
Stop,
Restart,
}
impl InfraAction {
@ -66,7 +65,6 @@ impl InfraAction {
match self {
InfraAction::Start => "start",
InfraAction::Stop => "stop",
InfraAction::Restart => "restart",
}
}
}
@ -150,28 +148,13 @@ impl InfraContainer {
InfraTarget::HostUnit(unit) => unit.to_owned(),
}
}
/// Whether an agent holding `infra_admin` may restart this target.
///
/// The gateway is excluded by operator ruling: nginx now fronts every
/// hive service from the host, so an agent restarting it can take the
/// forge, dashboard and matrix down with it — including the path its
/// own PR would have to travel to fix it. The operator surface
/// (`hivectl`, dashboard) is unaffected.
#[must_use]
pub fn agent_restartable(self) -> bool {
!matches!(self, InfraContainer::Gateway)
}
}
impl std::str::FromStr for InfraContainer {
type Err = ();
/// Parse an infra name (`hive-ci`, …) into a variant. Recognition
/// only — it says the name denotes a hive service, *not* that the
/// caller may act on it. The agent restart path additionally checks
/// [`agent_restartable`](InfraContainer::agent_restartable).
/// `Err(())` for anything that isn't one.
/// Parse an infra name (`hive-ci`, …) into a variant. `Err(())` for
/// anything that isn't one.
fn from_str(s: &str) -> Result<Self, ()> {
Self::ALL.into_iter().find(|c| c.name() == s).ok_or(())
}
@ -601,13 +584,13 @@ pub enum PrivRequest {
token: String,
},
/// Start / stop / restart a hive infrastructure container on the host
/// via `systemctl <action> container@<container>.service`. The
/// Start / stop a hive infrastructure container on the host via
/// `systemctl <action> container@<container>.service`. The
/// [`InfraContainer`] enum is the allowlist — serde rejects unknown /
/// unsafe names (notably `hive-c0re`, which has no variant) at the wire
/// boundary, so no root-side `.contains()` check is needed. Serves both
/// the hive-wide `hivectl stop` / `hivectl start` flow and an
/// `infra_admin` agent's `restart` (with `action = Restart`).
/// boundary, so no root-side `.contains()` check is needed. Serves the
/// hive-wide `hivectl stop` / `hivectl start` flow and the dashboard's
/// operator-only infra panel. No agent-facing path exists.
ControlInfraContainer {
container: InfraContainer,
action: InfraAction,
@ -956,15 +939,4 @@ mod tests {
);
assert_eq!(InfraContainer::Gateway.service_unit(), "nginx.service");
}
#[test]
fn only_the_gateway_is_off_limits_to_agents() {
// Recognising a name and being allowed to restart it are separate
// questions — the gateway parses fine and is still refused.
assert!("hive-gateway".parse::<InfraContainer>().is_ok());
assert!(!InfraContainer::Gateway.agent_restartable());
for c in InfraContainer::ALL {
assert_eq!(c.agent_restartable(), c != InfraContainer::Gateway, "{c:?}");
}
}
}

View file

@ -1318,13 +1318,13 @@ async fn register_ci_runner(token: &str) -> Result<(String, String)> {
))
}
/// `ControlInfraContainer` — start/stop/restart a hive infrastructure
/// service via `systemctl <verb> <unit>`. The [`InfraContainer`] enum is
/// the allowlist: serde already rejected any unknown / unsafe name
/// (hive-c0re has no variant, so a stop can't sever the daemon socket) at
/// deserialisation, so no root-side `.contains()` check is needed here.
/// Serves both the hive-wide `hivectl stop`/`start` flow and an
/// `infra_admin` agent's `restart` (action = Restart).
/// `ControlInfraContainer` — start/stop a hive infrastructure service via
/// `systemctl <verb> <unit>`. The [`InfraContainer`] enum is the allowlist:
/// serde already rejected any unknown / unsafe name (hive-c0re has no
/// variant, so a stop can't sever the daemon socket) at deserialisation, so
/// no root-side `.contains()` check is needed here. Serves the hive-wide
/// `hivectl stop`/`start` flow and the dashboard's operator-only infra
/// panel. No agent-facing path exists.
///
/// ⚠️ The unit is derived from the variant, never sent by the caller —
/// which is what keeps this from being a general `systemctl` pass-through.

View file

@ -223,19 +223,6 @@ 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-forge, hive-matrix) 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.
///
/// ⚠️ The gateway is **not** in reach of this capability, by operator
/// ruling — it is the host's nginx and fronts the forge, dashboard and
/// matrix, so an agent restarting it can cut the path its own fix
/// travels. That refusal is a property of the target, not of the
/// grant: no capability re-opens it.
InfraAdmin,
}
impl Capability {
@ -245,7 +232,6 @@ impl Capability {
Self::ManageRootAgent,
Self::ReadHostJournal,
Self::QueryAgentState,
Self::InfraAdmin,
];
/// Canonical `snake_case` name for this capability (matches serde).
@ -255,7 +241,6 @@ impl Capability {
Self::ManageRootAgent => "manage_root_agent",
Self::ReadHostJournal => "read_host_journal",
Self::QueryAgentState => "query_agent_state",
Self::InfraAdmin => "infra_admin",
}
}
@ -270,9 +255,6 @@ 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-forge, hive-matrix; not the gateway) via the restart tool"
}
}
}
}