diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index fb7c36ec..678e16e7 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -951,19 +951,20 @@ def _webui_display_url(url: str) -> str: return f"{prefix}{marker}" -def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool) -> bool: +def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool) -> tuple[bool, bool]: """Enable the local WebUI channel with safe localhost defaults.""" from nanobot.channels.websocket import WebSocketConfig current = getattr(config.channels, "websocket", None) or {} model = WebSocketConfig.model_validate(current) changed = False + generated_secret = False needs_enable = not model.enabled needs_port = port is not None and model.port != port needs_secret = not model.token_issue_secret.strip() and not model.token.strip() if not needs_enable and not needs_port and not needs_secret: - return False + return False, False target_port = port if port is not None else model.port console.print() @@ -993,9 +994,10 @@ def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool) model.token_issue_secret = secrets.token_urlsafe(32) changed = True + generated_secret = True setattr(config.channels, "websocket", model.model_dump(by_alias=True, exclude_none=True)) - return changed + return changed, generated_secret def _warn_webui_bind_scope(config: Config) -> None: @@ -1256,7 +1258,11 @@ def webui( setup_config.agents.defaults.workspace = workspace try: - changed_webui = _ensure_local_webui_channel(setup_config, port=port, yes=yes) + changed_webui, generated_bootstrap_secret = _ensure_local_webui_channel( + setup_config, + port=port, + yes=yes, + ) _warn_webui_bind_scope(setup_config) webui_url = _webui_browser_url(setup_config) except ValueError as exc: @@ -1279,6 +1285,14 @@ def webui( console.print(f"Gateway health: [cyan]http://{runtime_config.gateway.host}:{effective_gateway_port}/health[/cyan]") if no_open: console.print("[dim]Browser opening disabled by --no-open.[/dim]") + if generated_bootstrap_secret: + console.print( + "[yellow]A WebUI bootstrap secret was generated and saved in this config.[/yellow]" + ) + console.print( + "[dim]Open the WebUI and enter channels.websocket.tokenIssueSecret from " + f"{config_path}, or rerun without --no-open to open the authenticated URL.[/dim]" + ) if background: config_arg = str(config_path) @@ -1290,18 +1304,27 @@ def webui( config_path=config_arg, ) ) - result = runtime.start_background( - GatewayStartOptions( - port=effective_gateway_port, - workspace=workspace_arg, - config_path=config_arg, - ) + start_options = GatewayStartOptions( + port=effective_gateway_port, + workspace=workspace_arg, + config_path=config_arg, ) - if not result.ok and result.message != "gateway_already_running": - console.print(f"[yellow]Gateway was not started: {result.message}[/yellow]") + result = runtime.start_background(start_options) + restarted = False + restart_attempted = False + if not result.ok and result.message == "gateway_already_running" and changed_webui: + restart_attempted = True + console.print("[yellow]WebUI config changed; restarting the background gateway.[/yellow]") + result = runtime.restart(start_options, timeout_s=20) + restarted = result.ok + if not result.ok and (restart_attempted or result.message != "gateway_already_running"): + action = "restarted" if restart_attempted else "started" + console.print(f"[yellow]Gateway was not {action}: {result.message}[/yellow]") console.print(f"Logs: {result.status.log_path}") raise typer.Exit(1) - if result.ok: + if restarted: + console.print("[green]Gateway restarted in the background.[/green]") + elif result.ok: console.print("[green]Gateway started in the background.[/green]") else: console.print("[yellow]Gateway is already running in the background.[/yellow]") diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index e5b6a0a7..feeab51a 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -1672,6 +1672,10 @@ def test_webui_yes_creates_config_and_enables_local_websocket( assert data["agents"]["defaults"]["workspace"] == str(workspace) assert seen["templates"] == workspace assert seen["gateway_kwargs"] == {"port": 18888, "open_browser_url": None} + compact_output = _strip_ansi(result.stdout).replace("\n", " ") + assert "bootstrap secret was generated" in compact_output + assert "channels.websocket.tokenIssueSecret" in compact_output + assert "rerun without --no-open" in compact_output def test_webui_yes_refuses_missing_provider_setup(monkeypatch, tmp_path: Path) -> None: @@ -1753,6 +1757,80 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path assert "bootstrapSecret=" in opened_url +def test_webui_background_restarts_when_config_changes_and_gateway_is_running( + monkeypatch, + tmp_path: Path, +) -> None: + from nanobot.gateway import GatewayStartOptions, GatewayStatus, RuntimeResult + + config_file = tmp_path / "config.json" + workspace = tmp_path / "workspace" + config_file.write_text("{}") + seen: dict[str, object] = {} + _patch_webui_provider_ready(monkeypatch) + monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) + + def _status(options: GatewayStartOptions) -> GatewayStatus: + return GatewayStatus( + running=True, + pid=123, + state_path=tmp_path / "gateway.json", + log_path=tmp_path / "gateway.log", + port=options.port, + reason="running", + ) + + class _FakeRuntime: + def __init__(self, **kwargs) -> None: + seen["runtime_kwargs"] = kwargs + + def start_background(self, options: GatewayStartOptions) -> RuntimeResult: + seen["start_options"] = options + return RuntimeResult(False, "gateway_already_running", _status(options)) + + def restart(self, options: GatewayStartOptions, *, timeout_s: int) -> RuntimeResult: + seen["restart_options"] = options + seen["restart_timeout"] = timeout_s + return RuntimeResult(True, "gateway_started_background", _status(options)) + + monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime) + monkeypatch.setattr( + "nanobot.cli.commands._open_webui_browser", + lambda url: seen.__setitem__("opened_url", url), + ) + + result = runner.invoke( + app, + [ + "webui", + "--config", + str(config_file), + "--workspace", + str(workspace), + "--background", + "--gateway-port", + "18889", + "--yes", + ], + ) + + assert result.exit_code == 0 + compact_output = _strip_ansi(result.stdout).replace("\n", " ") + assert "WebUI config changed; restarting the background gateway" in compact_output + assert "Gateway restarted in the background" in compact_output + assert "Gateway is already running" not in compact_output + options = seen["restart_options"] + assert isinstance(options, GatewayStartOptions) + assert options is seen["start_options"] + assert seen["restart_timeout"] == 20 + assert options.port == 18889 + assert options.config_path == str(config_file.resolve(strict=False)) + assert options.workspace == str(workspace.resolve(strict=False)) + opened_url = seen["opened_url"] + assert isinstance(opened_url, str) + assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=") + + def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None: pytest.importorskip("aiohttp")