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        .route("/api/agents/{id}/pause", post(agents::pause_agent))
94        .route("/api/agents/{id}/resume", post(agents::resume_agent))
95        // Messages
96        .route("/api/agents/{id}/message", post(interactions::send_message))
97        // Interactions
98        .route(
99            "/api/agents/{id}/interaction",
100            get(interactions::get_interaction).post(interactions::submit_interaction),
101        )
102        // MCP servers - read-only surface. The mutating half is mounted by
103        // `execute_with_shutdown`, behind `--allow-admin`.
104        .route("/api/mcp/servers", get(mcp::list_servers))
105        .route("/api/mcp/servers/{name}/status", get(mcp::status))
106        .route("/api/mcp/servers/{name}/login", post(mcp::login))
107        .route("/api/mcp/servers/{name}/test", post(mcp::test_server))
108        // Config
109        .route("/api/config", get(config::get_config))
110        .route("/api/config/validate", post(config::validate_config_key))
111        .route("/api/models", get(config::get_models))
112        // WebSocket
113        .route("/ws", get(websocket::ws_global))
114        .route("/ws/agents/{id}", get(websocket::ws_agent))
115}
116
117/// Core of [`execute`], with an optional shutdown signal so tests can stop
118/// the server gracefully and cover the `Ok(())` return path.
119///
120/// Takes `shutdown` as a boxed trait object (`Pin<Box<dyn Future<...>>>`)
121/// rather than `impl Future<...>` so every caller - production's
122/// `std::future::pending()` and tests' various `async move { ... }` blocks
123/// awaiting a `oneshot::Receiver` - shares exactly ONE monomorphization of
124/// this (large, multi-branch) function instead of one per concrete future
125/// type. Confirmed via HTML/JSON segment inspection that every source
126/// position has a covered instantiation (this is the same trait-object-erasure
127/// technique used for `io::Write` in `leviath-package`'s `bundler.rs`).
128///
129/// `ready`, if given, is sent the real bound `SocketAddr` right after
130/// `TcpListener::bind` succeeds (before serving starts). Production passes
131/// `None`; tests pass `Some(tx)` with `args.port = 0` so the OS picks a free
132/// port and the test learns which one was actually bound directly - no
133/// probe-bind-drop-rebind dance, which is a genuine TOCTOU race (confirmed
134/// to reproduce on real CI: another process/test could grab the just-freed
135/// port before this function's own bind runs), not just a test-only
136/// convenience.
137async fn execute_with_shutdown(
138    args: ServeArgs,
139    control: leviath_runtime::control_socket::ControlClient,
140    shutdown: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
141    ready: Option<tokio::sync::oneshot::Sender<SocketAddr>>,
142) -> anyhow::Result<()> {
143    // Resolve the API token before binding - refuse to start unauthenticated.
144    let auth_token = std::sync::Arc::new(auth::resolve_token(args.token.as_deref())?);
145    // The API can spawn tool-executing agents; loudly warn if bound off-host.
146    if args.host != "127.0.0.1" && args.host != "localhost" && args.host != "::1" {
147        tracing::warn!(
148            host = %args.host,
149            "serving the agent API on a non-local address - anyone who can reach \
150             this host and holds the token can spawn agents"
151        );
152    }
153
154    let cfg = Config::load()?;
155    // Read before `cfg` moves into the shared state below.
156    let allow_local_network = cfg.security.allow_local_network;
157    for warning in cfg.validate_keys() {
158        tracing::warn!("{}", warning);
159    }
160
161    let (event_tx, _) = broadcast::channel::<ServerEvent>(1024);
162
163    let state = AppState {
164        config: Arc::new(cfg),
165        event_tx: event_tx.clone(),
166        control,
167        mcp: mcp::McpAdmin::default(),
168        limits: Arc::new(ServeLimits {
169            workdir_root: args.workdir_root.clone(),
170            no_remote_yolo: args.no_remote_yolo,
171            allow_local_network,
172        }),
173    };
174
175    // Background world-event consumer: subscribes to the daemon's pushed
176    // `WorldEvent` stream and forwards each event to WebSocket subscribers.
177    // Held behind an abort-on-drop guard so the task is torn down whenever this
178    // function returns *or* is cancelled - e.g. when a test aborts the outer
179    // `execute()`/`execute_with_shutdown()` task. Without this, aborting only
180    // the outer task left the inner `event_loop` (an unconditional
181    // subscribe-and-reconnect loop) running detached until the whole runtime
182    // was torn down.
183    let event_state = state.clone();
184    let _event_guard = AbortOnDrop(tokio::spawn(polling::event_loop(
185        event_state,
186        polling::RECONNECT_BACKOFF,
187    )));
188
189    // No `--cors` at all: no CORS layer. Programmatic clients are not subject to
190    // CORS, so the previous `*` default bought them nothing while telling every
191    // browser that any page may talk to this server.
192    let cors = match args.cors.as_deref() {
193        None => None,
194        Some("*") => Some(
195            CorsLayer::new()
196                .allow_origin(Any)
197                .allow_methods(Any)
198                // `Access-Control-Allow-Headers: *` does NOT cover
199                // `Authorization` per the Fetch spec, so a browser sending the
200                // required bearer token would be blocked. List the headers the
201                // API actually needs explicitly.
202                .allow_headers([
203                    axum::http::header::AUTHORIZATION,
204                    axum::http::header::CONTENT_TYPE,
205                ]),
206        ),
207        Some(origin) => {
208            // An unparseable value must not fall back to `*` - that silently
209            // turns a typo into "allow everything", the opposite of what was
210            // asked for. Refuse to start instead.
211            let value = origin.parse::<axum::http::HeaderValue>().map_err(|_| {
212                anyhow::anyhow!("--cors value '{origin}' is not a valid origin header")
213            })?;
214            Some(
215                CorsLayer::new()
216                    .allow_origin(value)
217                    .allow_methods(Any)
218                    // `Access-Control-Allow-Headers: *` does NOT cover
219                    // `Authorization` per the Fetch spec, so a browser sending the
220                    // required bearer token would be blocked. List the headers the
221                    // API actually needs explicitly.
222                    .allow_headers([
223                        axum::http::header::AUTHORIZATION,
224                        axum::http::header::CONTENT_TYPE,
225                    ]),
226            )
227        }
228    };
229
230    let app = api_router();
231
232    // The MCP administration endpoints are remote code execution by
233    // construction: `add_server` writes a `command` and `args` into
234    // `~/.leviath/config.toml`, and Leviath then spawns exactly that - for this
235    // run and every future one. The rest of the API can only run agents the user
236    // already installed. Not mounted unless the operator asked for them, so an
237    // unmounted route 404s rather than relying on a check inside the handler
238    // that someone could later route around.
239    let app = match args.allow_admin {
240        true => app
241            .route("/api/mcp/servers", post(mcp::add_server))
242            .route("/api/mcp/servers/{name}", delete(mcp::remove_server))
243            // Config-write persists provider secrets to disk, so it is gated the
244            // same way as MCP admin: unmounted (404) unless --allow-admin.
245            .route("/api/config", put(config::put_config)),
246        false => app,
247    };
248
249    let app = app
250        // Require a valid token on every route; CORS stays outermost so browser
251        // preflight (OPTIONS) is answered before the auth check.
252        .layer(axum::middleware::from_fn_with_state(
253            auth_token,
254            auth::require_auth,
255        ))
256        .with_state(state);
257    // Applied by branching on the router rather than layering an `Option`:
258    // `Option<CorsLayer>` is not a `Layer`, and a permissive-but-unused layer
259    // would be exactly the default this change removes.
260    let app = match cors {
261        Some(layer) => app.layer(layer),
262        None => app,
263    };
264
265    let addr: SocketAddr = format!("{}:{}", args.host, args.port).parse()?;
266    tracing::info!("Listening on http://{}", addr);
267    println!("Leviath API server listening on http://{}", addr);
268
269    let listener = tokio::net::TcpListener::bind(addr).await?;
270    if let Some(ready) = ready {
271        // A test-only observer failing to receive (e.g. it already gave up
272        // after a timeout) shouldn't stop the server from starting for real.
273        let local_addr = listener
274            .local_addr()
275            .expect("infallible: a freshly bound TcpListener always has a local address");
276        let _ = ready.send(local_addr);
277    }
278    // axum::serve with graceful shutdown always returns Ok(()) - discard the
279    // infallible Result so LLVM-cov does not instrument an unreachable Err branch.
280    let _ = axum::serve(listener, app)
281        .with_graceful_shutdown(shutdown)
282        .await;
283
284    Ok(())
285}
286
287// ─── Tests ──────────────────────────────────────────────────────────────────
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use axum::body::Body;
293    use axum::http::{Request, StatusCode};
294    use tower::ServiceExt;
295
296    use crate::runstate::RunMeta;
297    use crate::test_support::with_tracing;
298
299    /// Extracted so the `assert!` failure-message region (only executed
300    /// when the assertion fails) is covered by this function's own
301    /// `#[should_panic]` test below, rather than showing as a
302    /// permanently-uncovered region at every real call site.
303    fn assert_execute_failed_on_malformed_config(result: &anyhow::Result<()>) {
304        assert!(
305            result.is_err(),
306            "execute should fail when config is malformed"
307        );
308    }
309
310    #[test]
311    #[should_panic(expected = "execute should fail when config is malformed")]
312    fn assert_execute_failed_on_malformed_config_panics_when_ok() {
313        assert_execute_failed_on_malformed_config(&Ok(()));
314    }
315
316    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
317    /// for the bad-API-key startup failure-message region.
318    fn assert_connected_with_bad_api_key(connected: bool) {
319        assert!(connected, "server should start even with a bad API key");
320    }
321
322    #[test]
323    #[should_panic(expected = "server should start even with a bad API key")]
324    fn assert_connected_with_bad_api_key_panics_when_not_connected() {
325        assert_connected_with_bad_api_key(false);
326    }
327
328    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
329    /// for the graceful-shutdown return-value failure-message region.
330    fn assert_execute_returned_ok_after_shutdown(result: &Result<(), anyhow::Error>) {
331        assert!(
332            result.is_ok(),
333            "execute should return Ok after graceful shutdown"
334        );
335    }
336
337    #[test]
338    #[should_panic(expected = "execute should return Ok after graceful shutdown")]
339    fn assert_execute_returned_ok_after_shutdown_panics_when_err() {
340        assert_execute_returned_ok_after_shutdown(&Err(anyhow::anyhow!("boom")));
341    }
342
343    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
344    /// for the port-in-use failure-message region.
345    fn assert_execute_failed_on_port_in_use(result: &anyhow::Result<()>) {
346        assert!(
347            result.is_err(),
348            "execute should fail when port is already in use"
349        );
350    }
351
352    #[test]
353    #[should_panic(expected = "execute should fail when port is already in use")]
354    fn assert_execute_failed_on_port_in_use_panics_when_ok() {
355        assert_execute_failed_on_port_in_use(&Ok(()));
356    }
357
358    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
359    /// for `execute_with_shutdown`'s graceful-shutdown return-value
360    /// failure-message region.
361    fn assert_execute_with_shutdown_returned_ok(result: &Result<(), anyhow::Error>) {
362        assert!(
363            result.is_ok(),
364            "execute_with_shutdown should return Ok(()) after graceful shutdown"
365        );
366    }
367
368    #[test]
369    #[should_panic(expected = "execute_with_shutdown should return Ok(()) after graceful shutdown")]
370    fn assert_execute_with_shutdown_returned_ok_panics_when_err() {
371        assert_execute_with_shutdown_returned_ok(&Err(anyhow::anyhow!("boom")));
372    }
373
374    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
375    /// for the HTTP response status-line failure-message region.
376    fn assert_response_ok(resp_str: &str) {
377        assert!(resp_str.starts_with("HTTP/1.1 200"), "got: {resp_str}");
378    }
379
380    #[test]
381    #[should_panic(expected = "got: HTTP/1.1 404 Not Found")]
382    fn assert_response_ok_panics_when_not_200() {
383        assert_response_ok("HTTP/1.1 404 Not Found\r\n\r\n");
384    }
385
386    /// A control client pointing at an address with no daemon: agent-action
387    /// endpoints report "not reachable", and read/bootstrap paths don't touch it.
388    fn no_daemon_control() -> leviath_runtime::control_socket::ControlClient {
389        leviath_runtime::control_socket::ControlClient::new(
390            leviath_runtime::control_socket::control_id(std::path::Path::new("/no/such/leviath")),
391        )
392    }
393
394    fn test_state() -> AppState {
395        let (tx, _) = broadcast::channel(64);
396        AppState {
397            config: Arc::new(Config::default()),
398            event_tx: tx,
399            control: no_daemon_control(),
400            mcp: crate::commands::serve::mcp::McpAdmin::default(),
401            limits: Default::default(),
402        }
403    }
404
405    /// The production route table over a test state - auth, CORS, and the
406    /// admin routes are absent, exactly as `api_router` leaves them.
407    fn test_app() -> Router {
408        api_router().with_state(test_state())
409    }
410
411    #[tokio::test]
412    async fn test_list_blueprints() {
413        let app = test_app();
414        let req = Request::builder()
415            .uri("/api/blueprints")
416            .body(Body::empty())
417            .unwrap();
418        let resp = app.oneshot(req).await.unwrap();
419        assert_eq!(resp.status(), StatusCode::OK);
420    }
421
422    #[tokio::test]
423    async fn test_router_serves_routes_the_old_hand_copy_missed() {
424        // /api/mcp/servers was one of the seven routes present in production
425        // but absent from the hand-copied test router; with the shared table
426        // it must be reachable here too.
427        let app = test_app();
428        let req = Request::builder()
429            .uri("/api/mcp/servers")
430            .body(Body::empty())
431            .unwrap();
432        let resp = app.oneshot(req).await.unwrap();
433        assert_eq!(resp.status(), StatusCode::OK);
434    }
435
436    #[tokio::test]
437    async fn test_pause_and_resume_routes_are_mounted() {
438        // With no daemon behind the test state the handlers answer 503 - the
439        // point here is only that the routes exist in the shared table (an
440        // unmounted route would 404 at the router).
441        for action in ["pause", "resume"] {
442            let app = test_app();
443            let req = Request::builder()
444                .method("POST")
445                .uri(format!("/api/agents/some-run/{action}"))
446                .body(Body::empty())
447                .unwrap();
448            let resp = app.oneshot(req).await.unwrap();
449            assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
450        }
451    }
452
453    #[tokio::test]
454    async fn test_get_blueprint_not_found() {
455        let app = test_app();
456        let req = Request::builder()
457            .uri("/api/blueprints/nonexistent-agent-xyz")
458            .body(Body::empty())
459            .unwrap();
460        let resp = app.oneshot(req).await.unwrap();
461        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
462    }
463
464    #[tokio::test]
465    async fn test_validate_blueprint_valid() {
466        let app = test_app();
467        let manifest = r#"
468[agent]
469name = "test-agent"
470version = "0.1.0"
471description = "A test"
472
473[stages.main]
474mode = "autonomous"
475[stages.main.model]
476provider = "anthropic"
477model = "claude-sonnet-4-6"
478"#;
479        let body = serde_json::json!({ "manifest": manifest });
480        let req = Request::builder()
481            .method("POST")
482            .uri("/api/blueprints/validate")
483            .header("content-type", "application/json")
484            .body(Body::from(serde_json::to_string(&body).unwrap()))
485            .unwrap();
486        let resp = app.oneshot(req).await.unwrap();
487        assert_eq!(resp.status(), StatusCode::OK);
488
489        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
490            .await
491            .unwrap();
492        let val: types::ValidateResponse = serde_json::from_slice(&body).unwrap();
493        assert!(val.valid);
494    }
495
496    #[tokio::test]
497    async fn test_validate_blueprint_invalid() {
498        let app = test_app();
499        let body = serde_json::json!({ "manifest": "not valid toml {{{{" });
500        let req = Request::builder()
501            .method("POST")
502            .uri("/api/blueprints/validate")
503            .header("content-type", "application/json")
504            .body(Body::from(serde_json::to_string(&body).unwrap()))
505            .unwrap();
506        let resp = app.oneshot(req).await.unwrap();
507        assert_eq!(resp.status(), StatusCode::OK);
508
509        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
510            .await
511            .unwrap();
512        let val: types::ValidateResponse = serde_json::from_slice(&body).unwrap();
513        assert!(!val.valid);
514        assert!(val.errors.is_some());
515    }
516
517    #[tokio::test]
518    async fn test_list_agents() {
519        let app = test_app();
520        let req = Request::builder()
521            .uri("/api/agents")
522            .body(Body::empty())
523            .unwrap();
524        let resp = app.oneshot(req).await.unwrap();
525        assert_eq!(resp.status(), StatusCode::OK);
526    }
527
528    #[tokio::test]
529    async fn test_agents_tree() {
530        let app = test_app();
531        let req = Request::builder()
532            .uri("/api/agents/tree")
533            .body(Body::empty())
534            .unwrap();
535        let resp = app.oneshot(req).await.unwrap();
536        assert_eq!(resp.status(), StatusCode::OK);
537    }
538
539    #[tokio::test]
540    async fn test_get_agent_not_found() {
541        let app = test_app();
542        let req = Request::builder()
543            .uri("/api/agents/nonexistent-run-id-xyz")
544            .body(Body::empty())
545            .unwrap();
546        let resp = app.oneshot(req).await.unwrap();
547        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
548    }
549
550    #[tokio::test]
551    async fn test_agent_children_empty() {
552        let app = test_app();
553        let req = Request::builder()
554            .uri("/api/agents/nonexistent/children")
555            .body(Body::empty())
556            .unwrap();
557        let resp = app.oneshot(req).await.unwrap();
558        // children returns 200 with empty array even if parent doesn't exist
559        assert_eq!(resp.status(), StatusCode::OK);
560    }
561
562    #[tokio::test]
563    async fn test_agent_context_not_found() {
564        let app = test_app();
565        let req = Request::builder()
566            .uri("/api/agents/nonexistent/context")
567            .body(Body::empty())
568            .unwrap();
569        let resp = app.oneshot(req).await.unwrap();
570        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
571    }
572
573    #[tokio::test]
574    async fn test_agent_logs_not_found() {
575        let app = test_app();
576        let req = Request::builder()
577            .uri("/api/agents/nonexistent/logs")
578            .body(Body::empty())
579            .unwrap();
580        let resp = app.oneshot(req).await.unwrap();
581        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
582    }
583
584    #[tokio::test]
585    async fn test_agent_result_not_found() {
586        let app = test_app();
587        let req = Request::builder()
588            .uri("/api/agents/nonexistent/result")
589            .body(Body::empty())
590            .unwrap();
591        let resp = app.oneshot(req).await.unwrap();
592        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
593    }
594
595    #[tokio::test]
596    async fn test_agent_tree_status_not_found() {
597        let app = test_app();
598        let req = Request::builder()
599            .uri("/api/agents/nonexistent/tree-status")
600            .body(Body::empty())
601            .unwrap();
602        let resp = app.oneshot(req).await.unwrap();
603        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
604    }
605
606    #[tokio::test]
607    async fn test_interaction_route_reaches_daemon() {
608        // The route is wired to the handler, which (with no daemon in this test)
609        // reports the daemon unreachable - proving the request reached it.
610        let app = test_app();
611        let req = Request::builder()
612            .uri("/api/agents/nonexistent/interaction")
613            .body(Body::empty())
614            .unwrap();
615        let resp = app.oneshot(req).await.unwrap();
616        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
617    }
618
619    #[tokio::test]
620    async fn test_get_config() {
621        let app = test_app();
622        let req = Request::builder()
623            .uri("/api/config")
624            .body(Body::empty())
625            .unwrap();
626        let resp = app.oneshot(req).await.unwrap();
627        assert_eq!(resp.status(), StatusCode::OK);
628
629        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
630            .await
631            .unwrap();
632        let val: types::RedactedConfig = serde_json::from_slice(&body).unwrap();
633        assert_eq!(val.default_provider, "anthropic");
634        // Default config has no keys
635        assert!(!val.has_anthropic_key);
636        assert!(!val.has_openai_key);
637    }
638
639    #[tokio::test]
640    async fn test_tree_building() {
641        // Unit test for the tree builder
642        let runs = vec![
643            RunMeta::new(
644                "parent-1".to_string(),
645                "agent-a".to_string(),
646                "/path".to_string(),
647                "task".to_string(),
648                None,
649                "/work".to_string(),
650                1,
651            ),
652            {
653                let mut child = RunMeta::new(
654                    "child-1".to_string(),
655                    "agent-b".to_string(),
656                    "/path".to_string(),
657                    "sub-task".to_string(),
658                    None,
659                    "/work".to_string(),
660                    1,
661                );
662                child.parent_run_id = Some("parent-1".to_string());
663                child.prompt_tokens = 100;
664                child.completion_tokens = 50;
665                child
666            },
667        ];
668
669        let tree = tree::build_tree_status(&runs, None);
670        assert_eq!(tree.len(), 1);
671        assert_eq!(tree[0].run_id, "parent-1");
672        assert_eq!(tree[0].children.len(), 1);
673        assert_eq!(tree[0].subtree_prompt_tokens, 100); // parent (0) + child (100)
674        assert_eq!(tree[0].subtree_completion_tokens, 50);
675    }
676
677    #[tokio::test]
678    async fn test_delete_blueprint_not_found() {
679        let app = test_app();
680        let req = Request::builder()
681            .method("DELETE")
682            .uri("/api/blueprints/nonexistent-agent-xyz")
683            .body(Body::empty())
684            .unwrap();
685        let resp = app.oneshot(req).await.unwrap();
686        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
687    }
688
689    #[tokio::test]
690    async fn test_server_event_serialization() {
691        let event = ServerEvent::AgentStatus {
692            agent_id: "coder".to_string(),
693            run_id: "run-123".to_string(),
694            status: "running".to_string(),
695            stage: "implement".to_string(),
696            iteration: 5,
697            tool_calls: 0,
698            accepts_messages: true,
699        };
700        let json = serde_json::to_string(&event).unwrap();
701        assert!(json.contains("\"type\":\"agent_status\""));
702        assert!(json.contains("\"agent_id\":\"coder\""));
703
704        let event2 = ServerEvent::Tokens {
705            agent_id: "coder".to_string(),
706            run_id: "run-123".to_string(),
707            prompt_tokens: 5000,
708            completion_tokens: 1200,
709            cached_tokens: 0,
710            cache_write_tokens: 0,
711        };
712        let json2 = serde_json::to_string(&event2).unwrap();
713        assert!(json2.contains("\"type\":\"tokens\""));
714        assert!(json2.contains("\"prompt_tokens\":5000"));
715    }
716
717    #[tokio::test]
718    async fn test_full_router_create_blueprint_invalid() {
719        let app = test_app();
720        let body = serde_json::json!({
721            "name": "bad-agent",
722            "manifest": "not valid toml {{{"
723        });
724        let req = Request::builder()
725            .method("POST")
726            .uri("/api/blueprints")
727            .header("content-type", "application/json")
728            .body(Body::from(serde_json::to_string(&body).unwrap()))
729            .unwrap();
730        let resp = app.oneshot(req).await.unwrap();
731        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
732    }
733
734    #[tokio::test]
735    async fn test_full_router_update_blueprint_not_found() {
736        let app = test_app();
737        let body = serde_json::json!({
738            "manifest": r#"
739[agent]
740name = "no-such-agent"
741version = "1.0.0"
742description = "Missing"
743
744[stages.run]
745prompt = "Run"
746"#
747        });
748        let req = Request::builder()
749            .method("PUT")
750            .uri("/api/blueprints/no-such-agent-xyz-99999")
751            .header("content-type", "application/json")
752            .body(Body::from(serde_json::to_string(&body).unwrap()))
753            .unwrap();
754        let resp = app.oneshot(req).await.unwrap();
755        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
756    }
757
758    #[tokio::test]
759    async fn test_full_router_kill_agent_reaches_daemon() {
760        let app = test_app();
761        let req = Request::builder()
762            .method("DELETE")
763            .uri("/api/agents/nonexistent-kill-id-xyz")
764            .body(Body::empty())
765            .unwrap();
766        let resp = app.oneshot(req).await.unwrap();
767        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
768    }
769
770    #[tokio::test]
771    async fn test_full_router_send_message_reaches_daemon() {
772        let app = test_app();
773        let body = serde_json::json!({"message": "hello"});
774        let req = Request::builder()
775            .method("POST")
776            .uri("/api/agents/nonexistent-msg-id-xyz/message")
777            .header("content-type", "application/json")
778            .body(Body::from(serde_json::to_string(&body).unwrap()))
779            .unwrap();
780        let resp = app.oneshot(req).await.unwrap();
781        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
782    }
783
784    #[tokio::test]
785    async fn test_full_router_get_models() {
786        let app = test_app();
787        let req = Request::builder()
788            .uri("/api/models")
789            .body(Body::empty())
790            .unwrap();
791        let resp = app.oneshot(req).await.unwrap();
792        assert_eq!(resp.status(), StatusCode::OK);
793    }
794
795    #[tokio::test]
796    async fn test_full_router_spawn_agent_blueprint_not_found() {
797        let app = test_app();
798        let body = serde_json::json!({
799            "blueprint": "nonexistent-blueprint-xyz",
800            "task": "do something"
801        });
802        let req = Request::builder()
803            .method("POST")
804            .uri("/api/agents")
805            .header("content-type", "application/json")
806            .body(Body::from(serde_json::to_string(&body).unwrap()))
807            .unwrap();
808        let resp = app.oneshot(req).await.unwrap();
809        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
810    }
811
812    #[test]
813    fn test_serve_args_defaults() {
814        let args = ServeArgs {
815            port: 3000,
816            host: "127.0.0.1".to_string(),
817            cors: None,
818            token: Some("test-token".to_string()),
819            allow_admin: false,
820            workdir_root: None,
821            no_remote_yolo: false,
822        };
823        assert_eq!(args.port, 3000);
824        assert_eq!(args.host, "127.0.0.1");
825        assert_eq!(args.cors, None);
826    }
827
828    #[test]
829    fn test_app_state_clone() {
830        let state = test_state();
831        let cloned = state.clone();
832        // Both should work (no panic)
833        let _ = cloned.config.default_provider.clone();
834    }
835
836    #[test]
837    fn test_cors_wildcard_vs_specific() {
838        // Test the CORS logic paths used in execute()
839        let wildcard = "*";
840        let specific = "https://example.com";
841
842        let is_wildcard = wildcard == "*";
843        assert!(is_wildcard);
844
845        let is_specific = specific != "*";
846        assert!(is_specific);
847
848        // Test that specific CORS origin parses correctly
849        let parsed = specific.parse::<axum::http::HeaderValue>();
850        assert!(parsed.is_ok());
851    }
852
853    #[test]
854    fn test_cors_invalid_origin_falls_back() {
855        let invalid_cors = "not a valid header value \x00";
856        let result = invalid_cors.parse::<axum::http::HeaderValue>();
857        // Invalid header values fail to parse; the code falls back to "*"
858        assert!(result.is_err());
859    }
860
861    #[tokio::test]
862    async fn test_submit_interaction_full_router_reaches_daemon() {
863        // The POST-interaction route is wired to the handler, which reaches the
864        // (absent-in-test) daemon. The ACCEPTED path is covered by the
865        // interactions handler's own tests against a fake daemon.
866        let app = test_app();
867        let body = serde_json::json!({"request_id": "req-1", "value": "do it", "scope": "once"});
868        let req = Request::builder()
869            .method("POST")
870            .uri("/api/agents/any/interaction")
871            .header("content-type", "application/json")
872            .body(Body::from(serde_json::to_string(&body).unwrap()))
873            .unwrap();
874        let resp = app.oneshot(req).await.unwrap();
875        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
876    }
877
878    // ─── execute() - real server bootstrap ─────────────────────────────────
879    //
880    // These drive the actual `execute()` entrypoint (config load, CORS setup,
881    // full router construction, real TCP bind, background polling spawn) end
882    // to end using port 0 (OS-assigned ephemeral port) so no fixed port is
883    // required. Since `axum::serve(...).await` never returns on success, the
884    // task is aborted once we've proven the server is up and responding.
885    //
886    // Each holds `isolate_config_path_for_test` even though none of them
887    // care about specific config *content* - their own `Config::load()`
888    // call needs protecting from a DIFFERENT concurrently-running test that
889    // does mutate `LEVIATH_CONFIG_PATH` (e.g. `execute_with_malformed_config_
890    // returns_err`, which points it at a file containing invalid TOML for
891    // the duration of its own guard). `std::env::set_var` is process-global,
892    // not thread-local, so without holding the same lock here, this test's
893    // `Config::load()` could transiently observe that other test's malformed
894    // path and fail with a real (if confusing) parse error - confirmed to
895    // reproduce locally at default test-thread concurrency, not a hypothetical.
896
897    #[tokio::test]
898    async fn execute_binds_and_serves_with_wildcard_cors() {
899        crate::config::with_isolated_config_path_async(
900            "serve-mod-wildcard-cors",
901            |_fake_dir| async move {
902                with_tracing(|| {});
903                // port: 0 lets the OS assign a genuinely free ephemeral port at bind
904                // time; execute_with_shutdown reports the real bound SocketAddr back
905                // via `ready` the instant it's bound, so there's no
906                // probe-bind-drop-rebind gap for another process/test to race into
907                // (that gap is a real, CI-reproducing TOCTOU - see
908                // execute_with_shutdown's doc comment). Exercises the exact same
909                // production code path execute() does (its own body is just this
910                // call with `ready: None`), so this remains a real end-to-end test
911                // of execute()'s bootstrap logic.
912                let args = ServeArgs {
913                    port: 0,
914                    host: "127.0.0.1".to_string(),
915                    cors: None,
916                    token: Some("test-token".to_string()),
917                    allow_admin: false,
918                    workdir_root: None,
919                    no_remote_yolo: false,
920                };
921                let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
922                let handle = tokio::spawn(execute_with_shutdown(
923                    args,
924                    no_daemon_control(),
925                    Box::pin(std::future::pending()),
926                    Some(ready_tx),
927                ));
928                let addr = ready_rx
929                    .await
930                    .expect("server should report its bound address");
931
932                // Sanity-check a real request round trip through the full app.
933                let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
934                use tokio::io::{AsyncReadExt, AsyncWriteExt};
935                stream
936                    .write_all(
937                        b"GET /api/config HTTP/1.1\r\nHost: localhost\r\n\
938                          Authorization: Bearer test-token\r\nConnection: close\r\n\r\n",
939                    )
940                    .await
941                    .unwrap();
942                let mut resp = Vec::new();
943                stream.read_to_end(&mut resp).await.unwrap();
944                let resp_str = String::from_utf8_lossy(&resp);
945                assert_response_ok(&resp_str);
946
947                // Without the token the same request is rejected.
948                let mut unauth = tokio::net::TcpStream::connect(addr).await.unwrap();
949                unauth
950                    .write_all(
951                        b"GET /api/config HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
952                    )
953                    .await
954                    .unwrap();
955                let mut resp2 = Vec::new();
956                unauth.read_to_end(&mut resp2).await.unwrap();
957                assert!(
958                    String::from_utf8_lossy(&resp2).starts_with("HTTP/1.1 401"),
959                    "unauthenticated request should be 401"
960                );
961
962                handle.abort();
963            },
964        )
965        .await;
966    }
967
968    /// A browser preflight for a request carrying `Authorization` must be
969    /// allowed. `Access-Control-Allow-Headers: *` does NOT cover `Authorization`
970    /// per the Fetch spec, so the header has to be listed explicitly — without
971    /// it the console's authenticated requests are blocked by the browser. Also
972    /// covers the `Some("*")` CORS arm.
973    #[tokio::test]
974    async fn execute_cors_preflight_allows_authorization_header() {
975        crate::config::with_isolated_config_path_async(
976            "serve-mod-cors-preflight",
977            |_fake_dir| async move {
978                with_tracing(|| {});
979                let args = ServeArgs {
980                    port: 0,
981                    host: "127.0.0.1".to_string(),
982                    cors: Some("*".to_string()),
983                    token: Some("test-token".to_string()),
984                    allow_admin: false,
985                    workdir_root: None,
986                    no_remote_yolo: false,
987                };
988                let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
989                let handle = tokio::spawn(execute_with_shutdown(
990                    args,
991                    no_daemon_control(),
992                    Box::pin(std::future::pending()),
993                    Some(ready_tx),
994                ));
995                let addr = ready_rx
996                    .await
997                    .expect("server should report its bound address");
998
999                use tokio::io::{AsyncReadExt, AsyncWriteExt};
1000                let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
1001                stream
1002                    .write_all(
1003                        b"OPTIONS /api/config HTTP/1.1\r\nHost: localhost\r\n\
1004                          Origin: https://leviath.dev\r\n\
1005                          Access-Control-Request-Method: GET\r\n\
1006                          Access-Control-Request-Headers: authorization\r\n\
1007                          Connection: close\r\n\r\n",
1008                    )
1009                    .await
1010                    .unwrap();
1011                let mut resp = Vec::new();
1012                stream.read_to_end(&mut resp).await.unwrap();
1013                let lower = String::from_utf8_lossy(&resp).to_lowercase();
1014                assert!(
1015                    lower.contains("access-control-allow-headers")
1016                        && lower.contains("authorization"),
1017                    "preflight must allow the Authorization header, got:\n{lower}"
1018                );
1019
1020                handle.abort();
1021            },
1022        )
1023        .await;
1024    }
1025
1026    #[tokio::test]
1027    async fn execute_with_specific_cors_origin_serves() {
1028        crate::config::with_isolated_config_path_async(
1029            "serve-mod-specific-cors",
1030            |_fake_dir| async move {
1031                let args = ServeArgs {
1032                    port: 0,
1033                    host: "127.0.0.1".to_string(),
1034                    cors: Some("https://example.com".to_string()),
1035                    token: Some("test-token".to_string()),
1036                    allow_admin: false,
1037                    workdir_root: None,
1038                    no_remote_yolo: false,
1039                };
1040                let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1041                let handle = tokio::spawn(execute_with_shutdown(
1042                    args,
1043                    no_daemon_control(),
1044                    Box::pin(std::future::pending()),
1045                    Some(ready_tx),
1046                ));
1047                let addr = ready_rx
1048                    .await
1049                    .expect("server should report its bound address");
1050                assert!(tokio::net::TcpStream::connect(addr).await.is_ok());
1051
1052                handle.abort();
1053            },
1054        )
1055        .await;
1056    }
1057
1058    #[tokio::test]
1059    async fn execute_with_unparseable_addr_returns_err() {
1060        // Isolated: this reaches `Config::load()`, which reads process-wide
1061        // environment. Unisolated it races every `temp_env` test in the binary.
1062        crate::config::with_isolated_config_path_async("serve-badaddr", |_fake_dir| async move {
1063            // An invalid host string makes `format!("{host}:{port}").parse()`
1064            // fail, exercising execute()'s `?` on the SocketAddr parse.
1065            let args = ServeArgs {
1066                port: 0,
1067                host: "not a valid host".to_string(),
1068                cors: None,
1069                token: Some("test-token".to_string()),
1070                allow_admin: false,
1071                workdir_root: None,
1072                no_remote_yolo: false,
1073            };
1074            let result = execute(args, no_daemon_control()).await;
1075            assert!(result.is_err());
1076        })
1077        .await;
1078    }
1079
1080    #[tokio::test]
1081    async fn test_agent_list_with_status_filter_full_router() {
1082        let app = test_app();
1083        let req = Request::builder()
1084            .uri("/api/agents?status=running,complete")
1085            .body(Body::empty())
1086            .unwrap();
1087        let resp = app.oneshot(req).await.unwrap();
1088        assert_eq!(resp.status(), StatusCode::OK);
1089    }
1090
1091    /// Covers `Config::load()?` error path (line 31) by pointing
1092    /// `LEVIATH_CONFIG_PATH` at a file containing invalid TOML.
1093    #[tokio::test]
1094    async fn execute_with_malformed_config_returns_err() {
1095        crate::config::with_isolated_config_path_async(
1096            "serve-mod-malformed",
1097            |_fake_dir| async move {
1098                // After isolate_config_path_for_test, Config::config_path() returns the temp path.
1099                std::fs::write(Config::config_path(), "not valid toml [[[").unwrap();
1100
1101                let args = ServeArgs {
1102                    port: 0,
1103                    host: "127.0.0.1".to_string(),
1104                    cors: None,
1105                    token: Some("test-token".to_string()),
1106                    allow_admin: false,
1107                    workdir_root: None,
1108                    no_remote_yolo: false,
1109                };
1110                let result = execute(args, no_daemon_control()).await;
1111                assert_execute_failed_on_malformed_config(&result);
1112            },
1113        )
1114        .await;
1115    }
1116
1117    /// Covers the `for warning in cfg.validate_keys()` loop body (lines 32-33)
1118    /// by writing a config with a bad anthropic key, then running the server
1119    /// with a graceful-shutdown signal so the loop executes before bind.
1120    #[tokio::test]
1121    async fn execute_with_bad_api_key_logs_warning_and_serves() {
1122        with_tracing(|| {});
1123        crate::config::with_isolated_config_path_async("serve-mod-badkey", |_fake_dir| async move {
1124        // Write a config with an anthropic key that fails validate_keys().
1125        std::fs::write(
1126            Config::config_path(),
1127            "default_provider = \"anthropic\"\nagent_paths = []\n[providers]\nanthropic_api_key = \"bad-key-not-sk-ant\"\n",
1128        )
1129        .unwrap();
1130
1131        let args = ServeArgs {
1132            port: 0,
1133            host: "127.0.0.1".to_string(),
1134            cors: None,
1135            token: Some("test-token".to_string()),
1136            allow_admin: false,
1137            workdir_root: None,
1138            no_remote_yolo: false,
1139        };
1140
1141        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1142        let shutdown_fut = async move {
1143            let _ = shutdown_rx.await;
1144        };
1145        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1146
1147        let handle = tokio::spawn(execute_with_shutdown(
1148            args,
1149            no_daemon_control(),
1150            Box::pin(shutdown_fut),
1151            Some(ready_tx),
1152        ));
1153        let addr = ready_rx
1154            .await
1155            .expect("server should report its bound address");
1156        let connected = tokio::net::TcpStream::connect(addr).await.is_ok();
1157        assert_connected_with_bad_api_key(connected);
1158
1159        // Trigger graceful shutdown so execute_with_shutdown returns Ok(()).
1160        let _ = shutdown_tx.send(());
1161        let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1162            .await
1163            .expect("timed out waiting for execute to return")
1164            .expect("task panicked");
1165        assert_execute_returned_ok_after_shutdown(&result);
1166    }).await;
1167    }
1168
1169    /// Covers the `TcpListener::bind(addr).await?` error path deterministically
1170    /// by binding to a reserved TEST-NET-1 address (RFC 5737, `192.0.2.0/24`)
1171    /// that is never assigned to a local interface, so the bind always fails
1172    /// with `EADDRNOTAVAIL`. (A prior version reused an already-bound ephemeral
1173    /// port, which occasionally let the second bind succeed under parallel-test
1174    /// load and left this region uncovered - a genuine flake.)
1175    #[tokio::test]
1176    async fn execute_with_unbindable_address_returns_bind_error() {
1177        // Isolated: this reaches `Config::load()`, which reads process-wide
1178        // environment. Unisolated it races every `temp_env` test in the binary.
1179        crate::config::with_isolated_config_path_async(
1180            "serve-unbindable",
1181            |_fake_dir| async move {
1182                let args = ServeArgs {
1183                    port: 8080,
1184                    host: "192.0.2.1".to_string(),
1185                    cors: None,
1186                    token: Some("test-token".to_string()),
1187                    allow_admin: false,
1188                    workdir_root: None,
1189                    no_remote_yolo: false,
1190                };
1191                let result = execute(args, no_daemon_control()).await;
1192                assert_execute_failed_on_port_in_use(&result);
1193            },
1194        )
1195        .await;
1196    }
1197
1198    #[tokio::test]
1199    async fn execute_refuses_to_start_without_a_token() {
1200        // No --token and no LEVIATH_API_TOKEN ⇒ the server won't start.
1201        temp_env::async_with_vars([("LEVIATH_API_TOKEN", None::<&str>)], async {
1202            let args = ServeArgs {
1203                port: 0,
1204                host: "127.0.0.1".to_string(),
1205                cors: None,
1206                token: None,
1207                allow_admin: false,
1208                workdir_root: None,
1209                no_remote_yolo: false,
1210            };
1211            let result = execute(args, no_daemon_control()).await;
1212            assert!(result.is_err(), "must refuse to start unauthenticated");
1213        })
1214        .await;
1215    }
1216
1217    /// Covers `axum::serve(...).await?` Ok path (lines 117, 119) by running
1218    /// `execute_with_shutdown` and sending a graceful-shutdown signal.
1219    #[tokio::test]
1220    async fn execute_with_shutdown_signal_returns_ok() {
1221        crate::config::with_isolated_config_path_async(
1222            "serve-mod-shutdown-signal",
1223            |_fake_dir| async move {
1224                let args = ServeArgs {
1225                    port: 0,
1226                    host: "127.0.0.1".to_string(),
1227                    cors: None,
1228                    token: Some("test-token".to_string()),
1229                    allow_admin: false,
1230                    workdir_root: None,
1231                    no_remote_yolo: false,
1232                };
1233
1234                let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1235                let shutdown_fut = async move {
1236                    let _ = shutdown_rx.await;
1237                };
1238                let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1239
1240                let handle = tokio::spawn(execute_with_shutdown(
1241                    args,
1242                    no_daemon_control(),
1243                    Box::pin(shutdown_fut),
1244                    Some(ready_tx),
1245                ));
1246                ready_rx
1247                    .await
1248                    .expect("server should report its bound address");
1249
1250                // Send shutdown signal and wait for execute to return Ok.
1251                let _ = shutdown_tx.send(());
1252                let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1253                    .await
1254                    .expect("timed out waiting for execute_with_shutdown to return")
1255                    .expect("task panicked");
1256                assert_execute_with_shutdown_returned_ok(&result);
1257            },
1258        )
1259        .await;
1260    }
1261
1262    /// Covers the `ready: None` fall-through of the `if let Some(ready)` block
1263    /// (line 190): a successful bind with no ready-observer, shut down
1264    /// gracefully. Every other binding test passes `Some(ready)`, and every
1265    /// `None` caller (`execute()`) in other tests fails before binding, so this
1266    /// is the only path that reaches the block's None continuation.
1267    #[tokio::test]
1268    async fn execute_with_shutdown_no_ready_observer_returns_ok() {
1269        crate::config::with_isolated_config_path_async(
1270            "serve-mod-no-ready",
1271            |_fake_dir| async move {
1272                let args = ServeArgs {
1273                    port: 0,
1274                    host: "127.0.0.1".to_string(),
1275                    cors: None,
1276                    token: Some("test-token".to_string()),
1277                    allow_admin: false,
1278                    workdir_root: None,
1279                    no_remote_yolo: false,
1280                };
1281
1282                let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1283                let shutdown_fut = async move {
1284                    let _ = shutdown_rx.await;
1285                };
1286
1287                let handle = tokio::spawn(execute_with_shutdown(
1288                    args,
1289                    no_daemon_control(),
1290                    Box::pin(shutdown_fut),
1291                    None,
1292                ));
1293                // Give the server a moment to bind before shutting down.
1294                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1295                let _ = shutdown_tx.send(());
1296                let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1297                    .await
1298                    .expect("timed out waiting for execute_with_shutdown to return")
1299                    .expect("task panicked");
1300                assert_execute_with_shutdown_returned_ok(&result);
1301            },
1302        )
1303        .await;
1304    }
1305    /// The three CORS shapes. Default is *no layer*: the API's clients are
1306    /// programmatic and not subject to CORS, so a browser-facing `*` default
1307    /// gave them nothing and widened the surface for everyone else.
1308    #[tokio::test]
1309    async fn cors_is_off_by_default_explicit_when_asked_and_fatal_when_malformed() {
1310        // Isolated because `execute_with_shutdown` calls `Config::load()`, which
1311        // reads process-wide environment. Without this the test raced every
1312        // `temp_env` test in the binary - `temp_env` serializes against its own
1313        // calls, not against a test that reads the environment directly - and
1314        // failed on CI in two different places depending on when it lost.
1315        crate::config::with_isolated_config_path_async("serve-mod-cors", |_fake_dir| async move {
1316            fn args_with(cors: Option<&str>) -> ServeArgs {
1317                ServeArgs {
1318                    port: 0,
1319                    host: "127.0.0.1".to_string(),
1320                    cors: cors.map(str::to_string),
1321                    token: Some("t".to_string()),
1322                    allow_admin: false,
1323                    workdir_root: None,
1324                    no_remote_yolo: false,
1325                }
1326            }
1327
1328            /// Start, wait until bound, then shut down. Only reached for values that
1329            /// are accepted - a rejected one never binds.
1330            async fn starts(cors: Option<&str>) {
1331                let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1332                let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1333                let server = tokio::spawn(execute_with_shutdown(
1334                    args_with(cors),
1335                    no_daemon_control(),
1336                    Box::pin(async move {
1337                        let _ = stop_rx.await;
1338                    }),
1339                    Some(ready_tx),
1340                ));
1341                // `RecvError` here means the sender was dropped, which any early
1342                // return from `execute_with_shutdown` does - so this reads as "the
1343                // server failed to start" without saying why. Left as-is rather
1344                // than adding a reporting branch that only a failing run executes,
1345                // which the coverage gate would (correctly) flag as dead.
1346                ready_rx.await.expect("the server bound");
1347                let _ = stop_tx.send(());
1348                server.await.expect("join").expect("clean shutdown");
1349            }
1350
1351            starts(None).await;
1352            starts(Some("*")).await;
1353            starts(Some("https://ok.example")).await;
1354
1355            // A malformed origin fails before binding, so this can be awaited
1356            // directly rather than raced against a `ready` signal.
1357            let err = execute_with_shutdown(
1358                args_with(Some("not a valid\nheader")),
1359                no_daemon_control(),
1360                Box::pin(std::future::pending()),
1361                None,
1362            )
1363            .await
1364            .expect_err("a malformed origin must refuse to start");
1365            // Printed on failure: startup can fail earlier than the CORS check (the
1366            // config load, for one), and "assertion failed" alone does not say so.
1367            assert!(
1368                err.to_string().contains("not a valid origin header"),
1369                "expected the CORS parse to be what refused, got: {err}"
1370            );
1371        })
1372        .await;
1373    }
1374
1375    /// The MCP admin endpoints are mounted only with `--allow-admin`: adding an
1376    /// MCP server writes a spawn command into config, which Leviath then runs.
1377    #[tokio::test]
1378    async fn the_mcp_admin_routes_are_mounted_only_with_allow_admin() {
1379        // Same isolation, same reason: this one also reaches `Config::load()`.
1380        crate::config::with_isolated_config_path_async("serve-mod-admin", |_fake_dir| async move {
1381            for allow_admin in [false, true] {
1382                let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1383                let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1384                let args = ServeArgs {
1385                    port: 0,
1386                    host: "127.0.0.1".to_string(),
1387                    cors: None,
1388                    token: Some("t".to_string()),
1389                    allow_admin,
1390                    workdir_root: None,
1391                    no_remote_yolo: false,
1392                };
1393                let server = tokio::spawn(execute_with_shutdown(
1394                    args,
1395                    no_daemon_control(),
1396                    Box::pin(async move {
1397                        let _ = stop_rx.await;
1398                    }),
1399                    Some(ready_tx),
1400                ));
1401                let addr = ready_rx.await.expect("bound");
1402
1403                let status = reqwest::Client::new()
1404                    .post(format!("http://{addr}/api/mcp/servers"))
1405                    .bearer_auth("t")
1406                    .json(&serde_json::json!({}))
1407                    .send()
1408                    .await
1409                    .expect("request")
1410                    .status()
1411                    .as_u16();
1412                // 405 (Method Not Allowed) is the signature of "this path exists
1413                // for GET but POST is not mounted". Asserted as a presence check
1414                // rather than an exact code for the mounted case, whose status
1415                // depends on body validation rather than on routing.
1416                match allow_admin {
1417                    false => assert_eq!(status, 405, "the admin route must not be mounted"),
1418                    true => assert_ne!(status, 405, "the admin route must be mounted"),
1419                }
1420
1421                let _ = stop_tx.send(());
1422                let _ = server.await;
1423            }
1424        })
1425        .await;
1426    }
1427}