diff --git a/hive-priv-sock/src/lib.rs b/hive-priv-sock/src/lib.rs index 977917fcb..2120522bd 100644 --- a/hive-priv-sock/src/lib.rs +++ b/hive-priv-sock/src/lib.rs @@ -472,7 +472,11 @@ pub enum PrivRequest { /// hive-priv derives the path from `agent_name` (never passed over the /// wire), validates the name, then: /// - path already exists (dir or subvol) → no-op (progressive: existing - /// agents are left exactly as they are, never auto-migrated); + /// agents are left exactly as they are, never auto-migrated), except + /// that a root-owned dir under a non-root `AGENT_STATE_ROOT` is chowned + /// to the root's owner, since hive-c0re cannot write below it; + /// - `AGENT_STATE_ROOT` itself missing → created and chowned to its + /// parent's owner, never left root-owned; /// - parent FS is not btrfs → no-op (hive-c0re's normal `create_dir_all` /// makes a plain directory, the pre-subvolume behaviour); /// - else → `btrfs subvolume create ` and chown it to the owner of diff --git a/hive-priv/src/storage.rs b/hive-priv/src/storage.rs index c8c84355b..bea4691c6 100644 --- a/hive-priv/src/storage.rs +++ b/hive-priv/src/storage.rs @@ -244,17 +244,31 @@ async fn ensure_agent_subvolume( let root = env.state_root; let agent_root = root.join(agent_name); - // Progressive: existing agents (plain dir OR already a subvol) are left - // untouched — never auto-migrated. + // Progressive: existing agents (plain dir OR already a subvol) are never + // auto-migrated. The one exception is an agent dir this op itself left + // root-owned on a fresh host: hive-c0re cannot create below it. if agent_root.exists() { + if let (Ok(agent), Ok(root_meta)) = ( + std::fs::symlink_metadata(&agent_root), + std::fs::metadata(root), + ) && is_stranded_root_dir(agent.uid(), root_meta.uid()) + { + std::os::unix::fs::lchown(&agent_root, Some(root_meta.uid()), Some(root_meta.gid())) + .with_context(|| { + format!( + "chown stranded {} to agents-root owner", + agent_root.display() + ) + })?; + tracing::warn!(agent = %agent_name, path = %agent_root.display(), "handed a root-owned agent dir back to the agents-root owner"); + } return Ok((String::new(), String::new())); } // Only btrfs supports subvolumes; on anything else hive-c0re's normal // `create_dir_all` makes a plain dir (the pre-subvolume behaviour). The // parent must exist for both statfs and `btrfs subvolume create`. - std::fs::create_dir_all(root) - .with_context(|| format!("create agents root {}", root.display()))?; + ensure_agents_root(root)?; if !env.probes.on_btrfs(root)? { return Ok((String::new(), String::new())); } @@ -306,6 +320,30 @@ async fn ensure_agent_subvolume( Ok((String::new(), String::new())) } +/// Make the agents root if it is missing. Made by root here, so it is handed +/// to the owner of its parent (hive-core's state dir); the subvolume takes the +/// root's owner, and a root-owned root would strand every agent dir under it. +/// One made meanwhile by the tmpfiles rule is left as that rule made it. +fn ensure_agents_root(root: &Path) -> Result<()> { + use std::os::unix::fs::MetadataExt as _; + + match std::fs::create_dir(root) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(()), + Err(e) => return Err(e).with_context(|| format!("create agents root {}", root.display())), + } + let parent = std::fs::metadata(root.parent().unwrap_or(root)) + .with_context(|| format!("stat parent of {}", root.display()))?; + std::os::unix::fs::chown(root, Some(parent.uid()), Some(parent.gid())) + .with_context(|| format!("chown agents root {} to its parent's owner", root.display())) +} + +/// An agent dir owned by root under an agents root that is not: hive-c0re +/// (hive-core) cannot create `claude/` or `state/` in it. +fn is_stranded_root_dir(agent_uid: u32, root_uid: u32) -> bool { + agent_uid == 0 && root_uid != 0 +} + /// `DeleteAgentSubvolume` — delete the agent's state root iff it's a btrfs /// subvolume (purge path only). No-op for plain dirs / missing paths; /// hive-c0re's own `remove_dir_all` covers those. See the wire doc. @@ -1506,6 +1544,37 @@ esac"#; assert!(!fx.agent("iris").exists()); } + #[test] + fn the_agents_root_is_made_once_and_a_racing_creator_is_not_an_error() { + use super::ensure_agents_root; + use std::os::unix::fs::MetadataExt as _; + let fx = Fx::new(); + let root = fx.state.join("made"); + ensure_agents_root(&root).unwrap(); + let (dir, parent) = ( + std::fs::metadata(&root).unwrap(), + std::fs::metadata(&fx.state).unwrap(), + ); + assert_eq!((dir.uid(), dir.gid()), (parent.uid(), parent.gid())); + ensure_agents_root(&root).unwrap(); + assert!(ensure_agents_root(&root.join("no/parent")).is_err()); + } + + #[test] + fn only_a_root_owned_dir_under_a_non_root_root_is_stranded() { + use super::is_stranded_root_dir; + assert!(is_stranded_root_dir(0, 998)); + assert!( + !is_stranded_root_dir(0, 0), + "a root-run host is not stranded" + ); + assert!(!is_stranded_root_dir(998, 998)); + assert!( + !is_stranded_root_dir(1000, 998), + "never touch an agent-owned dir" + ); + } + #[tokio::test] async fn ensure_creates_the_subvolume_owned_like_the_root() { use std::os::unix::fs::MetadataExt as _;