diff --git a/navi/config.py b/navi/config.py index 8b51171..62c420b 100644 --- a/navi/config.py +++ b/navi/config.py @@ -107,6 +107,18 @@ # a profile_id. Empty string means "no default"; the client must supply one. navi_default_profile_id: str = "" + # Web UI switch. When false, the static mounts ("/", /assets, /images, + # /content-viewers, /content), the debug/admin panels and the navi_ui MCP + # server are not registered — pure API/WS server for terminal clients. + # The API itself is unaffected; re-enable later by editing .env only. + navi_webclient_enabled: bool = True + + # Bind address for the navi-server launcher (navi/server.py + systemd). + # 127.0.0.1 keeps the server local-only; terminals on other machines + # connect through SSH tunneling or a reverse proxy. + navi_host: str = "127.0.0.1" + navi_port: int = 8000 + # Internal navi_ui MCP server — lets the agent push structured UI components # (card_grid, form) to the webclient via the render_component tool. navi_ui_mcp_enabled: bool = True diff --git a/navi/main.py b/navi/main.py index ad17269..7a1d55d 100644 --- a/navi/main.py +++ b/navi/main.py @@ -64,8 +64,14 @@ "Refusing to start with permissive CORS in auth mode." ) + # The navi_ui MCP server's only consumer is the webclient — skip it when + # the web UI is off (deployed terminal-only mode). + ui_mcp_active = settings.navi_ui_mcp_enabled and settings.navi_webclient_enabled + if not settings.navi_webclient_enabled: + log.info("startup.webclient_disabled", message="Web UI is off — pure API/WS server (terminal clients only).") + ui_server_task: asyncio.Task | None = None - if settings.navi_ui_mcp_enabled: + if ui_mcp_active: from navi.mcp.ui_server import start_ui_server ui_server_task = asyncio.create_task( @@ -174,7 +180,7 @@ except asyncio.CancelledError: pass - if settings.navi_ui_mcp_enabled and ui_server_task is not None: + if ui_mcp_active and ui_server_task is not None: ui_server_task.cancel() try: await ui_server_task @@ -224,12 +230,6 @@ # (With auth disabled require_admin resolves to the anonymous admin user.) app.include_router(eval_router, dependencies=[Depends(require_admin)]) -app.mount("/assets", StaticFiles(directory=str(_base / "webclient" / "dist" / "assets")), name="assets") -app.mount("/images", StaticFiles(directory=str(_base / "webclient" / "dist" / "images")), name="images") -app.mount("/content-viewers", StaticFiles(directory=str(_base / "webclient" / "dist" / "content-viewers")), name="content_viewers") -app.mount("/content", StaticFiles(directory=str(_base / "navi" / "content")), name="content") - - @app.get("/api/sessions/{session_id}/files/{filename}", include_in_schema=False) async def api_session_file_redirect(session_id: str, filename: str, download: bool = False): target = f"/sessions/{session_id}/files/{filename}" @@ -238,24 +238,48 @@ return RedirectResponse(url=target, status_code=307) -@app.get("/", include_in_schema=False) -async def index() -> FileResponse: - return FileResponse(str(_base / "webclient" / "dist" / "index.html"), headers={"Cache-Control": "no-store"}) +if settings.navi_webclient_enabled: + # check_dir=False: a deployed tree may have no webclient/dist build at all + # (the UI is off), so the mount must not crash on import. Toggling the flag + # or building dist later needs a server restart — by design, config edits + # are the re-enable path, and systemd restarts the unit cleanly. + app.mount( + "/assets", + StaticFiles(directory=str(_base / "webclient" / "dist" / "assets"), check_dir=False), + name="assets", + ) + app.mount( + "/images", + StaticFiles(directory=str(_base / "webclient" / "dist" / "images"), check_dir=False), + name="images", + ) + app.mount( + "/content-viewers", + StaticFiles(directory=str(_base / "webclient" / "dist" / "content-viewers"), check_dir=False), + name="content_viewers", + ) + app.mount( + "/content", + StaticFiles(directory=str(_base / "navi" / "content"), check_dir=False), + name="content", + ) + + @app.get("/", include_in_schema=False) + async def index() -> FileResponse: + return FileResponse(str(_base / "webclient" / "dist" / "index.html"), headers={"Cache-Control": "no-store"}) + + if not settings.navi_auth_enabled: + # Debug panels are a local-development tool — not exposed in auth mode. + + @app.get("/debug", include_in_schema=False) + async def debug() -> FileResponse: + return FileResponse("debug/index.html", headers={"Cache-Control": "no-store"}) -if not settings.navi_auth_enabled: - # Debug panels are a local-development tool — not exposed in auth mode. + @app.get("/debug/eval", include_in_schema=False) + async def debug_eval() -> FileResponse: + return FileResponse("debug/eval/index.html", headers={"Cache-Control": "no-store"}) - @app.get("/debug", include_in_schema=False) - async def debug() -> FileResponse: - return FileResponse("debug/index.html", headers={"Cache-Control": "no-store"}) - - - @app.get("/debug/eval", include_in_schema=False) - async def debug_eval() -> FileResponse: - return FileResponse("debug/eval/index.html", headers={"Cache-Control": "no-store"}) - - -@app.get("/admin", include_in_schema=False) -async def admin_panel() -> FileResponse: - return FileResponse("admin/index.html", headers={"Cache-Control": "no-store"}) + @app.get("/admin", include_in_schema=False) + async def admin_panel() -> FileResponse: + return FileResponse("admin/index.html", headers={"Cache-Control": "no-store"}) diff --git a/navi/server.py b/navi/server.py new file mode 100644 index 0000000..591bc7b --- /dev/null +++ b/navi/server.py @@ -0,0 +1,25 @@ +"""`navi-server` — one-command launcher for the Navi API server. + +Reads bind address from settings (NAVI_HOST / NAVI_PORT, default 127.0.0.1:8000) +so a deployed unit needs no hardcoded values. Meant for systemd / interactive +use; for development `uvicorn navi.main:app --reload` is still the way. +""" + +from __future__ import annotations + +import uvicorn + +from navi.config import settings + + +def main() -> None: + uvicorn.run( + "navi.main:app", + host=settings.navi_host, + port=settings.navi_port, + log_level=settings.log_level.lower(), + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1c45d60..3bf18b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ [project.scripts] navi-code = "clients.terminal.cli:main" +navi-server = "navi.server:main" [project.optional-dependencies] dev = [ diff --git a/tests/unit/test_deployment_flags.py b/tests/unit/test_deployment_flags.py new file mode 100644 index 0000000..37deb40 --- /dev/null +++ b/tests/unit/test_deployment_flags.py @@ -0,0 +1,68 @@ +"""Deployment flags: NAVI_WEBCLIENT_ENABLED gating and navi-server settings. + +The webclient gating in navi/main.py runs at import time, so these tests +launch a subprocess with the flag in the environment and inspect the +registered routes — monkeypatching settings in-process would not re-gate. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys + +from navi.config import Settings + +ROUTES_SCRIPT = """ +import json +import navi.main as m +print(json.dumps(sorted({r.path for r in m.app.routes}))) +""" + + +def _app_routes(extra_env: dict[str, str]) -> list[str]: + env = {k: v for k, v in os.environ.items() if k != "NAVI_WEBCLIENT_ENABLED"} + env.update(extra_env) + out = subprocess.run( + [sys.executable, "-c", ROUTES_SCRIPT], + capture_output=True, text=True, env=env, cwd=os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + timeout=120, + ) + assert out.returncode == 0, out.stderr[-2000:] + # Import-time logging (profile loading etc.) shares stdout — the JSON + # payload is the last line printed by the script itself. + return json.loads(out.stdout.strip().splitlines()[-1]) + + +def test_settings_defaults(): + s = Settings(database_url="postgresql://x") + assert s.navi_webclient_enabled is True + assert s.navi_host == "127.0.0.1" + assert s.navi_port == 8000 + + +def test_webclient_enabled_default_registers_ui_routes(): + routes = _app_routes({"NAVI_WEBCLIENT_ENABLED": "true"}) + # Web panel, static mounts and the debug tools are all present. + assert "/" in routes + assert "/admin" in routes + assert "/health" in routes + assert "/ws/sessions/{session_id}" in routes + + +def test_webclient_disabled_drops_ui_routes_only(): + routes = _app_routes({"NAVI_WEBCLIENT_ENABLED": "false"}) + # Everything web-facing is gone … + assert "/" not in routes + assert "/admin" not in routes + assert "/debug" not in routes + assert not any(r.startswith(("/assets", "/images", "/content")) for r in routes) + # … but the API the terminal client uses is fully intact. + assert "/health" in routes + assert "/sessions" in routes + assert "/ws/sessions/{session_id}" in routes + assert "/api/sessions/{session_id}/files/{filename}" in routes + # The /admin/* JSON API (profile/memory/sessions management) stays — + # only the HTML panel is web UI. require_admin still gates it. + assert "/admin/profiles" in routes \ No newline at end of file