//! The claude credentials the swarm's login minter stored for this agent, at //! `swarm/agents//claude-credentials`, read under the agent's own //! certificate while it waits for a login ([`crate::login::wait_for_login`]), //! and every few minutes while it runs ([`crate::login_watch`]). //! //! ⚠️ **Installed only when the store's write is newer than the last one //! installed here**, never because the bytes differ. `claude` rotates the //! refresh token in `~/.claude/.credentials.json`, so after its first refresh //! the stored copy is stale; putting it back would end the rotated session. //! The last installed version is kept in [`VERSION_FILE`] under the harness //! dir. A credentials file `wait_for_login` sees land resumes the agent as a //! web-UI login does. //! //! A store that answers "nothing stored" after a version was installed here //! means the operator deleted the stored login ([`Outcome::Deleted`]); a store //! that does not answer is an error and means nothing. use std::future::Future; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::Context as _; use swarm_secret_client::{ SecretStore, claude_login::{Credentials, credentials_path}, client::DEFAULT_CERT_MOUNT, }; /// The file under the harness dir holding the last installed version. pub const VERSION_FILE: &str = "claude-credentials-version"; /// How often a waiting agent asks the store. pub const POLL_INTERVAL: Duration = Duration::from_secs(30); /// How long one read of the store may take. const STORE_READ_TIMEOUT: Duration = Duration::from_secs(10); /// The file `claude` reads its session from. const CREDENTIALS_FILE: &str = ".credentials.json"; /// A stored version's identity: its KV number, and when the store wrote it, in /// ms since the epoch. The number restarts when the operator deletes the stored /// login, so the time is what orders a new login after the one before the /// delete. A record from before the time was kept has none. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Stamp { pub version: u64, pub written_ms: Option, } impl Stamp { /// What the version file holds: `N`, or `N T` once the time is known. fn encode(self) -> String { match self.written_ms { Some(ms) => format!("{} {ms}", self.version), None => self.version.to_string(), } } fn parse(s: &str) -> Option { let mut parts = s.split_whitespace(); let version = parts.next()?.parse().ok()?; let written_ms = match parts.next() { Some(ms) => Some(ms.parse().ok()?), None => None, }; parts.next().is_none().then_some(Self { version, written_ms, }) } /// From the store's version: its number and RFC 3339 time. A time that /// does not parse is no time. fn of(version: &swarm_secret_client::client::Version) -> Self { Self { version: version.number, written_ms: chrono::DateTime::parse_from_rfc3339(&version.created_time) .ok() .map(|t| t.timestamp_millis()), } } } /// What a stored version is to what is installed. #[derive(Debug, PartialEq, Eq)] pub enum Verdict { /// Replace the installed login with it. Install, /// Leave the installed login alone. Leave, /// The same login the record names, which has no time yet: leave the login /// and record the time. Upgrade, } /// Whether a stored version replaces what is installed. Ordered by the time /// the store wrote it when both have one, so a login made after a delete /// installs though its number restarted; by number otherwise, where only a /// larger one is newer. A record without a time next to the same number is /// ambiguous (the same login, or the first after a delete), so it is left and /// gets its time. #[must_use] pub fn judge(stored: Stamp, installed: Option) -> Verdict { let Some(last) = installed else { return Verdict::Install; }; match (stored.written_ms, last.written_ms) { (Some(s), Some(l)) if s > l => Verdict::Install, (Some(_), None) if stored.version == last.version => Verdict::Upgrade, (Some(_), None) => Verdict::Install, (None, _) if stored.version > last.version => Verdict::Install, _ => Verdict::Leave, } } /// What one [`Pickup::check`] did. #[derive(Debug, PartialEq, Eq)] pub enum Outcome { /// Nothing is stored for this agent, and no version was ever installed /// here: no swarm login has reached this agent. NothingStored, /// The store answered that nothing is stored, though version `installed` /// was installed here: the operator deleted the stored login. Deleted { installed: u64 }, /// The stored version is not newer than the installed one. NotNewer { stored: u64, installed: u64 }, /// The stored version is installed now. Installed { version: u64 }, } /// Where [`Pickup`] reads from. A trait so the guard can be exercised without /// a store. pub trait Source: Send + Sync { /// The stored credentials and their version, or `None` when nothing is /// stored. fn fetch(&self) -> impl Future>> + Send; } /// The agent's own leaf, read under its own certificate. pub struct Store { settings: swarm_secret_client::client::Settings, role: String, path: String, } impl Source for Store { async fn fetch(&self) -> anyhow::Result> { let read = async { let store = SecretStore::connect(&self.settings, &self.role, DEFAULT_CERT_MOUNT) .await .context("logging in to the swarm secret store as this agent")?; let read = store .read_versioned(&self.path) .await .with_context(|| format!("reading {}", self.path))?; Ok::<_, anyhow::Error>(read.map(|(value, version)| (value, Stamp::of(&version)))) }; tokio::time::timeout(STORE_READ_TIMEOUT, read) .await .with_context(|| format!("no answer from the store within {STORE_READ_TIMEOUT:?}"))? } } impl Store { /// This agent's leaf, from the environment `crate::swarm_queue` reads. /// /// # Errors /// A store coordinate is missing. pub fn from_env() -> anyhow::Result { let (settings, role, agent) = crate::swarm_queue::agent_store(|k| std::env::var(k).ok())?; Ok(Self { settings, role, path: credentials_path(&agent)?, }) } } /// Installs the stored credentials into `claude_dir` when they are newer. pub struct Pickup { source: S, claude_dir: PathBuf, version_file: PathBuf, } impl Pickup { pub fn new(source: S, claude_dir: PathBuf, version_file: PathBuf) -> Self { Self { source, claude_dir, version_file, } } /// Read the store once and install what it holds if it is newer. /// /// # Errors /// The store read failed, or writing the credentials or the version did. pub async fn check(&self) -> anyhow::Result { let Some((credentials, stored)) = self.source.fetch().await? else { return Ok(match read_stamp(&self.version_file)? { Some(installed) => Outcome::Deleted { installed: installed.version, }, None => Outcome::NothingStored, }); }; let installed = read_stamp(&self.version_file)?; match judge(stored, installed) { Verdict::Install => {} verdict => { if verdict == Verdict::Upgrade { write_private(&self.version_file, stored.encode().as_bytes()) .context("recording when the installed credentials were stored")?; } return Ok(Outcome::NotNewer { stored: stored.version, installed: installed.map_or(0, |s| s.version), }); } } write_private( &self.claude_dir.join(CREDENTIALS_FILE), credentials.value.as_bytes(), ) .context("installing the stored claude credentials")?; write_private(&self.version_file, stored.encode().as_bytes()) .context("recording the installed credentials version")?; Ok(Outcome::Installed { version: stored.version, }) } /// Forget the installed version, once the deletion it showed has been /// acted on, so the next login installs whatever version it gets. /// /// # Errors /// Removing the version file failed for a reason other than its absence. pub fn forget_installed(&self) -> anyhow::Result<()> { match std::fs::remove_file(&self.version_file) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(e).with_context(|| format!("removing {}", self.version_file.display())), } } } /// The version recorded in `file`, or `None` when there is no file. fn read_stamp(file: &Path) -> anyhow::Result> { match std::fs::read_to_string(file) { Ok(s) => Stamp::parse(&s) .map(Some) .with_context(|| format!("{} does not hold a version", file.display())), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e).with_context(|| format!("reading {}", file.display())), } } /// Replace `path` with `bytes`, mode 0600, through a sibling temp file so a /// reader never sees half of it. fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { use std::io::Write as _; use std::os::unix::fs::OpenOptionsExt as _; let mut tmp = path.as_os_str().to_owned(); tmp.push(".tmp"); let tmp = PathBuf::from(tmp); let mut f = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(&tmp)?; f.write_all(bytes)?; f.sync_all()?; std::fs::rename(&tmp, path) } #[cfg(test)] mod tests { use std::sync::Mutex; use super::*; struct Fake(Mutex>); impl Source for Fake { async fn fetch(&self) -> anyhow::Result> { Ok(self .0 .lock() .expect("lock") .clone() .map(|(value, stamp)| (Credentials { value }, stamp))) } } struct Dir(PathBuf); impl Drop for Dir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn dir(name: &str) -> Dir { let d = std::env::temp_dir().join(format!( "hive-agent-claude-credentials-{name}-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(d.join("claude")).expect("temp dir"); Dir(d) } fn stamp(version: u64, written_ms: Option) -> Stamp { Stamp { version, written_ms, } } /// A pickup over a stored login that carries no write time. fn pickup(d: &Dir, stored: Option<(&str, u64)>) -> Pickup { pickup_stamped(d, stored.map(|(v, n)| (v, stamp(n, None)))) } fn pickup_stamped(d: &Dir, stored: Option<(&str, Stamp)>) -> Pickup { Pickup::new( Fake(Mutex::new(stored.map(|(v, s)| (v.to_owned(), s)))), d.0.join("claude"), d.0.join(VERSION_FILE), ) } fn set(p: &Pickup, stored: Option<(&str, Stamp)>) { *p.source.0.lock().expect("lock") = stored.map(|(v, s)| (v.to_owned(), s)); } fn installed(d: &Dir) -> Option { std::fs::read_to_string(d.0.join("claude").join(CREDENTIALS_FILE)).ok() } fn recorded(d: &Dir) -> Option { read_stamp(&d.0.join(VERSION_FILE)).expect("read") } #[test] fn without_times_only_a_larger_number_replaces_the_installed_one() { assert_eq!(judge(stamp(1, None), None), Verdict::Install); assert_eq!( judge(stamp(3, None), Some(stamp(2, None))), Verdict::Install ); assert_eq!(judge(stamp(2, None), Some(stamp(2, None))), Verdict::Leave); assert_eq!(judge(stamp(1, None), Some(stamp(2, None))), Verdict::Leave); } #[test] fn with_times_the_later_write_wins_whatever_its_number() { let before = Some(stamp(1, Some(1_000))); assert_eq!( judge(stamp(1, Some(2_000)), before), Verdict::Install, "a login after a delete restarts at 1 and is still newer" ); assert_eq!(judge(stamp(1, Some(1_000)), before), Verdict::Leave); assert_eq!( judge(stamp(5, Some(900)), before), Verdict::Leave, "an older write is not newer for its larger number" ); } #[test] fn a_record_without_a_time_gets_one_when_the_number_matches() { let legacy = Some(stamp(1, None)); assert_eq!(judge(stamp(1, Some(5)), legacy), Verdict::Upgrade); assert_eq!(judge(stamp(2, Some(5)), legacy), Verdict::Install); assert_eq!( judge(stamp(1, Some(5)), Some(stamp(3, None))), Verdict::Install, "a smaller number than recorded can only be a restarted counter" ); } #[test] fn the_version_file_round_trips_and_reads_the_old_form() { for s in [stamp(4, None), stamp(1, Some(1_731_000_000_123))] { assert_eq!(Stamp::parse(&s.encode()), Some(s)); } assert_eq!(Stamp::parse("7\n"), Some(stamp(7, None))); assert_eq!(Stamp::parse("7 x"), None); assert_eq!(Stamp::parse("7 8 9"), None); assert_eq!(Stamp::parse(""), None); } #[test] fn the_stores_rfc3339_time_is_read_to_ms() { let v = swarm_secret_client::client::Version { number: 3, created_time: "2026-11-09T20:00:00.123456789Z".to_owned(), }; assert_eq!( Stamp::of(&v), stamp(3, Some(1_794_254_400_123)), "nanoseconds are cut to ms" ); let v = swarm_secret_client::client::Version { number: 3, created_time: "not a time".to_owned(), }; assert_eq!(Stamp::of(&v), stamp(3, None)); } #[tokio::test] async fn the_first_stored_version_is_installed_private_and_recorded() { use std::os::unix::fs::PermissionsExt as _; let d = dir("first"); let p = pickup(&d, Some(("{\"v\":1}", 1))); assert_eq!( p.check().await.expect("check"), Outcome::Installed { version: 1 } ); assert_eq!(installed(&d).as_deref(), Some("{\"v\":1}")); let mode = std::fs::metadata(d.0.join("claude").join(CREDENTIALS_FILE)) .expect("installed") .permissions() .mode(); assert_eq!(mode & 0o777, 0o600); assert_eq!(recorded(&d), Some(stamp(1, None))); } #[tokio::test] async fn an_equal_or_older_version_leaves_the_rotated_session_alone() { let d = dir("stale"); let p = pickup_stamped(&d, Some(("{\"v\":2}", stamp(2, Some(2_000))))); p.check().await.expect("first install"); // claude refreshes the session in place. std::fs::write(d.0.join("claude").join(CREDENTIALS_FILE), "rotated").expect("refresh"); assert_eq!( p.check().await.expect("check"), Outcome::NotNewer { stored: 2, installed: 2 } ); set(&p, Some(("{\"v\":1}", stamp(1, Some(1_000))))); assert_eq!( p.check().await.expect("check"), Outcome::NotNewer { stored: 1, installed: 2 } ); assert_eq!(installed(&d).as_deref(), Some("rotated")); } #[tokio::test] async fn a_relogin_is_installed_over_the_rotated_session() { let d = dir("relogin"); let p = pickup_stamped(&d, Some(("{\"v\":1}", stamp(1, Some(1_000))))); p.check().await.expect("first install"); std::fs::write(d.0.join("claude").join(CREDENTIALS_FILE), "rotated").expect("refresh"); set(&p, Some(("{\"v\":2}", stamp(2, Some(2_000))))); assert_eq!( p.check().await.expect("check"), Outcome::Installed { version: 2 } ); assert_eq!(installed(&d).as_deref(), Some("{\"v\":2}")); } #[tokio::test] async fn nothing_stored_installs_nothing() { let d = dir("empty"); let p = pickup(&d, None); assert_eq!(p.check().await.expect("check"), Outcome::NothingStored); assert_eq!(installed(&d), None); assert_eq!(recorded(&d), None); } #[tokio::test] async fn a_login_deleted_after_an_install_is_reported_and_forgotten_on_request() { let d = dir("deleted"); let p = pickup_stamped(&d, Some(("{\"v\":1}", stamp(1, Some(1_000))))); p.check().await.expect("install"); set(&p, None); assert_eq!( p.check().await.expect("check"), Outcome::Deleted { installed: 1 } ); // Reporting it changes nothing: the caller acts, then forgets. assert_eq!( p.check().await.expect("check again"), Outcome::Deleted { installed: 1 } ); p.forget_installed().expect("forget"); p.forget_installed().expect("forgetting twice is fine"); assert_eq!(p.check().await.expect("check"), Outcome::NothingStored); set(&p, Some(("{\"v\":1}", stamp(1, Some(3_000))))); assert_eq!( p.check().await.expect("a new login"), Outcome::Installed { version: 1 }, "the counter restarts after a delete, and its first login still installs" ); } #[tokio::test] async fn a_login_made_between_two_polls_after_a_delete_still_installs() { // The agent installed version 1. The operator deletes it and logs in // again before the next poll, so the store never answers "nothing // stored": it answers version 1 again, written later. let d = dir("between"); let p = pickup_stamped(&d, Some(("{\"old\":1}", stamp(1, Some(1_000))))); p.check().await.expect("install"); std::fs::write(d.0.join("claude").join(CREDENTIALS_FILE), "rotated").expect("refresh"); set(&p, Some(("{\"new\":1}", stamp(1, Some(9_000))))); assert_eq!( p.check().await.expect("check"), Outcome::Installed { version: 1 } ); assert_eq!(installed(&d).as_deref(), Some("{\"new\":1}")); assert_eq!(recorded(&d), Some(stamp(1, Some(9_000)))); } #[tokio::test] async fn a_record_from_before_the_time_was_kept_gets_its_time_and_keeps_the_session() { let d = dir("upgrade"); std::fs::write(d.0.join(VERSION_FILE), "1").expect("an old record"); std::fs::write(d.0.join("claude").join(CREDENTIALS_FILE), "rotated").expect("session"); let p = pickup_stamped(&d, Some(("{\"v\":1}", stamp(1, Some(1_000))))); assert_eq!( p.check().await.expect("check"), Outcome::NotNewer { stored: 1, installed: 1 } ); assert_eq!(installed(&d).as_deref(), Some("rotated")); assert_eq!(recorded(&d), Some(stamp(1, Some(1_000)))); } struct Failing; impl Source for Failing { async fn fetch(&self) -> anyhow::Result> { anyhow::bail!("the store did not answer") } } #[tokio::test] async fn a_failed_read_is_an_error_never_a_deletion() { let d = dir("failing"); std::fs::write(d.0.join(VERSION_FILE), "4").expect("an installed version"); let p = Pickup::new(Failing, d.0.join("claude"), d.0.join(VERSION_FILE)); assert!(p.check().await.is_err()); assert_eq!(recorded(&d), Some(stamp(4, None))); } }