hyperhive/hivectl/src/choom.rs
atlas 170fd817ea 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.
2026-07-27 09:34:43 +02:00

101 lines
4.5 KiB
Rust

//! `hivectl choom <agent>` — drop into an interactive Claude session inside
//! an agent container by exec-ing `machinectl shell` running claude as the
//! agent user, mirroring the harness's per-turn claude invocation.
#[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
/// user from its state dir, reproducing the harness's per-turn claude
/// invocation (flags, session selection, why it never collides with the
/// live harness session). See `docs/tools/hivectl.md` (Choom) for the
/// full rationale. Inherits the caller's PTY; requires root + a running
/// container.
///
/// 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`.
let target = format!("{name}@{container}");
let claude = "/run/current-system/sw/bin/claude";
// Bind-mounted state dir; matches `hive-agent::paths::state_dir()`.
let state_dir = format!("/agents/{name}/state");
// Per-turn config the harness writes; matches `paths::config_dir()`.
let cfg = "/run/hive-config";
// `--resume <value>` passes through as `claude --resume <value>` —
// the rejoin-by-id surface. (claude's `--continue` is a bare flag
// that resumes the cwd's latest session — the harness's — and would
// consume a trailing value as the first PROMPT; choom never uses
// it.) The value is single-quoted into the shell script, so reject
// an embedded single quote (the only char that breaks
// single-quoting) to rule out injection — session ids never
// contain one.
let session_arg = match resume_session {
Some(val) => {
if val.contains('\'') {
bail!("invalid --resume value '{val}': must not contain a single quote");
}
format!("set -- --resume '{val}';")
}
None => "set --;".to_string(),
};
// Build claude's argv as the harness does, each flag included only
// when its file is present so a half-up container degrades to a bare
// session. `name` is `[a-z0-9._-]` so there's nothing to quote.
let inner = format!(
"cd {state_dir} || exit 1; {session_arg} \
[ -f {cfg}/claude-settings.json ] && set -- \"$@\" --settings {cfg}/claude-settings.json; \
[ -f {cfg}/claude-mcp-config.json ] && set -- \"$@\" --mcp-config {cfg}/claude-mcp-config.json; \
[ -f {cfg}/claude-system-prompt.md ] && set -- \"$@\" --system-prompt-file {cfg}/claude-system-prompt.md; \
exec {claude} \"$@\""
);
let mut cmd = std::process::Command::new("machinectl");
cmd.arg("shell")
.arg(&target)
.arg("/bin/sh")
.arg("-lc")
.arg(&inner);
// exec() replaces the current process — we inherit stdin/stdout/stderr
// (the caller's PTY) so the Claude session is fully interactive.
// This call only returns on error.
let err = cmd.exec();
Err(anyhow::anyhow!("exec machinectl: {err}"))
}