338 lines
14 KiB
Rust
338 lines
14 KiB
Rust
//! 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/<agent>` and the OAuth dir lives at
|
|
/// `/home/<agent>/.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<String>,
|
|
}
|
|
|
|
/// 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<Mutex<LoginState>>,
|
|
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<std::time::SystemTime>,
|
|
}
|
|
|
|
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())));
|
|
}
|
|
}
|