Watch
0
0
Fork
You've already forked hyperhive
0

agent permissions: one set, tool groups included, set at agent creation

mara on #5170: "dont split caps and perms. its all perms. / dont include
the now empty tool groups / nothing set -> default / also add perms to
agent creation screen".

- hive-sh4re: one `Permission` enum (messaging, meta, inbox, execution,
  web_tools, manage_root_agent) replaces `ToolGroup` and `Capability`;
  `lifecycle`/`forge` are gone. `Permission::default_for` is the default
  for an agent the swarm sets nothing for.
- The record is `{ "permissions": [...] }`. Missing key, unset field or
  no successful read yet = default; `[]` = none; a failed read after a
  good one keeps the last value.
- hive-agent reads its own key every 10s and builds each turn's --tools,
  --allowedTools and MCP config (rewritten before every turn) from it;
  the subagent daemon reads it on each start/continue. Agents get a
  read-only grant on their own key.
- hive-c0re restarts an agent only when its manage_root_agent changes.
  The import now merges meta/capabilities.json and meta/tool-groups.json
  into one list per agent and deletes both; the "import pending, don't
  read" decision is `ImportOutcome::read_allowed`, table-tested.
- swarm-controller: `GET /api/permissions` (names + default) and an
  optional `permissions` on `POST /api/agents`, stored before the job is
  queued. swarm-ui's create-agent form gets a permissions field.
- Deleted: HIVE_TOOL_GROUPS and its forward to the subagent daemon,
  tool_groups.rs, ruth's tool-group seed, the permissions rebuild job
  (PermPayload/WritePermFile/perm_change), the dashboard permissions tab
  and its stale-entries panel.

Part of #5170.
This commit is contained in:
atlas 2026-10-15 04:09:53 +02:00 • committed by mara
commit f0844f6da3
73 changed files with 1578 additions and 2742 deletions

View file

