diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c8e39f0c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +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" + + 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/skillopt_webui/app.py b/skillopt_webui/app.py index e4978c5f..88331089 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 @@ -643,17 +651,27 @@ 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 = dict(server_name=args.host, server_port=args.port, share=args.share) + if _GRADIO_MAJOR >= 6: + # Gradio 6 moved the theme to launch(); applying it here avoids an + # ignored-argument warning. + launch_kwargs["theme"] = gr.themes.Soft(primary_hue="indigo") + 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..b1e9bc5d --- /dev/null +++ b/tests/test_webui_build_gradio.py @@ -0,0 +1,49 @@ +"""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. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("gradio") + +import gradio as gr # noqa: E402 + +import skillopt_webui.app as app # noqa: E402 + + +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: + assert ui.theme is not None, "theme was not set on Blocks for Gradio <6" + + +def test_webui_builds_and_launches_theme(): + ui = app.build_ui() + launch_kwargs = {"prevent_thread_lock": True} + if app._GRADIO_MAJOR >= 6: + # Gradio 6 applies theme at launch(); we add it here and assert applied. + launch_kwargs["theme"] = gr.themes.Soft(primary_hue="indigo") + 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 ui.theme is not None, "theme was not applied on the launched app" + 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"