1mod agents;
7mod auth;
8mod blueprints;
9mod config;
10mod doctor;
11mod fs;
12mod interactions;
13mod mcp;
14mod polling;
15#[cfg(test)]
16mod testutil;
17mod tree;
18mod types;
19mod websocket;
20
21use types::ServeLimits;
22pub use types::{AppState, ServeArgs, ServerEvent};
23
24use std::net::SocketAddr;
25use std::sync::Arc;
26
27use axum::Router;
28use axum::routing::{delete, get, post, put};
29use tokio::sync::broadcast;
30use tower_http::cors::{Any, CorsLayer};
31
32use crate::config::Config;
33
34struct AbortOnDrop<T>(tokio::task::JoinHandle<T>);
40
41impl<T> Drop for AbortOnDrop<T> {
42 fn drop(&mut self) {
43 self.0.abort();
44 }
45}
46
47pub async fn execute(
48 args: ServeArgs,
49 control: leviath_runtime::control_socket::ControlClient,
50) -> anyhow::Result<()> {
51 execute_with_shutdown(args, control, Box::pin(std::future::pending()), None).await
52}
53
54fn api_router() -> Router<AppState> {
60 Router::new()
61 .route(
63 "/api/blueprints",
64 get(blueprints::list_blueprints).post(blueprints::create_blueprint),
65 )
66 .route(
67 "/api/blueprints/validate",
68 post(blueprints::validate_blueprint),
69 )
70 .route(
71 "/api/blueprints/{name}",
72 get(blueprints::get_blueprint)
73 .put(blueprints::update_blueprint)
74 .delete(blueprints::delete_blueprint),
75 )
76 .route(
78 "/api/agents",
79 get(agents::list_agents).post(agents::spawn_agent),
80 )
81 .route("/api/agents/tree", get(tree::agents_tree))
82 .route(
83 "/api/agents/{id}",
84 get(agents::get_agent).delete(agents::kill_agent),
85 )
86 .route("/api/agents/{id}/children", get(agents::agent_children))
87 .route("/api/agents/{id}/context", get(agents::agent_context))
88 .route(
89 "/api/agents/{id}/context/history",
90 get(agents::agent_context_history),
91 )
92 .route("/api/agents/{id}/files", get(agents::agent_file))
93 .route("/api/agents/{id}/logs", get(agents::agent_logs))
94 .route("/api/agents/{id}/result", get(agents::agent_result))
95 .route("/api/agents/{id}/tree-status", get(tree::agent_tree_status))
96 .route("/api/agents/{id}/pause", post(agents::pause_agent))
97 .route("/api/agents/{id}/resume", post(agents::resume_agent))
98 .route("/api/agents/{id}/message", post(interactions::send_message))
100 .route(
102 "/api/agents/{id}/interaction",
103 get(interactions::get_interaction).post(interactions::submit_interaction),
104 )
105 .route("/api/mcp/servers", get(mcp::list_servers))
108 .route("/api/mcp/servers/{name}/status", get(mcp::status))
109 .route("/api/mcp/servers/{name}/login", post(mcp::login))
110 .route("/api/mcp/servers/{name}/test", post(mcp::test_server))
111 .route("/api/doctor", get(doctor::run_doctor))
113 .route("/api/fs/dirs", get(fs::list_dirs))
115 .route("/api/config", get(config::get_config))
117 .route("/api/config/validate", post(config::validate_config_key))
118 .route("/api/models", get(config::get_models))
119 .route("/ws", get(websocket::ws_global))
121 .route("/ws/agents/{id}", get(websocket::ws_agent))
122}
123
124#[cfg(test)]
132fn declared_routes() -> Vec<(String, String)> {
133 const SOURCE: &str = include_str!("mod.rs");
134 let production = SOURCE.split("\nmod tests {").next().unwrap_or(SOURCE);
138 routes_in(production)
139}
140
141#[cfg(test)]
146fn routes_in(source: &str) -> Vec<(String, String)> {
147 let mut routes = Vec::new();
148 for chunk in source.split(".route(").skip(1) {
151 let mut depth = 1usize;
155 let mut body = String::new();
156 for ch in chunk.chars() {
157 match ch {
158 '(' => depth += 1,
159 ')' => {
160 depth -= 1;
161 if depth == 0 {
162 break;
163 }
164 }
165 _ => {}
166 }
167 body.push(ch);
168 }
169 let Some(path) = body
170 .split_once('"')
171 .and_then(|(_, rest)| rest.split_once('"'))
172 .map(|(path, _)| path)
173 else {
174 continue;
175 };
176 if !path.starts_with('/') {
180 continue;
181 }
182 for method in ["get", "post", "put", "delete", "patch"] {
183 if body.contains(&format!("{method}(")) {
184 routes.push((path.to_string(), method.to_uppercase()));
185 }
186 }
187 }
188 routes
189}
190
191async fn execute_with_shutdown(
212 args: ServeArgs,
213 control: leviath_runtime::control_socket::ControlClient,
214 shutdown: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
215 ready: Option<tokio::sync::oneshot::Sender<SocketAddr>>,
216) -> anyhow::Result<()> {
217 let auth_token = std::sync::Arc::new(auth::resolve_token(args.token.as_deref())?);
219 if args.host != "127.0.0.1" && args.host != "localhost" && args.host != "::1" {
221 tracing::warn!(
222 host = %args.host,
223 "serving the agent API on a non-local address - anyone who can reach \
224 this host and holds the token can spawn agents"
225 );
226 }
227
228 let cfg = Config::load()?;
229 let allow_local_network = cfg.security.allow_local_network;
231 for warning in cfg.validate_keys() {
232 tracing::warn!("{}", warning);
233 }
234
235 let (event_tx, _) = broadcast::channel::<ServerEvent>(1024);
236
237 let state = AppState {
238 config: Arc::new(cfg),
239 event_tx: event_tx.clone(),
240 control,
241 mcp: mcp::McpAdmin::default(),
242 limits: Arc::new(ServeLimits {
243 workdir_root: args.workdir_root.clone(),
244 no_remote_yolo: args.no_remote_yolo,
245 allow_local_network,
246 }),
247 };
248
249 let event_state = state.clone();
258 let _event_guard = AbortOnDrop(tokio::spawn(polling::event_loop(
259 event_state,
260 polling::RECONNECT_BACKOFF,
261 )));
262
263 let cors = match args.cors.as_deref() {
267 None => None,
268 Some("*") => Some(
269 CorsLayer::new()
270 .allow_origin(Any)
271 .allow_methods(Any)
272 .allow_headers([
277 axum::http::header::AUTHORIZATION,
278 axum::http::header::CONTENT_TYPE,
279 ]),
280 ),
281 Some(origin) => {
282 let value = origin.parse::<axum::http::HeaderValue>().map_err(|_| {
286 anyhow::anyhow!("--cors value '{origin}' is not a valid origin header")
287 })?;
288 Some(
289 CorsLayer::new()
290 .allow_origin(value)
291 .allow_methods(Any)
292 .allow_headers([
297 axum::http::header::AUTHORIZATION,
298 axum::http::header::CONTENT_TYPE,
299 ]),
300 )
301 }
302 };
303
304 let app = api_router();
305
306 let app = match args.allow_admin {
314 true => app
315 .route("/api/mcp/servers", post(mcp::add_server))
316 .route("/api/mcp/servers/{name}", delete(mcp::remove_server))
317 .route("/api/config", put(config::put_config)),
320 false => app,
321 };
322
323 let app = app
324 .layer(axum::middleware::from_fn_with_state(
327 auth_token,
328 auth::require_auth,
329 ))
330 .with_state(state);
331 let app = match cors {
335 Some(layer) => app.layer(layer),
336 None => app,
337 };
338
339 let addr: SocketAddr = format!("{}:{}", args.host, args.port).parse()?;
340 tracing::info!("Listening on http://{}", addr);
341 println!("Leviath API server listening on http://{}", addr);
342
343 let listener = tokio::net::TcpListener::bind(addr).await?;
344 if let Some(ready) = ready {
345 let local_addr = listener
348 .local_addr()
349 .expect("infallible: a freshly bound TcpListener always has a local address");
350 let _ = ready.send(local_addr);
351 }
352 let _ = axum::serve(listener, app)
355 .with_graceful_shutdown(shutdown)
356 .await;
357
358 Ok(())
359}
360
361#[cfg(test)]
364mod tests {
365 use super::*;
366 use axum::body::Body;
367 use axum::http::{Request, StatusCode};
368 use tower::ServiceExt;
369
370 use crate::runstate::RunMeta;
371 use crate::test_support::with_tracing;
372
373 const OPENAPI: &str = include_str!("../../../../../docs/schema/openapi.json");
375
376 fn documented_routes() -> Vec<(String, String)> {
378 let spec: serde_json::Value = serde_json::from_str(OPENAPI).expect("the spec is JSON");
379 let paths = spec["paths"].as_object().expect("the spec has paths");
380 let mut routes = Vec::new();
381 for (path, item) in paths {
382 let operations = item.as_object().expect("a path item is an object");
383 for method in ["get", "post", "put", "delete", "patch"] {
384 if operations.contains_key(method) {
385 routes.push((path.clone(), method.to_uppercase()));
386 }
387 }
388 }
389 routes
390 }
391
392 type Routes = Vec<(String, String)>;
394
395 fn spec_drift() -> (Routes, Routes) {
398 let declared = declared_routes();
399 let documented = documented_routes();
400 let missing = declared
401 .iter()
402 .filter(|r| !documented.contains(r))
403 .cloned()
404 .collect();
405 let extra = documented
406 .iter()
407 .filter(|r| !declared.contains(r))
408 .cloned()
409 .collect();
410 (missing, extra)
411 }
412
413 #[test]
414 fn the_openapi_spec_documents_exactly_the_routes_this_router_serves() {
415 let (missing, extra) = spec_drift();
426 assert!(missing.is_empty());
427 assert!(extra.is_empty());
428 }
429
430 #[test]
431 fn the_route_reader_finds_the_routes_that_are_actually_there() {
432 let declared = declared_routes();
436 assert!(declared.len() > 25);
437 assert!(declared.contains(&("/api/agents".to_string(), "POST".to_string())));
438 assert!(declared.contains(&("/api/agents/{id}".to_string(), "DELETE".to_string())));
439 assert!(declared.contains(&("/ws".to_string(), "GET".to_string())));
440 }
441
442 #[test]
443 fn the_route_reader_ignores_text_that_is_not_a_route() {
444 let source = concat!(
448 "let x = source.split(\".route(\").skip(1);\n",
449 ".route(\"not a path\", get(h))\n",
450 ".route(\"/real\", get(h).post(h))\n"
451 );
452 assert_eq!(
453 routes_in(source),
454 vec![
455 ("/real".to_string(), "GET".to_string()),
456 ("/real".to_string(), "POST".to_string()),
457 ]
458 );
459 }
460
461 #[test]
462 fn the_route_reader_reads_nothing_out_of_source_with_no_routes() {
463 assert_eq!(routes_in("fn main() {}"), Vec::new());
464 }
465
466 fn assert_execute_failed_on_malformed_config(result: &anyhow::Result<()>) {
471 assert!(
472 result.is_err(),
473 "execute should fail when config is malformed"
474 );
475 }
476
477 #[test]
478 #[should_panic(expected = "execute should fail when config is malformed")]
479 fn assert_execute_failed_on_malformed_config_panics_when_ok() {
480 assert_execute_failed_on_malformed_config(&Ok(()));
481 }
482
483 fn assert_connected_with_bad_api_key(connected: bool) {
486 assert!(connected, "server should start even with a bad API key");
487 }
488
489 #[test]
490 #[should_panic(expected = "server should start even with a bad API key")]
491 fn assert_connected_with_bad_api_key_panics_when_not_connected() {
492 assert_connected_with_bad_api_key(false);
493 }
494
495 fn assert_execute_returned_ok_after_shutdown(result: &Result<(), anyhow::Error>) {
498 assert!(
499 result.is_ok(),
500 "execute should return Ok after graceful shutdown"
501 );
502 }
503
504 #[test]
505 #[should_panic(expected = "execute should return Ok after graceful shutdown")]
506 fn assert_execute_returned_ok_after_shutdown_panics_when_err() {
507 assert_execute_returned_ok_after_shutdown(&Err(anyhow::anyhow!("boom")));
508 }
509
510 fn assert_execute_failed_on_port_in_use(result: &anyhow::Result<()>) {
513 assert!(
514 result.is_err(),
515 "execute should fail when port is already in use"
516 );
517 }
518
519 #[test]
520 #[should_panic(expected = "execute should fail when port is already in use")]
521 fn assert_execute_failed_on_port_in_use_panics_when_ok() {
522 assert_execute_failed_on_port_in_use(&Ok(()));
523 }
524
525 fn assert_execute_with_shutdown_returned_ok(result: &Result<(), anyhow::Error>) {
529 assert!(
530 result.is_ok(),
531 "execute_with_shutdown should return Ok(()) after graceful shutdown"
532 );
533 }
534
535 #[test]
536 #[should_panic(expected = "execute_with_shutdown should return Ok(()) after graceful shutdown")]
537 fn assert_execute_with_shutdown_returned_ok_panics_when_err() {
538 assert_execute_with_shutdown_returned_ok(&Err(anyhow::anyhow!("boom")));
539 }
540
541 fn assert_response_ok(resp_str: &str) {
544 assert!(resp_str.starts_with("HTTP/1.1 200"), "got: {resp_str}");
545 }
546
547 #[test]
548 #[should_panic(expected = "got: HTTP/1.1 404 Not Found")]
549 fn assert_response_ok_panics_when_not_200() {
550 assert_response_ok("HTTP/1.1 404 Not Found\r\n\r\n");
551 }
552
553 fn no_daemon_control() -> leviath_runtime::control_socket::ControlClient {
556 leviath_runtime::control_socket::ControlClient::new(
557 leviath_runtime::control_socket::control_id(std::path::Path::new("/no/such/leviath")),
558 )
559 }
560
561 fn test_state() -> AppState {
562 let (tx, _) = broadcast::channel(64);
563 AppState {
564 config: Arc::new(Config::default()),
565 event_tx: tx,
566 control: no_daemon_control(),
567 mcp: crate::commands::serve::mcp::McpAdmin::default(),
568 limits: Default::default(),
569 }
570 }
571
572 fn test_app() -> Router {
575 api_router().with_state(test_state())
576 }
577
578 #[tokio::test]
579 async fn test_list_blueprints() {
580 let app = test_app();
581 let req = Request::builder()
582 .uri("/api/blueprints")
583 .body(Body::empty())
584 .unwrap();
585 let resp = app.oneshot(req).await.unwrap();
586 assert_eq!(resp.status(), StatusCode::OK);
587 }
588
589 #[tokio::test]
590 async fn test_router_serves_routes_the_old_hand_copy_missed() {
591 let app = test_app();
595 let req = Request::builder()
596 .uri("/api/mcp/servers")
597 .body(Body::empty())
598 .unwrap();
599 let resp = app.oneshot(req).await.unwrap();
600 assert_eq!(resp.status(), StatusCode::OK);
601 }
602
603 #[tokio::test]
604 async fn test_pause_and_resume_routes_are_mounted() {
605 for action in ["pause", "resume"] {
609 let app = test_app();
610 let req = Request::builder()
611 .method("POST")
612 .uri(format!("/api/agents/some-run/{action}"))
613 .body(Body::empty())
614 .unwrap();
615 let resp = app.oneshot(req).await.unwrap();
616 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
617 }
618 }
619
620 #[tokio::test]
621 async fn test_agent_files_route_is_mounted() {
622 let app = test_app();
627 let req = Request::builder()
628 .uri("/api/agents/some-run/files")
629 .body(Body::empty())
630 .unwrap();
631 let resp = app.oneshot(req).await.unwrap();
632 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
633 }
634
635 #[tokio::test]
636 async fn test_fs_dirs_route_is_mounted() {
637 let app = test_app();
642 let req = Request::builder()
643 .uri("/api/fs/dirs?path=not/absolute")
644 .body(Body::empty())
645 .unwrap();
646 let resp = app.oneshot(req).await.unwrap();
647 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
648 }
649
650 #[tokio::test]
651 async fn test_get_blueprint_not_found() {
652 let app = test_app();
653 let req = Request::builder()
654 .uri("/api/blueprints/nonexistent-agent-xyz")
655 .body(Body::empty())
656 .unwrap();
657 let resp = app.oneshot(req).await.unwrap();
658 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
659 }
660
661 #[tokio::test]
662 async fn test_validate_blueprint_valid() {
663 let app = test_app();
664 let manifest = r#"
665[agent]
666name = "test-agent"
667version = "0.1.0"
668description = "A test"
669
670[stages.main]
671mode = "autonomous"
672[stages.main.model]
673provider = "anthropic"
674model = "claude-sonnet-4-6"
675"#;
676 let body = serde_json::json!({ "manifest": manifest });
677 let req = Request::builder()
678 .method("POST")
679 .uri("/api/blueprints/validate")
680 .header("content-type", "application/json")
681 .body(Body::from(serde_json::to_string(&body).unwrap()))
682 .unwrap();
683 let resp = app.oneshot(req).await.unwrap();
684 assert_eq!(resp.status(), StatusCode::OK);
685
686 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
687 .await
688 .unwrap();
689 let val: types::ValidateResponse = serde_json::from_slice(&body).unwrap();
690 assert!(val.valid);
691 }
692
693 #[tokio::test]
694 async fn test_validate_blueprint_invalid() {
695 let app = test_app();
696 let body = serde_json::json!({ "manifest": "not valid toml {{{{" });
697 let req = Request::builder()
698 .method("POST")
699 .uri("/api/blueprints/validate")
700 .header("content-type", "application/json")
701 .body(Body::from(serde_json::to_string(&body).unwrap()))
702 .unwrap();
703 let resp = app.oneshot(req).await.unwrap();
704 assert_eq!(resp.status(), StatusCode::OK);
705
706 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
707 .await
708 .unwrap();
709 let val: types::ValidateResponse = serde_json::from_slice(&body).unwrap();
710 assert!(!val.valid);
711 assert!(val.errors.is_some());
712 }
713
714 #[tokio::test]
715 async fn test_list_agents() {
716 let app = test_app();
717 let req = Request::builder()
718 .uri("/api/agents")
719 .body(Body::empty())
720 .unwrap();
721 let resp = app.oneshot(req).await.unwrap();
722 assert_eq!(resp.status(), StatusCode::OK);
723 }
724
725 #[tokio::test]
726 async fn test_agents_tree() {
727 let app = test_app();
728 let req = Request::builder()
729 .uri("/api/agents/tree")
730 .body(Body::empty())
731 .unwrap();
732 let resp = app.oneshot(req).await.unwrap();
733 assert_eq!(resp.status(), StatusCode::OK);
734 }
735
736 #[tokio::test]
737 async fn test_get_agent_not_found() {
738 let app = test_app();
739 let req = Request::builder()
740 .uri("/api/agents/nonexistent-run-id-xyz")
741 .body(Body::empty())
742 .unwrap();
743 let resp = app.oneshot(req).await.unwrap();
744 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
745 }
746
747 #[tokio::test]
748 async fn test_agent_children_empty() {
749 let app = test_app();
750 let req = Request::builder()
751 .uri("/api/agents/nonexistent/children")
752 .body(Body::empty())
753 .unwrap();
754 let resp = app.oneshot(req).await.unwrap();
755 assert_eq!(resp.status(), StatusCode::OK);
757 }
758
759 #[tokio::test]
760 async fn test_agent_context_not_found() {
761 let app = test_app();
762 let req = Request::builder()
763 .uri("/api/agents/nonexistent/context")
764 .body(Body::empty())
765 .unwrap();
766 let resp = app.oneshot(req).await.unwrap();
767 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
768 }
769
770 #[tokio::test]
771 async fn test_agent_logs_not_found() {
772 let app = test_app();
773 let req = Request::builder()
774 .uri("/api/agents/nonexistent/logs")
775 .body(Body::empty())
776 .unwrap();
777 let resp = app.oneshot(req).await.unwrap();
778 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
779 }
780
781 #[tokio::test]
782 async fn test_agent_result_not_found() {
783 let app = test_app();
784 let req = Request::builder()
785 .uri("/api/agents/nonexistent/result")
786 .body(Body::empty())
787 .unwrap();
788 let resp = app.oneshot(req).await.unwrap();
789 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
790 }
791
792 #[tokio::test]
793 async fn test_agent_tree_status_not_found() {
794 let app = test_app();
795 let req = Request::builder()
796 .uri("/api/agents/nonexistent/tree-status")
797 .body(Body::empty())
798 .unwrap();
799 let resp = app.oneshot(req).await.unwrap();
800 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
801 }
802
803 #[tokio::test]
804 async fn test_interaction_route_reaches_daemon() {
805 let app = test_app();
808 let req = Request::builder()
809 .uri("/api/agents/nonexistent/interaction")
810 .body(Body::empty())
811 .unwrap();
812 let resp = app.oneshot(req).await.unwrap();
813 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
814 }
815
816 #[tokio::test]
817 async fn test_get_config() {
818 let app = test_app();
819 let req = Request::builder()
820 .uri("/api/config")
821 .body(Body::empty())
822 .unwrap();
823 let resp = app.oneshot(req).await.unwrap();
824 assert_eq!(resp.status(), StatusCode::OK);
825
826 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
827 .await
828 .unwrap();
829 let val: types::RedactedConfig = serde_json::from_slice(&body).unwrap();
830 assert_eq!(val.default_provider, "anthropic");
831 assert!(!val.has_anthropic_key);
833 assert!(!val.has_openai_key);
834 }
835
836 #[tokio::test]
837 async fn test_tree_building() {
838 let runs = vec![
840 RunMeta::new(
841 "parent-1".to_string(),
842 "agent-a".to_string(),
843 "/path".to_string(),
844 "task".to_string(),
845 None,
846 "/work".to_string(),
847 1,
848 ),
849 {
850 let mut child = RunMeta::new(
851 "child-1".to_string(),
852 "agent-b".to_string(),
853 "/path".to_string(),
854 "sub-task".to_string(),
855 None,
856 "/work".to_string(),
857 1,
858 );
859 child.parent_run_id = Some("parent-1".to_string());
860 child.prompt_tokens = 100;
861 child.completion_tokens = 50;
862 child
863 },
864 ];
865
866 let tree = tree::build_tree_status(&runs, None);
867 assert_eq!(tree.len(), 1);
868 assert_eq!(tree[0].run_id, "parent-1");
869 assert_eq!(tree[0].children.len(), 1);
870 assert_eq!(tree[0].subtree_prompt_tokens, 100); assert_eq!(tree[0].subtree_completion_tokens, 50);
872 }
873
874 #[tokio::test]
875 async fn test_delete_blueprint_not_found() {
876 let app = test_app();
877 let req = Request::builder()
878 .method("DELETE")
879 .uri("/api/blueprints/nonexistent-agent-xyz")
880 .body(Body::empty())
881 .unwrap();
882 let resp = app.oneshot(req).await.unwrap();
883 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
884 }
885
886 #[tokio::test]
887 async fn test_server_event_serialization() {
888 let event = ServerEvent::AgentStatus {
889 agent_id: "coder".to_string(),
890 run_id: "run-123".to_string(),
891 status: "running".to_string(),
892 stage: "implement".to_string(),
893 iteration: 5,
894 tool_calls: 0,
895 accepts_messages: true,
896 };
897 let json = serde_json::to_string(&event).unwrap();
898 assert!(json.contains("\"type\":\"agent_status\""));
899 assert!(json.contains("\"agent_id\":\"coder\""));
900
901 let event2 = ServerEvent::Tokens {
902 agent_id: "coder".to_string(),
903 run_id: "run-123".to_string(),
904 prompt_tokens: 5000,
905 completion_tokens: 1200,
906 cached_tokens: 0,
907 cache_write_tokens: 0,
908 };
909 let json2 = serde_json::to_string(&event2).unwrap();
910 assert!(json2.contains("\"type\":\"tokens\""));
911 assert!(json2.contains("\"prompt_tokens\":5000"));
912 }
913
914 #[tokio::test]
915 async fn test_full_router_create_blueprint_invalid() {
916 let app = test_app();
917 let body = serde_json::json!({
918 "name": "bad-agent",
919 "manifest": "not valid toml {{{"
920 });
921 let req = Request::builder()
922 .method("POST")
923 .uri("/api/blueprints")
924 .header("content-type", "application/json")
925 .body(Body::from(serde_json::to_string(&body).unwrap()))
926 .unwrap();
927 let resp = app.oneshot(req).await.unwrap();
928 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
929 }
930
931 #[tokio::test]
932 async fn test_full_router_update_blueprint_not_found() {
933 let app = test_app();
934 let body = serde_json::json!({
935 "manifest": r#"
936[agent]
937name = "no-such-agent"
938version = "1.0.0"
939description = "Missing"
940
941[stages.run]
942prompt = "Run"
943"#
944 });
945 let req = Request::builder()
946 .method("PUT")
947 .uri("/api/blueprints/no-such-agent-xyz-99999")
948 .header("content-type", "application/json")
949 .body(Body::from(serde_json::to_string(&body).unwrap()))
950 .unwrap();
951 let resp = app.oneshot(req).await.unwrap();
952 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
953 }
954
955 #[tokio::test]
956 async fn test_full_router_kill_agent_reaches_daemon() {
957 let app = test_app();
958 let req = Request::builder()
959 .method("DELETE")
960 .uri("/api/agents/nonexistent-kill-id-xyz")
961 .body(Body::empty())
962 .unwrap();
963 let resp = app.oneshot(req).await.unwrap();
964 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
965 }
966
967 #[tokio::test]
968 async fn test_full_router_send_message_reaches_daemon() {
969 let app = test_app();
970 let body = serde_json::json!({"message": "hello"});
971 let req = Request::builder()
972 .method("POST")
973 .uri("/api/agents/nonexistent-msg-id-xyz/message")
974 .header("content-type", "application/json")
975 .body(Body::from(serde_json::to_string(&body).unwrap()))
976 .unwrap();
977 let resp = app.oneshot(req).await.unwrap();
978 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
979 }
980
981 #[tokio::test]
982 async fn test_full_router_get_models() {
983 let app = test_app();
984 let req = Request::builder()
985 .uri("/api/models")
986 .body(Body::empty())
987 .unwrap();
988 let resp = app.oneshot(req).await.unwrap();
989 assert_eq!(resp.status(), StatusCode::OK);
990 }
991
992 #[tokio::test]
993 async fn test_full_router_spawn_agent_blueprint_not_found() {
994 let app = test_app();
995 let body = serde_json::json!({
996 "blueprint": "nonexistent-blueprint-xyz",
997 "task": "do something"
998 });
999 let req = Request::builder()
1000 .method("POST")
1001 .uri("/api/agents")
1002 .header("content-type", "application/json")
1003 .body(Body::from(serde_json::to_string(&body).unwrap()))
1004 .unwrap();
1005 let resp = app.oneshot(req).await.unwrap();
1006 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1007 }
1008
1009 #[test]
1010 fn test_serve_args_defaults() {
1011 let args = ServeArgs {
1012 port: 3000,
1013 host: "127.0.0.1".to_string(),
1014 cors: None,
1015 token: Some("test-token".to_string()),
1016 allow_admin: false,
1017 workdir_root: None,
1018 no_remote_yolo: false,
1019 };
1020 assert_eq!(args.port, 3000);
1021 assert_eq!(args.host, "127.0.0.1");
1022 assert_eq!(args.cors, None);
1023 }
1024
1025 #[test]
1026 fn test_app_state_clone() {
1027 let state = test_state();
1028 let cloned = state.clone();
1029 let _ = cloned.config.default_provider.clone();
1031 }
1032
1033 #[test]
1034 fn test_cors_wildcard_vs_specific() {
1035 let wildcard = "*";
1037 let specific = "https://example.com";
1038
1039 let is_wildcard = wildcard == "*";
1040 assert!(is_wildcard);
1041
1042 let is_specific = specific != "*";
1043 assert!(is_specific);
1044
1045 let parsed = specific.parse::<axum::http::HeaderValue>();
1047 assert!(parsed.is_ok());
1048 }
1049
1050 #[test]
1051 fn test_cors_invalid_origin_falls_back() {
1052 let invalid_cors = "not a valid header value \x00";
1053 let result = invalid_cors.parse::<axum::http::HeaderValue>();
1054 assert!(result.is_err());
1056 }
1057
1058 #[tokio::test]
1059 async fn test_submit_interaction_full_router_reaches_daemon() {
1060 let app = test_app();
1064 let body = serde_json::json!({"request_id": "req-1", "value": "do it", "scope": "once"});
1065 let req = Request::builder()
1066 .method("POST")
1067 .uri("/api/agents/any/interaction")
1068 .header("content-type", "application/json")
1069 .body(Body::from(serde_json::to_string(&body).unwrap()))
1070 .unwrap();
1071 let resp = app.oneshot(req).await.unwrap();
1072 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1073 }
1074
1075 #[tokio::test]
1095 async fn execute_binds_and_serves_with_wildcard_cors() {
1096 crate::config::with_isolated_config_path_async(
1097 "serve-mod-wildcard-cors",
1098 |_fake_dir| async move {
1099 with_tracing(|| {});
1100 let args = ServeArgs {
1110 port: 0,
1111 host: "127.0.0.1".to_string(),
1112 cors: None,
1113 token: Some("test-token".to_string()),
1114 allow_admin: false,
1115 workdir_root: None,
1116 no_remote_yolo: false,
1117 };
1118 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1119 let handle = tokio::spawn(execute_with_shutdown(
1120 args,
1121 no_daemon_control(),
1122 Box::pin(std::future::pending()),
1123 Some(ready_tx),
1124 ));
1125 let addr = ready_rx
1126 .await
1127 .expect("server should report its bound address");
1128
1129 let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
1131 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1132 stream
1133 .write_all(
1134 b"GET /api/config HTTP/1.1\r\nHost: localhost\r\n\
1135 Authorization: Bearer test-token\r\nConnection: close\r\n\r\n",
1136 )
1137 .await
1138 .unwrap();
1139 let mut resp = Vec::new();
1140 stream.read_to_end(&mut resp).await.unwrap();
1141 let resp_str = String::from_utf8_lossy(&resp);
1142 assert_response_ok(&resp_str);
1143
1144 let mut unauth = tokio::net::TcpStream::connect(addr).await.unwrap();
1146 unauth
1147 .write_all(
1148 b"GET /api/config HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
1149 )
1150 .await
1151 .unwrap();
1152 let mut resp2 = Vec::new();
1153 unauth.read_to_end(&mut resp2).await.unwrap();
1154 assert!(
1155 String::from_utf8_lossy(&resp2).starts_with("HTTP/1.1 401"),
1156 "unauthenticated request should be 401"
1157 );
1158
1159 handle.abort();
1160 },
1161 )
1162 .await;
1163 }
1164
1165 #[tokio::test]
1171 async fn execute_cors_preflight_allows_authorization_header() {
1172 crate::config::with_isolated_config_path_async(
1173 "serve-mod-cors-preflight",
1174 |_fake_dir| async move {
1175 with_tracing(|| {});
1176 let args = ServeArgs {
1177 port: 0,
1178 host: "127.0.0.1".to_string(),
1179 cors: Some("*".to_string()),
1180 token: Some("test-token".to_string()),
1181 allow_admin: false,
1182 workdir_root: None,
1183 no_remote_yolo: false,
1184 };
1185 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1186 let handle = tokio::spawn(execute_with_shutdown(
1187 args,
1188 no_daemon_control(),
1189 Box::pin(std::future::pending()),
1190 Some(ready_tx),
1191 ));
1192 let addr = ready_rx
1193 .await
1194 .expect("server should report its bound address");
1195
1196 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1197 let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
1198 stream
1199 .write_all(
1200 b"OPTIONS /api/config HTTP/1.1\r\nHost: localhost\r\n\
1201 Origin: https://leviath.dev\r\n\
1202 Access-Control-Request-Method: GET\r\n\
1203 Access-Control-Request-Headers: authorization\r\n\
1204 Connection: close\r\n\r\n",
1205 )
1206 .await
1207 .unwrap();
1208 let mut resp = Vec::new();
1209 stream.read_to_end(&mut resp).await.unwrap();
1210 let lower = String::from_utf8_lossy(&resp).to_lowercase();
1211 assert!(
1212 lower.contains("access-control-allow-headers")
1213 && lower.contains("authorization"),
1214 "preflight must allow the Authorization header, got:\n{lower}"
1215 );
1216
1217 handle.abort();
1218 },
1219 )
1220 .await;
1221 }
1222
1223 #[tokio::test]
1224 async fn execute_with_specific_cors_origin_serves() {
1225 crate::config::with_isolated_config_path_async(
1226 "serve-mod-specific-cors",
1227 |_fake_dir| async move {
1228 let args = ServeArgs {
1229 port: 0,
1230 host: "127.0.0.1".to_string(),
1231 cors: Some("https://example.com".to_string()),
1232 token: Some("test-token".to_string()),
1233 allow_admin: false,
1234 workdir_root: None,
1235 no_remote_yolo: false,
1236 };
1237 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1238 let handle = tokio::spawn(execute_with_shutdown(
1239 args,
1240 no_daemon_control(),
1241 Box::pin(std::future::pending()),
1242 Some(ready_tx),
1243 ));
1244 let addr = ready_rx
1245 .await
1246 .expect("server should report its bound address");
1247 assert!(tokio::net::TcpStream::connect(addr).await.is_ok());
1248
1249 handle.abort();
1250 },
1251 )
1252 .await;
1253 }
1254
1255 #[tokio::test]
1256 async fn execute_with_unparseable_addr_returns_err() {
1257 crate::config::with_isolated_config_path_async("serve-badaddr", |_fake_dir| async move {
1260 let args = ServeArgs {
1263 port: 0,
1264 host: "not a valid host".to_string(),
1265 cors: None,
1266 token: Some("test-token".to_string()),
1267 allow_admin: false,
1268 workdir_root: None,
1269 no_remote_yolo: false,
1270 };
1271 let result = execute(args, no_daemon_control()).await;
1272 assert!(result.is_err());
1273 })
1274 .await;
1275 }
1276
1277 #[tokio::test]
1278 async fn test_agent_list_with_status_filter_full_router() {
1279 let app = test_app();
1280 let req = Request::builder()
1281 .uri("/api/agents?status=running,complete")
1282 .body(Body::empty())
1283 .unwrap();
1284 let resp = app.oneshot(req).await.unwrap();
1285 assert_eq!(resp.status(), StatusCode::OK);
1286 }
1287
1288 #[tokio::test]
1291 async fn execute_with_malformed_config_returns_err() {
1292 crate::config::with_isolated_config_path_async(
1293 "serve-mod-malformed",
1294 |_fake_dir| async move {
1295 std::fs::write(Config::config_path(), "not valid toml [[[").unwrap();
1297
1298 let args = ServeArgs {
1299 port: 0,
1300 host: "127.0.0.1".to_string(),
1301 cors: None,
1302 token: Some("test-token".to_string()),
1303 allow_admin: false,
1304 workdir_root: None,
1305 no_remote_yolo: false,
1306 };
1307 let result = execute(args, no_daemon_control()).await;
1308 assert_execute_failed_on_malformed_config(&result);
1309 },
1310 )
1311 .await;
1312 }
1313
1314 #[tokio::test]
1318 async fn execute_with_bad_api_key_logs_warning_and_serves() {
1319 with_tracing(|| {});
1320 crate::config::with_isolated_config_path_async("serve-mod-badkey", |_fake_dir| async move {
1321 std::fs::write(
1323 Config::config_path(),
1324 "default_provider = \"anthropic\"\nagent_paths = []\n[providers]\nanthropic_api_key = \"bad-key-not-sk-ant\"\n",
1325 )
1326 .unwrap();
1327
1328 let args = ServeArgs {
1329 port: 0,
1330 host: "127.0.0.1".to_string(),
1331 cors: None,
1332 token: Some("test-token".to_string()),
1333 allow_admin: false,
1334 workdir_root: None,
1335 no_remote_yolo: false,
1336 };
1337
1338 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1339 let shutdown_fut = async move {
1340 let _ = shutdown_rx.await;
1341 };
1342 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1343
1344 let handle = tokio::spawn(execute_with_shutdown(
1345 args,
1346 no_daemon_control(),
1347 Box::pin(shutdown_fut),
1348 Some(ready_tx),
1349 ));
1350 let addr = ready_rx
1351 .await
1352 .expect("server should report its bound address");
1353 let connected = tokio::net::TcpStream::connect(addr).await.is_ok();
1354 assert_connected_with_bad_api_key(connected);
1355
1356 let _ = shutdown_tx.send(());
1358 let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1359 .await
1360 .expect("timed out waiting for execute to return")
1361 .expect("task panicked");
1362 assert_execute_returned_ok_after_shutdown(&result);
1363 }).await;
1364 }
1365
1366 #[tokio::test]
1373 async fn execute_with_unbindable_address_returns_bind_error() {
1374 crate::config::with_isolated_config_path_async(
1377 "serve-unbindable",
1378 |_fake_dir| async move {
1379 let args = ServeArgs {
1380 port: 8080,
1381 host: "192.0.2.1".to_string(),
1382 cors: None,
1383 token: Some("test-token".to_string()),
1384 allow_admin: false,
1385 workdir_root: None,
1386 no_remote_yolo: false,
1387 };
1388 let result = execute(args, no_daemon_control()).await;
1389 assert_execute_failed_on_port_in_use(&result);
1390 },
1391 )
1392 .await;
1393 }
1394
1395 #[tokio::test]
1396 async fn execute_refuses_to_start_without_a_token() {
1397 temp_env::async_with_vars([("LEVIATH_API_TOKEN", None::<&str>)], async {
1399 let args = ServeArgs {
1400 port: 0,
1401 host: "127.0.0.1".to_string(),
1402 cors: None,
1403 token: None,
1404 allow_admin: false,
1405 workdir_root: None,
1406 no_remote_yolo: false,
1407 };
1408 let result = execute(args, no_daemon_control()).await;
1409 assert!(result.is_err(), "must refuse to start unauthenticated");
1410 })
1411 .await;
1412 }
1413
1414 #[tokio::test]
1417 async fn execute_with_shutdown_signal_returns_ok() {
1418 crate::config::with_isolated_config_path_async(
1419 "serve-mod-shutdown-signal",
1420 |_fake_dir| async move {
1421 let args = ServeArgs {
1422 port: 0,
1423 host: "127.0.0.1".to_string(),
1424 cors: None,
1425 token: Some("test-token".to_string()),
1426 allow_admin: false,
1427 workdir_root: None,
1428 no_remote_yolo: false,
1429 };
1430
1431 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1432 let shutdown_fut = async move {
1433 let _ = shutdown_rx.await;
1434 };
1435 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1436
1437 let handle = tokio::spawn(execute_with_shutdown(
1438 args,
1439 no_daemon_control(),
1440 Box::pin(shutdown_fut),
1441 Some(ready_tx),
1442 ));
1443 ready_rx
1444 .await
1445 .expect("server should report its bound address");
1446
1447 let _ = shutdown_tx.send(());
1449 let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1450 .await
1451 .expect("timed out waiting for execute_with_shutdown to return")
1452 .expect("task panicked");
1453 assert_execute_with_shutdown_returned_ok(&result);
1454 },
1455 )
1456 .await;
1457 }
1458
1459 #[tokio::test]
1465 async fn execute_with_shutdown_no_ready_observer_returns_ok() {
1466 crate::config::with_isolated_config_path_async(
1467 "serve-mod-no-ready",
1468 |_fake_dir| async move {
1469 let args = ServeArgs {
1470 port: 0,
1471 host: "127.0.0.1".to_string(),
1472 cors: None,
1473 token: Some("test-token".to_string()),
1474 allow_admin: false,
1475 workdir_root: None,
1476 no_remote_yolo: false,
1477 };
1478
1479 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1480 let shutdown_fut = async move {
1481 let _ = shutdown_rx.await;
1482 };
1483
1484 let handle = tokio::spawn(execute_with_shutdown(
1485 args,
1486 no_daemon_control(),
1487 Box::pin(shutdown_fut),
1488 None,
1489 ));
1490 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1492 let _ = shutdown_tx.send(());
1493 let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1494 .await
1495 .expect("timed out waiting for execute_with_shutdown to return")
1496 .expect("task panicked");
1497 assert_execute_with_shutdown_returned_ok(&result);
1498 },
1499 )
1500 .await;
1501 }
1502 #[tokio::test]
1506 async fn cors_is_off_by_default_explicit_when_asked_and_fatal_when_malformed() {
1507 crate::config::with_isolated_config_path_async("serve-mod-cors", |_fake_dir| async move {
1513 fn args_with(cors: Option<&str>) -> ServeArgs {
1514 ServeArgs {
1515 port: 0,
1516 host: "127.0.0.1".to_string(),
1517 cors: cors.map(str::to_string),
1518 token: Some("t".to_string()),
1519 allow_admin: false,
1520 workdir_root: None,
1521 no_remote_yolo: false,
1522 }
1523 }
1524
1525 async fn starts(cors: Option<&str>) {
1528 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1529 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1530 let server = tokio::spawn(execute_with_shutdown(
1531 args_with(cors),
1532 no_daemon_control(),
1533 Box::pin(async move {
1534 let _ = stop_rx.await;
1535 }),
1536 Some(ready_tx),
1537 ));
1538 ready_rx.await.expect("the server bound");
1544 let _ = stop_tx.send(());
1545 server.await.expect("join").expect("clean shutdown");
1546 }
1547
1548 starts(None).await;
1549 starts(Some("*")).await;
1550 starts(Some("https://ok.example")).await;
1551
1552 let err = execute_with_shutdown(
1555 args_with(Some("not a valid\nheader")),
1556 no_daemon_control(),
1557 Box::pin(std::future::pending()),
1558 None,
1559 )
1560 .await
1561 .expect_err("a malformed origin must refuse to start");
1562 assert!(
1565 err.to_string().contains("not a valid origin header"),
1566 "expected the CORS parse to be what refused, got: {err}"
1567 );
1568 })
1569 .await;
1570 }
1571
1572 #[tokio::test]
1575 async fn the_mcp_admin_routes_are_mounted_only_with_allow_admin() {
1576 crate::config::with_isolated_config_path_async("serve-mod-admin", |_fake_dir| async move {
1578 for allow_admin in [false, true] {
1579 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1580 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1581 let args = ServeArgs {
1582 port: 0,
1583 host: "127.0.0.1".to_string(),
1584 cors: None,
1585 token: Some("t".to_string()),
1586 allow_admin,
1587 workdir_root: None,
1588 no_remote_yolo: false,
1589 };
1590 let server = tokio::spawn(execute_with_shutdown(
1591 args,
1592 no_daemon_control(),
1593 Box::pin(async move {
1594 let _ = stop_rx.await;
1595 }),
1596 Some(ready_tx),
1597 ));
1598 let addr = ready_rx.await.expect("bound");
1599
1600 let status = reqwest::Client::new()
1601 .post(format!("http://{addr}/api/mcp/servers"))
1602 .bearer_auth("t")
1603 .json(&serde_json::json!({}))
1604 .send()
1605 .await
1606 .expect("request")
1607 .status()
1608 .as_u16();
1609 match allow_admin {
1614 false => assert_eq!(status, 405, "the admin route must not be mounted"),
1615 true => assert_ne!(status, 405, "the admin route must be mounted"),
1616 }
1617
1618 let _ = stop_tx.send(());
1619 let _ = server.await;
1620 }
1621 })
1622 .await;
1623 }
1624}