feat(extensions): wire runtime management and marketplace

This commit is contained in:
Xubin Ren 2026-07-26 17:32:15 +08:00
parent 6e0950833a
commit 83b54212ae
17 changed files with 1034 additions and 27 deletions

View File

@ -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

View File

@ -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())

View File

@ -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",

View 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

View 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 ""),
)

View File

@ -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] = []

View 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,
}

View File

@ -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),
)

View File

@ -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

View 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()

View File

@ -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(

View File

@ -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

View 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

View 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") == ()

View 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"] == []

View File

@ -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")

View 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)),
]