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

@ -45,22 +45,14 @@ pub struct AgentSpec {
pub port: u16,
}
/// Stage every generated meta JSON file that exists: topology.json is
/// regenerated by `reconcile` whenever the agent set changed;
/// tool-groups is created lazily on
/// first write (`set_groups`) — absent means
/// every agent is on defaults, no file needed. Without staging, an
/// Stage topology.json if it exists: it is regenerated by `reconcile`
/// whenever the agent set changed. Without staging, an
/// existing-but-untracked file shows up as untracked in the meta repo,
/// which can confuse nix's dirty-tree fetch. `git add` is a no-op when
/// content is unchanged.
async fn stage_generated_meta_files(dir: &std::path::Path) -> Result<()> {
for (path, name) in [
(crate::topology::topology_path(), "topology.json"),
(crate::tool_groups::tool_groups_path(), "tool-groups.json"),
] {
if path.exists() {
git(dir, &["add", name]).await?;
}
if crate::topology::topology_path().exists() {
git(dir, &["add", "topology.json"]).await?;
}
Ok(())
}
@ -80,7 +72,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
let dir = crate::paths::meta_root();
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let new_flake = render_hive_flake(hive, agents)?;
let new_flake = render_hive_flake(hive, agents);
let flake_path = dir.join("flake.nix");
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
let initial = !dir.join(".git").exists();
@ -193,7 +185,6 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
// Only ever a REMOVAL now; see `is_embedded_ca_name`.
f if f.starts_with("peer-ca-") && has_pem_ext(f) => Some("peer-ca"),
"topology.json" => Some("topology"),
"tool-groups.json" => Some("tool-groups"),
_ => None,
})
.collect();
@ -299,50 +290,35 @@ pub async fn lock_update_hyperhive() -> Result<()> {
git_commit_paths(&dir, "bump hyperhive", &["flake.lock"]).await
}
/// Write the tool-groups file for `agent` and commit it atomically
/// under `META_LOCK`. Ensures the JSON change is staged + committed
/// before the next `sync_agents` runs, so the
/// working tree is never left dirty by an untimely `PermChange` write.
pub async fn commit_tool_groups(agent: &str, groups: &[String]) -> Result<()> {
let _guard = META_LOCK.lock().await;
crate::tool_groups::set_groups(agent, groups)?;
let dir = crate::paths::meta_root();
if crate::tool_groups::tool_groups_path().exists() {
git(&dir, &["add", "tool-groups.json"]).await?;
}
if paths_dirty(&dir, &["tool-groups.json"]).await? {
git_commit_paths(
&dir,
&format!("set tool-groups for {agent}"),
&["tool-groups.json"],
)
.await?;
}
Ok(())
}
/// Delete `capabilities.json` from the meta repo and commit the removal under
/// `META_LOCK`. Its grants live in the swarm's `agent-permissions` bucket
/// Delete the meta repo's permission files and commit the removal under
/// `META_LOCK`. Their grants live in the swarm's `agent-permissions` bucket
/// once `workers::permissions_import` has sent them.
///
/// # Errors
///
/// Removing the file or a git stage/commit step fails.
pub async fn remove_capabilities_file() -> Result<()> {
const FILE: &str = crate::workers::permissions_import::META_FILE;
/// Removing a file or a git stage/commit step fails.
pub async fn remove_permission_files() -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = crate::paths::meta_root();
match std::fs::remove_file(dir.join(FILE)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("removing {FILE}")),
let mut removed = Vec::new();
for file in crate::workers::permissions_import::META_FILES {
match std::fs::remove_file(dir.join(file)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("removing {file}")),
}
git(&dir, &["rm", "--cached", "--ignore-unmatch", "-q", file]).await?;
// Only a tracked file has a removal to commit; naming an untracked
// one makes `git commit -- <path>` fail.
if paths_dirty(&dir, &[file]).await? {
removed.push(*file);
}
}
git(&dir, &["rm", "--cached", "--ignore-unmatch", "-q", FILE]).await?;
if paths_dirty(&dir, &[FILE]).await? {
if !removed.is_empty() {
git_commit_paths(
&dir,
"capabilities moved to the swarm's agent-permissions",
&[FILE],
"permissions moved to the swarm's agent-permissions",
&removed,
)
.await?;
}
@ -351,7 +327,7 @@ pub async fn remove_capabilities_file() -> Result<()> {
/// [`render_flake`] with its inputs taken from `hive`, as [`sync_agents`]
/// renders it.
fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<String> {
fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> String {
render_flake(
&hive.hyperhive_flake,
&hive.hyperhive_docs_flake,
@ -378,7 +354,7 @@ fn render_flake(
operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>,
agents: &[AgentSpec],
) -> Result<String> {
) -> String {
render_flake_with_lookup(
hyperhive_flake,
docs_flake,
@ -781,7 +757,7 @@ fn render_flake_with_lookup<F>(
context_window_tokens: &std::collections::HashMap<String, u64>,
agents: &[AgentSpec],
lookup: F,
) -> Result<String>
) -> String
where
F: Fn(&str) -> Vec<&'static str>,
{
@ -860,7 +836,7 @@ where
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(
out,
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, toolGroups ? null }}:"
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port }}:"
);
out.push_str(
r#" let
@ -869,7 +845,6 @@ where
else hyperhive.nixosConfigurations.agent-base;
input = inputs."agent-${name}";
service = "hive-agent";
toolGroupsEnv = if toolGroups == null then {} else { HIVE_TOOL_GROUPS = toolGroups; };
in
base.extendModules {
modules = [
@ -1046,7 +1021,7 @@ where
}
out.push_str(
r" };
systemd.services.${service}.environment = toolGroupsEnv // {
systemd.services.${service}.environment = {
HIVE_PORT = toString port;
HIVE_LABEL = name;
HIVE_DASHBOARD_PORT = toString dashboardPort;
@ -1090,34 +1065,18 @@ where
nixosConfigurations = {
"#,
);
// Propagated, never read as empty: an agent with no tool-groups entry
// renders `toolGroups = null` and gets `AGENT_DEFAULT`, which fails
// open for any agent whose explicit entry is narrower.
let tool_groups_map = crate::tool_groups::read()?;
for spec in agents {
// Emit `toolGroups = "group1,group2"` when the operator has
// explicitly configured groups for this agent. Absent entry = null
// = harness falls back to AGENT_DEFAULT (no env var emitted,
// no rebuild cascade for agents whose groups haven't changed).
let groups = tool_groups_map.get(&spec.name).cloned().unwrap_or_default();
let tool_groups_attr = if groups.is_empty() {
"null".to_owned()
} else {
let joined = groups.join(",");
format!("\"{joined}\"")
};
let _ = writeln!(
out,
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; }};",
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; }};",
spec.name,
spec.name,
if spec.is_manager { "true" } else { "false" },
spec.port,
tool_groups_attr,
);
}
out.push_str(" };\n };\n}\n");
Ok(out)
out
}
/// Return the list of file names that are currently staged (index differs
@ -1202,7 +1161,7 @@ async fn git_commit(dir: &Path, message: &str) -> Result<()> {
/// Path-limited commit: commits ONLY the given paths, so unrelated
/// staged content can never be swept into someone else's commit. Every targeted
/// meta commit (perm files, topology, lock bumps) goes through this;
/// meta commit (limits, topology, lock bumps) goes through this;
/// only `sync_agents` uses the bare [`git_commit`], because its
/// staged set *is* its intentional commit set.
async fn git_commit_paths(dir: &Path, message: &str, paths: &[&str]) -> Result<()> {
@ -1293,29 +1252,35 @@ mod tests {
git(dir, &["init", "--initial-branch=main"])
.await
.expect("git init");
std::fs::write(dir.join("tool-groups.json"), "{}").expect("write");
std::fs::write(dir.join("resource-limits.json"), "{}").expect("write");
std::fs::write(dir.join("flake.lock"), "v1").expect("write");
git(dir, &["add", "-A"]).await.expect("add");
git_commit(dir, "seed").await.expect("seed commit");
// Something stages a new lock (uncommitted)…
std::fs::write(dir.join("flake.lock"), "v2-staged-by-deploy").expect("write");
git(dir, &["add", "flake.lock"]).await.expect("stage lock");
// …and a perm change commits, path-limited.
std::fs::write(dir.join("tool-groups.json"), r#"{"alice":[]}"#).expect("write");
git(dir, &["add", "tool-groups.json"]).await.expect("add");
git_commit_paths(dir, "set tool-groups for alice", &["tool-groups.json"])
// …and a limits change commits, path-limited.
std::fs::write(dir.join("resource-limits.json"), r#"{"alice":[]}"#).expect("write");
git(dir, &["add", "resource-limits.json"])
.await
.expect("path-limited commit");
// The perm file is committed; the staged lock is not.
.expect("add");
git_commit_paths(
dir,
"set resource limits for alice",
&["resource-limits.json"],
)
.await
.expect("path-limited commit");
// The limits file is committed; the staged lock is not.
assert!(
!paths_dirty(dir, &["tool-groups.json"])
!paths_dirty(dir, &["resource-limits.json"])
.await
.expect("check"),
"perm file must be committed"
"limits file must be committed"
);
assert!(
paths_dirty(dir, &["flake.lock"]).await.expect("check"),
"staged deploy lock must survive the perm commit"
"staged deploy lock must survive the limits commit"
);
}
@ -1352,8 +1317,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
// nixpkgs is a top-level input with an explicit URL; hyperhive
// follows it.
assert!(
@ -1399,8 +1363,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
assert!(
!out.contains("hyperhive-docs"),
"no docs input/source when docs_flake is empty:\n{out}"
@ -1418,8 +1381,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
assert!(
out.contains(r#"networking.hostName = "h-${name}";"#),
"an agent container with no hostname of its own is called `nixos`, \
@ -1459,8 +1421,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
assert!(
out.contains(
"services.hyperhive.agent.claudeCodePath = \"/nix/store/cccc-claude-code-2.1.220\";"
@ -1488,8 +1449,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
assert!(
!out.contains("claudeCodePath"),
"no claude assignment when unpinned:\n{out}"
@ -1509,8 +1469,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
assert!(
out.contains("nixpkgs.follows = \"hyperhive/nixpkgs\""),
"expected fallback follows:\n{out}"
@ -1545,8 +1504,7 @@ mod tests {
sample_spec("dmatrix", false, 9003),
],
lookup,
)
.expect("render flake");
);
// bitburner declares nixpkgs → follows emitted.
assert!(
out.contains("agent-bitburner.inputs.nixpkgs.follows = \"nixpkgs\""),
@ -1575,8 +1533,7 @@ mod tests {
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
|_| Vec::new(),
)
.expect("render flake");
);
// No agent-side follows when the lookup reports nothing
// declared — protects agents whose flake.lock can't be read
// (missing / unparsable) from being broken by a follows on a
@ -1613,8 +1570,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
unsafe {
std::env::remove_var("HIVE_FORGE_URL");
}
@ -1660,7 +1616,6 @@ mod tests {
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
};
let alpha = render_on("alpha-hive", "alpha-hive.example.test");
let beta = render_on("beta-hive", "beta-hive.example.test");
@ -1840,8 +1795,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
unsafe {
std::env::remove_var("HIVE_FORGE_URL");
std::env::remove_var("HIVE_MATRIX_URL");
@ -1877,8 +1831,7 @@ mod tests {
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake");
);
let want = format!(
"agent-alice.url = \"git+file://{}\"",
crate::paths::applied_dir("alice").display()
@ -1923,7 +1876,6 @@ mod tests {
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
};
// A leftover peer-CA file + the env var that names it. Both must be
@ -2016,7 +1968,6 @@ mod tests {
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
};
unsafe {
std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES");
@ -2091,7 +2042,6 @@ mod tests {
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
};
unsafe {
std::env::remove_var("HYPERHIVE_GITHUB_DISABLED");
@ -2123,8 +2073,8 @@ mod tests {
..HiveEnv::default()
};
let agents = [sample_spec("alice", false, 9001)];
let small = render_hive_flake(&hive("4G"), &agents).expect("render 4G");
let large = render_hive_flake(&hive("16G"), &agents).expect("render 16G");
let small = render_hive_flake(&hive("4G"), &agents);
let large = render_hive_flake(&hive("16G"), &agents);
assert_eq!(
small, large,
"the rendered flake must not depend on the memory cap"