//! `POST /api/agents/{name}/input` — text the operator types in the swarm UI, //! handed to one agent's harness as an inbox turn. No hive is in the path: the //! agent subscribes to [`swarm_queue_client::agent_input_subject`] itself. //! //! **The sender is the `Remote-User` header**, which the gateway sets on //! `/api/` from authelia's auth-request answer and so overwrites whatever the //! client sent (`nix/host-modules/swarm-ui.nix`). A request without one is //! refused rather than sent anonymously. //! //! **Sent as a NATS request** so that an agent with no subscriber is a 503 //! instead of text that silently goes nowhere. The agent does not reply to //! input (it does to a command, see [`crate::agent_control`]). A request that //! times out therefore means a subscriber held the message, and that is all //! "sent" means. Core NATS, no stream: an agent that restarts before its turn //! loses the text. use std::time::Duration; use axum::Json; use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode}; use problem_details::ProblemDetails; use serde::Deserialize; use swarm_queue_client::AgentInput; use utoipa::ToSchema; use crate::{AppState, error_problem}; /// The header the gateway names the session's user in. const SENDER_HEADER: &str = "remote-user"; /// How long to wait for the server's no-responders answer. It comes back /// within a round trip when nothing subscribes; a subscriber sends nothing, /// so a send to a live agent always waits this long. const NO_RESPONDERS_WINDOW: Duration = Duration::from_secs(2); /// Text for one agent. #[derive(Deserialize, ToSchema)] pub struct PostAgentInputRequest { /// What the agent's turn shows as the message body, verbatim. Not logged. body: String, } /// What became of one message. #[derive(Debug, PartialEq, Eq)] pub(crate) enum Delivery { /// A subscriber held it. Sent, /// Nothing subscribes to the agent's input subject. NotRunning, /// The queue refused or failed the request. Failed(String), } /// Where a message goes: the queue in production, a fake in the tests. pub(crate) trait InputSink { fn deliver(&self, subject: String, payload: Vec) -> impl Future + Send; } impl InputSink for async_nats::Client { async fn deliver(&self, subject: String, payload: Vec) -> Delivery { use async_nats::RequestErrorKind; let request = async_nats::Request::new() .payload(payload.into()) .timeout(Some(NO_RESPONDERS_WINDOW)); match self.send_request(subject, request).await { Ok(_) => Delivery::Sent, Err(e) => match e.kind() { RequestErrorKind::TimedOut => Delivery::Sent, RequestErrorKind::NoResponders => Delivery::NotRunning, _ => Delivery::Failed(e.to_string()), }, } } } /// Send text to an agent as the logged-in operator. #[utoipa::path( post, path = "/api/agents/{name}/input", params(("name" = String, Path, description = "agent to send the text to")), request_body = PostAgentInputRequest, responses( (status = 202, description = "the agent's harness is subscribed and was handed the \ message; it becomes a turn from the sender when the harness next picks one up"), (status = 400, description = "the agent name is not shaped like an identifier, or \ the body is empty (problem+json)", body = String), (status = 401, description = "the request carries no `Remote-User`, so there is no \ sender to name (problem+json)", body = String), (status = 503, description = "the agent's harness is not subscribed — not running, \ or not connected to the swarm queue — or no swarm queue is configured on this \ host or it could not be reached (problem+json)", body = String), (status = 500, description = "the queue refused or failed the request (problem+json)", body = String), ), tag = "agents" )] pub(crate) async fn post_agent_input( State(state): State, Path(agent): Path, headers: HeaderMap, Json(req): Json, ) -> Result { let (agent, input) = message(&headers, &agent, req.body).map_err(|(s, d)| error_problem(s, d))?; let Some(status) = state.status.as_ref() else { return Err(error_problem( StatusCode::SERVICE_UNAVAILABLE, "no swarm queue is configured on this host", )); }; let client = status.queue_client(); swarm_queue_client::ensure_connected(&client).map_err(|e| { error_problem( StatusCode::SERVICE_UNAVAILABLE, &swarm_queue_client::chain(&e), ) })?; send(&client, &agent, &input).await } /// Who the gateway says is asking, or the 401 for a request that did not come /// through it. Shared with [`crate::agent_control`]. pub(crate) fn sender(headers: &HeaderMap) -> Result { headers .get(SENDER_HEADER) .and_then(|v| v.to_str().ok()) .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_owned) .ok_or(( StatusCode::UNAUTHORIZED, "no Remote-User header: the gateway names the sender, and this request \ did not come through it", )) } /// The agent and the message, or the refusal. Everything decided before the /// queue is touched. fn message( headers: &HeaderMap, agent: &str, body: String, ) -> Result<(String, AgentInput), (StatusCode, &'static str)> { let from = sender(headers)?; let agent = hive_types::Ident::parse(agent) .map_err(|reason| (StatusCode::BAD_REQUEST, reason))? .into_string(); if body.trim().is_empty() { return Err((StatusCode::BAD_REQUEST, "body is empty")); } Ok((agent, AgentInput { from, body })) } async fn send( sink: &impl InputSink, agent: &str, input: &AgentInput, ) -> Result { let subject = swarm_queue_client::agent_input_subject(agent); let payload = serde_json::to_vec(input).map_err(|e| { error_problem( StatusCode::INTERNAL_SERVER_ERROR, &format!("encoding the input failed: {e}"), ) })?; match sink.deliver(subject.clone(), payload).await { Delivery::Sent => { tracing::info!(%agent, from = %input.from, "agent input sent"); Ok(StatusCode::ACCEPTED) } Delivery::NotRunning => Err(error_problem( StatusCode::SERVICE_UNAVAILABLE, &format!( "{agent} is not listening for input: its harness is not running or not connected to the swarm queue" ), )), Delivery::Failed(e) => { tracing::warn!(%agent, %subject, error = %e, "agent input failed"); Err(error_problem( StatusCode::INTERNAL_SERVER_ERROR, &format!("sending to {subject} failed: {e}"), )) } } } #[cfg(test)] mod tests { use std::sync::Mutex; use axum::http::{HeaderMap, HeaderValue, StatusCode}; use swarm_queue_client::AgentInput; use super::{Delivery, InputSink, message, send}; /// Answers every message with one fixed outcome and records what it got. struct FakeSink { answer: fn() -> Delivery, got: Mutex)>>, } impl FakeSink { fn new(answer: fn() -> Delivery) -> Self { Self { answer, got: Mutex::default(), } } } impl InputSink for FakeSink { async fn deliver(&self, subject: String, payload: Vec) -> Delivery { self.got .lock() .expect("unpoisoned") .push((subject, payload)); (self.answer)() } } fn from(user: &str) -> HeaderMap { let mut h = HeaderMap::new(); h.insert("Remote-User", HeaderValue::from_str(user).expect("ascii")); h } fn status_of(r: Result<(String, AgentInput), (StatusCode, &'static str)>) -> StatusCode { r.expect_err("refused").0 } #[test] fn the_sender_is_the_remote_user_header() { let (agent, input) = message(&from("mara"), "atlas", "hi".to_owned()).expect("valid"); assert_eq!(agent, "atlas"); assert_eq!( input, AgentInput { from: "mara".to_owned(), body: "hi".to_owned() } ); } #[test] fn no_sender_is_refused_before_anything_else() { // A bad agent and an empty body as well: the missing sender is the // answer, not whichever check happens to run first. assert_eq!( status_of(message(&HeaderMap::new(), "../x", String::new())), StatusCode::UNAUTHORIZED ); assert_eq!( status_of(message(&from(" "), "atlas", "hi".to_owned())), StatusCode::UNAUTHORIZED ); } #[test] fn a_bad_agent_or_an_empty_body_is_refused() { assert_eq!( status_of(message(&from("mara"), "../x", "hi".to_owned())), StatusCode::BAD_REQUEST ); assert_eq!( status_of(message(&from("mara"), "atlas", " \n".to_owned())), StatusCode::BAD_REQUEST ); } #[tokio::test] async fn a_held_message_is_202_on_the_agent_s_own_subject() { let sink = FakeSink::new(|| Delivery::Sent); let input = AgentInput { from: "mara".to_owned(), body: "hi".to_owned(), }; let status = send(&sink, "atlas", &input).await.expect("sent"); assert_eq!(status, StatusCode::ACCEPTED); let got = sink.got.lock().expect("unpoisoned"); assert_eq!(got.len(), 1); assert_eq!(got[0].0, "$SWARM.input.atlas"); let decoded: AgentInput = serde_json::from_slice(&got[0].1).expect("an AgentInput"); assert_eq!(decoded, input); } #[tokio::test] async fn no_subscriber_is_503_and_a_queue_failure_is_500() { let input = AgentInput { from: "mara".to_owned(), body: "hi".to_owned(), }; let not_running = send(&FakeSink::new(|| Delivery::NotRunning), "atlas", &input) .await .expect_err("nobody listens"); assert_eq!(not_running.status, Some(StatusCode::SERVICE_UNAVAILABLE)); let failed = send( &FakeSink::new(|| Delivery::Failed("refused".to_owned())), "atlas", &input, ) .await .expect_err("the queue failed"); assert_eq!(failed.status, Some(StatusCode::INTERNAL_SERVER_ERROR)); } }