diff --git a/hivectl/src/agents.rs b/hivectl/src/agents.rs new file mode 100644 index 00000000..7c602acf --- /dev/null +++ b/hivectl/src/agents.rs @@ -0,0 +1,164 @@ +//! `hivectl agents` — container lifecycle over the host admin socket +//! (list/restart/restart-all/spawn/kill/destroy/rebuild/set-parent). + +use std::path::Path; + +use anyhow::{Context as _, Result, bail}; +use hive_host_sock::HostRequest; + +use crate::cli::AgentsCmd; +use crate::dag_progress::wait_for_dags; +use crate::util::render; + +async fn agents_restart(socket: &Path, name: &str, no_wait: bool) -> Result<()> { + let resp = crate::client::request( + socket, + hive_host_sock::HostRequest::Restart { + name: name.to_owned(), + }, + ) + .await + .with_context(|| format!("connect to daemon socket {}", socket.display()))?; + if resp.ok { + println!("restart queued: {name}"); + wait_for_dags(socket, resp.queued_dags.unwrap_or_default(), no_wait).await + } else { + bail!( + "restart {name}: {}", + resp.error.as_deref().unwrap_or("unknown error") + ) + } +} + +/// `hivectl agents list` — fetch the per-agent status roster from the +/// daemon (`HostRequest::AgentStatus`) and render it as a padded table, +/// or the raw JSON rows with `--json`. Reuses the dashboard's +/// `ContainerView` aggregation, so the CLI and the web UI never drift. +async fn agents_list(socket: &Path, json: bool) -> Result<()> { + let resp = crate::client::request(socket, hive_host_sock::HostRequest::AgentStatus) + .await + .with_context(|| format!("connect to daemon socket {}", socket.display()))?; + if !resp.ok { + bail!( + "agents list: {}", + resp.error.as_deref().unwrap_or("unknown error") + ); + } + let rows = resp.agent_statuses.unwrap_or_default(); + if json { + println!("{}", serde_json::to_string_pretty(&rows)?); + return Ok(()); + } + if rows.is_empty() { + println!("no managed agents found"); + return Ok(()); + } + // STATUS collapses the health flags into one space-separated token so + // the common case (`running`) stays short and anomalies stand out. + let status_of = |r: &hive_sh4re::AgentStatusRow| -> String { + let mut s = if r.running { "running" } else { "stopped" }.to_owned(); + if r.needs_login { + s.push_str(" needs-login"); + } + if r.needs_update { + s.push_str(" needs-update"); + } + s + }; + let headers = ["NAME", "STATUS", "REV", "PARENT", "REMIND"]; + let table: Vec<[String; 5]> = rows + .iter() + .map(|r| { + [ + r.name.clone(), + status_of(r), + r.deployed_sha.clone().unwrap_or_else(|| "-".to_owned()), + r.parent.clone().unwrap_or_else(|| "-".to_owned()), + if r.pending_reminders > 0 { + r.pending_reminders.to_string() + } else { + "-".to_owned() + }, + ] + }) + .collect(); + let mut widths = headers.map(str::len); + for row in &table { + for (i, cell) in row.iter().enumerate() { + widths[i] = widths[i].max(cell.len()); + } + } + let fmt_row = |cells: &[String]| -> String { + cells + .iter() + .enumerate() + .map(|(i, c)| format!("{c:>() + .join(" ") + .trim_end() + .to_owned() + }; + let header_cells: Vec = headers.iter().map(|h| (*h).to_owned()).collect(); + println!("{}", fmt_row(&header_cells)); + for row in &table { + println!("{}", fmt_row(row)); + } + Ok(()) +} + +async fn agents_restart_all(socket: &Path, no_wait: bool) -> Result<()> { + let resp = crate::client::request(socket, hive_host_sock::HostRequest::RestartAll) + .await + .with_context(|| format!("connect to daemon socket {}", socket.display()))?; + let agents = resp.agents.as_deref().unwrap_or(&[]); + if agents.is_empty() { + println!("restart-all: no managed containers found"); + } else { + for a in agents { + println!("restart queued: {a}"); + } + } + if !resp.ok { + bail!( + "restart-all: {}", + resp.error.as_deref().unwrap_or("unknown error") + ); + } + wait_for_dags(socket, resp.queued_dags.unwrap_or_default(), no_wait).await +} + +/// Dispatch `hivectl agents ` — container lifecycle over the host +/// admin socket. +pub(crate) async fn run_agents(socket: &Path, cmd: AgentsCmd) -> Result<()> { + match cmd { + AgentsCmd::List { json } => agents_list(socket, json).await, + AgentsCmd::Restart { name, no_wait } => agents_restart(socket, &name, no_wait).await, + AgentsCmd::RestartAll { no_wait } => agents_restart_all(socket, no_wait).await, + AgentsCmd::Spawn { name } => { + render(crate::client::request(socket, HostRequest::Spawn { name }).await?) + } + AgentsCmd::RequestSpawn { name } => { + render(crate::client::request(socket, HostRequest::RequestSpawn { name }).await?) + } + AgentsCmd::Kill { name } => { + render(crate::client::request(socket, HostRequest::Kill { name }).await?) + } + AgentsCmd::Destroy { name, purge } => { + render(crate::client::request(socket, HostRequest::Destroy { name, purge }).await?) + } + AgentsCmd::Rebuild { name } => { + render(crate::client::request(socket, HostRequest::Rebuild { name }).await?) + } + AgentsCmd::SetParent { + child, + parent, + root, + } => { + let new_parent = if root { None } else { parent }; + render( + crate::client::request(socket, HostRequest::SetParent { child, new_parent }) + .await?, + ) + } + } +} diff --git a/hivectl/src/approvals.rs b/hivectl/src/approvals.rs new file mode 100644 index 00000000..f09771da --- /dev/null +++ b/hivectl/src/approvals.rs @@ -0,0 +1,24 @@ +//! `hivectl approvals` — the operator approval queue (pending/approve/deny). + +use std::path::Path; + +use anyhow::Result; +use hive_host_sock::HostRequest; + +use crate::cli::ApprovalsCmd; +use crate::util::render; + +/// Dispatch `hivectl approvals ` — the operator approval queue. +pub(crate) async fn run_approvals(socket: &Path, cmd: ApprovalsCmd) -> Result<()> { + match cmd { + ApprovalsCmd::Pending => { + render(crate::client::request(socket, HostRequest::Pending).await?) + } + ApprovalsCmd::Approve { id } => { + render(crate::client::request(socket, HostRequest::Approve { id }).await?) + } + ApprovalsCmd::Deny { id } => { + render(crate::client::request(socket, HostRequest::Deny { id }).await?) + } + } +} diff --git a/hivectl/src/choom.rs b/hivectl/src/choom.rs new file mode 100644 index 00000000..598f35ae --- /dev/null +++ b/hivectl/src/choom.rs @@ -0,0 +1,74 @@ +//! `hivectl choom ` — drop into an interactive Claude session inside +//! an agent container by exec-ing `machinectl shell` running claude as the +//! agent user, mirroring the harness's per-turn claude invocation. + +#[cfg(unix)] +use std::os::unix::process::CommandExt as _; + +use anyhow::{Result, bail}; + +use crate::util::agent_exists; + +/// Drop into an interactive Claude session in the agent container. +/// +/// Execs `machinectl shell @h-` running claude as the agent +/// user from its state dir, reproducing the harness's per-turn claude +/// invocation (flags, session selection, why it never collides with the +/// live harness session). See `docs/tools/hivectl.md` (Choom) for the +/// full rationale. Inherits the caller's PTY; requires root + a running +/// container. +pub(crate) fn choom(name: &str, resume_session: Option<&str>) -> Result<()> { + if !agent_exists(name)? { + bail!( + "no such agent: '{name}' (no state dir under {}/)", + hive_host_sock::AGENTS_ROOT + ); + } + let container = hive_host_sock::container_name(name); + // Enter as the agent's unix user (== agent name) so claude reads the + // right `$HOME/.claude`. + let target = format!("{name}@{container}"); + let claude = "/run/current-system/sw/bin/claude"; + // Bind-mounted state dir; matches `hive-agent::paths::state_dir()`. + let state_dir = format!("/agents/{name}/state"); + // Per-turn config the harness writes; matches `paths::config_dir()`. + let cfg = "/run/hive-config"; + // `--resume ` passes through as `claude --resume ` — + // the rejoin-by-id surface. (claude's `--continue` is a bare flag + // that resumes the cwd's latest session — the harness's — and would + // consume a trailing value as the first PROMPT; choom never uses + // it.) The value is single-quoted into the shell script, so reject + // an embedded single quote (the only char that breaks + // single-quoting) to rule out injection — session ids never + // contain one. + let session_arg = match resume_session { + Some(val) => { + if val.contains('\'') { + bail!("invalid --resume value '{val}': must not contain a single quote"); + } + format!("set -- --resume '{val}';") + } + None => "set --;".to_string(), + }; + // Build claude's argv as the harness does, each flag included only + // when its file is present so a half-up container degrades to a bare + // session. `name` is `[a-z0-9._-]` so there's nothing to quote. + let inner = format!( + "cd {state_dir} || exit 1; {session_arg} \ + [ -f {cfg}/claude-settings.json ] && set -- \"$@\" --settings {cfg}/claude-settings.json; \ + [ -f {cfg}/claude-mcp-config.json ] && set -- \"$@\" --mcp-config {cfg}/claude-mcp-config.json; \ + [ -f {cfg}/claude-system-prompt.md ] && set -- \"$@\" --system-prompt-file {cfg}/claude-system-prompt.md; \ + exec {claude} \"$@\"" + ); + let mut cmd = std::process::Command::new("machinectl"); + cmd.arg("shell") + .arg(&target) + .arg("/bin/sh") + .arg("-lc") + .arg(&inner); + // exec() replaces the current process — we inherit stdin/stdout/stderr + // (the caller's PTY) so the Claude session is fully interactive. + // This call only returns on error. + let err = cmd.exec(); + Err(anyhow::anyhow!("exec machinectl: {err}")) +} diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs new file mode 100644 index 00000000..7d87aa13 --- /dev/null +++ b/hivectl/src/cli.rs @@ -0,0 +1,591 @@ +//! hivectl clap command tree: the `Cli` root, the top-level `Cmd` +//! verb enum, and every subcommand enum + shared args struct. + +use clap::{Args, Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command( + name = "hivectl", + about = "hyperhive host CLI — operator-facing administration", + long_about = "\ +Sibling to the `hive-c0re` daemon binary. Covers host-side admin \ +operations that don't go through the broker — manual user \ +provisioning on the bundled forge + matrix containers, plus future \ +recovery / debugging verbs.\ +" +)] +pub struct Cli { + /// Path to the hive-c0re host admin socket, used by the daemon-assisted + /// verbs (`agents`, `stop`, `start`). Global: accepted before or after + /// the subcommand. Verbs that don't talk to the daemon ignore it. + #[arg(long, global = true, default_value = DEFAULT_HOST_SOCKET)] + pub(crate) socket: PathBuf, + #[command(subcommand)] + pub(crate) cmd: Cmd, +} + +#[derive(Subcommand)] +pub enum Cmd { + /// Forgejo user provisioning. + /// + /// Manual entry point to the same idempotent provisioning c0re runs at + /// boot — for recovery, ad-hoc reprovisioning, or fixing one agent + /// without bouncing the daemon. + Forge { + #[command(subcommand)] + cmd: ForgeCmd, + }, + /// matrix-tuwunel user provisioning. + /// + /// Manual entry point to the same idempotent provisioning c0re runs at + /// boot — for re-registering an agent the boot sweep skipped, or after + /// wiping a token file. + Matrix { + #[command(subcommand)] + cmd: MatrixCmd, + }, + /// GitHub account provisioning. + /// + /// Store an operator-supplied personal access token (PAT) for an agent + /// so its `gh` and git can authenticate. No account is created — the + /// PAT is for an existing GitHub account. + Github { + #[command(subcommand)] + cmd: GithubCmd, + }, + /// Gateway htpasswd user management. + /// + /// Add, remove, or list users for the gateway's HTTP Basic auth. + Gateway { + #[command(subcommand)] + cmd: GatewayCmd, + }, + /// Agent container management. + /// + /// Lifecycle actions on managed agent containers. Needs the hive-c0re + /// daemon running. + Agents { + #[command(subcommand)] + cmd: AgentsCmd, + }, + /// Operator approval queue: list, approve, or deny pending requests. + /// + /// Needs the hive-c0re daemon running. + Approvals { + #[command(subcommand)] + cmd: ApprovalsCmd, + }, + /// WireGuard inter-hive mesh setup helpers. + /// + /// Generate this hive's mesh key and print the nix to enable the mesh, + /// add a peer, or inspect live interface state. + Wg { + #[command(subcommand)] + cmd: WgCmd, + }, + /// Generate the federation peer-config block for THIS hive. + /// + /// Prints the nix a peer operator pastes into their swarm config to + /// trust and reach this hive. + PeerConfig { + /// This hive's WireGuard mesh address (e.g. `10.42.0.1/32`), + /// emitted as `wireguardAddress`. Omit when not running the mesh. + #[arg(long)] + wg_address: Option, + /// This hive's public WireGuard endpoint (`host:port`), emitted as + /// `wireguardEndpoint`. Omit when peers dial in / no mesh. + #[arg(long)] + wg_endpoint: Option, + }, + /// Open an interactive Claude session inside an agent container. + /// + /// A fresh session by default, or resume a prior one. Requires root + /// and a running container. + Choom { + /// Agent name (e.g. `damocles`, `iris`). + name: String, + /// Resume a prior claude session by its session id, passed + /// through as `claude --resume ` (claude's `--continue` + /// takes no value — it resumes the cwd's latest session, which + /// is the harness's, so choom never uses it; this flag matches + /// the claude flag it maps to). Omit for a fresh blank session. + /// A value is required when the flag is given. + #[arg(long = "resume", value_name = "SESSION")] + resume_session: Option, + }, + /// Stop containers hive-wide in one operator action. + /// + /// Bare `hivectl stop` stops everything; scope flags narrow it to + /// specific sub-agents or infra containers. + Stop { + #[command(flatten)] + scope: ScopeArgs, + /// Gracefully quiesce each agent before stopping, instead of a + /// hard stop. Each agent gets a graceful-stop DAG on the job + /// queue: the harness is signalled, runs one stop-checkpoint + /// turn to flush durable `/state`, drains, then the container + /// is stopped (bounded by a 3-min timeout that falls back to a + /// hard stop). All drains overlap. Applies to agents only. + #[arg(long)] + graceful: bool, + /// Return immediately after the stop DAGs are queued instead of + /// waiting for them with live per-node progress. + #[arg(long)] + no_wait: bool, + }, + /// Start containers hive-wide — the inverse of `hivectl stop`. + /// + /// Bare `hivectl start` restores the agents stopped by the last + /// broad-scope `stop` (or starts everything if none); scope flags + /// narrow it. + Start { + #[command(flatten)] + scope: ScopeArgs, + /// Return immediately after the start DAGs are queued instead + /// of waiting for them with live per-node progress. + #[arg(long)] + no_wait: bool, + }, + /// Restart containers hive-wide — `stop` then `start` over one scope. + /// + /// Bare `hivectl restart` restarts everything; scope flags narrow it. + Restart { + #[command(flatten)] + scope: ScopeArgs, + /// Gracefully quiesce each agent on the stop half (see + /// `stop --graceful`). Applies to agents only. + #[arg(long)] + graceful: bool, + }, + /// Per-agent disk accounting + optional quotas via btrfs qgroups. + /// + /// Opt-in: enable qgroup accounting, then report per-agent usage or + /// cap an agent. No-op on non-btrfs hosts. + Quota { + #[command(subcommand)] + cmd: QuotaCmd, + }, + /// btrfs subvolume management for agent state dirs. + /// + /// Upgrade an existing plain-dir agent's state into a btrfs subvolume + /// so it gains snapshots and per-subvol usage/quota. + Subvol { + #[command(subcommand)] + cmd: SubvolCmd, + }, + /// Print (and best-effort open in a browser) a hive web surface URL. + /// + /// Resolves the URL from the running daemon so custom forge / matrix + /// domains work. Bare `hivectl open` opens the operator dashboard. + Open { + /// Which surface to open. Defaults to the operator dashboard. + #[arg(value_enum, default_value_t = OpenTarget::Home)] + target: OpenTarget, + }, + /// Emit the full CLI reference as `CommonMark` to stdout. + /// + /// Hidden tooling command used by the docs build to keep the published + /// `hivectl` reference in lockstep with the code. + #[command(hide = true)] + MarkdownDocs, + /// Generate a shell completion script for `hivectl` and print it to + /// stdout. + /// + /// Supports bash, zsh, fish, elvish, and powershell. The NixOS module + /// already installs the zsh script system-wide; this is for ad-hoc or + /// other-shell use. + Completions { + /// Shell to emit completions for. + shell: clap_complete::Shell, + }, +} + +/// Which hive web surface `hivectl open` targets. +#[derive(Copy, Clone, Debug, clap::ValueEnum)] +pub enum OpenTarget { + /// The operator dashboard (`https:///`). + Home, + /// The forge (Forgejo) web UI. + Forge, + /// The matrix GUI (fluffychat). + Matrix, +} + +/// Shared scope flags for `hivectl stop` / `hivectl start`. With no flag +/// set the verb targets **everything** (all sub-agents + every controllable +/// infra container). Setting any flag restricts to the selected classes, +/// additively. +// One bool per selectable container class, each mapping 1:1 to a clap flag; +// orthogonal toggles, not a state machine — hence the bools allow (mirrors +// `hive_host_sock::LifecycleScope`). +#[allow(clippy::struct_excessive_bools)] +#[derive(Args)] +pub struct ScopeArgs { + /// All sub-agent containers. + #[arg(long)] + agents: bool, + /// A specific sub-agent by name. Repeatable: `--agent a --agent b`. + #[arg(long = "agent", value_name = "NAME")] + agent: Vec, + /// The CI runner container (`hive-ci`). + #[arg(long)] + ci: bool, + /// The forge container (`hive-forge`). + #[arg(long)] + forge: bool, + /// The gateway container (`hive-gateway`). + #[arg(long)] + gateway: bool, + /// The matrix container (`hive-matrix`). + #[arg(long)] + matrix: bool, +} + +impl ScopeArgs { + pub(crate) fn to_scope(&self) -> hive_host_sock::LifecycleScope { + hive_host_sock::LifecycleScope { + agents: self.agents, + agent_names: self.agent.clone(), + ci: self.ci, + forge: self.forge, + gateway: self.gateway, + matrix: self.matrix, + } + } +} + +#[derive(Subcommand)] +pub enum ForgeCmd { + /// Create or refresh the Forgejo account + token for ``. + /// + /// For an existing agent, persists the token to its state dir; for a + /// human/other account, prints the token to stdout. Set a password to + /// enable forge web-UI login (a random throwaway is used otherwise). + CreateUser { + /// Forgejo username. For agents: the container/agent name + /// (`` in `h-`; manager uses the literal `manager`). + /// For humans: any forgejo username — `mara`, `damocles`, etc. + name: String, + /// Set the account password to this string instead of a random + /// throwaway. Use this for operator accounts that need to log + /// into the forge web UI. Mutually exclusive with + /// `--password-stdin`. WARNING: the password is visible in + /// shell history + process listings; prefer `--password-stdin` + /// for anything sensitive. + #[arg(long)] + password: Option, + /// Read the password from stdin (single line, trailing newline + /// stripped) instead of an inline flag. Mutually exclusive with + /// `--password`. + #[arg(long, conflicts_with = "password")] + password_stdin: bool, + }, +} + +#[derive(Subcommand)] +pub enum MatrixCmd { + /// Create or refresh the matrix account + access token for ``. + /// + /// For an existing agent, persists the token to its state dir; for a + /// human/other account, prints the access token to stdout. Set a + /// password to enable matrix web-client login (a random throwaway is + /// used otherwise). + CreateUser { + /// Matrix localpart. For agents: the container/agent name. + /// For humans: any matrix localpart — `mara`, `damocles`, etc. + name: String, + /// Set the account password to this string instead of a random + /// throwaway. Use this for operator accounts that need to log + /// into matrix web clients via `m.login.password`. Mutually + /// exclusive with `--password-stdin`. WARNING: the + /// password is visible in shell history + process listings; + /// prefer `--password-stdin` for anything sensitive. + #[arg(long)] + password: Option, + /// Read the password from stdin (single line, trailing newline + /// stripped) instead of an inline flag. Mutually exclusive with + /// `--password`. + #[arg(long, conflicts_with = "password")] + password_stdin: bool, + }, + /// Provision (or re-provision) the hive system admin matrix account. + /// + /// Runs automatically on startup; run manually to recover a missing + /// admin token. + SyncAdmin, + /// Promote a matrix user to homeserver admin. + PromoteUser { + /// Matrix localpart of the user to promote (e.g. `argus`). + name: String, + }, + /// Reset a matrix user's password via the admin API. + /// + /// Persists the new password so a later `create-user` can re-login. + ResetPassword { + /// Matrix localpart of the account to reset (e.g. `argus`). + name: String, + }, + /// Invite a matrix user to the hive Space, or a specific room with + /// `--room`. Idempotent. + Invite { + /// User to invite: a full id (`@mara:server`) or a bare + /// localpart (qualified with the homeserver's `server_name`). + user: String, + /// Target room id (`!abc:server`) or alias (`#name:server`). + /// Omit to invite to the hive Space. + #[arg(long)] + room: Option, + }, +} + +#[derive(Subcommand)] +pub enum GithubCmd { + /// Store a GitHub PAT for `` so its `gh` and git can + /// authenticate. + /// + /// Prefer `--token-stdin` — an inline `--token` is visible in shell + /// history. + SetToken { + /// Logical agent name (the container/agent name). + agent: String, + /// The PAT value inline. Mutually exclusive with `--token-stdin`. + #[arg(long)] + token: Option, + /// Read the PAT from stdin (trailing newline stripped). Mutually + /// exclusive with `--token`. + #[arg(long, conflicts_with = "token")] + token_stdin: bool, + }, +} + +#[derive(Subcommand)] +pub enum GatewayCmd { + /// Add a user or update an existing user's password in the gateway + /// htpasswd. + /// + /// Use `--password-stdin` to keep the password out of shell history. + CreateUser { + /// Username to add or update. + username: String, + /// Set the password inline. WARNING: visible in shell history and + /// process listings — prefer `--password-stdin` for sensitive input. + /// Mutually exclusive with `--password-stdin`. + #[arg(long, conflicts_with = "password_stdin")] + password: Option, + /// Read the password from stdin (single line, trailing newline + /// stripped). Mutually exclusive with `--password`. + #[arg(long)] + password_stdin: bool, + }, + /// Remove a user from the gateway htpasswd. + DeleteUser { + /// Username to remove. + username: String, + }, + /// List all gateway htpasswd usernames, one per line. + ListUsers, +} + +#[derive(Subcommand)] +pub enum WgCmd { + /// Generate this hive's WireGuard key (if absent) and print its public + /// key plus the nix to enable the mesh. + Init { + /// This hive's mesh address (e.g. `10.42.0.1/32`) to bake into the + /// printed snippet. Omit to get a placeholder you fill in. + #[arg(long)] + address: Option, + }, + /// Print the nix to add a peer hive to the mesh. + Peer { + /// Peer hive's DNS domain (the `swarm.peers` attrset key). + domain: String, + /// Peer's WireGuard public key (from its `hivectl wg init`). + #[arg(long)] + pubkey: String, + /// Peer's mesh address (e.g. `10.42.0.2/32`). + #[arg(long)] + address: String, + /// Peer's `host:port` endpoint (omit for a peer that only dials out, + /// e.g. one behind NAT — it must set an endpoint pointing back here). + #[arg(long)] + endpoint: Option, + }, + /// Show the live mesh interface state. + Status, +} + +#[derive(Subcommand)] +pub enum QuotaCmd { + /// Enable btrfs qgroup accounting on the agent-state filesystem. + /// + /// Run once before `show` / `limit`. No-op on non-btrfs hosts. + Enable, + /// Report per-agent disk usage from btrfs qgroups (all agents, or one + /// by name). + Show { + /// Agent to show (omit for all agents with a state subvolume). + name: Option, + }, + /// Set or clear an agent's disk-usage quota. + Limit { + /// Agent whose state subvolume to limit. + name: String, + /// Size cap (`5G`, `500M`, `1073741824`) or `none` to clear. + size: String, + }, +} + +// Default host admin socket path. Shared with `hive-c0re`'s `main.rs` +// default via `hive_host_sock::HOST_SOCKET` — the daemon binds there +// and `hivectl agents` connects to it. +pub(crate) use hive_host_sock::HOST_SOCKET as DEFAULT_HOST_SOCKET; + +#[derive(Subcommand)] +pub enum AgentsCmd { + /// Show all managed agents with their status and technical state. + List { + /// Emit the raw JSON rows instead of the padded table (for + /// scripting). The table is the default human-readable shape. + #[arg(long)] + json: bool, + }, + /// Stop and start a single agent container without rebuilding config. + Restart { + /// Agent name (e.g. `damocles`, `ruth`). + name: String, + /// Return immediately after the restart DAG is queued. + #[arg(long)] + no_wait: bool, + }, + /// Restart all managed agent containers. + RestartAll { + /// Return immediately after the restart DAGs are queued. + #[arg(long)] + no_wait: bool, + }, + /// Spawn a new agent container directly, bypassing the approval queue. + /// + /// Operator-on-the-host only; use `request-spawn` for an approval-gated + /// spawn. + Spawn { + /// Agent name (e.g. `iris`). + name: String, + }, + /// Queue a spawn request for operator approval. + RequestSpawn { + /// Agent name. + name: String, + }, + /// Stop a managed container (graceful). + Kill { + /// Agent name. + name: String, + }, + /// Tear down a sub-agent container, keeping its state by default. No + /// undo. + Destroy { + /// Agent name. + name: String, + /// Also wipe the agent's state dirs (config + creds + notes). + #[arg(long)] + purge: bool, + }, + /// Apply pending config to a managed container. + Rebuild { + /// Agent name. + name: String, + }, + /// Move an agent in the topology tree — under a new parent, or to root. + SetParent { + /// Agent to move. + child: String, + /// New parent agent name. Mutually exclusive with `--root`. + #[arg(long, conflicts_with = "root", required_unless_present = "root")] + parent: Option, + /// Promote `child` to root (no parent). + #[arg(long)] + root: bool, + }, +} + +/// Operator approval queue: list, approve, or deny pending requests. +#[derive(Subcommand)] +pub enum ApprovalsCmd { + /// List pending approval requests submitted by agents. + Pending, + /// Approve a pending request by id; the action runs immediately. + Approve { + /// Approval id (from `hivectl approvals pending`). + id: i64, + }, + /// Deny a pending request by id. + Deny { + /// Approval id. + id: i64, + }, +} + +#[derive(Subcommand)] +pub enum SubvolCmd { + /// Convert a plain-dir agent state root into a btrfs subvolume in + /// place, so it gains snapshots and per-subvol usage/quota. + /// + /// Bounces the agent to migrate its state, so it requires `--yes`. + Upgrade { + /// Agent name (e.g. `damocles`, `iris`). + name: String, + /// Confirm: this stops the agent, migrates its state dir, and + /// restarts it. Required — the command refuses without it. + #[arg(long)] + yes: bool, + }, + /// Read-only snapshots of an agent's state subvolume. + Snapshot { + #[command(subcommand)] + cmd: SnapshotCmd, + }, +} + +#[derive(Subcommand)] +pub enum SnapshotCmd { + /// Create a read-only snapshot (agent must already be a subvolume). + Create { + /// Agent name (e.g. `damocles`, `iris`). + name: String, + /// Snapshot label. Mandatory, and must start with `hive-` — the + /// prefix doubles as an allow-list hive-priv checks so only + /// hivectl-issued snapshot names can reach the `btrfs subvolume + /// snapshot` shellout. + #[arg(long)] + label: String, + }, + /// Delete a snapshot created by `subvol snapshot create`. + Delete { + /// Agent name the snapshot belongs to. + name: String, + /// Snapshot label passed to `subvol snapshot create --label`. + label: String, + }, + /// Export a snapshot to a local file via `btrfs send` (the local-file + /// half of inter-hive migration transport; the cross-hive `ssh ... + /// btrfs receive` leg isn't wired up yet). Also useful standalone as a + /// point-in-time backup: a full send with no `--parent` produces a + /// self-contained archive of the snapshot. + Send { + /// Agent name the snapshot belongs to. + name: String, + /// Snapshot label passed to `subvol snapshot create --label`. + label: String, + /// Optional parent snapshot label for an incremental send + /// (`btrfs send -p`) — must be an existing, older snapshot of the + /// same agent. Omit for a full send. + #[arg(long)] + parent: Option, + /// Destination filename (not a path) under the migrate-staging + /// dir. Refused if it already exists. + #[arg(long)] + dest: String, + }, +} diff --git a/hivectl/src/completions.rs b/hivectl/src/completions.rs new file mode 100644 index 00000000..45905652 --- /dev/null +++ b/hivectl/src/completions.rs @@ -0,0 +1,14 @@ +//! `hivectl completions ` — emit a shell completion script, +//! generated from the live clap command tree so it never drifts from the +//! actual verbs and flags. + +use crate::cli::Cli; + +/// Emit a shell completion script for `hivectl` to stdout. Walks the clap +/// command tree (the single source of truth — same tree `markdown-docs` +/// renders) so completions never drift from the actual verbs/flags. +pub(crate) fn generate_completions(shell: clap_complete::Shell) { + use clap::CommandFactory as _; + let mut cmd = Cli::command(); + clap_complete::generate(shell, &mut cmd, "hivectl", &mut std::io::stdout()); +} diff --git a/hivectl/src/forge.rs b/hivectl/src/forge.rs new file mode 100644 index 00000000..e16b82a0 --- /dev/null +++ b/hivectl/src/forge.rs @@ -0,0 +1,31 @@ +//! `hivectl forge create-user ` — provision a Forgejo account via the +//! daemon (which owns the forge admin token + account-token persistence); +//! hivectl just resolves the password client-side and relays the request. + +use std::path::Path; + +use anyhow::Result; + +use crate::util::{daemon_request, resolve_password}; + +pub(crate) async fn forge_create_user( + socket: &Path, + name: &str, + password: Option<&str>, + password_stdin: bool, +) -> Result<()> { + // Resolve the password client-side (inline flag or stdin read); the + // daemon never touches this process's stdin. The is-present check, the + // agent-vs-operator branch, and token persistence now live in the + // daemon handler. + let password = resolve_password(password, password_stdin)?; + daemon_request( + socket, + hive_host_sock::HostRequest::ForgeCreateUser { + name: name.to_owned(), + password, + }, + "forge", + ) + .await +} diff --git a/hivectl/src/gateway.rs b/hivectl/src/gateway.rs new file mode 100644 index 00000000..4a02be97 --- /dev/null +++ b/hivectl/src/gateway.rs @@ -0,0 +1,50 @@ +//! `hivectl gateway` — htpasswd user management for the gateway's HTTP +//! Basic auth. The daemon owns the bcrypt hash + htpasswd write (see the +//! `Gateway*User` `HostRequest`s); hivectl resolves the password +//! client-side and relays the request over the host admin socket. + +use std::path::Path; + +use anyhow::Result; + +use crate::util::{daemon_request, resolve_password}; + +pub(crate) async fn gateway_create_user( + socket: &Path, + username: &str, + password: Option<&str>, + password_stdin: bool, +) -> Result<()> { + let pw = resolve_password(password, password_stdin)?.ok_or_else(|| { + anyhow::anyhow!("a password is required — pass --password or --password-stdin") + })?; + daemon_request( + socket, + hive_host_sock::HostRequest::GatewayCreateUser { + username: username.to_owned(), + password: pw, + }, + "gateway", + ) + .await +} + +pub(crate) async fn gateway_delete_user(socket: &Path, username: &str) -> Result<()> { + daemon_request( + socket, + hive_host_sock::HostRequest::GatewayDeleteUser { + username: username.to_owned(), + }, + "gateway", + ) + .await +} + +pub(crate) async fn gateway_list_users(socket: &Path) -> Result<()> { + daemon_request( + socket, + hive_host_sock::HostRequest::GatewayListUsers, + "gateway", + ) + .await +} diff --git a/hivectl/src/github.rs b/hivectl/src/github.rs new file mode 100644 index 00000000..ef296b1b --- /dev/null +++ b/hivectl/src/github.rs @@ -0,0 +1,48 @@ +//! `hivectl github set-token ` — write an operator-supplied GitHub PAT +//! into an agent's `github-token` state file via the daemon's privileged +//! helper, so the agent's `gh` wrapper + git credential helper authenticate. + +use std::path::Path; + +use anyhow::{Result, bail}; + +use crate::util::daemon_request; + +/// `hivectl github set-token `: write an operator-supplied GitHub PAT +/// into the agent's `github-token` state file (0600, agent-owned) via +/// hive-priv, so the agent's `gh` wrapper + git credential helper can +/// authenticate. Read live at invocation, so no rebuild/restart is needed. +pub(crate) async fn github_set_token( + socket: &Path, + agent: &str, + token: Option, + token_stdin: bool, +) -> Result<()> { + // Resolve + validate the token client-side (inline flag or stdin read); + // the daemon never touches this process's stdin. Persistence happens + // daemon-side via the privileged helper. + let token = match (token, token_stdin) { + (Some(t), _) => t, + (None, true) => { + let mut s = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; + s.trim_end_matches(['\n', '\r']).to_owned() + } + (None, false) => bail!( + "provide the PAT via --token or --token-stdin (stdin preferred — \ + an inline token is visible in shell history + process listings)" + ), + }; + if token.is_empty() { + bail!("refusing to write an empty GitHub token for agent '{agent}'"); + } + daemon_request( + socket, + hive_host_sock::HostRequest::SetAgentGithubToken { + agent: agent.to_owned(), + token, + }, + "github", + ) + .await +} diff --git a/hivectl/src/main.rs b/hivectl/src/main.rs index 74ed07bb..b23bf3b4 100644 --- a/hivectl/src/main.rs +++ b/hivectl/src/main.rs @@ -1,620 +1,56 @@ //! `hivectl` — operator-facing host CLI for hyperhive. //! -//! Sibling binary to the `hive-c0re` daemon. Where `hive-c0re`'s -//! subcommands focus on the broker / approval / topology surface -//! (`spawn`, `kill`, `rebuild`, `approve` …), `hivectl` covers -//! host-side administration — both manual provisioning operations -//! that work without the daemon running (forge, matrix, gateway) AND -//! daemon-assisted agent management (agents restart/restart-all) that -//! goes through the host admin socket. +//! A thin client for the `hive-c0re` daemon: it speaks the host admin +//! socket protocol (`hive-host-sock`) and does NOT link the daemon crate. +//! Container lifecycle + the approval queue (`agents `, `approvals `, `stop` / `start`) and +//! provisioning (`forge` / `matrix` / `github` / `gateway`) all forward to +//! the daemon, which owns the broker, the credentials, and the provisioning +//! logic — a running daemon is required for those. A few verbs work off +//! local host state directly instead (`wg` / `peer-config` read the mesh key +//! + TLS CA; `choom` execs into a container), so they don't need the socket. //! -//! Verbs read configuration off the same on-disk paths c0re uses -//! (`/var/lib/hyperhive/forge-core-token`, -//! `/var/lib/hyperhive/matrix-register-token`, per-agent state -//! dirs) and reuse the `forge` / `matrix` modules from the -//! `hive-c0re` lib — single source of truth, no duplication. +//! One module per subcommand family (see the `mod` list below); `main` is +//! just the clap parse + the top-level dispatch match. -#[cfg(unix)] -use std::os::unix::process::CommandExt as _; -use std::path::{Path, PathBuf}; - -use anyhow::{Context as _, Result, bail}; -use clap::{Args, Parser, Subcommand}; -use hive_host_sock::HostRequest; +use anyhow::Result; +use clap::Parser; +mod cli; /// The host admin socket client (`request`), split out so it lives with /// hivectl rather than in the daemon crate. mod client; /// Rebuild-queue DAG progress rendering (`wait_for_dags` + the spinner / /// plain renderers), split out to keep this file manageable. mod dag_progress; -use dag_progress::wait_for_dags; - -#[derive(Parser)] -#[command( - name = "hivectl", - about = "hyperhive host CLI — operator-facing administration", - long_about = "\ -Sibling to the `hive-c0re` daemon binary. Covers host-side admin \ -operations that don't go through the broker — manual user \ -provisioning on the bundled forge + matrix containers, plus future \ -recovery / debugging verbs.\ -" -)] -struct Cli { - /// Path to the hive-c0re host admin socket, used by the daemon-assisted - /// verbs (`agents`, `stop`, `start`). Global: accepted before or after - /// the subcommand. Verbs that don't talk to the daemon ignore it. - #[arg(long, global = true, default_value = DEFAULT_HOST_SOCKET)] - socket: PathBuf, - #[command(subcommand)] - cmd: Cmd, -} - -#[derive(Subcommand)] -enum Cmd { - /// Forgejo user provisioning. - /// - /// Manual entry point to the same idempotent provisioning c0re runs at - /// boot — for recovery, ad-hoc reprovisioning, or fixing one agent - /// without bouncing the daemon. - Forge { - #[command(subcommand)] - cmd: ForgeCmd, - }, - /// matrix-tuwunel user provisioning. - /// - /// Manual entry point to the same idempotent provisioning c0re runs at - /// boot — for re-registering an agent the boot sweep skipped, or after - /// wiping a token file. - Matrix { - #[command(subcommand)] - cmd: MatrixCmd, - }, - /// GitHub account provisioning. - /// - /// Store an operator-supplied personal access token (PAT) for an agent - /// so its `gh` and git can authenticate. No account is created — the - /// PAT is for an existing GitHub account. - Github { - #[command(subcommand)] - cmd: GithubCmd, - }, - /// Gateway htpasswd user management. - /// - /// Add, remove, or list users for the gateway's HTTP Basic auth. - Gateway { - #[command(subcommand)] - cmd: GatewayCmd, - }, - /// Agent container management. - /// - /// Lifecycle actions on managed agent containers. Needs the hive-c0re - /// daemon running. - Agents { - #[command(subcommand)] - cmd: AgentsCmd, - }, - /// Operator approval queue: list, approve, or deny pending requests. - /// - /// Needs the hive-c0re daemon running. - Approvals { - #[command(subcommand)] - cmd: ApprovalsCmd, - }, - /// WireGuard inter-hive mesh setup helpers. - /// - /// Generate this hive's mesh key and print the nix to enable the mesh, - /// add a peer, or inspect live interface state. - Wg { - #[command(subcommand)] - cmd: WgCmd, - }, - /// Generate the federation peer-config block for THIS hive. - /// - /// Prints the nix a peer operator pastes into their swarm config to - /// trust and reach this hive. - PeerConfig { - /// This hive's WireGuard mesh address (e.g. `10.42.0.1/32`), - /// emitted as `wireguardAddress`. Omit when not running the mesh. - #[arg(long)] - wg_address: Option, - /// This hive's public WireGuard endpoint (`host:port`), emitted as - /// `wireguardEndpoint`. Omit when peers dial in / no mesh. - #[arg(long)] - wg_endpoint: Option, - }, - /// Open an interactive Claude session inside an agent container. - /// - /// A fresh session by default, or resume a prior one. Requires root - /// and a running container. - Choom { - /// Agent name (e.g. `damocles`, `iris`). - name: String, - /// Resume a prior claude session by its session id, passed - /// through as `claude --resume ` (claude's `--continue` - /// takes no value — it resumes the cwd's latest session, which - /// is the harness's, so choom never uses it; this flag matches - /// the claude flag it maps to). Omit for a fresh blank session. - /// A value is required when the flag is given. - #[arg(long = "resume", value_name = "SESSION")] - resume_session: Option, - }, - /// Stop containers hive-wide in one operator action. - /// - /// Bare `hivectl stop` stops everything; scope flags narrow it to - /// specific sub-agents or infra containers. - Stop { - #[command(flatten)] - scope: ScopeArgs, - /// Gracefully quiesce each agent before stopping, instead of a - /// hard stop. Each agent gets a graceful-stop DAG on the job - /// queue: the harness is signalled, runs one stop-checkpoint - /// turn to flush durable `/state`, drains, then the container - /// is stopped (bounded by a 3-min timeout that falls back to a - /// hard stop). All drains overlap. Applies to agents only. - #[arg(long)] - graceful: bool, - /// Return immediately after the stop DAGs are queued instead of - /// waiting for them with live per-node progress. - #[arg(long)] - no_wait: bool, - }, - /// Start containers hive-wide — the inverse of `hivectl stop`. - /// - /// Bare `hivectl start` restores the agents stopped by the last - /// broad-scope `stop` (or starts everything if none); scope flags - /// narrow it. - Start { - #[command(flatten)] - scope: ScopeArgs, - /// Return immediately after the start DAGs are queued instead - /// of waiting for them with live per-node progress. - #[arg(long)] - no_wait: bool, - }, - /// Restart containers hive-wide — `stop` then `start` over one scope. - /// - /// Bare `hivectl restart` restarts everything; scope flags narrow it. - Restart { - #[command(flatten)] - scope: ScopeArgs, - /// Gracefully quiesce each agent on the stop half (see - /// `stop --graceful`). Applies to agents only. - #[arg(long)] - graceful: bool, - }, - /// Per-agent disk accounting + optional quotas via btrfs qgroups. - /// - /// Opt-in: enable qgroup accounting, then report per-agent usage or - /// cap an agent. No-op on non-btrfs hosts. - Quota { - #[command(subcommand)] - cmd: QuotaCmd, - }, - /// btrfs subvolume management for agent state dirs. - /// - /// Upgrade an existing plain-dir agent's state into a btrfs subvolume - /// so it gains snapshots and per-subvol usage/quota. - Subvol { - #[command(subcommand)] - cmd: SubvolCmd, - }, - /// Print (and best-effort open in a browser) a hive web surface URL. - /// - /// Resolves the URL from the running daemon so custom forge / matrix - /// domains work. Bare `hivectl open` opens the operator dashboard. - Open { - /// Which surface to open. Defaults to the operator dashboard. - #[arg(value_enum, default_value_t = OpenTarget::Home)] - target: OpenTarget, - }, - /// Emit the full CLI reference as `CommonMark` to stdout. - /// - /// Hidden tooling command used by the docs build to keep the published - /// `hivectl` reference in lockstep with the code. - #[command(hide = true)] - MarkdownDocs, - /// Generate a shell completion script for `hivectl` and print it to - /// stdout. - /// - /// Supports bash, zsh, fish, elvish, and powershell. The NixOS module - /// already installs the zsh script system-wide; this is for ad-hoc or - /// other-shell use. - Completions { - /// Shell to emit completions for. - shell: clap_complete::Shell, - }, -} - -/// Which hive web surface `hivectl open` targets. -#[derive(Copy, Clone, Debug, clap::ValueEnum)] -enum OpenTarget { - /// The operator dashboard (`https:///`). - Home, - /// The forge (Forgejo) web UI. - Forge, - /// The matrix GUI (fluffychat). - Matrix, -} - -/// Shared scope flags for `hivectl stop` / `hivectl start`. With no flag -/// set the verb targets **everything** (all sub-agents + every controllable -/// infra container). Setting any flag restricts to the selected classes, -/// additively. -// One bool per selectable container class, each mapping 1:1 to a clap flag; -// orthogonal toggles, not a state machine — hence the bools allow (mirrors -// `hive_host_sock::LifecycleScope`). -#[allow(clippy::struct_excessive_bools)] -#[derive(Args)] -struct ScopeArgs { - /// All sub-agent containers. - #[arg(long)] - agents: bool, - /// A specific sub-agent by name. Repeatable: `--agent a --agent b`. - #[arg(long = "agent", value_name = "NAME")] - agent: Vec, - /// The CI runner container (`hive-ci`). - #[arg(long)] - ci: bool, - /// The forge container (`hive-forge`). - #[arg(long)] - forge: bool, - /// The gateway container (`hive-gateway`). - #[arg(long)] - gateway: bool, - /// The matrix container (`hive-matrix`). - #[arg(long)] - matrix: bool, -} - -impl ScopeArgs { - fn to_scope(&self) -> hive_host_sock::LifecycleScope { - hive_host_sock::LifecycleScope { - agents: self.agents, - agent_names: self.agent.clone(), - ci: self.ci, - forge: self.forge, - gateway: self.gateway, - matrix: self.matrix, - } - } -} - -#[derive(Subcommand)] -enum ForgeCmd { - /// Create or refresh the Forgejo account + token for ``. - /// - /// For an existing agent, persists the token to its state dir; for a - /// human/other account, prints the token to stdout. Set a password to - /// enable forge web-UI login (a random throwaway is used otherwise). - CreateUser { - /// Forgejo username. For agents: the container/agent name - /// (`` in `h-`; manager uses the literal `manager`). - /// For humans: any forgejo username — `mara`, `damocles`, etc. - name: String, - /// Set the account password to this string instead of a random - /// throwaway. Use this for operator accounts that need to log - /// into the forge web UI. Mutually exclusive with - /// `--password-stdin`. WARNING: the password is visible in - /// shell history + process listings; prefer `--password-stdin` - /// for anything sensitive. - #[arg(long)] - password: Option, - /// Read the password from stdin (single line, trailing newline - /// stripped) instead of an inline flag. Mutually exclusive with - /// `--password`. - #[arg(long, conflicts_with = "password")] - password_stdin: bool, - }, -} - -#[derive(Subcommand)] -enum MatrixCmd { - /// Create or refresh the matrix account + access token for ``. - /// - /// For an existing agent, persists the token to its state dir; for a - /// human/other account, prints the access token to stdout. Set a - /// password to enable matrix web-client login (a random throwaway is - /// used otherwise). - CreateUser { - /// Matrix localpart. For agents: the container/agent name. - /// For humans: any matrix localpart — `mara`, `damocles`, etc. - name: String, - /// Set the account password to this string instead of a random - /// throwaway. Use this for operator accounts that need to log - /// into matrix web clients via `m.login.password`. Mutually - /// exclusive with `--password-stdin`. WARNING: the - /// password is visible in shell history + process listings; - /// prefer `--password-stdin` for anything sensitive. - #[arg(long)] - password: Option, - /// Read the password from stdin (single line, trailing newline - /// stripped) instead of an inline flag. Mutually exclusive with - /// `--password`. - #[arg(long, conflicts_with = "password")] - password_stdin: bool, - }, - /// Provision (or re-provision) the hive system admin matrix account. - /// - /// Runs automatically on startup; run manually to recover a missing - /// admin token. - SyncAdmin, - /// Promote a matrix user to homeserver admin. - PromoteUser { - /// Matrix localpart of the user to promote (e.g. `argus`). - name: String, - }, - /// Reset a matrix user's password via the admin API. - /// - /// Persists the new password so a later `create-user` can re-login. - ResetPassword { - /// Matrix localpart of the account to reset (e.g. `argus`). - name: String, - }, - /// Invite a matrix user to the hive Space, or a specific room with - /// `--room`. Idempotent. - Invite { - /// User to invite: a full id (`@mara:server`) or a bare - /// localpart (qualified with the homeserver's `server_name`). - user: String, - /// Target room id (`!abc:server`) or alias (`#name:server`). - /// Omit to invite to the hive Space. - #[arg(long)] - room: Option, - }, -} - -#[derive(Subcommand)] -enum GithubCmd { - /// Store a GitHub PAT for `` so its `gh` and git can - /// authenticate. - /// - /// Prefer `--token-stdin` — an inline `--token` is visible in shell - /// history. - SetToken { - /// Logical agent name (the container/agent name). - agent: String, - /// The PAT value inline. Mutually exclusive with `--token-stdin`. - #[arg(long)] - token: Option, - /// Read the PAT from stdin (trailing newline stripped). Mutually - /// exclusive with `--token`. - #[arg(long, conflicts_with = "token")] - token_stdin: bool, - }, -} - -#[derive(Subcommand)] -enum GatewayCmd { - /// Add a user or update an existing user's password in the gateway - /// htpasswd. - /// - /// Use `--password-stdin` to keep the password out of shell history. - CreateUser { - /// Username to add or update. - username: String, - /// Set the password inline. WARNING: visible in shell history and - /// process listings — prefer `--password-stdin` for sensitive input. - /// Mutually exclusive with `--password-stdin`. - #[arg(long, conflicts_with = "password_stdin")] - password: Option, - /// Read the password from stdin (single line, trailing newline - /// stripped). Mutually exclusive with `--password`. - #[arg(long)] - password_stdin: bool, - }, - /// Remove a user from the gateway htpasswd. - DeleteUser { - /// Username to remove. - username: String, - }, - /// List all gateway htpasswd usernames, one per line. - ListUsers, -} - -#[derive(Subcommand)] -enum WgCmd { - /// Generate this hive's WireGuard key (if absent) and print its public - /// key plus the nix to enable the mesh. - Init { - /// This hive's mesh address (e.g. `10.42.0.1/32`) to bake into the - /// printed snippet. Omit to get a placeholder you fill in. - #[arg(long)] - address: Option, - }, - /// Print the nix to add a peer hive to the mesh. - Peer { - /// Peer hive's DNS domain (the `swarm.peers` attrset key). - domain: String, - /// Peer's WireGuard public key (from its `hivectl wg init`). - #[arg(long)] - pubkey: String, - /// Peer's mesh address (e.g. `10.42.0.2/32`). - #[arg(long)] - address: String, - /// Peer's `host:port` endpoint (omit for a peer that only dials out, - /// e.g. one behind NAT — it must set an endpoint pointing back here). - #[arg(long)] - endpoint: Option, - }, - /// Show the live mesh interface state. - Status, -} - -#[derive(Subcommand)] -enum QuotaCmd { - /// Enable btrfs qgroup accounting on the agent-state filesystem. - /// - /// Run once before `show` / `limit`. No-op on non-btrfs hosts. - Enable, - /// Report per-agent disk usage from btrfs qgroups (all agents, or one - /// by name). - Show { - /// Agent to show (omit for all agents with a state subvolume). - name: Option, - }, - /// Set or clear an agent's disk-usage quota. - Limit { - /// Agent whose state subvolume to limit. - name: String, - /// Size cap (`5G`, `500M`, `1073741824`) or `none` to clear. - size: String, - }, -} - -// Default host admin socket path. Shared with `hive-c0re`'s `main.rs` -// default via `hive_host_sock::HOST_SOCKET` — the daemon binds there -// and `hivectl agents` connects to it. -use hive_host_sock::HOST_SOCKET as DEFAULT_HOST_SOCKET; - -#[derive(Subcommand)] -enum AgentsCmd { - /// Show all managed agents with their status and technical state. - List { - /// Emit the raw JSON rows instead of the padded table (for - /// scripting). The table is the default human-readable shape. - #[arg(long)] - json: bool, - }, - /// Stop and start a single agent container without rebuilding config. - Restart { - /// Agent name (e.g. `damocles`, `ruth`). - name: String, - /// Return immediately after the restart DAG is queued. - #[arg(long)] - no_wait: bool, - }, - /// Restart all managed agent containers. - RestartAll { - /// Return immediately after the restart DAGs are queued. - #[arg(long)] - no_wait: bool, - }, - /// Spawn a new agent container directly, bypassing the approval queue. - /// - /// Operator-on-the-host only; use `request-spawn` for an approval-gated - /// spawn. - Spawn { - /// Agent name (e.g. `iris`). - name: String, - }, - /// Queue a spawn request for operator approval. - RequestSpawn { - /// Agent name. - name: String, - }, - /// Stop a managed container (graceful). - Kill { - /// Agent name. - name: String, - }, - /// Tear down a sub-agent container, keeping its state by default. No - /// undo. - Destroy { - /// Agent name. - name: String, - /// Also wipe the agent's state dirs (config + creds + notes). - #[arg(long)] - purge: bool, - }, - /// Apply pending config to a managed container. - Rebuild { - /// Agent name. - name: String, - }, - /// Move an agent in the topology tree — under a new parent, or to root. - SetParent { - /// Agent to move. - child: String, - /// New parent agent name. Mutually exclusive with `--root`. - #[arg(long, conflicts_with = "root", required_unless_present = "root")] - parent: Option, - /// Promote `child` to root (no parent). - #[arg(long)] - root: bool, - }, -} - -/// Operator approval queue: list, approve, or deny pending requests. -#[derive(Subcommand)] -enum ApprovalsCmd { - /// List pending approval requests submitted by agents. - Pending, - /// Approve a pending request by id; the action runs immediately. - Approve { - /// Approval id (from `hivectl approvals pending`). - id: i64, - }, - /// Deny a pending request by id. - Deny { - /// Approval id. - id: i64, - }, -} - -#[derive(Subcommand)] -enum SubvolCmd { - /// Convert a plain-dir agent state root into a btrfs subvolume in - /// place, so it gains snapshots and per-subvol usage/quota. - /// - /// Bounces the agent to migrate its state, so it requires `--yes`. - Upgrade { - /// Agent name (e.g. `damocles`, `iris`). - name: String, - /// Confirm: this stops the agent, migrates its state dir, and - /// restarts it. Required — the command refuses without it. - #[arg(long)] - yes: bool, - }, - /// Read-only snapshots of an agent's state subvolume. - Snapshot { - #[command(subcommand)] - cmd: SnapshotCmd, - }, -} - -#[derive(Subcommand)] -enum SnapshotCmd { - /// Create a read-only snapshot (agent must already be a subvolume). - Create { - /// Agent name (e.g. `damocles`, `iris`). - name: String, - /// Snapshot label. Mandatory, and must start with `hive-` — the - /// prefix doubles as an allow-list hive-priv checks so only - /// hivectl-issued snapshot names can reach the `btrfs subvolume - /// snapshot` shellout. - #[arg(long)] - label: String, - }, - /// Delete a snapshot created by `subvol snapshot create`. - Delete { - /// Agent name the snapshot belongs to. - name: String, - /// Snapshot label passed to `subvol snapshot create --label`. - label: String, - }, - /// Export a snapshot to a local file via `btrfs send` (the local-file - /// half of inter-hive migration transport; the cross-hive `ssh ... - /// btrfs receive` leg isn't wired up yet). Also useful standalone as a - /// point-in-time backup: a full send with no `--parent` produces a - /// self-contained archive of the snapshot. - Send { - /// Agent name the snapshot belongs to. - name: String, - /// Snapshot label passed to `subvol snapshot create --label`. - label: String, - /// Optional parent snapshot label for an incremental send - /// (`btrfs send -p`) — must be an existing, older snapshot of the - /// same agent. Omit for a full send. - #[arg(long)] - parent: Option, - /// Destination filename (not a path) under the migrate-staging - /// dir. Refused if it already exists. - #[arg(long)] - dest: String, - }, -} +use cli::{Cli, Cmd, ForgeCmd, GatewayCmd, GithubCmd, QuotaCmd, WgCmd}; +mod quota; +mod util; +use quota::{quota_enable, quota_limit, quota_show}; +mod completions; +use completions::generate_completions; +mod gateway; +use gateway::{gateway_create_user, gateway_delete_user, gateway_list_users}; +mod matrix; +use matrix::run_matrix_cmd; +mod open; +use open::open_url; +mod wg; +use wg::{peer_config, require_hive_domain, wg_init, wg_peer, wg_status}; +mod choom; +use choom::choom; +mod github; +use github::github_set_token; +mod forge; +use forge::forge_create_user; +mod agents; +use agents::run_agents; +mod power; +use power::{restart, start, stop}; +mod subvol; +use subvol::dispatch_subvol; +mod approvals; +use approvals::run_approvals; #[tokio::main] async fn main() -> Result<()> { @@ -702,1168 +138,3 @@ async fn main() -> Result<()> { } } } - -/// Route a `matrix` subcommand to its handler. Extracted from `main`'s -/// dispatch match so the top-level router stays small. -async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> { - match cmd { - MatrixCmd::CreateUser { - name, - password, - password_stdin, - } => matrix_create_user(socket, &name, password.as_deref(), password_stdin).await, - MatrixCmd::SyncAdmin => matrix_sync_admin(socket).await, - MatrixCmd::PromoteUser { name } => matrix_promote_user(socket, &name).await, - MatrixCmd::ResetPassword { name } => matrix_reset_password(socket, &name).await, - MatrixCmd::Invite { user, room } => matrix_invite(socket, &user, room.as_deref()).await, - } -} - -/// `open ` — resolve the surface URL from the daemon, -/// print it, then best-effort `xdg-open` it. Printing is the reliable -/// core (headless / SSH hosts where no browser opener exists); the open -/// is convenience on top, so a missing/failed `xdg-open` is not an error. -async fn open_url(socket: &Path, target: OpenTarget) -> Result<()> { - let urls = query_hive_urls(socket).await.with_context(|| { - format!( - "could not reach the hive-c0re daemon for URLs — is hive-c0re running? \ - (the socket is at {DEFAULT_HOST_SOCKET})" - ) - })?; - let (url, hint) = match target { - OpenTarget::Home => ( - urls.home, - "the dashboard URL needs `services.hyperhive.domain` to be set", - ), - OpenTarget::Forge => ( - urls.forge, - "the public forge URL needs `services.hyperhive.forge.behindGateway = true`", - ), - OpenTarget::Matrix => ( - urls.matrix, - "the matrix GUI URL needs `services.hyperhive.matrix.gui.enable = true`", - ), - }; - let url = url.with_context(|| format!("no URL available for this surface — {hint}"))?; - println!("{url}"); - // Best-effort: many hosts are headless, so a missing opener or a - // non-zero exit is fine — the URL is already printed. - match std::process::Command::new("xdg-open").arg(&url).status() { - Ok(status) if status.success() => {} - Ok(status) => eprintln!("note: xdg-open exited with {status} (URL printed above)"), - Err(e) => eprintln!("note: could not run xdg-open ({e}) (URL printed above)"), - } - Ok(()) -} - -/// Emit a shell completion script for `hivectl` to stdout. Walks the clap -/// command tree (the single source of truth — same tree `markdown-docs` -/// renders) so completions never drift from the actual verbs/flags. -fn generate_completions(shell: clap_complete::Shell) { - use clap::CommandFactory as _; - let mut cmd = Cli::command(); - clap_complete::generate(shell, &mut cmd, "hivectl", &mut std::io::stdout()); -} - -/// Host path of this hive's WireGuard private key (matches the -/// `privateKeyFile` example in the swarm.wireguard nix options). -const WG_KEY_PATH: &str = "/etc/wireguard/hive.key"; -/// The mesh interface name hive-c0re's nix module brings up. -const WG_INTERFACE: &str = "wg-hive"; - -/// Host path of this hive's self-signed CA cert (matches the -/// `services.hyperhive.tls.stateDir` default in hive-tls.nix). Its -/// existence means the gateway serves a self-signed, hive-CA-signed leaf, -/// so a federating peer needs this CA via `swarm.peers..caCert`. Absent -/// = ACME / operator cert (trusted by the default CA bundle, no `caCert`). -const HIVE_TLS_CA_PATH: &str = "/var/lib/hive-tls/ca.pem"; - -/// Best-effort query for this hive's domain from the running daemon -/// (`HostRequest::Urls`, which reads `HYPERHIVE_HIVE_DOMAIN` from c0re's -/// service env). `None` when the daemon is unreachable or the domain is -/// unset — callers decide whether that's fatal. -async fn query_hive_domain(socket: &Path) -> Option { - query_hive_urls(socket).await.and_then(|u| u.domain) -} - -/// Best-effort query for this hive's domain + browser-facing web URLs -/// (`HostRequest::Urls`). `None` when the daemon is unreachable. -async fn query_hive_urls(socket: &Path) -> Option { - crate::client::request(socket, hive_host_sock::HostRequest::Urls) - .await - .ok() - .and_then(|r| r.urls) -} - -/// Require this hive's domain from the daemon for snippet generation. -/// Errors with a clear hint when it can't be resolved, so `peer-config` -/// never silently emits a wrong key. -async fn require_hive_domain(socket: &Path) -> Result { - query_hive_domain(socket).await.context( - "could not determine this hive's domain from the daemon — is hive-c0re running \ - and `services.hyperhive.domain` set?", - ) -} - -/// `wg init` — generate (if absent) the hive's WireGuard key, print its -/// public key + the nix snippet to enable the mesh, then (best-effort) -/// the `peer-config` block peers paste to federate with this hive, so a -/// fresh setup is one command. The domain comes from the daemon; if it -/// can't be resolved, the peer block is skipped (init still succeeds — -/// its core job is enabling the mesh locally). -async fn wg_init(socket: &Path, address: Option<&str>) -> Result<()> { - use std::os::unix::fs::PermissionsExt as _; - let key_path = Path::new(WG_KEY_PATH); - if key_path.exists() { - println!( - "WireGuard private key already present at {WG_KEY_PATH} — reusing (not regenerating)." - ); - } else { - if let Some(parent) = key_path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("create {}", parent.display()))?; - std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).ok(); - } - let out = std::process::Command::new("wg") - .arg("genkey") - .output() - .context("run `wg genkey` (is wireguard-tools installed?)")?; - if !out.status.success() { - bail!( - "wg genkey failed: {}", - String::from_utf8_lossy(&out.stderr).trim() - ); - } - std::fs::write(key_path, &out.stdout) - .with_context(|| format!("write {}", key_path.display()))?; - std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o400)) - .with_context(|| format!("chmod 400 {}", key_path.display()))?; - println!("Generated WireGuard private key at {WG_KEY_PATH} (0400)."); - } - - let privkey = - std::fs::read(key_path).with_context(|| format!("read {}", key_path.display()))?; - let pubkey = wg_pubkey(&privkey)?; - let addr = address.unwrap_or(""); - println!("\nPublic key (share this with peer hives — they pass it to `hivectl wg peer`):"); - println!(" {pubkey}"); - println!("\nAdd to this hive's NixOS config:"); - println!(" services.hyperhive.swarm.wireguard = {{"); - println!(" enable = true;"); - println!(" privateKeyFile = \"{WG_KEY_PATH}\";"); - println!(" address = \"{addr}\";"); - println!(" # listenPort = 51820; # default"); - println!(" }};"); - - // Also print the block a peer pastes to federate with us (CA + this - // mesh key) — one-stop setup. Domain comes from the daemon; - // best-effort, so init still succeeds when it can't be resolved. - if let Some(d) = query_hive_domain(socket).await { - println!(); - peer_config(&d, address, None); - } - Ok(()) -} - -/// Derive a WireGuard public key from a private key by piping it through -/// `wg pubkey`. -fn wg_pubkey(privkey: &[u8]) -> Result { - use std::io::Write as _; - use std::process::{Command, Stdio}; - let mut child = Command::new("wg") - .arg("pubkey") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("run `wg pubkey` (is wireguard-tools installed?)")?; - child - .stdin - .take() - .context("wg pubkey: stdin unavailable")? - .write_all(privkey) - .context("write private key to `wg pubkey`")?; - let out = child.wait_with_output().context("wait for `wg pubkey`")?; - if !out.status.success() { - bail!( - "wg pubkey failed: {}", - String::from_utf8_lossy(&out.stderr).trim() - ); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) -} - -/// `wg peer` — print the nix snippet to add a peer hive to the mesh. -/// Pure output (no fallible work), so it returns `()`; the dispatch arm -/// wraps it in `Ok` to match the sibling verbs. -fn wg_peer(domain: &str, pubkey: &str, address: &str, endpoint: Option<&str>) { - println!("Add to this hive's NixOS config:"); - println!(" services.hyperhive.swarm.peers.\"{domain}\" = {{"); - println!(" wireguardPublicKey = \"{pubkey}\";"); - println!(" wireguardAddress = \"{address}\";"); - if let Some(ep) = endpoint { - println!(" wireguardEndpoint = \"{ep}\";"); - } - println!(" }};"); -} - -/// `peer-config` — print the `swarm.peers.""` block a peer -/// operator pastes to federate with THIS hive, plus a `cp` line for the -/// CA when this hive is self-signed. Reads local state only (the TLS CA -/// cert presence + the wg key); prints, never mutates. -fn peer_config(domain: &str, wg_address: Option<&str>, wg_endpoint: Option<&str>) { - let self_signed = Path::new(HIVE_TLS_CA_PATH).exists(); - // CA filename derived from the first DNS label so multiple peers' - // certs don't collide in the operator's config dir. - let ca_file = format!("{}-ca.pem", domain.split('.').next().unwrap_or("peer")); - // WireGuard public key, when this hive has a mesh key. Best-effort: - // a missing key or absent `wg` binary just omits the mesh lines. - let wg_pub = std::fs::read(WG_KEY_PATH) - .ok() - .and_then(|k| wg_pubkey(&k).ok()); - - if self_signed { - println!("# 1. copy this hive's CA cert next to the peer's config:"); - println!("cp {HIVE_TLS_CA_PATH} ./{ca_file}"); - println!(); - println!("# 2. paste into the peer hive's NixOS config:"); - } else { - println!("# paste into the peer hive's NixOS config:"); - } - println!("services.hyperhive.swarm.peers.\"{domain}\" = {{"); - if self_signed { - println!(" caCert = ./{ca_file};"); - } - if let Some(pk) = &wg_pub { - println!(" wireguardPublicKey = \"{pk}\";"); - } - if let Some(addr) = wg_address { - println!(" wireguardAddress = \"{addr}\";"); - } - if let Some(ep) = wg_endpoint { - println!(" wireguardEndpoint = \"{ep}\";"); - } - println!("}};"); - if !self_signed { - println!( - "# (this hive's cert chains to a public CA — no `caCert` needed; \ - it's trusted by the default bundle.)" - ); - } -} - -/// `wg status` — show the live mesh interface (`wg show wg-hive`), -/// inheriting stdout so the operator sees it directly. -fn wg_status() -> Result<()> { - let status = std::process::Command::new("wg") - .args(["show", WG_INTERFACE]) - .status() - .context("run `wg show` (is wireguard-tools installed?)")?; - if !status.success() { - bail!( - "`wg show {WG_INTERFACE}` failed — is the mesh enabled + up? \ - (services.hyperhive.swarm.wireguard.enable = true, then deploy)" - ); - } - Ok(()) -} - -/// `quota enable` — turn on btrfs qgroup accounting (operator opt-in). -async fn quota_enable(socket: &Path) -> Result<()> { - daemon_request(socket, hive_host_sock::HostRequest::QuotaEnable, "quota").await -} - -/// `quota show [name]` — report per-agent disk usage from btrfs qgroups. -/// The daemon resolves the agent set + reads each subvolume's usage (it -/// holds the privileged helper); the client just formats the returned rows. -async fn quota_show(socket: &Path, name: Option<&str>) -> Result<()> { - let resp = crate::client::request( - socket, - hive_host_sock::HostRequest::QuotaShow { - name: name.map(str::to_owned), - }, - ) - .await - .with_context(|| format!("connect to daemon socket {}", socket.display()))?; - if !resp.ok { - // Carries the "btrfs quota not enabled — run `hivectl quota enable` - // first" hint when qgroups are off. - bail!("{}", resp.error.as_deref().unwrap_or("quota show failed")); - } - let rows = resp.quota.unwrap_or_default(); - if rows.is_empty() { - println!("no agents with a state dir found"); - return Ok(()); - } - for row in &rows { - match (row.referenced, row.exclusive) { - (Some(rfer), Some(excl)) => println!( - "{:<12} referenced {:>10} exclusive {:>10}", - row.agent, - human_bytes(rfer), - human_bytes(excl) - ), - // Plain-dir agent (no qgroup) — the daemon set an explanatory note. - _ => println!( - "{:<12} ({})", - row.agent, - row.note.as_deref().unwrap_or("no qgroup data") - ), - } - } - Ok(()) -} - -/// `quota limit ` — set or clear an agent's disk quota. -async fn quota_limit(socket: &Path, name: &str, size: &str) -> Result<()> { - let limit = parse_quota_size(size)?; - daemon_request( - socket, - hive_host_sock::HostRequest::QuotaLimit { - name: name.to_owned(), - limit, - }, - "quota", - ) - .await?; - match limit { - Some(n) => println!("set {name} quota to {} ({n} bytes)", human_bytes(n)), - None => println!("cleared {name} quota"), - } - Ok(()) -} - -/// Parse a quota size: a byte count, a `K`/`M`/`G`/`T`-suffixed value -/// (powers of 1024), or `none` to clear. Rejects overflow + bad input. -fn parse_quota_size(s: &str) -> Result> { - let t = s.trim(); - if t.eq_ignore_ascii_case("none") { - return Ok(None); - } - let (num, mult) = match t.chars().last() { - Some('K' | 'k') => (&t[..t.len() - 1], 1024u64), - Some('M' | 'm') => (&t[..t.len() - 1], 1024 * 1024), - Some('G' | 'g') => (&t[..t.len() - 1], 1024 * 1024 * 1024), - Some('T' | 't') => (&t[..t.len() - 1], 1024u64 * 1024 * 1024 * 1024), - _ => (t, 1), - }; - let n: u64 = num.trim().parse().with_context(|| { - format!("invalid size {s:?} — use a byte count, a K/M/G/T suffix (e.g. 5G), or `none`") - })?; - n.checked_mul(mult) - .map(Some) - .with_context(|| format!("size {s:?} overflows u64")) -} - -/// Format a byte count as a human-friendly KiB/MiB/GiB/TiB string. -#[allow(clippy::cast_precision_loss)] // display-only; precision loss is cosmetic -fn human_bytes(n: u64) -> String { - const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; - if n < 1024 { - return format!("{n} B"); - } - let mut v = n as f64; - let mut i = 0; - while v >= 1024.0 && i < UNITS.len() - 1 { - v /= 1024.0; - i += 1; - } - format!("{v:.1} {}", UNITS[i]) -} - -/// True when `name` matches an existing hyperhive agent — i.e. it has a -/// persistent state dir under `/var/lib/hyperhive/agents/`. We use the -/// state dir (not the live container list) so kept-state tombstones -/// still resolve as agents — re-provisioning a destroyed-but-kept agent -/// should still drop its token in the existing state tree. -/// -/// Uses `try_exists()` rather than `Path::exists()` so a permission -/// error reaching the agents root is surfaced, not collapsed into -/// `false`. The agents root is `0700 hive-core`, so running hivectl -/// without root yields EACCES on traversal — `Path::exists()` would -/// silently report `false`, which callers turn into a misleading "no -/// such agent" (or, for the create-user paths, a silent misclassify of -/// a real agent as a non-agent account). Mapping EACCES to an explicit -/// "needs root" error fixes that first-run footgun, where running a -/// privileged verb without sudo reported as a missing agent. -fn agent_exists(name: &str) -> Result { - let root = hive_host_sock::agent_state_dir(name); - match root.try_exists() { - Ok(found) => Ok(found), - Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => bail!( - "cannot read the agents root {} (permission denied) - this command needs root; \ - re-run with sudo", - root.parent().unwrap_or(&root).display() - ), - Err(e) => Err(e).with_context(|| format!("check agent state dir {}", root.display())), - } -} - -/// Drop into an interactive Claude session in the agent container. -/// -/// Execs `machinectl shell @h-` running claude as the agent -/// user from its state dir, reproducing the harness's per-turn claude -/// invocation (flags, session selection, why it never collides with the -/// live harness session). See `docs/tools/hivectl.md` (Choom) for the -/// full rationale. Inherits the caller's PTY; requires root + a running -/// container. -fn choom(name: &str, resume_session: Option<&str>) -> Result<()> { - if !agent_exists(name)? { - bail!( - "no such agent: '{name}' (no state dir under {}/)", - hive_host_sock::AGENTS_ROOT - ); - } - let container = hive_host_sock::container_name(name); - // Enter as the agent's unix user (== agent name) so claude reads the - // right `$HOME/.claude`. - let target = format!("{name}@{container}"); - let claude = "/run/current-system/sw/bin/claude"; - // Bind-mounted state dir; matches `hive-agent::paths::state_dir()`. - let state_dir = format!("/agents/{name}/state"); - // Per-turn config the harness writes; matches `paths::config_dir()`. - let cfg = "/run/hive-config"; - // `--resume ` passes through as `claude --resume ` — - // the rejoin-by-id surface. (claude's `--continue` is a bare flag - // that resumes the cwd's latest session — the harness's — and would - // consume a trailing value as the first PROMPT; choom never uses - // it.) The value is single-quoted into the shell script, so reject - // an embedded single quote (the only char that breaks - // single-quoting) to rule out injection — session ids never - // contain one. - let session_arg = match resume_session { - Some(val) => { - if val.contains('\'') { - bail!("invalid --resume value '{val}': must not contain a single quote"); - } - format!("set -- --resume '{val}';") - } - None => "set --;".to_string(), - }; - // Build claude's argv as the harness does, each flag included only - // when its file is present so a half-up container degrades to a bare - // session. `name` is `[a-z0-9._-]` so there's nothing to quote. - let inner = format!( - "cd {state_dir} || exit 1; {session_arg} \ - [ -f {cfg}/claude-settings.json ] && set -- \"$@\" --settings {cfg}/claude-settings.json; \ - [ -f {cfg}/claude-mcp-config.json ] && set -- \"$@\" --mcp-config {cfg}/claude-mcp-config.json; \ - [ -f {cfg}/claude-system-prompt.md ] && set -- \"$@\" --system-prompt-file {cfg}/claude-system-prompt.md; \ - exec {claude} \"$@\"" - ); - let mut cmd = std::process::Command::new("machinectl"); - cmd.arg("shell") - .arg(&target) - .arg("/bin/sh") - .arg("-lc") - .arg(&inner); - // exec() replaces the current process — we inherit stdin/stdout/stderr - // (the caller's PTY) so the Claude session is fully interactive. - // This call only returns on error. - let err = cmd.exec(); - Err(anyhow::anyhow!("exec machinectl: {err}")) -} - -/// `hivectl github set-token `: write an operator-supplied GitHub PAT -/// into the agent's `github-token` state file (0600, agent-owned) via -/// hive-priv, so the agent's `gh` wrapper + git credential helper can -/// authenticate. Read live at invocation, so no rebuild/restart is needed. -async fn github_set_token( - socket: &Path, - agent: &str, - token: Option, - token_stdin: bool, -) -> Result<()> { - // Resolve + validate the token client-side (inline flag or stdin read); - // the daemon never touches this process's stdin. Persistence happens - // daemon-side via the privileged helper. - let token = match (token, token_stdin) { - (Some(t), _) => t, - (None, true) => { - let mut s = String::new(); - std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; - s.trim_end_matches(['\n', '\r']).to_owned() - } - (None, false) => bail!( - "provide the PAT via --token or --token-stdin (stdin preferred — \ - an inline token is visible in shell history + process listings)" - ), - }; - if token.is_empty() { - bail!("refusing to write an empty GitHub token for agent '{agent}'"); - } - daemon_request( - socket, - hive_host_sock::HostRequest::SetAgentGithubToken { - agent: agent.to_owned(), - token, - }, - "github", - ) - .await -} - -async fn forge_create_user( - socket: &Path, - name: &str, - password: Option<&str>, - password_stdin: bool, -) -> Result<()> { - // Resolve the password client-side (inline flag or stdin read); the - // daemon never touches this process's stdin. The is-present check, the - // agent-vs-operator branch, and token persistence now live in the - // daemon handler. - let password = resolve_password(password, password_stdin)?; - daemon_request( - socket, - hive_host_sock::HostRequest::ForgeCreateUser { - name: name.to_owned(), - password, - }, - "forge", - ) - .await -} - -/// Send a provisioning request to the daemon and print its result lines. -/// The daemon owns the provisioning logic; hivectl just relays the outcome, -/// prefixing any error with `label` (e.g. `forge` / `github`). -async fn daemon_request( - socket: &Path, - req: hive_host_sock::HostRequest, - label: &str, -) -> Result<()> { - let resp = crate::client::request(socket, req) - .await - .with_context(|| format!("connect to daemon socket {}", socket.display()))?; - if !resp.ok { - bail!( - "{label}: {}", - resp.error.as_deref().unwrap_or("unknown error") - ); - } - for line in &resp.messages { - println!("{line}"); - } - Ok(()) -} - -/// Resolve the password the caller asked for, or return `None` to fall -/// back to a random throwaway. `--password ` wins outright; -/// `--password-stdin` reads one line off stdin (trailing newline -/// stripped). Empty stdin is treated as an error so the caller doesn't -/// silently provision an empty-password account. -fn resolve_password(password: Option<&str>, password_stdin: bool) -> Result> { - if let Some(p) = password { - return Ok(Some(p.to_owned())); - } - if password_stdin { - use std::io::BufRead as _; - let stdin = std::io::stdin(); - let mut line = String::new(); - stdin - .lock() - .read_line(&mut line) - .context("read password from stdin")?; - let trimmed = line.trim_end_matches(['\r', '\n']).to_owned(); - if trimmed.is_empty() { - bail!("--password-stdin: empty input"); - } - return Ok(Some(trimmed)); - } - Ok(None) -} - -/// Send a matrix provisioning request to the daemon and print the -/// operator-facing result lines it returns. The daemon owns the register + -/// admin tokens and the matrix creds dir, so hivectl no longer links the -/// matrix machinery — it just forwards the request and renders the reply. -async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> { - let resp = crate::client::request(socket, req) - .await - .with_context(|| format!("connect to daemon socket {}", socket.display()))?; - if !resp.ok { - bail!( - "matrix: {}", - resp.error.as_deref().unwrap_or("unknown error") - ); - } - for line in &resp.messages { - println!("{line}"); - } - Ok(()) -} - -async fn matrix_create_user( - socket: &Path, - name: &str, - password: Option<&str>, - password_stdin: bool, -) -> Result<()> { - // Resolve the password client-side (an inline flag or a stdin read); - // the daemon never touches this process's stdin. The agent-vs-operator - // branch + throwaway-password handling now live in the daemon handler. - let password = resolve_password(password, password_stdin)?; - matrix_request( - socket, - hive_host_sock::HostRequest::MatrixCreateUser { - name: name.to_owned(), - password, - }, - ) - .await -} - -async fn matrix_sync_admin(socket: &Path) -> Result<()> { - matrix_request(socket, hive_host_sock::HostRequest::MatrixSyncAdmin).await -} - -async fn matrix_promote_user(socket: &Path, name: &str) -> Result<()> { - matrix_request( - socket, - hive_host_sock::HostRequest::MatrixPromoteUser { - name: name.to_owned(), - }, - ) - .await -} - -async fn matrix_invite(socket: &Path, user: &str, room: Option<&str>) -> Result<()> { - matrix_request( - socket, - hive_host_sock::HostRequest::MatrixInvite { - user: user.to_owned(), - room: room.map(str::to_owned), - }, - ) - .await -} - -async fn matrix_reset_password(socket: &Path, name: &str) -> Result<()> { - matrix_request( - socket, - hive_host_sock::HostRequest::MatrixResetPassword { - name: name.to_owned(), - }, - ) - .await -} - -// --------------------------------------------------------------------------- -// Gateway htpasswd helpers (require daemon via host admin socket) -// --------------------------------------------------------------------------- -// The daemon owns the bcrypt hash + htpasswd write (see the `Gateway*User` -// HostRequests); hivectl resolves the password client-side and relays. - -async fn gateway_create_user( - socket: &Path, - username: &str, - password: Option<&str>, - password_stdin: bool, -) -> Result<()> { - let pw = resolve_password(password, password_stdin)?.ok_or_else(|| { - anyhow::anyhow!("a password is required — pass --password or --password-stdin") - })?; - daemon_request( - socket, - hive_host_sock::HostRequest::GatewayCreateUser { - username: username.to_owned(), - password: pw, - }, - "gateway", - ) - .await -} - -async fn gateway_delete_user(socket: &Path, username: &str) -> Result<()> { - daemon_request( - socket, - hive_host_sock::HostRequest::GatewayDeleteUser { - username: username.to_owned(), - }, - "gateway", - ) - .await -} - -async fn gateway_list_users(socket: &Path) -> Result<()> { - daemon_request( - socket, - hive_host_sock::HostRequest::GatewayListUsers, - "gateway", - ) - .await -} - -// --------------------------------------------------------------------------- -// Agent management helpers (require daemon via host admin socket) -// --------------------------------------------------------------------------- - -async fn agents_restart(socket: &Path, name: &str, no_wait: bool) -> Result<()> { - let resp = crate::client::request( - socket, - hive_host_sock::HostRequest::Restart { - name: name.to_owned(), - }, - ) - .await - .with_context(|| format!("connect to daemon socket {}", socket.display()))?; - if resp.ok { - println!("restart queued: {name}"); - wait_for_dags(socket, resp.queued_dags.unwrap_or_default(), no_wait).await - } else { - bail!( - "restart {name}: {}", - resp.error.as_deref().unwrap_or("unknown error") - ) - } -} - -/// `hivectl agents list` — fetch the per-agent status roster from the -/// daemon (`HostRequest::AgentStatus`) and render it as a padded table, -/// or the raw JSON rows with `--json`. Reuses the dashboard's -/// `ContainerView` aggregation, so the CLI and the web UI never drift. -async fn agents_list(socket: &Path, json: bool) -> Result<()> { - let resp = crate::client::request(socket, hive_host_sock::HostRequest::AgentStatus) - .await - .with_context(|| format!("connect to daemon socket {}", socket.display()))?; - if !resp.ok { - bail!( - "agents list: {}", - resp.error.as_deref().unwrap_or("unknown error") - ); - } - let rows = resp.agent_statuses.unwrap_or_default(); - if json { - println!("{}", serde_json::to_string_pretty(&rows)?); - return Ok(()); - } - if rows.is_empty() { - println!("no managed agents found"); - return Ok(()); - } - // STATUS collapses the health flags into one space-separated token so - // the common case (`running`) stays short and anomalies stand out. - let status_of = |r: &hive_sh4re::AgentStatusRow| -> String { - let mut s = if r.running { "running" } else { "stopped" }.to_owned(); - if r.needs_login { - s.push_str(" needs-login"); - } - if r.needs_update { - s.push_str(" needs-update"); - } - s - }; - let headers = ["NAME", "STATUS", "REV", "PARENT", "REMIND"]; - let table: Vec<[String; 5]> = rows - .iter() - .map(|r| { - [ - r.name.clone(), - status_of(r), - r.deployed_sha.clone().unwrap_or_else(|| "-".to_owned()), - r.parent.clone().unwrap_or_else(|| "-".to_owned()), - if r.pending_reminders > 0 { - r.pending_reminders.to_string() - } else { - "-".to_owned() - }, - ] - }) - .collect(); - let mut widths = headers.map(str::len); - for row in &table { - for (i, cell) in row.iter().enumerate() { - widths[i] = widths[i].max(cell.len()); - } - } - let fmt_row = |cells: &[String]| -> String { - cells - .iter() - .enumerate() - .map(|(i, c)| format!("{c:>() - .join(" ") - .trim_end() - .to_owned() - }; - let header_cells: Vec = headers.iter().map(|h| (*h).to_owned()).collect(); - println!("{}", fmt_row(&header_cells)); - for row in &table { - println!("{}", fmt_row(row)); - } - Ok(()) -} - -async fn agents_restart_all(socket: &Path, no_wait: bool) -> Result<()> { - let resp = crate::client::request(socket, hive_host_sock::HostRequest::RestartAll) - .await - .with_context(|| format!("connect to daemon socket {}", socket.display()))?; - let agents = resp.agents.as_deref().unwrap_or(&[]); - if agents.is_empty() { - println!("restart-all: no managed containers found"); - } else { - for a in agents { - println!("restart queued: {a}"); - } - } - if !resp.ok { - bail!( - "restart-all: {}", - resp.error.as_deref().unwrap_or("unknown error") - ); - } - wait_for_dags(socket, resp.queued_dags.unwrap_or_default(), no_wait).await -} - -async fn stop( - socket: &Path, - scope: hive_host_sock::LifecycleScope, - graceful: bool, - no_wait: bool, -) -> Result<()> { - let resp = crate::client::request( - socket, - hive_host_sock::HostRequest::Stop { scope, graceful }, - ) - .await - .with_context(|| format!("connect to daemon socket {}", socket.display()))?; - // Render first, but even when an infra failure makes it bail, - // watch the already-queued agent DAGs before surfacing the error — - // they run regardless. - let rendered = render_lifecycle(&resp, "stop queued"); - wait_for_dags(socket, resp.queued_dags.unwrap_or_default(), no_wait).await?; - rendered -} - -async fn start(socket: &Path, scope: hive_host_sock::LifecycleScope, no_wait: bool) -> Result<()> { - let resp = crate::client::request(socket, hive_host_sock::HostRequest::Start { scope }) - .await - .with_context(|| format!("connect to daemon socket {}", socket.display()))?; - let rendered = render_lifecycle(&resp, "start queued"); - wait_for_dags(socket, resp.queued_dags.unwrap_or_default(), no_wait).await?; - rendered -} - -/// Restart — one `RestartScoped` daemon call, server-side DAG-based (see -/// issue tracker "dagify hivectl commands"). Each targeted agent rides -/// exactly one atomic DAG queued up front — `Restart` (mechanical stop + -/// reconcile), or `GracefulRestart` with `--graceful` (signal → drain → -/// mechanical stop → reconcile); infra containers restart synchronously. -/// No "submit one DAG, wait for it, submit another" composition on either -/// side of the wire: unlike the old client-side stop-then-start compose, a -/// dropped `hivectl` connection mid-restart no longer leaves an agent -/// stopped with no automatic follow-up — the daemon owns the whole -/// sequence once this call is made. -/// -/// No `--no-wait` here on purpose, same as before: the operator wants to -/// see the restart actually land, not just get queued. -async fn restart( - socket: &Path, - scope: hive_host_sock::LifecycleScope, - graceful: bool, -) -> Result<()> { - let resp = crate::client::request( - socket, - hive_host_sock::HostRequest::RestartScoped { scope, graceful }, - ) - .await - .with_context(|| format!("connect to daemon socket {}", socket.display()))?; - let rendered = render_lifecycle(&resp, "restart queued"); - wait_for_dags(socket, resp.queued_dags.unwrap_or_default(), false).await?; - rendered -} - -/// A [`LifecycleScope`](hive_host_sock::LifecycleScope) targeting exactly one -/// agent by name (no infra containers, no all-agents flag). -fn single_agent_scope(name: &str) -> hive_host_sock::LifecycleScope { - hive_host_sock::LifecycleScope { - agents: false, - agent_names: vec![name.to_owned()], - ci: false, - forge: false, - gateway: false, - matrix: false, - } -} - -/// `subvol upgrade ` — migrate an existing plain-dir agent state root -/// to a btrfs subvolume. Composed client-side (like `restart`): stop the -/// agent so its state bind-mount is released, run the privileged in-place -/// migration via hive-priv, then restart it. The restart is attempted -/// regardless of the migration outcome so a failed migration never leaves -/// the agent down; the migration error (if any) is surfaced afterwards. -/// Route a `hivectl subvol …` subcommand. Split out of `main`'s top-level -/// match so the CLI router stays within the clippy line budget and the -/// subvolume-op subcommands are dispatched in one place. -async fn dispatch_subvol(socket: &Path, cmd: SubvolCmd) -> Result<()> { - match cmd { - SubvolCmd::Upgrade { name, yes } => subvol_upgrade(socket, &name, yes).await, - SubvolCmd::Snapshot { cmd } => match cmd { - SnapshotCmd::Create { name, label } => { - subvol_snapshot_create(socket, &name, label).await - } - SnapshotCmd::Delete { name, label } => { - subvol_snapshot_delete(socket, &name, &label).await - } - SnapshotCmd::Send { - name, - label, - parent, - dest, - } => subvol_snapshot_send(socket, &name, &label, parent.as_deref(), &dest).await, - }, - } -} - -async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> { - if !agent_exists(name)? { - bail!("no agent named {name:?} (no state dir under the agents root)"); - } - if !yes { - bail!( - "`subvol upgrade {name}` stops the agent, migrates its state dir to a btrfs \ - subvolume, then restarts it. Re-run with --yes to proceed." - ); - } - - println!("stopping {name} (releasing its state bind-mount)…"); - let stop_resp = crate::client::request( - socket, - hive_host_sock::HostRequest::Stop { - scope: single_agent_scope(name), - graceful: false, - }, - ) - .await - .with_context(|| format!("connect to daemon socket {}", socket.display()))?; - if !stop_resp.ok { - bail!( - "stop {name}: {}", - stop_resp.error.as_deref().unwrap_or("unknown error") - ); - } - // The stop is a queued DAG now — the migration below snapshots + - // swaps the state dir and MUST NOT run under a live bind mount, so - // wait for the stop to actually execute before touching anything. - wait_for_dags(socket, stop_resp.queued_dags.unwrap_or_default(), false) - .await - .with_context(|| format!("waiting for {name} to stop before the migration"))?; - - println!("migrating {name} state dir to a btrfs subvolume…"); - let upgrade = daemon_request( - socket, - hive_host_sock::HostRequest::UpgradeSubvolume { - name: name.to_owned(), - }, - "upgrade", - ) - .await; - - // Always attempt the restart, even if the migration failed — don't leave - // the agent down. Capture the result rather than `?`-ing it so a - // start-side failure (incl. the IPC call itself erroring) can't mask the - // migration outcome below. - println!("starting {name}…"); - let start_result = crate::client::request( - socket, - hive_host_sock::HostRequest::Start { - scope: single_agent_scope(name), - }, - ) - .await; - - // Surface the migration outcome FIRST — it's the meaningful result and - // must not be shadowed by a restart-side failure. On migration failure the - // original state dir is untouched (the priv op rolls back before the swap). - upgrade.with_context(|| format!("upgrade {name} state subvolume"))?; - - // Migration succeeded; now surface any restart problem — either the IPC - // call erroring, or the daemon reporting a failed start. The migration is - // done regardless, so point at the manual recovery. - let start_resp = start_result.with_context(|| { - format!( - "{name} migrated to a btrfs subvolume, but the restart request to the daemon \ - socket {} failed — run `hivectl start --agent {name}` to bring it back up", - socket.display() - ) - })?; - if !start_resp.ok { - bail!( - "{name} migrated to a btrfs subvolume, but restarting it failed: {} — run \ - `hivectl start --agent {name}` to retry", - start_resp.error.as_deref().unwrap_or("unknown error") - ); - } - wait_for_dags(socket, start_resp.queued_dags.unwrap_or_default(), false) - .await - .with_context(|| { - format!( - "{name} migrated to a btrfs subvolume, but its restart job failed — run \ - `hivectl start --agent {name}` to retry" - ) - })?; - println!("upgraded {name} to a btrfs subvolume and restarted it"); - Ok(()) -} - -/// `subvol snapshot create --label