agent permissions: drop hive-c0re's swarm-agent-permissions.json fallback, fail closed before the first read
Removes the HiveCopy hand-off: hive-c0re no longer writes each agent's entry from swarm-agent-permissions.json into its runtime dir as swarm-permissions.json, and the harness and subagent daemon no longer read it. hive-c0re keeps the file as its own last read of the swarm for the manage_root_agent mounts. Also removes permissions_import::pending(), its only caller being the hand-off. No nix provisioned the copy, so no nix option is removed and no tombstone is needed. Rule after this change: until an agent's first successful read of its key it holds no permissions (no execution, so no bash), and a subagent it starts holds the same. After a read a set key wins, [] holds none and an unset key is the default. A failed read keeps the last value, so an unreachable queue at start keeps the agent closed until a read succeeds. The harness logs once at warn that it starts closed. Fixes #5315
This commit is contained in:
parent
6e7b5f84e9
commit
2f00f9041f
11 changed files with 121 additions and 283 deletions
|
|
@ -11,14 +11,12 @@
|
|||
//! hive-c0re enforces one member, [`Permission::ManageRootAgent`]: read by
|
||||
//! `lifecycle::host_config::set_nspawn_flags` on every container start, which
|
||||
//! is why a change to it takes a restart. The agent reads the tool members
|
||||
//! itself, and holds its entry here until its own first read succeeds:
|
||||
//! [`hand_at`] copies the entry into the agent's runtime dir.
|
||||
//! itself.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use hive_types::Permission;
|
||||
use swarm_queue_client::agent_permissions::{HIVE_COPY_FILE, HiveCopy};
|
||||
use swarm_wire::agent_permissions::AgentPermissions;
|
||||
|
||||
const FILE: &str = "swarm-agent-permissions.json";
|
||||
|
|
@ -58,30 +56,6 @@ pub fn holds(path: &Path, agent: &str, perm: Permission) -> std::io::Result<bool
|
|||
Ok(effective(read_at(path)?.get(agent)).contains(&perm))
|
||||
}
|
||||
|
||||
/// Write `agent`'s [`HiveCopy`] into `runtime_dir`: its entry in the file at
|
||||
/// `stored` (normally [`path`]) and `import_pending`. Left alone when the
|
||||
/// copy there already says the same.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// As [`read_at`], or the write failed.
|
||||
pub fn hand_at(
|
||||
stored: &Path,
|
||||
import_pending: bool,
|
||||
agent: &str,
|
||||
runtime_dir: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
let copy = HiveCopy {
|
||||
stored: read_at(stored)?.remove(agent).unwrap_or_default(),
|
||||
import_pending,
|
||||
};
|
||||
let dest = runtime_dir.join(HIVE_COPY_FILE);
|
||||
if dest.exists() && HiveCopy::read_at(&dest) == copy {
|
||||
return Ok(());
|
||||
}
|
||||
super::write_map(&dest, ©)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -129,32 +103,6 @@ mod tests {
|
|||
assert!(!holds(&path, "atlas", Permission::ManageRootAgent).expect("read"));
|
||||
}
|
||||
|
||||
/// With the queue down nothing is read, so the agent starts on the copy
|
||||
/// written here: the stored grant, not the default.
|
||||
#[test]
|
||||
fn the_agent_is_handed_its_stored_grant_and_whether_the_import_is_pending() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let stored = dir.path().join(FILE);
|
||||
let runtime = dir.path().join("run");
|
||||
write_at(
|
||||
&stored,
|
||||
&Stored::from([(
|
||||
"atlas".to_owned(),
|
||||
record(r#"{"permissions":["web_tools"]}"#),
|
||||
)]),
|
||||
)
|
||||
.expect("seed");
|
||||
|
||||
hand_at(&stored, true, "atlas", &runtime).expect("hand");
|
||||
let copy = HiveCopy::read_at(&runtime.join(HIVE_COPY_FILE));
|
||||
assert!(copy.import_pending);
|
||||
assert_eq!(copy.before_read(), vec![Permission::WebTools]);
|
||||
|
||||
hand_at(&stored, false, "iris", &runtime).expect("hand");
|
||||
let copy = HiveCopy::read_at(&runtime.join(HIVE_COPY_FILE));
|
||||
assert_eq!(copy, HiveCopy::default(), "no entry is the default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_file_is_an_error() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Per-container host-side config: the nspawn conf rewrite (bind mounts,
|
||||
//! network isolation, forwarded credentials), the systemd resource-limits
|
||||
//! drop-in, the agent's copies of its memory cap and stored permissions, and
|
||||
//! drop-in, the agent's copy of its memory cap, and
|
||||
//! the `write_dropins` verb that re-applies them.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -17,8 +17,8 @@ use super::{
|
|||
};
|
||||
|
||||
/// Re-apply the per-container host-side config: nspawn flags (bind
|
||||
/// mounts etc.), the systemd resource-limits drop-in, the agent's copies of
|
||||
/// its memory cap and stored permissions, and a daemon reload so all of it
|
||||
/// mounts etc.), the systemd resource-limits drop-in, the agent's copy of
|
||||
/// its memory cap, and a daemon reload so all of it
|
||||
/// takes effect on the next unit (re)start. Idempotent — the job queue's
|
||||
/// `WriteDropin` node, also folded into every `Swap` (rebuild is the
|
||||
/// reconcile verb).
|
||||
|
|
@ -26,7 +26,6 @@ pub async fn write_dropins(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Re
|
|||
validate(name)?;
|
||||
let container = container_name(name);
|
||||
set_nspawn_flags(&container, hive, &paths.agent, &paths.claude, &paths.notes).await?;
|
||||
hand_permissions(name, &paths.agent);
|
||||
if let Some((cpu_quota, memory_max)) = stage_limits(
|
||||
name,
|
||||
&crate::resource_limits::swarm_limits_path(),
|
||||
|
|
@ -401,22 +400,6 @@ fn holds_manage_root_agent(agent_name: &str, path: &Path) -> bool {
|
|||
})
|
||||
}
|
||||
|
||||
/// Copy `agent`'s stored permissions into its runtime dir, which it holds
|
||||
/// until its own first read of the swarm's key succeeds. A failure is logged
|
||||
/// and the agent still starts, on the default.
|
||||
pub fn hand_permissions(agent: &str, runtime_dir: &Path) {
|
||||
let pending = crate::workers::permissions_import::pending();
|
||||
if let Err(e) =
|
||||
crate::permissions::hand_at(&crate::permissions::path(), pending, agent, runtime_dir)
|
||||
{
|
||||
tracing::error!(
|
||||
agent = %agent,
|
||||
error = ?e,
|
||||
"could not hand the agent its stored permissions; it holds the default until it reads its key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotently rewrite the lines in `/etc/nixos-containers/<container>.conf`
|
||||
/// that hive-c0re owns: `PRIVATE_NETWORK` (always 1), `HOST_ADDRESS` (the
|
||||
/// bridge gateway IP) and `EXTRA_NSPAWN_FLAGS` (the runtime-dir bind). What
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pub use git::{
|
|||
git_is_ancestor, git_read_tree_reset, git_rev_parse, git_show_file, git_tag, git_update_ref,
|
||||
git_update_ref_cas, run_with_deadline,
|
||||
};
|
||||
pub use host_config::{hand_permissions, write_dropins};
|
||||
pub use host_config::write_dropins;
|
||||
pub use setup::{
|
||||
ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir, initial_flake_nix,
|
||||
setup_applied, setup_proposed,
|
||||
|
|
|
|||
|
|
@ -120,13 +120,6 @@ fn meta_dir() -> PathBuf {
|
|||
crate::paths::meta_root()
|
||||
}
|
||||
|
||||
/// Whether a meta repo file is still waiting to be imported.
|
||||
#[must_use]
|
||||
pub fn pending() -> bool {
|
||||
let meta = meta_dir();
|
||||
META_FILES.iter().any(|f| meta.join(f).exists())
|
||||
}
|
||||
|
||||
/// Store the files' grants as the last-read swarm permissions, when a file
|
||||
/// is still there and nothing has been stored yet.
|
||||
pub fn seed_local() {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@
|
|||
//! [`crate::permissions`], where the container's bind mounts are worked out.
|
||||
//! Those are fixed when the container starts, so an agent whose
|
||||
//! `manage_root_agent` changed is restarted, after its current turn. The
|
||||
//! agent applies the rest of its permissions itself; after every pass each
|
||||
//! agent's copy of its stored entry is refreshed
|
||||
//! ([`crate::permissions::hand_at`]), which it holds until it reads its key.
|
||||
//! agent applies the rest of its permissions itself.
|
||||
//!
|
||||
//! A failed read, or a bucket that does not answer, leaves the stored
|
||||
//! permissions as they are and restarts nothing.
|
||||
|
|
@ -71,7 +69,6 @@ pub async fn follow(
|
|||
});
|
||||
}
|
||||
}
|
||||
hand_all().await;
|
||||
tokio::select! {
|
||||
() = tokio::time::sleep(READ_INTERVAL) => {}
|
||||
_ = shutdown.changed() => {
|
||||
|
|
@ -136,24 +133,6 @@ where
|
|||
.collect())
|
||||
}
|
||||
|
||||
/// Refresh the copy of its stored permissions each agent with a runtime dir
|
||||
/// holds, so it follows this pass's reads and the import's progress.
|
||||
async fn hand_all() {
|
||||
let agents = match crate::lifecycle::agents_for_meta_listing().await {
|
||||
Ok(agents) => agents,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "swarm permissions: enumerating agents to hand their copies failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for spec in agents {
|
||||
let dir = crate::paths::agent_runtime_dir(&spec.name);
|
||||
if dir.is_dir() {
|
||||
crate::lifecycle::hand_permissions(&spec.name, &dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `agent` holds the one permission hive-c0re enforces under `stored`.
|
||||
fn manages(agent: &str, stored: &Stored) -> bool {
|
||||
permissions::effective(stored.get(agent)).contains(&Permission::ManageRootAgent)
|
||||
|
|
|
|||
Loading…
Reference in a new issue