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:
@@ -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
|
||||
|
||||
@@ -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 == ["*"]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"] == ["*"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user