From 7516a4e10eaeafad5b9ab785a824b92b0518bb94 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 30 Aug 2026 22:12:04 +0200 Subject: [PATCH 1/3] remove hive-level infra-container restart from web ui and agents --- docs/conventions.md | 1 - docs/web-ui/dashboard.md | 65 +++++++------- frontend/packages/dashboard/src/core.html | 10 +-- frontend/packages/dashboard/src/core.js | 4 +- hive-agent-mcp/src/mcp/mod.rs | 6 +- hive-agent/src/mcp_config.rs | 7 -- hive-c0re/src/coordinator.rs | 4 +- hive-c0re/src/dashboard/infra_containers.rs | 33 +++---- hive-c0re/src/dashboard/mod.rs | 2 +- hive-c0re/src/dashboard/state_snapshot.rs | 4 +- hive-c0re/src/dashboard_events.rs | 14 +-- hive-c0re/src/priv_client.rs | 18 ++-- .../src/socket_server/lifecycle_handlers.rs | 86 +------------------ hive-c0re/src/stores/audit_log.rs | 56 ++++++------ hive-priv-sock/src/lib.rs | 42 ++------- hive-priv/src/main.rs | 14 +-- hive-sh4re/src/permissions.rs | 18 ---- 17 files changed, 112 insertions(+), 272 deletions(-) diff --git a/docs/conventions.md b/docs/conventions.md index cbd3e46b..3282cffd 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -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`. diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index eaca8607..dfb64e7a 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -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@.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: "")` 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`. 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@.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). diff --git a/frontend/packages/dashboard/src/core.html b/frontend/packages/dashboard/src/core.html index b1ee18c8..59a7193e 100644 --- a/frontend/packages/dashboard/src/core.html +++ b/frontend/packages/dashboard/src/core.html @@ -55,11 +55,11 @@ - +

hive infrastructure containers — ci, forge, gateway, matrix. actions are logged to the AUDIT log same as agent-driven restarts.

diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js index 2fd5489a..5755f4be 100644 --- a/frontend/packages/dashboard/src/core.js +++ b/frontend/packages/dashboard/src/core.js @@ -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 { diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index 193eec35..ee94c465 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -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) -> String { let log = format!("{args:?}"); diff --git a/hive-agent/src/mcp_config.rs b/hive-agent/src/mcp_config.rs index fb7b92cc..8304d173 100644 --- a/hive-agent/src/mcp_config.rs +++ b/hive-agent/src/mcp_config.rs @@ -59,13 +59,6 @@ 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-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` diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 83a7543e..bc191d3e 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -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` through the agent-request surface. + // `dashboard::infra_containers::post_infra_container`) write + // without threading an `Arc` 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()); diff --git a/hive-c0re/src/dashboard/infra_containers.rs b/hive-c0re/src/dashboard/infra_containers.rs index 572adce8..7b1eb019 100644 --- a/hive-c0re/src/dashboard/infra_containers.rs +++ b/hive-c0re/src/dashboard/infra_containers.rs @@ -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(); diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index c0a62716..e07edfea 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -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"), diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 4b806fbb..6e576b21 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -119,8 +119,8 @@ pub(super) struct StateSnapshot { server_warnings: Vec, /// 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, } diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index b2112f01..ddf63c7d 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -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()); } diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index ec56567f..dae34209 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -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 -/// `, 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 `, +/// where the unit is derived root-side from the variant /// (`container@.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?) } diff --git a/hive-c0re/src/socket_server/lifecycle_handlers.rs b/hive-c0re/src/socket_server/lifecycle_handlers.rs index e7b497b8..e08f2c6b 100644 --- a/hive-c0re/src/socket_server/lifecycle_handlers.rs +++ b/hive-c0re/src/socket_server/lifecycle_handlers.rs @@ -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, 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, 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::() { - 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, 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, - 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, agent: &str, name: &str) -> Response { diff --git a/hive-c0re/src/stores/audit_log.rs b/hive-c0re/src/stores/audit_log.rs index b1de06e4..eedbe474 100644 --- a/hive-c0re/src/stores/audit_log.rs +++ b/hive-c0re/src/stores/audit_log.rs @@ -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` 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` 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, - /// 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"); diff --git a/hive-priv-sock/src/lib.rs b/hive-priv-sock/src/lib.rs index 0aaa9793..af15605b 100644 --- a/hive-priv-sock/src/lib.rs +++ b/hive-priv-sock/src/lib.rs @@ -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::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 container@.service`. The + /// Start / stop a hive infrastructure container on the host via + /// `systemctl 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::().is_ok()); - assert!(!InfraContainer::Gateway.agent_restartable()); - for c in InfraContainer::ALL { - assert_eq!(c.agent_restartable(), c != InfraContainer::Gateway, "{c:?}"); - } - } } diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 6d0799b3..686b5455 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -1318,13 +1318,13 @@ async fn register_ci_runner(token: &str) -> Result<(String, String)> { )) } -/// `ControlInfraContainer` — start/stop/restart a hive infrastructure -/// service via `systemctl `. 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 `. 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. diff --git a/hive-sh4re/src/permissions.rs b/hive-sh4re/src/permissions.rs index 42a35878..9fc6ba53 100644 --- a/hive-sh4re/src/permissions.rs +++ b/hive-sh4re/src/permissions.rs @@ -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" - } } } } From c3cd36bc410943898b79d10b51c720173a1d6350 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 30 Aug 2026 22:58:09 +0200 Subject: [PATCH 2/3] drop stale agent-restart comparison from 1NFR4 tooltip --- frontend/packages/dashboard/src/core.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/packages/dashboard/src/core.html b/frontend/packages/dashboard/src/core.html index 59a7193e..15868da8 100644 --- a/frontend/packages/dashboard/src/core.html +++ b/frontend/packages/dashboard/src/core.html @@ -62,7 +62,7 @@ as C0NT41N3R L04D. -->
-

hive infrastructure containers — ci, forge, gateway, matrix. actions are logged to the AUDIT log same as agent-driven restarts.

+

hive infrastructure containers — ci, forge, gateway, matrix. actions are logged to the AUDIT log.

loading…

From 22adfd14515754c930c4bed25bc4b127df28c557 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 30 Aug 2026 23:54:24 +0200 Subject: [PATCH 3/3] remove the 1NFR4 dashboard panel and the now-writer-less audit log --- docs/web-ui/dashboard.md | 71 +--- frontend/packages/dashboard/build.mjs | 4 +- frontend/packages/dashboard/src/core.css | 6 +- frontend/packages/dashboard/src/core.html | 25 +- frontend/packages/dashboard/src/core.js | 63 ---- .../packages/dashboard/src/credentials.html | 2 +- frontend/packages/dashboard/src/index.html | 2 +- frontend/packages/dashboard/src/logs.css | 70 ---- frontend/packages/dashboard/src/logs.html | 17 +- frontend/packages/dashboard/src/logs.js | 203 ++--------- frontend/packages/dashboard/src/tabs.js | 2 +- hive-c0re/src/coordinator.rs | 23 -- hive-c0re/src/dashboard/infra_containers.rs | 70 ---- hive-c0re/src/dashboard/misc_api.rs | 38 +- hive-c0re/src/dashboard/mod.rs | 8 +- hive-c0re/src/dashboard/state_snapshot.rs | 30 -- hive-c0re/src/dashboard_events.rs | 54 --- hive-c0re/src/lifecycle/mod.rs | 15 - hive-c0re/src/main.rs | 5 +- hive-c0re/src/stores/audit_log.rs | 329 ------------------ hive-c0re/src/stores/mod.rs | 7 +- 21 files changed, 61 insertions(+), 983 deletions(-) delete mode 100644 hive-c0re/src/dashboard/infra_containers.rs delete mode 100644 hive-c0re/src/stores/audit_log.rs diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index dfb64e7a..6cad964d 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -106,7 +106,7 @@ Passive / rare-interaction state. No longer a dashboard tab — it's a standalone page reached from the **Core** tile on the H0M3 hub (served at `/core.html`), with the same minimal chrome as `/logs.html`: a `← home` back-link + a `createTabStrip` sub-tab nav (**K3PT ST4T3** default, -then **C0NT41N3R L04D**, then **1NFR4**). The page is its own esbuild bundle (`core.js`) +then **C0NT41N3R L04D**). The page is its own esbuild bundle (`core.js`) that cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for `tombstones_changed`, `capabilities_changed`, and `tool_groups_changed` (the latter two re-render the stale-perms sub-section when permission @@ -159,20 +159,10 @@ 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 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`). Status rows ride the `infra_containers` field -on `GET /api/state`'s `StateSnapshot` (`{name, running}`, live -`systemctl is-active container@.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. +Hive infrastructure services (`hive-ci`, `hive-forge`, `hive-gateway`, +`hive-matrix`) have no dashboard panel — `hivectl stop`/`start`/`restart` +is the only control surface, a separate host-admin-socket path with no +HTTP route and no agent-facing equivalent. ## BU1LDS page (`/builds.html`) @@ -722,7 +712,7 @@ navigation. A dedicated log-viewer page (not a tab pane — a separate HTML page), reachable from the Logs tile on the H0M3 hub. Minimal chrome: a `← home` back link and a three-item sub-tab strip. Tab -routing is hash-based (`#agent`, `#system`, `#audit`); default is +routing is hash-based (`#agent`, `#infra`, `#system`); default is `#agent`. (Build log history has moved to the BU1LDS page — see above.) **AGENT sub-tab** — per-container journald viewer. Two selects: agent @@ -738,6 +728,16 @@ deep-link directly to a specific agent's journal. A "fetched N ago" chip appears after the `↻ refresh` button following each successful fetch and ticks every 30 s. +**INFRA sub-tab** — journald viewer for the four hive infrastructure +containers (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`), a +fixed client-side list (`INFRA_NAMES` in `logs.js` — no dashboard API +exposes just the name list). No unit filter (infra containers don't run +the per-agent hive daemons) — always the full machine journal (or, for +the gateway, the host journal filtered to its own unit). Fetches +`GET /api/journal/{name}?lines=500`, same "fetched N ago" ticker as +AGENT. A `?agent=` deep-link routes here instead of AGENT when +the name is one of the four infra containers. + **SYSTEM sub-tab** — host-side service logs. Unit selector (`hive-c0re.service` / `hive-priv.service`). Fetches `GET /api/journal-host?unit=&lines=500` on activation @@ -746,27 +746,6 @@ 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 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` -event on `/api/dashboard/stream` (the flattened row) is prepended to the table -and the "latest N of M" count bumped, de-duped by id against the cold -fetch. - ## Container row A full-height **square agent icon** (5em, capped) on the left. The @@ -1203,12 +1182,6 @@ below — some endpoints aren't in it yet. build logs. - `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for a managed container; rendered in the side panel. -- `GET /api/audit-log` — agent-initiated privileged-action audit - trail. Returns `{ entries, total }`: `entries` is a `Vec` - (`id`, `ts_unix` as RFC 3339, `agent`, `action`, `target`, `outcome` - `"ok"`/`"err"`, `detail` nullable), newest first, server-clamped to - 500; `total` is the full row count for a "latest 500 of N" header. - Backs the LOGS page AUDIT sub-tab. - `GET /static/marked.js` serves the vendored `marked` bundle used for markdown previews. - `GET /api/state-file?path=` — bounded @@ -1249,14 +1222,6 @@ 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 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@.service`, or `nginx.service` for the gateway. - `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). @@ -1422,10 +1387,6 @@ payload): - `meta_update_running` (running: bool) — emitted when a `nix flake update` ripple starts or completes. BU1LDS M3T4 1NPUTS tab uses this to show/hide the "⏳ meta-update running" banner. -- `audit_entry_added` (flattened `AuditEntry` fields: id, ts_unix, - agent, action, target, outcome, detail) — a single new audit-log - row. L0GS AUDIT sub-tab live-prepends the row and bumps the - "latest N of M" count, de-duped by id against the cold fetch. `/api/state` is **only fetched on cold-load and on the few forms that mutate non-event-derived state** (PURG3 + diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index b18e14ea..f4e16c3b 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -5,7 +5,7 @@ // dist/dashboard.html the operator dashboard SPA — served // at GET /dashboard.html // dist/flow.html served at GET /flow.html -// dist/logs.html served at GET /logs.html (AGENT/SYSTEM/AUDIT) +// dist/logs.html served at GET /logs.html (AGENT/INFRA/SYSTEM) // dist/core.html served at GET /core.html (C0R3: kept // state / container load) // dist/builds.html served at GET /builds.html (BU1LDS: @@ -18,7 +18,7 @@ // tab routing + refreshState // dist/static/flow.js /flow.html entry — broker terminal + // @-mention composer -// dist/static/logs.js /logs.html entry — agent/system/audit +// dist/static/logs.js /logs.html entry — agent/infra/system // log viewer sub-tabs // dist/static/builds.js /builds.html entry — rebuild queue, // meta inputs, build log history diff --git a/frontend/packages/dashboard/src/core.css b/frontend/packages/dashboard/src/core.css index 15fad5e8..df95e008 100644 --- a/frontend/packages/dashboard/src/core.css +++ b/frontend/packages/dashboard/src/core.css @@ -22,11 +22,11 @@ body.core-shell { panes; ensure it wins over any inherited display. */ .core-pane[hidden] { display: none; } -/* ─── K3PT ST4T3 + 1NFR4 container cards ──────────────────────────── +/* ─── K3PT ST4T3 container cards ──────────────────────────────────── core.html doesn't load dashboard.css (that's the operator SPA), so the .container-row card styles aren't inherited. Redefine them - here so tombstone and infra entries look like proper cards (similar - to agent cards on the main dashboard) rather than bare list items. */ + here so tombstone entries look like proper cards (similar to agent + cards on the main dashboard) rather than bare list items. */ .containers { list-style: none; padding: 0; diff --git a/frontend/packages/dashboard/src/core.html b/frontend/packages/dashboard/src/core.html index 15868da8..e2dcaf1a 100644 --- a/frontend/packages/dashboard/src/core.html +++ b/frontend/packages/dashboard/src/core.html @@ -16,12 +16,14 @@ /core.html) carved out of the dashboard's old SYST3M tab so the dashboard tab strip stays lean. Same minimal chrome as /logs.html — a `← home` back-link to the H0M3 hub + a - sub-tab nav. Three sub-tabs: kept state (tombstones), - container load, and infra containers (start/stop/restart the four - hive infra containers). Rebuild queue + meta inputs have moved to + sub-tab nav. Two sub-tabs: kept state (tombstones) + and container load. Rebuild queue + meta inputs have moved to /builds.html (the build lifecycle hub). Default tab: K3PT ST4T3. - The section
ids (tombstones-section, container-load-section, - infra-containers-section) match what core.js's renderers target. --> + The section
ids (tombstones-section, container-load-section) + match what core.js's renderers target. Hive infra containers + (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`) have no + dashboard panel — `hivectl stop`/`start`/`restart` is the only + control surface. -->
- -
-

hive infrastructure containers — ci, forge, gateway, matrix. actions are logged to the AUDIT log.

-
-

loading…

-
-
- diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js index 5755f4be..3c9f4b39 100644 --- a/frontend/packages/dashboard/src/core.js +++ b/frontend/packages/dashboard/src/core.js @@ -381,66 +381,6 @@ function stopContainerLoadPolling() { if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; } } -// ─── infra containers (start/stop the 4 hive infra containers) ──────────── -let infraTimer = null; - -function renderInfraContainers(rows) { - const root = $('infra-containers-section'); - if (!root) return; - root.replaceChildren(); - if (!Array.isArray(rows) || !rows.length) { - root.append(el('p', { class: 'meta' }, 'no infra container data')); - return; - } - const ul = el('ul', { class: 'containers' }); - for (const c of rows) { - const li = el('li', { class: 'container-row' }); - const head = el('div', { class: 'head' }); - head.append( - el('span', { class: 'name' }, c.name), - el('span', { class: 'hive-pill-sm ' + (c.running ? 'badge-ok' : 'badge-fail') }, - c.running ? 'running' : 'stopped'), - ); - li.append(head); - - const actions = el('div', { class: 'actions' }); - const base = '/api/infra-container/' + encodeURIComponent(c.name) + '/'; - if (c.running) { - actions.append(form(base + 'stop', 'btn-stop', '■ ST0P', - 'stop ' + c.name + '?')); - } else { - actions.append(form(base + 'start', 'btn-start', '▶ ST4RT', - 'start ' + c.name + '?')); - } - li.append(actions); - ul.append(li); - } - root.append(ul); -} - -async function refreshInfraContainers() { - try { - const resp = await fetch('/api/state'); - if (!resp.ok) throw new Error('http ' + resp.status); - const s = await resp.json(); - renderInfraContainers(s.infra_containers || []); - } catch (e) { - const root = $('infra-containers-section'); - if (root) { - root.replaceChildren(); - root.append(el('p', { class: 'meta' }, 'infra container fetch failed: ' + e)); - } - } -} -function startInfraPolling() { - refreshInfraContainers(); - if (infraTimer) return; - infraTimer = setInterval(refreshInfraContainers, 5000); -} -function stopInfraPolling() { - if (infraTimer) { clearInterval(infraTimer); infraTimer = null; } -} - // ─── render-all (cold load + any full re-render) ────────────────────────── function renderAll() { renderTombstones({ tombstones: tombstonesState }); @@ -505,14 +445,11 @@ async function init() { tabs: [ { id: 'kept', label: 'K3PT ST4T3' }, { id: 'load', label: 'C0NT41N3R L04D' }, - { id: 'infra', label: '1NFR4' }, ], defaultId: 'kept', onShow: (id) => { if (id === 'load') startContainerLoadPolling(); else stopContainerLoadPolling(); - if (id === 'infra') startInfraPolling(); - else stopInfraPolling(); // Lazy-load stale-perms on first K3PT ST4T3 activation; always // re-fetch on subsequent visits in case perms changed. if (id === 'kept') fetchAndRenderStalePerms(); diff --git a/frontend/packages/dashboard/src/credentials.html b/frontend/packages/dashboard/src/credentials.html index d4d14f26..0109f3aa 100644 --- a/frontend/packages/dashboard/src/credentials.html +++ b/frontend/packages/dashboard/src/credentials.html @@ -13,7 +13,7 @@
- -
-
- - - -
-

loading…

-
- diff --git a/frontend/packages/dashboard/src/logs.js b/frontend/packages/dashboard/src/logs.js index 81e081ad..3bdefc15 100644 --- a/frontend/packages/dashboard/src/logs.js +++ b/frontend/packages/dashboard/src/logs.js @@ -1,13 +1,11 @@ -// /logs.html entry point: log viewer with four sub-tabs (AGENT, INFRA, -// SYSTEM, AUDIT). Build log history has moved to /builds.html. +// /logs.html entry point: log viewer with three sub-tabs (AGENT, INFRA, +// SYSTEM). Build log history has moved to /builds.html. // AGENT — per-container journald viewer, backed by GET /api/journal/{name} // INFRA — infra-container journald viewer (hive-ci, hive-forge, …), // same API but full machine journal only (no unit filter) // SYSTEM — host service logs, backed by GET /api/journal-host -// AUDIT — agent-initiated privileged-action trail, GET /api/audit-log; -// live-appends via the `audit_entry_added` /dashboard/stream event // -// Tab routing via URL hash (#agent, #infra, #system, #audit). Default: #agent. +// Tab routing via URL hash (#agent, #infra, #system). Default: #agent. // // URL params `?agent=name` and `?unit=svc` pre-select the agent + unit. // When `?agent=` names an infra container the INFRA tab is activated instead. @@ -15,10 +13,9 @@ // INFRA, and SYSTEM tabs so the operator knows how stale the output is. import { - $, fmtAgeSecs, openStream, initServerWarnings, + $, fmtAgeSecs, initServerWarnings, } from './common.js'; import { el } from '@hive/shared/dom.js'; -import { epochSec } from './util.js'; import '@hive/shared/hive-tab-strip.js'; (() => { @@ -127,12 +124,25 @@ import '@hive/shared/hive-tab-strip.js'; if (infraSelect) infraSelect.addEventListener('change', fetchInfra); if (infraRefresh) infraRefresh.addEventListener('click', fetchInfra); + // Fixed allowlist — the four hive infra services never change at + // runtime, and there's no dashboard API exposing just the name list + // (the one that used to, `/api/state`'s `infra_containers` field, was + // start/stop-panel-only and is gone). Mirrors `hive_priv_sock::InfraContainer::ALL`. + const INFRA_NAMES = ['hive-ci', 'hive-forge', 'hive-gateway', 'hive-matrix']; + // ─── container list init ────────────────────────────────────────────── - // Fetch /api/state once and populate both the AGENT selector (agents only) - // and the INFRA selector (infra containers only). Also handles the - // ?agent= / ?unit= deep-link, routing to the INFRA tab when the named - // container is an infra container. + // Fetch /api/state once to populate the AGENT selector (agents only); + // INFRA is a fixed list (see INFRA_NAMES). Also handles the ?agent= / + // ?unit= deep-link, routing to the INFRA tab when the named container + // is an infra container. async function loadContainerLists() { + if (infraSelect) { + infraSelect.replaceChildren(); + infraSelect.append(el('option', { value: '' }, '— select container —')); + for (const name of INFRA_NAMES) { + infraSelect.append(el('option', { value: name }, name)); + } + } try { const resp = await fetch('/api/state'); if (!resp.ok) return; @@ -147,21 +157,11 @@ import '@hive/shared/hive-tab-strip.js'; } } - // Populate INFRA selector. - const infraNames = new Set((state.infra_containers || []).map((c) => c.name)); - if (infraSelect) { - infraSelect.replaceChildren(); - infraSelect.append(el('option', { value: '' }, '— select container —')); - for (const name of infraNames) { - infraSelect.append(el('option', { value: name }, name)); - } - } - // Deep-link: honour ?agent= and ?unit= URL params. const urlAgent = new URLSearchParams(location.search).get('agent'); const urlUnit = new URLSearchParams(location.search).get('unit'); if (urlAgent) { - if (infraNames.has(urlAgent)) { + if (INFRA_NAMES.includes(urlAgent)) { // Route to INFRA tab. logTabs.show('infra'); if (infraSelect) { @@ -225,180 +225,25 @@ import '@hive/shared/hive-tab-strip.js'; if (systemUnitSelect) systemUnitSelect.addEventListener('change', fetchSystem); if (systemRefresh) systemRefresh.addEventListener('click', fetchSystem); - // ─── AUDIT tab ────────────────────────────────────────────────────── - // Operator-visible trail of agent-initiated privileged actions, backed - // by GET /api/audit-log → { entries: [...], total: N } (entries - // newest-first, server-clamped to 500; `total` drives "latest 500 of N"). - // Per-entry: { id, ts_unix (secs), agent, action, target, outcome, detail }. - // outcome is 'ok' | 'err'; a capability denial is 'err' with detail - // starting "denied:" — coloured amber to read apart from an execution - // failure. Lazy-fetched on tab show (like SYSTEM); filter is a - // client-side substring on the cached rows. - - const auditList = $('audit-list'); - const auditFilter = $('audit-filter'); - const auditRefresh = $('audit-refresh'); - const auditCount = $('audit-count'); - - let auditEntries = []; - let auditTotal = 0; - let auditFetching = false; - - // ts_unix arrives as an RFC 3339 string — fmtAgeSecs wants an age - // in seconds, so normalize via epochSec first. - function auditFmtWhen(ts) { - if (!ts) return ''; - const age = Math.floor(Date.now() / 1000) - epochSec(ts); - return fmtAgeSecs(Math.max(0, age)) + ' ago'; - } - - // outcome → badge. 'ok' green; an 'err' whose detail starts "denied:" is a - // capability refusal (amber, labelled "denied"); other 'err' red. The - // literal outcome is the fallback label so a new value still renders. - function auditOutcomeBadge(outcome, detail) { - const denied = outcome === 'err' - && typeof detail === 'string' && detail.startsWith('denied:'); - const cls = outcome === 'ok' - ? 'audit-outcome audit-outcome-ok' - : denied - ? 'audit-outcome audit-outcome-denied' - : 'audit-outcome audit-outcome-err'; - return el('span', { class: cls }, denied ? 'denied' : (outcome || '?')); - } - - function auditMatches(e, q) { - if (!q) return true; - return `${e.agent || ''} ${e.action || ''} ${e.target || ''} ${e.detail || ''}` - .toLowerCase().includes(q); - } - - function renderAudit() { - if (!auditList) return; - const q = (auditFilter ? auditFilter.value : '').trim().toLowerCase(); - const rows = auditEntries.filter((e) => auditMatches(e, q)); - - if (auditCount) { - const shown = auditEntries.length; - const clamped = auditTotal > shown; - let txt = clamped - ? `latest ${shown} of ${auditTotal}` - : `${shown} entr${shown === 1 ? 'y' : 'ies'}`; - if (q) txt += ` · ${rows.length} match${rows.length === 1 ? '' : 'es'}`; - auditCount.textContent = txt; - } - - auditList.replaceChildren(); - if (rows.length === 0) { - auditList.append(el('p', { class: 'meta' }, - q ? '(no matching entries)' : '(no privileged actions recorded yet)')); - return; - } - - const table = el('table', { class: 'audit-table' }); - table.append(el('thead', {}, - el('tr', {}, - el('th', { class: 'audit-when-th' }, 'when'), - el('th', {}, 'agent'), - el('th', {}, 'action'), - el('th', {}, 'target'), - el('th', { class: 'audit-outcome-th' }, 'outcome'), - el('th', {}, 'detail'), - ))); - const tbody = el('tbody', {}); - for (const e of rows) { - tbody.append(el('tr', {}, - el('td', { - class: 'audit-when meta', - title: e.ts_unix ? new Date(e.ts_unix).toISOString() : '', - }, auditFmtWhen(e.ts_unix)), - el('td', { class: 'audit-agent' }, e.agent || ''), - el('td', { class: 'audit-action' }, e.action || ''), - el('td', { class: 'audit-target' }, e.target || ''), - el('td', { class: 'audit-outcome-td' }, auditOutcomeBadge(e.outcome, e.detail)), - el('td', { class: 'audit-detail meta' }, e.detail || ''), - )); - } - table.append(tbody); - const wrap = el('div', { class: 'audit-table-wrap' }); - wrap.append(table); - auditList.append(wrap); - } - - async function fetchAudit() { - if (!auditList || auditFetching) return; - auditFetching = true; - try { - const resp = await fetch('/api/audit-log'); - if (!resp.ok) throw new Error('http ' + resp.status); - const data = await resp.json(); - auditEntries = Array.isArray(data.entries) ? data.entries : []; - auditTotal = typeof data.total === 'number' ? data.total : auditEntries.length; - renderAudit(); - } catch (err) { - auditList.replaceChildren(); - auditList.append(el('p', { class: 'meta' }, 'fetch failed: ' + err)); - } finally { - auditFetching = false; - } - } - - if (auditRefresh) auditRefresh.addEventListener('click', fetchAudit); - if (auditFilter) auditFilter.addEventListener('input', renderAudit); - - // Live-append: an `audit_entry_added` event on /dashboard/stream carries a - // new row flattened at the top level ({ kind, seq, id, ts_unix, agent, - // action, target, outcome, detail }). Prepend it (newest-first), de-duped - // by id against whatever the cold fetch already returned, and bump the - // total so the "latest N of M" header stays right. Re-render only while - // the AUDIT tab is in view; otherwise the next tab-show fetch is - // authoritative anyway. Wired into the shared stream onmessage above. - function onAuditEntryAdded(ev) { - if (auditEntries.some((e) => e.id === ev.id)) return; - auditEntries.unshift({ - id: ev.id, ts_unix: ev.ts_unix, agent: ev.agent, action: ev.action, - target: ev.target, outcome: ev.outcome, detail: ev.detail, - }); - auditTotal += 1; - if (logTabs.active() === 'audit') renderAudit(); - } - // ─── init ───────────────────────────────────────────────────────────── // Wire the shared tab strip now that fetchSystem + the element refs it // needs are defined. Its initial show() paints the active pane and, if - // the deep-linked tab is SYSTEM/AUDIT, kicks off the lazy fetch via onShow. + // the deep-linked tab is SYSTEM, kicks off the lazy fetch via onShow. // Default: AGENT (build logs moved to /builds.html). logTabs = document.getElementById('logs-tabbar').configure({ tabs: [ { id: 'agent', label: 'AGENT' }, { id: 'infra', label: 'INFRA' }, { id: 'system', label: 'SYSTEM' }, - { id: 'audit', label: 'AUDIT' }, ], defaultId: 'agent', onShow: (id) => { if (id === 'system') fetchSystem(); - else if (id === 'audit') fetchAudit(); }, }); loadContainerLists(); - // Subscribe to the dashboard SSE stream for audit live-appends. - // kinds= narrows this from all 17 wire kinds down to the 1 this page - // acts on. This page was one of 4 unfiltered `/api/dashboard/stream` - // subscribers before this (subscription discipline, part 1 of the - // dashboard-event-stream-split issue). - { - const es = openStream('/api/dashboard/stream?kinds=audit_entry_added'); - if (es) { - es.onmessage = (e) => { - let ev; - try { ev = JSON.parse(e.data); } catch { return; } - if (ev.kind === 'audit_entry_added') onAuditEntryAdded(ev); - }; - } - } - // Tick the last-fetched timestamps every 30s so "fetched 1m ago" stays // accurate without a manual refresh. setInterval(() => { @@ -411,8 +256,6 @@ import '@hive/shared/hive-tab-strip.js'; if (systemLastFetch && systemFetchTs && !systemFetchTs.hidden) { systemFetchTs.textContent = fmtFetchTs(systemLastFetch); } - // Keep the audit "ago" column honest while that tab is in view. - if (auditEntries.length && logTabs.active() === 'audit') renderAudit(); }, 30_000); })(); diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index c3de4e24..08fa8b0a 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -308,7 +308,7 @@ window.marked = marked; // // kinds= matches MUTATION_HANDLERS below verbatim, plus `sent` // (checked separately, just above, for the operator inbox) — - // narrows this from all 17 wire kinds down to the 11 this page + // narrows this from all 15 wire kinds down to the 11 this page // actually acts on. This page was one of 4 unfiltered // `/api/dashboard/stream` subscribers before this (subscription // discipline, part 1 of the dashboard-event-stream-split issue). diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index bc191d3e..e2ff80ce 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -42,9 +42,6 @@ pub struct Coordinator { /// `get_full` for the per-card chip + side-panel viewer. See /// `build_logs.rs` for retention. pub build_logs: Arc, - /// Audit trail of agent-initiated privileged actions (infra restart, - /// …). See `audit_log.rs`. Same dir as `build_logs`. - pub audit_log: Arc, /// URL of the hyperhive flake (no fragment). Inlined into per-agent /// `flake.nix` files as `inputs.hyperhive.url`. pub hyperhive_flake: String, @@ -480,13 +477,6 @@ impl Coordinator { // to thread an `Arc` through every public entry // point in the lifecycle surface. 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. - // `dashboard::infra_containers::post_infra_container`) write - // without threading an `Arc` 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()); let power = Arc::new(crate::power::PowerStore::open(db_path).context("open agent_power")?); let (dashboard_events, _) = broadcast::channel(DASHBOARD_CHANNEL); let (shutdown_tx, _) = watch::channel(false); @@ -495,7 +485,6 @@ impl Coordinator { approvals: Arc::new(approvals), scheduled_prompts: Arc::new(scheduled_prompts), build_logs, - audit_log, hyperhive_flake, hyperhive_docs_flake, nixpkgs_flake, @@ -751,18 +740,6 @@ impl Coordinator { self.meta_updates_active.load(Ordering::SeqCst) > 0 } - /// Emit `AuditEntryAdded` immediately after a privileged-action row - /// is recorded, so the dashboard audit view live-appends it off - /// `/dashboard/stream`. Pass the [`AuditEntry`](crate::audit_log::AuditEntry) - /// returned by `audit_log::record` so the streamed event is the same - /// canonical row that was stored. - pub fn emit_audit_entry(&self, entry: crate::audit_log::AuditEntry) { - self.emit_dashboard_event(DashboardEvent::AuditEntryAdded { - seq: self.next_seq(), - entry, - }); - } - /// Emit `ApprovalAdded` immediately after the row is inserted in /// sqlite. pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) { diff --git a/hive-c0re/src/dashboard/infra_containers.rs b/hive-c0re/src/dashboard/infra_containers.rs deleted file mode 100644 index 7b1eb019..00000000 --- a/hive-c0re/src/dashboard/infra_containers.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! 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}, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use hive_priv_sock::{InfraAction, InfraContainer}; - -use super::{AppState, error_response}; - -/// Start / stop a hive infrastructure container from the dashboard. -/// -/// `name` parses into [`InfraContainer`] (the allowlist; unrecognised -/// 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"), - ), - responses( - (status = 200, description = "action completed", body = String), - (status = 500, description = "unknown container/action, or the systemd action failed"), - ), - tag = "infra_containers" -)] -pub(super) async fn post_infra_container( - State(state): State, - AxumPath((name, action)): AxumPath<(String, String)>, -) -> Response { - let Ok(container) = name.parse::() else { - return error_response(&format!("unknown infra container: {name}")); - }; - let (infra_action, action_label) = match action.as_str() { - "start" => (InfraAction::Start, "start_infra"), - "stop" => (InfraAction::Stop, "stop_infra"), - other => { - return error_response(&format!("unknown action: {other} (want start|stop)")); - } - }; - let target = container.name(); - tracing::info!(%target, %action, "dashboard: infra container action"); - let result = crate::priv_client::control_infra_container(container, infra_action).await; - let outcome = if result.is_ok() { - crate::audit_log::AuditOutcome::Ok - } else { - crate::audit_log::AuditOutcome::Err - }; - let detail = result.as_ref().err().map(|e| format!("{e:#}")); - if let Some(entry) = - state - .coord - .audit_log - .record("operator", action_label, target, outcome, detail.as_deref()) - { - state.coord.emit_audit_entry(entry); - } - match result { - Ok(()) => (StatusCode::OK, "ok").into_response(), - Err(e) => error_response(&format!("{target}: {e:#}")), - } -} diff --git a/hive-c0re/src/dashboard/misc_api.rs b/hive-c0re/src/dashboard/misc_api.rs index 51695b9a..a9a627ba 100644 --- a/hive-c0re/src/dashboard/misc_api.rs +++ b/hive-c0re/src/dashboard/misc_api.rs @@ -1,7 +1,6 @@ //! Remaining single-endpoint dashboard handlers: the operator inbox //! (`Y3R C4LL`) + mark-all-read, operator compose (`op-send`), -//! spawn-request, hive-wide turn stats, container resources, and the -//! audit log. +//! spawn-request, hive-wide turn stats, and container resources. use axum::{ extract::{Form, Path as AxumPath, State}, @@ -12,7 +11,6 @@ use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use super::{AppState, Ident, error_response, scan_validated_paths}; -use crate::audit_log::AuditEntry; use crate::container_stats::ContainerResource; use crate::hive_stats::HiveStats; @@ -129,40 +127,6 @@ pub(super) async fn api_container_resources() -> Response { axum::Json(crate::container_stats::gather().await).into_response() } -#[derive(Serialize, ToSchema)] -pub(super) struct AuditLogBody { - entries: Vec, - total: i64, -} - -/// Most-recent agent-initiated privileged-action -/// audit entries, newest first (server-clamped to 500). -/// -/// Backs the operator dashboard's audit view. `total` lets the UI show -/// "latest 500 of N" rather than silently capping. `ts_unix` is in -/// **seconds**. -#[utoipa::path( - get, - path = "/api/audit-log", - responses( - (status = 200, description = "recent audit entries + total count", body = AuditLogBody), - (status = 500, description = "sqlite read failed"), - ), - tag = "misc_api" -)] -pub(super) async fn api_audit_log(State(state): State) -> Response { - const LIMIT: usize = 500; - let entries = match state.coord.audit_log.list_recent(LIMIT) { - Ok(rows) => rows, - Err(e) => return error_response(&format!("audit-log: {e:#}")), - }; - let total = match state.coord.audit_log.count_total() { - Ok(n) => n, - Err(e) => return error_response(&format!("audit-log count: {e:#}")), - }; - axum::Json(AuditLogBody { entries, total }).into_response() -} - #[derive(Serialize, ToSchema)] pub(super) struct MarkAllReadBody { marked: u64, diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index e07edfea..4f56f7c2 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -38,11 +38,10 @@ 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 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"), - (name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats, audit log"), + (name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"), (name = "permissions", description = "tool-group + capability assignment for agents"), (name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"), (name = "state_files", description = "proxied reads of allow-listed per-agent state files"), @@ -63,7 +62,6 @@ mod extra_forges; // server reach it as `crate::dashboard::Ident`. pub(crate) use hive_types::Ident; mod health; -mod infra_containers; mod journal; mod lifecycle_ops; mod matrix_accounts; @@ -154,7 +152,6 @@ pub async fn serve( .routes(routes!(misc_api::api_operator_inbox)) .routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_container_resources)) - .routes(routes!(misc_api::api_audit_log)) .routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(misc_api::post_request_spawn)) .routes(routes!(misc_api::post_op_send)) @@ -195,7 +192,6 @@ pub async fn serve( .routes(routes!(lifecycle_ops::post_resume)) .routes(routes!(lifecycle_ops::post_resource_limits)) .routes(routes!(lifecycle_ops::post_update_all)) - .routes(routes!(infra_containers::post_infra_container)) .routes(routes!(tombstones::post_purge_tombstone)) .routes(routes!(meta_inputs::post_meta_update)) .routes(routes!(build_logs::get_build_log_stream)) @@ -397,7 +393,6 @@ mod router_build_probe { .routes(routes!(misc_api::api_operator_inbox)) .routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_container_resources)) - .routes(routes!(misc_api::api_audit_log)) .routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(misc_api::post_request_spawn)) .routes(routes!(misc_api::post_op_send)) @@ -438,7 +433,6 @@ mod router_build_probe { .routes(routes!(lifecycle_ops::post_resume)) .routes(routes!(lifecycle_ops::post_resource_limits)) .routes(routes!(lifecycle_ops::post_update_all)) - .routes(routes!(infra_containers::post_infra_container)) .routes(routes!(tombstones::post_purge_tombstone)) .routes(routes!(meta_inputs::post_meta_update)) .routes(routes!(build_logs::get_build_log_stream)) diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 6e576b21..ee578b3b 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -117,33 +117,6 @@ pub(super) struct StateSnapshot { /// `host_stats::server_warnings`; the frontend renders this list /// generically, so new warning kinds need no frontend change. server_warnings: Vec, - /// 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, the operator-only surface for starting - /// and stopping them. - infra_containers: Vec, -} - -/// One row for the C0R3 page's 1NFR4 sub-tab. -#[derive(Serialize)] -struct InfraContainerView { - /// Container / systemd-unit name (e.g. `"hive-ci"`). - name: &'static str, - running: bool, -} - -/// Live running/stopped status for all four hive infra containers. -/// Extracted out of [`api_state`] to keep it under clippy's -/// `too_many_lines` limit. -async fn infra_container_views() -> Vec { - let mut infra_containers = Vec::with_capacity(hive_priv_sock::InfraContainer::ALL.len()); - for container in hive_priv_sock::InfraContainer::ALL { - infra_containers.push(InfraContainerView { - name: container.name(), - running: crate::lifecycle::infra_is_running(container).await, - }); - } - infra_containers } #[derive(Serialize)] @@ -328,8 +301,6 @@ pub(super) async fn api_state( w }; - let infra_containers = infra_container_views().await; - axum::Json(StateSnapshot { seq, hostname, @@ -368,7 +339,6 @@ pub(super) async fn api_state( .ok() .filter(|s| !s.is_empty()), server_warnings, - infra_containers, }) } diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index ddf63c7d..12639d19 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -13,17 +13,6 @@ use chrono::{DateTime, Utc}; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum DashboardEvent { - /// A new agent-initiated privileged action was recorded in the audit - /// log. The audit view (`/audit.html`) prepends `entry` live off - /// `/dashboard/stream` instead of polling. The `AuditEntry` fields - /// are flattened alongside the `kind` tag + `seq`, so the wire shape - /// matches one row of the `/api/audit-log` `entries` array exactly - /// (`{kind, seq, id, ts_unix, agent, action, target, outcome, detail}`). - AuditEntryAdded { - seq: u64, - #[serde(flatten)] - entry: crate::audit_log::AuditEntry, - }, /// Broker `Sent` event mirrored onto the dashboard channel. /// `file_refs` carries every path-shaped token in `body` that /// hive-c0re verified is a regular file under the allow-listed @@ -270,7 +259,6 @@ impl DashboardEvent { DashboardEvent::SchedulesChanged { .. } => "schedules_changed", DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed", DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed", - DashboardEvent::AuditEntryAdded { .. } => "audit_entry_added", } } } @@ -385,18 +373,6 @@ mod tests { agents: Vec::new(), effective: std::collections::BTreeMap::new(), }, - DashboardEvent::AuditEntryAdded { - seq: 1, - entry: crate::audit_log::AuditEntry { - id: 1, - ts_unix: hive_sh4re::wire_time::from_secs(0), - agent: "operator".into(), - action: "stop_infra".into(), - target: "hive-ci".into(), - outcome: "ok".into(), - detail: None, - }, - }, ]; for ev in samples { let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise"); @@ -407,34 +383,4 @@ mod tests { assert_eq!(ev.kind_tag(), serde_kind, "kind_tag() drift on {ev:?}"); } } - - /// The flattened `AuditEntry` fields must sit alongside `kind`/`seq` - /// at the top level (not nested under `entry`) so the wire shape - /// matches one `/api/audit-log` row — the audit view prepends it - /// directly. - #[test] - fn audit_entry_added_flattens_to_top_level() { - let ev = DashboardEvent::AuditEntryAdded { - seq: 7, - entry: crate::audit_log::AuditEntry { - id: 42, - ts_unix: hive_sh4re::wire_time::from_secs(1_700_000_000), - agent: "operator".into(), - action: "stop_infra".into(), - target: "hive-gateway".into(), - outcome: "err".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"], "operator"); - assert_eq!(v["target"], "hive-gateway"); - assert_eq!(v["outcome"], "err"); - assert_eq!(v["detail"], "systemctl stop failed"); - // Not nested — there must be no `entry` sub-object. - assert!(v.get("entry").is_none()); - } } diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index 606fc414..8f20525d 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -680,21 +680,6 @@ pub async fn is_running(name: &str) -> bool { .is_ok_and(|s| s.success()) } -/// True when a hive infrastructure service's systemd unit is active. -/// Sibling of [`is_running`] for sub-agents, but infra names (`hive-ci`, …) -/// have no `h-` prefix to strip and are not all containers, so the unit -/// comes from the variant itself rather than from [`container_name`]. Used -/// by the dashboard C0R3 page's 1NFR4 sub-tab to show each one's live -/// status dot. -pub async fn infra_is_running(container: hive_priv_sock::InfraContainer) -> bool { - let unit = container.service_unit(); - Command::new("systemctl") - .args(["is-active", "--quiet", &unit]) - .status() - .await - .is_ok_and(|s| s.success()) -} - /// Fully tear down a sub-agent's container: stop + remove via `nixos-container /// destroy`, then clean our own systemd drop-in. Leaves it to the caller to /// wipe `/var/lib/hyperhive/...` state and the per-agent runtime dir. diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 9477bf2a..e3436613 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -46,7 +46,7 @@ pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups pub(crate) use stats::{ container_stats, hive_stats, host_stats, otel_metrics, sweep_health, warnings, }; -pub(crate) use stores::{approvals, audit_log, broker, build_logs, db, power, scheduled_prompts}; +pub(crate) use stores::{approvals, broker, build_logs, db, power, scheduled_prompts}; pub(crate) use workers::{ agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets, scheduled_prompts_worker, }; @@ -518,9 +518,6 @@ async fn cmd_serve( // build_logs.sqlite vacuum: c0re-side (single db). Failures kept // 30d, successes 24h — see `build_logs::vacuum` for the rule. crate::build_logs::spawn_vacuum(&coord); - // audit_log.sqlite vacuum: agent-initiated privileged-action trail, - // 90d retention — see `audit_log::vacuum`. - crate::audit_log::spawn_vacuum(&coord); // Container crash watcher: emits HelperEvent::ContainerCrash // when a previously-running container goes away without an // operator-initiated transient state. diff --git a/hive-c0re/src/stores/audit_log.rs b/hive-c0re/src/stores/audit_log.rs deleted file mode 100644 index eedbe474..00000000 --- a/hive-c0re/src/stores/audit_log.rs +++ /dev/null @@ -1,329 +0,0 @@ -//! 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`). -//! -//! 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 so they don't -//! have to thread an `Arc` 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}; - -use anyhow::{Context, Result}; - -use chrono::{DateTime, Utc}; -use rusqlite::{Connection, params}; -use serde::Serialize; -use utoipa::ToSchema; - -/// Process-singleton handle, set once at coordinator startup. Mirrors -/// `build_logs::GLOBAL` — lets recording sites write without threading an -/// `Arc` through every entry point. -static GLOBAL: OnceLock> = OnceLock::new(); - -/// Install the process-wide `AuditLog` handle. Idempotent: a second call -/// silently keeps the first handle. -pub fn install(handle: Arc) { - let _ = GLOBAL.set(handle); -} - -/// Retain audit rows for 90 days. Longer than build-log retention — this -/// is a security/accountability record, not debug noise; the operator may -/// want to review "who restarted what" well after the fact. -const KEEP_SECS: i64 = 90 * 24 * 3600; - -const SCHEMA: &str = " -CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts_unix INTEGER NOT NULL, - agent TEXT NOT NULL, - action TEXT NOT NULL, - target TEXT NOT NULL, - outcome TEXT NOT NULL, - detail TEXT -); -CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log (ts_unix DESC); -"; - -/// Outcome of a recorded privileged action. Stored as the literal string -/// in the `outcome` column. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum AuditOutcome { - /// The privileged action succeeded. - Ok, - /// The privileged action was attempted but failed (e.g. the - /// underlying systemctl call errored). Denied-by-capability attempts - /// are recorded too — see the recording site. - Err, -} - -impl AuditOutcome { - fn as_str(self) -> &'static str { - match self { - Self::Ok => "ok", - Self::Err => "err", - } - } -} - -/// One audit row as returned to the dashboard. -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct AuditEntry { - pub id: i64, - pub ts_unix: DateTime, - /// 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. `stop_infra`). - pub action: String, - /// What it acted on (e.g. `hive-ci`). - pub target: String, - /// `"ok"` | `"err"`. - pub outcome: String, - /// Optional free-text detail (e.g. the error message on failure). - pub detail: Option, -} - -/// Sqlite-backed audit-log store. `Arc`-friendly: all methods -/// take `&self`, an internal `Mutex` serializes access. -pub struct AuditLog { - conn: Mutex, -} - -impl AuditLog { - /// Open (creating if absent) the `audit_log.sqlite` store under - /// `db_dir` and apply the schema. `db_dir` is shared with - /// `build_logs` (the broker db's parent directory). - /// - /// # Errors - /// Returns an error if the directory can't be created, the sqlite - /// file can't be opened, or applying the schema fails. - pub fn open(db_dir: &Path) -> Result { - let path = db_dir.join("audit_log.sqlite"); - let conn = crate::db::open(&path, "audit_log")?; - conn.execute_batch(SCHEMA) - .context("apply audit_log schema")?; - Ok(Self { - conn: Mutex::new(conn), - }) - } - - /// Record one privileged action. Best-effort: a sqlite error is logged - /// but never returned, so a transient blip never fails the underlying - /// privileged action (the action already happened — losing its audit - /// row is strictly less bad than failing the action retroactively). - /// - /// Returns the inserted [`AuditEntry`] (with its assigned id + - /// timestamp) on success, or `None` if the insert failed. The - /// returned row is the canonical record — callers that also push a - /// live event (e.g. the dashboard stream) emit *this* rather than - /// re-deriving the fields, so the stored row and the streamed event - /// can't drift. - #[must_use] - pub fn record( - &self, - agent: &str, - action: &str, - target: &str, - outcome: AuditOutcome, - detail: Option<&str>, - ) -> Option { - let now = Utc::now().timestamp(); - let conn = self.conn.lock().unwrap(); - match conn.execute( - "INSERT INTO audit_log (ts_unix, agent, action, target, outcome, detail) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![now, agent, action, target, outcome.as_str(), detail], - ) { - Ok(_) => Some(AuditEntry { - id: conn.last_insert_rowid(), - ts_unix: hive_sh4re::wire_time::from_secs(now), - agent: agent.to_owned(), - action: action.to_owned(), - target: target.to_owned(), - outcome: outcome.as_str().to_owned(), - detail: detail.map(str::to_owned), - }), - Err(e) => { - tracing::warn!( - %agent, %action, %target, - error = ?e, - "audit_log: record failed (dropping entry)" - ); - None - } - } - } - - /// Return the most recent `limit` rows, newest first. Limit is - /// hard-clamped to 500 to bound the worst-case payload. - /// - /// # Errors - /// Returns an error if the query fails to prepare or a row fails to - /// deserialize. - pub fn list_recent(&self, limit: usize) -> Result> { - let limit = limit.min(500); - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, ts_unix, agent, action, target, outcome, detail - FROM audit_log - ORDER BY ts_unix DESC, id DESC - LIMIT ?1", - )?; - let rows = stmt.query_map(params![i64::try_from(limit).unwrap_or(500)], row_to_entry)?; - let mut out = Vec::new(); - for r in rows { - out.push(r?); - } - Ok(out) - } - - /// Total row count, regardless of the `list_recent` clamp. Lets the - /// dashboard show "latest N of TOTAL" instead of silently capping. - /// - /// # Errors - /// Returns an error if the `COUNT(*)` query fails. - pub fn count_total(&self) -> Result { - let conn = self.conn.lock().unwrap(); - let n: i64 = conn.query_row("SELECT COUNT(*) FROM audit_log", [], |r| r.get(0))?; - Ok(n) - } - - /// Drop rows older than the retention window. Returns the number of - /// rows deleted. Called from the hourly vacuum loop. - /// - /// # Errors - /// Returns an error if the `DELETE` query fails. - pub fn vacuum(&self) -> Result { - let cutoff = Utc::now().timestamp() - KEEP_SECS; - let conn = self.conn.lock().unwrap(); - let removed = conn.execute("DELETE FROM audit_log WHERE ts_unix < ?1", params![cutoff])?; - Ok(u64::try_from(removed).unwrap_or(0)) - } -} - -/// Spawn the hourly retention sweep. Mirrors `build_logs::spawn_vacuum` -/// in cadence + shutdown handling. -pub fn spawn_vacuum(coord: &Arc) { - use std::time::Duration; - let audit = coord.audit_log.clone(); - let mut shutdown = coord.shutdown_rx(); - let interval = Duration::from_hours(1); - tokio::spawn(async move { - loop { - match audit.vacuum() { - Ok(0) => {} - Ok(n) => tracing::info!(removed = n, "audit_log vacuum"), - Err(e) => tracing::warn!(error = ?e, "audit_log vacuum failed"), - } - tokio::select! { - () = tokio::time::sleep(interval) => {} - _ = shutdown.changed() => { - tracing::info!("audit_log vacuum: shutdown signal received"); - break; - } - } - } - }); -} - -fn row_to_entry(r: &rusqlite::Row) -> rusqlite::Result { - Ok(AuditEntry { - id: r.get(0)?, - ts_unix: hive_sh4re::wire_time::from_secs(r.get(1)?), - agent: r.get(2)?, - action: r.get(3)?, - target: r.get(4)?, - outcome: r.get(5)?, - detail: r.get(6)?, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn tmpdb() -> (tempfile::TempDir, AuditLog) { - let dir = tempfile::tempdir().expect("tempdir"); - let db = AuditLog::open(dir.path()).expect("open"); - (dir, db) - } - - #[test] - fn record_and_list_newest_first() { - let (_d, db) = tmpdb(); - // record() returns the canonical inserted row (id + ts assigned). - let entry = db - .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( - "operator", - "stop_infra", - "hive-gateway", - AuditOutcome::Err, - Some("systemctl failed"), - ); - let rows = db.list_recent(10).expect("list"); - assert_eq!(rows.len(), 2); - // Newest first: the gateway/err row was inserted last. - assert_eq!(rows[0].target, "hive-gateway"); - assert_eq!(rows[0].outcome, "err"); - assert_eq!(rows[0].detail.as_deref(), Some("systemctl failed")); - assert_eq!(rows[1].target, "hive-ci"); - assert_eq!(rows[1].outcome, "ok"); - assert!(rows[1].detail.is_none()); - assert_eq!(rows[0].agent, "operator"); - assert_eq!(rows[0].action, "stop_infra"); - assert_eq!(db.count_total().expect("count"), 2); - } - - #[test] - fn list_clamps_to_500() { - let (_d, db) = tmpdb(); - let _ = db.record("a", "x", "t", AuditOutcome::Ok, None); - let rows = db.list_recent(999_999).expect("list"); - assert!(rows.len() <= 500); - } - - #[test] - fn vacuum_drops_only_old_rows() { - let (_d, db) = tmpdb(); - let _ = db.record("operator", "stop_infra", "hive-ci", AuditOutcome::Ok, None); - // Backdate it past the retention window. - { - let conn = db.conn.lock().unwrap(); - conn.execute( - "UPDATE audit_log SET ts_unix = ?1", - params![Utc::now().timestamp() - KEEP_SECS - 60], - ) - .unwrap(); - } - 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"); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].target, "hive-forge"); - } -} diff --git a/hive-c0re/src/stores/mod.rs b/hive-c0re/src/stores/mod.rs index 2a27af28..fb05ed2a 100644 --- a/hive-c0re/src/stores/mod.rs +++ b/hive-c0re/src/stores/mod.rs @@ -1,10 +1,9 @@ //! Sqlite-backed host-side stores (broker, approval / schedule queues, -//! build logs, audit trail, power intent) plus the shared connection -//! open/migration helper (`db`). Each submodule is re-exported at the -//! crate root, so `crate::broker::…` etc. keep working unchanged. +//! build logs, power intent) plus the shared connection open/migration +//! helper (`db`). Each submodule is re-exported at the crate root, so +//! `crate::broker::…` etc. keep working unchanged. pub mod approvals; -pub mod audit_log; pub mod broker; pub mod build_logs; pub mod db;