Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-agent/src/login.rs
iris a2c9e9a8d4 hive-agent: a logged-in agent polls the swarm login, and logs out when it is deleted
wait_for_login read the store leaf only while the agent waited for a login.
A logged-in agent now reads it every 5 minutes, and a paused one once before
it resumes, so a login made or deleted in the swarm UI reaches an agent that
never failed. A newer version is installed; a store that answers nothing
stored after a version was installed means the stored login was deleted, and
the agent is logged out as /api/logout does it, with the recorded version
forgotten so the next login installs. A read that fails changes nothing.
2026-11-09 23:03:08 +01:00

425 lines
18 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::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/<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/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<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 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<Mutex<LoginState>>,
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<claude_credentials::Pickup<claude_credentials::Store>> {
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<String> {
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<String>,
) -> 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.