mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 09:28:34 +00:00
feat(extensions): add unified manifest and registry
This commit is contained in:
parent
ee93725e83
commit
336b2876d4
94
docs/extension-system.md
Normal file
94
docs/extension-system.md
Normal 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.
|
||||||
39
nanobot/extensions/__init__.py
Normal file
39
nanobot/extensions/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
191
nanobot/extensions/manifest.py
Normal file
191
nanobot/extensions/manifest.py
Normal 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__}")
|
||||||
191
nanobot/extensions/registry.py
Normal file
191
nanobot/extensions/registry.py
Normal 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
|
||||||
61
tests/extensions/test_manifest.py
Normal file
61
tests/extensions/test_manifest.py
Normal 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),
|
||||||
|
)
|
||||||
122
tests/extensions/test_registry.py
Normal file
122
tests/extensions/test_registry.py
Normal 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 == ()
|
||||||
Loading…
x
Reference in New Issue
Block a user