feat(gateway): hivectl gateway user management + fix htpasswdFile assertion
Add `hivectl gateway {create-user,delete-user,list-users}` subcommands for
managing htpasswd files used by gateway Basic auth. Pure Rust bcrypt
(cost 12, $2y$ prefix nginx accepts). No external htpasswd binary required.
Also fix the NixOS module assertion: `cfg.auth ? htpasswdFile` is always
true in the module system (declared options always exist as keys); switch
to `nullOr path; default = null` + `!= null` check so the assertion
actually fires with a useful error when enable=true but no file is set.
Guard bind-mount and nginx config against null to prevent eval errors.
Update docs/gateway.md to show hivectl commands instead of raw htpasswd.
This commit is contained in:
parent
25d2951d1e
commit
4bff450343
61 changed files with 1084 additions and 547 deletions
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue