Watch
0
0
Fork
You've already forked hyperhive
0

drop the config and /applied mounts: an agent reads its config by cloning agent-configs, and the prompt says so

This commit is contained in:
damocles 2026-11-01 15:38:50 +01:00 • committed by mara
commit 3c09e14a76

View file

@ -288,75 +288,34 @@ async fn systemd_daemon_reload() -> Result<()> {
crate::priv_client::daemon_reload().await
}
/// Where the manager sees the applied trees of every agent, read-only.
/// Manager runs `git fetch /applied/<n>/.git refs/tags/*:refs/tags/applied/*`
/// to learn what hive-c0re deployed (or rejected, or failed to
/// build); the RO bind makes accidental writes impossible from
/// inside the container.
pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied";
/// Host path behind every `/agents/<name>/config` mount: the **applied**
/// (deployed) repo, not the working clone at `agents/<name>/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 `ManageRootAgent` holder's view of another agent's) go through
/// here so they cannot drift.
/// Append the bind flag for `child`'s `state` dir into `binds`. See
/// docs/agent-lifecycle/persistence.md ("Cross-agent access to state") for what
/// the holder may touch and why. Creates the missing host-side directory so
/// nspawn doesn't refuse to start; a missing dir is non-fatal.
///
/// For a custom agent it is never empty under a live container:
/// `provision_container` runs `setup_applied` before `create_only` makes the
/// container at all. An archetype agent (`crate::archetypes`) has no applied
/// repo, so its mount is the empty directory the caller creates.
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/agent-lifecycle/persistence.md ("Cross-agent access to state")
/// for what the holder may touch and why. Creates missing host-side
/// directories so nspawn doesn't refuse to start; missing dirs are
/// non-fatal.
///
/// **Four dirs, three different answers** — the uniformity of the
/// original loop is what hid that:
///
/// - `state` is **read-write**: the holder reads and writes another
/// agent's notes to recover it, which is the one case that needs to
/// work while that agent is down.
/// - `config` is **read-only**, and read-only for the *holder* is the
/// point: a config change is a PR against the other agent'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 that agent's own runtime
/// material — `bash-tasks/`, turn-stats and event sqlite dbs — none of
/// which the holder 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 anyone else's container.
/// - `work` is **absent entirely**, like `harness`: the agent's own, often
/// very large, worktrees and build trees.
///
/// ⚠️ The config-repo seeding hive-c0re does at spawn 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 `lifecycle::setup_applied`), and
/// `read_only` on a bind constrains writers *inside* the container only.
/// **One dir, one answer.** `state` is **read-write**: the holder reads and
/// writes another agent's notes to recover it, which is the one case that
/// needs to work while that agent is down. The agent's `config` is not mounted
/// at all: a config change is a PR against its repo on the forge, reviewed and
/// merged, and the holder reads the config by cloning it from there. Its
/// `harness` and `work` are absent too: they hold that agent's own runtime
/// material (`bash-tasks/`, turn-stats and event sqlite dbs) and its, often
/// very large, worktrees and build trees, none of which the holder has a stated
/// reason to read, let alone write. hive-c0re still reads `harness` directly on
/// the host (`stats::hive_stats`), which needs no bind mount into anyone else's
/// container.
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, 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(),
container_path: format!("/agents/{child}/{sub}"),
read_only,
});
}
let host = crate::paths::agent_state_dir(&child).join("state");
let _ = std::fs::create_dir_all(&host);
binds.push(BindMount {
host_path: host.to_string_lossy().into_owned(),
container_path: format!("/agents/{child}/state"),
read_only: false,
});
}
/// Append RW bind flags for `agent_name`'s own `state`, `harness` and `work`
@ -442,25 +401,18 @@ async fn set_nspawn_flags(
let mut binds = base_binds(runtime_dir, claude_dir, container_claude_mount(agent_name));
// Own state, harness, work, and config dirs — same for every agent
// including the manager. Config is RO: an agent must not edit its own
// config; changes only ever flow through a config PR on the forge.
// Own state, harness and work dirs - same for every agent including the
// manager. There is no config mount: an agent reads its config by cloning
// `agent-configs/<name>` from the forge, and changes only ever flow
// through a config PR there.
bind_own_agent_dirs(agent_name, notes_dir, &mut binds);
hive_types::Ident::parse(agent_name)
.map_err(|e| anyhow::anyhow!("invalid agent name {agent_name:?}: {e}"))?;
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 {
host_path: own_config.to_string_lossy().into_owned(),
container_path: format!("/agents/{agent_name}/config"),
read_only: true,
});
// `ManageRootAgent` permission: mount *every* agent in the hive as a
// virtual child. Enables recovery — the holder can update another
// agent's config even when that agent is down. Also grants RO access
// to /applied and /meta.
// virtual child. Enables recovery: the holder can read and write another
// agent's notes even when that agent is down. Also grants RO access
// to /meta.
//
// ⚠️ "every agent" is the definition of this permission, not an
// accident of how the set is computed. The grant used to hang off a
@ -477,9 +429,8 @@ async fn set_nspawn_flags(
// no permission now sees its own dirs and nothing else.
if holds_manage_root_agent(agent_name, &crate::permissions::path()) {
// Skipping self is a no-op, not a narrowing: `agent_notes_dir` is
// `agent_state_dir/state` and `config_bind_source` is shared, so
// binding the holder as its own virtual child reproduced the two
// own-dir mounts pushed above, byte for byte.
// `agent_state_dir/state`, so binding the holder as its own virtual
// child reproduced the own-dir mount pushed above, byte for byte.
for other in crate::topology::all_agents() {
if other != agent_name {
bind_child_agent_dirs(&other, &mut binds);
@ -493,11 +444,6 @@ async fn set_nspawn_flags(
let meta_root = crate::paths::meta_root();
std::fs::create_dir_all(&meta_root)
.with_context(|| format!("create {}", meta_root.display()))?;
binds.push(BindMount {
host_path: crate::paths::applied_root().to_string_lossy().into_owned(),
container_path: CONTAINER_MANAGER_APPLIED_MOUNT.to_owned(),
read_only: true,
});
binds.push(BindMount {
host_path: meta_root.to_string_lossy().into_owned(),
container_path: crate::meta::CONTAINER_MANAGER_META_MOUNT.to_owned(),
@ -600,36 +546,15 @@ mod tests {
}
/// The boundary, asserted as a whole rather than per-dir: a
/// `ManageRootAgent` holder sees another agent's `state` and
/// `config`, and nothing else.
/// `ManageRootAgent` holder sees another agent's `state`, and nothing
/// else. In particular no `config`: it is read by cloning the repo.
#[test]
fn holder_sees_only_other_agents_state_and_config() {
fn holder_sees_only_other_agents_state() {
let paths: Vec<String> = child_binds()
.into_iter()
.map(|b| b.container_path)
.collect();
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
);
assert_eq!(paths, ["/agents/kiddo/state"]);
}
/// The regression this exists for. `harness` holds the other agent's