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