diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..1b4ce02b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: pip install -e ".[dev]" + - name: Pytest + run: python -m pytest -q + env: + # Live backend/transcript tests are opt-in (gated on == "1"); keep + # them off in CI so the run stays hermetic and fast. + SKILLOPT_TEST_REAL_OPENCODE: "0" + SKILLOPT_TEST_REAL_OPENCODE_SOURCE: "0" + + webui: + # The Gradio theme-compatibility path only exists because gradio changed + # where `theme` lives across majors: <6 puts it on Blocks, >=6 on launch(). + # The main `test` job installs only `.[dev]`, so those tests importorskip + # and never exercise this code — run them here across both supported majors. + # 5.50.0 is the DECLARED minimum in the `webui` extra (gradio>=5.50.0,<7), + # so the matrix tests the actual floor, not just a mid-version. + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + gradio-version: ["5.50.0", "6.26.0"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install (core + pinned gradio) + run: pip install -e ".[dev]" "gradio==${{ matrix.gradio-version }}" + - name: Pytest (gradio webui) + run: python -m pytest tests/test_webui_build_gradio.py tests/test_webui_env_preflight.py -q + + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install + run: pip install -e ".[docs]" + - name: Build docs (strict) + run: mkdocs build --strict diff --git a/pyproject.toml b/pyproject.toml index 45544eb3..5d50b8fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,14 @@ searchqa = ["datasets>=2.18.0"] # Documentation site docs = ["mkdocs-material>=9.5.0", "mkdocstrings[python]>=0.24.0"] # WebUI dashboard -webui = ["gradio>=4.0.0"] +# Broad gradio support is tied to a single huggingface_hub bound: gradio 4/5 +# need huggingface-hub<1 (gradio 4.44 imports HfFolder), but gradio 6 needs +# huggingface-hub>=1.16. A single bound cannot satisfy both, so narrow the range +# to the two majors this project actually validates (5 exercises the Blocks +# theme path, 6 the launch() theme path); the CI matrix tests both. The floor +# is 5.50.0: earlier gradio 5.x (5.0-5.49) still import HfFolder or don't apply +# the Blocks theme the same way, so they are not valid supported versions. +webui = ["gradio>=5.50.0,<7"] # Development tools dev = ["ruff>=0.4.0", "pytest>=8.0.0"] # All optional dependencies (except docs/dev/webui) diff --git a/skillopt_webui/app.py b/skillopt_webui/app.py index e4978c5f..75e0ef1d 100644 --- a/skillopt_webui/app.py +++ b/skillopt_webui/app.py @@ -24,6 +24,11 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent +# Gradio moved where `theme` lives across versions: <=5 uses `Blocks(theme=...)`, +# >=6 moved it to `launch()`. Detect the installed major so the WebUI works on +# any supported version without an ignored-argument warning or a TypeError. +_GRADIO_MAJOR = int((getattr(gr, "__version__", "4.0").split(".")[0]) or 4) + # ─── Config helpers ────────────────────────────────────────────────────────── @@ -468,9 +473,10 @@ def render_pipeline_html(active_stage: str = "") -> str: def build_ui(): configs = discover_configs() - with gr.Blocks( - title="SkillOpt WebUI", - ) as app: + _blocks_kwargs = {"title": "SkillOpt WebUI"} + if _GRADIO_MAJOR < 6: + _blocks_kwargs["theme"] = gr.themes.Soft(primary_hue="indigo") + with gr.Blocks(**_blocks_kwargs) as app: gr.Markdown("# 🧠 SkillOpt Training Dashboard") gr.Markdown("*SKILLOPT: Executive Strategy for Self-Evolving Agent Skills — Configure, launch, and monitor training.*") @@ -598,6 +604,8 @@ def on_refresh(): def scan_outputs(out_dir): rows = [] + if not out_dir: + return rows base = PROJECT_ROOT / out_dir if not base.exists(): return rows @@ -639,21 +647,41 @@ def scan_outputs(out_dir): return app +def build_launch_kwargs(server_name: str, server_port: int, share: bool) -> dict: + """Build the launch kwargs exactly as production ``main()`` applies them. + + Gradio 6 moved the theme to ``launch()``; applying it here (rather than in + ``build_ui``) keeps the theme on the right object for the installed major + without an ignored-argument warning. Tests call this so they exercise the + same path a real ``skillopt-webui`` run does instead of injecting their own. + """ + kwargs = dict(server_name=server_name, server_port=server_port, share=share) + if _GRADIO_MAJOR >= 6: + kwargs["theme"] = gr.themes.Soft(primary_hue="indigo") + return kwargs + + def main(): parser = argparse.ArgumentParser(description="SkillOpt WebUI") parser.add_argument("--port", type=int, default=7860) parser.add_argument("--share", action="store_true") - parser.add_argument("--host", type=str, default="0.0.0.0", - help="Server host. Use 0.0.0.0 for public access.") + parser.add_argument("--host", type=str, default="127.0.0.1", + help="Server host. Default is localhost; use 0.0.0.0 " + "to expose publicly (no auth, use with care).") args = parser.parse_args() + if args.host and args.host not in ("127.0.0.1", "localhost", "::1"): + print( + f"⚠ warning: binding SkillOpt WebUI on {args.host} with no auth " + "exposes the Output Explorer (reads any path you type) and the " + "training controls to reachable clients. Prefer --host 127.0.0.1; " + "use 0.0.0.0 only if you understand the risk.", + file=sys.stderr, + ) + app = build_ui() - app.launch( - server_name=args.host, - server_port=args.port, - share=args.share, - theme=gr.themes.Soft(primary_hue="indigo"), - ) + launch_kwargs = build_launch_kwargs(args.host, args.port, args.share) + app.launch(**launch_kwargs) if __name__ == "__main__": diff --git a/tests/test_webui_build_gradio.py b/tests/test_webui_build_gradio.py new file mode 100644 index 00000000..299691a4 --- /dev/null +++ b/tests/test_webui_build_gradio.py @@ -0,0 +1,76 @@ +"""Real Gradio build/launch smoke test (requires the `webui` extra). + +Verifies the WebUI builds (and launches where the environment allows) on the +installed Gradio without a TypeError or ignored-argument warning, and that the +theme is placed on the right object for the installed major version — as a +specific ``Soft`` schema, not merely "some non-None theme". Gradio 6 assigns a +default theme when none is passed, so ``theme is not None`` would pass even if +``Soft`` was never applied; these tests assert the exact theme. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("gradio") + +import gradio as gr # noqa: E402 + +import skillopt_webui.app as app # noqa: E402 + + +def _assert_soft_theme(theme) -> None: + assert isinstance(theme, gr.themes.Soft), f"theme was not Soft: {theme!r}" + + +def test_webui_builds_theme_on_blocks(): + """build_ui() must not raise; for Gradio <6 the theme lives on Blocks.""" + ui = app.build_ui() + assert ui is not None + if app._GRADIO_MAJOR < 6: + # Gradio 5 and below place the theme on Blocks. Assert it is the schema + # we chose (Soft), not merely any non-None default. + _assert_soft_theme(ui.theme) + else: + # Gradio 6 moved the theme to launch(); build_ui must NOT bake it into + # the Blocks kwargs (that would be an ignored argument). + assert ui.theme is None, "theme must live on launch kwargs for Gradio >=6" + + +def test_launch_kwargs_follow_production_main_path(): + # The production path applies Soft; the test must not inject its own theme. + kwargs = app.build_launch_kwargs( + server_name="127.0.0.1", server_port=7860, share=False + ) + if app._GRADIO_MAJOR >= 6: + _assert_soft_theme(kwargs.get("theme")) + assert kwargs["theme"].name == "soft" + else: + assert "theme" not in kwargs, "theme must live on Blocks for Gradio <6" + + +def test_webui_builds_and_launches_theme(): + ui = app.build_ui() + launch_kwargs = app.build_launch_kwargs( + server_name="127.0.0.1", server_port=7860, share=False + ) + launch_kwargs["prevent_thread_lock"] = True + try: + ui.launch(**launch_kwargs) + except ValueError as exc: + # Headless/sandboxed environments may not expose localhost; that is an + # environment limitation, not a theme-compatibility bug. + if "localhost is not accessible" in str(exc): + pytest.skip("headless environment blocks localhost launch") + raise + try: + # Assert Soft specifically: gradio >=6 would hand back a default theme + # if Soft were not actually applied via the production kwargs. + _assert_soft_theme(ui.theme) + finally: + ui.close() + + +def test_gradio_major_detected(): + # The constant must reflect the installed Gradio major. + assert app._GRADIO_MAJOR == int(gr.__version__.split(".")[0]) diff --git a/tests/test_webui_security.py b/tests/test_webui_security.py new file mode 100644 index 00000000..5886da65 --- /dev/null +++ b/tests/test_webui_security.py @@ -0,0 +1,57 @@ +"""Tests for the SkillOpt WebUI security posture (bind default + public warning). + +The WebUI is gradio-coupled, so we inject a minimal fake ``gradio`` module and +mock ``build_ui``/``launch`` to exercise ``main()``'s argparse + host-check +logic without the heavy ``webui`` extra. +""" + +from __future__ import annotations + +import sys +import types +import unittest.mock as mock + +import pytest + + +@pytest.fixture +def webui(monkeypatch): + fake_gradio = types.ModuleType("gradio") + fake_gradio.themes = types.SimpleNamespace(Soft=lambda **kw: mock.MagicMock()) + monkeypatch.setitem(sys.modules, "gradio", fake_gradio) + import skillopt_webui.app as app + + return app + + +def test_main_defaults_host_to_localhost(webui, monkeypatch): + """The server must not be publicly bound by default.""" + webui_mod = webui + launcher = mock.MagicMock() + app_mock = mock.MagicMock() + app_mock.launch = launcher + monkeypatch.setattr(webui_mod, "build_ui", lambda: app_mock) + monkeypatch.setattr(sys, "argv", ["app.py"]) + + webui_mod.main() + + launcher.assert_called_once() + _args, kwargs = launcher.call_args + assert kwargs["server_name"] == "127.0.0.1" + + +def test_main_warns_on_public_host(webui, monkeypatch, capsys): + """An explicit public bind must emit an unauthenticated-exposure warning.""" + webui_mod = webui + launcher = mock.MagicMock() + app_mock = mock.MagicMock() + app_mock.launch = launcher + monkeypatch.setattr(webui_mod, "build_ui", lambda: app_mock) + monkeypatch.setattr(sys, "argv", ["app.py", "--host", "0.0.0.0"]) + + webui_mod.main() + + captured = capsys.readouterr() + assert "warning" in captured.err.lower() + _args, kwargs = launcher.call_args + assert kwargs["server_name"] == "0.0.0.0"