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