release: public alpha 0.6.0 baseline
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
"""Signed startup catalog and isolated extension-host dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Protocol
|
||||
|
||||
from jwebot_sdk import API_VERSION, ContractViolation, ExtensionResult
|
||||
|
||||
from .activation import verify_installed_release_signature
|
||||
from .catalog import ExtensionCatalog
|
||||
from .host_client import ServiceHandler
|
||||
from .manifest import PluginManifest, load_manifest
|
||||
|
||||
|
||||
class HostedExtensionError(ValueError):
|
||||
"""Controlled signed-catalog, health, or invocation failure."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpectedExtension:
|
||||
plugin_id: str
|
||||
author: str
|
||||
commands: tuple[str, ...]
|
||||
service_profiles: tuple[str, ...]
|
||||
description: str = ""
|
||||
help_entries: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostedRelease:
|
||||
manifest: PluginManifest
|
||||
release_dir: Path
|
||||
|
||||
|
||||
class HostInvoker(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
request: Mapping[str, Any],
|
||||
service_handler: ServiceHandler | None,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
def _active_release_dir(plugin_root: Path) -> Path | None:
|
||||
current = plugin_root / "current"
|
||||
if not current.exists() and not current.is_symlink():
|
||||
return None
|
||||
if not current.is_symlink():
|
||||
raise HostedExtensionError("extension current pointer is not a symlink")
|
||||
target = Path(os.readlink(current))
|
||||
if (
|
||||
target.is_absolute()
|
||||
or len(target.parts) != 2
|
||||
or target.parts[0] != "releases"
|
||||
):
|
||||
raise HostedExtensionError("extension current pointer is unsafe")
|
||||
release_dir = plugin_root / target
|
||||
if not release_dir.is_dir() or release_dir.is_symlink():
|
||||
raise HostedExtensionError("extension current release is unavailable")
|
||||
return release_dir
|
||||
|
||||
|
||||
class SignedExtensionInventory:
|
||||
"""Load reviewed signed manifests during startup, then freeze routing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
catalog: ExtensionCatalog,
|
||||
releases: Mapping[str, HostedRelease],
|
||||
) -> None:
|
||||
if not catalog.frozen:
|
||||
raise HostedExtensionError("signed extension catalog must be frozen")
|
||||
self.catalog = catalog
|
||||
self._releases = MappingProxyType(dict(releases))
|
||||
|
||||
@property
|
||||
def releases(self) -> Mapping[str, HostedRelease]:
|
||||
return self._releases
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
state_root: Path,
|
||||
allowed_signers: Path,
|
||||
expected: Sequence[ExpectedExtension],
|
||||
*,
|
||||
expected_publisher: str | None = None,
|
||||
require_all: bool = False,
|
||||
) -> "SignedExtensionInventory":
|
||||
catalog = ExtensionCatalog()
|
||||
releases: dict[str, HostedRelease] = {}
|
||||
missing: list[str] = []
|
||||
for descriptor in sorted(expected, key=lambda item: item.plugin_id):
|
||||
plugin_root = state_root / descriptor.plugin_id
|
||||
release_dir = _active_release_dir(plugin_root)
|
||||
if release_dir is None:
|
||||
missing.append(descriptor.plugin_id)
|
||||
continue
|
||||
try:
|
||||
record, _ = verify_installed_release_signature(
|
||||
release_dir,
|
||||
allowed_signers,
|
||||
expected_publisher=expected_publisher,
|
||||
)
|
||||
manifest = load_manifest(release_dir / "plugin.toml")
|
||||
except Exception as error:
|
||||
raise HostedExtensionError(
|
||||
f"signed extension verification failed: {descriptor.plugin_id}"
|
||||
) from error
|
||||
if (
|
||||
record.plugin_id != descriptor.plugin_id
|
||||
or record.author != descriptor.author
|
||||
or manifest.plugin_id != descriptor.plugin_id
|
||||
or manifest.author != descriptor.author
|
||||
or (
|
||||
descriptor.description
|
||||
and manifest.description != descriptor.description
|
||||
)
|
||||
or (
|
||||
descriptor.help_entries
|
||||
and manifest.help_entries != descriptor.help_entries
|
||||
)
|
||||
or manifest.capabilities.commands != descriptor.commands
|
||||
or manifest.capabilities.service_profiles
|
||||
!= descriptor.service_profiles
|
||||
):
|
||||
raise HostedExtensionError(
|
||||
f"signed extension inventory mismatch: {descriptor.plugin_id}"
|
||||
)
|
||||
catalog.register(manifest)
|
||||
releases[descriptor.plugin_id] = HostedRelease(
|
||||
manifest=manifest,
|
||||
release_dir=release_dir,
|
||||
)
|
||||
if require_all and missing:
|
||||
raise HostedExtensionError(
|
||||
"required signed extensions are missing: "
|
||||
+ ", ".join(sorted(missing))
|
||||
)
|
||||
catalog.freeze()
|
||||
return cls(catalog, releases)
|
||||
|
||||
|
||||
class HostedExtensionRuntime:
|
||||
"""Health-gated dispatch over signed, frozen Extension Host releases."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inventory: SignedExtensionInventory,
|
||||
invokers: Mapping[str, HostInvoker],
|
||||
service_handler: ServiceHandler,
|
||||
*,
|
||||
require_all_healthy: bool = False,
|
||||
) -> None:
|
||||
self.inventory = inventory
|
||||
self.service_handler = service_handler
|
||||
active: dict[str, HostInvoker] = {}
|
||||
failures: dict[str, str] = {}
|
||||
for plugin_id, release in inventory.releases.items():
|
||||
invoker = invokers.get(plugin_id)
|
||||
if invoker is None:
|
||||
failures[plugin_id] = "HostInvokerMissing"
|
||||
continue
|
||||
try:
|
||||
response = invoker({"op": "health"}, None)
|
||||
self._validate_health(plugin_id, release.manifest, response)
|
||||
except Exception as error:
|
||||
failures[plugin_id] = type(error).__name__
|
||||
continue
|
||||
active[plugin_id] = invoker
|
||||
if require_all_healthy and len(active) != len(inventory.releases):
|
||||
unavailable = sorted(set(inventory.releases) - set(active))
|
||||
raise HostedExtensionError(
|
||||
"required extension hosts are unhealthy: "
|
||||
+ ", ".join(unavailable)
|
||||
)
|
||||
self._active = MappingProxyType(active)
|
||||
self._failures = MappingProxyType(failures)
|
||||
|
||||
@staticmethod
|
||||
def _validate_health(
|
||||
plugin_id: str,
|
||||
manifest: PluginManifest,
|
||||
response: Mapping[str, Any],
|
||||
) -> None:
|
||||
if (
|
||||
response.get("status") != "ok"
|
||||
or response.get("api_version") != API_VERSION
|
||||
or response.get("plugin_id") != plugin_id
|
||||
or response.get("author") != manifest.author
|
||||
or response.get("description") != manifest.description
|
||||
or tuple(response.get("help", ())) != manifest.help_entries
|
||||
or tuple(response.get("commands", ()))
|
||||
!= manifest.capabilities.commands
|
||||
or tuple(response.get("service_profiles", ()))
|
||||
!= manifest.capabilities.service_profiles
|
||||
):
|
||||
raise HostedExtensionError("extension host health response mismatch")
|
||||
|
||||
@property
|
||||
def active_plugin_ids(self) -> frozenset[str]:
|
||||
return frozenset(self._active)
|
||||
|
||||
@property
|
||||
def failures(self) -> Mapping[str, str]:
|
||||
return self._failures
|
||||
|
||||
def available(self, plugin_id: str) -> bool:
|
||||
return plugin_id in self._active
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
plugin_id: str,
|
||||
command: str,
|
||||
arguments: str,
|
||||
event: Mapping[str, Any],
|
||||
) -> ExtensionResult:
|
||||
invoker = self._active.get(plugin_id)
|
||||
if invoker is None:
|
||||
raise HostedExtensionError("extension host is not active")
|
||||
release = self.inventory.releases.get(plugin_id)
|
||||
if release is None:
|
||||
raise HostedExtensionError("extension release is not registered")
|
||||
if command.casefold() not in {
|
||||
value.casefold()
|
||||
for value in release.manifest.capabilities.commands
|
||||
}:
|
||||
raise HostedExtensionError("extension command is not declared")
|
||||
try:
|
||||
response = invoker(
|
||||
{
|
||||
"op": "invoke_command",
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": dict(event),
|
||||
},
|
||||
self.service_handler,
|
||||
)
|
||||
except Exception as error:
|
||||
raise HostedExtensionError("extension host invocation failed") from error
|
||||
if (
|
||||
response.get("status") != "ok"
|
||||
or response.get("api_version") != API_VERSION
|
||||
or response.get("plugin_id") != plugin_id
|
||||
or not isinstance(response.get("result"), Mapping)
|
||||
):
|
||||
raise HostedExtensionError("extension host response mismatch")
|
||||
try:
|
||||
return ExtensionResult.from_mapping(response["result"])
|
||||
except ContractViolation as error:
|
||||
raise HostedExtensionError(
|
||||
"extension host result violates the SDK contract"
|
||||
) from error
|
||||
Reference in New Issue
Block a user