feat(gateway): hivectl gateway user management + fix htpasswdFile assertion

Add `hivectl gateway {create-user,delete-user,list-users}` subcommands for
managing htpasswd files used by gateway Basic auth. Pure Rust bcrypt
(cost 12, $2y$ prefix nginx accepts). No external htpasswd binary required.

Also fix the NixOS module assertion: `cfg.auth ? htpasswdFile` is always
true in the module system (declared options always exist as keys); switch
to `nullOr path; default = null` + `!= null` check so the assertion
actually fires with a useful error when enable=true but no file is set.
Guard bind-mount and nginx config against null to prevent eval errors.

Update docs/gateway.md to show hivectl commands instead of raw htpasswd.
This commit is contained in:
atlas 2026-06-01 23:00:38 +02:00
commit 4bff450343
61 changed files with 1084 additions and 547 deletions

View file

@ -76,8 +76,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
// Pre-enqueue cascade rebuilds in topological order so
// agents depending on updated inputs are rebuilt after the
// lock bump, matching the dashboard post_meta_update path.
let cascade_agents =
crate::rebuild_queue::meta_update_cascade_agents(&inputs).await;
let cascade_agents = crate::rebuild_queue::meta_update_cascade_agents(&inputs).await;
let cascade_reason = format!("approval #{id} meta input cascade");
for name in cascade_agents {
coord.rebuild_queue.enqueue(

View file

@ -40,8 +40,7 @@ fn render(map: &BTreeMap<String, u16>) -> String {
// BTreeMap → serde_json::to_string_pretty preserves key order,
// so the output is deterministic across calls with the same
// agent set.
serde_json::to_string_pretty(map)
.expect("BTreeMap<String, u16> is always serialisable")
serde_json::to_string_pretty(map).expect("BTreeMap<String, u16> is always serialisable")
}
/// Atomically write the JSON for `names` to
@ -61,12 +60,10 @@ pub fn write(names: &[String]) -> Result<()> {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &body)
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| {
format!(
"rename {} -> {} (atomic publish)",
@ -132,9 +129,6 @@ mod tests {
// BTreeMap sorts → alpha before zeta in output.
let alpha_pos = body.find("alpha").expect("alpha in output");
let zeta_pos = body.find("zeta").expect("zeta in output");
assert!(
alpha_pos < zeta_pos,
"sorted order broken:\n{body}"
);
assert!(alpha_pos < zeta_pos, "sorted order broken:\n{body}");
}
}

View file

@ -129,9 +129,11 @@ pub(crate) async fn dispatch_shared(
) -> Option<hive_sh4re::Response> {
let broker = &coord.broker;
Some(match req {
hive_sh4re::Request::Send { to, body, in_reply_to } => {
handle_send(coord, agent, to, body, *in_reply_to)
}
hive_sh4re::Request::Send {
to,
body,
in_reply_to,
} => handle_send(coord, agent, to, body, *in_reply_to),
hive_sh4re::Request::Recv { wait_seconds, max } => {
let cap = max.unwrap_or(1).min(RECV_BATCH_MAX) as usize;
match broker
@ -223,8 +225,8 @@ pub(crate) async fn dispatch_shared(
if let Err(message) = crate::limits::check_status_text(text) {
return Some(hive_sh4re::Response::Err { message });
}
let path = crate::coordinator::Coordinator::agent_notes_dir(agent)
.join("hyperhive-status");
let path =
crate::coordinator::Coordinator::agent_notes_dir(agent).join("hyperhive-status");
let result = if text.trim().is_empty() {
std::fs::remove_file(&path).or_else(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
@ -242,11 +244,9 @@ pub(crate) async fn dispatch_shared(
tokio::spawn(async move { coord2.rescan_containers_and_emit().await });
hive_sh4re::Response::Ok
}
Err(e) => {
hive_sh4re::Response::Err {
message: format!("set_status write failed: {e}"),
}
}
Err(e) => hive_sh4re::Response::Err {
message: format!("set_status write failed: {e}"),
},
}
}
hive_sh4re::Request::GetAgentMeta { name } => {
@ -294,7 +294,15 @@ pub(crate) async fn dispatch_shared(
message: format!("{e:#}"),
},
},
hive_sh4re::Request::GetHostJournal { unit, container, lines, priority, grep, since, until } => {
hive_sh4re::Request::GetHostJournal {
unit,
container,
lines,
priority,
grep,
since,
until,
} => {
dispatch_host_journal(agent, unit, container, lines, priority, grep, since, until).await
}
// Not a shared variant.
@ -331,7 +339,10 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
Err(message) => AgentResponse::Err { message },
}
}
AgentRequest::ReminderRollup { since_secs, agent: target } => {
AgentRequest::ReminderRollup {
since_secs,
agent: target,
} => {
let name = resolve_agent_state_target(agent, target.as_deref());
match name {
Ok(name) => match coord.broker.reminder_rollup_for(name, *since_secs) {
@ -547,10 +558,7 @@ pub(crate) fn store_remind(
) -> Result<(), String> {
let max = remind_max_pending();
if max > 0 {
let pending = coord
.broker
.count_pending_reminders_for(agent)
.unwrap_or(0);
let pending = coord.broker.count_pending_reminders_for(agent).unwrap_or(0);
if pending >= max {
return Err(format!(
"reminder rejected: agent `{agent}` already has {pending} pending \
@ -604,8 +612,9 @@ fn prepare_remind_storage(
};
let host_path = crate::reminder_scheduler::resolve_host_path(agent, &req_path)
.map_err(|reason| format!("auto-save path `{req_path}` rejected: {reason}"))?;
crate::reminder_scheduler::write_payload(agent, &host_path, message)
.map_err(|reason| format!("auto-save of large reminder body to `{req_path}` failed: {reason}"))?;
crate::reminder_scheduler::write_payload(agent, &host_path, message).map_err(|reason| {
format!("auto-save of large reminder body to `{req_path}` failed: {reason}")
})?;
let hint = format!(
"[reminder body of {} bytes auto-saved to `{req_path}`; read with your filesystem tools]",
message.len()
@ -634,19 +643,26 @@ fn auto_reminder_path(agent: &str) -> String {
/// - `Some("<other>")` where other is not a child → requires the
/// `query_agent_state` capability; returns an error otherwise.
/// - `Some("*")` → always rejected (hive-wide scans are manager-only).
fn resolve_agent_state_target<'a>(caller: &'a str, target: Option<&'a str>) -> Result<&'a str, String> {
fn resolve_agent_state_target<'a>(
caller: &'a str,
target: Option<&'a str>,
) -> Result<&'a str, String> {
match target {
None => Ok(caller),
Some("*") => Err(
"hive-wide query (agent=\"*\") is not available on the agent socket; \
use the manager socket for swarm-wide scans".to_owned()
use the manager socket for swarm-wide scans"
.to_owned(),
),
Some(name) => {
if name == caller {
return Ok(caller);
}
// Direct children are visible to their parent without extra capability.
if crate::topology::children_of(caller).iter().any(|c| c == name) {
if crate::topology::children_of(caller)
.iter()
.any(|c| c == name)
{
return Ok(name);
}
if crate::capabilities::has_cap(caller, hive_sh4re::Capability::QueryAgentState) {

View file

@ -81,8 +81,7 @@ pub fn socket_path_for(name: &str) -> PathBuf {
#[must_use]
pub fn build_map(names: &[String]) -> BTreeMap<String, PathBuf> {
build_map_with(names, |name| {
ready_marker_for(name).exists()
|| agent_dir_for(name).join(READY_MARKER_LEGACY).exists()
ready_marker_for(name).exists() || agent_dir_for(name).join(READY_MARKER_LEGACY).exists()
})
}
@ -146,12 +145,10 @@ pub fn write(names: &[String]) -> Result<()> {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &body)
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| {
format!(
"rename {} -> {} (atomic publish)",
@ -305,24 +302,30 @@ mod tests {
let marker = ready_marker_for("iris");
let socket = socket_path_for("iris");
assert_eq!(marker.parent(), socket.parent());
assert_eq!(marker, Path::new("/run/hive-agent/iris/hyperhive-socket-bound"));
assert_eq!(
marker,
Path::new("/run/hive-agent/iris/hyperhive-socket-bound")
);
}
#[test]
fn render_is_pretty_and_sorted() {
let mut map = BTreeMap::new();
map.insert("zeta".to_owned(), PathBuf::from("/run/hive-agent/zeta/web.sock"));
map.insert("alpha".to_owned(), PathBuf::from("/run/hive-agent/alpha/web.sock"));
map.insert(
"zeta".to_owned(),
PathBuf::from("/run/hive-agent/zeta/web.sock"),
);
map.insert(
"alpha".to_owned(),
PathBuf::from("/run/hive-agent/alpha/web.sock"),
);
let body = render(&map);
// Pretty-print = newlines between keys + indentation.
assert!(body.contains('\n'));
// BTreeMap sorts → alpha before zeta in output.
let alpha_pos = body.find("alpha").expect("alpha in output");
let zeta_pos = body.find("zeta").expect("zeta in output");
assert!(
alpha_pos < zeta_pos,
"sorted order broken:\n{body}"
);
assert!(alpha_pos < zeta_pos, "sorted order broken:\n{body}");
}
#[test]
@ -332,10 +335,12 @@ mod tests {
// gateway-side reader can deserialise into String values
// without nested struct logic.
let mut map = BTreeMap::new();
map.insert("iris".to_owned(), PathBuf::from("/run/hive-agent/iris/web.sock"));
map.insert(
"iris".to_owned(),
PathBuf::from("/run/hive-agent/iris/web.sock"),
);
let body = render(&map);
assert!(body.contains("\"iris\""));
assert!(body.contains("\"/run/hive-agent/iris/web.sock\""));
}
}

View file

@ -209,16 +209,19 @@ pub async fn ensure_manager(coord: &Arc<Coordinator>) -> Result<()> {
/// Sort `names` in-place so parents precede their children in the topology.
/// Uses BFS from root agents (depth 0). Agents absent from `topo` sort last,
/// alphabetically within their tier. Stable within each depth tier.
pub fn topology_sort(names: &mut [String], topo: &std::collections::BTreeMap<String, Option<String>>) {
pub fn topology_sort(
names: &mut Vec<String>,
topo: &std::collections::BTreeMap<String, Option<String>>,
) {
use std::collections::{HashMap, VecDeque};
// Build depth map using owned clones so the borrow on `names` is released
// before the sort_by mutable borrow.
let name_set: Vec<String> = names.to_vec();
let name_set: Vec<String> = names.clone();
let mut depth: HashMap<String, usize> = HashMap::new();
let mut queue: VecDeque<String> = VecDeque::new();
// Seed roots: entries with no parent, or names not present in topo at all.
for name in &name_set {
if topo.get(name).is_none_or(Option::is_none) {
if topo.get(name).map_or(true, |p| p.is_none()) {
depth.insert(name.clone(), 0);
queue.push_back(name.clone());
}
@ -300,4 +303,3 @@ pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
coord.emit_rebuild_queue_snapshot();
Ok(())
}

View file

@ -23,7 +23,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::coordinator::Coordinator;
const VACUUM_INTERVAL: Duration = Duration::from_hours(1);
const VACUUM_INTERVAL: Duration = Duration::from_secs(3600);
/// Keep completed task files for 48 hours before sweeping them.
const KEEP_SECS: i64 = 48 * 3600;
@ -65,7 +65,9 @@ fn sweep_once() {
/// files removed (each represents one task; `.out`/`.err` deletions
/// are not counted separately).
fn vacuum_dir(dir: &Path, cutoff: i64) -> u64 {
let Ok(rd) = std::fs::read_dir(dir) else { return 0 };
let Ok(rd) = std::fs::read_dir(dir) else {
return 0;
};
let mut removed: u64 = 0;
for entry in rd.flatten() {
let path = entry.path();
@ -97,7 +99,10 @@ fn should_delete(json_path: &Path, cutoff: i64) -> bool {
if !TERMINAL_STATUSES.contains(&status) {
return false;
}
let completed_at = v.get("completed_at").and_then(serde_json::Value::as_i64).unwrap_or(i64::MAX);
let completed_at = v
.get("completed_at")
.and_then(|t| t.as_i64())
.unwrap_or(i64::MAX);
completed_at < cutoff
}
@ -106,10 +111,10 @@ fn should_delete(json_path: &Path, cutoff: i64) -> bool {
fn delete_trio(dir: &Path, stem: &str) {
for ext in ["json", "out", "err"] {
let path = dir.join(format!("{stem}.{ext}"));
if path.exists()
&& let Err(e) = std::fs::remove_file(&path)
{
tracing::warn!(path = %path.display(), error = ?e, "bash-tasks vacuum: remove failed");
if path.exists() {
if let Err(e) = std::fs::remove_file(&path) {
tracing::warn!(path = %path.display(), error = ?e, "bash-tasks vacuum: remove failed");
}
}
}
}

View file

@ -15,6 +15,8 @@
//! dirs) and reuse the `forge` / `matrix` modules from the
//! `hive-c0re` lib — single source of truth, no duplication.
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use clap::{Parser, Subcommand};
use hive_c0re::coordinator::Coordinator;
@ -54,6 +56,14 @@ enum Cmd {
#[command(subcommand)]
cmd: MatrixCmd,
},
/// Gateway htpasswd user management. Add, remove, or list users in
/// an htpasswd file used by the gateway's HTTP Basic auth
/// (`services.hyperhive.gateway.auth`). Credentials are stored as
/// BCrypt hashes — no extra service or PAM required.
Gateway {
#[command(subcommand)]
cmd: GatewayCmd,
},
}
#[derive(Subcommand)]
@ -137,6 +147,48 @@ enum MatrixCmd {
},
}
#[derive(Subcommand)]
enum GatewayCmd {
/// Add a new user or update the password of an existing user in an
/// htpasswd file. The password is hashed with BCrypt (cost 12).
///
/// Pass `--password-stdin` when scripting or when you don't want the
/// password visible in shell history. The file is created if it does
/// not exist; its parent directory must already exist.
CreateUser {
/// Path to the htpasswd file (the value of
/// `services.hyperhive.gateway.auth.htpasswdFile`).
#[arg(long, short = 'f')]
file: PathBuf,
/// Username to add or update.
username: String,
/// Set the password inline. WARNING: visible in shell history and
/// process listings — prefer `--password-stdin` for sensitive input.
/// Mutually exclusive with `--password-stdin`.
#[arg(long, conflicts_with = "password_stdin")]
password: Option<String>,
/// Read the password from stdin (single line, trailing newline
/// stripped). Mutually exclusive with `--password`.
#[arg(long)]
password_stdin: bool,
},
/// Remove a user from an htpasswd file. Exits with an error when the
/// user is not found so callers can detect the no-op case.
DeleteUser {
/// Path to the htpasswd file.
#[arg(long, short = 'f')]
file: PathBuf,
/// Username to remove.
username: String,
},
/// List all usernames in an htpasswd file, one per line.
ListUsers {
/// Path to the htpasswd file.
#[arg(long, short = 'f')]
file: PathBuf,
},
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
@ -161,6 +213,16 @@ async fn main() -> Result<()> {
password_stdin,
} => matrix_create_user(&name, password.as_deref(), password_stdin).await,
},
Cmd::Gateway { cmd } => match cmd {
GatewayCmd::CreateUser {
file,
username,
password,
password_stdin,
} => gateway_create_user(&file, &username, password.as_deref(), password_stdin),
GatewayCmd::DeleteUser { file, username } => gateway_delete_user(&file, &username),
GatewayCmd::ListUsers { file } => gateway_list_users(&file),
},
}
}
@ -173,11 +235,7 @@ fn is_agent(name: &str) -> bool {
Coordinator::agent_state_root(name).exists()
}
async fn forge_create_user(
name: &str,
password: Option<&str>,
password_stdin: bool,
) -> Result<()> {
async fn forge_create_user(name: &str, password: Option<&str>, password_stdin: bool) -> Result<()> {
if !hive_c0re::forge::is_present().await {
bail!(
"hive-forge container not running — start it (services.hyperhive.forge.enable = true) before provisioning forge users"
@ -274,13 +332,18 @@ async fn matrix_create_user(
} else {
let effective_password = match user_password {
Some(p) => p,
None => hive_c0re::matrix::random_password()
.context("generate random matrix password")?,
None => {
hive_c0re::matrix::random_password().context("generate random matrix password")?
}
};
let token =
hive_c0re::matrix::provision_user_token(&client, name, &register_token, &effective_password)
.await
.with_context(|| format!("matrix create-user {name}"))?;
let token = hive_c0re::matrix::provision_user_token(
&client,
name,
&register_token,
&effective_password,
)
.await
.with_context(|| format!("matrix create-user {name}"))?;
println!("matrix: provisioned user '{name}' (not an agent — token not persisted)");
println!("token: {token}");
if password.is_some() || password_stdin {
@ -293,3 +356,115 @@ async fn matrix_create_user(
}
Ok(())
}
// ---------------------------------------------------------------------------
// Gateway htpasswd helpers
// ---------------------------------------------------------------------------
/// Read an htpasswd file into a list of lines, or return an empty list
/// if the file does not exist yet.
fn htpasswd_read(path: &Path) -> Result<Vec<String>> {
if !path.exists() {
return Ok(vec![]);
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("read htpasswd file {}", path.display()))?;
Ok(content.lines().map(str::to_owned).collect())
}
/// Write lines back to `path` atomically (write to `<path>.tmp`, then
/// rename). A trailing newline is always appended to the last line.
fn htpasswd_write(path: &Path, lines: &[String]) -> Result<()> {
let tmp = path.with_extension("htpasswd.tmp");
let content = if lines.is_empty() {
String::new()
} else {
let mut s = lines.join("\n");
s.push('\n');
s
};
std::fs::write(&tmp, &content)
.with_context(|| format!("write htpasswd tmp {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {}{}", tmp.display(), path.display()))?;
Ok(())
}
/// Add or update `username` in the htpasswd file at `file`, hashing
/// `password` with BCrypt (cost 12). Creates the file when absent.
fn gateway_create_user(
file: &Path,
username: &str,
password: Option<&str>,
password_stdin: bool,
) -> Result<()> {
let pw = resolve_password(password, password_stdin)?.ok_or_else(|| {
anyhow::anyhow!("a password is required — pass --password or --password-stdin")
})?;
validate_htpasswd_username(username)?;
let raw_hash = bcrypt::hash(&pw, 12).context("bcrypt hash")?;
// nginx auth_basic only recognises $2a$/$2x$/$2y$ — not $2b$. The two
// prefixes are algorithmically identical; remap so nginx accepts the hash.
let hash = raw_hash.replacen("$2b$", "$2y$", 1);
let entry = format!("{username}:{hash}");
let mut lines = htpasswd_read(file)?;
let prefix = format!("{username}:");
if let Some(pos) = lines.iter().position(|l| l.starts_with(&prefix)) {
lines[pos] = entry;
htpasswd_write(file, &lines)?;
println!(
"gateway: updated password for '{username}' in {}",
file.display()
);
} else {
lines.push(entry);
htpasswd_write(file, &lines)?;
println!("gateway: added user '{username}' to {}", file.display());
}
Ok(())
}
/// Remove `username` from the htpasswd file. Errors when the user is
/// not present so callers can detect the no-op case.
fn gateway_delete_user(file: &Path, username: &str) -> Result<()> {
let mut lines = htpasswd_read(file)?;
let prefix = format!("{username}:");
let before = lines.len();
lines.retain(|l| !l.starts_with(&prefix));
if lines.len() == before {
bail!("gateway: user '{username}' not found in {}", file.display());
}
htpasswd_write(file, &lines)?;
println!("gateway: removed user '{username}' from {}", file.display());
Ok(())
}
/// Print one username per line from the htpasswd file.
fn gateway_list_users(file: &Path) -> Result<()> {
let lines = htpasswd_read(file)?;
for line in &lines {
// Skip blank lines and comments.
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((name, _)) = line.split_once(':') {
println!("{name}");
}
}
Ok(())
}
/// Reject usernames containing `:` (field separator) or control chars
/// that would corrupt the htpasswd file format.
fn validate_htpasswd_username(username: &str) -> Result<()> {
if username.is_empty() {
bail!("username must not be empty");
}
if username.contains(':') {
bail!("username must not contain ':' (htpasswd field separator)");
}
if username.chars().any(|c| c.is_control()) {
bail!("username must not contain control characters");
}
Ok(())
}

View file

@ -268,12 +268,7 @@ impl Broker {
/// broker. Used by the scheduler to skip re-delivery of the same
/// scheduled prompt without blocking distinct schedules whose
/// bodies differ.
pub fn has_pending_with_body(
&self,
recipient: &str,
sender: &str,
body: &str,
) -> Result<bool> {
pub fn has_pending_with_body(&self, recipient: &str, sender: &str, body: &str) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM messages
@ -391,7 +386,13 @@ impl Broker {
)?;
let rows: Vec<(i64, String, String, String, Option<i64>)> = stmt
.query_map(params![recipient, max_i], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
})?
.collect::<rusqlite::Result<_>>()?;
drop(stmt);
@ -673,7 +674,11 @@ impl Broker {
/// Reminder rollup stats for an agent over a time window. Returns
/// counts of scheduled, delivered, and pending reminders created
/// in the last `since_secs` seconds (0 = all reminders).
pub fn reminder_rollup_for(&self, agent: &str, since_secs: u64) -> Result<hive_sh4re::ReminderStats> {
pub fn reminder_rollup_for(
&self,
agent: &str,
since_secs: u64,
) -> Result<hive_sh4re::ReminderStats> {
let conn = self.conn.lock().unwrap();
let cutoff_time = if since_secs > 0 {
let now = std::time::SystemTime::now()
@ -740,9 +745,7 @@ impl Broker {
|| canceller == hive_sh4re::OPERATOR_RECIPIENT
|| canceller == hive_sh4re::MANAGER_AGENT;
if !authorised {
anyhow::bail!(
"reminder {id}: '{canceller}' not allowed to cancel (owner = '{owner}')"
);
anyhow::bail!("reminder {id}: '{canceller}' not allowed to cancel (owner = '{owner}')");
}
let n = conn.execute(
"DELETE FROM reminders WHERE id = ?1 AND sent_at IS NULL",
@ -862,7 +865,9 @@ impl Broker {
}
drop(conn);
// Emit per-row Sent events (only for rows that succeeded).
for (((id, agent, body), result), msg_id) in items.iter().zip(results.iter()).zip(msg_ids.iter()) {
for (((id, agent, body), result), msg_id) in
items.iter().zip(results.iter()).zip(msg_ids.iter())
{
if result.is_ok() {
let _ = self.events.send(MessageEvent::Sent {
id: *msg_id,
@ -1029,10 +1034,7 @@ mod tests {
assert_eq!(broker.requeue_inflight("b").unwrap(), 1);
let d2 = pop_one(broker, "b").expect("popped again");
assert_eq!(d2.message.body, "hi");
assert!(
d2.redelivered,
"second pop should be tagged redelivered"
);
assert!(d2.redelivered, "second pop should be tagged redelivered");
assert_eq!(broker.ack_turn("b").unwrap(), 1);
}
@ -1275,4 +1277,3 @@ mod tests {
assert!(pop_one(broker, "bob").is_none());
}
}

View file

@ -274,14 +274,8 @@ impl BuildLogs {
match row {
None => Ok(None),
Some((stdout, stderr, finished_at, status)) => {
let stdout_append = stdout
.get(stdout_cursor..)
.unwrap_or("")
.to_string();
let stderr_append = stderr
.get(stderr_cursor..)
.unwrap_or("")
.to_string();
let stdout_append = stdout.get(stdout_cursor..).unwrap_or("").to_string();
let stderr_append = stderr.get(stderr_cursor..).unwrap_or("").to_string();
Ok(Some(BuildLogProgress {
stdout_append,
stderr_append,
@ -296,11 +290,7 @@ impl BuildLogs {
/// Headers only (no stdout/stderr blobs) — keeps `/api/state`
/// payloads light. Limit is hard-clamped to 50 to bound worst-case
/// payload regardless of caller input.
pub fn list_recent_for_agent(
&self,
agent: &str,
limit: usize,
) -> Result<Vec<BuildLogHeader>> {
pub fn list_recent_for_agent(&self, agent: &str, limit: usize) -> Result<Vec<BuildLogHeader>> {
let limit = limit.min(50);
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
@ -310,7 +300,10 @@ impl BuildLogs {
ORDER BY started_at DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(params![agent, i64::try_from(limit).unwrap_or(50)], row_to_header)?;
let rows = stmt.query_map(
params![agent, i64::try_from(limit).unwrap_or(50)],
row_to_header,
)?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
@ -470,7 +463,9 @@ mod tests {
#[test]
fn start_appends_finish_flow() {
let (_d, db) = tmpdb();
let id = db.start("alice", "prebuild", "nix build foo").expect("start");
let id = db
.start("alice", "prebuild", "nix build foo")
.expect("start");
db.append_stdout(id, "building '/nix/store/abc.drv'");
db.append_stderr(id, "error: line 12");
db.append_stderr(id, " at /nix/store/.../module.nix:5");
@ -509,8 +504,7 @@ mod tests {
// but list_recent already orders by `started_at DESC` then
// sqlite's natural insertion-order tiebreak. We rely only on
// both IDs being present + correct count + agent isolation.
let ids: std::collections::HashSet<i64> =
alice_rows.iter().map(|h| h.id).collect();
let ids: std::collections::HashSet<i64> = alice_rows.iter().map(|h| h.id).collect();
assert!(ids.contains(&id_a1));
assert!(ids.contains(&id_a2));

View file

@ -115,7 +115,9 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
} else {
continue;
};
let deployed_full = locked.get(&format!("agent-{logical}")).map(std::string::String::as_str);
let deployed_full = locked
.get(&format!("agent-{logical}"))
.map(std::string::String::as_str);
let needs_update = crate::auto_update::agent_config_pending(&logical, deployed_full);
let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned());
// Recipient name the broker uses for this agent — sub-agents
@ -143,27 +145,40 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
// Static / declared fields (extra_links, deployed_sha,
// pending_reminders, needs_update, parent) stay populated
// regardless of run state.
let (needs_login, ctx_tokens, context_window_tokens, rate_limited, status_text, status_set_at) =
if running {
// needs_login fires when EITHER the claude session dir is
// missing (boot-time / fresh container) OR the harness wrote
// the auth-failed sentinel because a turn hit 401. The
// manager has its own session lifecycle and never
// participates in needs_login.
let needs_login = !is_manager
&& (!claude_has_session(&Coordinator::agent_claude_dir(&logical))
|| auth_failed_sentinel(&logical));
let last_turn = read_last_turn(&logical);
let ctx_tokens = last_turn.as_ref().map(|(toks, _)| *toks);
let context_window_tokens = last_turn
.as_ref()
.and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens));
let rate_limited = is_rate_limited(&logical);
let (status_text, status_set_at) = read_status(&logical);
(needs_login, ctx_tokens, context_window_tokens, rate_limited, status_text, status_set_at)
} else {
(false, None, None, false, None, None)
};
let (
needs_login,
ctx_tokens,
context_window_tokens,
rate_limited,
status_text,
status_set_at,
) = if running {
// needs_login fires when EITHER the claude session dir is
// missing (boot-time / fresh container) OR the harness wrote
// the auth-failed sentinel because a turn hit 401. The
// manager has its own session lifecycle and never
// participates in needs_login.
let needs_login = !is_manager
&& (!claude_has_session(&Coordinator::agent_claude_dir(&logical))
|| auth_failed_sentinel(&logical));
let last_turn = read_last_turn(&logical);
let ctx_tokens = last_turn.as_ref().map(|(toks, _)| *toks);
let context_window_tokens = last_turn
.as_ref()
.and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens));
let rate_limited = is_rate_limited(&logical);
let (status_text, status_set_at) = read_status(&logical);
(
needs_login,
ctx_tokens,
context_window_tokens,
rate_limited,
status_text,
status_set_at,
)
} else {
(false, None, None, false, None, None)
};
out.push(ContainerView {
port: lifecycle::agent_web_port(&logical),
running,
@ -217,12 +232,18 @@ fn read_dashboard_links(name: &str) -> Vec<DashboardLink> {
/// don't lose state during the transition window.
fn read_harness_flags(name: &str) -> (bool, bool) {
let dir = Coordinator::agent_notes_dir(name);
if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json"))
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw)
{
let rl = v.get("rate_limited").and_then(serde_json::Value::as_bool).unwrap_or(false);
let nl = v.get("needs_login").and_then(serde_json::Value::as_bool).unwrap_or(false);
return (rl, nl);
if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json")) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
let rl = v
.get("rate_limited")
.and_then(|x| x.as_bool())
.unwrap_or(false);
let nl = v
.get("needs_login")
.and_then(|x| x.as_bool())
.unwrap_or(false);
return (rl, nl);
}
}
// Legacy fallback: presence of individual sentinel files.
let rate_limited = dir.join("hyperhive-rate-limited").exists();
@ -249,14 +270,23 @@ pub fn read_agent_status(name: &str) -> (Option<String>, Option<i64>) {
let path = Coordinator::agent_notes_dir(name).join("hyperhive-status");
let meta = std::fs::metadata(&path).ok();
let s = std::fs::read_to_string(&path).ok();
let text = s.as_deref().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned);
let text = s
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_owned);
let mtime = meta.and_then(|m| {
m.modified().ok().and_then(|t| {
t.duration_since(std::time::UNIX_EPOCH).ok()
t.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
})
});
if text.is_none() { (None, None) } else { (text, mtime) }
if text.is_none() {
(None, None)
} else {
(text, mtime)
}
}
fn read_status(name: &str) -> (Option<String>, Option<i64>) {
@ -304,9 +334,7 @@ pub async fn read_agent_status_live(name: &str) -> (Option<String>, Option<i64>,
/// corresponding env var is unset or empty.
#[must_use]
pub fn hive_swarm_names() -> (Option<String>, Option<String>) {
let read = |var: &str| -> Option<String> {
std::env::var(var).ok().filter(|s| !s.is_empty())
};
let read = |var: &str| -> Option<String> { std::env::var(var).ok().filter(|s| !s.is_empty()) };
(read("HYPERHIVE_HIVE_NAME"), read("HYPERHIVE_SWARM_NAME"))
}
@ -321,11 +349,8 @@ pub fn hive_swarm_names() -> (Option<String>, Option<String>) {
/// mirroring `hive_ag3nt::events::TokenUsage::context_tokens`.
fn read_last_turn(name: &str) -> Option<(u64, String)> {
let path = Coordinator::agent_notes_dir(name).join("hyperhive-turn-stats.sqlite");
let conn = Connection::open_with_flags(
&path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.ok()?;
let conn =
Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?;
conn.query_row(
"SELECT last_input_tokens + last_cache_read_input_tokens + last_cache_creation_input_tokens, model \
FROM turn_stats ORDER BY started_at DESC LIMIT 1",
@ -409,14 +434,26 @@ mod tests {
#[test]
fn resolves_family_substring() {
assert_eq!(resolve_ctx_window("claude-3-5-haiku-20241022", &cfg()), Some(200_000));
assert_eq!(resolve_ctx_window("claude-sonnet-4-5", &cfg()), Some(1_000_000));
assert_eq!(resolve_ctx_window("claude-opus-4-1", &cfg()), Some(1_000_000));
assert_eq!(
resolve_ctx_window("claude-3-5-haiku-20241022", &cfg()),
Some(200_000)
);
assert_eq!(
resolve_ctx_window("claude-sonnet-4-5", &cfg()),
Some(1_000_000)
);
assert_eq!(
resolve_ctx_window("claude-opus-4-1", &cfg()),
Some(1_000_000)
);
}
#[test]
fn resolution_is_case_insensitive() {
assert_eq!(resolve_ctx_window("Claude-Sonnet-4", &cfg()), Some(1_000_000));
assert_eq!(
resolve_ctx_window("Claude-Sonnet-4", &cfg()),
Some(1_000_000)
);
}
#[test]
@ -426,7 +463,10 @@ mod tests {
#[test]
fn empty_config_yields_none() {
assert_eq!(resolve_ctx_window("claude-3-5-haiku", &HashMap::new()), None);
assert_eq!(
resolve_ctx_window("claude-3-5-haiku", &HashMap::new()),
None
);
}
#[test]

View file

@ -708,11 +708,16 @@ impl Coordinator {
/// whose stop the crash watcher should NOT classify as a crash.
/// Lazily reaps entries older than `grace` so the map stays
/// bounded by the active agent count.
pub fn recent_transient_within(&self, grace: std::time::Duration) -> HashMap<String, TransientKind> {
pub fn recent_transient_within(
&self,
grace: std::time::Duration,
) -> HashMap<String, TransientKind> {
let now = std::time::Instant::now();
let mut map = self.recent_transient.lock().unwrap();
map.retain(|_, (_, ts)| now.duration_since(*ts) <= grace);
map.iter().map(|(k, (kind, _))| (k.clone(), *kind)).collect()
map.iter()
.map(|(k, (kind, _))| (k.clone(), *kind))
.collect()
}
/// Set a transient state and return a guard that clears it on drop.

View file

@ -472,9 +472,15 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
let s = v.to_string_lossy().to_ascii_lowercase();
matches!(s.as_str(), "1" | "true" | "yes")
}),
forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL").ok().filter(|s| !s.is_empty()),
hive_name: std::env::var("HYPERHIVE_HIVE_NAME").ok().filter(|s| !s.is_empty()),
swarm_name: std::env::var("HYPERHIVE_SWARM_NAME").ok().filter(|s| !s.is_empty()),
forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL")
.ok()
.filter(|s| !s.is_empty()),
hive_name: std::env::var("HYPERHIVE_HIVE_NAME")
.ok()
.filter(|s| !s.is_empty()),
swarm_name: std::env::var("HYPERHIVE_SWARM_NAME")
.ok()
.filter(|s| !s.is_empty()),
peer_hives: parse_peer_hives(),
})
}
@ -1751,9 +1757,7 @@ async fn get_build_log_full(
) -> Response {
match state.coord.build_logs.get_full(id) {
Ok(Some(log)) => axum::Json(log).into_response(),
Ok(None) => {
(StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(),
Err(e) => error_response(&format!("build-log {id}: {e:#}")),
}
}
@ -1870,10 +1874,7 @@ async fn get_build_log_stream(
/// separator (same layout the JS side-panel renders). The
/// `Content-Disposition` header triggers a browser download with a
/// descriptive filename so the operator can save and share the log.
async fn get_build_log_raw(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
) -> Response {
async fn get_build_log_raw(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -> Response {
match state.coord.build_logs.get_full(id) {
Ok(Some(log)) => {
let mut text = log.stdout;
@ -1897,9 +1898,7 @@ async fn get_build_log_raw(
)
.into_response()
}
Ok(None) => {
(StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(),
Err(e) => error_response(&format!("build-log {id}: {e:#}")),
}
}
@ -2522,7 +2521,10 @@ async fn get_tool_groups(State(_state): State<AppState>) -> axum::Json<ToolGroup
.map(|g| g.as_str())
.collect();
let assignments = crate::tool_groups::read();
axum::Json(ToolGroupsSnapshot { groups, assignments })
axum::Json(ToolGroupsSnapshot {
groups,
assignments,
})
}
#[derive(Deserialize)]

View file

@ -59,9 +59,7 @@ async fn read_lock_at_tag(repo: &Path, tag: &str) -> Result<Option<String>> {
// `inputs = { }`. Any other git failure (permission denied,
// ref-not-found, etc.) propagates as a hard error rather than
// being silently swallowed.
if stderr.contains("does not exist")
|| stderr.contains("exists on disk, but not in")
{
if stderr.contains("does not exist") || stderr.contains("exists on disk, but not in") {
return Ok(None);
}
anyhow::bail!("git show {spec} failed: {}", stderr.trim());
@ -130,10 +128,7 @@ pub fn duplicate_groups(raw: &str) -> Result<Vec<DuplicateGroup>> {
});
entry.keys.push(name.clone());
}
let mut dups: Vec<DuplicateGroup> = groups
.into_values()
.filter(|g| g.keys.len() > 1)
.collect();
let mut dups: Vec<DuplicateGroup> = groups.into_values().filter(|g| g.keys.len() > 1).collect();
for g in &mut dups {
g.keys.sort();
}
@ -259,12 +254,7 @@ async fn lock_in_sync_inner(worktree: &Path) -> Result<()> {
async fn remove_worktree(repo: &Path, worktree: &Path) -> Result<()> {
let out = git_command()
.current_dir(repo)
.args([
"worktree",
"remove",
"--force",
&worktree.to_string_lossy(),
])
.args(["worktree", "remove", "--force", &worktree.to_string_lossy()])
.output()
.await
.with_context(|| format!("git worktree remove {}", worktree.display()))?;

View file

@ -217,7 +217,14 @@ async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) ->
/// from the operator's point of view: same password input → same final
/// account state.
async fn change_user_password(name: &str, password: &str) -> Result<()> {
let args = ["user", "change-password", "--username", name, "--password", password];
let args = [
"user",
"change-password",
"--username",
name,
"--password",
password,
];
forge_admin(&args)
.await
.with_context(|| format!("forgejo admin user change-password {name}"))?;
@ -551,9 +558,8 @@ pub async fn ensure_shared_docs_repo(core_token: &str) -> Result<()> {
/// Mirrors `meta_read_access` so agents can clone the shared docs repo
/// without authentication hassle.
pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> {
let url = format!(
"{FORGE_HTTP}/api/v1/repos/{SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name}"
);
let url =
format!("{FORGE_HTTP}/api/v1/repos/{SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name}");
let body = r#"{"permission":"read"}"#;
let out = Command::new("curl")
.args([

View file

@ -14,15 +14,14 @@
pub mod actions;
pub mod agent_ports;
pub mod capabilities;
pub mod agent_server;
pub mod agent_sockets;
pub mod gateway_nginx;
pub mod approvals;
pub mod auto_update;
pub mod bash_tasks_vacuum;
pub mod broker;
pub mod build_logs;
pub mod capabilities;
pub mod client;
pub mod container_view;
pub mod coordinator;
@ -32,6 +31,7 @@ pub mod dashboard_events;
pub mod events_vacuum;
pub mod flake_check;
pub mod forge;
pub mod gateway_nginx;
pub mod lifecycle;
pub mod limits;
pub mod loose_ends;

View file

@ -449,11 +449,12 @@ pub async fn rebuild_no_meta(
"kill before cold-start retry failed (ignored)"
);
});
run(&["start", &container]).await
.map_err(|e| anyhow::anyhow!(
run(&["start", &container]).await.map_err(|e| {
anyhow::anyhow!(
"cold-start fallback also failed: {e:#} \
(original start error: {start_err:#})"
))
)
})
} else {
Ok(())
}
@ -494,9 +495,7 @@ async fn prebuild_toplevel(name: &str, flake_ref: &str) -> Result<()> {
// pair (no current callsite does, but the pair is redundant
// and worth checking once).
if fragment != name {
anyhow::bail!(
"prebuild_toplevel: flake_ref fragment '{fragment}' ≠ agent name '{name}'"
);
anyhow::bail!("prebuild_toplevel: flake_ref fragment '{fragment}' ≠ agent name '{name}'");
}
let attr = format!("{flake_root}#nixosConfigurations.{name}.config.system.build.toplevel");
let args = vec![
@ -1135,8 +1134,7 @@ fn set_nspawn_flags(
);
}
let own_config = format!("{HOST_AGENTS_ROOT}/{agent_name}/config");
std::fs::create_dir_all(&own_config)
.with_context(|| format!("create {own_config}"))?;
std::fs::create_dir_all(&own_config).with_context(|| format!("create {own_config}"))?;
let _ = write!(binds, " --bind-ro={own_config}:/agents/{agent_name}/config");
}
@ -1337,9 +1335,9 @@ async fn run(args: &[&str]) -> Result<()> {
// every notification with the eval-error verbatim.
let journal = container_journal_tail(args).await;
match log_id {
Some(id) => bail!(
"nixos-container {cmdline} failed ({status}); see build log #{id}{journal}"
),
Some(id) => {
bail!("nixos-container {cmdline} failed ({status}); see build log #{id}{journal}")
}
None => bail!("nixos-container {cmdline} failed ({status}){journal}"),
}
}
@ -1443,4 +1441,3 @@ mod tests {
);
}
}

View file

@ -16,7 +16,7 @@
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use hive_sh4re::{MANAGER_AGENT, LooseEnd};
use hive_sh4re::{LooseEnd, MANAGER_AGENT};
use crate::coordinator::Coordinator;

View file

@ -12,8 +12,8 @@ use hive_sh4re::{HostRequest, HostResponse};
// explicit (any new daemon entry point reads off the next add).
use hive_c0re::coordinator::Coordinator;
use hive_c0re::{
agent_sockets, auto_update, broker, client, crash_watch, dashboard, dashboard_events,
bash_tasks_vacuum, events_vacuum, forge, manager_server, matrix, migrate, rebuild_queue,
agent_sockets, auto_update, bash_tasks_vacuum, broker, client, crash_watch, dashboard,
dashboard_events, events_vacuum, forge, manager_server, matrix, migrate, rebuild_queue,
reminder_scheduler, scheduled_prompts_worker, server, stats_vacuum,
};
@ -51,7 +51,10 @@ enum Cmd {
/// short name to token count. Threaded into each container as
/// `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars. Set via the
/// `services.hive-c0re.contextWindowTokens` NixOS option.
#[arg(long, default_value = r#"{"haiku":200000,"sonnet":1000000,"opus":1000000}"#)]
#[arg(
long,
default_value = r#"{"haiku":200000,"sonnet":1000000,"opus":1000000}"#
)]
context_window_tokens: String,
},
/// Spawn a new agent container directly (`hive-agent-<name>`). Bypasses
@ -119,7 +122,17 @@ async fn main() -> Result<()> {
dashboard_port,
operator_pronouns,
context_window_tokens,
} => cmd_serve(hyperhive_flake, db, dashboard_port, operator_pronouns, context_window_tokens, &cli.socket).await,
} => {
cmd_serve(
hyperhive_flake,
db,
dashboard_port,
operator_pronouns,
context_window_tokens,
&cli.socket,
)
.await
}
Cmd::Spawn { name } => {
render(client::request(&cli.socket, HostRequest::Spawn { name }).await?)
}
@ -148,11 +161,7 @@ async fn main() -> Result<()> {
} => {
let new_parent = if root { None } else { parent };
render(
client::request(
&cli.socket,
HostRequest::SetParent { child, new_parent },
)
.await?,
client::request(&cli.socket, HostRequest::SetParent { child, new_parent }).await?,
)
}
}
@ -169,9 +178,8 @@ async fn cmd_serve(
context_window_tokens: String,
socket: &std::path::Path,
) -> Result<()> {
let cwt: std::collections::HashMap<String, u64> =
serde_json::from_str(&context_window_tokens)
.context("--context-window-tokens: invalid JSON")?;
let cwt: std::collections::HashMap<String, u64> = serde_json::from_str(&context_window_tokens)
.context("--context-window-tokens: invalid JSON")?;
let coord = Arc::new(Coordinator::open(
&db,
hyperhive_flake,
@ -335,7 +343,14 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(MessageEvent::Sent { id, from, to, body, at, in_reply_to }) => {
Ok(MessageEvent::Sent {
id,
from,
to,
body,
at,
in_reply_to,
}) => {
let file_refs = dashboard::scan_validated_paths(&body);
coord.emit_dashboard_event(DashboardEvent::Sent {
seq: coord.next_seq(),
@ -348,7 +363,14 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
file_refs,
});
}
Ok(MessageEvent::Delivered { id, from, to, body, at, in_reply_to }) => {
Ok(MessageEvent::Delivered {
id,
from,
to,
body,
at,
in_reply_to,
}) => {
let file_refs = dashboard::scan_validated_paths(&body);
coord.emit_dashboard_event(DashboardEvent::Delivered {
seq: coord.next_seq(),

View file

@ -572,7 +572,10 @@ where
// Emit `capabilities = "cap1,cap2"` when the operator has
// granted capabilities to this agent. Absent entry = null = no
// capability env var injected, capability-gated tools hidden.
let caps = capabilities_map.get(&spec.name).cloned().unwrap_or_default();
let caps = capabilities_map
.get(&spec.name)
.cloned()
.unwrap_or_default();
let capabilities_attr = if caps.is_empty() {
"null".to_owned()
} else {

View file

@ -147,7 +147,9 @@ fn migrate_harness_files(name: &str) {
}
match std::fs::rename(&src, &dst) {
Ok(()) => tracing::info!(%name, %file, "migration: moved to harness dir"),
Err(e) => tracing::warn!(%name, %file, error = ?e, "migration: move to harness dir failed"),
Err(e) => {
tracing::warn!(%name, %file, error = ?e, "migration: move to harness dir failed")
}
}
}
}
@ -190,7 +192,11 @@ async fn rename_manager_container(coord: &Arc<Coordinator>) {
// Stop the old container. Abort if stop fails — continuing with a
// running `root` and then starting `h-root` risks two manager
// instances racing for the same broker / state files.
match Command::new("nixos-container").args(["stop", "root"]).status().await {
match Command::new("nixos-container")
.args(["stop", "root"])
.status()
.await
{
Ok(s) if s.success() => {}
Ok(s) => {
tracing::warn!(status = %s, "migration phase 5: nixos-container stop root failed — aborting");
@ -218,12 +224,20 @@ async fn rename_manager_container(coord: &Arc<Coordinator>) {
}
// Daemon reload so systemd picks up the new container@h-root unit.
if let Err(e) = Command::new("systemctl").args(["daemon-reload"]).status().await {
if let Err(e) = Command::new("systemctl")
.args(["daemon-reload"])
.status()
.await
{
tracing::warn!(error = ?e, "migration phase 5: systemctl daemon-reload failed");
}
// Start the renamed container.
if let Err(e) = Command::new("nixos-container").args(["start", "h-root"]).status().await {
if let Err(e) = Command::new("nixos-container")
.args(["start", "h-root"])
.status()
.await
{
tracing::warn!(error = ?e, "migration phase 5: nixos-container start h-root failed");
return;
}

View file

@ -209,11 +209,7 @@ impl OperatorQuestions {
///
/// Not the target — that's covered by `answer` (responding with
/// an actual reply, sentinel or otherwise).
pub fn cancel(
&self,
id: i64,
canceller: &str,
) -> Result<(String, String, Option<String>)> {
pub fn cancel(&self, id: i64, canceller: &str) -> Result<(String, String, Option<String>)> {
let conn = self.conn.lock().unwrap();
let row: Option<(String, String, Option<String>, Option<i64>)> = conn
.query_row(
@ -232,9 +228,7 @@ impl OperatorQuestions {
|| canceller == hive_sh4re::OPERATOR_RECIPIENT
|| canceller == hive_sh4re::MANAGER_AGENT;
if !authorised {
bail!(
"question {id}: '{canceller}' not allowed to cancel (asker = '{asker}')"
);
bail!("question {id}: '{canceller}' not allowed to cancel (asker = '{asker}')");
}
let sentinel = format!("[cancelled by {canceller}]");
conn.execute(
@ -289,7 +283,6 @@ impl OperatorQuestions {
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
}
fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {

View file

@ -7,7 +7,7 @@
//! a persistent connection.
use anyhow::{Context as _, Result, bail};
use hive_sh4re::priv_proto::{PRIV_SOCK, BindMount, PrivRequest, PrivResponse};
use hive_sh4re::priv_proto::{BindMount, PRIV_SOCK, PrivRequest, PrivResponse};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
@ -31,27 +31,49 @@ pub async fn call(req: &PrivRequest) -> Result<PrivResponse> {
}
pub async fn start_container(name: &str) -> Result<()> {
ok(call(&PrivRequest::StartContainer { name: name.to_owned() }).await?)
ok(call(&PrivRequest::StartContainer {
name: name.to_owned(),
})
.await?)
}
pub async fn stop_container(name: &str) -> Result<()> {
ok(call(&PrivRequest::StopContainer { name: name.to_owned() }).await?)
ok(call(&PrivRequest::StopContainer {
name: name.to_owned(),
})
.await?)
}
pub async fn kill_container(name: &str) -> Result<()> {
ok(call(&PrivRequest::KillContainer { name: name.to_owned() }).await?)
ok(call(&PrivRequest::KillContainer {
name: name.to_owned(),
})
.await?)
}
pub async fn update_container(name: &str) -> Result<(String, String)> {
check(call(&PrivRequest::UpdateContainer { name: name.to_owned() }).await?)
check(
call(&PrivRequest::UpdateContainer {
name: name.to_owned(),
})
.await?,
)
}
pub async fn create_container(name: &str) -> Result<(String, String)> {
check(call(&PrivRequest::CreateContainer { name: name.to_owned() }).await?)
check(
call(&PrivRequest::CreateContainer {
name: name.to_owned(),
})
.await?,
)
}
pub async fn destroy_container(name: &str) -> Result<()> {
ok(call(&PrivRequest::DestroyContainer { name: name.to_owned() }).await?)
ok(call(&PrivRequest::DestroyContainer {
name: name.to_owned(),
})
.await?)
}
pub async fn list_containers() -> Result<String> {
@ -63,7 +85,8 @@ pub async fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<
ok(call(&PrivRequest::WriteNspawnFlags {
container: container.to_owned(),
binds: binds.to_vec(),
}).await?)
})
.await?)
}
pub async fn write_resource_limits(
@ -75,13 +98,15 @@ pub async fn write_resource_limits(
container: container.to_owned(),
memory_max: memory_max.to_owned(),
cpu_quota: cpu_quota.to_owned(),
}).await?)
})
.await?)
}
pub async fn remove_service_dropin(container: &str) -> Result<()> {
ok(call(&PrivRequest::RemoveServiceDropin {
container: container.to_owned(),
}).await?)
})
.await?)
}
pub async fn daemon_reload() -> Result<()> {
@ -97,21 +122,26 @@ pub async fn chown_socket_dir(agent_name: &str, uid: u32, gid: u32) -> Result<()
agent_name: agent_name.to_owned(),
uid,
gid,
}).await?)
})
.await?)
}
pub async fn chmod_socket_dir(agent_name: &str, mode: u32) -> Result<()> {
ok(call(&PrivRequest::ChmodSocketDir {
agent_name: agent_name.to_owned(),
mode,
}).await?)
})
.await?)
}
fn check(resp: PrivResponse) -> Result<(String, String)> {
if resp.ok {
Ok((resp.stdout, resp.stderr))
} else {
bail!("{}", resp.error.as_deref().unwrap_or("hive-priv returned error"))
bail!(
"{}",
resp.error.as_deref().unwrap_or("hive-priv returned error")
)
}
}

View file

@ -236,8 +236,7 @@ mod tests {
// exact MANAGER_AGENT constant passes).
assert!(check_approval_canceller_is_manager("").is_err());
assert!(
check_approval_canceller_is_manager(hive_sh4re::OPERATOR_RECIPIENT)
.is_err(),
check_approval_canceller_is_manager(hive_sh4re::OPERATOR_RECIPIENT).is_err(),
"operator surface uses the dashboard cancel path, not this dispatcher",
);
}

View file

@ -1228,4 +1228,3 @@ mod tests {
);
}
}

View file

@ -130,8 +130,7 @@ pub fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(),
let Some(parent) = host_path.parent() else {
return Err("internal: host path has no parent".to_owned());
};
std::fs::create_dir_all(parent)
.map_err(|e| format!("parent dir create failed: {e}"))?;
std::fs::create_dir_all(parent).map_err(|e| format!("parent dir create failed: {e}"))?;
// Resolve symlinks in the parent chain, then re-verify the
// canonical form still lives under the agent's host state root —
// catches `ln -s /etc state/escape` style attacks.

View file

@ -101,7 +101,10 @@ fn fire_schedule(coord: &Arc<Coordinator>, schedule: &Schedule, now: i64) {
// scheduled prompt from stacking up when an agent is slow or
// briefly offline, while still allowing distinct scheduled
// messages (different body) to enqueue independently.
match coord.broker.has_pending_with_body(target, "scheduled", &schedule.body) {
match coord
.broker
.has_pending_with_body(target, "scheduled", &schedule.body)
{
Ok(true) => {
tracing::debug!(
schedule = schedule.id,
@ -384,4 +387,3 @@ async fn known_agents_async() -> std::collections::HashSet<String> {
}
out
}

View file

@ -39,8 +39,7 @@ pub fn spawn(coord: &Arc<Coordinator>) {
fn sweep_once() {
for name in Coordinator::kept_state_names() {
let path =
Coordinator::agent_harness_dir(&name).join("hyperhive-turn-stats.sqlite");
let path = Coordinator::agent_harness_dir(&name).join("hyperhive-turn-stats.sqlite");
if !path.exists() {
continue;
}
@ -60,7 +59,9 @@ fn vacuum_file(path: &Path) -> Result<u64> {
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
let cutoff = now - KEEP_SECS;
let removed =
conn.execute("DELETE FROM turn_stats WHERE started_at < ?1", params![cutoff])?;
let removed = conn.execute(
"DELETE FROM turn_stats WHERE started_at < ?1",
params![cutoff],
)?;
Ok(u64::try_from(removed).unwrap_or(0))
}

View file

@ -59,10 +59,7 @@ pub fn children_of(name: &str) -> Vec<String> {
/// Pure form of [`children_of`] for unit tests.
#[must_use]
pub fn children_of_in(
topo: &BTreeMap<String, Option<String>>,
name: &str,
) -> Vec<String> {
pub fn children_of_in(topo: &BTreeMap<String, Option<String>>, name: &str) -> Vec<String> {
topo.iter()
.filter_map(|(agent, parent)| {
if parent.as_deref() == Some(name) {
@ -90,7 +87,13 @@ pub fn top_level_agents() -> Vec<String> {
#[must_use]
pub fn top_level_agents_in(topo: &BTreeMap<String, Option<String>>) -> Vec<String> {
topo.iter()
.filter_map(|(name, parent)| if parent.is_none() { Some(name.clone()) } else { None })
.filter_map(|(name, parent)| {
if parent.is_none() {
Some(name.clone())
} else {
None
}
})
.collect()
}
@ -501,8 +504,12 @@ mod tests {
// `alice` who lives under the manager) would close the loop.
// The general cycle walk catches this; no separate manager
// guard needed.
let err = apply_set_parent(&topo_three_level(), crate::lifecycle::MANAGER_NAME, Some("bob"))
.unwrap_err();
let err = apply_set_parent(
&topo_three_level(),
crate::lifecycle::MANAGER_NAME,
Some("bob"),
)
.unwrap_err();
assert!(err.contains("cycle"), "err = {err}");
}
@ -678,20 +685,32 @@ mod tests {
"alice".to_owned(),
vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()],
);
assert!(has_role_in(&roles, "alice", ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS));
assert!(has_role_in(
&roles,
"alice",
ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS
));
}
#[test]
fn has_role_in_returns_false_for_absent_agent() {
let roles: BTreeMap<String, Vec<String>> = BTreeMap::new();
assert!(!has_role_in(&roles, "alice", ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS));
assert!(!has_role_in(
&roles,
"alice",
ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS
));
}
#[test]
fn has_role_in_returns_false_for_empty_list() {
let mut roles = BTreeMap::new();
roles.insert("alice".to_owned(), vec![]);
assert!(!has_role_in(&roles, "alice", ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS));
assert!(!has_role_in(
&roles,
"alice",
ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS
));
}
/// Revoking a role must leave the key present with an empty list so
@ -701,7 +720,10 @@ mod tests {
let mgr = crate::lifecycle::MANAGER_NAME;
// Build an in-memory roles map as set_role would see it after granting.
let mut roles: BTreeMap<String, Vec<String>> = BTreeMap::new();
roles.insert(mgr.to_owned(), vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()]);
roles.insert(
mgr.to_owned(),
vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()],
);
// Simulate the revoke path of set_role (in-memory, no disk).
let list = roles.entry(mgr.to_owned()).or_default();
@ -727,7 +749,10 @@ mod tests {
let mgr_present = agent_names.iter().any(|n| n == mgr);
let should_seed = mgr_present && !roles.contains_key(mgr);
// should_seed must be false because manager key is present (tombstone).
assert!(!should_seed, "reconcile_roles must not re-seed an explicit revoke");
assert!(
!should_seed,
"reconcile_roles must not re-seed an explicit revoke"
);
}
/// `reconcile_roles` seeds the manager on first appearance (no prior entry).