From eb8f9341242f7047afb2553d431baebbb02b2cdb Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:13:08 -0400 Subject: [PATCH] Install approved plugin wheels directly from R2 --- .env.example | 3 - docker-compose.yml | 4 - docs/plugins.md | 28 ++-- repeater/plugins/catalogue.py | 153 ++++++++++++++++++- repeater/plugins/github_releases.py | 13 +- repeater/plugins/manager.py | 178 ++++++++++++++++------ repeater/plugins/runtime.py | 3 +- repeater/web/openapi.yaml | 14 +- repeater/web/plugin_endpoints.py | 2 +- tests/test_container_workflow.py | 14 +- tests/test_plugin_catalogue.py | 68 +++++++++ tests/test_plugin_github_releases.py | 21 --- tests/test_plugin_manager_lifecycle.py | 16 +- tests/test_plugin_r2_catalogue_install.py | 141 +++++++++++++++++ 14 files changed, 534 insertions(+), 124 deletions(-) create mode 100644 tests/test_plugin_r2_catalogue_install.py diff --git a/.env.example b/.env.example index 5127aa8..02f018d 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,3 @@ SPI_GID=989 # image yourself instead of pulling OPENHOP_REPEATER_IMAGE. PUID=15888 PGID=15888 -# Optional fine-grained GitHub token for plugin catalogue release lookups. -# Public-repository read access is sufficient. Leave empty for anonymous access. -OPENHOP_PLUGIN_GITHUB_TOKEN= diff --git a/docker-compose.yml b/docker-compose.yml index d9a62bb..6e28463 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,10 +3,6 @@ services: image: ${OPENHOP_REPEATER_IMAGE:-${PYMC_REPEATER_IMAGE:-openhop/openhop-repeater:main}} container_name: openhop-repeater restart: unless-stopped - environment: - # Optional fine-grained, read-only token for catalogue release lookups. - # It raises GitHub's API limit; it is never sent to release asset hosts. - OPENHOP_PLUGIN_GITHUB_TOKEN: ${OPENHOP_PLUGIN_GITHUB_TOKEN:-} ports: - 8000:8000 devices: diff --git a/docs/plugins.md b/docs/plugins.md index 0c49316..8690475 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -240,16 +240,15 @@ curl -X DELETE -H "Authorization: Bearer $TOKEN" \ ## Future work (not in this version) -- GitHub release installation and catalogue -- Automatic updates / rollback between versions - Web Component dashboard widgets and settings panels -- Plugin management UI page - Permission scopes, signing, non-Python runtimes - Plugin SDK ## Plugin catalogue -Repeater can browse a curated static catalogue and install plugins from GitHub Releases. +Repeater browses a curated static catalogue and installs the exact plugin wheel +approved there. The catalogue and wheels are hosted on openHop's R2-backed +origin, so normal installs and update checks do not call the GitHub API. Default catalogue URL: @@ -264,20 +263,24 @@ plugins: catalogue_url: "https://repeater-plugins.openhop.dev/catalogue.json" ``` -The catalogue lists plugin **identity and repository only**. Versions and wheel assets come from GitHub Releases on each plugin repo (draft and prerelease tags are ignored by default). +Catalogue schema 2 records each plugin's currently approved `version`, exact +`wheel_url`, and lowercase `sha256`. The manager downloads only from +`https://repeater-plugins.openhop.dev/plugins/`, verifies the checksum before +installation, and confirms the wheel manifest ID and version match the +catalogue. Publishing a newer GitHub Release does not make it available until +the catalogue maintainers approve and publish that exact wheel. + +Schema 1 repository-only catalogues remain readable for compatibility with +custom deployments, but the default openHop catalogue uses schema 2 and needs +no GitHub token. -GitHub permits anonymous lookups, but the anonymous API limit is low and may be -shared by multiple containers behind the same public address. Docker operators -can set `OPENHOP_PLUGIN_GITHUB_TOKEN` in `.env` to a fine-grained token with -read-only access to public repository metadata. The token is used only for -`api.github.com` release lookups and is not sent to release asset URLs. ```bash # List catalogue (annotated with installed / latest when available) curl -H "Authorization: Bearer $TOKEN" \ http://127.0.0.1:8000/api/plugins/catalogue -# Install latest stable release of a catalogue plugin (enabled by default) +# Install the currently approved catalogue version (enabled by default) curl -X POST -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"id":"openhop.nomad"}' \ @@ -294,5 +297,6 @@ curl -X POST -H "Authorization: Bearer $TOKEN" \ http://127.0.0.1:8000/api/plugins/update ``` -Catalogue or GitHub outages do not affect already-installed plugins or Repeater startup. Local `.whl` install continues to work unchanged. +R2 catalogue or wheel outages do not affect already-installed plugins or +Repeater startup. Local `.whl` install continues to work unchanged. diff --git a/repeater/plugins/catalogue.py b/repeater/plugins/catalogue.py index 79f8b54..90fe7d7 100644 --- a/repeater/plugins/catalogue.py +++ b/repeater/plugins/catalogue.py @@ -2,25 +2,33 @@ from __future__ import annotations +import hashlib import json import logging +import os import re import ssl +import tempfile import time import urllib.error import urllib.request from dataclasses import dataclass +from pathlib import Path from typing import Any, Callable, Optional +from urllib.parse import urlsplit logger = logging.getLogger("PluginCatalogue") DEFAULT_CATALOGUE_URL = "https://repeater-plugins.openhop.dev/catalogue.json" DEFAULT_CACHE_TTL_SECONDS = 600 # 10 minutes -SUPPORTED_SCHEMA = 1 +SUPPORTED_SCHEMAS = (1, 2) USER_AGENT = "openhop-repeater-plugin-manager/1.0" +APPROVED_WHEEL_ORIGIN = "https://repeater-plugins.openhop.dev" +MAX_WHEEL_BYTES = 100 * 1024 * 1024 _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") _ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$") +_SHA256_RE = re.compile(r"^[a-f0-9]{64}$") _ssl_ctx: Optional[ssl.SSLContext] = None @@ -37,6 +45,24 @@ def _ssl_context() -> ssl.SSLContext: return _ssl_ctx +def _validate_approved_wheel_url(plugin_id: str, version: str, wheel_url: str) -> None: + parsed = urlsplit(wheel_url) + origin = f"{parsed.scheme}://{parsed.netloc}" + expected_prefix = f"/plugins/{plugin_id}/{version}/" + if ( + origin != APPROVED_WHEEL_ORIGIN + or not parsed.path.startswith(expected_prefix) + or not parsed.path.endswith(".whl") + or parsed.query + or parsed.fragment + ): + raise CatalogueError( + f"wheel_url must be an approved R2 wheel under " + f"{APPROVED_WHEEL_ORIGIN}{expected_prefix}", + code=400, + ) + + @dataclass(frozen=True) class CataloguePlugin: id: str @@ -45,6 +71,13 @@ class CataloguePlugin: repository: str category: str = "" logo: str = "" + version: Optional[str] = None + wheel_url: Optional[str] = None + sha256: Optional[str] = None + + @property + def has_approved_wheel(self) -> bool: + return bool(self.version and self.wheel_url and self.sha256) def to_dict(self) -> dict[str, Any]: out: dict[str, Any] = { @@ -57,6 +90,12 @@ class CataloguePlugin: out["category"] = self.category if self.logo: out["logo"] = self.logo + if self.version: + out["version"] = self.version + if self.wheel_url: + out["wheel_url"] = self.wheel_url + if self.sha256: + out["sha256"] = self.sha256 return out @@ -82,9 +121,9 @@ def parse_catalogue(data: Any) -> Catalogue: if not isinstance(data, dict): raise CatalogueError("catalogue must be a JSON object", code=400) schema = data.get("schema") - if schema != SUPPORTED_SCHEMA: + if schema not in SUPPORTED_SCHEMAS: raise CatalogueError( - f"unsupported catalogue schema {schema!r}; expected {SUPPORTED_SCHEMA}", + f"unsupported catalogue schema {schema!r}; expected one of {SUPPORTED_SCHEMAS}", code=400, ) raw_plugins = data.get("plugins") @@ -94,6 +133,7 @@ def parse_catalogue(data: Any) -> Catalogue: plugins: list[CataloguePlugin] = [] seen_ids: set[str] = set() seen_repos: set[str] = set() + seen_wheel_urls: set[str] = set() for idx, item in enumerate(raw_plugins): if not isinstance(item, dict): raise CatalogueError(f"plugins[{idx}] must be an object", code=400) @@ -144,6 +184,33 @@ def parse_catalogue(data: Any) -> Catalogue: code=400, ) + version = None + wheel_url = None + sha256 = None + if schema == 2: + raw_version = item.get("version") + raw_wheel_url = item.get("wheel_url") + raw_sha256 = item.get("sha256") + if not isinstance(raw_version, str) or not raw_version.strip(): + raise CatalogueError(f"plugins[{idx}].version is required", code=400) + if not isinstance(raw_wheel_url, str) or not raw_wheel_url.strip(): + raise CatalogueError(f"plugins[{idx}].wheel_url is required", code=400) + if not isinstance(raw_sha256, str) or not _SHA256_RE.fullmatch(raw_sha256): + raise CatalogueError( + f"plugins[{idx}].sha256 must be a lowercase SHA-256 digest", + code=400, + ) + version = raw_version.strip() + wheel_url = raw_wheel_url.strip() + sha256 = raw_sha256 + try: + _validate_approved_wheel_url(plugin_id, version, wheel_url) + except CatalogueError as exc: + raise CatalogueError(f"plugins[{idx}].{exc}", code=exc.code) from exc + if wheel_url in seen_wheel_urls: + raise CatalogueError(f"duplicate wheel URL: {wheel_url}", code=400) + seen_wheel_urls.add(wheel_url) + seen_ids.add(plugin_id) seen_repos.add(repository) plugins.append( @@ -154,13 +221,16 @@ def parse_catalogue(data: Any) -> Catalogue: repository=repository, category=category.strip(), logo=logo, + version=version, + wheel_url=wheel_url, + sha256=sha256, ) ) - return Catalogue(schema=SUPPORTED_SCHEMA, plugins=tuple(plugins)) + return Catalogue(schema=int(schema), plugins=tuple(plugins)) class CatalogueClient: - """Fetch and cache the curated plugin catalogue JSON.""" + """Fetch, cache, and download from the curated plugin catalogue.""" def __init__( self, @@ -178,7 +248,7 @@ class CatalogueClient: self._cached: Optional[Catalogue] = None def _default_open(self, request: urllib.request.Request, timeout: float = 30.0): - return urllib.request.urlopen( # nosec B310 - configured catalogue URL + return urllib.request.urlopen( # nosec B310 - validated HTTPS URLs request, timeout=timeout, context=_ssl_context() ) @@ -238,3 +308,74 @@ class CatalogueClient: if plugin is None: raise CatalogueError(f"plugin not in catalogue: {plugin_id}", code=404) return plugin + + def download_wheel(self, plugin: CataloguePlugin, dest_dir: Path | str) -> Path: + """Download and verify one schema-2 approved wheel from R2.""" + if not plugin.version or not plugin.wheel_url or not plugin.sha256: + raise CatalogueError( + f"catalogue entry {plugin.id} does not contain approved wheel metadata", + code=400, + ) + _validate_approved_wheel_url(plugin.id, plugin.version, plugin.wheel_url) + if not _SHA256_RE.fullmatch(plugin.sha256): + raise CatalogueError(f"invalid approved checksum for {plugin.id}", code=400) + + destination_dir = Path(dest_dir) + destination_dir.mkdir(parents=True, exist_ok=True) + destination = destination_dir / Path(urlsplit(plugin.wheel_url).path).name + fd, temporary_name = tempfile.mkstemp(prefix=".wheel-", dir=str(destination_dir)) + digest = hashlib.sha256() + total = 0 + request = urllib.request.Request( + plugin.wheel_url, + headers={ + "User-Agent": self.user_agent, + "Accept": "application/octet-stream", + }, + method="GET", + ) + try: + with os.fdopen(fd, "wb") as output: + try: + with self._opener(request, timeout=120.0) as response: + final_url = getattr(response, "geturl", lambda: plugin.wheel_url)() + _validate_approved_wheel_url( + plugin.id, + plugin.version, + final_url or plugin.wheel_url, + ) + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > MAX_WHEEL_BYTES: + raise CatalogueError( + "approved plugin wheel exceeds 100 MiB", code=502 + ) + digest.update(chunk) + output.write(chunk) + except CatalogueError: + raise + except urllib.error.HTTPError as exc: + raise CatalogueError( + f"approved plugin wheel is unavailable (HTTP {exc.code})", code=502 + ) from exc + except urllib.error.URLError as exc: + raise CatalogueError("approved plugin wheel is unavailable", code=502) from exc + except Exception as exc: + raise CatalogueError("approved plugin wheel is unavailable", code=502) from exc + + if total == 0: + raise CatalogueError("approved plugin wheel is empty", code=502) + if digest.hexdigest() != plugin.sha256: + raise CatalogueError( + f"approved plugin wheel checksum mismatch for {plugin.id}", code=502 + ) + os.replace(temporary_name, destination) + return destination + finally: + try: + Path(temporary_name).unlink(missing_ok=True) + except OSError as exc: + logger.debug("Could not remove temporary wheel %s: %s", temporary_name, exc) diff --git a/repeater/plugins/github_releases.py b/repeater/plugins/github_releases.py index 79c4293..0deef90 100644 --- a/repeater/plugins/github_releases.py +++ b/repeater/plugins/github_releases.py @@ -125,12 +125,10 @@ class GitHubReleaseClient: cache_ttl: float = DEFAULT_CACHE_TTL_SECONDS, opener: Optional[Callable[..., Any]] = None, user_agent: str = USER_AGENT, - token: str | None = None, ): self.cache_ttl = cache_ttl self._opener = opener or self._default_open self.user_agent = user_agent - self.token = str(token or "").strip() or None # repo -> (monotonic_ts, list[PluginRelease]) self._cache: dict[str, tuple[float, list[PluginRelease]]] = {} @@ -262,15 +260,12 @@ class GitHubReleaseClient: def _fetch_releases_json(self, repo: str) -> list[Any]: url = f"{GITHUB_API_BASE}/repos/{repo}/releases?per_page=30" - headers = { - "User-Agent": self.user_agent, - "Accept": "application/vnd.github+json", - } - if self.token: - headers["Authorization"] = f"Bearer {self.token}" request = urllib.request.Request( url, - headers=headers, + headers={ + "User-Agent": self.user_agent, + "Accept": "application/vnd.github+json", + }, method="GET", ) try: diff --git a/repeater/plugins/manager.py b/repeater/plugins/manager.py index bf29b0a..663cbcd 100644 --- a/repeater/plugins/manager.py +++ b/repeater/plugins/manager.py @@ -4,7 +4,6 @@ from __future__ import annotations import json import logging -import os import threading from pathlib import Path from typing import Any, Optional @@ -49,9 +48,7 @@ class PluginManager: self.runtime = runtime or PluginRuntime(storage) self._lock = threading.RLock() self.catalogue = catalogue_client or CatalogueClient(catalogue_url or DEFAULT_CATALOGUE_URL) - self.github = github_client or GitHubReleaseClient( - token=os.environ.get("OPENHOP_PLUGIN_GITHUB_TOKEN") - ) + self.github = github_client or GitHubReleaseClient() def start(self) -> None: """Load state and start enabled service plugins.""" @@ -321,17 +318,24 @@ class PluginManager: "installed": installed, "installedVersion": installed_version if installed else None, } - # Enrich with latest stable when possible (cached); failures are non-fatal per row - latest_version = None + # Schema 2 carries the openHop-approved version directly. Schema 1 + # remains readable for compatibility with custom legacy catalogues. + latest_version = entry.version update_available = False - try: - latest = self.github.latest_stable(entry.repository, force_refresh=force_refresh) - if latest is not None: - latest_version = latest.version - if installed and installed_version: - update_available = self._version_gt(latest_version, installed_version) - except GitHubReleasesError as exc: - row["releasesError"] = str(exc) + if entry.has_approved_wheel: + if installed and installed_version and latest_version: + update_available = self._version_gt(latest_version, installed_version) + else: + try: + latest = self.github.latest_stable( + entry.repository, force_refresh=force_refresh + ) + if latest is not None: + latest_version = latest.version + if installed and installed_version: + update_available = self._version_gt(latest_version, installed_version) + except GitHubReleasesError as exc: + row["releasesError"] = str(exc) row["latestVersion"] = latest_version row["updateAvailable"] = bool(update_available) plugins_out.append(row) @@ -344,7 +348,7 @@ class PluginManager: version: Optional[str] = None, force_refresh: bool = False, ) -> dict[str, Any]: - """Download catalogue plugin wheel and install via existing installer.""" + """Download an approved catalogue wheel and install it.""" try: entry = self.catalogue.get_plugin(plugin_id, force_refresh=force_refresh) except CatalogueError as exc: @@ -353,15 +357,27 @@ class PluginManager: download_root = self._download_dir() staging = make_download_temp_dir(download_root) try: - try: - release, wheel_path = self.github.download_latest_wheel( - entry.repository, - staging, - version=version, - force_refresh=force_refresh, - ) - except GitHubReleasesError as exc: - raise PluginManagerError(str(exc), int(exc.code)) from exc + if entry.has_approved_wheel: + if version and normalize_version(version) != normalize_version(entry.version or ""): + raise PluginManagerError( + f"version {version!r} is not approved for {entry.id}; " + f"approved version is {entry.version}", + 400, + ) + try: + wheel_path = self.catalogue.download_wheel(entry, staging) + except CatalogueError as exc: + raise PluginManagerError(str(exc), int(exc.code)) from exc + else: + try: + _release, wheel_path = self.github.download_latest_wheel( + entry.repository, + staging, + version=version, + force_refresh=force_refresh, + ) + except GitHubReleasesError as exc: + raise PluginManagerError(str(exc), int(exc.code)) from exc try: manifest = load_manifest_from_wheel(wheel_path) @@ -373,6 +389,12 @@ class PluginManager: f"manifest id {manifest.id!r} does not match catalogue id {entry.id!r}", 400, ) + if entry.has_approved_wheel and manifest.version != entry.version: + raise PluginManagerError( + f"manifest version {manifest.version!r} does not match approved " + f"catalogue version {entry.version!r}", + 400, + ) with self._lock: try: @@ -399,6 +421,30 @@ class PluginManager: raise PluginManagerError(f"plugin not found: {plugin_id}", 404) installed_version = str(state.get("version") or "") repository = state.get("repository") + + if state.get("source") == "catalogue": + try: + entry = self.catalogue.get_plugin(plugin_id, force_refresh=force_refresh) + except CatalogueError as exc: + raise PluginManagerError(str(exc), int(exc.code)) from exc + if entry.has_approved_wheel: + latest_version = entry.version + update_available = bool( + latest_version + and installed_version + and self._version_gt(latest_version, installed_version) + ) + return { + "id": plugin_id, + "installedVersion": installed_version or None, + "latestVersion": latest_version, + "updateAvailable": update_available, + "repository": entry.repository, + "releaseNotes": None, + "releaseUrl": None, + "releaseTag": None, + } + if not repository: return { "id": plugin_id, @@ -440,7 +486,7 @@ class PluginManager: version: Optional[str] = None, force_refresh: bool = False, ) -> dict[str, Any]: - """Download a newer (or pinned) release and reinstall via existing installer.""" + """Install the newer approved catalogue version when available.""" state = self.storage.read_state(plugin_id) if state is None: raise PluginManagerError(f"plugin not found: {plugin_id}", 404) @@ -455,31 +501,71 @@ class PluginManager: installed_version = str(state.get("version") or "") was_enabled = bool(state.get("enabled", False)) - # Resolve target release - try: - if version: - release = self.github.find_release(repository, version, force_refresh=force_refresh) - else: - release = self.github.latest_stable(repository, force_refresh=force_refresh) - if release is None: - raise PluginManagerError(f"no stable releases found for {repository}", 404) - if installed_version and not self._version_gt(release.version, installed_version): - # Already up to date — return status without changing + approved_entry = None + release = None + if state.get("source") == "catalogue": + try: + candidate = self.catalogue.get_plugin(plugin_id, force_refresh=force_refresh) + except CatalogueError as exc: + raise PluginManagerError(str(exc), int(exc.code)) from exc + if candidate.has_approved_wheel: + approved_entry = candidate + if version and normalize_version(version) != normalize_version( + approved_entry.version or "" + ): + raise PluginManagerError( + f"version {version!r} is not approved for {plugin_id}; " + f"approved version is {approved_entry.version}", + 400, + ) + if installed_version and not self._version_gt( + approved_entry.version or "", installed_version + ): status = self.status(plugin_id) status["updateAvailable"] = False - status["latestVersion"] = release.version + status["latestVersion"] = approved_entry.version status["updated"] = False return status - except GitHubReleasesError as exc: - raise PluginManagerError(str(exc), int(exc.code)) from exc + + if approved_entry is None: + # Legacy schema-1 catalogues continue to resolve GitHub Releases. + try: + if version: + release = self.github.find_release( + repository, version, force_refresh=force_refresh + ) + else: + release = self.github.latest_stable(repository, force_refresh=force_refresh) + if release is None: + raise PluginManagerError(f"no stable releases found for {repository}", 404) + if installed_version and not self._version_gt( + release.version, installed_version + ): + status = self.status(plugin_id) + status["updateAvailable"] = False + status["latestVersion"] = release.version + status["updated"] = False + return status + except GitHubReleasesError as exc: + raise PluginManagerError(str(exc), int(exc.code)) from exc download_root = self._download_dir() staging = make_download_temp_dir(download_root) try: - try: - wheel_path = self.github.download_wheel(release, staging) - except GitHubReleasesError as exc: - raise PluginManagerError(str(exc), int(exc.code)) from exc + if approved_entry is not None: + try: + wheel_path = self.catalogue.download_wheel(approved_entry, staging) + except CatalogueError as exc: + raise PluginManagerError(str(exc), int(exc.code)) from exc + target_version = approved_entry.version + else: + if release is None: + raise PluginManagerError("could not resolve plugin release", 500) + try: + wheel_path = self.github.download_wheel(release, staging) + except GitHubReleasesError as exc: + raise PluginManagerError(str(exc), int(exc.code)) from exc + target_version = release.version try: manifest = load_manifest_from_wheel(wheel_path) @@ -490,6 +576,12 @@ class PluginManager: f"manifest id {manifest.id!r} does not match installed id {plugin_id!r}", 400, ) + if approved_entry is not None and manifest.version != approved_entry.version: + raise PluginManagerError( + f"manifest version {manifest.version!r} does not match approved " + f"catalogue version {approved_entry.version!r}", + 400, + ) with self._lock: # Stop running process before swapping release @@ -513,7 +605,7 @@ class PluginManager: return self.enable(plugin_id) result = self.status(plugin_id) result["updated"] = True - result["latestVersion"] = release.version + result["latestVersion"] = target_version return result finally: shutil.rmtree(staging, ignore_errors=True) diff --git a/repeater/plugins/runtime.py b/repeater/plugins/runtime.py index 37f07e3..d566842 100644 --- a/repeater/plugins/runtime.py +++ b/repeater/plugins/runtime.py @@ -503,7 +503,8 @@ class PluginRuntime: log_fp = open(paths.log_file, "a", encoding="utf-8", buffering=1) env = os.environ.copy() - # Manager-only credentials must never become plugin credentials. + # Never expose a stale manager-only credential left behind by an + # upgraded deployment to third-party plugin processes. env.pop("OPENHOP_PLUGIN_GITHUB_TOKEN", None) env["OPENHOP_PLUGIN_ID"] = plugin_id env["OPENHOP_PLUGIN_DATA"] = str(paths.data_dir) diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index ee9603a..2c82c94 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -4604,15 +4604,15 @@ paths: summary: List curated plugin catalogue description: > Fetch the curated openHop plugin catalogue and annotate entries with - install state and latest stable GitHub Release when available. - Failures contacting the catalogue or GitHub do not affect installed plugins. + install state and the currently approved catalogue version. + Failures contacting the R2 catalogue do not affect installed plugins. parameters: - name: refresh in: query required: false schema: type: boolean - description: Force refresh of catalogue and release caches + description: Force refresh of the catalogue cache responses: '200': description: Catalogue entries @@ -4626,8 +4626,8 @@ paths: - Plugins summary: Install a plugin from the catalogue description: > - Resolve the catalogue entry to its GitHub repository, download the latest - (or pinned) stable wheel, install via the existing plugin installer, and enable. + Download the exact R2 wheel approved by catalogue version and SHA-256, + verify its manifest, install it, and enable the plugin. requestBody: required: true content: @@ -4640,7 +4640,7 @@ paths: type: string version: type: string - description: Optional release version (with or without leading v) + description: Optional assertion of the currently approved version responses: '200': description: Installed plugin status @@ -4673,7 +4673,7 @@ paths: post: tags: - Plugins - summary: Update an installed plugin from GitHub Releases + summary: Update an installed plugin to the approved catalogue version requestBody: required: true content: diff --git a/repeater/web/plugin_endpoints.py b/repeater/web/plugin_endpoints.py index 6dc1ba0..17b8e5e 100644 --- a/repeater/web/plugin_endpoints.py +++ b/repeater/web/plugin_endpoints.py @@ -408,7 +408,7 @@ class PluginAPIEndpoints: @cherrypy.tools.json_out() @cherrypy.tools.json_in(force=False) def update(self, **kwargs): - """POST /api/plugins/update {id, version?} — update from GitHub Releases.""" + """POST /api/plugins/update {id, version?} — install approved catalogue update.""" if cherrypy.request.method == "OPTIONS": return "" self._require_post() diff --git a/tests/test_container_workflow.py b/tests/test_container_workflow.py index a81a1f6..d1c0dc3 100644 --- a/tests/test_container_workflow.py +++ b/tests/test_container_workflow.py @@ -21,12 +21,16 @@ def test_container_smoke_script_checks_install_restart_and_shutdown(): assert "EXPECTED_PLUGIN_PID" in script -def test_compose_exposes_service_scoped_github_token(): - compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") - docs = (ROOT / "docs/plugins.md").read_text(encoding="utf-8") +def test_r2_catalogue_needs_no_github_token(): + paths = [ + ROOT / ".env.example", + ROOT / "docker-compose.yml", + ROOT / "docs/plugins.md", + ROOT / "repeater/plugins/manager.py", + ] - assert "OPENHOP_PLUGIN_GITHUB_TOKEN" in compose - assert "OPENHOP_PLUGIN_GITHUB_TOKEN" in docs + for path in paths: + assert "OPENHOP_PLUGIN_GITHUB_TOKEN" not in path.read_text(encoding="utf-8") def test_image_uses_tini_to_reap_orphaned_plugin_processes(): diff --git a/tests/test_plugin_catalogue.py b/tests/test_plugin_catalogue.py index a29cc58..ed332fb 100644 --- a/tests/test_plugin_catalogue.py +++ b/tests/test_plugin_catalogue.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import io import json from urllib.error import HTTPError, URLError @@ -30,6 +31,25 @@ VALID = { ], } +APPROVED_WHEEL = b"approved-wheel" +VALID_V2 = { + "schema": 2, + "plugins": [ + { + "id": "openhop.nomad", + "name": "NOMAD Bridge", + "description": "desc", + "repository": "openhop-dev/openhop-nomad-plugin", + "version": "0.1.0", + "wheel_url": ( + "https://repeater-plugins.openhop.dev/plugins/openhop.nomad/0.1.0/" + "openhop_nomad_plugin-0.1.0-py3-none-any.whl" + ), + "sha256": hashlib.sha256(APPROVED_WHEEL).hexdigest(), + } + ], +} + def test_default_catalogue_uses_openhop_r2_endpoint(): assert DEFAULT_CATALOGUE_URL == "https://repeater-plugins.openhop.dev/catalogue.json" @@ -43,6 +63,47 @@ def test_parse_valid_catalogue(): assert cat.get("openhop.nomad").repository == "openhop-dev/openhop-nomad-plugin" +def test_parse_v2_approved_release_metadata(): + cat = parse_catalogue(VALID_V2) + plugin = cat.get("openhop.nomad") + + assert cat.schema == 2 + assert plugin.version == "0.1.0" + assert plugin.wheel_url.endswith("openhop_nomad_plugin-0.1.0-py3-none-any.whl") + assert plugin.sha256 == hashlib.sha256(APPROVED_WHEEL).hexdigest() + + +def test_download_v2_wheel_verifies_checksum(tmp_path): + def opener(request: Request, timeout: float = 30.0): + assert request.full_url == VALID_V2["plugins"][0]["wheel_url"] + return _FakeResp(APPROVED_WHEEL) + + plugin = parse_catalogue(VALID_V2).plugins[0] + path = CatalogueClient(opener=opener).download_wheel(plugin, tmp_path) + + assert path.read_bytes() == APPROVED_WHEEL + + +def test_download_v2_wheel_rejects_checksum_mismatch(tmp_path): + def opener(request: Request, timeout: float = 30.0): + return _FakeResp(b"tampered") + + plugin = parse_catalogue(VALID_V2).plugins[0] + with pytest.raises(CatalogueError, match="checksum"): + CatalogueClient(opener=opener).download_wheel(plugin, tmp_path) + assert list(tmp_path.iterdir()) == [] + + +def test_download_v2_wheel_rejects_redirect_outside_approved_origin(tmp_path): + def opener(request: Request, timeout: float = 30.0): + return _FakeResp(APPROVED_WHEEL, final_url="https://example.com/plugin.whl") + + plugin = parse_catalogue(VALID_V2).plugins[0] + with pytest.raises(CatalogueError, match="approved R2"): + CatalogueClient(opener=opener).download_wheel(plugin, tmp_path) + assert list(tmp_path.iterdir()) == [] + + def test_unsupported_schema(): with pytest.raises(CatalogueError, match="unsupported catalogue schema"): parse_catalogue({"schema": 99, "plugins": []}) @@ -109,6 +170,13 @@ def test_duplicate_repository(): class _FakeResp(io.BytesIO): + def __init__(self, payload: bytes, *, final_url: str | None = None): + super().__init__(payload) + self._final_url = final_url + + def geturl(self): + return self._final_url + def __enter__(self): return self diff --git a/tests/test_plugin_github_releases.py b/tests/test_plugin_github_releases.py index df07c3b..84dd475 100644 --- a/tests/test_plugin_github_releases.py +++ b/tests/test_plugin_github_releases.py @@ -148,27 +148,6 @@ def test_download_wheel(tmp_path: Path): assert path.name.endswith(".whl") -def test_github_token_authenticates_api_but_not_asset_download(tmp_path: Path): - payload = [_release("v0.1.0")] - seen_headers = [] - - def opener(request: Request, timeout: float = 30.0): - seen_headers.append((request.full_url, dict(request.header_items()))) - if "api.github.com" in request.full_url: - return _FakeResp(json.dumps(payload).encode()) - if request.full_url.endswith(".whl"): - return _FakeResp(b"wheel-bytes") - raise AssertionError(request.full_url) - - client = GitHubReleaseClient(opener=opener, token="test-token") - client.download_latest_wheel("org/repo", tmp_path) - - api_headers = next(headers for url, headers in seen_headers if "api.github.com" in url) - asset_headers = next(headers for url, headers in seen_headers if url.endswith(".whl")) - assert api_headers["Authorization"] == "Bearer test-token" - assert "Authorization" not in asset_headers - - def test_rate_limit(): def opener(request: Request, timeout: float = 30.0): headers = {"X-RateLimit-Remaining": "0"} diff --git a/tests/test_plugin_manager_lifecycle.py b/tests/test_plugin_manager_lifecycle.py index ad8de3f..c4916fb 100644 --- a/tests/test_plugin_manager_lifecycle.py +++ b/tests/test_plugin_manager_lifecycle.py @@ -52,14 +52,6 @@ def _make_wheel(path: Path, manifest: dict, ui_files: dict[str, str] | None = No return path -def test_default_github_client_uses_service_scoped_token(tmp_path: Path, monkeypatch): - monkeypatch.setenv("OPENHOP_PLUGIN_GITHUB_TOKEN", "service-token") - - manager = PluginManager(PluginStorage(tmp_path / "plugins")) - - assert manager.github.token == "service-token" - - def test_install_creates_layout_and_isolated_venv(tmp_path: Path, monkeypatch): storage = PluginStorage(tmp_path / "plugins") calls = [] @@ -200,13 +192,12 @@ def test_manager_starts_enabled_plugin(tmp_path: Path): def test_enable_start_stop_disable(tmp_path: Path, monkeypatch): storage = PluginStorage(tmp_path / "plugins") procs: list[FakeProc] = [] - child_environments = [] - monkeypatch.setenv("OPENHOP_PLUGIN_GITHUB_TOKEN", "must-not-reach-plugin") + spawn_envs: list[dict[str, str]] = [] def fake_popen(cmd, **kwargs): + spawn_envs.append(kwargs["env"]) p = FakeProc(pid=1000 + len(procs)) procs.append(p) - child_environments.append(kwargs["env"]) # Write a line to log fd if provided log_fp = kwargs.get("stdout") if log_fp and hasattr(log_fp, "write"): @@ -242,10 +233,12 @@ def test_enable_start_stop_disable(tmp_path: Path, monkeypatch): ) manager.install(wheel) + monkeypatch.setenv("OPENHOP_PLUGIN_GITHUB_TOKEN", "legacy-token") st = manager.enable("openhop.demo") assert st["enabled"] is True assert st["state"] == PluginState.RUNNING.value assert len(procs) == 1 + assert "OPENHOP_PLUGIN_GITHUB_TOKEN" not in spawn_envs[0] st = manager.stop_plugin("openhop.demo") assert st["state"] == PluginState.STOPPED.value @@ -259,7 +252,6 @@ def test_enable_start_stop_disable(tmp_path: Path, monkeypatch): st = manager.disable("openhop.demo") assert st["enabled"] is False assert st["state"] == PluginState.DISABLED.value - assert all("OPENHOP_PLUGIN_GITHUB_TOKEN" not in env for env in child_environments) logs = manager.logs("openhop.demo", tail=10) assert any("hello" in line for line in logs["lines"]) diff --git a/tests/test_plugin_r2_catalogue_install.py b/tests/test_plugin_r2_catalogue_install.py new file mode 100644 index 0000000..7bf9b46 --- /dev/null +++ b/tests/test_plugin_r2_catalogue_install.py @@ -0,0 +1,141 @@ +"""Approved R2 catalogue install and update flows.""" + +from __future__ import annotations + +import hashlib +import io +import json +import zipfile +from pathlib import Path +from unittest.mock import MagicMock +from urllib.request import Request + +import pytest + +from repeater.plugins.catalogue import CatalogueClient +from repeater.plugins.github_releases import GitHubReleaseClient +from repeater.plugins.manager import PluginManager, PluginManagerError +from repeater.plugins.storage import PluginStorage + + +class _FakeResp(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + +def _make_wheel(path: Path, plugin_id: str, version: str) -> bytes: + manifest = { + "schema": 1, + "id": plugin_id, + "name": plugin_id, + "version": version, + "description": "test", + "ui": {"type": "application", "entry": "ui/index.html"}, + } + with zipfile.ZipFile(path, "w") as archive: + archive.writestr( + f"share/openhop/plugins/{plugin_id}/openhop-plugin.json", + json.dumps(manifest), + ) + archive.writestr("ui/index.html", "") + dist = f"test_plugin-{version}.dist-info" + archive.writestr( + f"{dist}/METADATA", + f"Metadata-Version: 2.1\nName: test-plugin\nVersion: {version}\n", + ) + archive.writestr( + f"{dist}/WHEEL", + "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + ) + archive.writestr(f"{dist}/RECORD", "") + return path.read_bytes() + + +def _entry(version: str, wheel: bytes) -> dict: + return { + "id": "openhop.demo", + "name": "Demo", + "description": "demo plugin", + "repository": "openhop-dev/demo-plugin", + "category": "test", + "version": version, + "wheel_url": ( + f"https://repeater-plugins.openhop.dev/plugins/openhop.demo/{version}/" + f"demo-{version}-py3-none-any.whl" + ), + "sha256": hashlib.sha256(wheel).hexdigest(), + } + + +@pytest.fixture +def r2_manager(tmp_path: Path): + wheels = { + "0.1.0": _make_wheel(tmp_path / "demo-0.1.0.whl", "openhop.demo", "0.1.0"), + "0.2.0": _make_wheel(tmp_path / "demo-0.2.0.whl", "openhop.demo", "0.2.0"), + } + state = {"entry": _entry("0.1.0", wheels["0.1.0"]), "requests": []} + + def opener(request: Request, timeout: float = 30.0): + state["requests"].append(request.full_url) + if request.full_url.endswith("catalogue.json"): + return _FakeResp(json.dumps({"schema": 2, "plugins": [state["entry"]]}).encode()) + for version, wheel in wheels.items(): + if request.full_url == _entry(version, wheel)["wheel_url"]: + return _FakeResp(wheel) + raise AssertionError(request.full_url) + + catalogue = CatalogueClient("https://example.test/catalogue.json", opener=opener) + github = MagicMock(spec=GitHubReleaseClient) + manager = PluginManager( + PluginStorage(tmp_path / "plugins"), + catalogue_client=catalogue, + github_client=github, + ) + manager._r2_test_state = state # type: ignore[attr-defined] + manager._r2_test_wheels = wheels # type: ignore[attr-defined] + return manager + + +def test_schema2_install_uses_only_approved_r2_wheel(r2_manager: PluginManager): + status = r2_manager.install_from_catalogue("openhop.demo") + + assert status["version"] == "0.1.0" + assert status["enabled"] is True + assert status["source"] == "catalogue" + assert all("api.github.com" not in url for url in r2_manager._r2_test_state["requests"]) + r2_manager.github.latest_stable.assert_not_called() + r2_manager.github.download_latest_wheel.assert_not_called() + + +def test_schema2_catalogue_version_controls_updates(r2_manager: PluginManager): + r2_manager.install_from_catalogue("openhop.demo") + wheels = r2_manager._r2_test_wheels + r2_manager._r2_test_state["entry"] = _entry("0.2.0", wheels["0.2.0"]) + r2_manager.catalogue.clear_cache() + + check = r2_manager.check_update("openhop.demo", force_refresh=True) + assert check["latestVersion"] == "0.2.0" + assert check["updateAvailable"] is True + + updated = r2_manager.update_plugin("openhop.demo", force_refresh=True) + assert updated["version"] == "0.2.0" + assert updated["enabled"] is True + r2_manager.github.latest_stable.assert_not_called() + r2_manager.github.download_wheel.assert_not_called() + + +def test_schema2_rejects_unapproved_requested_version(r2_manager: PluginManager): + with pytest.raises(PluginManagerError, match="not approved"): + r2_manager.install_from_catalogue("openhop.demo", version="9.9.9") + + +def test_schema2_checksum_failure_does_not_install(r2_manager: PluginManager): + r2_manager._r2_test_state["entry"]["sha256"] = "0" * 64 + r2_manager.catalogue.clear_cache() + + with pytest.raises(PluginManagerError, match="checksum"): + r2_manager.install_from_catalogue("openhop.demo", force_refresh=True) + assert r2_manager.storage.list_plugin_ids() == []