diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 4fe27d5d..95343ad8 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -162,6 +162,24 @@ fn extract_token(output: &str) -> Option { .map(str::to_owned) } +/// Best-effort chown `path` to the agent's container-local uid/gid. +/// Closes the gap where c0re (running as root on the host) writes +/// per-agent state files that the agent's non-root unix user then +/// can't read until the next container activation runs the chown +/// fixup in `harness-base.nix` (#673). +/// +/// Silently no-ops when the container hasn't been built yet (passwd +/// file absent) or when the chown syscall fails — the activation +/// script remains the safety net. +fn chown_to_agent(name: &str, path: &Path) { + let Some((uid, gid)) = crate::lifecycle::agent_uid_gid(name) else { + return; + }; + if let Err(e) = std::os::unix::fs::chown(path, Some(uid), Some(gid)) { + tracing::debug!(%name, path = %path.display(), error = %e, "forge: chown to agent failed"); + } +} + /// Canonical email address for a hive agent's Forgejo account. /// Must match the `user.email` set by `meta::render_flake` so commits /// by the agent link back to their Forgejo profile page. @@ -335,6 +353,7 @@ async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result std::fs::write(path, format!("{token}\n")) .with_context(|| format!("write token to {}", path.display()))?; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + chown_to_agent(name, path); tracing::info!(%name, path = %path.display(), "forge: persisted access token"); Ok(()) } diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index c81217a5..93e10594 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -92,6 +92,42 @@ pub fn is_manager(name: &str) -> bool { name == MANAGER_NAME } +/// Read the agent user's `(uid, gid)` from the container's nixos-managed +/// `/etc/passwd`. Returns `None` when the container hasn't been built +/// yet, the passwd file is unparseable, or the agent user is missing +/// (e.g. legacy pre-#658 container that still runs as root). +/// +/// Used by `forge` + `matrix` after writing per-agent state files so +/// the bind-mounted host file ends up readable by the agent user +/// without waiting for the next container activation to run the chown +/// fixup (#673). +/// +/// Notes: +/// - Reads the *container-local* passwd at +/// `/var/lib/nixos-containers//etc/passwd`, not the host's. +/// The container's user-namespace shares uids with the host (no +/// `PrivateUsers`), so the uid is directly usable in host-side +/// `chown(2)`. +/// - Best-effort: caller treats `None` as "skip the chown". +#[must_use] +pub fn agent_uid_gid(agent_name: &str) -> Option<(u32, u32)> { + let container = container_name(agent_name); + let passwd_path = format!("/var/lib/nixos-containers/{container}/etc/passwd"); + let content = std::fs::read_to_string(&passwd_path).ok()?; + for line in content.lines() { + let mut parts = line.split(':'); + let user = parts.next()?; + if user != agent_name { + continue; + } + let _ = parts.next()?; // x (password placeholder) + let uid: u32 = parts.next()?.parse().ok()?; + let gid: u32 = parts.next()?.parse().ok()?; + return Some((uid, gid)); + } + None +} + fn validate(name: &str) -> Result<()> { if name.is_empty() { bail!("agent name must not be empty"); diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index c695e17c..86948f63 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -274,10 +274,25 @@ pub async fn ensure_user_for( std::fs::write(&path, format!("{access_token}\n")) .with_context(|| format!("matrix: write token to {}", path.display()))?; let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + chown_to_agent(name, &path); tracing::info!(%name, path = %path.display(), "matrix: registered user + persisted access token"); Ok(()) } +/// Best-effort chown `path` to the agent's container-local uid/gid. +/// Mirrors `forge::chown_to_agent` for the matrix access-token write — +/// closes the gap where c0re (root on host) writes a file the agent's +/// non-root unix user then can't read until the next activation runs +/// the harness-base.nix chown fixup (#673). +fn chown_to_agent(name: &str, path: &Path) { + let Some((uid, gid)) = crate::lifecycle::agent_uid_gid(name) else { + return; + }; + if let Err(e) = std::os::unix::fs::chown(path, Some(uid), Some(gid)) { + tracing::debug!(%name, path = %path.display(), error = %e, "matrix: chown to agent failed"); + } +} + /// Register a matrix account for `name` with the supplied `password` /// and return the freshly-minted access token. Unlike [`ensure_user_for`], /// the token is **not** persisted to disk — the caller is responsible