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:
parent
248df20164
commit
d5de1e4943
48 changed files with 2680 additions and 42 deletions
305
hive-agent/src/claude_credentials.rs
Normal file
305
hive-agent/src/claude_credentials.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Reference in a new issue