145 lines
6.6 KiB
Rust
145 lines
6.6 KiB
Rust
//! `hivectl agent <name> 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 <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 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 <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}"))
|
|
}
|