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.
116 lines
4.7 KiB
Rust
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");
|
|
}
|
|
}
|