Watch
0
0
Fork
You've already forked hyperhive
0

derive an agent's backend hosts into the egress allow list at the controller

This commit is contained in:
atlas 2026-10-29 01:10:38 +01:00 • committed by mara
commit c5a876b845
18 changed files with 950 additions and 25 deletions

View file

@ -295,10 +295,9 @@ host. It never decrypts TLS, so it holds no signing key. It caches nothing.
(<https://code.claude.com/docs/en/network-config>): `api.anthropic.com` for
the model API and the usage probe (`hive-agent/src/claude_usage_watch.rs`),
`claude.ai` for the account login, and `platform.claude.com` for the OAuth
token exchange and refresh. An agent whose `ANTHROPIC_BASE_URL` or ACP
provider points elsewhere needs that host added to the option: the value
sits in the agent's own environment file, which the host doesn't read. The
list omits the plugin marketplace clone from `github.com`, since a failed add
token exchange and refresh. An agent whose `ANTHROPIC_BASE_URL` points
elsewhere needs no entry here: the swarm controller adds that host to the
agent's own swarm entries (see _Swarm allow-list_ below). The list omits the plugin marketplace clone from `github.com`, since a failed add
isn't fatal. The rules below still refuse an entry that resolves to a
private address.
- **Allow-list.** `services.hyperhive.squid.allowedDomains` defaults to empty,
@ -333,6 +332,18 @@ 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
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 controller also adds entries itself. When an operator saves an agent's
API-key environment, and once when the controller starts, it takes the host
from `ANTHROPIC_BASE_URL` and from every variable named `*_BASE_URL`
(the 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
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
entries, and destroying the agent drops all its entries. The controller reads only the
store's copy: 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 replaces the hive's `allowedDomains` as soon as it exists,
even when empty: add the names the hive allowed to the swarm's list
before relying on it. Until the controller has a list (it predates the

View file

@ -633,6 +633,13 @@ name the proxy's private-range rules leave. **remove** drops one entry;
**add** opens a dialog with a domain box and a tick for every agent in the
swarm. The tick is off by default, so an entry is for this agent alone.
An entry with the badge **automatic** has no **remove**: the controller keeps
it from the host in the agent's stored API-key environment
(`ANTHROPIC_BASE_URL`, or any `*_BASE_URL` variable), so a restricted agent on
another provider reaches it. Saving or removing the environment changes it, and
the API refuses to add or remove one (`400` and `409`). An agent that only has
the hand-placed environment file gets none; add its provider's host here.
A domain is a DNS name of at least two labels, such as `github.com`. One
leading dot, as in `.githubusercontent.com`, also allows every subdomain; the
dot form is the only wildcard, because exact names can't list a service that

View file

@ -299,6 +299,13 @@ text. An agent whose file has content creates the leaf from it again within 2
minutes; one without a file keeps its copy in `/run` until the container
restarts.
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
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
entry, and an operator adds its provider's host in the swarm UI (see
[allowed domains](../swarm/ui.md#allowed-domains)).
Provision the file once, out of band (never through nix — an API key in
the store is world-readable and travels with the flake closure):

View file

@ -16,6 +16,9 @@
// internet flag reaches any name the proxy's private-range rules leave, so an
// entry matters only to a restricted one.
//
// An entry the controller keeps from the agent's backend environment (`derived`)
// shows as automatic with no remove control; the route refuses to remove it.
//
// Its state belongs to one agent: callers key it by agent name.
import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
@ -117,11 +120,23 @@ export function AllowedDomains({ agent }: { agent: string }) {
{shown.length > 0 ? (
<ul class="ui-agent-limits-list">
{shown.map((e) => (
<li key={`${e.agent ?? "*"}/${e.domain}`}>
<li key={`${e.agent ?? "*"}/${e.domain}/${e.derived === true}`}>
<Badge
label={e.agent === undefined ? "swarm-wide" : agent}
label={
e.derived === true
? "automatic"
: e.agent === undefined
? "swarm-wide"
: agent
}
value={e.domain}
title={
e.derived === true
? "kept from this agent's backend environment; change that variable to change it"
: undefined
}
/>{" "}
{e.derived === true ? null : (
<Badge
variant="quiet"
value="remove"
@ -130,6 +145,7 @@ export function AllowedDomains({ agent }: { agent: string }) {
}`}
onClick={() => void remove(e)}
/>
)}
</li>
))}
</ul>

View file

@ -7,6 +7,11 @@ export interface AllowedDomain {
domain: string;
/** Absent for an entry that holds for every agent in the swarm. */
agent?: string;
/**
* Kept by the controller from the agent's backend environment: shown as
* automatic, and the route refuses to remove it.
*/
derived?: boolean;
}
export const ALLOWED_DOMAINS_URL = "/api/egress/allowed-domains";
@ -28,10 +33,12 @@ export function entriesFor(
export function allowedSummary(shown: AllowedDomain[]): string {
if (shown.length === 0) return "none added";
const swarmWide = shown.filter((e) => e.agent === undefined).length;
const own = shown.length - swarmWide;
const automatic = shown.filter((e) => e.derived === true).length;
const own = shown.length - swarmWide - automatic;
return [
swarmWide > 0 ? `${swarmWide} swarm-wide` : null,
own > 0 ? `${own} for this agent` : null,
automatic > 0 ? `${automatic} automatic` : null,
]
.filter((part) => part !== null)
.join(", ");

View file

@ -52,3 +52,13 @@ test("the body is swarm-wide or for the agent, with the name trimmed", () => {
agent: "atlas",
});
});
test("a derived entry is counted as automatic, not as the operator's own", () => {
const derived = { domain: "llm.example.com", agent: "atlas", derived: true };
assert.equal(
allowedSummary([...entriesFor(all, "atlas"), derived]),
"1 swarm-wide, 2 for this agent, 1 automatic",
);
assert.equal(allowedSummary([derived]), "1 automatic");
assert.deepEqual(entriesFor([derived], "atlas"), [derived]);
});

View file

@ -349,6 +349,7 @@ mod tests {
Entry {
domain: domain.to_owned(),
agent: agent.map(str::to_owned),
derived: false,
}
}

View file

@ -289,10 +289,11 @@ in
off the swarm to work at all. The defaults are the hosts Claude Code
requires. `api.anthropic.com` serves the model API and the usage
probe, `claude.ai` the account login, and `platform.claude.com` the
OAuth token exchange and refresh. An agent whose `claude` or ACP provider
points elsewhere (`ANTHROPIC_BASE_URL`, an `opencode` provider) needs
that host listed here: the value lives in the agent's own backend
environment, which this host does not read. Swarm-internal names are
OAuth token exchange and refresh. The swarm controller adds the host of an
agent's stored `ANTHROPIC_BASE_URL` to that agent's swarm entries; an
agent on the hand-placed environment file alone, or an `opencode`
provider, needs its host in the swarm UI or in `allowedDomains`.
Swarm-internal names are
not listed, since agents reach them directly. The destination denies
still apply to every entry, and the list is separate from
`allowedDomains`. A name the swarm's allow-list holds that this list

View file

@ -18,6 +18,11 @@ pub struct AllowedDomain {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schema", schema(example = "atlas"))]
pub agent: Option<String>,
/// Set on an entry the controller keeps from the agent's backend
/// environment (the host of its `ANTHROPIC_BASE_URL`). The list shows it
/// as automatic; adding or removing one is refused.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub derived: bool,
}
/// The swarm's allow-list: every entry, swarm-wide and per agent, sorted.
@ -36,6 +41,7 @@ mod tests {
let swarm_wide = AllowedDomain {
domain: "github.com".to_owned(),
agent: None,
derived: false,
};
let wire = r#"{"domain":"github.com"}"#;
assert_eq!(serde_json::to_string(&swarm_wide).unwrap(), wire);

View file

@ -67,7 +67,8 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
`egress-allow` KV bucket, which this daemon alone writes
(`GET`/`POST`/`DELETE /api/egress/allowed-domains`); every hive reads it and
renders the proxy's allow-list files, and it replaces the list the hive's own
configuration names →
configuration names. It also keeps a `derived` entry per agent for the host
of its stored backend environment's `ANTHROPIC_BASE_URL` →
[`docs/networking/network.md`](../docs/networking/network.md#egress-proxy-squid).
- **Agent instructions** — each agent's `AGENTS.md` as its harness last
published it into the `agents-md` KV bucket

View file

@ -0,0 +1,703 @@
//! 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.
//!
//! The controller sees the environment where it is written (`backend_env`) and
//! in the store, so it derives here and a hive renders the entries like any
//! other. An entry is marked `derived`: [`follow_env`] replaces an agent's
//! derived entries on every write or delete of its environment, and
//! [`spawn_backfill`] reconciles all of them from the stored environments at
//! start. Each 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
//! `backendEnvironmentFile` alone has no derived entry. 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, 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`.
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 = BTreeSet::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.insert(Entry {
derived: true,
..entry
});
}
Err(why) => {
tracing::info!(%agent, variable = name, %why, "backend egress: no entry for this host");
}
}
}
entries.into_iter().collect()
}
/// `list` with, for each agent in `desired`, its derived entries replaced by
/// the given ones. Entries of other agents and operator-added entries are
/// untouched. A full list takes no more derived entries.
fn reconciled(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))));
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| reconciled(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(())
}
/// Reconcile every agent's derived entries from the environments in `store`:
/// the live agents, and every agent that has a derived entry now. An agent
/// whose environment cannot be read keeps its entries.
///
/// 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,
live: &BTreeSet<String>,
) -> anyhow::Result<usize> {
let mut agents = live.clone();
if let Some(list) = crate::egress_allow::current(client).await? {
agents.extend(
list.entries
.iter()
.filter(|e| e.derived)
.filter_map(|e| e.agent.clone()),
);
}
let base = base_domains();
let mut desired = BTreeMap::new();
let mut unreadable = 0;
for agent in agents {
let Ok(path) = backend_env::env_path(&agent) else {
continue;
};
match store.read_optional::<BackendEnv>(&path).await {
Ok(stored) => {
let entries = stored.map_or_else(Vec::new, |s| derive(&agent, &s.env, &base));
desired.insert(agent, entries);
}
Err(e) => {
tracing::warn!(%agent, error = %e, "backend egress: reading the backend env failed; its entries stay");
unreadable += 1;
}
}
}
if modify(client, |list| reconciled(list, &desired)).await? {
let derived: usize = desired.values().map(Vec::len).sum();
tracing::info!(
agents = desired.len(),
derived,
"backend egress: backfilled the allow list"
);
}
Ok(unreadable)
}
/// 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
/// routes keep the entries after it.
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;
anyhow::ensure!(!live.is_empty(), "no live agent is declared yet");
reconcile(&client, &store, &live).await
}
.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 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,
}]
);
}
fn manual(domain: &str, agent: &str) -> Entry {
Entry {
domain: domain.to_owned(),
agent: Some(agent.to_owned()),
derived: false,
}
}
fn derived(domain: &str, agent: &str) -> Entry {
Entry {
derived: true,
..manual(domain, agent)
}
}
#[test]
fn reconciling_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 = reconciled(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"),
]
);
}
/// 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: each hive keeps its own list"
);
// 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);
}
#[tokio::test]
async fn the_backfill_derives_from_stored_envs_and_a_second_pass_changes_nothing() {
let nats = TestNats::start();
let client = nats.client().await;
let store = Arc::new(
FakeStore::default()
.with(
"swarm/agents/atlas/backend-env",
json!({"env": "ANTHROPIC_BASE_URL=https://llm.example.com/v1\n"}),
)
.with(
"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);
assert_eq!(
list_of(&client).await,
[derived("llm.example.com", "atlas")]
);
let published = published(&client).await;
assert_eq!(reconcile(&client, &*store, &live).await.expect("again"), 0);
assert_eq!(
self::published(&client).await,
published,
"the second pass writes nothing"
);
// An agent that stops being live but keeps its leaf keeps its entry;
// one whose leaf is gone loses it.
let gone: BTreeSet<String> = BTreeSet::new();
reconcile(&client, &*store, &gone).await.expect("pass");
assert_eq!(
list_of(&client).await,
[derived("llm.example.com", "atlas")]
);
let empty = FakeStore::default();
reconcile(&client, &empty, &gone).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 live: BTreeSet<String> = ["atlas"].map(str::to_owned).into();
let unreadable = reconcile(&client, &FakeStore::denying(), &live)
.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, []);
}
}

