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

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