diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 72f48d0c..4f9062b5 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -745,6 +745,10 @@ container lifetime: on the host before every start; the harness binds its sockets there as the agent user. Why the mode matters: [`boundary.md`](../trust-boundary/boundary.md#the-per-agent-socket-dir). + A namespaced agent has the dir bound at `/agents//sockets` and + linked from `/run/hive-agent/`; the activation chowns nothing, because + a mount point under `/run` would belong to root and the id mapping would + give the agent user no ids there. ## Matrix per-agent daemon + token-arrival trigger diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 669f07d7..1d868a16 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -465,11 +465,7 @@ async fn set_nspawn_flags( // to the agent user. Don't chown or chmod it here. let socket_dir = crate::agent_sockets::agent_dir_for(agent_name); crate::priv_client::ensure_agent_socket_dir(agent_name, private_users).await?; - binds.push(BindMount { - host_path: socket_dir.to_string_lossy().into_owned(), - container_path: socket_dir.to_string_lossy().into_owned(), - read_only: false, - }); + binds.push(socket_bind(agent_name, &socket_dir, private_users)); // Every container runs in a private network namespace with a veth // pair attached to the host bridge — including the manager (all @@ -495,6 +491,29 @@ async fn set_nspawn_flags( .await } +/// The bind of the agent's socket dir. +/// +/// A namespaced agent's binds are mounted `owneridmap`, which maps the owner of +/// the mount point inside the container to the host owner. nspawn mounts a +/// fresh tmpfs on `/run` before the binds, so a mount point under `/run` is +/// created by nspawn owned by root, and the agent user would have no mapping +/// (EOVERFLOW). A namespaced agent gets the dir at `/agents//sockets`, +/// whose mount point hive-priv owns to the agent; the agent module links +/// `/run/hive-agent/` to it. +fn socket_bind(agent_name: &str, socket_dir: &Path, private_users: Option) -> BindMount { + let host_path = socket_dir.to_string_lossy().into_owned(); + let container_path = if private_users == Some(true) { + format!("/agents/{agent_name}/sockets") + } else { + host_path.clone() + }; + BindMount { + host_path, + container_path, + read_only: false, + } +} + /// What hive-priv is told about the agent's user namespace: the record's flag, /// or `None` (leave the agent as it is) when there is no record or it cannot be /// read, so a swarm outage never turns a namespaced agent off or on. @@ -555,6 +574,21 @@ mod tests { /// The flag in the record is what hive-priv is told, both ways. No record /// and an unreadable one say nothing, so a swarm outage changes no agent's /// namespace; a record from before the field reads false. + /// Only a namespaced agent's socket dir moves off `/run`. + #[test] + fn the_socket_dir_leaves_run_only_for_a_namespaced_agent() { + let dir = std::path::Path::new("/run/hive-agent/iris"); + for private_users in [None, Some(false)] { + let b = super::socket_bind("iris", dir, private_users); + assert_eq!(b.container_path, "/run/hive-agent/iris"); + assert_eq!(b.host_path, "/run/hive-agent/iris"); + } + let b = super::socket_bind("iris", dir, Some(true)); + assert_eq!(b.container_path, "/agents/iris/sockets"); + assert_eq!(b.host_path, "/run/hive-agent/iris"); + assert!(!b.read_only); + } + #[test] fn the_records_flag_is_what_the_start_path_passes() { assert_eq!( diff --git a/nix/agent-modules/user.nix b/nix/agent-modules/user.nix index 6498223c..04b25d96 100644 --- a/nix/agent-modules/user.nix +++ b/nix/agent-modules/user.nix @@ -199,7 +199,12 @@ in "d ${homeDir}/.local/state 0755 ${owner} - -" "L ${homeDir}/.local/state/nix - - - - ${persisted}" "L ${homeDir}/.nix-profile - - - - ${homeDir}/.local/state/nix/profiles/profile" - ]; + ] + # A namespaced agent's socket dir is bound at /agents//sockets, not + # under /run, whose tmpfs nspawn mounts before the binds and so owns the + # mount point to root (hive-c0re `socket_bind`). The harness keeps the + # /run/hive-agent path. A dir already bound there is left alone. + ++ lib.optional config.services.hyperhive.agent.user.privateUsers "L /run/hive-agent/${userName} - - - - /agents/${userName}/sockets"; # `NOPASSWD: ALL` for the agent user. Lets claude's Bash tool # keep working with anything that expected root (systemctl, diff --git a/nix/vm-tests/agent-ephemeral-rootfs.nix b/nix/vm-tests/agent-ephemeral-rootfs.nix index 5e5098d6..a49d7676 100644 --- a/nix/vm-tests/agent-ephemeral-rootfs.nix +++ b/nix/vm-tests/agent-ephemeral-rootfs.nix @@ -54,7 +54,7 @@ let ++ [ { host_path = nsSocketDir; - container_path = nsSocketDir; + container_path = "/agents/${nsAgent}/sockets"; read_only = false; } ]; @@ -368,6 +368,9 @@ pkgs.testers.runNixOSTest { assert as_ns(f"cat /agents/{NS_AGENT}/state/pre.txt") == "before" assert as_ns(f"stat -c %u:%g /agents/{NS_AGENT}/state/pre.txt") == "1000:1000" as_ns(f"echo after > /agents/{NS_AGENT}/state/post.txt") + # The socket dir is bound off /run (an owneridmap mount point under the + # container's /run tmpfs would belong to root) and linked back. + assert as_ns(f"readlink /run/hive-agent/{NS_AGENT}") == f"/agents/{NS_AGENT}/sockets" as_ns(f"touch /run/hive-agent/{NS_AGENT}/web.sock") assert as_ns(f"cat /agents/{NS_AGENT}/config/cfg") == "from-config" host.fail(f"nixos-container run {NS} -- runuser -l {NS_AGENT} -c 'touch /agents/{NS_AGENT}/config/new'")