1mod agents;
7mod auth;
8mod blueprints;
9mod config;
10mod interactions;
11mod mcp;
12mod polling;
13#[cfg(test)]
14mod testutil;
15mod tree;
16mod types;
17mod websocket;
18
19use types::ServeLimits;
20pub use types::{AppState, ServeArgs, ServerEvent};
21
22use std::net::SocketAddr;
23use std::sync::Arc;
24
25use axum::Router;
26use axum::routing::{delete, get, post, put};
27use tokio::sync::broadcast;
28use tower_http::cors::{Any, CorsLayer};
29
30use crate::config::Config;
31
32struct AbortOnDrop<T>(tokio::task::JoinHandle<T>);
38
39impl<T> Drop for AbortOnDrop<T> {
40 fn drop(&mut self) {
41 self.0.abort();
42 }
43}
44
45pub async fn execute(
46 args: ServeArgs,
47 control: leviath_runtime::control_socket::ControlClient,
48) -> anyhow::Result<()> {
49 execute_with_shutdown(args, control, Box::pin(std::future::pending()), None).await
50}
51
52fn api_router() -> Router<AppState> {
58 Router::new()
59 .route(
61 "/api/blueprints",
62 get(blueprints::list_blueprints).post(blueprints::create_blueprint),
63 )
64 .route(
65 "/api/blueprints/validate",
66 post(blueprints::validate_blueprint),
67 )
68 .route(
69 "/api/blueprints/{name}",
70 get(blueprints::get_blueprint)
71 .put(blueprints::update_blueprint)
72 .delete(blueprints::delete_blueprint),
73 )
74 .route(
76 "/api/agents",
77 get(agents::list_agents).post(agents::spawn_agent),
78 )
79 .route("/api/agents/tree", get(tree::agents_tree))
80 .route(
81 "/api/agents/{id}",
82 get(agents::get_agent).delete(agents::kill_agent),
83 )
84 .route("/api/agents/{id}/children", get(agents::agent_children))
85 .route("/api/agents/{id}/context", get(agents::agent_context))
86 .route(
87 "/api/agents/{id}/context/history",
88 get(agents::agent_context_history),
89 )
90 .route("/api/agents/{id}/logs", get(agents::agent_logs))
91 .route("/api/agents/{id}/result", get(agents::agent_result))
92 .route("/api/agents/{id}/tree-status", get(tree::agent_tree_status))
93 .route("/api/agents/{id}/pause", post(agents::pause_agent))
94 .route("/api/agents/{id}/resume", post(agents::resume_agent))
95 .route("/api/agents/{id}/message", post(interactions::send_message))
97 .route(
99 "/api/agents/{id}/interaction",
100 get(interactions::get_interaction).post(interactions::submit_interaction),
101 )
102 .route("/api/mcp/servers", get(mcp::list_servers))
105 .route("/api/mcp/servers/{name}/status", get(mcp::status))
106 .route("/api/mcp/servers/{name}/login", post(mcp::login))
107 .route("/api/mcp/servers/{name}/test", post(mcp::test_server))
108 .route("/api/config", get(config::get_config))
110 .route("/api/config/validate", post(config::validate_config_key))
111 .route("/api/models", get(config::get_models))
112 .route("/ws", get(websocket::ws_global))
114 .route("/ws/agents/{id}", get(websocket::ws_agent))
115}
116
117async fn execute_with_shutdown(
138 args: ServeArgs,
139 control: leviath_runtime::control_socket::ControlClient,
140 shutdown: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
141 ready: Option<tokio::sync::oneshot::Sender<SocketAddr>>,
142) -> anyhow::Result<()> {
143 let auth_token = std::sync::Arc::new(auth::resolve_token(args.token.as_deref())?);
145 if args.host != "127.0.0.1" && args.host != "localhost" && args.host != "::1" {
147 tracing::warn!(
148 host = %args.host,
149 "serving the agent API on a non-local address - anyone who can reach \
150 this host and holds the token can spawn agents"
151 );
152 }
153
154 let cfg = Config::load()?;
155 let allow_local_network = cfg.security.allow_local_network;
157 for warning in cfg.validate_keys() {
158 tracing::warn!("{}", warning);
159 }
160
161 let (event_tx, _) = broadcast::channel::<ServerEvent>(1024);
162
163 let state = AppState {
164 config: Arc::new(cfg),
165 event_tx: event_tx.clone(),
166 control,
167 mcp: mcp::McpAdmin::default(),
168 limits: Arc::new(ServeLimits {
169 workdir_root: args.workdir_root.clone(),
170 no_remote_yolo: args.no_remote_yolo,
171 allow_local_network,
172 }),
173 };
174
175 let event_state = state.clone();
184 let _event_guard = AbortOnDrop(tokio::spawn(polling::event_loop(
185 event_state,
186 polling::RECONNECT_BACKOFF,
187 )));
188
189 let cors = match args.cors.as_deref() {
193 None => None,
194 Some("*") => Some(
195 CorsLayer::new()
196 .allow_origin(Any)
197 .allow_methods(Any)
198 .allow_headers([
203 axum::http::header::AUTHORIZATION,
204 axum::http::header::CONTENT_TYPE,
205 ]),
206 ),
207 Some(origin) => {
208 let value = origin.parse::<axum::http::HeaderValue>().map_err(|_| {
212 anyhow::anyhow!("--cors value '{origin}' is not a valid origin header")
213 })?;
214 Some(
215 CorsLayer::new()
216 .allow_origin(value)
217 .allow_methods(Any)
218 .allow_headers([
223 axum::http::header::AUTHORIZATION,
224 axum::http::header::CONTENT_TYPE,
225 ]),
226 )
227 }
228 };
229
230 let app = api_router();
231
232 let app = match args.allow_admin {
240 true => app
241 .route("/api/mcp/servers", post(mcp::add_server))
242 .route("/api/mcp/servers/{name}", delete(mcp::remove_server))
243 .route("/api/config", put(config::put_config)),
246 false => app,
247 };
248
249 let app = app
250 .layer(axum::middleware::from_fn_with_state(
253 auth_token,
254 auth::require_auth,
255 ))
256 .with_state(state);
257 let app = match cors {
261 Some(layer) => app.layer(layer),
262 None => app,
263 };
264
265 let addr: SocketAddr = format!("{}:{}", args.host, args.port).parse()?;
266 tracing::info!("Listening on http://{}", addr);
267 println!("Leviath API server listening on http://{}", addr);
268
269 let listener = tokio::net::TcpListener::bind(addr).await?;
270 if let Some(ready) = ready {
271 let local_addr = listener
274 .local_addr()
275 .expect("infallible: a freshly bound TcpListener always has a local address");
276 let _ = ready.send(local_addr);
277 }
278 let _ = axum::serve(listener, app)
281 .with_graceful_shutdown(shutdown)
282 .await;
283
284 Ok(())
285}
286
287#[cfg(test)]
290mod tests {
291 use super::*;
292 use axum::body::Body;
293 use axum::http::{Request, StatusCode};
294 use tower::ServiceExt;
295
296 use crate::runstate::RunMeta;
297 use crate::test_support::with_tracing;
298
299 fn assert_execute_failed_on_malformed_config(result: &anyhow::Result<()>) {
304 assert!(
305 result.is_err(),
306 "execute should fail when config is malformed"
307 );
308 }
309
310 #[test]
311 #[should_panic(expected = "execute should fail when config is malformed")]
312 fn assert_execute_failed_on_malformed_config_panics_when_ok() {
313 assert_execute_failed_on_malformed_config(&Ok(()));
314 }
315
316 fn assert_connected_with_bad_api_key(connected: bool) {
319 assert!(connected, "server should start even with a bad API key");
320 }
321
322 #[test]
323 #[should_panic(expected = "server should start even with a bad API key")]
324 fn assert_connected_with_bad_api_key_panics_when_not_connected() {
325 assert_connected_with_bad_api_key(false);
326 }
327
328 fn assert_execute_returned_ok_after_shutdown(result: &Result<(), anyhow::Error>) {
331 assert!(
332 result.is_ok(),
333 "execute should return Ok after graceful shutdown"
334 );
335 }
336
337 #[test]
338 #[should_panic(expected = "execute should return Ok after graceful shutdown")]
339 fn assert_execute_returned_ok_after_shutdown_panics_when_err() {
340 assert_execute_returned_ok_after_shutdown(&Err(anyhow::anyhow!("boom")));
341 }
342
343 fn assert_execute_failed_on_port_in_use(result: &anyhow::Result<()>) {
346 assert!(
347 result.is_err(),
348 "execute should fail when port is already in use"
349 );
350 }
351
352 #[test]
353 #[should_panic(expected = "execute should fail when port is already in use")]
354 fn assert_execute_failed_on_port_in_use_panics_when_ok() {
355 assert_execute_failed_on_port_in_use(&Ok(()));
356 }
357
358 fn assert_execute_with_shutdown_returned_ok(result: &Result<(), anyhow::Error>) {
362 assert!(
363 result.is_ok(),
364 "execute_with_shutdown should return Ok(()) after graceful shutdown"
365 );
366 }
367
368 #[test]
369 #[should_panic(expected = "execute_with_shutdown should return Ok(()) after graceful shutdown")]
370 fn assert_execute_with_shutdown_returned_ok_panics_when_err() {
371 assert_execute_with_shutdown_returned_ok(&Err(anyhow::anyhow!("boom")));
372 }
373
374 fn assert_response_ok(resp_str: &str) {
377 assert!(resp_str.starts_with("HTTP/1.1 200"), "got: {resp_str}");
378 }
379
380 #[test]
381 #[should_panic(expected = "got: HTTP/1.1 404 Not Found")]
382 fn assert_response_ok_panics_when_not_200() {
383 assert_response_ok("HTTP/1.1 404 Not Found\r\n\r\n");
384 }
385
386 fn no_daemon_control() -> leviath_runtime::control_socket::ControlClient {
389 leviath_runtime::control_socket::ControlClient::new(
390 leviath_runtime::control_socket::control_id(std::path::Path::new("/no/such/leviath")),
391 )
392 }
393
394 fn test_state() -> AppState {
395 let (tx, _) = broadcast::channel(64);
396 AppState {
397 config: Arc::new(Config::default()),
398 event_tx: tx,
399 control: no_daemon_control(),
400 mcp: crate::commands::serve::mcp::McpAdmin::default(),
401 limits: Default::default(),
402 }
403 }
404
405 fn test_app() -> Router {
408 api_router().with_state(test_state())
409 }
410
411 #[tokio::test]
412 async fn test_list_blueprints() {
413 let app = test_app();
414 let req = Request::builder()
415 .uri("/api/blueprints")
416 .body(Body::empty())
417 .unwrap();
418 let resp = app.oneshot(req).await.unwrap();
419 assert_eq!(resp.status(), StatusCode::OK);
420 }
421
422 #[tokio::test]
423 async fn test_router_serves_routes_the_old_hand_copy_missed() {
424 let app = test_app();
428 let req = Request::builder()
429 .uri("/api/mcp/servers")
430 .body(Body::empty())
431 .unwrap();
432 let resp = app.oneshot(req).await.unwrap();
433 assert_eq!(resp.status(), StatusCode::OK);
434 }
435
436 #[tokio::test]
437 async fn test_pause_and_resume_routes_are_mounted() {
438 for action in ["pause", "resume"] {
442 let app = test_app();
443 let req = Request::builder()
444 .method("POST")
445 .uri(format!("/api/agents/some-run/{action}"))
446 .body(Body::empty())
447 .unwrap();
448 let resp = app.oneshot(req).await.unwrap();
449 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
450 }
451 }
452
453 #[tokio::test]
454 async fn test_get_blueprint_not_found() {
455 let app = test_app();
456 let req = Request::builder()
457 .uri("/api/blueprints/nonexistent-agent-xyz")
458 .body(Body::empty())
459 .unwrap();
460 let resp = app.oneshot(req).await.unwrap();
461 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
462 }
463
464 #[tokio::test]
465 async fn test_validate_blueprint_valid() {
466 let app = test_app();
467 let manifest = r#"
468[agent]
469name = "test-agent"
470version = "0.1.0"
471description = "A test"
472
473[stages.main]
474mode = "autonomous"
475[stages.main.model]
476provider = "anthropic"
477model = "claude-sonnet-4-6"
478"#;
479 let body = serde_json::json!({ "manifest": manifest });
480 let req = Request::builder()
481 .method("POST")
482 .uri("/api/blueprints/validate")
483 .header("content-type", "application/json")
484 .body(Body::from(serde_json::to_string(&body).unwrap()))
485 .unwrap();
486 let resp = app.oneshot(req).await.unwrap();
487 assert_eq!(resp.status(), StatusCode::OK);
488
489 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
490 .await
491 .unwrap();
492 let val: types::ValidateResponse = serde_json::from_slice(&body).unwrap();
493 assert!(val.valid);
494 }
495
496 #[tokio::test]
497 async fn test_validate_blueprint_invalid() {
498 let app = test_app();
499 let body = serde_json::json!({ "manifest": "not valid toml {{{{" });
500 let req = Request::builder()
501 .method("POST")
502 .uri("/api/blueprints/validate")
503 .header("content-type", "application/json")
504 .body(Body::from(serde_json::to_string(&body).unwrap()))
505 .unwrap();
506 let resp = app.oneshot(req).await.unwrap();
507 assert_eq!(resp.status(), StatusCode::OK);
508
509 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
510 .await
511 .unwrap();
512 let val: types::ValidateResponse = serde_json::from_slice(&body).unwrap();
513 assert!(!val.valid);
514 assert!(val.errors.is_some());
515 }
516
517 #[tokio::test]
518 async fn test_list_agents() {
519 let app = test_app();
520 let req = Request::builder()
521 .uri("/api/agents")
522 .body(Body::empty())
523 .unwrap();
524 let resp = app.oneshot(req).await.unwrap();
525 assert_eq!(resp.status(), StatusCode::OK);
526 }
527
528 #[tokio::test]
529 async fn test_agents_tree() {
530 let app = test_app();
531 let req = Request::builder()
532 .uri("/api/agents/tree")
533 .body(Body::empty())
534 .unwrap();
535 let resp = app.oneshot(req).await.unwrap();
536 assert_eq!(resp.status(), StatusCode::OK);
537 }
538
539 #[tokio::test]
540 async fn test_get_agent_not_found() {
541 let app = test_app();
542 let req = Request::builder()
543 .uri("/api/agents/nonexistent-run-id-xyz")
544 .body(Body::empty())
545 .unwrap();
546 let resp = app.oneshot(req).await.unwrap();
547 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
548 }
549
550 #[tokio::test]
551 async fn test_agent_children_empty() {
552 let app = test_app();
553 let req = Request::builder()
554 .uri("/api/agents/nonexistent/children")
555 .body(Body::empty())
556 .unwrap();
557 let resp = app.oneshot(req).await.unwrap();
558 assert_eq!(resp.status(), StatusCode::OK);
560 }
561
562 #[tokio::test]
563 async fn test_agent_context_not_found() {
564 let app = test_app();
565 let req = Request::builder()
566 .uri("/api/agents/nonexistent/context")
567 .body(Body::empty())
568 .unwrap();
569 let resp = app.oneshot(req).await.unwrap();
570 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
571 }
572
573 #[tokio::test]
574 async fn test_agent_logs_not_found() {
575 let app = test_app();
576 let req = Request::builder()
577 .uri("/api/agents/nonexistent/logs")
578 .body(Body::empty())
579 .unwrap();
580 let resp = app.oneshot(req).await.unwrap();
581 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
582 }
583
584 #[tokio::test]
585 async fn test_agent_result_not_found() {
586 let app = test_app();
587 let req = Request::builder()
588 .uri("/api/agents/nonexistent/result")
589 .body(Body::empty())
590 .unwrap();
591 let resp = app.oneshot(req).await.unwrap();
592 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
593 }
594
595 #[tokio::test]
596 async fn test_agent_tree_status_not_found() {
597 let app = test_app();
598 let req = Request::builder()
599 .uri("/api/agents/nonexistent/tree-status")
600 .body(Body::empty())
601 .unwrap();
602 let resp = app.oneshot(req).await.unwrap();
603 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
604 }
605
606 #[tokio::test]
607 async fn test_interaction_route_reaches_daemon() {
608 let app = test_app();
611 let req = Request::builder()
612 .uri("/api/agents/nonexistent/interaction")
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 #[tokio::test]
620 async fn test_get_config() {
621 let app = test_app();
622 let req = Request::builder()
623 .uri("/api/config")
624 .body(Body::empty())
625 .unwrap();
626 let resp = app.oneshot(req).await.unwrap();
627 assert_eq!(resp.status(), StatusCode::OK);
628
629 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
630 .await
631 .unwrap();
632 let val: types::RedactedConfig = serde_json::from_slice(&body).unwrap();
633 assert_eq!(val.default_provider, "anthropic");
634 assert!(!val.has_anthropic_key);
636 assert!(!val.has_openai_key);
637 }
638
639 #[tokio::test]
640 async fn test_tree_building() {
641 let runs = vec![
643 RunMeta::new(
644 "parent-1".to_string(),
645 "agent-a".to_string(),
646 "/path".to_string(),
647 "task".to_string(),
648 None,
649 "/work".to_string(),
650 1,
651 ),
652 {
653 let mut child = RunMeta::new(
654 "child-1".to_string(),
655 "agent-b".to_string(),
656 "/path".to_string(),
657 "sub-task".to_string(),
658 None,
659 "/work".to_string(),
660 1,
661 );
662 child.parent_run_id = Some("parent-1".to_string());
663 child.prompt_tokens = 100;
664 child.completion_tokens = 50;
665 child
666 },
667 ];
668
669 let tree = tree::build_tree_status(&runs, None);
670 assert_eq!(tree.len(), 1);
671 assert_eq!(tree[0].run_id, "parent-1");
672 assert_eq!(tree[0].children.len(), 1);
673 assert_eq!(tree[0].subtree_prompt_tokens, 100); assert_eq!(tree[0].subtree_completion_tokens, 50);
675 }
676
677 #[tokio::test]
678 async fn test_delete_blueprint_not_found() {
679 let app = test_app();
680 let req = Request::builder()
681 .method("DELETE")
682 .uri("/api/blueprints/nonexistent-agent-xyz")
683 .body(Body::empty())
684 .unwrap();
685 let resp = app.oneshot(req).await.unwrap();
686 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
687 }
688
689 #[tokio::test]
690 async fn test_server_event_serialization() {
691 let event = ServerEvent::AgentStatus {
692 agent_id: "coder".to_string(),
693 run_id: "run-123".to_string(),
694 status: "running".to_string(),
695 stage: "implement".to_string(),
696 iteration: 5,
697 tool_calls: 0,
698 accepts_messages: true,
699 };
700 let json = serde_json::to_string(&event).unwrap();
701 assert!(json.contains("\"type\":\"agent_status\""));
702 assert!(json.contains("\"agent_id\":\"coder\""));
703
704 let event2 = ServerEvent::Tokens {
705 agent_id: "coder".to_string(),
706 run_id: "run-123".to_string(),
707 prompt_tokens: 5000,
708 completion_tokens: 1200,
709 cached_tokens: 0,
710 cache_write_tokens: 0,
711 };
712 let json2 = serde_json::to_string(&event2).unwrap();
713 assert!(json2.contains("\"type\":\"tokens\""));
714 assert!(json2.contains("\"prompt_tokens\":5000"));
715 }
716
717 #[tokio::test]
718 async fn test_full_router_create_blueprint_invalid() {
719 let app = test_app();
720 let body = serde_json::json!({
721 "name": "bad-agent",
722 "manifest": "not valid toml {{{"
723 });
724 let req = Request::builder()
725 .method("POST")
726 .uri("/api/blueprints")
727 .header("content-type", "application/json")
728 .body(Body::from(serde_json::to_string(&body).unwrap()))
729 .unwrap();
730 let resp = app.oneshot(req).await.unwrap();
731 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
732 }
733
734 #[tokio::test]
735 async fn test_full_router_update_blueprint_not_found() {
736 let app = test_app();
737 let body = serde_json::json!({
738 "manifest": r#"
739[agent]
740name = "no-such-agent"
741version = "1.0.0"
742description = "Missing"
743
744[stages.run]
745prompt = "Run"
746"#
747 });
748 let req = Request::builder()
749 .method("PUT")
750 .uri("/api/blueprints/no-such-agent-xyz-99999")
751 .header("content-type", "application/json")
752 .body(Body::from(serde_json::to_string(&body).unwrap()))
753 .unwrap();
754 let resp = app.oneshot(req).await.unwrap();
755 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
756 }
757
758 #[tokio::test]
759 async fn test_full_router_kill_agent_reaches_daemon() {
760 let app = test_app();
761 let req = Request::builder()
762 .method("DELETE")
763 .uri("/api/agents/nonexistent-kill-id-xyz")
764 .body(Body::empty())
765 .unwrap();
766 let resp = app.oneshot(req).await.unwrap();
767 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
768 }
769
770 #[tokio::test]
771 async fn test_full_router_send_message_reaches_daemon() {
772 let app = test_app();
773 let body = serde_json::json!({"message": "hello"});
774 let req = Request::builder()
775 .method("POST")
776 .uri("/api/agents/nonexistent-msg-id-xyz/message")
777 .header("content-type", "application/json")
778 .body(Body::from(serde_json::to_string(&body).unwrap()))
779 .unwrap();
780 let resp = app.oneshot(req).await.unwrap();
781 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
782 }
783
784 #[tokio::test]
785 async fn test_full_router_get_models() {
786 let app = test_app();
787 let req = Request::builder()
788 .uri("/api/models")
789 .body(Body::empty())
790 .unwrap();
791 let resp = app.oneshot(req).await.unwrap();
792 assert_eq!(resp.status(), StatusCode::OK);
793 }
794
795 #[tokio::test]
796 async fn test_full_router_spawn_agent_blueprint_not_found() {
797 let app = test_app();
798 let body = serde_json::json!({
799 "blueprint": "nonexistent-blueprint-xyz",
800 "task": "do something"
801 });
802 let req = Request::builder()
803 .method("POST")
804 .uri("/api/agents")
805 .header("content-type", "application/json")
806 .body(Body::from(serde_json::to_string(&body).unwrap()))
807 .unwrap();
808 let resp = app.oneshot(req).await.unwrap();
809 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
810 }
811
812 #[test]
813 fn test_serve_args_defaults() {
814 let args = ServeArgs {
815 port: 3000,
816 host: "127.0.0.1".to_string(),
817 cors: None,
818 token: Some("test-token".to_string()),
819 allow_admin: false,
820 workdir_root: None,
821 no_remote_yolo: false,
822 };
823 assert_eq!(args.port, 3000);
824 assert_eq!(args.host, "127.0.0.1");
825 assert_eq!(args.cors, None);
826 }
827
828 #[test]
829 fn test_app_state_clone() {
830 let state = test_state();
831 let cloned = state.clone();
832 let _ = cloned.config.default_provider.clone();
834 }
835
836 #[test]
837 fn test_cors_wildcard_vs_specific() {
838 let wildcard = "*";
840 let specific = "https://example.com";
841
842 let is_wildcard = wildcard == "*";
843 assert!(is_wildcard);
844
845 let is_specific = specific != "*";
846 assert!(is_specific);
847
848 let parsed = specific.parse::<axum::http::HeaderValue>();
850 assert!(parsed.is_ok());
851 }
852
853 #[test]
854 fn test_cors_invalid_origin_falls_back() {
855 let invalid_cors = "not a valid header value \x00";
856 let result = invalid_cors.parse::<axum::http::HeaderValue>();
857 assert!(result.is_err());
859 }
860
861 #[tokio::test]
862 async fn test_submit_interaction_full_router_reaches_daemon() {
863 let app = test_app();
867 let body = serde_json::json!({"request_id": "req-1", "value": "do it", "scope": "once"});
868 let req = Request::builder()
869 .method("POST")
870 .uri("/api/agents/any/interaction")
871 .header("content-type", "application/json")
872 .body(Body::from(serde_json::to_string(&body).unwrap()))
873 .unwrap();
874 let resp = app.oneshot(req).await.unwrap();
875 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
876 }
877
878 #[tokio::test]
898 async fn execute_binds_and_serves_with_wildcard_cors() {
899 crate::config::with_isolated_config_path_async(
900 "serve-mod-wildcard-cors",
901 |_fake_dir| async move {
902 with_tracing(|| {});
903 let args = ServeArgs {
913 port: 0,
914 host: "127.0.0.1".to_string(),
915 cors: None,
916 token: Some("test-token".to_string()),
917 allow_admin: false,
918 workdir_root: None,
919 no_remote_yolo: false,
920 };
921 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
922 let handle = tokio::spawn(execute_with_shutdown(
923 args,
924 no_daemon_control(),
925 Box::pin(std::future::pending()),
926 Some(ready_tx),
927 ));
928 let addr = ready_rx
929 .await
930 .expect("server should report its bound address");
931
932 let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
934 use tokio::io::{AsyncReadExt, AsyncWriteExt};
935 stream
936 .write_all(
937 b"GET /api/config HTTP/1.1\r\nHost: localhost\r\n\
938 Authorization: Bearer test-token\r\nConnection: close\r\n\r\n",
939 )
940 .await
941 .unwrap();
942 let mut resp = Vec::new();
943 stream.read_to_end(&mut resp).await.unwrap();
944 let resp_str = String::from_utf8_lossy(&resp);
945 assert_response_ok(&resp_str);
946
947 let mut unauth = tokio::net::TcpStream::connect(addr).await.unwrap();
949 unauth
950 .write_all(
951 b"GET /api/config HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
952 )
953 .await
954 .unwrap();
955 let mut resp2 = Vec::new();
956 unauth.read_to_end(&mut resp2).await.unwrap();
957 assert!(
958 String::from_utf8_lossy(&resp2).starts_with("HTTP/1.1 401"),
959 "unauthenticated request should be 401"
960 );
961
962 handle.abort();
963 },
964 )
965 .await;
966 }
967
968 #[tokio::test]
974 async fn execute_cors_preflight_allows_authorization_header() {
975 crate::config::with_isolated_config_path_async(
976 "serve-mod-cors-preflight",
977 |_fake_dir| async move {
978 with_tracing(|| {});
979 let args = ServeArgs {
980 port: 0,
981 host: "127.0.0.1".to_string(),
982 cors: Some("*".to_string()),
983 token: Some("test-token".to_string()),
984 allow_admin: false,
985 workdir_root: None,
986 no_remote_yolo: false,
987 };
988 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
989 let handle = tokio::spawn(execute_with_shutdown(
990 args,
991 no_daemon_control(),
992 Box::pin(std::future::pending()),
993 Some(ready_tx),
994 ));
995 let addr = ready_rx
996 .await
997 .expect("server should report its bound address");
998
999 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1000 let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
1001 stream
1002 .write_all(
1003 b"OPTIONS /api/config HTTP/1.1\r\nHost: localhost\r\n\
1004 Origin: https://leviath.dev\r\n\
1005 Access-Control-Request-Method: GET\r\n\
1006 Access-Control-Request-Headers: authorization\r\n\
1007 Connection: close\r\n\r\n",
1008 )
1009 .await
1010 .unwrap();
1011 let mut resp = Vec::new();
1012 stream.read_to_end(&mut resp).await.unwrap();
1013 let lower = String::from_utf8_lossy(&resp).to_lowercase();
1014 assert!(
1015 lower.contains("access-control-allow-headers")
1016 && lower.contains("authorization"),
1017 "preflight must allow the Authorization header, got:\n{lower}"
1018 );
1019
1020 handle.abort();
1021 },
1022 )
1023 .await;
1024 }
1025
1026 #[tokio::test]
1027 async fn execute_with_specific_cors_origin_serves() {
1028 crate::config::with_isolated_config_path_async(
1029 "serve-mod-specific-cors",
1030 |_fake_dir| async move {
1031 let args = ServeArgs {
1032 port: 0,
1033 host: "127.0.0.1".to_string(),
1034 cors: Some("https://example.com".to_string()),
1035 token: Some("test-token".to_string()),
1036 allow_admin: false,
1037 workdir_root: None,
1038 no_remote_yolo: false,
1039 };
1040 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1041 let handle = tokio::spawn(execute_with_shutdown(
1042 args,
1043 no_daemon_control(),
1044 Box::pin(std::future::pending()),
1045 Some(ready_tx),
1046 ));
1047 let addr = ready_rx
1048 .await
1049 .expect("server should report its bound address");
1050 assert!(tokio::net::TcpStream::connect(addr).await.is_ok());
1051
1052 handle.abort();
1053 },
1054 )
1055 .await;
1056 }
1057
1058 #[tokio::test]
1059 async fn execute_with_unparseable_addr_returns_err() {
1060 crate::config::with_isolated_config_path_async("serve-badaddr", |_fake_dir| async move {
1063 let args = ServeArgs {
1066 port: 0,
1067 host: "not a valid host".to_string(),
1068 cors: None,
1069 token: Some("test-token".to_string()),
1070 allow_admin: false,
1071 workdir_root: None,
1072 no_remote_yolo: false,
1073 };
1074 let result = execute(args, no_daemon_control()).await;
1075 assert!(result.is_err());
1076 })
1077 .await;
1078 }
1079
1080 #[tokio::test]
1081 async fn test_agent_list_with_status_filter_full_router() {
1082 let app = test_app();
1083 let req = Request::builder()
1084 .uri("/api/agents?status=running,complete")
1085 .body(Body::empty())
1086 .unwrap();
1087 let resp = app.oneshot(req).await.unwrap();
1088 assert_eq!(resp.status(), StatusCode::OK);
1089 }
1090
1091 #[tokio::test]
1094 async fn execute_with_malformed_config_returns_err() {
1095 crate::config::with_isolated_config_path_async(
1096 "serve-mod-malformed",
1097 |_fake_dir| async move {
1098 std::fs::write(Config::config_path(), "not valid toml [[[").unwrap();
1100
1101 let args = ServeArgs {
1102 port: 0,
1103 host: "127.0.0.1".to_string(),
1104 cors: None,
1105 token: Some("test-token".to_string()),
1106 allow_admin: false,
1107 workdir_root: None,
1108 no_remote_yolo: false,
1109 };
1110 let result = execute(args, no_daemon_control()).await;
1111 assert_execute_failed_on_malformed_config(&result);
1112 },
1113 )
1114 .await;
1115 }
1116
1117 #[tokio::test]
1121 async fn execute_with_bad_api_key_logs_warning_and_serves() {
1122 with_tracing(|| {});
1123 crate::config::with_isolated_config_path_async("serve-mod-badkey", |_fake_dir| async move {
1124 std::fs::write(
1126 Config::config_path(),
1127 "default_provider = \"anthropic\"\nagent_paths = []\n[providers]\nanthropic_api_key = \"bad-key-not-sk-ant\"\n",
1128 )
1129 .unwrap();
1130
1131 let args = ServeArgs {
1132 port: 0,
1133 host: "127.0.0.1".to_string(),
1134 cors: None,
1135 token: Some("test-token".to_string()),
1136 allow_admin: false,
1137 workdir_root: None,
1138 no_remote_yolo: false,
1139 };
1140
1141 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1142 let shutdown_fut = async move {
1143 let _ = shutdown_rx.await;
1144 };
1145 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1146
1147 let handle = tokio::spawn(execute_with_shutdown(
1148 args,
1149 no_daemon_control(),
1150 Box::pin(shutdown_fut),
1151 Some(ready_tx),
1152 ));
1153 let addr = ready_rx
1154 .await
1155 .expect("server should report its bound address");
1156 let connected = tokio::net::TcpStream::connect(addr).await.is_ok();
1157 assert_connected_with_bad_api_key(connected);
1158
1159 let _ = shutdown_tx.send(());
1161 let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1162 .await
1163 .expect("timed out waiting for execute to return")
1164 .expect("task panicked");
1165 assert_execute_returned_ok_after_shutdown(&result);
1166 }).await;
1167 }
1168
1169 #[tokio::test]
1176 async fn execute_with_unbindable_address_returns_bind_error() {
1177 crate::config::with_isolated_config_path_async(
1180 "serve-unbindable",
1181 |_fake_dir| async move {
1182 let args = ServeArgs {
1183 port: 8080,
1184 host: "192.0.2.1".to_string(),
1185 cors: None,
1186 token: Some("test-token".to_string()),
1187 allow_admin: false,
1188 workdir_root: None,
1189 no_remote_yolo: false,
1190 };
1191 let result = execute(args, no_daemon_control()).await;
1192 assert_execute_failed_on_port_in_use(&result);
1193 },
1194 )
1195 .await;
1196 }
1197
1198 #[tokio::test]
1199 async fn execute_refuses_to_start_without_a_token() {
1200 temp_env::async_with_vars([("LEVIATH_API_TOKEN", None::<&str>)], async {
1202 let args = ServeArgs {
1203 port: 0,
1204 host: "127.0.0.1".to_string(),
1205 cors: None,
1206 token: None,
1207 allow_admin: false,
1208 workdir_root: None,
1209 no_remote_yolo: false,
1210 };
1211 let result = execute(args, no_daemon_control()).await;
1212 assert!(result.is_err(), "must refuse to start unauthenticated");
1213 })
1214 .await;
1215 }
1216
1217 #[tokio::test]
1220 async fn execute_with_shutdown_signal_returns_ok() {
1221 crate::config::with_isolated_config_path_async(
1222 "serve-mod-shutdown-signal",
1223 |_fake_dir| async move {
1224 let args = ServeArgs {
1225 port: 0,
1226 host: "127.0.0.1".to_string(),
1227 cors: None,
1228 token: Some("test-token".to_string()),
1229 allow_admin: false,
1230 workdir_root: None,
1231 no_remote_yolo: false,
1232 };
1233
1234 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1235 let shutdown_fut = async move {
1236 let _ = shutdown_rx.await;
1237 };
1238 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1239
1240 let handle = tokio::spawn(execute_with_shutdown(
1241 args,
1242 no_daemon_control(),
1243 Box::pin(shutdown_fut),
1244 Some(ready_tx),
1245 ));
1246 ready_rx
1247 .await
1248 .expect("server should report its bound address");
1249
1250 let _ = shutdown_tx.send(());
1252 let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1253 .await
1254 .expect("timed out waiting for execute_with_shutdown to return")
1255 .expect("task panicked");
1256 assert_execute_with_shutdown_returned_ok(&result);
1257 },
1258 )
1259 .await;
1260 }
1261
1262 #[tokio::test]
1268 async fn execute_with_shutdown_no_ready_observer_returns_ok() {
1269 crate::config::with_isolated_config_path_async(
1270 "serve-mod-no-ready",
1271 |_fake_dir| async move {
1272 let args = ServeArgs {
1273 port: 0,
1274 host: "127.0.0.1".to_string(),
1275 cors: None,
1276 token: Some("test-token".to_string()),
1277 allow_admin: false,
1278 workdir_root: None,
1279 no_remote_yolo: false,
1280 };
1281
1282 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1283 let shutdown_fut = async move {
1284 let _ = shutdown_rx.await;
1285 };
1286
1287 let handle = tokio::spawn(execute_with_shutdown(
1288 args,
1289 no_daemon_control(),
1290 Box::pin(shutdown_fut),
1291 None,
1292 ));
1293 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1295 let _ = shutdown_tx.send(());
1296 let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1297 .await
1298 .expect("timed out waiting for execute_with_shutdown to return")
1299 .expect("task panicked");
1300 assert_execute_with_shutdown_returned_ok(&result);
1301 },
1302 )
1303 .await;
1304 }
1305 #[tokio::test]
1309 async fn cors_is_off_by_default_explicit_when_asked_and_fatal_when_malformed() {
1310 crate::config::with_isolated_config_path_async("serve-mod-cors", |_fake_dir| async move {
1316 fn args_with(cors: Option<&str>) -> ServeArgs {
1317 ServeArgs {
1318 port: 0,
1319 host: "127.0.0.1".to_string(),
1320 cors: cors.map(str::to_string),
1321 token: Some("t".to_string()),
1322 allow_admin: false,
1323 workdir_root: None,
1324 no_remote_yolo: false,
1325 }
1326 }
1327
1328 async fn starts(cors: Option<&str>) {
1331 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1332 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1333 let server = tokio::spawn(execute_with_shutdown(
1334 args_with(cors),
1335 no_daemon_control(),
1336 Box::pin(async move {
1337 let _ = stop_rx.await;
1338 }),
1339 Some(ready_tx),
1340 ));
1341 ready_rx.await.expect("the server bound");
1347 let _ = stop_tx.send(());
1348 server.await.expect("join").expect("clean shutdown");
1349 }
1350
1351 starts(None).await;
1352 starts(Some("*")).await;
1353 starts(Some("https://ok.example")).await;
1354
1355 let err = execute_with_shutdown(
1358 args_with(Some("not a valid\nheader")),
1359 no_daemon_control(),
1360 Box::pin(std::future::pending()),
1361 None,
1362 )
1363 .await
1364 .expect_err("a malformed origin must refuse to start");
1365 assert!(
1368 err.to_string().contains("not a valid origin header"),
1369 "expected the CORS parse to be what refused, got: {err}"
1370 );
1371 })
1372 .await;
1373 }
1374
1375 #[tokio::test]
1378 async fn the_mcp_admin_routes_are_mounted_only_with_allow_admin() {
1379 crate::config::with_isolated_config_path_async("serve-mod-admin", |_fake_dir| async move {
1381 for allow_admin in [false, true] {
1382 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1383 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1384 let args = ServeArgs {
1385 port: 0,
1386 host: "127.0.0.1".to_string(),
1387 cors: None,
1388 token: Some("t".to_string()),
1389 allow_admin,
1390 workdir_root: None,
1391 no_remote_yolo: false,
1392 };
1393 let server = tokio::spawn(execute_with_shutdown(
1394 args,
1395 no_daemon_control(),
1396 Box::pin(async move {
1397 let _ = stop_rx.await;
1398 }),
1399 Some(ready_tx),
1400 ));
1401 let addr = ready_rx.await.expect("bound");
1402
1403 let status = reqwest::Client::new()
1404 .post(format!("http://{addr}/api/mcp/servers"))
1405 .bearer_auth("t")
1406 .json(&serde_json::json!({}))
1407 .send()
1408 .await
1409 .expect("request")
1410 .status()
1411 .as_u16();
1412 match allow_admin {
1417 false => assert_eq!(status, 405, "the admin route must not be mounted"),
1418 true => assert_ne!(status, 405, "the admin route must be mounted"),
1419 }
1420
1421 let _ = stop_tx.send(());
1422 let _ = server.await;
1423 }
1424 })
1425 .await;
1426 }
1427}