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 cursor;
11mod doctor;
12mod fs;
13mod interactions;
14mod mcp;
15mod polling;
16mod runs;
17mod search;
18#[cfg(test)]
19mod testutil;
20mod tls;
21mod tree;
22mod types;
23mod websocket;
24
25use types::ServeLimits;
26pub use types::{AppState, ServeArgs, ServerEvent};
27
28use std::net::SocketAddr;
29use std::sync::Arc;
30
31use axum::Router;
32use axum::routing::{delete, get, post, put};
33use tokio::sync::broadcast;
34use tower_http::cors::{Any, CorsLayer};
35
36use crate::config::Config;
37
38// ─── Entrypoint ──────────────────────────────────────────────────────────────
39
40/// Aborts a spawned task when dropped - including when dropped mid-flight as
41/// part of an outer future's cancellation (e.g. `JoinHandle::abort()` on the
42/// task that owns this guard), not just on normal scope exit.
43struct AbortOnDrop<T>(tokio::task::JoinHandle<T>);
44
45impl<T> Drop for AbortOnDrop<T> {
46    fn drop(&mut self) {
47        self.0.abort();
48    }
49}
50
51/// Run `lev serve`: expose the HTTP + WebSocket API over the daemon.
52pub async fn execute(
53    args: ServeArgs,
54    control: leviath_runtime::control_socket::ControlClient,
55) -> anyhow::Result<()> {
56    execute_with_shutdown(args, control, Box::pin(std::future::pending()), None).await
57}
58
59/// Every API route with its production handlers - the single route table,
60/// shared by [`execute_with_shutdown`] and the tests. A hand-copied test
61/// router drifted seven routes behind production, which meant a route could
62/// be added, typo'd, and never exercised. Admin routes, the auth middleware,
63/// CORS, and `with_state` are layered on by the caller.
64fn api_router() -> Router<AppState> {
65    Router::new()
66        // Blueprints
67        .route(
68            "/api/blueprints",
69            get(blueprints::list_blueprints).post(blueprints::create_blueprint),
70        )
71        .route(
72            "/api/blueprints/validate",
73            post(blueprints::validate_blueprint),
74        )
75        .route(
76            "/api/blueprints/{name}",
77            get(blueprints::get_blueprint)
78                .put(blueprints::update_blueprint)
79                .delete(blueprints::delete_blueprint),
80        )
81        // Runs - the paginated, searchable listing. Supersedes the GET half of
82        // /api/agents, which stays as it is for existing clients.
83        .route("/api/runs", get(runs::list_runs))
84        // Agents
85        .route(
86            "/api/agents",
87            get(agents::list_agents).post(agents::spawn_agent),
88        )
89        .route("/api/agents/tree", get(tree::agents_tree))
90        .route(
91            "/api/agents/{id}",
92            get(agents::get_agent).delete(agents::kill_agent),
93        )
94        .route("/api/agents/{id}/children", get(agents::agent_children))
95        .route("/api/agents/{id}/context", get(agents::agent_context))
96        .route(
97            "/api/agents/{id}/context/history",
98            get(agents::agent_context_history),
99        )
100        .route("/api/agents/{id}/files", get(agents::agent_file))
101        .route("/api/agents/{id}/logs", get(agents::agent_logs))
102        .route("/api/agents/{id}/result", get(agents::agent_result))
103        .route("/api/agents/{id}/stages", get(agents::agent_stages))
104        .route("/api/agents/{id}/tree-status", get(tree::agent_tree_status))
105        .route("/api/agents/{id}/pause", post(agents::pause_agent))
106        .route("/api/agents/{id}/resume", post(agents::resume_agent))
107        // Messages
108        .route("/api/agents/{id}/message", post(interactions::send_message))
109        // Interactions
110        .route(
111            "/api/agents/{id}/interaction",
112            get(interactions::get_interaction).post(interactions::submit_interaction),
113        )
114        // MCP servers - read-only surface. The mutating half is mounted by
115        // `execute_with_shutdown`, behind `--allow-admin`.
116        .route("/api/mcp/servers", get(mcp::list_servers))
117        .route("/api/mcp/servers/{name}/status", get(mcp::status))
118        .route("/api/mcp/servers/{name}/login", post(mcp::login))
119        .route("/api/mcp/servers/{name}/test", post(mcp::test_server))
120        // Doctor - the same checks `lev doctor` runs, returned as data.
121        .route("/api/doctor", get(doctor::run_doctor))
122        // Filesystem - directory browsing for the console's folder picker.
123        .route("/api/fs/dirs", get(fs::list_dirs))
124        // Config
125        .route("/api/config", get(config::get_config))
126        .route("/api/config/validate", post(config::validate_config_key))
127        .route("/api/models", get(config::get_models))
128        // WebSocket
129        .route("/ws", get(websocket::ws_global))
130        .route("/ws/agents/{id}", get(websocket::ws_agent))
131}
132
133/// Every `(path, method)` this file registers, read out of its own source.
134///
135/// Axum's `Router` offers no way to enumerate what it holds, so the only place
136/// the route table can be read back is the text that declares it. Used by the
137/// test that holds `docs/schema/openapi.json` to the real router, so a route
138/// added without a spec entry fails the build rather than shipping undocumented.
139/// The same technique `xtask/src/docs.rs` uses on the docs.
140#[cfg(test)]
141fn declared_routes() -> Vec<(String, String)> {
142    const SOURCE: &str = include_str!("mod.rs");
143    // Only the half above the test module. The tests below declare routes of
144    // their own as fixtures for the reader, and those are not served by
145    // anything. Reading the whole file counted them as production routes.
146    let production = SOURCE.split("\nmod tests {").next().unwrap_or(SOURCE);
147    routes_in(production)
148}
149
150/// The route reader itself, over arbitrary source text.
151///
152/// Split from [`declared_routes`] so its parsing can be tested against input a
153/// test writes, rather than only against this file, which a test cannot vary.
154#[cfg(test)]
155fn routes_in(source: &str) -> Vec<(String, String)> {
156    let mut routes = Vec::new();
157    // Split rather than index: the workspace denies `clippy::string_slice`,
158    // and a byte range into UTF-8 text is exactly the hazard that lint is for.
159    for chunk in source.split(".route(").skip(1) {
160        // Balance parentheses to take just this call's arguments. The handler
161        // chain (`get(..).post(..)`) contains its own, and the chunk runs on
162        // past the call's end.
163        let mut depth = 1usize;
164        let mut body = String::new();
165        for ch in chunk.chars() {
166            match ch {
167                '(' => depth += 1,
168                ')' => {
169                    depth -= 1;
170                    if depth == 0 {
171                        break;
172                    }
173                }
174                _ => {}
175            }
176            body.push(ch);
177        }
178        let Some(path) = body
179            .split_once('"')
180            .and_then(|(_, rest)| rest.split_once('"'))
181            .map(|(path, _)| path)
182        else {
183            continue;
184        };
185        // This function's own source contains the text it splits on, so one
186        // chunk is always the split call itself. Requiring a route-shaped path
187        // drops it rather than inventing a route from the code around it.
188        if !path.starts_with('/') {
189            continue;
190        }
191        for method in ["get", "post", "put", "delete", "patch"] {
192            if body.contains(&format!("{method}(")) {
193                routes.push((path.to_string(), method.to_uppercase()));
194            }
195        }
196    }
197    routes
198}
199
200/// Core of [`execute`], with an optional shutdown signal so tests can stop
201/// the server gracefully and cover the `Ok(())` return path.
202///
203/// Takes `shutdown` as a boxed trait object (`Pin<Box<dyn Future<...>>>`)
204/// rather than `impl Future<...>` so every caller - production's
205/// `std::future::pending()` and tests' various `async move { ... }` blocks
206/// awaiting a `oneshot::Receiver` - shares exactly ONE monomorphization of
207/// this (large, multi-branch) function instead of one per concrete future
208/// type. Confirmed via HTML/JSON segment inspection that every source
209/// position has a covered instantiation (this is the same trait-object-erasure
210/// technique used for `io::Write` in `leviath-package`'s `bundler.rs`).
211///
212/// `ready`, if given, is sent the real bound `SocketAddr` right after
213/// `TcpListener::bind` succeeds (before serving starts). Production passes
214/// `None`; tests pass `Some(tx)` with `args.port = 0` so the OS picks a free
215/// port and the test learns which one was actually bound directly - no
216/// probe-bind-drop-rebind dance, which is a genuine TOCTOU race (confirmed
217/// to reproduce on real CI: another process/test could grab the just-freed
218/// port before this function's own bind runs), not just a test-only
219/// convenience.
220async fn execute_with_shutdown(
221    args: ServeArgs,
222    control: leviath_runtime::control_socket::ControlClient,
223    shutdown: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
224    ready: Option<tokio::sync::oneshot::Sender<SocketAddr>>,
225) -> anyhow::Result<()> {
226    // Resolve the API token before binding - refuse to start unauthenticated.
227    let auth_token = std::sync::Arc::new(auth::resolve_token(args.token.as_deref())?);
228    // The API can spawn tool-executing agents; loudly warn if bound off-host.
229    if args.host != "127.0.0.1" && args.host != "localhost" && args.host != "::1" {
230        tracing::warn!(
231            host = %args.host,
232            "serving the agent API on a non-local address - anyone who can reach \
233             this host and holds the token can spawn agents"
234        );
235    }
236
237    let cfg = Config::load()?;
238    // Read before `cfg` moves into the shared state below.
239    let allow_local_network = cfg.security.allow_local_network;
240    for warning in cfg.validate_keys() {
241        tracing::warn!("{}", warning);
242    }
243
244    // Sized like the daemon's WorldEvent ring (see WorldHost): the ring never
245    // shrinks, so its capacity is a permanent memory floor once filled.
246    let (event_tx, _) = broadcast::channel::<ServerEvent>(256);
247
248    let state = AppState {
249        config: Arc::new(cfg),
250        event_tx: event_tx.clone(),
251        control,
252        mcp: mcp::McpAdmin::default(),
253        limits: Arc::new(ServeLimits {
254            workdir_root: args.workdir_root.clone(),
255            no_remote_yolo: args.no_remote_yolo,
256            allow_local_network,
257        }),
258    };
259
260    // Background world-event consumer: subscribes to the daemon's pushed
261    // `WorldEvent` stream and forwards each event to WebSocket subscribers.
262    // Held behind an abort-on-drop guard so the task is torn down whenever this
263    // function returns *or* is cancelled - e.g. when a test aborts the outer
264    // `execute()`/`execute_with_shutdown()` task. Without this, aborting only
265    // the outer task left the inner `event_loop` (an unconditional
266    // subscribe-and-reconnect loop) running detached until the whole runtime
267    // was torn down.
268    let event_state = state.clone();
269    let _event_guard = AbortOnDrop(tokio::spawn(polling::event_loop(
270        event_state,
271        polling::RECONNECT_BACKOFF,
272    )));
273
274    // No `--cors` at all: no CORS layer. Programmatic clients are not subject to
275    // CORS, so the previous `*` default bought them nothing while telling every
276    // browser that any page may talk to this server.
277    let cors = match args.cors.as_deref() {
278        None => None,
279        Some("*") => Some(
280            CorsLayer::new()
281                .allow_origin(Any)
282                .allow_methods(Any)
283                // `Access-Control-Allow-Headers: *` does NOT cover
284                // `Authorization` per the Fetch spec, so a browser sending the
285                // required bearer token would be blocked. List the headers the
286                // API actually needs explicitly.
287                .allow_headers([
288                    axum::http::header::AUTHORIZATION,
289                    axum::http::header::CONTENT_TYPE,
290                ]),
291        ),
292        Some(origin) => {
293            // An unparseable value must not fall back to `*` - that silently
294            // turns a typo into "allow everything", the opposite of what was
295            // asked for. Refuse to start instead.
296            let value = origin.parse::<axum::http::HeaderValue>().map_err(|_| {
297                anyhow::anyhow!("--cors value '{origin}' is not a valid origin header")
298            })?;
299            Some(
300                CorsLayer::new()
301                    .allow_origin(value)
302                    .allow_methods(Any)
303                    // `Access-Control-Allow-Headers: *` does NOT cover
304                    // `Authorization` per the Fetch spec, so a browser sending the
305                    // required bearer token would be blocked. List the headers the
306                    // API actually needs explicitly.
307                    .allow_headers([
308                        axum::http::header::AUTHORIZATION,
309                        axum::http::header::CONTENT_TYPE,
310                    ]),
311            )
312        }
313    };
314
315    let app = api_router();
316
317    // The MCP administration endpoints are remote code execution by
318    // construction: `add_server` writes a `command` and `args` into
319    // `~/.leviath/config.toml`, and Leviath then spawns exactly that - for this
320    // run and every future one. The rest of the API can only run agents the user
321    // already installed. Not mounted unless the operator asked for them, so an
322    // unmounted route 404s rather than relying on a check inside the handler
323    // that someone could later route around.
324    let app = match args.allow_admin {
325        true => app
326            .route("/api/mcp/servers", post(mcp::add_server))
327            .route("/api/mcp/servers/{name}", delete(mcp::remove_server))
328            // Config-write persists provider secrets to disk, so it is gated the
329            // same way as MCP admin: unmounted (404) unless --allow-admin.
330            .route("/api/config", put(config::put_config)),
331        false => app,
332    };
333
334    let app = app
335        // Require a valid token on every route; CORS stays outermost so browser
336        // preflight (OPTIONS) is answered before the auth check.
337        .layer(axum::middleware::from_fn_with_state(
338            auth_token,
339            auth::require_auth,
340        ))
341        .with_state(state);
342
343    // Merged *after* the auth layer, which is the entire point: a browser tab
344    // cannot send an `Authorization` header, and this page exists to be opened
345    // in one. With a self-signed certificate that is how a user reaches the
346    // interstitial and accepts it, after which the console's `fetch` to the
347    // same origin inherits the exception.
348    //
349    // Deliberately says almost nothing. It is a new unauthenticated surface,
350    // and a visitor who can load it already knows the port is open - so it adds
351    // no version, no run counts, no endpoint list.
352    let app = app.merge(Router::new().route("/", get(status_page)));
353    // Applied by branching on the router rather than layering an `Option`:
354    // `Option<CorsLayer>` is not a `Layer`, and a permissive-but-unused layer
355    // would be exactly the default this change removes.
356    let app = match cors {
357        Some(layer) => app.layer(layer),
358        None => app,
359    };
360
361    // Resolved and loaded before the listener binds. A server that binds and
362    // then fails every handshake looks like a network fault from the other
363    // machine; one that refuses to start names the file it could not read.
364    let tls = tls::resolve(args.tls_cert.clone(), args.tls_key.clone())?;
365    let tls_config = match &tls {
366        Some(paths) => Some(tls::load(paths).await?),
367        None => None,
368    };
369
370    let addr: SocketAddr = format!("{}:{}", args.host, args.port).parse()?;
371    let scheme = tls::scheme(tls.as_ref());
372    tracing::info!("Listening on {}://{}", scheme, addr);
373    println!("Leviath API server listening on {scheme}://{addr}");
374
375    let listener = tokio::net::TcpListener::bind(addr).await?;
376    if let Some(ready) = ready {
377        // A test-only observer failing to receive (e.g. it already gave up
378        // after a timeout) shouldn't stop the server from starting for real.
379        let local_addr = listener
380            .local_addr()
381            .expect("infallible: a freshly bound TcpListener always has a local address");
382        let _ = ready.send(local_addr);
383    }
384
385    match tls_config {
386        // axum::serve with graceful shutdown always returns Ok(()) - discard the
387        // infallible Result so LLVM-cov does not instrument an unreachable Err branch.
388        None => {
389            let _ = axum::serve(listener, app)
390                .with_graceful_shutdown(shutdown)
391                .await;
392        }
393        Some(config) => serve_tls(listener, app, config, shutdown).await,
394    }
395
396    Ok(())
397}
398
399/// Serve over TLS on an already-bound listener, until `shutdown` resolves.
400///
401/// Takes the listener rather than an address so the bind, the `ready` report
402/// and the "port already in use" error are the same code on both schemes -
403/// letting `axum-server` bind would have given HTTPS its own second copy of all
404/// three, and a `--port 0` test no way to learn the port.
405///
406/// Shutdown is bridged rather than shared: `axum-server` signals through a
407/// `Handle` instead of taking a future, so a task waits on the same future the
408/// plain path awaits and converts it into a `graceful_shutdown` call.
409async fn serve_tls(
410    listener: tokio::net::TcpListener,
411    app: Router,
412    config: axum_server::tls_rustls::RustlsConfig,
413    shutdown: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
414) {
415    let handle = axum_server::Handle::new();
416    let signal = handle.clone();
417    tokio::spawn(async move {
418        shutdown.await;
419        // Some(..) rather than None: a connection that never closes would
420        // otherwise hold the process open for ever, and a WebSocket subscriber
421        // is exactly such a connection.
422        signal.graceful_shutdown(Some(std::time::Duration::from_secs(5)));
423    });
424    // Handed over still non-blocking, which is how tokio left it. Setting it
425    // back to blocking looks tidier and *panics*: `from_tcp` re-registers the
426    // socket with tokio, which refuses a blocking one. That is also why both
427    // conversions below cannot fail here - a bound, non-blocking listener is
428    // exactly what they accept.
429    let std_listener = listener
430        .into_std()
431        .expect("infallible: a bound tokio listener always converts back");
432    let server = axum_server::from_tcp_rustls(std_listener, config)
433        .expect("infallible: the listener is bound and non-blocking, which is all this checks");
434    // Discarded for the same reason the plain path discards `axum::serve`'s:
435    // with a shutdown signal wired up this resolves to `Ok(())`, and an
436    // unreachable `Err` branch is a region the coverage gate cannot forgive.
437    let _ = server.handle(handle).serve(app.into_make_service()).await;
438}
439
440/// The unauthenticated page at `GET /`.
441///
442/// Exists so a user can open the endpoint in a browser tab, meet the
443/// certificate interstitial, and accept it - after which the console's `fetch`
444/// to that origin inherits the exception. Strictly the mechanism does not need
445/// a page (the interstitial precedes any response, so even a 401 would do), but
446/// landing on an auth error reads like a mistake rather than confirmation.
447async fn status_page() -> axum::response::Html<&'static str> {
448    axum::response::Html(
449        "<!doctype html><meta charset=utf-8><title>Leviath</title>\
450         <body style=\"font:16px system-ui;margin:4rem auto;max-width:30rem\">\
451         <h1>Leviath is running.</h1>\
452         <p>The API needs a token; this page does not serve it.</p>",
453    )
454}
455
456// ─── Tests ──────────────────────────────────────────────────────────────────
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use axum::body::Body;
462    use axum::http::{Request, StatusCode};
463    use tower::ServiceExt;
464
465    use crate::runstate::RunMeta;
466    use crate::test_support::with_tracing;
467
468    /// The published OpenAPI spec for this API.
469    const OPENAPI: &str = include_str!("../../../../../docs/schema/openapi.json");
470
471    /// `GET /api/config` reports an `api_version`, and it has to mean
472    /// something. A version a client can read that disagrees with the document
473    /// describing that version is worse than publishing no version at all - the
474    /// client trusts it, and it is silently wrong.
475    ///
476    /// The same spirit as the route-drift test below: the spec is only a
477    /// contract while something holds the code to it.
478    #[test]
479    fn the_api_version_matches_the_spec_it_names() {
480        let spec: serde_json::Value = serde_json::from_str(OPENAPI).expect("the spec is JSON");
481        let documented = spec["info"]["version"]
482            .as_str()
483            .expect("the spec declares a version");
484        assert_eq!(documented, types::API_VERSION);
485    }
486
487    /// Every `(path, METHOD)` the spec documents.
488    fn documented_routes() -> Vec<(String, String)> {
489        let spec: serde_json::Value = serde_json::from_str(OPENAPI).expect("the spec is JSON");
490        let paths = spec["paths"].as_object().expect("the spec has paths");
491        let mut routes = Vec::new();
492        for (path, item) in paths {
493            let operations = item.as_object().expect("a path item is an object");
494            for method in ["get", "post", "put", "delete", "patch"] {
495                if operations.contains_key(method) {
496                    routes.push((path.clone(), method.to_uppercase()));
497                }
498            }
499        }
500        routes
501    }
502
503    /// A set of `(path, METHOD)` pairs.
504    type Routes = Vec<(String, String)>;
505
506    /// The two ways the spec can be wrong: a route served but not documented,
507    /// and one documented but no longer served.
508    fn spec_drift() -> (Routes, Routes) {
509        let declared = declared_routes();
510        let documented = documented_routes();
511        let missing = declared
512            .iter()
513            .filter(|r| !documented.contains(r))
514            .cloned()
515            .collect();
516        let extra = documented
517            .iter()
518            .filter(|r| !declared.contains(r))
519            .cloned()
520            .collect();
521        (missing, extra)
522    }
523
524    #[test]
525    fn the_openapi_spec_documents_exactly_the_routes_this_router_serves() {
526        // Hand-written, because there is no derive to generate it from. Without
527        // this the spec would be a snapshot of whatever the API looked like the
528        // day it was written, and an agent reading it would call routes that
529        // moved.
530        //
531        // Bare `assert!` over a bool, with no message: anything in an assert's
532        // format arguments is a region only the failing path reaches, which the
533        // 100% coverage gate then reports as uncovered. That costs the failure
534        // its detail, so when one of these trips, call `spec_drift()` and print
535        // it: `missing` is served but undocumented, `extra` is the reverse.
536        let (missing, extra) = spec_drift();
537        assert!(missing.is_empty());
538        assert!(extra.is_empty());
539    }
540
541    #[test]
542    fn the_route_reader_finds_the_routes_that_are_actually_there() {
543        // Guards the guard. This reads its own source text, so a change to how
544        // routes are written could quietly make it find nothing, and a test
545        // comparing two empty lists passes.
546        let declared = declared_routes();
547        assert!(declared.len() > 25);
548        assert!(declared.contains(&("/api/agents".to_string(), "POST".to_string())));
549        assert!(declared.contains(&("/api/agents/{id}".to_string(), "DELETE".to_string())));
550        assert!(declared.contains(&("/ws".to_string(), "GET".to_string())));
551    }
552
553    #[test]
554    fn the_route_reader_ignores_text_that_is_not_a_route() {
555        // The reader splits on a literal its own source contains, so it always
556        // sees at least one chunk that is not a route call. Anything without a
557        // route-shaped path has to be dropped rather than guessed at.
558        let source = concat!(
559            "let x = source.split(\".route(\").skip(1);\n",
560            ".route(\"not a path\", get(h))\n",
561            ".route(\"/real\", get(h).post(h))\n"
562        );
563        assert_eq!(
564            routes_in(source),
565            vec![
566                ("/real".to_string(), "GET".to_string()),
567                ("/real".to_string(), "POST".to_string()),
568            ]
569        );
570    }
571
572    #[test]
573    fn the_route_reader_reads_nothing_out_of_source_with_no_routes() {
574        assert_eq!(routes_in("fn main() {}"), Vec::new());
575    }
576
577    /// Extracted so the `assert!` failure-message region (only executed
578    /// when the assertion fails) is covered by this function's own
579    /// `#[should_panic]` test below, rather than showing as a
580    /// permanently-uncovered region at every real call site.
581    fn assert_execute_failed_on_malformed_config(result: &anyhow::Result<()>) {
582        assert!(
583            result.is_err(),
584            "execute should fail when config is malformed"
585        );
586    }
587
588    #[test]
589    #[should_panic(expected = "execute should fail when config is malformed")]
590    fn assert_execute_failed_on_malformed_config_panics_when_ok() {
591        assert_execute_failed_on_malformed_config(&Ok(()));
592    }
593
594    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
595    /// for the bad-API-key startup failure-message region.
596    fn assert_connected_with_bad_api_key(connected: bool) {
597        assert!(connected, "server should start even with a bad API key");
598    }
599
600    #[test]
601    #[should_panic(expected = "server should start even with a bad API key")]
602    fn assert_connected_with_bad_api_key_panics_when_not_connected() {
603        assert_connected_with_bad_api_key(false);
604    }
605
606    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
607    /// for the graceful-shutdown return-value failure-message region.
608    fn assert_execute_returned_ok_after_shutdown(result: &Result<(), anyhow::Error>) {
609        assert!(
610            result.is_ok(),
611            "execute should return Ok after graceful shutdown"
612        );
613    }
614
615    #[test]
616    #[should_panic(expected = "execute should return Ok after graceful shutdown")]
617    fn assert_execute_returned_ok_after_shutdown_panics_when_err() {
618        assert_execute_returned_ok_after_shutdown(&Err(anyhow::anyhow!("boom")));
619    }
620
621    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
622    /// for the port-in-use failure-message region.
623    fn assert_execute_failed_on_port_in_use(result: &anyhow::Result<()>) {
624        assert!(
625            result.is_err(),
626            "execute should fail when port is already in use"
627        );
628    }
629
630    #[test]
631    #[should_panic(expected = "execute should fail when port is already in use")]
632    fn assert_execute_failed_on_port_in_use_panics_when_ok() {
633        assert_execute_failed_on_port_in_use(&Ok(()));
634    }
635
636    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
637    /// for `execute_with_shutdown`'s graceful-shutdown return-value
638    /// failure-message region.
639    fn assert_execute_with_shutdown_returned_ok(result: &Result<(), anyhow::Error>) {
640        assert!(
641            result.is_ok(),
642            "execute_with_shutdown should return Ok(()) after graceful shutdown"
643        );
644    }
645
646    #[test]
647    #[should_panic(expected = "execute_with_shutdown should return Ok(()) after graceful shutdown")]
648    fn assert_execute_with_shutdown_returned_ok_panics_when_err() {
649        assert_execute_with_shutdown_returned_ok(&Err(anyhow::anyhow!("boom")));
650    }
651
652    /// See [`assert_execute_failed_on_malformed_config`] - same rationale,
653    /// for the HTTP response status-line failure-message region.
654    fn assert_response_ok(resp_str: &str) {
655        assert!(resp_str.starts_with("HTTP/1.1 200"), "got: {resp_str}");
656    }
657
658    #[test]
659    #[should_panic(expected = "got: HTTP/1.1 404 Not Found")]
660    fn assert_response_ok_panics_when_not_200() {
661        assert_response_ok("HTTP/1.1 404 Not Found\r\n\r\n");
662    }
663
664    /// A control client pointing at an address with no daemon: agent-action
665    /// endpoints report "not reachable", and read/bootstrap paths don't touch it.
666    fn no_daemon_control() -> leviath_runtime::control_socket::ControlClient {
667        leviath_runtime::control_socket::ControlClient::new(
668            leviath_runtime::control_socket::control_id(std::path::Path::new("/no/such/leviath")),
669        )
670    }
671
672    fn test_state() -> AppState {
673        let (tx, _) = broadcast::channel(64);
674        AppState {
675            config: Arc::new(Config::default()),
676            event_tx: tx,
677            control: no_daemon_control(),
678            mcp: crate::commands::serve::mcp::McpAdmin::default(),
679            limits: Default::default(),
680        }
681    }
682
683    /// The production route table over a test state - auth, CORS, and the
684    /// admin routes are absent, exactly as `api_router` leaves them.
685    fn test_app() -> Router {
686        api_router().with_state(test_state())
687    }
688
689    #[tokio::test]
690    async fn test_list_blueprints() {
691        let app = test_app();
692        let req = Request::builder()
693            .uri("/api/blueprints")
694            .body(Body::empty())
695            .unwrap();
696        let resp = app.oneshot(req).await.unwrap();
697        assert_eq!(resp.status(), StatusCode::OK);
698    }
699
700    #[tokio::test]
701    async fn test_router_serves_routes_the_old_hand_copy_missed() {
702        // /api/mcp/servers was one of the seven routes present in production
703        // but absent from the hand-copied test router; with the shared table
704        // it must be reachable here too.
705        let app = test_app();
706        let req = Request::builder()
707            .uri("/api/mcp/servers")
708            .body(Body::empty())
709            .unwrap();
710        let resp = app.oneshot(req).await.unwrap();
711        assert_eq!(resp.status(), StatusCode::OK);
712    }
713
714    #[tokio::test]
715    async fn test_pause_and_resume_routes_are_mounted() {
716        // With no daemon behind the test state the handlers answer 503 - the
717        // point here is only that the routes exist in the shared table (an
718        // unmounted route would 404 at the router).
719        for action in ["pause", "resume"] {
720            let app = test_app();
721            let req = Request::builder()
722                .method("POST")
723                .uri(format!("/api/agents/some-run/{action}"))
724                .body(Body::empty())
725                .unwrap();
726            let resp = app.oneshot(req).await.unwrap();
727            assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
728        }
729    }
730
731    #[tokio::test]
732    async fn test_agent_files_route_is_mounted() {
733        // Both a mounted and an unmounted route answer this with a 404 - the
734        // run does not exist either way - so the status alone proves nothing.
735        // What separates them is the body: the handler explains itself, and
736        // the router's own catch-all has nothing to say. (The handler's real
737        // behavior is covered in agents.rs.)
738        //
739        // `path` used to be required, and the resulting 400 was the proof.
740        // That stopped discriminating when it became optional so a bare call
741        // could list.
742        let app = test_app();
743        let req = Request::builder()
744            .uri("/api/agents/some-run/files")
745            .body(Body::empty())
746            .unwrap();
747        let resp = app.oneshot(req).await.unwrap();
748        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
749        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
750            .await
751            .unwrap();
752        let error = serde_json::from_slice::<serde_json::Value>(&body)
753            .ok()
754            .and_then(|v| v["error"].as_str().map(str::to_string))
755            .unwrap_or_default();
756        assert!(error.contains("some-run"));
757    }
758
759    #[tokio::test]
760    async fn test_fs_dirs_route_is_mounted() {
761        // A relative `path` is the one request whose answer never touches the
762        // filesystem: a mounted route rejects it with the handler's 400, an
763        // unmounted one 404s at the router. (The handler's own behavior is
764        // covered in fs.rs.)
765        let app = test_app();
766        let req = Request::builder()
767            .uri("/api/fs/dirs?path=not/absolute")
768            .body(Body::empty())
769            .unwrap();
770        let resp = app.oneshot(req).await.unwrap();
771        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
772    }
773
774    #[tokio::test]
775    async fn test_get_blueprint_not_found() {
776        let app = test_app();
777        let req = Request::builder()
778            .uri("/api/blueprints/nonexistent-agent-xyz")
779            .body(Body::empty())
780            .unwrap();
781        let resp = app.oneshot(req).await.unwrap();
782        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
783    }
784
785    #[tokio::test]
786    async fn test_validate_blueprint_valid() {
787        let app = test_app();
788        let manifest = r#"
789[agent]
790name = "test-agent"
791version = "0.1.0"
792description = "A test"
793
794[stages.main]
795mode = "autonomous"
796[stages.main.model]
797provider = "anthropic"
798model = "claude-sonnet-4-6"
799"#;
800        let body = serde_json::json!({ "manifest": manifest });
801        let req = Request::builder()
802            .method("POST")
803            .uri("/api/blueprints/validate")
804            .header("content-type", "application/json")
805            .body(Body::from(serde_json::to_string(&body).unwrap()))
806            .unwrap();
807        let resp = app.oneshot(req).await.unwrap();
808        assert_eq!(resp.status(), StatusCode::OK);
809
810        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
811            .await
812            .unwrap();
813        let val: types::ValidateResponse = serde_json::from_slice(&body).unwrap();
814        assert!(val.valid);
815    }
816
817    #[tokio::test]
818    async fn test_validate_blueprint_invalid() {
819        let app = test_app();
820        let body = serde_json::json!({ "manifest": "not valid toml {{{{" });
821        let req = Request::builder()
822            .method("POST")
823            .uri("/api/blueprints/validate")
824            .header("content-type", "application/json")
825            .body(Body::from(serde_json::to_string(&body).unwrap()))
826            .unwrap();
827        let resp = app.oneshot(req).await.unwrap();
828        assert_eq!(resp.status(), StatusCode::OK);
829
830        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
831            .await
832            .unwrap();
833        let val: types::ValidateResponse = serde_json::from_slice(&body).unwrap();
834        assert!(!val.valid);
835        assert!(val.errors.is_some());
836    }
837
838    #[tokio::test]
839    async fn test_list_agents() {
840        let app = test_app();
841        let req = Request::builder()
842            .uri("/api/agents")
843            .body(Body::empty())
844            .unwrap();
845        let resp = app.oneshot(req).await.unwrap();
846        assert_eq!(resp.status(), StatusCode::OK);
847    }
848
849    #[tokio::test]
850    async fn test_agents_tree() {
851        let app = test_app();
852        let req = Request::builder()
853            .uri("/api/agents/tree")
854            .body(Body::empty())
855            .unwrap();
856        let resp = app.oneshot(req).await.unwrap();
857        assert_eq!(resp.status(), StatusCode::OK);
858    }
859
860    #[tokio::test]
861    async fn test_get_agent_not_found() {
862        let app = test_app();
863        let req = Request::builder()
864            .uri("/api/agents/nonexistent-run-id-xyz")
865            .body(Body::empty())
866            .unwrap();
867        let resp = app.oneshot(req).await.unwrap();
868        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
869    }
870
871    #[tokio::test]
872    async fn test_agent_children_empty() {
873        let app = test_app();
874        let req = Request::builder()
875            .uri("/api/agents/nonexistent/children")
876            .body(Body::empty())
877            .unwrap();
878        let resp = app.oneshot(req).await.unwrap();
879        // children returns 200 with empty array even if parent doesn't exist
880        assert_eq!(resp.status(), StatusCode::OK);
881    }
882
883    #[tokio::test]
884    async fn test_agent_context_not_found() {
885        let app = test_app();
886        let req = Request::builder()
887            .uri("/api/agents/nonexistent/context")
888            .body(Body::empty())
889            .unwrap();
890        let resp = app.oneshot(req).await.unwrap();
891        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
892    }
893
894    #[tokio::test]
895    async fn test_agent_logs_not_found() {
896        let app = test_app();
897        let req = Request::builder()
898            .uri("/api/agents/nonexistent/logs")
899            .body(Body::empty())
900            .unwrap();
901        let resp = app.oneshot(req).await.unwrap();
902        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
903    }
904
905    #[tokio::test]
906    async fn test_agent_result_not_found() {
907        let app = test_app();
908        let req = Request::builder()
909            .uri("/api/agents/nonexistent/result")
910            .body(Body::empty())
911            .unwrap();
912        let resp = app.oneshot(req).await.unwrap();
913        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
914    }
915
916    #[tokio::test]
917    async fn test_agent_tree_status_not_found() {
918        let app = test_app();
919        let req = Request::builder()
920            .uri("/api/agents/nonexistent/tree-status")
921            .body(Body::empty())
922            .unwrap();
923        let resp = app.oneshot(req).await.unwrap();
924        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
925    }
926
927    #[tokio::test]
928    async fn test_interaction_route_reaches_daemon() {
929        // The route is wired to the handler, which (with no daemon in this test)
930        // reports the daemon unreachable - proving the request reached it.
931        let app = test_app();
932        let req = Request::builder()
933            .uri("/api/agents/nonexistent/interaction")
934            .body(Body::empty())
935            .unwrap();
936        let resp = app.oneshot(req).await.unwrap();
937        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
938    }
939
940    #[tokio::test]
941    async fn test_get_config() {
942        let app = test_app();
943        let req = Request::builder()
944            .uri("/api/config")
945            .body(Body::empty())
946            .unwrap();
947        let resp = app.oneshot(req).await.unwrap();
948        assert_eq!(resp.status(), StatusCode::OK);
949
950        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
951            .await
952            .unwrap();
953        let val: types::RedactedConfig = serde_json::from_slice(&body).unwrap();
954        assert_eq!(val.default_provider, "anthropic");
955        // Default config has no keys
956        assert!(!val.has_anthropic_key);
957        assert!(!val.has_openai_key);
958    }
959
960    #[tokio::test]
961    async fn test_tree_building() {
962        // Unit test for the tree builder
963        let runs = vec![
964            RunMeta::new(
965                "parent-1".to_string(),
966                "agent-a".to_string(),
967                "/path".to_string(),
968                "task".to_string(),
969                None,
970                "/work".to_string(),
971                1,
972            ),
973            {
974                let mut child = RunMeta::new(
975                    "child-1".to_string(),
976                    "agent-b".to_string(),
977                    "/path".to_string(),
978                    "sub-task".to_string(),
979                    None,
980                    "/work".to_string(),
981                    1,
982                );
983                child.parent_run_id = Some("parent-1".to_string());
984                child.prompt_tokens = 100;
985                child.completion_tokens = 50;
986                child
987            },
988        ];
989
990        let tree = tree::build_tree_status(&runs, None);
991        assert_eq!(tree.len(), 1);
992        assert_eq!(tree[0].run_id, "parent-1");
993        assert_eq!(tree[0].children.len(), 1);
994        assert_eq!(tree[0].subtree_prompt_tokens, 100); // parent (0) + child (100)
995        assert_eq!(tree[0].subtree_completion_tokens, 50);
996    }
997
998    #[tokio::test]
999    async fn test_delete_blueprint_not_found() {
1000        let app = test_app();
1001        let req = Request::builder()
1002            .method("DELETE")
1003            .uri("/api/blueprints/nonexistent-agent-xyz")
1004            .body(Body::empty())
1005            .unwrap();
1006        let resp = app.oneshot(req).await.unwrap();
1007        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1008    }
1009
1010    #[tokio::test]
1011    async fn test_server_event_serialization() {
1012        let event = ServerEvent::AgentStatus {
1013            agent_id: "coder".to_string(),
1014            run_id: "run-123".to_string(),
1015            status: "running".to_string(),
1016            stage: "implement".to_string(),
1017            iteration: 5,
1018            tool_calls: 0,
1019            accepts_messages: true,
1020        };
1021        let json = serde_json::to_string(&event).unwrap();
1022        assert!(json.contains("\"type\":\"agent_status\""));
1023        assert!(json.contains("\"agent_id\":\"coder\""));
1024
1025        let event2 = ServerEvent::Tokens {
1026            agent_id: "coder".to_string(),
1027            run_id: "run-123".to_string(),
1028            prompt_tokens: 5000,
1029            completion_tokens: 1200,
1030            cached_tokens: 0,
1031            cache_write_tokens: 0,
1032        };
1033        let json2 = serde_json::to_string(&event2).unwrap();
1034        assert!(json2.contains("\"type\":\"tokens\""));
1035        assert!(json2.contains("\"prompt_tokens\":5000"));
1036    }
1037
1038    #[tokio::test]
1039    async fn test_full_router_create_blueprint_invalid() {
1040        let app = test_app();
1041        let body = serde_json::json!({
1042            "name": "bad-agent",
1043            "manifest": "not valid toml {{{"
1044        });
1045        let req = Request::builder()
1046            .method("POST")
1047            .uri("/api/blueprints")
1048            .header("content-type", "application/json")
1049            .body(Body::from(serde_json::to_string(&body).unwrap()))
1050            .unwrap();
1051        let resp = app.oneshot(req).await.unwrap();
1052        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1053    }
1054
1055    #[tokio::test]
1056    async fn test_full_router_update_blueprint_not_found() {
1057        let app = test_app();
1058        let body = serde_json::json!({
1059            "manifest": r#"
1060[agent]
1061name = "no-such-agent"
1062version = "1.0.0"
1063description = "Missing"
1064
1065[stages.run]
1066system_prompt = "Run"
1067"#
1068        });
1069        let req = Request::builder()
1070            .method("PUT")
1071            .uri("/api/blueprints/no-such-agent-xyz-99999")
1072            .header("content-type", "application/json")
1073            .body(Body::from(serde_json::to_string(&body).unwrap()))
1074            .unwrap();
1075        let resp = app.oneshot(req).await.unwrap();
1076        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1077    }
1078
1079    #[tokio::test]
1080    async fn test_full_router_kill_agent_reaches_daemon() {
1081        let app = test_app();
1082        let req = Request::builder()
1083            .method("DELETE")
1084            .uri("/api/agents/nonexistent-kill-id-xyz")
1085            .body(Body::empty())
1086            .unwrap();
1087        let resp = app.oneshot(req).await.unwrap();
1088        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1089    }
1090
1091    #[tokio::test]
1092    async fn test_full_router_send_message_reaches_daemon() {
1093        let app = test_app();
1094        let body = serde_json::json!({"message": "hello"});
1095        let req = Request::builder()
1096            .method("POST")
1097            .uri("/api/agents/nonexistent-msg-id-xyz/message")
1098            .header("content-type", "application/json")
1099            .body(Body::from(serde_json::to_string(&body).unwrap()))
1100            .unwrap();
1101        let resp = app.oneshot(req).await.unwrap();
1102        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1103    }
1104
1105    #[tokio::test]
1106    async fn test_full_router_get_models() {
1107        let app = test_app();
1108        let req = Request::builder()
1109            .uri("/api/models")
1110            .body(Body::empty())
1111            .unwrap();
1112        let resp = app.oneshot(req).await.unwrap();
1113        assert_eq!(resp.status(), StatusCode::OK);
1114    }
1115
1116    #[tokio::test]
1117    async fn test_full_router_spawn_agent_blueprint_not_found() {
1118        let app = test_app();
1119        let body = serde_json::json!({
1120            "blueprint": "nonexistent-blueprint-xyz",
1121            "task": "do something"
1122        });
1123        let req = Request::builder()
1124            .method("POST")
1125            .uri("/api/agents")
1126            .header("content-type", "application/json")
1127            .body(Body::from(serde_json::to_string(&body).unwrap()))
1128            .unwrap();
1129        let resp = app.oneshot(req).await.unwrap();
1130        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1131    }
1132
1133    #[test]
1134    fn test_serve_args_defaults() {
1135        let args = ServeArgs {
1136            port: 3000,
1137            host: "127.0.0.1".to_string(),
1138            cors: None,
1139            token: Some("test-token".to_string()),
1140            allow_admin: false,
1141            workdir_root: None,
1142            no_remote_yolo: false,
1143            tls_cert: None,
1144            tls_key: None,
1145        };
1146        assert_eq!(args.port, 3000);
1147        assert_eq!(args.host, "127.0.0.1");
1148        assert_eq!(args.cors, None);
1149    }
1150
1151    #[test]
1152    fn test_app_state_clone() {
1153        let state = test_state();
1154        let cloned = state.clone();
1155        // Both should work (no panic)
1156        let _ = cloned.config.default_provider.clone();
1157    }
1158
1159    #[test]
1160    fn test_cors_wildcard_vs_specific() {
1161        // Test the CORS logic paths used in execute()
1162        let wildcard = "*";
1163        let specific = "https://example.com";
1164
1165        let is_wildcard = wildcard == "*";
1166        assert!(is_wildcard);
1167
1168        let is_specific = specific != "*";
1169        assert!(is_specific);
1170
1171        // Test that specific CORS origin parses correctly
1172        let parsed = specific.parse::<axum::http::HeaderValue>();
1173        assert!(parsed.is_ok());
1174    }
1175
1176    #[test]
1177    fn test_cors_invalid_origin_falls_back() {
1178        let invalid_cors = "not a valid header value \x00";
1179        let result = invalid_cors.parse::<axum::http::HeaderValue>();
1180        // Invalid header values fail to parse; the code falls back to "*"
1181        assert!(result.is_err());
1182    }
1183
1184    #[tokio::test]
1185    async fn test_submit_interaction_full_router_reaches_daemon() {
1186        // The POST-interaction route is wired to the handler, which reaches the
1187        // (absent-in-test) daemon. The ACCEPTED path is covered by the
1188        // interactions handler's own tests against a fake daemon.
1189        let app = test_app();
1190        let body = serde_json::json!({"request_id": "req-1", "value": "do it", "scope": "once"});
1191        let req = Request::builder()
1192            .method("POST")
1193            .uri("/api/agents/any/interaction")
1194            .header("content-type", "application/json")
1195            .body(Body::from(serde_json::to_string(&body).unwrap()))
1196            .unwrap();
1197        let resp = app.oneshot(req).await.unwrap();
1198        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1199    }
1200
1201    // ─── execute() - real server bootstrap ─────────────────────────────────
1202    //
1203    // These drive the actual `execute()` entrypoint (config load, CORS setup,
1204    // full router construction, real TCP bind, background polling spawn) end
1205    // to end using port 0 (OS-assigned ephemeral port) so no fixed port is
1206    // required. Since `axum::serve(...).await` never returns on success, the
1207    // task is aborted once we've proven the server is up and responding.
1208    //
1209    // Each holds `isolate_config_path_for_test` even though none of them
1210    // care about specific config *content* - their own `Config::load()`
1211    // call needs protecting from a DIFFERENT concurrently-running test that
1212    // does mutate `LEVIATH_CONFIG_PATH` (e.g. `execute_with_malformed_config_
1213    // returns_err`, which points it at a file containing invalid TOML for
1214    // the duration of its own guard). `std::env::set_var` is process-global,
1215    // not thread-local, so without holding the same lock here, this test's
1216    // `Config::load()` could transiently observe that other test's malformed
1217    // path and fail with a real (if confusing) parse error - confirmed to
1218    // reproduce locally at default test-thread concurrency, not a hypothetical.
1219
1220    #[tokio::test]
1221    async fn execute_binds_and_serves_with_wildcard_cors() {
1222        crate::config::with_isolated_config_path_async(
1223            "serve-mod-wildcard-cors",
1224            |_fake_dir| async move {
1225                with_tracing(|| {});
1226                // port: 0 lets the OS assign a genuinely free ephemeral port at bind
1227                // time; execute_with_shutdown reports the real bound SocketAddr back
1228                // via `ready` the instant it's bound, so there's no
1229                // probe-bind-drop-rebind gap for another process/test to race into
1230                // (that gap is a real, CI-reproducing TOCTOU - see
1231                // execute_with_shutdown's doc comment). Exercises the exact same
1232                // production code path execute() does (its own body is just this
1233                // call with `ready: None`), so this remains a real end-to-end test
1234                // of execute()'s bootstrap logic.
1235                let args = ServeArgs {
1236                    port: 0,
1237                    host: "127.0.0.1".to_string(),
1238                    cors: None,
1239                    token: Some("test-token".to_string()),
1240                    allow_admin: false,
1241                    workdir_root: None,
1242                    no_remote_yolo: false,
1243                    tls_cert: None,
1244                    tls_key: None,
1245                };
1246                let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1247                let handle = tokio::spawn(execute_with_shutdown(
1248                    args,
1249                    no_daemon_control(),
1250                    Box::pin(std::future::pending()),
1251                    Some(ready_tx),
1252                ));
1253                let addr = ready_rx
1254                    .await
1255                    .expect("server should report its bound address");
1256
1257                // Sanity-check a real request round trip through the full app.
1258                let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
1259                use tokio::io::{AsyncReadExt, AsyncWriteExt};
1260                stream
1261                    .write_all(
1262                        b"GET /api/config HTTP/1.1\r\nHost: localhost\r\n\
1263                          Authorization: Bearer test-token\r\nConnection: close\r\n\r\n",
1264                    )
1265                    .await
1266                    .unwrap();
1267                let mut resp = Vec::new();
1268                stream.read_to_end(&mut resp).await.unwrap();
1269                let resp_str = String::from_utf8_lossy(&resp);
1270                assert_response_ok(&resp_str);
1271
1272                // Without the token the same request is rejected.
1273                let mut unauth = tokio::net::TcpStream::connect(addr).await.unwrap();
1274                unauth
1275                    .write_all(
1276                        b"GET /api/config HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
1277                    )
1278                    .await
1279                    .unwrap();
1280                let mut resp2 = Vec::new();
1281                unauth.read_to_end(&mut resp2).await.unwrap();
1282                assert!(
1283                    String::from_utf8_lossy(&resp2).starts_with("HTTP/1.1 401"),
1284                    "unauthenticated request should be 401"
1285                );
1286
1287                handle.abort();
1288            },
1289        )
1290        .await;
1291    }
1292
1293    /// The whole feature, end to end: a real TLS handshake against a real
1294    /// listener, and the status page answering without a token.
1295    ///
1296    /// Everything else about TLS is tested in `tls::tests` against files. This
1297    /// is the one that would catch the wiring being wrong - a certificate that
1298    /// loads but a server that never speaks TLS, or a `/` route that ended up
1299    /// inside the auth layer after all.
1300    #[tokio::test]
1301    async fn execute_serves_https_and_the_status_page_needs_no_token() {
1302        crate::config::with_isolated_config_path_async("serve-mod-tls", |_fake_dir| async move {
1303            with_tracing(|| {});
1304            let dir = tempfile::tempdir().expect("tempdir");
1305            let cert = dir.path().join("cert.pem");
1306            let key = dir.path().join("key.pem");
1307            std::fs::write(&cert, tls::tests::TEST_CERT).expect("write cert");
1308            std::fs::write(&key, tls::tests::TEST_KEY).expect("write key");
1309
1310            let args = ServeArgs {
1311                port: 0,
1312                host: "127.0.0.1".to_string(),
1313                cors: None,
1314                token: Some("test-token".to_string()),
1315                allow_admin: false,
1316                workdir_root: None,
1317                no_remote_yolo: false,
1318                tls_cert: Some(cert),
1319                tls_key: Some(key),
1320            };
1321            let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1322            let handle = tokio::spawn(execute_with_shutdown(
1323                args,
1324                no_daemon_control(),
1325                Box::pin(std::future::pending()),
1326                Some(ready_tx),
1327            ));
1328            let addr = ready_rx.await.expect("server reports its address");
1329
1330            // A client that trusts exactly this certificate, so a successful
1331            // response proves the server presented it - not that verification
1332            // was skipped.
1333            let mut roots = tokio_rustls::rustls::RootCertStore::empty();
1334            use rustls_pki_types::pem::PemObject;
1335            for der in
1336                rustls_pki_types::CertificateDer::pem_slice_iter(tls::tests::TEST_CA.as_bytes())
1337            {
1338                roots
1339                    .add(der.expect("a parseable certificate"))
1340                    .expect("add to the root store");
1341            }
1342            let client_config = tokio_rustls::rustls::ClientConfig::builder()
1343                .with_root_certificates(roots)
1344                .with_no_client_auth();
1345            let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(client_config));
1346
1347            let stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
1348            let server_name = tokio_rustls::rustls::pki_types::ServerName::try_from("localhost")
1349                .expect("a valid name");
1350            let mut tls_stream = connector
1351                .connect(server_name, stream)
1352                .await
1353                .expect("the TLS handshake succeeds against the served certificate");
1354
1355            use tokio::io::{AsyncReadExt, AsyncWriteExt};
1356            tls_stream
1357                .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
1358                .await
1359                .expect("write");
1360            let mut resp = Vec::new();
1361            tls_stream.read_to_end(&mut resp).await.expect("read");
1362            let text = String::from_utf8_lossy(&resp).into_owned();
1363
1364            // No `Authorization` header was sent, and the page still answers -
1365            // which is the property the certificate-accepting flow depends on.
1366            assert!(text.starts_with("HTTP/1.1 200"), "{text}");
1367            assert!(text.contains("Leviath is running."), "{text}");
1368
1369            handle.abort();
1370        })
1371        .await;
1372    }
1373
1374    /// Both TLS failures stop the server before it binds, which is the whole
1375    /// point: one that binds and then rejects every handshake looks like a
1376    /// network fault from the other machine.
1377    #[tokio::test]
1378    async fn a_bad_tls_configuration_stops_the_server_before_it_binds() {
1379        crate::config::with_isolated_config_path_async(
1380            "serve-mod-tls-bad",
1381            |_fake_dir| async move {
1382                with_tracing(|| {});
1383                let dir = tempfile::tempdir().expect("tempdir");
1384                let cert = dir.path().join("cert.pem");
1385                std::fs::write(&cert, "not a certificate").expect("write");
1386
1387                let base = ServeArgs {
1388                    port: 0,
1389                    host: "127.0.0.1".to_string(),
1390                    cors: None,
1391                    token: Some("test-token".to_string()),
1392                    allow_admin: false,
1393                    workdir_root: None,
1394                    no_remote_yolo: false,
1395                    tls_cert: None,
1396                    tls_key: None,
1397                };
1398
1399                // One flag without the other.
1400                let lone = ServeArgs {
1401                    tls_cert: Some(cert.clone()),
1402                    ..base.clone()
1403                };
1404                let err = execute_with_shutdown(
1405                    lone,
1406                    no_daemon_control(),
1407                    Box::pin(std::future::pending()),
1408                    None,
1409                )
1410                .await
1411                .expect_err("one TLS flag alone is refused");
1412                let message = format!("{err:#}");
1413                assert!(message.contains("--tls-key"), "{message}");
1414
1415                // Both flags, but the certificate will not parse.
1416                let key = dir.path().join("key.pem");
1417                std::fs::write(&key, tls::tests::TEST_KEY).expect("write");
1418                let unreadable = ServeArgs {
1419                    tls_cert: Some(cert),
1420                    tls_key: Some(key),
1421                    ..base
1422                };
1423                let err = execute_with_shutdown(
1424                    unreadable,
1425                    no_daemon_control(),
1426                    Box::pin(std::future::pending()),
1427                    None,
1428                )
1429                .await
1430                .expect_err("a malformed certificate is refused");
1431                let message = format!("{err:#}");
1432                assert!(message.contains("cert.pem"), "{message}");
1433            },
1434        )
1435        .await;
1436    }
1437
1438    /// The HTTPS server stops when its shutdown future resolves.
1439    ///
1440    /// `axum-server` signals through a `Handle` rather than taking a future, so
1441    /// this is the one place the two shutdown models are bridged - and a bridge
1442    /// that never fires would leave `lev serve` unkillable by anything short of
1443    /// a signal.
1444    #[tokio::test]
1445    async fn https_shuts_down_when_its_signal_resolves() {
1446        crate::config::with_isolated_config_path_async(
1447            "serve-mod-tls-shutdown",
1448            |_fake_dir| async move {
1449                with_tracing(|| {});
1450                let dir = tempfile::tempdir().expect("tempdir");
1451                let cert = dir.path().join("cert.pem");
1452                let key = dir.path().join("key.pem");
1453                std::fs::write(&cert, tls::tests::TEST_CERT).expect("write cert");
1454                std::fs::write(&key, tls::tests::TEST_KEY).expect("write key");
1455
1456                let args = ServeArgs {
1457                    port: 0,
1458                    host: "127.0.0.1".to_string(),
1459                    cors: None,
1460                    token: Some("test-token".to_string()),
1461                    allow_admin: false,
1462                    workdir_root: None,
1463                    no_remote_yolo: false,
1464                    tls_cert: Some(cert),
1465                    tls_key: Some(key),
1466                };
1467                let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>();
1468                let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1469                let server = tokio::spawn(execute_with_shutdown(
1470                    args,
1471                    no_daemon_control(),
1472                    Box::pin(async move {
1473                        let _ = stop_rx.await;
1474                    }),
1475                    Some(ready_tx),
1476                ));
1477                ready_rx.await.expect("server reports its address");
1478
1479                stop_tx.send(()).expect("the server is listening for this");
1480                // Returns rather than being aborted, which is what proves the
1481                // signal reached `axum-server` instead of the task simply being
1482                // killed.
1483                let finished = tokio::time::timeout(std::time::Duration::from_secs(10), server)
1484                    .await
1485                    .expect("the server should stop on its own");
1486                finished
1487                    .expect("the task should not panic")
1488                    .expect("a clean shutdown is not an error");
1489            },
1490        )
1491        .await;
1492    }
1493
1494    /// A browser preflight for a request carrying `Authorization` must be
1495    /// allowed. `Access-Control-Allow-Headers: *` does NOT cover `Authorization`
1496    /// per the Fetch spec, so the header has to be listed explicitly — without
1497    /// it the console's authenticated requests are blocked by the browser. Also
1498    /// covers the `Some("*")` CORS arm.
1499    #[tokio::test]
1500    async fn execute_cors_preflight_allows_authorization_header() {
1501        crate::config::with_isolated_config_path_async(
1502            "serve-mod-cors-preflight",
1503            |_fake_dir| async move {
1504                with_tracing(|| {});
1505                let args = ServeArgs {
1506                    port: 0,
1507                    host: "127.0.0.1".to_string(),
1508                    cors: Some("*".to_string()),
1509                    token: Some("test-token".to_string()),
1510                    allow_admin: false,
1511                    workdir_root: None,
1512                    no_remote_yolo: false,
1513                    tls_cert: None,
1514                    tls_key: None,
1515                };
1516                let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1517                let handle = tokio::spawn(execute_with_shutdown(
1518                    args,
1519                    no_daemon_control(),
1520                    Box::pin(std::future::pending()),
1521                    Some(ready_tx),
1522                ));
1523                let addr = ready_rx
1524                    .await
1525                    .expect("server should report its bound address");
1526
1527                use tokio::io::{AsyncReadExt, AsyncWriteExt};
1528                let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
1529                stream
1530                    .write_all(
1531                        b"OPTIONS /api/config HTTP/1.1\r\nHost: localhost\r\n\
1532                          Origin: https://leviath.dev\r\n\
1533                          Access-Control-Request-Method: GET\r\n\
1534                          Access-Control-Request-Headers: authorization\r\n\
1535                          Connection: close\r\n\r\n",
1536                    )
1537                    .await
1538                    .unwrap();
1539                let mut resp = Vec::new();
1540                stream.read_to_end(&mut resp).await.unwrap();
1541                let lower = String::from_utf8_lossy(&resp).to_lowercase();
1542                assert!(
1543                    lower.contains("access-control-allow-headers")
1544                        && lower.contains("authorization"),
1545                    "preflight must allow the Authorization header, got:\n{lower}"
1546                );
1547
1548                handle.abort();
1549            },
1550        )
1551        .await;
1552    }
1553
1554    #[tokio::test]
1555    async fn execute_with_specific_cors_origin_serves() {
1556        crate::config::with_isolated_config_path_async(
1557            "serve-mod-specific-cors",
1558            |_fake_dir| async move {
1559                let args = ServeArgs {
1560                    port: 0,
1561                    host: "127.0.0.1".to_string(),
1562                    cors: Some("https://example.com".to_string()),
1563                    token: Some("test-token".to_string()),
1564                    allow_admin: false,
1565                    workdir_root: None,
1566                    no_remote_yolo: false,
1567                    tls_cert: None,
1568                    tls_key: None,
1569                };
1570                let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1571                let handle = tokio::spawn(execute_with_shutdown(
1572                    args,
1573                    no_daemon_control(),
1574                    Box::pin(std::future::pending()),
1575                    Some(ready_tx),
1576                ));
1577                let addr = ready_rx
1578                    .await
1579                    .expect("server should report its bound address");
1580                assert!(tokio::net::TcpStream::connect(addr).await.is_ok());
1581
1582                handle.abort();
1583            },
1584        )
1585        .await;
1586    }
1587
1588    #[tokio::test]
1589    async fn execute_with_unparseable_addr_returns_err() {
1590        // Isolated: this reaches `Config::load()`, which reads process-wide
1591        // environment. Unisolated it races every `temp_env` test in the binary.
1592        crate::config::with_isolated_config_path_async("serve-badaddr", |_fake_dir| async move {
1593            // An invalid host string makes `format!("{host}:{port}").parse()`
1594            // fail, exercising execute()'s `?` on the SocketAddr parse.
1595            let args = ServeArgs {
1596                port: 0,
1597                host: "not a valid host".to_string(),
1598                cors: None,
1599                token: Some("test-token".to_string()),
1600                allow_admin: false,
1601                workdir_root: None,
1602                no_remote_yolo: false,
1603                tls_cert: None,
1604                tls_key: None,
1605            };
1606            let result = execute(args, no_daemon_control()).await;
1607            assert!(result.is_err());
1608        })
1609        .await;
1610    }
1611
1612    #[tokio::test]
1613    async fn test_agent_list_with_status_filter_full_router() {
1614        let app = test_app();
1615        let req = Request::builder()
1616            .uri("/api/agents?status=running,complete")
1617            .body(Body::empty())
1618            .unwrap();
1619        let resp = app.oneshot(req).await.unwrap();
1620        assert_eq!(resp.status(), StatusCode::OK);
1621    }
1622
1623    /// Covers `Config::load()?` error path (line 31) by pointing
1624    /// `LEVIATH_CONFIG_PATH` at a file containing invalid TOML.
1625    #[tokio::test]
1626    async fn execute_with_malformed_config_returns_err() {
1627        crate::config::with_isolated_config_path_async(
1628            "serve-mod-malformed",
1629            |_fake_dir| async move {
1630                // After isolate_config_path_for_test, Config::config_path() returns the temp path.
1631                std::fs::write(Config::config_path(), "not valid toml [[[").unwrap();
1632
1633                let args = ServeArgs {
1634                    port: 0,
1635                    host: "127.0.0.1".to_string(),
1636                    cors: None,
1637                    token: Some("test-token".to_string()),
1638                    allow_admin: false,
1639                    workdir_root: None,
1640                    no_remote_yolo: false,
1641                    tls_cert: None,
1642                    tls_key: None,
1643                };
1644                let result = execute(args, no_daemon_control()).await;
1645                assert_execute_failed_on_malformed_config(&result);
1646            },
1647        )
1648        .await;
1649    }
1650
1651    /// Covers the `for warning in cfg.validate_keys()` loop body (lines 32-33)
1652    /// by writing a config with a bad anthropic key, then running the server
1653    /// with a graceful-shutdown signal so the loop executes before bind.
1654    #[tokio::test]
1655    async fn execute_with_bad_api_key_logs_warning_and_serves() {
1656        with_tracing(|| {});
1657        crate::config::with_isolated_config_path_async("serve-mod-badkey", |_fake_dir| async move {
1658        // Write a config with an anthropic key that fails validate_keys().
1659        std::fs::write(
1660            Config::config_path(),
1661            "default_provider = \"anthropic\"\nagent_paths = []\n[providers]\nanthropic_api_key = \"bad-key-not-sk-ant\"\n",
1662        )
1663        .unwrap();
1664
1665        let args = ServeArgs {
1666            port: 0,
1667            host: "127.0.0.1".to_string(),
1668            cors: None,
1669            token: Some("test-token".to_string()),
1670            allow_admin: false,
1671            workdir_root: None,
1672            no_remote_yolo: false,
1673                    tls_cert: None,
1674                    tls_key: None,
1675        };
1676
1677        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1678        let shutdown_fut = async move {
1679            let _ = shutdown_rx.await;
1680        };
1681        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1682
1683        let handle = tokio::spawn(execute_with_shutdown(
1684            args,
1685            no_daemon_control(),
1686            Box::pin(shutdown_fut),
1687            Some(ready_tx),
1688        ));
1689        let addr = ready_rx
1690            .await
1691            .expect("server should report its bound address");
1692        let connected = tokio::net::TcpStream::connect(addr).await.is_ok();
1693        assert_connected_with_bad_api_key(connected);
1694
1695        // Trigger graceful shutdown so execute_with_shutdown returns Ok(()).
1696        let _ = shutdown_tx.send(());
1697        let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1698            .await
1699            .expect("timed out waiting for execute to return")
1700            .expect("task panicked");
1701        assert_execute_returned_ok_after_shutdown(&result);
1702    }).await;
1703    }
1704
1705    /// Covers the `TcpListener::bind(addr).await?` error path deterministically
1706    /// by binding to a reserved TEST-NET-1 address (RFC 5737, `192.0.2.0/24`)
1707    /// that is never assigned to a local interface, so the bind always fails
1708    /// with `EADDRNOTAVAIL`. (A prior version reused an already-bound ephemeral
1709    /// port, which occasionally let the second bind succeed under parallel-test
1710    /// load and left this region uncovered - a genuine flake.)
1711    #[tokio::test]
1712    async fn execute_with_unbindable_address_returns_bind_error() {
1713        // Isolated: this reaches `Config::load()`, which reads process-wide
1714        // environment. Unisolated it races every `temp_env` test in the binary.
1715        crate::config::with_isolated_config_path_async(
1716            "serve-unbindable",
1717            |_fake_dir| async move {
1718                let args = ServeArgs {
1719                    port: 8080,
1720                    host: "192.0.2.1".to_string(),
1721                    cors: None,
1722                    token: Some("test-token".to_string()),
1723                    allow_admin: false,
1724                    workdir_root: None,
1725                    no_remote_yolo: false,
1726                    tls_cert: None,
1727                    tls_key: None,
1728                };
1729                let result = execute(args, no_daemon_control()).await;
1730                assert_execute_failed_on_port_in_use(&result);
1731            },
1732        )
1733        .await;
1734    }
1735
1736    #[tokio::test]
1737    async fn execute_refuses_to_start_without_a_token() {
1738        // No --token and no LEVIATH_API_TOKEN ⇒ the server won't start.
1739        temp_env::async_with_vars([("LEVIATH_API_TOKEN", None::<&str>)], async {
1740            let args = ServeArgs {
1741                port: 0,
1742                host: "127.0.0.1".to_string(),
1743                cors: None,
1744                token: None,
1745                allow_admin: false,
1746                workdir_root: None,
1747                no_remote_yolo: false,
1748                tls_cert: None,
1749                tls_key: None,
1750            };
1751            let result = execute(args, no_daemon_control()).await;
1752            assert!(result.is_err(), "must refuse to start unauthenticated");
1753        })
1754        .await;
1755    }
1756
1757    /// Covers `axum::serve(...).await?` Ok path (lines 117, 119) by running
1758    /// `execute_with_shutdown` and sending a graceful-shutdown signal.
1759    #[tokio::test]
1760    async fn execute_with_shutdown_signal_returns_ok() {
1761        crate::config::with_isolated_config_path_async(
1762            "serve-mod-shutdown-signal",
1763            |_fake_dir| async move {
1764                let args = ServeArgs {
1765                    port: 0,
1766                    host: "127.0.0.1".to_string(),
1767                    cors: None,
1768                    token: Some("test-token".to_string()),
1769                    allow_admin: false,
1770                    workdir_root: None,
1771                    no_remote_yolo: false,
1772                    tls_cert: None,
1773                    tls_key: None,
1774                };
1775
1776                let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1777                let shutdown_fut = async move {
1778                    let _ = shutdown_rx.await;
1779                };
1780                let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1781
1782                let handle = tokio::spawn(execute_with_shutdown(
1783                    args,
1784                    no_daemon_control(),
1785                    Box::pin(shutdown_fut),
1786                    Some(ready_tx),
1787                ));
1788                ready_rx
1789                    .await
1790                    .expect("server should report its bound address");
1791
1792                // Send shutdown signal and wait for execute to return Ok.
1793                let _ = shutdown_tx.send(());
1794                let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1795                    .await
1796                    .expect("timed out waiting for execute_with_shutdown to return")
1797                    .expect("task panicked");
1798                assert_execute_with_shutdown_returned_ok(&result);
1799            },
1800        )
1801        .await;
1802    }
1803
1804    /// Covers the `ready: None` fall-through of the `if let Some(ready)` block
1805    /// (line 190): a successful bind with no ready-observer, shut down
1806    /// gracefully. Every other binding test passes `Some(ready)`, and every
1807    /// `None` caller (`execute()`) in other tests fails before binding, so this
1808    /// is the only path that reaches the block's None continuation.
1809    #[tokio::test]
1810    async fn execute_with_shutdown_no_ready_observer_returns_ok() {
1811        crate::config::with_isolated_config_path_async(
1812            "serve-mod-no-ready",
1813            |_fake_dir| async move {
1814                let args = ServeArgs {
1815                    port: 0,
1816                    host: "127.0.0.1".to_string(),
1817                    cors: None,
1818                    token: Some("test-token".to_string()),
1819                    allow_admin: false,
1820                    workdir_root: None,
1821                    no_remote_yolo: false,
1822                    tls_cert: None,
1823                    tls_key: None,
1824                };
1825
1826                let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1827                let shutdown_fut = async move {
1828                    let _ = shutdown_rx.await;
1829                };
1830
1831                let handle = tokio::spawn(execute_with_shutdown(
1832                    args,
1833                    no_daemon_control(),
1834                    Box::pin(shutdown_fut),
1835                    None,
1836                ));
1837                // Give the server a moment to bind before shutting down.
1838                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1839                let _ = shutdown_tx.send(());
1840                let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1841                    .await
1842                    .expect("timed out waiting for execute_with_shutdown to return")
1843                    .expect("task panicked");
1844                assert_execute_with_shutdown_returned_ok(&result);
1845            },
1846        )
1847        .await;
1848    }
1849    /// The three CORS shapes. Default is *no layer*: the API's clients are
1850    /// programmatic and not subject to CORS, so a browser-facing `*` default
1851    /// gave them nothing and widened the surface for everyone else.
1852    #[tokio::test]
1853    async fn cors_is_off_by_default_explicit_when_asked_and_fatal_when_malformed() {
1854        // Isolated because `execute_with_shutdown` calls `Config::load()`, which
1855        // reads process-wide environment. Without this the test raced every
1856        // `temp_env` test in the binary - `temp_env` serializes against its own
1857        // calls, not against a test that reads the environment directly - and
1858        // failed on CI in two different places depending on when it lost.
1859        crate::config::with_isolated_config_path_async("serve-mod-cors", |_fake_dir| async move {
1860            fn args_with(cors: Option<&str>) -> ServeArgs {
1861                ServeArgs {
1862                    port: 0,
1863                    host: "127.0.0.1".to_string(),
1864                    cors: cors.map(str::to_string),
1865                    token: Some("t".to_string()),
1866                    allow_admin: false,
1867                    workdir_root: None,
1868                    no_remote_yolo: false,
1869                    tls_cert: None,
1870                    tls_key: None,
1871                }
1872            }
1873
1874            /// Start, wait until bound, then shut down. Only reached for values that
1875            /// are accepted - a rejected one never binds.
1876            async fn starts(cors: Option<&str>) {
1877                let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1878                let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1879                let server = tokio::spawn(execute_with_shutdown(
1880                    args_with(cors),
1881                    no_daemon_control(),
1882                    Box::pin(async move {
1883                        let _ = stop_rx.await;
1884                    }),
1885                    Some(ready_tx),
1886                ));
1887                // `RecvError` here means the sender was dropped, which any early
1888                // return from `execute_with_shutdown` does - so this reads as "the
1889                // server failed to start" without saying why. Left as-is rather
1890                // than adding a reporting branch that only a failing run executes,
1891                // which the coverage gate would (correctly) flag as dead.
1892                ready_rx.await.expect("the server bound");
1893                let _ = stop_tx.send(());
1894                server.await.expect("join").expect("clean shutdown");
1895            }
1896
1897            starts(None).await;
1898            starts(Some("*")).await;
1899            starts(Some("https://ok.example")).await;
1900
1901            // A malformed origin fails before binding, so this can be awaited
1902            // directly rather than raced against a `ready` signal.
1903            let err = execute_with_shutdown(
1904                args_with(Some("not a valid\nheader")),
1905                no_daemon_control(),
1906                Box::pin(std::future::pending()),
1907                None,
1908            )
1909            .await
1910            .expect_err("a malformed origin must refuse to start");
1911            // Printed on failure: startup can fail earlier than the CORS check (the
1912            // config load, for one), and "assertion failed" alone does not say so.
1913            assert!(
1914                err.to_string().contains("not a valid origin header"),
1915                "expected the CORS parse to be what refused, got: {err}"
1916            );
1917        })
1918        .await;
1919    }
1920
1921    /// The MCP admin endpoints are mounted only with `--allow-admin`: adding an
1922    /// MCP server writes a spawn command into config, which Leviath then runs.
1923    #[tokio::test]
1924    async fn the_mcp_admin_routes_are_mounted_only_with_allow_admin() {
1925        // Same isolation, same reason: this one also reaches `Config::load()`.
1926        crate::config::with_isolated_config_path_async("serve-mod-admin", |_fake_dir| async move {
1927            for allow_admin in [false, true] {
1928                let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1929                let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1930                let args = ServeArgs {
1931                    port: 0,
1932                    host: "127.0.0.1".to_string(),
1933                    cors: None,
1934                    token: Some("t".to_string()),
1935                    allow_admin,
1936                    workdir_root: None,
1937                    no_remote_yolo: false,
1938                    tls_cert: None,
1939                    tls_key: None,
1940                };
1941                let server = tokio::spawn(execute_with_shutdown(
1942                    args,
1943                    no_daemon_control(),
1944                    Box::pin(async move {
1945                        let _ = stop_rx.await;
1946                    }),
1947                    Some(ready_tx),
1948                ));
1949                let addr = ready_rx.await.expect("bound");
1950
1951                let status = reqwest::Client::new()
1952                    .post(format!("http://{addr}/api/mcp/servers"))
1953                    .bearer_auth("t")
1954                    .json(&serde_json::json!({}))
1955                    .send()
1956                    .await
1957                    .expect("request")
1958                    .status()
1959                    .as_u16();
1960                // 405 (Method Not Allowed) is the signature of "this path exists
1961                // for GET but POST is not mounted". Asserted as a presence check
1962                // rather than an exact code for the mounted case, whose status
1963                // depends on body validation rather than on routing.
1964                match allow_admin {
1965                    false => assert_eq!(status, 405, "the admin route must not be mounted"),
1966                    true => assert_ne!(status, 405, "the admin route must be mounted"),
1967                }
1968
1969                let _ = stop_tx.send(());
1970                let _ = server.await;
1971            }
1972        })
1973        .await;
1974    }
1975}