fix(3044): a parent's mount of a child's config is read-only

The parent's copy is for reading a child's config; a change to it is a PR
on the child's repo, made from a clone and merged after review. A
writable mount is a second path to the same file that skips that review,
which makes the boundary a convention rather than a permission.

Confirmed with ruth before flipping: it clones from the forge and opens a
PR, including for a brand-new child's first config.

The prose was the larger half. docs/approvals.md did not merely describe
the old mount, it *instructed* agents to use it ("can therefore edit,
commit, and submit changes for any of its direct children directly inside
its container"), and the doc comment in host_config.rs asserted a
dependency that never existed: the InitConfig seed runs as hive-c0re
against the host path, and read_only on a bind constrains writers inside
the container only. That comment is what produced issue #3206, now closed
as invalid.
This commit is contained in:
atlas 2026-08-12 20:03:43 +02:00 committed by mara
commit 0b6b3b755d
3 changed files with 59 additions and 39 deletions

View file

@ -71,37 +71,46 @@ async fn systemd_daemon_reload() -> Result<()> {
/// inside the container.
pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied";
/// Append bind flags for `child`'s state and config dirs into `binds`,
/// read-write. See docs/persistence.md ("Parent access to child state")
/// for what a parent may touch and why. Creates missing host-side
/// directories so nspawn doesn't refuse to start; missing dirs are
/// non-fatal.
/// 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
/// nspawn doesn't refuse to start; missing dirs are non-fatal.
///
/// **`harness` is deliberately absent.** It holds the child's own runtime
/// material — `bash-tasks/`, turn-stats and event sqlite dbs — none of
/// which a parent has a stated reason to read, let alone write. It was
/// mounted only because the loop treated all three dirs alike; the three
/// have three different answers, and the uniformity is what hid that.
/// hive-c0re still reads a child's harness dir directly on the host
/// (`stats::hive_stats`), which needs no bind mount into the parent.
/// **Three dirs, three different answers** — the uniformity of the
/// original loop is what hid that:
///
/// `config` is RW here **pending** the sequenced change: the ruling is
/// that it becomes read-only, but `request_init_config` still has the
/// manager seed a new child's config in place, so flipping the mount
/// before relocating that step breaks agent creation hive-wide.
/// - `state` is **read-write**: a parent reads and writes a child's notes
/// to recover it, which is the one case that needs to work while the
/// child is down.
/// - `config` is **read-only**, and read-only for the *parent* is the
/// point: a config change is a PR against the child's repo on the
/// forge, reviewed and merged, never an edit in place. A writable mount
/// here is a second path to the same file that skips the review — the
/// boundary would then be a convention rather than a permission.
/// - `harness` is **absent entirely**. It holds the child's own runtime
/// material — `bash-tasks/`, turn-stats and event sqlite dbs — none of
/// which a parent has a stated reason to read, let alone write.
/// hive-c0re still reads it directly on the host (`stats::hive_stats`),
/// which needs no bind mount into the parent.
///
/// ⚠️ The seeding done at `InitConfig` approval is **not** affected by the
/// `config` flag and must not be read as a reason to widen it: that runs
/// as hive-c0re against the host path (see `actions.rs`, which seeds the
/// repo and wires its forge remote inline), and `read_only` on a bind
/// constrains writers *inside* the container only.
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
let Ok(child) = hive_types::Ident::parse(child) else {
tracing::warn!(%child, "skipping child bind: invalid agent name");
return;
};
let child_root = crate::paths::agent_state_dir(&child);
for sub in ["state", "config"] {
for (sub, read_only) in [("state", false), ("config", true)] {
let host = child_root.join(sub);
let _ = std::fs::create_dir_all(&host);
binds.push(BindMount {
host_path: host.to_string_lossy().into_owned(),
container_path: format!("/agents/{child}/{sub}"),
read_only: false,
read_only,
});
}
}
@ -252,9 +261,8 @@ async fn set_nspawn_flags(
});
// Topology-driven child mounts: every direct child of this agent gets
// its state, harness, and config dirs bind-mounted RW (parent reads +
// writes child state for recovery, and manages config). See
// `bind_child_agent_dirs`.
// its state dir RW and its config dir RO. See `bind_child_agent_dirs`
// for why each is what it is.
let direct_children = crate::topology::children_of(agent_name);
for child in &direct_children {
bind_child_agent_dirs(child, &mut binds);