Skip to main content

agentmux_srv/server/
messagebus.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! HTTP endpoint handlers for the local MessageBus.
5//!
6//! Routes:
7//!   POST /api/bus/register      - Register an agent
8//!   POST /api/bus/send          - Send a message to an agent
9//!   POST /api/bus/inject        - Inject into an agent's terminal (jekt)
10//!   POST /api/bus/broadcast     - Broadcast to all agents
11//!   GET  /api/bus/messages      - Read queued messages (polling fallback)
12//!   GET  /api/bus/agents        - List connected agents
13//!   POST /api/bus/messages/delete - Delete messages by ID
14
15use axum::{
16    extract::{Query, State},
17    response::Json,
18};
19use serde::Deserialize;
20use serde_json::{json, Value};
21
22use crate::backend::messagebus::{BusMessage, MessageType, Priority};
23use crate::backend::reactive::InjectionRequest;
24use super::AppState;
25
26// ---- Request types ----
27
28#[derive(Deserialize)]
29pub(super) struct RegisterRequest {
30    agent_id: String,
31}
32
33#[derive(Deserialize)]
34pub(super) struct SendRequest {
35    from: String,
36    to: String,
37    payload: String,
38    #[serde(default)]
39    priority: Option<String>,
40}
41
42#[derive(Deserialize)]
43pub(super) struct InjectRequest {
44    from: String,
45    target: String,
46    message: String,
47    #[serde(default)]
48    priority: Option<String>,
49}
50
51#[derive(Deserialize)]
52pub(super) struct BroadcastRequest {
53    from: String,
54    payload: String,
55    #[serde(default)]
56    priority: Option<String>,
57}
58
59#[derive(Deserialize)]
60pub(super) struct ReadMessagesQuery {
61    agent_id: String,
62    #[serde(default = "default_limit")]
63    limit: usize,
64}
65
66fn default_limit() -> usize {
67    100
68}
69
70#[derive(Deserialize)]
71pub(super) struct DeleteMessagesRequest {
72    agent_id: String,
73    message_ids: Vec<String>,
74}
75
76// ---- Helpers ----
77
78fn parse_priority(s: &Option<String>) -> Priority {
79    match s.as_deref() {
80        Some("high") => Priority::High,
81        Some("urgent") => Priority::Urgent,
82        _ => Priority::Normal,
83    }
84}
85
86// ---- Handlers ----
87
88/// POST /api/bus/register
89pub(super) async fn handle_register(
90    State(state): State<AppState>,
91    Json(req): Json<RegisterRequest>,
92) -> Json<Value> {
93    // HTTP-registered agents use polling via /api/bus/messages.
94    // WebSocket agents get their push channel wired in the WS handler.
95    state.messagebus.register_http(&req.agent_id);
96    Json(json!({
97        "status": "registered",
98        "agent_id": req.agent_id,
99    }))
100}
101
102/// POST /api/bus/send
103pub(super) async fn handle_send(
104    State(state): State<AppState>,
105    caller: Option<axum::Extension<super::caller::Caller>>,
106    Json(req): Json<SendRequest>,
107) -> Json<Value> {
108    super::actor::check_actor(
109        &state,
110        caller.as_deref(),
111        super::actor::ActorSite::BusSend,
112        Some(&req.from),
113    );
114    let priority = parse_priority(&req.priority);
115    let msg = BusMessage::new(&req.from, &req.to, MessageType::Send, &req.payload, priority)
116        .with_from_uid(&super::caller::attributed_uid(caller.as_deref()));
117    let msg_id = msg.id.clone();
118
119    match state.messagebus.send(msg) {
120        Ok(()) => Json(json!({
121            "status": "sent",
122            "message_id": msg_id,
123            "to": req.to,
124        })),
125        Err(e) => Json(json!({
126            "status": "error",
127            "error": e,
128        })),
129    }
130}
131
132/// POST /api/bus/inject
133///
134/// Tries ReactiveHandler first (direct PTY write via blockcontroller).
135/// Falls back to MessageBus WebSocket push if agent has no block_id registered.
136pub(super) async fn handle_inject(
137    State(state): State<AppState>,
138    caller: Option<axum::Extension<super::caller::Caller>>,
139    Json(req): Json<InjectRequest>,
140) -> Json<Value> {
141    super::actor::check_actor(
142        &state,
143        caller.as_deref(),
144        super::actor::ActorSite::BusInject,
145        Some(&req.from),
146    );
147    // Try direct PTY injection via ReactiveHandler (agent has registered block_id)
148    let mut reactive_req = InjectionRequest {
149        target_agent: req.target.clone(),
150        message: req.message.clone(),
151        source_agent: Some(req.from.clone()),
152        request_id: None,
153        priority: req.priority.clone(),
154        jekt_tier: None,
155        delivery_tier: Some("host".to_string()),
156        forward_hops: 0,
157        audit_source_uid: super::caller::attributed_uid(caller.as_deref()),
158        ..Default::default()
159    };
160    // reagentx P0 on PR #2565: this endpoint used to call inject_message
161    // directly with a fully client-controlled `source_agent`, bypassing
162    // host-tier signature verification entirely — closing that gap here
163    // means a claimed identity with a signing key on file (this endpoint
164    // never carries a signature) now correctly renders TRUST=unverified and
165    // forces TIER=sensitive, instead of silently passing as self-declared.
166    super::reactive::verify_jekt_signature(&state, &mut reactive_req);
167    let resp = state.reactive_handler.inject_message(reactive_req);
168    if resp.success {
169        return Json(json!({
170            "status": "injected",
171            "via": "pty",
172            "block_id": resp.block_id,
173            "target": req.target,
174        }));
175    }
176
177    // Agent not registered with a block_id — fall back to MessageBus WS push
178    // (only fall back on "agent not found", propagate other errors)
179    let is_not_found = resp.error.as_deref().map(|e| e.contains("not found")).unwrap_or(false);
180    if !is_not_found {
181        return Json(json!({
182            "status": "error",
183            "error": resp.error,
184        }));
185    }
186
187    let priority = parse_priority(&req.priority);
188    let from_uid = super::caller::attributed_uid(caller.as_deref());
189    match state.messagebus.inject(&req.from, &from_uid, &req.target, &req.message, priority) {
190        Ok(msg_id) => Json(json!({
191            "status": "injected",
192            "via": "messagebus",
193            "message_id": msg_id,
194            "target": req.target,
195        })),
196        Err(e) => Json(json!({
197            "status": "error",
198            "error": e,
199        })),
200    }
201}
202
203/// POST /api/bus/broadcast
204pub(super) async fn handle_broadcast(
205    State(state): State<AppState>,
206    caller: Option<axum::Extension<super::caller::Caller>>,
207    Json(req): Json<BroadcastRequest>,
208) -> Json<Value> {
209    super::actor::check_actor(
210        &state,
211        caller.as_deref(),
212        super::actor::ActorSite::BusBroadcast,
213        Some(&req.from),
214    );
215    let priority = parse_priority(&req.priority);
216
217    let from_uid = super::caller::attributed_uid(caller.as_deref());
218    match state.messagebus.broadcast(&req.from, &from_uid, &req.payload, priority) {
219        Ok(delivered) => Json(json!({
220            "status": "broadcast",
221            "delivered": delivered,
222        })),
223        Err(e) => Json(json!({
224            "status": "error",
225            "error": e,
226        })),
227    }
228}
229
230/// GET /api/bus/messages?agent_id=...&limit=...
231pub(super) async fn handle_read_messages(
232    State(state): State<AppState>,
233    Query(query): Query<ReadMessagesQuery>,
234) -> Json<Value> {
235    let messages = state.messagebus.read_messages(&query.agent_id, query.limit);
236    Json(json!({
237        "agent_id": query.agent_id,
238        "messages": messages,
239        "count": messages.len(),
240    }))
241}
242
243/// GET /api/bus/agents
244pub(super) async fn handle_list_agents(
245    State(state): State<AppState>,
246) -> Json<Value> {
247    let agents = state.messagebus.list_agents();
248    Json(json!({
249        "agents": agents,
250        "total_count": agents.len(),
251    }))
252}
253
254/// POST /api/bus/messages/delete
255pub(super) async fn handle_delete_messages(
256    State(state): State<AppState>,
257    Json(req): Json<DeleteMessagesRequest>,
258) -> Json<Value> {
259    let deleted = state.messagebus.delete_messages(&req.agent_id, &req.message_ids);
260    Json(json!({
261        "status": "deleted",
262        "deleted": deleted,
263    }))
264}