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

85
Cargo.lock generated
View file

@ -14,7 +14,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"generic-array", "generic-array",
] ]
@ -25,7 +25,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cipher", "cipher 0.4.4",
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
] ]
@ -303,6 +303,19 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bcrypt"
version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ae5479c93d3720e4c1dbd6b945b97457c50cb672781104768190371df1a905"
dependencies = [
"base64",
"blowfish",
"getrandom 0.4.2",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.11.1"
@ -350,12 +363,28 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "blowfish"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298"
dependencies = [
"byteorder",
"cipher 0.5.2",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@ -374,7 +403,7 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [ dependencies = [
"cipher", "cipher 0.4.4",
] ]
[[package]] [[package]]
@ -406,7 +435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cipher", "cipher 0.4.4",
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
] ]
@ -418,7 +447,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [ dependencies = [
"aead", "aead",
"chacha20", "chacha20",
"cipher", "cipher 0.4.4",
"poly1305", "poly1305",
"zeroize", "zeroize",
] ]
@ -443,11 +472,21 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"inout", "inout 0.1.4",
"zeroize", "zeroize",
] ]
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.2",
"inout 0.2.2",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.1" version = "4.6.1"
@ -591,6 +630,15 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "curve25519-dalek" name = "curve25519-dalek"
version = "4.1.3" version = "4.1.3"
@ -742,7 +790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common 0.1.7",
"subtle", "subtle",
] ]
@ -1200,6 +1248,7 @@ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"base64", "base64",
"bcrypt",
"clap", "clap",
"hive-sh4re", "hive-sh4re",
"listenfd", "listenfd",
@ -1353,6 +1402,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
dependencies = [
"typenum",
]
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.10.1" version = "1.10.1"
@ -1618,6 +1676,15 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "inout"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@ -3740,7 +3807,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"subtle", "subtle",
] ]

View file

