From 83b54212ae59b21ff136666522a8574856ed01d7 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:32:15 +0800 Subject: [PATCH] feat(extensions): wire runtime management and marketplace --- nanobot/channels/manager.py | 6 + nanobot/cli/commands.py | 75 +++++++--- nanobot/extensions/__init__.py | 8 + nanobot/extensions/host.py | 92 ++++++++++++ nanobot/extensions/market.py | 101 +++++++++++++ nanobot/extensions/runtime.py | 3 +- nanobot/extensions/service.py | 206 ++++++++++++++++++++++++++ nanobot/extensions/store.py | 7 +- nanobot/nanobot.py | 21 ++- nanobot/webui/extensions_routes.py | 177 ++++++++++++++++++++++ nanobot/webui/gateway_services.py | 4 + nanobot/webui/ws_http.py | 15 ++ tests/extensions/test_host.py | 49 ++++++ tests/extensions/test_market.py | 59 ++++++++ tests/extensions/test_service.py | 58 ++++++++ tests/extensions/test_store.py | 18 +++ tests/webui/test_extensions_routes.py | 162 ++++++++++++++++++++ 17 files changed, 1034 insertions(+), 27 deletions(-) create mode 100644 nanobot/extensions/host.py create mode 100644 nanobot/extensions/market.py create mode 100644 nanobot/extensions/service.py create mode 100644 nanobot/webui/extensions_routes.py create mode 100644 tests/extensions/test_host.py create mode 100644 tests/extensions/test_market.py create mode 100644 tests/extensions/test_service.py create mode 100644 tests/webui/test_extensions_routes.py diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 03fe89beb..c865808ee 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -97,6 +97,7 @@ class ChannelManager: webui_static_dist: bool = True, webui_runtime_surface: str = "browser", webui_runtime_capabilities: dict[str, Any] | None = None, + webui_extension_service: Any | None = None, ): self.config = config self.bus = bus @@ -109,6 +110,7 @@ class ChannelManager: self._webui_static_dist = webui_static_dist self._webui_runtime_surface = webui_runtime_surface self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {}) + self._webui_extension_service = webui_extension_service self.channels: dict[str, BaseChannel] = {} self._channel_owners: dict[str, str] = {} self._channel_runtime_specs: dict[str, tuple[str, str]] = {} @@ -175,6 +177,10 @@ class ChannelManager: local_trigger_pending_ids=self._webui_local_trigger_pending_ids, channel_feature_action=self.apply_channel_feature_action, channel_runtime_status=self.get_status, + extension_service=self._webui_extension_service, + allow_remote_package_install=( + self.config.tools.webui_allow_remote_package_install + ), logger=logger, ) kwargs["gateway"] = gateway diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 71dd978dc..d5c6fbde7 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1336,6 +1336,7 @@ def serve( from nanobot.api.server import create_app from nanobot.bus.queue import MessageBus + from nanobot.extensions import ExtensionHost from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.session.manager import SessionManager @@ -1384,12 +1385,17 @@ def serve( agent_loop, model_name=model_name, request_timeout=timeout, api_key=api_key, ) + extension_host = ExtensionHost(agent_loop, lambda: runtime_config) async def on_startup(_app): + await extension_host.reload() await agent_loop._connect_mcp() async def on_cleanup(_app): - await agent_loop.close_mcp() + try: + await extension_host.close() + finally: + await agent_loop.close_mcp() api_app.on_startup.append(on_startup) api_app.on_cleanup.append(on_cleanup) @@ -1633,6 +1639,7 @@ def _run_gateway( from nanobot.cron.service import CronJobSkippedError, CronService from nanobot.cron.session_turns import is_bound_cron_job from nanobot.cron.types import CronJob + from nanobot.extensions import ExtensionHost, ExtensionService from nanobot.providers.factory import ( build_provider_snapshot, build_unconfigured_provider_snapshot, @@ -1752,6 +1759,8 @@ def _run_gateway( local_trigger_store=trigger_store, hook_factories=[create_file_edit_activity_hook], ) + extension_host = ExtensionHost(agent, lambda: config) + extension_service = ExtensionService(host=extension_host) webui_turn_coordinator = WebuiTurnCoordinator( bus=bus, sessions=session_manager, @@ -1979,6 +1988,7 @@ def _run_gateway( webui_static_dist=webui_static_dist, webui_runtime_surface=webui_runtime_surface, webui_runtime_capabilities=webui_runtime_capabilities, + webui_extension_service=extension_service, ) def _pick_heartbeat_target() -> tuple[str, str]: @@ -2128,6 +2138,7 @@ def _run_gateway( console.print, ) try: + await extension_host.reload() await cron.start() # Re-read once on first admission to close the watcher subscription window. agent.runtime_resolver.invalidate() @@ -2207,7 +2218,10 @@ def _run_gateway( if flushed: logger.info("Shutdown: flushed {} session(s) to disk", flushed) finally: - restore_shutdown_handlers() + try: + await extension_host.close() + finally: + restore_shutdown_handlers() asyncio.run(run()) @@ -2244,6 +2258,7 @@ def agent( """Interact with the agent directly.""" from nanobot.bus.queue import MessageBus from nanobot.cron.service import CronService + from nanobot.extensions import ExtensionHost from nanobot.providers.image_generation import image_gen_provider_configs config = _load_runtime_config(config, workspace) @@ -2271,6 +2286,7 @@ def agent( except ValueError as exc: console.print(f"[red]Error: {exc}[/red]") raise typer.Exit(1) from exc + extension_host = ExtensionHost(agent_loop, lambda: config) restart_notice = consume_restart_notice_from_env() if restart_notice and should_show_cli_restart_notice(restart_notice, session_id): _print_agent_response( @@ -2312,29 +2328,36 @@ def agent( if message: # Single message mode — direct call, no bus needed async def run_once(): - renderer = StreamRenderer( - render_markdown=markdown, - bot_name=config.agents.defaults.bot_name, - bot_icon=config.agents.defaults.bot_icon, - ) - response = await agent_loop.process_direct( - message, session_id, - on_progress=_make_progress(renderer), - on_stream=renderer.on_delta, - on_stream_end=renderer.on_end, - ) - if not renderer.streamed: - await renderer.close() - print_kwargs: dict[str, Any] = {} - if renderer.header_printed: - print_kwargs["show_header"] = False - _print_agent_response( - response.content if response else "", + try: + await extension_host.reload() + renderer = StreamRenderer( render_markdown=markdown, - metadata=response.metadata if response else None, - **print_kwargs, + bot_name=config.agents.defaults.bot_name, + bot_icon=config.agents.defaults.bot_icon, ) - await agent_loop.close_mcp() + response = await agent_loop.process_direct( + message, + session_id, + on_progress=_make_progress(renderer), + on_stream=renderer.on_delta, + on_stream_end=renderer.on_end, + ) + if not renderer.streamed: + await renderer.close() + print_kwargs: dict[str, Any] = {} + if renderer.header_printed: + print_kwargs["show_header"] = False + _print_agent_response( + response.content if response else "", + render_markdown=markdown, + metadata=response.metadata if response else None, + **print_kwargs, + ) + finally: + try: + await agent_loop.close_mcp() + finally: + await extension_host.close() asyncio.run(run_once()) else: @@ -2367,6 +2390,7 @@ def agent( signal.signal(signal.SIGPIPE, signal.SIG_IGN) async def run_interactive(): + await extension_host.reload() bus_task = asyncio.create_task(agent_loop.run()) turn_done = asyncio.Event() turn_done.set() @@ -2498,7 +2522,10 @@ def agent( agent_loop.stop() outbound_task.cancel() await asyncio.gather(bus_task, outbound_task, return_exceptions=True) - await agent_loop.close_mcp() + try: + await agent_loop.close_mcp() + finally: + await extension_host.close() asyncio.run(run_interactive()) diff --git a/nanobot/extensions/__init__.py b/nanobot/extensions/__init__.py index 8fae39e13..c8d96f15d 100644 --- a/nanobot/extensions/__init__.py +++ b/nanobot/extensions/__init__.py @@ -9,6 +9,7 @@ from nanobot.extensions.codec import ( manifest_from_mapping, manifest_to_mapping, ) +from nanobot.extensions.host import ExtensionHost, ExtensionHostSnapshot from nanobot.extensions.manifest import ( EXTENSION_API_VERSION, ContributionKind, @@ -19,6 +20,7 @@ from nanobot.extensions.manifest import ( ExtensionPermission, ExtensionRuntime, ) +from nanobot.extensions.market import ExtensionMarketplace, MarketplacePackage from nanobot.extensions.native import discover_native_extensions from nanobot.extensions.node_host import NodeSidecar from nanobot.extensions.package_adapter import AdaptedPackage, adapt_package @@ -43,6 +45,7 @@ from nanobot.extensions.runtime import ( ExtensionRuntimeManager, PythonExtensionApi, ) +from nanobot.extensions.service import ExtensionService from nanobot.extensions.store import ( ExtensionSourceKind, ExtensionStore, @@ -56,6 +59,9 @@ __all__ = [ "DependencyKind", "ExtensionCandidate", "ExtensionCatalog", + "ExtensionHost", + "ExtensionHostSnapshot", + "ExtensionMarketplace", "ExtensionContribution", "ExtensionDependency", "ExtensionDiagnostic", @@ -69,6 +75,7 @@ __all__ = [ "ExtensionSnapshot", "ExtensionSourceKind", "ExtensionStore", + "ExtensionService", "MANIFEST_FILENAME", "ManifestFormatError", "NODE_PROTOCOL_VERSION", @@ -82,6 +89,7 @@ __all__ = [ "PythonExtensionApi", "InstalledExtension", "InstallResult", + "MarketplacePackage", "ResolvedContribution", "build_extension_catalog", "adapt_package", diff --git a/nanobot/extensions/host.py b/nanobot/extensions/host.py new file mode 100644 index 000000000..679b34a7f --- /dev/null +++ b/nanobot/extensions/host.py @@ -0,0 +1,92 @@ +"""Agent-side lifecycle for first-class extensions.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from loguru import logger + +from nanobot.extensions.catalog import ExtensionCatalog, build_extension_catalog +from nanobot.extensions.registry import ExtensionDiagnostic +from nanobot.extensions.runtime import ActivationResult, ExtensionRuntimeManager + +if TYPE_CHECKING: + from nanobot.agent.loop import AgentLoop + from nanobot.config.schema import Config + + +@dataclass(frozen=True, slots=True) +class ExtensionHostSnapshot: + """Current discovery and activation result.""" + + catalog: ExtensionCatalog + activation: ActivationResult + + @property + def diagnostics(self) -> tuple[ExtensionDiagnostic, ...]: + return self.catalog.diagnostics + self.activation.diagnostics + + +class ExtensionHost: + """Reload external extensions without coupling their lifecycle to AgentLoop.""" + + def __init__( + self, + agent: AgentLoop, + config_loader: Callable[[], Config], + *, + user_root: Path | None = None, + ) -> None: + self._agent = agent + self._config_loader = config_loader + self._user_root = user_root + self._manager: ExtensionRuntimeManager | None = None + self._snapshot: ExtensionHostSnapshot | None = None + self._lock = asyncio.Lock() + + @property + def snapshot(self) -> ExtensionHostSnapshot | None: + return self._snapshot + + async def reload(self) -> ExtensionHostSnapshot: + async with self._lock: + await self._close_manager() + self._snapshot = None + config = self._config_loader() + catalog = build_extension_catalog( + config, + tools=self._agent.tools, + commands=self._agent.commands, + user_root=self._user_root, + ) + manager = ExtensionRuntimeManager( + tools=self._agent.tools, + commands=self._agent.commands, + config=config, + hook_factories=self._agent._hook_factories, + ) + activation = await manager.activate(catalog.snapshot) + self._manager = manager + self._snapshot = ExtensionHostSnapshot(catalog, activation) + for diagnostic in self._snapshot.diagnostics: + logger.warning( + "Extension {} [{}]: {}", + diagnostic.extension_id, + diagnostic.code, + diagnostic.message, + ) + return self._snapshot + + async def close(self) -> None: + async with self._lock: + await self._close_manager() + self._snapshot = None + + async def _close_manager(self) -> None: + if self._manager is not None: + await self._manager.close() + self._manager = None diff --git a/nanobot/extensions/market.py b/nanobot/extensions/market.py new file mode 100644 index 000000000..ead8d08fd --- /dev/null +++ b/nanobot/extensions/market.py @@ -0,0 +1,101 @@ +"""Package discovery for nanobot, Pi, and OpenClaw extension ecosystems.""" + +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass +from typing import Any + +_SEARCHES = { + "nanobot": "keywords:nanobot-extension", + "pi": "keywords:pi-package", + "openclaw": "keywords:openclaw-plugin", +} + + +@dataclass(frozen=True, slots=True) +class MarketplacePackage: + """One untrusted package candidate returned by a public package index.""" + + name: str + version: str + description: str + ecosystem: str + publisher: str = "" + license: str = "" + homepage: str = "" + repository: str = "" + published_at: str = "" + + +class ExtensionMarketplace: + """Search npm without making package installation an implicit trust action.""" + + def search( + self, + query: str = "", + *, + ecosystem: str = "all", + limit: int = 30, + ) -> tuple[MarketplacePackage, ...]: + if ecosystem != "all" and ecosystem not in _SEARCHES: + raise ValueError(f"unknown extension ecosystem: {ecosystem}") + if not 1 <= limit <= 100: + raise ValueError("market search limit must be between 1 and 100") + ecosystems = _SEARCHES if ecosystem == "all" else {ecosystem: _SEARCHES[ecosystem]} + found: dict[str, MarketplacePackage] = {} + for name, keyword in ecosystems.items(): + terms = " ".join(part for part in (keyword, query.strip()) if part) + for row in _npm_search(terms, limit=limit): + required_keyword = keyword.partition(":")[2] + keywords = row.get("keywords") + if not isinstance(keywords, list) or required_keyword not in keywords: + continue + package = _marketplace_package(row, ecosystem=name) + if not package.name or not package.version: + continue + found.setdefault(package.name, package) + return tuple( + sorted(found.values(), key=lambda item: (item.ecosystem, item.name))[:limit] + ) + + +def _npm_search(query: str, *, limit: int) -> list[dict[str, Any]]: + try: + result = subprocess.run( + ["npm", "search", "--json", f"--searchlimit={limit}", "--", query], + check=True, + capture_output=True, + text=True, + timeout=20, + ) + except FileNotFoundError as exc: + raise RuntimeError("npm is required to search the extension marketplace") from exc + except subprocess.TimeoutExpired as exc: + raise RuntimeError("extension marketplace search timed out") from exc + except subprocess.CalledProcessError as exc: + message = (exc.stderr or exc.stdout).strip() + raise RuntimeError(message or "extension marketplace search failed") from exc + value = json.loads(result.stdout) + if not isinstance(value, list): + raise RuntimeError("npm returned an invalid marketplace response") + return [row for row in value if isinstance(row, dict)] + + +def _marketplace_package(row: dict[str, Any], *, ecosystem: str) -> MarketplacePackage: + publisher = row.get("publisher") + publisher_name = publisher.get("username", "") if isinstance(publisher, dict) else "" + links = row.get("links") + links = links if isinstance(links, dict) else {} + return MarketplacePackage( + name=str(row.get("name") or ""), + version=str(row.get("version") or ""), + description=str(row.get("description") or ""), + ecosystem=ecosystem, + publisher=str(publisher_name), + license=str(row.get("license") or ""), + homepage=str(links.get("homepage") or ""), + repository=str(links.get("repository") or ""), + published_at=str(row.get("date") or ""), + ) diff --git a/nanobot/extensions/runtime.py b/nanobot/extensions/runtime.py index 3aa3afb14..a18a4cd01 100644 --- a/nanobot/extensions/runtime.py +++ b/nanobot/extensions/runtime.py @@ -82,12 +82,13 @@ class ExtensionRuntimeManager: tools: ToolRegistry, commands: CommandRouter, config: Config, + hook_factories: list[AgentTurnHookFactory] | None = None, ) -> None: self._tools = tools self._commands = commands self._config = config self._active: list[ActivatedExtension] = [] - self._hook_factories: list[AgentTurnHookFactory] = [] + self._hook_factories = hook_factories if hook_factories is not None else [] async def activate(self, snapshot: ExtensionSnapshot) -> ActivationResult: diagnostics: list[ExtensionDiagnostic] = [] diff --git a/nanobot/extensions/service.py b/nanobot/extensions/service.py new file mode 100644 index 000000000..d3a126b2f --- /dev/null +++ b/nanobot/extensions/service.py @@ -0,0 +1,206 @@ +"""Transport-neutral extension management service.""" + +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from pathlib import Path +from typing import Any + +from nanobot.extensions.host import ExtensionHost +from nanobot.extensions.market import ExtensionMarketplace +from nanobot.extensions.registry import ExtensionCandidate, ExtensionScope +from nanobot.extensions.store import ExtensionStore + + +class ExtensionService: + """One management boundary shared by CLI and WebUI.""" + + def __init__( + self, + *, + host: ExtensionHost | None = None, + store: ExtensionStore | None = None, + marketplace: ExtensionMarketplace | None = None, + ) -> None: + self.host = host + self.store = store or ExtensionStore() + self.marketplace = marketplace or ExtensionMarketplace() + self._mutation_lock = asyncio.Lock() + + async def status(self) -> dict[str, Any]: + catalog = self.host.snapshot.catalog if self.host and self.host.snapshot else None + if catalog is None: + discovery = self.store.discover() + candidates = discovery.candidates + diagnostics = discovery.diagnostics + active_ids: set[str] = set() + else: + candidates = catalog.candidates + diagnostics = catalog.diagnostics + active_ids = { + candidate.manifest.id for candidate in catalog.snapshot.extensions + } + if self.host and self.host.snapshot: + diagnostics += self.host.snapshot.activation.diagnostics + records = self.store.records() + return { + "extensions": [ + _candidate_payload(candidate, active_ids, records.get(candidate.manifest.id)) + for candidate in sorted( + candidates, + key=lambda item: (item.scope, item.manifest.name.lower()), + ) + ], + "diagnostics": [asdict(item) for item in diagnostics], + } + + async def search( + self, + query: str = "", + *, + ecosystem: str = "all", + limit: int = 30, + ) -> dict[str, Any]: + packages = await asyncio.to_thread( + self.marketplace.search, + query, + ecosystem=ecosystem, + limit=limit, + ) + return {"packages": [asdict(package) for package in packages]} + + async def install( + self, + source: str, + *, + kind: str = "npm", + ref: str = "", + trusted: bool = False, + ) -> dict[str, Any]: + async with self._mutation_lock: + if kind == "npm": + result = await asyncio.to_thread( + self.store.install_npm, + source, + trusted=trusted, + ) + elif kind == "git": + result = await asyncio.to_thread( + self.store.install_git, + source, + ref=ref, + trusted=trusted, + ) + elif kind == "local": + result = await asyncio.to_thread( + self.store.install_local, + Path(source), + trusted=trusted, + ) + else: + raise ValueError(f"unknown extension source kind: {kind}") + await self._reload() + return { + "record": _record_payload(result.record), + "manifest": _manifest_payload(result.package.manifest), + "diagnostics": list(result.package.diagnostics), + } + + async def set_enabled(self, extension_id: str, enabled: bool) -> dict[str, Any]: + return await self._update(extension_id, self.store.set_enabled, enabled) + + async def set_trusted(self, extension_id: str, trusted: bool) -> dict[str, Any]: + return await self._update(extension_id, self.store.set_trusted, trusted) + + async def set_permissions( + self, + extension_id: str, + permissions: set[str] | frozenset[str], + ) -> dict[str, Any]: + return await self._update( + extension_id, + self.store.set_permissions, + permissions, + ) + + async def uninstall(self, extension_id: str) -> dict[str, Any]: + async with self._mutation_lock: + await asyncio.to_thread(self.store.uninstall, extension_id) + await self._reload() + return {"removed": extension_id} + + async def _update(self, extension_id: str, action: Any, value: Any) -> dict[str, Any]: + async with self._mutation_lock: + record = await asyncio.to_thread(action, extension_id, value) + await self._reload() + return {"record": _record_payload(record)} + + async def _reload(self) -> None: + if self.host is not None: + await self.host.reload() + + +def _candidate_payload( + candidate: ExtensionCandidate, + active_ids: set[str], + record: Any | None, +) -> dict[str, Any]: + manifest = candidate.manifest + requested = [permission.name for permission in manifest.permissions] + return { + **_manifest_payload(manifest), + "scope": candidate.scope.name.lower(), + "location": str(candidate.location) if candidate.location else None, + "enabled": candidate.enabled, + "trusted": candidate.trusted, + "active": manifest.id in active_ids, + "requested_permissions": requested, + "granted_permissions": sorted(candidate.granted_permissions), + "source": record.source.value if record else ( + "builtin" if candidate.scope is ExtensionScope.BUILTIN else "path" + ), + "source_ref": record.source_ref if record else "", + "integrity": record.integrity if record else "", + "installed_at": record.installed_at if record else "", + } + + +def _manifest_payload(manifest: Any) -> dict[str, Any]: + return { + "id": manifest.id, + "name": manifest.name, + "version": manifest.version, + "runtime": manifest.runtime.value, + "description": manifest.description, + "homepage": manifest.homepage, + "license": manifest.license, + "contributions": [ + { + "kind": contribution.kind.value, + "name": contribution.name, + "description": contribution.description, + } + for contribution in manifest.contributions + ], + "dependencies": [ + { + "kind": dependency.kind.value, + "name": dependency.name, + "specifier": dependency.specifier, + "optional": dependency.optional, + } + for dependency in manifest.dependencies + ], + "permissions": [ + {"name": permission.name, "reason": permission.reason} + for permission in manifest.permissions + ], + } + + +def _record_payload(record: Any) -> dict[str, Any]: + return { + **asdict(record), + "source": record.source.value, + } diff --git a/nanobot/extensions/store.py b/nanobot/extensions/store.py index 5303928b8..9e26dfff5 100644 --- a/nanobot/extensions/store.py +++ b/nanobot/extensions/store.py @@ -52,6 +52,11 @@ class InstalledExtension: def from_mapping(cls, value: object) -> InstalledExtension: if not isinstance(value, dict): raise ValueError("extension registry record must be an object") + permissions = value.get("granted_permissions", ()) + if not isinstance(permissions, (list, tuple)) or not all( + isinstance(permission, str) for permission in permissions + ): + raise ValueError("extension granted permissions must be an array of strings") return cls( id=str(value["id"]), version=str(value["version"]), @@ -61,7 +66,7 @@ class InstalledExtension: installed_at=str(value["installed_at"]), enabled=bool(value.get("enabled", True)), trusted=bool(value.get("trusted", False)), - granted_permissions=tuple(value.get("granted_permissions", ())), + granted_permissions=tuple(permissions), ) diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index 9db9ab53d..5c807a1f0 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -11,6 +11,7 @@ from nanobot.agent.hook import AgentHook, SDKCaptureHook from nanobot.agent.hooks import create_file_edit_activity_hook from nanobot.agent.loop import AgentLoop from nanobot.config.schema import Config +from nanobot.extensions import ExtensionHost from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient from nanobot.sdk.runtime import ( @@ -77,6 +78,9 @@ class Nanobot: self.sessions = SessionClient(loop) self.memory = MemoryClient(loop) self.runtime = RuntimeClient(loop) + self._extensions = ExtensionHost(loop, lambda: config) if config else None + self._extensions_started = False + self._extensions_lock = asyncio.Lock() @classmethod def from_config( @@ -153,6 +157,7 @@ class Nanobot: model: Override the model for this run only. model_preset: Override the model preset for this run only. """ + await self._ensure_extensions() capture = SDKCaptureHook() per_run_hooks = [capture, *(hooks or [])] runtime = self._loop.runtime_resolver.resolve_override( @@ -193,6 +198,7 @@ class Nanobot: model_preset: str | None = None, ) -> RunStream: """Start a streamed run and return a handle for events and final result.""" + await self._ensure_extensions() override_runtime = self._loop.runtime_resolver.resolve_override( model=model, model_preset=model_preset, @@ -316,7 +322,20 @@ class Nanobot: async def aclose(self) -> None: """Release resources held by this instance (MCP connections, etc.).""" - await self._loop.close_mcp() + try: + if self._extensions is not None: + await self._extensions.close() + self._extensions_started = False + finally: + await self._loop.close_mcp() + + async def _ensure_extensions(self) -> None: + if self._extensions is None or self._extensions_started: + return + async with self._extensions_lock: + if not self._extensions_started: + await self._extensions.reload() + self._extensions_started = True async def __aenter__(self) -> Nanobot: return self diff --git a/nanobot/webui/extensions_routes.py b/nanobot/webui/extensions_routes.py new file mode 100644 index 000000000..e0704362e --- /dev/null +++ b/nanobot/webui/extensions_routes.py @@ -0,0 +1,177 @@ +"""Authenticated HTTP adapter for the extension management service.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +from websockets.http11 import Request as WsRequest +from websockets.http11 import Response + +from nanobot.extensions.service import ExtensionService +from nanobot.webui.http_utils import is_local_browser_request + +_VALUES_HEADER = "X-Nanobot-Extension-Values" +_VALUES_MAX_BYTES = 32 * 1024 +_ACTION_PATHS = { + "/api/extensions/install": "install", + "/api/extensions/enable": "enable", + "/api/extensions/disable": "disable", + "/api/extensions/trust": "trust", + "/api/extensions/untrust": "untrust", + "/api/extensions/permissions": "permissions", + "/api/extensions/uninstall": "uninstall", +} + + +class WebUIExtensionsRouter: + """Keep extension policy and installation outside WebSocket transport.""" + + def __init__( + self, + *, + service: ExtensionService | None, + check_api_token: Callable[[WsRequest], bool], + parse_query: Callable[[str], dict[str, list[str]]], + json_response: Callable[[dict[str, Any]], Response], + error_response: Callable[[int, str | None], Response], + allow_remote_package_install: bool = False, + logger: Any, + ) -> None: + self._service = service + self._check_api_token = check_api_token + self._parse_query = parse_query + self._json_response = json_response + self._error_response = error_response + self._allow_remote_package_install = allow_remote_package_install + self._logger = logger + + async def dispatch( + self, + connection: Any, + request: WsRequest, + path: str, + ) -> Response | None: + if not path.startswith("/api/extensions"): + return None + if not self._check_api_token(request): + return self._error_response(401, "Unauthorized") + if self._service is None: + return self._error_response(503, "Extension service is not available") + try: + if path == "/api/extensions": + if _method(request) != "GET": + return self._error_response(405, "Method not allowed") + return self._json_response(await self._service.status()) + if path == "/api/extensions/market": + if _method(request) != "GET": + return self._error_response(405, "Method not allowed") + query = self._parse_query(request.path) + return self._json_response( + await self._service.search( + _first(query, "q"), + ecosystem=_first(query, "ecosystem") or "all", + limit=_int_value(_first(query, "limit"), default=30), + ) + ) + action = _ACTION_PATHS.get(path) + if action is None: + return None + if _method(request) != "POST": + return self._error_response(405, "Method not allowed") + if not self._mutation_allowed(connection, request): + return self._error_response( + 403, + "Extension changes require a local WebUI connection", + ) + values = self._values(request) + if ( + action == "install" + and str(values.get("kind") or "npm") == "local" + and not is_local_browser_request(connection, request.headers) + ): + return self._error_response( + 403, + "Local extension paths require a local WebUI connection", + ) + return self._json_response(await self._run_action(action, values)) + except KeyError as exc: + return self._error_response(404, str(exc)) + except ValueError as exc: + return self._error_response(400, str(exc)) + except RuntimeError as exc: + return self._error_response(502, str(exc)) + except Exception: + self._logger.exception("extension management request failed") + return self._error_response(500, "Extension operation failed") + + async def _run_action(self, action: str, values: dict[str, Any]) -> dict[str, Any]: + assert self._service is not None + extension_id = str(values.get("id") or "").strip() + if action == "install": + source = str(values.get("source") or "").strip() + if not source: + raise ValueError("Missing extension source") + return await self._service.install( + source, + kind=str(values.get("kind") or "npm"), + ref=str(values.get("ref") or ""), + trusted=False, + ) + if not extension_id: + raise ValueError("Missing extension ID") + if action == "enable": + return await self._service.set_enabled(extension_id, True) + if action == "disable": + return await self._service.set_enabled(extension_id, False) + if action == "trust": + return await self._service.set_trusted(extension_id, True) + if action == "untrust": + return await self._service.set_trusted(extension_id, False) + if action == "permissions": + permissions = values.get("permissions", []) + if not isinstance(permissions, list) or not all( + isinstance(permission, str) for permission in permissions + ): + raise ValueError("Extension permissions must be an array of strings") + return await self._service.set_permissions(extension_id, set(permissions)) + return await self._service.uninstall(extension_id) + + def _values(self, request: WsRequest) -> dict[str, Any]: + raw = request.headers.get(_VALUES_HEADER) + if not raw: + return {} + if len(raw.encode("utf-8")) > _VALUES_MAX_BYTES: + raise ValueError("Extension request is too large") + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError("Invalid extension request") from exc + if not isinstance(value, dict): + raise ValueError("Extension request must be a JSON object") + return value + + def _mutation_allowed(self, connection: Any, request: WsRequest) -> bool: + return self._allow_remote_package_install or is_local_browser_request( + connection, + request.headers, + ) + + +def _first(query: dict[str, list[str]], key: str) -> str: + values = query.get(key, []) + return values[0] if values else "" + + +def _int_value(value: str, *, default: int) -> int: + if not value: + return default + try: + return int(value) + except ValueError as exc: + raise ValueError("Extension market limit must be a number") from exc + + +def _method(request: WsRequest) -> str: + return str(getattr(request, "method", "GET")).upper() diff --git a/nanobot/webui/gateway_services.py b/nanobot/webui/gateway_services.py index 6bf438f39..5fdd89c83 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -51,6 +51,8 @@ def build_gateway_services( local_trigger_pending_ids: Callable[[str], set[str]] | None = None, channel_feature_action: Callable[..., Any] | None = None, channel_runtime_status: Callable[[], dict[str, Any]] | None = None, + extension_service: Any | None = None, + allow_remote_package_install: bool = False, logger: Any = default_logger, ) -> GatewayServices: tokens = GatewayTokenStore() @@ -94,6 +96,8 @@ def build_gateway_services( local_trigger_pending_ids=local_trigger_pending_ids, channel_feature_action=channel_feature_action, channel_runtime_status=channel_runtime_status, + extension_service=extension_service, + allow_remote_package_install=allow_remote_package_install, log=logger, ) return GatewayServices( diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index af2b2a3ac..332ce0d49 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -170,6 +170,8 @@ class GatewayHTTPHandler: local_trigger_pending_ids: Callable[[str], set[str]] | None = None, channel_feature_action: Callable[..., Any] | None = None, channel_runtime_status: Callable[[], dict[str, Any]] | None = None, + extension_service: Any | None = None, + allow_remote_package_install: bool = False, log: Any = logger, ) -> None: self.config = config @@ -190,6 +192,7 @@ class GatewayHTTPHandler: self._log = log self._runtime_surface = runtime_surface + from nanobot.webui.extensions_routes import WebUIExtensionsRouter from nanobot.webui.settings_api import runtime_capabilities as _rc from nanobot.webui.settings_routes import WebUISettingsRouter @@ -206,6 +209,15 @@ class GatewayHTTPHandler: channel_feature_action=channel_feature_action, channel_runtime_status=channel_runtime_status, ) + self.extensions_routes = WebUIExtensionsRouter( + service=extension_service, + check_api_token=self.check_api_token, + parse_query=_parse_query, + json_response=_http_json_response, + error_response=_http_error, + allow_remote_package_install=allow_remote_package_install, + logger=self._log, + ) def workspace_controls_available(self, connection: Any) -> bool: return self._runtime_surface == "native" or _is_localhost(connection) @@ -247,6 +259,9 @@ class GatewayHTTPHandler: # Settings routes (delegated) response = await self.settings_routes.dispatch(connection, request, got) + if response is not None: + return response + response = await self.extensions_routes.dispatch(connection, request, got) if response is not None: return response diff --git a/tests/extensions/test_host.py b/tests/extensions/test_host.py new file mode 100644 index 000000000..75c2a9d82 --- /dev/null +++ b/tests/extensions/test_host.py @@ -0,0 +1,49 @@ +from pathlib import Path + +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.command.router import CommandRouter +from nanobot.config.schema import Config +from nanobot.extensions.host import ExtensionHost +from nanobot.extensions.runtime import ExtensionRuntimeManager + + +class _Agent: + def __init__(self) -> None: + self.tools = ToolRegistry() + self.commands = CommandRouter() + self._hook_factories = [] + + +async def test_host_reloads_and_closes_runtime( + monkeypatch, + tmp_path: Path, +) -> None: + agent = _Agent() + config = Config() + activated: list[object] = [] + closed: list[object] = [] + + async def activate(self, snapshot): + activated.append(snapshot) + return type( + "Result", + (), + {"extensions": (), "hook_factories": (), "diagnostics": ()}, + )() + + async def close(self): + closed.append(self) + + monkeypatch.setattr(ExtensionRuntimeManager, "activate", activate) + monkeypatch.setattr(ExtensionRuntimeManager, "close", close) + + host = ExtensionHost(agent, lambda: config, user_root=tmp_path) + first = await host.reload() + second = await host.reload() + await host.close() + + assert host.snapshot is None + assert first.catalog.snapshot.extensions + assert second.catalog.snapshot.extensions + assert len(activated) == 2 + assert len(closed) == 2 diff --git a/tests/extensions/test_market.py b/tests/extensions/test_market.py new file mode 100644 index 000000000..f03c3664e --- /dev/null +++ b/tests/extensions/test_market.py @@ -0,0 +1,59 @@ +import json +import subprocess + +import pytest + +from nanobot.extensions.market import ExtensionMarketplace + + +def test_market_search_normalizes_npm_packages(monkeypatch) -> None: + payload = [ + { + "name": "pi-example", + "version": "1.2.3", + "description": "Example", + "keywords": ["pi-package"], + "publisher": {"username": "alice"}, + "links": {"repository": "https://example.com/repo"}, + } + ] + + def run(*_args, **_kwargs): + return subprocess.CompletedProcess([], 0, json.dumps(payload), "") + + monkeypatch.setattr(subprocess, "run", run) + + package = ExtensionMarketplace().search("example", ecosystem="pi")[0] + + assert package.name == "pi-example" + assert package.ecosystem == "pi" + assert package.publisher == "alice" + assert package.repository == "https://example.com/repo" + + +def test_market_rejects_unknown_ecosystem() -> None: + with pytest.raises(ValueError, match="unknown extension ecosystem"): + ExtensionMarketplace().search(ecosystem="other") + + +def test_market_ignores_fuzzy_npm_results(monkeypatch) -> None: + payload = [ + { + "name": "unrelated", + "version": "1.0.0", + "keywords": ["pi"], + } + ] + + monkeypatch.setattr( + subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess( + [], + 0, + json.dumps(payload), + "", + ), + ) + + assert ExtensionMarketplace().search(ecosystem="pi") == () diff --git a/tests/extensions/test_service.py b/tests/extensions/test_service.py new file mode 100644 index 000000000..ba5d351b6 --- /dev/null +++ b/tests/extensions/test_service.py @@ -0,0 +1,58 @@ +from pathlib import Path + +from nanobot.extensions.service import ExtensionService +from nanobot.extensions.store import ExtensionStore + + +async def test_service_installs_untrusted_extension_and_reports_status( + tmp_path: Path, +) -> None: + source = tmp_path / "source" + source.mkdir() + (source / "nanobot.extension.json").write_text( + """ +{ + "apiVersion": 1, + "id": "sample", + "name": "Sample", + "version": "1.0.0", + "runtime": "declarative" +} +""", + encoding="utf-8", + ) + service = ExtensionService(store=ExtensionStore(tmp_path / "installed")) + + installed = await service.install(str(source), kind="local") + status = await service.status() + + assert installed["record"]["trusted"] is False + assert status["extensions"][0]["id"] == "sample" + assert status["extensions"][0]["active"] is False + + +async def test_service_updates_policy_and_uninstalls(tmp_path: Path) -> None: + source = tmp_path / "source" + source.mkdir() + (source / "nanobot.extension.json").write_text( + """ +{ + "apiVersion": 1, + "id": "sample", + "name": "Sample", + "version": "1.0.0", + "runtime": "declarative" +} +""", + encoding="utf-8", + ) + service = ExtensionService(store=ExtensionStore(tmp_path / "installed")) + await service.install(str(source), kind="local") + + trusted = await service.set_trusted("sample", True) + await service.set_enabled("sample", False) + removed = await service.uninstall("sample") + + assert trusted["record"]["trusted"] is True + assert removed == {"removed": "sample"} + assert (await service.status())["extensions"] == [] diff --git a/tests/extensions/test_store.py b/tests/extensions/test_store.py index 189beebda..3561a85bb 100644 --- a/tests/extensions/test_store.py +++ b/tests/extensions/test_store.py @@ -2,6 +2,8 @@ import json from pathlib import Path from unittest.mock import patch +import pytest + from nanobot.extensions import ( DependencyKind, ExtensionDependency, @@ -9,6 +11,7 @@ from nanobot.extensions import ( ExtensionRuntime, ExtensionSourceKind, ExtensionStore, + InstalledExtension, dump_manifest, ) @@ -50,6 +53,21 @@ def test_store_installs_and_applies_trust_state(tmp_path: Path) -> None: ) +def test_registry_permissions_must_be_a_string_array() -> None: + with pytest.raises(ValueError, match="array of strings"): + InstalledExtension.from_mapping( + { + "id": "sample", + "version": "1.0.0", + "source": "npm", + "source_ref": "sample", + "integrity": "sha256:test", + "installed_at": "2026-01-01T00:00:00Z", + "granted_permissions": "network", + } + ) + + def test_store_updates_and_uninstalls_atomically(tmp_path: Path) -> None: source = _pi_package(tmp_path / "source") store = ExtensionStore(tmp_path / "extensions") diff --git a/tests/webui/test_extensions_routes.py b/tests/webui/test_extensions_routes.py new file mode 100644 index 000000000..a64a8d48b --- /dev/null +++ b/tests/webui/test_extensions_routes.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace +from urllib.parse import parse_qs, urlsplit + +import pytest +from websockets.datastructures import Headers + +from nanobot.webui.extensions_routes import WebUIExtensionsRouter +from nanobot.webui.http_utils import http_json_response + + +class _Service: + def __init__(self) -> None: + self.calls: list[tuple[str, object]] = [] + + async def status(self): + self.calls.append(("status", None)) + return {"extensions": [], "diagnostics": []} + + async def search(self, query, *, ecosystem, limit): + self.calls.append(("search", (query, ecosystem, limit))) + return {"packages": []} + + async def install(self, source, *, kind, ref, trusted): + self.calls.append(("install", (source, kind, ref, trusted))) + return {"record": {"id": "sample"}} + + +def _router( + service: _Service, + *, + authorized: bool = True, + allow_remote: bool = False, +) -> WebUIExtensionsRouter: + return WebUIExtensionsRouter( + service=service, + check_api_token=lambda _request: authorized, + parse_query=lambda path: parse_qs(urlsplit(path).query), + json_response=http_json_response, + error_response=lambda status, message: http_json_response( + {"error": message}, + status=status, + ), + allow_remote_package_install=allow_remote, + logger=SimpleNamespace(exception=lambda *_args: None), + ) + + +def _request( + path: str, + *, + method: str = "GET", + values: dict[str, object] | None = None, + host: str = "127.0.0.1:8765", +): + headers = Headers([("Host", host)]) + if values is not None: + headers["X-Nanobot-Extension-Values"] = json.dumps(values) + return SimpleNamespace(path=path, method=method, headers=headers) + + +_LOCAL = SimpleNamespace(remote_address=("127.0.0.1", 12345)) +_REMOTE = SimpleNamespace(remote_address=("192.0.2.1", 12345)) + + +@pytest.mark.asyncio +async def test_extension_status_requires_auth_and_get() -> None: + service = _Service() + + unauthorized = await _router(service, authorized=False).dispatch( + _LOCAL, + _request("/api/extensions"), + "/api/extensions", + ) + wrong_method = await _router(service).dispatch( + _LOCAL, + _request("/api/extensions", method="POST"), + "/api/extensions", + ) + response = await _router(service).dispatch( + _LOCAL, + _request("/api/extensions"), + "/api/extensions", + ) + + assert unauthorized is not None and unauthorized.status_code == 401 + assert wrong_method is not None and wrong_method.status_code == 405 + assert response is not None and response.status_code == 200 + assert service.calls == [("status", None)] + + +@pytest.mark.asyncio +async def test_extension_market_parses_query() -> None: + service = _Service() + response = await _router(service).dispatch( + _LOCAL, + _request("/api/extensions/market?q=web&ecosystem=pi&limit=7"), + "/api/extensions/market", + ) + + assert response is not None and response.status_code == 200 + assert service.calls == [("search", ("web", "pi", 7))] + + +@pytest.mark.asyncio +async def test_extension_install_is_local_and_untrusted() -> None: + service = _Service() + response = await _router(service).dispatch( + _LOCAL, + _request( + "/api/extensions/install", + method="POST", + values={"source": "pi-example", "kind": "npm"}, + ), + "/api/extensions/install", + ) + + assert response is not None and response.status_code == 200 + assert service.calls == [ + ("install", ("pi-example", "npm", "", False)), + ] + + +@pytest.mark.asyncio +async def test_remote_install_policy_never_exposes_server_local_paths() -> None: + service = _Service() + denied = await _router(service).dispatch( + _REMOTE, + _request( + "/api/extensions/install", + method="POST", + values={"source": "pi-example", "kind": "npm"}, + ), + "/api/extensions/install", + ) + npm_allowed = await _router(service, allow_remote=True).dispatch( + _REMOTE, + _request( + "/api/extensions/install", + method="POST", + values={"source": "pi-example", "kind": "npm"}, + ), + "/api/extensions/install", + ) + local_denied = await _router(service, allow_remote=True).dispatch( + _REMOTE, + _request( + "/api/extensions/install", + method="POST", + values={"source": "/tmp/example", "kind": "local"}, + ), + "/api/extensions/install", + ) + + assert denied is not None and denied.status_code == 403 + assert npm_allowed is not None and npm_allowed.status_code == 200 + assert local_denied is not None and local_denied.status_code == 403 + assert service.calls == [ + ("install", ("pi-example", "npm", "", False)), + ]