1mod agents;
7mod auth;
8mod blueprints;
9mod config;
10mod cursor;
11mod doctor;
12mod fs;
13mod interactions;
14mod mcp;
15mod polling;
16mod runs;
17mod search;
18#[cfg(test)]
19mod testutil;
20mod tls;
21mod tree;
22mod types;
23mod websocket;
24
25use types::ServeLimits;
26pub use types::{AppState, ServeArgs, ServerEvent};
27
28use std::net::SocketAddr;
29use std::sync::Arc;
30
31use axum::Router;
32use axum::routing::{delete, get, post, put};
33use tokio::sync::broadcast;
34use tower_http::cors::{Any, CorsLayer};
35
36use crate::config::Config;
37
38struct AbortOnDrop<T>(tokio::task::JoinHandle<T>);
44
45impl<T> Drop for AbortOnDrop<T> {
46 fn drop(&mut self) {
47 self.0.abort();
48 }
49}
50
51pub async fn execute(
53 args: ServeArgs,
54 control: leviath_runtime::control_socket::ControlClient,
55) -> anyhow::Result<()> {
56 execute_with_shutdown(args, control, Box::pin(std::future::pending()), None).await
57}
58
59fn api_router() -> Router<AppState> {
65 Router::new()
66 .route(
68 "/api/blueprints",
69 get(blueprints::list_blueprints).post(blueprints::create_blueprint),
70 )
71 .route(
72 "/api/blueprints/validate",
73 post(blueprints::validate_blueprint),
74 )
75 .route(
76 "/api/blueprints/{name}",
77 get(blueprints::get_blueprint)
78 .put(blueprints::update_blueprint)
79 .delete(blueprints::delete_blueprint),
80 )
81 .route("/api/runs", get(runs::list_runs))
84 .route(
86 "/api/agents",
87 get(agents::list_agents).post(agents::spawn_agent),
88 )
89 .route("/api/agents/tree", get(tree::agents_tree))
90 .route(
91 "/api/agents/{id}",
92 get(agents::get_agent).delete(agents::kill_agent),
93 )
94 .route("/api/agents/{id}/children", get(agents::agent_children))
95 .route("/api/agents/{id}/context", get(agents::agent_context))
96 .route(
97 "/api/agents/{id}/context/history",
98 get(agents::agent_context_history),
99 )
100 .route("/api/agents/{id}/files", get(agents::agent_file))
101 .route("/api/agents/{id}/logs", get(agents::agent_logs))
102 .route("/api/agents/{id}/result", get(agents::agent_result))
103 .route("/api/agents/{id}/tree-status", get(tree::agent_tree_status))
104 .route("/api/agents/{id}/pause", post(agents::pause_agent))
105 .route("/api/agents/{id}/resume", post(agents::resume_agent))
106 .route("/api/agents/{id}/message", post(interactions::send_message))
108 .route(
110 "/api/agents/{id}/interaction",
111 get(interactions::get_interaction).post(interactions::submit_interaction),
112 )
113 .route("/api/mcp/servers", get(mcp::list_servers))
116 .route("/api/mcp/servers/{name}/status", get(mcp::status))
117 .route("/api/mcp/servers/{name}/login", post(mcp::login))
118 .route("/api/mcp/servers/{name}/test", post(mcp::test_server))
119 .route("/api/doctor", get(doctor::run_doctor))
121 .route("/api/fs/dirs", get(fs::list_dirs))
123 .route("/api/config", get(config::get_config))
125 .route("/api/config/validate", post(config::validate_config_key))
126 .route("/api/models", get(config::get_models))
127 .route("/ws", get(websocket::ws_global))
129 .route("/ws/agents/{id}", get(websocket::ws_agent))
130}
131
132#[cfg(test)]
140fn declared_routes() -> Vec<(String, String)> {
141 const SOURCE: &str = include_str!("mod.rs");
142 let production = SOURCE.split("\nmod tests {").next().unwrap_or(SOURCE);
146 routes_in(production)
147}
148
149#[cfg(test)]
154fn routes_in(source: &str) -> Vec<(String, String)> {
155 let mut routes = Vec::new();
156 for chunk in source.split(".route(").skip(1) {
159 let mut depth = 1usize;
163 let mut body = String::new();
164 for ch in chunk.chars() {
165 match ch {
166 '(' => depth += 1,
167 ')' => {
168 depth -= 1;
169 if depth == 0 {
170 break;
171 }
172 }
173 _ => {}
174 }
175 body.push(ch);
176 }
177 let Some(path) = body
178 .split_once('"')
179 .and_then(|(_, rest)| rest.split_once('"'))
180 .map(|(path, _)| path)
181 else {
182 continue;
183 };
184 if !path.starts_with('/') {
188 continue;
189 }
190 for method in ["get", "post", "put", "delete", "patch"] {
191 if body.contains(&format!("{method}(")) {
192 routes.push((path.to_string(), method.to_uppercase()));
193 }
194 }
195 }
196 routes
197}
198
199async fn execute_with_shutdown(
220 args: ServeArgs,
221 control: leviath_runtime::control_socket::ControlClient,
222 shutdown: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
223 ready: Option<tokio::sync::oneshot::Sender<SocketAddr>>,
224) -> anyhow::Result<()> {
225 let auth_token = std::sync::Arc::new(auth::resolve_token(args.token.as_deref())?);
227 if args.host != "127.0.0.1" && args.host != "localhost" && args.host != "::1" {
229 tracing::warn!(
230 host = %args.host,
231 "serving the agent API on a non-local address - anyone who can reach \
232 this host and holds the token can spawn agents"
233 );
234 }
235
236 let cfg = Config::load()?;
237 let allow_local_network = cfg.security.allow_local_network;
239 for warning in cfg.validate_keys() {
240 tracing::warn!("{}", warning);
241 }
242
243 let (event_tx, _) = broadcast::channel::<ServerEvent>(256);
246
247 let state = AppState {
248 config: Arc::new(cfg),
249 event_tx: event_tx.clone(),
250 control,
251 mcp: mcp::McpAdmin::default(),
252 limits: Arc::new(ServeLimits {
253 workdir_root: args.workdir_root.clone(),
254 no_remote_yolo: args.no_remote_yolo,
255 allow_local_network,
256 }),
257 };
258
259 let event_state = state.clone();
268 let _event_guard = AbortOnDrop(tokio::spawn(polling::event_loop(
269 event_state,
270 polling::RECONNECT_BACKOFF,
271 )));
272
273 let cors = match args.cors.as_deref() {
277 None => None,
278 Some("*") => Some(
279 CorsLayer::new()
280 .allow_origin(Any)
281 .allow_methods(Any)
282 .allow_headers([
287 axum::http::header::AUTHORIZATION,
288 axum::http::header::CONTENT_TYPE,
289 ]),
290 ),
291 Some(origin) => {
292 let value = origin.parse::<axum::http::HeaderValue>().map_err(|_| {
296 anyhow::anyhow!("--cors value '{origin}' is not a valid origin header")
297 })?;
298 Some(
299 CorsLayer::new()
300 .allow_origin(value)
301 .allow_methods(Any)
302 .allow_headers([
307 axum::http::header::AUTHORIZATION,
308 axum::http::header::CONTENT_TYPE,
309 ]),
310 )
311 }
312 };
313
314 let app = api_router();
315
316 let app = match args.allow_admin {
324 true => app
325 .route("/api/mcp/servers", post(mcp::add_server))
326 .route("/api/mcp/servers/{name}", delete(mcp::remove_server))
327 .route("/api/config", put(config::put_config)),
330 false => app,
331 };
332
333 let app = app
334 .layer(axum::middleware::from_fn_with_state(
337 auth_token,
338 auth::require_auth,
339 ))
340 .with_state(state);
341
342 let app = app.merge(Router::new().route("/", get(status_page)));
352 let app = match cors {
356 Some(layer) => app.layer(layer),
357 None => app,
358 };
359
360 let tls = tls::resolve(args.tls_cert.clone(), args.tls_key.clone())?;
364 let tls_config = match &tls {
365 Some(paths) => Some(tls::load(paths).await?),
366 None => None,
367 };
368
369 let addr: SocketAddr = format!("{}:{}", args.host, args.port).parse()?;
370 let scheme = tls::scheme(tls.as_ref());
371 tracing::info!("Listening on {}://{}", scheme, addr);
372 println!("Leviath API server listening on {scheme}://{addr}");
373
374 let listener = tokio::net::TcpListener::bind(addr).await?;
375 if let Some(ready) = ready {
376 let local_addr = listener
379 .local_addr()
380 .expect("infallible: a freshly bound TcpListener always has a local address");
381 let _ = ready.send(local_addr);
382 }
383
384 match tls_config {
385 None => {
388 let _ = axum::serve(listener, app)
389 .with_graceful_shutdown(shutdown)
390 .await;
391 }
392 Some(config) => serve_tls(listener, app, config, shutdown).await,
393 }
394
395 Ok(())
396}
397
398async fn serve_tls(
409 listener: tokio::net::TcpListener,
410 app: Router,
411 config: axum_server::tls_rustls::RustlsConfig,
412 shutdown: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
413) {
414 let handle = axum_server::Handle::new();
415 let signal = handle.clone();
416 tokio::spawn(async move {
417 shutdown.await;
418 signal.graceful_shutdown(Some(std::time::Duration::from_secs(5)));
422 });
423 let std_listener = listener
429 .into_std()
430 .expect("infallible: a bound tokio listener always converts back");
431 let server = axum_server::from_tcp_rustls(std_listener, config)
432 .expect("infallible: the listener is bound and non-blocking, which is all this checks");
433 let _ = server.handle(handle).serve(app.into_make_service()).await;
437}
438
439async fn status_page() -> axum::response::Html<&'static str> {
447 axum::response::Html(
448 "<!doctype html><meta charset=utf-8><title>Leviath</title>\
449 <body style=\"font:16px system-ui;margin:4rem auto;max-width:30rem\">\
450 <h1>Leviath is running.</h1>\
451 <p>The API needs a token; this page does not serve it.</p>",
452 )
453}
454
455#[cfg(test)]
458mod tests {
459 use super::*;
460 use axum::body::Body;
461 use axum::http::{Request, StatusCode};
462 use tower::ServiceExt;
463
464 use crate::runstate::RunMeta;
465 use crate::test_support::with_tracing;
466
467 const OPENAPI: &str = include_str!("../../../../../docs/schema/openapi.json");
469
470 #[test]
478 fn the_api_version_matches_the_spec_it_names() {
479 let spec: serde_json::Value = serde_json::from_str(OPENAPI).expect("the spec is JSON");
480 let documented = spec["info"]["version"]
481 .as_str()
482 .expect("the spec declares a version");
483 assert_eq!(documented, types::API_VERSION);
484 }
485
486 fn documented_routes() -> Vec<(String, String)> {
488 let spec: serde_json::Value = serde_json::from_str(OPENAPI).expect("the spec is JSON");
489 let paths = spec["paths"].as_object().expect("the spec has paths");
490 let mut routes = Vec::new();
491 for (path, item) in paths {
492 let operations = item.as_object().expect("a path item is an object");
493 for method in ["get", "post", "put", "delete", "patch"] {
494 if operations.contains_key(method) {
495 routes.push((path.clone(), method.to_uppercase()));
496 }
497 }
498 }
499 routes
500 }
501
502 type Routes = Vec<(String, String)>;
504
505 fn spec_drift() -> (Routes, Routes) {
508 let declared = declared_routes();
509 let documented = documented_routes();
510 let missing = declared
511 .iter()
512 .filter(|r| !documented.contains(r))
513 .cloned()
514 .collect();
515 let extra = documented
516 .iter()
517 .filter(|r| !declared.contains(r))
518 .cloned()
519 .collect();
520 (missing, extra)
521 }
522
523 #[test]
524 fn the_openapi_spec_documents_exactly_the_routes_this_router_serves() {
525 let (missing, extra) = spec_drift();
536 assert!(missing.is_empty());
537 assert!(extra.is_empty());
538 }
539
540 #[test]
541 fn the_route_reader_finds_the_routes_that_are_actually_there() {
542 let declared = declared_routes();
546 assert!(declared.len() > 25);
547 assert!(declared.contains(&("/api/agents".to_string(), "POST".to_string())));
548 assert!(declared.contains(&("/api/agents/{id}".to_string(), "DELETE".to_string())));
549 assert!(declared.contains(&("/ws".to_string(), "GET".to_string())));
550 }
551
552 #[test]
553 fn the_route_reader_ignores_text_that_is_not_a_route() {
554 let source = concat!(
558 "let x = source.split(\".route(\").skip(1);\n",
559 ".route(\"not a path\", get(h))\n",
560 ".route(\"/real\", get(h).post(h))\n"
561 );
562 assert_eq!(
563 routes_in(source),
564 vec![
565 ("/real".to_string(), "GET".to_string()),
566 ("/real".to_string(), "POST".to_string()),
567 ]
568 );
569 }
570
571 #[test]
572 fn the_route_reader_reads_nothing_out_of_source_with_no_routes() {
573 assert_eq!(routes_in("fn main() {}"), Vec::new());
574 }
575
576 fn assert_execute_failed_on_malformed_config(result: &anyhow::Result<()>) {
581 assert!(
582 result.is_err(),
583 "execute should fail when config is malformed"
584 );
585 }
586
587 #[test]
588 #[should_panic(expected = "execute should fail when config is malformed")]
589 fn assert_execute_failed_on_malformed_config_panics_when_ok() {
590 assert_execute_failed_on_malformed_config(&Ok(()));
591 }
592
593 fn assert_connected_with_bad_api_key(connected: bool) {
596 assert!(connected, "server should start even with a bad API key");
597 }
598
599 #[test]
600 #[should_panic(expected = "server should start even with a bad API key")]
601 fn assert_connected_with_bad_api_key_panics_when_not_connected() {
602 assert_connected_with_bad_api_key(false);
603 }
604
605 fn assert_execute_returned_ok_after_shutdown(result: &Result<(), anyhow::Error>) {
608 assert!(
609 result.is_ok(),
610 "execute should return Ok after graceful shutdown"
611 );
612 }
613
614 #[test]
615 #[should_panic(expected = "execute should return Ok after graceful shutdown")]
616 fn assert_execute_returned_ok_after_shutdown_panics_when_err() {
617 assert_execute_returned_ok_after_shutdown(&Err(anyhow::anyhow!("boom")));
618 }
619
620 fn assert_execute_failed_on_port_in_use(result: &anyhow::Result<()>) {
623 assert!(
624 result.is_err(),
625 "execute should fail when port is already in use"
626 );
627 }
628
629 #[test]
630 #[should_panic(expected = "execute should fail when port is already in use")]
631 fn assert_execute_failed_on_port_in_use_panics_when_ok() {
632 assert_execute_failed_on_port_in_use(&Ok(()));
633 }
634
635 fn assert_execute_with_shutdown_returned_ok(result: &Result<(), anyhow::Error>) {
639 assert!(
640 result.is_ok(),
641 "execute_with_shutdown should return Ok(()) after graceful shutdown"
642 );
643 }
644
645 #[test]
646 #[should_panic(expected = "execute_with_shutdown should return Ok(()) after graceful shutdown")]
647 fn assert_execute_with_shutdown_returned_ok_panics_when_err() {
648 assert_execute_with_shutdown_returned_ok(&Err(anyhow::anyhow!("boom")));
649 }
650
651 fn assert_response_ok(resp_str: &str) {
654 assert!(resp_str.starts_with("HTTP/1.1 200"), "got: {resp_str}");
655 }
656
657 #[test]
658 #[should_panic(expected = "got: HTTP/1.1 404 Not Found")]
659 fn assert_response_ok_panics_when_not_200() {
660 assert_response_ok("HTTP/1.1 404 Not Found\r\n\r\n");
661 }
662
663 fn no_daemon_control() -> leviath_runtime::control_socket::ControlClient {
666 leviath_runtime::control_socket::ControlClient::new(
667 leviath_runtime::control_socket::control_id(std::path::Path::new("/no/such/leviath")),
668 )
669 }
670
671 fn test_state() -> AppState {
672 let (tx, _) = broadcast::channel(64);
673 AppState {
674 config: Arc::new(Config::default()),
675 event_tx: tx,
676 control: no_daemon_control(),
677 mcp: crate::commands::serve::mcp::McpAdmin::default(),
678 limits: Default::default(),
679 }
680 }
681
682 fn test_app() -> Router {
685 api_router().with_state(test_state())
686 }
687
688 #[tokio::test]
689 async fn test_list_blueprints() {
690 let app = test_app();
691 let req = Request::builder()
692 .uri("/api/blueprints")
693 .body(Body::empty())
694 .unwrap();
695 let resp = app.oneshot(req).await.unwrap();
696 assert_eq!(resp.status(), StatusCode::OK);
697 }
698
699 #[tokio::test]
700 async fn test_router_serves_routes_the_old_hand_copy_missed() {
701 let app = test_app();
705 let req = Request::builder()
706 .uri("/api/mcp/servers")
707 .body(Body::empty())
708 .unwrap();
709 let resp = app.oneshot(req).await.unwrap();
710 assert_eq!(resp.status(), StatusCode::OK);
711 }
712
713 #[tokio::test]
714 async fn test_pause_and_resume_routes_are_mounted() {
715 for action in ["pause", "resume"] {
719 let app = test_app();
720 let req = Request::builder()
721 .method("POST")
722 .uri(format!("/api/agents/some-run/{action}"))
723 .body(Body::empty())
724 .unwrap();
725 let resp = app.oneshot(req).await.unwrap();
726 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
727 }
728 }
729
730 #[tokio::test]
731 async fn test_agent_files_route_is_mounted() {
732 let app = test_app();
742 let req = Request::builder()
743 .uri("/api/agents/some-run/files")
744 .body(Body::empty())
745 .unwrap();
746 let resp = app.oneshot(req).await.unwrap();
747 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
748 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
749 .await
750 .unwrap();
751 let error = serde_json::from_slice::<serde_json::Value>(&body)
752 .ok()
753 .and_then(|v| v["error"].as_str().map(str::to_string))
754 .unwrap_or_default();
755 assert!(error.contains("some-run"));
756 }
757
758 #[tokio::test]
759 async fn test_fs_dirs_route_is_mounted() {
760 let app = test_app();
765 let req = Request::builder()
766 .uri("/api/fs/dirs?path=not/absolute")
767 .body(Body::empty())
768 .unwrap();
769 let resp = app.oneshot(req).await.unwrap();
770 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
771 }
772
773 #[tokio::test]
774 async fn test_get_blueprint_not_found() {
775 let app = test_app();
776 let req = Request::builder()
777 .uri("/api/blueprints/nonexistent-agent-xyz")
778 .body(Body::empty())
779 .unwrap();
780 let resp = app.oneshot(req).await.unwrap();
781 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
782 }
783
784 #[tokio::test]
785 async fn test_validate_blueprint_valid() {
786 let app = test_app();
787 let manifest = r#"
788[agent]
789name = "test-agent"
790version = "0.1.0"
791description = "A test"
792
793[stages.main]
794mode = "autonomous"
795[stages.main.model]
796provider = "anthropic"
797model = "claude-sonnet-4-6"
798"#;
799 let body = serde_json::json!({ "manifest": manifest });
800 let req = Request::builder()
801 .method("POST")
802 .uri("/api/blueprints/validate")
803 .header("content-type", "application/json")
804 .body(Body::from(serde_json::to_string(&body).unwrap()))
805 .unwrap();
806 let resp = app.oneshot(req).await.unwrap();
807 assert_eq!(resp.status(), StatusCode::OK);
808
809 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
810 .await
811 .unwrap();
812 let val: types::ValidateResponse = serde_json::from_slice(&body).unwrap();
813 assert!(val.valid);
814 }
815
816 #[tokio::test]
817 async fn test_validate_blueprint_invalid() {
818 let app = test_app();
819 let body = serde_json::json!({ "manifest": "not valid toml {{{{" });
820 let req = Request::builder()
821 .method("POST")
822 .uri("/api/blueprints/validate")
823 .header("content-type", "application/json")
824 .body(Body::from(serde_json::to_string(&body).unwrap()))
825 .unwrap();
826 let resp = app.oneshot(req).await.unwrap();
827 assert_eq!(resp.status(), StatusCode::OK);
828
829 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
830 .await
831 .unwrap();
832 let val: types::ValidateResponse = serde_json::from_slice(&body).unwrap();
833 assert!(!val.valid);
834 assert!(val.errors.is_some());
835 }
836
837 #[tokio::test]
838 async fn test_list_agents() {
839 let app = test_app();
840 let req = Request::builder()
841 .uri("/api/agents")
842 .body(Body::empty())
843 .unwrap();
844 let resp = app.oneshot(req).await.unwrap();
845 assert_eq!(resp.status(), StatusCode::OK);
846 }
847
848 #[tokio::test]
849 async fn test_agents_tree() {
850 let app = test_app();
851 let req = Request::builder()
852 .uri("/api/agents/tree")
853 .body(Body::empty())
854 .unwrap();
855 let resp = app.oneshot(req).await.unwrap();
856 assert_eq!(resp.status(), StatusCode::OK);
857 }
858
859 #[tokio::test]
860 async fn test_get_agent_not_found() {
861 let app = test_app();
862 let req = Request::builder()
863 .uri("/api/agents/nonexistent-run-id-xyz")
864 .body(Body::empty())
865 .unwrap();
866 let resp = app.oneshot(req).await.unwrap();
867 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
868 }
869
870 #[tokio::test]
871 async fn test_agent_children_empty() {
872 let app = test_app();
873 let req = Request::builder()
874 .uri("/api/agents/nonexistent/children")
875 .body(Body::empty())
876 .unwrap();
877 let resp = app.oneshot(req).await.unwrap();
878 assert_eq!(resp.status(), StatusCode::OK);
880 }
881
882 #[tokio::test]
883 async fn test_agent_context_not_found() {
884 let app = test_app();
885 let req = Request::builder()
886 .uri("/api/agents/nonexistent/context")
887 .body(Body::empty())
888 .unwrap();
889 let resp = app.oneshot(req).await.unwrap();
890 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
891 }
892
893 #[tokio::test]
894 async fn test_agent_logs_not_found() {
895 let app = test_app();
896 let req = Request::builder()
897 .uri("/api/agents/nonexistent/logs")
898 .body(Body::empty())
899 .unwrap();
900 let resp = app.oneshot(req).await.unwrap();
901 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
902 }
903
904 #[tokio::test]
905 async fn test_agent_result_not_found() {
906 let app = test_app();
907 let req = Request::builder()
908 .uri("/api/agents/nonexistent/result")
909 .body(Body::empty())
910 .unwrap();
911 let resp = app.oneshot(req).await.unwrap();
912 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
913 }
914
915 #[tokio::test]
916 async fn test_agent_tree_status_not_found() {
917 let app = test_app();
918 let req = Request::builder()
919 .uri("/api/agents/nonexistent/tree-status")
920 .body(Body::empty())
921 .unwrap();
922 let resp = app.oneshot(req).await.unwrap();
923 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
924 }
925
926 #[tokio::test]
927 async fn test_interaction_route_reaches_daemon() {
928 let app = test_app();
931 let req = Request::builder()
932 .uri("/api/agents/nonexistent/interaction")
933 .body(Body::empty())
934 .unwrap();
935 let resp = app.oneshot(req).await.unwrap();
936 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
937 }
938
939 #[tokio::test]
940 async fn test_get_config() {
941 let app = test_app();
942 let req = Request::builder()
943 .uri("/api/config")
944 .body(Body::empty())
945 .unwrap();
946 let resp = app.oneshot(req).await.unwrap();
947 assert_eq!(resp.status(), StatusCode::OK);
948
949 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
950 .await
951 .unwrap();
952 let val: types::RedactedConfig = serde_json::from_slice(&body).unwrap();
953 assert_eq!(val.default_provider, "anthropic");
954 assert!(!val.has_anthropic_key);
956 assert!(!val.has_openai_key);
957 }
958
959 #[tokio::test]
960 async fn test_tree_building() {
961 let runs = vec![
963 RunMeta::new(
964 "parent-1".to_string(),
965 "agent-a".to_string(),
966 "/path".to_string(),
967 "task".to_string(),
968 None,
969 "/work".to_string(),
970 1,
971 ),
972 {
973 let mut child = RunMeta::new(
974 "child-1".to_string(),
975 "agent-b".to_string(),
976 "/path".to_string(),
977 "sub-task".to_string(),
978 None,
979 "/work".to_string(),
980 1,
981 );
982 child.parent_run_id = Some("parent-1".to_string());
983 child.prompt_tokens = 100;
984 child.completion_tokens = 50;
985 child
986 },
987 ];
988
989 let tree = tree::build_tree_status(&runs, None);
990 assert_eq!(tree.len(), 1);
991 assert_eq!(tree[0].run_id, "parent-1");
992 assert_eq!(tree[0].children.len(), 1);
993 assert_eq!(tree[0].subtree_prompt_tokens, 100); assert_eq!(tree[0].subtree_completion_tokens, 50);
995 }
996
997 #[tokio::test]
998 async fn test_delete_blueprint_not_found() {
999 let app = test_app();
1000 let req = Request::builder()
1001 .method("DELETE")
1002 .uri("/api/blueprints/nonexistent-agent-xyz")
1003 .body(Body::empty())
1004 .unwrap();
1005 let resp = app.oneshot(req).await.unwrap();
1006 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1007 }
1008
1009 #[tokio::test]
1010 async fn test_server_event_serialization() {
1011 let event = ServerEvent::AgentStatus {
1012 agent_id: "coder".to_string(),
1013 run_id: "run-123".to_string(),
1014 status: "running".to_string(),
1015 stage: "implement".to_string(),
1016 iteration: 5,
1017 tool_calls: 0,
1018 accepts_messages: true,
1019 };
1020 let json = serde_json::to_string(&event).unwrap();
1021 assert!(json.contains("\"type\":\"agent_status\""));
1022 assert!(json.contains("\"agent_id\":\"coder\""));
1023
1024 let event2 = ServerEvent::Tokens {
1025 agent_id: "coder".to_string(),
1026 run_id: "run-123".to_string(),
1027 prompt_tokens: 5000,
1028 completion_tokens: 1200,
1029 cached_tokens: 0,
1030 cache_write_tokens: 0,
1031 };
1032 let json2 = serde_json::to_string(&event2).unwrap();
1033 assert!(json2.contains("\"type\":\"tokens\""));
1034 assert!(json2.contains("\"prompt_tokens\":5000"));
1035 }
1036
1037 #[tokio::test]
1038 async fn test_full_router_create_blueprint_invalid() {
1039 let app = test_app();
1040 let body = serde_json::json!({
1041 "name": "bad-agent",
1042 "manifest": "not valid toml {{{"
1043 });
1044 let req = Request::builder()
1045 .method("POST")
1046 .uri("/api/blueprints")
1047 .header("content-type", "application/json")
1048 .body(Body::from(serde_json::to_string(&body).unwrap()))
1049 .unwrap();
1050 let resp = app.oneshot(req).await.unwrap();
1051 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1052 }
1053
1054 #[tokio::test]
1055 async fn test_full_router_update_blueprint_not_found() {
1056 let app = test_app();
1057 let body = serde_json::json!({
1058 "manifest": r#"
1059[agent]
1060name = "no-such-agent"
1061version = "1.0.0"
1062description = "Missing"
1063
1064[stages.run]
1065system_prompt = "Run"
1066"#
1067 });
1068 let req = Request::builder()
1069 .method("PUT")
1070 .uri("/api/blueprints/no-such-agent-xyz-99999")
1071 .header("content-type", "application/json")
1072 .body(Body::from(serde_json::to_string(&body).unwrap()))
1073 .unwrap();
1074 let resp = app.oneshot(req).await.unwrap();
1075 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1076 }
1077
1078 #[tokio::test]
1079 async fn test_full_router_kill_agent_reaches_daemon() {
1080 let app = test_app();
1081 let req = Request::builder()
1082 .method("DELETE")
1083 .uri("/api/agents/nonexistent-kill-id-xyz")
1084 .body(Body::empty())
1085 .unwrap();
1086 let resp = app.oneshot(req).await.unwrap();
1087 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1088 }
1089
1090 #[tokio::test]
1091 async fn test_full_router_send_message_reaches_daemon() {
1092 let app = test_app();
1093 let body = serde_json::json!({"message": "hello"});
1094 let req = Request::builder()
1095 .method("POST")
1096 .uri("/api/agents/nonexistent-msg-id-xyz/message")
1097 .header("content-type", "application/json")
1098 .body(Body::from(serde_json::to_string(&body).unwrap()))
1099 .unwrap();
1100 let resp = app.oneshot(req).await.unwrap();
1101 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1102 }
1103
1104 #[tokio::test]
1105 async fn test_full_router_get_models() {
1106 let app = test_app();
1107 let req = Request::builder()
1108 .uri("/api/models")
1109 .body(Body::empty())
1110 .unwrap();
1111 let resp = app.oneshot(req).await.unwrap();
1112 assert_eq!(resp.status(), StatusCode::OK);
1113 }
1114
1115 #[tokio::test]
1116 async fn test_full_router_spawn_agent_blueprint_not_found() {
1117 let app = test_app();
1118 let body = serde_json::json!({
1119 "blueprint": "nonexistent-blueprint-xyz",
1120 "task": "do something"
1121 });
1122 let req = Request::builder()
1123 .method("POST")
1124 .uri("/api/agents")
1125 .header("content-type", "application/json")
1126 .body(Body::from(serde_json::to_string(&body).unwrap()))
1127 .unwrap();
1128 let resp = app.oneshot(req).await.unwrap();
1129 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1130 }
1131
1132 #[test]
1133 fn test_serve_args_defaults() {
1134 let args = ServeArgs {
1135 port: 3000,
1136 host: "127.0.0.1".to_string(),
1137 cors: None,
1138 token: Some("test-token".to_string()),
1139 allow_admin: false,
1140 workdir_root: None,
1141 no_remote_yolo: false,
1142 tls_cert: None,
1143 tls_key: None,
1144 };
1145 assert_eq!(args.port, 3000);
1146 assert_eq!(args.host, "127.0.0.1");
1147 assert_eq!(args.cors, None);
1148 }
1149
1150 #[test]
1151 fn test_app_state_clone() {
1152 let state = test_state();
1153 let cloned = state.clone();
1154 let _ = cloned.config.default_provider.clone();
1156 }
1157
1158 #[test]
1159 fn test_cors_wildcard_vs_specific() {
1160 let wildcard = "*";
1162 let specific = "https://example.com";
1163
1164 let is_wildcard = wildcard == "*";
1165 assert!(is_wildcard);
1166
1167 let is_specific = specific != "*";
1168 assert!(is_specific);
1169
1170 let parsed = specific.parse::<axum::http::HeaderValue>();
1172 assert!(parsed.is_ok());
1173 }
1174
1175 #[test]
1176 fn test_cors_invalid_origin_falls_back() {
1177 let invalid_cors = "not a valid header value \x00";
1178 let result = invalid_cors.parse::<axum::http::HeaderValue>();
1179 assert!(result.is_err());
1181 }
1182
1183 #[tokio::test]
1184 async fn test_submit_interaction_full_router_reaches_daemon() {
1185 let app = test_app();
1189 let body = serde_json::json!({"request_id": "req-1", "value": "do it", "scope": "once"});
1190 let req = Request::builder()
1191 .method("POST")
1192 .uri("/api/agents/any/interaction")
1193 .header("content-type", "application/json")
1194 .body(Body::from(serde_json::to_string(&body).unwrap()))
1195 .unwrap();
1196 let resp = app.oneshot(req).await.unwrap();
1197 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1198 }
1199
1200 #[tokio::test]
1220 async fn execute_binds_and_serves_with_wildcard_cors() {
1221 crate::config::with_isolated_config_path_async(
1222 "serve-mod-wildcard-cors",
1223 |_fake_dir| async move {
1224 with_tracing(|| {});
1225 let args = ServeArgs {
1235 port: 0,
1236 host: "127.0.0.1".to_string(),
1237 cors: None,
1238 token: Some("test-token".to_string()),
1239 allow_admin: false,
1240 workdir_root: None,
1241 no_remote_yolo: false,
1242 tls_cert: None,
1243 tls_key: None,
1244 };
1245 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1246 let handle = tokio::spawn(execute_with_shutdown(
1247 args,
1248 no_daemon_control(),
1249 Box::pin(std::future::pending()),
1250 Some(ready_tx),
1251 ));
1252 let addr = ready_rx
1253 .await
1254 .expect("server should report its bound address");
1255
1256 let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
1258 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1259 stream
1260 .write_all(
1261 b"GET /api/config HTTP/1.1\r\nHost: localhost\r\n\
1262 Authorization: Bearer test-token\r\nConnection: close\r\n\r\n",
1263 )
1264 .await
1265 .unwrap();
1266 let mut resp = Vec::new();
1267 stream.read_to_end(&mut resp).await.unwrap();
1268 let resp_str = String::from_utf8_lossy(&resp);
1269 assert_response_ok(&resp_str);
1270
1271 let mut unauth = tokio::net::TcpStream::connect(addr).await.unwrap();
1273 unauth
1274 .write_all(
1275 b"GET /api/config HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
1276 )
1277 .await
1278 .unwrap();
1279 let mut resp2 = Vec::new();
1280 unauth.read_to_end(&mut resp2).await.unwrap();
1281 assert!(
1282 String::from_utf8_lossy(&resp2).starts_with("HTTP/1.1 401"),
1283 "unauthenticated request should be 401"
1284 );
1285
1286 handle.abort();
1287 },
1288 )
1289 .await;
1290 }
1291
1292 #[tokio::test]
1300 async fn execute_serves_https_and_the_status_page_needs_no_token() {
1301 crate::config::with_isolated_config_path_async("serve-mod-tls", |_fake_dir| async move {
1302 with_tracing(|| {});
1303 let dir = tempfile::tempdir().expect("tempdir");
1304 let cert = dir.path().join("cert.pem");
1305 let key = dir.path().join("key.pem");
1306 std::fs::write(&cert, tls::tests::TEST_CERT).expect("write cert");
1307 std::fs::write(&key, tls::tests::TEST_KEY).expect("write key");
1308
1309 let args = ServeArgs {
1310 port: 0,
1311 host: "127.0.0.1".to_string(),
1312 cors: None,
1313 token: Some("test-token".to_string()),
1314 allow_admin: false,
1315 workdir_root: None,
1316 no_remote_yolo: false,
1317 tls_cert: Some(cert),
1318 tls_key: Some(key),
1319 };
1320 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1321 let handle = tokio::spawn(execute_with_shutdown(
1322 args,
1323 no_daemon_control(),
1324 Box::pin(std::future::pending()),
1325 Some(ready_tx),
1326 ));
1327 let addr = ready_rx.await.expect("server reports its address");
1328
1329 let mut roots = tokio_rustls::rustls::RootCertStore::empty();
1333 use rustls_pki_types::pem::PemObject;
1334 for der in
1335 rustls_pki_types::CertificateDer::pem_slice_iter(tls::tests::TEST_CA.as_bytes())
1336 {
1337 roots
1338 .add(der.expect("a parseable certificate"))
1339 .expect("add to the root store");
1340 }
1341 let client_config = tokio_rustls::rustls::ClientConfig::builder()
1342 .with_root_certificates(roots)
1343 .with_no_client_auth();
1344 let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(client_config));
1345
1346 let stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
1347 let server_name = tokio_rustls::rustls::pki_types::ServerName::try_from("localhost")
1348 .expect("a valid name");
1349 let mut tls_stream = connector
1350 .connect(server_name, stream)
1351 .await
1352 .expect("the TLS handshake succeeds against the served certificate");
1353
1354 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1355 tls_stream
1356 .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
1357 .await
1358 .expect("write");
1359 let mut resp = Vec::new();
1360 tls_stream.read_to_end(&mut resp).await.expect("read");
1361 let text = String::from_utf8_lossy(&resp).into_owned();
1362
1363 assert!(text.starts_with("HTTP/1.1 200"), "{text}");
1366 assert!(text.contains("Leviath is running."), "{text}");
1367
1368 handle.abort();
1369 })
1370 .await;
1371 }
1372
1373 #[tokio::test]
1377 async fn a_bad_tls_configuration_stops_the_server_before_it_binds() {
1378 crate::config::with_isolated_config_path_async(
1379 "serve-mod-tls-bad",
1380 |_fake_dir| async move {
1381 with_tracing(|| {});
1382 let dir = tempfile::tempdir().expect("tempdir");
1383 let cert = dir.path().join("cert.pem");
1384 std::fs::write(&cert, "not a certificate").expect("write");
1385
1386 let base = ServeArgs {
1387 port: 0,
1388 host: "127.0.0.1".to_string(),
1389 cors: None,
1390 token: Some("test-token".to_string()),
1391 allow_admin: false,
1392 workdir_root: None,
1393 no_remote_yolo: false,
1394 tls_cert: None,
1395 tls_key: None,
1396 };
1397
1398 let lone = ServeArgs {
1400 tls_cert: Some(cert.clone()),
1401 ..base.clone()
1402 };
1403 let err = execute_with_shutdown(
1404 lone,
1405 no_daemon_control(),
1406 Box::pin(std::future::pending()),
1407 None,
1408 )
1409 .await
1410 .expect_err("one TLS flag alone is refused");
1411 let message = format!("{err:#}");
1412 assert!(message.contains("--tls-key"), "{message}");
1413
1414 let key = dir.path().join("key.pem");
1416 std::fs::write(&key, tls::tests::TEST_KEY).expect("write");
1417 let unreadable = ServeArgs {
1418 tls_cert: Some(cert),
1419 tls_key: Some(key),
1420 ..base
1421 };
1422 let err = execute_with_shutdown(
1423 unreadable,
1424 no_daemon_control(),
1425 Box::pin(std::future::pending()),
1426 None,
1427 )
1428 .await
1429 .expect_err("a malformed certificate is refused");
1430 let message = format!("{err:#}");
1431 assert!(message.contains("cert.pem"), "{message}");
1432 },
1433 )
1434 .await;
1435 }
1436
1437 #[tokio::test]
1444 async fn https_shuts_down_when_its_signal_resolves() {
1445 crate::config::with_isolated_config_path_async(
1446 "serve-mod-tls-shutdown",
1447 |_fake_dir| async move {
1448 with_tracing(|| {});
1449 let dir = tempfile::tempdir().expect("tempdir");
1450 let cert = dir.path().join("cert.pem");
1451 let key = dir.path().join("key.pem");
1452 std::fs::write(&cert, tls::tests::TEST_CERT).expect("write cert");
1453 std::fs::write(&key, tls::tests::TEST_KEY).expect("write key");
1454
1455 let args = ServeArgs {
1456 port: 0,
1457 host: "127.0.0.1".to_string(),
1458 cors: None,
1459 token: Some("test-token".to_string()),
1460 allow_admin: false,
1461 workdir_root: None,
1462 no_remote_yolo: false,
1463 tls_cert: Some(cert),
1464 tls_key: Some(key),
1465 };
1466 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>();
1467 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1468 let server = tokio::spawn(execute_with_shutdown(
1469 args,
1470 no_daemon_control(),
1471 Box::pin(async move {
1472 let _ = stop_rx.await;
1473 }),
1474 Some(ready_tx),
1475 ));
1476 ready_rx.await.expect("server reports its address");
1477
1478 stop_tx.send(()).expect("the server is listening for this");
1479 let finished = tokio::time::timeout(std::time::Duration::from_secs(10), server)
1483 .await
1484 .expect("the server should stop on its own");
1485 finished
1486 .expect("the task should not panic")
1487 .expect("a clean shutdown is not an error");
1488 },
1489 )
1490 .await;
1491 }
1492
1493 #[tokio::test]
1499 async fn execute_cors_preflight_allows_authorization_header() {
1500 crate::config::with_isolated_config_path_async(
1501 "serve-mod-cors-preflight",
1502 |_fake_dir| async move {
1503 with_tracing(|| {});
1504 let args = ServeArgs {
1505 port: 0,
1506 host: "127.0.0.1".to_string(),
1507 cors: Some("*".to_string()),
1508 token: Some("test-token".to_string()),
1509 allow_admin: false,
1510 workdir_root: None,
1511 no_remote_yolo: false,
1512 tls_cert: None,
1513 tls_key: None,
1514 };
1515 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1516 let handle = tokio::spawn(execute_with_shutdown(
1517 args,
1518 no_daemon_control(),
1519 Box::pin(std::future::pending()),
1520 Some(ready_tx),
1521 ));
1522 let addr = ready_rx
1523 .await
1524 .expect("server should report its bound address");
1525
1526 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1527 let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
1528 stream
1529 .write_all(
1530 b"OPTIONS /api/config HTTP/1.1\r\nHost: localhost\r\n\
1531 Origin: https://leviath.dev\r\n\
1532 Access-Control-Request-Method: GET\r\n\
1533 Access-Control-Request-Headers: authorization\r\n\
1534 Connection: close\r\n\r\n",
1535 )
1536 .await
1537 .unwrap();
1538 let mut resp = Vec::new();
1539 stream.read_to_end(&mut resp).await.unwrap();
1540 let lower = String::from_utf8_lossy(&resp).to_lowercase();
1541 assert!(
1542 lower.contains("access-control-allow-headers")
1543 && lower.contains("authorization"),
1544 "preflight must allow the Authorization header, got:\n{lower}"
1545 );
1546
1547 handle.abort();
1548 },
1549 )
1550 .await;
1551 }
1552
1553 #[tokio::test]
1554 async fn execute_with_specific_cors_origin_serves() {
1555 crate::config::with_isolated_config_path_async(
1556 "serve-mod-specific-cors",
1557 |_fake_dir| async move {
1558 let args = ServeArgs {
1559 port: 0,
1560 host: "127.0.0.1".to_string(),
1561 cors: Some("https://example.com".to_string()),
1562 token: Some("test-token".to_string()),
1563 allow_admin: false,
1564 workdir_root: None,
1565 no_remote_yolo: false,
1566 tls_cert: None,
1567 tls_key: None,
1568 };
1569 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1570 let handle = tokio::spawn(execute_with_shutdown(
1571 args,
1572 no_daemon_control(),
1573 Box::pin(std::future::pending()),
1574 Some(ready_tx),
1575 ));
1576 let addr = ready_rx
1577 .await
1578 .expect("server should report its bound address");
1579 assert!(tokio::net::TcpStream::connect(addr).await.is_ok());
1580
1581 handle.abort();
1582 },
1583 )
1584 .await;
1585 }
1586
1587 #[tokio::test]
1588 async fn execute_with_unparseable_addr_returns_err() {
1589 crate::config::with_isolated_config_path_async("serve-badaddr", |_fake_dir| async move {
1592 let args = ServeArgs {
1595 port: 0,
1596 host: "not a valid host".to_string(),
1597 cors: None,
1598 token: Some("test-token".to_string()),
1599 allow_admin: false,
1600 workdir_root: None,
1601 no_remote_yolo: false,
1602 tls_cert: None,
1603 tls_key: None,
1604 };
1605 let result = execute(args, no_daemon_control()).await;
1606 assert!(result.is_err());
1607 })
1608 .await;
1609 }
1610
1611 #[tokio::test]
1612 async fn test_agent_list_with_status_filter_full_router() {
1613 let app = test_app();
1614 let req = Request::builder()
1615 .uri("/api/agents?status=running,complete")
1616 .body(Body::empty())
1617 .unwrap();
1618 let resp = app.oneshot(req).await.unwrap();
1619 assert_eq!(resp.status(), StatusCode::OK);
1620 }
1621
1622 #[tokio::test]
1625 async fn execute_with_malformed_config_returns_err() {
1626 crate::config::with_isolated_config_path_async(
1627 "serve-mod-malformed",
1628 |_fake_dir| async move {
1629 std::fs::write(Config::config_path(), "not valid toml [[[").unwrap();
1631
1632 let args = ServeArgs {
1633 port: 0,
1634 host: "127.0.0.1".to_string(),
1635 cors: None,
1636 token: Some("test-token".to_string()),
1637 allow_admin: false,
1638 workdir_root: None,
1639 no_remote_yolo: false,
1640 tls_cert: None,
1641 tls_key: None,
1642 };
1643 let result = execute(args, no_daemon_control()).await;
1644 assert_execute_failed_on_malformed_config(&result);
1645 },
1646 )
1647 .await;
1648 }
1649
1650 #[tokio::test]
1654 async fn execute_with_bad_api_key_logs_warning_and_serves() {
1655 with_tracing(|| {});
1656 crate::config::with_isolated_config_path_async("serve-mod-badkey", |_fake_dir| async move {
1657 std::fs::write(
1659 Config::config_path(),
1660 "default_provider = \"anthropic\"\nagent_paths = []\n[providers]\nanthropic_api_key = \"bad-key-not-sk-ant\"\n",
1661 )
1662 .unwrap();
1663
1664 let args = ServeArgs {
1665 port: 0,
1666 host: "127.0.0.1".to_string(),
1667 cors: None,
1668 token: Some("test-token".to_string()),
1669 allow_admin: false,
1670 workdir_root: None,
1671 no_remote_yolo: false,
1672 tls_cert: None,
1673 tls_key: None,
1674 };
1675
1676 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1677 let shutdown_fut = async move {
1678 let _ = shutdown_rx.await;
1679 };
1680 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1681
1682 let handle = tokio::spawn(execute_with_shutdown(
1683 args,
1684 no_daemon_control(),
1685 Box::pin(shutdown_fut),
1686 Some(ready_tx),
1687 ));
1688 let addr = ready_rx
1689 .await
1690 .expect("server should report its bound address");
1691 let connected = tokio::net::TcpStream::connect(addr).await.is_ok();
1692 assert_connected_with_bad_api_key(connected);
1693
1694 let _ = shutdown_tx.send(());
1696 let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1697 .await
1698 .expect("timed out waiting for execute to return")
1699 .expect("task panicked");
1700 assert_execute_returned_ok_after_shutdown(&result);
1701 }).await;
1702 }
1703
1704 #[tokio::test]
1711 async fn execute_with_unbindable_address_returns_bind_error() {
1712 crate::config::with_isolated_config_path_async(
1715 "serve-unbindable",
1716 |_fake_dir| async move {
1717 let args = ServeArgs {
1718 port: 8080,
1719 host: "192.0.2.1".to_string(),
1720 cors: None,
1721 token: Some("test-token".to_string()),
1722 allow_admin: false,
1723 workdir_root: None,
1724 no_remote_yolo: false,
1725 tls_cert: None,
1726 tls_key: None,
1727 };
1728 let result = execute(args, no_daemon_control()).await;
1729 assert_execute_failed_on_port_in_use(&result);
1730 },
1731 )
1732 .await;
1733 }
1734
1735 #[tokio::test]
1736 async fn execute_refuses_to_start_without_a_token() {
1737 temp_env::async_with_vars([("LEVIATH_API_TOKEN", None::<&str>)], async {
1739 let args = ServeArgs {
1740 port: 0,
1741 host: "127.0.0.1".to_string(),
1742 cors: None,
1743 token: None,
1744 allow_admin: false,
1745 workdir_root: None,
1746 no_remote_yolo: false,
1747 tls_cert: None,
1748 tls_key: None,
1749 };
1750 let result = execute(args, no_daemon_control()).await;
1751 assert!(result.is_err(), "must refuse to start unauthenticated");
1752 })
1753 .await;
1754 }
1755
1756 #[tokio::test]
1759 async fn execute_with_shutdown_signal_returns_ok() {
1760 crate::config::with_isolated_config_path_async(
1761 "serve-mod-shutdown-signal",
1762 |_fake_dir| async move {
1763 let args = ServeArgs {
1764 port: 0,
1765 host: "127.0.0.1".to_string(),
1766 cors: None,
1767 token: Some("test-token".to_string()),
1768 allow_admin: false,
1769 workdir_root: None,
1770 no_remote_yolo: false,
1771 tls_cert: None,
1772 tls_key: None,
1773 };
1774
1775 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1776 let shutdown_fut = async move {
1777 let _ = shutdown_rx.await;
1778 };
1779 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1780
1781 let handle = tokio::spawn(execute_with_shutdown(
1782 args,
1783 no_daemon_control(),
1784 Box::pin(shutdown_fut),
1785 Some(ready_tx),
1786 ));
1787 ready_rx
1788 .await
1789 .expect("server should report its bound address");
1790
1791 let _ = shutdown_tx.send(());
1793 let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1794 .await
1795 .expect("timed out waiting for execute_with_shutdown to return")
1796 .expect("task panicked");
1797 assert_execute_with_shutdown_returned_ok(&result);
1798 },
1799 )
1800 .await;
1801 }
1802
1803 #[tokio::test]
1809 async fn execute_with_shutdown_no_ready_observer_returns_ok() {
1810 crate::config::with_isolated_config_path_async(
1811 "serve-mod-no-ready",
1812 |_fake_dir| async move {
1813 let args = ServeArgs {
1814 port: 0,
1815 host: "127.0.0.1".to_string(),
1816 cors: None,
1817 token: Some("test-token".to_string()),
1818 allow_admin: false,
1819 workdir_root: None,
1820 no_remote_yolo: false,
1821 tls_cert: None,
1822 tls_key: None,
1823 };
1824
1825 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1826 let shutdown_fut = async move {
1827 let _ = shutdown_rx.await;
1828 };
1829
1830 let handle = tokio::spawn(execute_with_shutdown(
1831 args,
1832 no_daemon_control(),
1833 Box::pin(shutdown_fut),
1834 None,
1835 ));
1836 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1838 let _ = shutdown_tx.send(());
1839 let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1840 .await
1841 .expect("timed out waiting for execute_with_shutdown to return")
1842 .expect("task panicked");
1843 assert_execute_with_shutdown_returned_ok(&result);
1844 },
1845 )
1846 .await;
1847 }
1848 #[tokio::test]
1852 async fn cors_is_off_by_default_explicit_when_asked_and_fatal_when_malformed() {
1853 crate::config::with_isolated_config_path_async("serve-mod-cors", |_fake_dir| async move {
1859 fn args_with(cors: Option<&str>) -> ServeArgs {
1860 ServeArgs {
1861 port: 0,
1862 host: "127.0.0.1".to_string(),
1863 cors: cors.map(str::to_string),
1864 token: Some("t".to_string()),
1865 allow_admin: false,
1866 workdir_root: None,
1867 no_remote_yolo: false,
1868 tls_cert: None,
1869 tls_key: None,
1870 }
1871 }
1872
1873 async fn starts(cors: Option<&str>) {
1876 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1877 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1878 let server = tokio::spawn(execute_with_shutdown(
1879 args_with(cors),
1880 no_daemon_control(),
1881 Box::pin(async move {
1882 let _ = stop_rx.await;
1883 }),
1884 Some(ready_tx),
1885 ));
1886 ready_rx.await.expect("the server bound");
1892 let _ = stop_tx.send(());
1893 server.await.expect("join").expect("clean shutdown");
1894 }
1895
1896 starts(None).await;
1897 starts(Some("*")).await;
1898 starts(Some("https://ok.example")).await;
1899
1900 let err = execute_with_shutdown(
1903 args_with(Some("not a valid\nheader")),
1904 no_daemon_control(),
1905 Box::pin(std::future::pending()),
1906 None,
1907 )
1908 .await
1909 .expect_err("a malformed origin must refuse to start");
1910 assert!(
1913 err.to_string().contains("not a valid origin header"),
1914 "expected the CORS parse to be what refused, got: {err}"
1915 );
1916 })
1917 .await;
1918 }
1919
1920 #[tokio::test]
1923 async fn the_mcp_admin_routes_are_mounted_only_with_allow_admin() {
1924 crate::config::with_isolated_config_path_async("serve-mod-admin", |_fake_dir| async move {
1926 for allow_admin in [false, true] {
1927 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1928 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1929 let args = ServeArgs {
1930 port: 0,
1931 host: "127.0.0.1".to_string(),
1932 cors: None,
1933 token: Some("t".to_string()),
1934 allow_admin,
1935 workdir_root: None,
1936 no_remote_yolo: false,
1937 tls_cert: None,
1938 tls_key: None,
1939 };
1940 let server = tokio::spawn(execute_with_shutdown(
1941 args,
1942 no_daemon_control(),
1943 Box::pin(async move {
1944 let _ = stop_rx.await;
1945 }),
1946 Some(ready_tx),
1947 ));
1948 let addr = ready_rx.await.expect("bound");
1949
1950 let status = reqwest::Client::new()
1951 .post(format!("http://{addr}/api/mcp/servers"))
1952 .bearer_auth("t")
1953 .json(&serde_json::json!({}))
1954 .send()
1955 .await
1956 .expect("request")
1957 .status()
1958 .as_u16();
1959 match allow_admin {
1964 false => assert_eq!(status, 405, "the admin route must not be mounted"),
1965 true => assert_ne!(status, 405, "the admin route must be mounted"),
1966 }
1967
1968 let _ = stop_tx.send(());
1969 let _ = server.await;
1970 }
1971 })
1972 .await;
1973 }
1974}