1use std::net::SocketAddr;
2use std::sync::Arc;
3
4use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
5use axum::extract::State;
6use axum::http::StatusCode;
7use axum::response::IntoResponse;
8use axum::routing::{get, post};
9use axum::{Json, Router};
10use serde::{Deserialize, Serialize};
11use tokio::sync::mpsc;
12
13use crate::agent::stream::AgentStreamEvent;
14use crate::agent::AgentRunner;
15use crate::channels::http_inject::HttpInjectChannel;
16use crate::channels::IncomingMessage;
17
18#[derive(Debug, Deserialize)]
19pub struct ChatRequestBody {
20 pub message: String,
21 #[serde(default = "default_chat_id")]
22 pub chat_id: String,
23}
24
25fn default_chat_id() -> String {
26 "embed".into()
27}
28
29#[derive(Debug, Serialize)]
30pub struct ChatResponseBody {
31 pub response: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36pub struct StateBody {
37 pub model: String,
38 pub provider: String,
39 pub engine: String,
40 pub mode: String,
41 pub cost_usd: f64,
42 pub total_tokens: usize,
43 pub call_count: usize,
44 pub context_tokens: usize,
47 pub context_window: usize,
51 pub context_pct: u8,
52 pub message_count: usize,
55}
56
57#[derive(Debug, Deserialize)]
58pub struct ModelRequestBody {
59 pub model: String,
60}
61
62#[derive(Debug, Deserialize)]
63pub struct ChatIdRequestBody {
64 #[serde(default = "default_chat_id")]
65 pub chat_id: String,
66}
67
68#[derive(Debug, Deserialize)]
69pub struct ChatIdQuery {
70 #[serde(default = "default_chat_id")]
71 pub chat_id: String,
72}
73
74fn mode_name(mode: &crate::agent::mode::AgentMode) -> &'static str {
75 use crate::agent::mode::AgentMode;
76 match mode {
77 AgentMode::Auto => "auto",
78 AgentMode::BypassPermissions => "bypass",
79 AgentMode::Coding { .. } => "coding",
80 AgentMode::Swarm { .. } => "swarm",
81 }
82}
83
84fn context_percent(used: usize, window: usize) -> u8 {
85 if window == 0 {
86 return 0;
87 }
88 ((used * 100) / window).min(100) as u8
89}
90
91const MESSAGE_COUNT_CAP: usize = 10_000;
96
97pub async fn build_state(runner: &AgentRunner, chat_id: &str) -> StateBody {
98 let summary = runner.get_cost_summary().await;
99 let message_count = runner
100 .memory()
101 .get_conversation_history(chat_id, MESSAGE_COUNT_CAP)
102 .await
103 .map(|history| history.len())
104 .unwrap_or(0);
105 let context_tokens = runner
106 .cost_tracker()
107 .history(1)
108 .await
109 .last()
110 .map(|record| record.input_tokens)
111 .unwrap_or(0);
112 let context_window = runner.agent_config.max_context_chars / 4;
113 StateBody {
114 model: runner.get_model(),
115 provider: runner.provider_name().to_string(),
116 engine: "rx4".into(),
117 mode: mode_name(&runner.get_mode()).into(),
118 cost_usd: summary.total_cost,
119 total_tokens: summary.total_tokens,
120 call_count: summary.call_count,
121 context_tokens,
122 context_window,
123 context_pct: context_percent(context_tokens, context_window),
124 message_count,
125 }
126}
127
128pub async fn chat_once(
129 runner: &AgentRunner,
130 message: &str,
131 chat_id: &str,
132) -> anyhow::Result<String> {
133 let msg = IncomingMessage {
134 id: uuid::Uuid::new_v4().to_string(),
135 sender_id: "http".into(),
136 sender_name: Some("HTTP".into()),
137 chat_id: chat_id.to_string(),
138 text: message.to_string(),
139 is_group: false,
140 reply_to: None,
141 timestamp: chrono::Utc::now(),
142 };
143 let channel = HttpInjectChannel::new();
144 runner.handle_message(&msg, &channel).await
145}
146
147pub fn http_listen_addr() -> SocketAddr {
148 let port = std::env::var("APOLLO_HTTP_PORT")
149 .ok()
150 .and_then(|p| p.parse().ok())
151 .unwrap_or(31338);
152 SocketAddr::from(([127, 0, 0, 1], port))
153}
154
155fn reject_browser_origin(headers: &axum::http::HeaderMap) -> Result<(), StatusCode> {
173 if headers.contains_key(axum::http::header::ORIGIN) {
174 tracing::warn!("rejected apollo HTTP request carrying an Origin header");
175 return Err(StatusCode::FORBIDDEN);
176 }
177 Ok(())
178}
179
180static HTTP_TOKEN: std::sync::OnceLock<String> = std::sync::OnceLock::new();
181
182pub fn token_path() -> Option<std::path::PathBuf> {
188 let home = std::env::var_os("HOME").filter(|h| !h.is_empty())?;
189 Some(std::path::PathBuf::from(home).join(".apollo/http-token"))
190}
191
192pub fn load_or_create_token() -> anyhow::Result<String> {
197 if let Ok(token) = std::env::var("APOLLO_HTTP_TOKEN") {
198 if !token.trim().is_empty() {
199 return Ok(token.trim().to_string());
200 }
201 }
202
203 let path = token_path().ok_or_else(|| anyhow::anyhow!("cannot locate HOME for token file"))?;
204 if let Ok(existing) = std::fs::read_to_string(&path) {
205 if !existing.trim().is_empty() {
206 return Ok(existing.trim().to_string());
207 }
208 }
209
210 let token = format!(
211 "{}{}",
212 uuid::Uuid::new_v4().simple(),
213 uuid::Uuid::new_v4().simple()
214 );
215 if let Some(parent) = path.parent() {
216 std::fs::create_dir_all(parent)?;
217 }
218 crate::fs_secure::write_secret_file(&path, &token)?;
220 tracing::info!("wrote a new apollo HTTP token to {}", path.display());
221 Ok(token)
222}
223
224fn secret_eq(a: &str, b: &str) -> bool {
227 let (a, b) = (a.as_bytes(), b.as_bytes());
228 if a.len() != b.len() {
229 return false;
230 }
231 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
232}
233
234fn check_auth(headers: &axum::http::HeaderMap) -> Result<(), StatusCode> {
235 let Some(expected) = HTTP_TOKEN.get() else {
236 tracing::error!("apollo HTTP has no token configured; refusing request");
239 return Err(StatusCode::SERVICE_UNAVAILABLE);
240 };
241 verify_token(expected, headers)
242}
243
244fn verify_token(expected: &str, headers: &axum::http::HeaderMap) -> Result<(), StatusCode> {
245 let presented = headers
246 .get(axum::http::header::AUTHORIZATION)
247 .and_then(|v| v.to_str().ok())
248 .and_then(|v| v.strip_prefix("Bearer "))
249 .unwrap_or_default();
250
251 if secret_eq(presented.trim(), expected) {
252 Ok(())
253 } else {
254 tracing::warn!("rejected apollo HTTP request with a missing or invalid token");
255 Err(StatusCode::UNAUTHORIZED)
256 }
257}
258
259fn authorize(headers: &axum::http::HeaderMap) -> Result<(), StatusCode> {
261 reject_browser_origin(headers)?;
262 check_auth(headers)
263}
264
265#[must_use = "await this handle on shutdown, or in-flight requests are cut off"]
272pub fn spawn_http_server(runner: Arc<AgentRunner>) -> tokio::task::JoinHandle<()> {
273 let addr = http_listen_addr();
274 match load_or_create_token() {
275 Ok(token) => {
276 let _ = HTTP_TOKEN.set(token);
277 }
278 Err(e) => {
279 tracing::error!("apollo http token: {e}; /v1/chat will refuse all requests");
280 }
281 }
282 tokio::spawn(async move {
283 let app = Router::new()
284 .route("/health", get(|| async { "ok" }))
285 .route("/v1/chat", post(chat_handler))
286 .route("/v1/chat/stream", get(ws_chat_upgrade))
287 .route("/v1/state", get(state_handler))
288 .route("/v1/model", post(model_handler))
289 .route("/v1/clear", post(clear_handler))
290 .route("/shutdown", post(shutdown_handler))
291 .with_state(runner);
292 let listener = match tokio::net::TcpListener::bind(addr).await {
293 Ok(l) => l,
294 Err(e) => {
295 tracing::error!("apollo http bind {}: {}", addr, e);
296 return;
297 }
298 };
299 tracing::info!(
300 "apollo agent HTTP http://{}/v1/chat · WS /v1/chat/stream",
301 addr
302 );
303 if let Err(e) = axum::serve(listener, app)
304 .with_graceful_shutdown(wait_for_shutdown())
305 .await
306 {
307 tracing::error!("apollo http server: {}", e);
308 }
309 })
310}
311
312pub const DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
315
316pub async fn drain_http_server(handle: tokio::task::JoinHandle<()>) {
318 match tokio::time::timeout(DRAIN_TIMEOUT, handle).await {
319 Ok(Ok(())) => {}
320 Ok(Err(e)) => tracing::warn!("apollo http server task ended abnormally: {e}"),
321 Err(_) => tracing::warn!(
322 "apollo http server did not finish draining within {}s; exiting anyway",
323 DRAIN_TIMEOUT.as_secs()
324 ),
325 }
326}
327
328static SHUTDOWN: tokio::sync::Notify = tokio::sync::Notify::const_new();
332static SHUTTING_DOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
333
334pub async fn wait_for_shutdown() {
336 loop {
337 let notified = SHUTDOWN.notified();
340 tokio::pin!(notified);
341 notified.as_mut().enable();
342 if SHUTTING_DOWN.load(std::sync::atomic::Ordering::SeqCst) {
343 return;
344 }
345 notified.await;
346 }
347}
348
349pub fn request_shutdown() {
351 SHUTTING_DOWN.store(true, std::sync::atomic::Ordering::SeqCst);
352 SHUTDOWN.notify_waiters();
353}
354
355async fn shutdown_handler(headers: axum::http::HeaderMap) -> Result<&'static str, StatusCode> {
360 authorize(&headers)?;
361 tracing::info!("shutdown requested over HTTP");
362 request_shutdown();
365 Ok("stopping")
366}
367
368async fn chat_handler(
369 State(runner): State<Arc<AgentRunner>>,
370 headers: axum::http::HeaderMap,
371 Json(body): Json<ChatRequestBody>,
372) -> Result<Json<ChatResponseBody>, StatusCode> {
373 authorize(&headers)?;
374 if body.message.trim().is_empty() {
375 return Err(StatusCode::BAD_REQUEST);
376 }
377 match chat_once(&runner, body.message.trim(), &body.chat_id).await {
378 Ok(response) => Ok(Json(ChatResponseBody { response })),
379 Err(e) => {
380 tracing::error!("http chat: {}", e);
381 Err(StatusCode::INTERNAL_SERVER_ERROR)
382 }
383 }
384}
385
386async fn state_handler(
387 State(runner): State<Arc<AgentRunner>>,
388 axum::extract::Query(query): axum::extract::Query<ChatIdQuery>,
389 headers: axum::http::HeaderMap,
390) -> Result<Json<StateBody>, StatusCode> {
391 authorize(&headers)?;
392 Ok(Json(build_state(&runner, &query.chat_id).await))
393}
394
395async fn model_handler(
396 State(runner): State<Arc<AgentRunner>>,
397 axum::extract::Query(query): axum::extract::Query<ChatIdQuery>,
398 headers: axum::http::HeaderMap,
399 Json(body): Json<ModelRequestBody>,
400) -> Result<Json<StateBody>, StatusCode> {
401 authorize(&headers)?;
402 let model = body.model.trim();
403 if model.is_empty() {
404 return Err(StatusCode::BAD_REQUEST);
405 }
406 if model == "default" || model == "reset" {
407 runner.reset_model();
408 } else {
409 runner.set_model(model);
410 }
411 tracing::info!("model switched over HTTP to {}", runner.get_model());
412 Ok(Json(build_state(&runner, &query.chat_id).await))
413}
414
415#[derive(Debug, Serialize)]
423pub struct ClearedBody {
424 pub cleared: usize,
425 pub chat_id: String,
426}
427
428async fn clear_handler(
429 State(runner): State<Arc<AgentRunner>>,
430 headers: axum::http::HeaderMap,
431 Json(body): Json<ChatIdRequestBody>,
432) -> Result<Json<ClearedBody>, StatusCode> {
433 authorize(&headers)?;
434 let memory = runner.memory();
435 let cleared = memory
436 .get_conversation_history(&body.chat_id, MESSAGE_COUNT_CAP)
437 .await
438 .map(|history| history.len())
439 .unwrap_or(0);
440 if let Err(e) = memory.clear_conversation(&body.chat_id).await {
441 tracing::error!("http clear {}: {}", body.chat_id, e);
442 return Err(StatusCode::INTERNAL_SERVER_ERROR);
443 }
444 tracing::info!("cleared {} messages from {}", cleared, body.chat_id);
445 Ok(Json(ClearedBody {
446 cleared,
447 chat_id: body.chat_id,
448 }))
449}
450
451async fn ws_chat_upgrade(
452 ws: WebSocketUpgrade,
453 State(runner): State<Arc<AgentRunner>>,
454 headers: axum::http::HeaderMap,
455) -> axum::response::Response {
456 if let Err(status) = authorize(&headers) {
457 return status.into_response();
458 }
459 ws.on_upgrade(move |socket| handle_ws_chat(socket, runner))
460}
461
462async fn handle_ws_chat(mut socket: WebSocket, runner: Arc<AgentRunner>) {
463 let Some(Ok(Message::Text(text))) = socket.recv().await else {
464 return;
465 };
466 let Ok(body) = serde_json::from_str::<ChatRequestBody>(&text) else {
467 let _ = socket
468 .send(Message::text(
469 serde_json::json!({"type":"error","message":"invalid JSON"}).to_string(),
470 ))
471 .await;
472 return;
473 };
474 if body.message.trim().is_empty() {
475 let _ = socket
476 .send(Message::text(
477 serde_json::json!({"type":"error","message":"empty message"}).to_string(),
478 ))
479 .await;
480 return;
481 }
482
483 let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<AgentStreamEvent>();
484 let runner_bg = Arc::clone(&runner);
485 let message = body.message.trim().to_string();
486 let chat_id = body.chat_id.clone();
487 let mut chat_task = tokio::spawn(async move {
489 crate::agent::stream::with_turn_sink(Some(stream_tx), async {
490 chat_once(&runner_bg, &message, &chat_id).await
491 })
492 .await
493 });
494
495 let mut client_gone = false;
498
499 loop {
500 tokio::select! {
501 Some(ev) = stream_rx.recv() => {
502 if !client_gone {
503 if let Ok(json) = serde_json::to_string(&ev) {
504 if socket.send(Message::text(json)).await.is_err() {
505 client_gone = true;
506 }
507 }
508 }
509 }
510 result = &mut chat_task => {
511 match result {
512 Ok(Ok(response)) => {
513 let payload = serde_json::to_string(&AgentStreamEvent::Done {
514 response: response.clone(),
515 })
516 .unwrap_or_else(|_| {
517 serde_json::json!({"type":"done","response": response}).to_string()
518 });
519 let _ = socket.send(Message::text(payload)).await;
520 }
521 Ok(Err(e)) => {
522 let payload = serde_json::to_string(&AgentStreamEvent::Error {
523 message: e.to_string(),
524 })
525 .unwrap_or_else(|_| {
526 serde_json::json!({"type":"error","message": e.to_string()}).to_string()
527 });
528 let _ = socket.send(Message::text(payload)).await;
529 }
530 Err(e) => {
531 let payload = serde_json::json!({"type":"error","message": e.to_string()});
532 let _ = socket.send(Message::text(payload.to_string())).await;
533 }
534 }
535 break;
536 }
537 }
538 }
539
540 while let Ok(ev) = stream_rx.try_recv() {
541 if let Ok(json) = serde_json::to_string(&ev) {
542 let _ = socket.send(Message::text(json)).await;
543 }
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use axum::http::{header, HeaderMap, HeaderValue};
551
552 fn headers(pairs: &[(header::HeaderName, &str)]) -> HeaderMap {
553 let mut map = HeaderMap::new();
554 for (name, value) in pairs {
555 map.insert(name.clone(), HeaderValue::from_str(value).unwrap());
556 }
557 map
558 }
559
560 #[test]
561 fn secret_eq_matches_only_identical_secrets() {
562 assert!(secret_eq("abc", "abc"));
563 assert!(!secret_eq("abc", "abd"));
564 assert!(!secret_eq("abc", "ab"));
565 assert!(!secret_eq("", "abc"));
566 assert!(secret_eq("", ""));
567 }
568
569 #[test]
570 fn a_correct_bearer_token_is_accepted() {
571 let h = headers(&[(header::AUTHORIZATION, "Bearer s3cret")]);
572 assert!(verify_token("s3cret", &h).is_ok());
573 }
574
575 #[test]
576 fn a_wrong_missing_or_malformed_token_is_rejected() {
577 for h in [
578 headers(&[(header::AUTHORIZATION, "Bearer wrong")]),
579 headers(&[(header::AUTHORIZATION, "s3cret")]),
580 headers(&[(header::AUTHORIZATION, "Basic s3cret")]),
581 headers(&[]),
582 ] {
583 assert_eq!(verify_token("s3cret", &h), Err(StatusCode::UNAUTHORIZED));
584 }
585 }
586
587 #[test]
588 fn an_origin_header_is_refused_even_with_a_valid_token() {
589 let h = headers(&[
590 (header::AUTHORIZATION, "Bearer s3cret"),
591 (header::ORIGIN, "https://evil.example"),
592 ]);
593 assert_eq!(reject_browser_origin(&h), Err(StatusCode::FORBIDDEN));
594 }
595
596 #[test]
597 fn requests_without_an_origin_pass_the_browser_check() {
598 assert!(reject_browser_origin(&headers(&[])).is_ok());
599 }
600
601 #[test]
602 fn a_model_request_needs_only_the_model_field() {
603 let body: ModelRequestBody = serde_json::from_str(r#"{"model":"claude-opus-4"}"#).unwrap();
604 assert_eq!(body.model, "claude-opus-4");
605 assert!(serde_json::from_str::<ModelRequestBody>("{}").is_err());
606 }
607
608 #[test]
609 fn a_chat_id_request_defaults_to_the_embed_chat() {
610 let body: ChatIdRequestBody = serde_json::from_str("{}").unwrap();
611 assert_eq!(body.chat_id, "embed");
612 let body: ChatIdRequestBody = serde_json::from_str(r#"{"chat_id":"tui"}"#).unwrap();
613 assert_eq!(body.chat_id, "tui");
614 }
615
616 #[test]
617 fn state_round_trips_through_json_with_every_field() {
618 let state = StateBody {
619 model: "claude-sonnet-4-5".into(),
620 provider: "anthropic".into(),
621 engine: "rx4".into(),
622 mode: "auto".into(),
623 cost_usd: 0.25,
624 total_tokens: 1234,
625 call_count: 3,
626 context_tokens: 8000,
627 context_window: 32_000,
628 context_pct: 25,
629 message_count: 12,
630 };
631 let json = serde_json::to_string(&state).unwrap();
632 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
633 for key in [
634 "model",
635 "provider",
636 "engine",
637 "mode",
638 "cost_usd",
639 "total_tokens",
640 "call_count",
641 "context_tokens",
642 "context_window",
643 "context_pct",
644 "message_count",
645 ] {
646 assert!(value.get(key).is_some(), "missing field: {key}");
647 }
648 assert_eq!(serde_json::from_str::<StateBody>(&json).unwrap(), state);
649 }
650
651 #[test]
652 fn context_percent_saturates_and_survives_a_zero_window() {
653 assert_eq!(context_percent(0, 1000), 0);
654 assert_eq!(context_percent(700, 1000), 70);
655 assert_eq!(context_percent(5000, 1000), 100);
656 assert_eq!(context_percent(500, 0), 0);
657 }
658
659 #[test]
660 fn every_agent_mode_has_a_stable_name() {
661 use crate::agent::mode::AgentMode;
662 assert_eq!(mode_name(&AgentMode::Auto), "auto");
663 assert_eq!(mode_name(&AgentMode::BypassPermissions), "bypass");
664 assert_eq!(
665 mode_name(&AgentMode::Coding {
666 plan_approval: false,
667 project_path: None,
668 }),
669 "coding"
670 );
671 assert_eq!(mode_name(&AgentMode::Swarm { parallelism: 2 }), "swarm");
672 }
673}