//! Login-state probe for the bind-mounted `~/.claude/` dir. The dir is //! provided by hive-c0re and persists across container destroy/recreate so //! OAuth tokens survive. //! //! "Has session" means the dir contains at least one of the credential files //! in [`CRED_FILE_NAMES`] — the same set `/logout` (`web_ui::auth`) deletes to //! force re-login. Keying both off one constant keeps boot detection and //! logout in agreement: logout deliberately preserves session-history files, //! so a "contains any regular file" check would wrongly report `Online` after //! a logout + container recreate and burn a turn 401-ing before it reroutes. use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; use crate::events::Bus; /// Returns the Claude credentials directory for this agent. Delegates /// to `paths::claude_dir`, which reads `$HOME/.claude`. The service /// runs as a non-root unix user named after the agent so `$HOME` /// resolves to `/home/` and the OAuth dir lives at /// `/home//.claude`. Overridable via `HYPERHIVE_CLAUDE_DIR`. #[must_use] pub fn default_dir() -> PathBuf { crate::paths::claude_dir() } /// The credential files that constitute a logged-in claude session inside /// [`default_dir`]. A session exists iff at least one is present; a login /// "refresh" is a change to one of them. `/logout` (`web_ui::auth`) deletes /// exactly these to force re-login while preserving session-history files — /// so boot detection ([`has_session`]) and logout agree by construction. /// Rationale + the previous wholesale-wipe shape we replaced live in /// [`docs/web-ui/agent.md::Per-agent endpoints`](../../docs/web-ui/agent.md) /// (the `/api/logout` bullet). pub const CRED_FILE_NAMES: &[&str] = &[".credentials.json", "mcp-needs-auth-cache.json"]; /// Is `entry` a regular file whose name is one of [`CRED_FILE_NAMES`]? fn is_cred_file(entry: &std::fs::DirEntry) -> bool { entry.file_type().is_ok_and(|t| t.is_file()) && entry .file_name() .to_str() .is_some_and(|n| CRED_FILE_NAMES.contains(&n)) } /// Returns `true` if `dir` exists and holds at least one credential file /// (see [`CRED_FILE_NAMES`]). Used at startup to decide whether to enter the /// turn loop (logged in) or stay in the partial-run "needs login" state. #[must_use] pub fn has_session(dir: &Path) -> bool { let Ok(entries) = std::fs::read_dir(dir) else { return false; }; entries.flatten().any(|e| is_cred_file(&e)) } /// Outcome of [`clear_session`]: which credential files were removed and any /// non-fatal per-file errors (e.g. permission denied). A file that was already /// absent is not reported — deletion is idempotent. #[derive(Debug, Default)] pub struct ClearedSession { pub wiped: Vec<&'static str>, pub warnings: Vec, } /// Delete the credential files (see [`CRED_FILE_NAMES`]) from `dir`, forcing a /// re-login on the next turn, while preserving the session-history files /// alongside them so `claude --continue` keeps working after a fresh login. /// Idempotent: an already-absent file is skipped, not reported. This is the /// write-side counterpart to [`has_session`]; `/logout` (`web_ui::auth`) drives /// it. pub async fn clear_session(dir: &Path) -> ClearedSession { let mut cleared = ClearedSession::default(); for name in CRED_FILE_NAMES { match tokio::fs::remove_file(dir.join(name)).await { Ok(()) => cleared.wiped.push(name), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => cleared.warnings.push(format!("{name}: {e}")), } } cleared } /// Login state the harness reports to its web UI. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LoginState { /// `~/.claude/` has credentials; turn loop is running. Online, /// `~/.claude/` is empty; harness is up, web UI is bound, turn loop is NOT /// running. Operator needs to complete login from the web UI. NeedsLogin, } impl LoginState { #[must_use] pub fn from_dir(dir: &Path) -> Self { if has_session(dir) { Self::Online } else { Self::NeedsLogin } } } /// Block until the bound `~/.claude/` dir contains a session that /// post-dates this call, polling on a `poll_ms` interval (min 2s). /// Flips `state` to `Online` when login lands; caller resumes its /// serve loop. Snapshots the dir at entry and only resumes when the /// snapshot advances (mtime OR file-count change), avoiding the /// infinite-401 loop a bare-existence check would produce when stale /// credentials are already on disk. Mtime-snapshot resumption rationale /// and `DirSnapshot` two-axis design: see /// [`docs/turn-loop.md::The loop`](../../docs/turn-loop.md). /// /// # Panics /// /// Panics if the internal login-state lock is poisoned. pub async fn wait_for_login( claude_dir: &Path, state: Arc>, bus: &Bus, poll_ms: u64, ) { tracing::warn!( claude_dir = %claude_dir.display(), "no claude session — staying in partial-run mode (web UI only)" ); // Announce `needs_login_idle` to the bus so the sentinel file // (`{state_dir}/hyperhive-needs-login`) gets written on every entry // path — cold-boot, 401-mid-turn, and `/api/logout`. The host's // `auth_failed_sentinel` reads that file to surface `needs_login` // on the dashboard. Idempotent — `emit_status` is a `write` on a // small empty file, so re-entering this function after a transient // operator action is a no-op for the on-disk state. bus.emit_status("needs_login_idle"); let snapshot = snapshot_dir(claude_dir); let probe = Duration::from_millis(poll_ms.max(2000)); loop { tokio::time::sleep(probe).await; if session_refreshed(snapshot, snapshot_dir(claude_dir)) { tracing::info!("claude session refreshed — entering turn loop"); *state.lock().unwrap() = LoginState::Online; bus.emit_status("online"); return; } } } /// Snapshot of the credential files (see [`CRED_FILE_NAMES`]) in the dir at a /// point in time: how many are present + newest `mtime` across them. The two /// axes are both load-bearing for `wait_for_login`'s refresh check /// (`session_refreshed`): mtime catches the common case (re-login overwrites /// an existing credentials file in-place), `file_count` catches the /// pathological case where `meta.modified()` errors on every file (exotic fs, /// NFS quirks) so the mtime axis stays `None` forever but a new credential /// file still triggers a resume. Defaults to `{0, None}` on `read_dir` failure /// (missing or unreadable dir) — `wait_for_login` then resumes when a /// credential file first appears. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct DirSnapshot { file_count: usize, newest_mtime: Option, } fn snapshot_dir(dir: &Path) -> DirSnapshot { let Ok(entries) = std::fs::read_dir(dir) else { return DirSnapshot::default(); }; let mut snap = DirSnapshot::default(); for entry in entries.flatten() { if !is_cred_file(&entry) { continue; } snap.file_count += 1; let Ok(meta) = entry.metadata() else { continue }; let Ok(mtime) = meta.modified() else { continue }; if snap.newest_mtime.is_none_or(|cur| mtime > cur) { snap.newest_mtime = Some(mtime); } } snap } /// Has the credentials dir been written since `prev`? Used as the /// exit condition for `wait_for_login`: /// /// - `file_count` changed → something was added or removed, treat as /// refresh (covers the "all files have unreadable mtime" edge case). /// - `newest_mtime` advanced → existing file was rewritten in place /// (the common claude re-login path). /// - prev had no mtime (empty or all-unreadable) and now has one → /// first useful signal we've seen, treat as refresh. fn session_refreshed(prev: DirSnapshot, now: DirSnapshot) -> bool { if now.file_count != prev.file_count { return true; } match (prev.newest_mtime, now.newest_mtime) { (None, Some(_)) => true, (Some(p), Some(n)) => n > p, _ => false, } } #[cfg(test)] mod tests { use std::fs; use std::time::{Duration, SystemTime}; use super::{DirSnapshot, has_session, session_refreshed, snapshot_dir}; #[test] fn has_session_only_counts_credential_files() { let dir = tempfile::tempdir().unwrap(); // Session-history files (what `/logout` preserves) must NOT read as a // logged-in session — this is the logout+recreate 401 bug. fs::write(dir.path().join("history.jsonl"), b"{}").unwrap(); fs::write(dir.path().join("some-project-uuid.json"), b"{}").unwrap(); assert!(!has_session(dir.path())); // A real credential file does. fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); assert!(has_session(dir.path())); } #[test] fn snapshot_dir_ignores_non_credential_files() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("history.jsonl"), b"{}").unwrap(); let snap = snapshot_dir(dir.path()); assert_eq!(snap.file_count, 0, "history files must not count as session"); } #[test] fn snapshot_dir_empty_dir_is_default() { let dir = tempfile::tempdir().unwrap(); let snap = snapshot_dir(dir.path()); assert_eq!(snap.file_count, 0); assert!(snap.newest_mtime.is_none()); } #[test] fn snapshot_dir_missing_dir_is_default() { // Defensive: a nonexistent dir must NOT panic. Bind mounts that // disappear mid-poll (host purge during operator intervention) // would otherwise crash the harness. let missing = tempfile::tempdir() .unwrap() .path() .join("never-created-subdir"); let snap = snapshot_dir(&missing); assert_eq!(snap, DirSnapshot::default()); } #[test] fn snapshot_dir_picks_latest_mtime_and_counts_files() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); // Sleep so the second file's mtime is strictly greater than // the first on filesystems with low timestamp resolution. std::thread::sleep(Duration::from_millis(20)); let newer_path = dir.path().join("mcp-needs-auth-cache.json"); fs::write(&newer_path, b"{}").unwrap(); let snap = snapshot_dir(dir.path()); assert_eq!(snap.file_count, 2); let newer_meta = fs::metadata(&newer_path).unwrap().modified().unwrap(); assert_eq!(snap.newest_mtime, Some(newer_meta)); } #[test] fn session_refreshed_first_login_flips_on_cred_file() { // Empty-dir snapshot → a credential file appearing means a fresh // login landed. First-time login semantics. let dir = tempfile::tempdir().unwrap(); let snapshot = snapshot_dir(dir.path()); assert!(!session_refreshed(snapshot, snapshot_dir(dir.path()))); fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); assert!(session_refreshed(snapshot, snapshot_dir(dir.path()))); } #[test] fn session_refreshed_stale_creds_dont_flip_immediately() { // Stale credentials.json already exists at entry; wait_for_login // must NOT immediately return — it would loop straight into // another 401-failing turn. let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); let snapshot = snapshot_dir(dir.path()); assert_eq!(snapshot.file_count, 1); // No change to the file → loop must NOT exit. assert!(!session_refreshed(snapshot, snapshot_dir(dir.path()))); } #[test] fn session_refreshed_after_creds_rewrite_flips() { // After the stale-creds snapshot, the operator's `/login/code` // flow lands a refreshed credentials file — its mtime bumps // strictly past the snapshot and wait_for_login resumes. let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); let snapshot = snapshot_dir(dir.path()); std::thread::sleep(Duration::from_millis(20)); fs::write(dir.path().join(".credentials.json"), b"{\"v\":2}").unwrap(); assert!(session_refreshed(snapshot, snapshot_dir(dir.path()))); } #[test] fn session_refreshed_snapshot_with_future_mtime_doesnt_flip() { // Defensive: a snapshot set to a future timestamp (e.g. clock // skew between snapshot and probe) must keep waiting until a // file's mtime actually exceeds it, not return on first poll. let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); let snapshot = DirSnapshot { file_count: 1, newest_mtime: Some(SystemTime::now() + Duration::from_hours(1)), }; assert!(!session_refreshed(snapshot, snapshot_dir(dir.path()))); } #[test] fn session_refreshed_count_change_flips_when_mtime_unreadable() { // Defensive: if all files have unreadable `meta.modified()` // (exotic fs / NFS), newest_mtime stays `None` forever — but // file_count axis still catches new files appearing. Simulated // here by forging a snapshot with file_count=1 + no mtime, then // writing a second file. let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); let forged = DirSnapshot { file_count: 1, newest_mtime: None, }; fs::write(dir.path().join("mcp-needs-auth-cache.json"), b"{}").unwrap(); // Real snapshot has file_count=2, so refresh fires even // though the mtime axis would be inconclusive. assert!(session_refreshed(forged, snapshot_dir(dir.path()))); } }