agents: drop root, run as per-agent unix user with passwordless sudo (#658)

This commit is contained in:
damocles 2026-05-30 21:14:46 +02:00 committed by Mara
commit 6b6c6775ee
10 changed files with 349 additions and 71 deletions

View file

@ -10,9 +10,11 @@
use std::path::{Path, PathBuf};
/// Returns the Claude credentials directory for this agent, derived from
/// `HIVE_LABEL`. Manager ("hm1nd") uses `/root/.claude`; sub-agents use
/// `/agents/{label}/claude`. Overridable via `HYPERHIVE_CLAUDE_DIR`.
/// Returns the Claude credentials directory for this agent. Delegates
/// to `paths::claude_dir`, which reads `$HOME/.claude` (post-#658 the
/// service runs as a non-root unix user named after the agent, so
/// `$HOME` resolves to `/home/<agent>` and the OAuth dir lives at
/// `/home/<agent>/.claude`). Overridable via `HYPERHIVE_CLAUDE_DIR`.
#[must_use]
pub fn default_dir() -> PathBuf {
crate::paths::claude_dir()

View file

@ -62,9 +62,11 @@ impl LoginSession {
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// `claude` reads $HOME for the credentials dir; the bind-mount
// puts it at /root/.claude, which is already the default home
// for uid 0 inside the container. Nothing extra to set here.
// `claude` reads $HOME/.claude for the credentials dir. The
// harness service env sets HOME to /home/<agent> (post-#658)
// and the bind-mount lands the OAuth dir at the same path,
// so the child inherits the right HOME without any further
// wiring here.
.kill_on_drop(true)
.spawn()
.with_context(|| format!("spawn `{cmd}`"))?;

View file

@ -1,7 +1,9 @@
//! Per-agent path resolution for state and credential directories.
//!
//! All agents (including the manager "hm1nd") use `/agents/{label}/state`.
//! Claude credentials are always at `/root/.claude` for all agents.
//! Claude credentials live at `$HOME/.claude` (post-#658:
//! `/home/<agent-name>/.claude` because the harness service now runs
//! as a non-root unix user matching the agent label).
//!
//! Both paths can be overridden via env vars (`HYPERHIVE_STATE_DIR`,
//! `HYPERHIVE_CLAUDE_DIR`) for dev / test scenarios.
@ -21,12 +23,22 @@ pub fn state_dir() -> PathBuf {
PathBuf::from(format!("/agents/{label}/state"))
}
/// Claude credentials directory for the current agent. Always `/root/.claude`
/// because the `claude` CLI reads `$HOME/.claude` (uid 0 → `/root`), and
/// hive-c0re binds the per-agent credentials dir there for every container.
/// Claude credentials directory for the current agent. `$HOME/.claude`
/// matches what the `claude` CLI reads at runtime — both binaries see
/// the same `$HOME` set by the per-service systemd `environment`
/// declaration (`/home/<agent>` post-#658). Falls back to `/root/.claude`
/// for dev / test environments where `HOME` isn't set so the previous
/// root-by-default shape keeps working without env wiring.
/// Overridable via `HYPERHIVE_CLAUDE_DIR` for dev / test scenarios.
#[must_use]
pub fn claude_dir() -> PathBuf {
std::env::var_os("HYPERHIVE_CLAUDE_DIR")
.map_or_else(|| PathBuf::from("/root/.claude"), PathBuf::from)
if let Some(p) = std::env::var_os("HYPERHIVE_CLAUDE_DIR") {
return PathBuf::from(p);
}
if let Some(home) = std::env::var_os("HOME") {
let mut path = PathBuf::from(home);
path.push(".claude");
return path;
}
PathBuf::from("/root/.claude")
}

View file

@ -47,8 +47,9 @@ const RATE_LIMIT_MARKERS: &[&str] = &[
];
/// Substrings that indicate the Anthropic API rejected the request as
/// unauthenticated — the OAuth session in `/root/.claude/` has expired
/// or been revoked. Surfaced as `TurnOutcome::AuthFailed`, which the
/// unauthenticated — the OAuth session in `$HOME/.claude/` (post-#658
/// `/home/<agent>/.claude`, previously `/root/.claude`) has expired or
/// been revoked. Surfaced as `TurnOutcome::AuthFailed`, which the
/// harness uses to flip the container into `needs_login_idle` so the
/// dashboard's re-auth flow takes over (closes #419). Matched against
/// both stdout JSON `error` events and stderr; the markers come from
@ -163,9 +164,13 @@ pub async fn write_settings(socket: &Path) -> Result<PathBuf> {
// same socket-adjacent location every time and so a future override
// (per-agent settings JSON layer) drops in cleanly.
let src = hive_sh4re::assets::claude_settings();
tokio::fs::copy(&src, &path)
.await
.with_context(|| format!("copy claude settings from {} to {}", src.display(), path.display()))?;
tokio::fs::copy(&src, &path).await.with_context(|| {
format!(
"copy claude settings from {} to {}",
src.display(),
path.display()
)
})?;
tracing::info!(path = %path.display(), "wrote claude settings");
Ok(path)
}
@ -919,7 +924,10 @@ mod tests {
// file_count=1 + no mtime, then writing a second file.
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a"), b"{}").unwrap();
let forged = DirSnapshot { file_count: 1, newest_mtime: None };
let forged = DirSnapshot {
file_count: 1,
newest_mtime: None,
};
fs::write(dir.path().join("b"), b"{}").unwrap();
// Real snapshot has file_count=2, so refresh fires even
// though the mtime axis would be inconclusive.