//! 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) -> 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"); } }