feat(webui): add guided setup flows

* feat(channels): add guided setup flows

* test(channels): preserve setup config values

* fix(channels): reflect saved setup state

* refactor(channels): simplify setup state metadata

* fix(channels): harden setup lifecycle

* refactor(channels): centralize setup contracts

* fix(channels): route setup actions through webui shim

* fix(channels): adapt settings for compact screens

* fix(models): preserve default preset display

* feat(models): add curated Codex catalog

* fix(webui): stop attached gateway on interrupt

* fix(webui): simplify apps catalog

* docs(webui): clarify apps and runtime features

* feat(settings): add guided capability setup

* fix(webui): harden setup and managed services

* test: keep managed runtime checks portable

* test: scope POSIX runtime coverage

* fix(webui): simplify file settings

* feat(files): bundle document reading

* fix(webui): harden setup request boundaries

* fix(webui): prevent channel setup status squeeze

* fix(settings): group provider compatibility aliases

* refactor(settings): remove redundant setup surfaces

* fix(webui): harden guided setup lifecycle

* fix(webui): preserve channel setup compatibility
This commit is contained in:
Xubin Ren
2026-07-13 13:11:46 +08:00
committed by GitHub
parent 791c7fd505
commit fe0717b385
92 changed files with 15058 additions and 1311 deletions
+15 -15
View File
@@ -11,7 +11,6 @@ from typing import Any, cast
from pydantic import BaseModel, Field
from nanobot.cli import onboard as onboard_wizard
from nanobot.cli.commands import _merge_missing_defaults
from nanobot.cli.onboard import (
_BACK_PRESSED,
_configure_pydantic_model,
@@ -22,18 +21,19 @@ from nanobot.cli.onboard import (
_input_text,
run_onboard,
)
from nanobot.config.loader import merge_missing_defaults
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.utils.helpers import sync_workspace_templates
class TestMergeMissingDefaults:
"""Tests for _merge_missing_defaults recursive config merging."""
"""Tests for recursive config default merging."""
def test_adds_missing_top_level_keys(self):
existing = {"a": 1}
defaults = {"a": 1, "b": 2, "c": 3}
result = _merge_missing_defaults(existing, defaults)
result = merge_missing_defaults(existing, defaults)
assert result == {"a": 1, "b": 2, "c": 3}
@@ -41,7 +41,7 @@ class TestMergeMissingDefaults:
existing = {"a": "custom_value"}
defaults = {"a": "default_value"}
result = _merge_missing_defaults(existing, defaults)
result = merge_missing_defaults(existing, defaults)
assert result == {"a": "custom_value"}
@@ -63,7 +63,7 @@ class TestMergeMissingDefaults:
}
}
result = _merge_missing_defaults(existing, defaults)
result = merge_missing_defaults(existing, defaults)
assert result == {
"level1": {
@@ -76,19 +76,19 @@ class TestMergeMissingDefaults:
}
def test_returns_existing_if_not_dict(self):
assert _merge_missing_defaults("string", {"a": 1}) == "string"
assert _merge_missing_defaults([1, 2, 3], {"a": 1}) == [1, 2, 3]
assert _merge_missing_defaults(None, {"a": 1}) is None
assert _merge_missing_defaults(42, {"a": 1}) == 42
assert merge_missing_defaults("string", {"a": 1}) == "string"
assert merge_missing_defaults([1, 2, 3], {"a": 1}) == [1, 2, 3]
assert merge_missing_defaults(None, {"a": 1}) is None
assert merge_missing_defaults(42, {"a": 1}) == 42
def test_returns_existing_if_defaults_not_dict(self):
assert _merge_missing_defaults({"a": 1}, "string") == {"a": 1}
assert _merge_missing_defaults({"a": 1}, None) == {"a": 1}
assert merge_missing_defaults({"a": 1}, "string") == {"a": 1}
assert merge_missing_defaults({"a": 1}, None) == {"a": 1}
def test_handles_empty_dicts(self):
assert _merge_missing_defaults({}, {"a": 1}) == {"a": 1}
assert _merge_missing_defaults({"a": 1}, {}) == {"a": 1}
assert _merge_missing_defaults({}, {}) == {}
assert merge_missing_defaults({}, {"a": 1}) == {"a": 1}
assert merge_missing_defaults({"a": 1}, {}) == {"a": 1}
assert merge_missing_defaults({}, {}) == {}
def test_backfills_channel_config(self):
"""Real-world scenario: backfill missing channel fields."""
@@ -105,7 +105,7 @@ class TestMergeMissingDefaults:
"allowFrom": [],
}
result = _merge_missing_defaults(existing_channel, default_channel)
result = merge_missing_defaults(existing_channel, default_channel)
assert result["msgFormat"] == "plain"
assert result["allowFrom"] == []
@@ -0,0 +1,114 @@
from __future__ import annotations
import asyncio
import pytest
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
from nanobot.config.schema import Config
class _HotChannel(BaseChannel):
name = "hot"
display_name = "Hot"
def __init__(self, config, bus):
super().__init__(config, bus)
self.started = asyncio.Event()
self.stopped = asyncio.Event()
async def start(self):
self._running = True
self.started.set()
await self.stopped.wait()
async def stop(self):
self._running = False
self.stopped.set()
async def send(self, msg): # pragma: no cover - not used by this test
raise AssertionError("send should not be called")
@pytest.mark.asyncio
async def test_apply_channel_feature_action_starts_and_stops_channel(monkeypatch):
disabled = Config.model_validate({
"channels": {
"websocket": {"enabled": False},
"hot": {"enabled": False},
}
})
enabled = Config.model_validate({
"channels": {
"websocket": {"enabled": False},
"hot": {"enabled": True},
}
})
import nanobot.channels.registry as registry
def discover_enabled(enabled_names, **_kwargs):
return {"hot": _HotChannel} if "hot" in enabled_names else {}
configs = iter([enabled, disabled])
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
monkeypatch.setattr(registry, "discover_enabled", discover_enabled)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs))
manager = ChannelManager(disabled, MessageBus())
manager._started = True
enabled_result = await manager.apply_channel_feature_action("enable", "hot")
assert enabled_result["handled"] is True
assert enabled_result["requires_restart"] is False
channel = manager.channels["hot"]
await asyncio.wait_for(channel.started.wait(), timeout=1)
assert channel.is_running is True
disabled_result = await manager.apply_channel_feature_action("disable", "hot")
assert disabled_result["handled"] is True
assert disabled_result["requires_restart"] is False
assert "hot" not in manager.channels
assert channel.is_running is False
@pytest.mark.asyncio
async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_fails(monkeypatch):
enabled = Config.model_validate({
"channels": {
"websocket": {"enabled": False},
"hot": {"enabled": True},
}
})
import nanobot.channels.registry as registry
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
monkeypatch.setattr(
registry,
"discover_enabled",
lambda enabled_names, **_kwargs: {"hot": _HotChannel},
)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: enabled)
manager = ChannelManager(enabled, MessageBus())
old_channel = manager.channels["hot"]
old_channel._running = True
def fail_build(*_args, **_kwargs):
raise RuntimeError("invalid replacement config")
monkeypatch.setattr(manager, "_build_channel", fail_build)
result = await manager.apply_channel_feature_action("enable", "hot")
assert result["requires_restart"] is True
assert manager.channels["hot"] is old_channel
assert old_channel.is_running is True
assert not old_channel.stopped.is_set()
+486 -4
View File
@@ -72,6 +72,34 @@ class _FakeTelegram(BaseChannel):
pass
class _FakeFeishu(BaseChannel):
name = "feishu"
display_name = "Feishu"
@classmethod
def default_config(cls) -> dict:
return {
"instanceId": "default",
"name": "nanobot",
"enabled": False,
"appId": "",
"appSecret": "",
"domain": "feishu",
"groupPolicy": "mention",
"topicIsolation": True,
"allowFrom": [],
}
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def send(self, msg: OutboundMessage) -> None:
pass
def _make_entry_point(name: str, cls: type):
"""Create a mock entry point that returns *cls* on load()."""
ep = SimpleNamespace(name=name, load=lambda _cls=cls: _cls)
@@ -136,6 +164,49 @@ def test_channels_config_extract_document_text_accepts_camel_alias():
assert cfg.extract_document_text is False
def test_channel_manager_expands_feishu_instances(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr(
"nanobot.channels.registry.discover_enabled",
lambda enabled, _names=None, warn_import_errors=True: {"feishu": _FakeFeishu}
if "feishu" in enabled
else {},
)
cfg = Config.model_validate({
"channels": {
"feishu": {
"instances": [
{
"id": "default",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
},
{
"id": "product",
"enabled": True,
"appId": "cli_product",
"appSecret": "secret",
},
{
"id": "off",
"enabled": False,
"appId": "cli_off",
"appSecret": "secret",
},
]
}
}
})
manager = ChannelManager(cfg, MessageBus())
assert set(manager.channels) == {"feishu", "feishu.product"}
assert manager.channels["feishu"].name == "feishu"
assert manager.channels["feishu.product"].name == "feishu.product"
# ---------------------------------------------------------------------------
# discover_plugins
# ---------------------------------------------------------------------------
@@ -201,6 +272,17 @@ def test_discover_all_includes_builtins():
assert name in discover_channel_names()
def test_discover_channel_names_excludes_internal_helpers():
from nanobot.channels.registry import discover_channel_names
names = discover_channel_names()
assert "_feishu_ws" not in names
assert "_setup" not in names
assert "setup" not in names
assert "_feishu_instances" not in names
def test_discover_all_includes_external_plugin():
from nanobot.channels.registry import discover_all
@@ -891,6 +973,25 @@ def test_enable_optional_feature_skips_install_when_dependency_present(
assert not config_path.exists()
def test_enable_optional_feature_lazy_reader_does_not_require_restart(monkeypatch, tmp_path):
from nanobot.optional_features import 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: {"documents": ["pypdf>=5.0.0,<6.0.0"]},
)
monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: True)
payload = enable_optional_feature("documents", config_path=config_path)
assert payload["requires_restart"] is False
assert payload["last_action"]["message"] == "Feature 'documents' is included with nanobot"
def test_enable_optional_feature_reports_install_failure(monkeypatch, tmp_path):
from nanobot.optional_features import (
InstallResult,
@@ -1009,6 +1110,370 @@ def test_optional_features_payload_counts_enabled_channel_with_missing_dependenc
assert payload["enabled_count"] == 1
def test_optional_features_payload_reflects_saved_channel_config(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"discord": {
"enabled": False,
"token": "discord-secret-token",
"allowChannels": ["123", "456"],
"groupPolicy": "open",
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["discord"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
discord = payload["features"][0]
assert discord["name"] == "discord"
assert discord["enabled"] is False
assert discord["configured"] is True
assert discord["config_values"] == {
"channels.discord.allowChannels": "123, 456",
"channels.discord.groupPolicy": "open",
}
assert discord["configured_fields"] == [
"channels.discord.token",
"channels.discord.allowChannels",
"channels.discord.groupPolicy",
]
assert "discord-secret-token" not in json.dumps(payload)
def test_optional_features_payload_marks_enabled_channel_missing_credentials(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({"channels": {"discord": {"enabled": True}}})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["discord"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
discord = payload["features"][0]
assert discord["enabled"] is True
assert discord["configured"] is False
assert "config_values" not in discord
assert "configured_fields" not in discord
def test_optional_features_payload_detects_saved_weixin_login_state(tmp_path, monkeypatch):
from nanobot.optional_features import optional_features_payload
state_dir = tmp_path / "weixin-state"
state_dir.mkdir()
(state_dir / "account.json").write_text(
json.dumps({"token": "saved-weixin-token"}),
encoding="utf-8",
)
config = Config.model_validate({
"channels": {
"weixin": {
"enabled": True,
"stateDir": str(state_dir),
}
}
})
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: {})
payload = optional_features_payload(config=config)
weixin = payload["features"][0]
assert weixin["enabled"] is True
assert weixin["configured"] is True
def test_optional_features_payload_detects_legacy_default_weixin_state(tmp_path, monkeypatch):
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
state_dir = tmp_path / "weixin"
state_dir.mkdir()
(state_dir / "account.json").write_text(
json.dumps({"token": "legacy-weixin-token"}),
encoding="utf-8",
)
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: {})
payload = optional_features_payload(config=Config())
weixin = payload["features"][0]
assert weixin["enabled"] is False
assert weixin["configured"] is True
@pytest.mark.parametrize("device_id", ["", "DEVICE-ID"])
def test_optional_features_payload_requires_matrix_device_id_for_token_login(
monkeypatch,
device_id,
):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"matrix": {
"enabled": False,
"homeserver": "https://matrix.example",
"userId": "@nanobot:matrix.example",
"accessToken": "saved-token",
"deviceId": device_id,
}
}
})
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: {})
payload = optional_features_payload(config=config)
assert payload["features"][0]["configured"] is bool(device_id)
def test_optional_features_payload_marks_disabled_feishu_as_configured(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"feishu": {
"enabled": False,
"appId": "cli_test",
"appSecret": "secret",
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
feishu = payload["features"][0]
assert feishu["name"] == "feishu"
assert feishu["enabled"] is False
assert feishu["configured"] is True
assert feishu["ready"] is False
assert payload["enabled_count"] == 0
def test_optional_features_payload_lists_feishu_instances(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"feishu": {
"instances": [
{
"id": "default",
"name": "nanobot",
"displayName": "Voraflare Bot",
"avatarUrl": "https://example.com/bot.png",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
},
{
"id": "product",
"name": "Product bot",
"enabled": False,
"appId": "cli_product",
"appSecret": "secret",
},
]
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
feishu = payload["features"][0]
assert feishu["name"] == "feishu"
assert feishu["enabled"] is True
assert feishu["configured"] is True
assert payload["enabled_count"] == 1
assert feishu["instances"] == [
{
"id": "default",
"name": "nanobot",
"display_name": "Voraflare Bot",
"avatar_url": "https://example.com/bot.png",
"domain": "feishu",
"enabled": True,
"configured": True,
"app_id": "cli_default",
"group_policy": "mention",
"allow_from": [],
},
{
"id": "product",
"name": "Product bot",
"display_name": "Product bot",
"avatar_url": "",
"domain": "feishu",
"enabled": False,
"configured": True,
"app_id": "cli_product",
"group_policy": "mention",
"allow_from": [],
},
]
def test_optional_features_payload_backfills_saved_feishu_identity(monkeypatch, tmp_path):
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({
"channels": {
"feishu": {
"instances": [{
"id": "default",
"name": "nanobot",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
}]
}
}
}),
config_path,
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": "Xubin Ren的智能助手",
"avatarUrl": "https://example.com/assistant.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
payload = optional_features_payload()
instance = payload["features"][0]["instances"][0]
assert instance["display_name"] == "Xubin Ren的智能助手"
assert instance["avatar_url"] == "https://example.com/assistant.png"
data = json.loads(config_path.read_text(encoding="utf-8"))
saved = data["channels"]["feishu"]["instances"][0]
assert saved["displayName"] == "Xubin Ren的智能助手"
assert saved["avatarUrl"] == "https://example.com/assistant.png"
assert saved["identityFetchedAt"] == "2026-07-06T00:00:00Z"
def test_optional_features_payload_records_feishu_identity_attempt_on_empty_result(
monkeypatch,
tmp_path,
):
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({
"channels": {
"feishu": {
"instances": [{
"id": "default",
"name": "nanobot",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
}]
}
}
}),
config_path,
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *args: {})
monkeypatch.setattr(feishu_module, "_identity_timestamp", lambda: "2026-07-06T00:00:00Z")
payload = optional_features_payload()
instance = payload["features"][0]["instances"][0]
assert instance["display_name"] == "nanobot"
assert instance["avatar_url"] == ""
data = json.loads(config_path.read_text(encoding="utf-8"))
saved = data["channels"]["feishu"]["instances"][0]
assert saved["identityFetchedAt"] == "2026-07-06T00:00:00Z"
assert "displayName" not in saved
assert "avatarUrl" not in saved
def test_optional_features_payload_preserves_legacy_flat_feishu_config(monkeypatch, tmp_path):
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({
"channels": {
"feishu": {
"enabled": True,
"appId": "cli_legacy",
"appSecret": "legacy-secret",
"groupPolicy": "mention",
}
}
}),
config_path,
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda *_args: {
"displayName": "Legacy assistant",
"avatarUrl": "https://example.com/legacy.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
payload = optional_features_payload()
assert payload["features"][0]["instances"][0]["display_name"] == "Legacy assistant"
saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["feishu"]
assert saved["appId"] == "cli_legacy"
assert saved["appSecret"] == "legacy-secret"
assert saved["displayName"] == "Legacy assistant"
assert saved["avatarUrl"] == "https://example.com/legacy.png"
assert "instances" not in saved
def test_enable_bootstraps_pip_with_ensurepip(monkeypatch):
from nanobot import optional_features
@@ -1066,6 +1531,8 @@ def test_run_install_command_returns_failure_on_timeout(monkeypatch):
def test_optional_dependency_metadata_for_enable():
from nanobot import optional_features
data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
deps = data["project"]["optional-dependencies"]
required = data["project"]["dependencies"]
@@ -1077,25 +1544,32 @@ def test_optional_dependency_metadata_for_enable():
"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)
for dependency in (
"defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
"python-pptx>=1.0.0,<2.0.0",
):
assert dependency in required
assert deps["dingtalk"] == ["dingtalk-stream>=0.24.0,<1.0.0"]
assert deps["documents"] == [
"defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
"python-pptx>=1.0.0,<2.0.0",
]
assert deps["pdf"] == ["pypdf>=5.0.0,<6.0.0"]
assert deps["feishu"] == ["lark-oapi>=1.5.0,<2.0.0"]
assert deps["langfuse"] == ["langfuse>=3.0.0,<4.0.0"]
assert deps["mochat"] == [
"python-socketio>=5.16.0,<6.0.0",
"msgpack>=1.1.0,<2.0.0",
@@ -1107,6 +1581,10 @@ def test_optional_dependency_metadata_for_enable():
"slack-sdk>=3.39.0,<4.0.0",
"slackify-markdown>=0.2.0,<1.0.0",
]
visible = optional_features.optional_dependency_groups()
assert "documents" not in visible
assert "pdf" not in visible
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
@@ -1986,6 +2464,7 @@ async def test_stop_all_cancels_dispatcher_and_stops_channels():
ch = _StartableChannel(fake_config, mgr.bus)
mgr.channels = {"startable": ch}
mgr._channel_tasks = {}
# Create a real cancelled task
async def dummy_task():
@@ -2063,6 +2542,7 @@ async def test_stop_all_handles_channel_exception():
mgr.config = fake_config
mgr.bus = MessageBus()
mgr.channels = {"stopfailing": _StopFailingChannel(fake_config, mgr.bus)}
mgr._channel_tasks = {}
mgr._dispatch_task = None
# Should not raise even if channel.stop() raises
@@ -2101,6 +2581,7 @@ async def test_stop_all_handles_channel_stop_cancelled_task():
"stopcancelled": _StopCancelledChannel(fake_config, mgr.bus),
"next": next_channel,
}
mgr._channel_tasks = {}
mgr._dispatch_task = None
await mgr.stop_all()
@@ -2142,6 +2623,7 @@ async def test_start_all_creates_dispatch_task():
ch = _StartableChannel(fake_config, mgr.bus)
mgr.channels = {"startable": ch}
mgr._channel_tasks = {}
mgr._dispatch_task = None
# Cancel immediately after start to avoid running forever
+54
View File
@@ -0,0 +1,54 @@
from nanobot.channels._setup import channel_setup_spec
def test_channel_setup_spec_derives_route_and_secret_metadata() -> None:
slack = channel_setup_spec("slack")
assert slack is not None
assert slack.secrets == {"appToken", "botToken"}
assert slack.route_field_types == {
"appToken": "secret",
"botToken": "secret",
"groupPolicy": ("enum", {"mention", "open", "allowlist"}),
}
assert slack.simple_required_fields == ("appToken", "botToken")
def test_matrix_setup_requires_one_complete_login_method() -> None:
matrix = channel_setup_spec("matrix")
assert matrix is not None
base = {
"homeserver": "https://matrix.example",
"userId": "@nanobot:matrix.example",
}
assert matrix.is_configured(base | {"password": "secret"})
assert matrix.is_configured(base | {"accessToken": "token", "deviceId": "DEVICE"})
assert not matrix.is_configured(base | {"accessToken": "token"})
def test_channel_setup_spec_separates_writable_and_snapshot_fields() -> None:
matrix = channel_setup_spec("matrix")
discord = channel_setup_spec("discord")
assert matrix is not None
assert discord is not None
assert "allowFrom" not in matrix.route_field_types
assert "allowFrom" in matrix.snapshot_fields
assert "allowFrom" in discord.route_field_types
assert "allowFrom" not in discord.snapshot_fields
def test_webui_forms_have_writable_mattermost_and_whatsapp_contracts() -> None:
mattermost = channel_setup_spec("mattermost")
whatsapp = channel_setup_spec("whatsapp")
assert mattermost is not None
assert whatsapp is not None
assert mattermost.route_field_types["serverUrl"] == "string"
assert mattermost.route_field_types["token"] == "secret"
assert whatsapp.route_field_types["allowFrom"] == "list"
assert whatsapp.route_field_types["groupPolicy"] == (
"enum",
{"mention", "open"},
)
+319 -5
View File
@@ -7,6 +7,11 @@ from nanobot.channels import feishu as feishu_module
from nanobot.channels.feishu import FeishuChannel
from nanobot.config import loader
from nanobot.config.schema import Config
from nanobot.pairing import store as pairing_store
def _default_feishu_instance(data: dict) -> dict:
return data["channels"]["feishu"]["instances"][0]
@pytest.mark.asyncio
@@ -25,15 +30,30 @@ async def test_feishu_login_writes_credentials_to_active_config(monkeypatch, tmp
"domain": "lark",
},
)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": "Voraflare Bot",
"avatarUrl": "https://example.com/avatar.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
channel = FeishuChannel({"enabled": False, "domain": "feishu"}, None)
assert await channel.login() is True
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["feishu"]["appId"] == "cli_app"
assert data["channels"]["feishu"]["appSecret"] == "secret"
assert data["channels"]["feishu"]["domain"] == "lark"
assert data["channels"]["feishu"]["enabled"] is True
instance = _default_feishu_instance(data)
assert instance["id"] == "default"
assert instance["appId"] == "cli_app"
assert instance["appSecret"] == "secret"
assert instance["domain"] == "lark"
assert instance["identityKey"] == "lark:cli_app"
assert instance["enabled"] is True
assert instance["displayName"] == "Voraflare Bot"
assert instance["avatarUrl"] == "https://example.com/avatar.png"
assert instance["identityFetchedAt"] == "2026-07-06T00:00:00Z"
def test_begin_registration_requires_login_url(monkeypatch):
@@ -70,6 +90,298 @@ def test_qr_register_returns_none_on_network_error(monkeypatch):
assert feishu_module.qr_register() is None
def test_save_registration_result_keeps_credentials_when_identity_fetch_fails(monkeypatch, tmp_path):
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
def fail_identity(_app_id, _app_secret, _domain):
raise RuntimeError("metadata unavailable")
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", fail_identity)
feishu_module.save_registration_result({
"app_id": "cli_app",
"app_secret": "secret",
"domain": "feishu",
})
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = _default_feishu_instance(data)
assert instance["appId"] == "cli_app"
assert instance["appSecret"] == "secret"
assert instance["identityKey"] == "feishu:cli_app"
assert "displayName" not in instance
assert "avatarUrl" not in instance
def test_save_registration_result_resets_access_when_instance_app_changes(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "old assistant",
"enabled": True,
"appId": "cli_old",
"appSecret": "old-secret",
"identityKey": "feishu:cli_old",
"allowFrom": ["old-open-id"],
"allow_from": ["old-snake-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
feishu_module.save_registration_result(
{
"app_id": "cli_new",
"app_secret": "new-secret",
"domain": "feishu",
},
instance_id="assistant-test",
name="new assistant",
)
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["appId"] == "cli_new"
assert instance["appSecret"] == "new-secret"
assert instance["identityKey"] == "feishu:cli_new"
assert instance["allowFrom"] == []
assert instance["allow_from"] == []
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
assert pairing_store.approve_code(pending_code) is None
def test_save_registration_result_keeps_access_when_only_secret_rotates(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "same assistant",
"enabled": True,
"appId": "cli_same",
"appSecret": "old-secret",
"domain": "feishu",
"identityKey": "feishu:cli_same",
"allowFrom": ["old-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
feishu_module.save_registration_result(
{
"app_id": "cli_same",
"app_secret": "new-secret",
"domain": "feishu",
},
instance_id="assistant-test",
name="same assistant",
)
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["appSecret"] == "new-secret"
assert instance["identityKey"] == "feishu:cli_same"
assert instance["allowFrom"] == ["old-open-id"]
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is True
assert pairing_store.approve_code(pending_code) == (
"feishu.assistant-test",
"pending-user",
)
def test_save_registration_result_resets_access_when_domain_changes(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "lark assistant",
"enabled": True,
"appId": "cli_same",
"appSecret": "old-secret",
"domain": "lark",
"identityKey": "lark:cli_same",
"allowFrom": ["old-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
feishu_module.save_registration_result(
{
"app_id": "cli_same",
"app_secret": "new-secret",
"domain": "feishu",
},
instance_id="assistant-test",
name="feishu assistant",
)
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["domain"] == "feishu"
assert instance["identityKey"] == "feishu:cli_same"
assert instance["allowFrom"] == []
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
def test_sync_saved_identity_boundary_resets_access_after_manual_app_change(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "manual assistant",
"enabled": True,
"appId": "cli_new",
"appSecret": "secret",
"domain": "feishu",
"identityKey": "feishu:cli_old",
"allowFrom": ["old-open-id"],
"allow_from": ["old-snake-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
assert feishu_module.sync_saved_feishu_identity_boundary(
instance_id="assistant-test",
app_id="cli_new",
domain="feishu",
) is True
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["identityKey"] == "feishu:cli_new"
assert instance["allowFrom"] == []
assert instance["allow_from"] == []
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
assert pairing_store.approve_code(pending_code) is None
def test_sync_saved_identity_boundary_backfills_marker_without_resetting_access(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "existing assistant",
"enabled": True,
"appId": "cli_existing",
"appSecret": "secret",
"domain": "feishu",
"allowFrom": ["old-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
assert feishu_module.sync_saved_feishu_identity_boundary(
instance_id="assistant-test",
app_id="cli_existing",
domain="feishu",
) is False
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["identityKey"] == "feishu:cli_existing"
assert instance["allowFrom"] == ["old-open-id"]
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is True
def test_sync_saved_identity_boundary_preserves_legacy_flat_config(monkeypatch, tmp_path):
config_path = tmp_path / "config.json"
config = Config()
config.channels.feishu = {
"enabled": True,
"appId": "cli_existing",
"appSecret": "secret",
"domain": "feishu",
"allowFrom": ["old-open-id"],
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
assert feishu_module.sync_saved_feishu_identity_boundary(
instance_id="default",
app_id="cli_existing",
domain="feishu",
) is False
saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["feishu"]
assert saved["appId"] == "cli_existing"
assert saved["appSecret"] == "secret"
assert saved["identityKey"] == "feishu:cli_existing"
assert saved["allowFrom"] == ["old-open-id"]
assert "instances" not in saved
@pytest.mark.asyncio
async def test_feishu_login_creates_missing_active_config(monkeypatch, tmp_path):
missing_config = tmp_path / "missing.json"
@@ -89,4 +401,6 @@ async def test_feishu_login_creates_missing_active_config(monkeypatch, tmp_path)
assert await channel.login() is True
assert missing_config.exists()
data = json.loads(missing_config.read_text(encoding="utf-8"))
assert data["channels"]["feishu"]["appId"] == "cli_app"
instance = _default_feishu_instance(data)
assert instance["id"] == "default"
assert instance["appId"] == "cli_app"
+118
View File
@@ -44,6 +44,7 @@ def _make_feishu_channel(
channel._client = MagicMock()
# _loop is only used by the WebSocket thread bridge; not needed for unit tests
channel._loop = None
channel._running = True
return channel
@@ -209,6 +210,35 @@ def test_reply_message_sync_returns_false_on_api_error() -> None:
assert ok is False
def test_reply_message_sync_falls_back_to_text_for_interactive_error() -> None:
channel = _make_feishu_channel()
interactive_resp = MagicMock()
interactive_resp.success.return_value = False
interactive_resp.code = 230099
interactive_resp.msg = "cardid is invalid"
interactive_resp.get_log_id.return_value = "log_x"
text_resp = MagicMock()
text_resp.success.return_value = True
channel._client.im.v1.message.reply.side_effect = [interactive_resp, text_resp]
ok = channel._reply_message_sync(
"om_parent",
"interactive",
json.dumps(
{
"config": {"wide_screen_mode": True},
"elements": [{"tag": "markdown", "content": "fallback body"}],
},
ensure_ascii=False,
),
)
assert ok is True
assert channel._client.im.v1.message.reply.call_count == 2
def test_reply_message_sync_returns_false_on_exception() -> None:
channel = _make_feishu_channel()
channel._client.im.v1.message.reply.side_effect = RuntimeError("network error")
@@ -368,6 +398,37 @@ async def test_send_fallback_to_create_when_reply_fails() -> None:
channel._client.im.v1.message.create.assert_called_once()
def test_send_message_sync_falls_back_to_text_for_interactive_error() -> None:
channel = _make_feishu_channel()
interactive_resp = MagicMock()
interactive_resp.success.return_value = False
interactive_resp.code = 230099
interactive_resp.msg = "cardid is invalid"
interactive_resp.get_log_id.return_value = "log_x"
text_resp = MagicMock()
text_resp.success.return_value = True
text_resp.data = SimpleNamespace(message_id="om_fallback")
channel._client.im.v1.message.create.side_effect = [interactive_resp, text_resp]
message_id = channel._send_message_sync(
"chat_id",
"oc_abc",
"interactive",
json.dumps(
{
"config": {"wide_screen_mode": True},
"elements": [{"tag": "markdown", "content": "fallback body"}],
},
ensure_ascii=False,
),
)
assert message_id == "om_fallback"
assert channel._client.im.v1.message.create.call_count == 2
@pytest.mark.asyncio
async def test_send_multiple_messages_all_use_reply_when_in_topic(tmp_path: Path) -> None:
"""When in a topic (has thread_id), all messages use reply API to stay in topic."""
@@ -786,6 +847,36 @@ async def test_session_key_group_no_root_id_uses_message_id() -> None:
assert bus_spy[0].session_key == "feishu:oc_abc:om_001"
@pytest.mark.asyncio
async def test_session_key_named_instance_uses_runtime_channel_namespace() -> None:
"""Named Feishu assistant instances keep group sessions separate."""
channel = _make_feishu_channel(group_policy="open")
channel.name = "feishu.product"
bus_spy = []
original_publish = channel.bus.publish_inbound
async def capture(msg):
bus_spy.append(msg)
await original_publish(msg)
channel.bus.publish_inbound = capture
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
channel.transcribe_audio = AsyncMock(return_value="")
channel._add_reaction = AsyncMock(return_value=None)
event = _make_feishu_event(
chat_type="group",
content='{"text": "hello"}',
root_id="om_root123",
message_id="om_child456",
)
await channel._on_message(event)
assert len(bus_spy) == 1
assert bus_spy[0].channel == "feishu.product"
assert bus_spy[0].session_key == "feishu.product:oc_abc:om_root123"
@pytest.mark.asyncio
async def test_session_key_private_chat_no_override() -> None:
"""Private chat never overrides session key (consistent with Telegram/Slack)."""
@@ -1036,6 +1127,33 @@ def test_on_background_task_done_removes_from_set() -> None:
assert task not in channel._background_tasks
def test_on_message_sync_ignores_events_after_channel_stops() -> None:
"""Late WebSocket callbacks should not schedule work after the assistant is off."""
channel = _make_feishu_channel()
channel._running = False
channel._loop = MagicMock()
channel._loop.is_running.return_value = True
with patch("asyncio.run_coroutine_threadsafe") as schedule:
channel._on_message_sync(_make_feishu_event())
schedule.assert_not_called()
@pytest.mark.asyncio
async def test_on_message_ignores_events_after_channel_stops() -> None:
"""Stopped assistants must not react, pair, or publish stale Feishu events."""
channel = _make_feishu_channel(group_policy="open")
channel._running = False
channel._add_reaction = AsyncMock()
channel._handle_message = AsyncMock()
await channel._on_message(_make_feishu_event())
channel._add_reaction.assert_not_awaited()
channel._handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_on_message_unauthorized_dm_sends_pairing_code_without_side_effects() -> None:
"""Unauthorized DM sender gets a pairing code but no media side effects."""
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import asyncio
import threading
from nanobot.channels._feishu_ws import FeishuWsRunner
def test_concurrent_loop_initialization_starts_one_thread(monkeypatch) -> None:
runner = FeishuWsRunner()
created_loops: list[asyncio.AbstractEventLoop] = []
release_start = threading.Event()
def fake_run_loop() -> None:
loop = asyncio.new_event_loop()
created_loops.append(loop)
assert release_start.wait(timeout=2)
runner._loop = loop
runner._ready.set()
monkeypatch.setattr(runner, "_run_loop", fake_run_loop)
loops: list[asyncio.AbstractEventLoop] = []
threads = [threading.Thread(target=lambda: loops.append(runner._ensure_loop())) for _ in range(2)]
for thread in threads:
thread.start()
release_start.set()
for thread in threads:
thread.join(timeout=2)
assert len(created_loops) == 1
assert loops == [created_loops[0], created_loops[0]]
created_loops[0].close()
@@ -76,6 +76,7 @@ def _make_handler(
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Any | None = None,
local_trigger_pending_ids: Any | None = None,
channel_feature_action: Any | None = None,
) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = workspace_path or Path.cwd()
@@ -93,6 +94,7 @@ def _make_handler(
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
)
@@ -108,6 +110,7 @@ def _ch(
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Any | None = None,
local_trigger_pending_ids: Any | None = None,
channel_feature_action: Any | None = None,
**extra: Any,
) -> WebSocketChannel:
cfg: dict[str, Any] = {
@@ -129,6 +132,7 @@ def _ch(
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
)
return WebSocketChannel(cfg, bus, gateway=gateway)
@@ -650,6 +654,128 @@ async def test_nanobot_feature_routes_require_token_and_enable(
await server_task
@pytest.mark.asyncio
async def test_pairing_routes_require_token_and_approve_or_deny(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
pending = [
{
"code": "ABCD-EFGH",
"channel": "feishu",
"sender_id": "ou_123",
"created_at": 1_000.0,
"expires_at": 1_600.0,
}
]
approved: list[str] = []
denied: list[str] = []
monkeypatch.setattr("nanobot.webui.settings_routes.list_pending", lambda: list(pending))
monkeypatch.setattr(
"nanobot.webui.settings_routes.approve_code",
lambda code: approved.append(code) or ("feishu", "ou_123") if code == "ABCD-EFGH" else None,
)
monkeypatch.setattr(
"nanobot.webui.settings_routes.deny_code",
lambda code: denied.append(code) or code == "ABCD-EFGH",
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
denied_response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(path="/api/settings/pairing"),
"/api/settings/pairing",
)
assert denied_response is not None
assert denied_response.status_code == 401
auth = {"Authorization": f"Bearer {token}"}
listed = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing"),
"/api/settings/pairing",
)
assert listed is not None
assert listed.status_code == 200
body = json.loads(listed.body.decode())
assert body["requests"][0]["code"] == "ABCD-EFGH"
assert body["requests"][0]["channel"] == "feishu"
assert body["requests"][0]["sender_id"] == "ou_123"
assert body["requests"][0]["created_at_ms"] == 1_000_000
assert body["requests"][0]["expires_at_ms"] == 1_600_000
approved_response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing/approve?code=ABCD-EFGH"),
"/api/settings/pairing/approve",
)
assert approved_response is not None
assert approved_response.status_code == 200
body = json.loads(approved_response.body.decode())
assert body["last_action"]["action"] == "approve"
assert body["last_action"]["sender_id"] == "ou_123"
assert approved == ["ABCD-EFGH"]
denied_action = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing/deny?code=ABCD-EFGH"),
"/api/settings/pairing/deny",
)
assert denied_action is not None
assert denied_action.status_code == 200
assert json.loads(denied_action.body.decode())["last_action"]["action"] == "deny"
assert denied == ["ABCD-EFGH"]
missing_code = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing/approve"),
"/api/settings/pairing/approve",
)
assert missing_code is not None
assert missing_code.status_code == 400
assert "Missing pairing code" in missing_code.body.decode()
def test_api_service_settings_read_api_key_from_private_header(bus: MagicMock) -> None:
channel = _ch(bus)
request = _FakeReq(
{"X-Nanobot-API-Service-Values": json.dumps({"api_key": "secret-token"})},
path="/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
)
query = channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
assert query == {
"host": ["0.0.0.0"],
"port": ["8900"],
"timeout": ["120"],
"api_key": ["secret-token"],
}
def test_api_service_settings_reject_invalid_private_header(bus: MagicMock) -> None:
from nanobot.webui.settings_api import WebUISettingsError
channel = _ch(bus)
request = _FakeReq(
{"X-Nanobot-API-Service-Values": json.dumps({"api_key": 123})},
path="/api/settings/api-service/start?host=127.0.0.1",
)
with pytest.raises(WebUISettingsError, match="API key must be a string"):
channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
query_secret = _FakeReq(
path="/api/settings/api-service/start?host=127.0.0.1&api_key=secret-token",
)
with pytest.raises(WebUISettingsError, match="private header"):
channel.gateway.http.settings_routes._parse_api_service_settings_query(query_secret)
@pytest.mark.asyncio
async def test_nanobot_feature_remote_install_requires_opt_in(
bus: MagicMock,
@@ -733,6 +859,506 @@ async def test_nanobot_feature_local_install_allowed_by_default(
] is True
@pytest.mark.asyncio
async def test_nanobot_feature_channel_action_can_apply_without_restart(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
_stub_matrix_feature(monkeypatch, config_path, deps=["matrix-nio>=0.25.2"])
calls: list[tuple[str, str]] = []
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
calls.append((action, name))
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": "Matrix channel applied without restart.",
}
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
port=_free_port(),
channel_feature_action=channel_feature_action,
)
token = channel.gateway.tokens.issue_api_token(300)
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
body = json.loads(response.body.decode())
assert calls == [("enable", "matrix")]
assert body["requires_restart"] is False
assert body["restart_required_sections"] == []
assert body["last_action"]["hot_reload"] is True
assert body["last_action"]["message"].endswith("Matrix channel applied without restart.")
@pytest.mark.asyncio
async def test_feishu_connect_routes_write_config_and_hot_reload(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(feishu_module, "_init_registration", lambda _domain: None)
monkeypatch.setattr(
feishu_module,
"_begin_registration",
lambda _domain: {
"device_code": "device",
"qr_url": "https://accounts.feishu.cn/login?device_code=device",
"interval": 2,
"expire_in": 600,
},
)
monkeypatch.setattr(
feishu_module,
"poll_registration_once",
lambda *, device_code, domain: {
"status": "succeeded",
"app_id": "cli_app",
"app_secret": "secret",
"domain": "feishu",
},
)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": "Voraflare Bot",
"avatarUrl": "https://example.com/feishu.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
monkeypatch.setattr(
"nanobot.webui.settings_routes.nanobot_features_action",
lambda _action, _query, *, allow_install=True: {
"features": [{
"name": "feishu",
"display_name": "Feishu",
"type": "channel",
"enabled": True,
"installed": True,
"ready": True,
"status": "enabled",
"install_supported": True,
"requires_restart": True,
}],
"enabled_count": 1,
"requires_restart": True,
"last_action": {"ok": True, "message": "Enabled channel 'feishu'", "enabled": True},
},
)
calls: list[tuple[str, str]] = []
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
calls.append((action, name))
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": "Feishu channel applied without restart.",
}
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
port=_free_port(),
channel_feature_action=channel_feature_action,
)
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"}
started = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
auth,
path="/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default",
),
"/api/settings/channels/feishu/connect/start",
)
assert started is not None
assert started.status_code == 200
start_body = json.loads(started.body.decode())
assert start_body["status"] == "pending"
assert start_body["instance_id"] == "default"
assert start_body["qr_url"].startswith("https://accounts.feishu.cn/")
polled = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
auth,
path=f"/api/settings/channels/feishu/connect/poll?session_id={start_body['session_id']}",
),
"/api/settings/channels/feishu/connect/poll",
)
assert polled is not None
assert polled.status_code == 200
body = json.loads(polled.body.decode())
assert body["status"] == "succeeded"
assert body["instance_id"] == "default"
assert "app_secret" not in body
assert calls == [("enable", "feishu")]
assert body["nanobot_features"]["requires_restart"] is False
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["feishu"]["instances"][0]["id"] == "default"
assert data["channels"]["feishu"]["instances"][0]["appId"] == "cli_app"
assert data["channels"]["feishu"]["instances"][0]["appSecret"] == "secret"
assert data["channels"]["feishu"]["instances"][0]["enabled"] is True
assert data["channels"]["feishu"]["instances"][0]["displayName"] == "Voraflare Bot"
assert data["channels"]["feishu"]["instances"][0]["avatarUrl"] == "https://example.com/feishu.png"
def test_feishu_connect_create_appends_instance(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.webui.channel_connect import FeishuConnectStore
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({
"channels": {
"feishu": {
"instances": [{
"id": "default",
"name": "nanobot",
"enabled": True,
"appId": "cli_default",
"appSecret": "default-secret",
}]
}
}
}),
encoding="utf-8",
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(feishu_module, "_init_registration", lambda _domain: None)
monkeypatch.setattr(
feishu_module,
"_begin_registration",
lambda _domain: {
"device_code": "device",
"qr_url": "https://accounts.feishu.cn/login?device_code=device",
"interval": 2,
"expire_in": 600,
},
)
monkeypatch.setattr(
feishu_module,
"poll_registration_once",
lambda *, device_code, domain: {
"status": "succeeded",
"app_id": "cli_new",
"app_secret": "new-secret",
"domain": "feishu",
},
)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": f"Assistant {app_id}",
"avatarUrl": f"https://example.com/{app_id}.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
store = FeishuConnectStore()
started = store.start(mode="create")
polled = store.poll(started["session_id"])
assert polled["status"] == "succeeded"
assert polled["instance_id"] != "default"
data = json.loads(config_path.read_text(encoding="utf-8"))
instances = data["channels"]["feishu"]["instances"]
assert [item["id"] for item in instances] == ["default", polled["instance_id"]]
assert instances[0]["appId"] == "cli_default"
assert instances[1]["appId"] == "cli_new"
assert instances[0].get("displayName") is None
assert instances[1]["displayName"] == "Assistant cli_new"
assert instances[1]["avatarUrl"] == "https://example.com/cli_new.png"
@pytest.mark.asyncio
async def test_channel_configure_route_saves_discord_config_and_hot_reloads(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
def fake_feature_action(
action: str,
query: dict[str, list[str]],
*,
allow_install: bool = True,
) -> dict[str, Any]:
assert action == "enable"
assert query == {"name": ["discord"]}
cfg = loader.load_config()
section = dict(getattr(cfg.channels, "discord", {}) or {})
section["enabled"] = True
setattr(cfg.channels, "discord", section)
loader.save_config(cfg)
return {
"features": [{
"name": "discord",
"display_name": "Discord",
"type": "channel",
"enabled": True,
"installed": True,
"ready": True,
"status": "enabled",
"install_supported": True,
"requires_restart": True,
}],
"enabled_count": 1,
"requires_restart": True,
"last_action": {"ok": True, "message": "Enabled channel 'discord'", "enabled": True},
}
monkeypatch.setattr("nanobot.webui.settings_routes.nanobot_features_action", fake_feature_action)
calls: list[tuple[str, str]] = []
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
calls.append((action, name))
cfg = loader.load_config()
assert getattr(cfg.channels, "discord")["token"] == "discord-token"
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": "Discord channel applied without restart.",
}
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
port=_free_port(),
channel_feature_action=channel_feature_action,
)
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.discord.token": "discord-token",
"channels.discord.allowChannels": "123, 456",
"channels.discord.groupPolicy": "open",
}
),
},
path="/api/settings/channels/configure?name=discord&enable=true",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
body = json.loads(response.body.decode())
assert body["saved"] is True
assert body["name"] == "discord"
assert "discord-token" not in response.body.decode()
assert calls == [("enable", "discord")]
assert body["nanobot_features"]["requires_restart"] is False
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["discord"] == {
"token": "discord-token",
"allowChannels": ["123", "456"],
"groupPolicy": "open",
"enabled": True,
}
@pytest.mark.asyncio
async def test_channel_configure_route_preserves_existing_channel_values(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
config = Config()
setattr(
config.channels,
"discord",
{
"enabled": True,
"token": "old-discord-token",
"allowChannels": ["old-channel"],
"groupPolicy": "mention",
"customExtra": "keep-me",
"nested": {"value": 42},
},
)
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.discord.token": "",
"channels.discord.allowChannels": "new-channel",
}
),
},
path="/api/settings/channels/configure?name=discord",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
body = json.loads(response.body.decode())
assert body["saved_keys"] == ["channels.discord.allowChannels"]
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["discord"] == {
"enabled": True,
"token": "old-discord-token",
"allowChannels": ["new-channel"],
"groupPolicy": "mention",
"customExtra": "keep-me",
"nested": {"value": 42},
}
@pytest.mark.asyncio
async def test_channel_configure_route_saves_matrix_device_id_without_replacing_token(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
config = Config()
setattr(
config.channels,
"matrix",
{
"enabled": False,
"homeserver": "https://matrix.example",
"userId": "@nanobot:matrix.example",
"accessToken": "saved-token",
},
)
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.matrix.accessToken": "",
"channels.matrix.deviceId": "DEVICE-ID",
}
),
},
path="/api/settings/channels/configure?name=matrix",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["matrix"]["accessToken"] == "saved-token"
assert data["channels"]["matrix"]["deviceId"] == "DEVICE-ID"
@pytest.mark.asyncio
async def test_channel_configure_route_saves_mattermost_setup(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.mattermost.serverUrl": "https://chat.example.com",
"channels.mattermost.token": "mattermost-token",
"channels.mattermost.teamId": "platform",
}
),
},
path="/api/settings/channels/configure?name=mattermost",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["mattermost"] == {
"serverUrl": "https://chat.example.com",
"token": "mattermost-token",
"teamId": "platform",
}
@pytest.mark.asyncio
async def test_nanobot_feature_loopback_reverse_proxy_install_requires_opt_in(
bus: MagicMock,
+180 -1
View File
@@ -7,6 +7,7 @@ from contextlib import suppress
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import parse_qs, urlparse
import pytest
from typer.testing import CliRunner
@@ -1607,6 +1608,12 @@ def _patch_webui_provider_ready(monkeypatch) -> None:
monkeypatch.setattr("nanobot.providers.factory.build_provider_snapshot", _snapshot)
def _patch_gateway_ports_free(monkeypatch) -> None:
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_a, **_kw: False)
monkeypatch.setattr("nanobot.cli.commands._tcp_endpoint_reachable", lambda *_a, **_kw: False)
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_a, **_kw: False)
def _patch_cli_command_runtime(
monkeypatch,
config: Config,
@@ -1643,6 +1650,7 @@ def _patch_cli_command_runtime(
"nanobot.providers.factory.load_provider_snapshot",
lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config),
)
_patch_gateway_ports_free(monkeypatch)
if message_bus is not None:
monkeypatch.setattr("nanobot.bus.queue.MessageBus", message_bus)
@@ -1701,11 +1709,17 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
assert len(websocket["tokenIssueSecret"]) >= 32
assert data["agents"]["defaults"]["workspace"] == str(workspace)
assert seen["templates"] == workspace
assert seen["gateway_kwargs"] == {"port": 18888, "open_browser_url": None}
assert seen["gateway_kwargs"] == {
"port": 18888,
"open_browser_url": None,
"webui_bundle_mode": "auto",
}
compact_output = re.sub(r"\s+", " ", _strip_ansi(result.stdout))
assert "bootstrap secret was generated" in compact_output
assert "channels.websocket.tokenIssueSecret" in compact_output
assert "rerun without --no-open" in compact_output
assert "nanobot is running in this terminal" in compact_output
assert "Press Ctrl+C here to stop nanobot" in compact_output
def test_webui_yes_refuses_missing_provider_setup(monkeypatch, tmp_path: Path) -> None:
@@ -1732,6 +1746,10 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
"nanobot.cli.commands._prepare_webui_bundle_for_gateway",
lambda *_args, **_kwargs: None,
)
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
@@ -1785,6 +1803,21 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=")
assert "bootstrapSecret=<redacted>" in compact_output
assert "bootstrapSecret=" in opened_url
assert "Closing the browser does not stop channels or automations" in compact_output
assert "nanobot gateway stop --config" in compact_output
def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> None:
opened: list[str] = []
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret"
monkeypatch.setattr("webbrowser.open", lambda value: opened.append(value))
cli_commands._open_webui_browser(url, wait=False)
assert opened == [url]
output = _strip_ansi(capsys.readouterr().out)
assert "bootstrapSecret=<redacted>" in output
assert "super-secret" not in output
def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
@@ -1799,6 +1832,10 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
"nanobot.cli.commands._prepare_webui_bundle_for_gateway",
lambda *_args, **_kwargs: None,
)
def _status(options: GatewayStartOptions) -> GatewayStatus:
return GatewayStatus(
@@ -1861,6 +1898,125 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=")
def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text("{}")
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args, **_kwargs: True)
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
monkeypatch.setattr(
"nanobot.cli.commands._open_webui_browser",
lambda url, **kwargs: seen.update({"opened_url": url, "open_kwargs": kwargs}),
)
monkeypatch.setattr(
"nanobot.cli.commands._run_gateway",
lambda *_args, **_kwargs: pytest.fail("existing gateway should be reused"),
)
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
seen["runtime_kwargs"] = kwargs
def status(self):
return SimpleNamespace(running=True)
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
"nanobot.cli.commands._attach_to_background_gateway",
lambda runtime: seen.__setitem__("attached_runtime", runtime),
)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
assert result.exit_code == 0
assert "Gateway is already running; attaching to the existing WebUI" in result.stdout
assert isinstance(seen["attached_runtime"], _FakeRuntime)
opened_url = seen["opened_url"]
assert isinstance(opened_url, str)
parsed = urlparse(opened_url)
assert f"{parsed.scheme}://{parsed.netloc}" == "http://127.0.0.1:8765"
fragment = parsed.fragment.removeprefix("/?")
assert parse_qs(fragment).get("bootstrapSecret")
assert seen["open_kwargs"] == {"wait": False}
def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> None:
stopped = False
class _FakeRuntime:
def status(self):
return SimpleNamespace(running=True)
def stop(self):
nonlocal stopped
stopped = True
return SimpleNamespace(ok=True, message="gateway_stopped")
def _interrupt(_seconds: float) -> None:
raise KeyboardInterrupt
monkeypatch.setattr("nanobot.cli.commands.time.sleep", _interrupt)
cli_commands._attach_to_background_gateway(_FakeRuntime())
assert stopped is True
output = capsys.readouterr().out
assert "Closing the browser does not stop channels or automations" in output
assert "Press Ctrl+C here to stop nanobot" in output
assert "Gateway stopped" in output
def test_webui_foreground_does_not_claim_unmanaged_gateway(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text("{}")
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args: True)
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args: True)
monkeypatch.setattr("nanobot.cli.commands._open_webui_browser", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"nanobot.cli.commands._attach_to_background_gateway",
lambda _runtime: pytest.fail("unmanaged gateway must not be attached"),
)
class _FakeRuntime:
def __init__(self, **_kwargs) -> None:
pass
def status(self):
return SimpleNamespace(running=False)
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
assert result.exit_code == 0
assert "controlled by another foreground command" in result.stdout
def test_webui_foreground_refuses_occupied_webui_port(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text("{}")
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args, **_kwargs: False)
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
monkeypatch.setattr("nanobot.cli.commands._tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
monkeypatch.setattr(
"nanobot.cli.commands._run_gateway",
lambda *_args, **_kwargs: pytest.fail("gateway should not start on occupied ports"),
)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
assert result.exit_code == 1
assert "nanobot cannot start because one of its local ports is already in use" in result.stdout
assert "--port" in result.stdout
assert "--gateway-port" in result.stdout
def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None:
pytest.importorskip("aiohttp")
@@ -1998,6 +2154,7 @@ def test_gateway_unbound_agent_cron_is_skipped(
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
_patch_gateway_ports_free(monkeypatch)
monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(provider, _config),
@@ -2124,6 +2281,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
_patch_gateway_ports_free(monkeypatch)
monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(provider, _config),
@@ -3062,6 +3220,27 @@ def test_serve_rejects_wildcard_host_without_api_key(monkeypatch, tmp_path: Path
assert "api_app" not in seen
def test_serve_rejects_specific_network_interface_without_api_key(
monkeypatch,
tmp_path: Path,
) -> None:
config_file = _write_instance_config(tmp_path)
config = Config()
seen: dict[str, object] = {}
_patch_serve_runtime(monkeypatch, config, seen)
result = runner.invoke(
app,
["serve", "--config", str(config_file), "--host", "192.168.1.10"],
)
assert result.exit_code == 1
assert "api_key" in result.stdout
assert "prevent unauthenticated access" in result.stdout
assert "api_app" not in seen
def test_channels_login_requires_channel_name() -> None:
result = runner.invoke(app, ["channels", "login"])
+26 -13
View File
@@ -88,13 +88,22 @@ def _test_app(tmp_path: Path, config: Config | None = None):
app = typer.Typer()
fake_runtime = FakeRuntime(tmp_path)
fake_service = FakeServiceInstaller(tmp_path)
run_calls: list[tuple[Config, int | None]] = []
run_calls: list[tuple[Config, int | None, str | None]] = []
prepare_calls: list[tuple[Config, str]] = []
def load_runtime_config(_config_path: str | None, _workspace: str | None) -> Config:
return config or Config()
def run_gateway(config: Config, *, port: int | None = None) -> None:
run_calls.append((config, port))
def run_gateway(
config: Config,
*,
port: int | None = None,
webui_bundle_mode: str | None = None,
) -> None:
run_calls.append((config, port, webui_bundle_mode))
def prepare_webui_bundle(config: Config, mode: str) -> None:
prepare_calls.append((config, mode))
app.add_typer(
create_gateway_app(
@@ -104,36 +113,39 @@ def _test_app(tmp_path: Path, config: Config | None = None):
run_gateway=run_gateway,
runtime_factory=lambda **_kwargs: fake_runtime,
service_factory=lambda: fake_service,
prepare_webui_bundle=prepare_webui_bundle,
),
name="gateway",
)
return app, fake_runtime, fake_service, run_calls
return app, fake_runtime, fake_service, run_calls, prepare_calls
def test_gateway_default_still_runs_foreground(tmp_path):
app, _runtime, _service, calls = _test_app(tmp_path)
app, _runtime, _service, calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(app, ["gateway", "--port", "18791"])
assert result.exit_code == 0
assert len(calls) == 1
assert calls[0][1] == 18791
assert calls[0][2] == "warn"
def test_gateway_background_starts_detached_runtime(tmp_path):
config = Config()
config.gateway.port = 18792
app, fake_runtime, _service, _calls = _test_app(tmp_path, config=config)
app, fake_runtime, _service, _calls, prepare_calls = _test_app(tmp_path, config=config)
result = runner.invoke(app, ["gateway", "--background"])
assert result.exit_code == 0
assert "Gateway started in the background" in result.stdout
assert fake_runtime.started_options == GatewayStartOptions(port=18792)
assert prepare_calls == [(config, "warn")]
def test_gateway_rejects_conflicting_modes(tmp_path):
app, _runtime, _service, _calls = _test_app(tmp_path)
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(app, ["gateway", "--foreground", "--background"])
@@ -142,7 +154,7 @@ def test_gateway_rejects_conflicting_modes(tmp_path):
def test_gateway_status_uses_runtime(tmp_path):
app, _runtime, _service, _calls = _test_app(tmp_path)
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(app, ["gateway", "status"])
@@ -152,7 +164,7 @@ def test_gateway_status_uses_runtime(tmp_path):
def test_gateway_logs_can_read_without_following(tmp_path):
app, _runtime, _service, _calls = _test_app(tmp_path)
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(app, ["gateway", "logs", "--tail", "12", "--no-follow"])
@@ -161,7 +173,7 @@ def test_gateway_logs_can_read_without_following(tmp_path):
def test_gateway_stop_treats_not_running_as_clean(tmp_path):
app, fake_runtime, _service, _calls = _test_app(tmp_path)
app, fake_runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
def fake_stop(*, timeout_s: int) -> RuntimeResult:
fake_runtime.stop_timeout = timeout_s
@@ -179,7 +191,7 @@ def test_gateway_stop_treats_not_running_as_clean(tmp_path):
def test_gateway_restart_starts_background_runtime(tmp_path):
config = Config()
config.gateway.port = 18793
app, fake_runtime, _service, _calls = _test_app(tmp_path, config=config)
app, fake_runtime, _service, _calls, prepare_calls = _test_app(tmp_path, config=config)
result = runner.invoke(app, ["gateway", "restart", "--timeout", "9", "--verbose"])
@@ -187,12 +199,13 @@ def test_gateway_restart_starts_background_runtime(tmp_path):
assert "Gateway restarted in the background" in result.stdout
assert fake_runtime.stop_timeout == 9
assert fake_runtime.restarted_options == GatewayStartOptions(port=18793, verbose=True)
assert prepare_calls == [(config, "warn")]
def test_gateway_install_service_uses_service_installer(tmp_path):
config = Config()
config.gateway.port = 18794
app, _runtime, service, _calls = _test_app(tmp_path, config=config)
app, _runtime, service, _calls, _prepare_calls = _test_app(tmp_path, config=config)
result = runner.invoke(app, ["gateway", "install-service", "--dry-run", "--manager", "systemd"])
@@ -205,7 +218,7 @@ def test_gateway_install_service_uses_service_installer(tmp_path):
def test_gateway_uninstall_service_uses_service_installer(tmp_path):
app, _runtime, service, _calls = _test_app(tmp_path)
app, _runtime, service, _calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(
app,
+57
View File
@@ -0,0 +1,57 @@
from pathlib import Path
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
class FakeProcess:
pid = 23456
def test_api_runtime_uses_isolated_paths(tmp_path: Path) -> None:
paths = api_runtime_paths(tmp_path / "config.json")
assert paths.state_path.parent == tmp_path / "run"
assert paths.state_path.name.startswith("api.")
assert paths.log_path.parent == tmp_path / "logs"
def test_api_runtime_builds_detached_serve_command(tmp_path: Path, monkeypatch) -> None:
calls: list[list[str]] = []
def fake_popen(command, **_kwargs):
calls.append(command)
return FakeProcess()
runtime = ApiRuntime(
paths=api_runtime_paths(tmp_path / "config.json"),
platform_name="Linux",
python_executable="/python",
popen=fake_popen,
sleep=lambda _seconds: None,
)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 23456)
result = runtime.start_background(ApiStartOptions(
host="0.0.0.0",
port=9900,
workspace="/tmp/workspace",
config_path="/tmp/config.json",
))
assert result.ok is True
assert result.message == "api_started_background"
assert calls == [[
"/python",
"-m",
"nanobot",
"serve",
"--host",
"0.0.0.0",
"--port",
"9900",
"--workspace",
"/tmp/workspace",
"--config",
"/tmp/config.json",
]]
+112 -2
View File
@@ -1,9 +1,13 @@
import json
import signal
import subprocess
import sys
import threading
from pathlib import Path
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
import pytest
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions, GatewayStatus
class FakeProcess:
@@ -83,6 +87,57 @@ def test_start_background_writes_state_and_child_command(tmp_path, monkeypatch):
assert state["port"] == 18790
def test_concurrent_background_starts_create_only_one_process(tmp_path, monkeypatch):
first_spawned = threading.Event()
release_first = threading.Event()
calls: list[list[str]] = []
def fake_popen(command, **_kwargs):
calls.append(command)
first_spawned.set()
return FakeProcess()
def first_sleep(_seconds):
assert release_first.wait(timeout=2)
first = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Linux",
popen=fake_popen,
sleep=first_sleep,
)
second = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Linux",
popen=fake_popen,
sleep=lambda _seconds: None,
)
for runtime in (first, second):
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 12345)
results = []
first_thread = threading.Thread(
target=lambda: results.append(first.start_background(GatewayStartOptions(port=18790)))
)
second_thread = threading.Thread(
target=lambda: results.append(second.start_background(GatewayStartOptions(port=18790)))
)
first_thread.start()
assert first_spawned.wait(timeout=2)
second_thread.start()
release_first.set()
first_thread.join(timeout=2)
second_thread.join(timeout=2)
assert len(calls) == 1
assert sorted((result.ok, result.message) for result in results) == [
(False, "gateway_already_running"),
(True, "gateway_started_background"),
]
def test_start_background_uses_windows_process_group_flags(tmp_path, monkeypatch):
calls: list[dict] = []
@@ -172,6 +227,34 @@ def test_stop_keeps_state_when_process_survives_timeout(tmp_path, monkeypatch):
assert runtime.paths.state_path.exists()
def test_stop_succeeds_when_process_exits_at_timeout_boundary(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
running = GatewayStatus(
running=True,
pid=12345,
state_path=runtime.paths.state_path,
log_path=runtime.paths.log_path,
)
stopped = GatewayStatus(
running=False,
pid=None,
state_path=runtime.paths.state_path,
log_path=runtime.paths.log_path,
reason="stop_timeout",
)
statuses = iter([running, stopped])
monkeypatch.setattr(runtime, "status", lambda **_kwargs: next(statuses))
monkeypatch.setattr(runtime, "_read_state", lambda: {"pid": 12345, "identity": 12345})
monkeypatch.setattr(runtime, "_record_matches_process", lambda *_args: True)
monkeypatch.setattr(runtime, "_terminate", lambda *_args, **_kwargs: False)
result = runtime.stop(timeout_s=0)
assert result.ok is True
assert result.message == "gateway_stopped"
assert result.status.running is False
def test_terminate_windows_falls_back_when_ctrl_break_is_rejected(tmp_path, monkeypatch):
taskkill_calls: list[dict] = []
wait_timeouts: list[int | float] = []
@@ -191,7 +274,7 @@ def test_terminate_windows_falls_back_when_ctrl_break_is_rejected(tmp_path, monk
def fake_kill(_pid, _signal):
raise OSError(87, "The parameter is incorrect")
monkeypatch.setattr("nanobot.gateway.runtime.os.kill", fake_kill)
monkeypatch.setattr("nanobot.process_runtime.os.kill", fake_kill)
def fake_wait_for_exit(_pid, _timeout_s):
wait_timeouts.append(_timeout_s)
@@ -212,3 +295,30 @@ def test_terminate_windows_falls_back_when_ctrl_break_is_rejected(tmp_path, monk
},
}
]
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups are unavailable")
def test_terminate_posix_tolerates_process_group_disappearing_before_sigkill(
tmp_path,
monkeypatch,
) -> None:
runtime = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Darwin",
sleep=lambda _seconds: None,
)
waits = iter([False, True])
monkeypatch.setattr(
"nanobot.process_runtime.os.getpgid",
lambda _pid: 1234,
raising=False,
)
def fake_killpg(_pgid, sent_signal):
if sent_signal == signal.SIGKILL:
raise PermissionError(1, "Operation not permitted")
monkeypatch.setattr("nanobot.process_runtime.os.killpg", fake_killpg, raising=False)
monkeypatch.setattr(runtime, "_wait_for_exit", lambda *_args: next(waits))
assert runtime._terminate_posix(1234, timeout_s=1) is True
+24
View File
@@ -42,6 +42,15 @@ class TestGenerateCode:
assert store.approve_code(code2) is None
class TestFormatPairingReply:
def test_points_owner_to_webui_with_command_fallback(self) -> None:
reply = store.format_pairing_reply("ABCD-EFGH")
assert "nanobot WebUI" in reply
assert "ABCD-EFGH" in reply
assert "/pairing approve ABCD-EFGH" in reply
class TestApproveDeny:
def test_approve_moves_to_approved(self) -> None:
code = store.generate_code("telegram", "123")
@@ -82,6 +91,21 @@ class TestRevoke:
def test_revoke_unknown_returns_false(self) -> None:
assert store.revoke("telegram", "999") is False
def test_clear_channel_removes_approved_and_pending(self) -> None:
code = store.generate_code("telegram", "123")
store.approve_code(code)
store.generate_code("telegram", "456")
store.generate_code("discord", "789")
assert store.clear_channel("telegram") == {"approved": 1, "pending": 1}
assert store.is_approved("telegram", "123") is False
pending = store.list_pending()
assert [item["channel"] for item in pending] == ["discord"]
def test_clear_channel_unknown_returns_zero_counts(self) -> None:
assert store.clear_channel("telegram") == {"approved": 0, "pending": 0}
class TestListPending:
def test_empty(self) -> None:
+17
View File
@@ -13,6 +13,7 @@ from nanobot.security.network import (
contains_internal_url,
env_proxy_applies_to_url,
httpx_env_proxy_mounts,
is_loopback_host,
pin_resolved_url_dns,
resolve_url_target,
validate_url_target,
@@ -21,6 +22,22 @@ from nanobot.security.network import (
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
@pytest.mark.parametrize(
"host",
["localhost", "LOCALHOST.", "127.0.0.1", "127.0.0.2", "::1", "[::1]"],
)
def test_is_loopback_host_accepts_explicit_loopback(host: str) -> None:
assert is_loopback_host(host)
@pytest.mark.parametrize(
"host",
["0.0.0.0", "::", "192.168.1.10", "api.internal", "example.com"],
)
def test_is_loopback_host_rejects_network_targets(host: str) -> None:
assert not is_loopback_host(host)
@pytest.fixture(autouse=True)
def _clear_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None:
for name in (*_PROXY_ENV_VARS, "NO_PROXY", "no_proxy"):
+75
View File
@@ -1,10 +1,15 @@
"""Tests for document text extraction utilities."""
from pathlib import Path
from zipfile import ZipFile
import pytest
from nanobot.utils.document import (
SUPPORTED_EXTENSIONS,
PdfSafetyError,
_is_text_extension,
extract_pdf_pages,
extract_text,
)
@@ -252,6 +257,76 @@ class TestExtractText:
assert result is not None
assert "Inside group" in result
def test_extract_text_rejects_oversized_office_archive(self, tmp_path, monkeypatch):
office_file = tmp_path / "oversized.docx"
with ZipFile(office_file, "w") as archive:
archive.writestr("word/document.xml", "x" * 32)
monkeypatch.setattr("nanobot.utils.document._MAX_OFFICE_UNCOMPRESSED_SIZE", 16)
assert "Office document expands beyond" in (extract_text(office_file) or "")
def test_extract_text_stops_streaming_xlsx_at_text_limit(self, tmp_path, monkeypatch):
from openpyxl import Workbook, load_workbook
xlsx_file = tmp_path / "large.xlsx"
wb = Workbook(write_only=True)
ws = wb.create_sheet()
for index in range(100):
ws.append([f"row-{index}-" + "x" * 20])
wb.save(xlsx_file)
visited = 0
real_load_workbook = load_workbook
def tracked_load_workbook(*args, **kwargs):
workbook = real_load_workbook(*args, **kwargs)
worksheet = workbook[workbook.sheetnames[0]]
original_iter_rows = worksheet.iter_rows
def tracked_rows(*row_args, **row_kwargs):
nonlocal visited
for row in original_iter_rows(*row_args, **row_kwargs):
visited += 1
yield row
worksheet.iter_rows = tracked_rows
return workbook
monkeypatch.setattr("openpyxl.load_workbook", tracked_load_workbook)
monkeypatch.setattr("nanobot.utils.document._MAX_TEXT_LENGTH", 80)
result = extract_text(xlsx_file)
assert result is not None
assert "truncated at 80 chars" in result
assert visited < 100
def test_extract_pdf_pages_rejects_large_content_stream(self, tmp_path, monkeypatch):
class _Contents:
@staticmethod
def get_data():
return b"x" * 17
class _Page:
@staticmethod
def get_contents():
return _Contents()
@staticmethod
def extract_text():
return "should not be reached"
class _Reader:
def __init__(self, *_args, **_kwargs):
self.pages = [_Page()]
monkeypatch.setattr("pypdf.PdfReader", _Reader)
monkeypatch.setattr("nanobot.utils.document._MAX_PDF_CONTENT_STREAM_SIZE", 16)
with pytest.raises(PdfSafetyError, match="content stream exceeds"):
extract_pdf_pages(tmp_path / "large.pdf")
def test_extract_text_pdf_not_found(self, tmp_path: Path):
"""Test that missing PDF files return error string."""
missing_pdf = tmp_path / "nonexistent.pdf"
+14 -1
View File
@@ -6,8 +6,8 @@ from unittest.mock import patch
import pytest
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
from nanobot.agent.tools import file_state
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
@pytest.fixture(autouse=True)
@@ -197,6 +197,19 @@ class TestReadPdf:
assert "Page 3 content" in result
assert "Page 1 content" not in result
@pytest.mark.asyncio
async def test_pdf_rejects_invalid_page_range(self, tool, tmp_path):
fitz = pytest.importorskip("fitz")
pdf_path = tmp_path / "test.pdf"
doc = fitz.open()
doc.new_page()
doc.save(str(pdf_path))
doc.close()
result = await tool.execute(path=str(pdf_path), pages="bad")
assert "Invalid page range" in result
@pytest.mark.asyncio
async def test_pdf_file_not_found_error(self, tool, tmp_path):
result = await tool.execute(path=str(tmp_path / "nope.pdf"))
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import os
from pathlib import Path
from nanobot.webui.build import ensure_webui_bundle, inspect_webui_bundle
_MTIME_BASE_NS = 1_700_000_000_000_000_000
_MTIME_STEP_NS = 5_000_000_000
def _touch(path: Path, *, mtime_ns: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(path.name, encoding="utf-8")
if mtime_ns < 1_000_000_000_000_000:
mtime_ns = _MTIME_BASE_NS + mtime_ns * _MTIME_STEP_NS
os.utime(path, ns=(mtime_ns, mtime_ns))
def test_inspect_webui_bundle_ignores_packaged_install_without_source(tmp_path: Path) -> None:
source = tmp_path / "site-packages" / "webui"
dist = tmp_path / "site-packages" / "nanobot" / "web" / "dist"
_touch(dist / "index.html", mtime_ns=20)
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
assert status.source_available is False
assert status.stale is False
assert status.reason == "no_source"
def test_inspect_webui_bundle_marks_missing_dist_stale(tmp_path: Path) -> None:
source = tmp_path / "webui"
dist = tmp_path / "nanobot" / "web" / "dist"
_touch(source / "package.json", mtime_ns=10)
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
assert status.source_available is True
assert status.dist_available is False
assert status.stale is True
assert status.reason == "missing_dist"
def test_inspect_webui_bundle_detects_source_newer_than_dist(tmp_path: Path) -> None:
source = tmp_path / "webui"
dist = tmp_path / "nanobot" / "web" / "dist"
_touch(source / "package.json", mtime_ns=10)
_touch(source / "src" / "App.tsx", mtime_ns=30)
_touch(dist / "index.html", mtime_ns=20)
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
assert status.stale is True
assert status.reason == "source_newer"
assert status.newest_source == source / "src" / "App.tsx"
def test_inspect_webui_bundle_accepts_fresh_dist(tmp_path: Path) -> None:
source = tmp_path / "webui"
dist = tmp_path / "nanobot" / "web" / "dist"
_touch(source / "package.json", mtime_ns=10)
_touch(source / "src" / "App.tsx", mtime_ns=20)
_touch(dist / "index.html", mtime_ns=30)
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
assert status.stale is False
assert status.reason == "fresh"
def test_ensure_webui_bundle_auto_builds_stale_dist(tmp_path: Path) -> None:
source = tmp_path / "webui"
dist = tmp_path / "nanobot" / "web" / "dist"
_touch(source / "package.json", mtime_ns=10)
_touch(source / "src" / "App.tsx", mtime_ns=30)
_touch(dist / "index.html", mtime_ns=20)
commands: list[tuple[str, ...]] = []
def fake_run(command, *, cwd: Path, check: bool) -> None:
commands.append(tuple(command))
assert cwd == source
assert check is True
if command == ["bun", "run", "build"]:
_touch(dist / "index.html", mtime_ns=40)
status = ensure_webui_bundle(
mode="auto",
source_dir=source,
dist_dir=dist,
runner="bun",
subprocess_run=fake_run,
)
assert status.stale is False
assert commands == [("bun", "install"), ("bun", "run", "build")]
def test_ensure_webui_bundle_warns_without_building(tmp_path: Path) -> None:
source = tmp_path / "webui"
dist = tmp_path / "nanobot" / "web" / "dist"
_touch(source / "package.json", mtime_ns=10)
_touch(source / "src" / "App.tsx", mtime_ns=30)
_touch(dist / "index.html", mtime_ns=20)
messages: list[str] = []
status = ensure_webui_bundle(
mode="warn",
source_dir=source,
dist_dir=dist,
output=messages.append,
)
assert status.stale is True
assert messages
assert "Run `cd" in messages[0]
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
import json
from typing import Any
import pytest
from nanobot.channels.weixin import WeixinChannel
from nanobot.config.loader import save_config
from nanobot.config.schema import Config
from nanobot.webui.channel_connect import WeixinConnectStore
@pytest.mark.asyncio
async def test_weixin_connect_store_saves_confirmed_qr_login(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
state_dir = tmp_path / "weixin-state"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-1", "https://qr.example/1"
async def fake_api_get_with_base(
self: WeixinChannel,
*,
base_url: str,
endpoint: str,
params: dict[str, Any],
auth: bool,
) -> dict[str, str]:
assert base_url == "https://ilinkai.weixin.qq.com"
assert endpoint == "ilink/bot/get_qrcode_status"
assert params == {"qrcode": "qr-1"}
assert auth is False
return {
"status": "confirmed",
"bot_token": "wx-token",
"baseurl": "https://weixin.example",
"ilink_user_id": "wx-user",
}
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
store = WeixinConnectStore()
started = await store.start()
assert started["status"] == "pending"
assert started["qr_url"] == "https://qr.example/1"
completed = await store.poll(started["session_id"])
assert completed["status"] == "succeeded"
assert completed["account"] == "wx-user"
saved = json.loads((state_dir / "account.json").read_text())
assert saved["token"] == "wx-token"
assert saved["base_url"] == "https://weixin.example"
@pytest.mark.asyncio
async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
state_dir = tmp_path / "weixin-state"
state_dir.mkdir()
existing = {
"token": "working-token",
"base_url": "https://working.weixin.example",
"context_tokens": {"user-1": "context-1"},
}
state_file = state_dir / "account.json"
state_file.write_text(json.dumps(existing), encoding="utf-8")
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-reconnect", "https://qr.example/reconnect"
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
store = WeixinConnectStore()
started = await store.start(force=True)
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
cancelled = await store.cancel(started["session_id"])
assert cancelled["status"] == "cancelled"
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
+231
View File
@@ -0,0 +1,231 @@
from __future__ import annotations
import httpx
import pytest
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config
from nanobot.webui import channel_validation
from nanobot.webui.channel_validation import validate_channel_config
def test_validate_channel_does_not_write_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
config = Config.model_validate(
{
"channels": {
"slack": {
"appToken": "xapp-old",
"botToken": "xoxb-old",
"groupPolicy": "mention",
}
}
}
)
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(channel_validation, "_http_post", lambda *_args, **_kwargs: {"ok": True})
payload = validate_channel_config(
"slack",
{
"channels.slack.appToken": "",
"channels.slack.botToken": "",
},
)
assert payload["status"] == "connected"
saved = load_config(config_path)
assert saved.channels.slack["appToken"] == "xapp-old"
assert saved.channels.slack["botToken"] == "xoxb-old"
def test_validate_telegram_bad_token_is_invalid(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = validate_channel_config("telegram", {"channels.telegram.token": "not-a-token"})
assert payload["status"] == "invalid"
assert payload["can_enable"] is False
assert payload["missing_fields"] == []
def test_validate_telegram_does_not_expose_saved_token_in_http_errors(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"telegram": {"token": token}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def raise_http_error(url: str, **_kwargs) -> dict:
request = httpx.Request("GET", url)
response = httpx.Response(401, request=request)
raise httpx.HTTPStatusError("unauthorized", request=request, response=response)
monkeypatch.setattr(channel_validation, "_http_get", raise_http_error)
payload = validate_channel_config("telegram", {"channels.telegram.token": ""})
assert token not in str(payload)
assert any("HTTP 401" in check.get("message", "") for check in payload["checks"])
def test_validate_email_presets_are_checked_without_saving(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(channel_validation, "_probe_tcp", lambda *_args, **_kwargs: None)
payload = validate_channel_config(
"email",
{
"channels.email.consentGranted": "true",
"channels.email.imapHost": "imap.gmail.com",
"channels.email.imapUsername": "bot@example.com",
"channels.email.imapPassword": "imap-secret",
"channels.email.smtpHost": "smtp.gmail.com",
"channels.email.smtpUsername": "bot@example.com",
"channels.email.smtpPassword": "smtp-secret",
},
)
assert payload["status"] == "connected"
assert payload["can_enable"] is True
assert not hasattr(load_config(config_path).channels, "email")
def test_validate_email_blocks_private_targets_when_local_access_is_disabled(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.tools.webui_allow_local_service_access = False
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(
channel_validation.socket,
"create_connection",
lambda *_args, **_kwargs: pytest.fail("blocked target must not be connected"),
)
payload = validate_channel_config(
"email",
{
"channels.email.consentGranted": "true",
"channels.email.imapHost": "127.0.0.1",
"channels.email.imapUsername": "bot@example.com",
"channels.email.imapPassword": "imap-secret",
"channels.email.smtpHost": "192.168.1.10",
"channels.email.smtpUsername": "bot@example.com",
"channels.email.smtpPassword": "smtp-secret",
},
)
warnings = [check["message"] for check in payload["checks"] if check["status"] == "warn"]
assert len(warnings) == 2
assert all("private/internal" in message for message in warnings)
def test_probe_tcp_connects_to_the_validated_ip(monkeypatch: pytest.MonkeyPatch) -> None:
connected: list[tuple[str, int]] = []
class FakeSocket:
def __enter__(self):
return self
def __exit__(self, *_args):
return None
monkeypatch.setattr(
channel_validation,
"resolve_url_target",
lambda *_args, **_kwargs: (True, "", ("203.0.113.10",)),
)
monkeypatch.setattr(
channel_validation.socket,
"create_connection",
lambda target, **_kwargs: connected.append(target) or FakeSocket(),
)
channel_validation._probe_tcp("mail.example.com", 2525)
assert connected == [("203.0.113.10", 2525)]
def test_validate_manual_channel_returns_configured(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
save_config(
Config.model_validate(
{
"channels": {
"dingtalk": {
"clientId": "ding-client",
"clientSecret": "ding-secret",
}
}
}
),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = validate_channel_config("dingtalk", {})
assert payload["status"] == "configured"
assert payload["can_enable"] is True
assert any(check["status"] == "skipped" for check in payload["checks"])
@pytest.mark.parametrize(
("credentials", "expected_status", "expected_missing"),
[
({}, "needs_setup", "password_or_accessToken"),
({"channels.matrix.accessToken": "token"}, "needs_setup", "deviceId"),
({"channels.matrix.password": "secret"}, "configured", None),
(
{
"channels.matrix.accessToken": "token",
"channels.matrix.deviceId": "DEVICE",
},
"configured",
None,
),
],
)
def test_validate_matrix_requires_a_complete_login_method(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
credentials: dict[str, str],
expected_status: str,
expected_missing: str | None,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = validate_channel_config(
"matrix",
{
"channels.matrix.homeserver": "https://matrix.example",
"channels.matrix.userId": "@nanobot:matrix.example",
**credentials,
},
)
assert payload["status"] == expected_status
assert payload["can_enable"] is (expected_status == "configured")
if expected_missing is None:
assert payload["missing_fields"] == []
else:
assert expected_missing in payload["missing_fields"]
+153
View File
@@ -12,6 +12,7 @@ from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.registry import find_by_name
from nanobot.webui.settings_api import (
WebUISettingsError,
_docs_version,
_model_catalog_kind,
_oauth_provider_status,
create_model_configuration,
@@ -20,6 +21,7 @@ from nanobot.webui.settings_api import (
settings_payload,
settings_usage_payload,
update_agent_settings,
update_api_settings,
update_model_configuration,
update_network_safety_settings,
update_provider_settings,
@@ -31,6 +33,100 @@ DYNAMIC_PROVIDER_NAME = "my-company-api"
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
def test_docs_version_uses_released_versions_and_falls_back_for_dev() -> None:
assert _docs_version("0.2.3") == "0.2.3"
assert _docs_version("0.2.3.post1") == "0.2.3.post1"
assert _docs_version("0.2.3.dev0") == "latest"
assert _docs_version("0.2.3+editable") == "latest"
def test_settings_payload_includes_versioned_docs(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.webui.settings_api.__version__", "0.2.3")
payload = settings_payload()
assert payload["docs"] == {
"version": "0.2.3",
"base_url": "https://nanobot.wiki/docs/0.2.3",
"chat_apps_url": "https://nanobot.wiki/docs/0.2.3/getting-started/chat-apps",
"latest_url": "https://nanobot.wiki/docs/latest",
}
def test_settings_payload_includes_relocated_capabilities(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.api.port = 9910
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "secret")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "public")
payload = settings_payload()
assert payload["api"]["port"] == 9910
assert payload["api"]["api_key_hint"] is None
assert payload["observability"]["provider"] == "langfuse"
assert payload["observability"]["configured"] is True
def test_update_api_settings_requires_key_for_network_access(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
with pytest.raises(WebUISettingsError, match="API key"):
update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]})
payload = update_api_settings({
"host": ["0.0.0.0"],
"port": ["9900"],
"api_key": ["secret-token"],
})
saved = load_config(config_path)
assert saved.api.host == "0.0.0.0"
assert saved.api.port == 9900
assert saved.api.api_key == "secret-token"
assert payload["api"]["api_key_hint"]
def test_update_api_settings_requires_key_for_specific_network_interface(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
with pytest.raises(WebUISettingsError, match="API key"):
update_api_settings({"host": ["192.168.1.10"], "port": ["8900"]})
def test_update_api_settings_allows_alternate_loopback_without_key(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
update_api_settings({"host": ["127.0.0.2"], "port": ["8900"]})
assert load_config(config_path).api.host == "127.0.0.2"
def _dynamic_provider_config(
*,
api_base: str = DYNAMIC_PROVIDER_API_BASE,
@@ -348,6 +444,42 @@ def test_settings_payload_includes_dynamic_custom_provider(
assert providers[DYNAMIC_PROVIDER_NAME]["api_base"] == DYNAMIC_PROVIDER_API_BASE
def test_settings_payload_groups_opencode_compatibility_alias(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
opencode_rows = [row for row in payload["providers"] if row["label"].startswith("OpenCode")]
assert [(row["name"], row["label"]) for row in opencode_rows] == [
("opencode", "OpenCode Zen"),
("opencode_go", "OpenCode Go"),
]
def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.json"
config = Config.model_validate({
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
"agents": {
"defaults": {
"provider": "opencode_zen",
"model": "opencode/deepseek-v4-pro",
}
},
})
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
zen_rows = [row for row in payload["providers"] if row["label"] == "OpenCode Zen"]
assert len(zen_rows) == 1
assert zen_rows[0]["name"] == "opencode_zen"
assert zen_rows[0]["configured"] is True
def test_settings_payload_marks_dynamic_custom_provider_without_api_base_unconfigured(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -952,6 +1084,26 @@ def test_provider_models_payload_fetches_openai_compatible_models(
assert payload["models"][1]["context_window"] == 65536
def test_provider_models_payload_returns_curated_openai_codex_models() -> None:
payload = provider_models_payload({"provider": ["openai_codex"]})
assert payload["status"] == "available"
assert payload["catalog_kind"] == "builtin"
assert payload["model_count"] == 7
assert payload["models"][0] == {
"id": "openai-codex/gpt-5.6-sol",
"label": "GPT-5.6-Sol",
"description": "Latest frontier agentic coding model.",
"owned_by": "OpenAI Codex",
"context_window": 372000,
}
assert [model["id"] for model in payload["models"][:3]] == [
"openai-codex/gpt-5.6-sol",
"openai-codex/gpt-5.6-terra",
"openai-codex/gpt-5.6-luna",
]
def test_provider_models_payload_fetches_dynamic_custom_provider_models(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -1044,6 +1196,7 @@ def test_model_catalog_kind_uses_provider_spec_metadata() -> None:
assert _model_catalog_kind(find_by_name("skywork")) == "official"
assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported"
assert _model_catalog_kind(find_by_name("openrouter")) == "catalog"
assert _model_catalog_kind(find_by_name("openai_codex")) == "builtin"
def test_create_model_configuration_accepts_configured_oauth_provider(