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}/message", post(interactions::send_message))
95 .route(
97 "/api/agents/{id}/interaction",
98 get(interactions::get_interaction).post(interactions::submit_interaction),
99 )
100 .route("/api/mcp/servers", get(mcp::list_servers))
103 .route("/api/mcp/servers/{name}/status", get(mcp::status))
104 .route("/api/mcp/servers/{name}/login", post(mcp::login))
105 .route("/api/mcp/servers/{name}/test", post(mcp::test_server))
106 .route("/api/config", get(config::get_config))
108 .route("/api/config/validate", post(config::validate_config_key))
109 .route("/api/models", get(config::get_models))
110 .route("/ws", get(websocket::ws_global))
112 .route("/ws/agents/{id}", get(websocket::ws_agent))
113}
114
115async fn execute_with_shutdown(
136 args: ServeArgs,
137 control: leviath_runtime::control_socket::ControlClient,
138 shutdown: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
139 ready: Option<tokio::sync::oneshot::Sender<SocketAddr>>,
140) -> anyhow::Result<()> {
141 let auth_token = std::sync::Arc::new(auth::resolve_token(args.token.as_deref())?);
143 if args.host != "127.0.0.1" && args.host != "localhost" && args.host != "::1" {
145 tracing::warn!(
146 host = %args.host,
147 "serving the agent API on a non-local address - anyone who can reach \
148 this host and holds the token can spawn agents"
149 );
150 }
151
152 let cfg = Config::load()?;
153 let allow_local_network = cfg.security.allow_local_network;
155 for warning in cfg.validate_keys() {
156 tracing::warn!("{}", warning);
157 }
158
159 let (event_tx, _) = broadcast::channel::<ServerEvent>(1024);
160
161 let state = AppState {
162 config: Arc::new(cfg),
163 event_tx: event_tx.clone(),
164 control,
165 mcp: mcp::McpAdmin::default(),
166 limits: Arc::new(ServeLimits {
167 workdir_root: args.workdir_root.clone(),
168 no_remote_yolo: args.no_remote_yolo,
169 allow_local_network,
170 }),
171 };
172
173 let event_state = state.clone();
182 let _event_guard = AbortOnDrop(tokio::spawn(polling::event_loop(
183 event_state,
184 polling::RECONNECT_BACKOFF,
185 )));
186
187 let cors = match args.cors.as_deref() {
191 None => None,
192 Some("*") => Some(
193 CorsLayer::new()
194 .allow_origin(Any)
195 .allow_methods(Any)
196 .allow_headers([
201 axum::http::header::AUTHORIZATION,
202 axum::http::header::CONTENT_TYPE,
203 ]),
204 ),
205 Some(origin) => {
206 let value = origin.parse::<axum::http::HeaderValue>().map_err(|_| {
210 anyhow::anyhow!("--cors value '{origin}' is not a valid origin header")
211 })?;
212 Some(
213 CorsLayer::new()
214 .allow_origin(value)
215 .allow_methods(Any)
216 .allow_headers([
221 axum::http::header::AUTHORIZATION,
222 axum::http::header::CONTENT_TYPE,
223 ]),
224 )
225 }
226 };
227
228 let app = api_router();
229
230 let app = match args.allow_admin {
238 true => app
239 .route("/api/mcp/servers", post(mcp::add_server))
240 .route("/api/mcp/servers/{name}", delete(mcp::remove_server))
241 .route("/api/config", put(config::put_config)),
244 false => app,
245 };
246
247 let app = app
248 .layer(axum::middleware::from_fn_with_state(
251 auth_token,
252 auth::require_auth,
253 ))
254 .with_state(state);
255 let app = match cors {
259 Some(layer) => app.layer(layer),
260 None => app,
261 };
262
263 let addr: SocketAddr = format!("{}:{}", args.host, args.port).parse()?;
264 tracing::info!("Listening on http://{}", addr);
265 println!("Leviath API server listening on http://{}", addr);
266
267 let listener = tokio::net::TcpListener::bind(addr).await?;
268 if let Some(ready) = ready {
269 let local_addr = listener
272 .local_addr()
273 .expect("infallible: a freshly bound TcpListener always has a local address");
274 let _ = ready.send(local_addr);
275 }
276 let _ = axum::serve(listener, app)
279 .with_graceful_shutdown(shutdown)
280 .await;
281
282 Ok(())
283}
284
285#[cfg(test)]
288mod tests {
289 use super::*;
290 use axum::body::Body;
291 use axum::http::{Request, StatusCode};
292 use tower::ServiceExt;
293
294 use crate::runstate::RunMeta;
295 use crate::test_support::with_tracing;
296
297 fn assert_execute_failed_on_malformed_config(result: &anyhow::Result<()>) {
302 assert!(
303 result.is_err(),
304 "execute should fail when config is malformed"
305 );
306 }
307
308 #[test]
309 #[should_panic(expected = "execute should fail when config is malformed")]
310 fn assert_execute_failed_on_malformed_config_panics_when_ok() {
311 assert_execute_failed_on_malformed_config(&Ok(()));
312 }
313
314 fn assert_connected_with_bad_api_key(connected: bool) {
317 assert!(connected, "server should start even with a bad API key");
318 }
319
320 #[test]
321 #[should_panic(expected = "server should start even with a bad API key")]
322 fn assert_connected_with_bad_api_key_panics_when_not_connected() {
323 assert_connected_with_bad_api_key(false);
324 }
325
326 fn assert_execute_returned_ok_after_shutdown(result: &Result<(), anyhow::Error>) {
329 assert!(
330 result.is_ok(),
331 "execute should return Ok after graceful shutdown"
332 );
333 }
334
335 #[test]
336 #[should_panic(expected = "execute should return Ok after graceful shutdown")]
337 fn assert_execute_returned_ok_after_shutdown_panics_when_err() {
338 assert_execute_returned_ok_after_shutdown(&Err(anyhow::anyhow!("boom")));
339 }
340
341 fn assert_execute_failed_on_port_in_use(result: &anyhow::Result<()>) {
344 assert!(
345 result.is_err(),
346 "execute should fail when port is already in use"
347 );
348 }
349
350 #[test]
351 #[should_panic(expected = "execute should fail when port is already in use")]
352 fn assert_execute_failed_on_port_in_use_panics_when_ok() {
353 assert_execute_failed_on_port_in_use(&Ok(()));
354 }
355
356 fn assert_execute_with_shutdown_returned_ok(result: &Result<(), anyhow::Error>) {
360 assert!(
361 result.is_ok(),
362 "execute_with_shutdown should return Ok(()) after graceful shutdown"
363 );
364 }
365
366 #[test]
367 #[should_panic(expected = "execute_with_shutdown should return Ok(()) after graceful shutdown")]
368 fn assert_execute_with_shutdown_returned_ok_panics_when_err() {
369 assert_execute_with_shutdown_returned_ok(&Err(anyhow::anyhow!("boom")));
370 }
371
372 fn assert_response_ok(resp_str: &str) {
375 assert!(resp_str.starts_with("HTTP/1.1 200"), "got: {resp_str}");
376 }
377
378 #[test]
379 #[should_panic(expected = "got: HTTP/1.1 404 Not Found")]
380 fn assert_response_ok_panics_when_not_200() {
381 assert_response_ok("HTTP/1.1 404 Not Found\r\n\r\n");
382 }
383
384 fn no_daemon_control() -> leviath_runtime::control_socket::ControlClient {
387 leviath_runtime::control_socket::ControlClient::new(
388 leviath_runtime::control_socket::control_id(std::path::Path::new("/no/such/leviath")),
389 )
390 }
391
392 fn test_state() -> AppState {
393 let (tx, _) = broadcast::channel(64);
394 AppState {
395 config: Arc::new(Config::default()),
396 event_tx: tx,
397 control: no_daemon_control(),
398 mcp: crate::commands::serve::mcp::McpAdmin::default(),
399 limits: Default::default(),
400 }
401 }
402
403 fn test_app() -> Router {
406 api_router().with_state(test_state())
407 }
408
409 #[tokio::test]
410 async fn test_list_blueprints() {
411 let app = test_app();
412 let req = Request::builder()
413 .uri("/api/blueprints")
414 .body(Body::empty())
415 .unwrap();
416 let resp = app.oneshot(req).await.unwrap();
417 assert_eq!(resp.status(), StatusCode::OK);
418 }
419
420 #[tokio::test]
421 async fn test_router_serves_routes_the_old_hand_copy_missed() {
422 let app = test_app();
426 let req = Request::builder()
427 .uri("/api/mcp/servers")
428 .body(Body::empty())
429 .unwrap();
430 let resp = app.oneshot(req).await.unwrap();
431 assert_eq!(resp.status(), StatusCode::OK);
432 }
433
434 #[tokio::test]
435 async fn test_get_blueprint_not_found() {
436 let app = test_app();
437 let req = Request::builder()
438 .uri("/api/blueprints/nonexistent-agent-xyz")
439 .body(Body::empty())
440 .unwrap();
441 let resp = app.oneshot(req).await.unwrap();
442 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
443 }
444
445 #[tokio::test]
446 async fn test_validate_blueprint_valid() {
447 let app = test_app();
448 let manifest = r#"
449[agent]
450name = "test-agent"
451version = "0.1.0"
452description = "A test"
453
454[stages.main]
455mode = "autonomous"
456[stages.main.model]
457provider = "anthropic"
458model = "claude-sonnet-4-6"
459"#;
460 let body = serde_json::json!({ "manifest": manifest });
461 let req = Request::builder()
462 .method("POST")
463 .uri("/api/blueprints/validate")
464 .header("content-type", "application/json")
465 .body(Body::from(serde_json::to_string(&body).unwrap()))
466 .unwrap();
467 let resp = app.oneshot(req).await.unwrap();
468 assert_eq!(resp.status(), StatusCode::OK);
469
470 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
471 .await
472 .unwrap();
473 let val: types::ValidateResponse = serde_json::from_slice(&body).unwrap();
474 assert!(val.valid);
475 }
476
477 #[tokio::test]
478 async fn test_validate_blueprint_invalid() {
479 let app = test_app();
480 let body = serde_json::json!({ "manifest": "not valid toml {{{{" });
481 let req = Request::builder()
482 .method("POST")
483 .uri("/api/blueprints/validate")
484 .header("content-type", "application/json")
485 .body(Body::from(serde_json::to_string(&body).unwrap()))
486 .unwrap();
487 let resp = app.oneshot(req).await.unwrap();
488 assert_eq!(resp.status(), StatusCode::OK);
489
490 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
491 .await
492 .unwrap();
493 let val: types::ValidateResponse = serde_json::from_slice(&body).unwrap();
494 assert!(!val.valid);
495 assert!(val.errors.is_some());
496 }
497
498 #[tokio::test]
499 async fn test_list_agents() {
500 let app = test_app();
501 let req = Request::builder()
502 .uri("/api/agents")
503 .body(Body::empty())
504 .unwrap();
505 let resp = app.oneshot(req).await.unwrap();
506 assert_eq!(resp.status(), StatusCode::OK);
507 }
508
509 #[tokio::test]
510 async fn test_agents_tree() {
511 let app = test_app();
512 let req = Request::builder()
513 .uri("/api/agents/tree")
514 .body(Body::empty())
515 .unwrap();
516 let resp = app.oneshot(req).await.unwrap();
517 assert_eq!(resp.status(), StatusCode::OK);
518 }
519
520 #[tokio::test]
521 async fn test_get_agent_not_found() {
522 let app = test_app();
523 let req = Request::builder()
524 .uri("/api/agents/nonexistent-run-id-xyz")
525 .body(Body::empty())
526 .unwrap();
527 let resp = app.oneshot(req).await.unwrap();
528 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
529 }
530
531 #[tokio::test]
532 async fn test_agent_children_empty() {
533 let app = test_app();
534 let req = Request::builder()
535 .uri("/api/agents/nonexistent/children")
536 .body(Body::empty())
537 .unwrap();
538 let resp = app.oneshot(req).await.unwrap();
539 assert_eq!(resp.status(), StatusCode::OK);
541 }
542
543 #[tokio::test]
544 async fn test_agent_context_not_found() {
545 let app = test_app();
546 let req = Request::builder()
547 .uri("/api/agents/nonexistent/context")
548 .body(Body::empty())
549 .unwrap();
550 let resp = app.oneshot(req).await.unwrap();
551 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
552 }
553
554 #[tokio::test]
555 async fn test_agent_logs_not_found() {
556 let app = test_app();
557 let req = Request::builder()
558 .uri("/api/agents/nonexistent/logs")
559 .body(Body::empty())
560 .unwrap();
561 let resp = app.oneshot(req).await.unwrap();
562 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
563 }
564
565 #[tokio::test]
566 async fn test_agent_result_not_found() {
567 let app = test_app();
568 let req = Request::builder()
569 .uri("/api/agents/nonexistent/result")
570 .body(Body::empty())
571 .unwrap();
572 let resp = app.oneshot(req).await.unwrap();
573 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
574 }
575
576 #[tokio::test]
577 async fn test_agent_tree_status_not_found() {
578 let app = test_app();
579 let req = Request::builder()
580 .uri("/api/agents/nonexistent/tree-status")
581 .body(Body::empty())
582 .unwrap();
583 let resp = app.oneshot(req).await.unwrap();
584 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
585 }
586
587 #[tokio::test]
588 async fn test_interaction_route_reaches_daemon() {
589 let app = test_app();
592 let req = Request::builder()
593 .uri("/api/agents/nonexistent/interaction")
594 .body(Body::empty())
595 .unwrap();
596 let resp = app.oneshot(req).await.unwrap();
597 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
598 }
599
600 #[tokio::test]
601 async fn test_get_config() {
602 let app = test_app();
603 let req = Request::builder()
604 .uri("/api/config")
605 .body(Body::empty())
606 .unwrap();
607 let resp = app.oneshot(req).await.unwrap();
608 assert_eq!(resp.status(), StatusCode::OK);
609
610 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
611 .await
612 .unwrap();
613 let val: types::RedactedConfig = serde_json::from_slice(&body).unwrap();
614 assert_eq!(val.default_provider, "anthropic");
615 assert!(!val.has_anthropic_key);
617 assert!(!val.has_openai_key);
618 }
619
620 #[tokio::test]
621 async fn test_tree_building() {
622 let runs = vec![
624 RunMeta::new(
625 "parent-1".to_string(),
626 "agent-a".to_string(),
627 "/path".to_string(),
628 "task".to_string(),
629 None,
630 "/work".to_string(),
631 1,
632 ),
633 {
634 let mut child = RunMeta::new(
635 "child-1".to_string(),
636 "agent-b".to_string(),
637 "/path".to_string(),
638 "sub-task".to_string(),
639 None,
640 "/work".to_string(),
641 1,
642 );
643 child.parent_run_id = Some("parent-1".to_string());
644 child.prompt_tokens = 100;
645 child.completion_tokens = 50;
646 child
647 },
648 ];
649
650 let tree = tree::build_tree_status(&runs, None);
651 assert_eq!(tree.len(), 1);
652 assert_eq!(tree[0].run_id, "parent-1");
653 assert_eq!(tree[0].children.len(), 1);
654 assert_eq!(tree[0].subtree_prompt_tokens, 100); assert_eq!(tree[0].subtree_completion_tokens, 50);
656 }
657
658 #[tokio::test]
659 async fn test_delete_blueprint_not_found() {
660 let app = test_app();
661 let req = Request::builder()
662 .method("DELETE")
663 .uri("/api/blueprints/nonexistent-agent-xyz")
664 .body(Body::empty())
665 .unwrap();
666 let resp = app.oneshot(req).await.unwrap();
667 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
668 }
669
670 #[tokio::test]
671 async fn test_server_event_serialization() {
672 let event = ServerEvent::AgentStatus {
673 agent_id: "coder".to_string(),
674 run_id: "run-123".to_string(),
675 status: "running".to_string(),
676 stage: "implement".to_string(),
677 iteration: 5,
678 tool_calls: 0,
679 accepts_messages: true,
680 };
681 let json = serde_json::to_string(&event).unwrap();
682 assert!(json.contains("\"type\":\"agent_status\""));
683 assert!(json.contains("\"agent_id\":\"coder\""));
684
685 let event2 = ServerEvent::Tokens {
686 agent_id: "coder".to_string(),
687 run_id: "run-123".to_string(),
688 prompt_tokens: 5000,
689 completion_tokens: 1200,
690 cached_tokens: 0,
691 cache_write_tokens: 0,
692 };
693 let json2 = serde_json::to_string(&event2).unwrap();
694 assert!(json2.contains("\"type\":\"tokens\""));
695 assert!(json2.contains("\"prompt_tokens\":5000"));
696 }
697
698 #[tokio::test]
699 async fn test_full_router_create_blueprint_invalid() {
700 let app = test_app();
701 let body = serde_json::json!({
702 "name": "bad-agent",
703 "manifest": "not valid toml {{{"
704 });
705 let req = Request::builder()
706 .method("POST")
707 .uri("/api/blueprints")
708 .header("content-type", "application/json")
709 .body(Body::from(serde_json::to_string(&body).unwrap()))
710 .unwrap();
711 let resp = app.oneshot(req).await.unwrap();
712 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
713 }
714
715 #[tokio::test]
716 async fn test_full_router_update_blueprint_not_found() {
717 let app = test_app();
718 let body = serde_json::json!({
719 "manifest": r#"
720[agent]
721name = "no-such-agent"
722version = "1.0.0"
723description = "Missing"
724
725[stages.run]
726prompt = "Run"
727"#
728 });
729 let req = Request::builder()
730 .method("PUT")
731 .uri("/api/blueprints/no-such-agent-xyz-99999")
732 .header("content-type", "application/json")
733 .body(Body::from(serde_json::to_string(&body).unwrap()))
734 .unwrap();
735 let resp = app.oneshot(req).await.unwrap();
736 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
737 }
738
739 #[tokio::test]
740 async fn test_full_router_kill_agent_reaches_daemon() {
741 let app = test_app();
742 let req = Request::builder()
743 .method("DELETE")
744 .uri("/api/agents/nonexistent-kill-id-xyz")
745 .body(Body::empty())
746 .unwrap();
747 let resp = app.oneshot(req).await.unwrap();
748 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
749 }
750
751 #[tokio::test]
752 async fn test_full_router_send_message_reaches_daemon() {
753 let app = test_app();
754 let body = serde_json::json!({"message": "hello"});
755 let req = Request::builder()
756 .method("POST")
757 .uri("/api/agents/nonexistent-msg-id-xyz/message")
758 .header("content-type", "application/json")
759 .body(Body::from(serde_json::to_string(&body).unwrap()))
760 .unwrap();
761 let resp = app.oneshot(req).await.unwrap();
762 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
763 }
764
765 #[tokio::test]
766 async fn test_full_router_get_models() {
767 let app = test_app();
768 let req = Request::builder()
769 .uri("/api/models")
770 .body(Body::empty())
771 .unwrap();
772 let resp = app.oneshot(req).await.unwrap();
773 assert_eq!(resp.status(), StatusCode::OK);
774 }
775
776 #[tokio::test]
777 async fn test_full_router_spawn_agent_blueprint_not_found() {
778 let app = test_app();
779 let body = serde_json::json!({
780 "blueprint": "nonexistent-blueprint-xyz",
781 "task": "do something"
782 });
783 let req = Request::builder()
784 .method("POST")
785 .uri("/api/agents")
786 .header("content-type", "application/json")
787 .body(Body::from(serde_json::to_string(&body).unwrap()))
788 .unwrap();
789 let resp = app.oneshot(req).await.unwrap();
790 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
791 }
792
793 #[test]
794 fn test_serve_args_defaults() {
795 let args = ServeArgs {
796 port: 3000,
797 host: "127.0.0.1".to_string(),
798 cors: None,
799 token: Some("test-token".to_string()),
800 allow_admin: false,
801 workdir_root: None,
802 no_remote_yolo: false,
803 };
804 assert_eq!(args.port, 3000);
805 assert_eq!(args.host, "127.0.0.1");
806 assert_eq!(args.cors, None);
807 }
808
809 #[test]
810 fn test_app_state_clone() {
811 let state = test_state();
812 let cloned = state.clone();
813 let _ = cloned.config.default_provider.clone();
815 }
816
817 #[test]
818 fn test_cors_wildcard_vs_specific() {
819 let wildcard = "*";
821 let specific = "https://example.com";
822
823 let is_wildcard = wildcard == "*";
824 assert!(is_wildcard);
825
826 let is_specific = specific != "*";
827 assert!(is_specific);
828
829 let parsed = specific.parse::<axum::http::HeaderValue>();
831 assert!(parsed.is_ok());
832 }
833
834 #[test]
835 fn test_cors_invalid_origin_falls_back() {
836 let invalid_cors = "not a valid header value \x00";
837 let result = invalid_cors.parse::<axum::http::HeaderValue>();
838 assert!(result.is_err());
840 }
841
842 #[tokio::test]
843 async fn test_submit_interaction_full_router_reaches_daemon() {
844 let app = test_app();
848 let body = serde_json::json!({"request_id": "req-1", "value": "do it", "scope": "once"});
849 let req = Request::builder()
850 .method("POST")
851 .uri("/api/agents/any/interaction")
852 .header("content-type", "application/json")
853 .body(Body::from(serde_json::to_string(&body).unwrap()))
854 .unwrap();
855 let resp = app.oneshot(req).await.unwrap();
856 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
857 }
858
859 #[tokio::test]
879 async fn execute_binds_and_serves_with_wildcard_cors() {
880 crate::config::with_isolated_config_path_async(
881 "serve-mod-wildcard-cors",
882 |_fake_dir| async move {
883 with_tracing(|| {});
884 let args = ServeArgs {
894 port: 0,
895 host: "127.0.0.1".to_string(),
896 cors: None,
897 token: Some("test-token".to_string()),
898 allow_admin: false,
899 workdir_root: None,
900 no_remote_yolo: false,
901 };
902 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
903 let handle = tokio::spawn(execute_with_shutdown(
904 args,
905 no_daemon_control(),
906 Box::pin(std::future::pending()),
907 Some(ready_tx),
908 ));
909 let addr = ready_rx
910 .await
911 .expect("server should report its bound address");
912
913 let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
915 use tokio::io::{AsyncReadExt, AsyncWriteExt};
916 stream
917 .write_all(
918 b"GET /api/config HTTP/1.1\r\nHost: localhost\r\n\
919 Authorization: Bearer test-token\r\nConnection: close\r\n\r\n",
920 )
921 .await
922 .unwrap();
923 let mut resp = Vec::new();
924 stream.read_to_end(&mut resp).await.unwrap();
925 let resp_str = String::from_utf8_lossy(&resp);
926 assert_response_ok(&resp_str);
927
928 let mut unauth = tokio::net::TcpStream::connect(addr).await.unwrap();
930 unauth
931 .write_all(
932 b"GET /api/config HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
933 )
934 .await
935 .unwrap();
936 let mut resp2 = Vec::new();
937 unauth.read_to_end(&mut resp2).await.unwrap();
938 assert!(
939 String::from_utf8_lossy(&resp2).starts_with("HTTP/1.1 401"),
940 "unauthenticated request should be 401"
941 );
942
943 handle.abort();
944 },
945 )
946 .await;
947 }
948
949 #[tokio::test]
955 async fn execute_cors_preflight_allows_authorization_header() {
956 crate::config::with_isolated_config_path_async(
957 "serve-mod-cors-preflight",
958 |_fake_dir| async move {
959 with_tracing(|| {});
960 let args = ServeArgs {
961 port: 0,
962 host: "127.0.0.1".to_string(),
963 cors: Some("*".to_string()),
964 token: Some("test-token".to_string()),
965 allow_admin: false,
966 workdir_root: None,
967 no_remote_yolo: false,
968 };
969 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
970 let handle = tokio::spawn(execute_with_shutdown(
971 args,
972 no_daemon_control(),
973 Box::pin(std::future::pending()),
974 Some(ready_tx),
975 ));
976 let addr = ready_rx
977 .await
978 .expect("server should report its bound address");
979
980 use tokio::io::{AsyncReadExt, AsyncWriteExt};
981 let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
982 stream
983 .write_all(
984 b"OPTIONS /api/config HTTP/1.1\r\nHost: localhost\r\n\
985 Origin: https://leviath.dev\r\n\
986 Access-Control-Request-Method: GET\r\n\
987 Access-Control-Request-Headers: authorization\r\n\
988 Connection: close\r\n\r\n",
989 )
990 .await
991 .unwrap();
992 let mut resp = Vec::new();
993 stream.read_to_end(&mut resp).await.unwrap();
994 let lower = String::from_utf8_lossy(&resp).to_lowercase();
995 assert!(
996 lower.contains("access-control-allow-headers")
997 && lower.contains("authorization"),
998 "preflight must allow the Authorization header, got:\n{lower}"
999 );
1000
1001 handle.abort();
1002 },
1003 )
1004 .await;
1005 }
1006
1007 #[tokio::test]
1008 async fn execute_with_specific_cors_origin_serves() {
1009 crate::config::with_isolated_config_path_async(
1010 "serve-mod-specific-cors",
1011 |_fake_dir| async move {
1012 let args = ServeArgs {
1013 port: 0,
1014 host: "127.0.0.1".to_string(),
1015 cors: Some("https://example.com".to_string()),
1016 token: Some("test-token".to_string()),
1017 allow_admin: false,
1018 workdir_root: None,
1019 no_remote_yolo: false,
1020 };
1021 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1022 let handle = tokio::spawn(execute_with_shutdown(
1023 args,
1024 no_daemon_control(),
1025 Box::pin(std::future::pending()),
1026 Some(ready_tx),
1027 ));
1028 let addr = ready_rx
1029 .await
1030 .expect("server should report its bound address");
1031 assert!(tokio::net::TcpStream::connect(addr).await.is_ok());
1032
1033 handle.abort();
1034 },
1035 )
1036 .await;
1037 }
1038
1039 #[tokio::test]
1040 async fn execute_with_unparseable_addr_returns_err() {
1041 crate::config::with_isolated_config_path_async("serve-badaddr", |_fake_dir| async move {
1044 let args = ServeArgs {
1047 port: 0,
1048 host: "not a valid host".to_string(),
1049 cors: None,
1050 token: Some("test-token".to_string()),
1051 allow_admin: false,
1052 workdir_root: None,
1053 no_remote_yolo: false,
1054 };
1055 let result = execute(args, no_daemon_control()).await;
1056 assert!(result.is_err());
1057 })
1058 .await;
1059 }
1060
1061 #[tokio::test]
1062 async fn test_agent_list_with_status_filter_full_router() {
1063 let app = test_app();
1064 let req = Request::builder()
1065 .uri("/api/agents?status=running,complete")
1066 .body(Body::empty())
1067 .unwrap();
1068 let resp = app.oneshot(req).await.unwrap();
1069 assert_eq!(resp.status(), StatusCode::OK);
1070 }
1071
1072 #[tokio::test]
1075 async fn execute_with_malformed_config_returns_err() {
1076 crate::config::with_isolated_config_path_async(
1077 "serve-mod-malformed",
1078 |_fake_dir| async move {
1079 std::fs::write(Config::config_path(), "not valid toml [[[").unwrap();
1081
1082 let args = ServeArgs {
1083 port: 0,
1084 host: "127.0.0.1".to_string(),
1085 cors: None,
1086 token: Some("test-token".to_string()),
1087 allow_admin: false,
1088 workdir_root: None,
1089 no_remote_yolo: false,
1090 };
1091 let result = execute(args, no_daemon_control()).await;
1092 assert_execute_failed_on_malformed_config(&result);
1093 },
1094 )
1095 .await;
1096 }
1097
1098 #[tokio::test]
1102 async fn execute_with_bad_api_key_logs_warning_and_serves() {
1103 with_tracing(|| {});
1104 crate::config::with_isolated_config_path_async("serve-mod-badkey", |_fake_dir| async move {
1105 std::fs::write(
1107 Config::config_path(),
1108 "default_provider = \"anthropic\"\nagent_paths = []\n[providers]\nanthropic_api_key = \"bad-key-not-sk-ant\"\n",
1109 )
1110 .unwrap();
1111
1112 let args = ServeArgs {
1113 port: 0,
1114 host: "127.0.0.1".to_string(),
1115 cors: None,
1116 token: Some("test-token".to_string()),
1117 allow_admin: false,
1118 workdir_root: None,
1119 no_remote_yolo: false,
1120 };
1121
1122 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1123 let shutdown_fut = async move {
1124 let _ = shutdown_rx.await;
1125 };
1126 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1127
1128 let handle = tokio::spawn(execute_with_shutdown(
1129 args,
1130 no_daemon_control(),
1131 Box::pin(shutdown_fut),
1132 Some(ready_tx),
1133 ));
1134 let addr = ready_rx
1135 .await
1136 .expect("server should report its bound address");
1137 let connected = tokio::net::TcpStream::connect(addr).await.is_ok();
1138 assert_connected_with_bad_api_key(connected);
1139
1140 let _ = shutdown_tx.send(());
1142 let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1143 .await
1144 .expect("timed out waiting for execute to return")
1145 .expect("task panicked");
1146 assert_execute_returned_ok_after_shutdown(&result);
1147 }).await;
1148 }
1149
1150 #[tokio::test]
1157 async fn execute_with_unbindable_address_returns_bind_error() {
1158 crate::config::with_isolated_config_path_async(
1161 "serve-unbindable",
1162 |_fake_dir| async move {
1163 let args = ServeArgs {
1164 port: 8080,
1165 host: "192.0.2.1".to_string(),
1166 cors: None,
1167 token: Some("test-token".to_string()),
1168 allow_admin: false,
1169 workdir_root: None,
1170 no_remote_yolo: false,
1171 };
1172 let result = execute(args, no_daemon_control()).await;
1173 assert_execute_failed_on_port_in_use(&result);
1174 },
1175 )
1176 .await;
1177 }
1178
1179 #[tokio::test]
1180 async fn execute_refuses_to_start_without_a_token() {
1181 temp_env::async_with_vars([("LEVIATH_API_TOKEN", None::<&str>)], async {
1183 let args = ServeArgs {
1184 port: 0,
1185 host: "127.0.0.1".to_string(),
1186 cors: None,
1187 token: None,
1188 allow_admin: false,
1189 workdir_root: None,
1190 no_remote_yolo: false,
1191 };
1192 let result = execute(args, no_daemon_control()).await;
1193 assert!(result.is_err(), "must refuse to start unauthenticated");
1194 })
1195 .await;
1196 }
1197
1198 #[tokio::test]
1201 async fn execute_with_shutdown_signal_returns_ok() {
1202 crate::config::with_isolated_config_path_async(
1203 "serve-mod-shutdown-signal",
1204 |_fake_dir| async move {
1205 let args = ServeArgs {
1206 port: 0,
1207 host: "127.0.0.1".to_string(),
1208 cors: None,
1209 token: Some("test-token".to_string()),
1210 allow_admin: false,
1211 workdir_root: None,
1212 no_remote_yolo: false,
1213 };
1214
1215 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1216 let shutdown_fut = async move {
1217 let _ = shutdown_rx.await;
1218 };
1219 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1220
1221 let handle = tokio::spawn(execute_with_shutdown(
1222 args,
1223 no_daemon_control(),
1224 Box::pin(shutdown_fut),
1225 Some(ready_tx),
1226 ));
1227 ready_rx
1228 .await
1229 .expect("server should report its bound address");
1230
1231 let _ = shutdown_tx.send(());
1233 let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1234 .await
1235 .expect("timed out waiting for execute_with_shutdown to return")
1236 .expect("task panicked");
1237 assert_execute_with_shutdown_returned_ok(&result);
1238 },
1239 )
1240 .await;
1241 }
1242
1243 #[tokio::test]
1249 async fn execute_with_shutdown_no_ready_observer_returns_ok() {
1250 crate::config::with_isolated_config_path_async(
1251 "serve-mod-no-ready",
1252 |_fake_dir| async move {
1253 let args = ServeArgs {
1254 port: 0,
1255 host: "127.0.0.1".to_string(),
1256 cors: None,
1257 token: Some("test-token".to_string()),
1258 allow_admin: false,
1259 workdir_root: None,
1260 no_remote_yolo: false,
1261 };
1262
1263 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1264 let shutdown_fut = async move {
1265 let _ = shutdown_rx.await;
1266 };
1267
1268 let handle = tokio::spawn(execute_with_shutdown(
1269 args,
1270 no_daemon_control(),
1271 Box::pin(shutdown_fut),
1272 None,
1273 ));
1274 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1276 let _ = shutdown_tx.send(());
1277 let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1278 .await
1279 .expect("timed out waiting for execute_with_shutdown to return")
1280 .expect("task panicked");
1281 assert_execute_with_shutdown_returned_ok(&result);
1282 },
1283 )
1284 .await;
1285 }
1286 #[tokio::test]
1290 async fn cors_is_off_by_default_explicit_when_asked_and_fatal_when_malformed() {
1291 crate::config::with_isolated_config_path_async("serve-mod-cors", |_fake_dir| async move {
1297 fn args_with(cors: Option<&str>) -> ServeArgs {
1298 ServeArgs {
1299 port: 0,
1300 host: "127.0.0.1".to_string(),
1301 cors: cors.map(str::to_string),
1302 token: Some("t".to_string()),
1303 allow_admin: false,
1304 workdir_root: None,
1305 no_remote_yolo: false,
1306 }
1307 }
1308
1309 async fn starts(cors: Option<&str>) {
1312 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1313 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1314 let server = tokio::spawn(execute_with_shutdown(
1315 args_with(cors),
1316 no_daemon_control(),
1317 Box::pin(async move {
1318 let _ = stop_rx.await;
1319 }),
1320 Some(ready_tx),
1321 ));
1322 ready_rx.await.expect("the server bound");
1328 let _ = stop_tx.send(());
1329 server.await.expect("join").expect("clean shutdown");
1330 }
1331
1332 starts(None).await;
1333 starts(Some("*")).await;
1334 starts(Some("https://ok.example")).await;
1335
1336 let err = execute_with_shutdown(
1339 args_with(Some("not a valid\nheader")),
1340 no_daemon_control(),
1341 Box::pin(std::future::pending()),
1342 None,
1343 )
1344 .await
1345 .expect_err("a malformed origin must refuse to start");
1346 assert!(
1349 err.to_string().contains("not a valid origin header"),
1350 "expected the CORS parse to be what refused, got: {err}"
1351 );
1352 })
1353 .await;
1354 }
1355
1356 #[tokio::test]
1359 async fn the_mcp_admin_routes_are_mounted_only_with_allow_admin() {
1360 crate::config::with_isolated_config_path_async("serve-mod-admin", |_fake_dir| async move {
1362 for allow_admin in [false, true] {
1363 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1364 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1365 let args = ServeArgs {
1366 port: 0,
1367 host: "127.0.0.1".to_string(),
1368 cors: None,
1369 token: Some("t".to_string()),
1370 allow_admin,
1371 workdir_root: None,
1372 no_remote_yolo: false,
1373 };
1374 let server = tokio::spawn(execute_with_shutdown(
1375 args,
1376 no_daemon_control(),
1377 Box::pin(async move {
1378 let _ = stop_rx.await;
1379 }),
1380 Some(ready_tx),
1381 ));
1382 let addr = ready_rx.await.expect("bound");
1383
1384 let status = reqwest::Client::new()
1385 .post(format!("http://{addr}/api/mcp/servers"))
1386 .bearer_auth("t")
1387 .json(&serde_json::json!({}))
1388 .send()
1389 .await
1390 .expect("request")
1391 .status()
1392 .as_u16();
1393 match allow_admin {
1398 false => assert_eq!(status, 405, "the admin route must not be mounted"),
1399 true => assert_ne!(status, 405, "the admin route must be mounted"),
1400 }
1401
1402 let _ = stop_tx.send(());
1403 let _ = server.await;
1404 }
1405 })
1406 .await;
1407 }
1408}