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:
+180
-1
@@ -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"])
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user