fix(#2733): write the agent pause marker via hive-priv

`Coordinator::set_paused` wrote the marker directly with `std::fs::write`
from hive-c0re, which runs as the unprivileged `hive-core` user. The
agent's harness dir is chowned to the agent user on every container boot
(`user.nix`'s activation chown), mode 0755 — so hive-core can stat the
marker but gets EACCES creating or unlinking it. Pause therefore only
ever worked on an agent that had never booted; the read side works
because a stat needs traverse, not write, which is why the paused pill
and `is_paused` looked healthy.

Route both directions through hive-priv, the root helper that already
owns the other writes into agent-owned directories:

- `PrivRequest::SetAgentPaused { agent_name, paused }`, with the marker
  filename constant moved to hive-priv-sock. That is the narrowest crate
  all three sides share (hive-priv deliberately does not depend on
  hive-sh4re, which re-exports it for the in-container resolver). A
  private copy on any one side would break pause silently, since every
  reader just sees "no marker".
- `write_agent_state_file` generalised to `write_agent_dir_file`, taking
  the target directory: `state/` and `harness/` are both agent-owned,
  which is the same reason both need root.
- resume unlinks via `remove_file`, which acts on the leaf and never
  follows a symlink — an agent could otherwise plant a link at the
  marker path and have root delete an arbitrary file.

`Coordinator::set_paused` becomes an async round-trip; its three call
sites were already async. Both directions stay idempotent because the
dashboard toggle and `hivectl pause|resume` fire without reading the
current state first.
This commit is contained in:
atlas 2026-07-26 23:50:05 +02:00 committed by mara
commit de09628c7c
9 changed files with 193 additions and 36 deletions

View file

@ -1382,32 +1382,26 @@ impl Coordinator {
Self::agent_paused_marker(name).exists()
}
/// Create or remove the pause marker. Idempotent in both
/// directions: pausing an already-paused agent (or resuming a
/// Create or remove the pause marker, **via hive-priv**. Idempotent in
/// both directions: pausing an already-paused agent (or resuming a
/// running one) is a no-op rather than an error, so the dashboard
/// toggle and `hivectl pause|resume` don't have to read-then-write.
///
/// The write cannot happen in-process. The harness dir is chowned to
/// the agent user on the container's first boot (mode 0755), and this
/// daemon runs as the unprivileged `hive-core` user — so the read side
/// ([`Self::is_paused`], a stat) works while a direct `fs::write` here
/// fails with `EACCES` on every agent that has ever booted. The root
/// helper owns both directions instead.
///
/// # Errors
///
/// Returns the underlying `io::Error` when the marker can't be
/// created (agent dir missing and un-creatable, permissions, disk
/// full) or can't be removed. A `NotFound` on removal is *not* an
/// error — that's the idempotent resume case.
pub fn set_paused(name: &hive_types::Ident, paused: bool) -> std::io::Result<()> {
let marker = Self::agent_paused_marker(name);
if paused {
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent)?;
}
// `create_new` would fail on the second pause; truncating an
// existing empty marker is the idempotent equivalent.
std::fs::write(&marker, b"")
} else {
match std::fs::remove_file(&marker) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
other => other,
}
}
/// Returns the hive-priv error when the marker can't be created (agent
/// dir missing and un-creatable, disk full) or can't be removed, and
/// the transport error when the helper is unreachable. A `NotFound` on
/// removal is *not* an error — that's the idempotent resume case.
pub async fn set_paused(name: &hive_types::Ident, paused: bool) -> anyhow::Result<()> {
crate::priv_client::set_agent_paused(name.as_str(), paused).await
}
/// Enumerate names that have a persistent state dir under

View file

@ -157,7 +157,7 @@ pub(super) async fn post_pause(
Ok(i) => i,
Err(e) => return (StatusCode::BAD_REQUEST, format!("bad agent name: {e}")).into_response(),
};
if let Err(e) = crate::coordinator::Coordinator::set_paused(&ident, true) {
if let Err(e) = crate::coordinator::Coordinator::set_paused(&ident, true).await {
return error_response(&format!("pause {logical}: {e}"));
}
state.coord.rescan_containers_and_emit().await;
@ -180,7 +180,7 @@ pub(super) async fn post_resume(
Ok(i) => i,
Err(e) => return (StatusCode::BAD_REQUEST, format!("bad agent name: {e}")).into_response(),
};
if let Err(e) = crate::coordinator::Coordinator::set_paused(&ident, false) {
if let Err(e) = crate::coordinator::Coordinator::set_paused(&ident, false).await {
return error_response(&format!("resume {logical}: {e}"));
}
state.coord.rescan_containers_and_emit().await;

View file

@ -223,6 +223,21 @@ pub async fn run_forge_admin(args: &[&str]) -> Result<(String, String)> {
check(call(&PrivRequest::RunForgeAdmin { args: owned }).await?)
}
/// Create (`paused: true`) or remove (`paused: false`) the pause marker in
/// `agent_name`'s harness dir via hive-priv (running as root).
///
/// hive-c0re cannot do this itself: the harness dir is chowned to the agent
/// user on the container's first boot and left mode 0755, so this process
/// can stat the marker (that's what `Coordinator::is_paused` does) but gets
/// `EACCES` on create *and* unlink. Both directions are idempotent.
pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> {
ok(call(&PrivRequest::SetAgentPaused {
agent_name: agent_name.to_owned(),
paused,
})
.await?)
}
/// Write the Forgejo access token for `agent_name` to
/// `<agent_state_root>/<agent_name>/state/forge-token` via hive-priv
/// (running as root). The file is written 0600 and chowned to the agent

View file

@ -328,7 +328,7 @@ async fn handle_set_paused(
name: &hive_types::Ident,
paused: bool,
) -> HostResponse {
if let Err(e) = Coordinator::set_paused(name, paused) {
if let Err(e) = Coordinator::set_paused(name, paused).await {
return HostResponse::error(format!("set paused={paused} for {name}: {e}"));
}
tracing::info!(%name, paused, "agent pause marker updated");