View file

@ -76,6 +76,8 @@ pub async fn put_backend_env(
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
crate::backend_egress::follow_env(&state, &agent, Some(&stored.env)).await?;
tracing::info!(%hive, %agent, "backend env stored");
Ok(StatusCode::NO_CONTENT)
}

View file

@ -28,6 +28,10 @@ use crate::schedules::{live, queue, read_or_default, refused, write_revision};
/// `wanted::MAX_ATTEMPTS`.
const MAX_ATTEMPTS: usize = 5;
/// Why an operator can neither add nor remove an entry the controller derived.
const DERIVED_REFUSED: &str = "this entry is kept automatically from the agent's backend environment; \
change or remove that variable instead";
/// Set from the hive module's `baseAllowedDomains` default by
/// `nix/host-modules/swarm-controller.nix`: the names, separated by spaces,
/// that every hive's proxy lets every authenticated agent reach. Unset means
@ -35,7 +39,7 @@ const MAX_ATTEMPTS: usize = 5;
/// the entries its own base list covers.
pub(crate) const ENV_BASE_DOMAINS: &str = "SWARM_CONTROLLER_SQUID_BASE_DOMAINS";
fn base_domains() -> Vec<String> {
pub(crate) fn base_domains() -> Vec<String> {
std::env::var(ENV_BASE_DOMAINS)
.unwrap_or_default()
.split_whitespace()
@ -60,7 +64,11 @@ pub(crate) fn checked(req: &AllowedDomain, base: &[String]) -> Result<Entry, Str
.map_err(|reason| format!("agent {a:?}: {reason}"))
})
.transpose()?;
Ok(Entry { domain, agent })
Ok(Entry {
domain,
agent,
derived: false,
})
}
/// What adding an entry to a list came to.
@ -95,6 +103,7 @@ fn view(list: AllowList) -> AllowedDomains {
.map(|e| AllowedDomain {
domain: e.domain,
agent: e.agent,
derived: e.derived,
})
.collect(),
}
@ -158,7 +167,7 @@ pub(crate) async fn get_allowed_domains(
(status = 400, description = "the domain is not a DNS name (an IP address, a \
wildcard, a single label and a port are refused), the proxy already allows \
it to everyone, the agent is not an identifier or not declared on any hive, \
or the list is full (problem+json)", body = String),
or the list is full, or the entry is marked derived (problem+json)", body = String),
(status = 422, description = "an unknown field"),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
@ -171,6 +180,9 @@ pub(crate) async fn add_allowed_domain(
State(state): State<AppState>,
Json(req): Json<AllowedDomain>,
) -> Result<Json<AllowedDomains>, ProblemDetails> {
if req.derived {
return Err(error_problem(StatusCode::BAD_REQUEST, DERIVED_REFUSED));
}
let entry = checked(&req, &base_domains())
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
let client = queue(&state).map_err(refused)?;
@ -219,6 +231,9 @@ pub(crate) async fn add_allowed_domain(
(status = 200, description = "the whole list after the removal", body = AllowedDomains),
(status = 400, description = "the domain is not a DNS name, or the agent is not an \
identifier (problem+json)", body = String),
(status = 409, description = "the entry is one the controller keeps from the agent's \
backend environment, which an operator cannot remove (problem+json)",
body = String),
(status = 422, description = "an unknown field"),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
@ -231,6 +246,9 @@ pub(crate) async fn remove_allowed_domain(
State(state): State<AppState>,
Json(req): Json<AllowedDomain>,
) -> Result<Json<AllowedDomains>, ProblemDetails> {
if req.derived {
return Err(error_problem(StatusCode::CONFLICT, DERIVED_REFUSED));
}
// The base list is not consulted: an entry stored before a name joined it
// can still be removed.
let entry = checked(&req, &[]).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
@ -241,6 +259,13 @@ pub(crate) async fn remove_allowed_domain(
.await
.map_err(|e| queue_problem(&e))?;
if !list.entries.contains(&entry) {
if list
.entries
.iter()
.any(|e| e.derived && e.domain == entry.domain && e.agent == entry.agent)
{
return Err(error_problem(StatusCode::CONFLICT, DERIVED_REFUSED));
}
return Ok(Json(view(list)));
}
list.entries.retain(|e| *e != entry);
@ -259,6 +284,87 @@ pub(crate) async fn remove_allowed_domain(
)))
}
/// The stored list, or `None` when no one has written it.
///
/// # Errors
/// A queue failure.
pub(crate) async fn current(client: &async_nats::Client) -> anyhow::Result<Option<AllowList>> {
let Some(store) = egress_allow::open(client).await? else {
return Ok(None);
};
let (list, _, _) = read_or_default::<AllowList>(&store, KEY).await?;
Ok(Some(list))
}
/// Re-read the list and write `change` of it, retrying a lost write, until it
/// is written or `change` leaves it as it is. Answers whether a write landed.
///
/// A list that was never written is written only when `change` makes it
/// non-empty: the key's existence is what makes the swarm's list replace each
/// hive's own, so a change that adds nothing must not create it.
///
/// # Errors
/// A queue failure, or `MAX_ATTEMPTS` conflicting writes.
pub(crate) async fn modify(
client: &async_nats::Client,
change: impl Fn(AllowList) -> AllowList,
) -> anyhow::Result<bool> {
let mut store = egress_allow::open(client).await?;
for _ in 0..MAX_ATTEMPTS {
let (mut list, revision) = match &store {
Some(store) => {
let (list, revision, _) = read_or_default::<AllowList>(store, KEY).await?;
(list, revision)
}
None => (AllowList::default(), None),
};
list.entries.sort();
let mut changed = change(list.clone());
changed.entries.sort();
changed.entries.dedup();
if changed == list && (revision.is_some() || changed.entries.is_empty()) {
return Ok(false);
}
if store.is_none() {
store = Some(egress_allow::open_or_create(client).await?);
}
let target = store.as_ref().expect("opened above");
if write_revision(target, KEY, &changed, revision)
.await?
.is_some()
{
return Ok(true);
}
}
anyhow::bail!("gave up after {MAX_ATTEMPTS} conflicting writes")
}
/// Drop every entry of a destroyed agent, derived and operator-added: a name
/// is free again once its agent is destroyed, and the next agent of that name
/// would inherit what it left. A failure is logged and does not stop the
/// destroy, as in `agent_permissions::clear_on_destroy`.
pub(crate) async fn clear_on_destroy(state: &AppState, hive: &str, agent: &str) {
let cleared = async {
let client = queue(state).map_err(|(_, why)| anyhow::anyhow!(why))?;
modify(&client, |mut list| {
list.entries.retain(|e| e.agent.as_deref() != Some(agent));
list
})
.await
}
.await;
match cleared {
Ok(_) => tracing::info!(hive, agent, "destroyed agent's allowed domains cleared"),
Err(e) => tracing::error!(
hive,
agent,
error = %format!("{e:#}"),
"clearing this agent's allowed domains FAILED; it was destroyed anyway, so a new \
agent of the same name inherits them. Declaring the agent destroyed again re-runs this."
),
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -269,6 +375,7 @@ mod tests {
AllowedDomain {
domain: domain.to_owned(),
agent: agent.map(str::to_owned),
derived: false,
}
}
@ -445,12 +552,14 @@ mod tests {
.map(|i| Entry {
domain: format!("host{i}.example.com"),
agent: None,
derived: false,
})
.collect(),
};
let new = Entry {
domain: "github.com".to_owned(),
agent: None,
derived: false,
};
let why = added(full.clone(), &new).err().expect("a full list");
assert!(why.contains("1000"), "{why}");

View file

@ -621,6 +621,8 @@ pub async fn delete_backend_env(
e.problem()
})?;
crate::backend_egress::follow_env(&state, &agent, None).await?;
tracing::info!(%hive, %agent, "backend env deleted");
Ok(StatusCode::NO_CONTENT)
}

View file

@ -39,6 +39,7 @@ mod alerts;
mod archetypes;
mod attic_copy;
mod auth;
mod backend_egress;
mod backend_env;
mod builder;
mod cache_miss;

View file

@ -17,7 +17,7 @@ use crate::routes::hives::HiveEntry;
use crate::routes::{AppState, StatusUnavailable, error_problem, wanted_error_status};
use crate::{
agent_archetype, agent_identity, agent_limits, agent_permissions, agent_status,
agents_md_initial, auth, inference, schedules, wanted,
agents_md_initial, auth, egress_allow, inference, schedules, wanted,
};
/// The swarm's agent roster — every agent the swarm holds an identity for.
@ -214,6 +214,7 @@ pub(crate) async fn set_agent_state(
agent_permissions::clear_on_destroy(&state, &hive, &agent).await;
agent_archetype::clear_on_destroy(&state, &hive, &agent).await;
agents_md_initial::clear_on_destroy(&state, &hive, &agent).await;
egress_allow::clear_on_destroy(&state, &hive, &agent).await;
}
Ok(Json(render(&declaration)))
}

View file

@ -375,7 +375,8 @@ fn spawn_subagent_streams(status: Option<&Arc<status::StatusReader>>, hives: &[H
}
/// The per-agent background passes: subagent and terminal streams, and
/// schedule keys, and the permissions bucket and its import, and the
/// schedule keys, and the permissions bucket and its import, the allowed
/// domains derived from backend environments, and the
/// archetypes agents are created from. Beside them, the
/// copy of the cache facts to each hive, and the proxy credentials squid checks.
/// Lifted out of `main` for `clippy::too_many_lines`.
@ -386,6 +387,7 @@ pub(crate) fn spawn_per_agent_upkeep(
spawn_subagent_streams(status, hives);
spawn_schedule_reconcile(status, hives);
spawn_agent_permissions(status, hives);
spawn_backend_egress_backfill(status, hives);
crate::attic_copy::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 {
@ -403,6 +405,17 @@ fn spawn_agent_permissions(status: Option<&Arc<status::StatusReader>>, hives: &[
agent_permissions::spawn(status.queue_client(), wanted, hives);
}
/// Derive each agent's allowed-domain entries from the backend environments in
/// the store when a swarm queue is wired up. Same shape as
/// [`spawn_subagent_streams`].
fn spawn_backend_egress_backfill(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else {
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
/// swarm queue is wired up. Same shape as [`spawn_subagent_streams`].
fn spawn_schedule_reconcile(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {

View file

@ -44,6 +44,12 @@ pub struct Entry {
/// The agent this entry is for; absent means every agent in the swarm.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
/// The controller derived this entry from the agent's backend environment
/// and replaces it whenever that changes; an operator can neither add nor
/// remove one. A hive renders it like any other entry, and a reader that
/// predates the field ignores it.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub derived: bool,
}
/// The value under [`BUCKET`]/[`KEY`]. No `deny_unknown_fields`: a field a
@ -222,10 +228,12 @@ mod tests {
Entry {
domain: "github.com".to_owned(),
agent: None,
derived: false,
},
Entry {
domain: "crates.io".to_owned(),
agent: Some("atlas".to_owned()),
derived: false,
},
],
};
@ -238,4 +246,23 @@ mod tests {
AllowList::default()
);
}
/// A hive that predates `derived` decodes the entry through this same
/// tolerance: it keeps `domain` and `agent` and drops the rest.
#[test]
fn a_derived_entry_round_trips_and_an_entry_tolerates_fields_it_does_not_know() {
let derived = Entry {
domain: "llm.example.com".to_owned(),
agent: Some("atlas".to_owned()),
derived: true,
};
let wire = r#"{"domain":"llm.example.com","agent":"atlas","derived":true}"#;
assert_eq!(serde_json::to_string(&derived).unwrap(), wire);
assert_eq!(serde_json::from_str::<Entry>(wire).unwrap(), derived);
// Absent means a manual entry; an unknown field is dropped.
let manual: Entry =
serde_json::from_str(r#"{"domain":"a.io","agent":"atlas","later":1}"#).unwrap();
assert!(!manual.derived);
assert_eq!(manual.domain, "a.io");
}
}