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.
425 lines
18 KiB
Rust
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.
|