//! 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`]). //! //! ⚠️ **Installed only when the store's version 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. 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"; /// Whether a stored `version` replaces what is installed: only a version /// newer than the last one installed, or any when none has been. #[must_use] pub fn is_newer(stored: u64, installed: Option) -> bool { installed.is_none_or(|last| stored > last) } /// What one [`Pickup::check`] did. #[derive(Debug, PartialEq, Eq)] pub enum Outcome { /// Nothing is stored for this agent. NothingStored, /// 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")?; store .read_versioned(&self.path) .await .with_context(|| format!("reading {}", self.path)) }; 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(Outcome::NothingStored); }; let installed = read_version(&self.version_file)?; if !is_newer(stored, installed) { return Ok(Outcome::NotNewer { stored, installed: installed.unwrap_or_default(), }); } write_private( &self.claude_dir.join(CREDENTIALS_FILE), credentials.value.as_bytes(), ) .context("installing the stored claude credentials")?; write_private(&self.version_file, stored.to_string().as_bytes()) .context("recording the installed credentials version")?; Ok(Outcome::Installed { version: stored }) } } /// The version recorded in `file`, or `None` when there is no file. fn read_version(file: &Path) -> anyhow::Result> { match std::fs::read_to_string(file) { Ok(s) => s .trim() .parse() .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, v)| (Credentials { value }, v))) } } 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 pickup(d: &Dir, stored: Option<(&str, u64)>) -> Pickup { Pickup::new( Fake(Mutex::new(stored.map(|(v, n)| (v.to_owned(), n)))), d.0.join("claude"), d.0.join(VERSION_FILE), ) } fn installed(d: &Dir) -> Option { std::fs::read_to_string(d.0.join("claude").join(CREDENTIALS_FILE)).ok() } #[test] fn only_a_newer_version_replaces_the_installed_one() { assert!(is_newer(1, None)); assert!(is_newer(3, Some(2))); assert!( !is_newer(2, Some(2)), "an equal version was installed again" ); assert!(!is_newer(1, Some(2)), "an older version was installed"); } #[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!( read_version(&d.0.join(VERSION_FILE)).expect("read"), Some(1) ); } #[tokio::test] async fn an_equal_or_older_version_leaves_the_rotated_session_alone() { let d = dir("stale"); let p = pickup(&d, Some(("{\"v\":2}", 2))); 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 } ); *p.source.0.lock().expect("lock") = Some(("{\"v\":1}".to_owned(), 1)); 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(&d, Some(("{\"v\":1}", 1))); p.check().await.expect("first install"); std::fs::write(d.0.join("claude").join(CREDENTIALS_FILE), "rotated").expect("refresh"); *p.source.0.lock().expect("lock") = Some(("{\"v\":2}".to_owned(), 2)); 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!(read_version(&d.0.join(VERSION_FILE)).expect("read"), None); } }