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:
@@ -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 "")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "):
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user