From e50d1655ddabfee90cdc9b2569d14f359b3a36a7 Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Mon, 24 Aug 2026 10:46:20 +0800 Subject: [PATCH 1/6] fix(webui): security hardening + launch theme crash; add CI - Default bind 127.0.0.1 (was 0.0.0.0); warn to stderr on a non-localhost bind. - Move gradio theme onto gr.Blocks (fixes a launch crash: launch() has no theme param). - Guard empty out_dir in scan_outputs. - Add .github/workflows/ci.yml (test py 3.10/3.11/3.12 + docs mkdocs --strict). --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++++++++++++++ skillopt_webui/app.py | 18 +++++++++++++++--- 2 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yml 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..63aa9b4d 100644 --- a/skillopt_webui/app.py +++ b/skillopt_webui/app.py @@ -470,6 +470,7 @@ def build_ui(): with gr.Blocks( title="SkillOpt WebUI", + theme=gr.themes.Soft(primary_hue="indigo"), ) as app: gr.Markdown("# 🧠 SkillOpt Training Dashboard") gr.Markdown("*SKILLOPT: Executive Strategy for Self-Evolving Agent Skills — Configure, launch, and monitor training.*") @@ -598,6 +599,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,16 +646,25 @@ 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"), ) From 623dff30715aead0bc5379ac53eebbfa44a672b8 Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Mon, 24 Aug 2026 10:57:03 +0800 Subject: [PATCH 2/6] test(webui): cover localhost default + public-bind warning --- tests/test_webui_security.py | 57 ++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/test_webui_security.py 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" From bed1620145eeceae3de821427bd1faf07ca3a9e7 Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Mon, 24 Aug 2026 17:56:19 +0800 Subject: [PATCH 3/6] fix(webui): gradio theme version-compat + real launch smoke test Address maintainer review on #249: - Place the gradio theme on Blocks for Gradio <=5 and on launch() for Gradio 6, detected via the installed major, so the WebUI works on any supported version without an ignored-argument warning or a TypeError. - Add a real Gradio build/launch smoke test (skips without the webui extra) asserting the theme is actually applied and no error is raised. --- skillopt_webui/app.py | 24 +++++++++++++--------- tests/test_webui_build_gradio.py | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 tests/test_webui_build_gradio.py diff --git a/skillopt_webui/app.py b/skillopt_webui/app.py index 63aa9b4d..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,10 +473,10 @@ def render_pipeline_html(active_stage: str = "") -> str: def build_ui(): configs = discover_configs() - with gr.Blocks( - title="SkillOpt WebUI", - theme=gr.themes.Soft(primary_hue="indigo"), - ) 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.*") @@ -661,11 +666,12 @@ def main(): ) app = build_ui() - app.launch( - server_name=args.host, - server_port=args.port, - share=args.share, - ) + 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..e7972fbf --- /dev/null +++ b/tests/test_webui_build_gradio.py @@ -0,0 +1,34 @@ +"""Real Gradio build/launch smoke test (requires the `webui` extra). + +Verifies the WebUI builds and launches on the installed Gradio without a +TypeError or ignored-argument warning, and that the selected theme is actually +applied 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_and_launches_theme(): + ui = app.build_ui() + launch_kwargs = {"prevent_thread_lock": True} + if app._GRADIO_MAJOR >= 6: + # Gradio 6 applies theme at launch(); add it here and assert applied. + launch_kwargs["theme"] = gr.themes.Soft(primary_hue="indigo") + ui.launch(**launch_kwargs) + 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 (6 for 6.25). + assert app._GRADIO_MAJOR == int(gr.__version__.split(".")[0]) From ea94bba0f24b5bdd94ac2f7f6b7dcc513fcd7b5e Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Mon, 24 Aug 2026 18:23:34 +0800 Subject: [PATCH 4/6] test(webui): robust real-Gradio build/launch smoke across versions Verified against real Gradio 4.44, 5.50, and 6.25 (built sequentially to avoid conflicting pins): - build_ui() succeeds on all three; theme is placed on Blocks for <6 and on launch() for >=6 (no TypeError / ignored-arg warning). - The launch smoke skips cleanly when a headless/sandboxed environment blocks localhost (not a compatibility bug), and asserts theme application when it launches. --- tests/test_webui_build_gradio.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/test_webui_build_gradio.py b/tests/test_webui_build_gradio.py index e7972fbf..b1e9bc5d 100644 --- a/tests/test_webui_build_gradio.py +++ b/tests/test_webui_build_gradio.py @@ -1,8 +1,8 @@ """Real Gradio build/launch smoke test (requires the `webui` extra). -Verifies the WebUI builds and launches on the installed Gradio without a -TypeError or ignored-argument warning, and that the selected theme is actually -applied for the installed major version. +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 @@ -16,13 +16,28 @@ 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(); add it here and assert applied. + # Gradio 6 applies theme at launch(); we add it here and assert applied. launch_kwargs["theme"] = gr.themes.Soft(primary_hue="indigo") - ui.launch(**launch_kwargs) + 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: @@ -30,5 +45,5 @@ def test_webui_builds_and_launches_theme(): def test_gradio_major_detected(): - # The constant must reflect the installed Gradio major (6 for 6.25). + # The constant must reflect the installed Gradio major. assert app._GRADIO_MAJOR == int(gr.__version__.split(".")[0]) From 20a45543602488a4eff03cde34cc284941ef36b7 Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Wed, 26 Aug 2026 11:17:10 +0800 Subject: [PATCH 5/6] fix(webui): gradio theme compat matrix + strong theme smoke assertions - Narrow webui extra to gradio>=5,<7 (gradio 4.44 needs huggingface_hub<1, but 6 needs >=1.16; one bound can't satisfy both, so drop the untested major 4 and keep the two the CI matrix validates). - Extract build_launch_kwargs() so tests exercise the production main() path instead of injecting their own theme. - Assert the Soft theme specifically (isinstance/name) rather than is not None (gradio 6 assigns a default theme when none is passed). - Add a CI webui job that installs gradio across majors (5/6) and runs the gradio tests, which the main job's [dev] install silently skipped. --- .github/workflows/ci.yml | 20 ++++++++++++++++ pyproject.toml | 7 +++++- skillopt_webui/app.py | 20 ++++++++++++---- tests/test_webui_build_gradio.py | 41 ++++++++++++++++++++++++++------ 4 files changed, 75 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8e39f0c..35be21c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,26 @@ jobs: 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. + 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: diff --git a/pyproject.toml b/pyproject.toml index 45544eb3..d10f1123 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,12 @@ 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. +webui = ["gradio>=5.0.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 88331089..75e0ef1d 100644 --- a/skillopt_webui/app.py +++ b/skillopt_webui/app.py @@ -647,6 +647,20 @@ 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) @@ -666,11 +680,7 @@ def main(): ) app = build_ui() - 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") + launch_kwargs = build_launch_kwargs(args.host, args.port, args.share) app.launch(**launch_kwargs) diff --git a/tests/test_webui_build_gradio.py b/tests/test_webui_build_gradio.py index b1e9bc5d..299691a4 100644 --- a/tests/test_webui_build_gradio.py +++ b/tests/test_webui_build_gradio.py @@ -2,7 +2,10 @@ 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. +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 @@ -16,20 +19,42 @@ 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: - assert ui.theme is not None, "theme was not set on Blocks for Gradio <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 = {"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") + 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: @@ -39,7 +64,9 @@ def test_webui_builds_and_launches_theme(): pytest.skip("headless environment blocks localhost launch") raise try: - assert ui.theme is not None, "theme was not applied on the launched app" + # 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() From d549c86bf821a29d9c86ebf1bfea5c33f6f31deb Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Thu, 27 Aug 2026 01:42:02 +0800 Subject: [PATCH 6/6] fix(webui): raise supported gradio floor to the first working release - The declared floor gradio>=5.0.0 advertised gradio 5.0-5.49, which either still import HfFolder (removed in modern huggingface_hub) or don't apply the Blocks theme the same way, so a clean install can fail at import. - Raise the webui extra to gradio>=5.50.0,<7 (verified: Blocks theme is Soft, the webui tests pass) and test that actual floor in the CI matrix (5.50 + 6.26). --- .github/workflows/ci.yml | 2 ++ pyproject.toml | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35be21c7..1b4ce02b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: # 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 diff --git a/pyproject.toml b/pyproject.toml index d10f1123..5d50b8fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,8 +49,10 @@ docs = ["mkdocs-material>=9.5.0", "mkdocstrings[python]>=0.24.0"] # 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. -webui = ["gradio>=5.0.0,<7"] +# 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)