diff --git a/CLAUDE.md b/CLAUDE.md index 33c4a4c6..6449ba0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,13 +25,19 @@ hand-maintained per-file tree drifts out of sync with the code. ### Rust workspace (`Cargo.toml` members) - **`hive-c0re/`** — host daemon (runs as the unprivileged `hive-core` - user) plus two operator CLIs. `src/main.rs` is the `hive-c0re` binary - (serve / spawn / kill / rebuild / approve / destroy / periodic vacuum - loops); `src/bin/hivectl.rs` is the ad-hoc operator admin CLI. Owns - the sqlite broker, approval + question + reminder + schedule queues, - the meta flake, lifecycle (`nixos-container` shellouts), gateway / - forge / matrix provisioning, per-container stats, and the axum - operator dashboard (`dashboard.rs`). Largest crate. + user). `src/main.rs` is the `hive-c0re` binary — **daemon-only** + (`serve` + the periodic vacuum/sweep loops); the operator CLI lives in + the separate `hivectl` crate, which talks to the daemon over the host + admin socket. Owns the sqlite broker, approval + question + reminder + + schedule queues, the meta flake, lifecycle (`nixos-container` + shellouts), gateway / forge / matrix provisioning, per-container stats, + and the axum operator dashboard (`dashboard.rs`). Largest crate. +- **`hivectl/`** — standalone operator CLI (`hivectl` binary). Talks to + the `hive-c0re` daemon over the host admin socket (`hive-host-sock` + wire types) — does NOT link `hive-c0re`. Verbs: `agents `, `approvals `, `forge`/`matrix`/`github`/`gateway` provisioning, + `choom`, `stop`/`start`, `wg`/`peer-config`. - **`hive-ag3nt/`** — in-container harness; three sibling binaries for every agent (`hive-agent` serve loop, `hive-agent-mcp`, `hive-agent-wake`). Turn-loop *policy* layer (`turn.rs`) over the `hive-claude` diff --git a/Cargo.lock b/Cargo.lock index 451888d7..a9b68c45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1665,6 +1665,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "hivectl" +version = "0.1.0" +dependencies = [ + "anyhow", + "bcrypt", + "clap", + "clap-markdown", + "clap_complete", + "hive-host-sock", + "hive-sh4re", + "indicatif", + "serde_json", + "tokio", + "tracing-subscriber", +] + [[package]] name = "hkdf" version = "0.12.4" diff --git a/Cargo.toml b/Cargo.toml index 66dee2e4..c93c116e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "hive-priv", "hive-priv-sock", "hive-sh4re", + "hivectl", ] [workspace.package] diff --git a/docs/agent-hierarchy.md b/docs/agent-hierarchy.md index a88374f5..044e7d15 100644 --- a/docs/agent-hierarchy.md +++ b/docs/agent-hierarchy.md @@ -26,7 +26,7 @@ above was spawned by `alice`), and the operator can reparent any agent. The bootstrap container (`ruth`) is just another root. Re-parenting is operator-driven: -- CLI: `hive-c0re set-parent --parent ` (or `--root` to +- CLI: `hivectl agents set-parent --parent ` (or `--root` to promote). Exactly one of `--parent` / `--root` is required. - Dashboard: `POST /api/topology/set-parent` (form fields `child`, optional `new_parent` — absent / empty ⇒ promote to root). diff --git a/docs/approvals.md b/docs/approvals.md index 9bf08ebd..c1713fb5 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -37,7 +37,7 @@ request. 3. The operator reviews the PR **on the forge** (native diff, threaded comments, CI status) and sees a matching card on the dashboard with a "review PR on forge" deep link. They click ◆ APPR0VE (or - `hive-c0re approve ` on the CLI) once satisfied. + `hivectl approvals approve ` on the CLI) once satisfied. 4. On approve, `run_merge_config_pr`: - re-reads the live PR head and **aborts if it drifted** from the reviewed `fetched_sha` (the submitter must push again, which queues @@ -110,7 +110,7 @@ kind-specific payload carrier. - `Spawn` — direct container creation from the agent's config repo. `commit_ref` is empty. Submitted via `HostRequest::RequestSpawn` (operator-gated, the `◆ R3QU3ST SP4WN` dashboard button + - `hive-c0re request-spawn` CLI). The host-level `HostRequest::Spawn` + `hivectl agents request-spawn` CLI). The host-level `HostRequest::Spawn` variant bypasses the approval queue entirely — privileged-context use only (operator on the host shell, test scripts, one-off recoveries). This is the **canonical first-spawn**: a new agent's `InitConfig` diff --git a/docs/persistence.md b/docs/persistence.md index 6f1dcccf..006db829 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -304,7 +304,7 @@ step would re-fire). `R3V1V3` queues a Spawn approval that reuses the kept state on approve (no re-login). - `PURG3` (opt-in via the dashboard button or - `hive-c0re destroy --purge `) — DESTR0Y plus wipes + `hivectl agents destroy --purge `) — DESTR0Y plus wipes `/var/lib/hyperhive/{agents,applied}//`. Config history, claude creds, /state/ notes, and the harness dir are all gone. No undo. diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 99825efd..155de994 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -23,6 +23,16 @@ This document contains the help content for the `hivectl` command-line program. * [`hivectl agents list`↴](#hivectl-agents-list) * [`hivectl agents restart`↴](#hivectl-agents-restart) * [`hivectl agents restart-all`↴](#hivectl-agents-restart-all) +* [`hivectl agents spawn`↴](#hivectl-agents-spawn) +* [`hivectl agents request-spawn`↴](#hivectl-agents-request-spawn) +* [`hivectl agents kill`↴](#hivectl-agents-kill) +* [`hivectl agents destroy`↴](#hivectl-agents-destroy) +* [`hivectl agents rebuild`↴](#hivectl-agents-rebuild) +* [`hivectl agents set-parent`↴](#hivectl-agents-set-parent) +* [`hivectl approvals`↴](#hivectl-approvals) +* [`hivectl approvals pending`↴](#hivectl-approvals-pending) +* [`hivectl approvals approve`↴](#hivectl-approvals-approve) +* [`hivectl approvals deny`↴](#hivectl-approvals-deny) * [`hivectl wg`↴](#hivectl-wg) * [`hivectl wg init`↴](#hivectl-wg-init) * [`hivectl wg peer`↴](#hivectl-wg-peer) @@ -58,6 +68,7 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that * `github` — GitHub account provisioning: write an operator-supplied personal access token (PAT) into an agent's `github-token` state file so its `gh` wrapper + git credential helper can authenticate. Unlike forge/matrix there is no account creation — the operator supplies a PAT for an existing GitHub account. A CLI alternative to the dashboard credentials tab; the integration is on by default (`hyperhive.github.enable`), so no per-agent config is needed * `gateway` — Gateway htpasswd user management. Add, remove, or list users in an htpasswd file used by the gateway's HTTP Basic auth (`services.hyperhive.gateway.auth`). Credentials are stored as `BCrypt` hashes — no extra service or PAM required * `agents` — Agent container management. Requires the hive-c0re daemon to be running (connects to the host admin socket) +* `approvals` — Operator approval queue: list pending requests, approve / deny them. Requires the hive-c0re daemon to be running (connects to the host admin socket) * `wg` — WireGuard inter-hive mesh setup helpers (`services.hyperhive.swarm`) * `peer-config` — Generate the federation peer-config block for THIS hive — the nix a peer operator pastes into their `services.hyperhive.swarm.peers` to trust + reach this hive. Emits `caCert` (+ a `cp` line for the cert) when this hive serves a self-signed CA, the WireGuard public key when the mesh key exists, and the `wireguard{Address,Endpoint}` you pass. The hive's own domain is filled in automatically from the running daemon (`services.hyperhive.domain`). Reads local state (the TLS CA cert, the wg key); never mutates. `wg init` calls this at the end, so a fresh mesh setup prints the hand-over block too * `choom` — Open an interactive Claude session inside an agent container @@ -309,6 +320,12 @@ Agent container management. Requires the hive-c0re daemon to be running (connect * `list` — Show all managed agents with their status (running / needs-login / needs-update) and technical state (deployed sha, parent, pending reminders). The host roster overview; reuses the dashboard's per-agent aggregation. Requires the daemon running * `restart` — Stop and start a single agent container without rebuilding config. Useful for "kick the container" when the process is stuck or the container needs a clean restart without changing the NixOS config. Rides the job queue (serialized against in-flight rebuilds for the same agent); waits with live progress unless `--no-wait` * `restart-all` — Restart ALL managed agent containers via one restart DAG each — unrelated agents overlap, each serializes on its own lease. Waits for the whole set with live progress unless `--no-wait` +* `spawn` — Spawn a new agent container directly (`h-`). Bypasses the approval queue — operator-on-the-host only. For approval-gated spawns, use `request-spawn` +* `request-spawn` — Queue a spawn request as an approval. The container is created on `hivectl approvals approve ` (or the dashboard APPR0VE button) +* `kill` — Stop a managed container (graceful) +* `destroy` — Tear down a sub-agent container. The container is removed; persistent state (config repos + Claude credentials) is kept by default. Pass `--purge` to also wipe the agent's state dirs (config + creds + notes). No undo +* `rebuild` — Apply pending config to a managed container +* `set-parent` — Move an agent in the topology tree. Set `--parent` to a new parent agent name, or pass `--root` to promote the agent to root (no parent). Exactly one is required. Refuses cycles and unknown agents @@ -352,6 +369,133 @@ Restart ALL managed agent containers via one restart DAG each — unrelated agen +## `hivectl agents spawn` + +Spawn a new agent container directly (`h-`). Bypasses the approval queue — operator-on-the-host only. For approval-gated spawns, use `request-spawn` + +**Usage:** `hivectl agents spawn ` + +###### **Arguments:** + +* `` — Agent name (e.g. `iris`) + + + +## `hivectl agents request-spawn` + +Queue a spawn request as an approval. The container is created on `hivectl approvals approve ` (or the dashboard APPR0VE button) + +**Usage:** `hivectl agents request-spawn ` + +###### **Arguments:** + +* `` — Agent name + + + +## `hivectl agents kill` + +Stop a managed container (graceful) + +**Usage:** `hivectl agents kill ` + +###### **Arguments:** + +* `` — Agent name + + + +## `hivectl agents destroy` + +Tear down a sub-agent container. The container is removed; persistent state (config repos + Claude credentials) is kept by default. Pass `--purge` to also wipe the agent's state dirs (config + creds + notes). No undo + +**Usage:** `hivectl agents destroy [OPTIONS] ` + +###### **Arguments:** + +* `` — Agent name + +###### **Options:** + +* `--purge` + + + +## `hivectl agents rebuild` + +Apply pending config to a managed container + +**Usage:** `hivectl agents rebuild ` + +###### **Arguments:** + +* `` — Agent name + + + +## `hivectl agents set-parent` + +Move an agent in the topology tree. Set `--parent` to a new parent agent name, or pass `--root` to promote the agent to root (no parent). Exactly one is required. Refuses cycles and unknown agents + +**Usage:** `hivectl agents set-parent [OPTIONS] ` + +###### **Arguments:** + +* `` — Agent to move + +###### **Options:** + +* `--parent ` — New parent agent name. Mutually exclusive with `--root` +* `--root` — Promote `child` to root (no parent) + + + +## `hivectl approvals` + +Operator approval queue: list pending requests, approve / deny them. Requires the hive-c0re daemon to be running (connects to the host admin socket) + +**Usage:** `hivectl approvals ` + +###### **Subcommands:** + +* `pending` — List pending approval requests submitted by agents +* `approve` — Approve a pending request by id; the action runs immediately +* `deny` — Deny a pending request by id + + + +## `hivectl approvals pending` + +List pending approval requests submitted by agents + +**Usage:** `hivectl approvals pending` + + + +## `hivectl approvals approve` + +Approve a pending request by id; the action runs immediately + +**Usage:** `hivectl approvals approve ` + +###### **Arguments:** + +* `` — Approval id (from `hivectl approvals pending`) + + + +## `hivectl approvals deny` + +Deny a pending request by id + +**Usage:** `hivectl approvals deny ` + +###### **Arguments:** + +* `` — Approval id + + + ## `hivectl wg` WireGuard inter-hive mesh setup helpers (`services.hyperhive.swarm`). diff --git a/hive-c0re/src/lib.rs b/hive-c0re/src/lib.rs index bba068cf..783a48b9 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -1,15 +1,15 @@ -//! `hive-c0re` library — module surface shared by the `hive-c0re` -//! daemon binary and the `hivectl` operator CLI. +//! `hive-c0re` library — the coordinator daemon's module surface +//! (coordinator, broker, axum dashboard, admin/manager/agent unix +//! sockets, background sweepers). Consumed by the `hive-c0re` daemon +//! binary (`src/main.rs`). //! -//! `hive-c0re` (daemon) keeps the systemd service shape it always had: -//! coordinator, broker, axum dashboard, admin/manager/agent unix -//! sockets, background sweepers. `hivectl` (sibling bin under -//! `src/bin/hivectl.rs`) reuses a thin subset (`forge`, `matrix`, -//! `lifecycle`) to expose host-side administration verbs — manually -//! provisioning forge / matrix users for an agent, etc. +//! The operator CLI lives in the **standalone `hivectl` crate**, which +//! talks to the daemon over the host admin socket (`hive-host-sock` wire +//! types) rather than linking this crate — so `hive-c0re` is daemon-only, +//! not a library shared with a CLI. //! //! Every module is re-exported `pub` so anything in the crate is -//! addressable from either binary; the lib doesn't have a curated +//! addressable from the daemon binary; the lib doesn't have a curated //! surface beyond "this is where the modules live". //! //! Cohesive clusters live in directory submodules (`stores`, `stats`, @@ -19,7 +19,6 @@ pub mod actions; pub mod agent_config; -pub mod client; pub mod container_view; pub mod coordinator; pub mod dashboard; diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index 29ea64f8..acfa722a 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -25,10 +25,14 @@ use tokio::process::Command; use crate::coordinator::{AgentPaths, HiveEnv}; -/// Sub-agent container prefix. `nixos-container` caps the total container name -/// at 11 chars (it gets encoded into network interface names), so the agent -/// name itself can be at most `MAX_AGENT_NAME` chars. -pub const AGENT_PREFIX: &str = "h-"; +// `AGENT_PREFIX` (`h-`) + `container_name` are shared with the host-side +// `hivectl` CLI, so they live in `hive-host-sock`; re-exported here so this +// module stays the daemon's facade for its own callsites. +pub use hive_host_sock::{AGENT_PREFIX, container_name}; + +/// Max agent-name length. `nixos-container` caps the total container name at +/// 11 chars (it gets encoded into network interface names) and `AGENT_PREFIX` +/// (`h-`) takes 2, so the agent name itself is at most `MAX_AGENT_NAME` chars. pub const MAX_AGENT_NAME: usize = 9; /// Logical name of the manager agent (broker recipient, state-dir key, /// meta flake attribute). All persistent state lives under `ruth/`. @@ -124,11 +128,6 @@ pub fn bridge_gateway_ip(subnet_cidr: &str) -> Option { Some(ip_str.to_owned()) } -#[must_use] -pub fn container_name(name: &str) -> String { - format!("{AGENT_PREFIX}{name}") -} - /// Read the agent user's `(uid, gid)` from the container's nixos-managed /// `/etc/passwd`. Returns `None` when the container hasn't been built /// yet, the passwd file is unparseable, or the agent user is missing diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 2db071f6..f642d111 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -1,9 +1,8 @@ use std::path::PathBuf; use std::sync::Arc; -use anyhow::{Context as _, Result, bail}; +use anyhow::{Context as _, Result}; use clap::{Parser, Subcommand}; -use hive_host_sock::{HostRequest, HostResponse}; // Every module hangs off the `hive_c0re` library (see `src/lib.rs`). // The daemon and the `hivectl` sibling binary share the same module @@ -12,7 +11,7 @@ use hive_host_sock::{HostRequest, HostResponse}; // explicit (any new daemon entry point reads off the next add). use hive_c0re::coordinator::{Coordinator, HiveEnv, ServeConfig}; use hive_c0re::{ - agent_sockets, auto_update, broker, client, crash_watch, dashboard, dashboard_events, forge, + agent_sockets, auto_update, broker, crash_watch, dashboard, dashboard_events, forge, host_stats, job_queue, knowledge, matrix, mcp_sockets, migrate, reminder_scheduler, scheduled_prompts_worker, server, socket_server, sweep_health, warnings, }; @@ -91,52 +90,6 @@ enum Cmd { #[arg(long)] build_slots: Option, }, - /// Spawn a new agent container directly (`hive-agent-`). Bypasses - /// the approval queue — use only as an operator on the host. For - /// approval-gated spawns, use `request-spawn` instead. - Spawn { name: String }, - /// Queue a spawn request as an approval. The container is created on - /// `approve ` (CLI) or the dashboard's APPR0VE button. - RequestSpawn { name: String }, - /// Stop a managed container (graceful). - Kill { name: String }, - /// Tear down a sub-agent container. Container is removed; persistent - /// state (config repos + Claude credentials) is kept by default. Pass - /// `--purge` to also wipe the agent's state dirs (config + creds + - /// notes). No undo. - Destroy { - name: String, - #[arg(long)] - purge: bool, - }, - /// Apply pending config to a managed container. - Rebuild { name: String }, - /// List managed containers. - List, - /// List pending approval requests submitted by the manager. - 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. Set `--parent` to a new - /// parent agent name; pass `--root` to promote the agent to root - /// (no parent). Refuses cycles and unknown agents. The manager - /// is reparentable like any other agent — its privileges come - /// from the privileged MCP socket, not its tree position. - SetParent { - child: String, - /// New parent agent name. Mutually exclusive with `--root`. - /// Exactly one of `--parent` / `--root` is required — clap - /// rejects both-absent calls so a fat-fingered - /// `hive-c0re set-parent alice` doesn't silently promote - /// alice to root. - #[arg(long, conflicts_with = "root", required_unless_present = "root")] - parent: Option, - /// Promote `child` to root (no parent). - #[arg(long)] - root: bool, - }, } #[tokio::main] @@ -206,37 +159,6 @@ async fn main() -> Result<()> { } cmd_serve(sc.env, sc.model_prices, sc.build_slots, db, &cli.socket).await } - Cmd::Spawn { name } => { - render(client::request(&cli.socket, HostRequest::Spawn { name }).await?) - } - Cmd::RequestSpawn { name } => { - render(client::request(&cli.socket, HostRequest::RequestSpawn { name }).await?) - } - Cmd::Kill { name } => { - render(client::request(&cli.socket, HostRequest::Kill { name }).await?) - } - Cmd::Destroy { name, purge } => { - render(client::request(&cli.socket, HostRequest::Destroy { name, purge }).await?) - } - Cmd::Rebuild { name } => { - render(client::request(&cli.socket, HostRequest::Rebuild { name }).await?) - } - Cmd::List => render(client::request(&cli.socket, HostRequest::List).await?), - Cmd::Pending => render(client::request(&cli.socket, HostRequest::Pending).await?), - Cmd::Approve { id } => { - render(client::request(&cli.socket, HostRequest::Approve { id }).await?) - } - Cmd::Deny { id } => render(client::request(&cli.socket, HostRequest::Deny { id }).await?), - Cmd::SetParent { - child, - parent, - root, - } => { - let new_parent = if root { None } else { parent }; - render( - client::request(&cli.socket, HostRequest::SetParent { child, new_parent }).await?, - ) - } } } @@ -648,11 +570,3 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc) { } }); } - -fn render(resp: HostResponse) -> Result<()> { - println!("{}", serde_json::to_string_pretty(&resp)?); - if !resp.ok { - bail!(resp.error.unwrap_or_else(|| "request failed".to_owned())); - } - Ok(()) -} diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 045d4761..4c5a2d7b 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -24,6 +24,12 @@ use std::path::{Path, PathBuf}; +// Layout facts shared with the host-side `hivectl` CLI live in +// `hive-host-sock` (the crate hivectl links instead of the whole daemon); +// re-exported here so this module stays the daemon's single-source facade for +// its own callsites. +pub use hive_host_sock::{AGENTS_ROOT, GATEWAY_HTPASSWD, HOST_SOCKET, agent_state_dir}; + /// Root of all hive-c0re persistent state. // nix: bind-mount source `services.hyperhive.c0re.statePath` (hive-c0re.nix) — must match. pub const STATE_ROOT: &str = "/var/lib/hyperhive"; @@ -35,11 +41,6 @@ pub const STATE_ROOT: &str = "/var/lib/hyperhive"; // stay in sync; the privsep boundary prevents importing across the crate. pub const RUNTIME_ROOT: &str = "/run/hyperhive"; -/// Default host admin socket (`/run/hyperhive/host.sock`). Exposed as a -/// `&str` for the `--socket` / `--host-socket` clap `default_value` in -/// `main.rs` (hive-c0re) and `bin/hivectl.rs`. -pub const HOST_SOCKET: &str = "/run/hyperhive/host.sock"; - /// `/run/hive-agent` — per-agent runtime socket dir root (web + bound /// markers), one subdir per agent. // nix: agent container bind-mount / `RuntimeDirectory` (the harness nix modules) — must match. @@ -158,26 +159,12 @@ pub fn agent_sockets_file() -> PathBuf { // single Rust-side source. // --------------------------------------------------------------------------- -/// `agents/` — per-agent persistent state root (one subdir per agent, -/// bind-mounted into each container as `/agents/`). A `&str` (the -/// dashboard state-file allow-list uses it for `strip_prefix` / -/// `starts_with` checks), so it stays a const; [`agents_root`] wraps it. -// 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/` root (`AGENTS_ROOT`) as an owned `PathBuf`. #[must_use] pub fn agents_root() -> PathBuf { PathBuf::from(AGENTS_ROOT) } -/// `agents/` — one agent's persistent state root. -#[must_use] -pub fn agent_state_dir(name: &str) -> PathBuf { - agents_root().join(name) -} - /// `applied/` — per-agent *applied* (deployed) config repos + rev markers, /// distinct from the proposed configs under `agents//config`. // nix: read by hive-c0re only, but paired with `agents/` in the deploy flow. @@ -253,12 +240,6 @@ pub fn gateway_agents_conf() -> PathBuf { gateway_dir().join("agents.conf") } -/// `gateway/gateway.htpasswd` — nginx basic-auth credential store for the -/// operator dashboard vhost. A `&str` (used as a `hivectl` clap -/// `default_value`), so it stays a const rather than a `PathBuf` fn. -// nix: read by the gateway container's nginx (hive-gateway.nix) — must match. -pub const GATEWAY_HTPASSWD: &str = "/var/lib/hyperhive/gateway/gateway.htpasswd"; - /// `forge-core-token` — the hive-c0re forge account API token. A `&str` /// (used in `Path::new` + user-facing `format!` messages), so it stays a /// const rather than a `PathBuf` fn. diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 1833c8f7..b4c9a15a 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -6,9 +6,51 @@ //! 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. +#[must_use] +pub fn agent_state_dir(name: &str) -> PathBuf { + PathBuf::from(AGENTS_ROOT).join(name) +} + +/// `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}") +} + /// Requests on the host admin socket. /// /// Wire format: one JSON object per line. diff --git a/hivectl/Cargo.toml b/hivectl/Cargo.toml new file mode 100644 index 00000000..0bc1094f --- /dev/null +++ b/hivectl/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "hivectl" +edition.workspace = true +version.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "hivectl" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +bcrypt.workspace = true +clap.workspace = true +clap_complete.workspace = true +clap-markdown = "0.1" +hive-host-sock.workspace = true +hive-sh4re.workspace = true +indicatif.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing-subscriber.workspace = true diff --git a/hive-c0re/src/client.rs b/hivectl/src/client.rs similarity index 100% rename from hive-c0re/src/client.rs rename to hivectl/src/client.rs diff --git a/hive-c0re/src/bin/hivectl/dag_progress.rs b/hivectl/src/dag_progress.rs similarity index 96% rename from hive-c0re/src/bin/hivectl/dag_progress.rs rename to hivectl/src/dag_progress.rs index a6c5c053..8fb6d76a 100644 --- a/hive-c0re/src/bin/hivectl/dag_progress.rs +++ b/hivectl/src/dag_progress.rs @@ -42,10 +42,9 @@ async fn wait_for_dags_plain(socket: &Path, ids: Vec) -> Result<()> { let mut failed: Vec = Vec::new(); while !pending.is_empty() { for id in pending.clone() { - let resp = - hive_c0re::client::request(socket, hive_host_sock::HostRequest::QueueDag { id }) - .await - .with_context(|| format!("connect to daemon socket {}", socket.display()))?; + let resp = crate::client::request(socket, hive_host_sock::HostRequest::QueueDag { id }) + .await + .with_context(|| format!("connect to daemon socket {}", socket.display()))?; let dags = resp.dags.unwrap_or_default(); if dags.is_empty() { // Evicted from the queue's history tail — it finished a @@ -116,10 +115,9 @@ async fn wait_for_dags_animated(socket: &Path, ids: Vec) -> Result<()> { while !pending.is_empty() { let now = now_unix(); for id in pending.clone() { - let resp = - hive_c0re::client::request(socket, hive_host_sock::HostRequest::QueueDag { id }) - .await - .with_context(|| format!("connect to daemon socket {}", socket.display()))?; + let resp = crate::client::request(socket, hive_host_sock::HostRequest::QueueDag { id }) + .await + .with_context(|| format!("connect to daemon socket {}", socket.display()))?; let dags = resp.dags.unwrap_or_default(); if dags.is_empty() { mp.println(format!("job #{id}: gone from queue history")) diff --git a/hive-c0re/src/bin/hivectl.rs b/hivectl/src/main.rs similarity index 92% rename from hive-c0re/src/bin/hivectl.rs rename to hivectl/src/main.rs index b14210fe..940996d3 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hivectl/src/main.rs @@ -20,12 +20,13 @@ use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use clap::{Args, Parser, Subcommand}; +use hive_host_sock::HostRequest; +/// 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. `#[path]` keeps -/// the file under `bin/hivectl/` (a subdir cargo won't treat as its own -/// binary) rather than the sibling `bin/dag_progress.rs` a bare `mod` maps to. -#[path = "hivectl/dag_progress.rs"] +/// plain renderers), split out to keep this file manageable. mod dag_progress; use dag_progress::wait_for_dags; @@ -94,6 +95,13 @@ enum Cmd { #[command(subcommand)] cmd: AgentsCmd, }, + /// Operator approval queue: list pending requests, approve / deny them. + /// Requires the hive-c0re daemon to be running (connects to the host + /// admin socket). + Approvals { + #[command(subcommand)] + cmd: ApprovalsCmd, + }, /// WireGuard inter-hive mesh setup helpers (`services.hyperhive.swarm`). /// /// One-time-setup convenience so nobody has to remember the `wg` dance: @@ -456,8 +464,8 @@ enum GithubCmd { // Default htpasswd file path — the host-side location of the gateway's // credential store, pre-created by a tmpfiles rule when // `services.hyperhive.gateway.auth.enable = true`. Literal lives in -// `hive_c0re::paths`. -use hive_c0re::paths::GATEWAY_HTPASSWD as DEFAULT_HTPASSWD_FILE; +// `hive_host_sock`. +use hive_host_sock::GATEWAY_HTPASSWD as DEFAULT_HTPASSWD_FILE; #[derive(Subcommand)] enum GatewayCmd { @@ -564,9 +572,9 @@ enum QuotaCmd { } // Default host admin socket path. Shared with `hive-c0re`'s `main.rs` -// default via `hive_c0re::paths::HOST_SOCKET` — the daemon binds there +// default via `hive_host_sock::HOST_SOCKET` — the daemon binds there // and `hivectl agents` connects to it. -use hive_c0re::paths::HOST_SOCKET as DEFAULT_HOST_SOCKET; +use hive_host_sock::HOST_SOCKET as DEFAULT_HOST_SOCKET; #[derive(Subcommand)] enum AgentsCmd { @@ -600,6 +608,70 @@ enum AgentsCmd { #[arg(long)] no_wait: bool, }, + /// Spawn a new agent container directly (`h-`). Bypasses the + /// approval queue — operator-on-the-host only. For approval-gated + /// spawns, use `request-spawn`. + Spawn { + /// Agent name (e.g. `iris`). + name: String, + }, + /// Queue a spawn request as an approval. The container is created on + /// `hivectl approvals approve ` (or the dashboard APPR0VE button). + RequestSpawn { + /// Agent name. + name: String, + }, + /// Stop a managed container (graceful). + Kill { + /// Agent name. + name: String, + }, + /// Tear down a sub-agent container. The container is removed; persistent + /// state (config repos + Claude credentials) is kept by default. Pass + /// `--purge` to also wipe the agent's state dirs (config + creds + + /// notes). No undo. + Destroy { + /// Agent name. + name: String, + #[arg(long)] + purge: bool, + }, + /// Apply pending config to a managed container. + Rebuild { + /// Agent name. + name: String, + }, + /// Move an agent in the topology tree. Set `--parent` to a new parent + /// agent name, or pass `--root` to promote the agent to root (no + /// parent). Exactly one is required. Refuses cycles and unknown agents. + 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 pending requests and approve / deny them. +/// Requires the hive-c0re daemon (connects to the host admin socket). +#[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)] @@ -707,11 +779,8 @@ async fn main() -> Result<()> { GatewayCmd::DeleteUser { file, username } => gateway_delete_user(&file, &username), GatewayCmd::ListUsers { file } => gateway_list_users(&file), }, - Cmd::Agents { cmd } => 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, - }, + Cmd::Agents { cmd } => run_agents(&socket, cmd).await, + Cmd::Approvals { cmd } => run_approvals(&socket, cmd).await, Cmd::Wg { cmd } => match cmd { WgCmd::Init { address } => wg_init(&socket, address.as_deref()).await, WgCmd::Peer { @@ -848,7 +917,7 @@ async fn query_hive_domain(socket: &Path) -> Option { /// 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 { - hive_c0re::client::request(socket, hive_host_sock::HostRequest::Urls) + crate::client::request(socket, hive_host_sock::HostRequest::Urls) .await .ok() .and_then(|r| r.urls) @@ -1036,7 +1105,7 @@ async fn quota_enable(socket: &Path) -> Result<()> { /// 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 = hive_c0re::client::request( + let resp = crate::client::request( socket, hive_host_sock::HostRequest::QuotaShow { name: name.map(str::to_owned), @@ -1146,7 +1215,7 @@ fn human_bytes(n: u64) -> String { /// "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_c0re::paths::agent_state_dir(name); + 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!( @@ -1170,10 +1239,10 @@ fn choom(name: &str, resume_session: Option<&str>) -> Result<()> { if !agent_exists(name)? { bail!( "no such agent: '{name}' (no state dir under {}/)", - hive_c0re::paths::AGENTS_ROOT + hive_host_sock::AGENTS_ROOT ); } - let container = hive_c0re::lifecycle::container_name(name); + 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}"); @@ -1291,7 +1360,7 @@ async fn daemon_request( req: hive_host_sock::HostRequest, label: &str, ) -> Result<()> { - let resp = hive_c0re::client::request(socket, req) + let resp = crate::client::request(socket, req) .await .with_context(|| format!("connect to daemon socket {}", socket.display()))?; if !resp.ok { @@ -1337,7 +1406,7 @@ fn resolve_password(password: Option<&str>, password_stdin: bool) -> Result Result<()> { - let resp = hive_c0re::client::request(socket, req) + let resp = crate::client::request(socket, req) .await .with_context(|| format!("connect to daemon socket {}", socket.display()))?; if !resp.ok { @@ -1509,7 +1578,7 @@ fn gateway_list_users(file: &Path) -> Result<()> { // --------------------------------------------------------------------------- async fn agents_restart(socket: &Path, name: &str, no_wait: bool) -> Result<()> { - let resp = hive_c0re::client::request( + let resp = crate::client::request( socket, hive_host_sock::HostRequest::Restart { name: name.to_owned(), @@ -1533,7 +1602,7 @@ async fn agents_restart(socket: &Path, name: &str, no_wait: bool) -> Result<()> /// 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 = hive_c0re::client::request(socket, hive_host_sock::HostRequest::AgentStatus) + let resp = crate::client::request(socket, hive_host_sock::HostRequest::AgentStatus) .await .with_context(|| format!("connect to daemon socket {}", socket.display()))?; if !resp.ok { @@ -1605,7 +1674,7 @@ async fn agents_list(socket: &Path, json: bool) -> Result<()> { } async fn agents_restart_all(socket: &Path, no_wait: bool) -> Result<()> { - let resp = hive_c0re::client::request(socket, hive_host_sock::HostRequest::RestartAll) + 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(&[]); @@ -1631,7 +1700,7 @@ async fn stop( graceful: bool, no_wait: bool, ) -> Result<()> { - let resp = hive_c0re::client::request( + let resp = crate::client::request( socket, hive_host_sock::HostRequest::Stop { scope, graceful }, ) @@ -1646,7 +1715,7 @@ async fn stop( } async fn start(socket: &Path, scope: hive_host_sock::LifecycleScope, no_wait: bool) -> Result<()> { - let resp = hive_c0re::client::request(socket, hive_host_sock::HostRequest::Start { scope }) + 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"); @@ -1672,7 +1741,7 @@ async fn restart( scope: hive_host_sock::LifecycleScope, graceful: bool, ) -> Result<()> { - let resp = hive_c0re::client::request( + let resp = crate::client::request( socket, hive_host_sock::HostRequest::RestartScoped { scope, graceful }, ) @@ -1737,7 +1806,7 @@ async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> { } println!("stopping {name} (releasing its state bind-mount)…"); - let stop_resp = hive_c0re::client::request( + let stop_resp = crate::client::request( socket, hive_host_sock::HostRequest::Stop { scope: single_agent_scope(name), @@ -1774,7 +1843,7 @@ async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> { // start-side failure (incl. the IPC call itself erroring) can't mask the // migration outcome below. println!("starting {name}…"); - let start_result = hive_c0re::client::request( + let start_result = crate::client::request( socket, hive_host_sock::HostRequest::Start { scope: single_agent_scope(name), @@ -1899,6 +1968,67 @@ async fn subvol_snapshot_send( /// touched container, then surface any aggregated per-target failure as a /// non-zero exit. `verb` is the past-tense word printed per item /// (`stopped` / `started`). +/// Dispatch `hivectl agents ` — container lifecycle over the host +/// admin socket. +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?, + ) + } + } +} + +/// Dispatch `hivectl approvals ` — the operator approval queue. +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?) + } + } +} + +/// Pretty-print a `HostResponse` as JSON and bail on failure. Used by the +/// agent-lifecycle + approval verbs that just relay a daemon result verbatim. +fn render(resp: hive_host_sock::HostResponse) -> Result<()> { + println!("{}", serde_json::to_string_pretty(&resp)?); + if !resp.ok { + bail!(resp.error.unwrap_or_else(|| "request failed".to_owned())); + } + Ok(()) +} + fn render_lifecycle(resp: &hive_host_sock::HostResponse, verb: &str) -> Result<()> { let items = resp.agents.as_deref().unwrap_or(&[]); if items.is_empty() { diff --git a/nix/host-modules/hive-c0re/options.nix b/nix/host-modules/hive-c0re/options.nix index 30080d20..2700dfa5 100644 --- a/nix/host-modules/hive-c0re/options.nix +++ b/nix/host-modules/hive-c0re/options.nix @@ -23,8 +23,8 @@ defaultText = lib.literalExpression "hyperhive.packages.\${system}.default"; description = '' hyperhive workspace package. Provides `/bin/hive-c0re` - (coordinator daemon + admin-socket CLI) and `/bin/hivectl` - (operator-facing host CLI for ad-hoc administration). Wired to + (coordinator daemon) and `/bin/hivectl` (operator-facing host + CLI for ad-hoc administration + the host admin socket). Wired to this flake's `packages..default` by `nixosModules.default` (via `lib.mkDefault`, so setting it here wins).