@ -1,6 +1,13 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["hive-ag3nt", "hive-c0re", "hive-forge", "hive-matrix-mcp", "hive-priv", "hive-sh4re"] members = [
"hive-ag3nt",
"hive-c0re",
"hive-forge",
"hive-matrix-mcp",
"hive-priv",
"hive-sh4re",
]
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
@ -18,6 +25,7 @@ must_use_candidate = "allow"
anyhow = "1" anyhow = "1"
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
base64 = "0.22" base64 = "0.22"
bcrypt = "0.19"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
hive-sh4re = { path = "hive-sh4re" } hive-sh4re = { path = "hive-sh4re" }
tower-http = { version = "0.6", features = ["fs"] } tower-http = { version = "0.6", features = ["fs"] }
@ -45,6 +53,13 @@ tokio = { version = "1", features = [
tokio-stream = { version = "0.1", features = ["sync"] } tokio-stream = { version = "0.1", features = ["sync"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = [
matrix-sdk = { version = "0.14", default-features = false, features = ["rustls-tls", "sqlite", "markdown"] } "json",
"rustls-tls",
] }
matrix-sdk = { version = "0.14", default-features = false, features = [
"rustls-tls",
"sqlite",
"markdown",
] }
futures-util = "0.3" futures-util = "0.3"

View file

@ -356,16 +356,27 @@ services.hyperhive.gateway.auth = {
}; };
``` ```
Create the htpasswd file on the host: Manage users with `hivectl gateway`:
```sh ```sh
# Create new file with first user (BCrypt, recommended): # Add or update a user (prompted for password):
htpasswd -Bc /etc/hyperhive/gateway.htpasswd alice hivectl gateway create-user --file /etc/hyperhive/gateway.htpasswd alice --password-stdin
# Add subsequent users: # Add with inline password (visible in shell history — avoid for sensitive creds):
htpasswd -B /etc/hyperhive/gateway.htpasswd bob hivectl gateway create-user --file /etc/hyperhive/gateway.htpasswd bob --password hunter2
# Remove a user:
hivectl gateway delete-user --file /etc/hyperhive/gateway.htpasswd bob
# List current usernames:
hivectl gateway list-users --file /etc/hyperhive/gateway.htpasswd
``` ```
`hivectl gateway create-user` hashes passwords with BCrypt (cost 12) and
writes `$2y$`-prefixed hashes that nginx accepts natively. No external
`htpasswd` binary is required. The file is created on first add if absent;
its parent directory must already exist.
The file must be readable by the `nginx` user inside the container The file must be readable by the `nginx` user inside the container
(`chmod 0644`). The module bind-mounts the file's parent directory (`chmod 0644`). The module bind-mounts the file's parent directory
read-only into the container at `/run/gateway-auth/`; nginx reads read-only into the container at `/run/gateway-auth/`; nginx reads

View file

@ -60,10 +60,7 @@ fn tasks_dir() -> PathBuf {
} else { } else {
// Pre-split fallback: derive harness/ as a sibling of state/. // Pre-split fallback: derive harness/ as a sibling of state/.
let state = crate::paths::state_dir(); let state = crate::paths::state_dir();
state state.parent().map(|p| p.join("harness")).unwrap_or(state)
.parent()
.map(|p| p.join("harness"))
.unwrap_or(state)
}; };
base.join("bash-tasks") base.join("bash-tasks")
} }
@ -136,9 +133,8 @@ impl TaskFile {
/// Write a task file atomically (tmp + rename). /// Write a task file atomically (tmp + rename).
fn write_task(task: &TaskFile) -> std::io::Result<()> { fn write_task(task: &TaskFile) -> std::io::Result<()> {
let json = serde_json::to_string_pretty(task).map_err(|e| { let json = serde_json::to_string_pretty(task)
std::io::Error::new(std::io::ErrorKind::InvalidData, e) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
})?;
let dest = task_json(&task.id); let dest = task_json(&task.id);
let tmp = dest.with_extension("json.tmp"); let tmp = dest.with_extension("json.tmp");
std::fs::write(&tmp, json)?; std::fs::write(&tmp, json)?;
@ -229,7 +225,7 @@ async fn run_loop(socket: PathBuf) {
let claimed: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new())); let claimed: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
loop { loop {
poll_once(&socket, &claimed); poll_once(&socket, &claimed).await;
tokio::time::sleep(POLL_INTERVAL).await; tokio::time::sleep(POLL_INTERVAL).await;
} }
} }
@ -237,14 +233,20 @@ async fn run_loop(socket: PathBuf) {
/// On boot, find any task files in `running` state and flip them to /// On boot, find any task files in `running` state and flip them to
/// `interrupted`, then fire a wake so the agent unblocks. /// `interrupted`, then fire a wake so the agent unblocks.
async fn mark_interrupted(socket: &Path) { async fn mark_interrupted(socket: &Path) {
let Ok(rd) = std::fs::read_dir(tasks_dir()) else { return }; let Ok(rd) = std::fs::read_dir(tasks_dir()) else {
return;
};
for entry in rd.flatten() { for entry in rd.flatten() {
let path = entry.path(); let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") { if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue; continue;
} }
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue }; let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
let Some(mut task) = read_task(&id) else { continue }; continue;
};
let Some(mut task) = read_task(&id) else {
continue;
};
if task.status != TaskStatus::Running { if task.status != TaskStatus::Running {
continue; continue;
} }
@ -258,14 +260,18 @@ async fn mark_interrupted(socket: &Path) {
} }
} }
fn poll_once(socket: &Path, claimed: &Arc<Mutex<HashSet<String>>>) { async fn poll_once(socket: &Path, claimed: &Arc<Mutex<HashSet<String>>>) {
let Ok(rd) = std::fs::read_dir(tasks_dir()) else { return }; let Ok(rd) = std::fs::read_dir(tasks_dir()) else {
return;
};
for entry in rd.flatten() { for entry in rd.flatten() {
let path = entry.path(); let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") { if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue; continue;
} }
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue }; let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
continue;
};
{ {
let guard = claimed.lock().unwrap(); let guard = claimed.lock().unwrap();
if guard.contains(&id) { if guard.contains(&id) {
@ -322,7 +328,11 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
let stdout_tail = tail_file(&out_path, SUMMARY_BYTES); let stdout_tail = tail_file(&out_path, SUMMARY_BYTES);
let stderr_tail = tail_file(&err_path, SUMMARY_BYTES); let stderr_tail = tail_file(&err_path, SUMMARY_BYTES);
task.status = if timed_out { TaskStatus::TimedOut } else { TaskStatus::Done }; task.status = if timed_out {
TaskStatus::TimedOut
} else {
TaskStatus::Done
};
task.completed_at = Some(crate::serve_common::now_unix()); task.completed_at = Some(crate::serve_common::now_unix());
task.exit_code = exit_code; task.exit_code = exit_code;
task.stdout_tail = stdout_tail.clone().filter(|s| !s.is_empty()); task.stdout_tail = stdout_tail.clone().filter(|s| !s.is_empty());
@ -344,7 +354,12 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
/// Run `sh -c cmd`, streaming output to files. Returns `(exit_code, timed_out)`. /// Run `sh -c cmd`, streaming output to files. Returns `(exit_code, timed_out)`.
/// On timeout the child process is explicitly killed before returning. /// On timeout the child process is explicitly killed before returning.
async fn exec_cmd(cmd: &str, out_path: &Path, err_path: &Path, timeout: Duration) -> Result<(i32, bool)> { async fn exec_cmd(
cmd: &str,
out_path: &Path,
err_path: &Path,
timeout: Duration,
) -> Result<(i32, bool)> {
use tokio::process::Command; use tokio::process::Command;
let mut child = Command::new("sh") let mut child = Command::new("sh")
.arg("-c") .arg("-c")
@ -396,7 +411,9 @@ where
let _ = tokio::io::copy(&mut reader, &mut f).await; let _ = tokio::io::copy(&mut reader, &mut f).await;
let _ = f.flush().await; let _ = f.flush().await;
} }
Err(e) => tracing::warn!(path = %path.display(), error = ?e, "bash_runner: open output file failed"), Err(e) => {
tracing::warn!(path = %path.display(), error = ?e, "bash_runner: open output file failed")
}
} }
} }
@ -415,12 +432,7 @@ fn tail_file(path: &Path, max_bytes: usize) -> Option<String> {
// Wake delivery // Wake delivery
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async fn send_wake( async fn send_wake(socket: &Path, id: &str, summary: &str, output: Option<(&str, &str)>) {
socket: &Path,
id: &str,
summary: &str,
output: Option<(&str, &str)>,
) {
let mut body = format!("bash task `{id}` finished: {summary}"); let mut body = format!("bash task `{id}` finished: {summary}");
if let Some((stdout, stderr)) = output { if let Some((stdout, stderr)) = output {
if !stdout.is_empty() { if !stdout.is_empty() {

View file

@ -18,7 +18,9 @@ use clap::{Parser, Subcommand};
use hive_ag3nt::events::{Bus, LiveEvent, TurnState}; use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
use hive_ag3nt::login::{self, LoginState}; use hive_ag3nt::login::{self, LoginState};
use hive_ag3nt::turn_stats::TurnStats; use hive_ag3nt::turn_stats::TurnStats;
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, serve_common, turn, web_ui}; use hive_ag3nt::{
DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, serve_common, turn, web_ui,
};
use hive_sh4re::{ use hive_sh4re::{
AgentRequest, AgentResponse, HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER, AgentRequest, AgentResponse, HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER,
}; };
@ -129,7 +131,9 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) {
} else { } else {
tracing::info!(%from, %body, "system message"); tracing::info!(%from, %body, "system message");
} }
bus.emit(LiveEvent::Note { text: format!("[system] {body}") }); bus.emit(LiveEvent::Note {
text: format!("[system] {body}"),
});
} }
/// Body string for the turn-failure notification we route to /// Body string for the turn-failure notification we route to
@ -140,7 +144,11 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) {
/// misconfigured harness still produces a parseable line. /// misconfigured harness still produces a parseable line.
fn format_turn_failure(err: &anyhow::Error) -> String { fn format_turn_failure(err: &anyhow::Error) -> String {
let who = hive_ag3nt::identity::qualified_label(); let who = hive_ag3nt::identity::qualified_label();
let who = if who.is_empty() { "<unknown>".to_owned() } else { who }; let who = if who.is_empty() {
"<unknown>".to_owned()
} else {
who
};
format!("[system] `{who}` claude turn failed:\n{err:#}") format!("[system] `{who}` claude turn failed:\n{err:#}")
} }
@ -202,9 +210,7 @@ trait Surface {
/// `(open_threads, open_reminders)` for the post-turn stats row. /// `(open_threads, open_reminders)` for the post-turn stats row.
/// Either field is `None` when the underlying request errors. /// Either field is `None` when the underlying request errors.
fn post_turn_counts( fn post_turn_counts(socket: &Path) -> impl Future<Output = (Option<u64>, Option<u64>)>;
socket: &Path,
) -> impl Future<Output = (Option<u64>, Option<u64>)>;
/// Send a message addressed to `<parent>` (broker resolves the /// Send a message addressed to `<parent>` (broker resolves the
/// sentinel via `topology::parent_of` at delivery time; root /// sentinel via `topology::parent_of` at delivery time; root
@ -225,11 +231,8 @@ trait Surface {
/// by co-process daemons like matrix to push events into the /// by co-process daemons like matrix to push events into the
/// harness inbox). Errors out via `anyhow::bail!` so the calling /// harness inbox). Errors out via `anyhow::bail!` so the calling
/// binary surfaces them on stderr. /// binary surfaces them on stderr.
fn wake_external( fn wake_external(socket: &Path, from: String, body: String)
socket: &Path, -> impl Future<Output = Result<()>>;
from: String,
body: String,
) -> impl Future<Output = Result<()>>;
} }
// ---------- AgentSurface ---------- // ---------- AgentSurface ----------
@ -271,13 +274,15 @@ impl Surface for AgentSurface {
} }
async fn post_turn_counts(socket: &Path) -> (Option<u64>, Option<u64>) { async fn post_turn_counts(socket: &Path) -> (Option<u64>, Option<u64>) {
let threads = let threads = match client::request::<_, AgentResponse>(
match client::request::<_, AgentResponse>(socket, &AgentRequest::GetLooseEnds { agent: None }).await { socket,
Ok(AgentResponse::LooseEnds { loose_ends }) => { &AgentRequest::GetLooseEnds { agent: None },
u64::try_from(loose_ends.len()).ok() )
} .await
_ => None, {
}; Ok(AgentResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(),
_ => None,
};
let reminders = match client::request::<_, AgentResponse>( let reminders = match client::request::<_, AgentResponse>(
socket, socket,
&AgentRequest::CountPendingReminders { agent: None }, &AgentRequest::CountPendingReminders { agent: None },
@ -415,9 +420,7 @@ impl Surface for ManagerSurface {
) )
.await .await
{ {
Ok(ManagerResponse::LooseEnds { loose_ends }) => { Ok(ManagerResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(),
u64::try_from(loose_ends.len()).ok()
}
_ => None, _ => None,
}; };
let reminders = match client::request::<_, ManagerResponse>( let reminders = match client::request::<_, ManagerResponse>(
@ -569,8 +572,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
); );
tokio::spawn(async move { tokio::spawn(async move {
let (label, port, login_state, bus, socket, files, turn_lock) = web_ui_args; let (label, port, login_state, bus, socket, files, turn_lock) = web_ui_args;
if let Err(e) = if let Err(e) = web_ui::serve(label, port, login_state, bus, socket, files, turn_lock).await
web_ui::serve(label, port, login_state, bus, socket, files, turn_lock).await
{ {
tracing::error!(error = %e, "web_ui::serve exited with error"); tracing::error!(error = %e, "web_ui::serve exited with error");
} }
@ -658,7 +660,11 @@ async fn handle_turn<S: Surface>(
log_system_event(bus, &from, &body); log_system_event(bus, &from, &body);
tracing::info!(%from, %body, %redelivered, "inbox"); tracing::info!(%from, %body, %redelivered, "inbox");
let unread = S::inbox_unread(socket).await; let unread = S::inbox_unread(socket).await;
bus.emit(LiveEvent::TurnStart { from: from.clone(), body: body.clone(), unread }); bus.emit(LiveEvent::TurnStart {
from: from.clone(),
body: body.clone(),
unread,
});
bus.set_state(TurnState::Thinking); bus.set_state(TurnState::Thinking);
let started_at = serve_common::now_unix(); let started_at = serve_common::now_unix();
let started_instant = std::time::Instant::now(); let started_instant = std::time::Instant::now();
@ -670,7 +676,10 @@ async fn handle_turn<S: Surface>(
}; };
turn::emit_turn_end(bus, &outcome); turn::emit_turn_end(bus, &outcome);
bus.set_state(TurnState::Idle); bus.set_state(TurnState::Idle);
if matches!(outcome, turn::TurnOutcome::Ok | turn::TurnOutcome::Compacted) { if matches!(
outcome,
turn::TurnOutcome::Ok | turn::TurnOutcome::Compacted
) {
S::ack_turn(socket).await; S::ack_turn(socket).await;
} }
if matches!(outcome, turn::TurnOutcome::RateLimited) { if matches!(outcome, turn::TurnOutcome::RateLimited) {
@ -697,8 +706,7 @@ async fn handle_turn<S: Surface>(
} }
if let Some(stats) = stats { if let Some(stats) = stats {
let ended_at = serve_common::now_unix(); let ended_at = serve_common::now_unix();
let duration_ms = let duration_ms = i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
let (open_threads, open_reminders) = S::post_turn_counts(socket).await; let (open_threads, open_reminders) = S::post_turn_counts(socket).await;
let row = serve_common::build_row( let row = serve_common::build_row(
started_at, started_at,

View file

@ -80,12 +80,18 @@ fn harness_json_path() -> PathBuf {
fn read_harness_state() -> (bool, bool) { fn read_harness_state() -> (bool, bool) {
// Try the new consolidated file first. // Try the new consolidated file first.
if let Ok(raw) = std::fs::read_to_string(harness_json_path()) if let Ok(raw) = std::fs::read_to_string(harness_json_path()) {
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
{ let rate_limited = v
let rate_limited = v.get("rate_limited").and_then(serde_json::Value::as_bool).unwrap_or(false); .get("rate_limited")
let needs_login = v.get("needs_login").and_then(serde_json::Value::as_bool).unwrap_or(false); .and_then(|x| x.as_bool())
return (rate_limited, needs_login); .unwrap_or(false);
let needs_login = v
.get("needs_login")
.and_then(|x| x.as_bool())
.unwrap_or(false);
return (rate_limited, needs_login);
}
} }
// Fall back to legacy sentinel files written by older harness builds. // Fall back to legacy sentinel files written by older harness builds.
let state_dir = crate::paths::state_dir(); let state_dir = crate::paths::state_dir();

View file

@ -244,10 +244,7 @@ fn is_username_byte(b: u8) -> bool {
/// window so addressed agents never silently miss a mention on a long /// window so addressed agents never silently miss a mention on a long
/// body. See `docs/forge.md::Body excerpt + truncation + heading /// body. See `docs/forge.md::Body excerpt + truncation + heading
/// escape` for the truncate-before-escape ordering rule. /// escape` for the truncate-before-escape ordering rule.
fn extract_truncated_mention_lines<'a>( fn extract_truncated_mention_lines<'a>(full_body: &'a str, included_excerpt: &str) -> Vec<&'a str> {
full_body: &'a str,
included_excerpt: &str,
) -> Vec<&'a str> {
full_body full_body
.lines() .lines()
.filter(|line| { .filter(|line| {
@ -914,7 +911,10 @@ mod tests {
let full = "# @argus check this\nmore body\n"; let full = "# @argus check this\nmore body\n";
let raw_excerpt = full; // fits entirely let raw_excerpt = full; // fits entirely
let lines = extract_truncated_mention_lines(full, raw_excerpt); let lines = extract_truncated_mention_lines(full, raw_excerpt);
assert!(lines.is_empty(), "heading+mention inside window must not be re-surfaced, got {lines:?}"); assert!(
lines.is_empty(),
"heading+mention inside window must not be re-surfaced, got {lines:?}"
);
} }
#[test] #[test]

View file

@ -122,7 +122,9 @@ mod tests {
swarm_name: Option<&str>, swarm_name: Option<&str>,
f: F, f: F,
) { ) {
let _guard = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prev_label = env::var("HIVE_LABEL").ok(); let prev_label = env::var("HIVE_LABEL").ok();
let prev_domain = env::var("HYPERHIVE_HIVE_DOMAIN").ok(); let prev_domain = env::var("HYPERHIVE_HIVE_DOMAIN").ok();
let prev_hive_name = env::var("HYPERHIVE_HIVE_NAME").ok(); let prev_hive_name = env::var("HYPERHIVE_HIVE_NAME").ok();

View file

@ -437,31 +437,30 @@ fn format_bash_status(id: &str) -> String {
let Some(task) = crate::bash_runner::read_task(id) else { let Some(task) = crate::bash_runner::read_task(id) else {
return format!("bash_status: unknown task id `{id}`"); return format!("bash_status: unknown task id `{id}`");
}; };
let mut out = format!( let mut out = format!("task `{id}`: status={status:?}", status = task.status);
"task `{id}`: status={status:?}",
status = task.status
);
if let Some(code) = task.exit_code { if let Some(code) = task.exit_code {
let _ = write!(out, ", exit={code}"); let _ = write!(out, ", exit={code}");
} }
if let Some(t) = task.started_at && task.completed_at.is_none() { if let Some(t) = task.started_at
&& task.completed_at.is_none()
{
let age = crate::serve_common::now_unix() - t; let age = crate::serve_common::now_unix() - t;
let _ = write!(out, ", running for {age}s"); let _ = write!(out, ", running for {age}s");
} }
if let Some(t) = task.completed_at if let Some(t) = task.completed_at {
&& let Some(s) = task.started_at if let Some(s) = task.started_at {
{ let _ = write!(out, ", took {}s", t - s);
let _ = write!(out, ", took {}s", t - s); }
} }
if let Some(ref stdout) = task.stdout_tail if let Some(ref stdout) = task.stdout_tail {
&& !stdout.trim().is_empty() if !stdout.trim().is_empty() {
{ let _ = write!(out, "\n\nstdout:\n```\n{}\n```", stdout.trim());
let _ = write!(out, "\n\nstdout:\n```\n{}\n```", stdout.trim()); }
} }
if let Some(ref stderr) = task.stderr_tail if let Some(ref stderr) = task.stderr_tail {
&& !stderr.trim().is_empty() if !stderr.trim().is_empty() {
{ let _ = write!(out, "\n\nstderr:\n```\n{}\n```", stderr.trim());
let _ = write!(out, "\n\nstderr:\n```\n{}\n```", stderr.trim()); }
} }
out out
} }
@ -668,7 +667,9 @@ impl AgentServer {
)] )]
async fn get_loose_ends(&self, Parameters(args): Parameters<AgentGetLooseEndsArgs>) -> String { async fn get_loose_ends(&self, Parameters(args): Parameters<AgentGetLooseEndsArgs>) -> String {
run_tool_envelope("get_loose_ends", String::new(), async move { run_tool_envelope("get_loose_ends", String::new(), async move {
let (resp, retries) = self.dispatch(hive_sh4re::AgentRequest::GetLooseEnds { agent: args.agent }).await; let (resp, retries) = self
.dispatch(hive_sh4re::AgentRequest::GetLooseEnds { agent: args.agent })
.await;
let mut out = annotate_retries(format_loose_ends(resp), retries); let mut out = annotate_retries(format_loose_ends(resp), retries);
// Append any local bash tasks still in pending/running state so // Append any local bash tasks still in pending/running state so
// the agent sees all outstanding work in one call. // the agent sees all outstanding work in one call.
@ -678,8 +679,11 @@ impl AgentServer {
let _ = write!(out, "\n\n{} active bash task(s):", active.len()); let _ = write!(out, "\n\n{} active bash task(s):", active.len());
for task in &active { for task in &active {
let age = crate::serve_common::now_unix() - task.created_at; let age = crate::serve_common::now_unix() - task.created_at;
let _ = write!(out, "\n- `{}` status={:?}, cmd: `{}`, age {}s", let _ = write!(
task.id, task.status, task.cmd, age); out,
"\n- `{}` status={:?}, cmd: `{}`, age {}s",
task.id, task.status, task.cmd, age
);
} }
} }
out out
@ -830,9 +834,11 @@ impl AgentServer {
)] )]
async fn bash_status(&self, Parameters(args): Parameters<BashStatusArgs>) -> String { async fn bash_status(&self, Parameters(args): Parameters<BashStatusArgs>) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");
run_tool_envelope("bash_status", log, async move { run_tool_envelope(
format_bash_status(&args.id) "bash_status",
}) log,
async move { format_bash_status(&args.id) },
)
.await .await
} }
@ -875,10 +881,7 @@ impl AgentServer {
`since`: show entries on or newer than this (e.g. `-1h`, `2024-01-01 12:00:00`). \ `since`: show entries on or newer than this (e.g. `-1h`, `2024-01-01 12:00:00`). \
`until`: show entries on or older than this." `until`: show entries on or older than this."
)] )]
async fn get_host_journal( async fn get_host_journal(&self, Parameters(args): Parameters<GetHostJournalArgs>) -> String {
&self,
Parameters(args): Parameters<GetHostJournalArgs>,
) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");
run_tool_envelope("get_host_journal", log, async move { run_tool_envelope("get_host_journal", log, async move {
let (resp, retries) = self let (resp, retries) = self
@ -1913,14 +1916,14 @@ pub enum Flavor {
} }
/// Env var written by the meta renderer with a comma-separated list of /// Env var written by the meta renderer with a comma-separated list of
/// `hive_sh4re::ToolGroup` `snake_case` names (e.g. `"messaging,inbox,meta"`). /// `hive_sh4re::ToolGroup` snake_case names (e.g. `"messaging,inbox,meta"`).
/// When present, the harness expands the groups into per-tool allow entries /// When present, the harness expands the groups into per-tool allow entries
/// instead of using the hardcoded flavor default. See `docs/conventions.md::Tool groups`. /// instead of using the hardcoded flavor default. See `docs/conventions.md::Tool groups`.
const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS"; const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS";
/// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the /// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the
/// operator grants capabilities to this agent. Comma-separated /// operator grants capabilities to this agent. Comma-separated
/// `hive_sh4re::Capability` `snake_case` names. Absent = no extra capabilities. /// `hive_sh4re::Capability` snake_case names. Absent = no extra capabilities.
const CAPABILITIES_ENV: &str = "HIVE_CAPABILITIES"; const CAPABILITIES_ENV: &str = "HIVE_CAPABILITIES";
/// Returns the MCP tool names (without `mcp__hyperhive__` prefix) that are /// Returns the MCP tool names (without `mcp__hyperhive__` prefix) that are
@ -1976,12 +1979,13 @@ fn effective_tool_groups(flavor: Flavor) -> Vec<hive_sh4re::ToolGroup> {
for token in raw.split(',') { for token in raw.split(',') {
let t = token.trim().to_ascii_lowercase(); let t = token.trim().to_ascii_lowercase();
// Parse via serde_json (the canonical deserialization path). // Parse via serde_json (the canonical deserialization path).
if let Ok(g) = serde_json::from_value::<hive_sh4re::ToolGroup>( match serde_json::from_value::<hive_sh4re::ToolGroup>(serde_json::Value::String(t.clone()))
serde_json::Value::String(t.clone()), {
) { Ok(g) => groups.push(g),
groups.push(g); Err(_) => tracing::warn!(
} else { token = %t,
tracing::warn!(token = %t, "{TOOL_GROUPS_ENV}: unknown tool group, skipping"); "{TOOL_GROUPS_ENV}: unknown tool group, skipping"
),
} }
} }
if groups.is_empty() { if groups.is_empty() {

View file

@ -391,7 +391,11 @@ fn summarize_durations(all: &mut [i64]) -> DurationSummary {
} }
} }
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)] #[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn percentile(sorted: &[i64], pct: u8) -> f64 { fn percentile(sorted: &[i64], pct: u8) -> f64 {
if sorted.is_empty() { if sorted.is_empty() {
return 0.0; return 0.0;
@ -471,7 +475,15 @@ mod tests {
(started_at, ended_at, duration_ms, model, wake_from, (started_at, ended_at, duration_ms, model, wake_from,
last_input_tokens, tool_call_breakdown_json, result_kind) last_input_tokens, tool_call_breakdown_json, result_kind)
VALUES (?1, ?2, ?3, ?4, ?5, 1000, ?6, ?7)", VALUES (?1, ?2, ?3, ?4, ?5, 1000, ?6, ?7)",
params![started, started + dur / 1000, dur, model, wake, tools_json, result], params![
started,
started + dur / 1000,
dur,
model,
wake,
tools_json,
result
],
) )
.unwrap(); .unwrap();
} }
@ -485,7 +497,14 @@ mod tests {
seed_db( seed_db(
&db, &db,
&[ &[
(now - 600, 5_000, "opus", "recv", "ok", r#"{"Read":2,"Bash":1}"#), (
now - 600,
5_000,
"opus",
"recv",
"ok",
r#"{"Read":2,"Bash":1}"#,
),
(now - 300, 10_000, "opus", "recv", "ok", r#"{"Read":3}"#), (now - 300, 10_000, "opus", "recv", "ok", r#"{"Read":3}"#),
(now - 100, 20_000, "sonnet", "operator", "failed", "{}"), (now - 100, 20_000, "sonnet", "operator", "failed", "{}"),
], ],

View file

@ -561,7 +561,9 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
ctx_usage, ctx_usage,
cost_usage, cost_usage,
links: agent_links(&state.label, state.gui_vnc_port.is_some()), links: agent_links(&state.label, state.gui_vnc_port.is_some()),
forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL").ok().filter(|s| !s.is_empty()), forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL")
.ok()
.filter(|s| !s.is_empty()),
hive_name: crate::identity::hive_name(), hive_name: crate::identity::hive_name(),
swarm_name: crate::identity::swarm_name(), swarm_name: crate::identity::swarm_name(),
}) })
@ -613,8 +615,7 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
// `{state_dir}/hyperhive-dashboard-links.json`). Shape on disk // `{state_dir}/hyperhive-dashboard-links.json`). Shape on disk
// is `{label, icon, url}` with absolute URLs — those become // is `{label, icon, url}` with absolute URLs — those become
// `kind = External` links, passed through verbatim. // `kind = External` links, passed through verbatim.
let extras_path = let extras_path = crate::paths::state_dir().join("hyperhive-dashboard-links.json");
crate::paths::state_dir().join("hyperhive-dashboard-links.json");
if let Ok(text) = std::fs::read_to_string(&extras_path) if let Ok(text) = std::fs::read_to_string(&extras_path)
&& !text.trim().is_empty() && !text.trim().is_empty()
&& let Ok(extras) = serde_json::from_str::<Vec<ExtraLink>>(&text) && let Ok(extras) = serde_json::from_str::<Vec<ExtraLink>>(&text)
@ -662,7 +663,10 @@ async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::InboxRow> {
/// Fetch reminder activity stats from the broker via the per-agent / /// Fetch reminder activity stats from the broker via the per-agent /
/// manager socket. Returns None on any transport / decode failure — the /// manager socket. Returns None on any transport / decode failure — the
/// stats are decorative, not authoritative. /// stats are decorative, not authoritative.
async fn fetch_reminder_stats(socket: &std::path::Path, window_secs: u64) -> Option<hive_sh4re::ReminderStats> { async fn fetch_reminder_stats(
socket: &std::path::Path,
window_secs: u64,
) -> Option<hive_sh4re::ReminderStats> {
match client::request::<_, hive_sh4re::Response>( match client::request::<_, hive_sh4re::Response>(
socket, socket,
&hive_sh4re::Request::ReminderRollup { &hive_sh4re::Request::ReminderRollup {
@ -956,7 +960,9 @@ async fn post_cancel_turn(State(state): State<AppState>) -> Response {
), ),
Err(e) => format!("operator: /cancel — pkill failed: {e}"), Err(e) => format!("operator: /cancel — pkill failed: {e}"),
}; };
state.bus.emit(crate::events::LiveEvent::Note { text: note }); state
.bus
.emit(crate::events::LiveEvent::Note { text: note });
(axum::http::StatusCode::OK, "ok").into_response() (axum::http::StatusCode::OK, "ok").into_response()
} }

View file

@ -9,6 +9,7 @@ workspace = true
anyhow.workspace = true anyhow.workspace = true
axum.workspace = true axum.workspace = true
base64.workspace = true base64.workspace = true
bcrypt.workspace = true
reqwest.workspace = true reqwest.workspace = true
clap.workspace = true clap.workspace = true
hive-sh4re.workspace = true hive-sh4re.workspace = true

View file

@ -76,8 +76,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
// Pre-enqueue cascade rebuilds in topological order so // Pre-enqueue cascade rebuilds in topological order so
// agents depending on updated inputs are rebuilt after the // agents depending on updated inputs are rebuilt after the
// lock bump, matching the dashboard post_meta_update path. // lock bump, matching the dashboard post_meta_update path.
let cascade_agents = let cascade_agents = crate::rebuild_queue::meta_update_cascade_agents(&inputs).await;
crate::rebuild_queue::meta_update_cascade_agents(&inputs).await;
let cascade_reason = format!("approval #{id} meta input cascade"); let cascade_reason = format!("approval #{id} meta input cascade");
for name in cascade_agents { for name in cascade_agents {
coord.rebuild_queue.enqueue( 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, // BTreeMap → serde_json::to_string_pretty preserves key order,
// so the output is deterministic across calls with the same // so the output is deterministic across calls with the same
// agent set. // agent set.
serde_json::to_string_pretty(map) serde_json::to_string_pretty(map).expect("BTreeMap<String, u16> is always serialisable")
.expect("BTreeMap<String, u16> is always serialisable")
} }
/// Atomically write the JSON for `names` to /// Atomically write the JSON for `names` to
@ -61,12 +60,10 @@ pub fn write(names: &[String]) -> Result<()> {
return Ok(()); return Ok(());
} }
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
.with_context(|| format!("create {}", parent.display()))?;
} }
let tmp = path.with_extension("json.tmp"); let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &body) std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| { std::fs::rename(&tmp, &path).with_context(|| {
format!( format!(
"rename {} -> {} (atomic publish)", "rename {} -> {} (atomic publish)",
@ -132,9 +129,6 @@ mod tests {
// BTreeMap sorts → alpha before zeta in output. // BTreeMap sorts → alpha before zeta in output.
let alpha_pos = body.find("alpha").expect("alpha in output"); let alpha_pos = body.find("alpha").expect("alpha in output");
let zeta_pos = body.find("zeta").expect("zeta in output"); let zeta_pos = body.find("zeta").expect("zeta in output");
assert!( assert!(alpha_pos < zeta_pos, "sorted order broken:\n{body}");
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> { ) -> Option<hive_sh4re::Response> {
let broker = &coord.broker; let broker = &coord.broker;
Some(match req { Some(match req {
hive_sh4re::Request::Send { to, body, in_reply_to } => { hive_sh4re::Request::Send {
handle_send(coord, agent, to, body, *in_reply_to) to,
} body,
in_reply_to,
} => handle_send(coord, agent, to, body, *in_reply_to),
hive_sh4re::Request::Recv { wait_seconds, max } => { hive_sh4re::Request::Recv { wait_seconds, max } => {
let cap = max.unwrap_or(1).min(RECV_BATCH_MAX) as usize; let cap = max.unwrap_or(1).min(RECV_BATCH_MAX) as usize;
match broker match broker
@ -223,8 +225,8 @@ pub(crate) async fn dispatch_shared(
if let Err(message) = crate::limits::check_status_text(text) { if let Err(message) = crate::limits::check_status_text(text) {
return Some(hive_sh4re::Response::Err { message }); return Some(hive_sh4re::Response::Err { message });
} }
let path = crate::coordinator::Coordinator::agent_notes_dir(agent) let path =
.join("hyperhive-status"); crate::coordinator::Coordinator::agent_notes_dir(agent).join("hyperhive-status");
let result = if text.trim().is_empty() { let result = if text.trim().is_empty() {
std::fs::remove_file(&path).or_else(|e| { std::fs::remove_file(&path).or_else(|e| {
if e.kind() == std::io::ErrorKind::NotFound { 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 }); tokio::spawn(async move { coord2.rescan_containers_and_emit().await });
hive_sh4re::Response::Ok hive_sh4re::Response::Ok
} }
Err(e) => { Err(e) => hive_sh4re::Response::Err {
hive_sh4re::Response::Err { message: format!("set_status write failed: {e}"),
message: format!("set_status write failed: {e}"), },
}
}
} }
} }
hive_sh4re::Request::GetAgentMeta { name } => { hive_sh4re::Request::GetAgentMeta { name } => {
@ -294,7 +294,15 @@ pub(crate) async fn dispatch_shared(
message: format!("{e:#}"), 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 dispatch_host_journal(agent, unit, container, lines, priority, grep, since, until).await
} }
// Not a shared variant. // Not a shared variant.
@ -331,7 +339,10 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
Err(message) => AgentResponse::Err { message }, 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()); let name = resolve_agent_state_target(agent, target.as_deref());
match name { match name {
Ok(name) => match coord.broker.reminder_rollup_for(name, *since_secs) { Ok(name) => match coord.broker.reminder_rollup_for(name, *since_secs) {
@ -547,10 +558,7 @@ pub(crate) fn store_remind(
) -> Result<(), String> { ) -> Result<(), String> {
let max = remind_max_pending(); let max = remind_max_pending();
if max > 0 { if max > 0 {
let pending = coord let pending = coord.broker.count_pending_reminders_for(agent).unwrap_or(0);
.broker
.count_pending_reminders_for(agent)
.unwrap_or(0);
if pending >= max { if pending >= max {
return Err(format!( return Err(format!(
"reminder rejected: agent `{agent}` already has {pending} pending \ "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) let host_path = crate::reminder_scheduler::resolve_host_path(agent, &req_path)
.map_err(|reason| format!("auto-save path `{req_path}` rejected: {reason}"))?; .map_err(|reason| format!("auto-save path `{req_path}` rejected: {reason}"))?;
crate::reminder_scheduler::write_payload(agent, &host_path, message) crate::reminder_scheduler::write_payload(agent, &host_path, message).map_err(|reason| {
.map_err(|reason| format!("auto-save of large reminder body to `{req_path}` failed: {reason}"))?; format!("auto-save of large reminder body to `{req_path}` failed: {reason}")
})?;
let hint = format!( let hint = format!(
"[reminder body of {} bytes auto-saved to `{req_path}`; read with your filesystem tools]", "[reminder body of {} bytes auto-saved to `{req_path}`; read with your filesystem tools]",
message.len() message.len()
@ -634,19 +643,26 @@ fn auto_reminder_path(agent: &str) -> String {
/// - `Some("<other>")` where other is not a child → requires the /// - `Some("<other>")` where other is not a child → requires the
/// `query_agent_state` capability; returns an error otherwise. /// `query_agent_state` capability; returns an error otherwise.
/// - `Some("*")` → always rejected (hive-wide scans are manager-only). /// - `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 { match target {
None => Ok(caller), None => Ok(caller),
Some("*") => Err( Some("*") => Err(
"hive-wide query (agent=\"*\") is not available on the agent socket; \ "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) => { Some(name) => {
if name == caller { if name == caller {
return Ok(caller); return Ok(caller);
} }
// Direct children are visible to their parent without extra capability. // 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); return Ok(name);
} }
if crate::capabilities::has_cap(caller, hive_sh4re::Capability::QueryAgentState) { 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] #[must_use]
pub fn build_map(names: &[String]) -> BTreeMap<String, PathBuf> { pub fn build_map(names: &[String]) -> BTreeMap<String, PathBuf> {
build_map_with(names, |name| { build_map_with(names, |name| {
ready_marker_for(name).exists() ready_marker_for(name).exists() || agent_dir_for(name).join(READY_MARKER_LEGACY).exists()
|| agent_dir_for(name).join(READY_MARKER_LEGACY).exists()
}) })
} }
@ -146,12 +145,10 @@ pub fn write(names: &[String]) -> Result<()> {
return Ok(()); return Ok(());
} }
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
.with_context(|| format!("create {}", parent.display()))?;
} }
let tmp = path.with_extension("json.tmp"); let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &body) std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| { std::fs::rename(&tmp, &path).with_context(|| {
format!( format!(
"rename {} -> {} (atomic publish)", "rename {} -> {} (atomic publish)",
@ -305,24 +302,30 @@ mod tests {
let marker = ready_marker_for("iris"); let marker = ready_marker_for("iris");
let socket = socket_path_for("iris"); let socket = socket_path_for("iris");
assert_eq!(marker.parent(), socket.parent()); 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] #[test]
fn render_is_pretty_and_sorted() { fn render_is_pretty_and_sorted() {
let mut map = BTreeMap::new(); let mut map = BTreeMap::new();
map.insert("zeta".to_owned(), PathBuf::from("/run/hive-agent/zeta/web.sock")); map.insert(
map.insert("alpha".to_owned(), PathBuf::from("/run/hive-agent/alpha/web.sock")); "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); let body = render(&map);
// Pretty-print = newlines between keys + indentation. // Pretty-print = newlines between keys + indentation.
assert!(body.contains('\n')); assert!(body.contains('\n'));
// BTreeMap sorts → alpha before zeta in output. // BTreeMap sorts → alpha before zeta in output.
let alpha_pos = body.find("alpha").expect("alpha in output"); let alpha_pos = body.find("alpha").expect("alpha in output");
let zeta_pos = body.find("zeta").expect("zeta in output"); let zeta_pos = body.find("zeta").expect("zeta in output");
assert!( assert!(alpha_pos < zeta_pos, "sorted order broken:\n{body}");
alpha_pos < zeta_pos,
"sorted order broken:\n{body}"
);
} }
#[test] #[test]
@ -332,10 +335,12 @@ mod tests {
// gateway-side reader can deserialise into String values // gateway-side reader can deserialise into String values
// without nested struct logic. // without nested struct logic.
let mut map = BTreeMap::new(); 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); let body = render(&map);
assert!(body.contains("\"iris\"")); assert!(body.contains("\"iris\""));
assert!(body.contains("\"/run/hive-agent/iris/web.sock\"")); 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. /// 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, /// Uses BFS from root agents (depth 0). Agents absent from `topo` sort last,
/// alphabetically within their tier. Stable within each depth tier. /// 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}; use std::collections::{HashMap, VecDeque};
// Build depth map using owned clones so the borrow on `names` is released // Build depth map using owned clones so the borrow on `names` is released
// before the sort_by mutable borrow. // 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 depth: HashMap<String, usize> = HashMap::new();
let mut queue: VecDeque<String> = VecDeque::new(); let mut queue: VecDeque<String> = VecDeque::new();
// Seed roots: entries with no parent, or names not present in topo at all. // Seed roots: entries with no parent, or names not present in topo at all.
for name in &name_set { 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); depth.insert(name.clone(), 0);
queue.push_back(name.clone()); queue.push_back(name.clone());
} }
@ -300,4 +303,3 @@ pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
coord.emit_rebuild_queue_snapshot(); coord.emit_rebuild_queue_snapshot();
Ok(()) Ok(())
} }

View file

@ -23,7 +23,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::coordinator::Coordinator; 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. /// Keep completed task files for 48 hours before sweeping them.
const KEEP_SECS: i64 = 48 * 3600; const KEEP_SECS: i64 = 48 * 3600;
@ -65,7 +65,9 @@ fn sweep_once() {
/// files removed (each represents one task; `.out`/`.err` deletions /// files removed (each represents one task; `.out`/`.err` deletions
/// are not counted separately). /// are not counted separately).
fn vacuum_dir(dir: &Path, cutoff: i64) -> u64 { 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; let mut removed: u64 = 0;
for entry in rd.flatten() { for entry in rd.flatten() {
let path = entry.path(); let path = entry.path();
@ -97,7 +99,10 @@ fn should_delete(json_path: &Path, cutoff: i64) -> bool {
if !TERMINAL_STATUSES.contains(&status) { if !TERMINAL_STATUSES.contains(&status) {
return false; 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 completed_at < cutoff
} }
@ -106,10 +111,10 @@ fn should_delete(json_path: &Path, cutoff: i64) -> bool {
fn delete_trio(dir: &Path, stem: &str) { fn delete_trio(dir: &Path, stem: &str) {
for ext in ["json", "out", "err"] { for ext in ["json", "out", "err"] {
let path = dir.join(format!("{stem}.{ext}")); let path = dir.join(format!("{stem}.{ext}"));
if path.exists() if path.exists() {
&& let Err(e) = std::fs::remove_file(&path) if let Err(e) = std::fs::remove_file(&path) {
{ tracing::warn!(path = %path.display(), error = ?e, "bash-tasks vacuum: remove failed");
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 //! dirs) and reuse the `forge` / `matrix` modules from the
//! `hive-c0re` lib — single source of truth, no duplication. //! `hive-c0re` lib — single source of truth, no duplication.
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use hive_c0re::coordinator::Coordinator; use hive_c0re::coordinator::Coordinator;
@ -54,6 +56,14 @@ enum Cmd {
#[command(subcommand)] #[command(subcommand)]
cmd: MatrixCmd, 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)] #[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] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
@ -161,6 +213,16 @@ async fn main() -> Result<()> {
password_stdin, password_stdin,
} => matrix_create_user(&name, password.as_deref(), password_stdin).await, } => 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() Coordinator::agent_state_root(name).exists()
} }
async fn forge_create_user( async fn forge_create_user(name: &str, password: Option<&str>, password_stdin: bool) -> Result<()> {
name: &str,
password: Option<&str>,
password_stdin: bool,
) -> Result<()> {
if !hive_c0re::forge::is_present().await { if !hive_c0re::forge::is_present().await {
bail!( bail!(
"hive-forge container not running — start it (services.hyperhive.forge.enable = true) before provisioning forge users" "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 { } else {
let effective_password = match user_password { let effective_password = match user_password {
Some(p) => p, Some(p) => p,
None => hive_c0re::matrix::random_password() None => {
.context("generate random matrix password")?, hive_c0re::matrix::random_password().context("generate random matrix password")?
}
}; };
let token = let token = hive_c0re::matrix::provision_user_token(
hive_c0re::matrix::provision_user_token(&client, name, &register_token, &effective_password) &client,
.await name,
.with_context(|| format!("matrix create-user {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!("matrix: provisioned user '{name}' (not an agent — token not persisted)");
println!("token: {token}"); println!("token: {token}");
if password.is_some() || password_stdin { if password.is_some() || password_stdin {
@ -293,3 +356,115 @@ async fn matrix_create_user(
} }
Ok(()) 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 /// broker. Used by the scheduler to skip re-delivery of the same
/// scheduled prompt without blocking distinct schedules whose /// scheduled prompt without blocking distinct schedules whose
/// bodies differ. /// bodies differ.
pub fn has_pending_with_body( pub fn has_pending_with_body(&self, recipient: &str, sender: &str, body: &str) -> Result<bool> {
&self,
recipient: &str,
sender: &str,
body: &str,
) -> Result<bool> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let n: i64 = conn.query_row( let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM messages "SELECT COUNT(*) FROM messages
@ -391,7 +386,13 @@ impl Broker {
)?; )?;
let rows: Vec<(i64, String, String, String, Option<i64>)> = stmt let rows: Vec<(i64, String, String, String, Option<i64>)> = stmt
.query_map(params![recipient, max_i], |row| { .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<_>>()?; .collect::<rusqlite::Result<_>>()?;
drop(stmt); drop(stmt);
@ -673,7 +674,11 @@ impl Broker {
/// Reminder rollup stats for an agent over a time window. Returns /// Reminder rollup stats for an agent over a time window. Returns
/// counts of scheduled, delivered, and pending reminders created /// counts of scheduled, delivered, and pending reminders created
/// in the last `since_secs` seconds (0 = all reminders). /// 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 conn = self.conn.lock().unwrap();
let cutoff_time = if since_secs > 0 { let cutoff_time = if since_secs > 0 {
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
@ -740,9 +745,7 @@ impl Broker {
|| canceller == hive_sh4re::OPERATOR_RECIPIENT || canceller == hive_sh4re::OPERATOR_RECIPIENT
|| canceller == hive_sh4re::MANAGER_AGENT; || canceller == hive_sh4re::MANAGER_AGENT;
if !authorised { if !authorised {
anyhow::bail!( anyhow::bail!("reminder {id}: '{canceller}' not allowed to cancel (owner = '{owner}')");
"reminder {id}: '{canceller}' not allowed to cancel (owner = '{owner}')"
);
} }
let n = conn.execute( let n = conn.execute(
"DELETE FROM reminders WHERE id = ?1 AND sent_at IS NULL", "DELETE FROM reminders WHERE id = ?1 AND sent_at IS NULL",
@ -862,7 +865,9 @@ impl Broker {
} }
drop(conn); drop(conn);
// Emit per-row Sent events (only for rows that succeeded). // 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() { if result.is_ok() {
let _ = self.events.send(MessageEvent::Sent { let _ = self.events.send(MessageEvent::Sent {
id: *msg_id, id: *msg_id,
@ -1029,10 +1034,7 @@ mod tests {
assert_eq!(broker.requeue_inflight("b").unwrap(), 1); assert_eq!(broker.requeue_inflight("b").unwrap(), 1);
let d2 = pop_one(broker, "b").expect("popped again"); let d2 = pop_one(broker, "b").expect("popped again");
assert_eq!(d2.message.body, "hi"); assert_eq!(d2.message.body, "hi");
assert!( assert!(d2.redelivered, "second pop should be tagged redelivered");
d2.redelivered,
"second pop should be tagged redelivered"
);
assert_eq!(broker.ack_turn("b").unwrap(), 1); assert_eq!(broker.ack_turn("b").unwrap(), 1);
} }
@ -1275,4 +1277,3 @@ mod tests {
assert!(pop_one(broker, "bob").is_none()); assert!(pop_one(broker, "bob").is_none());
} }
} }

View file

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

View file

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

View file

@ -708,11 +708,16 @@ impl Coordinator {
/// whose stop the crash watcher should NOT classify as a crash. /// whose stop the crash watcher should NOT classify as a crash.
/// Lazily reaps entries older than `grace` so the map stays /// Lazily reaps entries older than `grace` so the map stays
/// bounded by the active agent count. /// 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 now = std::time::Instant::now();
let mut map = self.recent_transient.lock().unwrap(); let mut map = self.recent_transient.lock().unwrap();
map.retain(|_, (_, ts)| now.duration_since(*ts) <= grace); 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. /// 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(); let s = v.to_string_lossy().to_ascii_lowercase();
matches!(s.as_str(), "1" | "true" | "yes") matches!(s.as_str(), "1" | "true" | "yes")
}), }),
forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL").ok().filter(|s| !s.is_empty()), forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL")
hive_name: std::env::var("HYPERHIVE_HIVE_NAME").ok().filter(|s| !s.is_empty()), .ok()
swarm_name: std::env::var("HYPERHIVE_SWARM_NAME").ok().filter(|s| !s.is_empty()), .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(), peer_hives: parse_peer_hives(),
}) })
} }
@ -1751,9 +1757,7 @@ async fn get_build_log_full(
) -> Response { ) -> Response {
match state.coord.build_logs.get_full(id) { match state.coord.build_logs.get_full(id) {
Ok(Some(log)) => axum::Json(log).into_response(), Ok(Some(log)) => axum::Json(log).into_response(),
Ok(None) => { Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(),
(StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response()
}
Err(e) => error_response(&format!("build-log {id}: {e:#}")), 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 /// separator (same layout the JS side-panel renders). The
/// `Content-Disposition` header triggers a browser download with a /// `Content-Disposition` header triggers a browser download with a
/// descriptive filename so the operator can save and share the log. /// descriptive filename so the operator can save and share the log.
async fn get_build_log_raw( async fn get_build_log_raw(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -> Response {
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
) -> Response {
match state.coord.build_logs.get_full(id) { match state.coord.build_logs.get_full(id) {
Ok(Some(log)) => { Ok(Some(log)) => {
let mut text = log.stdout; let mut text = log.stdout;
@ -1897,9 +1898,7 @@ async fn get_build_log_raw(
) )
.into_response() .into_response()
} }
Ok(None) => { Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(),
(StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response()
}
Err(e) => error_response(&format!("build-log {id}: {e:#}")), 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()) .map(|g| g.as_str())
.collect(); .collect();
let assignments = crate::tool_groups::read(); let assignments = crate::tool_groups::read();
axum::Json(ToolGroupsSnapshot { groups, assignments }) axum::Json(ToolGroupsSnapshot {
groups,
assignments,
})
} }
#[derive(Deserialize)] #[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, // `inputs = { }`. Any other git failure (permission denied,
// ref-not-found, etc.) propagates as a hard error rather than // ref-not-found, etc.) propagates as a hard error rather than
// being silently swallowed. // being silently swallowed.
if stderr.contains("does not exist") if stderr.contains("does not exist") || stderr.contains("exists on disk, but not in") {
|| stderr.contains("exists on disk, but not in")
{
return Ok(None); return Ok(None);
} }
anyhow::bail!("git show {spec} failed: {}", stderr.trim()); 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()); entry.keys.push(name.clone());
} }
let mut dups: Vec<DuplicateGroup> = groups let mut dups: Vec<DuplicateGroup> = groups.into_values().filter(|g| g.keys.len() > 1).collect();
.into_values()
.filter(|g| g.keys.len() > 1)
.collect();
for g in &mut dups { for g in &mut dups {
g.keys.sort(); 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<()> { async fn remove_worktree(repo: &Path, worktree: &Path) -> Result<()> {
let out = git_command() let out = git_command()
.current_dir(repo) .current_dir(repo)
.args([ .args(["worktree", "remove", "--force", &worktree.to_string_lossy()])
"worktree",
"remove",
"--force",
&worktree.to_string_lossy(),
])
.output() .output()
.await .await
.with_context(|| format!("git worktree remove {}", worktree.display()))?; .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 /// from the operator's point of view: same password input → same final
/// account state. /// account state.
async fn change_user_password(name: &str, password: &str) -> Result<()> { 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) forge_admin(&args)
.await .await
.with_context(|| format!("forgejo admin user change-password {name}"))?; .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 /// Mirrors `meta_read_access` so agents can clone the shared docs repo
/// without authentication hassle. /// without authentication hassle.
pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> { pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> {
let url = format!( let url =
"{FORGE_HTTP}/api/v1/repos/{SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name}" format!("{FORGE_HTTP}/api/v1/repos/{SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name}");
);
let body = r#"{"permission":"read"}"#; let body = r#"{"permission":"read"}"#;
let out = Command::new("curl") let out = Command::new("curl")
.args([ .args([

View file

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

View file

@ -449,11 +449,12 @@ pub async fn rebuild_no_meta(
"kill before cold-start retry failed (ignored)" "kill before cold-start retry failed (ignored)"
); );
}); });
run(&["start", &container]).await run(&["start", &container]).await.map_err(|e| {
.map_err(|e| anyhow::anyhow!( anyhow::anyhow!(
"cold-start fallback also failed: {e:#} \ "cold-start fallback also failed: {e:#} \
(original start error: {start_err:#})" (original start error: {start_err:#})"
)) )
})
} else { } else {
Ok(()) 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 // pair (no current callsite does, but the pair is redundant
// and worth checking once). // and worth checking once).
if fragment != name { if fragment != name {
anyhow::bail!( anyhow::bail!("prebuild_toplevel: flake_ref fragment '{fragment}' ≠ agent name '{name}'");
"prebuild_toplevel: flake_ref fragment '{fragment}' ≠ agent name '{name}'"
);
} }
let attr = format!("{flake_root}#nixosConfigurations.{name}.config.system.build.toplevel"); let attr = format!("{flake_root}#nixosConfigurations.{name}.config.system.build.toplevel");
let args = vec![ let args = vec![
@ -1135,8 +1134,7 @@ fn set_nspawn_flags(
); );
} }
let own_config = format!("{HOST_AGENTS_ROOT}/{agent_name}/config"); let own_config = format!("{HOST_AGENTS_ROOT}/{agent_name}/config");
std::fs::create_dir_all(&own_config) std::fs::create_dir_all(&own_config).with_context(|| format!("create {own_config}"))?;
.with_context(|| format!("create {own_config}"))?;
let _ = write!(binds, " --bind-ro={own_config}:/agents/{agent_name}/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. // every notification with the eval-error verbatim.
let journal = container_journal_tail(args).await; let journal = container_journal_tail(args).await;
match log_id { match log_id {
Some(id) => bail!( Some(id) => {
"nixos-container {cmdline} failed ({status}); see build log #{id}{journal}" bail!("nixos-container {cmdline} failed ({status}); see build log #{id}{journal}")
), }
None => bail!("nixos-container {cmdline} failed ({status}){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 std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result; use anyhow::Result;
use hive_sh4re::{MANAGER_AGENT, LooseEnd}; use hive_sh4re::{LooseEnd, MANAGER_AGENT};
use crate::coordinator::Coordinator; 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). // explicit (any new daemon entry point reads off the next add).
use hive_c0re::coordinator::Coordinator; use hive_c0re::coordinator::Coordinator;
use hive_c0re::{ use hive_c0re::{
agent_sockets, auto_update, broker, client, crash_watch, dashboard, dashboard_events, agent_sockets, auto_update, bash_tasks_vacuum, broker, client, crash_watch, dashboard,
bash_tasks_vacuum, events_vacuum, forge, manager_server, matrix, migrate, rebuild_queue, dashboard_events, events_vacuum, forge, manager_server, matrix, migrate, rebuild_queue,
reminder_scheduler, scheduled_prompts_worker, server, stats_vacuum, reminder_scheduler, scheduled_prompts_worker, server, stats_vacuum,
}; };
@ -51,7 +51,10 @@ enum Cmd {
/// short name to token count. Threaded into each container as /// short name to token count. Threaded into each container as
/// `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars. Set via the /// `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars. Set via the
/// `services.hive-c0re.contextWindowTokens` NixOS option. /// `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, context_window_tokens: String,
}, },
/// Spawn a new agent container directly (`hive-agent-<name>`). Bypasses /// Spawn a new agent container directly (`hive-agent-<name>`). Bypasses
@ -119,7 +122,17 @@ async fn main() -> Result<()> {
dashboard_port, dashboard_port,
operator_pronouns, operator_pronouns,
context_window_tokens, 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 } => { Cmd::Spawn { name } => {
render(client::request(&cli.socket, HostRequest::Spawn { name }).await?) 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 }; let new_parent = if root { None } else { parent };
render( render(
client::request( client::request(&cli.socket, HostRequest::SetParent { child, new_parent }).await?,
&cli.socket,
HostRequest::SetParent { child, new_parent },
)
.await?,
) )
} }
} }
@ -169,9 +178,8 @@ async fn cmd_serve(
context_window_tokens: String, context_window_tokens: String,
socket: &std::path::Path, socket: &std::path::Path,
) -> Result<()> { ) -> Result<()> {
let cwt: std::collections::HashMap<String, u64> = let cwt: std::collections::HashMap<String, u64> = serde_json::from_str(&context_window_tokens)
serde_json::from_str(&context_window_tokens) .context("--context-window-tokens: invalid JSON")?;
.context("--context-window-tokens: invalid JSON")?;
let coord = Arc::new(Coordinator::open( let coord = Arc::new(Coordinator::open(
&db, &db,
hyperhive_flake, hyperhive_flake,
@ -335,7 +343,14 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
match rx.recv().await { 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); let file_refs = dashboard::scan_validated_paths(&body);
coord.emit_dashboard_event(DashboardEvent::Sent { coord.emit_dashboard_event(DashboardEvent::Sent {
seq: coord.next_seq(), seq: coord.next_seq(),
@ -348,7 +363,14 @@ fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
file_refs, 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); let file_refs = dashboard::scan_validated_paths(&body);
coord.emit_dashboard_event(DashboardEvent::Delivered { coord.emit_dashboard_event(DashboardEvent::Delivered {
seq: coord.next_seq(), seq: coord.next_seq(),

View file

@ -572,7 +572,10 @@ where
// Emit `capabilities = "cap1,cap2"` when the operator has // Emit `capabilities = "cap1,cap2"` when the operator has
// granted capabilities to this agent. Absent entry = null = no // granted capabilities to this agent. Absent entry = null = no
// capability env var injected, capability-gated tools hidden. // 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() { let capabilities_attr = if caps.is_empty() {
"null".to_owned() "null".to_owned()
} else { } else {

View file

@ -147,7 +147,9 @@ fn migrate_harness_files(name: &str) {
} }
match std::fs::rename(&src, &dst) { match std::fs::rename(&src, &dst) {
Ok(()) => tracing::info!(%name, %file, "migration: moved to harness dir"), 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 // Stop the old container. Abort if stop fails — continuing with a
// running `root` and then starting `h-root` risks two manager // running `root` and then starting `h-root` risks two manager
// instances racing for the same broker / state files. // 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) if s.success() => {}
Ok(s) => { Ok(s) => {
tracing::warn!(status = %s, "migration phase 5: nixos-container stop root failed — aborting"); 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. // 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"); tracing::warn!(error = ?e, "migration phase 5: systemctl daemon-reload failed");
} }
// Start the renamed container. // 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"); tracing::warn!(error = ?e, "migration phase 5: nixos-container start h-root failed");
return; return;
} }

View file

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

View file

@ -7,7 +7,7 @@
//! a persistent connection. //! a persistent connection.
use anyhow::{Context as _, Result, bail}; 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::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream; use tokio::net::UnixStream;
@ -31,27 +31,49 @@ pub async fn call(req: &PrivRequest) -> Result<PrivResponse> {
} }
pub async fn start_container(name: &str) -> Result<()> { 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<()> { 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<()> { 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)> { 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)> { 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<()> { 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> { 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 { ok(call(&PrivRequest::WriteNspawnFlags {
container: container.to_owned(), container: container.to_owned(),
binds: binds.to_vec(), binds: binds.to_vec(),
}).await?) })
.await?)
} }
pub async fn write_resource_limits( pub async fn write_resource_limits(
@ -75,13 +98,15 @@ pub async fn write_resource_limits(
container: container.to_owned(), container: container.to_owned(),
memory_max: memory_max.to_owned(), memory_max: memory_max.to_owned(),
cpu_quota: cpu_quota.to_owned(), cpu_quota: cpu_quota.to_owned(),
}).await?) })
.await?)
} }
pub async fn remove_service_dropin(container: &str) -> Result<()> { pub async fn remove_service_dropin(container: &str) -> Result<()> {
ok(call(&PrivRequest::RemoveServiceDropin { ok(call(&PrivRequest::RemoveServiceDropin {
container: container.to_owned(), container: container.to_owned(),
}).await?) })
.await?)
} }
pub async fn daemon_reload() -> Result<()> { 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(), agent_name: agent_name.to_owned(),
uid, uid,
gid, gid,
}).await?) })
.await?)
} }
pub async fn chmod_socket_dir(agent_name: &str, mode: u32) -> Result<()> { pub async fn chmod_socket_dir(agent_name: &str, mode: u32) -> Result<()> {
ok(call(&PrivRequest::ChmodSocketDir { ok(call(&PrivRequest::ChmodSocketDir {
agent_name: agent_name.to_owned(), agent_name: agent_name.to_owned(),
mode, mode,
}).await?) })
.await?)
} }
fn check(resp: PrivResponse) -> Result<(String, String)> { fn check(resp: PrivResponse) -> Result<(String, String)> {
if resp.ok { if resp.ok {
Ok((resp.stdout, resp.stderr)) Ok((resp.stdout, resp.stderr))
} else { } 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). // exact MANAGER_AGENT constant passes).
assert!(check_approval_canceller_is_manager("").is_err()); assert!(check_approval_canceller_is_manager("").is_err());
assert!( assert!(
check_approval_canceller_is_manager(hive_sh4re::OPERATOR_RECIPIENT) check_approval_canceller_is_manager(hive_sh4re::OPERATOR_RECIPIENT).is_err(),
.is_err(),
"operator surface uses the dashboard cancel path, not this dispatcher", "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 { let Some(parent) = host_path.parent() else {
return Err("internal: host path has no parent".to_owned()); return Err("internal: host path has no parent".to_owned());
}; };
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent).map_err(|e| format!("parent dir create failed: {e}"))?;
.map_err(|e| format!("parent dir create failed: {e}"))?;
// Resolve symlinks in the parent chain, then re-verify the // Resolve symlinks in the parent chain, then re-verify the
// canonical form still lives under the agent's host state root — // canonical form still lives under the agent's host state root —
// catches `ln -s /etc state/escape` style attacks. // 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 // scheduled prompt from stacking up when an agent is slow or
// briefly offline, while still allowing distinct scheduled // briefly offline, while still allowing distinct scheduled
// messages (different body) to enqueue independently. // 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) => { Ok(true) => {
tracing::debug!( tracing::debug!(
schedule = schedule.id, schedule = schedule.id,
@ -384,4 +387,3 @@ async fn known_agents_async() -> std::collections::HashSet<String> {
} }
out out
} }

View file

@ -39,8 +39,7 @@ pub fn spawn(coord: &Arc<Coordinator>) {
fn sweep_once() { fn sweep_once() {
for name in Coordinator::kept_state_names() { for name in Coordinator::kept_state_names() {
let path = let path = Coordinator::agent_harness_dir(&name).join("hyperhive-turn-stats.sqlite");
Coordinator::agent_harness_dir(&name).join("hyperhive-turn-stats.sqlite");
if !path.exists() { if !path.exists() {
continue; continue;
} }
@ -60,7 +59,9 @@ fn vacuum_file(path: &Path) -> Result<u64> {
.and_then(|d| i64::try_from(d.as_secs()).ok()) .and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0); .unwrap_or(0);
let cutoff = now - KEEP_SECS; let cutoff = now - KEEP_SECS;
let removed = let removed = conn.execute(
conn.execute("DELETE FROM turn_stats WHERE started_at < ?1", params![cutoff])?; "DELETE FROM turn_stats WHERE started_at < ?1",
params![cutoff],
)?;
Ok(u64::try_from(removed).unwrap_or(0)) 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. /// Pure form of [`children_of`] for unit tests.
#[must_use] #[must_use]
pub fn children_of_in( pub fn children_of_in(topo: &BTreeMap<String, Option<String>>, name: &str) -> Vec<String> {
topo: &BTreeMap<String, Option<String>>,
name: &str,
) -> Vec<String> {
topo.iter() topo.iter()
.filter_map(|(agent, parent)| { .filter_map(|(agent, parent)| {
if parent.as_deref() == Some(name) { if parent.as_deref() == Some(name) {
@ -90,7 +87,13 @@ pub fn top_level_agents() -> Vec<String> {
#[must_use] #[must_use]
pub fn top_level_agents_in(topo: &BTreeMap<String, Option<String>>) -> Vec<String> { pub fn top_level_agents_in(topo: &BTreeMap<String, Option<String>>) -> Vec<String> {
topo.iter() 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() .collect()
} }
@ -501,8 +504,12 @@ mod tests {
// `alice` who lives under the manager) would close the loop. // `alice` who lives under the manager) would close the loop.
// The general cycle walk catches this; no separate manager // The general cycle walk catches this; no separate manager
// guard needed. // guard needed.
let err = apply_set_parent(&topo_three_level(), crate::lifecycle::MANAGER_NAME, Some("bob")) let err = apply_set_parent(
.unwrap_err(); &topo_three_level(),
crate::lifecycle::MANAGER_NAME,
Some("bob"),
)
.unwrap_err();
assert!(err.contains("cycle"), "err = {err}"); assert!(err.contains("cycle"), "err = {err}");
} }
@ -678,20 +685,32 @@ mod tests {
"alice".to_owned(), "alice".to_owned(),
vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.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] #[test]
fn has_role_in_returns_false_for_absent_agent() { fn has_role_in_returns_false_for_absent_agent() {
let roles: BTreeMap<String, Vec<String>> = BTreeMap::new(); 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] #[test]
fn has_role_in_returns_false_for_empty_list() { fn has_role_in_returns_false_for_empty_list() {
let mut roles = BTreeMap::new(); let mut roles = BTreeMap::new();
roles.insert("alice".to_owned(), vec![]); 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 /// 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; let mgr = crate::lifecycle::MANAGER_NAME;
// Build an in-memory roles map as set_role would see it after granting. // Build an in-memory roles map as set_role would see it after granting.
let mut roles: BTreeMap<String, Vec<String>> = BTreeMap::new(); 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). // Simulate the revoke path of set_role (in-memory, no disk).
let list = roles.entry(mgr.to_owned()).or_default(); 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 mgr_present = agent_names.iter().any(|n| n == mgr);
let should_seed = mgr_present && !roles.contains_key(mgr); let should_seed = mgr_present && !roles.contains_key(mgr);
// should_seed must be false because manager key is present (tombstone). // 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). /// `reconcile_roles` seeds the manager on first appearance (no prior entry).

View file

@ -10,7 +10,12 @@ path = "src/main.rs"
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = { workspace = true }
clap = { workspace = true } clap = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls", "blocking", "multipart"] } reqwest = { workspace = true, features = [
"json",
"rustls-tls",
"blocking",
"multipart",
] }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }

View file

@ -23,8 +23,8 @@ pub fn resolve(body: Option<&str>, file: Option<&str>) -> Result<Option<String>>
if path == "-" { if path == "-" {
return Ok(Some(read_stdin().context("read stdin for --body-file -")?)); return Ok(Some(read_stdin().context("read stdin for --body-file -")?));
} }
let s = std::fs::read_to_string(path) let s =
.with_context(|| format!("read --body-file {path}"))?; std::fs::read_to_string(path).with_context(|| format!("read --body-file {path}"))?;
return Ok(Some(s)); return Ok(Some(s));
} }
if !std::io::stdin().is_terminal() { if !std::io::stdin().is_terminal() {

View file

@ -180,7 +180,12 @@ impl Client {
let form = reqwest::blocking::multipart::Form::new() let form = reqwest::blocking::multipart::Form::new()
.file("attachment", file) .file("attachment", file)
.with_context(|| format!("read {}", file.display()))?; .with_context(|| format!("read {}", file.display()))?;
let resp = self.http.post(&url).multipart(form).send().context("POST")?; let resp = self
.http
.post(&url)
.multipart(form)
.send()
.context("POST")?;
decode_json(resp, &format!("POST {url}")) decode_json(resp, &format!("POST {url}"))
} }
} }

View file

@ -103,8 +103,7 @@ enum Verb {
fn main() -> Result<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let client = let client = client::Client::from_env(cli.repo, cli.json).context("initialize forge client")?;
client::Client::from_env(cli.repo, cli.json).context("initialize forge client")?;
match cli.verb { match cli.verb {
Verb::View(a) => verbs::view::run(&client, a), Verb::View(a) => verbs::view::run(&client, a),
Verb::Issue(a) => verbs::issue::run(&client, a), Verb::Issue(a) => verbs::issue::run(&client, a),

View file

@ -105,10 +105,7 @@ fn fetch_tail(client: &Client, repo: &str, number: u64, n: usize) -> Result<Vec<
return Ok(Vec::new()); return Ok(Vec::new());
} }
let issue = client.get_json(&format!("/repos/{repo}/issues/{number}"))?; let issue = client.get_json(&format!("/repos/{repo}/issues/{number}"))?;
let total = issue let total = issue.get("comments").and_then(Value::as_u64).unwrap_or(0) as usize;
.get("comments")
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
if total == 0 { if total == 0 {
return Ok(Vec::new()); return Ok(Vec::new());
} }

View file

@ -32,7 +32,10 @@ pub struct Args {
pub fn run(client: &Client, args: Args) -> Result<()> { pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(); let repo = client.repo();
let diff = client.get_text(&format!("/repos/{repo}/pulls/{}.diff", args.number), "text/plain")?; let diff = client.get_text(
&format!("/repos/{repo}/pulls/{}.diff", args.number),
"text/plain",
)?;
let out = if args.full { let out = if args.full {
diff diff
} else { } else {
@ -218,7 +221,9 @@ mod tests {
assert!(is_autogenerated("flake.lock")); assert!(is_autogenerated("flake.lock"));
assert!(is_autogenerated("a/flake.lock")); assert!(is_autogenerated("a/flake.lock"));
assert!(is_autogenerated("hive-c0re/Cargo.lock")); assert!(is_autogenerated("hive-c0re/Cargo.lock"));
assert!(is_autogenerated("frontend/packages/dashboard/package-lock.json")); assert!(is_autogenerated(
"frontend/packages/dashboard/package-lock.json"
));
assert!(!is_autogenerated("src/main.rs")); assert!(!is_autogenerated("src/main.rs"));
assert!(!is_autogenerated("Cargo.toml")); assert!(!is_autogenerated("Cargo.toml"));
// Suffix-only files we deliberately don't match — keep // Suffix-only files we deliberately don't match — keep
@ -346,10 +351,7 @@ index 1111..2222 100644
#[test] #[test]
fn parse_diff_git_path_picks_b_side() { fn parse_diff_git_path_picks_b_side() {
assert_eq!( assert_eq!(parse_diff_git_path("a/foo b/foo"), Some("foo".to_owned()));
parse_diff_git_path("a/foo b/foo"),
Some("foo".to_owned())
);
assert_eq!( assert_eq!(
parse_diff_git_path("a/old.txt b/new.txt"), parse_diff_git_path("a/old.txt b/new.txt"),
Some("new.txt".to_owned()) Some("new.txt".to_owned())

View file

@ -36,7 +36,8 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(); let repo = client.repo();
match args.action.unwrap_or(Action::List) { match args.action.unwrap_or(Action::List) {
Action::List => { Action::List => {
let labels = client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?; let labels =
client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?;
print_label_names(&labels); print_label_names(&labels);
} }
Action::Add { labels } => { Action::Add { labels } => {
@ -64,7 +65,8 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
); );
} }
} }
let labels = client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?; let labels =
client.get_json(&format!("/repos/{repo}/issues/{}/labels", args.number))?;
print_label_names(&labels); print_label_names(&labels);
} }
} }

View file

@ -167,9 +167,7 @@ fn print_row(item: &Value) {
// forge always populates `pull_request` but sets it to `null` for // forge always populates `pull_request` but sets it to `null` for
// issues; we check non-null specifically rather than just-present // issues; we check non-null specifically rather than just-present
// (which would render every issue as a PR). // (which would render every issue as a PR).
let is_pr = item let is_pr = item.get("pull_request").is_some_and(|v| !v.is_null());
.get("pull_request")
.is_some_and(|v| !v.is_null());
let kind = if is_pr { "PR" } else { " " }; let kind = if is_pr { "PR" } else { " " };
println!("#{number:>4} {kind} [{author}] {title}"); println!("#{number:>4} {kind} [{author}] {title}");
} }

View file

@ -41,8 +41,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo(); let repo = client.repo();
match args.action.unwrap_or(Action::List) { match args.action.unwrap_or(Action::List) {
Action::List => { Action::List => {
let v = let v = client.get_json(&format!("/repos/{repo}/milestones?state=open&limit=50"))?;
client.get_json(&format!("/repos/{repo}/milestones?state=open&limit=50"))?;
let trimmed: Vec<Value> = v let trimmed: Vec<Value> = v
.as_array() .as_array()
.map(|a| { .map(|a| {

View file

@ -222,7 +222,9 @@ To http://localhost:3000/hyperhive/hyperhive.git\n\
assert!(is_pr_hint_opener( assert!(is_pr_hint_opener(
"remote: Create a new pull request for 'x':\n" "remote: Create a new pull request for 'x':\n"
)); ));
assert!(is_pr_hint_opener("remote: Visit the existing pull request:\n")); assert!(is_pr_hint_opener(
"remote: Visit the existing pull request:\n"
));
assert!(!is_pr_hint_opener("remote: some other thing\n")); assert!(!is_pr_hint_opener("remote: some other thing\n"));
assert!(!is_pr_hint_opener("To http://example.com\n")); assert!(!is_pr_hint_opener("To http://example.com\n"));
// Trailing-CRLF safety on windows-cloned forge clones. // Trailing-CRLF safety on windows-cloned forge clones.

View file

@ -29,7 +29,11 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
return Ok(()); return Ok(());
} }
if args.watch || args.ignore { if args.watch || args.ignore {
let (subscribed, ignored) = if args.ignore { (false, true) } else { (true, false) }; let (subscribed, ignored) = if args.ignore {
(false, true)
} else {
(true, false)
};
let resp = client.post_json( let resp = client.post_json(
&format!("/repos/{repo}/subscription"), &format!("/repos/{repo}/subscription"),
&json!({ "subscribed": subscribed, "ignored": ignored }), &json!({ "subscribed": subscribed, "ignored": ignored }),

View file

@ -170,7 +170,10 @@ fn format_event(ev: &Value) -> String {
} }
} }
"commit_ref" => { "commit_ref" => {
let sha = ev.get("ref_commit_sha").and_then(Value::as_str).unwrap_or(""); let sha = ev
.get("ref_commit_sha")
.and_then(Value::as_str)
.unwrap_or("");
let short: String = sha.chars().take(7).collect(); let short: String = sha.chars().take(7).collect();
if short.is_empty() { if short.is_empty() {
"referenced from a commit".to_owned() "referenced from a commit".to_owned()
@ -217,7 +220,10 @@ mod tests {
"created_at": "2026-05-31T12:00:00Z", "created_at": "2026-05-31T12:00:00Z",
"body": "looks good to me", "body": "looks good to me",
}); });
assert_eq!(format_event(&ev), "**iris @ 2026-05-31T12:00:00Z**: looks good to me"); assert_eq!(
format_event(&ev),
"**iris @ 2026-05-31T12:00:00Z**: looks good to me"
);
} }
#[test] #[test]

View file

@ -139,21 +139,25 @@ impl MatrixBridge {
Returns the new event id." Returns the new event id."
)] )]
async fn send_message(&self, Parameters(args): Parameters<SendMessageArgs>) -> String { async fn send_message(&self, Parameters(args): Parameters<SendMessageArgs>) -> String {
render(round_trip(DaemonRequest::SendMessage { render(
room: args.room, round_trip(DaemonRequest::SendMessage {
body: args.body, room: args.room,
}).await) body: args.body,
})
.await,
)
} }
#[tool( #[tool(description = "Open (or reuse) a direct message room with `user_id` \
description = "Open (or reuse) a direct message room with `user_id` \ (@user:server) and post `body` to it.")]
(@user:server) and post `body` to it."
)]
async fn send_dm(&self, Parameters(args): Parameters<SendDmArgs>) -> String { async fn send_dm(&self, Parameters(args): Parameters<SendDmArgs>) -> String {
render(round_trip(DaemonRequest::SendDm { render(
user_id: args.user_id, round_trip(DaemonRequest::SendDm {
body: args.body, user_id: args.user_id,
}).await) body: args.body,
})
.await,
)
} }
#[tool( #[tool(
@ -162,35 +166,40 @@ impl MatrixBridge {
standard clients." standard clients."
)] )]
async fn send_reaction(&self, Parameters(args): Parameters<SendReactionArgs>) -> String { async fn send_reaction(&self, Parameters(args): Parameters<SendReactionArgs>) -> String {
render(round_trip(DaemonRequest::SendReaction { render(
room: args.room, round_trip(DaemonRequest::SendReaction {
event_id: args.event_id, room: args.room,
key: args.key, event_id: args.event_id,
}).await) key: args.key,
})
.await,
)
} }
#[tool( #[tool(description = "Reply to a specific matrix event in a room, threaded \
description = "Reply to a specific matrix event in a room, threaded \ via m.in_reply_to. Returns the reply's event id.")]
via m.in_reply_to. Returns the reply's event id."
)]
async fn send_reply(&self, Parameters(args): Parameters<SendReplyArgs>) -> String { async fn send_reply(&self, Parameters(args): Parameters<SendReplyArgs>) -> String {
render(round_trip(DaemonRequest::SendReply { render(
room: args.room, round_trip(DaemonRequest::SendReply {
event_id: args.event_id, room: args.room,
body: args.body, event_id: args.event_id,
}).await) body: args.body,
})
.await,
)
} }
#[tool( #[tool(description = "Mark a specific event as read for this agent. Updates \
description = "Mark a specific event as read for this agent. Updates \
the room's unread indicator + sends a read receipt other \ the room's unread indicator + sends a read receipt other \
participants can see." participants can see.")]
)]
async fn mark_read(&self, Parameters(args): Parameters<MarkReadArgs>) -> String { async fn mark_read(&self, Parameters(args): Parameters<MarkReadArgs>) -> String {
render(round_trip(DaemonRequest::MarkRead { render(
room: args.room, round_trip(DaemonRequest::MarkRead {
event_id: args.event_id, room: args.room,
}).await) event_id: args.event_id,
})
.await,
)
} }
#[tool( #[tool(
@ -209,28 +218,27 @@ impl MatrixBridge {
render(round_trip(DaemonRequest::ListRoomMembers { room: args.room }).await) render(round_trip(DaemonRequest::ListRoomMembers { room: args.room }).await)
} }
#[tool( #[tool(description = "Read the most recent N events from a matrix room \
description = "Read the most recent N events from a matrix room \
(default 50, max 200). Returns each event's id, sender, timestamp, \ (default 50, max 200). Returns each event's id, sender, timestamp, \
type, and best-effort plain-text body." type, and best-effort plain-text body.")]
)]
async fn read_room(&self, Parameters(args): Parameters<ReadRoomArgs>) -> String { async fn read_room(&self, Parameters(args): Parameters<ReadRoomArgs>) -> String {
render(round_trip(DaemonRequest::ReadRoom { render(
room: args.room, round_trip(DaemonRequest::ReadRoom {
limit: args.limit, room: args.room,
}).await) limit: args.limit,
})
.await,
)
} }
} }
#[tool_handler( #[tool_handler(instructions = "Matrix client for an agent on a hyperhive swarm. Use \
instructions = "Matrix client for an agent on a hyperhive swarm. Use \
`send_message` to post in a joined room, `send_dm` to message a \ `send_message` to post in a joined room, `send_dm` to message a \
specific user, `send_reaction` to react with an emoji, `send_reply` \ specific user, `send_reaction` to react with an emoji, `send_reply` \
to thread a reply, `mark_read` to acknowledge an event. Discover \ to thread a reply, `mark_read` to acknowledge an event. Discover \
rooms with `list_rooms`, members with `list_room_members`, recent \ rooms with `list_rooms`, members with `list_room_members`, recent \
timeline with `read_room`. Room references accept ids (!abc:server) \ timeline with `read_room`. Room references accept ids (!abc:server) \
or aliases (#name:server); user references use @user:server." or aliases (#name:server); user references use @user:server.")]
)]
impl ServerHandler for MatrixBridge {} impl ServerHandler for MatrixBridge {}
#[tokio::main] #[tokio::main]
@ -258,7 +266,13 @@ async fn main() -> Result<()> {
} }
let bridge = MatrixBridge::new(); let bridge = MatrixBridge::new();
let service = bridge.serve(stdio()).await.context("serve MCP over stdio")?; let service = bridge
service.waiting().await.context("MCP service exited unexpectedly")?; .serve(stdio())
.await
.context("serve MCP over stdio")?;
service
.waiting()
.await
.context("MCP service exited unexpectedly")?;
Ok(()) Ok(())
} }

View file

@ -57,10 +57,7 @@ pub async fn build_and_restore(
.trim() .trim()
.to_owned(); .to_owned();
if token.is_empty() { if token.is_empty() {
return Err(anyhow!( return Err(anyhow!("matrix token at {} is empty", token_file.display()));
"matrix token at {} is empty",
token_file.display()
));
} }
let (user_id, device_id) = whoami(homeserver, &token).await?; let (user_id, device_id) = whoami(homeserver, &token).await?;

View file

@ -16,8 +16,8 @@ use matrix_sdk::{
OwnedEventId, OwnedRoomId, OwnedUserId, RoomOrAliasId, OwnedEventId, OwnedRoomId, OwnedUserId, RoomOrAliasId,
api::client::receipt::create_receipt::v3::ReceiptType, api::client::receipt::create_receipt::v3::ReceiptType,
events::{ events::{
receipt::ReceiptThread,
reaction::ReactionEventContent, reaction::ReactionEventContent,
receipt::ReceiptThread,
relation::Annotation, relation::Annotation,
room::message::{MessageType, RoomMessageEventContent}, room::message::{MessageType, RoomMessageEventContent},
}, },
@ -64,17 +64,20 @@ async fn resolve_room(
client: &Client, client: &Client,
reference: &str, reference: &str,
) -> Result<matrix_sdk::Room, DaemonResponse> { ) -> Result<matrix_sdk::Room, DaemonResponse> {
let parsed: &RoomOrAliasId = reference.try_into().map_err(|e| { let parsed: &RoomOrAliasId = reference
DaemonResponse::error(format!("invalid room reference {reference}: {e}")) .try_into()
})?; .map_err(|e| DaemonResponse::error(format!("invalid room reference {reference}: {e}")))?;
let room_id: OwnedRoomId = if parsed.is_room_id() { let room_id: OwnedRoomId = if parsed.is_room_id() {
OwnedRoomId::try_from(reference) OwnedRoomId::try_from(reference)
.map_err(|e| DaemonResponse::error(format!("invalid room_id: {e}")))? .map_err(|e| DaemonResponse::error(format!("invalid room_id: {e}")))?
} else { } else {
client client
.resolve_room_alias(parsed.as_str().try_into().map_err(|e| { .resolve_room_alias(
DaemonResponse::error(format!("invalid alias: {e}")) parsed
})?) .as_str()
.try_into()
.map_err(|e| DaemonResponse::error(format!("invalid alias: {e}")))?,
)
.await .await
.map(|r| r.room_id) .map(|r| r.room_id)
.map_err(|e| DaemonResponse::error(format!("resolve_room_alias {reference}: {e}")))? .map_err(|e| DaemonResponse::error(format!("resolve_room_alias {reference}: {e}")))?
@ -93,16 +96,14 @@ async fn resolve_room(
fn extract_body(event: &matrix_sdk::ruma::events::AnyTimelineEvent) -> String { fn extract_body(event: &matrix_sdk::ruma::events::AnyTimelineEvent) -> String {
use matrix_sdk::ruma::events::{AnyMessageLikeEvent, AnyTimelineEvent}; use matrix_sdk::ruma::events::{AnyMessageLikeEvent, AnyTimelineEvent};
match event { match event {
AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(ev)) => { AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(ev)) => ev
ev.as_original().map_or_else(String::new, |orig| { .as_original()
match &orig.content.msgtype { .map_or_else(String::new, |orig| match &orig.content.msgtype {
MessageType::Text(t) => t.body.clone(), MessageType::Text(t) => t.body.clone(),
MessageType::Notice(n) => n.body.clone(), MessageType::Notice(n) => n.body.clone(),
MessageType::Emote(e) => format!("* {}", e.body), MessageType::Emote(e) => format!("* {}", e.body),
_ => String::new(), _ => String::new(),
} }),
})
}
_ => String::new(), _ => String::new(),
} }
} }
@ -128,14 +129,13 @@ pub async fn send_dm(client: &Client, user_id: &str, body: &str) -> DaemonRespon
Err(e) => return DaemonResponse::error(format!("invalid user_id {user_id}: {e}")), Err(e) => return DaemonResponse::error(format!("invalid user_id {user_id}: {e}")),
}; };
// Find existing DM or create one. // Find existing DM or create one.
let room = client let room = client.joined_rooms().into_iter().find(|r| {
.joined_rooms() // is_direct() is async; check direct_targets() instead which
.into_iter() // reads from cached state.
.find(|r| { r.direct_targets()
// is_direct() is async; check direct_targets() instead which .iter()
// reads from cached state. .any(|t| t.as_str() == uid.as_str())
r.direct_targets().iter().any(|t| t.as_str() == uid.as_str()) });
});
let room = match room { let room = match room {
Some(r) => r, Some(r) => r,
None => match client.create_dm(&uid).await { None => match client.create_dm(&uid).await {
@ -268,16 +268,18 @@ pub async fn list_room_members(client: &Client, room_ref: &str) -> DaemonRespons
} }
pub async fn read_room(client: &Client, room_ref: &str, limit: Option<usize>) -> DaemonResponse { pub async fn read_room(client: &Client, room_ref: &str, limit: Option<usize>) -> DaemonResponse {
use matrix_sdk::ruma::api::client::message::get_message_events;
use matrix_sdk::ruma::api::Direction; use matrix_sdk::ruma::api::Direction;
use matrix_sdk::ruma::api::client::message::get_message_events;
let room = match resolve_room(client, room_ref).await { let room = match resolve_room(client, room_ref).await {
Ok(r) => r, Ok(r) => r,
Err(e) => return e, Err(e) => return e,
}; };
let limit = limit.unwrap_or(50).min(200); let limit = limit.unwrap_or(50).min(200);
let mut req = get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward); let mut req =
req.limit = matrix_sdk::ruma::UInt::try_from(limit as u64).unwrap_or(matrix_sdk::ruma::UInt::from(50u32)); get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward);
req.limit = matrix_sdk::ruma::UInt::try_from(limit as u64)
.unwrap_or(matrix_sdk::ruma::UInt::from(50u32));
let resp = match client.send(req).await { let resp = match client.send(req).await {
Ok(r) => r, Ok(r) => r,
Err(e) => return DaemonResponse::error(format!("get_message_events: {e}")), Err(e) => return DaemonResponse::error(format!("get_message_events: {e}")),

View file

@ -45,7 +45,8 @@ pub fn homeserver_url() -> String {
/// `HIVE_MATRIX_SOCKET`; default is `/run/hive-matrix/socket`. /// `HIVE_MATRIX_SOCKET`; default is `/run/hive-matrix/socket`.
#[must_use] #[must_use]
pub fn daemon_socket() -> PathBuf { pub fn daemon_socket() -> PathBuf {
std::env::var_os("HIVE_MATRIX_SOCKET").map_or_else(|| PathBuf::from(DEFAULT_DAEMON_SOCKET), PathBuf::from) std::env::var_os("HIVE_MATRIX_SOCKET")
.map_or_else(|| PathBuf::from(DEFAULT_DAEMON_SOCKET), PathBuf::from)
} }
/// Persistent sqlite store directory for matrix-sdk's state (event /// Persistent sqlite store directory for matrix-sdk's state (event
@ -62,5 +63,6 @@ pub fn matrix_state_dir() -> PathBuf {
/// Mirrors the path `forge_notify` writes to. /// Mirrors the path `forge_notify` writes to.
#[must_use] #[must_use]
pub fn hyperhive_socket() -> PathBuf { pub fn hyperhive_socket() -> PathBuf {
std::env::var_os("HIVE_CONTROL_SOCKET").map_or_else(|| PathBuf::from("/run/hive/mcp.sock"), PathBuf::from) std::env::var_os("HIVE_CONTROL_SOCKET")
.map_or_else(|| PathBuf::from("/run/hive/mcp.sock"), PathBuf::from)
} }

View file

@ -84,7 +84,10 @@ mod tests {
#[test] #[test]
fn format_wake_body_short_passes_through() { fn format_wake_body_short_passes_through() {
let body = format_wake_body("@iris:matrix.darkest.space", "#general", "hi all"); let body = format_wake_body("@iris:matrix.darkest.space", "#general", "hi all");
assert_eq!(body, "[matrix] @iris:matrix.darkest.space in #general: hi all"); assert_eq!(
body,
"[matrix] @iris:matrix.darkest.space in #general: hi all"
);
} }
#[test] #[test]

View file

@ -17,11 +17,13 @@
//! `LISTEN_FDS=1` + `LISTEN_PID=<self>`, the inherited fd 3 is used //! `LISTEN_FDS=1` + `LISTEN_PID=<self>`, the inherited fd 3 is used
//! instead of binding a fresh socket. //! instead of binding a fresh socket.
use std::fmt::Write as _;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use hive_sh4re::priv_proto::{AGENT_PREFIX, MANAGER_NAME, META_DIR, PRIV_SOCK, SIBLING_CONTAINERS, BindMount, PrivRequest, PrivResponse}; use hive_sh4re::priv_proto::{
AGENT_PREFIX, BindMount, MANAGER_NAME, META_DIR, PRIV_SOCK, PrivRequest, PrivResponse,
SIBLING_CONTAINERS,
};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream}; use tokio::net::{UnixListener, UnixStream};
use tokio::process::Command; use tokio::process::Command;
@ -33,8 +35,7 @@ const SOCKET_DIR_ROOT: &str = "/run/hive-agent";
async fn main() -> Result<()> { async fn main() -> Result<()> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
.unwrap_or_else(|_| "info".into()),
) )
.init(); .init();
@ -54,7 +55,6 @@ async fn main() -> Result<()> {
} }
fn socket_listener() -> Result<UnixListener> { fn socket_listener() -> Result<UnixListener> {
use std::os::unix::fs::PermissionsExt as _;
// Socket activation: systemd passes the socket as fd 3 when // Socket activation: systemd passes the socket as fd 3 when
// LISTEN_FDS >= 1 and LISTEN_PID matches our pid. // LISTEN_FDS >= 1 and LISTEN_PID matches our pid.
let listen_fds: Option<i32> = std::env::var("LISTEN_FDS") let listen_fds: Option<i32> = std::env::var("LISTEN_FDS")
@ -64,32 +64,32 @@ fn socket_listener() -> Result<UnixListener> {
.ok() .ok()
.and_then(|s| s.parse().ok()); .and_then(|s| s.parse().ok());
if let (Some(n), Some(p)) = (listen_fds, listen_pid) if let (Some(n), Some(p)) = (listen_fds, listen_pid) {
&& n >= 1 && p == std::process::id() if n >= 1 && p == std::process::id() {
{ // SAFETY: systemd has passed us a ready UnixListener on fd 3.
// SAFETY: systemd has passed us a ready UnixListener on fd 3. let std_listener = unsafe {
let std_listener = unsafe { use std::os::unix::io::FromRawFd;
use std::os::unix::io::FromRawFd; std::os::unix::net::UnixListener::from_raw_fd(3)
std::os::unix::net::UnixListener::from_raw_fd(3) };
}; std_listener
std_listener .set_nonblocking(true)
.set_nonblocking(true) .context("set socket non-blocking")?;
.context("set socket non-blocking")?; let listener =
let listener = tokio::net::UnixListener::from_std(std_listener).context("wrap systemd socket")?;
tokio::net::UnixListener::from_std(std_listener).context("wrap systemd socket")?; tracing::info!("using systemd-activated socket");
tracing::info!("using systemd-activated socket"); return Ok(listener);
return Ok(listener); }
} }
// Fallback: bind the socket ourselves. // Fallback: bind the socket ourselves.
let path = Path::new(PRIV_SOCK); let path = Path::new(PRIV_SOCK);
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
.with_context(|| format!("create {}", parent.display()))?;
} }
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
let listener = UnixListener::bind(path).with_context(|| format!("bind {PRIV_SOCK}"))?; let listener = UnixListener::bind(path).with_context(|| format!("bind {PRIV_SOCK}"))?;
// Mode 0660: only the hive-core group can connect. // Mode 0660: only the hive-core group can connect.
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660)) std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660))
.context("chmod priv.sock")?; .context("chmod priv.sock")?;
tracing::info!(path = PRIV_SOCK, "bound priv socket"); tracing::info!(path = PRIV_SOCK, "bound priv socket");
@ -138,7 +138,6 @@ async fn dispatch(line: &str) -> PrivResponse {
} }
/// Execute a validated `PrivRequest`. Returns `(stdout, stderr)` on success. /// Execute a validated `PrivRequest`. Returns `(stdout, stderr)` on success.
#[allow(clippy::too_many_lines)]
async fn exec(req: PrivRequest) -> Result<(String, String)> { async fn exec(req: PrivRequest) -> Result<(String, String)> {
match req { match req {
PrivRequest::StartContainer { ref name } => { PrivRequest::StartContainer { ref name } => {
@ -159,13 +158,25 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> {
PrivRequest::UpdateContainer { ref name } => { PrivRequest::UpdateContainer { ref name } => {
validate_container_name(name)?; validate_container_name(name)?;
let flake_ref = agent_flake_ref(name); let flake_ref = agent_flake_ref(name);
container_run(&["update", &container_system_name(name), "--flake", &flake_ref]).await container_run(&[
"update",
&container_system_name(name),
"--flake",
&flake_ref,
])
.await
} }
PrivRequest::CreateContainer { ref name } => { PrivRequest::CreateContainer { ref name } => {
validate_container_name(name)?; validate_container_name(name)?;
let flake_ref = agent_flake_ref(name); let flake_ref = agent_flake_ref(name);
container_run(&["create", &container_system_name(name), "--flake", &flake_ref]).await container_run(&[
"create",
&container_system_name(name),
"--flake",
&flake_ref,
])
.await
} }
PrivRequest::DestroyContainer { ref name } => { PrivRequest::DestroyContainer { ref name } => {
@ -175,7 +186,10 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> {
PrivRequest::ListContainers => container_run(&["list"]).await, PrivRequest::ListContainers => container_run(&["list"]).await,
PrivRequest::WriteNspawnFlags { ref container, ref binds } => { PrivRequest::WriteNspawnFlags {
ref container,
ref binds,
} => {
validate_container_system_name(container)?; validate_container_system_name(container)?;
for bind in binds { for bind in binds {
validate_bind_path(&bind.host_path)?; validate_bind_path(&bind.host_path)?;
@ -203,8 +217,7 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> {
validate_container_system_name(container)?; validate_container_system_name(container)?;
let dir = format!("/run/systemd/system/container@{container}.service.d"); let dir = format!("/run/systemd/system/container@{container}.service.d");
if Path::new(&dir).exists() { if Path::new(&dir).exists() {
std::fs::remove_dir_all(&dir) std::fs::remove_dir_all(&dir).with_context(|| format!("remove {dir}"))?;
.with_context(|| format!("remove {dir}"))?;
} }
Ok((String::new(), String::new())) Ok((String::new(), String::new()))
} }
@ -227,7 +240,14 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> {
PrivRequest::ReloadGatewayNginx => { PrivRequest::ReloadGatewayNginx => {
let out = Command::new("systemd-run") let out = Command::new("systemd-run")
.args(["--machine=hive-gateway", "--quiet", "--", "nginx", "-s", "reload"]) .args([
"--machine=hive-gateway",
"--quiet",
"--",
"nginx",
"-s",
"reload",
])
.output() .output()
.await .await
.context("invoke systemd-run for gateway nginx reload")?; .context("invoke systemd-run for gateway nginx reload")?;
@ -241,7 +261,11 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> {
Ok((String::new(), String::new())) Ok((String::new(), String::new()))
} }
PrivRequest::ChownSocketDir { ref agent_name, uid, gid } => { PrivRequest::ChownSocketDir {
ref agent_name,
uid,
gid,
} => {
validate_agent_name(agent_name)?; validate_agent_name(agent_name)?;
let path = socket_dir_path(agent_name); let path = socket_dir_path(agent_name);
std::os::unix::fs::chown(&path, Some(uid), Some(gid)) std::os::unix::fs::chown(&path, Some(uid), Some(gid))
@ -249,10 +273,13 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> {
Ok((String::new(), String::new())) Ok((String::new(), String::new()))
} }
PrivRequest::ChmodSocketDir { ref agent_name, mode } => { PrivRequest::ChmodSocketDir {
use std::os::unix::fs::PermissionsExt as _; ref agent_name,
mode,
} => {
validate_agent_name(agent_name)?; validate_agent_name(agent_name)?;
let path = socket_dir_path(agent_name); let path = socket_dir_path(agent_name);
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)) std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode))
.with_context(|| format!("chmod {:o} {}", mode, path.display()))?; .with_context(|| format!("chmod {:o} {}", mode, path.display()))?;
Ok((String::new(), String::new())) Ok((String::new(), String::new()))
@ -361,7 +388,9 @@ fn agent_flake_ref(name: &str) -> String {
fn validate_bind_path(path: &str) -> Result<()> { fn validate_bind_path(path: &str) -> Result<()> {
if path.is_empty() if path.is_empty()
|| !path.starts_with('/') || !path.starts_with('/')
|| path.bytes().any(|b| b == 0 || b == b'\n' || b == b'"' || b == b':') || path
.bytes()
.any(|b| b == 0 || b == b'\n' || b == b'"' || b == b':')
{ {
bail!( bail!(
"invalid bind path {path:?}: must be an absolute path with no colons, newlines, null bytes, or double-quotes" "invalid bind path {path:?}: must be an absolute path with no colons, newlines, null bytes, or double-quotes"
@ -376,8 +405,7 @@ fn validate_bind_path(path: &str) -> Result<()> {
/// then appends `EXTRA_NSPAWN_FLAGS="<flags>"`. /// then appends `EXTRA_NSPAWN_FLAGS="<flags>"`.
fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> { fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> {
let path = format!("/etc/nixos-containers/{container}.conf"); let path = format!("/etc/nixos-containers/{container}.conf");
let original = std::fs::read_to_string(&path) let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
.with_context(|| format!("read {path}"))?;
let lines: Vec<&str> = original let lines: Vec<&str> = original
.lines() .lines()
.filter(|line| { .filter(|line| {
@ -401,11 +429,14 @@ fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> {
out.push_str("HOST_ADDRESS6=\n"); out.push_str("HOST_ADDRESS6=\n");
out.push_str("LOCAL_ADDRESS6=\n"); out.push_str("LOCAL_ADDRESS6=\n");
out.push_str("HOST_BRIDGE=\n"); out.push_str("HOST_BRIDGE=\n");
let flags: Vec<String> = binds.iter().map(|b| { let flags: Vec<String> = binds
let flag = if b.read_only { "--bind-ro" } else { "--bind" }; .iter()
format!("{flag}={}:{}", b.host_path, b.container_path) .map(|b| {
}).collect(); let flag = if b.read_only { "--bind-ro" } else { "--bind" };
format!("{flag}={}:{}", b.host_path, b.container_path)
})
.collect();
let flags_joined = flags.join(" "); let flags_joined = flags.join(" ");
writeln!(out, "EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"").unwrap(); out.push_str(&format!("EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"\n"));
std::fs::write(&path, out).with_context(|| format!("write {path}")) std::fs::write(&path, out).with_context(|| format!("write {path}"))
} }

View file

@ -463,7 +463,6 @@ pub enum Request {
}, },
// ---- privileged (manager socket only for now) --------------------------- // ---- privileged (manager socket only for now) ---------------------------
/// *(privileged)* Initialise a brand-new agent's proposed config repo /// *(privileged)* Initialise a brand-new agent's proposed config repo
/// and queue an approval for the operator to review. /// and queue an approval for the operator to review.
RequestInitConfig { RequestInitConfig {
@ -724,7 +723,6 @@ pub enum HelperEvent {
}, },
} }
/// Submission payload for `RequestSchedulePrompt`. Lives outside the /// Submission payload for `RequestSchedulePrompt`. Lives outside the
/// enum so it can also serialize into the approval row's `commit_ref` /// enum so it can also serialize into the approval row's `commit_ref`
/// (the dispatcher re-parses it on approve and inserts the schedule). /// (the dispatcher re-parses it on approve and inserts the schedule).
@ -976,4 +974,3 @@ pub struct WireScheduleTarget {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub last_result: Option<String>, pub last_result: Option<String>,
} }

View file

@ -42,7 +42,6 @@ pub struct BindMount {
#[serde(tag = "op", rename_all = "snake_case")] #[serde(tag = "op", rename_all = "snake_case")]
pub enum PrivRequest { pub enum PrivRequest {
// --- Container lifecycle --- // --- Container lifecycle ---
/// `nixos-container start <name>` /// `nixos-container start <name>`
StartContainer { name: String }, StartContainer { name: String },
@ -67,11 +66,13 @@ pub enum PrivRequest {
ListContainers, ListContainers,
// --- Config file writes --- // --- Config file writes ---
/// Update `/etc/nixos-containers/<container>.conf`: strip network-isolation /// Update `/etc/nixos-containers/<container>.conf`: strip network-isolation
/// vars, force `PRIVATE_NETWORK=0`, and set `EXTRA_NSPAWN_FLAGS` from the /// vars, force `PRIVATE_NETWORK=0`, and set `EXTRA_NSPAWN_FLAGS` from the
/// provided bind-mount list. Written by `lifecycle::set_nspawn_flags`. /// provided bind-mount list. Written by `lifecycle::set_nspawn_flags`.
WriteNspawnFlags { container: String, binds: Vec<BindMount> }, WriteNspawnFlags {
container: String,
binds: Vec<BindMount>,
},
/// Write `/run/systemd/system/container@<container>.service.d/hyperhive-limits.conf` /// Write `/run/systemd/system/container@<container>.service.d/hyperhive-limits.conf`
/// with `[Service]\nMemoryMax=<memory_max>\nCPUQuota=<cpu_quota>\n`. /// with `[Service]\nMemoryMax=<memory_max>\nCPUQuota=<cpu_quota>\n`.
@ -87,7 +88,6 @@ pub enum PrivRequest {
RemoveServiceDropin { container: String }, RemoveServiceDropin { container: String },
// --- System --- // --- System ---
/// Run `systemctl daemon-reload`. /// Run `systemctl daemon-reload`.
DaemonReload, DaemonReload,
@ -96,10 +96,13 @@ pub enum PrivRequest {
ReloadGatewayNginx, ReloadGatewayNginx,
// --- Socket dir ownership --- // --- Socket dir ownership ---
/// Set ownership of `/run/hive-agent/<agent_name>/` to `uid:gid`. /// Set ownership of `/run/hive-agent/<agent_name>/` to `uid:gid`.
/// Called by `lifecycle::set_nspawn_flags` after `create_dir_all`. /// Called by `lifecycle::set_nspawn_flags` after `create_dir_all`.
ChownSocketDir { agent_name: String, uid: u32, gid: u32 }, ChownSocketDir {
agent_name: String,
uid: u32,
gid: u32,
},
/// Set mode of `/run/hive-agent/<agent_name>/`. /// Set mode of `/run/hive-agent/<agent_name>/`.
/// Fallback when uid lookup returns `None` on first spawn. /// Fallback when uid lookup returns `None` on first spawn.

View file

@ -213,7 +213,8 @@ in
''; '';
htpasswdFile = lib.mkOption { htpasswdFile = lib.mkOption {
type = lib.types.path; type = lib.types.nullOr lib.types.path;
default = null;
example = "/etc/hyperhive/gateway.htpasswd"; example = "/etc/hyperhive/gateway.htpasswd";
description = '' description = ''
Path on the **host** to an htpasswd-format file whose Path on the **host** to an htpasswd-format file whose
@ -254,16 +255,15 @@ in
''; '';
} }
{ {
assertion = !cfg.auth.enable || cfg.auth ? htpasswdFile; assertion = !cfg.auth.enable || cfg.auth.htpasswdFile != null;
message = '' message = ''
services.hyperhive.gateway.auth.enable = true requires services.hyperhive.gateway.auth.enable = true requires
services.hyperhive.gateway.auth.htpasswdFile to be set. services.hyperhive.gateway.auth.htpasswdFile to be set.
Create an htpasswd file with: htpasswd -Bc /path/to/file <username> Create an htpasswd file with: hivectl gateway create-user --file /path/to/file <username>
''; '';
} }
]; ];
# Ensure bind-mount sources exist at host boot before the gateway # Ensure bind-mount sources exist at host boot before the gateway
# container's first start. nspawn would auto-create missing dirs # container's first start. nspawn would auto-create missing dirs
# tmpfiles rules make the intent explicit # tmpfiles rules make the intent explicit
@ -316,7 +316,7 @@ in
# Using the parent directory (not the file itself) because nspawn # Using the parent directory (not the file itself) because nspawn
# bind-mounts need a pre-existing destination — binding a directory # bind-mounts need a pre-existing destination — binding a directory
# is always safe; nginx picks the file up by name inside. # is always safe; nginx picks the file up by name inside.
bindMounts."/run/gateway-auth" = lib.mkIf cfg.auth.enable { bindMounts."/run/gateway-auth" = lib.mkIf (cfg.auth.enable && cfg.auth.htpasswdFile != null) {
hostPath = builtins.dirOf cfg.auth.htpasswdFile; hostPath = builtins.dirOf cfg.auth.htpasswdFile;
isReadOnly = true; isReadOnly = true;
}; };
@ -583,7 +583,7 @@ in
extraConfig = '' extraConfig = ''
proxy_buffering off; proxy_buffering off;
proxy_read_timeout 1d; proxy_read_timeout 1d;
${lib.optionalString cfg.auth.enable '' ${lib.optionalString (cfg.auth.enable && cfg.auth.htpasswdFile != null) ''
auth_basic "${cfg.auth.realm}"; auth_basic "${cfg.auth.realm}";
auth_basic_user_file /run/gateway-auth/${builtins.baseNameOf cfg.auth.htpasswdFile}; auth_basic_user_file /run/gateway-auth/${builtins.baseNameOf cfg.auth.htpasswdFile};
''} ''}