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:
parent
f862354e43
commit
f0844f6da3
73 changed files with 1578 additions and 2742 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue