Progressive enhancement: a brand-new agent's state root under /var/lib/hyperhive/agents is created as a btrfs subvolume when the host filesystem is btrfs, otherwise it falls back to a plain directory. No existing agent is auto-migrated — the new path only fires when the root does not yet exist, so plain-dir agents are left untouched until an explicit opt-in upgrade. Two new privileged ops (subvolume create/delete are root-only): EnsureAgentSubvolume statfs-gates on btrfs, creates the subvolume, and chowns it to the hive-core user so the normal state/claude/harness mkdirs succeed inside it; DeleteAgentSubvolume btrfs-subvolume-deletes the root iff it is actually a subvolume. hive-c0re calls Ensure before the per-agent dirs are created (spawn/rebuild/InitConfig) and Delete on the purge path only — destroy keeps the subvolume for revival, matching plain-dir semantics. btrfs-progs added to the hive-priv unit PATH. Per-subvolume usage accounting + optional quota is a separate follow-up.
444 lines
18 KiB
Rust
444 lines
18 KiB
Rust
//! 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-<agent_name>`.
|
|
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 <verb> container@<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 `<name>` 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: `<AGENT_STATE_ROOT>/<name>/state/<file>`.
|
|
/// 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 <lines>`.
|
|
pub lines: u32,
|
|
/// `-b` — restrict to the current boot.
|
|
#[serde(default)]
|
|
pub boot: bool,
|
|
/// `--output=<...>`.
|
|
#[serde(default)]
|
|
pub output: JournalOutput,
|
|
/// `-u <unit>`.
|
|
#[serde(default)]
|
|
pub unit: Option<String>,
|
|
/// `-p <priority>`.
|
|
#[serde(default)]
|
|
pub priority: Option<String>,
|
|
/// `--grep=<regex>`.
|
|
#[serde(default)]
|
|
pub grep: Option<String>,
|
|
/// `--since=<ts>`.
|
|
#[serde(default)]
|
|
pub since: Option<String>,
|
|
/// `--until=<ts>`.
|
|
#[serde(default)]
|
|
pub until: Option<String>,
|
|
}
|
|
|
|
/// One bind-mount entry for `WriteNspawnFlags`.
|
|
/// hive-priv constructs `--bind=<host_path>:<container_path>` (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 <gw>`):
|
|
/// 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 <name>`
|
|
StartContainer { name: String },
|
|
|
|
/// `nixos-container stop <name>`
|
|
StopContainer { name: String },
|
|
|
|
/// `nixos-container kill <name>`
|
|
KillContainer { name: String },
|
|
|
|
/// `nixos-container update <name> --flake <flake_ref>`
|
|
/// 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 <name> --flake <flake_ref>`
|
|
/// 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 <name>`
|
|
DestroyContainer { name: String },
|
|
|
|
/// `nixos-container list`
|
|
ListContainers,
|
|
|
|
// --- Container journal reads ---
|
|
/// Read a container's journal via `journalctl -M <container>`.
|
|
/// 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-<agent>` or a sibling service).
|
|
container: String,
|
|
/// journalctl knobs (see [`JournalQuery`]).
|
|
query: JournalQuery,
|
|
},
|
|
|
|
// --- Config file writes ---
|
|
/// Update `/etc/nixos-containers/<container>.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<BindMount>,
|
|
/// `None` = host netns (`PRIVATE_NETWORK=0`). `Some` = private netns with
|
|
/// veth on the specified bridge (`PRIVATE_NETWORK=1`).
|
|
#[serde(default)]
|
|
isolation: Option<NetworkIsolation>,
|
|
},
|
|
|
|
/// Write `/run/systemd/system/container@<container>.service.d/hyperhive-limits.conf`
|
|
/// with `[Service]\nMemoryMax=<memory_max>\nCPUQuota=<cpu_quota>\n`.
|
|
/// Written by `lifecycle::set_resource_limits`.
|
|
WriteResourceLimits {
|
|
container: String,
|
|
memory_max: String,
|
|
cpu_quota: String,
|
|
},
|
|
|
|
/// Remove `/run/systemd/system/container@<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/<agent_name>/` 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/<agent_name>/`.
|
|
/// Fallback when uid lookup returns `None` on first spawn.
|
|
ChmodSocketDir { agent_name: String, mode: u32 },
|
|
|
|
// --- Forge admin CLI ---
|
|
/// Run `forgejo admin <args>` 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>
|
|
///
|
|
/// `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<String>,
|
|
},
|
|
|
|
// --- Agent credential writes ---
|
|
/// Write `forge-token` into `AGENT_STATE_ROOT/<agent_name>/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-<name>` 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-<name>` (validated as a plain ident).
|
|
account: Option<String>,
|
|
},
|
|
|
|
/// Restart `hive-matrix-daemon.service` inside an agent container via
|
|
/// `systemctl --machine=h-<agent_name> 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 <action> container@<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
|
|
/// (`<AGENT_STATE_ROOT>/<agent_name>`) 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 <path>` 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<String>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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"));
|
|
}
|
|
}
|