//! 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", } } } /// A hive infrastructure container that can be controlled (start / stop / /// restart) via [`PrivRequest::ControlInfraContainer`]. The variants ARE /// the allowlist: serde rejects any other value at the wire boundary, so an /// unknown or unsafe target — notably `hive-c0re`, which has no variant and /// would sever the daemon socket — is *unrepresentable* rather than caught /// by a runtime check. The c0re↔hive-priv wire form uses serde's default /// variant naming (`"Ci"`, `"Forge"`, …); it's an internal protocol (both /// ends rebuild together) so it needn't match the container name. /// [`unit_name`](Self::unit_name) is the separate systemd / container name /// (`hive-ci`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum InfraContainer { Ci, Forge, Gateway, Matrix, } impl InfraContainer { /// Every controllable infra container. The source of truth that /// [`SIBLING_CONTAINERS`] is kept consistent with (see the test). pub const ALL: [InfraContainer; 4] = [ InfraContainer::Ci, InfraContainer::Forge, InfraContainer::Gateway, InfraContainer::Matrix, ]; /// The container / systemd-unit name, e.g. `hive-ci` → /// `container@hive-ci.service`. (Distinct from the serde wire form, /// which is the default variant name `"Ci"`.) #[must_use] pub fn unit_name(self) -> &'static str { match self { InfraContainer::Ci => "hive-ci", InfraContainer::Forge => "hive-forge", InfraContainer::Gateway => "hive-gateway", InfraContainer::Matrix => "hive-matrix", } } } impl std::str::FromStr for InfraContainer { type Err = (); /// Parse a container name (`hive-ci`, …) into a variant. Used to decide /// whether an MCP `restart()` target is a controllable infra /// container. `Err(())` for anything that isn't one. fn from_str(s: &str) -> Result { Self::ALL.into_iter().find(|c| c.unit_name() == s).ok_or(()) } } /// 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, } /// One credential-forwarding entry for `WriteNspawnFlags`. hive-priv /// constructs `--load-credential=:` so systemd-nspawn /// loads the host secret file into the container's credential store; an /// inner unit then reads it via `LoadCredential=` (inherit form). /// The secret never lands in a bind mount, the nix store, or the /// generated config — only its host path (validated like a bind path) /// crosses the wire. Used for the hive-wide OTEL auth-header credential. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CredentialMount { /// systemd credential id (e.g. `otel-headers`); inner units inherit /// it by this name. Restricted to `[A-Za-z0-9_.-]` by hive-priv. pub name: String, /// Host path to the secret file, forwarded via nspawn /// `--load-credential=:`. pub host_path: String, } /// 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 }, /// `machinectl kill --signal=SIGKILL` — force-kills all processes /// in the container. nixos-container has no kill verb. 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, /// Host secrets forwarded into the container's credential store via /// nspawn `--load-credential=:`. Empty for agents /// with no credentials configured (the common case). `#[serde(default)]` /// so a hive-priv built before this field deserialises new requests. #[serde(default)] load_credentials: Vec, }, /// 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, /// Homeserver URL for an extra account. When `Some` (only meaningful /// alongside `account: Some`), hive-priv also writes the sidecar /// `matrix-account-.json` (`{"homeserver": }`, 0600, /// chowned to the agent) so the daemon can auto-discover the account /// without a config declaration. `None` → no sidecar written. #[serde(default)] homeserver: 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`. 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`). ControlInfraContainer { container: InfraContainer, 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, }, // --- btrfs qgroup accounting + quota (operator opt-in) --- /// Enable btrfs qgroup accounting on the filesystem holding /// `AGENT_STATE_ROOT` (`btrfs quota enable `). /// Prerequisite for per-agent usage reads + quotas. **Operator /// opt-in** — never run automatically: enabling triggers a full /// rescan with real I/O cost on a large filesystem. Idempotent /// (already-enabled is success); a no-op on non-btrfs (statfs gate). /// Requires root. EnsureBtrfsQuota, /// Read an agent state subvolume's btrfs qgroup usage /// (`btrfs qgroup show -f --raw /`). /// Returns the raw `qgroup show` row in stdout for hive-c0re to parse /// (referenced + exclusive bytes). Errors with "quota not enabled" when /// accounting is off — hive-c0re surfaces that gracefully. Requires root /// (qgroup show on a subvolume needs `CAP_SYS_ADMIN`). ReadSubvolumeUsage { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, }, /// Set (or clear) a btrfs qgroup size limit on an agent's state /// subvolume (`btrfs qgroup limit <…/agent_name>`). /// `limit_bytes = Some(n)` caps referenced usage at `n` bytes; /// `None` clears the limit (`none`). Requires quota enabled first /// ([`PrivRequest::EnsureBtrfsQuota`]). Requires root. SetSubvolumeQuota { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, /// Byte cap on referenced usage; `None` clears the limit. limit_bytes: Option, }, /// Convert an existing **plain-directory** agent state root into a btrfs /// subvolume in place. The operator opt-in counterpart to the progressive /// `EnsureAgentSubvolume` (which only ever makes *new* agents subvolumes): /// it migrates an already-existing plain dir so the agent gains the /// subvolume feature set (snapshots, per-subvol usage/quota, send/receive). /// /// btrfs cannot promote a directory in place, so the helper does the move: /// create a fresh subvolume, copy the dir's contents into it preserving /// ownership/permissions/xattrs (`cp -a --reflink=auto`), then atomically /// rename the original aside and the subvolume into place, and finally /// remove the original. The caller (hivectl) MUST stop the agent first so /// its state bind-mount is gone before the host dir moves, and restart it /// after. Behaviour: /// - path missing → error (nothing to upgrade); /// - already a subvolume → no-op success (idempotent); /// - parent FS not btrfs → error (subvolumes unsupported here); /// - any failure before the final swap leaves the original dir untouched /// (no half-migration). Requires root. UpgradeAgentSubvolume { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, }, /// Write `/etc/tmpfiles.d/hyperhive-agents.conf` for the given agent set /// and immediately apply it with `systemd-tmpfiles --create`. Each entry /// declares the per-agent runtime dirs (`/run/hyperhive/agents/` and /// `/run/hive-agent/`) so systemd recreates them at every boot before /// any container units start — preventing bind-mount source missing errors /// when container@h-* units race hive-c0re after a reboot. /// /// Called at hive-c0re startup and after every agent spawn / destroy. /// Agents are logical names (validated by `validate_agent_name`). SyncAgentTmpfiles { /// Logical agent names (e.g. `"atlas"`, `"ruth"`). hive-priv validates /// each name before writing any path component derived from it. agents: Vec, }, } /// 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::{InfraContainer, 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")); } #[test] fn infra_container_enum_matches_sibling_containers() { // The InfraContainer enum (the control-path allowlist) and the // SIBLING_CONTAINERS slice (the general container-name validator) // must list exactly the same four containers — they're separate // surfaces for the same set, so keep them in lockstep. let mut from_enum: Vec<&str> = InfraContainer::ALL.iter().map(|c| c.unit_name()).collect(); from_enum.sort_unstable(); let mut from_slice: Vec<&str> = SIBLING_CONTAINERS.to_vec(); from_slice.sort_unstable(); assert_eq!(from_enum, from_slice); // hive-c0re has no variant — unrepresentable, can't be controlled. assert!("hive-c0re".parse::().is_err()); } #[test] fn infra_container_name_round_trips() { // `unit_name` is the single source of truth for the wire form (the // serde impls + FromStr all key off it), so a name→variant→name // round-trip proves the mapping is consistent in both directions. for c in InfraContainer::ALL { assert_eq!(c.unit_name().parse::(), Ok(c)); } } }