feat(extensions): add unified manifest and registry

This commit is contained in:
Xubin Ren 2026-07-26 16:16:08 +08:00
parent ee93725e83
commit 336b2876d4
6 changed files with 698 additions and 0 deletions

94
docs/extension-system.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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