//! `hivectl agent choom` — 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; /// Whether the caller's supplementary groups include `hive-admin`. /// /// hive-c0re's polkit rule (`nix/host-modules/hive-c0re/default.nix`) grants /// `org.freedesktop.machine1.shell` to this group, so a member reaches the /// same outcome as root without one. `getgrnam`/`getgroups` read the /// *process's* actual credentials, the same ones the kernel would check — /// not a fresh `/etc/group` lookup by name — so this can say no for a user /// who was just added to the group but hasn't logged back in yet, same /// caveat as the host-socket grant (`hivectl/src/client.rs::connect_hint`). fn in_hive_admin_group() -> bool { // SAFETY: `getgrnam` takes a valid NUL-terminated string literal and // returns either null (no such group) or a pointer to static storage // owned by libc, which is read once, immediately, before any other // libc call could invalidate it. let gid = unsafe { let grp = libc::getgrnam(c"hive-admin".as_ptr()); if grp.is_null() { return false; } (*grp).gr_gid }; // SAFETY: a zero-length call with a null buffer is documented to return // the caller's supplementary-group count without writing anything, so // the real buffer below is sized to exactly what the second call needs. let count = unsafe { libc::getgroups(0, std::ptr::null_mut()) }; let Ok(count) = usize::try_from(count) else { return false; }; let mut groups = vec![0u32; count]; let Ok(capacity) = i32::try_from(groups.len()) else { return false; }; // SAFETY: `capacity` is exactly `groups.len()`, so this write cannot // overflow the buffer. let n = unsafe { libc::getgroups(capacity, groups.as_mut_ptr()) }; let Ok(n) = usize::try_from(n) else { return false; }; groups.truncate(n); groups.contains(&gid) } /// Refuse early when the caller can't actually run `machinectl shell`. /// /// Root always can. So can a `hive-admin` member, via the polkit grant /// above. Anyone else hits an opaque polkit authentication prompt/refusal /// from systemd that names neither hivectl nor the fix — naming both here /// instead is the entire point of this check. fn require_privilege() -> 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 && !in_hive_admin_group() { bail!( "choom needs root or `hive-admin` group membership: it runs `machinectl shell`, \ gated by a polkit rule for that group. If you were just added to `hive-admin`, \ log out and back in first — secondary group membership applies at login, so a \ shell opened before the change still won't have it. `id -nG` shows what your \ running shell actually has" ); } Ok(()) } /// Drop into an interactive Claude session in the agent container. /// /// Execs `machinectl shell @h-` 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 or `hive-admin` /// group membership, plus 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_privilege()?; 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 ` passes through as `claude --resume ` — // 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}")) }