Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 39 additions & 11 deletions skillopt_webui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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.*")

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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__":
Expand Down
76 changes: 76 additions & 0 deletions tests/test_webui_build_gradio.py
Original file line number Diff line number Diff line change
@@ -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])
57 changes: 57 additions & 0 deletions tests/test_webui_security.py
Original file line number Diff line number Diff line change
@@ -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"