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