From 067e0c4a4061b4816d8b4fe75ca633d022ea8ee5 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:58:04 +0800 Subject: [PATCH] feat(cli): add safe WebUI first-run launcher (#4688) --- README.md | 37 +- docs/README.md | 6 +- docs/cli-reference.md | 29 +- docs/configuration.md | 4 +- docs/multiple-instances.md | 7 + docs/quick-start.md | 10 +- docs/start-without-technical-background.md | 16 +- docs/troubleshooting.md | 5 +- docs/webui.md | 48 +-- nanobot/cli/commands.py | 399 +++++++++++++++++++-- nanobot/cli/onboard.py | 9 + tests/cli/test_commands.py | 192 ++++++++++ 12 files changed, 661 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 08f60d7b..f57ae212 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ |---|---| | Install nanobot with no terminal/config background | [Start Without Technical Background](./docs/start-without-technical-background.md) | | Install quickly and get one CLI reply | [Install](#-install) and [Quick Start](#-quick-start) | -| Open the bundled browser UI after the CLI works | [WebUI](#-webui) | +| Open the bundled browser UI | [WebUI](#-webui) | | Connect Telegram, Discord, WeChat, Slack, Email, or another chat app | [Chat Apps](./docs/chat-apps.md) | | Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) | | Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) | @@ -237,7 +237,7 @@ Windows PowerShell: irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex ``` -The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes and you enabled the WebSocket channel, skip the manual initialize/configure steps below and go straight to **Open the WebUI**. +The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, skip the manual initialize/configure steps below and go straight to **Open the WebUI**. To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install. @@ -358,14 +358,13 @@ For another provider, the same config shape still applies: **3. Open the WebUI** -If Quick Start enabled the WebSocket channel, start the gateway: +Start the browser workbench: ```bash -nanobot gateway +nanobot webui ``` -Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard, then send your first message there. -Prefer not to keep a terminal open? Use `nanobot gateway --background`, then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`. +`nanobot webui` prepares the local WebSocket channel if needed, starts the gateway, and opens `http://127.0.0.1:8765`. It binds the first-run WebUI to `127.0.0.1` by default, so it is not exposed to your LAN. Prefer not to keep a terminal open? Use `nanobot webui --background`, then manage the gateway with `nanobot gateway status`, `logs`, `restart`, and `stop`. For manual or terminal-only setup, test one CLI message: @@ -399,33 +398,13 @@ The WebUI ships **inside the published wheel** — no extra build step. It is th nanobot webui preview

-**1. Enable the WebSocket channel in `~/.nanobot/config.json`** - -Merge this block into your existing config: - -```json -{ - "channels": { - "websocket": { - "enabled": true, - "tokenIssueSecret": "your-webui-password", - "websocketRequiresToken": true - } - } -} -``` - -**2. Start the gateway** +**Open it** ```bash -nanobot gateway +nanobot webui ``` -Use `nanobot gateway --background` for a local background process you can manage later with `nanobot gateway status`, `logs`, `restart`, and `stop`. - -**3. Open the WebUI** - -Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access). +The command enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access). The WebUI is served by the WebSocket channel on port `8765` by default. The gateway's `18790` port is for the health endpoint, not the browser UI. diff --git a/docs/README.md b/docs/README.md index e551f41b..d2cda4c3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ For published release documentation, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview). The pages in this directory track the current repository and may describe features that have not reached the published site yet. -If you have never used a terminal or edited a config file before, start with [`start-without-technical-background.md`](./start-without-technical-background.md). Otherwise, start with [`quick-start.md`](./quick-start.md) and get one local `nanobot agent -m "Hello!"` reply working before connecting chat apps, WebUI, Docker, or custom tools. +If you have never used a terminal or edited a config file before, start with [`start-without-technical-background.md`](./start-without-technical-background.md). Otherwise, start with [`quick-start.md`](./quick-start.md), open the browser workbench with `nanobot webui`, and use terminal checks when you need lower-level diagnosis. Most JSON examples in these docs are snippets to merge into `~/.nanobot/config.json`, not full replacement files. @@ -38,7 +38,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask | Next goal | Read | First check | |---|---|---| -| Use nanobot in a browser | [`webui.md`](./webui.md) | Enable WebSocket, run `nanobot gateway`, open `http://127.0.0.1:8765` | +| Use nanobot in a browser | [`webui.md`](./webui.md) | Run `nanobot webui` and open the local browser workbench | | Talk through a chat app | [`chat-apps.md`](./chat-apps.md) | Merge one channel snippet, run `nanobot channels status`, keep `nanobot gateway` running | | Change provider or add fallbacks | [`provider-cookbook.md`](./provider-cookbook.md) | Keep `modelPresets` named and set `agents.defaults.modelPreset` | | Call nanobot from Python | [`python-sdk.md`](./python-sdk.md) | Reuse the same config/workspace from code, then run or stream one agent turn | @@ -49,7 +49,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask | Goal | Read | Outcome | |---|---|---| -| Open the bundled browser UI | [`webui.md`](./webui.md) | WebUI on port `8765`, chat workspace, Apps, Skills, Automations, and settings | +| Open the bundled browser UI | [`webui.md`](./webui.md) | `nanobot webui`, chat workspace, Apps, Skills, Automations, and settings | | Connect Telegram, Discord, WeChat, Slack, and other apps | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control | | Use slash commands and automations | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, local triggers, heartbeat tasks, and chat-side controls | | Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 78a1d1b3..6d4fc1c9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -9,10 +9,11 @@ Use this page when you know what you want to run and need the command shape. For | Check the install | `nanobot --version` | If this fails, try `python -m nanobot --version` | | Create or refresh config | `nanobot onboard` | Creates `~/.nanobot/config.json` and `~/.nanobot/workspace/` | | Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON | -| Check config without calling a model | `nanobot status` | Reads the default config and summarizes the active model/provider | +| Open the browser workbench | `nanobot webui` | Prepares local WebUI settings, starts the gateway, and opens the browser | +| Check config without calling a model | `nanobot status` | Summarizes the selected config, workspace, active model, and providers | | Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work | | Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` | -| Use WebUI or chat apps | `nanobot gateway` | Keep this terminal running, or use `nanobot gateway --background` | +| Run the gateway directly | `nanobot gateway` | Service/ops command for WebUI, chat apps, cron, and heartbeat | | Deliver a local trigger | `nanobot trigger "message"` | Created first with `/trigger ` in the target chat/session | | Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` | | Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` | @@ -81,9 +82,22 @@ Default paths: Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`. +## WebUI + +| Command | Description | +|---|---| +| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, and open `http://127.0.0.1:8765` | +| `nanobot webui --background` | Start or reuse a background gateway, then open the WebUI | +| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser | +| `nanobot webui --port ` | Set the WebUI/WebSocket port | +| `nanobot webui --gateway-port ` | Override the gateway health port | +| `nanobot webui --yes` | Apply safe localhost WebUI defaults without confirmation; provider credentials still require interactive setup | + +First-run WebUI setup binds to `127.0.0.1` by default. Use manual configuration and a WebUI password before exposing the WebSocket channel beyond localhost. + ## Gateway -`nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint. By default it runs in the foreground, which keeps existing scripts and terminal workflows unchanged. Use `--background` when you want a local macOS, Linux, or Windows process that you can manage from the CLI. +`nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint. Most local browser users should start with `nanobot webui`; use `gateway` directly for service management, chat app operation, and advanced deployment. By default it runs in the foreground, which keeps existing scripts and terminal workflows unchanged. Use `--background` when you want a local macOS, Linux, or Windows process that you can manage from the CLI. | Command | Description | |---|---| @@ -196,7 +210,13 @@ See [`openai-api.md`](./openai-api.md) for request examples. nanobot status ``` -Shows the default config path, workspace path, active model, and provider summary. This command does not currently accept `--config`; use explicit `--config` and `--workspace` on `agent`, `gateway`, or `serve` when debugging a specific instance. +Shows the config path, workspace path, active model, and provider summary without calling a model. + +| Command | Description | +|---|---| +| `nanobot status` | Inspect the default instance | +| `nanobot status --config ` | Inspect a specific config | +| `nanobot status --config --workspace ` | Inspect a specific config with a workspace override | ## Channels @@ -207,6 +227,7 @@ Shows the default config path, workspace path, active model, and provider summar | `nanobot channels login ` | Run interactive login for supported channels | | `nanobot channels login --force` | Re-authenticate even if credentials already exist | | `nanobot channels login --config ` | Use a specific config file | +| `nanobot plugins list --config ` | Show plugin/channel enabled state for a specific config | Examples: diff --git a/docs/configuration.md b/docs/configuration.md index ca5c4e7e..162c1c9e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,7 +41,7 @@ If you are not sure where a setting belongs, start from the task you are trying | Make the first model reply work | `providers..apiKey`, optional `providers..apiBase`, `modelPresets.`, `agents.defaults.modelPreset` | `nanobot status`, then `nanobot agent -m "Hello!"` | [Providers](#providers), [Model Presets](#model-presets) | | Add fallback models | `modelPresets.`, `agents.defaults.fallbackModels` | `nanobot status`, then a normal agent run | [Model Fallbacks](#model-fallbacks) | | Keep secrets out of the config file | `${ENV_VAR}` placeholders inside any string value | Start nanobot from the same environment that sets the variable | [Environment Variables for Secrets](#environment-variables-for-secrets) | -| Open the bundled WebUI | Optional `channels.websocket.port`, `channels.websocket.tokenIssueSecret` | `nanobot gateway`, then open `http://127.0.0.1:8765` | [Channel Settings](#channel-settings), [WebSocket docs](./websocket.md) | +| Open the bundled WebUI | `channels.websocket.enabled`, optional `channels.websocket.port`, `channels.websocket.tokenIssueSecret` | `nanobot webui` | [Channel Settings](#channel-settings), [WebSocket docs](./websocket.md) | | Connect one chat app | `channels..enabled`, channel credentials, `channels..allowFrom` | `nanobot channels status`, then `nanobot gateway --verbose` | [Channel Settings](#channel-settings), [Chat Apps](./chat-apps.md) | | Enable voice transcription | `transcription.enabled`, `transcription.provider`, matching `providers..apiKey` | Send or upload a short voice message through a configured surface | [Transcription Settings](#transcription-settings) | | Enable web search or fetch | `tools.web.search.*`, `tools.web.fetch.*`, optional `tools.ssrfWhitelist` | Ask a question that requires current web information, then inspect logs if needed | [Web Tools](#web-tools), [Security](#security) | @@ -49,7 +49,7 @@ If you are not sure where a setting belongs, start from the task you are trying | Add external tools through MCP | `tools.mcpServers.` | Start `nanobot gateway --verbose` and check startup/tool logs | [MCP](#mcp-model-context-protocol) | | Tighten tool and network safety | `tools.restrictToWorkspace`, `tools.exec.sandbox`, `tools.ssrfWhitelist`, `channels.*.allowFrom` | Run the same workflow through the channel or CLI you plan to expose | [Security](#security), [Pairing](#pairing) | | Tune request timeouts or process concurrency | `NANOBOT_LLM_TIMEOUT_S`, `NANOBOT_STREAM_IDLE_TIMEOUT_S`, `NANOBOT_MAX_CONCURRENT_REQUESTS` | Start nanobot from the same environment and inspect startup/runtime logs | [Runtime Environment Variables](#runtime-environment-variables) | -| Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Start each process with explicit paths and run `nanobot status` for the default instance only | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) | +| Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Use the same explicit paths with `nanobot status`, `agent`, `webui`, `gateway`, and `serve` | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) | | Observe model calls | `LANGFUSE_SECRET_KEY`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_BASE_URL` environment variables | Run one model call, then check the matching Langfuse project | [Langfuse Observability](#langfuse-observability) | ## Environment Variables for Secrets diff --git a/docs/multiple-instances.md b/docs/multiple-instances.md index 04ab9b8f..fd7a9b8d 100644 --- a/docs/multiple-instances.md +++ b/docs/multiple-instances.md @@ -22,6 +22,9 @@ Edit `~/.nanobot-telegram/config.json`, `~/.nanobot-discord/config.json`, etc. w **Run instances:** ```bash +# Check one instance before starting it +nanobot status --config ~/.nanobot-telegram/config.json + # Instance A - Telegram bot nanobot gateway --config ~/.nanobot-telegram/config.json @@ -42,6 +45,9 @@ To open a CLI session against one of these instances locally: nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello from Telegram instance" nanobot agent -c ~/.nanobot-discord/config.json -m "Hello from Discord instance" +# Open the browser workbench for a specific instance +nanobot webui -c ~/.nanobot-telegram/config.json + # Optional one-off workspace override nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test ``` @@ -94,6 +100,7 @@ The copied base config can keep using the same `modelPresets` and `agents.defaul Start separate instances: ```bash +nanobot status --config ~/.nanobot-telegram/config.json nanobot gateway --config ~/.nanobot-telegram/config.json nanobot gateway --config ~/.nanobot-discord/config.json ``` diff --git a/docs/quick-start.md b/docs/quick-start.md index a5abf2c4..0d33e03e 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -32,7 +32,7 @@ On Windows PowerShell: irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex ``` -The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes and you enabled the WebSocket channel, go straight to [Open the WebUI](#5-open-the-webui). +The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, go straight to [Open the WebUI](#5-open-the-webui). To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install. @@ -235,13 +235,13 @@ Read it like this: ## 5. Open the WebUI -If Quick Start enabled the WebSocket channel, start the gateway: +Start the browser workbench: ```bash -nanobot gateway +nanobot webui ``` -Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard, then send your first message there. +`nanobot webui` prepares the local WebSocket channel if needed, starts the gateway, and opens `http://127.0.0.1:8765`. First-run WebUI setup binds to `127.0.0.1` by default, so it is not exposed to your LAN. Use `nanobot webui --background` when you want the gateway to keep running without an open terminal. ## 6. Test One CLI Message @@ -342,6 +342,6 @@ nanobot plugins enable whatsapp | Authentication or 401 errors | Check that the API key is valid, copied without spaces, and placed under the provider you selected. | | Provider/model errors | Make sure the active preset uses the provider that owns your API key and that the model exists there. | | The CLI works but a chat app does not reply | First keep `nanobot gateway` running, then follow [`chat-apps.md`](./chat-apps.md). | -| WebUI does not open | Enable the WebSocket channel and open port `8765`, not the gateway health port `18790`. | +| WebUI does not open | Run `nanobot webui`; the browser UI uses port `8765`, not the gateway health port `18790`. | For a fuller diagnosis flow, see [`troubleshooting.md`](./troubleshooting.md). diff --git a/docs/start-without-technical-background.md b/docs/start-without-technical-background.md index e93a9272..f9a110e6 100644 --- a/docs/start-without-technical-background.md +++ b/docs/start-without-technical-background.md @@ -315,10 +315,10 @@ It is normal for most providers to say `not set`. Only the provider you selected Start the local browser UI: ```bash -nanobot gateway +nanobot webui ``` -Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard or the `tokenIssueSecret` value from your manual config. +This starts nanobot and opens `http://127.0.0.1:8765` in your browser. Leave the terminal open while you use the WebUI. Enter the WebUI password you set in the wizard if the browser asks for one. Send this first message in the browser: @@ -335,10 +335,10 @@ Hello! How can I help you today? If `nanobot` is not found, run: ```bash -python -m nanobot gateway +python -m nanobot webui ``` -Use `python3 -m nanobot gateway` or `py -m nanobot gateway` if that is the Python command that worked in step 2. +Use `python3 -m nanobot webui` or `py -m nanobot webui` if that is the Python command that worked in step 2. Once this works, nanobot can help with its own next setup step. In the browser UI, ask it to read these docs and update your current config for one specific goal, then run `/restart` when nanobot tells you the config is ready. For example, ask it to add one provider preset or configure one chat app. @@ -367,21 +367,21 @@ Skip these until the first local message works: ## Next Steps -After the first reply works, choose only one next goal. Keep the terminal that runs `nanobot gateway` open whenever you use the WebUI or a chat app. +After the first reply works, choose only one next goal. Keep the terminal that runs `nanobot webui` open whenever you use the WebUI. Chat apps use the same gateway service underneath. ### Open the Browser UI Again Run: ```bash -nanobot gateway +nanobot webui ``` -Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. +Leave that terminal open; the browser should open automatically. To stop the WebUI later, return to the gateway terminal and press `Ctrl+C`. -If `nanobot` is not found, run `python -m nanobot gateway`, `python3 -m nanobot gateway`, or `py -m nanobot gateway`, matching the Python command that worked earlier. More details are in [`webui.md`](./webui.md). +If `nanobot` is not found, run `python -m nanobot webui`, `python3 -m nanobot webui`, or `py -m nanobot webui`, matching the Python command that worked earlier. More details are in [`webui.md`](./webui.md). ### Connect a Chat App diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ee374422..5b0504e3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -31,7 +31,7 @@ If `nanobot agent -m "Hello!"` fails, fix that before debugging WebUI, Telegram, ## How to Read `nanobot status` -`nanobot status` does not call a model. It only checks whether nanobot can find the default config, default workspace, active model or preset, and provider setup summary. +`nanobot status` does not call a model. It only checks whether nanobot can find the selected config, selected workspace, active model or preset, and provider setup summary. The output has this shape: @@ -90,9 +90,10 @@ Default workspace path: ~/.nanobot/workspace/ ``` -`nanobot status` reads the default config. Use explicit paths on commands that support them when debugging multiple instances: +`nanobot status` reads the default config unless you pass explicit paths. Use the same `--config` and `--workspace` across status checks and runtime commands when debugging multiple instances: ```bash +nanobot status --config ./bot-a/config.json --workspace ./bot-a/workspace nanobot agent --config ./bot-a/config.json --workspace ./bot-a/workspace -m "Hello" nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace ``` diff --git a/docs/webui.md b/docs/webui.md index 3730858d..40515c31 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -1,28 +1,44 @@ # WebUI -The WebUI is nanobot's browser workbench. Use it after a basic CLI reply already -works, when you want a persistent chat workspace, visible agent activity, -workspace controls, Apps, Skills, settings, and Automations in one place. +The WebUI is nanobot's browser workbench for persistent chat sessions, visible +agent activity, workspace controls, Apps, Skills, settings, and Automations in +one place. The published `nanobot-ai` wheel already includes the WebUI bundle. You only need the `webui/` source directory when you are changing the frontend itself. ## Open the WebUI -First confirm your provider and model can answer: +Use the launcher: ```bash -nanobot agent -m "Hello!" +nanobot webui ``` -The local WebSocket channel is enabled by default because it serves the bundled -WebUI. To require a browser login password, merge `tokenIssueSecret` into your -existing `~/.nanobot/config.json`: +`nanobot webui` creates the config/workspace when needed, checks provider setup, +offers Quick Start when the model provider is not ready, enables the local +WebSocket channel after confirmation, starts the gateway, and opens the browser. +The first-run path binds the WebUI to `127.0.0.1` by default, so it is not +available from other devices on your LAN. + +Run it in the background when you do not want to keep a terminal open: + +```bash +nanobot webui --background +``` + +Manage the background gateway with `nanobot gateway status`, `nanobot gateway +logs`, `nanobot gateway restart`, and `nanobot gateway stop`. + +Manual config still works. Set `tokenIssueSecret` when you intentionally expose +the WebUI beyond localhost or want a browser password: ```json { "channels": { "websocket": { + "enabled": true, + "host": "127.0.0.1", "tokenIssueSecret": "your-webui-password", "websocketRequiresToken": true } @@ -30,20 +46,8 @@ existing `~/.nanobot/config.json`: } ``` -If you are new to JSON snippets, see -[`start-without-technical-background.md#how-to-merge-json-snippets`](./start-without-technical-background.md#how-to-merge-json-snippets). - -Start the gateway: - -```bash -nanobot gateway -``` - -Leave the gateway running and open -[`http://127.0.0.1:8765`](http://127.0.0.1:8765). The WebUI is served by the -WebSocket channel on port `8765` by default. The gateway health endpoint, -`18790` by default, is not the browser UI. -Enter `tokenIssueSecret` when the WebUI asks for a password. +The WebUI is served by the WebSocket channel on port `8765` by default. The +gateway health endpoint, `18790` by default, is not the browser UI. ## What It Is For diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index c7a3387d..4c6c3d9a 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -788,6 +788,240 @@ def _warn_deprecated_config_keys(config_path: Path | None) -> None: ) +def _load_inspection_config( + config: str | None = None, + workspace: str | None = None, +) -> tuple[Path, Config]: + """Load config for diagnostic commands without resolving secret env refs.""" + from nanobot.config.loader import get_config_path, load_config, set_config_path + + config_path = None + if config: + config_path = Path(config).expanduser().resolve(strict=False) + set_config_path(config_path) + console.print(f"[dim]Using config: {config_path}[/dim]") + + display_path = config_path or get_config_path() + try: + loaded = load_config(config_path) + except ValueError as exc: + console.print(f"[red]Error: {exc}[/red]") + raise typer.Exit(1) from exc + _warn_deprecated_config_keys(display_path) + if workspace: + loaded.agents.defaults.workspace = workspace + return display_path, loaded + + +def _confirm_webui_action(message: str, *, yes: bool) -> None: + """Confirm a WebUI first-run mutation or fail clearly in non-interactive shells.""" + if yes: + return + try: + interactive = sys.stdin.isatty() + except Exception: + interactive = False + if not interactive: + console.print( + "[red]Error: WebUI setup needs confirmation. Re-run with --yes or use " + "`nanobot onboard --wizard`.[/red]" + ) + raise typer.Exit(1) + if not typer.confirm(message, default=True): + console.print("[yellow]WebUI setup cancelled.[/yellow]") + raise typer.Exit(1) + + +def _resolve_webui_config_path(config: str | None) -> Path: + """Resolve the config path used by ``nanobot webui`` and bind loader state.""" + from nanobot.config.loader import get_config_path, set_config_path + + if not config: + return get_config_path() + config_path = Path(config).expanduser().resolve(strict=False) + set_config_path(config_path) + console.print(f"[dim]Using config: {config_path}[/dim]") + return config_path + + +def _load_webui_setup_config(config_path: Path) -> Config: + """Load config for first-run mutation without resolving env-var placeholders.""" + from nanobot.config.loader import load_config + + try: + return load_config(config_path) + except ValueError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) from e + + +def _provider_setup_error(config: Config) -> str | None: + """Return the provider setup error, or None when the current model can start.""" + from nanobot.config.loader import resolve_config_env_vars + from nanobot.providers.factory import build_provider_snapshot + + try: + build_provider_snapshot(resolve_config_env_vars(config.model_copy(deep=True))) + except ValueError as exc: + return str(exc) + return None + + +def _webui_config_dict(config: Config) -> dict[str, Any]: + """Return the current WebSocket config as a mutable alias-key dictionary.""" + from nanobot.channels.websocket import WebSocketConfig + + current = getattr(config.channels, "websocket", None) or {} + model = WebSocketConfig.model_validate(current) + return model.model_dump(by_alias=True, exclude_none=True) + + +def _host_for_local_browser(host: str) -> str: + """Map bind hosts to a browser-openable local host.""" + if host in {"0.0.0.0", ""}: + return "127.0.0.1" + if host == "::": + return "[::1]" + if ":" in host and not host.startswith("["): + return f"[{host}]" + return host + + +def _webui_browser_url(config: Config) -> str: + ws_cfg = _webui_config_dict(config) + host = _host_for_local_browser(str(ws_cfg.get("host") or "127.0.0.1")) + port = int(ws_cfg.get("port") or 8765) + return f"http://{host}:{port}" + + +def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: 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 + + needs_enable = not model.enabled + needs_port = port is not None and model.port != port + if not needs_enable and not needs_port: + return False + + target_port = port if port is not None else model.port + console.print() + console.print("[bold]Local WebUI setup[/bold]") + console.print(f" URL: [cyan]http://127.0.0.1:{target_port}[/cyan]") + console.print(" Bind: [cyan]127.0.0.1 only[/cyan] (not exposed to your LAN)") + console.print(" Auth: localhost bootstrap issues short-lived WebSocket tokens") + console.print( + " LAN access requires an explicit host change plus a WebUI password in config." + ) + _confirm_webui_action("Enable the local WebUI channel in this config?", yes=yes) + + if not model.enabled: + model.enabled = True + changed = True + if model.host != "127.0.0.1": + model.host = "127.0.0.1" + changed = True + if port is not None and model.port != port: + model.port = port + changed = True + if not model.websocket_requires_token: + model.websocket_requires_token = True + changed = True + + setattr(config.channels, "websocket", model.model_dump(by_alias=True, exclude_none=True)) + return changed + + +def _warn_webui_bind_scope(config: Config) -> None: + ws_cfg = _webui_config_dict(config) + host = str(ws_cfg.get("host") or "127.0.0.1") + if host in {"127.0.0.1", "localhost", "::1"}: + return + console.print( + "[yellow]Warning: WebUI is configured to bind outside localhost. " + "Keep tokenIssueSecret set and use this only on trusted networks.[/yellow]" + ) + + +def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None: + """Best-effort wait for the WebUI listener before opening a browser.""" + import socket + import time + from urllib.parse import urlparse + + parsed = urlparse(url) + host = parsed.hostname or "127.0.0.1" + port = parsed.port or (443 if parsed.scheme == "https" else 80) + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + try: + with socket.create_connection((host, port), timeout=0.2): + return + except OSError: + time.sleep(0.1) + + +def _open_webui_browser(url: str, *, wait: bool = True) -> None: + """Open the WebUI in the user's default browser, with a copyable fallback.""" + import webbrowser + + if wait: + _wait_for_webui(url) + try: + webbrowser.open(url) + console.print(f"[green]✓[/green] Opened WebUI: [cyan]{url}[/cyan]") + except Exception as exc: + console.print(f"[yellow]Could not open browser ({exc}); visit {url}[/yellow]") + + +def _gateway_instance_command( + subcommand: str, + *, + config_path: Path, + workspace: str | None, +) -> str: + """Return a copyable gateway command for the same config/workspace instance.""" + import shlex + + parts = ["nanobot", "gateway", subcommand, "--config", str(config_path)] + if workspace: + workspace_path = str(Path(workspace).expanduser().resolve(strict=False)) + parts.extend(["--workspace", workspace_path]) + return " ".join(shlex.quote(part) for part in parts) + + +def _run_quick_start_for_webui(config: Config, *, yes: bool) -> Config: + """Offer the existing Quick Start flow when provider setup is missing.""" + if yes: + console.print( + "[red]Error: provider/model setup is incomplete, and --yes cannot answer " + "provider credentials. Run `nanobot webui` interactively or " + "`nanobot onboard --wizard`.[/red]" + ) + raise typer.Exit(1) + + console.print() + console.print("[yellow]Model provider setup is not ready.[/yellow]") + console.print("Quick Start will ask for provider, API key/base URL, model, and WebUI password.") + _confirm_webui_action("Run Quick Start now?", yes=False) + + from nanobot.cli.onboard import run_quick_start_onboard + + try: + result = run_quick_start_onboard(config) + except RuntimeError as exc: + console.print(f"[red]Error: {exc}[/red]") + console.print("[yellow]Run `nanobot onboard --wizard` after installing wizard dependencies.[/yellow]") + raise typer.Exit(1) from exc + if not result.should_save: + console.print("[yellow]Quick Start cancelled. No changes were saved.[/yellow]") + raise typer.Exit(1) + return result.config + + def _migrate_cron_store(config: "Config") -> None: """One-time migration: move legacy global cron store into the workspace.""" from nanobot.config.paths import get_cron_dir @@ -913,6 +1147,120 @@ def serve( web.run_app(api_app, host=host, port=port, print=lambda msg: logger.info(msg)) +# ============================================================================ +# WebUI Launcher +# ============================================================================ + + +@app.command() +def webui( + port: int | None = typer.Option(None, "--port", "-p", help="WebUI port"), + gateway_port: int | None = typer.Option( + None, + "--gateway-port", + help="Gateway health port", + ), + workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), + config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), + background: bool = typer.Option(False, "--background", help="Start gateway in the background"), + no_open: bool = typer.Option(False, "--no-open", help="Do not open a browser"), + yes: bool = typer.Option( + False, + "--yes", + "-y", + help="Apply safe local WebUI defaults without prompting", + ), +) -> None: + """Prepare the local WebUI, start the gateway, and open the browser workbench.""" + from nanobot.config.loader import save_config + from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions + + config_path = _resolve_webui_config_path(config) + created_config = not config_path.exists() + if created_config: + console.print(f"[yellow]No config found at {config_path}.[/yellow]") + _confirm_webui_action("Create a nanobot config and workspace now?", yes=yes) + + setup_config = _load_webui_setup_config(config_path) + if workspace: + setup_config.agents.defaults.workspace = workspace + + provider_error = _provider_setup_error(setup_config) + if provider_error: + console.print(f"[dim]Provider check: {provider_error}[/dim]") + setup_config = _run_quick_start_for_webui(setup_config, yes=yes) + if workspace: + setup_config.agents.defaults.workspace = workspace + + try: + changed_webui = _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: + console.print(f"[red]Error: invalid WebUI channel config: {exc}[/red]") + raise typer.Exit(1) from exc + + if created_config or provider_error or changed_webui or workspace: + save_config(setup_config, config_path) + console.print(f"[green]✓[/green] Saved config: {config_path}") + + workspace_path = get_workspace_path(setup_config.workspace_path) + workspace_path.mkdir(parents=True, exist_ok=True) + sync_workspace_templates(workspace_path) + + runtime_config = _load_runtime_config(str(config_path), workspace) + effective_gateway_port = gateway_port if gateway_port is not None else runtime_config.gateway.port + + console.print() + console.print(f"WebUI: [cyan]{webui_url}[/cyan]") + 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 background: + config_arg = str(config_path) + workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None + runtime = GatewayRuntime( + paths=GatewayRuntimePaths.for_instance( + data_dir=config_path.parent, + workspace=workspace_arg, + config_path=config_arg, + ) + ) + result = runtime.start_background( + 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]") + console.print(f"Logs: {result.status.log_path}") + raise typer.Exit(1) + if result.ok: + console.print("[green]Gateway started in the background.[/green]") + else: + console.print("[yellow]Gateway is already running in the background.[/yellow]") + console.print( + "Manage this instance: " + f"[cyan]{_gateway_instance_command('status', config_path=config_path, workspace=workspace)}[/cyan]" + ) + console.print( + "View logs: " + f"[cyan]{_gateway_instance_command('logs', config_path=config_path, workspace=workspace)}[/cyan]" + ) + if not no_open: + _open_webui_browser(webui_url) + return + + _run_gateway( + runtime_config, + port=effective_gateway_port, + open_browser_url=None if no_open else webui_url, + ) + + # ============================================================================ # Gateway / Server # ============================================================================ @@ -1294,11 +1642,17 @@ def _run_gateway( if not open_browser_url: return import webbrowser + from urllib.parse import urlparse + + parsed = urlparse(open_browser_url) + target_host = parsed.hostname or config.gateway.host or "127.0.0.1" + target_port = parsed.port or port # Channels start asynchronously; a short poll lets us avoid racing the bind. for _ in range(40): # ~4s max try: reader, writer = await asyncio.open_connection( - config.gateway.host or "127.0.0.1", port + target_host, + target_port, ) writer.close() with suppress(Exception): @@ -1684,24 +2038,19 @@ app.add_typer(channels_app, name="channels") @channels_app.command("status") def channels_status( - config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), + config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), ): """Show channel status.""" from nanobot.channels.registry import discover_all - from nanobot.config.loader import load_config, set_config_path - resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None - if resolved_config_path is not None: - set_config_path(resolved_config_path) - - config = load_config(resolved_config_path) + _, loaded = _load_inspection_config(config=config) table = Table(title="Channel Status") table.add_column("Channel", style="cyan") table.add_column("Enabled") for name, cls in sorted(discover_all().items()): - section = getattr(config.channels, name, None) + section = getattr(loaded.channels, name, None) if section is None: enabled = False elif isinstance(section, dict): @@ -1720,18 +2069,13 @@ def channels_status( def channels_login( channel_name: str = typer.Argument(..., help="Channel name (e.g. weixin, whatsapp)"), force: bool = typer.Option(False, "--force", "-f", help="Force re-authentication even if already logged in"), - config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), + config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), ): """Authenticate with a channel via QR code or other interactive login.""" from nanobot.channels.registry import discover_all - from nanobot.config.loader import load_config, set_config_path - resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None - if resolved_config_path is not None: - set_config_path(resolved_config_path) - - config = load_config(resolved_config_path) - channel_cfg = getattr(config.channels, channel_name, None) or {} + _, loaded = _load_inspection_config(config=config) + channel_cfg = getattr(loaded.channels, channel_name, None) or {} # Validate channel exists all_channels = discover_all() @@ -1837,28 +2181,31 @@ def plugins_disable( @app.command() -def status(): +def status( + config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), + workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), +): """Show nanobot status.""" - from nanobot.config.loader import get_config_path, load_config - - config_path = get_config_path() - config = load_config() - workspace = config.workspace_path + config_path, loaded = _load_inspection_config(config=config, workspace=workspace) + workspace_path = loaded.workspace_path console.print(f"{__logo__} nanobot Status\n") console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}") - console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}") + console.print( + f"Workspace: {workspace_path} " + f"{'[green]✓[/green]' if workspace_path.exists() else '[red]✗[/red]'}" + ) if config_path.exists(): from nanobot.providers.registry import PROVIDERS - _model, _preset_tag = _model_display(config) + _model, _preset_tag = _model_display(loaded) console.print(f"Model: {_model}{_preset_tag}") # Check API keys from registry for spec in PROVIDERS: - p = getattr(config.providers, spec.name, None) + p = getattr(loaded.providers, spec.name, None) if p is None: continue if spec.is_oauth: diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index a4a6d1e1..d1a0cf0f 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -1980,3 +1980,12 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult: return OnboardResult(config=original_config, should_save=False) if answer == "[A] Advanced Settings": _configure_advanced_settings(config) + + +def run_quick_start_onboard(initial_config: Config) -> OnboardResult: + """Run the compact provider + local WebUI setup path directly.""" + _get_questionary() + draft = initial_config.model_copy(deep=True) + if _configure_quick_start(draft): + return OnboardResult(config=draft, should_save=True) + return OnboardResult(config=initial_config, should_save=False) diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index b37d367d..3369f8bb 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -264,6 +264,39 @@ def test_onboard_help_shows_workspace_and_config_options(): assert "--dir" not in stripped_output +def test_status_help_shows_workspace_and_config_options(): + result = runner.invoke(app, ["status", "--help"]) + + assert result.exit_code == 0 + stripped_output = _strip_ansi(result.stdout) + assert "--workspace" in stripped_output + assert "-w" in stripped_output + assert "--config" in stripped_output + assert "-c" in stripped_output + + +def test_status_uses_explicit_config_and_workspace(tmp_path: Path): + config_path = tmp_path / "instance" / "config.json" + config_workspace = tmp_path / "config-workspace" + override_workspace = tmp_path / "override-workspace" + config = Config() + config.agents.defaults.workspace = str(config_workspace) + config_path.parent.mkdir(parents=True) + config_path.write_text(json.dumps(config.model_dump(mode="json", by_alias=True))) + + result = runner.invoke( + app, + ["status", "--config", str(config_path), "--workspace", str(override_workspace)], + ) + + assert result.exit_code == 0 + stripped_output = _strip_ansi(result.stdout) + compact_output = stripped_output.replace("\n", "") + assert str(config_path.resolve(strict=False)) in compact_output + assert str(override_workspace) in compact_output + assert str(config_workspace) not in compact_output + + def test_onboard_interactive_discard_does_not_save_or_create_workspace(mock_paths, monkeypatch): config_file, workspace_dir, _ = mock_paths @@ -352,6 +385,33 @@ def test_config_dump_excludes_oauth_provider_blocks(): assert "githubCopilot" not in providers +def test_plugins_list_uses_explicit_config(monkeypatch, tmp_path: Path): + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps({"channels": {"example": {"enabled": True}}}), + encoding="utf-8", + ) + monkeypatch.setattr( + "nanobot.channels.registry.discover_channel_names", + lambda: ["example"], + ) + monkeypatch.setattr( + "nanobot.channels.registry.discover_plugins", + lambda: {}, + ) + monkeypatch.setattr( + "nanobot.optional_features.optional_dependency_groups", + lambda: {}, + ) + + result = runner.invoke(app, ["plugins", "list", "--config", str(config_path)]) + + assert result.exit_code == 0 + stripped_output = _strip_ansi(result.stdout) + assert "example" in stripped_output + assert "yes" in stripped_output + + def test_provider_logout_openai_codex_removes_local_oauth_files(tmp_path, monkeypatch): token_path = tmp_path / "auth" / "codex.json" lock_path = token_path.with_suffix(".lock") @@ -1493,6 +1553,15 @@ def _test_provider_snapshot(provider: object, config: Config) -> ProviderSnapsho ) +def _patch_webui_provider_ready(monkeypatch) -> None: + provider = _fake_provider() + + def _snapshot(config: Config, **_kwargs) -> ProviderSnapshot: + return _test_provider_snapshot(provider, config) + + monkeypatch.setattr("nanobot.providers.factory.build_provider_snapshot", _snapshot) + + def _patch_cli_command_runtime( monkeypatch, config: Config, @@ -1540,6 +1609,129 @@ def _patch_cli_command_runtime( monkeypatch.setattr("nanobot.config.paths.get_cron_dir", get_cron_dir) +def test_webui_yes_creates_config_and_enables_local_websocket( + monkeypatch, + tmp_path: Path, +) -> None: + config_file = tmp_path / "instance" / "config.json" + workspace = tmp_path / "workspace" + seen: dict[str, object] = {} + _patch_webui_provider_ready(monkeypatch) + monkeypatch.setattr( + "nanobot.cli.commands.sync_workspace_templates", + lambda path: seen.__setitem__("templates", path), + ) + + def _fake_run_gateway(config: Config, **kwargs) -> None: + seen["gateway_config"] = config + seen["gateway_kwargs"] = kwargs + + monkeypatch.setattr("nanobot.cli.commands._run_gateway", _fake_run_gateway) + + result = runner.invoke( + app, + [ + "webui", + "--config", + str(config_file), + "--workspace", + str(workspace), + "--port", + "8899", + "--gateway-port", + "18888", + "--yes", + "--no-open", + ], + ) + + assert result.exit_code == 0 + data = json.loads(config_file.read_text(encoding="utf-8")) + websocket = data["channels"]["websocket"] + assert websocket["enabled"] is True + assert websocket["host"] == "127.0.0.1" + assert websocket["port"] == 8899 + assert websocket["websocketRequiresToken"] is True + assert data["agents"]["defaults"]["workspace"] == str(workspace) + assert seen["templates"] == workspace + assert seen["gateway_kwargs"] == {"port": 18888, "open_browser_url": None} + + +def test_webui_yes_refuses_missing_provider_setup(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "config.json" + + def _missing_provider(_config: Config, **_kwargs) -> ProviderSnapshot: + raise ValueError("No API key configured for provider 'custom'.") + + monkeypatch.setattr("nanobot.providers.factory.build_provider_snapshot", _missing_provider) + + result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"]) + + assert result.exit_code == 1 + assert "provider/model setup is incomplete" in result.stdout + assert not config_file.exists() + + +def test_webui_background_starts_runtime_and_opens_browser(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) + + class _FakeRuntime: + def __init__(self, **kwargs) -> None: + seen["runtime_kwargs"] = kwargs + + def start_background(self, options: GatewayStartOptions) -> RuntimeResult: + seen["start_options"] = options + status = GatewayStatus( + running=True, + pid=123, + state_path=tmp_path / "gateway.json", + log_path=tmp_path / "gateway.log", + port=options.port, + reason="running", + ) + return RuntimeResult(True, "gateway_started_background", status) + + 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 + assert "Gateway started in the background" in result.stdout + compact_output = _strip_ansi(result.stdout).replace("\n", " ") + assert "nanobot gateway status --config" in compact_output + assert "--workspace" in compact_output + options = seen["start_options"] + assert isinstance(options, GatewayStartOptions) + assert options.port == 18889 + assert options.config_path == str(config_file.resolve(strict=False)) + assert options.workspace == str(workspace.resolve(strict=False)) + assert seen["opened_url"] == "http://127.0.0.1:8765" + + def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None: pytest.importorskip("aiohttp")