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