//! 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::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; use crate::events::Bus; use crate::{claude_credentials, paths}; /// 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/2-handbook/agents/web-page.md::Per-agent endpoints` /// (the `/api/logout` bullet). /// /// `.credentials.json` is the actual OAuth session; `mcp-needs-auth-cache.json` /// is claude-code's MCP-auth cache and a weaker signal. Both are kept in the /// set only because `/logout` deletes both, so the "either present ⇒ logged /// in" check can never disagree with a logout. (If a future edit ever removes /// `.credentials.json` without the cache — a state `/logout` doesn't produce — /// keying purely on `.credentials.json` would be the stronger boot signal.) 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 the session resumed by title survives 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. The operator logs in from the swarm UI; the credentials then /// arrive from the swarm secret store. NeedsLogin, } impl LoginState { #[must_use] pub fn from_dir(dir: &Path) -> Self { // API-key backends (OpenRouter etc.) never populate `~/.claude/` — // there is no OAuth flow to complete, `claude` reads // `ANTHROPIC_BASE_URL`/`ANTHROPIC_API_KEY` from the environment // instead. Checked first: an api-key agent with a stale or absent // credentials dir must report `Online`, not park the turn loop // waiting for a login that will never happen. if using_api_key() || has_session(dir) { Self::Online } else { Self::NeedsLogin } } } /// Whether this agent is configured to authenticate to its backend with an /// API key rather than a Claude OAuth session — set by /// `services.hyperhive.agent.useApiKey` via the `HIVE_USE_API_KEY` env var /// (`nix/agent-modules/agent-service.nix`). Read fresh on every call rather /// than cached: it's an env var, not a value worth a `OnceLock` for. #[must_use] pub fn using_api_key() -> bool { std::env::var("HIVE_USE_API_KEY").is_ok_and(|v| v == "1") } /// Baseline for [`has_fresh_credentials`] when the caller has no specific /// prior-failure instant to compare against (the cold-boot call site, where /// `has_session` already established no credential file exists yet — so /// there's nothing that could be mistaken for stale). Any real file's mtime /// postdates the Unix epoch, so this baseline behaves as "resume the first /// time a credential file with a readable mtime shows up." pub const NO_PRIOR_FAILURE: SystemTime = SystemTime::UNIX_EPOCH; /// Block until the bound `~/.claude/` dir holds a credential file whose /// mtime postdates `since`, polling on a `poll_ms` interval (min 2s). Flips /// `state` to `Online` when login lands; caller resumes its serve loop. /// /// `since` is normally the instant the caller detected the failure that /// parked it here (an API 401) — see [`NO_PRIOR_FAILURE`] for the cold-boot /// case, which has no such instant and doesn't need one. Comparing against /// a fixed instant rather than an entry-time directory snapshot is what /// avoids the infinite-401 loop a bare-existence check would produce when /// stale credentials are already on disk: a stale file's mtime predates /// `since` (keep waiting), a fresh login's mtime postdates it (resume) — /// including a login that lands *before* this call even starts polling, /// which an entry-snapshot comparison could miss entirely. Full rationale: /// `docs/4-implementation/turn-loop/::The loop`. /// /// # 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, since: SystemTime, ) { 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 // `hyperhive-harness.json` records it on every entry path — // cold-boot, 401-mid-turn, and `/api/logout`. The host reads that // file to surface `needs_login` on the dashboard. Idempotent — // re-entering this function after a transient operator action // rewrites the same on-disk state. bus.emit_status("needs_login_idle"); // Baseline for the unreadable-mtime fallback in `has_fresh_credentials` // — captured once, here, not recomputed per poll. See that fn's doc // comment for why a *standing* unreadable-mtime file must not // re-trigger the fallback on every iteration. let baseline_unreadable = unreadable_mtime_names(claude_dir); let probe = Duration::from_millis(poll_ms.max(2000)); let pickup = swarm_pickup(claude_dir); let mut next_pickup = tokio::time::Instant::now(); loop { if let Some(pickup) = &pickup && tokio::time::Instant::now() >= next_pickup { next_pickup = tokio::time::Instant::now() + claude_credentials::POLL_INTERVAL; match pickup.check().await { Ok(claude_credentials::Outcome::Installed { version }) => tracing::info!( version, "installed the claude credentials the swarm login stored" ), Ok(claude_credentials::Outcome::Deleted { installed }) => { // Nothing to log out of, but the recorded version would // keep a restarted counter's first login from installing. tracing::info!(installed, "the swarm's stored claude login was deleted"); if let Err(e) = pickup.forget_installed() { tracing::warn!(error = %format!("{e:#}"), "forgetting the installed login version failed"); } } Ok(outcome) => tracing::debug!(?outcome, "no newer swarm claude credentials"), Err(e) => tracing::warn!( error = %format!("{e:#}"), "checking the swarm store for claude credentials failed" ), } } tokio::time::sleep(probe).await; // A login that already landed by the time this first poll runs // (the race this baseline design closes) is caught here on the // very first iteration — nothing about the check depends on how // long we've been polling. if has_fresh_credentials(claude_dir, since, &baseline_unreadable) { tracing::info!("claude session refreshed — entering turn loop"); *state.lock().unwrap() = LoginState::Online; bus.emit_status("online"); return; } } } /// The swarm-login pickup for `claude_dir`, or `None` when this process has /// no store to read from. pub(crate) fn swarm_pickup( claude_dir: &Path, ) -> Option> { match claude_credentials::Store::from_env() { Ok(store) => Some(claude_credentials::Pickup::new( store, claude_dir.to_owned(), paths::harness_dir().join(claude_credentials::VERSION_FILE), )), Err(e) => { tracing::warn!( error = %format!("{e:#}"), "no swarm store coordinates, so a swarm login cannot reach this agent" ); None } } } /// Names of credential files (see [`CRED_FILE_NAMES`]) currently present in /// `dir` whose mtime is *not* readable (`metadata()`/`modified()` errors — /// exotic fs, NFS quirks). Used once, at [`wait_for_login`]'s entry, as the /// baseline its unreadable-mtime fallback compares against. fn unreadable_mtime_names(dir: &Path) -> HashSet { let Ok(entries) = std::fs::read_dir(dir) else { return HashSet::new(); }; entries .flatten() .filter(is_cred_file) .filter(|e| e.metadata().and_then(|m| m.modified()).is_err()) .filter_map(|e| e.file_name().to_str().map(str::to_owned)) .collect() } /// True if `dir` holds a credential file (see [`CRED_FILE_NAMES`]) whose /// mtime is strictly newer than `since`, or — fallback — if a credential /// file's mtime is unreadable at all (exotic fs, NFS quirks) *and its name /// isn't in `baseline_unreadable`* — i.e. it's a new occurrence since /// [`wait_for_login`] started polling, not a standing condition. /// /// The `baseline_unreadable` guard matters: without it, a *stale* file /// whose mtime happens to be permanently unreadable would satisfy the /// fallback on every single poll (it's always "present with no readable /// mtime"), resuming instantly and reintroducing the exact infinite-401 /// loop this whole mechanism exists to prevent. Requiring the name to be /// new mirrors what an earlier entry-snapshot-diffing design covered with /// a raw file-count comparison — "something *changed*", not "something /// *is present*". fn has_fresh_credentials( dir: &Path, since: SystemTime, baseline_unreadable: &HashSet, ) -> bool { let Ok(entries) = std::fs::read_dir(dir) else { return false; }; for entry in entries.flatten() { if !is_cred_file(&entry) { continue; } let Ok(meta) = entry.metadata() else { continue }; match meta.modified() { Ok(mtime) if mtime > since => return true, Ok(_) => {} Err(_) => { let is_new = entry .file_name() .to_str() .is_some_and(|name| !baseline_unreadable.contains(name)); if is_new { return true; } } } } false } #[cfg(test)] mod tests { use std::collections::HashSet; use std::fs; use std::time::{Duration, SystemTime}; use super::{NO_PRIOR_FAILURE, has_fresh_credentials, has_session}; /// Test-only shorthand: most cases don't exercise the unreadable-mtime /// fallback, so they don't care about its baseline. fn fresh(dir: &std::path::Path, since: SystemTime) -> bool { has_fresh_credentials(dir, since, &HashSet::new()) } #[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 has_fresh_credentials_ignores_non_credential_files() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("history.jsonl"), b"{}").unwrap(); assert!( !fresh(dir.path(), NO_PRIOR_FAILURE), "history files must not count as a session" ); } #[test] fn has_fresh_credentials_missing_dir_is_false() { // 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"); assert!(!fresh(&missing, NO_PRIOR_FAILURE)); } #[test] fn has_fresh_credentials_first_login_resumes() { // Cold-boot case: no prior failure instant, empty dir. A credential // file appearing is a fresh login by construction — its mtime // postdates the epoch baseline. let dir = tempfile::tempdir().unwrap(); assert!(!fresh(dir.path(), NO_PRIOR_FAILURE)); fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); assert!(fresh(dir.path(), NO_PRIOR_FAILURE)); } #[test] fn has_fresh_credentials_stale_creds_dont_resume() { // Stale credentials.json already predates the failure baseline; // wait_for_login must NOT resume — it would loop straight into // another 401-failing turn. let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); let since = SystemTime::now() + Duration::from_secs(1); assert!(!fresh(dir.path(), since)); } #[test] fn has_fresh_credentials_login_already_landed_before_baseline_check_resumes() { // The exact race this design closes: a fresh login's mtime // postdates `since` even though it was written before this // function is ever called — no entry-time snapshot to miss it // against. let dir = tempfile::tempdir().unwrap(); let since = SystemTime::now(); std::thread::sleep(Duration::from_millis(20)); fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); assert!(fresh(dir.path(), since)); } #[test] fn has_fresh_credentials_rewrite_after_failure_resumes() { // A fresh login from the swarm rewrites the stale file in // place; its mtime bumps strictly past the failure baseline. let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); let since = SystemTime::now(); std::thread::sleep(Duration::from_millis(20)); fs::write(dir.path().join(".credentials.json"), b"{\"v\":2}").unwrap(); assert!(fresh(dir.path(), since)); } #[test] fn has_fresh_credentials_future_baseline_doesnt_resume() { // Defensive: a baseline set to a future timestamp (e.g. clock // skew) must keep waiting until a file's mtime actually exceeds // it, not resume on the strength of an existing file alone. let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); let since = SystemTime::now() + Duration::from_hours(1); assert!(!fresh(dir.path(), since)); } } // The unreadable-mtime fallback and its `baseline_unreadable` guard (see // `has_fresh_credentials`'s doc comment) aren't covered by a unit test here // — forging an unreadable `metadata()`/`modified()` call portably needs // platform-specific setup (a permission-denied file, an exotic fs) this // test module doesn't have infrastructure for, and a normal file's mtime is // always readable in a test tempdir, so there's no way to reach the `Err` // arm (or meaningfully exercise `baseline_unreadable`, which only matters // inside it) without faking that. The branch exists for a real production // failure mode (NFS quirks), not a hypothetical — a review pass on this // module caught that the original version of this fallback ignored `since` // entirely and could re-trigger the infinite-401 loop this file exists to // prevent; the current shape (only a *new* unreadable-mtime name resumes) // is reasoned about in the doc comment above since it can't be asserted // here.