Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/backend_egress.rs

898 lines
31 KiB
Rust

//! The hosts an agent's backend environment names, kept as derived entries of
//! the egress allow list (`crate::egress_allow`), so a restricted agent whose
//! `ANTHROPIC_BASE_URL` points at a provider other than Anthropic reaches that
//! provider through its hive's proxy.
//!
//! An entry is marked `derived` and a hive renders it like any other. Only the
//! operator's save or delete of the environment through the controller creates
//! one ([`follow_env`]): the agent can create its own `backend-env` leaf while
//! it is empty, so what the store holds is not evidence that an operator chose
//! it. [`spawn_backfill`] therefore only drops: it keeps a derived entry while
//! the stored environment still names its exact host, and never adds one. Each
//! host goes through `egress_allow::checked`, the check an operator's own
//! entry gets.
//!
//! An agent still on the hand-placed `backendEnvironmentFile` alone has no
//! derived entry, and neither does an environment saved before this existed
//! until the operator saves it once more. The opencode preset's provider URL
//! (`acp.opencode.provider.baseUrl`) is host configuration, not part of the
//! 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, 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
/// file.
fn is_url_variable(name: &str) -> bool {
name == "ANTHROPIC_BASE_URL" || name.ends_with("_BASE_URL")
}
/// The URL variables `env` assigns, as `(name, value)`. A later assignment of a
/// name replaces an earlier one, as systemd's `EnvironmentFile=` does.
fn url_assignments(env: &str) -> BTreeMap<&str, &str> {
let mut found = BTreeMap::new();
for line in env.lines().map(str::trim) {
if line.is_empty() || line.starts_with(['#', ';']) {
continue;
}
let Some((name, value)) = line.split_once('=') else {
continue;
};
let name = name.trim();
if is_url_variable(name) {
found.insert(name, unquoted(value.trim()));
}
}
found
}
fn unquoted(value: &str) -> &str {
for quote in ['"', '\''] {
if let Some(inner) = value
.strip_prefix(quote)
.and_then(|v| v.strip_suffix(quote))
{
return inner;
}
}
value
}
/// 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`.
pub(crate) fn host_of(url: &str) -> Option<&str> {
let rest = match url.split_once("://") {
Some(("http" | "https", rest)) => rest,
Some(_) => return None,
None => url,
};
let authority = rest.split(['/', '?', '#']).next()?;
let authority = authority.rsplit('@').next()?;
Some(match authority.rsplit_once(':') {
Some((host, port)) if port.bytes().all(|b| b.is_ascii_digit()) => host,
_ => authority,
})
}
/// The derived entries for `agent`'s `env`: one per distinct host that passes
/// `egress_allow::checked`. A host that does not (an IP address, `localhost`,
/// a name the base list already allows) is logged by variable name and left
/// out. The value is never logged: a URL can carry credentials.
fn derive(agent: &str, env: &str, base: &[String]) -> Vec<Entry> {
let mut entries: Vec<Entry> = derive_named(agent, env, base)
.into_iter()
.map(|(_, entry)| entry)
.collect();
entries.sort();
entries.dedup();
entries
}
/// [`fn@derive`] with the variable each entry came from, one per variable.
fn derive_named<'a>(agent: &str, env: &'a str, base: &[String]) -> Vec<(&'a str, Entry)> {
let mut entries = Vec::new();
for (name, value) in url_assignments(env) {
let Some(host) = host_of(value) else {
tracing::info!(%agent, variable = name, "backend egress: not an http(s) URL; no entry");
continue;
};
let request = swarm_controller_api::egress::AllowedDomain {
domain: host.to_owned(),
agent: Some(agent.to_owned()),
derived: false,
};
match checked(&request, base) {
Ok(entry) => entries.push((
name,
Entry {
derived: true,
source: Some(BACKEND_ENV_SOURCE.to_owned()),
..entry
},
)),
Err(why) => {
tracing::info!(%agent, variable = name, %why, "backend egress: no entry for this host");
}
}
}
entries
}
/// `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.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!(
domain = %entry.domain,
"backend egress: the allow list is full; no entry for this host"
);
break;
}
list.entries.push(entry.clone());
}
list
}
/// Replace `agent`'s derived entries with those of `env`, or with none when
/// `env` is `None` (the environment was deleted). A host without a queue
/// (`state.status` unset) has no allow list, so nothing happens.
///
/// # Errors
/// A problem naming that the environment is stored but the list was not
/// updated; saving the environment again retries.
pub(crate) async fn follow_env(
state: &AppState,
agent: &str,
env: Option<&str>,
) -> Result<(), ProblemDetails> {
if state.status.is_none() {
return Ok(());
}
let base = base_domains();
let desired = BTreeMap::from([(
agent.to_owned(),
env.map(|text| derive(agent, text, &base))
.unwrap_or_default(),
)]);
let updated = async {
let client = queue(state).map_err(|(_, why)| anyhow::anyhow!(why))?;
modify(&client, |list| replaced(list, &desired)).await
}
.await;
match updated {
Ok(true) => tracing::info!(%agent, "backend egress: derived entries updated"),
Ok(false) => {}
Err(e) => {
tracing::warn!(%agent, error = %format!("{e:#}"), "backend egress: updating the allow list failed");
return Err(error_problem(
wanted_error_status(&e),
&format!(
"the backend environment is stored, but the allowed domains were not \
updated ({e:#}); saving it again retries"
),
));
}
}
Ok(())
}
/// `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.from_backend_env() || hosts.contains(&e.domain)
});
list
}
/// Drop the derived entries whose host the stored environment no longer names.
///
/// Only the agents that have a derived entry are looked at. An agent whose
/// stored environment is gone loses all of them; one whose environment cannot
/// be read keeps them. A host the stored environment names that is not
/// already derived is left out and logged by variable name, since whoever
/// wrote the store may be the agent itself.
///
/// Answers how many agents could not be read.
///
/// # Errors
/// A queue failure reading or writing the list.
pub(crate) async fn reconcile(
client: &async_nats::Client,
store: &impl AccountStore,
) -> anyhow::Result<usize> {
let Some(current) = crate::egress_allow::current(client).await? else {
return Ok(0);
};
let mut derived: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for entry in current.entries.iter().filter(|e| e.from_backend_env()) {
if let Some(agent) = &entry.agent {
derived
.entry(agent.clone())
.or_default()
.insert(entry.domain.clone());
}
}
let base = base_domains();
let mut still_named = BTreeMap::new();
let mut unreadable = 0;
for (agent, kept) in derived {
let Ok(path) = backend_env::env_path(&agent) else {
continue;
};
match store.read_optional::<BackendEnv>(&path).await {
Ok(stored) => {
let named = stored.map_or_else(Vec::new, |s| {
derive_named(&agent, &s.env, &base)
.into_iter()
.map(|(variable, entry)| (variable.to_owned(), entry.domain))
.collect()
});
for (variable, domain) in &named {
if !kept.contains(domain) {
tracing::warn!(
%agent,
%variable,
"backend egress: the agent's stored env names a host not derived \
(only an operator save derives)"
);
}
}
still_named.insert(agent, named.into_iter().map(|(_, d)| d).collect());
}
Err(e) => {
tracing::warn!(%agent, error = %e, "backend egress: reading the backend env failed; its entries stay");
unreadable += 1;
}
}
}
if modify(client, |list| pruned(list, &still_named)).await? {
tracing::info!(
agents = still_named.len(),
"backend egress: dropped derived entries the stored env no longer names"
);
}
Ok(unreadable)
}
/// 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?;
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 {
Ok(0) => return,
Ok(n) => tracing::warn!(
unreadable = n,
"backend egress: backfill incomplete; retrying"
),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "backend egress: backfill failed; retrying");
}
}
tokio::time::sleep(wait).await;
wait = (wait * 2).min(Duration::from_mins(5));
}
});
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::agent_limits::tests::declared;
use crate::linked_accounts::tests::FakeStore;
use crate::test_nats::TestNats;
use serde_json::json;
fn base() -> Vec<String> {
vec!["api.anthropic.com".to_owned(), ".claude.ai".to_owned()]
}
fn hosts(env: &str) -> Vec<String> {
derive("atlas", env, &base())
.into_iter()
.map(|e| e.domain)
.collect()
}
#[test]
fn a_url_is_reduced_to_its_host() {
for (value, host) in [
("https://openrouter.ai/api/v1", "openrouter.ai"),
("http://llm.example.com", "llm.example.com"),
("https://LLM.Example.com:8443/v1?x=1#f", "llm.example.com"),
("llm.example.com/v1", "llm.example.com"),
("llm.example.com:8080", "llm.example.com"),
("\"https://llm.example.com/v1\"", "llm.example.com"),
("'https://llm.example.com/v1'", "llm.example.com"),
("https://user:pw@llm.example.com/v1", "llm.example.com"),
] {
assert_eq!(
hosts(&format!("ANTHROPIC_BASE_URL={value}\n")),
[host],
"{value}"
);
}
}
#[test]
fn a_host_the_swarm_would_refuse_gets_no_entry() {
for value in [
"http://10.0.0.5:8080/v1",
"https://127.0.0.1",
"http://[::1]:11434",
"http://localhost:11434/v1",
"https://metadata",
"ftp://llm.example.com",
"https://*.example.com",
"",
"https://",
// The base list already allows it.
"https://api.anthropic.com/v1",
"https://a.claude.ai",
] {
assert_eq!(
hosts(&format!("ANTHROPIC_BASE_URL={value}\n")),
Vec::<String>::new(),
"{value:?}"
);
}
// Control: the same shape with a public name gets one.
assert_eq!(
hosts("ANTHROPIC_BASE_URL=https://llm.example.com\n"),
["llm.example.com"]
);
}
#[test]
fn only_url_variables_count_and_the_last_assignment_wins() {
let env = "# ANTHROPIC_BASE_URL=https://commented.example.com\n\
ANTHROPIC_API_KEY=https://not-a-url-var.example.com\n\
OPENAI_BASE_URL=https://acp.example.org/v1\n\
ANTHROPIC_BASE_URL=https://first.example.com\n\
ANTHROPIC_BASE_URL=https://second.example.com\n";
assert_eq!(hosts(env), ["acp.example.org", "second.example.com"]);
assert_eq!(hosts("ANTHROPIC_API_KEY=sk-x\n"), Vec::<String>::new());
}
#[test]
fn a_derived_entry_is_marked_and_scoped_to_the_agent() {
let entries = derive(
"atlas",
"ANTHROPIC_BASE_URL=https://llm.example.com\n",
&base(),
);
assert_eq!(
entries,
[Entry {
domain: "llm.example.com".to_owned(),
agent: Some("atlas".to_owned()),
derived: true,
source: Some(BACKEND_ENV_SOURCE.to_owned()),
}]
);
}
fn manual(domain: &str, agent: &str) -> Entry {
Entry {
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)
}
}
#[test]
fn replacing_replaces_only_the_named_agents_derived_entries() {
let list = AllowList {
entries: vec![
derived("old.example.com", "atlas"),
manual("old.example.com", "atlas"),
manual("github.com", "atlas"),
derived("other.example.com", "iris"),
],
};
let desired = BTreeMap::from([(
"atlas".to_owned(),
vec![derived("new.example.com", "atlas")],
)]);
let after = replaced(list, &desired);
assert_eq!(
after.entries,
[
manual("old.example.com", "atlas"),
manual("github.com", "atlas"),
derived("other.example.com", "iris"),
derived("new.example.com", "atlas"),
]
);
}
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 {
AppState {
accounts: crate::linked_accounts::Accounts::Fake(Arc::clone(store)),
..declared(client).await
}
}
async fn list_of(client: &async_nats::Client) -> Vec<Entry> {
crate::egress_allow::current(client)
.await
.expect("the queue answers")
.map(|l| {
let mut e = l.entries;
e.sort();
e
})
.unwrap_or_default()
}
async fn put(state: &AppState, agent: &str, env: &str) {
crate::backend_env::put_backend_env(
axum::extract::State(state.clone()),
axum::extract::Path(("pr1ma".to_owned(), agent.to_owned())),
axum::Json(serde_json::from_value(json!({"env": env})).expect("a request body")),
)
.await
.expect("stored");
}
#[tokio::test]
async fn saving_changing_and_deleting_the_env_moves_the_derived_entries() {
let nats = TestNats::start();
let client = nats.client().await;
let store = Arc::default();
let state = swarm(&client, &store).await;
put(
&state,
"atlas",
"ANTHROPIC_BASE_URL=https://one.example.com/v1\n",
)
.await;
assert_eq!(
list_of(&client).await,
[derived("one.example.com", "atlas")]
);
// A change replaces the host, an operator's own entry stays.
let _ = crate::egress_allow::add_allowed_domain(
axum::extract::State(state.clone()),
axum::Json(swarm_controller_api::egress::AllowedDomain {
domain: "github.com".to_owned(),
agent: Some("atlas".to_owned()),
derived: false,
}),
)
.await
.expect("manual entry");
put(
&state,
"atlas",
"ANTHROPIC_BASE_URL=https://two.example.com/v1\n",
)
.await;
assert_eq!(
list_of(&client).await,
[
manual("github.com", "atlas"),
derived("two.example.com", "atlas")
]
);
// Removing the variable removes the entry.
put(&state, "atlas", "ANTHROPIC_API_KEY=sk-live\n").await;
assert_eq!(list_of(&client).await, [manual("github.com", "atlas")]);
// Deleting the env takes the entries of the env it held.
put(
&state,
"atlas",
"ANTHROPIC_BASE_URL=https://three.example.com\n",
)
.await;
crate::linked_accounts::delete_backend_env(
axum::extract::State(state.clone()),
axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned())),
)
.await
.expect("deleted");
assert_eq!(list_of(&client).await, [manual("github.com", "atlas")]);
}
#[tokio::test]
async fn an_env_with_no_host_never_creates_the_list() {
let nats = TestNats::start();
let client = nats.client().await;
let store = Arc::default();
let state = swarm(&client, &store).await;
put(&state, "atlas", "ANTHROPIC_API_KEY=sk-live\n").await;
assert!(
swarm_queue_client::egress_allow::open(&client)
.await
.expect("the queue answers")
.is_none(),
"no bucket: nothing was written"
);
// Control: a host does create it.
put(
&state,
"atlas",
"ANTHROPIC_BASE_URL=https://llm.example.com\n",
)
.await;
assert_eq!(list_of(&client).await.len(), 1);
}
const OPERATOR: &str = "ANTHROPIC_BASE_URL=https://llm.example.com/v1\n";
fn leaf(agent: &str) -> String {
format!("swarm/agents/{agent}/backend-env")
}
async fn write_leaf(store: &FakeStore, agent: &str, env: &str) {
store
.write(
&leaf(agent),
&BackendEnv {
env: env.to_owned(),
},
)
.await
.expect("written");
}
/// The agent creates its own leaf while it is empty, so a stored value is
/// not evidence of an operator's choice: the backfill adds nothing for it.
#[tokio::test]
async fn a_leaf_created_outside_the_route_yields_no_entry() {
let nats = TestNats::start();
let client = nats.client().await;
let store = Arc::new(FakeStore::default());
let state = swarm(&client, &store).await;
write_leaf(
&store,
"atlas",
"ANTHROPIC_BASE_URL=https://evil.example.net\n",
)
.await;
// With no list at all, the pass creates nothing.
assert_eq!(reconcile(&client, &*store).await.expect("pass"), 0);
assert!(
swarm_queue_client::egress_allow::open(&client)
.await
.expect("the queue answers")
.is_none()
);
// With a list that holds only an operator's entry, nothing is added.
modify(&client, |mut list| {
list.entries.push(manual("github.com", "atlas"));
list
})
.await
.expect("seed");
assert_eq!(reconcile(&client, &*store).await.expect("pass"), 0);
assert_eq!(list_of(&client).await, [manual("github.com", "atlas")]);
// Control: the operator's save of the same text derives the host.
put(
&state,
"atlas",
"ANTHROPIC_BASE_URL=https://evil.example.net\n",
)
.await;
assert_eq!(
list_of(&client).await,
[
derived("evil.example.net", "atlas"),
manual("github.com", "atlas")
]
);
}
/// A pass keeps an entry whose exact host the stored env still names,
/// drops one it no longer names, and never adds the new host.
#[tokio::test]
async fn the_backfill_keeps_a_named_host_drops_a_stale_one_and_adds_nothing() {
let nats = TestNats::start();
let client = nats.client().await;
let store = Arc::new(FakeStore::default());
let state = swarm(&client, &store).await;
put(&state, "atlas", OPERATOR).await;
let entry = derived("llm.example.com", "atlas");
// Control: unchanged content keeps the entry, and a second pass writes
// nothing.
assert_eq!(reconcile(&client, &*store).await.expect("pass"), 0);
assert_eq!(list_of(&client).await, [entry]);
let before = published(&client).await;
reconcile(&client, &*store).await.expect("again");
assert_eq!(published(&client).await, before);
// The stored text changed outside the route: the old host goes, the
// new one does not come.
write_leaf(
&store,
"atlas",
"ANTHROPIC_BASE_URL=https://other.example.net\n",
)
.await;
reconcile(&client, &*store).await.expect("pass");
assert_eq!(list_of(&client).await, []);
}
/// The leaf is deleted outside the controller and created again by the
/// agent with another host before the pass: the old entry goes and the new
/// host is not derived. A leaf that is simply gone drops its entries too.
#[tokio::test]
async fn a_leaf_deleted_and_recreated_by_the_agent_loses_the_entry_and_gains_none() {
let nats = TestNats::start();
let client = nats.client().await;
let state = swarm(&client, &Arc::default()).await;
put(&state, "atlas", OPERATOR).await;
put(&state, "iris", OPERATOR).await;
assert_eq!(list_of(&client).await.len(), 2, "control: both derived");
let recreated = FakeStore::default();
write_leaf(
&recreated,
"atlas",
"ANTHROPIC_BASE_URL=https://evil.example.net\n",
)
.await;
// iris's leaf is gone, atlas's was created again by atlas.
reconcile(&client, &recreated).await.expect("pass");
assert_eq!(list_of(&client).await, []);
}
#[tokio::test]
async fn an_unreadable_env_keeps_its_entries() {
let nats = TestNats::start();
let client = nats.client().await;
let state = swarm(&client, &Arc::default()).await;
put(
&state,
"atlas",
"ANTHROPIC_BASE_URL=https://llm.example.com\n",
)
.await;
let unreadable = reconcile(&client, &FakeStore::denying())
.await
.expect("pass");
assert_eq!(unreadable, 1);
assert_eq!(
list_of(&client).await,
[derived("llm.example.com", "atlas")]
);
}
async fn published(client: &async_nats::Client) -> Option<String> {
swarm_queue_client::egress_allow::open_or_create(client)
.await
.expect("bucket")
.get(swarm_wire::egress_allow::KEY)
.await
.expect("read")
.map(|v| String::from_utf8(v.to_vec()).expect("utf-8"))
}
#[tokio::test]
async fn an_operator_cannot_add_or_remove_a_derived_entry() {
let nats = TestNats::start();
let client = nats.client().await;
let store = Arc::default();
let state = swarm(&client, &store).await;
put(
&state,
"atlas",
"ANTHROPIC_BASE_URL=https://llm.example.com\n",
)
.await;
let request = |derived: bool| swarm_controller_api::egress::AllowedDomain {
domain: "llm.example.com".to_owned(),
agent: Some("atlas".to_owned()),
derived,
};
for flagged in [true, false] {
let problem = crate::egress_allow::remove_allowed_domain(
axum::extract::State(state.clone()),
axum::Json(request(flagged)),
)
.await
.expect_err("refused");
assert_eq!(
problem.status,
Some(axum::http::StatusCode::CONFLICT),
"{problem:?}"
);
}
let problem = crate::egress_allow::add_allowed_domain(
axum::extract::State(state.clone()),
axum::Json(request(true)),
)
.await
.expect_err("refused");
assert_eq!(problem.status, Some(axum::http::StatusCode::BAD_REQUEST));
assert_eq!(
list_of(&client).await,
[derived("llm.example.com", "atlas")]
);
// Control: the same name added by the operator is a separate entry that
// the operator can remove, and the derived one stays.
let manual_request = request(false);
let _ = crate::egress_allow::add_allowed_domain(
axum::extract::State(state.clone()),
axum::Json(manual_request.clone()),
)
.await
.expect("a manual entry");
let _ = crate::egress_allow::remove_allowed_domain(
axum::extract::State(state.clone()),
axum::Json(manual_request),
)
.await
.expect("removes the manual one");
assert_eq!(
list_of(&client).await,
[derived("llm.example.com", "atlas")]
);
}
#[tokio::test]
async fn destroying_an_agent_drops_all_its_entries_and_only_its_own() {
let nats = TestNats::start();
let client = nats.client().await;
let state = swarm(&client, &Arc::default()).await;
put(
&state,
"atlas",
"ANTHROPIC_BASE_URL=https://llm.example.com\n",
)
.await;
modify(&client, |mut list| {
list.entries.push(manual("github.com", "atlas"));
list.entries.push(manual("crates.io", "iris"));
list
})
.await
.expect("seed");
crate::egress_allow::clear_on_destroy(&state, "pr1ma", "atlas").await;
assert_eq!(list_of(&client).await, [manual("crates.io", "iris")]);
}
#[tokio::test]
async fn a_host_without_a_queue_stores_the_env_and_skips_the_list() {
let nats = TestNats::start();
let client = nats.client().await;
let store = Arc::new(FakeStore::default());
let mut state = swarm(&client, &store).await;
state.status = None;
put(
&state,
"atlas",
"ANTHROPIC_BASE_URL=https://llm.example.com\n",
)
.await;
assert_eq!(store.written(), ["swarm/agents/atlas/backend-env"]);
assert_eq!(list_of(&client).await, []);
}
}