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
+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