feat(#2014): hivectl open verb + Urls host request for web surfaces

This commit is contained in:
damocles 2026-06-26 23:13:06 +02:00 committed by mara
commit cae1dd8147
6 changed files with 200 additions and 32 deletions

View file

@ -36,6 +36,7 @@ This document contains the help content for the `hivectl` command-line program.
* [`hivectl quota limit`↴](#hivectl-quota-limit)
* [`hivectl subvol`↴](#hivectl-subvol)
* [`hivectl subvol upgrade`↴](#hivectl-subvol-upgrade)
* [`hivectl open`↴](#hivectl-open)
* [`hivectl completions`↴](#hivectl-completions)
## `hivectl`
@ -58,6 +59,7 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that
* `restart` — Restart containers hive-wide — `stop` then `start` over the same scope. Bare `hivectl restart` restarts **everything** (all sub-agents plus the ci/forge/gateway/matrix infra containers); the same scope flags as `stop`/`start` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, `--matrix`, `--agent <name>`). If the stop phase reports a failure the start phase is skipped so the operator can investigate. Requires the hive-c0re daemon
* `quota` — Per-agent disk accounting + optional quotas via btrfs qgroups
* `subvol` — btrfs subvolume management for agent state dirs
* `open` — Print (and best-effort open in a browser) a hive web surface URL
* `completions` — Generate a shell completion script for `hivectl` and print it to stdout
###### **Options:**
@ -527,6 +529,31 @@ Convert an existing plain-dir agent state root into a btrfs subvolume in place.
## `hivectl open`
Print (and best-effort open in a browser) a hive web surface URL.
Resolves the URL from the running daemon (`HostRequest::Urls`), so custom forge / matrix domains work without guessing `forge.<domain>`. Prints the URL unconditionally — the reliable core, since the host is usually headless / driven over SSH where `xdg-open` is a no-op — then tries `xdg-open` as a convenience. Bare `hivectl open` opens the operator dashboard.
**Usage:** `hivectl open [TARGET]`
###### **Arguments:**
* `<TARGET>` — Which surface to open. Defaults to the operator dashboard
Default value: `home`
Possible values:
- `home`:
The operator dashboard (`https://<domain>/`)
- `forge`:
The forge (Forgejo) web UI
- `matrix`:
The matrix GUI (fluffychat)
## `hivectl completions`
Generate a shell completion script for `hivectl` and print it to stdout.

View file

@ -151,3 +151,29 @@ lands in a faithful copy of the agent's environment:
the hyperhive/matrix MCP tools (which `--continue` needs to replay a
tool-using history), and the role prompt. Each flag is included only
when its file exists.
## Open
Print (and best-effort open in a browser) one of the hive's web surfaces.
Requires the `hive-c0re` daemon to be running.
```bash
hivectl open # operator dashboard (same as `open home`)
hivectl open home # operator dashboard (https://<domain>/)
hivectl open forge # the forge (Forgejo) web UI
hivectl open matrix # the matrix GUI (fluffychat)
```
The URL is resolved from the running daemon (`HostRequest::Urls`), which
reads the per-surface public URLs from c0re's service env — so custom
forge / matrix domains resolve correctly instead of assuming
`forge.<domain>`. The URL is **always printed** (the reliable core, since
the host is usually headless / driven over SSH), then `xdg-open` is tried
as a convenience — a missing or failing opener is reported as a note, not
an error.
A surface has no URL when it isn't browser-reachable: `home` needs
`services.hyperhive.domain`; `forge` needs
`services.hyperhive.forge.behindGateway = true`; `matrix` needs
`services.hyperhive.matrix.gui.enable = true`. In those cases the command
exits with a hint naming the option to set.

View file

@ -204,6 +204,19 @@ enum Cmd {
#[command(subcommand)]
cmd: SubvolCmd,
},
/// Print (and best-effort open in a browser) a hive web surface URL.
///
/// Resolves the URL from the running daemon (`HostRequest::Urls`), so
/// custom forge / matrix domains work without guessing `forge.<domain>`.
/// Prints the URL unconditionally — the reliable core, since the host
/// is usually headless / driven over SSH where `xdg-open` is a no-op —
/// then tries `xdg-open` as a convenience. 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 (not part of day-to-day operator admin):
@ -227,6 +240,17 @@ enum Cmd {
},
}
/// Which hive web surface `hivectl open` targets.
#[derive(Copy, Clone, Debug, clap::ValueEnum)]
enum OpenTarget {
/// The operator dashboard (`https://<domain>/`).
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,
@ -628,6 +652,7 @@ async fn main() -> Result<()> {
print!("{}", clap_markdown::help_markdown::<Cli>());
Ok(())
}
Cmd::Open { target } => open_url(&socket, target).await,
Cmd::Completions { shell } => {
generate_completions(shell);
Ok(())
@ -635,6 +660,41 @@ async fn main() -> Result<()> {
}
}
/// `open <home|forge|matrix>` — 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.context(
"could not reach the hive-c0re daemon for URLs — is hive-c0re running? \
(the socket is at /run/hyperhive/host.sock)",
)?;
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.
@ -658,14 +718,20 @@ const WG_INTERFACE: &str = "wg-hive";
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::HiveDomain`, 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.
/// (`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<String> {
hive_c0re::client::request(socket, hive_sh4re::HostRequest::HiveDomain)
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<hive_sh4re::HiveUrls> {
hive_c0re::client::request(socket, hive_sh4re::HostRequest::Urls)
.await
.ok()
.and_then(|r| r.domain)
.and_then(|r| r.urls)
}
/// Require this hive's domain from the daemon for snippet generation.

View file

@ -155,16 +155,12 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
.collect();
HostResponse::agent_statuses(rows)
}
// The hive domain is injected into c0re's service env by
// hive-c0re.nix (`HYPERHIVE_HIVE_DOMAIN`); surface it so the
// operator CLI can fill in this hive's own identity.
HostRequest::HiveDomain => HostResponse::hive_domain(
// Treat an empty env value as unset — otherwise the CLI
// would emit `swarm.peers."" = …`, invalid nix.
std::env::var("HYPERHIVE_HIVE_DOMAIN")
.ok()
.filter(|d| !d.is_empty()),
),
// The hive domain + per-surface public URLs are injected into
// c0re's service env by hive-c0re.nix; surface them so the
// operator CLI can fill in this hive's own identity (the
// federation peer-config block) and open the web surfaces
// (`hivectl open`).
HostRequest::Urls => HostResponse::urls(hive_urls()),
HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?),
HostRequest::Approve { id } => {
actions::approve(coord.clone(), *id).await?;
@ -261,7 +257,7 @@ async fn handle_restart_all() -> Result<HostResponse> {
error: Some(errors.join("; ")),
agents: Some(ok_agents),
approvals: None,
domain: None,
urls: None,
agent_statuses: None,
})
}
@ -379,6 +375,25 @@ fn is_broad_scope(scope: &LifecycleScope) -> bool {
scope.agents || scope.is_everything()
}
/// Assemble this hive's domain + browser-facing web URLs from c0re's
/// service env (injected by hive-c0re.nix). Each field is `None` when its
/// surface isn't browser-reachable (domain unset, forge not behind the
/// gateway, matrix GUI off), so the CLI can hint precisely instead of
/// opening a dead link. Scheme matches the existing `HIVE_FORGE_PUBLIC_URL`
/// convention (gateway terminates TLS, so https).
fn hive_urls() -> hive_sh4re::HiveUrls {
// Treat an empty env value as unset everywhere — an empty domain would
// otherwise render `swarm.peers."" = …` (invalid nix) and `https:///`.
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let domain = env("HYPERHIVE_HIVE_DOMAIN");
hive_sh4re::HiveUrls {
home: domain.as_ref().map(|d| format!("https://{d}/")),
forge: env("HIVE_FORGE_PUBLIC_URL"),
matrix: env("HIVE_MATRIX_PUBLIC_URL"),
domain,
}
}
async fn scoped_agents(scope: &LifecycleScope) -> Result<Vec<String>> {
use std::collections::BTreeSet;
let mut set: BTreeSet<String> = BTreeSet::new();
@ -430,7 +445,7 @@ fn finish_lifecycle(ok_items: Vec<String>, errors: &[String]) -> HostResponse {
error: Some(errors.join("; ")),
agents: Some(ok_items),
approvals: None,
domain: None,
urls: None,
agent_statuses: None,
}
}

View file

@ -50,10 +50,13 @@ pub enum HostRequest {
/// Reuses the dashboard's per-agent `ContainerView` aggregation.
AgentStatus,
/// Report this hive's canonical DNS domain
/// (`services.hyperhive.domain`), or `None` when unset. Lets the
/// operator CLI fill in the hive's own identity (e.g. the federation
/// peer-config block) without the operator retyping it.
HiveDomain,
/// (`services.hyperhive.domain`) plus the browser-facing home /
/// forge / matrix URLs, daemon-sourced so custom forge/matrix
/// domains resolve correctly. Each URL is `None` when its subsystem
/// is unreachable from a browser (e.g. forge not behind the gateway,
/// matrix GUI disabled). Backs `hivectl open` + the federation
/// peer-config block (which reads the bare `domain`).
Urls,
/// List pending approval requests.
Pending,
/// Approve a pending request by id; the action runs immediately.
@ -132,6 +135,25 @@ impl LifecycleScope {
}
}
/// This hive's canonical domain plus the browser-facing URLs for its
/// web surfaces — the `Urls` request result. Every field is `None` when
/// the corresponding surface can't be reached from a browser (domain
/// unset, forge not behind the gateway, matrix GUI disabled), so the CLI
/// can give a precise hint instead of opening a dead link.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HiveUrls {
/// Canonical hive domain (`services.hyperhive.domain`).
pub domain: Option<String>,
/// Operator dashboard root (`https://<domain>/`).
pub home: Option<String>,
/// Forge browser URL (`HIVE_FORGE_PUBLIC_URL`) — only the
/// behind-gateway public URL; `None` on direct-port forge deploys.
pub forge: Option<String>,
/// Matrix GUI (fluffychat) browser URL — `None` when the matrix GUI
/// is disabled.
pub matrix: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostResponse {
pub ok: bool,
@ -141,10 +163,10 @@ pub struct HostResponse {
pub agents: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approvals: Option<Vec<Approval>>,
/// This hive's canonical DNS domain — `HiveDomain` result. `None`
/// when the domain is unset (no `services.hyperhive.domain`).
/// `Urls` result — this hive's domain plus the browser-facing
/// home / forge / matrix URLs. `None` for every other request kind.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
pub urls: Option<HiveUrls>,
/// `AgentStatus` result — one row per managed agent with its
/// running/health flags + technical state. `None` for every other
/// request kind.
@ -245,7 +267,7 @@ impl HostResponse {
error: None,
agents: None,
approvals: None,
domain: None,
urls: None,
agent_statuses: None,
}
}
@ -257,7 +279,7 @@ impl HostResponse {
error: Some(message.into()),
agents: None,
approvals: None,
domain: None,
urls: None,
agent_statuses: None,
}
}
@ -269,7 +291,7 @@ impl HostResponse {
error: None,
agents: Some(agents),
approvals: None,
domain: None,
urls: None,
agent_statuses: None,
}
}
@ -281,20 +303,20 @@ impl HostResponse {
error: None,
agents: None,
approvals: Some(approvals),
domain: None,
urls: None,
agent_statuses: None,
}
}
/// `HiveDomain` result — this hive's canonical domain (or `None`).
/// `Urls` result — this hive's domain + browser-facing web URLs.
#[must_use]
pub fn hive_domain(domain: Option<String>) -> Self {
pub fn urls(urls: HiveUrls) -> Self {
Self {
ok: true,
error: None,
agents: None,
approvals: None,
domain,
urls: Some(urls),
agent_statuses: None,
}
}
@ -307,7 +329,7 @@ impl HostResponse {
error: None,
agents: None,
approvals: None,
domain: None,
urls: None,
agent_statuses: Some(rows),
}
}

View file

@ -953,6 +953,18 @@ in
# falls back to `<hostname>:3000`.
HIVE_FORGE_PUBLIC_URL = "https://${config.services.hyperhive.forge.domain}";
}
//
lib.optionalAttrs
(
config.services.hyperhive.matrix.gui.enable && config.services.hyperhive.matrix.gatewayHost != null
)
{
# Browser-facing matrix GUI (fluffychat) URL — the gateway
# vhost (`matrix.<domain>`). Surfaced via the daemon's `Urls`
# request for `hivectl open matrix`. Absent when the GUI is off
# or no gatewayHost is set (no browser-reachable matrix vhost).
HIVE_MATRIX_PUBLIC_URL = "https://${config.services.hyperhive.matrix.gatewayHost}/";
}
// lib.optionalAttrs (config.services.hyperhive.swarm.peers != { }) {
# Peer hives serialised as a JSON array of {domain, cert_fingerprint,
# wireguard_address?} objects. Consumed by hive-ag3nt::identity::peers()