diff --git a/docs/administration/server-configuration.mdx b/docs/administration/server-configuration.mdx index 418acdcb9..9156ecc02 100644 --- a/docs/administration/server-configuration.mdx +++ b/docs/administration/server-configuration.mdx @@ -44,6 +44,7 @@ ca = "/etc/fabro/tls/ca.pem" type = "Jwt" [web] +enabled = true url = "https://fabro-web.example.com" [web.auth] @@ -103,6 +104,8 @@ Several `settings.toml` settings can be overridden via `fabro server start` flag | Flag | Default | Description | |---|---|---| | `--bind` | `~/.fabro/fabro.sock` | Address to bind: `IP` or `IP:port` for TCP, or a path for Unix socket | +| `--web` | enabled | Enable the embedded web UI, browser auth routes, and web-only helper endpoints | +| `--no-web` | disabled | Disable the embedded web UI, browser auth routes, and web-only helper endpoints | | `--foreground` | — | Run in the foreground instead of daemonizing | | `--model` | — | Override default LLM model | | `--provider` | — | Override default LLM provider | @@ -113,6 +116,17 @@ Several `settings.toml` settings can be overridden via `fabro server start` flag CLI flags take precedence over `settings.toml` values. See [Run Configuration — Precedence](/execution/run-configuration#precedence) for the full resolution order. +### `[web]` section + +Control the embedded SPA and browser-oriented routes. + +| Key | Description | Default | +|---|---|---| +| `enabled` | Serve the embedded SPA, `/auth/*`, and the web-only helper endpoints under `/api/v1` | `true` | +| `url` | External web UI URL used for OAuth redirects | `http://localhost:3000` | + +When `enabled = false`, the server still exposes the machine API and `/health`, but `/`, `/auth/*`, SPA client routes, `/api/v1/auth/me`, `/api/v1/setup/*`, and `/api/v1/demo/toggle` all return `404`. + ### Run defaults The `[llm]`, `[setup]`, `[sandbox]`, `[checkpoint]`, and `[vars]` sections in `settings.toml` act as defaults for every run. diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 2c4d6ccd2..76cbc8ffe 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -4653,6 +4653,9 @@ components: description: Web UI configuration. type: object properties: + enabled: + type: boolean + description: Whether the embedded web UI and browser-oriented routes are enabled. url: type: string description: Web UI URL. diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index bb1c1531a..a85d5302e 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -328,6 +328,7 @@ Start the Fabro server daemon. By default, the server launches as a background p fabro server start # background daemon on Unix socket fabro server start --bind 127.0.0.1 # TCP on 32276, or random port if 32276 is busy fabro server start --bind 127.0.0.1:8080 # TCP on a specific port +fabro server start --no-web # API and /health only fabro server start --foreground # blocking foreground mode fabro server start --sandbox daytona --max-concurrent-runs 4 ``` @@ -335,6 +336,8 @@ fabro server start --sandbox daytona --max-concurrent-runs 4 | Flag | Description | Default | |---|---|---| | `--bind ` | Address to bind: `IP` or `IP:port` for TCP, or a path for Unix socket | `~/.fabro/fabro.sock` | +| `--web` | Enable the embedded web UI, browser auth routes, and web-only helper endpoints | Enabled | +| `--no-web` | Disable the embedded web UI, browser auth routes, and web-only helper endpoints | Disabled | | `--foreground` | Run in the foreground instead of daemonizing | — | | `--model ` | Override default LLM model | — | | `--provider ` | Override default LLM provider | — | @@ -345,6 +348,8 @@ fabro server start --sandbox daytona --max-concurrent-runs 4 Demo mode is per-request: send the `X-Fabro-Demo: 1` header to get static demo data with auth disabled. +When `--no-web` is set, the server still exposes the machine API under `/api/v1` and `/health`, but it returns `404` for `/`, `/auth/*`, SPA client routes, and the web-only helper endpoints under `/api/v1`. + ## `fabro server stop` Stop the running server daemon. Sends SIGTERM and waits for graceful shutdown, escalating to SIGKILL after the timeout. diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs index 4613eafdf..c7a03f109 100644 --- a/lib/crates/fabro-cli/src/commands/server/start.rs +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -69,6 +69,8 @@ fn ensure_server_running_with_bind( let serve_args = ServeArgs { bind: None, + web: false, + no_web: false, model: None, provider: None, dry_run: false, @@ -207,6 +209,12 @@ fn execute_daemon( if let Some(ref provider) = serve_args.provider { cmd.args(["--provider", provider]); } + if serve_args.web { + cmd.arg("--web"); + } + if serve_args.no_web { + cmd.arg("--no-web"); + } if serve_args.dry_run { cmd.arg("--dry-run"); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs index 5c937e426..149076c32 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs @@ -35,14 +35,18 @@ fn help() { Address to bind to (IP or IP:port for TCP, or path containing / for Unix socket) --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --model - Override default LLM model --quiet Suppress non-essential output [env: FABRO_QUIET=] - --provider - Override default LLM provider + --web + Enable the embedded web UI and browser auth routes + --no-web + Disable the embedded web UI, browser auth routes, and web-only helper endpoints --verbose Enable verbose output [env: FABRO_VERBOSE=] + --model + Override default LLM model + --provider + Override default LLM provider --dry-run Execute with simulated LLM backend --sandbox @@ -188,8 +192,11 @@ fn start_with_tcp_host_only_bind_warns_and_falls_back_when_default_port_is_unava let context = test_context!(); let storage_root = isolated_storage_dir(); let storage_dir = storage_root.path().join("storage"); - let occupied = std::net::TcpListener::bind(("127.0.0.1", 32276)) - .expect("test requires default TCP port 32276 to be free before occupying it"); + let occupied = match std::net::TcpListener::bind(("127.0.0.1", 32276)) { + Ok(listener) => Some(listener), + Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => None, + Err(error) => panic!("failed to bind default TCP port 32276: {error}"), + }; let mut filters = context.filters(); filters.push((r"pid \d+".to_string(), "pid [PID]".to_string())); diff --git a/lib/crates/fabro-config/src/server.rs b/lib/crates/fabro-config/src/server.rs index e038b5ab7..aec1d4e25 100644 --- a/lib/crates/fabro-config/src/server.rs +++ b/lib/crates/fabro-config/src/server.rs @@ -134,6 +134,7 @@ impl TryFrom for GitSettings { #[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct WebConfig { + pub enabled: Option, pub url: Option, pub auth: Option, } @@ -145,6 +146,7 @@ fn default_web_url() -> String { impl From for WebSettings { fn from(value: WebConfig) -> Self { Self { + enabled: value.enabled.unwrap_or(true), url: value.url.unwrap_or_else(default_web_url), auth: value.auth.map(Into::into).unwrap_or_default(), } diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 0f95fa8a3..581abb5bb 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -1495,6 +1495,7 @@ mod settings { storage_dir: Some("/home/fabro/.fabro".into()), max_concurrent_runs: Some(10), web: Some(WebSettings { + enabled: true, url: "https://fabro.example.com".into(), auth: AuthSettings { provider: AuthProvider::Github, diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index f227f2e8a..86d613ad2 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -24,8 +24,8 @@ use crate::github_webhooks::WebhookManager; use crate::jwt_auth::{AuthMode, AuthStrategy, resolve_auth_mode_with_lookup}; use crate::secret_store::SecretStore; use crate::server::{ - build_app_state_with_path, build_router, reconcile_incomplete_runs_on_startup, - shutdown_active_workers, spawn_scheduler, + build_app_state_with_path, reconcile_incomplete_runs_on_startup, shutdown_active_workers, + spawn_scheduler, }; use crate::tls::{ClientAuth, build_rustls_config, serve_tls_with_shutdown}; use fabro_llm::client::Client as LlmClient; @@ -47,6 +47,14 @@ pub struct ServeArgs { #[arg(long)] pub bind: Option, + /// Enable the embedded web UI and browser auth routes + #[arg(long, conflicts_with = "no_web")] + pub web: bool, + + /// Disable the embedded web UI, browser auth routes, and web-only helper endpoints + #[arg(long, conflicts_with = "web")] + pub no_web: bool, + /// Override default LLM model #[arg(long)] pub model: Option, @@ -85,6 +93,9 @@ fn apply_serve_overrides(base: &Settings, args: &ServeArgs, dry_run_mode: bool) if dry_run_mode { settings.dry_run = Some(true); } + if args.web || args.no_web { + settings.web.get_or_insert_default().enabled = args.web; + } if let Some(ref model) = args.model { settings.llm.get_or_insert_default().model = Some(model.clone()); } @@ -250,6 +261,13 @@ where .unwrap_or(5); (auth_mode, client_auth, max_concurrent_runs) }; + let web_enabled = shared_settings + .read() + .expect("config lock poisoned") + .web + .as_ref() + .map(|web| web.enabled) + .unwrap_or(true); let store_path = storage.store_dir(); let object_store = build_object_store(&store_path)?; @@ -281,7 +299,11 @@ where ); } spawn_scheduler(Arc::clone(&state)); - let router = build_router(Arc::clone(&state), auth_mode); + let router = crate::server::build_router_with_options( + Arc::clone(&state), + auth_mode, + crate::server::RouterOptions { web_enabled }, + ); let bind_request = match args.bind { Some(ref s) => bind::parse_bind(s)?, @@ -631,6 +653,8 @@ mod tests { provider: None, dry_run: false, sandbox: None, + web: false, + no_web: false, max_concurrent_runs: None, config: None, }; @@ -644,6 +668,52 @@ mod tests { ); } + #[test] + fn apply_runtime_settings_enables_web_from_cli_flag() { + let base: Settings = toml::from_str( + r#" +[web] +enabled = false +"#, + ) + .unwrap(); + let args = ServeArgs { + bind: None, + model: None, + provider: None, + dry_run: false, + sandbox: None, + web: true, + no_web: false, + max_concurrent_runs: None, + config: None, + }; + + let resolved = apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro")); + + assert!(resolved.web.expect("web settings should exist").enabled); + } + + #[test] + fn apply_runtime_settings_disables_web_from_cli_flag() { + let base = Settings::default(); + let args = ServeArgs { + bind: None, + model: None, + provider: None, + dry_run: false, + sandbox: None, + web: false, + no_web: true, + max_concurrent_runs: None, + config: None, + }; + + let resolved = apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro")); + + assert!(!resolved.web.expect("web settings should exist").enabled); + } + #[test] fn server_title_formats_boot_listening_and_stopping() { let bind = Bind::Tcp("127.0.0.1:3000".parse().unwrap()); diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 2775ea4dd..1cc70d4ab 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -785,20 +785,46 @@ fn start_optional_slack_service(state: &Arc) { /// Build the axum Router with all run endpoints and embedded static assets. pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { + build_router_with_options(state, auth_mode, RouterOptions::default()) +} + +#[derive(Clone, Copy, Debug)] +pub struct RouterOptions { + pub web_enabled: bool, +} + +impl Default for RouterOptions { + fn default() -> Self { + Self { web_enabled: true } + } +} + +/// Build the axum Router with configurable web surface routing. +pub fn build_router_with_options( + state: Arc, + auth_mode: AuthMode, + options: RouterOptions, +) -> Router { start_optional_slack_service(&state); let middleware_state = Arc::clone(&state); - let api_common = Router::new() - .route("/openapi.json", get(openapi_spec)) - .merge(web_auth::api_routes()); + let api_common = if options.web_enabled { + Router::new() + .route("/openapi.json", get(openapi_spec)) + .merge(web_auth::api_routes()) + } else { + Router::new().route("/openapi.json", get(openapi_spec)) + }; let demo_router = Router::new() .nest("/api/v1", api_common.clone().merge(demo_routes())) .layer(axum::Extension(AuthMode::Disabled)) .with_state(state.clone()); - let real_router = Router::new() - .nest("/api/v1", api_common.merge(real_routes())) - .nest("/auth", web_auth::routes()) + let mut real_router = Router::new().nest("/api/v1", api_common.merge(real_routes())); + if options.web_enabled { + real_router = real_router.nest("/auth", web_auth::routes()); + } + let real_router = real_router .layer(axum::Extension(auth_mode)) .with_state(state); @@ -806,7 +832,7 @@ pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { let demo = demo_router.clone(); let real = real_router.clone(); async move { - if req.headers().get("x-fabro-demo").is_some_and(|v| v == "1") { + if options.web_enabled && req.headers().get("x-fabro-demo").is_some_and(|v| v == "1") { demo.oneshot(req).await } else { real.oneshot(req).await @@ -837,19 +863,27 @@ pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { }, ); - Router::new() - .route("/health", get(health)) - .layer(middleware::from_fn_with_state( + let mut router = Router::new().route("/health", get(health)); + if options.web_enabled { + router = router.layer(middleware::from_fn_with_state( middleware_state, cookie_and_demo_middleware, - )) + )); + } + + router .fallback_service(service_fn(move |req: axum_extract::Request| { let dispatch = dispatch.clone(); async move { let path = req.uri().path().to_string(); - if path.starts_with("/api/v1/") || path.starts_with("/auth/") || path == "/health" { + let dispatch_path = path.starts_with("/api/v1/") + || path == "/health" + || (options.web_enabled && path.starts_with("/auth/")); + if dispatch_path { dispatch.oneshot(req).await - } else if matches!(req.method(), &Method::GET | &Method::HEAD) { + } else if options.web_enabled + && matches!(req.method(), &Method::GET | &Method::HEAD) + { Ok::<_, std::convert::Infallible>(static_files::serve(&path)) } else { Ok::<_, std::convert::Infallible>(StatusCode::NOT_FOUND.into_response()) @@ -6156,6 +6190,7 @@ mod tests { async fn auth_login_github_redirects_to_github() { let mut settings = Settings::default(); settings.web = Some(WebSettings { + enabled: true, url: "http://localhost:3000".to_string(), auth: AuthSettings { provider: AuthProvider::Github, diff --git a/lib/crates/fabro-server/tests/it/api/routing.rs b/lib/crates/fabro-server/tests/it/api/routing.rs index 83d949196..93971cca2 100644 --- a/lib/crates/fabro-server/tests/it/api/routing.rs +++ b/lib/crates/fabro-server/tests/it/api/routing.rs @@ -1,7 +1,11 @@ use axum::body::{Body, to_bytes}; use axum::http::{Method, Request, StatusCode}; use fabro_server::jwt_auth::AuthMode; -use fabro_server::server::{build_router, create_app_state}; +use fabro_server::server::{ + RouterOptions, build_router, build_router_with_options, create_app_state, + create_app_state_with_options, +}; +use fabro_types::Settings; use std::path::PathBuf; use tower::ServiceExt; @@ -82,6 +86,122 @@ async fn source_maps_are_not_served() { assert_eq!(response.status(), StatusCode::NOT_FOUND); } +#[tokio::test] +async fn web_enabled_serves_web_only_routes() { + let app = build_router(create_app_state(), AuthMode::Disabled); + + let auth_me_request = Request::builder() + .method("GET") + .uri("/api/v1/auth/me") + .body(Body::empty()) + .unwrap(); + let auth_me_response = app.clone().oneshot(auth_me_request).await.unwrap(); + assert_eq!(auth_me_response.status(), StatusCode::UNAUTHORIZED); + + let setup_status_request = Request::builder() + .method("GET") + .uri("/api/v1/setup/status") + .body(Body::empty()) + .unwrap(); + let setup_status_response = app.clone().oneshot(setup_status_request).await.unwrap(); + assert_eq!(setup_status_response.status(), StatusCode::OK); + + let demo_toggle_request = Request::builder() + .method("POST") + .uri("/api/v1/demo/toggle") + .header("content-type", "application/json") + .body(Body::from(r#"{"enabled":true}"#)) + .unwrap(); + let demo_toggle_response = app.oneshot(demo_toggle_request).await.unwrap(); + assert_eq!(demo_toggle_response.status(), StatusCode::OK); + assert!( + demo_toggle_response.headers().contains_key("set-cookie"), + "demo toggle should set a cookie" + ); +} + +#[tokio::test] +async fn web_disabled_returns_404_for_web_routes_and_keeps_machine_api() { + let settings: Settings = toml::from_str( + r#" +[web] +enabled = false +"#, + ) + .expect("settings fixture should parse"); + let app = build_router_with_options( + create_app_state_with_options(settings, 5), + AuthMode::Disabled, + RouterOptions { web_enabled: false }, + ); + + for (method, path, body) in [ + ("GET", "/", Body::empty()), + ("GET", "/runs/abc", Body::empty()), + ("GET", "/auth/login/github", Body::empty()), + ("GET", "/api/v1/auth/me", Body::empty()), + ("GET", "/api/v1/setup/status", Body::empty()), + ( + "POST", + "/api/v1/demo/toggle", + Body::from(r#"{"enabled":true}"#), + ), + ] { + let request = Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .body(body) + .unwrap(); + + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{method} {path}"); + } + + let settings_request = Request::builder() + .method("GET") + .uri("/api/v1/settings") + .body(Body::empty()) + .unwrap(); + let settings_response = app.clone().oneshot(settings_request).await.unwrap(); + assert_eq!(settings_response.status(), StatusCode::OK); + + let health_request = Request::builder() + .method("GET") + .uri("/health") + .body(Body::empty()) + .unwrap(); + let health_response = app.oneshot(health_request).await.unwrap(); + assert_eq!(health_response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn web_disabled_ignores_demo_header_dispatch() { + let settings: Settings = toml::from_str( + r#" +[web] +enabled = false +"#, + ) + .expect("settings fixture should parse"); + let app = build_router_with_options( + create_app_state_with_options(settings, 5), + AuthMode::Disabled, + RouterOptions { web_enabled: false }, + ); + let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; + + let request = Request::builder() + .method("GET") + .uri(format!("/api/v1/runs/{run_id}")) + .header("X-Fabro-Demo", "1") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + fn find_dist_source_map() -> String { let dist_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../apps/fabro-web/dist"); let mut entries = std::fs::read_dir(&dist_dir) diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs index 1afe1241b..33fc5e158 100644 --- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs +++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs @@ -262,6 +262,7 @@ fn fully_populated_server_config() -> Settings { storage_dir: Some("/data".into()), max_concurrent_runs: Some(10), web: Some(WebSettings { + enabled: true, url: "https://example.com".into(), auth: AuthSettings { provider: AuthProvider::Github, diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index 83e943b87..b1f006932 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -97,12 +97,18 @@ pub struct GitSettings { #[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] pub struct WebSettings { + #[serde(default = "default_web_enabled")] + pub enabled: bool, #[serde(default = "default_web_url")] pub url: String, #[serde(default)] pub auth: AuthSettings, } +fn default_web_enabled() -> bool { + true +} + fn default_web_url() -> String { "http://localhost:3000".to_string() } @@ -110,6 +116,7 @@ fn default_web_url() -> String { impl Default for WebSettings { fn default() -> Self { Self { + enabled: default_web_enabled(), url: default_web_url(), auth: AuthSettings::default(), }