mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 01:48:53 +00:00
feat(extensions): wire runtime management and marketplace
This commit is contained in:
parent
6e0950833a
commit
83b54212ae
@ -97,6 +97,7 @@ class ChannelManager:
|
|||||||
webui_static_dist: bool = True,
|
webui_static_dist: bool = True,
|
||||||
webui_runtime_surface: str = "browser",
|
webui_runtime_surface: str = "browser",
|
||||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
|
webui_extension_service: Any | None = None,
|
||||||
):
|
):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
@ -109,6 +110,7 @@ class ChannelManager:
|
|||||||
self._webui_static_dist = webui_static_dist
|
self._webui_static_dist = webui_static_dist
|
||||||
self._webui_runtime_surface = webui_runtime_surface
|
self._webui_runtime_surface = webui_runtime_surface
|
||||||
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
||||||
|
self._webui_extension_service = webui_extension_service
|
||||||
self.channels: dict[str, BaseChannel] = {}
|
self.channels: dict[str, BaseChannel] = {}
|
||||||
self._channel_owners: dict[str, str] = {}
|
self._channel_owners: dict[str, str] = {}
|
||||||
self._channel_runtime_specs: dict[str, tuple[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,
|
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
|
||||||
channel_feature_action=self.apply_channel_feature_action,
|
channel_feature_action=self.apply_channel_feature_action,
|
||||||
channel_runtime_status=self.get_status,
|
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,
|
logger=logger,
|
||||||
)
|
)
|
||||||
kwargs["gateway"] = gateway
|
kwargs["gateway"] = gateway
|
||||||
|
|||||||
@ -1336,6 +1336,7 @@ def serve(
|
|||||||
|
|
||||||
from nanobot.api.server import create_app
|
from nanobot.api.server import create_app
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.extensions import ExtensionHost
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
@ -1384,12 +1385,17 @@ def serve(
|
|||||||
agent_loop, model_name=model_name, request_timeout=timeout,
|
agent_loop, model_name=model_name, request_timeout=timeout,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
)
|
)
|
||||||
|
extension_host = ExtensionHost(agent_loop, lambda: runtime_config)
|
||||||
|
|
||||||
async def on_startup(_app):
|
async def on_startup(_app):
|
||||||
|
await extension_host.reload()
|
||||||
await agent_loop._connect_mcp()
|
await agent_loop._connect_mcp()
|
||||||
|
|
||||||
async def on_cleanup(_app):
|
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_startup.append(on_startup)
|
||||||
api_app.on_cleanup.append(on_cleanup)
|
api_app.on_cleanup.append(on_cleanup)
|
||||||
@ -1633,6 +1639,7 @@ def _run_gateway(
|
|||||||
from nanobot.cron.service import CronJobSkippedError, CronService
|
from nanobot.cron.service import CronJobSkippedError, CronService
|
||||||
from nanobot.cron.session_turns import is_bound_cron_job
|
from nanobot.cron.session_turns import is_bound_cron_job
|
||||||
from nanobot.cron.types import CronJob
|
from nanobot.cron.types import CronJob
|
||||||
|
from nanobot.extensions import ExtensionHost, ExtensionService
|
||||||
from nanobot.providers.factory import (
|
from nanobot.providers.factory import (
|
||||||
build_provider_snapshot,
|
build_provider_snapshot,
|
||||||
build_unconfigured_provider_snapshot,
|
build_unconfigured_provider_snapshot,
|
||||||
@ -1752,6 +1759,8 @@ def _run_gateway(
|
|||||||
local_trigger_store=trigger_store,
|
local_trigger_store=trigger_store,
|
||||||
hook_factories=[create_file_edit_activity_hook],
|
hook_factories=[create_file_edit_activity_hook],
|
||||||
)
|
)
|
||||||
|
extension_host = ExtensionHost(agent, lambda: config)
|
||||||
|
extension_service = ExtensionService(host=extension_host)
|
||||||
webui_turn_coordinator = WebuiTurnCoordinator(
|
webui_turn_coordinator = WebuiTurnCoordinator(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
sessions=session_manager,
|
sessions=session_manager,
|
||||||
@ -1979,6 +1988,7 @@ def _run_gateway(
|
|||||||
webui_static_dist=webui_static_dist,
|
webui_static_dist=webui_static_dist,
|
||||||
webui_runtime_surface=webui_runtime_surface,
|
webui_runtime_surface=webui_runtime_surface,
|
||||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
|
webui_extension_service=extension_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||||
@ -2128,6 +2138,7 @@ def _run_gateway(
|
|||||||
console.print,
|
console.print,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
await extension_host.reload()
|
||||||
await cron.start()
|
await cron.start()
|
||||||
# Re-read once on first admission to close the watcher subscription window.
|
# Re-read once on first admission to close the watcher subscription window.
|
||||||
agent.runtime_resolver.invalidate()
|
agent.runtime_resolver.invalidate()
|
||||||
@ -2207,7 +2218,10 @@ def _run_gateway(
|
|||||||
if flushed:
|
if flushed:
|
||||||
logger.info("Shutdown: flushed {} session(s) to disk", flushed)
|
logger.info("Shutdown: flushed {} session(s) to disk", flushed)
|
||||||
finally:
|
finally:
|
||||||
restore_shutdown_handlers()
|
try:
|
||||||
|
await extension_host.close()
|
||||||
|
finally:
|
||||||
|
restore_shutdown_handlers()
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
@ -2244,6 +2258,7 @@ def agent(
|
|||||||
"""Interact with the agent directly."""
|
"""Interact with the agent directly."""
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
from nanobot.extensions import ExtensionHost
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
|
|
||||||
config = _load_runtime_config(config, workspace)
|
config = _load_runtime_config(config, workspace)
|
||||||
@ -2271,6 +2286,7 @@ def agent(
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
|
extension_host = ExtensionHost(agent_loop, lambda: config)
|
||||||
restart_notice = consume_restart_notice_from_env()
|
restart_notice = consume_restart_notice_from_env()
|
||||||
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
||||||
_print_agent_response(
|
_print_agent_response(
|
||||||
@ -2312,29 +2328,36 @@ def agent(
|
|||||||
if message:
|
if message:
|
||||||
# Single message mode — direct call, no bus needed
|
# Single message mode — direct call, no bus needed
|
||||||
async def run_once():
|
async def run_once():
|
||||||
renderer = StreamRenderer(
|
try:
|
||||||
render_markdown=markdown,
|
await extension_host.reload()
|
||||||
bot_name=config.agents.defaults.bot_name,
|
renderer = StreamRenderer(
|
||||||
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 "",
|
|
||||||
render_markdown=markdown,
|
render_markdown=markdown,
|
||||||
metadata=response.metadata if response else None,
|
bot_name=config.agents.defaults.bot_name,
|
||||||
**print_kwargs,
|
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())
|
asyncio.run(run_once())
|
||||||
else:
|
else:
|
||||||
@ -2367,6 +2390,7 @@ def agent(
|
|||||||
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
||||||
|
|
||||||
async def run_interactive():
|
async def run_interactive():
|
||||||
|
await extension_host.reload()
|
||||||
bus_task = asyncio.create_task(agent_loop.run())
|
bus_task = asyncio.create_task(agent_loop.run())
|
||||||
turn_done = asyncio.Event()
|
turn_done = asyncio.Event()
|
||||||
turn_done.set()
|
turn_done.set()
|
||||||
@ -2498,7 +2522,10 @@ def agent(
|
|||||||
agent_loop.stop()
|
agent_loop.stop()
|
||||||
outbound_task.cancel()
|
outbound_task.cancel()
|
||||||
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
|
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())
|
asyncio.run(run_interactive())
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from nanobot.extensions.codec import (
|
|||||||
manifest_from_mapping,
|
manifest_from_mapping,
|
||||||
manifest_to_mapping,
|
manifest_to_mapping,
|
||||||
)
|
)
|
||||||
|
from nanobot.extensions.host import ExtensionHost, ExtensionHostSnapshot
|
||||||
from nanobot.extensions.manifest import (
|
from nanobot.extensions.manifest import (
|
||||||
EXTENSION_API_VERSION,
|
EXTENSION_API_VERSION,
|
||||||
ContributionKind,
|
ContributionKind,
|
||||||
@ -19,6 +20,7 @@ from nanobot.extensions.manifest import (
|
|||||||
ExtensionPermission,
|
ExtensionPermission,
|
||||||
ExtensionRuntime,
|
ExtensionRuntime,
|
||||||
)
|
)
|
||||||
|
from nanobot.extensions.market import ExtensionMarketplace, MarketplacePackage
|
||||||
from nanobot.extensions.native import discover_native_extensions
|
from nanobot.extensions.native import discover_native_extensions
|
||||||
from nanobot.extensions.node_host import NodeSidecar
|
from nanobot.extensions.node_host import NodeSidecar
|
||||||
from nanobot.extensions.package_adapter import AdaptedPackage, adapt_package
|
from nanobot.extensions.package_adapter import AdaptedPackage, adapt_package
|
||||||
@ -43,6 +45,7 @@ from nanobot.extensions.runtime import (
|
|||||||
ExtensionRuntimeManager,
|
ExtensionRuntimeManager,
|
||||||
PythonExtensionApi,
|
PythonExtensionApi,
|
||||||
)
|
)
|
||||||
|
from nanobot.extensions.service import ExtensionService
|
||||||
from nanobot.extensions.store import (
|
from nanobot.extensions.store import (
|
||||||
ExtensionSourceKind,
|
ExtensionSourceKind,
|
||||||
ExtensionStore,
|
ExtensionStore,
|
||||||
@ -56,6 +59,9 @@ __all__ = [
|
|||||||
"DependencyKind",
|
"DependencyKind",
|
||||||
"ExtensionCandidate",
|
"ExtensionCandidate",
|
||||||
"ExtensionCatalog",
|
"ExtensionCatalog",
|
||||||
|
"ExtensionHost",
|
||||||
|
"ExtensionHostSnapshot",
|
||||||
|
"ExtensionMarketplace",
|
||||||
"ExtensionContribution",
|
"ExtensionContribution",
|
||||||
"ExtensionDependency",
|
"ExtensionDependency",
|
||||||
"ExtensionDiagnostic",
|
"ExtensionDiagnostic",
|
||||||
@ -69,6 +75,7 @@ __all__ = [
|
|||||||
"ExtensionSnapshot",
|
"ExtensionSnapshot",
|
||||||
"ExtensionSourceKind",
|
"ExtensionSourceKind",
|
||||||
"ExtensionStore",
|
"ExtensionStore",
|
||||||
|
"ExtensionService",
|
||||||
"MANIFEST_FILENAME",
|
"MANIFEST_FILENAME",
|
||||||
"ManifestFormatError",
|
"ManifestFormatError",
|
||||||
"NODE_PROTOCOL_VERSION",
|
"NODE_PROTOCOL_VERSION",
|
||||||
@ -82,6 +89,7 @@ __all__ = [
|
|||||||
"PythonExtensionApi",
|
"PythonExtensionApi",
|
||||||
"InstalledExtension",
|
"InstalledExtension",
|
||||||
"InstallResult",
|
"InstallResult",
|
||||||
|
"MarketplacePackage",
|
||||||
"ResolvedContribution",
|
"ResolvedContribution",
|
||||||
"build_extension_catalog",
|
"build_extension_catalog",
|
||||||
"adapt_package",
|
"adapt_package",
|
||||||
|
|||||||
92
nanobot/extensions/host.py
Normal file
92
nanobot/extensions/host.py
Normal file
@ -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
|
||||||
101
nanobot/extensions/market.py
Normal file
101
nanobot/extensions/market.py
Normal file
@ -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 ""),
|
||||||
|
)
|
||||||
@ -82,12 +82,13 @@ class ExtensionRuntimeManager:
|
|||||||
tools: ToolRegistry,
|
tools: ToolRegistry,
|
||||||
commands: CommandRouter,
|
commands: CommandRouter,
|
||||||
config: Config,
|
config: Config,
|
||||||
|
hook_factories: list[AgentTurnHookFactory] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._tools = tools
|
self._tools = tools
|
||||||
self._commands = commands
|
self._commands = commands
|
||||||
self._config = config
|
self._config = config
|
||||||
self._active: list[ActivatedExtension] = []
|
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:
|
async def activate(self, snapshot: ExtensionSnapshot) -> ActivationResult:
|
||||||
diagnostics: list[ExtensionDiagnostic] = []
|
diagnostics: list[ExtensionDiagnostic] = []
|
||||||
|
|||||||
206
nanobot/extensions/service.py
Normal file
206
nanobot/extensions/service.py
Normal file
@ -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,
|
||||||
|
}
|
||||||
@ -52,6 +52,11 @@ class InstalledExtension:
|
|||||||
def from_mapping(cls, value: object) -> InstalledExtension:
|
def from_mapping(cls, value: object) -> InstalledExtension:
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise ValueError("extension registry record must be an object")
|
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(
|
return cls(
|
||||||
id=str(value["id"]),
|
id=str(value["id"]),
|
||||||
version=str(value["version"]),
|
version=str(value["version"]),
|
||||||
@ -61,7 +66,7 @@ class InstalledExtension:
|
|||||||
installed_at=str(value["installed_at"]),
|
installed_at=str(value["installed_at"]),
|
||||||
enabled=bool(value.get("enabled", True)),
|
enabled=bool(value.get("enabled", True)),
|
||||||
trusted=bool(value.get("trusted", False)),
|
trusted=bool(value.get("trusted", False)),
|
||||||
granted_permissions=tuple(value.get("granted_permissions", ())),
|
granted_permissions=tuple(permissions),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from nanobot.agent.hook import AgentHook, SDKCaptureHook
|
|||||||
from nanobot.agent.hooks import create_file_edit_activity_hook
|
from nanobot.agent.hooks import create_file_edit_activity_hook
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.extensions import ExtensionHost
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient
|
from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient
|
||||||
from nanobot.sdk.runtime import (
|
from nanobot.sdk.runtime import (
|
||||||
@ -77,6 +78,9 @@ class Nanobot:
|
|||||||
self.sessions = SessionClient(loop)
|
self.sessions = SessionClient(loop)
|
||||||
self.memory = MemoryClient(loop)
|
self.memory = MemoryClient(loop)
|
||||||
self.runtime = RuntimeClient(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
|
@classmethod
|
||||||
def from_config(
|
def from_config(
|
||||||
@ -153,6 +157,7 @@ class Nanobot:
|
|||||||
model: Override the model for this run only.
|
model: Override the model for this run only.
|
||||||
model_preset: Override the model preset for this run only.
|
model_preset: Override the model preset for this run only.
|
||||||
"""
|
"""
|
||||||
|
await self._ensure_extensions()
|
||||||
capture = SDKCaptureHook()
|
capture = SDKCaptureHook()
|
||||||
per_run_hooks = [capture, *(hooks or [])]
|
per_run_hooks = [capture, *(hooks or [])]
|
||||||
runtime = self._loop.runtime_resolver.resolve_override(
|
runtime = self._loop.runtime_resolver.resolve_override(
|
||||||
@ -193,6 +198,7 @@ class Nanobot:
|
|||||||
model_preset: str | None = None,
|
model_preset: str | None = None,
|
||||||
) -> RunStream:
|
) -> RunStream:
|
||||||
"""Start a streamed run and return a handle for events and final result."""
|
"""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(
|
override_runtime = self._loop.runtime_resolver.resolve_override(
|
||||||
model=model,
|
model=model,
|
||||||
model_preset=model_preset,
|
model_preset=model_preset,
|
||||||
@ -316,7 +322,20 @@ class Nanobot:
|
|||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
"""Release resources held by this instance (MCP connections, etc.)."""
|
"""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:
|
async def __aenter__(self) -> Nanobot:
|
||||||
return self
|
return self
|
||||||
|
|||||||
177
nanobot/webui/extensions_routes.py
Normal file
177
nanobot/webui/extensions_routes.py
Normal file
@ -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()
|
||||||
@ -51,6 +51,8 @@ def build_gateway_services(
|
|||||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, 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,
|
logger: Any = default_logger,
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
tokens = GatewayTokenStore()
|
tokens = GatewayTokenStore()
|
||||||
@ -94,6 +96,8 @@ def build_gateway_services(
|
|||||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
|
extension_service=extension_service,
|
||||||
|
allow_remote_package_install=allow_remote_package_install,
|
||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
return GatewayServices(
|
return GatewayServices(
|
||||||
|
|||||||
@ -170,6 +170,8 @@ class GatewayHTTPHandler:
|
|||||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, 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,
|
log: Any = logger,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
@ -190,6 +192,7 @@ class GatewayHTTPHandler:
|
|||||||
self._log = log
|
self._log = log
|
||||||
self._runtime_surface = runtime_surface
|
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_api import runtime_capabilities as _rc
|
||||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
|
|
||||||
@ -206,6 +209,15 @@ class GatewayHTTPHandler:
|
|||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
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:
|
def workspace_controls_available(self, connection: Any) -> bool:
|
||||||
return self._runtime_surface == "native" or _is_localhost(connection)
|
return self._runtime_surface == "native" or _is_localhost(connection)
|
||||||
@ -247,6 +259,9 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
# Settings routes (delegated)
|
# Settings routes (delegated)
|
||||||
response = await self.settings_routes.dispatch(connection, request, got)
|
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:
|
if response is not None:
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
49
tests/extensions/test_host.py
Normal file
49
tests/extensions/test_host.py
Normal file
@ -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
|
||||||
59
tests/extensions/test_market.py
Normal file
59
tests/extensions/test_market.py
Normal file
@ -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") == ()
|
||||||
58
tests/extensions/test_service.py
Normal file
58
tests/extensions/test_service.py
Normal file
@ -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"] == []
|
||||||
@ -2,6 +2,8 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from nanobot.extensions import (
|
from nanobot.extensions import (
|
||||||
DependencyKind,
|
DependencyKind,
|
||||||
ExtensionDependency,
|
ExtensionDependency,
|
||||||
@ -9,6 +11,7 @@ from nanobot.extensions import (
|
|||||||
ExtensionRuntime,
|
ExtensionRuntime,
|
||||||
ExtensionSourceKind,
|
ExtensionSourceKind,
|
||||||
ExtensionStore,
|
ExtensionStore,
|
||||||
|
InstalledExtension,
|
||||||
dump_manifest,
|
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:
|
def test_store_updates_and_uninstalls_atomically(tmp_path: Path) -> None:
|
||||||
source = _pi_package(tmp_path / "source")
|
source = _pi_package(tmp_path / "source")
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
store = ExtensionStore(tmp_path / "extensions")
|
||||||
|
|||||||
162
tests/webui/test_extensions_routes.py
Normal file
162
tests/webui/test_extensions_routes.py
Normal file
@ -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)),
|
||||||
|
]
|
||||||
Loading…
x
Reference in New Issue
Block a user