diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index e44124f3..e6347c49 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -124,6 +124,7 @@ pub async fn serve( .route("/api/compact", post(post_compact)) .route("/api/model", post(post_set_model)) .route("/api/new-session", post(post_new_session)) + .route("/api/logout", post(post_logout)) .route("/api/loose-ends", get(api_loose_ends)) .route("/api/stats", get(api_stats)) .route("/screen/ws", get(screen_ws)) @@ -890,6 +891,62 @@ async fn post_new_session(State(state): State) -> Response { (axum::http::StatusCode::OK, "ok").into_response() } +/// Operator-driven `/logout` (closes #576). Three-step teardown of the +/// claude session: +/// +/// 1. SIGINT any running claude process so we don't race a turn that's +/// mid-API-call. Same pattern as `post_cancel_turn`; idempotent +/// (no-op when nothing is running). +/// 2. Wipe the credentials dir (`paths::claude_dir()`, normally +/// `/root/.claude`). Recursive remove; recreate empty so the +/// bind-mount target keeps existing for the next `claude auth login`. +/// 3. Flip the in-memory `LoginState` to `NeedsLogin` and emit a Note. +/// The turn-loop's next iteration sees the flipped state and parks +/// into `wait_for_login`, which snapshots the (now-empty) dir and +/// resumes only when a fresh credentials file appears via the +/// dashboard's `/login/code` flow (#542 mtime resumption). +/// +/// Always returns 200 with a body describing what happened — the +/// frontend's `/logout` slash command / overflow menu item just needs +/// to show a confirmation toast and re-fetch state. Errors at any +/// step are logged + folded into the Note so the operator sees them in +/// the live panel rather than as an HTTP error. +async fn post_logout(State(state): State) -> Response { + // Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`). + let _ = tokio::process::Command::new("pkill") + .args(["-INT", "claude"]) + .output() + .await; + // Step 2: wipe + recreate credentials dir. + let dir = crate::paths::claude_dir(); + let mut wipe_note = String::new(); + if let Err(e) = tokio::fs::remove_dir_all(&dir).await { + // ENOENT is fine — the dir was already empty (no creds), e.g. + // operator clicked /logout while already logged out. + if e.kind() != std::io::ErrorKind::NotFound { + wipe_note = format!(" (wipe warning: {e})"); + } + } + if let Err(e) = tokio::fs::create_dir_all(&dir).await { + wipe_note = format!(" (recreate warning: {e})"); + } + // Step 3: flip LoginState + emit Note. Turn loop sees the flip on + // its next iteration and parks into wait_for_login. + *state.login.lock().unwrap() = LoginState::NeedsLogin; + state.bus.emit(crate::events::LiveEvent::Note { + text: format!( + "operator: /logout — credentials wiped at {}{wipe_note}", + dir.display() + ), + }); + state.bus.emit_status("needs_login_idle"); + ( + axum::http::StatusCode::OK, + format!("ok: credentials wiped at {}{wipe_note}", dir.display()), + ) + .into_response() +} + async fn post_cancel_turn(State(state): State) -> Response { let out = tokio::process::Command::new("pkill") .args(["-INT", "claude"])