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

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

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

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

View file

@ -18,7 +18,9 @@ use clap::{Parser, Subcommand};
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
use hive_ag3nt::login::{self, LoginState};
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::{
AgentRequest, AgentResponse, HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER,
};
@ -129,7 +131,9 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) {
} else {
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
@ -140,7 +144,11 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) {
/// misconfigured harness still produces a parseable line.
fn format_turn_failure(err: &anyhow::Error) -> String {
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:#}")
}
@ -202,9 +210,7 @@ trait Surface {
/// `(open_threads, open_reminders)` for the post-turn stats row.
/// Either field is `None` when the underlying request errors.
fn post_turn_counts(
socket: &Path,
) -> impl Future<Output = (Option<u64>, Option<u64>)>;
fn post_turn_counts(socket: &Path) -> impl Future<Output = (Option<u64>, Option<u64>)>;
/// Send a message addressed to `<parent>` (broker resolves the
/// 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
/// harness inbox). Errors out via `anyhow::bail!` so the calling
/// binary surfaces them on stderr.
fn wake_external(
socket: &Path,
from: String,
body: String,
) -> impl Future<Output = Result<()>>;
fn wake_external(socket: &Path, from: String, body: String)
-> impl Future<Output = Result<()>>;
}
// ---------- AgentSurface ----------
@ -271,13 +274,15 @@ impl Surface for AgentSurface {
}
async fn post_turn_counts(socket: &Path) -> (Option<u64>, Option<u64>) {
let threads =
match client::request::<_, AgentResponse>(socket, &AgentRequest::GetLooseEnds { agent: None }).await {
Ok(AgentResponse::LooseEnds { loose_ends }) => {
u64::try_from(loose_ends.len()).ok()
}
_ => None,
};
let threads = match client::request::<_, AgentResponse>(
socket,
&AgentRequest::GetLooseEnds { agent: None },
)
.await
{
Ok(AgentResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(),
_ => None,
};
let reminders = match client::request::<_, AgentResponse>(
socket,
&AgentRequest::CountPendingReminders { agent: None },
@ -415,9 +420,7 @@ impl Surface for ManagerSurface {
)
.await
{
Ok(ManagerResponse::LooseEnds { loose_ends }) => {
u64::try_from(loose_ends.len()).ok()
}
Ok(ManagerResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(),
_ => None,
};
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 {
let (label, port, login_state, bus, socket, files, turn_lock) = web_ui_args;
if let Err(e) =
web_ui::serve(label, port, login_state, bus, socket, files, turn_lock).await
if let Err(e) = web_ui::serve(label, port, login_state, bus, socket, files, turn_lock).await
{
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);
tracing::info!(%from, %body, %redelivered, "inbox");
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);
let started_at = serve_common::now_unix();
let started_instant = std::time::Instant::now();
@ -670,7 +676,10 @@ async fn handle_turn<S: Surface>(
};
turn::emit_turn_end(bus, &outcome);
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;
}
if matches!(outcome, turn::TurnOutcome::RateLimited) {
@ -697,8 +706,7 @@ async fn handle_turn<S: Surface>(
}
if let Some(stats) = stats {
let ended_at = serve_common::now_unix();
let duration_ms =
i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
let duration_ms = i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
let (open_threads, open_reminders) = S::post_turn_counts(socket).await;
let row = serve_common::build_row(
started_at,