mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 09:28:34 +00:00
230 lines
8.0 KiB
Python
230 lines
8.0 KiB
Python
"""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
|
|
granted_permissions: frozenset[str] = frozenset()
|
|
|
|
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")
|
|
if not isinstance(self.granted_permissions, frozenset):
|
|
raise TypeError("extension granted permissions must be a frozenset")
|
|
|
|
|
|
@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
|
|
requested = {
|
|
permission.name for permission in candidate.manifest.permissions
|
|
}
|
|
return (
|
|
candidate.enabled
|
|
and (
|
|
candidate.scope is ExtensionScope.BUILTIN
|
|
or (
|
|
candidate.trusted
|
|
and requested <= candidate.granted_permissions
|
|
)
|
|
)
|
|
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, selection_diagnostics = self._select_active_extensions()
|
|
resolved, resolution_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(selection_diagnostics + resolution_diagnostics),
|
|
)
|
|
|
|
def _select_active_extensions(
|
|
self,
|
|
) -> tuple[dict[str, ExtensionCandidate], list[ExtensionDiagnostic]]:
|
|
active: dict[str, ExtensionCandidate] = {}
|
|
diagnostics: list[ExtensionDiagnostic] = []
|
|
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
|
|
continue
|
|
if (
|
|
candidate.enabled
|
|
and candidate.trusted
|
|
and candidate.scope is not ExtensionScope.BUILTIN
|
|
):
|
|
requested = {
|
|
permission.name
|
|
for permission in candidate.manifest.permissions
|
|
}
|
|
missing = sorted(requested - candidate.granted_permissions)
|
|
if missing:
|
|
diagnostics.append(
|
|
ExtensionDiagnostic(
|
|
code="permission_required",
|
|
extension_id=candidate.manifest.id,
|
|
message=(
|
|
"Grant required extension permissions: "
|
|
+ ", ".join(missing)
|
|
),
|
|
)
|
|
)
|
|
return active, diagnostics
|
|
|
|
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
|