Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re: fail on an unparseable permission file, write it atomically

tool_groups::read and capabilities::read returned an empty map when
their file existed but didn't parse. Every set_*/remove_agent is a
read-modify-write, and write() rewrote the file in place, so a crash or
ENOSPC mid-write left a truncated file, and the next write (e.g. the
manager-spawn seed of ruth's tool groups) replaced it with a map holding
only one agent. The scheduling and approval gates then denied every
other agent, recoverable only from meta git history.

- Both registries now read through agent_config::read_map: a missing
  file is still the empty map, any other read failure or a parse
  failure is an io::Error. set_groups / set_caps / remove_agent fail
  without writing.
- Writes go through agent_config::write_map: temp file in the same
  directory, fsync, rename, fsync the directory. hive-c0re had no
  shared atomic-write helper (the existing tmp+rename sites are inline
  and don't fsync).
- Callers of read / groups_for / has_cap now handle the error:
  * dashboard GET /api/tool-groups, /api/capabilities,
    /api/permissions/stale return 500 instead of an empty table;
  * the SSE permission snapshots are skipped with a warn;
  * render_flake returns Result, so sync_agents fails instead of
    rendering every agent without its tool groups / capabilities;
  * set_nspawn_flags propagates has_cap's error;
  * the socket tool-group gates deny with the read error as message;
  * seed_manager_tool_groups logs and does not seed.
- capabilities::write had no callers left once set_caps writes through
  write_map, and is removed.

Closes #4719
This commit is contained in:
atlas 2026-09-26 02:17:54 +02:00 • committed by mara
commit e0b08fe362
9 changed files with 341 additions and 129 deletions

View file

@ -102,7 +102,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
&hive.context_window_tokens,
&hive.agent_memory_max,
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();
@ -585,7 +585,7 @@ fn render_flake(
context_window_tokens: &std::collections::HashMap<String, u64>,
hive_memory_max: &str,
agents: &[AgentSpec],
) -> String {
) -> Result<String> {
render_flake_with_lookup(
hyperhive_flake,
docs_flake,
@ -1000,7 +1000,7 @@ fn render_flake_with_lookup<F>(
hive_memory_max: &str,
agents: &[AgentSpec],
lookup: F,
) -> String
) -> Result<String>
where
F: Fn(&str) -> Vec<&'static str>,
{
@ -1316,8 +1316,8 @@ where
nixosConfigurations = {
"#,
);
let tool_groups_map = crate::tool_groups::read();
let capabilities_map = crate::capabilities::read();
let tool_groups_map = crate::tool_groups::read()?;
let capabilities_map = crate::capabilities::read()?;
let resource_limits_map = crate::resource_limits::read();
for spec in agents {
// Emit `toolGroups = "group1,group2"` when the operator has
@ -1370,7 +1370,7 @@ where
);
}
out.push_str(" };\n };\n}\n");
out
Ok(out)
}
/// Return the list of file names that are currently staged (index differs
@ -1671,7 +1671,8 @@ mod tests {
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
)
.expect("render flake");
// nixpkgs is a top-level input with an explicit URL; hyperhive
// follows it.
assert!(
@ -1718,7 +1719,8 @@ mod tests {
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
)
.expect("render flake");
assert!(
!out.contains("hyperhive-docs"),
"no docs input/source when docs_flake is empty:\n{out}"
@ -1737,7 +1739,8 @@ mod tests {
&std::collections::HashMap::new(),
"4G",
&[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`, \
@ -1778,7 +1781,8 @@ mod tests {
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
)
.expect("render flake");
assert!(
out.contains(
"services.hyperhive.agent.claudeCodePath = \"/nix/store/cccc-claude-code-2.1.220\";"
@ -1807,7 +1811,8 @@ mod tests {
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
)
.expect("render flake");
assert!(
!out.contains("claudeCodePath"),
"no claude assignment when unpinned:\n{out}"
@ -1828,7 +1833,8 @@ mod tests {
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
)
.expect("render flake");
assert!(
out.contains("nixpkgs.follows = \"hyperhive/nixpkgs\""),
"expected fallback follows:\n{out}"
@ -1864,7 +1870,8 @@ 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\""),
@ -1894,7 +1901,8 @@ mod tests {
"4G",
&[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
@ -1932,7 +1940,8 @@ mod tests {
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
)
.expect("render flake");
unsafe {
std::env::remove_var("HIVE_FORGE_URL");
}
@ -2110,7 +2119,8 @@ mod tests {
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
)
.expect("render flake");
unsafe {
std::env::remove_var("HIVE_FORGE_URL");
std::env::remove_var("HIVE_MATRIX_URL");
@ -2147,7 +2157,8 @@ mod tests {
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
)
.expect("render flake");
let want = format!(
"agent-alice.url = \"git+file://{}\"",
crate::paths::applied_dir("alice").display()
@ -2193,6 +2204,7 @@ mod tests {
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
};
// A leftover peer-CA file + the env var that used to name it. Both
@ -2286,6 +2298,7 @@ mod tests {
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
};
unsafe {
std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES");
@ -2361,6 +2374,7 @@ mod tests {
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
};
unsafe {
std::env::remove_var("HYPERHIVE_GITHUB_DISABLED");
@ -2403,7 +2417,8 @@ mod tests {
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
);
)
.expect("render flake");
let want_bytes = 4u64 * 1024 * 1024 * 1024;
assert!(
out.contains(&format!("memoryMaxBytes = {want_bytes};")),