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:
parent
25d2951d1e
commit
4bff450343
61 changed files with 1084 additions and 547 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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\""));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ®ister_token, &effective_password)
|
||||
.await
|
||||
.with_context(|| format!("matrix create-user {name}"))?;
|
||||
let token = hive_c0re::matrix::provision_user_token(
|
||||
&client,
|
||||
name,
|
||||
®ister_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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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()))?;
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1228,4 +1228,3 @@ mod tests {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue