diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 1e8eb46e..8c227a16 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -1567,8 +1567,13 @@ impl Coordinator { crate::paths::agent_runtime_dir(name).join("mcp.sock") } - /// Manager-editable proposed config repo. Bind-mounted into the manager - /// container as `/agents//config/`. + /// The *proposed* config repo: where a config change lands before it is + /// applied, and what an approved deploy promotes into `applied_dir`. + /// + /// **Not bind-mounted into any container.** An agent that edits a config + /// clones it from the forge itself; `/agents//config` shows the + /// applied (deployed) tree instead — see `config_bind_source` in + /// `lifecycle/host_config.rs`. pub fn agent_proposed_dir(name: &hive_types::Ident) -> PathBuf { crate::paths::agent_state_dir(name).join("config") } diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 8d5ac725..cedf84cf 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -2,7 +2,7 @@ //! network isolation, forwarded credentials), the systemd resource-limits //! drop-in, and the `write_dropins` verb that re-applies both. -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use hive_priv_sock::{BindMount, CredentialMount}; @@ -71,6 +71,19 @@ async fn systemd_daemon_reload() -> Result<()> { /// inside the container. pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied"; +/// Host path behind every `/agents//config` mount: the **applied** +/// (deployed) repo, not the working clone at `agents//config`. That +/// clone is where a config change is staged, so it can hold a proposal +/// that is still under review or was rejected outright — mounting it shows +/// an agent a config which does not govern it. Both mounts (an agent's own +/// and a parent's view of a child's) go through here so they cannot drift. +/// +/// Never empty under a live container: `provision_container` runs +/// `setup_applied` before `create_only` makes the container at all. +fn config_bind_source(name: &str) -> PathBuf { + crate::paths::applied_dir(name) +} + /// Append bind flags for `child`'s state and config dirs into `binds`. /// See docs/persistence.md ("Parent access to child state") for what a /// parent may touch and why. Creates missing host-side directories so @@ -104,8 +117,10 @@ fn bind_child_agent_dirs(child: &str, binds: &mut Vec) { return; }; let child_root = crate::paths::agent_state_dir(&child); - for (sub, read_only) in [("state", false), ("config", true)] { - let host = child_root.join(sub); + for (sub, host, read_only) in [ + ("state", child_root.join("state"), false), + ("config", config_bind_source(child.as_str()), true), + ] { let _ = std::fs::create_dir_all(&host); binds.push(BindMount { host_path: host.to_string_lossy().into_owned(), @@ -249,9 +264,9 @@ async fn set_nspawn_flags( read_only: false, }); } - let agent_id = hive_types::Ident::parse(agent_name) + hive_types::Ident::parse(agent_name) .map_err(|e| anyhow::anyhow!("invalid agent name {agent_name:?}: {e}"))?; - let own_config = crate::paths::agent_state_dir(&agent_id).join("config"); + let own_config = config_bind_source(agent_name); std::fs::create_dir_all(&own_config) .with_context(|| format!("create {}", own_config.display()))?; binds.push(BindMount { @@ -381,6 +396,27 @@ mod tests { assert_eq!(paths, ["/agents/kiddo/state", "/agents/kiddo/config"]); } + /// The `config` mount names the **deployed** tree, not the working + /// clone the proposal is staged in. Asserted as "outside the child's + /// own dir" rather than by equality: the point is that the two are + /// different objects, which is what makes the mount unable to show a + /// config that was never approved. Equality with `applied_dir` would + /// restate the implementation and pass under any future relocation. + #[test] + fn child_config_mount_is_the_deployed_tree_not_the_working_clone() { + let working_clone = + crate::paths::agent_state_dir(&hive_types::Ident::parse("kiddo").expect("valid ident")); + let config = child_binds() + .into_iter() + .find(|b| b.container_path.ends_with("/config")) + .expect("a config bind"); + assert!( + !std::path::Path::new(&config.host_path).starts_with(&working_clone), + "config mount must not come from the child's working clone: {}", + config.host_path + ); + } + /// The regression this exists for. `harness` holds the child's own /// runtime material and was only ever mounted because one loop /// treated all three dirs alike — re-adding it to that loop is a