Watch
0
0
Fork
You've already forked hyperhive
0

derive per-agent egress entries from operator-linked accounts

This commit is contained in:
atlas 2026-10-30 12:28:51 +01:00
commit 9193f37963

View file

@ -19,16 +19,18 @@
//! environment, so it is never derived either.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use std::time::Duration;
use problem_details::ProblemDetails;
use swarm_secret_client::backend_env::{self, BackendEnv};
use swarm_wire::egress_allow::{AllowList, Entry, MAX_ENTRIES};
use swarm_wire::egress_allow::{AllowList, BACKEND_ENV_SOURCE, Entry, MAX_ENTRIES};
use crate::egress_allow::{base_domains, checked, modify};
use crate::linked_accounts::AccountStore;
use crate::routes::{AppState, error_problem, wanted_error_status};
use crate::schedules::queue;
use crate::wanted::WantedWriter;
/// `ANTHROPIC_BASE_URL` and every variable named `<PROVIDER>_BASE_URL`, which
/// is where a provider's address sits for an ACP agent that loads the same
@ -71,7 +73,7 @@ fn unquoted(value: &str) -> &str {
/// The host of `url`, as written: its scheme (`http` or `https`, or none),
/// user information, port, path, query and fragment are dropped. Nothing is
/// validated; the result goes through `egress_allow::checked`.
fn host_of(url: &str) -> Option<&str> {
pub(crate) fn host_of(url: &str) -> Option<&str> {
let rest = match url.split_once("://") {
Some(("http" | "https", rest)) => rest,
Some(_) => return None,
@ -117,6 +119,7 @@ fn derive_named<'a>(agent: &str, env: &'a str, base: &[String]) -> Vec<(&'a str,
name,
Entry {
derived: true,
source: Some(BACKEND_ENV_SOURCE.to_owned()),
..entry
},
)),
@ -128,12 +131,14 @@ fn derive_named<'a>(agent: &str, env: &'a str, base: &[String]) -> Vec<(&'a str,
entries
}
/// `list` with, for each agent in `desired`, its derived entries replaced by
/// the given ones. Entries of other agents and operator-added entries are
/// `list` with, for each agent in `desired`, its entries derived from the backend
/// environment replaced by the given ones. Entries of other agents,
/// operator-added entries and entries derived from linked accounts are
/// untouched. A full list takes no more derived entries.
fn replaced(mut list: AllowList, desired: &BTreeMap<String, Vec<Entry>>) -> AllowList {
list.entries
.retain(|e| !(e.derived && e.agent.as_ref().is_some_and(|a| desired.contains_key(a))));
list.entries.retain(|e| {
!(e.from_backend_env() && e.agent.as_ref().is_some_and(|a| desired.contains_key(a)))
});
for entry in desired.values().flatten() {
if list.entries.len() >= MAX_ENTRIES {
tracing::warn!(
@ -190,15 +195,15 @@ pub(crate) async fn follow_env(
Ok(())
}
/// `list` without the derived entries of the agents in `still_named` whose host
/// is not in that agent's set. Nothing is added; entries of agents not in the
/// map and operator-added entries are untouched.
/// `list` without the backend-env entries of the agents in `still_named` whose
/// host is not in that agent's set. Nothing is added; entries of agents not in
/// the map, operator-added entries and link-derived entries are untouched.
fn pruned(mut list: AllowList, still_named: &BTreeMap<String, BTreeSet<String>>) -> AllowList {
list.entries.retain(|e| {
let Some(hosts) = e.agent.as_ref().and_then(|a| still_named.get(a)) else {
return true;
};
!e.derived || hosts.contains(&e.domain)
!e.from_backend_env() || hosts.contains(&e.domain)
});
list
}
@ -223,7 +228,7 @@ pub(crate) async fn reconcile(
return Ok(0);
};
let mut derived: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for entry in current.entries.iter().filter(|e| e.derived) {
for entry in current.entries.iter().filter(|e| e.from_backend_env()) {
if let Some(agent) = &entry.agent {
derived
.entry(agent.clone())
@ -273,16 +278,24 @@ pub(crate) async fn reconcile(
Ok(unreadable)
}
/// Run [`reconcile`] once the queue and the store answer, retrying with a
/// growing wait until a pass reads every agent that has an entry.
pub(crate) fn spawn_backfill(client: async_nats::Client) {
/// Run [`reconcile`] and `link_egress::reconcile` once the queue and the store
/// answer, retrying with a growing wait until a pass reads every agent that
/// has an entry and some hive has declared an agent.
pub(crate) fn spawn_backfill(
client: async_nats::Client,
wanted: Arc<WantedWriter>,
hives: Vec<String>,
) {
tokio::spawn(async move {
let mut wait = Duration::from_secs(5);
loop {
let pass = async {
swarm_queue_client::ensure_connected(&client)?;
let store = crate::store::connect().await?;
reconcile(&client, &store).await
let live = crate::subagent_term::live_agents(&wanted, &hives).await;
let unreadable = reconcile(&client, &store).await?
+ crate::link_egress::reconcile(&client, &store, &live).await?;
Ok::<_, anyhow::Error>(unreadable + usize::from(live.is_empty()))
}
.await;
match pass {
@ -395,6 +408,7 @@ mod tests {
domain: "llm.example.com".to_owned(),
agent: Some("atlas".to_owned()),
derived: true,
source: Some(BACKEND_ENV_SOURCE.to_owned()),
}]
);
}
@ -404,12 +418,14 @@ mod tests {
domain: domain.to_owned(),
agent: Some(agent.to_owned()),
derived: false,
source: None,
}
}
fn derived(domain: &str, agent: &str) -> Entry {
Entry {
derived: true,
source: Some(BACKEND_ENV_SOURCE.to_owned()),
..manual(domain, agent)
}
}
@ -440,6 +456,68 @@ mod tests {
);
}
fn linked(domain: &str, agent: &str, source: &str) -> Entry {
Entry {
derived: true,
source: Some(source.to_owned()),
..manual(domain, agent)
}
}
/// A derived entry written before sources existed.
fn untagged(domain: &str, agent: &str) -> Entry {
Entry {
source: None,
..derived(domain, agent)
}
}
#[test]
fn a_backend_env_save_leaves_link_entries_and_replaces_untagged_ones() {
let list = AllowList {
entries: vec![
linked("github.com", "atlas", "link:github"),
linked("hs.example.org", "atlas", "link:matrix:work"),
untagged("old.example.com", "atlas"),
],
};
let desired = BTreeMap::from([(
"atlas".to_owned(),
vec![derived("new.example.com", "atlas")],
)]);
let after = replaced(list, &desired);
assert_eq!(
after.entries,
[
linked("github.com", "atlas", "link:github"),
linked("hs.example.org", "atlas", "link:matrix:work"),
derived("new.example.com", "atlas"),
]
);
}
#[test]
fn pruning_the_backend_env_leaves_link_entries_and_drops_untagged_ones() {
let list = AllowList {
entries: vec![
linked("github.com", "atlas", "link:github"),
untagged("old.example.com", "atlas"),
derived("kept.example.com", "atlas"),
],
};
let still_named = BTreeMap::from([(
"atlas".to_owned(),
BTreeSet::from(["kept.example.com".to_owned()]),
)]);
assert_eq!(
pruned(list, &still_named).entries,
[
linked("github.com", "atlas", "link:github"),
derived("kept.example.com", "atlas"),
]
);
}
/// The state of a test swarm whose `pr1ma` declares `atlas`, with `store`
/// as the secret store.
async fn swarm(client: &async_nats::Client, store: &Arc<FakeStore>) -> AppState {