mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +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