mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 01:18:45 +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_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
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
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,
|
||||
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] = []
|
||||
|
||||
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:
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
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,
|
||||
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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
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 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")
|
||||
|
||||
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