fix(hivectl): ask the daemon whether an agent exists

The agents root is 0700 and owned by the daemon's user, so hivectl's
client-side existence guard hit EACCES on traversal for anyone not root.
It reported that as "this command needs root; re-run with sudo", which
turned three verbs' pre-flight check into a permission error about the
wrong thing: `choom`, `subvol upgrade` and `subvol snapshot create` all
failed at the guard rather than at whatever they actually needed.

The daemon runs as the owning user and already answers this question for
its own provisioning paths, so expose it on the host socket as
`AgentExists` and have hivectl ask. Operators reach that socket through
the `hive-admin` group, so the guard now works without sudo.

`choom` still needs root for `machinectl shell` — we ship no polkit rule
granting those actions — so it now checks the effective uid and says so
directly instead of failing later inside systemd's authorisation.
This commit is contained in:
atlas 2026-07-26 20:57:49 +02:00 committed by mara
commit 170fd817ea
9 changed files with 119 additions and 36 deletions

1
Cargo.lock generated
View file

@ -1817,6 +1817,7 @@ dependencies = [
"hive-sh4re",
"hive-types",
"indicatif",
"libc",
"serde_json",
"tokio",
"tracing-subscriber",

View file

@ -211,7 +211,17 @@ new containers and restarts pick the values up immediately.
Drop into an interactive Claude session inside an agent container.
Replaces the current process with `machinectl shell <name>@h-<name>`
running claude from the agent's state dir. Requires root (same as all
`machinectl shell` operations).
`machinectl shell` operations) — hyperhive ships no polkit rule granting
those actions to the operator group, so `choom` refuses up front with a
message naming that requirement rather than letting systemd reject the
exec later.
It also needs the daemon socket, unlike the other exec-into-a-container
paths: the "is this actually an agent?" pre-flight reads the agents root,
which is owned by the daemon's user and not group-readable, so the check
is a `HostRequest` rather than a local `stat`. A rootless `choom` therefore
tells you it needs root, instead of reporting a permission problem with
the state dir.
```bash
hivectl choom iris # fresh blank Claude session in iris's container

View file

@ -162,6 +162,10 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
HostResponse::dags(dags)
}
HostRequest::List => HostResponse::list(lifecycle::list().await?),
// The agents root is ours and not world-traversable, so this
// question is only answerable on this side of the socket —
// see the request's doc comment for why the client asks.
HostRequest::AgentExists { name } => HostResponse::agent_exists(agent_exists(name)?),
HostRequest::AgentStatus => handle_agent_status(&coord).await,
// The hive domain + per-surface public URLs are injected into
// c0re's service env by hive-c0re.nix; surface them so the
@ -380,7 +384,12 @@ fn matrix_http_client() -> Result<reqwest::Client> {
}
/// True when `name` has a state dir under the agents root, i.e. it's a
/// managed agent rather than a bare (operator/human) matrix account.
/// managed agent rather than a bare (operator/human) account.
///
/// Used by the provisioning handlers below to tell an agent account from
/// a human one, and exposed over the socket as
/// [`HostRequest::AgentExists`] for clients that can't read the agents
/// root themselves (it's `0700` and owned by the daemon's user).
fn agent_exists(name: &hive_types::Ident) -> Result<bool> {
crate::paths::agent_state_dir(name)
.try_exists()

View file

@ -136,6 +136,19 @@ pub enum HostRequest {
Rebuild { name: Ident },
/// List managed containers.
List,
/// Report whether `name` is a managed agent, i.e. whether it has a
/// persistent state dir under the agents root. Answered daemon-side
/// because that root is `0700 hive-core`: a client stat-ing it
/// without root gets EACCES, so the pre-flight "does this agent
/// exist?" guard in front of `hivectl choom` / `agents subvol` used
/// to fail with a permission error instead of an answer. The daemon
/// already runs as the owning user and does the same check for its
/// own provisioning paths. Result: [`HostResponse::agent_exists`].
///
/// State dir, not the live container list — a destroyed-but-kept
/// agent is still an agent, and re-provisioning one should drop its
/// credentials into the existing state tree.
AgentExists { name: Ident },
/// List managed agents with their full status + technical state
/// (running / needs-login / needs-update / deployed sha / parent /
/// pending reminders) — the `hivectl agents list` roster view.
@ -441,6 +454,13 @@ pub struct HostResponse {
/// request kind.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_statuses: Option<Vec<AgentStatusRow>>,
/// `AgentExists` result — whether the named agent has a state dir
/// under the agents root. `None` for every other request kind, which
/// is why it's an `Option<bool>` and not a bare `bool`: a client must
/// be able to tell "the daemon said no" from "the daemon answered a
/// different question".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_exists: Option<bool>,
/// Ids of the job-queue DAGs this request submitted (rebuild /
/// restart / power ops). Clients poll them via
/// [`HostRequest::QueueDag`]; `None` for non-submitting requests.
@ -520,6 +540,16 @@ impl HostResponse {
}
}
/// `AgentExists` result — whether the named agent has a state dir.
#[must_use]
pub fn agent_exists(exists: bool) -> Self {
Self {
ok: true,
agent_exists: Some(exists),
..Self::default()
}
}
/// A request that submitted job-queue DAGs — carries their ids for
/// the client's wait/progress loop.
#[must_use]

View file

@ -19,6 +19,7 @@ hive-host-sock.workspace = true
hive-sh4re.workspace = true
hive-types.workspace = true
indicatif.workspace = true
libc.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing-subscriber.workspace = true

View file

@ -4,11 +4,32 @@
#[cfg(unix)]
use std::os::unix::process::CommandExt as _;
use std::path::Path;
use anyhow::{Result, bail};
use crate::util::agent_exists;
/// Refuse early when the caller isn't root.
///
/// `machinectl shell` needs root or a polkit grant, and hyperhive ships
/// no polkit rules — so unprivileged callers hit an authentication
/// prompt/refusal from systemd that says nothing about which hivectl
/// verb wanted it. Naming the requirement here keeps the failure honest
/// and points at the one thing that would fix it.
fn require_root() -> Result<()> {
// SAFETY: `geteuid` takes no arguments, reads a process attribute
// the kernel always has, and cannot fail.
let euid = unsafe { libc::geteuid() };
if euid != 0 {
bail!(
"choom needs root: it runs `machinectl shell`, which requires root \
(hyperhive ships no polkit rule granting it) - re-run with sudo"
);
}
Ok(())
}
/// Drop into an interactive Claude session in the agent container.
///
/// Execs `machinectl shell <name>@h-<name>` running claude as the agent
@ -17,13 +38,19 @@ use crate::util::agent_exists;
/// 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)? {
///
/// The existence check goes over the daemon socket even though the exec
/// itself doesn't need it: the agents root isn't readable by the
/// operator group, so checking locally reported every rootless invocation
/// as a permission problem with the state dir rather than with the shell.
pub(crate) async fn choom(socket: &Path, name: &str, resume_session: Option<&str>) -> Result<()> {
if !agent_exists(socket, name).await? {
bail!(
"no such agent: '{name}' (no state dir under {}/)",
hive_host_sock::AGENTS_ROOT
);
}
require_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`.

