Add optional Nanobot plugin controls (#4396)

* feat: add optional nanobot features

* test: update azure install hint expectation

* fix: validate optional feature extras

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

* fix: bound optional feature installs

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

* feat: slim optional channel dependencies

* fix: log optional install commands

* fix(webui): gate remote feature installs

* docs: clarify webhook plugin example

* fix(webui): harden optional feature installs

* fix: install optional deps without package fallback

* fix(cli): refine plugin feature controls

* fix(webui): count enabled nanobot features

* fix(webui): allow slow feature install routes

* fix(webui): allow disabling websocket channel

* fix(plugins): simplify optional feature controls

* fix(webui): polish apps catalog states

* fix(webui): confirm nanobot support installs

* fix(webui): polish nanobot install dialog

* fix(webui): suppress empty websocket handshakes

* fix(webui): clarify apps plugin summary

* fix(webui): localize workspace access copy

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

---------

Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
This commit is contained in:
chengyongru
2026-07-03 18:17:52 +08:00
committed by GitHub
co-authored by Xubin Ren
parent 00cc0da530
commit 5283ceae85
61 changed files with 3061 additions and 258 deletions
+9 -1
View File
@@ -17,6 +17,7 @@ from typing import Any
from urllib.parse import urlparse
import httpx
from loguru import logger
from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.config.paths import get_runtime_subdir
@@ -941,12 +942,19 @@ class CliAppManager:
raise CliAppError("this CLI app uses an unsupported install strategy")
def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command = subprocess.list2cmdline(argv)
logger.info("CLI Apps: running {}", command)
result = subprocess.run(
argv,
capture_output=True,
text=True,
timeout=timeout,
)
logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command)
output = (result.stderr or result.stdout or "").strip()
if output:
logger.info("CLI Apps command output:\n{}", _truncate(output, 4000))
return result
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
entry_point = str(app.get("entry_point") or "")
+1 -1
View File
@@ -217,7 +217,7 @@ class DingTalkChannel(BaseChannel):
try:
if not DINGTALK_AVAILABLE:
self.logger.error(
"Stream SDK not installed. Run: pip install dingtalk-stream"
"Stream SDK not installed. Run: nanobot plugins enable dingtalk"
)
return
+1 -1
View File
@@ -405,7 +405,7 @@ class DiscordChannel(BaseChannel):
async def start(self) -> None:
"""Start the Discord client."""
if not DISCORD_AVAILABLE:
self.logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]")
self.logger.error("discord.py not installed. Run: nanobot plugins enable discord")
return
if not self.config.token:
+1 -1
View File
@@ -672,7 +672,7 @@ class FeishuChannel(BaseChannel):
async def start(self) -> None:
"""Start the Feishu bot with WebSocket long connection."""
if not FEISHU_AVAILABLE:
self.logger.error("SDK not installed. Run: pip install lark-oapi")
self.logger.error("SDK not installed. Run: nanobot plugins enable feishu")
return
if not self.config.app_id or not self.config.app_secret:
+35 -8
View File
@@ -25,6 +25,7 @@ from nanobot.bus.outbound_events import (
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
from nanobot.config.schema import Config
from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message
@@ -51,6 +52,21 @@ _BOOL_CAMEL_ALIASES: dict[str, str] = {
"show_reasoning": "showReasoning",
}
def _default_channel_config(name: str) -> dict[str, Any] | None:
if name != "websocket":
return None
from nanobot.channels.websocket import WebSocketChannel
return WebSocketChannel.default_config()
def _channel_config_enabled(name: str, section: Any) -> bool:
default_enabled = name in DEFAULT_ENABLED_CHANNELS
if isinstance(section, dict):
return bool(section.get("enabled", default_enabled))
return bool(getattr(section, "enabled", default_enabled))
class ChannelManager:
"""
Manages chat channels and coordinates message routing.
@@ -105,21 +121,32 @@ class ChannelManager:
candidate_names = set(names)
extra = getattr(self.config.channels, "__pydantic_extra__", None) or {}
candidate_names.update(extra.keys())
default_sections: dict[str, Any] = {}
def section_for(name: str) -> Any:
section = getattr(self.config.channels, name, None)
if section is not None or name not in DEFAULT_ENABLED_CHANNELS:
return section
if name not in default_sections:
default = _default_channel_config(name)
if default is not None:
default_sections[name] = default
return default_sections.get(name)
enabled_names: set[str] = set()
for name in candidate_names:
section = getattr(self.config.channels, name, None)
section = section_for(name)
if section is None:
continue
if (
section.get("enabled", False)
if isinstance(section, dict)
else getattr(section, "enabled", False)
):
if _channel_config_enabled(name, section):
enabled_names.add(name)
for name, cls in discover_enabled(enabled_names, _names=names).items():
section = getattr(self.config.channels, name, None)
for name, cls in discover_enabled(
enabled_names,
_names=names,
warn_import_errors=True,
).items():
section = section_for(name)
if section is None:
continue
try:
+3 -2
View File
@@ -3,6 +3,7 @@
import asyncio
import json
import mimetypes
import sys
import time
from contextlib import suppress
from dataclasses import dataclass
@@ -45,7 +46,7 @@ try:
from nio.exceptions import EncryptionError
except ImportError as e:
raise ImportError(
"Matrix dependencies not installed. Run: pip install nanobot-ai[matrix]"
"Matrix dependencies not installed. Run: nanobot plugins enable matrix"
) from e
from nanobot.bus.events import OutboundMessage
@@ -200,7 +201,7 @@ class MatrixConfig(Base):
password: str = ""
access_token: str = ""
device_id: str = ""
e2ee_enabled: bool = Field(default=True, alias="e2eeEnabled")
e2ee_enabled: bool = Field(default=sys.platform != "win32", alias="e2eeEnabled")
sas_verification: bool = Field(default=False, alias="sasVerification")
sync_stop_grace_seconds: int = 2
max_media_bytes: int = 20 * 1024 * 1024
+2 -2
View File
@@ -142,7 +142,7 @@ class MSTeamsChannel(BaseChannel):
async def start(self) -> None:
"""Start the Teams webhook listener."""
if not MSTEAMS_AVAILABLE:
self.logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
self.logger.error("PyJWT not installed. Run: nanobot plugins enable msteams")
return
if not self.config.app_id or not self.config.app_password:
@@ -458,7 +458,7 @@ class MSTeamsChannel(BaseChannel):
async def _validate_inbound_auth(self, auth_header: str, activity: dict[str, Any]) -> None:
"""Validate inbound Bot Framework bearer token."""
if not MSTEAMS_AVAILABLE:
raise RuntimeError("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
raise RuntimeError("PyJWT not installed. Run: nanobot plugins enable msteams")
if not auth_header.lower().startswith("bearer "):
raise ValueError("missing bearer token")
+1 -1
View File
@@ -195,7 +195,7 @@ class QQChannel(BaseChannel):
"""Start the QQ bot with auto-reconnect loop."""
redirect_lib_logging("botpy", level="WARNING")
if not QQ_AVAILABLE:
self.logger.error("SDK not installed. Run: pip install qq-botpy")
self.logger.error("SDK not installed. Run: nanobot plugins enable qq")
return
if not self.config.app_id or not self.config.secret:
+8 -2
View File
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
from nanobot.channels.base import BaseChannel
_INTERNAL = frozenset({"base", "manager", "registry"})
DEFAULT_ENABLED_CHANNELS = frozenset({"websocket"})
def discover_channel_names() -> list[str]:
@@ -57,6 +58,7 @@ def discover_enabled(
*,
_names: list[str] | None = None,
_include_all_external: bool = False,
warn_import_errors: bool = False,
) -> dict[str, type[BaseChannel]]:
"""Return channels whose module names are in *enabled_names*.
@@ -72,10 +74,14 @@ def discover_enabled(
try:
result[modname] = load_channel_class(modname)
except ImportError as e:
logger.debug("Skipping built-in channel '{}': {}", modname, e)
message = "Enabled built-in channel '{}' is not available: {}"
if warn_import_errors:
logger.warning(message, modname, e)
else:
logger.debug(message, modname, e)
external = discover_plugins(None if _include_all_external else enabled_names)
shadowed = set(external) & set(result)
shadowed = set(external) & set(names)
if shadowed:
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)
if _include_all_external:
+6 -1
View File
@@ -59,6 +59,9 @@ from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
from nanobot.webui.transcription_ws import webui_transcription_event
from nanobot.webui.websocket_logging import websockets_server_logger
# Plain HTTP WebUI routes also run through websockets.process_request.
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
class WebSocketConfig(Base):
"""WebSocket server channel configuration.
@@ -80,7 +83,7 @@ class WebSocketConfig(Base):
shared filesystem or an HTTP file server to access these files.
"""
enabled: bool = False
enabled: bool = True
host: str = "127.0.0.1"
port: int = 8765
unix_socket_path: str = ""
@@ -482,6 +485,7 @@ class WebSocketChannel(BaseChannel):
handler,
socket_path,
process_request=process_request,
open_timeout=_WEBUI_HTTP_OPEN_TIMEOUT_S,
max_size=self.config.max_message_bytes,
ping_interval=self.config.ping_interval_s,
ping_timeout=self.config.ping_timeout_s,
@@ -495,6 +499,7 @@ class WebSocketChannel(BaseChannel):
self.config.host,
self.config.port,
process_request=process_request,
open_timeout=_WEBUI_HTTP_OPEN_TIMEOUT_S,
max_size=self.config.max_message_bytes,
ping_interval=self.config.ping_interval_s,
ping_timeout=self.config.ping_timeout_s,
+1 -1
View File
@@ -103,7 +103,7 @@ class WecomChannel(BaseChannel):
async def start(self) -> None:
"""Start the WeCom bot with WebSocket long connection."""
if not WECOM_AVAILABLE:
self.logger.error("SDK not installed. Run: pip install nanobot-ai[wecom]")
self.logger.error("SDK not installed. Run: nanobot plugins enable wecom")
return
if not self.config.bot_id or not self.config.secret:
+1 -1
View File
@@ -72,7 +72,7 @@ def _load_neonize() -> _NeonizeAPI:
from neonize.utils.jid import build_jid
except ImportError as exc:
raise RuntimeError(
'WhatsApp dependencies not installed. Run: pip install "nanobot-ai[whatsapp]"'
"WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp"
) from exc
_NEONIZE_API = _NeonizeAPI(
+105 -40
View File
@@ -38,6 +38,14 @@ _log_handler_id = logger.add(
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
)
def _set_nanobot_logs(enabled: bool) -> None:
if enabled:
logger.enable("nanobot")
else:
logger.disable("nanobot")
from prompt_toolkit import PromptSession, print_formatted_text # noqa: E402
from prompt_toolkit.application import run_in_terminal # noqa: E402
from prompt_toolkit.formatted_text import ANSI, HTML # noqa: E402
@@ -45,10 +53,12 @@ from prompt_toolkit.history import FileHistory # noqa: E402
from prompt_toolkit.patch_stdout import patch_stdout # noqa: E402
from rich.console import Console # noqa: E402
from rich.markdown import Markdown # noqa: E402
from rich.markup import escape # noqa: E402
from rich.table import Table # noqa: E402
from rich.text import Text # noqa: E402
from nanobot import __logo__, __version__ # noqa: E402
from nanobot import optional_features as feature_support # noqa: E402
from nanobot.agent.loop import AgentLoop # noqa: E402
from nanobot.bus.outbound_events import ( # noqa: E402
ProgressEvent,
@@ -686,6 +696,33 @@ def _onboard_plugins(config_path: Path) -> None:
json.dump(data, f, indent=2, ensure_ascii=False)
def _print_enable_options(
extras: dict[str, list[str] | None],
builtin_channels: set[str],
plugin_channels: dict[str, Any],
config: Config,
) -> None:
table = Table(title="Available Features")
table.add_column("Name", style="cyan")
table.add_column("Type")
table.add_column("Enabled")
for item in sorted(builtin_channels | set(plugin_channels) | set(extras)):
is_channel = item in builtin_channels or item in plugin_channels
enabled = (
feature_support.channel_enabled(config, item)
if is_channel
else feature_support.extra_installed(item, extras[item])
)
table.add_row(
item,
"channel" if is_channel else "feature",
"[green]yes[/green]" if enabled else "[dim]no[/dim]",
)
console.print(table)
def _model_display(config: Config) -> tuple[str, str]:
"""Return (resolved_model_name, preset_tag) for display strings."""
resolved = config.resolve_preset()
@@ -811,20 +848,15 @@ def serve(
try:
from aiohttp import web # noqa: F401
except ImportError:
console.print("[red]aiohttp is required. Install with: pip install 'nanobot-ai[api]'[/red]")
console.print("[red]aiohttp is required. Install with: nanobot plugins enable api[/red]")
raise typer.Exit(1)
from loguru import logger
from nanobot.api.server import create_app
from nanobot.bus.queue import MessageBus
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager
if verbose:
logger.enable("nanobot")
else:
logger.disable("nanobot")
_set_nanobot_logs(verbose)
runtime_config = _load_runtime_config(config, workspace)
api_cfg = runtime_config.api
@@ -1392,8 +1424,6 @@ def agent(
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
):
"""Interact with the agent directly."""
from loguru import logger
from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService
from nanobot.providers.image_generation import image_gen_provider_configs
@@ -1411,10 +1441,7 @@ def agent(
cron_store_path = config.workspace_path / "cron" / "jobs.json"
cron = CronService(cron_store_path)
if logs:
logger.enable("nanobot")
else:
logger.disable("nanobot")
_set_nanobot_logs(logs)
try:
agent_loop = AgentLoop.from_config(
@@ -1728,42 +1755,80 @@ def channels_login(
# Plugin Commands
# ============================================================================
plugins_app = typer.Typer(help="Manage channel plugins")
plugins_app = typer.Typer(help="Manage optional nanobot features")
app.add_typer(plugins_app, name="plugins")
@plugins_app.command("list")
def plugins_list():
"""List all discovered channels (built-in and plugins)."""
from nanobot.channels.registry import discover_all, discover_channel_names
from nanobot.config.loader import load_config
def plugins_list(
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""List optional nanobot features."""
from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.config.loader import load_config, set_config_path
config = load_config()
builtin_names = set(discover_channel_names())
all_channels = discover_all()
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
if resolved_config_path is not None:
set_config_path(resolved_config_path)
table = Table(title="Channel Plugins")
table.add_column("Name", style="cyan")
table.add_column("Source", style="magenta")
table.add_column("Enabled")
_print_enable_options(
feature_support.optional_dependency_groups(),
set(discover_channel_names()),
discover_plugins(),
load_config(resolved_config_path),
)
for name in sorted(all_channels):
cls = all_channels[name]
source = "builtin" if name in builtin_names else "plugin"
section = getattr(config.channels, name, None)
if section is None:
enabled = False
elif isinstance(section, dict):
enabled = section.get("enabled", False)
else:
enabled = getattr(section, "enabled", False)
table.add_row(
cls.display_name,
source,
"[green]yes[/green]" if enabled else "[dim]no[/dim]",
@plugins_app.command("enable")
def plugins_enable(
name: str = typer.Argument(..., help="Feature name (e.g. weixin, matrix, pdf)"),
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show optional package install logs"),
):
"""Enable a nanobot feature."""
from nanobot.config.loader import get_config_path, set_config_path
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
if resolved_config_path is not None:
set_config_path(resolved_config_path)
resolved_config_path = resolved_config_path or get_config_path()
_set_nanobot_logs(logs)
try:
payload = feature_support.enable_optional_feature(
name,
config_path=resolved_config_path,
runner=feature_support.run_install_command,
)
except feature_support.OptionalFeatureError as exc:
console.print(f"[red]{escape(exc.message)}[/red]")
raise typer.Exit(1) from exc
console.print(table)
message = payload.get("last_action", {}).get("message") or f"Enabled feature '{name}'"
console.print(f"[green]{escape(message)}[/green]")
@plugins_app.command("disable")
def plugins_disable(
name: str = typer.Argument(..., help="Channel name (e.g. telegram, matrix, slack)"),
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""Disable a nanobot channel feature."""
from nanobot.config.loader import get_config_path, set_config_path
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
if resolved_config_path is not None:
set_config_path(resolved_config_path)
resolved_config_path = resolved_config_path or get_config_path()
try:
payload = feature_support.disable_optional_feature(name, config_path=resolved_config_path)
except feature_support.OptionalFeatureError as exc:
console.print(f"[red]{escape(exc.message)}[/red]")
raise typer.Exit(1) from exc
message = payload.get("last_action", {}).get("message") or f"Disabled channel '{name}'"
console.print(f"[green]{escape(message)}[/green] in {resolved_config_path}")
# ============================================================================
+7
View File
@@ -377,6 +377,13 @@ class ToolsConfig(Base):
"allow_local_preview_access",
),
) # allow WebUI Full Access shell checks against localhost services; legacy allowLocalPreviewAccess still reads
webui_allow_remote_package_install: bool = Field(
default=False,
validation_alias=AliasChoices(
"webuiAllowRemotePackageInstall",
"webui_allow_remote_package_install",
),
) # allow non-local WebUI clients to install optional Python packages
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
+434
View File
@@ -0,0 +1,434 @@
"""Optional nanobot feature discovery and enablement."""
from __future__ import annotations
import json
import subprocess
import sys
from dataclasses import dataclass
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path
from typing import Any
from loguru import logger
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
from nanobot.config.schema import Config
class OptionalFeatureError(Exception):
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
@dataclass
class InstallResult:
ok: bool
label: str
pip_cmd: list[str]
failed_cmd: list[str] | None = None
output: str = ""
_INSTALL_TIMEOUT_SECONDS = 300
_LOG_OUTPUT_LIMIT = 4000
def load_pyproject(path: Path) -> dict[str, Any]:
try:
import tomllib
return tomllib.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def optional_dependency_groups_from_metadata() -> dict[str, list[str] | None]:
try:
from importlib.metadata import metadata, requires
except Exception:
return {}
try:
extras = metadata("nanobot-ai").get_all("Provides-Extra") or []
groups: dict[str, list[str] | None] = {name: [] for name in extras if name != "dev"}
for raw in requires("nanobot-ai") or []:
try:
req = Requirement(raw)
except Exception:
continue
if not req.marker:
continue
for extra, deps in groups.items():
if deps is not None and req.marker.evaluate({"extra": extra}):
deps.append(raw)
return groups
except Exception:
return {}
def optional_dependency_groups() -> dict[str, list[str] | None]:
root = Path(__file__).resolve().parents[1]
project = load_pyproject(root / "pyproject.toml").get("project", {})
deps = project.get("optional-dependencies", {})
if isinstance(deps, dict) and deps:
return {
name: list(values)
for name, values in deps.items()
if name != "dev" and isinstance(values, list)
}
return optional_dependency_groups_from_metadata()
def _install_requirements_for_extra(extra: str, deps: list[str]) -> list[str]:
install_args: list[str] = []
for raw in deps:
try:
req = Requirement(raw)
except Exception:
install_args.append(raw)
continue
if req.marker and not req.marker.evaluate({"extra": extra}):
continue
req.marker = None
install_args.append(str(req))
return install_args
def install_args_for_extra(
extra: str,
deps: list[str] | None,
) -> tuple[list[str], str]:
if deps:
install_args = _install_requirements_for_extra(extra, deps)
if install_args:
return install_args, f"{extra} support"
return [], f"{extra} support"
target = f"nanobot-ai[{extra}]"
return [target], f'"{target}"'
def _requirement_installed(req: Requirement, extra: str, seen: set[tuple[str, str]]) -> bool:
if req.marker and not req.marker.evaluate({"extra": extra}):
return True
key = (
canonicalize_name(req.name),
",".join(sorted(canonicalize_name(value) for value in req.extras)),
)
if key in seen:
return True
seen.add(key)
try:
dist = distribution(req.name)
except PackageNotFoundError:
return False
if req.specifier and not req.specifier.contains(dist.version, prereleases=True):
return False
for requested_extra in req.extras:
if not _extra_dependencies_installed(dist, requested_extra, seen):
return False
return True
def _extra_dependencies_installed(
dist: Any,
requested_extra: str,
seen: set[tuple[str, str]],
) -> bool:
normalized = canonicalize_name(requested_extra)
provided = {
canonicalize_name(value)
for value in (dist.metadata.get_all("Provides-Extra") or [])
}
if provided and normalized not in provided:
return False
matched = False
for raw in dist.requires or []:
try:
req = Requirement(raw)
except Exception:
continue
if req.marker and not req.marker.evaluate({"extra": requested_extra}):
continue
matched = True
if not _requirement_installed(req, requested_extra, seen):
return False
return matched or bool(provided)
def requirement_installed(raw: str, extra: str = "") -> bool:
return _requirement_installed(Requirement(raw), extra, set())
def extra_installed(extra: str, deps: list[str] | None) -> bool:
if deps is None:
return True
return all(requirement_installed(dep, extra) for dep in deps)
def run_install_command(argv: list[str]) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
argv,
capture_output=True,
text=True,
timeout=_INSTALL_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired as exc:
stdout = exc.stdout.decode(errors="replace") if isinstance(exc.stdout, bytes) else exc.stdout
stderr = exc.stderr.decode(errors="replace") if isinstance(exc.stderr, bytes) else exc.stderr
message = f"Timed out after {_INSTALL_TIMEOUT_SECONDS}s"
stderr = "\n".join(part for part in ((stderr or "").rstrip(), message) if part)
return subprocess.CompletedProcess(argv, 124, stdout=stdout or "", stderr=stderr)
def command_text(argv: list[str]) -> str:
return subprocess.list2cmdline([str(part) for part in argv])
def _log_completed_command(label: str, proc: subprocess.CompletedProcess[str]) -> None:
logger.info("{} exited with code {}", label, proc.returncode)
output = (proc.stderr or proc.stdout or "").strip()
if output:
logger.info("{} output:\n{}", label, output[:_LOG_OUTPUT_LIMIT])
def missing_pip(proc: subprocess.CompletedProcess[str]) -> bool:
return "no module named pip" in f"{proc.stdout}\n{proc.stderr}".lower()
def install_extra(
extra: str,
deps: list[str] | None,
*,
runner: Any = run_install_command,
) -> InstallResult:
import importlib
install_args, label = install_args_for_extra(extra, deps)
pip_cmd = [sys.executable, "-m", "pip", "install", *install_args]
if not install_args:
logger.info("Optional feature '{}' has no installable dependencies for this platform", extra)
return InstallResult(True, label, pip_cmd)
logger.info("Installing optional feature '{}': {}", extra, command_text(pip_cmd))
proc = runner(pip_cmd)
_log_completed_command(f"Optional feature '{extra}' install", proc)
if proc.returncode == 0:
importlib.invalidate_caches()
return InstallResult(True, label, pip_cmd)
failed_cmd = pip_cmd
failed_proc = proc
if missing_pip(proc):
ensure_cmd = [sys.executable, "-m", "ensurepip", "--upgrade"]
logger.info("pip missing while installing '{}'; running {}", extra, command_text(ensure_cmd))
ensure_proc = runner(ensure_cmd)
_log_completed_command(f"Optional feature '{extra}' ensurepip", ensure_proc)
if ensure_proc.returncode == 0:
logger.info("Retrying optional feature '{}': {}", extra, command_text(pip_cmd))
proc = runner(pip_cmd)
_log_completed_command(f"Optional feature '{extra}' install retry", proc)
if proc.returncode == 0:
importlib.invalidate_caches()
return InstallResult(True, label, pip_cmd)
failed_cmd = pip_cmd
failed_proc = proc
else:
failed_cmd = ensure_cmd
failed_proc = ensure_proc
output = (failed_proc.stderr or failed_proc.stdout or "").strip()
return InstallResult(False, label, pip_cmd, failed_cmd=failed_cmd, output=output)
def read_config_data(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
with open(path, encoding="utf-8") as f:
return json.load(f)
def write_config_data(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def merge_missing_defaults(existing: dict[str, Any], defaults: dict[str, Any]) -> dict[str, Any]:
merged = dict(defaults)
for key, value in existing.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = merge_missing_defaults(value, merged[key])
else:
merged[key] = value
return merged
def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[str, Any]) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
existing = channels.get(channel_name, {})
if not isinstance(existing, dict):
existing = {}
merged = merge_missing_defaults(existing, defaults)
merged["enabled"] = True
channels[channel_name] = merged
write_config_data(config_path, data)
def disable_channel_config(config_path: Path, channel_name: str) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
existing = channels.get(channel_name, {})
if not isinstance(existing, dict):
existing = {}
existing["enabled"] = False
channels[channel_name] = existing
write_config_data(config_path, data)
def channel_enabled(config: Config, name: str) -> bool:
section = getattr(config.channels, name, None)
default_enabled = name in DEFAULT_ENABLED_CHANNELS
if section is None:
return default_enabled
if isinstance(section, dict):
return bool(section.get("enabled", default_enabled))
return bool(getattr(section, "enabled", default_enabled))
def optional_features_payload(
*,
config: Config | None = None,
last_action: dict[str, Any] | None = None,
) -> dict[str, Any]:
from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.config.loader import load_config
config = config or load_config()
extras = optional_dependency_groups()
builtin_channels = set(discover_channel_names())
plugin_channels = discover_plugins()
features: list[dict[str, Any]] = []
for name in sorted(builtin_channels | set(plugin_channels) | set(extras)):
is_channel = name in builtin_channels or name in plugin_channels
installed = extra_installed(name, extras[name]) if name in extras else True
enabled = channel_enabled(config, name) if is_channel else installed
ready = bool(enabled and installed)
status = "enabled" if ready else "missing_dependency" if not installed else "not_enabled"
features.append(
{
"name": name,
"display_name": name.replace("_", " ").title(),
"type": "channel" if is_channel else "feature",
"enabled": enabled,
"installed": installed,
"ready": ready,
"status": status,
"install_supported": name in extras or is_channel,
"requires_restart": is_channel or name in extras,
}
)
payload = {
"features": features,
"enabled_count": sum(1 for feature in features if feature["enabled"]),
}
if last_action:
payload["last_action"] = last_action
return payload
def enable_optional_feature(
name: str,
*,
config_path: Path | None = None,
allow_install: bool = True,
runner: Any = run_install_command,
) -> dict[str, Any]:
from nanobot.channels.registry import (
discover_channel_names,
discover_plugins,
load_channel_class,
)
from nanobot.config.loader import get_config_path
config_path = config_path or get_config_path()
extras = optional_dependency_groups()
builtin_channels = set(discover_channel_names())
plugin_channels = discover_plugins()
known = builtin_channels | set(plugin_channels) | set(extras)
if name not in known:
available = ", ".join(sorted(known))
raise OptionalFeatureError(f"Unknown feature: {name}. Available: {available}", status=404)
if name in extras and not extra_installed(name, extras[name]):
if not allow_install:
raise OptionalFeatureError(
"Installing optional features from a remote WebUI is disabled. "
"Run this action from localhost or set tools.webuiAllowRemotePackageInstall to true.",
status=403,
)
result = install_extra(
name,
extras[name],
runner=runner,
)
if not result.ok:
failed = command_text(result.failed_cmd or result.pip_cmd)
detail = f": {result.output}" if result.output else ""
raise OptionalFeatureError(f"Failed: {failed}{detail}", status=500)
if name in builtin_channels:
try:
channel_cls = load_channel_class(name)
except Exception as exc:
raise OptionalFeatureError(
f"Channel '{name}' is not importable after enable: {exc}",
status=500,
) from exc
enable_channel_config(config_path, name, channel_cls.default_config())
message = f"Enabled channel '{name}'"
elif name in plugin_channels:
enable_channel_config(config_path, name, plugin_channels[name].default_config())
message = f"Enabled channel '{name}'"
else:
message = f"Enabled feature '{name}'"
payload = optional_features_payload(last_action={"ok": True, "message": message, "enabled": True})
payload["requires_restart"] = bool(name in builtin_channels or name in plugin_channels or name in extras)
return payload
def disable_optional_feature(
name: str,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.config.loader import get_config_path
config_path = config_path or get_config_path()
extras = optional_dependency_groups()
builtin_channels = set(discover_channel_names())
plugin_channels = discover_plugins()
known_channels = builtin_channels | set(plugin_channels)
known = known_channels | set(extras)
if name not in known:
available = ", ".join(sorted(known))
raise OptionalFeatureError(f"Unknown feature: {name}. Available: {available}", status=404)
if name not in known_channels:
raise OptionalFeatureError(f"Feature '{name}' cannot be disabled", status=400)
disable_channel_config(config_path, name)
payload = optional_features_payload(
last_action={"ok": True, "message": f"Disabled channel '{name}'", "enabled": False}
)
payload["requires_restart"] = True
return payload
+2 -2
View File
@@ -14,7 +14,7 @@ Two modes are supported, selected automatically:
falls back to :class:`azure.identity.aio.DefaultAzureCredential` and
acquires a bearer token scoped to
``https://cognitiveservices.azure.com/.default``. ``azure-identity``
is an optional dependency installed via ``pip install nanobot-ai[azure]``.
is an optional dependency installed via ``nanobot plugins enable azure``.
"""
from __future__ import annotations
@@ -55,7 +55,7 @@ class _AzureTokenProvider:
except ImportError as exc:
raise RuntimeError(
"Azure OpenAI AAD authentication requires the 'azure-identity' package. "
"Install it with: pip install 'nanobot-ai[azure]'"
"Run: nanobot plugins enable azure"
) from exc
self._scope = scope
+1 -1
View File
@@ -71,7 +71,7 @@ class BedrockProvider(LLMProvider):
import boto3
except ImportError as exc: # pragma: no cover - exercised only without boto3 installed
raise RuntimeError(
"AWS Bedrock provider requires boto3. Install it with `pip install boto3`."
"AWS Bedrock provider requires boto3. Run `nanobot plugins enable bedrock`."
) from exc
session_kwargs: dict[str, Any] = {}
+1 -1
View File
@@ -58,7 +58,7 @@ If the user selected `source (git clone)`, ask for the local checkout path:
**Question 2 — Optional dependencies:**
```
question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, wecom, weixin, msteams, matrix, discord, langsmith, pdf"
question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, dingtalk, discord, documents, feishu, matrix, mochat, msteams, napcat, qq, slack, telegram, wecom, weixin, langsmith, pdf"
```
Parse the reply. If the user says "none" or similar, set extras to empty. Otherwise collect the valid names.
+65
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import email.utils
import hmac
import http
import ipaddress
import json
import re
from typing import Any
@@ -131,6 +132,70 @@ def is_localhost(connection: Any) -> bool:
return host in {"127.0.0.1", "::1", "localhost"}
def _host_without_port(value: str) -> str:
value = value.strip().strip('"').strip("'")
if not value:
return ""
if value.startswith("["):
end = value.find("]")
return value[1:end] if end > 0 else value
if value.count(":") == 1:
host, port = value.rsplit(":", 1)
if port.isdigit():
return host
return value
def is_loopback_host(value: str) -> bool:
host = _host_without_port(value)
if host.startswith("::ffff:"):
host = host[7:]
host = host.rstrip(".").lower()
if host == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def _split_comma_header(value: str) -> list[str]:
return [part.strip() for part in value.split(",") if part.strip()]
def _forwarded_header_values(value: str, key: str) -> list[str]:
values: list[str] = []
for entry in _split_comma_header(value):
for part in entry.split(";"):
name, sep, raw = part.partition("=")
if sep and name.strip().lower() == key:
cleaned = raw.strip().strip('"')
if cleaned:
values.append(cleaned)
return values
def _all_forwarded_values_are_loopback(headers: Any) -> bool:
checks: list[str] = []
checks.extend(_split_comma_header(case_insensitive_header(headers, "X-Forwarded-For")))
checks.extend(_split_comma_header(case_insensitive_header(headers, "X-Real-IP")))
checks.extend(_split_comma_header(case_insensitive_header(headers, "X-Forwarded-Host")))
forwarded = case_insensitive_header(headers, "Forwarded")
checks.extend(_forwarded_header_values(forwarded, "for"))
checks.extend(_forwarded_header_values(forwarded, "host"))
return all(is_loopback_host(value) for value in checks)
def is_local_browser_request(connection: Any, headers: Any) -> bool:
"""Return True only for a local TCP peer presenting a local browser origin."""
if not is_localhost(connection):
return False
host = case_insensitive_header(headers, "Host")
if not is_loopback_host(host):
return False
return _all_forwarded_values_are_loopback(headers)
def bearer_token(headers: Any) -> str | None:
auth = headers.get("Authorization") or headers.get("authorization")
if auth and auth.lower().startswith("bearer "):
+40
View File
@@ -0,0 +1,40 @@
"""Nanobot optional feature helpers for WebUI Settings."""
from __future__ import annotations
from typing import Any
from nanobot.optional_features import (
OptionalFeatureError,
disable_optional_feature,
enable_optional_feature,
optional_features_payload,
)
from nanobot.webui.http_utils import query_first
QueryParams = dict[str, list[str]]
def nanobot_features_payload() -> dict[str, Any]:
return optional_features_payload()
def nanobot_features_action(
action: str,
query: QueryParams,
*,
allow_install: bool = True,
) -> dict[str, Any]:
name = (query_first(query, "name") or "").strip()
if not name:
raise OptionalFeatureError("missing feature name")
if action == "enable":
return enable_optional_feature(name, allow_install=allow_install)
if action == "disable":
if name == "websocket":
raise OptionalFeatureError(
"The WebUI websocket channel cannot be disabled from WebUI. "
"Use `nanobot plugins disable websocket` from a terminal if you need to disable it.",
status=400,
)
return disable_optional_feature(name)
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
+56 -1
View File
@@ -17,9 +17,13 @@ from websockets.http11 import Response
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import load_config
from nanobot.optional_features import OptionalFeatureError
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
from nanobot.webui.http_utils import query_first as _query_first
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
from nanobot.webui.nanobot_features_api import nanobot_features_action, nanobot_features_payload
from nanobot.webui.settings_api import (
WebUISettingsError,
create_model_configuration,
@@ -80,7 +84,7 @@ class WebUISettingsRouter:
self._runtime_capabilities = runtime_capabilities
self._restart_sections: set[str] = set()
async def dispatch(self, request: WsRequest, path: str) -> Response | None:
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
if path == "/api/settings":
return self._handle_settings(request)
if path == "/api/settings/usage":
@@ -117,6 +121,12 @@ class WebUISettingsRouter:
return await self._handle_settings_cli_apps_action(request, "uninstall")
if path == "/api/settings/cli-apps/test":
return await self._handle_settings_cli_apps_action(request, "test")
if path == "/api/settings/nanobot-features":
return await self._handle_settings_nanobot_features(request)
if path == "/api/settings/nanobot-features/enable":
return await self._handle_settings_nanobot_features_action(connection, request, "enable")
if path == "/api/settings/nanobot-features/disable":
return await self._handle_settings_nanobot_features_action(connection, request, "disable")
if path == "/api/settings/mcp-presets":
return await self._handle_settings_mcp_presets(request)
if path == "/api/settings/version-check":
@@ -334,6 +344,51 @@ class WebUISettingsRouter:
return self._error_response(status, message)
return self._json_response(payload)
async def _handle_settings_nanobot_features(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(nanobot_features_payload)
except Exception:
self.logger.exception("failed to load nanobot features")
return self._error_response(500, "failed to load nanobot features")
return self._json_response(payload)
async def _handle_settings_nanobot_features_action(
self,
connection: Any,
request: WsRequest,
action: str,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(
nanobot_features_action,
action,
self._query(request),
allow_install=action != "enable"
or self._allow_feature_package_install(connection, request),
)
except OptionalFeatureError as e:
return self._error_response(e.status, e.message)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("nanobot feature action '{}' failed", action)
return self._error_response(status, message)
return self._json_response(self._with_restart_state(payload, section="runtime"))
def _allow_feature_package_install(self, connection: Any, request: WsRequest) -> bool:
if _is_local_browser_request(connection, request.headers):
return True
try:
return bool(load_config().tools.webui_allow_remote_package_install)
except Exception:
self.logger.exception("failed to load remote package install policy")
return False
async def _handle_settings_mcp_presets(
self,
request: WsRequest,
+1
View File
@@ -21,6 +21,7 @@ def _exception_chain_has_disconnect(exc: BaseException | None) -> bool:
ConnectionAbortedError,
ConnectionResetError,
ConnectionClosed,
EOFError,
)):
return True
exc = exc.__cause__ or exc.__context__
+1 -1
View File
@@ -231,7 +231,7 @@ class GatewayHTTPHandler:
return self._handle_bootstrap(connection, request)
# Settings routes (delegated)
response = await self.settings_routes.dispatch(request, got)
response = await self.settings_routes.dispatch(connection, request, got)
if response is not None:
return response