Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-agent/src/web_ui/auth.rs
iris 2acde41e54 docs: the hive dashboard and agent page shrink to overviews
The hive dashboard and the per-agent page lose their control-by-control UI
description and endpoint tables from the handbook. The full text moves to
4-implementation/web-ui/dashboard.md and agent-page.md, and the handbook pages
keep what an operator needs to find and use them. Code comments that named the
old sections point at the new pages.
2026-11-11 01:50:55 +01:00

116 lines
4.7 KiB
Rust

//! Logout handler (`/api/logout`). The agent has no login flow of its own:
//! credentials come from the swarm's secret store (`claude_credentials`).
use axum::{
extract::State,
response::{IntoResponse, Response},
};
use crate::events::Bus;
use crate::login::LoginState;
use super::{AppState, LoginStateCell};
/// Operator-driven `/logout`: SIGINT claude, delete the credential
/// files (via [`crate::login::clear_session`]), flip `LoginState::NeedsLogin`.
/// The turn loop's next iteration parks into `wait_for_login` which
/// resumes when a fresh credentials file appears (the swarm login stores
/// one and `claude_credentials` installs it).
/// Always returns 200 with a body describing what happened. See
/// `docs/4-implementation/web-ui/agent-page.md::Per-agent endpoints`
/// (the `/api/logout` bullet) for the three-step rationale +
/// preservation invariants.
pub(super) async fn post_logout(State(state): State<AppState>) -> Response {
let said = force_logout(&state.login, &state.bus, "operator: /logout").await;
(axum::http::StatusCode::OK, format!("ok: {said}")).into_response()
}
/// The three steps of a logout, shared by `/api/logout` and the swarm login
/// watch: `by` heads the Note. Returns what was wiped, for the response.
pub(crate) async fn force_logout(login: &LoginStateCell, bus: &Bus, by: &str) -> String {
// Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`).
let _ = super::sigint_claude();
// Step 2: delete OAuth credential files only — login::clear_session owns
// the file set and preserves session-history files alongside them.
let dir = crate::paths::claude_dir();
let cleared = crate::login::clear_session(&dir).await;
let wipe_summary = wipe_summary(&cleared);
let warn_suffix = if cleared.warnings.is_empty() {
String::new()
} else {
format!(" (warnings: {})", cleared.warnings.join("; "))
};
// Step 3: flip LoginState + emit Note. Turn loop sees the flip on
// its next iteration and parks into wait_for_login.
*login.lock().unwrap() = LoginState::NeedsLogin;
let said = format!("{wipe_summary} in {}{warn_suffix}", dir.display());
bus.emit(crate::events::LiveEvent::Note {
text: format!("{by} — {said}"),
});
bus.emit_status("needs_login_idle");
said
}
/// Human-readable summary of a [`crate::login::ClearedSession`] outcome for
/// the `/api/logout` response + note. Deliberately distinguishes "nothing to
/// delete" from "something's there but we couldn't delete it" — both leave
/// `wiped` empty, but only the former is actually "already logged out";
/// conflating them would report a stuck undeletable `.credentials.json` as
/// "already logged out" in the same breath as a delete-failed warning.
fn wipe_summary(cleared: &crate::login::ClearedSession) -> String {
if !cleared.wiped.is_empty() {
format!("wiped {}", cleared.wiped.join(", "))
} else if cleared.warnings.is_empty() {
"no credential files present (already logged out)".to_owned()
} else {
"failed to delete existing credential file(s), NOT logged out".to_owned()
}
}
#[cfg(test)]
mod tests {
use super::wipe_summary;
use crate::login::ClearedSession;
#[test]
fn nothing_found_reports_already_logged_out() {
let cleared = ClearedSession::default();
assert_eq!(
wipe_summary(&cleared),
"no credential files present (already logged out)"
);
}
#[test]
fn wiped_files_are_named() {
let cleared = ClearedSession {
wiped: vec![".credentials.json"],
warnings: vec![],
};
assert_eq!(wipe_summary(&cleared), "wiped .credentials.json");
}
#[test]
fn permission_error_does_not_claim_already_logged_out() {
// A real file blocked by EPERM must not be reported the same way
// as a genuinely absent one.
let cleared = ClearedSession {
wiped: vec![],
warnings: vec![".credentials.json: Permission denied (os error 13)".to_owned()],
};
let summary = wipe_summary(&cleared);
assert!(!summary.contains("already logged out"));
assert!(summary.contains("failed to delete"));
}
#[test]
fn partial_wipe_with_warnings_still_reports_wiped_files() {
// A mix of successes and failures should favor telling the operator
// what actually happened over the negative "nothing" framing.
let cleared = ClearedSession {
wiped: vec![".credentials.json"],
warnings: vec!["settings.json: Permission denied (os error 13)".to_owned()],
};
assert_eq!(wipe_summary(&cleared), "wiped .credentials.json");
}
}