feat(webui): add guided setup flows

* feat(channels): add guided setup flows

* test(channels): preserve setup config values

* fix(channels): reflect saved setup state

* refactor(channels): simplify setup state metadata

* fix(channels): harden setup lifecycle

* refactor(channels): centralize setup contracts

* fix(channels): route setup actions through webui shim

* fix(channels): adapt settings for compact screens

* fix(models): preserve default preset display

* feat(models): add curated Codex catalog

* fix(webui): stop attached gateway on interrupt

* fix(webui): simplify apps catalog

* docs(webui): clarify apps and runtime features

* feat(settings): add guided capability setup

* fix(webui): harden setup and managed services

* test: keep managed runtime checks portable

* test: scope POSIX runtime coverage

* fix(webui): simplify file settings

* feat(files): bundle document reading

* fix(webui): harden setup request boundaries

* fix(webui): prevent channel setup status squeeze

* fix(settings): group provider compatibility aliases

* refactor(settings): remove redundant setup surfaces

* fix(webui): harden guided setup lifecycle

* fix(webui): preserve channel setup compatibility
This commit is contained in:
Xubin Ren
2026-07-13 13:11:46 +08:00
committed by GitHub
parent 791c7fd505
commit fe0717b385
92 changed files with 15058 additions and 1311 deletions
+180 -1
View File
@@ -7,6 +7,7 @@ from contextlib import suppress
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import parse_qs, urlparse
import pytest
from typer.testing import CliRunner
@@ -1607,6 +1608,12 @@ def _patch_webui_provider_ready(monkeypatch) -> None:
monkeypatch.setattr("nanobot.providers.factory.build_provider_snapshot", _snapshot)
def _patch_gateway_ports_free(monkeypatch) -> None:
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_a, **_kw: False)
monkeypatch.setattr("nanobot.cli.commands._tcp_endpoint_reachable", lambda *_a, **_kw: False)
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_a, **_kw: False)
def _patch_cli_command_runtime(
monkeypatch,
config: Config,
@@ -1643,6 +1650,7 @@ def _patch_cli_command_runtime(
"nanobot.providers.factory.load_provider_snapshot",
lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config),
)
_patch_gateway_ports_free(monkeypatch)
if message_bus is not None:
monkeypatch.setattr("nanobot.bus.queue.MessageBus", message_bus)
@@ -1701,11 +1709,17 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
assert len(websocket["tokenIssueSecret"]) >= 32
assert data["agents"]["defaults"]["workspace"] == str(workspace)
assert seen["templates"] == workspace
assert seen["gateway_kwargs"] == {"port": 18888, "open_browser_url": None}
assert seen["gateway_kwargs"] == {
"port": 18888,
"open_browser_url": None,
"webui_bundle_mode": "auto",
}
compact_output = re.sub(r"\s+", " ", _strip_ansi(result.stdout))
assert "bootstrap secret was generated" in compact_output
assert "channels.websocket.tokenIssueSecret" in compact_output
assert "rerun without --no-open" in compact_output
assert "nanobot is running in this terminal" in compact_output
assert "Press Ctrl+C here to stop nanobot" in compact_output
def test_webui_yes_refuses_missing_provider_setup(monkeypatch, tmp_path: Path) -> None:
@@ -1732,6 +1746,10 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
"nanobot.cli.commands._prepare_webui_bundle_for_gateway",
lambda *_args, **_kwargs: None,
)
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
@@ -1785,6 +1803,21 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=")
assert "bootstrapSecret=<redacted>" in compact_output
assert "bootstrapSecret=" in opened_url
assert "Closing the browser does not stop channels or automations" in compact_output
assert "nanobot gateway stop --config" in compact_output
def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> None:
opened: list[str] = []
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret"
monkeypatch.setattr("webbrowser.open", lambda value: opened.append(value))
cli_commands._open_webui_browser(url, wait=False)
assert opened == [url]
output = _strip_ansi(capsys.readouterr().out)
assert "bootstrapSecret=<redacted>" in output
assert "super-secret" not in output
def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
@@ -1799,6 +1832,10 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
"nanobot.cli.commands._prepare_webui_bundle_for_gateway",
lambda *_args, **_kwargs: None,
)
def _status(options: GatewayStartOptions) -> GatewayStatus:
return GatewayStatus(
@@ -1861,6 +1898,125 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=")
def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text("{}")
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args, **_kwargs: True)
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
monkeypatch.setattr(
"nanobot.cli.commands._open_webui_browser",
lambda url, **kwargs: seen.update({"opened_url": url, "open_kwargs": kwargs}),
)
monkeypatch.setattr(
"nanobot.cli.commands._run_gateway",
lambda *_args, **_kwargs: pytest.fail("existing gateway should be reused"),
)
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
seen["runtime_kwargs"] = kwargs
def status(self):
return SimpleNamespace(running=True)
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
"nanobot.cli.commands._attach_to_background_gateway",
lambda runtime: seen.__setitem__("attached_runtime", runtime),
)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
assert result.exit_code == 0
assert "Gateway is already running; attaching to the existing WebUI" in result.stdout
assert isinstance(seen["attached_runtime"], _FakeRuntime)
opened_url = seen["opened_url"]
assert isinstance(opened_url, str)
parsed = urlparse(opened_url)
assert f"{parsed.scheme}://{parsed.netloc}" == "http://127.0.0.1:8765"
fragment = parsed.fragment.removeprefix("/?")
assert parse_qs(fragment).get("bootstrapSecret")
assert seen["open_kwargs"] == {"wait": False}
def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> None:
stopped = False
class _FakeRuntime:
def status(self):
return SimpleNamespace(running=True)
def stop(self):
nonlocal stopped
stopped = True
return SimpleNamespace(ok=True, message="gateway_stopped")
def _interrupt(_seconds: float) -> None:
raise KeyboardInterrupt
monkeypatch.setattr("nanobot.cli.commands.time.sleep", _interrupt)
cli_commands._attach_to_background_gateway(_FakeRuntime())
assert stopped is True
output = capsys.readouterr().out
assert "Closing the browser does not stop channels or automations" in output
assert "Press Ctrl+C here to stop nanobot" in output
assert "Gateway stopped" in output
def test_webui_foreground_does_not_claim_unmanaged_gateway(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text("{}")
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args: True)
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args: True)
monkeypatch.setattr("nanobot.cli.commands._open_webui_browser", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"nanobot.cli.commands._attach_to_background_gateway",
lambda _runtime: pytest.fail("unmanaged gateway must not be attached"),
)
class _FakeRuntime:
def __init__(self, **_kwargs) -> None:
pass
def status(self):
return SimpleNamespace(running=False)
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
assert result.exit_code == 0
assert "controlled by another foreground command" in result.stdout
def test_webui_foreground_refuses_occupied_webui_port(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text("{}")
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args, **_kwargs: False)
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
monkeypatch.setattr("nanobot.cli.commands._tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
monkeypatch.setattr(
"nanobot.cli.commands._run_gateway",
lambda *_args, **_kwargs: pytest.fail("gateway should not start on occupied ports"),
)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
assert result.exit_code == 1
assert "nanobot cannot start because one of its local ports is already in use" in result.stdout
assert "--port" in result.stdout
assert "--gateway-port" in result.stdout
def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None:
pytest.importorskip("aiohttp")
@@ -1998,6 +2154,7 @@ def test_gateway_unbound_agent_cron_is_skipped(
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
_patch_gateway_ports_free(monkeypatch)
monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(provider, _config),
@@ -2124,6 +2281,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
_patch_gateway_ports_free(monkeypatch)
monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(provider, _config),
@@ -3062,6 +3220,27 @@ def test_serve_rejects_wildcard_host_without_api_key(monkeypatch, tmp_path: Path
assert "api_app" not in seen
def test_serve_rejects_specific_network_interface_without_api_key(
monkeypatch,
tmp_path: Path,
) -> None:
config_file = _write_instance_config(tmp_path)
config = Config()
seen: dict[str, object] = {}
_patch_serve_runtime(monkeypatch, config, seen)
result = runner.invoke(
app,
["serve", "--config", str(config_file), "--host", "192.168.1.10"],
)
assert result.exit_code == 1
assert "api_key" in result.stdout
assert "prevent unauthenticated access" in result.stdout
assert "api_app" not in seen
def test_channels_login_requires_channel_name() -> None:
result = runner.invoke(app, ["channels", "login"])
+26 -13
View File
@@ -88,13 +88,22 @@ def _test_app(tmp_path: Path, config: Config | None = None):
app = typer.Typer()
fake_runtime = FakeRuntime(tmp_path)
fake_service = FakeServiceInstaller(tmp_path)
run_calls: list[tuple[Config, int | None]] = []
run_calls: list[tuple[Config, int | None, str | None]] = []
prepare_calls: list[tuple[Config, str]] = []
def load_runtime_config(_config_path: str | None, _workspace: str | None) -> Config:
return config or Config()
def run_gateway(config: Config, *, port: int | None = None) -> None:
run_calls.append((config, port))
def run_gateway(
config: Config,
*,
port: int | None = None,
webui_bundle_mode: str | None = None,
) -> None:
run_calls.append((config, port, webui_bundle_mode))
def prepare_webui_bundle(config: Config, mode: str) -> None:
prepare_calls.append((config, mode))
app.add_typer(
create_gateway_app(
@@ -104,36 +113,39 @@ def _test_app(tmp_path: Path, config: Config | None = None):
run_gateway=run_gateway,
runtime_factory=lambda **_kwargs: fake_runtime,
service_factory=lambda: fake_service,
prepare_webui_bundle=prepare_webui_bundle,
),
name="gateway",
)
return app, fake_runtime, fake_service, run_calls
return app, fake_runtime, fake_service, run_calls, prepare_calls
def test_gateway_default_still_runs_foreground(tmp_path):
app, _runtime, _service, calls = _test_app(tmp_path)
app, _runtime, _service, calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(app, ["gateway", "--port", "18791"])
assert result.exit_code == 0
assert len(calls) == 1
assert calls[0][1] == 18791
assert calls[0][2] == "warn"
def test_gateway_background_starts_detached_runtime(tmp_path):
config = Config()
config.gateway.port = 18792
app, fake_runtime, _service, _calls = _test_app(tmp_path, config=config)
app, fake_runtime, _service, _calls, prepare_calls = _test_app(tmp_path, config=config)
result = runner.invoke(app, ["gateway", "--background"])
assert result.exit_code == 0
assert "Gateway started in the background" in result.stdout
assert fake_runtime.started_options == GatewayStartOptions(port=18792)
assert prepare_calls == [(config, "warn")]
def test_gateway_rejects_conflicting_modes(tmp_path):
app, _runtime, _service, _calls = _test_app(tmp_path)
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(app, ["gateway", "--foreground", "--background"])
@@ -142,7 +154,7 @@ def test_gateway_rejects_conflicting_modes(tmp_path):
def test_gateway_status_uses_runtime(tmp_path):
app, _runtime, _service, _calls = _test_app(tmp_path)
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(app, ["gateway", "status"])
@@ -152,7 +164,7 @@ def test_gateway_status_uses_runtime(tmp_path):
def test_gateway_logs_can_read_without_following(tmp_path):
app, _runtime, _service, _calls = _test_app(tmp_path)
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(app, ["gateway", "logs", "--tail", "12", "--no-follow"])
@@ -161,7 +173,7 @@ def test_gateway_logs_can_read_without_following(tmp_path):
def test_gateway_stop_treats_not_running_as_clean(tmp_path):
app, fake_runtime, _service, _calls = _test_app(tmp_path)
app, fake_runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
def fake_stop(*, timeout_s: int) -> RuntimeResult:
fake_runtime.stop_timeout = timeout_s
@@ -179,7 +191,7 @@ def test_gateway_stop_treats_not_running_as_clean(tmp_path):
def test_gateway_restart_starts_background_runtime(tmp_path):
config = Config()
config.gateway.port = 18793
app, fake_runtime, _service, _calls = _test_app(tmp_path, config=config)
app, fake_runtime, _service, _calls, prepare_calls = _test_app(tmp_path, config=config)
result = runner.invoke(app, ["gateway", "restart", "--timeout", "9", "--verbose"])
@@ -187,12 +199,13 @@ def test_gateway_restart_starts_background_runtime(tmp_path):
assert "Gateway restarted in the background" in result.stdout
assert fake_runtime.stop_timeout == 9
assert fake_runtime.restarted_options == GatewayStartOptions(port=18793, verbose=True)
assert prepare_calls == [(config, "warn")]
def test_gateway_install_service_uses_service_installer(tmp_path):
config = Config()
config.gateway.port = 18794
app, _runtime, service, _calls = _test_app(tmp_path, config=config)
app, _runtime, service, _calls, _prepare_calls = _test_app(tmp_path, config=config)
result = runner.invoke(app, ["gateway", "install-service", "--dry-run", "--manager", "systemd"])
@@ -205,7 +218,7 @@ def test_gateway_install_service_uses_service_installer(tmp_path):
def test_gateway_uninstall_service_uses_service_installer(tmp_path):
app, _runtime, service, _calls = _test_app(tmp_path)
app, _runtime, service, _calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(
app,