Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hivectl/src/util.rs
atlas 58ef299c3c hive-c0re: remove the approvals subsystem
Nothing submits an approval since scheduled prompts left hive-c0re, so the
queue only acted on rows written before that.

- hive-c0re: the approvals store and its migrations, approve/deny/
  resolve actions, the ResolveApproval tail node (and meta_update's
  approval_id), POST /api/approve and /api/deny, the approval views on
  /api/state, the approval_resolved dashboard event, the orphan GC,
  destroy/purge failing pending rows, and the approval rows in
  get_loose_ends. DestroyBookkeeping loses the purge flag, which only
  worded the approval-failure note.
- The CancelLooseEnd request goes: its only hive-c0re arm was the
  approval one. cancel_loose_end handles reminder and todo in the harness.
- Wire types: hive_sh4re::approvals (ReminderStats moves to inbox),
  LooseEnd::Approval, CancelLooseEndKind, HelperEvent::ApprovalResolved,
  ToolGroup::Approvals, HostRequest::{Pending,Approve,Deny} and
  HostResponse.approvals. HOST_SOCK_VERSION goes to 2 for the removed
  field.
- hivectl loses the approvals verbs; docs/tools/hivectl-cli.md is
  regenerated.
- The system prompt drops approval_resolved.
- The approvals table stays in broker.sqlite, unread, the same way the
  operator_questions table was left when its store was removed.
- An `approvals` entry still in tool-groups.json is skipped with a warning
  by ToolGroup::parse_list, like any unknown group.
- docs/agent-lifecycle/approvals.md is renamed to config-changes.md so the
  code's doc pointers resolve; its content follows in the docs commit.
2026-10-04 23:55:32 +02:00

150 lines
5.8 KiB
Rust

//! Shared helpers used across hivectl's command handling: daemon
//! request/response plumbing (`daemon_request`, `render`,
//! `render_lifecycle`) and password input (`resolve_password`).
use std::path::Path;
use anyhow::{Context as _, Result, bail};
/// Parse a CLI-supplied agent/account name into a validated
/// [`hive_types::Ident`], mapping the parse error to an `anyhow` error that
/// names the offending input. Used at hivectl's `HostRequest` construction
/// sites so the wire `Ident` fields are built from validated names (the daemon
/// re-validates on deserialize; parsing here gives the operator an immediate,
/// local error instead of a round-trip rejection).
pub(crate) fn parse_ident(name: &str) -> Result<hive_types::Ident> {
hive_types::Ident::parse(name).map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))
}
/// 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` / `gateway`).
pub(crate) async fn daemon_request(
socket: &Path,
req: hive_host_sock::HostRequest,
label: &str,
) -> Result<()> {
// No extra context on the request: `client::request` already names the
// socket and classifies the failure, and wrapping it here would put a
// vaguer line on top of the actionable one.
let resp = crate::client::request(socket, req).await?;
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 <PW>` 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.
pub(crate) fn resolve_password(
password: Option<&str>,
password_stdin: bool,
) -> Result<Option<String>> {
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)
}
/// Pretty-print a `HostResponse` as JSON and bail on failure. Used by the
/// agent-lifecycle verbs that just relay a daemon result verbatim.
pub(crate) 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(())
}
/// Render a hive-wide stop/start/restart response: one `<verb>: <name>`
/// line per 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`).
pub(crate) fn render_lifecycle(resp: &hive_host_sock::HostResponse, verb: &str) -> Result<()> {
let items = resp.agents.as_deref().unwrap_or(&[]);
if items.is_empty() {
println!("{verb}: nothing matched the requested scope");
} else {
for item in items {
println!("{verb}: {item}");
}
}
if !resp.ok {
bail!(
"{verb}: {}",
resp.error.as_deref().unwrap_or("unknown error")
);
}
Ok(())
}
/// Query this hive's domain + browser-facing web URLs (`HostRequest::Urls`).
///
/// `Ok(None)` = the daemon answered and has nothing to report; `Err` = it was
/// never reached, and the error carries the actionable connect hint (see
/// [`crate::client::request`]). Keep the two apart: collapsing the error into
/// `None` here is what made a permission problem on the socket read as "is
/// hive-c0re running?", sending the operator to fix the wrong thing.
pub(crate) async fn query_hive_urls(socket: &Path) -> Result<Option<hive_host_sock::HiveUrls>> {
Ok(
crate::client::request(socket, hive_host_sock::HostRequest::Urls)
.await?
.urls,
)
}
/// True when `name` matches an existing hyperhive agent — i.e. it has a
/// persistent state dir under the agents root. The state dir (not the
/// live container list) is the test, so kept-state tombstones still
/// resolve as agents: re-provisioning a destroyed-but-kept agent should
/// drop its token into the existing state tree.
///
/// Asks the daemon rather than stat-ing the path. hivectl used to check
/// locally, but the agents root is `0700` and owned by the daemon's
/// user, so an operator without root got EACCES on traversal — which
/// this function then had to report as "needs root", turning every
/// caller's existence guard into a permission error. The daemon owns
/// that directory and answers the same question over the socket, which
/// operators can already reach via the `hive-admin` group, so the guard
/// works sudoless and the remaining root requirements (if any) surface
/// where they actually are.
pub(crate) async fn agent_exists(socket: &Path, name: &str) -> Result<bool> {
let resp = crate::client::request(
socket,
hive_host_sock::HostRequest::AgentExists {
name: parse_ident(name)?,
},
)
.await?;
if !resp.ok {
bail!(
"check whether {name:?} is an agent: {}",
resp.error.as_deref().unwrap_or("unknown error")
);
}
resp.agent_exists
.context("daemon answered the agent-exists check without a result")
}