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