harness: logout only wipes OAuth cred files, preserves session history (closes #584)

This commit is contained in:
damocles 2026-05-29 16:57:06 +02:00 committed by Mara
commit 72c7c73f28

View file

@ -891,58 +891,82 @@ async fn post_new_session(State(state): State<AppState>) -> Response {
(axum::http::StatusCode::OK, "ok").into_response() (axum::http::StatusCode::OK, "ok").into_response()
} }
/// Operator-driven `/logout` (closes #576). Three-step teardown of the /// OAuth credential filenames inside `paths::claude_dir()`. These are
/// claude session: /// 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 /// 1. SIGINT any running claude process so we don't race a turn
/// mid-API-call. Same pattern as `post_cancel_turn`; idempotent /// that's mid-API-call. Same pattern as `post_cancel_turn`;
/// (no-op when nothing is running). /// idempotent (no-op when nothing is running).
/// 2. Wipe the credentials dir (`paths::claude_dir()`, normally /// 2. Delete the OAuth credential files listed in `CRED_FILE_NAMES`
/// `/root/.claude`). Recursive remove; recreate empty so the /// inside `paths::claude_dir()`. **Preserves** session history
/// bind-mount target keeps existing for the next `claude auth login`. /// (`projects/<hash>/*.jsonl`), sessions, shell-snapshots, plans,
/// 3. Flip the in-memory `LoginState` to `NeedsLogin` and emit a Note. /// settings, telemetry, and the dir itself — `--continue` keeps
/// The turn-loop's next iteration sees the flipped state and parks /// working with the same session after a fresh login.
/// into `wait_for_login`, which snapshots the (now-empty) dir and /// 3. Flip `LoginState::NeedsLogin` + emit Note + status. The turn-
/// resumes only when a fresh credentials file appears via the /// loop's next iteration parks into `wait_for_login`, which
/// dashboard's `/login/code` flow (#542 mtime resumption). /// 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 /// Always returns 200 with a body describing what happened — errors
/// frontend's `/logout` slash command / overflow menu item just needs /// per file are folded into the response + the Note so the operator
/// to show a confirmation toast and re-fetch state. Errors at any /// sees them in the live panel rather than as an HTTP error. Missing
/// step are logged + folded into the Note so the operator sees them in /// files (already logged out) are treated as idempotent.
/// the live panel rather than as an HTTP error.
async fn post_logout(State(state): State<AppState>) -> Response { async fn post_logout(State(state): State<AppState>) -> Response {
// Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`). // Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`).
let _ = tokio::process::Command::new("pkill") let _ = tokio::process::Command::new("pkill")
.args(["-INT", "claude"]) .args(["-INT", "claude"])
.output() .output()
.await; .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 dir = crate::paths::claude_dir();
let mut wipe_note = String::new(); let mut warnings: Vec<String> = Vec::new();
if let Err(e) = tokio::fs::remove_dir_all(&dir).await { let mut wiped: Vec<&str> = Vec::new();
// ENOENT is fine — the dir was already empty (no creds), e.g. for name in CRED_FILE_NAMES {
// operator clicked /logout while already logged out. let path = dir.join(name);
if e.kind() != std::io::ErrorKind::NotFound { match tokio::fs::remove_file(&path).await {
wipe_note = format!(" (wipe warning: {e})"); 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 { let wipe_summary = if wiped.is_empty() {
wipe_note = format!(" (recreate warning: {e})"); "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 // Step 3: flip LoginState + emit Note. Turn loop sees the flip on
// its next iteration and parks into wait_for_login. // its next iteration and parks into wait_for_login.
*state.login.lock().unwrap() = LoginState::NeedsLogin; *state.login.lock().unwrap() = LoginState::NeedsLogin;
state.bus.emit(crate::events::LiveEvent::Note { state.bus.emit(crate::events::LiveEvent::Note {
text: format!( text: format!(
"operator: /logout — credentials wiped at {}{wipe_note}", "operator: /logout — {wipe_summary} in {}{warn_suffix}",
dir.display() dir.display()
), ),
}); });
state.bus.emit_status("needs_login_idle"); state.bus.emit_status("needs_login_idle");
( (
axum::http::StatusCode::OK, axum::http::StatusCode::OK,
format!("ok: credentials wiped at {}{wipe_note}", dir.display()), format!("ok: {wipe_summary} in {}{warn_suffix}", dir.display()),
) )
.into_response() .into_response()
} }