Watch
0
0
Fork
You've already forked hyperhive
0

swarm UI: send a message to an agent, without its hive

The agent view in the swarm UI gets a message box. The text reaches the
agent's harness straight from swarm-controller over the swarm queue and
becomes an inbox turn from the operator's authelia username. hive-c0re is
not in the path.

- gateway: `/api/` forwards authelia's `Remote-User`, replacing any the
  client sent.
- swarm-controller: `POST /api/agents/{name}/input` takes that header as
  the sender (401 without it) and sends `{from, body}` as a NATS request
  on `$SWARM.input.<agent>`. No subscriber is a 503.
- swarm-nats-auth: the controller is granted publish on `$SWARM.input.*`;
  no hive or agent grant names the family, so the harness can trust
  `from`.
- hive-agent: a task subscribes to the agent's own input subject and
  pushes each message into the serve loop's existing reminder channel,
  renamed `local_msg_*` since it now carries both.

The harness does not reply to the request: no agent grant covers a reply
subject. The controller therefore answers 202 once the request times out
(2s) and 503 on the server's no-responders status.

Closes #4828
This commit is contained in:
atlas 2026-10-04 03:00:05 +02:00
commit 48ac4ac423
13 changed files with 664 additions and 24 deletions

View file

@ -0,0 +1,300 @@
//! `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: no
//! agent grant covers a reply subject. 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<u8>) -> impl Future<Output = Delivery> + Send;
}
impl InputSink for async_nats::Client {
async fn deliver(&self, subject: String, payload: Vec<u8>) -> 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<AppState>,
Path(agent): Path<String>,
headers: HeaderMap,
Json(req): Json<PostAgentInputRequest>,
) -> Result<StatusCode, ProblemDetails> {
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
}
/// 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 = headers
.get(SENDER_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or((
StatusCode::UNAUTHORIZED,
"no Remote-User header: the gateway names the sender, and this request \
did not come through it",
))?
.to_owned();
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<StatusCode, ProblemDetails> {
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<Vec<(String, Vec<u8>)>>,
}
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<u8>) -> 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));
}
}

View file

@ -42,6 +42,7 @@ use utoipa_axum::{router::OpenApiRouter, routes};
mod agent_icon;
mod agent_identity;
mod agent_input;
mod agent_renewal;
mod agent_state_stream;
mod agent_status;
@ -3007,6 +3008,7 @@ fn build_app(state: AppState) -> axum::Router {
.routes(routes!(subagent_term::list_subagents))
.routes(routes!(subagent_term::stream_subagent_term))
.routes(routes!(agent_state_stream::stream_agent_state))
.routes(routes!(agent_input::post_agent_input))
.routes(routes!(issue_report::get_repos))
.routes(routes!(issue_report::get_issue_report_all))
.routes(routes!(issue_report::get_issue_report))