//! Host admin socket wire types (`/run/hyperhive/host.sock`). //! //! The host-control protocol spoken between `hivectl` and the `hive-c0re` //! daemon. Re-homed out of `hive-sh4re` so a standalone `hivectl` can depend //! on just this protocol crate instead of the whole daemon crate. The shared //! payload types it references (`Approval`, `AgentStatusRow`, `jobs::DagView`) //! stay in `hive-sh4re`. use std::path::PathBuf; use hive_sh4re::{AgentStatusRow, Approval, jobs}; use serde::{Deserialize, Serialize}; // ── Shared hive layout facts ────────────────────────────────────────────── // Paths + names both the `hive-c0re` daemon and the host-side `hivectl` CLI // must agree on. Homed here (the protocol crate both sides already depend on) // so a standalone `hivectl` can reach them without linking the whole daemon // crate. `hive-c0re`'s `paths` / `lifecycle` modules re-export these, staying // the daemon's single-source facade for its own callsites. /// Default host admin socket (`/run/hyperhive/host.sock`). Used as the /// `--socket` / `--host-socket` clap `default_value` in the daemon and /// `hivectl`. pub const HOST_SOCKET: &str = "/run/hyperhive/host.sock"; /// `agents/` — per-agent persistent state root (one subdir per agent, /// bind-mounted into each container as `/agents/`). // nix: agent container bind-mount source (harness modules / agent.nix template) — must match. // priv-sock: `hive_priv_sock::AGENT_STATE_ROOT` is the same value and must stay in sync; // the privsep boundary prevents importing across the crate. pub const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents"; /// `agents/` — one agent's persistent state root. /// /// Takes a validated [`Ident`] (not a raw `&str`) so a per-agent state path /// can never be built from an unvalidated name — the `../` traversal guard is /// the type, enforced at the one place every agent path is rooted. #[must_use] pub fn agent_state_dir(name: &Ident) -> PathBuf { PathBuf::from(AGENTS_ROOT).join(name.as_str()) } /// `gateway/gateway.htpasswd` — nginx basic-auth credential store for the /// operator dashboard vhost. `hivectl`'s `--htpasswd-file` clap default. // nix: read by the gateway container's nginx (hive-gateway.nix) — must match. pub const GATEWAY_HTPASSWD: &str = "/var/lib/hyperhive/gateway/gateway.htpasswd"; /// nspawn machine-name prefix for agent containers (`h-`). A single /// `starts_with(AGENT_PREFIX)` filter enumerates managed containers. pub const AGENT_PREFIX: &str = "h-"; /// Map an agent's logical name to its nspawn machine name (`h-`). #[must_use] pub fn container_name(name: &str) -> String { format!("{AGENT_PREFIX}{name}") } /// A validated hive identifier: 1-63 chars of `[a-z0-9-]`. /// /// The single ident type for agent names, forge labels, and matrix / github /// account names — every value that becomes a filesystem path segment or an /// nspawn machine-name component. Constructed only through the validating /// [`Ident::parse`], so "this string passed the naming whitelist" is a fact /// the type carries instead of a convention every call site re-checks against /// a raw `String`. The charset is deliberately conservative — lowercase /// ascii, digits, and hyphen only (no underscore, dot, slash, or non-ASCII) — /// and length-capped, tracking `nixos-container` basename rules and keeping /// `../` traversal, unicode homoglyphs, and unbounded path segments out of /// any path built from it. Deserialization runs the same parse, so a value /// arriving over the wire is validated on the way in. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Ident(String); impl Ident { /// Maximum length in bytes. A cap stops an unbounded operator-supplied /// name from becoming an over-long path segment (a filesystem / `DoS` /// footgun). pub const MAX_LEN: usize = 63; /// Parse + validate an identifier. /// /// # Errors /// Returns `Err(reason)` — a caller-ready message — when `s` is empty, /// longer than [`Ident::MAX_LEN`], or contains any byte outside /// `[a-z0-9-]`. pub fn parse(s: &str) -> Result { if s.is_empty() { return Err("identifier must not be empty"); } if s.len() > Self::MAX_LEN { return Err("identifier must be 63 characters or fewer"); } if !s .bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') { return Err("identifier must contain only [a-z0-9-]"); } Ok(Self(s.to_owned())) } /// The validated identifier as a string slice. #[must_use] pub fn as_str(&self) -> &str { &self.0 } /// Consume into the inner `String`. #[must_use] pub fn into_string(self) -> String { self.0 } } impl std::fmt::Display for Ident { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } impl AsRef for Ident { fn as_ref(&self) -> &str { &self.0 } } /// Lets an `Ident` key a `HashMap`/`BTreeMap` be looked up with a `&str`. impl std::borrow::Borrow for Ident { fn borrow(&self) -> &str { &self.0 } } impl serde::Serialize for Ident { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(&self.0) } } impl<'de> serde::Deserialize<'de> for Ident { fn deserialize>(deserializer: D) -> Result { use serde::de::Error as _; let s = String::deserialize(deserializer)?; Ident::parse(&s).map_err(D::Error::custom) } } #[cfg(test)] mod ident_tests { use super::Ident; #[test] fn accepts_canonical_shapes() { for ok in [ "damocles", "hm1nd", "agent-with-dashes", "codeberg", "acct-1", ] { assert!(Ident::parse(ok).is_ok(), "should accept {ok:?}"); } assert!( Ident::parse(&"a".repeat(Ident::MAX_LEN)).is_ok(), "63 chars is the boundary" ); } #[test] fn rejects_bad_input() { let too_long = "a".repeat(Ident::MAX_LEN + 1); for bad in [ "", &too_long, "Alice", // uppercase "snake_case", // underscore (tightened out) "alice.bob", // dot "alice/bob", // slash "../etc/passwd", // traversal "damóclès", // non-ASCII "alice\u{2013}b", // en-dash homoglyph ] { assert!(Ident::parse(bad).is_err(), "should reject {bad:?}"); } } #[test] fn round_trips_and_serde_validates() { let id = Ident::parse("damocles").unwrap(); assert_eq!(id.as_str(), "damocles"); // Serialize is transparent (just the inner string). let json = serde_json::to_string(&id).unwrap(); assert_eq!(json, "\"damocles\""); // Deserialize runs the same parse. let back: Ident = serde_json::from_str(&json).unwrap(); assert_eq!(back, id); assert!( serde_json::from_str::("\"BAD_NAME\"").is_err(), "deserialize must reject an invalid ident" ); } } /// Which way to reconcile an agent's config branches /// ([`HostRequest::ReconcileConfigApply`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ReconcileDirection { /// Reset the local applied checkout to forge `main`. Forge, /// Advance forge `main` from local — not supported yet. Local, } /// Requests on the host admin socket. /// /// Wire format: one JSON object per line. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "cmd", rename_all = "snake_case")] pub enum HostRequest { /// Create and start a sub-agent container directly, bypassing the /// approval queue. Privileged-context only. See /// `docs/approvals.md::Approval kinds (wire shapes)`. Spawn { name: String }, /// Submit a spawn request for the operator to approve. See /// `docs/approvals.md::Approval kinds (wire shapes)` (`Spawn`). RequestSpawn { name: String }, /// Stop a managed container (graceful). Kill { name: String }, /// Tear down a sub-agent container, optionally purging state. /// See `docs/approvals.md::Destroy semantics`. Destroy { name: String, #[serde(default)] purge: bool, }, /// Stop and start a managed container without rebuilding config. /// For "kick the container" operations that don't touch the flake or /// nspawn flags. Mirrors `lifecycle::restart` (kill + start). Restart { name: String }, /// Stop and restart all managed containers in sequence. Convenience /// wrapper for `hivectl agents restart-all`; iterates the live /// container list and restarts each one. RestartAll, /// Restart containers hive-wide (`hivectl restart`), scoped like /// `Stop`/`Start`. Each targeted agent rides exactly one DAG /// server-side — the `Restart` template (mechanical stop + reconcile), /// or, when `graceful` is set, the `GracefulRestart` template (signal → /// drain → mechanical stop → reconcile) — rather than the old /// client-side stop-then-start composition (and, briefly, a server-side /// "submit stop DAG, await it, submit start DAG" composition): a /// dropped `hivectl` connection mid-way, or a crash between the two /// submits, used to leave the agent stopped with no automatic /// follow-up, since nothing durable remembered "finish the restart" /// once the calling process/turn was gone. `GracefulRestart` closes /// that gap the same way `Restart` already does — one DAG, queued up /// front, that owns the whole sequence. Infra containers have no /// lease/DAG and restart synchronously (stop then start), same as /// before. Scope semantics match `Stop`/`Start` (all-false = /// everything). RestartScoped { #[serde(default)] scope: LifecycleScope, #[serde(default)] graceful: bool, }, /// Apply pending config to a managed container. Rebuild { name: String }, /// List managed containers. List, /// List managed agents with their full status + technical state /// (running / needs-login / needs-update / deployed sha / parent / /// pending reminders) — the `hivectl agents list` roster view. /// Reuses the dashboard's per-agent `ContainerView` aggregation. AgentStatus, /// Report this hive's canonical DNS domain /// (`services.hyperhive.domain`) plus the browser-facing home / /// forge / matrix URLs, daemon-sourced so custom forge/matrix /// domains resolve correctly. Each URL is `None` when its subsystem /// is unreachable from a browser (e.g. forge not behind the gateway, /// matrix GUI disabled). Backs `hivectl open` + the federation /// peer-config block (which reads the bare `domain`). Urls, /// Fetch one job-queue DAG by id — the polling surface behind /// `hivectl`'s wait/progress loop. A multi-step op is a single DAG /// (its whole graph in `nodes`). Result: [`HostResponse::dags`]. QueueDag { id: u64 }, /// List pending approval requests. Pending, /// Approve a pending request by id; the action runs immediately. Approve { id: i64 }, /// Deny a pending request by id. Deny { id: i64 }, /// Move an agent in the topology tree. `new_parent = None` /// promotes the agent to root, `Some(name)` sets a new parent. /// Validation rules + bind-mount caveat documented in /// `docs/agent-hierarchy.md::Current state`. SetParent { child: String, new_parent: Option, }, /// Stop managed containers hive-wide in one operator action /// (`hivectl stop`): agents plus the selected infra containers. `scope` /// selects which classes; an all-false scope means **everything** (the /// bare `hivectl stop`). `graceful` runs the per-agent quiesce (graceful /// agent stop, issue tracker `graceful agent stop`) instead of a hard /// stop. Agents stop via the lifecycle path; infra containers via the /// host `container@` units. Stop { #[serde(default)] scope: LifecycleScope, #[serde(default)] graceful: bool, }, /// Start managed containers hive-wide — the inverse of `Stop` /// (`hivectl start`). Same `scope` semantics (all-false = everything); /// no graceful flag (start is unconditional). Start { #[serde(default)] scope: LifecycleScope, }, /// Create or refresh a matrix account + access token for `name`. /// The daemon runs the provisioning (it holds the register + admin /// tokens and the matrix creds dir) and returns the operator-facing /// results (persisted-token path for agents, or the freshly-minted /// token + password for non-agent accounts) in /// [`HostResponse::messages`]. `password` is resolved by the client /// (inline flag or stdin) and `None` requests a random throwaway. MatrixCreateUser { name: String, #[serde(default)] password: Option, }, /// Provision (or re-provision) the hive system admin matrix account. /// Daemon-side equivalent of `hivectl matrix sync-admin`. MatrixSyncAdmin, /// Promote a matrix user to homeserver admin via the admin API. /// Uses the daemon's system admin token; `server_name` is discovered /// from the running homeserver. MatrixPromoteUser { name: String }, /// Reset a matrix user's password via the admin API and persist the /// new password to the matrix creds dir so a later token mint can /// re-login. Returns the outcome in [`HostResponse::messages`]. MatrixResetPassword { name: String }, /// Invite a matrix user to the hive Space (default) or a specific /// `room`. Uses the daemon's admin token; idempotent /// (already-member / already-invited is a no-op). MatrixInvite { user: String, #[serde(default)] room: Option, }, /// Create or refresh a forge account + API token for `name`. Daemon-side /// equivalent of `hivectl forge create-user`: for an existing agent it /// provisions the account and persists the token to `/forge-token`; /// for a non-agent (operator/human) it mints a user and returns the token /// in [`HostResponse::messages`]. `password` is resolved client-side /// (inline flag or stdin) and only meaningful for non-agent accounts. ForgeCreateUser { name: String, #[serde(default)] password: Option, }, /// Report the divergence between agent `agent`'s local applied config /// checkout and its forge `agent-configs/` `main`. The daemon /// fetches forge `main` read-only and returns a human-readable report /// (ahead/behind counts, commit-range summary, `git diff --stat`, and /// the full diff when `verbose`) in [`HostResponse::messages`]. Read-only /// — never mutates either side. Backs `hivectl forge reconcile-config` /// (the diff it always shows first). ReconcileConfigStatus { agent: String, #[serde(default)] verbose: bool, }, /// Reconcile agent `agent`'s config branches in `direction`. /// `Forge` resets the local applied checkout to forge `main` (takes /// effect on the next deploy); `Local` is not supported yet and returns /// an [`HostResponse::error`] (advancing the protected forge `main` from /// local needs lifting branch protection — resolve via a config PR). /// Backs `hivectl forge reconcile-config --from `. ReconcileConfigApply { agent: String, direction: ReconcileDirection, }, /// Add or update a gateway HTTP-Basic user in the daemon's htpasswd file /// (`paths::GATEWAY_HTPASSWD`). Daemon-side equivalent of `hivectl gateway /// create-user`: the daemon bcrypt-hashes `password` (cost 12, remapped to /// the `$2y$` prefix nginx accepts) and writes the entry, so hivectl never /// touches the file. `password` is read client-side (inline flag or stdin). /// Returns a confirmation line in [`HostResponse::messages`]. GatewayCreateUser { username: String, password: String }, /// Remove a gateway HTTP-Basic user from the daemon's htpasswd file. /// Daemon-side equivalent of `hivectl gateway delete-user`. Errors if the /// user isn't present so a no-op is detectable. GatewayDeleteUser { username: String }, /// List the gateway HTTP-Basic usernames in the daemon's htpasswd file. /// Daemon-side equivalent of `hivectl gateway list-users`; the usernames /// come back in [`HostResponse::messages`], one per line. GatewayListUsers, /// Write (or overwrite) an agent's GitHub PAT under its state dir, via /// the privileged helper. Daemon-side equivalent of `hivectl github /// set-token`. `token` is resolved + non-empty-validated client-side /// (inline flag or stdin); the daemon just persists it. Read live by /// the agent's `gh` wrapper / git credential helper — no rebuild needed. SetAgentGithubToken { agent: String, token: String }, /// Turn on btrfs qgroup accounting on the agent-state filesystem, via /// the privileged helper. Daemon-side equivalent of `hivectl quota /// enable`. Returns advisory lines in [`HostResponse::messages`]. QuotaEnable, /// Set (or, with `limit = None`, clear) an agent's btrfs disk quota via /// the privileged helper. Daemon-side equivalent of `hivectl quota /// limit`; the size string is parsed to bytes client-side. Returns a /// bare success — the client prints the confirmation from the value it /// sent. QuotaLimit { name: String, #[serde(default)] limit: Option, }, /// Report per-agent btrfs qgroup usage (`hivectl quota show [name]`). /// The daemon resolves the agent set (all kept state dirs when `name` /// is absent) and reads each subvolume's referenced/exclusive usage via /// the privileged helper. Result rows land in [`HostResponse::quota`]; /// a "btrfs quota not enabled" error short-circuits the whole sweep as a /// plain [`HostResponse::error`] so the client can print the enable hint. QuotaShow { #[serde(default)] name: Option, }, /// Migrate an agent's plain state dir to a btrfs subvolume via the /// privileged helper (`hivectl subvol upgrade`). The agent MUST already /// be stopped — the client orchestrates stop → this → start. Returns a /// bare success; the client prints its own progress lines. UpgradeSubvolume { name: String }, /// Create a read-only btrfs snapshot of an agent's state subvolume /// (`hivectl subvol snapshot create`). `label` is validated client-side /// AND by hive-priv. Returns the snapshot's host path in /// [`HostResponse::messages`]. SnapshotSubvolume { name: String, label: String }, /// Delete a snapshot created by `SnapshotSubvolume` (`hivectl subvol /// snapshot delete`). Bare success; the client prints the confirmation. DeleteSnapshot { name: String, label: String }, /// Export a snapshot to a local file via `btrfs send` (`hivectl subvol /// snapshot send`). `dest` is a bare filename (hive-priv rejects paths); /// `parent` names an optional parent snapshot for an incremental send. /// Returns the written file's host path in [`HostResponse::messages`]. SendSnapshot { name: String, label: String, #[serde(default)] parent: Option, dest: String, }, } /// One agent's btrfs qgroup usage row — the [`HostRequest::QuotaShow`] /// result unit. `referenced` / `exclusive` are byte counts when the agent /// has a live qgroup; both are `None` (with an explanatory `note`) for a /// plain-dir agent that has no subvolume to account. The client formats the /// byte counts into human-readable columns. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QuotaRow { pub agent: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub referenced: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub exclusive: Option, /// Set instead of the byte counts when the agent has no qgroup data /// (plain dir, or a non-"quota not enabled" read error), so the client /// can print an inline explanation and keep going. #[serde(default, skip_serializing_if = "Option::is_none")] pub note: Option, } /// Selects which container classes a hive-wide [`HostRequest::Stop`] / /// [`HostRequest::Start`] touches. An all-false scope means **everything** /// (the bare `hivectl stop` / `start`); set individual fields to restrict /// (e.g. only `agents` → just the sub-agent containers). `agents` covers /// every managed sub-agent container; the rest are the named infra /// containers (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`). // // A flat bag of independent flag toggles — one bool per selectable // container class — is exactly the right shape here: each maps 1:1 to a // `hivectl` `--ci` / `--forge` / `--gateway` / `--matrix` flag, and they're // orthogonal (any subset is valid), so a state machine or two-variant enums // would only obscure the mapping. Hence the `struct_excessive_bools` allow. #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct LifecycleScope { /// All sub-agent containers (`--agents`). #[serde(default)] pub agents: bool, /// Specific sub-agents by logical name (`--agent `, repeatable). /// Additive with the rest of the scope; redundant when `agents` is set /// (which already covers every sub-agent). #[serde(default)] pub agent_names: Vec, #[serde(default)] pub ci: bool, #[serde(default)] pub forge: bool, #[serde(default)] pub gateway: bool, #[serde(default)] pub matrix: bool, } impl LifecycleScope { /// True when nothing is explicitly selected — interpreted as "all /// classes" (the bare `hivectl stop` / `start` with no scope flags). pub fn is_everything(&self) -> bool { !(self.agents || self.ci || self.forge || self.gateway || self.matrix) && self.agent_names.is_empty() } } /// This hive's canonical domain plus the browser-facing URLs for its /// web surfaces — the `Urls` request result. Every field is `None` when /// the corresponding surface can't be reached from a browser (domain /// unset, forge not behind the gateway, matrix GUI disabled), so the CLI /// can give a precise hint instead of opening a dead link. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct HiveUrls { /// Canonical hive domain (`services.hyperhive.domain`). #[serde(default, skip_serializing_if = "Option::is_none")] pub domain: Option, /// Operator dashboard root (`https:///`). #[serde(default, skip_serializing_if = "Option::is_none")] pub home: Option, /// Forge browser URL (`HIVE_FORGE_PUBLIC_URL`) — only the /// behind-gateway public URL; `None` on direct-port forge deploys. #[serde(default, skip_serializing_if = "Option::is_none")] pub forge: Option, /// Matrix GUI (fluffychat) browser URL — `None` when the matrix GUI /// is disabled. #[serde(default, skip_serializing_if = "Option::is_none")] pub matrix: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct HostResponse { pub ok: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub agents: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub approvals: Option>, /// `Urls` result — this hive's domain plus the browser-facing /// home / forge / matrix URLs. `None` for every other request kind. #[serde(default, skip_serializing_if = "Option::is_none")] pub urls: Option, /// `AgentStatus` result — one row per managed agent with its /// running/health flags + technical state. `None` for every other /// request kind. #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_statuses: Option>, /// Ids of the job-queue DAGs this request submitted (rebuild / /// restart / power ops). Clients poll them via /// [`HostRequest::QueueDag`]; `None` for non-submitting requests. #[serde(default, skip_serializing_if = "Option::is_none")] pub queued_dags: Option>, /// `QueueDag` result — the requested DAG followed by its live /// fan-out children ([`jobs::DagView`]). Empty when the DAG has /// been evicted from the queue's history tail. #[serde(default, skip_serializing_if = "Option::is_none")] pub dags: Option>, /// Free-form operator-facing output lines the client prints verbatim /// (one per line). Carries results a request produced daemon-side that /// have no structured home — e.g. a freshly-minted matrix token, a /// reset password, or an invited room id from the `Matrix*` requests. /// Empty for requests that produce no such output. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub messages: Vec, /// `QuotaShow` result — one row per agent with its btrfs qgroup usage. /// `None` for every other request kind. #[serde(default, skip_serializing_if = "Option::is_none")] pub quota: Option>, } impl HostResponse { #[must_use] pub fn success() -> Self { Self { ok: true, ..Self::default() } } #[must_use] pub fn error(message: impl Into) -> Self { Self { ok: false, error: Some(message.into()), ..Self::default() } } #[must_use] pub fn list(agents: Vec) -> Self { Self { ok: true, agents: Some(agents), ..Self::default() } } #[must_use] pub fn pending(approvals: Vec) -> Self { Self { ok: true, approvals: Some(approvals), ..Self::default() } } /// `Urls` result — this hive's domain + browser-facing web URLs. #[must_use] pub fn urls(urls: HiveUrls) -> Self { Self { ok: true, urls: Some(urls), ..Self::default() } } /// `AgentStatus` result — one row per managed agent. #[must_use] pub fn agent_statuses(rows: Vec) -> Self { Self { ok: true, agent_statuses: Some(rows), ..Self::default() } } /// A request that submitted job-queue DAGs — carries their ids for /// the client's wait/progress loop. #[must_use] pub fn queued(ids: Vec) -> Self { Self { ok: true, queued_dags: Some(ids), ..Self::default() } } /// `QueueDag` result — the polled DAG + its live children. #[must_use] pub fn dags(dags: Vec) -> Self { Self { ok: true, dags: Some(dags), ..Self::default() } } /// A success carrying operator-facing output lines the client prints /// verbatim — the result shape for the `Matrix*` provisioning requests. #[must_use] pub fn messages(messages: Vec) -> Self { Self { ok: true, messages, ..Self::default() } } /// `QuotaShow` result — one usage row per agent. #[must_use] pub fn quota(rows: Vec) -> Self { Self { ok: true, quota: Some(rows), ..Self::default() } } }