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
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
40 changes: 29 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 @@ -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__":
Expand Down
49 changes: 49 additions & 0 deletions tests/test_webui_build_gradio.py
Original file line number Diff line number Diff line change
@@ -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])
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"