Watch
0
0
Fork
You've already forked hyperhive
0

swarm: serve an agent's icon at swarm scope

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/<name>/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
This commit is contained in:
atlas 2026-09-19 16:02:09 +02:00 • committed by mara
commit 9513058a71
5 changed files with 317 additions and 1 deletions

View file

@ -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<async_nats::jetstream::kv::Store>,
}
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<Option<Vec<u8>>> {
// 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))
}
}

View file

@ -40,6 +40,7 @@ use swarm_authelia_bridge_sock::BridgeResponse;
use utoipa::{OpenApi, ToSchema}; use utoipa::{OpenApi, ToSchema};
use utoipa_axum::{router::OpenApiRouter, routes}; use utoipa_axum::{router::OpenApiRouter, routes};
mod agent_icon;
mod agent_identity; mod agent_identity;
mod agent_state_stream; mod agent_state_stream;
mod agent_status; mod agent_status;
@ -607,6 +608,10 @@ struct AppState {
/// connection, several consumers" rationale as `wanted` above. `None` /// connection, several consumers" rationale as `wanted` above. `None`
/// in exactly the state `status` is. /// in exactly the state `status` is.
agent_status: Option<Arc<agent_status::AgentStatusReader>>, agent_status: Option<Arc<agent_status::AgentStatusReader>>,
/// 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<Arc<agent_icon::AgentIconReader>>,
/// The swarm-level job graph, wrapped in its /// The swarm-level job graph, wrapped in its
/// [`hive_jobq::scheduler::Scheduler`] now that something drives it /// [`hive_jobq::scheduler::Scheduler`] now that something drives it
/// (`spawn_jobq_worker`) — the graph alone was enough for the /// (`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) 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 /// `WantedWriter::view`/`set` call `ensure_connected` internally and
/// propagate through `anyhow`, so the concrete /// 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<status::StatusReader>>,
) -> Option<Arc<agent_icon::AgentIconReader>> {
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. /// 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 /// Reports the status and the detail rather than a rendered
@ -1996,6 +2012,95 @@ where
Ok(Json(MakeForgeAdminResponse { change })) 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 `<img>`
/// 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<AppState>,
Path(name): Path<String>,
) -> Result<axum::response::Response, problem_details::ProblemDetails> {
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 /// 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 /// 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 /// 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()), links: Arc::new(load_links()),
wanted: wanted_writer(status.as_ref()), wanted: wanted_writer(status.as_ref()),
agent_status: agent_status_reader(status.as_ref()), agent_status: agent_status_reader(status.as_ref()),
agent_icons: agent_icon_reader(status.as_ref()),
status, status,
jobq, jobq,
webhook_secret, webhook_secret,
@ -2334,6 +2440,7 @@ fn build_app(state: AppState) -> axum::Router {
.routes(routes!(get_jobq_graph)) .routes(routes!(get_jobq_graph))
.routes(routes!(get_jobq_rollup)) .routes(routes!(get_jobq_rollup))
.routes(routes!(get_agent_config_pr)) .routes(routes!(get_agent_config_pr))
.routes(routes!(get_agent_icon))
.routes(routes!(get_config_prs)) .routes(routes!(get_config_prs))
.routes(routes!(create_agent)) .routes(routes!(create_agent))
.routes(routes!(mint_agent_identity)) .routes(routes!(mint_agent_identity))
@ -2455,6 +2562,7 @@ mod tests {
// that would write a declaration. // that would write a declaration.
wanted: None, wanted: None,
agent_status: None, agent_status: None,
agent_icons: None,
jobq: std::sync::Arc::clone(&sched), jobq: std::sync::Arc::clone(&sched),
webhook_secret: None, webhook_secret: None,
config_prs: 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
/// `<img>` — 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 /// 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 /// 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 /// that is not in this swarm must be refused **before anything is
@ -3688,6 +3823,32 @@ mod tests {
"{problem:?}" "{problem:?}"
); );
} }
/// `GET /api/agents/{name}/icon` against a disconnected queue answers
/// 503 like its siblings, not the 404 an `<img>` 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)] #[cfg(test)]

View file

@ -573,6 +573,7 @@ mod tests {
status: Some(std::sync::Arc::new(status)), status: Some(std::sync::Arc::new(status)),
wanted: None, wanted: None,
agent_status: None, agent_status: None,
agent_icons: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(), hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(), hive_jobq::resources::ResourceTable::new(),

View file

@ -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.<bucket>.<hive>.*`), 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<async_nats::jetstream::kv::Store, Error> {
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.<bucket>.<key>`, 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.<bucket>.<hive>.*`, 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");
}
}

View file

@ -182,6 +182,12 @@ pub mod agent_status;
/// formats it without linking the secret-store client. /// formats it without linking the secret-store client.
pub mod agent_token; 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 /// The subject the swarm controller publishes on when the hive-wide knowledge
/// repository has changed. One writer, many readers — every hive subscribes. /// repository has changed. One writer, many readers — every hive subscribes.
/// ///