From 336b2876d4fdd43a27eeca4635a3d900b431f206 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:16:08 +0800 Subject: [PATCH] feat(extensions): add unified manifest and registry --- docs/extension-system.md | 94 +++++++++++++++ nanobot/extensions/__init__.py | 39 ++++++ nanobot/extensions/manifest.py | 191 ++++++++++++++++++++++++++++++ nanobot/extensions/registry.py | 191 ++++++++++++++++++++++++++++++ tests/extensions/test_manifest.py | 61 ++++++++++ tests/extensions/test_registry.py | 122 +++++++++++++++++++ 6 files changed, 698 insertions(+) create mode 100644 docs/extension-system.md create mode 100644 nanobot/extensions/__init__.py create mode 100644 nanobot/extensions/manifest.py create mode 100644 nanobot/extensions/registry.py create mode 100644 tests/extensions/test_manifest.py create mode 100644 tests/extensions/test_registry.py diff --git a/docs/extension-system.md b/docs/extension-system.md new file mode 100644 index 000000000..1c224e096 --- /dev/null +++ b/docs/extension-system.md @@ -0,0 +1,94 @@ +# Extension system + +nanobot treats an extension as an installable, governable unit and a +contribution as one capability supplied by that unit. This distinction keeps +the agent core small without forcing tools, channels, providers, skills, MCP +servers, hooks, commands, and WebUI code into one artificial runtime interface. + +## Architecture + +The extension platform is a control plane over existing native registries: + +```text +package / workspace directory / compatibility package + | + v + ExtensionManifest + | + v + ExtensionRegistry + selection, policy, ownership + | + +----------------+----------------+ + | | | + v v v + native adapters Pi adapter OpenClaw adapter + | | | + +----------------+----------------+ + | + v + tools / skills / channels / providers / MCP / + hooks / commands / WebUI +``` + +`ExtensionManifest` is dependency-free metadata. Discovery can inspect it +without importing optional SDKs or executing plugin code. `ExtensionRegistry` +selects the active installation, applies allow/deny policy, and resolves +contribution ownership. Runtime adapters activate only the contributions the +host supports. + +The agent loop does not discover or execute plugins. Assembly code resolves +extensions before constructing the runtime and passes native tools, hooks, and +other contributions through the interfaces those subsystems already expose. + +## Identity and precedence + +An extension ID is stable across installations. The same ID may exist in three +scopes: + +1. `builtin` +2. `user` +3. `workspace` + +The nearest scope wins for the same extension ID. Different extensions may not +silently take over the same contribution name. Replacing another extension's +contribution must be explicit and may only come from an equal or higher scope. +Conflicts become diagnostics instead of crashing unrelated extensions. + +## Compatibility runtimes + +Pi and OpenClaw extensions are JavaScript or TypeScript programs, so Python +cannot import them as native nanobot modules. Compatibility runs them in a +Node.js sidecar and projects supported registrations into nanobot's native +registries over a versioned protocol. + +Compatibility is capability-based rather than all-or-nothing: + +- A package may load while one unsupported contribution is disabled. +- Inspection reports every supported, translated, degraded, and unsupported + contribution. +- UI- or host-specific behavior is never reported as working when nanobot + cannot provide the required host interface. +- Plugin failures are isolated from the agent process and produce actionable + diagnostics. + +## Security model + +Extensions are trusted code, not prompts or static skills. Installation and +activation are separate actions. The host records source, version, requested +permissions, dependency state, and trust scope before executing code. + +Project-local extensions require workspace trust. Contribution conflicts never +grant an implicit override. Secrets remain in nanobot provider or host config +and are exposed only through declared host interfaces. Existing workspace, +network, SSRF, and shell restrictions continue to apply to host-provided +operations. + +## Market boundary + +The market is an index, not a runtime. It describes packages available from +PyPI, npm, Git, ClawHub, Pi catalogs, or local sources using the same manifest +shape. Installing a listing still goes through the local installer, policy, +dependency checks, and trust flow. This keeps discovery independent from code +execution and allows multiple catalogs without coupling the agent to one +store. diff --git a/nanobot/extensions/__init__.py b/nanobot/extensions/__init__.py new file mode 100644 index 000000000..434c601ac --- /dev/null +++ b/nanobot/extensions/__init__.py @@ -0,0 +1,39 @@ +"""First-class extension metadata and discovery primitives.""" + +from nanobot.extensions.manifest import ( + EXTENSION_API_VERSION, + ContributionKind, + DependencyKind, + ExtensionContribution, + ExtensionDependency, + ExtensionManifest, + ExtensionPermission, + ExtensionRuntime, +) +from nanobot.extensions.registry import ( + ExtensionCandidate, + ExtensionDiagnostic, + ExtensionPolicy, + ExtensionRegistry, + ExtensionScope, + ExtensionSnapshot, + ResolvedContribution, +) + +__all__ = [ + "EXTENSION_API_VERSION", + "ContributionKind", + "DependencyKind", + "ExtensionCandidate", + "ExtensionContribution", + "ExtensionDependency", + "ExtensionDiagnostic", + "ExtensionManifest", + "ExtensionPermission", + "ExtensionPolicy", + "ExtensionRegistry", + "ExtensionRuntime", + "ExtensionScope", + "ExtensionSnapshot", + "ResolvedContribution", +] diff --git a/nanobot/extensions/manifest.py b/nanobot/extensions/manifest.py new file mode 100644 index 000000000..27a8ea69a --- /dev/null +++ b/nanobot/extensions/manifest.py @@ -0,0 +1,191 @@ +"""Dependency-free metadata shared by every nanobot extension format.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import Enum + +EXTENSION_API_VERSION = 1 + +_IDENTIFIER = re.compile(r"[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?") +_PERMISSION = re.compile(r"[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*") + + +class ExtensionRuntime(str, Enum): + """Runtime used to activate an extension package.""" + + PYTHON = "python" + PI = "pi" + OPENCLAW = "openclaw" + DECLARATIVE = "declarative" + + +class ContributionKind(str, Enum): + """Native capability slots an extension may contribute to.""" + + TOOL = "tool" + SKILL = "skill" + CHANNEL = "channel" + LLM_PROVIDER = "llm_provider" + TRANSCRIPTION_PROVIDER = "transcription_provider" + IMAGE_GENERATION_PROVIDER = "image_generation_provider" + WEB_SEARCH_PROVIDER = "web_search_provider" + MCP_SERVER = "mcp_server" + HOOK = "hook" + COMMAND = "command" + WEBUI = "webui" + + +class DependencyKind(str, Enum): + """Kinds of prerequisites resolved before activation.""" + + PYTHON = "python" + NPM = "npm" + EXECUTABLE = "executable" + ENVIRONMENT = "environment" + EXTENSION = "extension" + + +@dataclass(frozen=True, slots=True) +class ExtensionDependency: + """One activation prerequisite declared by an extension.""" + + kind: DependencyKind + name: str + specifier: str = "" + optional: bool = False + + def __post_init__(self) -> None: + if not isinstance(self.kind, DependencyKind): + raise TypeError("extension dependency kind must be a DependencyKind") + _require_text(self.name, "extension dependency name") + if self.kind is DependencyKind.EXTENSION: + _require_identifier(self.name, "extension dependency name") + if not isinstance(self.specifier, str): + raise TypeError("extension dependency specifier must be a string") + + +@dataclass(frozen=True, slots=True) +class ExtensionPermission: + """A privileged host capability requested by an extension.""" + + name: str + reason: str = "" + + def __post_init__(self) -> None: + if not isinstance(self.name, str) or _PERMISSION.fullmatch(self.name) is None: + raise ValueError( + "extension permission must be a lowercase namespaced identifier" + ) + if not isinstance(self.reason, str): + raise TypeError("extension permission reason must be a string") + + +@dataclass(frozen=True, slots=True) +class ExtensionContribution: + """A contribution projected into one existing nanobot registry.""" + + kind: ContributionKind + name: str + target: str = "" + description: str = "" + replaces: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.kind, ContributionKind): + raise TypeError("extension contribution kind must be a ContributionKind") + _require_identifier(self.name, "extension contribution name") + if not isinstance(self.target, str): + raise TypeError("extension contribution target must be a string") + if not isinstance(self.description, str): + raise TypeError("extension contribution description must be a string") + if not isinstance(self.replaces, tuple): + raise TypeError("extension contribution replaces must be a tuple") + for extension_id in self.replaces: + _require_identifier(extension_id, "replaced extension id") + if len(set(self.replaces)) != len(self.replaces): + raise ValueError("extension contribution replaces contains duplicates") + + +@dataclass(frozen=True, slots=True) +class ExtensionManifest: + """Portable identity and capability declaration for one extension.""" + + id: str + name: str + version: str + runtime: ExtensionRuntime + contributions: tuple[ExtensionContribution, ...] = () + description: str = "" + dependencies: tuple[ExtensionDependency, ...] = () + permissions: tuple[ExtensionPermission, ...] = () + api_version: int = EXTENSION_API_VERSION + homepage: str = "" + license: str = "" + + def __post_init__(self) -> None: + _require_identifier(self.id, "extension id") + _require_text(self.name, "extension name") + _require_text(self.version, "extension version") + if not isinstance(self.runtime, ExtensionRuntime): + raise TypeError("extension runtime must be an ExtensionRuntime") + if self.api_version != EXTENSION_API_VERSION: + raise ValueError( + f"unsupported extension API version {self.api_version}; " + f"expected {EXTENSION_API_VERSION}" + ) + _require_tuple_of( + self.contributions, + ExtensionContribution, + "extension contributions", + ) + _require_tuple_of( + self.dependencies, + ExtensionDependency, + "extension dependencies", + ) + _require_tuple_of( + self.permissions, + ExtensionPermission, + "extension permissions", + ) + for value, label in ( + (self.description, "extension description"), + (self.homepage, "extension homepage"), + (self.license, "extension license"), + ): + if not isinstance(value, str): + raise TypeError(f"{label} must be a string") + + contribution_keys = [ + (contribution.kind, contribution.name) + for contribution in self.contributions + ] + if len(set(contribution_keys)) != len(contribution_keys): + raise ValueError("extension manifest contains duplicate contributions") + permission_names = [permission.name for permission in self.permissions] + if len(set(permission_names)) != len(permission_names): + raise ValueError("extension manifest contains duplicate permissions") + + +def _require_text(value: object, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{label} must be a non-empty string") + return value + + +def _require_identifier(value: object, label: str) -> str: + text = _require_text(value, label) + if _IDENTIFIER.fullmatch(text) is None: + raise ValueError( + f"{label} must use lowercase letters, digits, dots, underscores, or hyphens" + ) + return text + + +def _require_tuple_of(value: object, item_type: type, label: str) -> None: + if not isinstance(value, tuple) or not all( + isinstance(item, item_type) for item in value + ): + raise TypeError(f"{label} must be a tuple of {item_type.__name__}") diff --git a/nanobot/extensions/registry.py b/nanobot/extensions/registry.py new file mode 100644 index 000000000..45217fce9 --- /dev/null +++ b/nanobot/extensions/registry.py @@ -0,0 +1,191 @@ +"""Deterministic extension selection and contribution ownership.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum +from pathlib import Path + +from nanobot.extensions.manifest import ( + ContributionKind, + ExtensionContribution, + ExtensionManifest, +) + + +class ExtensionScope(IntEnum): + """Installation scope. Higher scopes shadow lower copies of the same ID.""" + + BUILTIN = 10 + USER = 20 + WORKSPACE = 30 + + +@dataclass(frozen=True, slots=True) +class ExtensionCandidate: + """One discovered installation of an extension manifest.""" + + manifest: ExtensionManifest + scope: ExtensionScope + location: Path | None = None + enabled: bool = True + trusted: bool = False + + def __post_init__(self) -> None: + if not isinstance(self.manifest, ExtensionManifest): + raise TypeError("extension candidate manifest must be an ExtensionManifest") + if not isinstance(self.scope, ExtensionScope): + raise TypeError("extension candidate scope must be an ExtensionScope") + if self.location is not None and not isinstance(self.location, Path): + raise TypeError("extension candidate location must be a Path or None") + + +@dataclass(frozen=True, slots=True) +class ExtensionPolicy: + """Host allow/deny policy applied after discovery.""" + + allow: frozenset[str] = frozenset() + deny: frozenset[str] = frozenset() + + def __post_init__(self) -> None: + if not isinstance(self.allow, frozenset) or not isinstance( + self.deny, frozenset + ): + raise TypeError("extension policy allow and deny values must be frozensets") + overlap = self.allow & self.deny + if overlap: + raise ValueError( + f"extension policy contains IDs in both allow and deny: {sorted(overlap)}" + ) + + def permits(self, candidate: ExtensionCandidate) -> bool: + extension_id = candidate.manifest.id + return ( + candidate.enabled + and extension_id not in self.deny + and (not self.allow or extension_id in self.allow) + ) + + +@dataclass(frozen=True, slots=True) +class ResolvedContribution: + """An active contribution and the extension that owns it.""" + + contribution: ExtensionContribution + owner: ExtensionCandidate + + +@dataclass(frozen=True, slots=True) +class ExtensionDiagnostic: + """A non-fatal discovery or ownership problem.""" + + code: str + extension_id: str + message: str + + +@dataclass(frozen=True, slots=True) +class ExtensionSnapshot: + """Immutable result consumed by runtime adapters and management surfaces.""" + + extensions: tuple[ExtensionCandidate, ...] + contributions: tuple[ResolvedContribution, ...] + diagnostics: tuple[ExtensionDiagnostic, ...] + + def by_kind( + self, + kind: ContributionKind, + ) -> tuple[ResolvedContribution, ...]: + return tuple( + item for item in self.contributions if item.contribution.kind is kind + ) + + +class ExtensionRegistry: + """Collect candidates and resolve one safe, deterministic active snapshot.""" + + def __init__(self, policy: ExtensionPolicy | None = None) -> None: + self._policy = policy or ExtensionPolicy() + self._candidates: dict[ + tuple[str, ExtensionScope], + ExtensionCandidate, + ] = {} + + def register(self, candidate: ExtensionCandidate) -> None: + key = (candidate.manifest.id, candidate.scope) + existing = self._candidates.get(key) + if existing is not None: + raise ValueError( + f"extension '{candidate.manifest.id}' is already registered in " + f"{candidate.scope.name.lower()} scope" + ) + self._candidates[key] = candidate + + def snapshot(self) -> ExtensionSnapshot: + active = self._select_active_extensions() + resolved, diagnostics = self._resolve_contributions(active) + return ExtensionSnapshot( + extensions=tuple(sorted(active.values(), key=lambda item: item.manifest.id)), + contributions=tuple( + sorted( + resolved.values(), + key=lambda item: ( + item.contribution.kind.value, + item.contribution.name, + ), + ) + ), + diagnostics=tuple(diagnostics), + ) + + def _select_active_extensions(self) -> dict[str, ExtensionCandidate]: + active: dict[str, ExtensionCandidate] = {} + for candidate in sorted( + self._candidates.values(), + key=lambda item: (item.scope, item.manifest.id), + ): + if self._policy.permits(candidate): + active[candidate.manifest.id] = candidate + return active + + def _resolve_contributions( + self, + active: dict[str, ExtensionCandidate], + ) -> tuple[ + dict[tuple[ContributionKind, str], ResolvedContribution], + list[ExtensionDiagnostic], + ]: + resolved: dict[ + tuple[ContributionKind, str], + ResolvedContribution, + ] = {} + diagnostics: list[ExtensionDiagnostic] = [] + for candidate in sorted( + active.values(), + key=lambda item: (item.scope, item.manifest.id), + ): + for contribution in candidate.manifest.contributions: + key = (contribution.kind, contribution.name) + existing = resolved.get(key) + if existing is None: + resolved[key] = ResolvedContribution(contribution, candidate) + continue + existing_id = existing.owner.manifest.id + if ( + existing_id in contribution.replaces + and candidate.scope >= existing.owner.scope + ): + resolved[key] = ResolvedContribution(contribution, candidate) + continue + diagnostics.append( + ExtensionDiagnostic( + code="contribution_conflict", + extension_id=candidate.manifest.id, + message=( + f"{contribution.kind.value} '{contribution.name}' is already " + f"owned by extension '{existing_id}'; declare an explicit " + "replacement from an equal or higher scope to override it" + ), + ) + ) + return resolved, diagnostics diff --git a/tests/extensions/test_manifest.py b/tests/extensions/test_manifest.py new file mode 100644 index 000000000..0089cb725 --- /dev/null +++ b/tests/extensions/test_manifest.py @@ -0,0 +1,61 @@ +import pytest + +from nanobot.extensions import ( + ContributionKind, + ExtensionContribution, + ExtensionManifest, + ExtensionPermission, + ExtensionRuntime, +) + + +def test_manifest_accepts_portable_contributions() -> None: + manifest = ExtensionManifest( + id="acme.research", + name="Acme Research", + version="1.2.0", + runtime=ExtensionRuntime.PYTHON, + contributions=( + ExtensionContribution( + kind=ContributionKind.TOOL, + name="research", + target="acme_nanobot:ResearchTool", + ), + ), + permissions=( + ExtensionPermission( + name="network", + reason="Fetch sources selected by the user.", + ), + ), + ) + + assert manifest.api_version == 1 + assert manifest.contributions[0].name == "research" + + +@pytest.mark.parametrize("extension_id", ["Uppercase", "../escape", "two words", ""]) +def test_manifest_rejects_invalid_ids(extension_id: str) -> None: + with pytest.raises(ValueError): + ExtensionManifest( + id=extension_id, + name="Invalid", + version="1.0.0", + runtime=ExtensionRuntime.PYTHON, + ) + + +def test_manifest_rejects_duplicate_contributions() -> None: + contribution = ExtensionContribution( + kind=ContributionKind.SKILL, + name="review", + ) + + with pytest.raises(ValueError, match="duplicate contributions"): + ExtensionManifest( + id="duplicate", + name="Duplicate", + version="1.0.0", + runtime=ExtensionRuntime.DECLARATIVE, + contributions=(contribution, contribution), + ) diff --git a/tests/extensions/test_registry.py b/tests/extensions/test_registry.py new file mode 100644 index 000000000..3d080e2c1 --- /dev/null +++ b/tests/extensions/test_registry.py @@ -0,0 +1,122 @@ +from nanobot.extensions import ( + ContributionKind, + ExtensionCandidate, + ExtensionContribution, + ExtensionManifest, + ExtensionPolicy, + ExtensionRegistry, + ExtensionRuntime, + ExtensionScope, +) + + +def _candidate( + extension_id: str, + *, + scope: ExtensionScope, + contribution_name: str = "", + replaces: tuple[str, ...] = (), +) -> ExtensionCandidate: + contributions = ( + ExtensionContribution( + kind=ContributionKind.TOOL, + name=contribution_name, + replaces=replaces, + ), + ) if contribution_name else () + return ExtensionCandidate( + manifest=ExtensionManifest( + id=extension_id, + name=extension_id, + version="1.0.0", + runtime=ExtensionRuntime.PYTHON, + contributions=contributions, + ), + scope=scope, + ) + + +def test_workspace_copy_shadows_user_and_builtin_copy_of_same_extension() -> None: + registry = ExtensionRegistry() + registry.register(_candidate("acme", scope=ExtensionScope.BUILTIN)) + registry.register(_candidate("acme", scope=ExtensionScope.USER)) + registry.register(_candidate("acme", scope=ExtensionScope.WORKSPACE)) + + snapshot = registry.snapshot() + + assert len(snapshot.extensions) == 1 + assert snapshot.extensions[0].scope is ExtensionScope.WORKSPACE + + +def test_policy_filters_extensions_before_contribution_resolution() -> None: + registry = ExtensionRegistry( + ExtensionPolicy(allow=frozenset({"allowed"}), deny=frozenset()) + ) + registry.register( + _candidate( + "allowed", + scope=ExtensionScope.USER, + contribution_name="allowed_tool", + ) + ) + registry.register( + _candidate( + "hidden", + scope=ExtensionScope.USER, + contribution_name="hidden_tool", + ) + ) + + snapshot = registry.snapshot() + + assert [extension.manifest.id for extension in snapshot.extensions] == ["allowed"] + assert [ + contribution.contribution.name for contribution in snapshot.contributions + ] == ["allowed_tool"] + + +def test_conflicting_contribution_does_not_silently_replace_owner() -> None: + registry = ExtensionRegistry() + registry.register( + _candidate( + "core", + scope=ExtensionScope.BUILTIN, + contribution_name="shell", + ) + ) + registry.register( + _candidate( + "third-party", + scope=ExtensionScope.WORKSPACE, + contribution_name="shell", + ) + ) + + snapshot = registry.snapshot() + + assert snapshot.contributions[0].owner.manifest.id == "core" + assert snapshot.diagnostics[0].code == "contribution_conflict" + + +def test_explicit_higher_scope_replacement_takes_ownership() -> None: + registry = ExtensionRegistry() + registry.register( + _candidate( + "core", + scope=ExtensionScope.BUILTIN, + contribution_name="shell", + ) + ) + registry.register( + _candidate( + "replacement", + scope=ExtensionScope.WORKSPACE, + contribution_name="shell", + replaces=("core",), + ) + ) + + snapshot = registry.snapshot() + + assert snapshot.contributions[0].owner.manifest.id == "replacement" + assert snapshot.diagnostics == ()