diff --git a/docs/channel-plugin-guide.md b/docs/channel-plugin-guide.md index 9fbdf978..ddecf5f3 100644 --- a/docs/channel-plugin-guide.md +++ b/docs/channel-plugin-guide.md @@ -155,7 +155,7 @@ The key (`webhook`) becomes the config section name. The value points to your `B ```bash python -m pip install -e . -nanobot plugins list # verify "Webhook" shows as "plugin" +nanobot plugins list # verify the installed example plugin appears as "webhook" nanobot onboard # auto-adds default config for detected plugins ``` @@ -552,7 +552,7 @@ If not overridden, the base class returns `{"enabled": false}`. git clone https://github.com/you/nanobot-channel-webhook cd nanobot-channel-webhook python -m pip install -e . -nanobot plugins list # should show "Webhook" as "plugin" +nanobot plugins list # should show the installed example plugin as "webhook" nanobot gateway # test end-to-end ``` @@ -561,8 +561,8 @@ nanobot gateway # test end-to-end ```bash $ nanobot plugins list - Name Source Enabled - telegram builtin yes - discord builtin no - webhook plugin yes + Name Type Enabled + discord channel no + telegram channel yes + webhook channel yes ``` diff --git a/docs/chat-apps.md b/docs/chat-apps.md index 26531e15..a71609df 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -12,6 +12,21 @@ If that fails, fix installation, config, provider, or model setup first with [`q Most examples below are snippets to merge into `~/.nanobot/config.json`. +> [!NOTE] +> If you are upgrading from a version where chat app SDKs were installed by default, +> install the channel extra in the same Python environment before enabling or +> restarting that channel: +> +> ```bash +> nanobot plugins enable +> ``` +> +> Replace `` with names such as `telegram`, `slack`, `feishu`, +> `dingtalk`, `matrix`, `qq`, `napcat`, `weixin`, `wecom`, or `msteams`. +> To turn a channel off later, run `nanobot plugins disable `. +> nanobot keeps the saved settings, but stops loading that channel after the +> next restart. + ## Common Setup Pattern Every chat app uses the same shape: @@ -59,6 +74,12 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
Telegram +**Install the optional channel dependency** + +```bash +nanobot plugins enable telegram +``` + **1. Create a bot** - Open Telegram, search `@BotFather` - Send `/newbot`, follow prompts @@ -123,6 +144,14 @@ Telegram uses long polling by default. To receive updates through a webhook, exp Uses **Socket.IO WebSocket** by default, with HTTP polling fallback. +**Install the optional realtime dependency** + +```bash +nanobot plugins enable mochat +``` + +Without this extra, Mochat still works through HTTP polling. + **1. Ask nanobot to set up Mochat for you** Simply send this message to nanobot (replace `xxx@xxx` with your real email): @@ -233,14 +262,14 @@ nanobot gateway
Matrix (Element) -Install Matrix dependencies first: +Enable Matrix support first: ```bash -python -m pip install "nanobot-ai[matrix]" +nanobot plugins enable matrix ``` > [!NOTE] -> Matrix is not supported on Windows. `matrix-nio[e2e]` depends on `python-olm`, which has no pre-built Windows wheel and is skipped by the `matrix` extra on `sys_platform == 'win32'`. The command above will still succeed on Windows but without `matrix-nio` installed, so enabling the Matrix channel will fail at startup. Use macOS, Linux, or WSL2. +> Matrix encryption is disabled by default on Windows because `matrix-nio[e2e]` depends on `python-olm`, which has no pre-built Windows wheel. Use macOS, Linux, or WSL2 if you need Matrix E2EE. **1. Create/choose a Matrix account** @@ -306,9 +335,7 @@ nanobot gateway Requires the WhatsApp optional dependencies: ```bash -pip install "nanobot-ai[whatsapp]" -# Source checkout: -python -m pip install -e ".[whatsapp]" +nanobot plugins enable whatsapp ``` **1. Link device with QR** @@ -384,6 +411,7 @@ Uses **WebSocket** long connection — no public IP required. **Quick setup: QR login** ```bash +nanobot plugins enable feishu nanobot channels login feishu # Use --force to create/sign in with a new bot ``` @@ -454,6 +482,12 @@ nanobot gateway Uses **botpy SDK** with WebSocket — no public IP required. Currently supports **private messages only**. +**Install the optional channel dependency** + +```bash +nanobot plugins enable qq +``` + **1. Register & create bot** - Visit [QQ Open Platform](https://q.qq.com) → Register as a developer (personal or enterprise) - Create a new bot application @@ -506,6 +540,12 @@ Connects to a [Napcat](https://github.com/NapNeko/NapCatQQ) instance over its ** - Copy the forward websocket server's token - (Optional) In the webui, follow "系统配置" -> "登陆配置" -> "快速登录QQ" to automatically login after restarts +**Install the optional channel dependency** + +```bash +nanobot plugins enable napcat +``` + **2. Configure** ```json @@ -543,6 +583,12 @@ Connects to a [Napcat](https://github.com/NapNeko/NapCatQQ) instance over its ** Uses **Stream Mode** — no public IP required. +**Install the optional channel dependency** + +```bash +nanobot plugins enable dingtalk +``` + **1. Create a DingTalk bot** - Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/) - Create a new app -> Add **Robot** capability @@ -585,6 +631,12 @@ nanobot gateway Uses **Socket Mode** — no public URL required. +**Install the optional channel dependency** + +```bash +nanobot plugins enable slack +``` + **1. Create a Slack app** - Go to [Slack API](https://api.slack.com/apps) → **Create New App** → "From scratch" - Pick a name and select your workspace @@ -695,10 +747,10 @@ nanobot gateway Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required. -**1. Install with WeChat support** +**1. Enable WeChat support** ```bash -python -m pip install "nanobot-ai[weixin]" +nanobot plugins enable weixin ``` **2. Configure** @@ -747,10 +799,10 @@ nanobot gateway > > Uses **WebSocket** long connection — no public IP required. -**1. Install the optional dependency** +**1. Enable WeCom support** ```bash -python -m pip install "nanobot-ai[wecom]" +nanobot plugins enable wecom ``` **2. Create a WeCom AI Bot** @@ -786,10 +838,10 @@ nanobot gateway > Direct-message text in/out, tenant-aware OAuth, conversation reference persistence. > Uses a public HTTPS webhook — no WebSocket; you need a tunnel or reverse proxy. -**1. Install the optional dependency** +**1. Enable Microsoft Teams support** ```bash -python -m pip install "nanobot-ai[msteams]" +nanobot plugins enable msteams ``` **2. Create a Teams / Azure bot app registration** diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a92ecf1b..78a1d1b3 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -16,6 +16,7 @@ Use this page when you know what you want to run and need the command shape. For | 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` | +| Manage optional features | `nanobot plugins list` | Shows channels and optional capabilities you can turn on | | Log in to QR/OAuth-style channels | `nanobot channels login ` | Used by channels such as WhatsApp and WeChat | | Log in to OAuth model providers | `nanobot provider login ` | Used by OAuth providers such as OpenAI Codex and GitHub Copilot | @@ -217,6 +218,23 @@ nanobot channels status See [`chat-apps.md`](./chat-apps.md) for channel-specific setup. +## Optional Features + +Use these commands when you want nanobot to add or remove a built-in capability +without hand-editing JSON. Enabling may install the support package first. +Disabling is for channels such as Telegram, Matrix, or Slack; it keeps your +saved settings and turns the channel off. + +| Command | Description | +|---|---| +| `nanobot plugins list` | Show available channels and optional capabilities | +| `nanobot plugins enable ` | Install missing support and enable the feature or channel | +| `nanobot plugins enable --logs` | Show package install logs while enabling | +| `nanobot plugins disable ` | Turn off a channel without deleting its saved settings | +| `nanobot plugins list --config ` | Read a specific config file | +| `nanobot plugins enable --config ` | Update a specific config file | +| `nanobot plugins disable --config ` | Turn off a channel in a specific config file | + ## Provider OAuth | Command | Description | diff --git a/docs/configuration.md b/docs/configuration.md index 8b309ecb..ca5c4e7e 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 | `channels.websocket.enabled`, 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 | 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) | | 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) | @@ -380,7 +380,7 @@ Omit `apiKey` (or leave it empty / unset). The provider falls back to [`DefaultA Install the optional dependency: ```bash -python -m pip install 'nanobot-ai[azure]' +nanobot plugins enable azure ``` `DefaultAzureCredential` walks this chain in order and uses the first identity that succeeds: @@ -395,7 +395,7 @@ python -m pip install 'nanobot-ai[azure]' The identity that ends up signing the request **must be assigned the `Cognitive Services OpenAI User` RBAC role** (or higher) on the Azure OpenAI resource. Without that role you will see `401`/`403` errors at the first request. -> `apiBase` remains mandatory in both modes — it's your Azure resource endpoint and cannot be inferred. If neither `apiKey` is set nor `azure-identity` is installed, the provider raises a clear error pointing you at `python -m pip install 'nanobot-ai[azure]'`. +> `apiBase` remains mandatory in both modes — it's your Azure resource endpoint and cannot be inferred. If neither `apiKey` is set nor `azure-identity` is installed, the provider raises a clear error pointing you at `nanobot plugins enable azure`.
@@ -439,6 +439,17 @@ Bedrock uses the native `bedrock-runtime` Converse API, so it can call Bedrock m This provider is for Bedrock's native Converse API, not Bedrock's OpenAI-compatible `/openai/v1` endpoint. For OpenAI-compatible Bedrock models, you can still use `custom` if you specifically want that API surface. +Install Bedrock support first: + +```bash +nanobot plugins enable bedrock +``` + +> [!NOTE] +> If you configured Bedrock before `boto3` became an optional dependency, run +> `nanobot plugins enable bedrock` after upgrading. Otherwise the provider will +> fail when it first tries to create a Bedrock client. + **1. Configure credentials** Use the normal AWS credential chain (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, an AWS profile, or an IAM role). The IAM identity needs: @@ -1511,7 +1522,7 @@ Global settings that apply to all channels. Configure under the `channels` secti | `sendProgress` | `true` | Stream agent's text progress to the channel | | `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) | | `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. | -| `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. Set to `false` to keep document content out of the prompt and include attachment path references instead. | +| `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. Install parser dependencies with `nanobot plugins enable documents`. If you used document parsing before those parsers became optional, run that command after upgrading. Set to `false` to keep document content out of the prompt and include attachment path references instead. | | `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) | `channels.transcriptionProvider` and `channels.transcriptionLanguage` are deprecated compatibility fields. They remain as a read-only fallback for older configs, but new configuration should use top-level `transcription.provider` and `transcription.language`. @@ -1906,6 +1917,7 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets] | `tools.exec.timeout` | `60` | Default hard timeout in seconds for shell commands. Config values may exceed the per-call tool cap; set `0` to disable the hard timeout for trusted long-running commands. | | `tools.exec.pathPrepend` | `""` | Extra directories to prepend to `PATH` when running shell commands. Use this when configured tools should win executable lookup precedence, such as a Python virtual environment's `bin` or `Scripts` directory. | | `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). | +| `tools.webuiAllowRemotePackageInstall` | `false` | When `false`, the WebUI can install missing optional packages only from a browser opened on the same machine as nanobot. Set to `true` only when a trusted remote admin is allowed to install Python packages into this nanobot environment. | | `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. | | `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. | diff --git a/docs/deployment.md b/docs/deployment.md index 7442c30f..6c9f6a60 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -38,14 +38,13 @@ Restart the deployed process after editing `config.json`. Long-running processes > Official Docker usage currently means building from this repository with the included `Dockerfile`. Docker Hub images under third-party namespaces are not maintained or verified by HKUDS/nanobot; do not mount API keys or bot tokens into them unless you trust the publisher. > [!IMPORTANT] -> The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container. To serve the bundled WebUI from Docker, enable the WebSocket channel and protect bootstrap with a secret: +> The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container. To serve the bundled WebUI from Docker, bind the WebSocket channel externally and protect bootstrap with a secret: > > ```json > { > "gateway": { "host": "0.0.0.0" }, > "channels": { > "websocket": { -> "enabled": true, > "host": "0.0.0.0", > "port": 8765, > "tokenIssueSecret": "your-secret-here" diff --git a/docs/openai-api.md b/docs/openai-api.md index 6dbfc146..f31e7e42 100644 --- a/docs/openai-api.md +++ b/docs/openai-api.md @@ -3,7 +3,7 @@ nanobot can expose a minimal OpenAI-compatible endpoint for local integrations: ```bash -python -m pip install "nanobot-ai[api]" +nanobot plugins enable api nanobot agent -m "Hello!" nanobot serve ``` diff --git a/docs/quick-start.md b/docs/quick-start.md index be5ef72f..a5abf2c4 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -329,7 +329,7 @@ nanobot --version If you use WhatsApp from a source checkout, keep the optional dependencies installed: ```bash -python -m pip install -e ".[whatsapp]" +nanobot plugins enable whatsapp ``` ## First-Run Troubleshooting diff --git a/docs/start-without-technical-background.md b/docs/start-without-technical-background.md index 2c38ca1f..e93a9272 100644 --- a/docs/start-without-technical-background.md +++ b/docs/start-without-technical-background.md @@ -181,11 +181,11 @@ For the first setup, choose `[Q] Quick Start`. It configures the recommended loc 4. Paste your API key if the wizard asks for one. 5. Paste the provider base URL if the wizard asks for one. 6. Paste a model ID that provider can run. -7. Confirm that Quick Start should enable the WebSocket channel for the local WebUI. +7. Confirm that Quick Start should configure the local WebUI. 8. Set the WebUI password when prompted. 9. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes. -The recommended path enables `channels.websocket` for the local WebUI, requires a WebUI password, and writes default AI settings. You do not need to choose a separate chat app for the first run. +The recommended path configures the local WebUI, requires a WebUI password, and writes default AI settings. You do not need to choose a separate chat app for the first run. If you already know that you need custom headers, provider-specific request fields, a chat app, or tools, choose `Advanced Settings` instead. [`provider-cookbook.md`](./provider-cookbook.md) has copyable examples for several common provider setups. After you change advanced settings, a save option appears in the main menu. Choose `[S] Save and Exit`. @@ -225,7 +225,6 @@ Merge them into one object: }, "channels": { "websocket": { - "enabled": true, "tokenIssueSecret": "your-webui-password", "websocketRequiresToken": true } @@ -288,7 +287,6 @@ If this is a brand-new install and you have not configured anything else yet, re }, "channels": { "websocket": { - "enabled": true, "tokenIssueSecret": "your-webui-password", "websocketRequiresToken": true } diff --git a/docs/websocket.md b/docs/websocket.md index 24f95033..4bb08576 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -16,13 +16,13 @@ Nanobot can act as a WebSocket server, allowing external clients (web apps, CLIs ### 1. Configure -Add to `config.json` under `channels.websocket`: +The WebSocket channel is enabled by default. Add only the fields you want to +override under `channels.websocket`: ```json { "channels": { "websocket": { - "enabled": true, "host": "127.0.0.1", "port": 8765, "path": "/", @@ -208,7 +208,7 @@ All fields go under `channels.websocket` in `config.json`. | Field | Type | Default | Description | |-------|------|---------|-------------| -| `enabled` | bool | `false` | Enable the WebSocket server. | +| `enabled` | bool | `true` | Enable the WebSocket server. Set to `false` only when you intentionally do not want the bundled WebUI/WebSocket surface. | | `host` | string | `"127.0.0.1"` | Bind address. Use `"0.0.0.0"` to accept external connections. | | `port` | int | `8765` | Listen port. | | `path` | string | `"/"` | WebSocket upgrade path. Trailing slashes are normalized (root `/` is preserved). | @@ -272,7 +272,6 @@ For production deployments where `websocketRequiresToken: true`, use short-lived { "channels": { "websocket": { - "enabled": true, "port": 8765, "path": "/ws", "tokenIssuePath": "/auth/token", @@ -367,7 +366,6 @@ Outbound `message` events may include a `media` field containing local filesyste { "channels": { "websocket": { - "enabled": true, "host": "0.0.0.0", "port": 8765, "websocketRequiresToken": false, @@ -384,7 +382,6 @@ Outbound `message` events may include a `media` field containing local filesyste { "channels": { "websocket": { - "enabled": true, "token": "my-shared-secret", "allowFrom": ["alice", "bob"] } @@ -400,7 +397,6 @@ Clients connect with `?token=my-shared-secret&client_id=alice`. { "channels": { "websocket": { - "enabled": true, "host": "0.0.0.0", "port": 8765, "path": "/ws", @@ -421,7 +417,6 @@ Clients connect with `?token=my-shared-secret&client_id=alice`. { "channels": { "websocket": { - "enabled": true, "path": "/chat/ws", "allowFrom": ["*"] } diff --git a/docs/webui.md b/docs/webui.md index d73914ea..3730858d 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -15,14 +15,14 @@ First confirm your provider and model can answer: nanobot agent -m "Hello!" ``` -Then merge the WebSocket channel into your existing `~/.nanobot/config.json`. -Set `tokenIssueSecret` to the password you will enter in the WebUI login form: +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`: ```json { "channels": { "websocket": { - "enabled": true, "tokenIssueSecret": "your-webui-password", "websocketRequiresToken": true } @@ -94,9 +94,20 @@ for provider setup and output behavior. ## Apps Open Apps from the sidebar or settings navigation to manage integrations that -nanobot can call from a chat. CLI Apps install local adapters that nanobot runs -on your machine; they do not modify the native apps themselves. MCP presets add -predefined MCP server configurations. +nanobot can call from a chat. Nanobot features can enable built-in channels and +optional capabilities such as `bedrock` or `documents`. CLI Apps install local +adapters that nanobot runs on your machine; they do not modify the native apps +themselves. MCP presets add predefined MCP server configurations. + +Enabling a Nanobot feature may install Python packages into the environment +running nanobot. By default, the WebUI can install missing packages only when +you open it on the same machine as nanobot. If you open the WebUI from another +device, a domain name, a tunnel, or a reverse proxy, package install is blocked +unless you explicitly allow it with `tools.webuiAllowRemotePackageInstall`. + +Optional feature installs use your existing pip download settings. If PyPI is +slow or unavailable from your network, configure pip or set `PIP_INDEX_URL` +before starting nanobot. Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and @@ -187,7 +198,6 @@ channel to all interfaces and set a token or token issue secret: { "channels": { "websocket": { - "enabled": true, "host": "0.0.0.0", "port": 8765, "tokenIssueSecret": "your-secret-here" @@ -201,12 +211,36 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or `http://:8765` from the other device and enter the secret in the login form. +Remote WebUI clients can view Apps and toggle already-installed features with a +valid token, but they cannot install missing Python packages by default. To allow +trusted remote admins to install optional feature dependencies from the WebUI, +opt in explicitly: + +```json +{ + "tools": { + "webuiAllowRemotePackageInstall": true + } +} +``` + +Use this only for a private deployment where every authenticated WebUI user is +trusted to change the Python environment that nanobot runs in. If you publish +the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it +as remote access and leave package installs disabled unless that is intentional. + +Optional feature installs use pip's configured package index, including +`PIP_INDEX_URL`. + +Leave remote package installs disabled when the WebUI is exposed beyond a +private, trusted network. + ## Troubleshooting If the page does not open, check these in order: 1. `nanobot agent -m "Hello!"` works in the same Python environment. -2. The WebSocket channel is enabled in `~/.nanobot/config.json`. +2. `~/.nanobot/config.json` does not explicitly set `channels.websocket.enabled` to `false`. 3. `nanobot gateway` is still running. 4. You are opening port `8765`, not the gateway health port. 5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret. diff --git a/nanobot/apps/cli/service.py b/nanobot/apps/cli/service.py index ef4cb409..8fdcbe7b 100644 --- a/nanobot/apps/cli/service.py +++ b/nanobot/apps/cli/service.py @@ -17,6 +17,7 @@ from typing import Any from urllib.parse import urlparse import httpx +from loguru import logger from nanobot.apps.protocol import app_manifest, compact_dict from nanobot.config.paths import get_runtime_subdir @@ -941,12 +942,19 @@ class CliAppManager: raise CliAppError("this CLI app uses an unsupported install strategy") def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]: - return subprocess.run( + command = subprocess.list2cmdline(argv) + logger.info("CLI Apps: running {}", command) + result = subprocess.run( argv, capture_output=True, text=True, timeout=timeout, ) + logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command) + output = (result.stderr or result.stdout or "").strip() + if output: + logger.info("CLI Apps command output:\n{}", _truncate(output, 4000)) + return result def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]: entry_point = str(app.get("entry_point") or "") diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 401fb375..73136dd1 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -217,7 +217,7 @@ class DingTalkChannel(BaseChannel): try: if not DINGTALK_AVAILABLE: self.logger.error( - "Stream SDK not installed. Run: pip install dingtalk-stream" + "Stream SDK not installed. Run: nanobot plugins enable dingtalk" ) return diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 69427287..9e0f9829 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -405,7 +405,7 @@ class DiscordChannel(BaseChannel): async def start(self) -> None: """Start the Discord client.""" if not DISCORD_AVAILABLE: - self.logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]") + self.logger.error("discord.py not installed. Run: nanobot plugins enable discord") return if not self.config.token: diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 4f1d3602..3f8e4349 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -672,7 +672,7 @@ class FeishuChannel(BaseChannel): async def start(self) -> None: """Start the Feishu bot with WebSocket long connection.""" if not FEISHU_AVAILABLE: - self.logger.error("SDK not installed. Run: pip install lark-oapi") + self.logger.error("SDK not installed. Run: nanobot plugins enable feishu") return if not self.config.app_id or not self.config.app_secret: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index d904a704..34fb7c5f 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -25,6 +25,7 @@ from nanobot.bus.outbound_events import ( ) from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS from nanobot.config.schema import Config from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message @@ -51,6 +52,21 @@ _BOOL_CAMEL_ALIASES: dict[str, str] = { "show_reasoning": "showReasoning", } +def _default_channel_config(name: str) -> dict[str, Any] | None: + if name != "websocket": + return None + from nanobot.channels.websocket import WebSocketChannel + + return WebSocketChannel.default_config() + + +def _channel_config_enabled(name: str, section: Any) -> bool: + default_enabled = name in DEFAULT_ENABLED_CHANNELS + if isinstance(section, dict): + return bool(section.get("enabled", default_enabled)) + return bool(getattr(section, "enabled", default_enabled)) + + class ChannelManager: """ Manages chat channels and coordinates message routing. @@ -105,21 +121,32 @@ class ChannelManager: candidate_names = set(names) extra = getattr(self.config.channels, "__pydantic_extra__", None) or {} candidate_names.update(extra.keys()) + default_sections: dict[str, Any] = {} + + def section_for(name: str) -> Any: + section = getattr(self.config.channels, name, None) + if section is not None or name not in DEFAULT_ENABLED_CHANNELS: + return section + if name not in default_sections: + default = _default_channel_config(name) + if default is not None: + default_sections[name] = default + return default_sections.get(name) enabled_names: set[str] = set() for name in candidate_names: - section = getattr(self.config.channels, name, None) + section = section_for(name) if section is None: continue - if ( - section.get("enabled", False) - if isinstance(section, dict) - else getattr(section, "enabled", False) - ): + if _channel_config_enabled(name, section): enabled_names.add(name) - for name, cls in discover_enabled(enabled_names, _names=names).items(): - section = getattr(self.config.channels, name, None) + for name, cls in discover_enabled( + enabled_names, + _names=names, + warn_import_errors=True, + ).items(): + section = section_for(name) if section is None: continue try: diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 73e34d58..481b2b86 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -3,6 +3,7 @@ import asyncio import json import mimetypes +import sys import time from contextlib import suppress from dataclasses import dataclass @@ -45,7 +46,7 @@ try: from nio.exceptions import EncryptionError except ImportError as e: raise ImportError( - "Matrix dependencies not installed. Run: pip install nanobot-ai[matrix]" + "Matrix dependencies not installed. Run: nanobot plugins enable matrix" ) from e from nanobot.bus.events import OutboundMessage @@ -200,7 +201,7 @@ class MatrixConfig(Base): password: str = "" access_token: str = "" device_id: str = "" - e2ee_enabled: bool = Field(default=True, alias="e2eeEnabled") + e2ee_enabled: bool = Field(default=sys.platform != "win32", alias="e2eeEnabled") sas_verification: bool = Field(default=False, alias="sasVerification") sync_stop_grace_seconds: int = 2 max_media_bytes: int = 20 * 1024 * 1024 diff --git a/nanobot/channels/msteams.py b/nanobot/channels/msteams.py index 96080d25..f989cfb1 100644 --- a/nanobot/channels/msteams.py +++ b/nanobot/channels/msteams.py @@ -142,7 +142,7 @@ class MSTeamsChannel(BaseChannel): async def start(self) -> None: """Start the Teams webhook listener.""" if not MSTEAMS_AVAILABLE: - self.logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]") + self.logger.error("PyJWT not installed. Run: nanobot plugins enable msteams") return if not self.config.app_id or not self.config.app_password: @@ -458,7 +458,7 @@ class MSTeamsChannel(BaseChannel): async def _validate_inbound_auth(self, auth_header: str, activity: dict[str, Any]) -> None: """Validate inbound Bot Framework bearer token.""" if not MSTEAMS_AVAILABLE: - raise RuntimeError("PyJWT not installed. Run: pip install nanobot-ai[msteams]") + raise RuntimeError("PyJWT not installed. Run: nanobot plugins enable msteams") if not auth_header.lower().startswith("bearer "): raise ValueError("missing bearer token") diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 0d5ed9cd..fc65e96e 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -195,7 +195,7 @@ class QQChannel(BaseChannel): """Start the QQ bot with auto-reconnect loop.""" redirect_lib_logging("botpy", level="WARNING") if not QQ_AVAILABLE: - self.logger.error("SDK not installed. Run: pip install qq-botpy") + self.logger.error("SDK not installed. Run: nanobot plugins enable qq") return if not self.config.app_id or not self.config.secret: diff --git a/nanobot/channels/registry.py b/nanobot/channels/registry.py index 53d90e44..c6ff6073 100644 --- a/nanobot/channels/registry.py +++ b/nanobot/channels/registry.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from nanobot.channels.base import BaseChannel _INTERNAL = frozenset({"base", "manager", "registry"}) +DEFAULT_ENABLED_CHANNELS = frozenset({"websocket"}) def discover_channel_names() -> list[str]: @@ -57,6 +58,7 @@ def discover_enabled( *, _names: list[str] | None = None, _include_all_external: bool = False, + warn_import_errors: bool = False, ) -> dict[str, type[BaseChannel]]: """Return channels whose module names are in *enabled_names*. @@ -72,10 +74,14 @@ def discover_enabled( try: result[modname] = load_channel_class(modname) except ImportError as e: - logger.debug("Skipping built-in channel '{}': {}", modname, e) + message = "Enabled built-in channel '{}' is not available: {}" + if warn_import_errors: + logger.warning(message, modname, e) + else: + logger.debug(message, modname, e) external = discover_plugins(None if _include_all_external else enabled_names) - shadowed = set(external) & set(result) + shadowed = set(external) & set(names) if shadowed: logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed) if _include_all_external: diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index e5d237ef..024ad5f4 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -59,6 +59,9 @@ from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions from nanobot.webui.transcription_ws import webui_transcription_event from nanobot.webui.websocket_logging import websockets_server_logger +# Plain HTTP WebUI routes also run through websockets.process_request. +_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0 + class WebSocketConfig(Base): """WebSocket server channel configuration. @@ -80,7 +83,7 @@ class WebSocketConfig(Base): shared filesystem or an HTTP file server to access these files. """ - enabled: bool = False + enabled: bool = True host: str = "127.0.0.1" port: int = 8765 unix_socket_path: str = "" @@ -482,6 +485,7 @@ class WebSocketChannel(BaseChannel): handler, socket_path, process_request=process_request, + open_timeout=_WEBUI_HTTP_OPEN_TIMEOUT_S, max_size=self.config.max_message_bytes, ping_interval=self.config.ping_interval_s, ping_timeout=self.config.ping_timeout_s, @@ -495,6 +499,7 @@ class WebSocketChannel(BaseChannel): self.config.host, self.config.port, process_request=process_request, + open_timeout=_WEBUI_HTTP_OPEN_TIMEOUT_S, max_size=self.config.max_message_bytes, ping_interval=self.config.ping_interval_s, ping_timeout=self.config.ping_timeout_s, diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py index ef185b1c..f4705196 100644 --- a/nanobot/channels/wecom.py +++ b/nanobot/channels/wecom.py @@ -103,7 +103,7 @@ class WecomChannel(BaseChannel): async def start(self) -> None: """Start the WeCom bot with WebSocket long connection.""" if not WECOM_AVAILABLE: - self.logger.error("SDK not installed. Run: pip install nanobot-ai[wecom]") + self.logger.error("SDK not installed. Run: nanobot plugins enable wecom") return if not self.config.bot_id or not self.config.secret: diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 10c4cc10..d69bcbd2 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -72,7 +72,7 @@ def _load_neonize() -> _NeonizeAPI: from neonize.utils.jid import build_jid except ImportError as exc: raise RuntimeError( - 'WhatsApp dependencies not installed. Run: pip install "nanobot-ai[whatsapp]"' + "WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp" ) from exc _NEONIZE_API = _NeonizeAPI( diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 7447726b..c7a3387d 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -38,6 +38,14 @@ _log_handler_id = logger.add( filter=lambda record: record["extra"].setdefault("channel", "-") or True, ) + +def _set_nanobot_logs(enabled: bool) -> None: + if enabled: + logger.enable("nanobot") + else: + logger.disable("nanobot") + + from prompt_toolkit import PromptSession, print_formatted_text # noqa: E402 from prompt_toolkit.application import run_in_terminal # noqa: E402 from prompt_toolkit.formatted_text import ANSI, HTML # noqa: E402 @@ -45,10 +53,12 @@ from prompt_toolkit.history import FileHistory # noqa: E402 from prompt_toolkit.patch_stdout import patch_stdout # noqa: E402 from rich.console import Console # noqa: E402 from rich.markdown import Markdown # noqa: E402 +from rich.markup import escape # noqa: E402 from rich.table import Table # noqa: E402 from rich.text import Text # noqa: E402 from nanobot import __logo__, __version__ # noqa: E402 +from nanobot import optional_features as feature_support # noqa: E402 from nanobot.agent.loop import AgentLoop # noqa: E402 from nanobot.bus.outbound_events import ( # noqa: E402 ProgressEvent, @@ -686,6 +696,33 @@ def _onboard_plugins(config_path: Path) -> None: json.dump(data, f, indent=2, ensure_ascii=False) +def _print_enable_options( + extras: dict[str, list[str] | None], + builtin_channels: set[str], + plugin_channels: dict[str, Any], + config: Config, +) -> None: + table = Table(title="Available Features") + table.add_column("Name", style="cyan") + table.add_column("Type") + table.add_column("Enabled") + + for item in sorted(builtin_channels | set(plugin_channels) | set(extras)): + is_channel = item in builtin_channels or item in plugin_channels + enabled = ( + feature_support.channel_enabled(config, item) + if is_channel + else feature_support.extra_installed(item, extras[item]) + ) + table.add_row( + item, + "channel" if is_channel else "feature", + "[green]yes[/green]" if enabled else "[dim]no[/dim]", + ) + + console.print(table) + + def _model_display(config: Config) -> tuple[str, str]: """Return (resolved_model_name, preset_tag) for display strings.""" resolved = config.resolve_preset() @@ -811,20 +848,15 @@ def serve( try: from aiohttp import web # noqa: F401 except ImportError: - console.print("[red]aiohttp is required. Install with: pip install 'nanobot-ai[api]'[/red]") + console.print("[red]aiohttp is required. Install with: nanobot plugins enable api[/red]") raise typer.Exit(1) - from loguru import logger - from nanobot.api.server import create_app from nanobot.bus.queue import MessageBus from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.session.manager import SessionManager - if verbose: - logger.enable("nanobot") - else: - logger.disable("nanobot") + _set_nanobot_logs(verbose) runtime_config = _load_runtime_config(config, workspace) api_cfg = runtime_config.api @@ -1392,8 +1424,6 @@ def agent( logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), ): """Interact with the agent directly.""" - from loguru import logger - from nanobot.bus.queue import MessageBus from nanobot.cron.service import CronService from nanobot.providers.image_generation import image_gen_provider_configs @@ -1411,10 +1441,7 @@ def agent( cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) - if logs: - logger.enable("nanobot") - else: - logger.disable("nanobot") + _set_nanobot_logs(logs) try: agent_loop = AgentLoop.from_config( @@ -1728,42 +1755,80 @@ def channels_login( # Plugin Commands # ============================================================================ -plugins_app = typer.Typer(help="Manage channel plugins") +plugins_app = typer.Typer(help="Manage optional nanobot features") app.add_typer(plugins_app, name="plugins") @plugins_app.command("list") -def plugins_list(): - """List all discovered channels (built-in and plugins).""" - from nanobot.channels.registry import discover_all, discover_channel_names - from nanobot.config.loader import load_config +def plugins_list( + config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), +): + """List optional nanobot features.""" + from nanobot.channels.registry import discover_channel_names, discover_plugins + from nanobot.config.loader import load_config, set_config_path - config = load_config() - builtin_names = set(discover_channel_names()) - all_channels = discover_all() + 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) - table = Table(title="Channel Plugins") - table.add_column("Name", style="cyan") - table.add_column("Source", style="magenta") - table.add_column("Enabled") + _print_enable_options( + feature_support.optional_dependency_groups(), + set(discover_channel_names()), + discover_plugins(), + load_config(resolved_config_path), + ) - for name in sorted(all_channels): - cls = all_channels[name] - source = "builtin" if name in builtin_names else "plugin" - section = getattr(config.channels, name, None) - if section is None: - enabled = False - elif isinstance(section, dict): - enabled = section.get("enabled", False) - else: - enabled = getattr(section, "enabled", False) - table.add_row( - cls.display_name, - source, - "[green]yes[/green]" if enabled else "[dim]no[/dim]", + +@plugins_app.command("enable") +def plugins_enable( + name: str = typer.Argument(..., help="Feature name (e.g. weixin, matrix, pdf)"), + config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), + logs: bool = typer.Option(False, "--logs/--no-logs", help="Show optional package install logs"), +): + """Enable a nanobot feature.""" + from nanobot.config.loader import get_config_path, 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) + resolved_config_path = resolved_config_path or get_config_path() + _set_nanobot_logs(logs) + + try: + payload = feature_support.enable_optional_feature( + name, + config_path=resolved_config_path, + runner=feature_support.run_install_command, ) + except feature_support.OptionalFeatureError as exc: + console.print(f"[red]{escape(exc.message)}[/red]") + raise typer.Exit(1) from exc - console.print(table) + message = payload.get("last_action", {}).get("message") or f"Enabled feature '{name}'" + console.print(f"[green]{escape(message)}[/green]") + + +@plugins_app.command("disable") +def plugins_disable( + name: str = typer.Argument(..., help="Channel name (e.g. telegram, matrix, slack)"), + config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), +): + """Disable a nanobot channel feature.""" + from nanobot.config.loader import get_config_path, 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) + resolved_config_path = resolved_config_path or get_config_path() + + try: + payload = feature_support.disable_optional_feature(name, config_path=resolved_config_path) + except feature_support.OptionalFeatureError as exc: + console.print(f"[red]{escape(exc.message)}[/red]") + raise typer.Exit(1) from exc + + message = payload.get("last_action", {}).get("message") or f"Disabled channel '{name}'" + console.print(f"[green]{escape(message)}[/green] in {resolved_config_path}") # ============================================================================ diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 78aa12b2..2d99468a 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -377,6 +377,13 @@ class ToolsConfig(Base): "allow_local_preview_access", ), ) # allow WebUI Full Access shell checks against localhost services; legacy allowLocalPreviewAccess still reads + webui_allow_remote_package_install: bool = Field( + default=False, + validation_alias=AliasChoices( + "webuiAllowRemotePackageInstall", + "webui_allow_remote_package_install", + ), + ) # allow non-local WebUI clients to install optional Python packages mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale) diff --git a/nanobot/optional_features.py b/nanobot/optional_features.py new file mode 100644 index 00000000..056f3b3a --- /dev/null +++ b/nanobot/optional_features.py @@ -0,0 +1,434 @@ +"""Optional nanobot feature discovery and enablement.""" +from __future__ import annotations + +import json +import subprocess +import sys +from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError, distribution +from pathlib import Path +from typing import Any + +from loguru import logger +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name + +from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS +from nanobot.config.schema import Config + + +class OptionalFeatureError(Exception): + def __init__(self, message: str, *, status: int = 400) -> None: + super().__init__(message) + self.message = message + self.status = status + + +@dataclass +class InstallResult: + ok: bool + label: str + pip_cmd: list[str] + failed_cmd: list[str] | None = None + output: str = "" + + +_INSTALL_TIMEOUT_SECONDS = 300 +_LOG_OUTPUT_LIMIT = 4000 + + +def load_pyproject(path: Path) -> dict[str, Any]: + try: + import tomllib + + return tomllib.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def optional_dependency_groups_from_metadata() -> dict[str, list[str] | None]: + try: + from importlib.metadata import metadata, requires + except Exception: + return {} + + try: + extras = metadata("nanobot-ai").get_all("Provides-Extra") or [] + groups: dict[str, list[str] | None] = {name: [] for name in extras if name != "dev"} + for raw in requires("nanobot-ai") or []: + try: + req = Requirement(raw) + except Exception: + continue + if not req.marker: + continue + for extra, deps in groups.items(): + if deps is not None and req.marker.evaluate({"extra": extra}): + deps.append(raw) + return groups + except Exception: + return {} + + +def optional_dependency_groups() -> dict[str, list[str] | None]: + root = Path(__file__).resolve().parents[1] + project = load_pyproject(root / "pyproject.toml").get("project", {}) + deps = project.get("optional-dependencies", {}) + if isinstance(deps, dict) and deps: + return { + name: list(values) + for name, values in deps.items() + if name != "dev" and isinstance(values, list) + } + return optional_dependency_groups_from_metadata() + + +def _install_requirements_for_extra(extra: str, deps: list[str]) -> list[str]: + install_args: list[str] = [] + for raw in deps: + try: + req = Requirement(raw) + except Exception: + install_args.append(raw) + continue + if req.marker and not req.marker.evaluate({"extra": extra}): + continue + req.marker = None + install_args.append(str(req)) + return install_args + + +def install_args_for_extra( + extra: str, + deps: list[str] | None, +) -> tuple[list[str], str]: + if deps: + install_args = _install_requirements_for_extra(extra, deps) + if install_args: + return install_args, f"{extra} support" + return [], f"{extra} support" + target = f"nanobot-ai[{extra}]" + return [target], f'"{target}"' + + +def _requirement_installed(req: Requirement, extra: str, seen: set[tuple[str, str]]) -> bool: + if req.marker and not req.marker.evaluate({"extra": extra}): + return True + key = ( + canonicalize_name(req.name), + ",".join(sorted(canonicalize_name(value) for value in req.extras)), + ) + if key in seen: + return True + seen.add(key) + try: + dist = distribution(req.name) + except PackageNotFoundError: + return False + if req.specifier and not req.specifier.contains(dist.version, prereleases=True): + return False + + for requested_extra in req.extras: + if not _extra_dependencies_installed(dist, requested_extra, seen): + return False + return True + + +def _extra_dependencies_installed( + dist: Any, + requested_extra: str, + seen: set[tuple[str, str]], +) -> bool: + normalized = canonicalize_name(requested_extra) + provided = { + canonicalize_name(value) + for value in (dist.metadata.get_all("Provides-Extra") or []) + } + if provided and normalized not in provided: + return False + + matched = False + for raw in dist.requires or []: + try: + req = Requirement(raw) + except Exception: + continue + if req.marker and not req.marker.evaluate({"extra": requested_extra}): + continue + matched = True + if not _requirement_installed(req, requested_extra, seen): + return False + return matched or bool(provided) + + +def requirement_installed(raw: str, extra: str = "") -> bool: + return _requirement_installed(Requirement(raw), extra, set()) + + +def extra_installed(extra: str, deps: list[str] | None) -> bool: + if deps is None: + return True + return all(requirement_installed(dep, extra) for dep in deps) + + +def run_install_command(argv: list[str]) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + argv, + capture_output=True, + text=True, + timeout=_INSTALL_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout.decode(errors="replace") if isinstance(exc.stdout, bytes) else exc.stdout + stderr = exc.stderr.decode(errors="replace") if isinstance(exc.stderr, bytes) else exc.stderr + message = f"Timed out after {_INSTALL_TIMEOUT_SECONDS}s" + stderr = "\n".join(part for part in ((stderr or "").rstrip(), message) if part) + return subprocess.CompletedProcess(argv, 124, stdout=stdout or "", stderr=stderr) + + +def command_text(argv: list[str]) -> str: + return subprocess.list2cmdline([str(part) for part in argv]) + + +def _log_completed_command(label: str, proc: subprocess.CompletedProcess[str]) -> None: + logger.info("{} exited with code {}", label, proc.returncode) + output = (proc.stderr or proc.stdout or "").strip() + if output: + logger.info("{} output:\n{}", label, output[:_LOG_OUTPUT_LIMIT]) + + +def missing_pip(proc: subprocess.CompletedProcess[str]) -> bool: + return "no module named pip" in f"{proc.stdout}\n{proc.stderr}".lower() + + +def install_extra( + extra: str, + deps: list[str] | None, + *, + runner: Any = run_install_command, +) -> InstallResult: + import importlib + + install_args, label = install_args_for_extra(extra, deps) + pip_cmd = [sys.executable, "-m", "pip", "install", *install_args] + if not install_args: + logger.info("Optional feature '{}' has no installable dependencies for this platform", extra) + return InstallResult(True, label, pip_cmd) + + logger.info("Installing optional feature '{}': {}", extra, command_text(pip_cmd)) + proc = runner(pip_cmd) + _log_completed_command(f"Optional feature '{extra}' install", proc) + if proc.returncode == 0: + importlib.invalidate_caches() + return InstallResult(True, label, pip_cmd) + + failed_cmd = pip_cmd + failed_proc = proc + if missing_pip(proc): + ensure_cmd = [sys.executable, "-m", "ensurepip", "--upgrade"] + logger.info("pip missing while installing '{}'; running {}", extra, command_text(ensure_cmd)) + ensure_proc = runner(ensure_cmd) + _log_completed_command(f"Optional feature '{extra}' ensurepip", ensure_proc) + if ensure_proc.returncode == 0: + logger.info("Retrying optional feature '{}': {}", extra, command_text(pip_cmd)) + proc = runner(pip_cmd) + _log_completed_command(f"Optional feature '{extra}' install retry", proc) + if proc.returncode == 0: + importlib.invalidate_caches() + return InstallResult(True, label, pip_cmd) + failed_cmd = pip_cmd + failed_proc = proc + else: + failed_cmd = ensure_cmd + failed_proc = ensure_proc + + output = (failed_proc.stderr or failed_proc.stdout or "").strip() + return InstallResult(False, label, pip_cmd, failed_cmd=failed_cmd, output=output) + + +def read_config_data(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def write_config_data(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + +def merge_missing_defaults(existing: dict[str, Any], defaults: dict[str, Any]) -> dict[str, Any]: + merged = dict(defaults) + for key, value in existing.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = merge_missing_defaults(value, merged[key]) + else: + merged[key] = value + return merged + + +def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[str, Any]) -> None: + data = read_config_data(config_path) + channels = data.setdefault("channels", {}) + existing = channels.get(channel_name, {}) + if not isinstance(existing, dict): + existing = {} + merged = merge_missing_defaults(existing, defaults) + merged["enabled"] = True + channels[channel_name] = merged + write_config_data(config_path, data) + + +def disable_channel_config(config_path: Path, channel_name: str) -> None: + data = read_config_data(config_path) + channels = data.setdefault("channels", {}) + existing = channels.get(channel_name, {}) + if not isinstance(existing, dict): + existing = {} + existing["enabled"] = False + channels[channel_name] = existing + write_config_data(config_path, data) + + +def channel_enabled(config: Config, name: str) -> bool: + section = getattr(config.channels, name, None) + default_enabled = name in DEFAULT_ENABLED_CHANNELS + if section is None: + return default_enabled + if isinstance(section, dict): + return bool(section.get("enabled", default_enabled)) + return bool(getattr(section, "enabled", default_enabled)) + + +def optional_features_payload( + *, + config: Config | None = None, + last_action: dict[str, Any] | None = None, +) -> dict[str, Any]: + from nanobot.channels.registry import discover_channel_names, discover_plugins + from nanobot.config.loader import load_config + + config = config or load_config() + extras = optional_dependency_groups() + builtin_channels = set(discover_channel_names()) + plugin_channels = discover_plugins() + features: list[dict[str, Any]] = [] + + for name in sorted(builtin_channels | set(plugin_channels) | set(extras)): + is_channel = name in builtin_channels or name in plugin_channels + installed = extra_installed(name, extras[name]) if name in extras else True + enabled = channel_enabled(config, name) if is_channel else installed + ready = bool(enabled and installed) + status = "enabled" if ready else "missing_dependency" if not installed else "not_enabled" + features.append( + { + "name": name, + "display_name": name.replace("_", " ").title(), + "type": "channel" if is_channel else "feature", + "enabled": enabled, + "installed": installed, + "ready": ready, + "status": status, + "install_supported": name in extras or is_channel, + "requires_restart": is_channel or name in extras, + } + ) + + payload = { + "features": features, + "enabled_count": sum(1 for feature in features if feature["enabled"]), + } + if last_action: + payload["last_action"] = last_action + return payload + + +def enable_optional_feature( + name: str, + *, + config_path: Path | None = None, + allow_install: bool = True, + runner: Any = run_install_command, +) -> dict[str, Any]: + from nanobot.channels.registry import ( + discover_channel_names, + discover_plugins, + load_channel_class, + ) + from nanobot.config.loader import get_config_path + + config_path = config_path or get_config_path() + extras = optional_dependency_groups() + builtin_channels = set(discover_channel_names()) + plugin_channels = discover_plugins() + known = builtin_channels | set(plugin_channels) | set(extras) + if name not in known: + available = ", ".join(sorted(known)) + raise OptionalFeatureError(f"Unknown feature: {name}. Available: {available}", status=404) + + if name in extras and not extra_installed(name, extras[name]): + if not allow_install: + raise OptionalFeatureError( + "Installing optional features from a remote WebUI is disabled. " + "Run this action from localhost or set tools.webuiAllowRemotePackageInstall to true.", + status=403, + ) + result = install_extra( + name, + extras[name], + runner=runner, + ) + if not result.ok: + failed = command_text(result.failed_cmd or result.pip_cmd) + detail = f": {result.output}" if result.output else "" + raise OptionalFeatureError(f"Failed: {failed}{detail}", status=500) + + if name in builtin_channels: + try: + channel_cls = load_channel_class(name) + except Exception as exc: + raise OptionalFeatureError( + f"Channel '{name}' is not importable after enable: {exc}", + status=500, + ) from exc + enable_channel_config(config_path, name, channel_cls.default_config()) + message = f"Enabled channel '{name}'" + elif name in plugin_channels: + enable_channel_config(config_path, name, plugin_channels[name].default_config()) + message = f"Enabled channel '{name}'" + else: + message = f"Enabled feature '{name}'" + + payload = optional_features_payload(last_action={"ok": True, "message": message, "enabled": True}) + payload["requires_restart"] = bool(name in builtin_channels or name in plugin_channels or name in extras) + return payload + + +def disable_optional_feature( + name: str, + *, + config_path: Path | None = None, +) -> dict[str, Any]: + from nanobot.channels.registry import discover_channel_names, discover_plugins + from nanobot.config.loader import get_config_path + + config_path = config_path or get_config_path() + extras = optional_dependency_groups() + builtin_channels = set(discover_channel_names()) + plugin_channels = discover_plugins() + known_channels = builtin_channels | set(plugin_channels) + known = known_channels | set(extras) + if name not in known: + available = ", ".join(sorted(known)) + raise OptionalFeatureError(f"Unknown feature: {name}. Available: {available}", status=404) + if name not in known_channels: + raise OptionalFeatureError(f"Feature '{name}' cannot be disabled", status=400) + disable_channel_config(config_path, name) + payload = optional_features_payload( + last_action={"ok": True, "message": f"Disabled channel '{name}'", "enabled": False} + ) + payload["requires_restart"] = True + return payload diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index 50256a1c..5100344e 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -14,7 +14,7 @@ Two modes are supported, selected automatically: falls back to :class:`azure.identity.aio.DefaultAzureCredential` and acquires a bearer token scoped to ``https://cognitiveservices.azure.com/.default``. ``azure-identity`` - is an optional dependency installed via ``pip install nanobot-ai[azure]``. + is an optional dependency installed via ``nanobot plugins enable azure``. """ from __future__ import annotations @@ -55,7 +55,7 @@ class _AzureTokenProvider: except ImportError as exc: raise RuntimeError( "Azure OpenAI AAD authentication requires the 'azure-identity' package. " - "Install it with: pip install 'nanobot-ai[azure]'" + "Run: nanobot plugins enable azure" ) from exc self._scope = scope diff --git a/nanobot/providers/bedrock_provider.py b/nanobot/providers/bedrock_provider.py index 129d7edb..b704195a 100644 --- a/nanobot/providers/bedrock_provider.py +++ b/nanobot/providers/bedrock_provider.py @@ -71,7 +71,7 @@ class BedrockProvider(LLMProvider): import boto3 except ImportError as exc: # pragma: no cover - exercised only without boto3 installed raise RuntimeError( - "AWS Bedrock provider requires boto3. Install it with `pip install boto3`." + "AWS Bedrock provider requires boto3. Run `nanobot plugins enable bedrock`." ) from exc session_kwargs: dict[str, Any] = {} diff --git a/nanobot/skills/update-setup/SKILL.md b/nanobot/skills/update-setup/SKILL.md index 0838168f..5c5bf0e1 100644 --- a/nanobot/skills/update-setup/SKILL.md +++ b/nanobot/skills/update-setup/SKILL.md @@ -58,7 +58,7 @@ If the user selected `source (git clone)`, ask for the local checkout path: **Question 2 — Optional dependencies:** ``` -question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, wecom, weixin, msteams, matrix, discord, langsmith, pdf" +question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, dingtalk, discord, documents, feishu, matrix, mochat, msteams, napcat, qq, slack, telegram, wecom, weixin, langsmith, pdf" ``` Parse the reply. If the user says "none" or similar, set extras to empty. Otherwise collect the valid names. diff --git a/nanobot/webui/http_utils.py b/nanobot/webui/http_utils.py index 01f3f54b..24fd265d 100644 --- a/nanobot/webui/http_utils.py +++ b/nanobot/webui/http_utils.py @@ -5,6 +5,7 @@ from __future__ import annotations import email.utils import hmac import http +import ipaddress import json import re from typing import Any @@ -131,6 +132,70 @@ def is_localhost(connection: Any) -> bool: return host in {"127.0.0.1", "::1", "localhost"} +def _host_without_port(value: str) -> str: + value = value.strip().strip('"').strip("'") + if not value: + return "" + if value.startswith("["): + end = value.find("]") + return value[1:end] if end > 0 else value + if value.count(":") == 1: + host, port = value.rsplit(":", 1) + if port.isdigit(): + return host + return value + + +def is_loopback_host(value: str) -> bool: + host = _host_without_port(value) + if host.startswith("::ffff:"): + host = host[7:] + host = host.rstrip(".").lower() + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _split_comma_header(value: str) -> list[str]: + return [part.strip() for part in value.split(",") if part.strip()] + + +def _forwarded_header_values(value: str, key: str) -> list[str]: + values: list[str] = [] + for entry in _split_comma_header(value): + for part in entry.split(";"): + name, sep, raw = part.partition("=") + if sep and name.strip().lower() == key: + cleaned = raw.strip().strip('"') + if cleaned: + values.append(cleaned) + return values + + +def _all_forwarded_values_are_loopback(headers: Any) -> bool: + checks: list[str] = [] + checks.extend(_split_comma_header(case_insensitive_header(headers, "X-Forwarded-For"))) + checks.extend(_split_comma_header(case_insensitive_header(headers, "X-Real-IP"))) + checks.extend(_split_comma_header(case_insensitive_header(headers, "X-Forwarded-Host"))) + forwarded = case_insensitive_header(headers, "Forwarded") + checks.extend(_forwarded_header_values(forwarded, "for")) + checks.extend(_forwarded_header_values(forwarded, "host")) + return all(is_loopback_host(value) for value in checks) + + +def is_local_browser_request(connection: Any, headers: Any) -> bool: + """Return True only for a local TCP peer presenting a local browser origin.""" + if not is_localhost(connection): + return False + host = case_insensitive_header(headers, "Host") + if not is_loopback_host(host): + return False + return _all_forwarded_values_are_loopback(headers) + + def bearer_token(headers: Any) -> str | None: auth = headers.get("Authorization") or headers.get("authorization") if auth and auth.lower().startswith("bearer "): diff --git a/nanobot/webui/nanobot_features_api.py b/nanobot/webui/nanobot_features_api.py new file mode 100644 index 00000000..36773c60 --- /dev/null +++ b/nanobot/webui/nanobot_features_api.py @@ -0,0 +1,40 @@ +"""Nanobot optional feature helpers for WebUI Settings.""" +from __future__ import annotations + +from typing import Any + +from nanobot.optional_features import ( + OptionalFeatureError, + disable_optional_feature, + enable_optional_feature, + optional_features_payload, +) +from nanobot.webui.http_utils import query_first + +QueryParams = dict[str, list[str]] + + +def nanobot_features_payload() -> dict[str, Any]: + return optional_features_payload() + + +def nanobot_features_action( + action: str, + query: QueryParams, + *, + allow_install: bool = True, +) -> dict[str, Any]: + name = (query_first(query, "name") or "").strip() + if not name: + raise OptionalFeatureError("missing feature name") + if action == "enable": + return enable_optional_feature(name, allow_install=allow_install) + if action == "disable": + if name == "websocket": + raise OptionalFeatureError( + "The WebUI websocket channel cannot be disabled from WebUI. " + "Use `nanobot plugins disable websocket` from a terminal if you need to disable it.", + status=400, + ) + return disable_optional_feature(name) + raise OptionalFeatureError(f"unknown feature action '{action}'", status=404) diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index b66c2b6e..52223520 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -17,9 +17,13 @@ from websockets.http11 import Response from nanobot.agent.tools.mcp import request_mcp_reload from nanobot.bus.queue import MessageBus +from nanobot.config.loader import load_config +from nanobot.optional_features import OptionalFeatureError from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload +from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request from nanobot.webui.http_utils import query_first as _query_first from nanobot.webui.mcp_presets_api import mcp_presets_settings_action +from nanobot.webui.nanobot_features_api import nanobot_features_action, nanobot_features_payload from nanobot.webui.settings_api import ( WebUISettingsError, create_model_configuration, @@ -80,7 +84,7 @@ class WebUISettingsRouter: self._runtime_capabilities = runtime_capabilities self._restart_sections: set[str] = set() - async def dispatch(self, request: WsRequest, path: str) -> Response | None: + async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None: if path == "/api/settings": return self._handle_settings(request) if path == "/api/settings/usage": @@ -117,6 +121,12 @@ class WebUISettingsRouter: return await self._handle_settings_cli_apps_action(request, "uninstall") if path == "/api/settings/cli-apps/test": return await self._handle_settings_cli_apps_action(request, "test") + if path == "/api/settings/nanobot-features": + return await self._handle_settings_nanobot_features(request) + if path == "/api/settings/nanobot-features/enable": + return await self._handle_settings_nanobot_features_action(connection, request, "enable") + if path == "/api/settings/nanobot-features/disable": + return await self._handle_settings_nanobot_features_action(connection, request, "disable") if path == "/api/settings/mcp-presets": return await self._handle_settings_mcp_presets(request) if path == "/api/settings/version-check": @@ -334,6 +344,51 @@ class WebUISettingsRouter: return self._error_response(status, message) return self._json_response(payload) + async def _handle_settings_nanobot_features(self, request: WsRequest) -> Response: + if not self._authorized(request): + return self._unauthorized() + try: + payload = await asyncio.to_thread(nanobot_features_payload) + except Exception: + self.logger.exception("failed to load nanobot features") + return self._error_response(500, "failed to load nanobot features") + return self._json_response(payload) + + async def _handle_settings_nanobot_features_action( + self, + connection: Any, + request: WsRequest, + action: str, + ) -> Response: + if not self._authorized(request): + return self._unauthorized() + try: + payload = await asyncio.to_thread( + nanobot_features_action, + action, + self._query(request), + allow_install=action != "enable" + or self._allow_feature_package_install(connection, request), + ) + except OptionalFeatureError as e: + return self._error_response(e.status, e.message) + except Exception as e: + status = getattr(e, "status", 500) + message = getattr(e, "message", str(e)) + if status >= 500: + self.logger.exception("nanobot feature action '{}' failed", action) + return self._error_response(status, message) + return self._json_response(self._with_restart_state(payload, section="runtime")) + + def _allow_feature_package_install(self, connection: Any, request: WsRequest) -> bool: + if _is_local_browser_request(connection, request.headers): + return True + try: + return bool(load_config().tools.webui_allow_remote_package_install) + except Exception: + self.logger.exception("failed to load remote package install policy") + return False + async def _handle_settings_mcp_presets( self, request: WsRequest, diff --git a/nanobot/webui/websocket_logging.py b/nanobot/webui/websocket_logging.py index 046b7a06..a9f3ef9b 100644 --- a/nanobot/webui/websocket_logging.py +++ b/nanobot/webui/websocket_logging.py @@ -21,6 +21,7 @@ def _exception_chain_has_disconnect(exc: BaseException | None) -> bool: ConnectionAbortedError, ConnectionResetError, ConnectionClosed, + EOFError, )): return True exc = exc.__cause__ or exc.__context__ diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 5964db25..24b3b6ce 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -231,7 +231,7 @@ class GatewayHTTPHandler: return self._handle_bootstrap(connection, request) # Settings routes (delegated) - response = await self.settings_routes.dispatch(request, got) + response = await self.settings_routes.dispatch(connection, request, got) if response is not None: return response diff --git a/pyproject.toml b/pyproject.toml index cdc6cc0f..45fd5615 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,16 +37,6 @@ dependencies = [ "lxml-html-clean>=0.4.0,<1.0.0", "rich>=14.0.0,<15.0.0", "croniter>=6.0.0,<7.0.0", - "dingtalk-stream>=0.24.0,<1.0.0", - "python-telegram-bot[socks,webhooks]>=22.6,<23.0", - "lark-oapi>=1.5.0,<2.0.0", - "socksio>=1.0.0,<2.0.0", - "python-socketio>=5.16.0,<6.0.0", - "msgpack>=1.1.0,<2.0.0", - "slack-sdk>=3.39.0,<4.0.0", - "slackify-markdown>=0.2.0,<1.0.0", - "qq-botpy>=1.2.0,<2.0.0", - "python-socks[asyncio]>=2.8.0,<3.0.0; sys_platform != 'win32'", "prompt-toolkit>=3.0.50,<4.0.0", "questionary>=2.0.0,<3.0.0", "mcp>=1.26.0,<2.0.0", @@ -57,12 +47,8 @@ dependencies = [ "jinja2>=3.1.0,<4.0.0", "dulwich>=0.22.0,<1.0.0", "pyyaml>=6.0,<7.0.0", - "pypdf>=5.0.0,<6.0.0", - "python-docx>=1.1.0,<2.0.0", - "openpyxl>=3.1.0,<4.0.0", - "python-pptx>=1.0.0,<2.0.0", "filelock>=3.25.2", - "boto3>=1.43.0", + "packaging>=24.0", ] [project.optional-dependencies] @@ -72,6 +58,41 @@ api = [ azure = [ "azure-identity>=1.19.0,<2.0.0", ] +bedrock = [ + "boto3>=1.43.0", +] +dingtalk = [ + "dingtalk-stream>=0.24.0,<1.0.0", +] +documents = [ + "pypdf>=5.0.0,<6.0.0", + "python-docx>=1.1.0,<2.0.0", + "openpyxl>=3.1.0,<4.0.0", + "python-pptx>=1.0.0,<2.0.0", +] +feishu = [ + "lark-oapi>=1.5.0,<2.0.0", +] +mochat = [ + "python-socketio>=5.16.0,<6.0.0", + "msgpack>=1.1.0,<2.0.0", +] +napcat = [ + "aiohttp>=3.9.0,<4.0.0", +] +qq = [ + "aiohttp>=3.9.0,<4.0.0", + "qq-botpy>=1.2.0,<2.0.0", +] +slack = [ + "slack-sdk>=3.39.0,<4.0.0", + "slackify-markdown>=0.2.0,<1.0.0", +] +telegram = [ + "python-telegram-bot[socks,webhooks]>=22.6,<23.0", + "socksio>=1.0.0,<2.0.0", + "python-socks[asyncio]>=2.8.0,<3.0.0; sys_platform != 'win32'", +] wecom = [ "wecom-aibot-sdk-python>=0.1.5", ] @@ -86,6 +107,7 @@ msteams = [ matrix = [ "matrix-nio[e2e]>=0.25.2; sys_platform != 'win32'", + "matrix-nio>=0.25.2; sys_platform == 'win32'", "aiohttp>=3.9.0,<4.0.0", "mistune>=3.0.0,<4.0.0", "nh3>=0.2.17,<1.0.0", @@ -113,6 +135,12 @@ dev = [ "pytest-cov>=6.0.0,<7.0.0", "ruff>=0.1.0", "pymupdf>=1.25.0", + "pypdf>=5.0.0,<6.0.0", + "python-docx>=1.1.0,<2.0.0", + "openpyxl>=3.1.0,<4.0.0", + "python-pptx>=1.0.0,<2.0.0", + "python-socketio>=5.16.0,<6.0.0", + "msgpack>=1.1.0,<2.0.0", ] [project.scripts] diff --git a/tests/channels/test_channel_manager_delta_coalescing.py b/tests/channels/test_channel_manager_delta_coalescing.py index 688e133f..df8c3f0e 100644 --- a/tests/channels/test_channel_manager_delta_coalescing.py +++ b/tests/channels/test_channel_manager_delta_coalescing.py @@ -62,7 +62,8 @@ class MockChannel(BaseChannel): @pytest.fixture def config(): - return Config() + """Create a minimal config for testing.""" + return Config.model_validate({"channels": {"websocket": {"enabled": False}}}) @pytest.fixture diff --git a/tests/channels/test_channel_manager_reasoning.py b/tests/channels/test_channel_manager_reasoning.py index 1ad480dd..df593aa6 100644 --- a/tests/channels/test_channel_manager_reasoning.py +++ b/tests/channels/test_channel_manager_reasoning.py @@ -21,7 +21,11 @@ from unittest.mock import AsyncMock import pytest from nanobot.bus.events import OutboundMessage -from nanobot.bus.outbound_events import ProgressEvent, outbound_message_for_event +from nanobot.bus.outbound_events import ( + ProgressEvent, + outbound_event_from_message, + outbound_message_for_event, +) from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.channels.manager import ChannelManager @@ -60,7 +64,8 @@ class _MockChannel(BaseChannel): @pytest.fixture def manager() -> ChannelManager: - mgr = ChannelManager(Config(), MessageBus()) + config = Config.model_validate({"channels": {"websocket": {"enabled": False}}}) + mgr = ChannelManager(config, MessageBus()) mgr.channels["mock"] = _MockChannel({}, mgr.bus) return mgr @@ -291,14 +296,22 @@ async def test_reasoning_routing_does_not_consult_send_progress(manager): async def _pump_one(manager: ChannelManager) -> None: - """Drive the dispatcher until the outbound queue drains, then cancel.""" - task = asyncio.create_task(manager._dispatch_outbound()) - for _ in range(50): - await asyncio.sleep(0.01) - if manager.bus.outbound.qsize() == 0: + """Process currently queued messages through the reasoning dispatch branch.""" + + async def dispatch_one(msg: OutboundMessage) -> None: + event = outbound_event_from_message(msg) + if isinstance(event, ProgressEvent) and ( + event.reasoning_delta + or event.reasoning_end + or event.reasoning + ): + channel = manager.channels.get(msg.channel) + if channel is not None and channel.show_reasoning: + await manager._send_with_retry(channel, msg) + + await dispatch_one(await asyncio.wait_for(manager.bus.consume_outbound(), timeout=1.0)) + while True: + try: + await dispatch_one(manager.bus.outbound.get_nowait()) + except asyncio.QueueEmpty: break - task.cancel() - try: - await task - except asyncio.CancelledError: - pass diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index ccf8a4b8..aff917a0 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -3,6 +3,12 @@ from __future__ import annotations import asyncio +import json +import subprocess +import sys +import tomllib +from importlib.metadata import PackageNotFoundError +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch @@ -72,6 +78,28 @@ def _make_entry_point(name: str, cls: type): return ep +def _stub_optional_feature_cli( + monkeypatch: pytest.MonkeyPatch, + *, + extras: dict[str, list[str] | None], + installed: bool, + commands: list[list[str]] | None = None, + channels: list[str] | None = None, + channel_cls: type[BaseChannel] | None = None, +) -> None: + monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: channels or []) + monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + if channel_cls is not None: + monkeypatch.setattr("nanobot.channels.registry.load_channel_class", lambda _name: channel_cls) + monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: extras) + monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: installed) + if commands is not None: + monkeypatch.setattr( + "nanobot.optional_features.run_install_command", + lambda argv: commands.append(argv) or subprocess.CompletedProcess(argv, 0, "", ""), + ) + + # --------------------------------------------------------------------------- # ChannelsConfig extra="allow" # --------------------------------------------------------------------------- @@ -203,6 +231,23 @@ def test_discover_enabled_imports_only_enabled_builtins(): assert loaded == ["enabled"] +def test_discover_enabled_warns_for_enabled_builtin_import_errors(): + from nanobot.channels.registry import discover_enabled + + with ( + patch("nanobot.channels.registry.load_channel_class", side_effect=ImportError("missing sdk")), + patch(_EP_TARGET, return_value=[]), + patch("nanobot.channels.registry.logger.warning") as warning, + ): + result = discover_enabled({"matrix"}, _names=["matrix"], warn_import_errors=True) + + assert result == {} + warning.assert_called_once() + assert warning.call_args.args[0] == "Enabled built-in channel '{}' is not available: {}" + assert warning.call_args.args[1] == "matrix" + assert "missing sdk" in str(warning.call_args.args[2]) + + def test_discover_all_builtin_shadows_plugin(): from nanobot.channels.registry import discover_all @@ -214,6 +259,20 @@ def test_discover_all_builtin_shadows_plugin(): assert result["telegram"] is not _FakeTelegram +def test_discover_all_builtin_name_shadows_plugin_when_dependency_missing(): + from nanobot.channels.registry import discover_all + + ep = _make_entry_point("telegram", _FakeTelegram) + with ( + patch("nanobot.channels.registry.discover_channel_names", return_value=["telegram"]), + patch("nanobot.channels.registry.load_channel_class", side_effect=ImportError("missing")), + patch(_EP_TARGET, return_value=[ep]), + ): + result = discover_all() + + assert "telegram" not in result + + # --------------------------------------------------------------------------- # Manager _init_channels with dict config (plugin scenario) # --------------------------------------------------------------------------- @@ -245,6 +304,54 @@ async def test_manager_loads_plugin_from_dict_config(): assert isinstance(mgr.channels["fakeplugin"], _FakePlugin) +def test_manager_loads_websocket_from_default_config(): + from nanobot.channels.manager import ChannelManager + + class _FakeWebSocket(_FakePlugin): + name = "websocket" + display_name = "WebSocket" + + def __init__(self, config, bus, *, gateway): + super().__init__(config, bus) + self.gateway = gateway + + seen_enabled: set[str] = set() + + def _discover_enabled(enabled_names: set[str], _names=None, warn_import_errors: bool = False): + seen_enabled.update(enabled_names) + return {"websocket": _FakeWebSocket} if "websocket" in enabled_names else {} + + with ( + patch("nanobot.channels.registry.discover_channel_names", return_value=["websocket"]), + patch("nanobot.channels.registry.discover_enabled", side_effect=_discover_enabled), + ): + mgr = ChannelManager(Config(), MessageBus(), webui_static_dist=False) + + assert "websocket" in seen_enabled + assert mgr.channels["websocket"].config["enabled"] is True + assert mgr.channels["websocket"].config["host"] == "127.0.0.1" + + +def test_manager_respects_explicitly_disabled_websocket_config(): + from nanobot.channels.manager import ChannelManager + + seen_enabled: set[str] = set() + + def _discover_enabled(enabled_names: set[str], _names=None, warn_import_errors: bool = False): + seen_enabled.update(enabled_names) + return {} + + config = Config.model_validate({"channels": {"websocket": {"enabled": False}}}) + with ( + patch("nanobot.channels.registry.discover_channel_names", return_value=["websocket"]), + patch("nanobot.channels.registry.discover_enabled", side_effect=_discover_enabled), + ): + mgr = ChannelManager(config, MessageBus(), webui_static_dist=False) + + assert "websocket" not in seen_enabled + assert "websocket" not in mgr.channels + + @pytest.mark.asyncio async def test_base_channel_reads_current_transcription_config_each_call( tmp_path, @@ -510,6 +617,592 @@ def test_channels_status_sets_custom_config_path(monkeypatch, tmp_path): assert seen["config_path"] == config_path.resolve() +def test_plugins_list_shows_available_features(monkeypatch): + from typer.testing import CliRunner + + from nanobot.cli.commands import app + from nanobot.config.schema import Config + + runner = CliRunner() + config = Config.model_validate({"channels": {"weixin": {"enabled": True}}}) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: config) + monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"]) + monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + monkeypatch.setattr( + "nanobot.optional_features.optional_dependency_groups", + lambda: {"weixin": ["qrcode[pil]>=8.0"], "bedrock": ["boto3>=1.43.0"]}, + ) + + result = runner.invoke(app, ["plugins", "list"]) + + assert result.exit_code == 0 + assert "Available Features" in result.stdout + assert "weixin" in result.stdout + assert "bedrock" in result.stdout + assert "channel" in result.stdout + assert "feature" in result.stdout + assert " - " not in result.stdout + + +def test_plugins_enable_channel_installs_extra_and_writes_config(monkeypatch, tmp_path): + from typer.testing import CliRunner + + from nanobot.cli.commands import app + + class _WeixinChannel(_FakePlugin): + name = "weixin" + display_name = "Weixin" + + @classmethod + def default_config(cls): + return {"enabled": False, "token": "", "allowFrom": []} + + commands: list[list[str]] = [] + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps({"channels": {"weixin": {"enabled": False, "token": "keep"}}}), + encoding="utf-8", + ) + + runner = CliRunner() + _stub_optional_feature_cli( + monkeypatch, + extras={"weixin": ["qrcode[pil]>=8.0", "pycryptodome>=3.20.0"]}, + installed=False, + commands=commands, + channels=["weixin"], + channel_cls=_WeixinChannel, + ) + + result = runner.invoke(app, ["plugins", "enable", "weixin", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert commands == [ + [sys.executable, "-m", "pip", "install", "qrcode[pil]>=8.0", "pycryptodome>=3.20.0"] + ] + data = json.loads(config_path.read_text(encoding="utf-8")) + assert data["channels"]["weixin"]["enabled"] is True + assert data["channels"]["weixin"]["token"] == "keep" + assert data["channels"]["weixin"]["allowFrom"] == [] + + +def test_plugins_enable_extra_without_channel_only_installs(monkeypatch, tmp_path): + from typer.testing import CliRunner + + from nanobot.cli import commands as cli_commands + from nanobot.cli.commands import app + + commands: list[list[str]] = [] + log_flags: list[bool] = [] + config_path = tmp_path / "config.json" + original_set_logs = cli_commands._set_nanobot_logs + + def _set_logs(enabled: bool) -> None: + log_flags.append(enabled) + original_set_logs(enabled) + + runner = CliRunner() + _stub_optional_feature_cli( + monkeypatch, + extras={"bedrock": ["boto3>=1.43.0"]}, + installed=False, + commands=commands, + ) + monkeypatch.setattr("nanobot.cli.commands._set_nanobot_logs", _set_logs) + + result = runner.invoke(app, ["plugins", "enable", "bedrock", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert log_flags == [False] + assert commands == [[sys.executable, "-m", "pip", "install", "boto3>=1.43.0"]] + assert "Installing optional feature" not in result.output + assert not config_path.exists() + + +def test_plugins_enable_logs_option_enables_nanobot_logs(monkeypatch, tmp_path): + from typer.testing import CliRunner + + from nanobot.cli import commands as cli_commands + from nanobot.cli.commands import app + + config_path = tmp_path / "config.json" + log_flags: list[bool] = [] + original_set_logs = cli_commands._set_nanobot_logs + + def _set_logs(enabled: bool) -> None: + log_flags.append(enabled) + original_set_logs(enabled) + + runner = CliRunner() + _stub_optional_feature_cli( + monkeypatch, + extras={"bedrock": ["boto3>=1.43.0"]}, + installed=False, + commands=[], + ) + monkeypatch.setattr("nanobot.cli.commands._set_nanobot_logs", _set_logs) + + result = runner.invoke( + app, + ["plugins", "enable", "bedrock", "--logs", "--config", str(config_path)], + ) + + assert result.exit_code == 0 + assert log_flags == [True] + assert "Enabled feature 'bedrock'" in result.output + + +def test_plugins_enable_skips_install_when_extra_is_present(monkeypatch, tmp_path): + from typer.testing import CliRunner + + from nanobot.cli.commands import app + + commands: list[list[str]] = [] + config_path = tmp_path / "config.json" + + runner = CliRunner() + _stub_optional_feature_cli( + monkeypatch, + extras={"bedrock": ["boto3>=1.43.0"]}, + installed=True, + commands=commands, + ) + + result = runner.invoke(app, ["plugins", "enable", "bedrock", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert commands == [] + assert not config_path.exists() + + +def test_plugins_disable_channel_writes_config(monkeypatch, tmp_path): + from typer.testing import CliRunner + + from nanobot.cli.commands import app + + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps({"channels": {"matrix": {"enabled": True, "homeserver": "keep"}}}), + encoding="utf-8", + ) + runner = CliRunner() + monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["matrix"]) + monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) + + result = runner.invoke(app, ["plugins", "disable", "matrix", "--config", str(config_path)]) + + assert result.exit_code == 0 + assert "Disabled channel 'matrix'" in result.output + data = json.loads(config_path.read_text(encoding="utf-8")) + assert data["channels"]["matrix"]["enabled"] is False + assert data["channels"]["matrix"]["homeserver"] == "keep" + + +def test_plugins_disable_rejects_non_channel_and_allows_websocket(monkeypatch, tmp_path): + from typer.testing import CliRunner + + from nanobot.cli.commands import app + + config_path = tmp_path / "config.json" + runner = CliRunner() + monkeypatch.setattr( + "nanobot.channels.registry.discover_channel_names", + lambda: ["matrix", "websocket"], + ) + monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + monkeypatch.setattr( + "nanobot.optional_features.optional_dependency_groups", + lambda: {"bedrock": ["boto3>=1.43.0"]}, + ) + + non_channel = runner.invoke( + app, + ["plugins", "disable", "bedrock", "--config", str(config_path)], + ) + websocket = runner.invoke( + app, + ["plugins", "disable", "websocket", "--config", str(config_path)], + ) + + assert non_channel.exit_code == 1 + assert "Feature 'bedrock' cannot be disabled" in non_channel.output + assert websocket.exit_code == 0 + assert "Disabled channel 'websocket'" in websocket.output + assert json.loads(config_path.read_text(encoding="utf-8"))["channels"]["websocket"][ + "enabled" + ] is False + + +def test_enable_optional_feature_blocks_install_when_disallowed(monkeypatch, tmp_path): + from nanobot.optional_features import OptionalFeatureError, enable_optional_feature + + config_path = tmp_path / "config.json" + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: []) + monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + monkeypatch.setattr( + "nanobot.optional_features.optional_dependency_groups", + lambda: {"bedrock": ["boto3>=1.43.0"]}, + ) + monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: False) + + with pytest.raises(OptionalFeatureError) as exc: + enable_optional_feature("bedrock", config_path=config_path, allow_install=False) + + assert exc.value.status == 403 + assert "remote WebUI is disabled" in exc.value.message + assert not config_path.exists() + + +def test_enable_optional_feature_skips_install_when_dependency_present( + monkeypatch, + tmp_path, +): + from nanobot.optional_features import InstallResult, enable_optional_feature + + config_path = tmp_path / "config.json" + install_calls: list[str] = [] + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: []) + monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + monkeypatch.setattr( + "nanobot.optional_features.optional_dependency_groups", + lambda: {"bedrock": ["boto3>=1.43.0"]}, + ) + monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: True) + + def _install_extra( + name: str, + deps: list[str] | None, + *, + runner, + ) -> InstallResult: + install_calls.append(name) + return InstallResult(True, f"{name} support", ["python", "-m", "pip", "install", name]) + + monkeypatch.setattr("nanobot.optional_features.install_extra", _install_extra) + + payload = enable_optional_feature("bedrock", config_path=config_path, allow_install=False) + + assert install_calls == [] + assert payload["last_action"]["message"] == "Enabled feature 'bedrock'" + assert payload["requires_restart"] is True + assert not config_path.exists() + + +def test_enable_optional_feature_reports_install_failure(monkeypatch, tmp_path): + from nanobot.optional_features import ( + InstallResult, + OptionalFeatureError, + enable_optional_feature, + ) + + config_path = tmp_path / "config.json" + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: []) + monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + monkeypatch.setattr( + "nanobot.optional_features.optional_dependency_groups", + lambda: {"bedrock": ["boto3>=1.43.0"]}, + ) + monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: False) + monkeypatch.setattr( + "nanobot.optional_features.install_extra", + lambda _name, _deps, *, runner: InstallResult( + False, + "bedrock support", + ["python", "-m", "pip", "install", "boto3>=1.43.0"], + failed_cmd=["python", "-m", "pip", "install", "boto3>=1.43.0"], + output="network unavailable", + ), + ) + + with pytest.raises(OptionalFeatureError) as exc: + enable_optional_feature("bedrock", config_path=config_path) + + assert exc.value.status == 500 + assert "Failed:" in exc.value.message + assert "network unavailable" in exc.value.message + assert not config_path.exists() + + +def test_disable_optional_feature_rejects_unknown_features_and_non_channels( + monkeypatch, + tmp_path, +): + from nanobot.optional_features import OptionalFeatureError, disable_optional_feature + + config_path = tmp_path / "config.json" + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + monkeypatch.setattr( + "nanobot.channels.registry.discover_channel_names", + lambda: ["matrix", "websocket"], + ) + monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + monkeypatch.setattr( + "nanobot.optional_features.optional_dependency_groups", + lambda: {"bedrock": ["boto3>=1.43.0"]}, + ) + + with pytest.raises(OptionalFeatureError) as unknown: + disable_optional_feature("missing", config_path=config_path) + assert unknown.value.status == 404 + assert "Unknown feature: missing" in unknown.value.message + + with pytest.raises(OptionalFeatureError) as non_channel: + disable_optional_feature("bedrock", config_path=config_path) + assert non_channel.value.status == 400 + assert non_channel.value.message == "Feature 'bedrock' cannot be disabled" + + assert not config_path.exists() + + +def test_disable_optional_feature_writes_channel_disabled(monkeypatch, tmp_path): + from nanobot.optional_features import disable_optional_feature + + config_path = tmp_path / "config.json" + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + config_path.write_text( + json.dumps({"channels": {"matrix": {"enabled": True, "homeserver": "keep"}}}), + encoding="utf-8", + ) + monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["matrix", "websocket"]) + monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) + + payload = disable_optional_feature("matrix", config_path=config_path) + + data = json.loads(config_path.read_text(encoding="utf-8")) + assert data["channels"]["matrix"]["enabled"] is False + assert data["channels"]["matrix"]["homeserver"] == "keep" + assert payload["last_action"]["message"] == "Disabled channel 'matrix'" + assert payload["requires_restart"] is True + + payload = disable_optional_feature("websocket", config_path=config_path) + data = json.loads(config_path.read_text(encoding="utf-8")) + assert data["channels"]["websocket"]["enabled"] is False + assert payload["last_action"]["message"] == "Disabled channel 'websocket'" + + +def test_optional_features_payload_counts_enabled_channel_with_missing_dependency( + monkeypatch, +): + from nanobot.optional_features import optional_features_payload + + config = Config.model_validate({"channels": {"matrix": {"enabled": True}}}) + monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["matrix"]) + monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + monkeypatch.setattr( + "nanobot.optional_features.optional_dependency_groups", + lambda: {"matrix": ["matrix-nio>=0.25.2"]}, + ) + monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: False) + + payload = optional_features_payload(config=config) + + matrix = payload["features"][0] + assert matrix["name"] == "matrix" + assert matrix["enabled"] is True + assert matrix["installed"] is False + assert matrix["ready"] is False + assert payload["enabled_count"] == 1 + + +def test_enable_bootstraps_pip_with_ensurepip(monkeypatch): + from nanobot import optional_features + + calls: list[list[str]] = [] + + def _run(argv: list[str]) -> subprocess.CompletedProcess[str]: + calls.append(argv) + if len(calls) == 1: + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="No module named pip") + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + + assert optional_features.install_extra("weixin", None, runner=_run).ok is True + assert calls == [ + [sys.executable, "-m", "pip", "install", "nanobot-ai[weixin]"], + [sys.executable, "-m", "ensurepip", "--upgrade"], + [sys.executable, "-m", "pip", "install", "nanobot-ai[weixin]"], + ] + + +def test_install_extra_logs_command_and_output(monkeypatch): + from nanobot import optional_features + + records: list[str] = [] + + class _Logger: + def info(self, message: str, *args: object) -> None: + records.append(message.format(*args)) + + def _run(argv: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(argv, 0, stdout="install ok", stderr="") + + monkeypatch.setattr(optional_features, "logger", _Logger()) + + result = optional_features.install_extra("weixin", ["qrcode[pil]>=8.0"], runner=_run) + + assert result.ok is True + assert any("Installing optional feature 'weixin':" in record for record in records) + assert any("Optional feature 'weixin' install exited with code 0" in record for record in records) + assert any("install ok" in record for record in records) + + +def test_run_install_command_returns_failure_on_timeout(monkeypatch): + from nanobot import optional_features + + def _run(*args, **kwargs): + raise subprocess.TimeoutExpired(["pip"], 300, output="partial", stderr=b"still running") + + monkeypatch.setattr(optional_features.subprocess, "run", _run) + + result = optional_features.run_install_command(["pip"]) + + assert result.returncode == 124 + assert result.stdout == "partial" + assert result.stderr == "still running\nTimed out after 300s" + + +def test_optional_dependency_metadata_for_enable(): + data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) + deps = data["project"]["optional-dependencies"] + required = data["project"]["dependencies"] + + assert "boto3>=1.43.0" not in data["project"]["dependencies"] + assert deps["bedrock"] == ["boto3>=1.43.0"] + for dep_name in ( + "aiohttp", + "dingtalk-stream", + "lark-oapi", + "msgpack", + "openpyxl", + "pypdf", + "python-telegram-bot", + "python-docx", + "python-pptx", + "python-socketio", + "qq-botpy", + "slack-sdk", + "slackify-markdown", + ): + assert not any(dep.startswith(dep_name) for dep in required) + assert deps["dingtalk"] == ["dingtalk-stream>=0.24.0,<1.0.0"] + assert deps["documents"] == [ + "pypdf>=5.0.0,<6.0.0", + "python-docx>=1.1.0,<2.0.0", + "openpyxl>=3.1.0,<4.0.0", + "python-pptx>=1.0.0,<2.0.0", + ] + assert deps["feishu"] == ["lark-oapi>=1.5.0,<2.0.0"] + assert deps["mochat"] == [ + "python-socketio>=5.16.0,<6.0.0", + "msgpack>=1.1.0,<2.0.0", + ] + assert deps["napcat"] == ["aiohttp>=3.9.0,<4.0.0"] + assert deps["qq"] == ["aiohttp>=3.9.0,<4.0.0", "qq-botpy>=1.2.0,<2.0.0"] + assert deps["slack"] == [ + "slack-sdk>=3.39.0,<4.0.0", + "slackify-markdown>=0.2.0,<1.0.0", + ] + assert any(dep.startswith("python-telegram-bot") for dep in deps["telegram"]) + assert any( + dep.startswith("matrix-nio>=0.25.2") and "sys_platform == 'win32'" in dep + for dep in deps["matrix"] + ) + + +def test_optional_dependency_groups_falls_back_to_package_metadata(monkeypatch): + from nanobot import optional_features + + class _Metadata: + def get_all(self, key: str): + assert key == "Provides-Extra" + return ["bedrock", "dev"] + + monkeypatch.setattr(optional_features, "load_pyproject", lambda _path: {}) + monkeypatch.setattr("importlib.metadata.metadata", lambda _name: _Metadata()) + monkeypatch.setattr( + "importlib.metadata.requires", + lambda _name: [ + "packaging>=24.0", + "boto3>=1.43.0; extra == 'bedrock'", + "pytest>=8.0; extra == 'dev'", + ], + ) + + deps = optional_features.optional_dependency_groups() + + assert deps == {"bedrock": ["boto3>=1.43.0; extra == 'bedrock'"]} + assert optional_features.install_args_for_extra("bedrock", deps["bedrock"]) == ( + ["boto3>=1.43.0"], + "bedrock support", + ) + + +def test_install_args_for_extra_resolves_metadata_markers_for_current_platform(): + from nanobot import optional_features + + current_platform = sys.platform + deps = [ + f"current-platform-package>=1.0; sys_platform == '{current_platform}' and extra == 'matrix'", + "other-platform-package>=1.0; sys_platform == 'never' and extra == 'matrix'", + ] + + assert optional_features.install_args_for_extra("matrix", deps) == ( + ["current-platform-package>=1.0"], + "matrix support", + ) + + +def test_requirement_installed_validates_requested_extras(monkeypatch): + from nanobot import optional_features + + class _Metadata: + def __init__(self, extras: list[str] | None = None) -> None: + self._extras = extras or [] + + def get_all(self, key: str): + assert key == "Provides-Extra" + return self._extras + + class _Distribution: + def __init__( + self, + version: str, + *, + requires: list[str] | None = None, + extras: list[str] | None = None, + ) -> None: + self.version = version + self.requires = requires or [] + self.metadata = _Metadata(extras) + + installed: dict[str, _Distribution] = { + "qrcode": _Distribution( + "8.2", + requires=["pillow>=9.1; extra == 'pil'"], + extras=["pil"], + ), + } + + def _distribution(name: str) -> _Distribution: + normalized = name.lower() + if normalized not in installed: + raise PackageNotFoundError(name) + return installed[normalized] + + monkeypatch.setattr(optional_features, "distribution", _distribution) + + assert optional_features.requirement_installed("qrcode>=8.0") is True + assert optional_features.requirement_installed("qrcode[pil]>=8.0") is False + + installed["pillow"] = _Distribution("10.0") + + assert optional_features.requirement_installed("qrcode[pil]>=8.0") is True + + @pytest.mark.asyncio async def test_manager_skips_disabled_plugin(): fake_config = SimpleNamespace( @@ -537,19 +1230,19 @@ async def test_manager_skips_disabled_plugin(): def test_builtin_channel_default_config(): """Built-in channels expose default_config() returning a dict with 'enabled': False.""" - from nanobot.channels.telegram import TelegramChannel - cfg = TelegramChannel.default_config() + from nanobot.channels.dingtalk import DingTalkChannel + cfg = DingTalkChannel.default_config() assert isinstance(cfg, dict) assert cfg["enabled"] is False - assert "token" in cfg + assert "clientId" in cfg def test_builtin_channel_init_from_dict(): """Built-in channels accept a raw dict and convert to Pydantic internally.""" - from nanobot.channels.telegram import TelegramChannel + from nanobot.channels.dingtalk import DingTalkChannel bus = MessageBus() - ch = TelegramChannel({"enabled": False, "token": "test-tok", "allowFrom": ["*"]}, bus) - assert ch.config.token == "test-tok" + ch = DingTalkChannel({"enabled": False, "clientId": "test-id", "allowFrom": ["*"]}, bus) + assert ch.config.client_id == "test-id" assert ch.config.allow_from == ["*"] diff --git a/tests/channels/test_matrix_channel.py b/tests/channels/test_matrix_channel.py index 9b3349be..f1e20dfd 100644 --- a/tests/channels/test_matrix_channel.py +++ b/tests/channels/test_matrix_channel.py @@ -1,4 +1,5 @@ import asyncio +import sys from pathlib import Path from types import SimpleNamespace @@ -24,6 +25,10 @@ from nanobot.channels.matrix import ( _ROOM_SEND_UNSET = object() +def test_default_e2ee_matches_platform_support() -> None: + assert MatrixConfig().e2ee_enabled is (sys.platform != "win32") + + class _DummyTask: def __init__(self) -> None: self.cancelled = False @@ -293,7 +298,7 @@ async def test_start_skips_load_store_when_device_id_missing( "nanobot.channels.matrix.asyncio.create_task", _fake_create_task ) - channel = MatrixChannel(_make_config(device_id=""), MessageBus()) + channel = MatrixChannel(_make_config(device_id="", e2ee_enabled=True), MessageBus()) await channel.start() assert len(clients) == 1 @@ -320,7 +325,7 @@ async def test_register_event_callbacks_uses_media_base_filter() -> None: def test_register_to_device_callbacks_when_sas_verification_enabled() -> None: - channel = MatrixChannel(_make_config(sas_verification=True), MessageBus()) + channel = MatrixChannel(_make_config(e2ee_enabled=True, sas_verification=True), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client @@ -348,7 +353,11 @@ def test_register_to_device_callbacks_skips_when_e2ee_disabled() -> None: async def test_sas_verification_start_accepts_allowed_sender(monkeypatch) -> None: _patch_key_verification_events(monkeypatch) channel = MatrixChannel( - _make_config(allow_from=["@alice:matrix.org"], sas_verification=True), + _make_config( + allow_from=["@alice:matrix.org"], + e2ee_enabled=True, + sas_verification=True, + ), MessageBus(), ) client = _FakeAsyncClient("", "", "", None) @@ -367,7 +376,11 @@ async def test_sas_verification_start_accepts_allowed_sender(monkeypatch) -> Non async def test_sas_verification_ignores_denied_sender(monkeypatch) -> None: _patch_key_verification_events(monkeypatch) channel = MatrixChannel( - _make_config(allow_from=["@alice:matrix.org"], sas_verification=True), + _make_config( + allow_from=["@alice:matrix.org"], + e2ee_enabled=True, + sas_verification=True, + ), MessageBus(), ) client = _FakeAsyncClient("", "", "", None) @@ -403,7 +416,11 @@ async def test_sas_verification_ignores_when_disabled(monkeypatch) -> None: async def test_sas_verification_key_confirms_allowed_sender(monkeypatch) -> None: _patch_key_verification_events(monkeypatch) channel = MatrixChannel( - _make_config(allow_from=["@alice:matrix.org"], sas_verification=True), + _make_config( + allow_from=["@alice:matrix.org"], + e2ee_enabled=True, + sas_verification=True, + ), MessageBus(), ) client = _FakeAsyncClient("", "", "", None) @@ -1203,7 +1220,7 @@ async def test_on_media_message_handles_decrypt_error(monkeypatch, tmp_path) -> @pytest.mark.asyncio async def test_send_clears_typing_after_send() -> None: - channel = MatrixChannel(_make_config(), MessageBus()) + channel = MatrixChannel(_make_config(e2ee_enabled=True), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 83c15933..5636320b 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -132,6 +132,36 @@ def bus() -> MagicMock: return b +@pytest.mark.asyncio +async def test_start_extends_http_open_timeout_for_slow_settings_routes( + bus, + monkeypatch, +) -> None: + import nanobot.channels.websocket as websocket_module + + channel = _ch(bus, port=0) + seen: dict[str, Any] = {} + + class Server: + def close(self) -> None: + pass + + async def wait_closed(self) -> None: + pass + + async def fake_serve(*args: Any, **kwargs: Any) -> Server: + seen.update(kwargs) + assert channel._stop_event is not None + channel._stop_event.set() + return Server() + + monkeypatch.setattr(websocket_module, "serve", fake_serve) + + await channel.start() + + assert seen["open_timeout"] >= 300 + + @pytest.fixture(autouse=True) def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None: monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) @@ -285,7 +315,7 @@ def test_ssl_context_requires_both_cert_and_key_files() -> None: def test_default_config_includes_safe_bind_and_streaming() -> None: defaults = WebSocketChannel.default_config() - assert defaults["enabled"] is False + assert defaults["enabled"] is True assert defaults["host"] == "127.0.0.1" assert defaults["streaming"] is True assert defaults["allowFrom"] == ["*"] diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index c967a5d6..2d5f42d1 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -15,9 +15,12 @@ from urllib.parse import quote, urlencode import httpx import pytest +from nanobot.bus.events import OutboundMessage +from nanobot.channels.base import BaseChannel from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig from nanobot.cron.service import CronService from nanobot.cron.types import CronJob, CronPayload, CronSchedule +from nanobot.optional_features import InstallResult from nanobot.session.keys import UNIFIED_SESSION_KEY from nanobot.session.manager import Session, SessionManager from nanobot.triggers.local_store import LocalTriggerStore @@ -26,6 +29,24 @@ from nanobot.webui.gateway_services import GatewayServices, build_gateway_servic _PORT = 29900 +class _MatrixChannel(BaseChannel): + name = "matrix" + display_name = "Matrix" + + @classmethod + def default_config(cls) -> dict[str, Any]: + return {"enabled": False, "allowFrom": []} + + async def start(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def send(self, msg: OutboundMessage) -> None: + pass + + def _free_port() -> int: for _ in range(100): port = random.randint(30_000, 60_000) @@ -140,6 +161,35 @@ def _seed_many(workspace: Path, keys: list[str]) -> SessionManager: return sm +def _stub_matrix_feature( + monkeypatch: pytest.MonkeyPatch, + config_path: Path, + *, + deps: list[str] | None = None, + installed: bool = True, + install_calls: list[str] | None = None, + channels: list[str] | None = None, +) -> None: + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + monkeypatch.setattr( + "nanobot.channels.registry.discover_channel_names", + lambda: channels or ["matrix"], + ) + monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + monkeypatch.setattr("nanobot.channels.registry.load_channel_class", lambda _name: _MatrixChannel) + monkeypatch.setattr( + "nanobot.optional_features.optional_dependency_groups", + lambda: {"matrix": deps if deps is not None else []}, + ) + monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: installed) + if install_calls is not None: + monkeypatch.setattr( + "nanobot.optional_features.install_extra", + lambda name, _deps, *, runner: install_calls.append(name) + or InstallResult(True, f"{name} support", ["python", "-m", "pip", "install", name]), + ) + + @pytest.mark.asyncio async def test_bootstrap_returns_token_for_localhost( bus: MagicMock, tmp_path: Path @@ -538,6 +588,272 @@ async def test_cli_apps_routes_require_token_and_return_payload( await server_task +@pytest.mark.asyncio +async def test_nanobot_feature_routes_require_token_and_enable( + bus: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + _stub_matrix_feature(monkeypatch, config_path, channels=["matrix", "websocket"]) + channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29916) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + deny = await _http_get("http://127.0.0.1:29916/api/settings/nanobot-features") + assert deny.status_code == 401 + + boot = await _http_get("http://127.0.0.1:29916/webui/bootstrap") + token = boot.json()["token"] + auth = {"Authorization": f"Bearer {token}"} + + catalog = await _http_get( + "http://127.0.0.1:29916/api/settings/nanobot-features", + headers=auth, + ) + assert catalog.status_code == 200 + features = {feature["name"]: feature for feature in catalog.json()["features"]} + assert features["matrix"]["status"] == "not_enabled" + assert features["websocket"]["enabled"] is True + assert features["websocket"]["ready"] is True + + enabled = await _http_get( + "http://127.0.0.1:29916/api/settings/nanobot-features/enable?name=matrix", + headers=auth, + ) + assert enabled.status_code == 200 + body = enabled.json() + assert body["last_action"]["message"] == "Enabled channel 'matrix'" + assert body["restart_required_sections"] == ["runtime"] + + disabled_websocket = await _http_get( + "http://127.0.0.1:29916/api/settings/nanobot-features/disable?name=websocket", + headers=auth, + ) + assert disabled_websocket.status_code == 400 + assert "cannot be disabled from WebUI" in disabled_websocket.text + assert "websocket" not in json.loads(config_path.read_text(encoding="utf-8"))["channels"] + + disabled = await _http_get( + "http://127.0.0.1:29916/api/settings/nanobot-features/disable?name=matrix", + headers=auth, + ) + assert disabled.status_code == 200 + body = disabled.json() + assert body["last_action"]["message"] == "Disabled channel 'matrix'" + assert body["restart_required_sections"] == ["runtime"] + assert json.loads(config_path.read_text(encoding="utf-8"))["channels"]["matrix"][ + "enabled" + ] is False + finally: + await channel.stop() + await server_task + + +@pytest.mark.asyncio +async def test_nanobot_feature_remote_install_requires_opt_in( + bus: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + install_calls: list[str] = [] + _stub_matrix_feature( + monkeypatch, + config_path, + deps=["matrix-nio>=0.25.2"], + installed=False, + install_calls=install_calls, + ) + channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) + token = channel.gateway.tokens.issue_token(300, api_token=True) + path = "/api/settings/nanobot-features/enable?name=matrix" + request = _FakeReq({"Authorization": f"Bearer {token}"}, path=path) + + blocked = await channel.gateway.http.settings_routes.dispatch( + _REMOTE, + request, + "/api/settings/nanobot-features/enable", + ) + + assert blocked is not None + assert blocked.status_code == 403 + assert "remote WebUI is disabled" in blocked.body.decode() + assert install_calls == [] + + config_path.write_text( + json.dumps({"tools": {"webuiAllowRemotePackageInstall": True}}), + encoding="utf-8", + ) + + allowed = await channel.gateway.http.settings_routes.dispatch( + _REMOTE, + request, + "/api/settings/nanobot-features/enable", + ) + + assert allowed is not None + assert allowed.status_code == 200 + assert install_calls == ["matrix"] + + +@pytest.mark.asyncio +async def test_nanobot_feature_local_install_allowed_by_default( + bus: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + install_calls: list[str] = [] + _stub_matrix_feature( + monkeypatch, + config_path, + deps=["matrix-nio>=0.25.2"], + installed=False, + install_calls=install_calls, + ) + channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) + token = channel.gateway.tokens.issue_token(300, api_token=True) + request = _FakeReq( + {"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"}, + path="/api/settings/nanobot-features/enable?name=matrix", + ) + + response = await channel.gateway.http.settings_routes.dispatch( + _LOCAL, + request, + "/api/settings/nanobot-features/enable", + ) + + assert response is not None + assert response.status_code == 200 + assert install_calls == ["matrix"] + assert json.loads(config_path.read_text(encoding="utf-8"))["channels"]["matrix"][ + "enabled" + ] is True + + +@pytest.mark.asyncio +async def test_nanobot_feature_loopback_reverse_proxy_install_requires_opt_in( + bus: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + install_calls: list[str] = [] + _stub_matrix_feature( + monkeypatch, + config_path, + deps=["matrix-nio>=0.25.2"], + installed=False, + install_calls=install_calls, + ) + channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) + token = channel.gateway.tokens.issue_token(300, api_token=True) + request = _FakeReq( + { + "Authorization": f"Bearer {token}", + "Host": "nanobot.example", + "X-Forwarded-For": "203.0.113.42", + }, + path="/api/settings/nanobot-features/enable?name=matrix", + ) + + blocked = await channel.gateway.http.settings_routes.dispatch( + _LOCAL, + request, + "/api/settings/nanobot-features/enable", + ) + + assert blocked is not None + assert blocked.status_code == 403 + assert install_calls == [] + + config_path.write_text( + json.dumps({"tools": {"webuiAllowRemotePackageInstall": True}}), + encoding="utf-8", + ) + + allowed = await channel.gateway.http.settings_routes.dispatch( + _LOCAL, + request, + "/api/settings/nanobot-features/enable", + ) + + assert allowed is not None + assert allowed.status_code == 200 + assert install_calls == ["matrix"] + + +@pytest.mark.asyncio +async def test_nanobot_feature_remote_enable_without_install_is_allowed( + bus: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + install_calls: list[str] = [] + _stub_matrix_feature( + monkeypatch, + config_path, + deps=["matrix-nio>=0.25.2"], + installed=True, + install_calls=install_calls, + ) + channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) + token = channel.gateway.tokens.issue_token(300, api_token=True) + request = _FakeReq( + {"Authorization": f"Bearer {token}"}, + path="/api/settings/nanobot-features/enable?name=matrix", + ) + + response = await channel.gateway.http.settings_routes.dispatch( + _REMOTE, + request, + "/api/settings/nanobot-features/enable", + ) + + assert response is not None + assert response.status_code == 200 + assert install_calls == [] + assert json.loads(config_path.read_text(encoding="utf-8"))["channels"]["matrix"][ + "enabled" + ] is True + + +@pytest.mark.asyncio +async def test_nanobot_feature_remote_disable_does_not_need_install_policy( + bus: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps({"channels": {"matrix": {"enabled": True, "homeserver": "keep"}}}), + encoding="utf-8", + ) + _stub_matrix_feature(monkeypatch, config_path, deps=["matrix-nio>=0.25.2"], installed=False) + + channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) + token = channel.gateway.tokens.issue_token(300, api_token=True) + request = _FakeReq( + {"Authorization": f"Bearer {token}"}, + path="/api/settings/nanobot-features/disable?name=matrix", + ) + + response = await channel.gateway.http.settings_routes.dispatch( + _REMOTE, + request, + "/api/settings/nanobot-features/disable", + ) + + assert response is not None + assert response.status_code == 200 + data = json.loads(config_path.read_text(encoding="utf-8")) + assert data["channels"]["matrix"]["enabled"] is False + assert data["channels"]["matrix"]["homeserver"] == "keep" + + @pytest.mark.asyncio async def test_cli_apps_catalog_does_not_block_other_webui_http_routes( bus: MagicMock, @@ -1668,8 +1984,9 @@ class _FakeConn: class _FakeReq: """Minimal request stub with configurable headers.""" - def __init__(self, headers: dict[str, str] | None = None): + def __init__(self, headers: dict[str, str] | None = None, *, path: str = "/"): self.headers = headers or {} + self.path = path _REMOTE = _FakeConn(("192.168.1.5", 12345)) @@ -1677,6 +1994,43 @@ _LOCAL = _FakeConn(("127.0.0.1", 12345)) _NO_HEADERS = _FakeReq() +def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None: + from nanobot.webui.http_utils import is_local_browser_request + + assert is_local_browser_request(_LOCAL, {"Host": "127.0.0.1:8765"}) is True + assert is_local_browser_request(_LOCAL, {"Host": "localhost:8765"}) is True + assert ( + is_local_browser_request( + _LOCAL, + {"Host": "localhost:8765", "X-Forwarded-For": "127.0.0.1"}, + ) + is True + ) + assert is_local_browser_request(_REMOTE, {"Host": "127.0.0.1:8765"}) is False + assert is_local_browser_request(_LOCAL, {"Host": "nanobot.example"}) is False + assert ( + is_local_browser_request( + _LOCAL, + {"Host": "127.0.0.1:8765", "X-Forwarded-For": "203.0.113.42"}, + ) + is False + ) + assert ( + is_local_browser_request( + _LOCAL, + {"Host": "127.0.0.1:8765", "X-Forwarded-Host": "nanobot.example"}, + ) + is False + ) + assert ( + is_local_browser_request( + _LOCAL, + {"Host": "127.0.0.1:8765", "Forwarded": "for=203.0.113.42;host=nanobot.example"}, + ) + is False + ) + + def test_wildcard_host_without_auth_raises_on_startup(bus: MagicMock) -> None: import pytest from pydantic_core import ValidationError diff --git a/tests/cli_apps/test_service.py b/tests/cli_apps/test_service.py index a42fa430..d33a8f83 100644 --- a/tests/cli_apps/test_service.py +++ b/tests/cli_apps/test_service.py @@ -348,6 +348,42 @@ def test_install_dispatches_safe_pip_and_installs_skill( assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8") +def test_run_argv_logs_command_exit_and_output( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from nanobot.apps.cli import service as cli_service + + manager = _manager(tmp_path) + records: list[str] = [] + + class _Logger: + def info(self, message: str, *args: object) -> None: + records.append(message.format(*args)) + + def fake_run( + argv: list[str], + *, + capture_output: bool, + text: bool, + timeout: int, + ) -> subprocess.CompletedProcess[str]: + assert capture_output is True + assert text is True + assert timeout == 5 + return subprocess.CompletedProcess(argv, 0, stdout="installed ok", stderr="") + + monkeypatch.setattr(cli_service, "logger", _Logger()) + monkeypatch.setattr(cli_service.subprocess, "run", fake_run) + + result = manager._run_argv(["python", "-m", "pip", "install", "sample"], timeout=5) + + assert result.returncode == 0 + assert any(record.startswith("CLI Apps: running ") for record in records) + assert any("command exited with code 0" in record for record in records) + assert any("installed ok" in record for record in records) + + def test_install_records_available_cli_without_reinstalling( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/config/test_config_migration.py b/tests/config/test_config_migration.py index 0f7d408a..96221d09 100644 --- a/tests/config/test_config_migration.py +++ b/tests/config/test_config_migration.py @@ -283,3 +283,28 @@ def test_load_config_accepts_legacy_local_preview_access(tmp_path) -> None: config = load_config(config_path) assert config.tools.webui_allow_local_service_access is False + + +def test_load_config_defaults_remote_package_install_to_disabled(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps({"tools": {}}), encoding="utf-8") + + config = load_config(config_path) + + assert config.tools.webui_allow_remote_package_install is False + + +def test_load_config_accepts_remote_package_install_aliases(tmp_path) -> None: + camel_path = tmp_path / "camel.json" + camel_path.write_text( + json.dumps({"tools": {"webuiAllowRemotePackageInstall": True}}), + encoding="utf-8", + ) + snake_path = tmp_path / "snake.json" + snake_path.write_text( + json.dumps({"tools": {"webui_allow_remote_package_install": True}}), + encoding="utf-8", + ) + + assert load_config(camel_path).tools.webui_allow_remote_package_install is True + assert load_config(snake_path).tools.webui_allow_remote_package_install is True diff --git a/tests/providers/test_azure_openai_provider.py b/tests/providers/test_azure_openai_provider.py index 9b0e9b5d..df78acfc 100644 --- a/tests/providers/test_azure_openai_provider.py +++ b/tests/providers/test_azure_openai_provider.py @@ -156,7 +156,7 @@ def test_init_explicit_key_does_not_construct_credential(monkeypatch): def test_init_missing_key_without_azure_identity_raises(monkeypatch): - """Clear RuntimeError with pip-install hint when azure-identity is missing.""" + """Clear RuntimeError with install hint when azure-identity is missing.""" # Force the import inside _AzureTokenProvider to fail. real_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__ @@ -166,7 +166,7 @@ def test_init_missing_key_without_azure_identity_raises(monkeypatch): return real_import(name, *args, **kwargs) with patch("builtins.__import__", side_effect=fake_import): - with pytest.raises(RuntimeError, match=r"pip install 'nanobot-ai\[azure\]'"): + with pytest.raises(RuntimeError, match=r"nanobot plugins enable azure"): AzureOpenAIProvider(api_key="", api_base="https://res.openai.azure.com") diff --git a/tests/test_msteams.py b/tests/test_msteams.py index 6be6424f..aa6d8c11 100644 --- a/tests/test_msteams.py +++ b/tests/test_msteams.py @@ -11,7 +11,11 @@ except ImportError: MSTEAMS_AVAILABLE = False if not MSTEAMS_AVAILABLE: - pytest.skip("MSTeams dependencies not installed (PyJWT, cryptography). Run: pip install nanobot-ai[msteams]", allow_module_level=True) + pytest.skip( + "MSTeams dependencies not installed (PyJWT, cryptography). " + "Run: nanobot plugins enable msteams", + allow_module_level=True, + ) import jwt from cryptography.hazmat.primitives.asymmetric import rsa @@ -903,7 +907,7 @@ async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypa await ch.start() - assert errors == ["PyJWT not installed. Run: pip install nanobot-ai[msteams]"] + assert errors == ["PyJWT not installed. Run: nanobot plugins enable msteams"] def test_save_refs_prunes_webchat_and_stale_refs(make_channel): diff --git a/tests/utils/test_webui_websocket_logging.py b/tests/utils/test_webui_websocket_logging.py index 9adee336..28e4c985 100644 --- a/tests/utils/test_webui_websocket_logging.py +++ b/tests/utils/test_webui_websocket_logging.py @@ -26,9 +26,12 @@ def test_websocket_handshake_noise_filter_suppresses_disconnects() -> None: filter_ = WebSocketHandshakeNoiseFilter() wrapped = RuntimeError("wrapped") wrapped.__cause__ = BrokenPipeError(32, "Broken pipe") + empty_handshake = RuntimeError("wrapped") + empty_handshake.__cause__ = EOFError("connection closed while reading HTTP request line") assert not filter_.filter(_log_record(OPENING_HANDSHAKE_FAILED_MESSAGE, BrokenPipeError())) assert not filter_.filter(_log_record(OPENING_HANDSHAKE_FAILED_MESSAGE, wrapped)) + assert not filter_.filter(_log_record(OPENING_HANDSHAKE_FAILED_MESSAGE, empty_handshake)) def test_websocket_handshake_noise_filter_keeps_real_errors() -> None: diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index c76f6164..c4404489 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -83,11 +83,14 @@ import { Textarea } from "@/components/ui/textarea"; import { checkVersion, createModelConfiguration, + disableNanobotFeature, + enableNanobotFeature, fetchAutomations, fetchSettings, fetchSettingsUsage, fetchCliApps, fetchMcpPresets, + fetchNanobotFeatures, fetchProviderModels, importMcpConfig, loginProviderOAuth, @@ -127,6 +130,8 @@ import type { ImageGenerationSettingsUpdate, McpPresetInfo, McpPresetsPayload, + NanobotFeatureInfo, + NanobotFeaturesPayload, NetworkSafetySettingsUpdate, ProviderModelsPayload, SessionAutomationJob, @@ -152,11 +157,12 @@ export type SettingsSectionKey = type LocalDensity = "comfortable" | "compact"; type LocalActivityMode = "auto" | "expanded"; -type AppsKindFilter = "all" | "cli" | "mcp"; +type AppsKindFilter = "all" | "nanobot" | "cli" | "mcp"; type AutomationFilter = "all" | "active" | "paused" | "failed" | "system"; type AutomationSort = "next" | "last" | "updated" | "name"; type AutomationAction = "enable" | "disable" | "delete" | "run"; type AppsCatalogItem = + | { id: string; kind: "nanobot"; feature: NanobotFeatureInfo } | { id: string; kind: "cli"; app: CliAppInfo } | { id: string; kind: "mcp"; preset: McpPresetInfo }; @@ -259,7 +265,7 @@ const DEFAULT_LOCAL_PREFS: LocalPreferences = { density: "comfortable", activityMode: "auto", codeWrap: true, - brandLogos: true, + brandLogos: false, }; const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }> = [ { value: "auto", label: "Auto" }, @@ -321,7 +327,7 @@ function readLocalPreferences(): LocalPreferences { density: parsed.density === "compact" ? "compact" : "comfortable", activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto", codeWrap: parsed.codeWrap !== false, - brandLogos: parsed.brandLogos !== false, + brandLogos: parsed.brandLogos === true, }; } catch { return DEFAULT_LOCAL_PREFS; @@ -536,10 +542,12 @@ export function SettingsView({ const { token } = useClient(); const [settings, setSettings] = useState(() => initialSettings); const [cliApps, setCliApps] = useState(null); + const [nanobotFeatures, setNanobotFeatures] = useState(null); const [mcpPresets, setMcpPresets] = useState(null); const [automations, setAutomations] = useState(null); const [loading, setLoading] = useState(() => initialSettings === null); const [cliAppsLoading, setCliAppsLoading] = useState(true); + const [nanobotFeaturesLoading, setNanobotFeaturesLoading] = useState(true); const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true); const [automationsLoading, setAutomationsLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -551,6 +559,8 @@ export function SettingsView({ model: "", }); const [cliAppsAction, setCliAppsAction] = useState(null); + const [nanobotFeatureAction, setNanobotFeatureAction] = useState(null); + const [nanobotFeatureConfirm, setNanobotFeatureConfirm] = useState(null); const [mcpPresetAction, setMcpPresetAction] = useState(null); const [providerSaving, setProviderSaving] = useState(null); const [webSearchSaving, setWebSearchSaving] = useState(false); @@ -568,6 +578,8 @@ export function SettingsView({ const [automationsSort, setAutomationsSort] = useState("next"); const [cliAppsMessage, setCliAppsMessage] = useState(null); const [cliAppsError, setCliAppsError] = useState(null); + const [nanobotFeaturesMessage, setNanobotFeaturesMessage] = useState(null); + const [nanobotFeaturesError, setNanobotFeaturesError] = useState(null); const [cliAppsFocusName, setCliAppsFocusName] = useState(null); const [appsKindFilter, setAppsKindFilter] = useState("all"); const [mcpMessage, setMcpMessage] = useState(null); @@ -731,6 +743,29 @@ export function SettingsView({ }; }, [activeSection, token]); + useEffect(() => { + if (activeSection !== "apps") return; + let cancelled = false; + setNanobotFeaturesLoading(true); + fetchNanobotFeatures(token) + .then((payload) => { + if (!cancelled) { + setNanobotFeatures(payload); + setNanobotFeaturesError(null); + } + }) + .catch((err) => { + const message = (err as Error).message; + if (!cancelled && message !== "HTTP 404") setNanobotFeaturesError(message); + }) + .finally(() => { + if (!cancelled) setNanobotFeaturesLoading(false); + }); + return () => { + cancelled = true; + }; + }, [activeSection, token]); + useEffect(() => { if (activeSection !== "apps") return; let cancelled = false; @@ -1330,6 +1365,42 @@ export function SettingsView({ } }; + const handleNanobotFeatureAction = async ( + action: "enable" | "disable", + name: string, + confirmed = false, + ) => { + const feature = nanobotFeatures?.features.find((item) => item.name === name); + if (action === "enable" && !confirmed && feature && !feature.installed && feature.install_supported) { + setNanobotFeaturesMessage(null); + setNanobotFeaturesError(null); + setNanobotFeatureConfirm(feature); + return; + } + const key = `${action}:${name}`; + setNanobotFeatureAction(key); + setNanobotFeatureConfirm(null); + setNanobotFeaturesMessage(null); + setNanobotFeaturesError(null); + try { + const payload = action === "enable" + ? await enableNanobotFeature(token, name) + : await disableNanobotFeature(token, name); + setNanobotFeatures(payload); + setNanobotFeaturesMessage(payload.last_action?.message ?? null); + if ( + payload.requires_restart || + payload.features.some((feature) => feature.name === name && feature.requires_restart) + ) { + setPendingRestartSections((prev) => ({ ...prev, runtime: true })); + } + } catch (err) { + setNanobotFeaturesError((err as Error).message); + } finally { + setNanobotFeatureAction(null); + } + }; + const handleAutomationAction = async ( action: AutomationAction, job: SessionAutomationJob, @@ -1604,15 +1675,20 @@ export function SettingsView({ return ( { setCliAppsMessage(null); setCliAppsError(null); + setNanobotFeaturesMessage(null); + setNanobotFeaturesError(null); setMcpMessage(null); setMcpError(null); }} @@ -1733,6 +1812,15 @@ export function SettingsView({ onSave={handleCreateModelConfiguration} /> + { + if (!open) setNanobotFeatureConfirm(null); + }} + onConfirm={(feature) => handleNanobotFeatureAction("enable", feature.name, true)} + /> + void; + onConfirm: (feature: NanobotFeatureInfo) => void | Promise; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string, values?: Record) => + t(key, { defaultValue: fallback, ...(values ?? {}) }); + const name = feature?.display_name || feature?.name || ""; + return ( + + + + + {tx("settings.nanobotFeatures.installConfirmTitle", "Install support for {{name}}?", { name })} + + + {tx( + "settings.nanobotFeatures.installConfirmDescription", + "nanobot will add what {{name}} needs, then turn it on. Continue?", + { name }, + )} + + + + + + + + + ); +} + function isLocalTriggerAutomation(job: SessionAutomationJob | null): boolean { if (!job) return false; return job.kind === "local_trigger" @@ -4906,15 +5052,20 @@ function formatAutomationInterval(ms: number, locale: string): string { function AppsCatalogSettings({ cliApps, + nanobotFeatures, mcpPresets, cliAppsLoading, + nanobotFeaturesLoading, mcpPresetsLoading, query, filter, cliActionKey, + nanobotActionKey, mcpActionKey, cliMessage, cliError, + nanobotMessage, + nanobotError, cliFocusName, mcpMessage, mcpError, @@ -4926,6 +5077,7 @@ function AppsCatalogSettings({ onQueryChange, onFilterChange, onCliAction, + onNanobotAction, onMcpAction, onDismissStatus, onBackToChat, @@ -4939,15 +5091,20 @@ function AppsCatalogSettings({ isRestarting, }: { cliApps: CliAppsPayload | null; + nanobotFeatures: NanobotFeaturesPayload | null; mcpPresets: McpPresetsPayload | null; cliAppsLoading: boolean; + nanobotFeaturesLoading: boolean; mcpPresetsLoading: boolean; query: string; filter: AppsKindFilter; cliActionKey: string | null; + nanobotActionKey: string | null; mcpActionKey: string | null; cliMessage: string | null; cliError: string | null; + nanobotMessage: string | null; + nanobotError: string | null; cliFocusName: string | null; mcpMessage: string | null; mcpError: string | null; @@ -4959,6 +5116,7 @@ function AppsCatalogSettings({ onQueryChange: (value: string) => void; onFilterChange: (value: AppsKindFilter) => void; onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void; + onNanobotAction: (action: "enable" | "disable", name: string) => void; onMcpAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void; onDismissStatus: () => void; onBackToChat: () => void; @@ -4975,11 +5133,17 @@ function AppsCatalogSettings({ const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const filterOptions = [ { value: "all", label: tx("settings.apps.filterAll", "All") }, + { value: "nanobot", label: tx("settings.apps.filterPlugins", "Plugins") }, { value: "cli", label: tx("settings.apps.filterCli", "App CLIs") }, { value: "mcp", label: tx("settings.apps.filterMcp", "MCP services") }, ]; const normalizedQuery = query.trim().toLowerCase(); const items: AppsCatalogItem[] = [ + ...(nanobotFeatures?.features ?? []).map((feature) => ({ + id: `nanobot:${feature.name}`, + kind: "nanobot" as const, + feature, + })), ...(cliApps?.apps ?? []).map((app) => ({ id: `cli:${app.name}`, kind: "cli" as const, app })), ...(mcpPresets?.presets ?? []).map((preset) => ({ id: `mcp:${preset.name}`, @@ -4996,13 +5160,22 @@ function AppsCatalogSettings({ const focusedApp = cliFocusName ? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed) : null; - const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets; - const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null); - const statusIsError = Boolean(cliError || mcpError); + const loading = + (cliAppsLoading || nanobotFeaturesLoading || mcpPresetsLoading) && + !cliApps && + !nanobotFeatures && + !mcpPresets; + const statusMessage = + cliError || + nanobotError || + mcpError || + (!focusedApp ? cliMessage || nanobotMessage || mcpMessage : null); + const statusIsError = Boolean(cliError || nanobotError || mcpError); const caption = t("settings.apps.caption", { + plugins: nanobotFeatures?.enabled_count ?? 0, cli: cliApps?.installed_count ?? 0, mcp: mcpPresets?.installed_count ?? 0, - defaultValue: "{{cli}} CLI · {{mcp}} MCP", + defaultValue: "{{plugins}} Plugin · {{cli}} CLI · {{mcp}} MCP", }); return ( @@ -5012,7 +5185,7 @@ function AppsCatalogSettings({

{tx( "settings.apps.description", - "Add local app adapters and connected tool servers that nanobot can use from chat.", + "Enable plugins, local app adapters, and connected tool servers.", )}

{caption} @@ -5068,7 +5241,7 @@ function AppsCatalogSettings({ {requiresRestartPending ? (
- {tx("settings.mcp.restartRequired", "Restart nanobot to connect updated MCP tools.")} + {tx("settings.apps.restartRequired", "Restart nanobot to apply updated apps and features.")} {onRestart ? (