From 9513058a71d1a6b36299c68392a5c93188e3ab64 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 19 Sep 2026 16:02:09 +0200 Subject: [PATCH] swarm: serve an agent's icon at swarm scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent is not fixed to a hive, so its icon cannot be resolved as hive -> agent. This adds the swarm-level half: an `agent-icons` KV bucket keyed by the agent name alone — no hive token, so an agent that moves hives keeps its icon and one that is stopped still has one — and `GET /api/agents//icon` on swarm-controller serving it same-origin, like every other `/api/*` route swarm-ui calls. 404 is the "this agent has no icon" answer, the same contract the per-agent harness's own `GET /icon` has for an unconfigured agent. Until the agent-side publisher lands, that is every agent's answer: the publisher runs inside the container and an agent's NATS grants are hive-scoped, which cannot authorise a write to a single-token agent key. The read side needs no grant change — the controller already holds `$KV.*.>` and `$JS.API.DIRECT.GET.*.>`. The response carries `Content-Security-Policy: sandbox` and `nosniff`: the body is an operator-authored SVG served from this daemon's own origin, and an SVG can carry script. Hive-side icon serving is untouched. Refs #4502 --- swarm-controller/src/agent_icon.rs | 57 +++++++++ swarm-controller/src/main.rs | 163 ++++++++++++++++++++++++- swarm-controller/src/matrix_account.rs | 1 + swarm-queue-client/src/agent_icon.rs | 91 ++++++++++++++ swarm-queue-client/src/lib.rs | 6 + 5 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 swarm-controller/src/agent_icon.rs create mode 100644 swarm-queue-client/src/agent_icon.rs diff --git a/swarm-controller/src/agent_icon.rs b/swarm-controller/src/agent_icon.rs new file mode 100644 index 00000000..c735af16 --- /dev/null +++ b/swarm-controller/src/agent_icon.rs @@ -0,0 +1,57 @@ +//! Reads an agent's icon out of the swarm's agent-icon KV bucket. +//! +//! Sibling of [`crate::agent_status`] and built the same way — the bucket +//! is resolved on first use and cached, a resolution failure is not — but +//! a much smaller read: one key, no roster to be complete against and no +//! freshness to derive. An icon is not a report about the agent, it is a +//! property of it, so there is nothing for a timestamp to mean here. +//! +//! **No hive is named on this path**, because the key does not carry one — +//! see `swarm_queue_client::agent_icon` for why, and for the grant work +//! the publish side is still waiting on. + +use anyhow::{Context, Result}; + +/// Reads the agent-icon bucket. Holds a NATS client rather than a bucket +/// handle, so a controller that starts before the bucket exists picks it +/// up without a restart. +pub struct AgentIconReader { + client: async_nats::Client, + store: tokio::sync::OnceCell, +} + +impl AgentIconReader { + #[must_use] + pub fn new(client: async_nats::Client) -> Self { + Self { + client, + store: tokio::sync::OnceCell::new(), + } + } + + async fn store( + &self, + ) -> std::result::Result<&async_nats::jetstream::kv::Store, swarm_queue_client::Error> { + self.store + .get_or_try_init(|| swarm_queue_client::agent_icon::open_or_create(&self.client)) + .await + } + + /// `agent`'s icon, or `None` when it has published none. + /// + /// The bytes are returned unopened: this daemon does not parse, rewrite + /// or validate the SVG, and a consumer that renders it must treat it as + /// the untrusted document it is — see `get_agent_icon`'s response + /// headers. + pub async fn get(&self, agent: &str) -> Result>> { + // An unconnected client does not fail a JetStream request, it hangs + // on it — see `swarm_queue_client::ensure_connected`. + swarm_queue_client::ensure_connected(&self.client)?; + let store = self.store().await?; + let icon = store + .get(agent) + .await + .with_context(|| format!("reading the agent-icon entry for {agent}"))?; + Ok(icon.map(Into::into)) + } +} diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index a256aa1d..6fc30988 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -40,6 +40,7 @@ use swarm_authelia_bridge_sock::BridgeResponse; use utoipa::{OpenApi, ToSchema}; use utoipa_axum::{router::OpenApiRouter, routes}; +mod agent_icon; mod agent_identity; mod agent_state_stream; mod agent_status; @@ -607,6 +608,10 @@ struct AppState { /// connection, several consumers" rationale as `wanted` above. `None` /// in exactly the state `status` is. agent_status: Option>, + /// Per-agent icons, sharing `status`'s connection — same "one queue + /// connection, several consumers" rationale as `agent_status` above, + /// and `None` in exactly the state `status` is. + agent_icons: Option>, /// The swarm-level job graph, wrapped in its /// [`hive_jobq::scheduler::Scheduler`] now that something drives it /// (`spawn_jobq_worker`) — the graph alone was enough for the @@ -862,7 +867,8 @@ fn error_problem(status: axum::http::StatusCode, detail: &str) -> problem_detail problem_details::ProblemDetails::from_status_code(status).with_detail(detail) } -/// The status a [`wanted::WantedWriter`] failure should answer with. +/// The status a [`wanted::WantedWriter`] or [`agent_icon::AgentIconReader`] +/// failure should answer with. /// /// `WantedWriter::view`/`set` call `ensure_connected` internally and /// propagate through `anyhow`, so the concrete @@ -936,6 +942,16 @@ fn agent_status_reader( }) } +/// The per-agent icon reader, sharing the status reader's connection — +/// same rationale as [`wanted_writer`]. No staleness threshold, unlike +/// [`agent_status_reader`]: an icon is a property of the agent rather than +/// a report about it, so there is no cadence it could be late against. +fn agent_icon_reader( + status: Option<&Arc>, +) -> Option> { + status.map(|s| Arc::new(agent_icon::AgentIconReader::new(s.queue_client()))) +} + /// A hive name that is shaped like one and names a hive this swarm has. /// /// Reports the status and the detail rather than a rendered @@ -1996,6 +2012,95 @@ where Ok(Json(MakeForgeAdminResponse { change })) } +/// How long a served icon may be reused without asking again. +/// +/// An agent roster asks once per row, and an icon changes about as often +/// as an agent is reconfigured — so a few minutes of browser caching is +/// what keeps a page render from being one bucket read per agent, and +/// costs nothing anyone will notice. +const ICON_MAX_AGE: u32 = 300; + +/// `agent`'s icon — the swarm-level answer to "what does this agent look +/// like". +/// +/// **Nothing on this path names a hive.** The icon is a property of the +/// agent, held at swarm scope under the agent's own key, so it is +/// answerable for an agent that has moved hives or that no hive is +/// currently running. That is the whole point of it living here rather +/// than being fetched from wherever the agent happens to run. +/// +/// **404 means this agent has no icon**, which is the expected +/// unconfigured state and not an error — the same contract the per-agent +/// harness's own `GET /icon` already has, so a caller falls back +/// client-side on a failed load rather than probing first. +/// +/// ⚠️ Until the agent-side publisher lands (see +/// `swarm_queue_client::agent_icon`), that 404 is every agent's answer. +/// +/// 🩸 **The body is an untrusted document served from this daemon's own +/// origin**, and an SVG can carry script. The response headers say it may +/// not run any: `Content-Security-Policy: sandbox` with no `allow-scripts` +/// keeps a direct navigation to this URL from executing it, and `nosniff` +/// keeps a browser from deciding the bytes are something else. An `` +/// load — the one consumer — never runs script regardless; the headers are +/// for every other way a URL gets opened. +#[utoipa::path( + get, + path = "/api/agents/{name}/icon", + params(("name" = String, Path, description = "agent name")), + responses( + (status = 200, description = "the agent's icon, an SVG", content_type = "image/svg+xml"), + (status = 400, description = "not an identifier (problem+json)", body = String), + (status = 404, description = "this agent has no icon (problem+json)", body = String), + (status = 500, description = "the icon bucket could not be read (problem+json)", body = String), + (status = 503, description = "no swarm queue is wired up, or it is not connected (problem+json)", body = String), + ), + tag = "agents" +)] +async fn get_agent_icon( + State(state): State, + Path(name): Path, +) -> Result { + use axum::http::{StatusCode, header}; + use axum::response::IntoResponse as _; + + let Some(reader) = state.agent_icons.as_ref() else { + return Err(error_problem( + StatusCode::SERVICE_UNAVAILABLE, + "no swarm queue is wired up on this host", + )); + }; + let agent = hive_types::Ident::parse(&name) + .map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))? + .into_string(); + let icon = reader.get(&agent).await.map_err(|e| { + tracing::warn!(agent = %agent, error = %format!("{e:#}"), "reading the agent icon failed"); + error_problem(wanted_error_status(&e), &format!("{e:#}")) + })?; + let Some(icon) = icon else { + return Err(error_problem( + StatusCode::NOT_FOUND, + "this agent has no icon", + )); + }; + Ok(( + [ + ( + header::CONTENT_TYPE, + swarm_queue_client::agent_icon::MEDIA_TYPE.to_owned(), + ), + ( + header::CACHE_CONTROL, + format!("public, max-age={ICON_MAX_AGE}"), + ), + (header::CONTENT_SECURITY_POLICY, "sandbox".to_owned()), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_owned()), + ], + icon, + ) + .into_response()) +} + /// Every agent with an open config PR, in one response — the bulk /// counterpart to [`get_agent_config_pr`]. swarm-ui's config-PR table needs /// every agent's status to render, and fetching them one at a time doesn't @@ -2303,6 +2408,7 @@ async fn main() -> Result<()> { links: Arc::new(load_links()), wanted: wanted_writer(status.as_ref()), agent_status: agent_status_reader(status.as_ref()), + agent_icons: agent_icon_reader(status.as_ref()), status, jobq, webhook_secret, @@ -2334,6 +2440,7 @@ fn build_app(state: AppState) -> axum::Router { .routes(routes!(get_jobq_graph)) .routes(routes!(get_jobq_rollup)) .routes(routes!(get_agent_config_pr)) + .routes(routes!(get_agent_icon)) .routes(routes!(get_config_prs)) .routes(routes!(create_agent)) .routes(routes!(mint_agent_identity)) @@ -2455,6 +2562,7 @@ mod tests { // that would write a declaration. wanted: None, agent_status: None, + agent_icons: None, jobq: std::sync::Arc::clone(&sched), webhook_secret: None, config_prs: None, @@ -2506,6 +2614,33 @@ mod tests { ); } + /// An icon route with no bucket behind it must say it could not ask, + /// not that the agent has no icon. + /// + /// The two are one status code apart and read identically to an + /// `` — both end in the fallback glyph — which is exactly why the + /// distinction has to be asserted here: nothing downstream would ever + /// notice the 503 silently becoming a 404. + #[tokio::test] + async fn an_icon_with_no_queue_refuses_rather_than_answering_no_icon() { + use axum::response::IntoResponse as _; + + let (state, _sched) = state_with_roster(); + assert!( + state.agent_icons.is_none(), + "the fixture must have no queue" + ); + + let err = super::get_agent_icon( + axum::extract::State(state), + axum::extract::Path("iris".to_owned()), + ) + .await + .expect_err("a queue-less controller cannot answer an icon"); + let resp = err.into_response(); + assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE); + } + /// The roster check is the half that makes the recorded hive worth /// having, so assert it by EFFECT rather than by the message: a hive /// that is not in this swarm must be refused **before anything is @@ -3688,6 +3823,32 @@ mod tests { "{problem:?}" ); } + + /// `GET /api/agents/{name}/icon` against a disconnected queue answers + /// 503 like its siblings, not the 404 an `` would show identically + /// or the 500 of a failed read. + #[tokio::test] + async fn get_agent_icon_answers_503_on_a_disconnected_queue() { + let (state, _sched) = state_with_roster(); + let state = super::AppState { + agent_icons: Some(std::sync::Arc::new( + super::agent_icon::AgentIconReader::new(disconnected_client().await), + )), + ..state + }; + + let problem = super::get_agent_icon( + axum::extract::State(state), + axum::extract::Path("iris".to_owned()), + ) + .await + .expect_err("a disconnected queue must not read as an icon"); + assert_eq!( + problem.status, + Some(axum::http::StatusCode::SERVICE_UNAVAILABLE), + "{problem:?}" + ); + } } #[cfg(test)] diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs index 1b9c68c8..094c1c0d 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -573,6 +573,7 @@ mod tests { status: Some(std::sync::Arc::new(status)), wanted: None, agent_status: None, + agent_icons: None, jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( hive_jobq::Graph::new(), hive_jobq::resources::ResourceTable::new(), diff --git a/swarm-queue-client/src/agent_icon.rs b/swarm-queue-client/src/agent_icon.rs new file mode 100644 index 00000000..2f8e799e --- /dev/null +++ b/swarm-queue-client/src/agent_icon.rs @@ -0,0 +1,91 @@ +//! The per-agent icon KV bucket: one key per agent, holding the icon the +//! swarm shows for it. +//! +//! **Keyed by the agent alone — no hive token**, which is the one way this +//! bucket deliberately differs from its otherwise-identical sibling +//! [`crate::agent_status`] (`{hive}.{agent}`). An agent is not fixed to a +//! hive and can move between them; an icon keyed by where the agent +//! currently runs would be stranded under the old key by a migration, and +//! a reader would have to know the placement to ask the question at all. +//! Operator ruling, hyperhive#4502: *"the bucket is per agent, not per +//! hive. agents can move hives."* +//! +//! That is also what makes the icon answerable for an agent whose +//! container is stopped: the value's lifetime is the agent's, not its +//! placement's. +//! +//! ⚠️ **Nothing writes this bucket yet.** The publisher runs inside the +//! agent's own container, and an agent's NATS grants are hive-scoped +//! (`$KV...*`), which cannot authorise a write to a +//! single-token agent key — so the publish side is blocked on a grant +//! shape the policy layer does not have today. Until it lands, every read +//! here answers "no icon", which is the same answer an agent that never +//! set one gets, and the same 404 the per-agent harness's own `GET /icon` +//! has always returned for an unconfigured agent. + +#[cfg(feature = "kv")] +use crate::Error; + +/// The KV bucket agent icons are published into, keyed by agent name. +/// +/// A constant and not an option, for the reason +/// [`crate::agent_status::BUCKET`] gives: writer and reader must name the +/// same bucket, and an option is a way for the two to disagree. +pub const BUCKET: &str = "agent-icons"; + +/// The media type of every value in this bucket. +/// +/// The value is the icon's bytes **verbatim, not a JSON envelope**: an SVG +/// carries no metadata this bucket would have to describe, and the one +/// consumer serves the bytes straight back out. So the type is fixed here +/// rather than stored per entry — a publisher that has something other +/// than an SVG does not have an agent icon. +pub const MEDIA_TYPE: &str = "image/svg+xml"; + +/// Open the agent-icon bucket, creating it if nothing has yet. +/// +/// `history: 1`, same rationale as [`crate::agent_status::open_or_create`]: +/// a consumer wants each agent's current icon, not every icon it has ever +/// had. +#[cfg(feature = "kv")] +pub async fn open_or_create( + client: &async_nats::Client, +) -> Result { + let js = async_nats::jetstream::new(client.clone()); + match js.get_key_value(BUCKET).await { + Ok(store) => Ok(store), + Err(e) => { + tracing::info!( + bucket = BUCKET, + reason = %e, + "agent-icon bucket not available, creating it" + ); + js.create_key_value(async_nats::jetstream::kv::Config { + bucket: BUCKET.to_owned(), + description: "Current icon published by each agent".to_owned(), + history: 1, + ..Default::default() + }) + .await + .map_err(|source| Error::CreateBucket { + bucket: BUCKET.to_owned(), + source, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::BUCKET; + + /// The published subject is `$KV..`, and this key is the + /// agent name alone — so the subject carries **one** token after the + /// bucket. Pinned here because that is precisely what a hive-scoped + /// grant (`$KV...*`, two tokens) cannot match, and the + /// reason the publish side needs a grant shape of its own. + #[test] + fn the_published_subject_carries_the_agent_and_no_placement() { + assert_eq!(format!("$KV.{BUCKET}.iris"), "$KV.agent-icons.iris"); + } +} diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index 8676a15c..d7e673a6 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -182,6 +182,12 @@ pub mod agent_status; /// formats it without linking the secret-store client. pub mod agent_token; +/// The bucket agent icons are published into — one key per agent, with no +/// hive in it, unlike [`agent_status`]. See the module doc for why the +/// placement stays out of the key, and for what still has to land before +/// anything can write it. +pub mod agent_icon; + /// The subject the swarm controller publishes on when the hive-wide knowledge /// repository has changed. One writer, many readers — every hive subscribes. ///