@ -23,7 +23,6 @@ use anyhow::Result;
use crate::coordinator::Coordinator;
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
use crate::tool_groups;
/// Resolve the current rev of `hyperhive_flake`. For a path on disk we
/// canonicalize (following symlinks) so a /etc/hyperhive → /nix/store/...
@ -200,7 +199,6 @@ pub async fn ensure_root_agent(coord: &Arc<Coordinator>) -> Result<()> {
let hive = coord.hive_env();
let paths = Coordinator::agent_paths(MANAGER_NAME, runtime)?;
lifecycle::spawn(MANAGER_NAME, &hive, &paths).await?;
seed_manager_tool_groups();
if let Err(e) = coord.power.set(MANAGER_NAME, crate::power::Wanted::Up) {
tracing::warn!(error = ?e, "agent_power: set manager wanted=up failed");
}
@ -212,47 +210,6 @@ pub async fn ensure_root_agent(coord: &Arc<Coordinator>) -> Result<()> {
Ok(())
}
/// Give ruth her privileged tool groups on the one path that creates her.
///
/// `effective_tool_groups()` has no manager-flavour fallback, so an agent
/// with no entry in `tool-groups.json` is an agent with no privileged
/// tools. Ruth needs hers from her first turn, and this is the only place
/// she is brought into existence — so it is written once, here, rather
/// than re-checked on every hive-c0re boot.
///
/// Skips a name that already has an entry: a destroy+recreate under the
/// same name must not silently reset an operator's chosen group set back
/// to the default. An unreadable file is logged and left alone: ruth has
/// already been spawned, and seeding stays best-effort like the write
/// below.
fn seed_manager_tool_groups() {
match tool_groups::groups_for(MANAGER_NAME) {
Ok(groups) if groups.is_empty() => {}
Ok(_) => {
tracing::debug!("manager tool groups already set — leaving as-is");
return;
}
Err(e) => {
tracing::warn!(
error = ?e,
"tool-groups file unreadable — not seeding ruth's tool groups"
);
return;
}
}
let all_groups: Vec<String> = hive_sh4re::permissions::ToolGroup::MANAGER_DEFAULT
.iter()
.map(|g| <&str>::from(*g).to_owned())
.collect();
match tool_groups::set_groups(MANAGER_NAME, &all_groups) {
Ok(()) => tracing::info!("seeded ruth's tool groups to MANAGER_DEFAULT (all groups)"),
Err(e) => tracing::warn!(
error = ?e,
"failed to seed ruth's tool groups — she will start without privileged tools"
),
}
}
/// Boot reconcile (see the module doc): classify every agent by rev
/// freshness + persisted `wanted` intent, submit one `Boot` DAG
/// (hyperhive lock bump growing an in-DAG rebuild subgraph per stale

View file

@ -1,75 +1,146 @@
//! The one-shot move of the capabilities this hive's meta repo held
//! (`meta/capabilities.json`) into the swarm's `agent-permissions` bucket.
//! The one-shot move of the permissions this hive's meta repo held
//! (`meta/capabilities.json` and `meta/tool-groups.json`) into the swarm's
//! `agent-permissions` bucket, as one list per agent.
//!
//! At boot, before any agent starts, [`seed_local`] copies the file's grants
//! into [`crate::capabilities`] when nothing is stored there yet, so agents
//! At boot, before any agent starts, [`seed_local`] copies the files' grants
//! into [`crate::permissions`] when nothing is stored there yet, so agents
//! keep their mounts until the swarm answers. [`send_pending`] then sends the
//! grants to swarm-controller, which writes each agent's key only where there
//! is none, and on its answer deletes the file from the meta repo. Nothing
//! reads the file after that.
//! is none, and on its answer deletes both files from the meta repo. Nothing
//! reads them after that.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use hive_sh4re::permissions::Capability;
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{
self, AgentPermissions, IMPORT_REPLY_DEADLINE, Import, ImportReply,
};
/// The meta repo file the grants are moved out of.
pub const META_FILE: &str = "capabilities.json";
const CAPABILITIES_FILE: &str = "capabilities.json";
const TOOL_GROUPS_FILE: &str = "tool-groups.json";
fn meta_path() -> PathBuf {
crate::paths::meta_root().join(META_FILE)
/// The meta repo files the grants are moved out of.
pub const META_FILES: &[&str] = &[CAPABILITIES_FILE, TOOL_GROUPS_FILE];
/// Tool groups the files may name that give nothing and have no
/// [`Permission`]: dropped.
const EMPTY_TOOL_GROUPS: &[&str] = &["lifecycle", "forge"];
type Grants = BTreeMap<String, Vec<Permission>>;
type FileMap = BTreeMap<String, Vec<String>>;
fn parse(agent: &str, name: &str) -> Option<Permission> {
let found = Permission::ALL
.iter()
.copied()
.find(|p| name.eq_ignore_ascii_case(<&str>::from(*p)));
if found.is_none()
&& !EMPTY_TOOL_GROUPS
.iter()
.any(|g| name.eq_ignore_ascii_case(g))
{
tracing::warn!(%agent, permission = %name, "import: dropping unrecognised name");
}
found
}
/// The grants the meta file holds, by agent, as the swarm stores them. A name
/// that is not a [`Capability`] is dropped, with a warning.
/// The one list per agent the two files add up to, as the swarm stores it.
/// `None` for either file means it does not exist.
///
/// `ruth` with no entry in a file that exists had her grant revoked: the file
/// was created holding it, and a revoke removed the entry. She is sent an
/// empty list, since unset would give her the default back.
fn grants(file: BTreeMap<String, Vec<String>>) -> BTreeMap<String, Vec<Capability>> {
let mut out: BTreeMap<String, Vec<Capability>> = file
.into_iter()
.map(|(agent, names)| {
let caps = names
/// Each half is what the agent held under the old readers:
/// - tool groups: the entry's known names; an absent entry, or one naming
/// only unknown names, was the default groups. `lifecycle`/`forge` count as
/// known (they parsed) and are then dropped, so an entry of only those
/// held no tools and imports as none.
/// - capabilities: the entry's names. With the file present, `ruth` having no
/// entry means her grant was revoked: the file was created holding it, and
/// a revoke removed the entry. With no file, the default.
///
/// An agent whose list equals [`Permission::default_for`] is left out, so its
/// key stays unset and follows the default.
fn grants(caps: Option<&FileMap>, groups: Option<&FileMap>) -> Grants {
let mut agents: BTreeSet<&str> = BTreeSet::new();
agents.extend(caps.iter().flat_map(|m| m.keys().map(String::as_str)));
agents.extend(groups.iter().flat_map(|m| m.keys().map(String::as_str)));
if caps.is_some() {
agents.insert(crate::lifecycle::MANAGER_NAME);
}
let mut out = Grants::new();
for agent in agents {
let default = Permission::default_for(agent);
let tool_default: Vec<Permission> = default
.iter()
.copied()
.filter(|p| *p != Permission::ManageRootAgent)
.collect();
let tool_part = match groups.and_then(|m| m.get(agent)) {
Some(names)
if names.iter().any(|n| {
parse(agent, n).is_some()
|| EMPTY_TOOL_GROUPS.iter().any(|g| n.eq_ignore_ascii_case(g))
}) =>
{
names.iter().filter_map(|n| parse(agent, n)).collect()
}
_ => tool_default,
};
let cap_part: Vec<Permission> = match caps {
Some(m) => m
.get(agent)
.map(|names| names.iter().filter_map(|n| parse(agent, n)).collect())
.unwrap_or_default(),
None => default
.iter()
.filter_map(|name| {
let cap = Capability::ALL
.iter()
.copied()
.find(|c| name.eq_ignore_ascii_case(<&str>::from(*c)));
if cap.is_none() {
tracing::warn!(%agent, capability = %name, "import: dropping unrecognised capability");
}
cap
})
.collect();
(agent, caps)
})
.collect();
out.entry(crate::lifecycle::MANAGER_NAME.to_owned())
.or_default();
.copied()
.filter(|p| *p == Permission::ManageRootAgent)
.collect(),
};
let mut list: Vec<Permission> = Vec::new();
for perm in tool_part.into_iter().chain(cap_part) {
if !list.contains(&perm) {
list.push(perm);
}
}
let same_as_default =
list.len() == default.len() && default.iter().all(|p| list.contains(p));
if !same_as_default {
out.insert(agent.to_owned(), list);
}
}
out
}
fn read_grants(path: &Path) -> Result<Option<BTreeMap<String, Vec<Capability>>>> {
fn read_file(path: &Path) -> Result<Option<FileMap>> {
if !path.exists() {
return Ok(None);
}
let file: BTreeMap<String, Vec<String>> = serde_json::from_slice(
let map = serde_json::from_slice(
&std::fs::read(path).with_context(|| format!("reading {}", path.display()))?,
)
.with_context(|| format!("parsing {}", path.display()))?;
Ok(Some(grants(file)))
Ok(Some(map))
}
/// Store the meta file's grants as the last-read swarm permissions, when the
/// file is still there and nothing has been stored yet.
/// The grants the files in `meta` hold, or `None` when neither exists.
fn read_grants(meta: &Path) -> Result<Option<Grants>> {
let caps = read_file(&meta.join(CAPABILITIES_FILE))?;
let groups = read_file(&meta.join(TOOL_GROUPS_FILE))?;
if caps.is_none() && groups.is_none() {
return Ok(None);
}
Ok(Some(grants(caps.as_ref(), groups.as_ref())))
}
fn meta_dir() -> PathBuf {
crate::paths::meta_root()
}
/// 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() {
if let Err(e) = seed_local_at(&meta_path(), &crate::capabilities::path()) {
if let Err(e) = seed_local_at(&meta_dir(), &crate::permissions::path()) {
tracing::warn!(error = ?e, "import: seeding the stored permissions from the meta repo failed");
}
}
@ -83,39 +154,71 @@ fn seed_local_at(meta: &Path, stored: &Path) -> Result<()> {
};
let records = grants
.into_iter()
.map(|(agent, caps)| {
.map(|(agent, perms)| {
(
agent,
AgentPermissions {
capabilities: Some(caps),
permissions: Some(perms),
},
)
})
.collect();
crate::capabilities::write_at(stored, &records).context("storing the seeded permissions")?;
tracing::info!("import: seeded the stored permissions from the meta repo's capabilities");
crate::permissions::write_at(stored, &records).context("storing the seeded permissions")?;
tracing::info!("import: seeded the stored permissions from the meta repo");
Ok(())
}
/// Send the meta file's grants to swarm-controller while the file is there,
/// and delete it once the controller has answered that it handled them.
///
/// # Errors
///
/// The file is still there: the import failed, or deleting the file did. The
/// next pass sends again; the controller keeps a key it already has.
pub async fn send_pending(client: &async_nats::Client, hive: &str) -> Result<()> {
send_at(client, hive, &meta_path())
.await
.context("importing the meta repo's capabilities")
/// What one attempt to send the import came to.
#[derive(Debug)]
pub enum ImportOutcome {
/// No meta file is left: nothing to import.
NothingPending,
/// The controller took the grants and the files are deleted.
Imported,
/// The controller answered with a refusal; the files stay.
Refused(String),
/// No answer (the controller or the queue is down), or a file could not
/// be read or deleted; the files stay.
Unsettled(anyhow::Error),
}
async fn send_at(client: &async_nats::Client, hive: &str, path: &Path) -> Result<()> {
let Some(capabilities) = read_grants(path)? else {
return Ok(());
impl ImportOutcome {
/// Whether the swarm's keys may be read after this outcome. While a file
/// is left, an unset key may only mean its grants are not imported yet,
/// and reading it as the default would undo them.
#[must_use]
pub fn read_allowed(&self) -> bool {
matches!(self, Self::NothingPending | Self::Imported)
}
/// Why reading is not allowed, for the caller's log and banner.
#[must_use]
pub fn into_error(self) -> Option<anyhow::Error> {
match self {
Self::NothingPending | Self::Imported => None,
Self::Refused(why) => Some(anyhow::anyhow!("the controller refused the import: {why}")),
Self::Unsettled(e) => Some(e.context("importing the meta repo's permissions")),
}
}
}
/// Send the files' grants to swarm-controller while a file is there, and
/// delete both once the controller has answered that it handled them. The
/// next pass sends again after anything but [`ImportOutcome::Imported`]; the
/// controller keeps a key it already has.
pub async fn send_pending(client: &async_nats::Client, hive: &str) -> ImportOutcome {
match send_at(client, hive, &meta_dir()).await {
Ok(outcome) => outcome,
Err(e) => ImportOutcome::Unsettled(e),
}
}
async fn send_at(client: &async_nats::Client, hive: &str, meta: &Path) -> Result<ImportOutcome> {
let Some(permissions) = read_grants(meta)? else {
return Ok(ImportOutcome::NothingPending);
};
swarm_queue_client::ensure_connected(client)?;
let payload = serde_json::to_vec(&Import { capabilities })?;
let payload = serde_json::to_vec(&Import { permissions })?;
let reply = tokio::time::timeout(
IMPORT_REPLY_DEADLINE,
client.request(agent_permissions::import_subject(hive), payload.into()),
@ -133,65 +236,96 @@ async fn send_at(client: &async_nats::Client, hive: &str, path: &Path) -> Result
?written,
?kept,
?skipped,
"import: the controller took the meta repo's capabilities"
"import: the controller took the meta repo's permissions"
);
crate::meta::remove_capabilities_file()
crate::meta::remove_permission_files()
.await
.context("deleting the imported capabilities file")
}
ImportReply::Failed { error } => {
anyhow::bail!("the controller refused the import: {error}")
.context("deleting the imported permission files")?;
Ok(ImportOutcome::Imported)
}
ImportReply::Failed { error } => Ok(ImportOutcome::Refused(error)),
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{FileMap, grants, seed_local_at};
use hive_sh4re::permissions::Permission::{self, *};
use super::{grants, seed_local_at};
use hive_sh4re::permissions::Capability;
fn file(json: &str) -> BTreeMap<String, Vec<String>> {
fn file(json: &str) -> FileMap {
serde_json::from_str(json).expect("decodes")
}
/// Both files add up per agent; the empty groups and unknown names drop;
/// an agent left on the default is not sent.
#[test]
fn grants_keep_known_names_and_a_revoked_ruth_stays_revoked() {
let out = grants(file(
r#"{"atlas": ["manage_root_agent", "fly_to_the_moon"], "iris": []}"#,
));
assert_eq!(out["atlas"], vec![Capability::ManageRootAgent]);
assert_eq!(out["iris"], Vec::<Capability>::new());
assert_eq!(out["ruth"], Vec::<Capability>::new());
let out = grants(file(r#"{"ruth": ["manage_root_agent"]}"#));
assert_eq!(out["ruth"], vec![Capability::ManageRootAgent]);
fn the_two_files_merge_into_one_list() {
let caps = file(r#"{"ruth": ["manage_root_agent"], "atlas": ["manage_root_agent"]}"#);
let groups = file(
r#"{"ruth": ["messaging", "meta", "inbox", "lifecycle", "execution"],
"atlas": ["messaging", "web_tools", "forge"],
"iris": ["messaging", "fly_to_the_moon"],
"sock": ["lifecycle"]}"#,
);
let out = grants(Some(&caps), Some(&groups));
assert!(!out.contains_key("ruth"), "ruth's seed is her default");
assert_eq!(out["atlas"], vec![Messaging, WebTools, ManageRootAgent]);
assert_eq!(out["iris"], vec![Messaging]);
assert_eq!(out["sock"], Vec::<Permission>::new());
}
/// The seed writes once, from the file, and never over a stored read.
/// Without a capabilities entry, ruth's existing file means a revoke; with
/// no capabilities file at all, her default stands.
#[test]
fn the_seed_copies_the_file_only_when_nothing_is_stored() {
fn a_revoked_ruth_stays_revoked() {
let out = grants(Some(&file("{}")), None);
assert_eq!(out["ruth"], vec![Messaging, Meta, Inbox, Execution]);
let out = grants(None, Some(&file(r#"{"atlas": ["messaging"]}"#)));
assert!(!out.contains_key("ruth"));
assert_eq!(out["atlas"], vec![Messaging]);
}
/// The seed writes once, from the files, and never over a stored read.
#[test]
fn the_seed_copies_the_files_only_when_nothing_is_stored() {
let dir = tempfile::tempdir().expect("tempdir");
let meta = dir.path().join("capabilities.json");
let meta = dir.path();
let stored = dir.path().join("stored.json");
seed_local_at(&meta, &stored).expect("no file is no seed");
seed_local_at(meta, &stored).expect("no file is no seed");
assert!(!stored.exists());
std::fs::write(&meta, r#"{"atlas": ["manage_root_agent"]}"#).expect("seed");
seed_local_at(&meta, &stored).expect("seeded");
assert!(
crate::capabilities::holds(&stored, "atlas", Capability::ManageRootAgent)
.expect("read")
);
assert!(
!crate::capabilities::holds(&stored, "ruth", Capability::ManageRootAgent)
.expect("read")
);
std::fs::write(
meta.join("capabilities.json"),
r#"{"atlas": ["manage_root_agent"]}"#,
)
.expect("seed");
seed_local_at(meta, &stored).expect("seeded");
assert!(crate::permissions::holds(&stored, "atlas", ManageRootAgent).expect("read"));
assert!(!crate::permissions::holds(&stored, "ruth", ManageRootAgent).expect("read"));
std::fs::write(&stored, "{}").expect("a later read");
seed_local_at(&meta, &stored).expect("left alone");
seed_local_at(meta, &stored).expect("left alone");
assert_eq!(std::fs::read_to_string(&stored).expect("read"), "{}");
}
/// The read gate, one row per outcome: only a settled import lets the
/// swarm's keys be read.
#[test]
fn keys_are_read_only_once_the_import_is_settled() {
use super::ImportOutcome;
for (outcome, allowed) in [
(ImportOutcome::NothingPending, true),
(ImportOutcome::Imported, true),
(ImportOutcome::Refused("not a hive".to_owned()), false),
(
ImportOutcome::Unsettled(anyhow::anyhow!("no responders")),
false,
),
] {
assert_eq!(outcome.read_allowed(), allowed, "{outcome:?}");
assert_eq!(outcome.into_error().is_none(), allowed);
}
}
}

View file

@ -1,25 +1,26 @@
//! Reading the capabilities the swarm grants this hive's agents.
//! Reading the permissions the swarm grants this hive's agents.
//!
//! swarm-controller keeps one key per agent in the `agent-permissions` bucket
//! ([`swarm_queue_client::agent_permissions`]). Every [`READ_INTERVAL`] this
//! reads the key of each agent this hive has and stores what it finds with
//! [`crate::capabilities`], where the container's bind mounts are worked out.
//! Those are fixed when the container starts, so an agent whose capabilities
//! changed is restarted, after its current turn.
//! [`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.
//!
//! A failed read, or a bucket that does not answer, leaves the stored
//! permissions as they are and restarts nothing: dropping them would put an
//! agent back on its default, which can be wider than what the swarm set.
//! permissions as they are and restarts nothing.
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{self, AgentPermissions, Read};
use crate::capabilities::{self, Stored};
use crate::coordinator::Coordinator;
use crate::permissions::{self, Stored};
use crate::stats::sweep_health::{self, SweepHealth};
/// How often the permissions are read.
@ -38,11 +39,13 @@ pub async fn follow(
) {
let mut health = SweepHealth::new("swarm_permissions_read", "warn", FAILURES_BEFORE_BANNER);
loop {
// While the meta repo's grants are not in the swarm yet, an unset key
// is not the swarm's answer, so nothing is read until they are.
let pass = match super::permissions_import::send_pending(&client, &hive).await {
Ok(()) => sync(&client).await,
Err(e) => Err(e),
let import = super::permissions_import::send_pending(&client, &hive).await;
let pass = if import.read_allowed() {
sync(&client).await
} else {
Err(import
.into_error()
.unwrap_or_else(|| anyhow::anyhow!("import unsettled")))
};
match pass {
Ok(changed) => {
@ -76,7 +79,7 @@ pub async fn follow(
}
/// Read every agent's key and store the result. Returns the agents whose
/// capabilities changed.
/// `manage_root_agent` changed.
async fn sync(client: &async_nats::Client) -> Result<Vec<String>> {
// An unconnected client does not fail a JetStream request, it hangs on it.
swarm_queue_client::ensure_connected(client)?;
@ -86,7 +89,7 @@ async fn sync(client: &async_nats::Client) -> Result<Vec<String>> {
.into_iter()
.map(|spec| spec.name)
.collect();
sync_with(&agents, &capabilities::path(), |agent| async move {
sync_with(&agents, &permissions::path(), |agent| async move {
agent_permissions::read(client, &agent)
.await
.map_err(|e| anyhow::anyhow!(e))
@ -116,24 +119,26 @@ where
}
}
}
let stored = capabilities::read_at(path).context("reading the stored swarm permissions")?;
let stored = permissions::read_at(path).context("reading the stored swarm permissions")?;
if read_now == stored {
return Ok(Vec::new());
}
capabilities::write_at(path, &read_now).context("storing the swarm permissions")?;
permissions::write_at(path, &read_now).context("storing the swarm permissions")?;
tracing::info!(permissions = ?read_now, "swarm permissions: stored the swarm's permissions");
Ok(agents
.iter()
.filter(|a| {
capabilities::effective(a, stored.get(*a))
!= capabilities::effective(a, read_now.get(*a))
})
.filter(|a| manages(a, &stored) != manages(a, &read_now))
.cloned()
.collect())
}
/// Whether `agent` holds the one permission hive-c0re enforces under `stored`.
fn manages(agent: &str, stored: &Stored) -> bool {
permissions::effective(agent, stored.get(agent)).contains(&Permission::ManageRootAgent)
}
/// Restart each running agent in `changed` once its current turn is done, so
/// its container starts with the bind mounts its new capabilities give. A
/// its container starts with the bind mounts its new permissions give. A
/// stopped agent gets them at its next start.
async fn restart(coord: &Arc<Coordinator>, changed: Vec<String>) {
let mut running = Vec::new();
@ -145,7 +150,7 @@ async fn restart(coord: &Arc<Coordinator>, changed: Vec<String>) {
if running.is_empty() {
return;
}
tracing::info!(agents = ?running, "swarm permissions: capabilities changed, restarting");
tracing::info!(agents = ?running, "swarm permissions: manage_root_agent changed, restarting");
if let Err(e) = crate::job_queue::power::restart_many(coord, &running, true).await {
tracing::error!(agents = ?running, error = ?e, "swarm permissions: restart insert failed");
}
@ -188,16 +193,12 @@ mod tests {
.await
.expect("read");
assert!(changed.is_empty());
assert!(crate::permissions::read_at(&path).expect("read").is_empty());
assert!(
crate::capabilities::read_at(&path)
.expect("read")
.is_empty()
);
assert!(
crate::capabilities::holds(
crate::permissions::holds(
&path,
"ruth",
hive_sh4re::permissions::Capability::ManageRootAgent
hive_sh4re::permissions::Permission::ManageRootAgent
)
.expect("read")
);
@ -206,14 +207,14 @@ mod tests {
/// Revoking ruth's default, and granting atlas, restarts both; clearing
/// them again restarts both back.
#[tokio::test]
async fn a_changed_capability_asks_for_a_restart() {
async fn a_changed_manage_root_agent_asks_for_a_restart() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json");
let changed = sync(&path, |agent| {
Ok(Read::Current(record(if agent == "ruth" {
r#"{"capabilities":[]}"#
r#"{"permissions":[]}"#
} else {
r#"{"capabilities":["manage_root_agent"]}"#
r#"{"permissions":["manage_root_agent"]}"#
})))
})
.await
@ -223,9 +224,9 @@ mod tests {
// The same answer again changes nothing.
let unchanged = sync(&path, |agent| {
Ok(Read::Current(record(if agent == "ruth" {
r#"{"capabilities":[]}"#
r#"{"permissions":[]}"#
} else {
r#"{"capabilities":["manage_root_agent"]}"#
r#"{"permissions":["manage_root_agent"]}"#
})))
})
.await
@ -238,37 +239,37 @@ mod tests {
assert_eq!(cleared, agents());
}
/// Setting ruth's default explicitly is a different record but the same
/// mounts, so it is stored and restarts nothing.
/// A change that leaves `manage_root_agent` where it was is stored and
/// restarts nothing: the agent applies tool permissions itself.
#[tokio::test]
async fn an_explicit_default_is_stored_without_a_restart() {
async fn a_tool_only_change_is_stored_without_a_restart() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json");
let changed = sync(&path, |agent| {
Ok(Read::Current(if agent == "ruth" {
record(r#"{"capabilities":["manage_root_agent"]}"#)
record(r#"{"permissions":["manage_root_agent"]}"#)
} else {
AgentPermissions::default()
record(r#"{"permissions":["messaging","web_tools"]}"#)
}))
})
.await
.expect("read");
assert!(changed.is_empty());
assert!(
crate::capabilities::read_at(&path)
crate::permissions::read_at(&path)
.expect("read")
.contains_key("ruth")
.contains_key("atlas")
);
}
/// One failed read, or a bucket that does not answer, keeps what was
/// stored and restarts nothing: never a fall back to the default.
/// stored and restarts nothing.
#[tokio::test]
async fn a_failed_read_keeps_the_last_value() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json");
let revoked = || Ok(Read::Current(record(r#"{"capabilities":[]}"#)));
// An empty list is atlas's default already, so only ruth changes.
let revoked = || Ok(Read::Current(record(r#"{"permissions":[]}"#)));
// atlas holds no manage_root_agent either way, so only ruth changes.
assert_eq!(
sync(&path, |_| revoked()).await.expect("read"),
vec!["ruth".to_owned()]