hyperhive/hive-agent/src/web_ui/auth.rs
atlas a6acf58b4f docs: stop writing repo-doc pointers as relative links rustdoc cannot resolve
Eleven doc comments pointed at `docs/` files as markdown links. Ten of
them render as broken hyperlinks in the docs rustdoc CI builds, and
nothing in the tree can tell.

Rustdoc renders a page at `target/doc/<crate>/<module…>/`, so a relative
link resolves against that directory and not against the source file it
was typed in. Every one of these except the single crate-root `//!` was
written for a reader resolving from the source tree, which is one `../`
short at module level and two short one directory deeper.

Two measurements on a throwaway crate, same build and same
`RUSTDOCFLAGS="-D rustdoc::all"`:

  * a bogus intra-doc link `[`no_such_item`]` is a hard error, so the
    `docs-rustdoc` check in nix/checks.nix works for its class;
  * a relative link to a nonexistent file in the same comment produces
    no diagnostic at all and lands in the html verbatim as
    href="../../../docs/does-not-exist.md".

So the class is invisible to the one gate whose stated purpose is to
stop a doc pointer dangling — and it is worse than the plain-text
failure that gate's comment describes, because a broken href still
looks clickable.

Fixing the depths was the other option and is rejected: the correct
depth is a function of how deeply the module is nested, so any module
move silently breaks it again, and no check we have would notice.

The link text was already the canonical pointer — `docs/x.md::Section`,
the same repo-root-relative form used everywhere else in the tree and
the form scripts/check-doc-refs.sh gates. Dropping the `[…](…)` wrapper
keeps every byte of information a reader uses and removes the only part
that was ever wrong.

Refs #3926.
2026-09-02 08:59:48 +02:00

177 lines
6.4 KiB
Rust

//! Login / logout flow handlers (`/login/*`, `/api/logout`).
use std::sync::Arc;
use axum::{
Form,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use crate::login::LoginState;
use crate::login_session::{LoginSession, drop_if_finished};
use super::{AppState, error_response};
pub(super) async fn post_login_start(State(state): State<AppState>) -> Response {
drop_if_finished(&state.session);
{
let guard = state.session.lock().unwrap();
if guard.is_some() {
return (axum::http::StatusCode::OK, "ok").into_response();
}
}
match LoginSession::start() {
Ok(session) => {
*state.session.lock().unwrap() = Some(Arc::new(session));
// Flip status from needs_login_idle → needs_login_in_progress
// so the web UI's badge + polling kick in (polling is still
// the right tool for the streaming session output during
// the login flow itself; events drop the poll for
// *everything else*).
state.bus.emit_status("needs_login_in_progress");
(axum::http::StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("login start failed: {e:#}"),
),
}
}
#[derive(Deserialize)]
pub(super) struct CodeForm {
code: String,
}
pub(super) async fn post_login_code(
State(state): State<AppState>,
Form(form): Form<CodeForm>,
) -> Response {
let session = state.session.lock().unwrap().clone();
let Some(session) = session else {
return error_response(StatusCode::CONFLICT, "no login session running");
};
if let Err(e) = session.submit_code(&form.code).await {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("submit code failed: {e:#}"),
);
}
(axum::http::StatusCode::OK, "ok").into_response()
}
pub(super) async fn post_login_cancel(State(state): State<AppState>) -> Response {
let session = state.session.lock().unwrap().take();
if let Some(session) = session {
session.close_stdin().await;
session.kill();
}
// Back to needs_login_idle (LoginState unchanged, session gone).
state.bus.emit_status("needs_login_idle");
(axum::http::StatusCode::OK, "ok").into_response()
}
/// 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 via `/login/code`.
/// Always returns 200 with a body describing what happened. See
/// `docs/web-ui/agent.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 {
// Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`).
let _ = super::sigint_claude().await;
// 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.
*state.login.lock().unwrap() = LoginState::NeedsLogin;
state.bus.emit(crate::events::LiveEvent::Note {
text: format!(
"operator: /logout — {wipe_summary} in {}{warn_suffix}",
dir.display()
),
});
state.bus.emit_status("needs_login_idle");
(
axum::http::StatusCode::OK,
format!("ok: {wipe_summary} in {}{warn_suffix}", dir.display()),
)
.into_response()
}
/// 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");
}
}