View file

@ -6,9 +6,12 @@
//! restart|…>`, `approvals <pending|approve|deny>`, `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.
//! logic — a running daemon is required for those. A couple of verbs work
//! off local host state directly instead (`wg` / `peer-config` read the mesh
//! key + TLS CA), so they don't need the socket. `choom` execs into a
//! container rather than asking the daemon to do anything, but still uses the
//! socket for its "is this an agent?" pre-flight — that answer lives in a
//! directory only the daemon's user can read.
//!
//! One module per subcommand family (see the `mod` list below); `main` is
//! just the clap parse + the top-level dispatch match.
@ -123,7 +126,7 @@ async fn main() -> Result<()> {
Cmd::Choom {
name,
resume_session,
} => choom(&name, resume_session.as_deref()),
} => choom(&socket, &name, resume_session.as_deref()).await,
Cmd::MarkdownDocs => {
print!("{}", clap_markdown::help_markdown::<Cli>());
Ok(())

View file

@ -52,7 +52,7 @@ pub(crate) async fn dispatch_subvol(socket: &Path, cmd: SubvolCmd) -> Result<()>
}
async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> {
if !agent_exists(name)? {
if !agent_exists(socket, name).await? {
bail!("no agent named {name:?} (no state dir under the agents root)");
}
if !yes {
@ -151,7 +151,7 @@ async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> {
/// enforces the same rules server-side, so this check is
/// belt-and-suspenders (fail fast client-side with a clear message).
async fn subvol_snapshot_create(socket: &Path, name: &str, label: String) -> Result<()> {
if !agent_exists(name)? {
if !agent_exists(socket, name).await? {
bail!("no agent named {name:?} (no state dir under the agents root)");
}
if !label.starts_with("hive-") {

View file

@ -117,32 +117,34 @@ pub(crate) async fn query_hive_urls(socket: &Path) -> Result<Option<hive_host_so
}
/// 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.
/// 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.
///
/// 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.
pub(crate) fn agent_exists(name: &str) -> Result<bool> {
let Ok(name) = hive_types::Ident::parse(name) else {
bail!("invalid agent name {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!(
"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())),
/// 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")
}