From 72c7c73f2835761dec43274ab1aae8cef414c62a Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 29 May 2026 16:57:06 +0200 Subject: [PATCH] harness: logout only wipes OAuth cred files, preserves session history (closes #584) --- hive-ag3nt/src/web_ui.rs | 84 ++++++++++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 30 deletions(-) diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index e6347c49..0c330f7f 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -891,58 +891,82 @@ 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: +/// OAuth credential filenames inside `paths::claude_dir()`. These are +/// the files `claude auth login` writes (the bearer token + an internal +/// MCP auth cache); wiping them invalidates the session without +/// touching the rest of `~/.claude/` — projects/ (jsonl session +/// history), sessions/, shell-snapshots/, telemetry/, settings.json, +/// etc. all survive so `--continue` keeps working after a re-login. +/// (#584 — fix for #582 which did a wholesale `remove_dir_all`.) +const CRED_FILE_NAMES: &[&str] = &[".credentials.json", "mcp-needs-auth-cache.json"]; + +/// Operator-driven `/logout` (closes #576, narrowed scope per #584). +/// Three-step teardown: /// -/// 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). +/// 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. Delete the OAuth credential files listed in `CRED_FILE_NAMES` +/// inside `paths::claude_dir()`. **Preserves** session history +/// (`projects//*.jsonl`), sessions, shell-snapshots, plans, +/// settings, telemetry, and the dir itself — `--continue` keeps +/// working with the same session after a fresh login. +/// 3. Flip `LoginState::NeedsLogin` + emit Note + status. The turn- +/// loop's next iteration parks into `wait_for_login`, which +/// snapshots the dir (now missing the cred files) 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. +/// Always returns 200 with a body describing what happened — errors +/// per file are folded into the response + the Note so the operator +/// sees them in the live panel rather than as an HTTP error. Missing +/// files (already logged out) are treated as idempotent. 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. + // Step 2: delete OAuth credential files only — preserve session + // history files alongside them. 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})"); + let mut warnings: Vec = Vec::new(); + let mut wiped: Vec<&str> = Vec::new(); + for name in CRED_FILE_NAMES { + let path = dir.join(name); + match tokio::fs::remove_file(&path).await { + Ok(()) => wiped.push(name), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Already gone — operator clicked /logout while + // already logged out, or the file simply didn't exist + // for this agent. Idempotent. + } + Err(e) => warnings.push(format!("{name}: {e}")), } } - if let Err(e) = tokio::fs::create_dir_all(&dir).await { - wipe_note = format!(" (recreate warning: {e})"); - } + let wipe_summary = if wiped.is_empty() { + "no credential files present (already logged out)".to_owned() + } else { + format!("wiped {}", wiped.join(", ")) + }; + let warn_suffix = if warnings.is_empty() { + String::new() + } else { + format!(" (warnings: {})", warnings.join("; ")) + }; // 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}", + "operator: /logout — {wipe_summary} in {}{warn_suffix}", dir.display() ), }); state.bus.emit_status("needs_login_idle"); ( axum::http::StatusCode::OK, - format!("ok: credentials wiped at {}{wipe_note}", dir.display()), + format!("ok: {wipe_summary} in {}{warn_suffix}", dir.display()), ) .into_response() }