Skip to main content

leviath_cli/commands/serve/
mod.rs

1//! `lev serve` - REST + WebSocket API server.
2//!
3//! Exposes agent management, blueprint CRUD, and live event streaming over
4//! HTTP. No web UI - the frontend lives in a separate repo.
5
6mod 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
32// ─── Entrypoint ──────────────────────────────────────────────────────────────
33
34/// Aborts a spawned task when dropped - including when dropped mid-flight as
35/// part of an outer future's cancellation (e.g. `JoinHandle::abort()` on the
36/// task that owns this guard), not just on normal scope exit.
37struct 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
52/// Every API route with its production handlers - the single route table,
53/// shared by [`execute_with_shutdown`] and the tests. A hand-copied test
54/// router drifted seven routes behind production, which meant a route could
55/// be added, typo'd, and never exercised. Admin routes, the auth middleware,
56/// CORS, and `with_state` are layered on by the caller.
57fn api_router() -> Router<AppState> {
58    Router::new()
59        // Blueprints
60        .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        // Agents
75        .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        // Messages
94        .route("/api/agents/{id}/message", post(interactions::send_message))
95        // Interactions
96        .route(
97            "/api/agents/{id}/interaction",
98            get(interactions::get_interaction).post(interactions::submit_interaction),
99        )
100        // MCP servers - read-only surface. The mutating half is mounted by
101        // `execute_with_shutdown`, behind `--allow-admin`.
102        .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        // Config
107        .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        // WebSocket
111        .route("/ws", get(websocket::ws_global))
112        .route("/ws/agents/{id}", get(websocket::ws_agent))
113}
114
115/// Core of [`execute`], with an optional shutdown signal so tests can stop
116/// the server gracefully and cover the `Ok(())` return path.
117///
118/// Takes `shutdown` as a boxed trait object (`Pin<Box<dyn Future<...>>>`)
119/// rather than `impl Future<...>` so every caller - production's
120/// `std::future::pending()` and tests' various `async move { ... }` blocks
121/// awaiting a `oneshot::Receiver` - shares exactly ONE monomorphization of
122/// this (large, multi-branch) function instead of one per concrete future
123/// type. Confirmed via HTML/JSON segment inspection that every source
124/// position has a covered instantiation (this is the same trait-object-erasure
125/// technique used for `io::Write` in `leviath-package`'s `bundler.rs`).
126///
127/// `ready`, if given, is sent the real bound `SocketAddr` right after
128/// `TcpListener::bind` succeeds (before serving starts). Production passes
129/// `None`; tests pass `Some(tx)` with `args.port = 0` so the OS picks a free
130/// port and the test learns which one was actually bound directly - no
131/// probe-bind-drop-rebind dance, which is a genuine TOCTOU race (confirmed
132/// to reproduce on real CI: another process/test could grab the just-freed
133/// port before this function's own bind runs), not just a test-only
134/// convenience.
135async 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    // Resolve the API token before binding - refuse to start unauthenticated.
142    let auth_token = std::sync::Arc::new(auth::resolve_token(args.token.as_deref())?);
143    // The API can spawn tool-executing agents; loudly warn if bound off-host.
144    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    // Read before `cfg` moves into the shared state below.
154    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    // Background world-event consumer: subscribes to the daemon's pushed
174    // `WorldEvent` stream and forwards each event to WebSocket subscribers.
175    // Held behind an abort-on-drop guard so the task is torn down whenever this
176    // function returns *or* is cancelled - e.g. when a test aborts the outer
177    // `execute()`/`execute_with_shutdown()` task. Without this, aborting only
178    // the outer task left the inner `event_loop` (an unconditional
179    // subscribe-and-reconnect loop) running detached until the whole runtime
180    // was torn down.
181    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    // No `--cors` at all: no CORS layer. Programmatic clients are not subject to
188    // CORS, so the previous `*` default bought them nothing while telling every
189    // browser that any page may talk to this server.
190    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                // `Access-Control-Allow-Headers: *` does NOT cover
197                // `Authorization` per the Fetch spec, so a browser sending the
198                // required bearer token would be blocked. List the headers the
199                // API actually needs explicitly.
200                .allow_headers([
201                    axum::http::header::AUTHORIZATION,
202                    axum::http::header::CONTENT_TYPE,
203                ]),
204        ),
205        Some(origin) => {
206            // An unparseable value must not fall back to `*` - that silently
207            // turns a typo into "allow everything", the opposite of what was
208            // asked for. Refuse to start instead.
209            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                    // `Access-Control-Allow-Headers: *` does NOT cover
217                    // `Authorization` per the Fetch spec, so a browser sending the
218                    // required bearer token would be blocked. List the headers the
219                    // API actually needs explicitly.
220                    .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    // The MCP administration endpoints are remote code execution by
231    // construction: `add_server` writes a `command` and `args` into
232    // `~/.leviath/config.toml`, and Leviath then spawns exactly that - for this
233    // run and every future one. The rest of the API can only run agents the user
234    // already installed. Not mounted unless the operator asked for them, so an
235    // unmounted route 404s rather than relying on a check inside the handler
236    // that someone could later route around.
237    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            // Config-write persists provider secrets to disk, so it is gated the
242            // same way as MCP admin: unmounted (404) unless --allow-admin.
243            .route("/api/config", put(config::put_config)),
244        false => app,
245    };
246
247    let app = app
248        // Require a valid token on every route; CORS stays outermost so browser
249        // preflight (OPTIONS) is answered before the auth check.
250        .layer(axum::middleware::from_fn_with_state(
251            auth_token,
252            auth::require_auth,
253        ))
254        .with_state(state);
255    // Applied by branching on the router rather than layering an `Option`:
256    // `Option<CorsLayer>` is not a `Layer`, and a permissive-but-unused layer
257    // would be exactly the default this change removes.
258    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        // A test-only observer failing to receive (e.g. it already gave up
270        // after a timeout) shouldn't stop the server from starting for real.
271        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    // axum::serve with graceful shutdown always returns Ok(()) - discard the
277    // infallible Result so LLVM-cov does not instrument an unreachable Err branch.
278    let _ = axum::serve(listener, app)
279        .with_graceful_shutdown(shutdown)
280        .await;
281
282    Ok(())
283}
284
285// ─── Tests ──────────────────────────────────────────────────────────────────
286
287#[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    /// Extracted so the `assert!` failure-message region (only executed
298    /// when the assertion fails) is covered by this function's own
299    /// `#[should_panic]` test below, rather than showing as a
300    /// permanently-uncovered region at every real call site.
301    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    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
315    /// for the bad-API-key startup failure-message region.
316    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    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
327    /// for the graceful-shutdown return-value failure-message region.
328    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    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
342    /// for the port-in-use failure-message region.
343    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    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
357    /// for `execute_with_shutdown`'s graceful-shutdown return-value
358    /// failure-message region.
359    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    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
373    /// for the HTTP response status-line failure-message region.
374    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    /// A control client pointing at an address with no daemon: agent-action
385    /// endpoints report "not reachable", and read/bootstrap paths don't touch it.
386    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    /// The production route table over a test state - auth, CORS, and the
404    /// admin routes are absent, exactly as `api_router` leaves them.
405    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        // /api/mcp/servers was one of the seven routes present in production
423        // but absent from the hand-copied test router; with the shared table
424        // it must be reachable here too.
425        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        // children returns 200 with empty array even if parent doesn't exist
540        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        // The route is wired to the handler, which (with no daemon in this test)
590        // reports the daemon unreachable - proving the request reached it.
591        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        // Default config has no keys
616        assert!(!val.has_anthropic_key);
617        assert!(!val.has_openai_key);
618    }
619
620    #[tokio::test]
621    async fn test_tree_building() {
622        // Unit test for the tree builder
623        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); // parent (0) + child (100)
655        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        // Both should work (no panic)
814        let _ = cloned.config.default_provider.clone();
815    }
816
817    #[test]
818    fn test_cors_wildcard_vs_specific() {
819        // Test the CORS logic paths used in execute()
820        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        // Test that specific CORS origin parses correctly
830        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        // Invalid header values fail to parse; the code falls back to "*"
839        assert!(result.is_err());
840    }
841
842    #[tokio::test]
843    async fn test_submit_interaction_full_router_reaches_daemon() {
844        // The POST-interaction route is wired to the handler, which reaches the
845        // (absent-in-test) daemon. The ACCEPTED path is covered by the
846        // interactions handler's own tests against a fake daemon.
847        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    // ─── execute() - real server bootstrap ─────────────────────────────────
860    //
861    // These drive the actual `execute()` entrypoint (config load, CORS setup,
862    // full router construction, real TCP bind, background polling spawn) end
863    // to end using port 0 (OS-assigned ephemeral port) so no fixed port is
864    // required. Since `axum::serve(...).await` never returns on success, the
865    // task is aborted once we've proven the server is up and responding.
866    //
867    // Each holds `isolate_config_path_for_test` even though none of them
868    // care about specific config *content* - their own `Config::load()`
869    // call needs protecting from a DIFFERENT concurrently-running test that
870    // does mutate `LEVIATH_CONFIG_PATH` (e.g. `execute_with_malformed_config_
871    // returns_err`, which points it at a file containing invalid TOML for
872    // the duration of its own guard). `std::env::set_var` is process-global,
873    // not thread-local, so without holding the same lock here, this test's
874    // `Config::load()` could transiently observe that other test's malformed
875    // path and fail with a real (if confusing) parse error - confirmed to
876    // reproduce locally at default test-thread concurrency, not a hypothetical.
877
878    #[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                // port: 0 lets the OS assign a genuinely free ephemeral port at bind
885                // time; execute_with_shutdown reports the real bound SocketAddr back
886                // via `ready` the instant it's bound, so there's no
887                // probe-bind-drop-rebind gap for another process/test to race into
888                // (that gap is a real, CI-reproducing TOCTOU - see
889                // execute_with_shutdown's doc comment). Exercises the exact same
890                // production code path execute() does (its own body is just this
891                // call with `ready: None`), so this remains a real end-to-end test
892                // of execute()'s bootstrap logic.
893                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                // Sanity-check a real request round trip through the full app.
914                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                // Without the token the same request is rejected.
929                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    /// A browser preflight for a request carrying `Authorization` must be
950    /// allowed. `Access-Control-Allow-Headers: *` does NOT cover `Authorization`
951    /// per the Fetch spec, so the header has to be listed explicitly — without
952    /// it the console's authenticated requests are blocked by the browser. Also
953    /// covers the `Some("*")` CORS arm.
954    #[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        // Isolated: this reaches `Config::load()`, which reads process-wide
1042        // environment. Unisolated it races every `temp_env` test in the binary.
1043        crate::config::with_isolated_config_path_async("serve-badaddr", |_fake_dir| async move {
1044            // An invalid host string makes `format!("{host}:{port}").parse()`
1045            // fail, exercising execute()'s `?` on the SocketAddr parse.
1046            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    /// Covers `Config::load()?` error path (line 31) by pointing
1073    /// `LEVIATH_CONFIG_PATH` at a file containing invalid TOML.
1074    #[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                // After isolate_config_path_for_test, Config::config_path() returns the temp path.
1080                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    /// Covers the `for warning in cfg.validate_keys()` loop body (lines 32-33)
1099    /// by writing a config with a bad anthropic key, then running the server
1100    /// with a graceful-shutdown signal so the loop executes before bind.
1101    #[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        // Write a config with an anthropic key that fails validate_keys().
1106        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        // Trigger graceful shutdown so execute_with_shutdown returns Ok(()).
1141        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    /// Covers the `TcpListener::bind(addr).await?` error path deterministically
1151    /// by binding to a reserved TEST-NET-1 address (RFC 5737, `192.0.2.0/24`)
1152    /// that is never assigned to a local interface, so the bind always fails
1153    /// with `EADDRNOTAVAIL`. (A prior version reused an already-bound ephemeral
1154    /// port, which occasionally let the second bind succeed under parallel-test
1155    /// load and left this region uncovered - a genuine flake.)
1156    #[tokio::test]
1157    async fn execute_with_unbindable_address_returns_bind_error() {
1158        // Isolated: this reaches `Config::load()`, which reads process-wide
1159        // environment. Unisolated it races every `temp_env` test in the binary.
1160        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        // No --token and no LEVIATH_API_TOKEN ⇒ the server won't start.
1182        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    /// Covers `axum::serve(...).await?` Ok path (lines 117, 119) by running
1199    /// `execute_with_shutdown` and sending a graceful-shutdown signal.
1200    #[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                // Send shutdown signal and wait for execute to return Ok.
1232                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    /// Covers the `ready: None` fall-through of the `if let Some(ready)` block
1244    /// (line 190): a successful bind with no ready-observer, shut down
1245    /// gracefully. Every other binding test passes `Some(ready)`, and every
1246    /// `None` caller (`execute()`) in other tests fails before binding, so this
1247    /// is the only path that reaches the block's None continuation.
1248    #[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                // Give the server a moment to bind before shutting down.
1275                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    /// The three CORS shapes. Default is *no layer*: the API's clients are
1287    /// programmatic and not subject to CORS, so a browser-facing `*` default
1288    /// gave them nothing and widened the surface for everyone else.
1289    #[tokio::test]
1290    async fn cors_is_off_by_default_explicit_when_asked_and_fatal_when_malformed() {
1291        // Isolated because `execute_with_shutdown` calls `Config::load()`, which
1292        // reads process-wide environment. Without this the test raced every
1293        // `temp_env` test in the binary - `temp_env` serializes against its own
1294        // calls, not against a test that reads the environment directly - and
1295        // failed on CI in two different places depending on when it lost.
1296        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            /// Start, wait until bound, then shut down. Only reached for values that
1310            /// are accepted - a rejected one never binds.
1311            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                // `RecvError` here means the sender was dropped, which any early
1323                // return from `execute_with_shutdown` does - so this reads as "the
1324                // server failed to start" without saying why. Left as-is rather
1325                // than adding a reporting branch that only a failing run executes,
1326                // which the coverage gate would (correctly) flag as dead.
1327                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            // A malformed origin fails before binding, so this can be awaited
1337            // directly rather than raced against a `ready` signal.
1338            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            // Printed on failure: startup can fail earlier than the CORS check (the
1347            // config load, for one), and "assertion failed" alone does not say so.
1348            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    /// The MCP admin endpoints are mounted only with `--allow-admin`: adding an
1357    /// MCP server writes a spawn command into config, which Leviath then runs.
1358    #[tokio::test]
1359    async fn the_mcp_admin_routes_are_mounted_only_with_allow_admin() {
1360        // Same isolation, same reason: this one also reaches `Config::load()`.
1361        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                // 405 (Method Not Allowed) is the signature of "this path exists
1394                // for GET but POST is not mounted". Asserted as a presence check
1395                // rather than an exact code for the mounted case, whose status
1396                // depends on body validation rather than on routing.
1397                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}