Watch
0
0
Fork
You've already forked hyperhive
0

derive backend hosts only from the operator's save; the backfill only drops

This commit is contained in:
atlas 2026-10-29 02:26:47 +01:00 • committed by mara
commit 0ad1dad8b5
4 changed files with 229 additions and 108 deletions

View file

@ -332,18 +332,23 @@ host. It never decrypts TLS, so it holds no signing key. It caches nothing.
you add the entry, and `hive-c0re` checks each name again and skips one that you add the entry, and `hive-c0re` checks each name again and skips one that
fails, or that the base list or the canary covers, or that another entry of fails, or that the base list or the canary covers, or that another entry of
the same list covers. A name that resolves to a private address stays a 403. the same list covers. A name that resolves to a private address stays a 403.
The controller also adds entries itself. When an operator saves an agent's The controller also adds entries itself, and only from the operator's save
API-key environment, and once when the controller starts, it takes the host of an agent's API-key environment in the swarm UI: it takes the host from
from `ANTHROPIC_BASE_URL` and from every variable named `*_BASE_URL` `ANTHROPIC_BASE_URL` and from every variable named `*_BASE_URL` (the
(the address an ACP agent that loads the same file reads), and keeps one address an ACP agent that loads the same file reads), and keeps one
`derived` entry for that agent per host. Each host passes the same check as `derived` entry for that agent per host. Each host passes the same check as
an operator's entry, so an IP address, `localhost` and a base-list name get an operator's entry, so an IP address, `localhost` and a base-list name get
none. A save or a delete of the environment replaces the agent's derived none. A save or a delete of the environment replaces the agent's derived
entries, and destroying the agent drops all its entries. The controller reads only the entries, and destroying the agent drops all its entries. The agent can
store's copy: an agent on the hand-placed `backendEnvironmentFile` alone, and create its own `backend-env` leaf while the leaf is empty, so text found in
the `opencode` preset's `acp.opencode.provider.baseUrl`, get no derived the store never creates an entry: when the controller starts, it only
entry, so add one in the swarm UI. A `hive-c0re` that predates the field drops derived entries whose exact host the stored environment no longer
renders a derived entry like any other. names, and logs a host it didn't derive. An environment saved before this
existed gets its entries when the operator saves it once more. An agent on
the hand-placed `backendEnvironmentFile` alone, and the `opencode` preset's
`acp.opencode.provider.baseUrl`, get no derived entry, so add one in the
swarm UI. A `hive-c0re` that predates the field renders a derived entry
like any other.
The swarm's list adds to the hive's `allowedDomains`: `allowedFile` holds The swarm's list adds to the hive's `allowedDomains`: `allowedFile` holds
those names plus the swarm-wide entries, so removing an entry never removes a those names plus the swarm-wide entries, so removing an entry never removes a
name the hive lists itself. Until the controller has a list (it predates the name the hive lists itself. Until the controller has a list (it predates the

View file

@ -302,8 +302,10 @@ restarts.
A save also keeps the host of `ANTHROPIC_BASE_URL` (and of every `*_BASE_URL` A save also keeps the host of `ANTHROPIC_BASE_URL` (and of every `*_BASE_URL`
variable) as an automatic allowed-domain entry for the agent, so a restricted variable) as an automatic allowed-domain entry for the agent, so a restricted
agent reaches its provider through the hive's proxy; **Remove** drops it. The agent reaches its provider through the hive's proxy; **Remove** drops it. The
controller reads the stored text only. An agent that has just the file gets no controller derives only from an operator's save, never from text it finds in the
entry, and an operator adds its provider's host in the swarm UI (see store, so an environment saved before this existed needs one more save. An
agent that has just the file gets no entry, and an operator adds its
provider's host in the swarm UI (see
[allowed domains](../swarm/ui.md#allowed-domains)). [allowed domains](../swarm/ui.md#allowed-domains)).
Provision the file once, out of band (never through nix — an API key in Provision the file once, out of band (never through nix — an API key in

View file

@ -3,21 +3,22 @@
//! `ANTHROPIC_BASE_URL` points at a provider other than Anthropic reaches that //! `ANTHROPIC_BASE_URL` points at a provider other than Anthropic reaches that
//! provider through its hive's proxy. //! provider through its hive's proxy.
//! //!
//! The controller sees the environment where it is written (`backend_env`) and //! An entry is marked `derived` and a hive renders it like any other. Only the
//! in the store, so it derives here and a hive renders the entries like any //! operator's save or delete of the environment through the controller creates
//! other. An entry is marked `derived`: [`follow_env`] replaces an agent's //! one ([`follow_env`]): the agent can create its own `backend-env` leaf while
//! derived entries on every write or delete of its environment, and //! it is empty, so what the store holds is not evidence that an operator chose
//! [`spawn_backfill`] reconciles all of them from the stored environments at //! it. [`spawn_backfill`] therefore only drops: it keeps a derived entry while
//! start. Each host goes through `egress_allow::checked`, the check an //! the stored environment still names its exact host, and never adds one. Each
//! operator's own entry gets. //! host goes through `egress_allow::checked`, the check an operator's own
//! entry gets.
//! //!
//! Only the store's copy is read. An agent still on the hand-placed //! An agent still on the hand-placed `backendEnvironmentFile` alone has no
//! `backendEnvironmentFile` alone has no derived entry. The opencode preset's //! derived entry, and neither does an environment saved before this existed
//! provider URL (`acp.opencode.provider.baseUrl`) is host configuration, not //! until the operator saves it once more. The opencode preset's provider URL
//! part of the environment, so it is never derived either. //! (`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::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use problem_details::ProblemDetails; use problem_details::ProblemDetails;
@ -28,7 +29,6 @@ use crate::egress_allow::{base_domains, checked, modify};
use crate::linked_accounts::AccountStore; use crate::linked_accounts::AccountStore;
use crate::routes::{AppState, error_problem, wanted_error_status}; use crate::routes::{AppState, error_problem, wanted_error_status};
use crate::schedules::queue; use crate::schedules::queue;
use crate::wanted::WantedWriter;
/// `ANTHROPIC_BASE_URL` and every variable named `<PROVIDER>_BASE_URL`, which /// `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 /// is where a provider's address sits for an ACP agent that loads the same
@ -90,7 +90,18 @@ fn host_of(url: &str) -> Option<&str> {
/// a name the base list already allows) is logged by variable name and left /// 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. /// out. The value is never logged: a URL can carry credentials.
fn derive(agent: &str, env: &str, base: &[String]) -> Vec<Entry> { fn derive(agent: &str, env: &str, base: &[String]) -> Vec<Entry> {
let mut entries = BTreeSet::new(); 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) { for (name, value) in url_assignments(env) {
let Some(host) = host_of(value) else { let Some(host) = host_of(value) else {
tracing::info!(%agent, variable = name, "backend egress: not an http(s) URL; no entry"); tracing::info!(%agent, variable = name, "backend egress: not an http(s) URL; no entry");
@ -102,24 +113,25 @@ fn derive(agent: &str, env: &str, base: &[String]) -> Vec<Entry> {
derived: false, derived: false,
}; };
match checked(&request, base) { match checked(&request, base) {
Ok(entry) => { Ok(entry) => entries.push((
entries.insert(Entry { name,
Entry {
derived: true, derived: true,
..entry ..entry
}); },
} )),
Err(why) => { Err(why) => {
tracing::info!(%agent, variable = name, %why, "backend egress: no entry for this host"); tracing::info!(%agent, variable = name, %why, "backend egress: no entry for this host");
} }
} }
} }
entries.into_iter().collect() entries
} }
/// `list` with, for each agent in `desired`, its derived entries replaced by /// `list` with, for each agent in `desired`, its derived entries replaced by
/// the given ones. Entries of other agents and operator-added entries are /// the given ones. Entries of other agents and operator-added entries are
/// untouched. A full list takes no more derived entries. /// untouched. A full list takes no more derived entries.
fn reconciled(mut list: AllowList, desired: &BTreeMap<String, Vec<Entry>>) -> AllowList { fn replaced(mut list: AllowList, desired: &BTreeMap<String, Vec<Entry>>) -> AllowList {
list.entries list.entries
.retain(|e| !(e.derived && e.agent.as_ref().is_some_and(|a| desired.contains_key(a)))); .retain(|e| !(e.derived && e.agent.as_ref().is_some_and(|a| desired.contains_key(a))));
for entry in desired.values().flatten() { for entry in desired.values().flatten() {
@ -158,7 +170,7 @@ pub(crate) async fn follow_env(
)]); )]);
let updated = async { let updated = async {
let client = queue(state).map_err(|(_, why)| anyhow::anyhow!(why))?; let client = queue(state).map_err(|(_, why)| anyhow::anyhow!(why))?;
modify(&client, |list| reconciled(list, &desired)).await modify(&client, |list| replaced(list, &desired)).await
} }
.await; .await;
match updated { match updated {
@ -178,9 +190,26 @@ pub(crate) async fn follow_env(
Ok(()) Ok(())
} }
/// Reconcile every agent's derived entries from the environments in `store`: /// `list` without the derived entries of the agents in `still_named` whose host
/// the live agents, and every agent that has a derived entry now. An agent /// is not in that agent's set. Nothing is added; entries of agents not in the
/// whose environment cannot be read keeps its entries. /// map and operator-added 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)
});
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. /// Answers how many agents could not be read.
/// ///
@ -189,28 +218,45 @@ pub(crate) async fn follow_env(
pub(crate) async fn reconcile( pub(crate) async fn reconcile(
client: &async_nats::Client, client: &async_nats::Client,
store: &impl AccountStore, store: &impl AccountStore,
live: &BTreeSet<String>,
) -> anyhow::Result<usize> { ) -> anyhow::Result<usize> {
let mut agents = live.clone(); let Some(current) = crate::egress_allow::current(client).await? else {
if let Some(list) = crate::egress_allow::current(client).await? { return Ok(0);
agents.extend( };
list.entries let mut derived: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
.iter() for entry in current.entries.iter().filter(|e| e.derived) {
.filter(|e| e.derived) if let Some(agent) = &entry.agent {
.filter_map(|e| e.agent.clone()), derived
); .entry(agent.clone())
.or_default()
.insert(entry.domain.clone());
}
} }
let base = base_domains(); let base = base_domains();
let mut desired = BTreeMap::new(); let mut still_named = BTreeMap::new();
let mut unreadable = 0; let mut unreadable = 0;
for agent in agents { for (agent, kept) in derived {
let Ok(path) = backend_env::env_path(&agent) else { let Ok(path) = backend_env::env_path(&agent) else {
continue; continue;
}; };
match store.read_optional::<BackendEnv>(&path).await { match store.read_optional::<BackendEnv>(&path).await {
Ok(stored) => { Ok(stored) => {
let entries = stored.map_or_else(Vec::new, |s| derive(&agent, &s.env, &base)); let named = stored.map_or_else(Vec::new, |s| {
desired.insert(agent, entries); 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) => { Err(e) => {
tracing::warn!(%agent, error = %e, "backend egress: reading the backend env failed; its entries stay"); tracing::warn!(%agent, error = %e, "backend egress: reading the backend env failed; its entries stay");
@ -218,34 +264,25 @@ pub(crate) async fn reconcile(
} }
} }
} }
if modify(client, |list| reconciled(list, &desired)).await? { if modify(client, |list| pruned(list, &still_named)).await? {
let derived: usize = desired.values().map(Vec::len).sum();
tracing::info!( tracing::info!(
agents = desired.len(), agents = still_named.len(),
derived, "backend egress: dropped derived entries the stored env no longer names"
"backend egress: backfilled the allow list"
); );
} }
Ok(unreadable) Ok(unreadable)
} }
/// Run [`reconcile`] once the queue and the store answer, retrying with a /// Run [`reconcile`] once the queue and the store answer, retrying with a
/// growing wait until a pass reads every agent. One pass at start: the /// growing wait until a pass reads every agent that has an entry.
/// routes keep the entries after it. pub(crate) fn spawn_backfill(client: async_nats::Client) {
pub(crate) fn spawn_backfill(
client: async_nats::Client,
wanted: Arc<WantedWriter>,
hives: Vec<String>,
) {
tokio::spawn(async move { tokio::spawn(async move {
let mut wait = Duration::from_secs(5); let mut wait = Duration::from_secs(5);
loop { loop {
let pass = async { let pass = async {
swarm_queue_client::ensure_connected(&client)?; swarm_queue_client::ensure_connected(&client)?;
let store = crate::store::connect().await?; let store = crate::store::connect().await?;
let live = crate::subagent_term::live_agents(&wanted, &hives).await; reconcile(&client, &store).await
anyhow::ensure!(!live.is_empty(), "no live agent is declared yet");
reconcile(&client, &store, &live).await
} }
.await; .await;
match pass { match pass {
@ -266,6 +303,8 @@ pub(crate) fn spawn_backfill(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Arc;
use super::*; use super::*;
use crate::agent_limits::tests::declared; use crate::agent_limits::tests::declared;
use crate::linked_accounts::tests::FakeStore; use crate::linked_accounts::tests::FakeStore;
@ -376,7 +415,7 @@ mod tests {
} }
#[test] #[test]
fn reconciling_replaces_only_the_named_agents_derived_entries() { fn replacing_replaces_only_the_named_agents_derived_entries() {
let list = AllowList { let list = AllowList {
entries: vec![ entries: vec![
derived("old.example.com", "atlas"), derived("old.example.com", "atlas"),
@ -389,7 +428,7 @@ mod tests {
"atlas".to_owned(), "atlas".to_owned(),
vec![derived("new.example.com", "atlas")], vec![derived("new.example.com", "atlas")],
)]); )]);
let after = reconciled(list, &desired); let after = replaced(list, &desired);
assert_eq!( assert_eq!(
after.entries, after.entries,
[ [
@ -520,47 +559,126 @@ mod tests {
assert_eq!(list_of(&client).await.len(), 1); 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] #[tokio::test]
async fn the_backfill_derives_from_stored_envs_and_a_second_pass_changes_nothing() { async fn a_leaf_created_outside_the_route_yields_no_entry() {
let nats = TestNats::start(); let nats = TestNats::start();
let client = nats.client().await; let client = nats.client().await;
let store = Arc::new( let store = Arc::new(FakeStore::default());
FakeStore::default() let state = swarm(&client, &store).await;
.with( write_leaf(
"swarm/agents/atlas/backend-env", &store,
json!({"env": "ANTHROPIC_BASE_URL=https://llm.example.com/v1\n"}), "atlas",
"ANTHROPIC_BASE_URL=https://evil.example.net\n",
) )
.with( .await;
"swarm/agents/iris/backend-env",
json!({"env": "ANTHROPIC_API_KEY=sk-live\n"}),
),
);
let live: BTreeSet<String> = ["atlas", "iris", "scribe"].map(str::to_owned).into();
assert_eq!(reconcile(&client, &*store, &live).await.expect("pass"), 0); // 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!( assert_eq!(
list_of(&client).await, list_of(&client).await,
[derived("llm.example.com", "atlas")] [
derived("evil.example.net", "atlas"),
manual("github.com", "atlas")
]
); );
}
let published = published(&client).await; /// A pass keeps an entry whose exact host the stored env still names,
assert_eq!(reconcile(&client, &*store, &live).await.expect("again"), 0); /// drops one it no longer names, and never adds the new host.
assert_eq!( #[tokio::test]
self::published(&client).await, async fn the_backfill_keeps_a_named_host_drops_a_stale_one_and_adds_nothing() {
published, let nats = TestNats::start();
"the second pass writes nothing" 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");
// An agent that stops being live but keeps its leaf keeps its entry; // Control: unchanged content keeps the entry, and a second pass writes
// one whose leaf is gone loses it. // nothing.
let gone: BTreeSet<String> = BTreeSet::new(); assert_eq!(reconcile(&client, &*store).await.expect("pass"), 0);
reconcile(&client, &*store, &gone).await.expect("pass"); assert_eq!(list_of(&client).await, [entry]);
assert_eq!( let before = published(&client).await;
list_of(&client).await, reconcile(&client, &*store).await.expect("again");
[derived("llm.example.com", "atlas")] assert_eq!(published(&client).await, before);
);
let empty = FakeStore::default(); // The stored text changed outside the route: the old host goes, the
reconcile(&client, &empty, &gone).await.expect("pass"); // 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, []); assert_eq!(list_of(&client).await, []);
} }
@ -576,8 +694,7 @@ mod tests {
) )
.await; .await;
let live: BTreeSet<String> = ["atlas"].map(str::to_owned).into(); let unreadable = reconcile(&client, &FakeStore::denying())
let unreadable = reconcile(&client, &FakeStore::denying(), &live)
.await .await
.expect("pass"); .expect("pass");
assert_eq!(unreadable, 1); assert_eq!(unreadable, 1);

View file

@ -387,7 +387,7 @@ pub(crate) fn spawn_per_agent_upkeep(
spawn_subagent_streams(status, hives); spawn_subagent_streams(status, hives);
spawn_schedule_reconcile(status, hives); spawn_schedule_reconcile(status, hives);
spawn_agent_permissions(status, hives); spawn_agent_permissions(status, hives);
spawn_backend_egress_backfill(status, hives); spawn_backend_egress_backfill(status);
crate::attic_copy::spawn(hives.iter().map(|h| h.name.clone()).collect()); crate::attic_copy::spawn(hives.iter().map(|h| h.name.clone()).collect());
crate::proxy_credential::spawn(hives.iter().map(|h| h.name.clone()).collect()); crate::proxy_credential::spawn(hives.iter().map(|h| h.name.clone()).collect());
if let Some(status) = status { if let Some(status) = status {
@ -405,15 +405,12 @@ fn spawn_agent_permissions(status: Option<&Arc<status::StatusReader>>, hives: &[
agent_permissions::spawn(status.queue_client(), wanted, hives); agent_permissions::spawn(status.queue_client(), wanted, hives);
} }
/// Derive each agent's allowed-domain entries from the backend environments in /// Drop the allowed-domain entries derived from backend environments that no
/// the store when a swarm queue is wired up. Same shape as /// longer name their host, when a swarm queue is wired up.
/// [`spawn_subagent_streams`]. fn spawn_backend_egress_backfill(status: Option<&Arc<status::StatusReader>>) {
fn spawn_backend_egress_backfill(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) { if let Some(status) = status {
let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else { crate::backend_egress::spawn_backfill(status.queue_client());
return; }
};
let hives = hives.iter().map(|h| h.name.clone()).collect();
crate::backend_egress::spawn_backfill(status.queue_client(), wanted, hives);
} }
/// Start rewriting every agent's schedule key from the definitions when a /// Start rewriting every agent's schedule key from the definitions when a