//! Wire types for the `hive-priv` privileged-helper socket. //! //! Both `hive-priv` (server) and `hive-c0re` (client via `priv_client`) //! import these so the shapes stay in sync. use serde::{Deserialize, Serialize}; /// Default socket path for the privileged helper. pub const PRIV_SOCK: &str = "/run/hive/priv.sock"; /// Manager logical agent name. The manager's system container name is /// `h-ruth` (same `h-` prefix convention as every other agent). pub const MANAGER_NAME: &str = "ruth"; /// Sub-agent container prefix. System container name = `h-`. pub const AGENT_PREFIX: &str = "h-"; /// Sibling service containers managed by hive-c0re. This doubles as the /// authoritative allowlist for infra lifecycle ops /// ([`PrivRequest::ControlInfraContainer`]): any of these four may be /// started / stopped / restarted (by the hive-wide `hivectl stop`/`start` /// flow or an `infra_admin` agent's `restart`). `hive-c0re` is deliberately /// absent — stopping it would sever the very socket the request arrived on. /// hive-priv re-validates against this list root-side, so it's authoritative /// regardless of what the caller sends. pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gateway", "hive-ci"]; /// Lifecycle verb for [`PrivRequest::ControlInfraContainer`]. Maps directly /// to `systemctl container@.service`. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum InfraAction { Start, Stop, Restart, } impl InfraAction { /// The `systemctl` subcommand this action maps to. pub fn systemctl_verb(self) -> &'static str { match self { InfraAction::Start => "start", InfraAction::Stop => "stop", InfraAction::Restart => "restart", } } } /// Host path of the meta flake. The flake ref for agent `` is /// `{META_DIR}#{name}`, derived by `hive-priv` — never passed over the wire. pub const META_DIR: &str = "/var/lib/hyperhive/meta"; /// Root of per-agent state directories on the host. /// Subdirectory layout: `//state/`. /// Used by `WriteAgentStateFile` to derive the write path so the /// exact path is never passed over the wire. pub const AGENT_STATE_ROOT: &str = "/var/lib/hyperhive/agents"; /// Output format for `ReadContainerJournal`. Maps to journalctl /// `--output=<...>`. Restricted to the two formats hive callers use so /// the wire type can't smuggle an arbitrary `--output` value. #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum JournalOutput { /// `short` — the journalctl default (syslog-style timestamps). #[default] Short, /// `short-iso` — ISO 8601 timestamps. ShortIso, } impl JournalOutput { /// The string journalctl expects after `--output=`. #[must_use] pub fn as_journalctl(self) -> &'static str { match self { JournalOutput::Short => "short", JournalOutput::ShortIso => "short-iso", } } } /// journalctl knobs for `ReadContainerJournal`. Grouped into one value so /// the read-journal call chain (`priv_client::read_container_journal` → /// hive-priv's executor) and its several hive-c0re callers pass a single /// struct instead of eight positional args that travelled together 1:1. /// `Default` is the common case (last N lines, short format, no filters); /// callers fill `lines` and override only the knobs they need via /// `..Default::default()`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct JournalQuery { /// `-n `. pub lines: u32, /// `-b` — restrict to the current boot. #[serde(default)] pub boot: bool, /// `--output=<...>`. #[serde(default)] pub output: JournalOutput, /// `-u `. #[serde(default)] pub unit: Option, /// `-p `. #[serde(default)] pub priority: Option, /// `--grep=`. #[serde(default)] pub grep: Option, /// `--since=`. #[serde(default)] pub since: Option, /// `--until=`. #[serde(default)] pub until: Option, } /// One bind-mount entry for `WriteNspawnFlags`. /// hive-priv constructs `--bind=:` (or `--bind-ro=`) /// and validates both paths before writing the conf file. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BindMount { pub host_path: String, pub container_path: String, pub read_only: bool, } /// Network isolation parameters for `WriteNspawnFlags`. When `Some`, /// hive-priv writes `PRIVATE_NETWORK=1` + veth bridge wiring instead /// of the default `PRIVATE_NETWORK=0`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetworkIsolation { /// Static IP address to assign to this container on the bridge subnet. pub agent_ip: String, /// Host bridge interface name (e.g. `hive0`). pub bridge: String, /// Bridge gateway IP (the host-side bridge address, e.g. `10.42.0.1`). /// Written as `HOST_ADDRESS=` in the nspawn conf so nixos-container's /// container-side setup installs a default route (`default via `): /// without it the container comes up with an address but no route off /// the bridge subnet — no internet, no `api.anthropic.com`. The same IP /// runs the hive dnsmasq resolver, so it's also written into the /// container's `/etc/resolv.conf` (see the isolated-DNS oneshot in /// `harness-base.nix`, gated on the marker hive-priv drops). pub gateway_ip: String, } /// A request to the privileged helper. /// /// Wire format: one JSON object per line over `/run/hive/priv.sock`. /// Every variant is a specific known operation — no pass-through /// shell commands or arbitrary paths. New privileged ops get new /// variants. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "op", rename_all = "snake_case")] pub enum PrivRequest { // --- Container lifecycle --- /// `nixos-container start ` StartContainer { name: String }, /// `nixos-container stop ` StopContainer { name: String }, /// `nixos-container kill ` KillContainer { name: String }, /// `nixos-container update --flake ` /// The flake ref is derived from `name` by hive-priv. /// When `stream` is `true`, hive-priv sends `PrivEvent::Line` messages /// as the process runs, then a terminal `PrivEvent::Done`. UpdateContainer { name: String, #[serde(default)] stream: bool, }, /// `nixos-container create --flake ` /// The flake ref is derived from `name` by hive-priv. /// When `stream` is `true`, hive-priv sends `PrivEvent::Line` messages /// as the process runs, then a terminal `PrivEvent::Done`. CreateContainer { name: String, #[serde(default)] stream: bool, }, /// `nixos-container destroy ` DestroyContainer { name: String }, /// `nixos-container list` ListContainers, // --- Container journal reads --- /// Read a container's journal via `journalctl -M `. /// Requires root: the machine-bus transport enters the container's /// namespace, so this can't run from the unprivileged hive-c0re /// process. hive-priv validates `container` against the managed- /// container allowlist, then runs journalctl and returns its output. /// /// The filters (`unit` / `priority` / `grep` / `since` / `until`) /// are applied within the already-authorized machine and passed to /// journalctl as plain argument values; they can't widen access /// beyond the validated `container`. ReadContainerJournal { /// System container name (`h-` or a sibling service). container: String, /// journalctl knobs (see [`JournalQuery`]). query: JournalQuery, }, // --- Config file writes --- /// Update `/etc/nixos-containers/.conf`: strip old network-isolation /// vars, write `PRIVATE_NETWORK` + bridge settings, and set `EXTRA_NSPAWN_FLAGS` /// from the provided bind-mount list. Written by `lifecycle::set_nspawn_flags`. /// When `isolation` is `Some`, writes `PRIVATE_NETWORK=1` + veth wiring; /// when `None`, writes `PRIVATE_NETWORK=0`. WriteNspawnFlags { container: String, binds: Vec, /// `None` = host netns (`PRIVATE_NETWORK=0`). `Some` = private netns with /// veth on the specified bridge (`PRIVATE_NETWORK=1`). #[serde(default)] isolation: Option, }, /// Write `/run/systemd/system/container@.service.d/hyperhive-limits.conf` /// with `[Service]\nMemoryMax=\nCPUQuota=\n`. /// Written by `lifecycle::set_resource_limits`. WriteResourceLimits { container: String, memory_max: String, cpu_quota: String, }, /// Remove `/run/systemd/system/container@.service.d/` if present. /// Called by `lifecycle::destroy` to clean up the resource-limits drop-in. RemoveServiceDropin { container: String }, // --- System --- /// Run `systemctl daemon-reload`. DaemonReload, /// Synchronise the nginx unit inside the `hive-gateway` container. /// /// hive-priv queries `ActiveState` and dispatches: /// - `active` → `systemctl reload nginx` (SIGHUP, zero-downtime) /// - `failed` → `systemctl reset-failed nginx` + `systemctl start nginx` /// - otherwise → `systemctl start nginx` /// /// Requires root: `--machine=hive-gateway` enters the container /// namespace via the machine bus (forbidden for unprivileged users). ReloadGatewayNginx, // --- Socket dir ownership --- /// Set ownership of `/run/hive-agent//` to `uid:gid`. /// Called by `lifecycle::set_nspawn_flags` after `create_dir_all`. ChownSocketDir { agent_name: String, uid: u32, gid: u32, }, /// Set mode of `/run/hive-agent//`. /// Fallback when uid lookup returns `None` on first spawn. ChmodSocketDir { agent_name: String, mode: u32 }, // --- Forge admin CLI --- /// Run `forgejo admin ` inside the `hive-forge` container as the /// `forgejo` unix user. hive-priv executes: /// /// nixos-container run hive-forge -- runuser -u forgejo -- /// forgejo --work-path /var/lib/forgejo admin /// /// `args` must not contain null bytes, newlines, or shell metacharacters; /// hive-priv validates this before spawning the subprocess. /// /// This operation requires root (to nsenter into the forge container's /// namespaces); hive-c0re (which runs as `hive-core`) calls it through /// this route instead of spawning `nixos-container run` directly. RunForgeAdmin { /// Argument list appended after `forgejo --work-path /var/lib/forgejo admin`. /// Each element is a separate argv word — no shell expansion occurs. args: Vec, }, // --- Agent credential writes --- /// Write `forge-token` into `AGENT_STATE_ROOT//state/forge-token`. /// /// hive-priv validates `agent_name`, creates the state dir if absent, /// writes the file 0600, and chowns it to the state dir's owner so /// the agent process can read it. Required because hive-c0re runs /// unprivileged and cannot write to agent-owned state directories. WriteAgentForgeToken { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, /// Token value. hive-priv appends a trailing newline before writing. token: String, }, /// Write a matrix access token into the agent's state dir. With /// `account: None` it targets the hive-internal `matrix-token`; with /// `account: Some(name)` it targets `matrix-token-` for an extra /// (external) account. hive-priv validates both `agent_name` and the /// `account` suffix as plain identifiers before building the path, so a /// crafted account name cannot traverse out of the state dir. /// /// Same write semantics as `WriteAgentForgeToken` — validates names, /// creates dir, writes 0600, chowns to agent owner. WriteAgentMatrixToken { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, /// Token value. hive-priv appends a trailing newline before writing. token: String, /// Extra-account suffix. `None` → `matrix-token` (the hive account); /// `Some(name)` → `matrix-token-` (validated as a plain ident). account: Option, }, /// Restart `hive-matrix-daemon.service` inside an agent container via /// `systemctl --machine=h- restart hive-matrix-daemon.service`. /// Used by hive-c0re to kick the daemon after a successful token write /// so it picks up the new credential without a full container restart. RestartMatrixDaemon { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, }, /// Start / stop / restart a hive infrastructure container on the host /// via `systemctl container@.service`. hive-priv /// validates `container` against [`SIBLING_CONTAINERS`] root-side (the /// authoritative allowlist; `hive-c0re` is never in it). Serves both the /// hive-wide `hivectl stop` / `hivectl start` flow and an `infra_admin` /// agent's `restart` (with `action = Restart`). ControlInfraContainer { /// Infra container name (e.g. `hive-ci`); must be in /// [`SIBLING_CONTAINERS`]. container: String, action: InfraAction, }, // --- Agent state subvolumes (btrfs) --- /// Ensure the agent's persistent state root /// (`/`) is a btrfs subvolume — IF the /// underlying filesystem is btrfs and the root doesn't already exist. /// /// hive-priv derives the path from `agent_name` (never passed over the /// wire), validates the name, then: /// - path already exists (dir or subvol) → no-op (progressive: existing /// agents are left exactly as they are, never auto-migrated); /// - parent FS is not btrfs → no-op (hive-c0re's normal `create_dir_all` /// makes a plain directory, the pre-subvolume behaviour); /// - else → `btrfs subvolume create ` and chown it to the owner of /// `AGENT_STATE_ROOT` (the `hive-core` user) so hive-c0re can create the /// `state/` / `claude/` / `harness/` subdirs inside it as before. /// /// Idempotent and safe to call on every provision. Requires root: btrfs /// subvolume creation is privileged. EnsureAgentSubvolume { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, }, /// Delete the agent's persistent state root if — and only if — it is a /// btrfs subvolume. Called by hive-c0re on the **purge** path only /// (never on a plain destroy, which keeps state for revival). /// /// A subvolume root cannot be removed with `rmdir`/`remove_dir_all`, so /// this routes through hive-priv to run `btrfs subvolume delete`. If the /// path is a plain directory (pre-subvolume agent) or doesn't exist, it's /// a no-op — hive-c0re's own `remove_dir_all` handles the plain-dir case. /// hive-priv derives + validates the path the same way as /// [`PrivRequest::EnsureAgentSubvolume`]. Requires root. DeleteAgentSubvolume { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, }, } /// Response from the privileged helper. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PrivResponse { pub ok: bool, #[serde(default)] pub stdout: String, #[serde(default)] pub stderr: String, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } // --------------------------------------------------------------------------- // Streaming protocol // --------------------------------------------------------------------------- /// Which output stream a `PrivStreamLine` came from. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PrivStream { Stdout, Stderr, } /// A single line forwarded from a streaming privileged operation. /// Wire shape: `{"stream":"stdout","data":"..."}` — distinct from /// `PrivResponse` (which has `ok` but not `stream`/`data`) so that /// `PrivEvent` can disambiguate with `#[serde(untagged)]`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PrivStreamLine { pub stream: PrivStream, pub data: String, } /// An event in the streaming protocol used by long-running priv ops. /// /// Wire format (multiple JSON lines over one connection): /// - Zero or more `Line` events as the subprocess runs. /// - One terminal `Done` event carrying the final status. /// /// Non-streaming ops (and old hive-priv) send exactly one `Done` line, /// which is wire-identical to a bare `PrivResponse` — so old `call()` /// callers that deserialise straight to `PrivResponse` continue to work /// with new hive-priv's terminal event. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum PrivEvent { /// A line of output from the running subprocess. Line(PrivStreamLine), /// Terminal event: the operation has finished. Done(PrivResponse), } #[cfg(test)] mod tests { use super::SIBLING_CONTAINERS; #[test] fn infra_control_allowlist_excludes_c0re_includes_matrix() { // SIBLING_CONTAINERS is the authoritative allowlist for infra // lifecycle ops. hive-c0re must NEVER be in it — stopping the daemon // would sever the socket the request arrived on. assert!(!SIBLING_CONTAINERS.contains(&"hive-c0re")); // hive-matrix IS controllable (operator can stop/start/restart it). assert!(SIBLING_CONTAINERS.contains(&"hive-matrix")); } }