Watch
0
0
Fork
You've already forked hyperhive
0

hive-bash-mcp: publish each task's status and output to the swarm queue

This commit is contained in:
damocles 2026-11-15 17:48:13 +01:00
commit c0b9c1778c
2 changed files with 841 additions and 211 deletions

View file

@ -0,0 +1,236 @@
//! The connection a daemon publishes to the swarm queue over, **as the agent**.
//!
//! The agent's own queue credential is read from the swarm secret store under
//! the agent's store identity on every connection attempt, so a re-minted
//! secret is picked up on reconnect. The hive's shared client is never tried:
//! the queue grants it nothing under the agent's own subjects.
//!
//! [`Target::from_lookup`] reads where to connect from the environment and is
//! `None` in a container without a queue address or without a store;
//! [`Queue`] connects lazily and at most once per [`RETRY_AFTER`]. Callers
//! publish; they never subscribe.
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Context as _, anyhow};
use swarm_secret_client::{
SecretStore,
client::{DEFAULT_CERT_MOUNT, ENV_ADDR, ENV_CACERT, Settings},
policy, queue,
};
/// Names the agent. The store role, the store path, the queue token and the
/// subjects are all built from it.
pub const ENV_AGENT_NAME: &str = "HIVE_AGENT_NAME";
/// Where the swarm queue listens, as this container reaches it.
pub const ENV_NATS_URL: &str = "HIVE_AGENT_NATS_URL";
/// How long one read of the store may take; it runs inside a queue
/// connection attempt.
const STORE_READ_TIMEOUT: Duration = Duration::from_secs(10);
/// How long after a failed connect the task tries again. What arrives before a first connect
/// succeeds is dropped.
pub const RETRY_AFTER: Duration = Duration::from_mins(1);
/// Where to publish, and what to connect with.
pub struct Target {
pub url: String,
pub agent: String,
store: StoreSource,
}
/// This agent's queue secret as the store holds it.
struct StoreSource {
settings: Settings,
role: String,
path: String,
}
impl Target {
/// `None` when the queue address or the store address is absent, which
/// is a container without a swarm queue or without a store.
///
/// # Errors
/// A store address without the agent name or the identity beside it.
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<Option<Self>> {
let Some(url) = get(ENV_NATS_URL).filter(|v| !v.is_empty()) else {
return Ok(None);
};
if get(ENV_ADDR).is_none_or(|v| v.is_empty()) {
return Ok(None);
}
let agent = get(ENV_AGENT_NAME)
.filter(|v| !v.is_empty())
.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")?;
let store = StoreSource {
settings,
role: policy::agent_object_name(&agent)?,
path: queue::agent_queue_path(&agent)?,
};
Ok(Some(Self { url, agent, store }))
}
}
/// Whether the CA bundle at `path` is a file with bytes in it. Absent means
/// the container's own trust store.
fn ca_is_usable(path: &str) -> bool {
std::fs::metadata(path).is_ok_and(|m| m.len() > 0)
}
impl StoreSource {
/// The stored secret, read with a fresh login. Errors name the path and
/// never the value.
async fn read(&self) -> anyhow::Result<String> {
let fetch = 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 credential: Option<queue::AgentCredential> = store
.read_optional(&self.path)
.await
.with_context(|| format!("reading {} from the store", self.path))?;
credential
.map(|c| c.value.trim().to_owned())
.filter(|v| !v.is_empty())
.with_context(|| format!("nothing is stored at {}", self.path))
};
tokio::time::timeout(STORE_READ_TIMEOUT, fetch)
.await
.map_err(|_| anyhow!("the store did not answer within {STORE_READ_TIMEOUT:?}"))?
}
}
/// Connect presenting the agent's own token, read from the store on every
/// connection attempt so a re-minted secret is picked up on reconnect.
pub async fn connect(target: &Arc<Target>) -> anyhow::Result<async_nats::Client> {
let url = target.url.clone();
let target = Arc::clone(target);
let agent = target.agent.clone();
crate::connect_with_token(&url, None, &agent, move || {
let target = Arc::clone(&target);
// Spawned so the future handed back is only a `JoinHandle`, which is
// `Sync` as async-nats's auth callback requires; the store read is not.
let attempt = tokio::spawn(async move {
let value = target.store.read().await.map_err(|e| format!("{e:#}"))?;
swarm_agent_verify::agent_token::format_agent_token(&target.agent, &value)
.map_err(|e| e.to_string())
});
async move { attempt.await.map_err(|e| e.to_string())? }
})
.await
.map_err(|e| anyhow!(crate::chain(&e)))
}
/// The connection, retried at most once per [`RETRY_AFTER`].
pub struct Queue {
client: Option<async_nats::Client>,
connect_after: Option<Instant>,
/// What the log lines call the publisher, e.g. `subagent terminal`.
label: &'static str,
}
impl Queue {
/// A queue that has not connected yet; `label` names the publisher in
/// the log.
#[must_use]
pub fn new(label: &'static str) -> Self {
Self {
client: None,
connect_after: None,
label,
}
}
/// A client to publish on, connecting first when a connect is due.
pub async fn ready(&mut self, target: &Arc<Target>) -> Option<async_nats::Client> {
let now = Instant::now();
if self.client.is_none() && self.connect_after.is_none_or(|t| now >= t) {
match connect(target).await {
Ok(client) => {
tracing::info!(url = %target.url, label = self.label, "connected to the swarm queue as this agent");
self.client = Some(client);
}
Err(e) => {
tracing::warn!(
error = format!("{e:#}"),
retry_in = ?RETRY_AFTER,
label = self.label,
"connecting with this agent's own queue credential failed; nothing is \
published until a connect succeeds"
);
self.connect_after = Some(now + RETRY_AFTER);
}
}
}
self.client.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let pairs: Vec<(String, String)> = pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect();
move |k| pairs.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone())
}
const STORE: [(&str, &str); 3] = [
("BAO_ADDR", "https://bao.t.local:8200"),
("BAO_CLIENT_CERT", "/run/credentials/c"),
("BAO_CLIENT_KEY", "/run/credentials/k"),
];
fn full() -> Vec<(&'static str, &'static str)> {
let mut pairs = STORE.to_vec();
pairs.push((ENV_NATS_URL, "tls://nats.t.local:4222"));
pairs.push((ENV_AGENT_NAME, "iris"));
pairs
}
#[test]
fn the_target_names_the_agent_and_its_queue_secret() {
let target = Target::from_lookup(env(&full()))
.expect("complete")
.expect("configured");
assert_eq!(target.agent, "iris");
assert_eq!(target.url, "tls://nats.t.local:4222");
assert_eq!(target.store.path, "swarm/agents/iris/queue");
}
#[test]
fn without_a_queue_address_or_a_store_nothing_is_published() {
let no_url: Vec<_> = full()
.into_iter()
.filter(|(k, _)| *k != ENV_NATS_URL)
.collect();
assert!(Target::from_lookup(env(&no_url)).expect("legal").is_none());
let no_store: Vec<_> = full()
.into_iter()
.filter(|(k, _)| *k != "BAO_ADDR")
.collect();
assert!(
Target::from_lookup(env(&no_store))
.expect("legal")
.is_none()
);
}
#[test]
fn a_store_without_the_agent_name_is_an_error() {
let no_name: Vec<_> = full()
.into_iter()
.filter(|(k, _)| *k != ENV_AGENT_NAME)
.collect();
assert!(Target::from_lookup(env(&no_name)).is_err());
}
}

View file

@ -484,6 +484,9 @@ pub mod notices;
#[cfg(feature = "agent-term")]
pub mod agent_term;
#[cfg(feature = "agent-publisher")]
pub mod agent_publisher;
/// Creating the per-agent stream that lists an agent's subagents. The names,
/// subjects and grants are `swarm_wire::subagent_term`. Behind the
/// `subagent-term` feature.