Add optional Nanobot plugin controls (#4396)

* feat: add optional nanobot features

* test: update azure install hint expectation

* fix: validate optional feature extras

maintainer edit: verify requested dependency extras before treating optional features as installed, propagate restart state from feature enablement, and align docs with the new plugins enable command.

* fix: bound optional feature installs

maintainer edit: make optional feature installs time out as a normal install failure instead of leaving the WebUI or CLI action waiting indefinitely.

* feat: slim optional channel dependencies

* fix: log optional install commands

* fix(webui): gate remote feature installs

* docs: clarify webhook plugin example

* fix(webui): harden optional feature installs

* fix: install optional deps without package fallback

* fix(cli): refine plugin feature controls

* fix(webui): count enabled nanobot features

* fix(webui): allow slow feature install routes

* fix(webui): allow disabling websocket channel

* fix(plugins): simplify optional feature controls

* fix(webui): polish apps catalog states

* fix(webui): confirm nanobot support installs

* fix(webui): polish nanobot install dialog

* fix(webui): suppress empty websocket handshakes

* fix(webui): clarify apps plugin summary

* fix(webui): localize workspace access copy

* fix(plugins): polish optional feature controls (#4691)

---------

Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
This commit is contained in:
chengyongru
2026-07-03 18:17:52 +08:00
committed by GitHub
co-authored by Xubin Ren
parent 00cc0da530
commit 5283ceae85
61 changed files with 3061 additions and 258 deletions
@@ -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
@@ -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
+699 -6
View File
@@ -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 == ["*"]
+23 -6
View File
@@ -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
+31 -1
View File
@@ -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"] == ["*"]
+355 -1
View File
@@ -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