hivectl: say which of the three socket failures actually happened

`hivectl open forge` on a host where the daemon is fine and the socket is
fine printed "could not reach the hive-c0re daemon for URLs — is hive-c0re
running?". It was running. The operator was not in `hive-admin` in that
shell, and the connect got EACCES.

The message was a guess, not a diagnosis, because `query_hive_urls`
returned `Option` and threw the cause away with `.ok()`. Three different
failures — not in the group, no socket at all, nobody listening — all
arrived as the same sentence, and only one of the three is fixed by
looking at the daemon.

Classify the connect error in `client::request`, which every
daemon-assisted verb goes through, and keep the io error as the anyhow
cause so the output reads fix-first. EACCES names `hive-admin`,
`services.hyperhive.adminUsers`, and — the part that actually bites — the
re-login, since secondary group membership is only applied at login, so a
shell opened before the grant still cannot connect. ENOENT and
ECONNREFUSED point at the units instead.

Then stop discarding it: `query_hive_urls` returns `Result<Option<_>>`,
`open` and `require_hive_domain` propagate, and `daemon_request` drops its
own "connect to daemon socket" context, which only buried the actionable
line under a vaguer one. `wg init`'s domain lookup stays best-effort by
an explicit `.ok().flatten()` rather than by accident.

Same footgun `agent_exists` was already fixed for: a permission error
collapsed into a value that reads as a different, wrong story.
This commit is contained in:
atlas 2026-07-26 16:27:38 +02:00 committed by mara
commit f108c72f25
4 changed files with 139 additions and 28 deletions

View file

@ -24,9 +24,10 @@ pub(crate) async fn daemon_request(
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()))?;
// 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}: {}",
@ -100,13 +101,19 @@ pub(crate) fn render_lifecycle(resp: &hive_host_sock::HostResponse, verb: &str)
Ok(())
}
/// Best-effort query for this hive's domain + browser-facing web URLs
/// (`HostRequest::Urls`). `None` when the daemon is unreachable.
pub(crate) async fn query_hive_urls(socket: &Path) -> Option<hive_host_sock::HiveUrls> {
crate::client::request(socket, hive_host_sock::HostRequest::Urls)
.await
.ok()
.and_then(|r| r.urls)
/// 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