Watch
0
0
Fork
You've already forked hyperhive
0

swarm UI: log an agent into claude from its card; the credentials go to bao and the agent picks them up

A new swarm service, `swarm-claude-login`, in its own container on the host
that sets `deploy.claude-login.enable` (defaulted on by `allSwarmServices`
where the host has the minter's store leaf, like the review classifier).

- swarm-ui: an agent card showing "login needed" gets a "log in" button.
  Its dialog starts the login, shows the URL claude printed, and takes the
  pasted code.
- swarm-controller: `POST/DELETE /api/agents/{name}/claude-login` and
  `POST /api/agents/{name}/claude-login/code`, behind the swarm UI's
  authelia-gated `/api/`, relay the steps to the minter as requests on
  `$SWARM.claude-login.request`.
- swarm-claude-login: runs `claude auth login` over piped stdio with HOME
  and CLAUDE_CONFIG_DIR in a per-login scratch dir, scrapes the first
  https URL, writes the code to stdin, and stores the `.credentials.json`
  at `swarm/agents/<agent>/claude-credentials` with a KV v2 write, a new
  version over any earlier one. The scratch dir goes on every exit path; a
  login waits 15 minutes for its code.
- hive-agent: while parked in `wait_for_login`, reads that leaf every 30 s
  under the agent's own cert and installs it only when its KV version is
  newer than the one recorded in `<harness>/claude-credentials-version`.
  An equal or older version is never put back, because claude rotates the
  refresh token and the stored copy is stale after the first refresh.
- swarm-secret-client: `claude_login` paths and `SecretStore::read_versioned`.
- Credentials: a `claude-login` store leaf minted by glue-bao-tls, a
  `swarm-claude-login` policy (create/update on
  `swarm/agents/+/claude-credentials`, read on its own queue client
  secret), an authelia machine client, and a `--claude-login-client`
  grant in the queue's auth-callout responder. The unit runs as a static
  system user.

The hive's own web-UI login is unchanged.

Refs #5268
This commit is contained in:
atlas 2026-10-14 22:26:53 +02:00 • committed by mara
commit d5de1e4943
48 changed files with 2680 additions and 42 deletions

View file

@ -0,0 +1,305 @@
//! The claude credentials the swarm's login minter stored for this agent, at
//! `swarm/agents/<agent>/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<u64>) -> 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<Output = anyhow::Result<Option<(Credentials, u64)>>> + 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<Option<(Credentials, u64)>> {
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<Self> {
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<S> {
source: S,
claude_dir: PathBuf,
version_file: PathBuf,
}
impl<S: Source> Pickup<S> {
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<Outcome> {
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<Option<u64>> {
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<Option<(String, u64)>>);
impl Source for Fake {
async fn fetch(&self) -> anyhow::Result<Option<(Credentials, u64)>> {
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<Fake> {
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<String> {
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);
}
}

View file

@ -15,6 +15,7 @@ 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
@ -178,7 +179,25 @@ pub async fn wait_for_login(
// 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(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
@ -193,6 +212,27 @@ pub async fn wait_for_login(
}
}
/// The swarm-login pickup for `claude_dir`, or `None` when this process has
/// no store to read from.
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

View file

@ -10,6 +10,7 @@
//! before lib + bin were collapsed into one) plus the serve loop.
mod boot_announce;
mod claude_credentials;
mod claude_md_watch;
mod claude_usage_watch;
mod db_migrate;

View file

@ -210,10 +210,26 @@ impl<S: SecretSource> AgentSecret<S> {
/// # Errors
/// No store address, or one without the agent name or the identity beside it.
fn store_source(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<StoreSource> {
let (settings, role, agent) = agent_store(get)?;
Ok(StoreSource {
settings,
role,
path: queue::agent_queue_path(&agent)?,
})
}
/// Where the store is, the cert-auth role this agent logs in as, and the
/// agent's name, from `get`.
///
/// # Errors
/// No store address, or one without the agent name or the identity beside it.
pub(crate) fn agent_store(
get: impl Fn(&str) -> Option<String>,
) -> anyhow::Result<(Settings, String, String)> {
if get(ENV_ADDR).is_none_or(|v| v.is_empty()) {
anyhow::bail!(
"{ENV_ADDR} is unset or empty, so there is no secret store to read this agent's \
queue credential from"
secrets from"
);
}
let agent = get(ENV_AGENT_NAME)
@ -221,11 +237,8 @@ fn store_source(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<StoreSou
.with_context(|| format!("{ENV_AGENT_NAME} is unset or empty"))?;
let settings = Settings::from_lookup(|k| get(k).filter(|v| k != ENV_CACERT || ca_is_usable(v)))
.context("reading the swarm secret store's coordinates from the environment")?;
Ok(StoreSource {
settings,
role: policy::agent_object_name(&agent)?,
path: queue::agent_queue_path(&agent)?,
})
let role = policy::agent_object_name(&agent)?;
Ok((settings, role, agent))
}
/// Whether the CA bundle at `path` is a file with bytes in it. `BAO_CACERT`