From 3b635819f27ce75b32a35a8c1a347ff6dbfacf71 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 17 Nov 2025 13:32:00 -0500 Subject: [PATCH 01/23] plugin installer Signed-off-by: Patrick St-Louis --- acapy_agent/admin/routes.py | 28 +- acapy_agent/admin/server.py | 2 +- acapy_agent/commands/start.py | 39 ++ acapy_agent/config/argparse.py | 35 ++ acapy_agent/utils/plugin_installer.py | 653 ++++++++++++++++++++++++++ docs/features/PlugIns.md | 72 +++ scripts/check_plugin_versions.py | 31 ++ 7 files changed, 857 insertions(+), 3 deletions(-) create mode 100644 acapy_agent/utils/plugin_installer.py create mode 100644 scripts/check_plugin_versions.py diff --git a/acapy_agent/admin/routes.py b/acapy_agent/admin/routes.py index eefcbd4bb8..0df1499c7a 100644 --- a/acapy_agent/admin/routes.py +++ b/acapy_agent/admin/routes.py @@ -8,6 +8,7 @@ from ..core.plugin_registry import PluginRegistry from ..messaging.models.openapi import OpenAPISchema +from ..utils.plugin_installer import get_plugin_version from ..utils.stats import Collector from ..version import __version__ from .decorators.auth import admin_authentication @@ -71,12 +72,35 @@ async def plugins_handler(request: web.BaseRequest): request: aiohttp request object Returns: - The module list response + The module list response with plugin names and versions """ registry = request.app["context"].inject_or(PluginRegistry) plugins = registry and sorted(registry.plugin_names) or [] - return web.json_response({"result": plugins}) + + # Get versions for external plugins only (skip built-in acapy_agent plugins) + external_plugins = [] + for plugin_name in plugins: + if not plugin_name.startswith("acapy_agent."): + # External plugin - try to get version info + version_info = get_plugin_version(plugin_name) + if version_info: + external_plugins.append({ + "name": plugin_name, + "package_version": version_info.get("package_version"), + "source_version": version_info.get("source_version"), # Git tag used for installation + }) + else: + external_plugins.append({ + "name": plugin_name, + "package_version": None, + "source_version": None, + }) + + return web.json_response({ + "result": plugins, + "external": external_plugins + }) @docs(tags=["server"], summary="Fetch the server configuration") diff --git a/acapy_agent/admin/server.py b/acapy_agent/admin/server.py index bd55141817..cf64f5d2fa 100644 --- a/acapy_agent/admin/server.py +++ b/acapy_agent/admin/server.py @@ -395,7 +395,7 @@ async def setup_context(request: web.Request, handler): server_routes = [ web.get("/", redirect_handler, allow_head=True), - web.get("/plugins", plugins_handler, allow_head=False), + web.get("/server/plugins", plugins_handler, allow_head=False), web.get("/status", status_handler, allow_head=False), web.get("/status/config", config_handler, allow_head=False), web.post("/status/reset", status_reset_handler), diff --git a/acapy_agent/commands/start.py b/acapy_agent/commands/start.py index 3b2b5ec044..97e2187954 100644 --- a/acapy_agent/commands/start.py +++ b/acapy_agent/commands/start.py @@ -19,6 +19,8 @@ from ..config.default_context import DefaultContextBuilder from ..config.util import common_config from ..core.conductor import Conductor +from ..utils.plugin_installer import install_plugins_from_config +from ..version import __version__ as acapy_version from . import PROG LOGGER = logging.getLogger(__name__) @@ -60,6 +62,43 @@ async def run_app(argv: Sequence[str] = None): settings = get_settings(args) common_config(settings) + # Install plugins if auto-install is enabled and plugins are specified + external_plugins = settings.get("external_plugins", []) + if external_plugins: + auto_install = settings.get("auto_install_plugins", False) + plugin_version = settings.get("plugin_install_version") + + if auto_install: + version_info = ( + f"version {plugin_version}" + if plugin_version + else f"current ACA-Py version ({acapy_version})" + ) + # Always print to console for visibility + print(f"Auto-installing plugins from acapy-plugins repository: {', '.join(external_plugins)} ({version_info})") + LOGGER.info( + "Auto-installing plugins from acapy-plugins repository: %s (%s)", + ", ".join(external_plugins), + version_info, + ) + + failed_plugins = install_plugins_from_config( + plugin_names=external_plugins, + auto_install=auto_install, + plugin_version=plugin_version, + ) + + if failed_plugins: + LOGGER.error( + "Failed to install the following plugins: %s", + ", ".join(failed_plugins), + ) + LOGGER.error( + "Please ensure these plugins are available in the acapy-plugins repository " + "or install them manually before starting ACA-Py." + ) + sys.exit(1) + # Set ledger to read-only if explicitly specified settings["ledger.read_only"] = settings.get("read_only_ledger", False) diff --git a/acapy_agent/config/argparse.py b/acapy_agent/config/argparse.py index c76c4add92..cbc0efe601 100644 --- a/acapy_agent/config/argparse.py +++ b/acapy_agent/config/argparse.py @@ -705,6 +705,22 @@ def add_arguments(self, parser: ArgumentParser): ), ) + parser.add_argument( + "--auto-install-plugins", + dest="auto_install_plugins", + nargs="?", + const=True, + default=False, + metavar="", + env_var="ACAPY_AUTO_INSTALL_PLUGINS", + help=( + "Automatically install missing plugins from the acapy-plugins repository. " + "If specified without a value, uses current ACA-Py version. " + "If a version is provided (e.g., 1.3.2), uses that version for plugin installation. " + "Default: false (disabled)." + ), + ) + parser.add_argument( "--storage-type", type=str, @@ -803,6 +819,25 @@ def get_settings(self, args: Namespace) -> dict: reduce(lambda v, k: {k: v}, key.split(".")[::-1], value), ) + # Auto-install plugins: can be True (use current version), version string (e.g., "1.3.2"), or False + if hasattr(args, "auto_install_plugins"): + auto_install_value = args.auto_install_plugins + if auto_install_value is True: + # Flag present without value - use current ACA-Py version + settings["auto_install_plugins"] = True + settings["plugin_install_version"] = None # Use current version + elif isinstance(auto_install_value, str): + # Flag present with version value + settings["auto_install_plugins"] = True + settings["plugin_install_version"] = auto_install_value + else: + # False or None - disabled + settings["auto_install_plugins"] = False + settings["plugin_install_version"] = None + else: + settings["auto_install_plugins"] = False + settings["plugin_install_version"] = None + if args.storage_type: settings["storage_type"] = args.storage_type diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py new file mode 100644 index 0000000000..bcd169e09d --- /dev/null +++ b/acapy_agent/utils/plugin_installer.py @@ -0,0 +1,653 @@ +"""Plugin installer for dynamic plugin installation at runtime.""" + +import importlib +import json +import logging +import subprocess +import sys +from pathlib import Path +from typing import List, Optional, Set + +from importlib.metadata import version as get_package_version, PackageNotFoundError, distributions + +from ..version import __version__ + +LOGGER = logging.getLogger(__name__) + + +class PluginInstaller: + """Handles dynamic installation of ACA-Py plugins from the acapy-plugins repository.""" + + def __init__( + self, + auto_install: bool = True, + plugin_version: Optional[str] = None, + ): + """ + Initialize the plugin installer. + + Args: + auto_install: Whether to automatically install missing plugins + plugin_version: Version to use for plugin installation. If None, uses current ACA-Py version. + """ + self.auto_install = auto_install + self.plugin_version = plugin_version + self.installed_plugins: Set[str] = set() + + def _get_installed_plugin_version(self, plugin_name: str) -> Optional[dict]: + """ + Get the version information of an installed plugin, including package version and installation source. + + Args: + plugin_name: The name of the plugin module + + Returns: + Dictionary with 'package_version' and optionally 'source_version' (git tag), or None if not found + """ + result = {} + + try: + # Try to get version from package metadata (pip installed packages) + # First try the module name directly + package_name_to_check = None + try: + version = get_package_version(plugin_name) + package_name_to_check = plugin_name + result["package_version"] = version + LOGGER.debug("Found version for plugin '%s' via metadata: %s", plugin_name, version) + except PackageNotFoundError: + pass + + # Try common package name variations + if "package_version" not in result: + package_name = plugin_name.replace("_", "-") + try: + version = get_package_version(package_name) + package_name_to_check = package_name + result["package_version"] = version + LOGGER.debug("Found version for plugin '%s' via metadata (as %s): %s", plugin_name, package_name, version) + except PackageNotFoundError: + pass + + # Try acapy-plugin- prefix + if "package_version" not in result: + try: + prefixed_name = f"acapy-plugin-{plugin_name.replace('_', '-')}" + version = get_package_version(prefixed_name) + package_name_to_check = prefixed_name + result["package_version"] = version + LOGGER.debug("Found version for plugin '%s' via metadata (as %s): %s", plugin_name, prefixed_name, version) + except PackageNotFoundError: + pass + + # Try to get version from module's __version__ attribute + if "package_version" not in result: + try: + module = importlib.import_module(plugin_name) + if hasattr(module, "__version__"): + version = str(module.__version__) + result["package_version"] = version + LOGGER.debug("Found version for plugin '%s' via __version__ attribute: %s", plugin_name, version) + except (ImportError, AttributeError) as e: + LOGGER.debug("Could not get __version__ from plugin '%s': %s", plugin_name, e) + + # Try to get installation source (git tag) from pip metadata + if package_name_to_check: + try: + # First, try pip show to get direct URL information + try: + pip_show_result = subprocess.run( + [sys.executable, "-m", "pip", "show", package_name_to_check], + capture_output=True, + text=True, + check=False, + ) + if pip_show_result.returncode == 0: + # Check for "Location:" field to find the package directory + location = None + for line in pip_show_result.stdout.split("\n"): + if line.startswith("Location:"): + location = line.split(":", 1)[1].strip() + break + + # Try to find direct_url.json in all possible locations + if location: + location_path = Path(location) + # Try to find .dist-info directory for this package + for item in location_path.iterdir(): + if item.is_dir() and item.name.endswith(".dist-info"): + direct_url_file = item / "direct_url.json" + if direct_url_file.exists(): + try: + with open(direct_url_file, "r") as f: + direct_url_data = json.load(f) + # direct_url.json can have different formats + # Format 1: {"url": "git+https://...@tag#subdirectory=..."} + # Format 2: {"vcs_info": {...}, "url": "..."} + url_info = direct_url_data.get("url", "") + + # Try to extract from vcs_info if available + vcs_info = direct_url_data.get("vcs_info", {}) + if vcs_info and vcs_info.get("vcs") == "git": + requested_revision = vcs_info.get("requested_revision") + if requested_revision: + result["source_version"] = requested_revision + LOGGER.debug("Found source version from vcs_info for plugin '%s': %s", plugin_name, requested_revision) + break + + # Fallback: Extract from URL + if url_info and "@" in url_info and "github.com" in url_info: + parts = url_info.split("@") + if len(parts) > 1: + tag_part = parts[1].split("#")[0] if "#" in parts[1] else parts[1] + # Check if it looks like a version tag (not a commit hash) + if "." in tag_part or tag_part in ["main", "master", "develop"]: + result["source_version"] = tag_part + LOGGER.debug("Found source version from URL for plugin '%s': %s", plugin_name, tag_part) + break + except (json.JSONDecodeError, IOError) as e: + LOGGER.debug("Could not read direct_url.json for plugin '%s': %s", plugin_name, e) + except Exception as e: + LOGGER.debug("Could not get installation source from pip show for plugin '%s': %s", plugin_name, e) + + # Fallback: Try to find distribution and check direct_url.json + if "source_version" not in result: + for dist in distributions(): + if dist.metadata["Name"].lower() == package_name_to_check.lower(): + # Try multiple path formats for direct_url.json + dist_location = Path(dist.location) + package_name = dist.metadata["Name"] + package_version = dist.version + + # Try different naming conventions for .dist-info directory + dist_info_names = [ + f"{package_name}-{package_version}.dist-info", + f"{package_name.replace('-', '_')}-{package_version}.dist-info", + f"{package_name.replace('.', '_')}-{package_version}.dist-info", + ] + + for dist_info_name in dist_info_names: + dist_info_dir = dist_location / dist_info_name + direct_url_file = dist_info_dir / "direct_url.json" + + if direct_url_file.exists(): + try: + with open(direct_url_file, "r") as f: + direct_url_data = json.load(f) + vcs_info = direct_url_data.get("vcs_info", {}) + if vcs_info and vcs_info.get("vcs") == "git": + requested_revision = vcs_info.get("requested_revision") + if requested_revision: + result["source_version"] = requested_revision + LOGGER.debug("Found source version from direct_url.json vcs_info for plugin '%s': %s", plugin_name, requested_revision) + break + + url_info = direct_url_data.get("url", "") + if url_info and "@" in url_info and "github.com" in url_info: + parts = url_info.split("@") + if len(parts) > 1: + tag_part = parts[1].split("#")[0] if "#" in parts[1] else parts[1] + if "." in tag_part or tag_part in ["main", "master", "develop"]: + result["source_version"] = tag_part + LOGGER.debug("Found source version from direct_url.json URL for plugin '%s': %s", plugin_name, tag_part) + break + except (json.JSONDecodeError, IOError) as e: + LOGGER.debug("Could not read direct_url.json for plugin '%s': %s", plugin_name, e) + + if "source_version" in result: + break + + # Last fallback: Try pip freeze to get installation source + if "source_version" not in result: + try: + pip_freeze_result = subprocess.run( + [sys.executable, "-m", "pip", "freeze"], + capture_output=True, + text=True, + check=False, + ) + if pip_freeze_result.returncode == 0: + for line in pip_freeze_result.stdout.split("\n"): + # Look for package installed from git + # Format: package==version @ git+https://github.com/...@tag#subdirectory=... + # or: package @ git+https://github.com/...@tag#subdirectory=... + line_lower = line.lower() + if (package_name_to_check.lower() in line_lower or + package_name_to_check.replace("-", "_").lower() in line_lower) and "@ git+" in line: + # Extract the git URL part + if "@ git+" in line: + git_part = line.split("@ git+")[1] + if "@" in git_part: + tag_part = git_part.split("@")[1].split("#")[0] if "#" in git_part.split("@")[1] else git_part.split("@")[1] + if "." in tag_part or tag_part in ["main", "master", "develop"]: + result["source_version"] = tag_part + LOGGER.debug("Found source version from pip freeze for plugin '%s': %s", plugin_name, tag_part) + break + except Exception as e: + LOGGER.debug("Could not get installation source from pip freeze for plugin '%s': %s", plugin_name, e) + + except Exception as e: + LOGGER.debug("Could not get installation source for plugin '%s': %s", plugin_name, e) + + # Return the result if we found at least package_version + if "package_version" in result: + return result + + except Exception as e: + LOGGER.debug("Error determining version for plugin '%s': %s", plugin_name, e) + + LOGGER.debug("Could not determine version for plugin '%s' using any method", plugin_name) + return None + + def _get_plugin_source(self, plugin_name: str) -> str: + """Get the installation source for a plugin from acapy-plugins repository.""" + # Install from acapy-plugins repo + # Use provided version or current ACA-Py version + version_to_use = self.plugin_version if self.plugin_version is not None else __version__ + plugin_source = ( + f"git+https://github.com/openwallet-foundation/acapy-plugins@{version_to_use}" + f"#subdirectory={plugin_name}" + ) + LOGGER.info( + "Installing plugin '%s' from acapy-plugins repository (version %s)", + plugin_name, + version_to_use, + ) + LOGGER.debug("Installation source: %s", plugin_source) + return plugin_source + + def _install_plugin(self, plugin_source: str, plugin_name: str = None, upgrade: bool = False) -> bool: + """ + Install a plugin using pip. + + Args: + plugin_source: The pip installable source (package name, git URL, etc.) + plugin_name: Optional plugin name for logging + upgrade: Whether to upgrade/reinstall if already installed + + Returns: + True if installation succeeded, False otherwise + """ + try: + # Extract version from source for logging + version_info = "" + if "@" in plugin_source: + # Extract version/tag from git URL or package version + parts = plugin_source.split("@") + if len(parts) > 1: + version_part = parts[1].split("#")[0] if "#" in parts[1] else parts[1] + version_info = f" (version: {version_part})" + + log_name = plugin_name if plugin_name else plugin_source + if upgrade: + LOGGER.info("Upgrading plugin '%s'%s", log_name, version_info) + LOGGER.debug("Upgrade source: %s", plugin_source) + else: + LOGGER.info("Installing plugin '%s'%s", log_name, version_info) + LOGGER.debug("Installation source: %s", plugin_source) + + # Use pip programmatically to install + cmd = [ + sys.executable, + "-m", + "pip", + "install", + "--no-cache-dir", + ] + + if upgrade: + # Use --upgrade --force-reinstall to ensure correct version + cmd.extend(["--upgrade", "--force-reinstall", "--no-deps"]) + + cmd.append(plugin_source) + + # Run pip install + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + ) + + if result.returncode == 0: + if upgrade: + LOGGER.info("Successfully upgraded plugin '%s'%s", log_name, version_info) + else: + LOGGER.info("Successfully installed plugin '%s'%s", log_name, version_info) + return True + else: + action = "upgrade" if upgrade else "install" + LOGGER.error( + "Failed to %s plugin '%s'%s: %s", action, log_name, version_info, result.stderr + ) + return False + + except Exception as e: + LOGGER.error("Error installing plugin %s: %s", plugin_source, e) + return False + + def ensure_plugin_installed(self, plugin_name: str) -> bool: + """ + Ensure a plugin is installed with the correct version. If not, or if version doesn't match, attempt to install it. + + Args: + plugin_name: The name of the plugin module + + Returns: + True if plugin is available (was already installed or successfully installed) + """ + # Get target version + target_version = self.plugin_version if self.plugin_version is not None else __version__ + + # Check if plugin can be imported (exists) + plugin_exists = False + try: + importlib.import_module(plugin_name) + plugin_exists = True + except ImportError: + plugin_exists = False + + # Get installed version info if plugin exists + installed_version_info = None + installed_package_version = None + installed_source_version = None + if plugin_exists: + installed_version_info = self._get_installed_plugin_version(plugin_name) + if installed_version_info: + installed_package_version = installed_version_info.get("package_version") + installed_source_version = installed_version_info.get("source_version") + + # For git-installed packages, we check both package version and source version (git tag). + # When a version is explicitly specified, we check if the source version matches. + + if plugin_exists and not self.plugin_version: + # No explicit version specified - using current ACA-Py version + # If plugin exists and is importable, assume it's fine (might be from git/main) + if installed_package_version: + # Try to match with ACA-Py version if possible + normalized_installed = installed_package_version.split("+")[0].split("-")[0].strip() + normalized_target = target_version.split("+")[0].split("-")[0].strip() + if normalized_installed == normalized_target: + LOGGER.info( + "Plugin '%s' is already installed with correct version: %s", + plugin_name, + installed_package_version, + ) + self.installed_plugins.add(plugin_name) + return True + # Plugin exists but version doesn't match or can't be determined + # Since we're using current ACA-Py version, reinstall to be safe + LOGGER.info( + "Plugin '%s' exists but version check inconclusive. " + "Reinstalling to ensure correct version (%s)...", + plugin_name, + target_version, + ) + elif plugin_exists and self.plugin_version: + # Explicit version specified - check if source version matches + if installed_source_version and installed_source_version == target_version: + # Source version matches - check if we should still reinstall + LOGGER.info( + "Plugin '%s' is already installed from source version %s (package version: %s). " + "Skipping reinstallation.", + plugin_name, + installed_source_version, + installed_package_version or "unknown", + ) + self.installed_plugins.add(plugin_name) + return True + elif installed_package_version: + # Try to compare package versions + normalized_installed = installed_package_version.split("+")[0].split("-")[0].strip() + normalized_target = target_version.split("+")[0].split("-")[0].strip() + # Check if it looks like a version number match (not a git ref) + try: + if (normalized_installed.count(".") >= 1 and + normalized_target.count(".") >= 1 and + normalized_installed == normalized_target): + # Package version matches, but source might be different + # Still reinstall to ensure correct git tag + LOGGER.info( + "Plugin '%s' package version matches (%s) but checking source version...", + plugin_name, + installed_package_version, + ) + except Exception: + pass + # Version mismatch or can't determine - upgrade + if installed_source_version: + LOGGER.info( + "Plugin '%s' source version mismatch: installed=%s, target=%s. " + "Upgrading to version %s...", + plugin_name, + installed_source_version, + target_version, + target_version, + ) + elif installed_package_version: + LOGGER.info( + "Plugin '%s' version mismatch: installed=%s, target=%s. " + "Upgrading to version %s...", + plugin_name, + installed_package_version, + target_version, + target_version, + ) + else: + LOGGER.info( + "Plugin '%s' is installed but version cannot be determined. " + "Upgrading to ensure correct version (%s)...", + plugin_name, + target_version, + ) + elif not plugin_exists: + # Plugin doesn't exist - install it + LOGGER.info( + "Plugin '%s' not found. Installing version %s...", + plugin_name, + target_version, + ) + + if not self.auto_install: + LOGGER.warning( + "Plugin '%s' is not installed and auto-install is disabled", plugin_name + ) + return False + + # Determine if this is an upgrade (plugin exists) + is_upgrade = plugin_exists + + # Get installation source from acapy-plugins repo + plugin_source = self._get_plugin_source(plugin_name) + + # Attempt installation (with upgrade to ensure correct version) + if self._install_plugin(plugin_source, plugin_name=plugin_name, upgrade=is_upgrade): + # Verify installation - first check if it can be imported + try: + importlib.import_module(plugin_name) + except ImportError as e: + LOGGER.error( + "Plugin '%s' was installed but cannot be imported: %s", + plugin_name, + e, + ) + return False + + # Check version after successful import + verified_version_info = self._get_installed_plugin_version(plugin_name) + if verified_version_info: + verified_package_version = verified_version_info.get("package_version") + verified_source_version = verified_version_info.get("source_version") + + # Check if source version matches (for git-installed packages) + if verified_source_version and verified_source_version == target_version: + self.installed_plugins.add(plugin_name) + if is_upgrade: + LOGGER.info( + "Plugin '%s' successfully upgraded to source version %s (package version: %s)", + plugin_name, + verified_source_version, + verified_package_version or "unknown", + ) + else: + LOGGER.info( + "Plugin '%s' successfully installed (source version: %s, package version: %s)", + plugin_name, + verified_source_version, + verified_package_version or "unknown", + ) + return True + elif verified_package_version: + # Normalize package versions for comparison + normalized_installed = verified_package_version.split("+")[0].split("-")[0].strip() + normalized_target = target_version.split("+")[0].split("-")[0].strip() + + if normalized_installed == normalized_target: + self.installed_plugins.add(plugin_name) + if is_upgrade: + LOGGER.info( + "Plugin '%s' successfully upgraded (package version: %s)", + plugin_name, + verified_package_version, + ) + else: + LOGGER.info( + "Plugin '%s' successfully installed (package version: %s)", + plugin_name, + verified_package_version, + ) + return True + else: + LOGGER.warning( + "Plugin '%s' installed package version (%s) doesn't match target (%s), " + "but plugin is importable. Continuing with installed version.", + plugin_name, + verified_package_version, + target_version, + ) + self.installed_plugins.add(plugin_name) + return True + else: + # Version info available but no package or source version + self.installed_plugins.add(plugin_name) + if is_upgrade: + LOGGER.info( + "Plugin '%s' reinstalled successfully (version cannot be verified, target was %s)", + plugin_name, + target_version, + ) + else: + LOGGER.info( + "Plugin '%s' installed successfully (version cannot be verified, target was %s)", + plugin_name, + target_version, + ) + return True + else: + # Can't determine version, but plugin is importable - consider it successful + if is_upgrade: + LOGGER.info( + "Plugin '%s' reinstalled successfully (version cannot be verified, target was %s)", + plugin_name, + target_version, + ) + else: + LOGGER.info( + "Plugin '%s' installed successfully (version cannot be verified, target was %s)", + plugin_name, + target_version, + ) + self.installed_plugins.add(plugin_name) + return True + else: + LOGGER.error( + "Failed to install plugin '%s' (version %s)", + plugin_name, + target_version, + ) + + return False + + def ensure_plugins_installed(self, plugin_names: List[str]) -> List[str]: + """ + Ensure multiple plugins are installed. + + Args: + plugin_names: List of plugin module names + + Returns: + List of plugin names that failed to install + """ + failed = [] + for plugin_name in plugin_names: + if not self.ensure_plugin_installed(plugin_name): + failed.append(plugin_name) + + return failed + + +def install_plugins_from_config( + plugin_names: List[str], + auto_install: bool = True, + plugin_version: Optional[str] = None, +) -> List[str]: + """ + Install plugins from a list of plugin names. + + Args: + plugin_names: List of plugin module names to install + auto_install: Whether to automatically install missing plugins + plugin_version: Version to use for plugin installation. If None, uses current ACA-Py version. + + Returns: + List of plugin names that failed to install + """ + if not plugin_names: + return [] + + installer = PluginInstaller( + auto_install=auto_install, + plugin_version=plugin_version, + ) + + return installer.ensure_plugins_installed(plugin_names) + + +def get_plugin_version(plugin_name: str) -> Optional[dict]: + """ + Get the installed version information of a plugin, including package version and installation source. + + Args: + plugin_name: The name of the plugin module + + Returns: + Dictionary with 'package_version' and optionally 'source_version' (git tag), or None if not found + """ + installer = PluginInstaller(auto_install=False) + return installer._get_installed_plugin_version(plugin_name) + + +def list_plugin_versions(plugin_names: List[str] = None) -> dict: + """ + Get version information for a list of plugins, or all installed plugins. + + Args: + plugin_names: Optional list of plugin names to check. If None, attempts to detect installed plugins. + + Returns: + Dictionary mapping plugin names to their version info dicts (or None if version cannot be determined) + """ + installer = PluginInstaller(auto_install=False) + result = {} + + if plugin_names: + for plugin_name in plugin_names: + version_info = installer._get_installed_plugin_version(plugin_name) + result[plugin_name] = version_info + else: + # Try to detect installed plugins - this is limited without knowing what's installed + # For now, just return empty dict - callers should provide plugin names + pass + + return result + diff --git a/docs/features/PlugIns.md b/docs/features/PlugIns.md index f3f929b69c..940bf59305 100644 --- a/docs/features/PlugIns.md +++ b/docs/features/PlugIns.md @@ -72,6 +72,78 @@ The attributes are: - `minimum_minor_version` - specifies the minimum supported version (if a lower version is installed in another agent) - `path` - specifies the sub-path within the package for this version +## Dynamic Plugin Installation + +ACA-Py supports automatic installation of plugins at runtime from the acapy-plugins repository, eliminating the need to pre-install plugins in Docker images. This feature uses the `PluginInstaller` utility to automatically install missing plugins before loading them. + +### Auto-Install Configuration + +Plugins are automatically installed from the acapy-plugins repository using the `--auto-install-plugins` flag: + +- **Enable with current ACA-Py version** (flag without value): + ```bash + aca-py start --plugin webvh --auto-install-plugins + ``` + Installs from: `git+https://github.com/openwallet-foundation/acapy-plugins@{current-version}#subdirectory=webvh` + +- **Enable with specific version** (flag with version): + ```bash + aca-py start --plugin webvh --auto-install-plugins 1.3.2 + ``` + Installs from: `git+https://github.com/openwallet-foundation/acapy-plugins@1.3.2#subdirectory=webvh` + +- **Disabled by default** (flag not present): + ```bash + aca-py start --plugin webvh + ``` + Plugins must be pre-installed. + +### Installation Logging + +When plugins are installed, ACA-Py logs detailed information including: +- Plugin name and version being installed +- Installation source +- Success or failure status + +Example log output: +``` +INFO: Auto-installing plugins from acapy-plugins repository: webvh, connection_update (current ACA-Py version (1.4.0)) +INFO: Installing plugin: webvh (version: 1.4.0) +INFO: Successfully installed plugin: webvh (version: 1.4.0) +``` + +### Checking Installed Plugin Versions + +You can check the installed version of a plugin in several ways: + +**1. Via Admin API (after ACA-Py is running):** +```bash +curl http://localhost:8020/server/plugins +``` + +The response includes plugin versions: +```json +{ + "result": ["webvh"], + "plugins": [ + {"name": "webvh", "version": "0.1.0"} + ] +} +``` + +**2. Using Python:** +```python +from acapy_agent.utils.plugin_installer import get_plugin_version + +version = get_plugin_version("webvh") +print(f"webvh version: {version}") +``` + +**3. Using the helper script:** +```bash +python scripts/check_plugin_versions.py webvh +``` + ## Loading ACA-Py Plug-Ins at Runtime The load sequence for a plug-in (the "Startup" class depends on how ACA-Py is running - `upgrade`, `provision` or `start`): diff --git a/scripts/check_plugin_versions.py b/scripts/check_plugin_versions.py new file mode 100644 index 0000000000..c3de94b0c6 --- /dev/null +++ b/scripts/check_plugin_versions.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Check installed plugin versions.""" + +import sys +from acapy_agent.utils.plugin_installer import get_plugin_version, list_plugin_versions + + +def main(): + """Check plugin versions.""" + if len(sys.argv) < 2: + print("Usage: python check_plugin_versions.py [plugin_name2 ...]") + print("\nExample:") + print(" python check_plugin_versions.py webvh") + print(" python check_plugin_versions.py webvh connection_update") + sys.exit(1) + + plugin_names = sys.argv[1:] + versions = list_plugin_versions(plugin_names) + + print("Installed plugin versions:") + print("-" * 50) + for plugin_name, version in versions.items(): + if version: + print(f"{plugin_name:30s} {version}") + else: + print(f"{plugin_name:30s} (version unknown)") + + +if __name__ == "__main__": + main() + From 9f88e54b7f9daa88009e190166c56004602e6001 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 17 Nov 2025 13:34:26 -0500 Subject: [PATCH 02/23] simplify logic Signed-off-by: Patrick St-Louis --- acapy_agent/utils/plugin_installer.py | 553 ++++++-------------------- 1 file changed, 129 insertions(+), 424 deletions(-) diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index bcd169e09d..ad7c4a58b7 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -34,296 +34,158 @@ def __init__( self.plugin_version = plugin_version self.installed_plugins: Set[str] = set() - def _get_installed_plugin_version(self, plugin_name: str) -> Optional[dict]: - """ - Get the version information of an installed plugin, including package version and installation source. + def _try_get_package_version(self, names: List[str]) -> tuple[Optional[str], Optional[str]]: + """Try to get package version from multiple name variations. Returns (version, package_name).""" + for name in names: + try: + return get_package_version(name), name + except PackageNotFoundError: + continue + return None, None + + def _extract_source_version_from_direct_url(self, direct_url_data: dict) -> Optional[str]: + """Extract git tag/version from direct_url.json data.""" + vcs_info = direct_url_data.get("vcs_info", {}) + if vcs_info.get("vcs") == "git": + revision = vcs_info.get("requested_revision") + if revision and ("." in revision or revision in ["main", "master", "develop"]): + return revision - Args: - plugin_name: The name of the plugin module - - Returns: - Dictionary with 'package_version' and optionally 'source_version' (git tag), or None if not found - """ - result = {} + url = direct_url_data.get("url", "") + if "@" in url and "github.com" in url: + tag = url.split("@")[1].split("#")[0] + if "." in tag or tag in ["main", "master", "develop"]: + return tag + return None + + def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str]: + """Get source version from pip's .dist-info/direct_url.json file.""" + # Try pip show to find location + try: + result = subprocess.run( + [sys.executable, "-m", "pip", "show", package_name], + capture_output=True, text=True, check=False + ) + if result.returncode == 0: + location = next( + (line.split(":", 1)[1].strip() for line in result.stdout.split("\n") if line.startswith("Location:")), + None + ) + if location: + for item in Path(location).iterdir(): + if item.is_dir() and item.name.endswith(".dist-info"): + direct_url_file = item / "direct_url.json" + if direct_url_file.exists(): + try: + with open(direct_url_file) as f: + source_version = self._extract_source_version_from_direct_url(json.load(f)) + if source_version: + return source_version + except (json.JSONDecodeError, IOError): + pass + except Exception: + pass + # Fallback: search distributions + for dist in distributions(): + if dist.metadata["Name"].lower() == package_name.lower(): + dist_location = Path(dist.location) + pkg_name, pkg_version = dist.metadata["Name"], dist.version + for name_variant in [ + f"{pkg_name}-{pkg_version}.dist-info", + f"{pkg_name.replace('-', '_')}-{pkg_version}.dist-info", + f"{pkg_name.replace('.', '_')}-{pkg_version}.dist-info", + ]: + direct_url_file = dist_location / name_variant / "direct_url.json" + if direct_url_file.exists(): + try: + with open(direct_url_file) as f: + source_version = self._extract_source_version_from_direct_url(json.load(f)) + if source_version: + return source_version + except (json.JSONDecodeError, IOError): + pass + + # Last resort: pip freeze try: - # Try to get version from package metadata (pip installed packages) - # First try the module name directly - package_name_to_check = None + result = subprocess.run( + [sys.executable, "-m", "pip", "freeze"], + capture_output=True, text=True, check=False + ) + if result.returncode == 0: + for line in result.stdout.split("\n"): + if package_name.lower() in line.lower() and "@ git+" in line: + git_part = line.split("@ git+")[1] + if "@" in git_part: + tag = git_part.split("@")[1].split("#")[0] + if "." in tag or tag in ["main", "master", "develop"]: + return tag + except Exception: + pass + return None + + def _get_installed_plugin_version(self, plugin_name: str) -> Optional[dict]: + """Get version info of an installed plugin (package version and source version/git tag).""" + result = {} + + # Try to get package version from various name variations + name_variants = [ + plugin_name, + plugin_name.replace("_", "-"), + f"acapy-plugin-{plugin_name.replace('_', '-')}", + ] + package_version, package_name = self._try_get_package_version(name_variants) + + if not package_version: + # Try __version__ attribute try: - version = get_package_version(plugin_name) - package_name_to_check = plugin_name - result["package_version"] = version - LOGGER.debug("Found version for plugin '%s' via metadata: %s", plugin_name, version) - except PackageNotFoundError: + module = importlib.import_module(plugin_name) + if hasattr(module, "__version__"): + package_version = str(module.__version__) + except (ImportError, AttributeError): pass - - # Try common package name variations - if "package_version" not in result: - package_name = plugin_name.replace("_", "-") - try: - version = get_package_version(package_name) - package_name_to_check = package_name - result["package_version"] = version - LOGGER.debug("Found version for plugin '%s' via metadata (as %s): %s", plugin_name, package_name, version) - except PackageNotFoundError: - pass - - # Try acapy-plugin- prefix - if "package_version" not in result: - try: - prefixed_name = f"acapy-plugin-{plugin_name.replace('_', '-')}" - version = get_package_version(prefixed_name) - package_name_to_check = prefixed_name - result["package_version"] = version - LOGGER.debug("Found version for plugin '%s' via metadata (as %s): %s", plugin_name, prefixed_name, version) - except PackageNotFoundError: - pass - - # Try to get version from module's __version__ attribute - if "package_version" not in result: - try: - module = importlib.import_module(plugin_name) - if hasattr(module, "__version__"): - version = str(module.__version__) - result["package_version"] = version - LOGGER.debug("Found version for plugin '%s' via __version__ attribute: %s", plugin_name, version) - except (ImportError, AttributeError) as e: - LOGGER.debug("Could not get __version__ from plugin '%s': %s", plugin_name, e) - - # Try to get installation source (git tag) from pip metadata - if package_name_to_check: - try: - # First, try pip show to get direct URL information - try: - pip_show_result = subprocess.run( - [sys.executable, "-m", "pip", "show", package_name_to_check], - capture_output=True, - text=True, - check=False, - ) - if pip_show_result.returncode == 0: - # Check for "Location:" field to find the package directory - location = None - for line in pip_show_result.stdout.split("\n"): - if line.startswith("Location:"): - location = line.split(":", 1)[1].strip() - break - - # Try to find direct_url.json in all possible locations - if location: - location_path = Path(location) - # Try to find .dist-info directory for this package - for item in location_path.iterdir(): - if item.is_dir() and item.name.endswith(".dist-info"): - direct_url_file = item / "direct_url.json" - if direct_url_file.exists(): - try: - with open(direct_url_file, "r") as f: - direct_url_data = json.load(f) - # direct_url.json can have different formats - # Format 1: {"url": "git+https://...@tag#subdirectory=..."} - # Format 2: {"vcs_info": {...}, "url": "..."} - url_info = direct_url_data.get("url", "") - - # Try to extract from vcs_info if available - vcs_info = direct_url_data.get("vcs_info", {}) - if vcs_info and vcs_info.get("vcs") == "git": - requested_revision = vcs_info.get("requested_revision") - if requested_revision: - result["source_version"] = requested_revision - LOGGER.debug("Found source version from vcs_info for plugin '%s': %s", plugin_name, requested_revision) - break - - # Fallback: Extract from URL - if url_info and "@" in url_info and "github.com" in url_info: - parts = url_info.split("@") - if len(parts) > 1: - tag_part = parts[1].split("#")[0] if "#" in parts[1] else parts[1] - # Check if it looks like a version tag (not a commit hash) - if "." in tag_part or tag_part in ["main", "master", "develop"]: - result["source_version"] = tag_part - LOGGER.debug("Found source version from URL for plugin '%s': %s", plugin_name, tag_part) - break - except (json.JSONDecodeError, IOError) as e: - LOGGER.debug("Could not read direct_url.json for plugin '%s': %s", plugin_name, e) - except Exception as e: - LOGGER.debug("Could not get installation source from pip show for plugin '%s': %s", plugin_name, e) - - # Fallback: Try to find distribution and check direct_url.json - if "source_version" not in result: - for dist in distributions(): - if dist.metadata["Name"].lower() == package_name_to_check.lower(): - # Try multiple path formats for direct_url.json - dist_location = Path(dist.location) - package_name = dist.metadata["Name"] - package_version = dist.version - - # Try different naming conventions for .dist-info directory - dist_info_names = [ - f"{package_name}-{package_version}.dist-info", - f"{package_name.replace('-', '_')}-{package_version}.dist-info", - f"{package_name.replace('.', '_')}-{package_version}.dist-info", - ] - - for dist_info_name in dist_info_names: - dist_info_dir = dist_location / dist_info_name - direct_url_file = dist_info_dir / "direct_url.json" - - if direct_url_file.exists(): - try: - with open(direct_url_file, "r") as f: - direct_url_data = json.load(f) - vcs_info = direct_url_data.get("vcs_info", {}) - if vcs_info and vcs_info.get("vcs") == "git": - requested_revision = vcs_info.get("requested_revision") - if requested_revision: - result["source_version"] = requested_revision - LOGGER.debug("Found source version from direct_url.json vcs_info for plugin '%s': %s", plugin_name, requested_revision) - break - - url_info = direct_url_data.get("url", "") - if url_info and "@" in url_info and "github.com" in url_info: - parts = url_info.split("@") - if len(parts) > 1: - tag_part = parts[1].split("#")[0] if "#" in parts[1] else parts[1] - if "." in tag_part or tag_part in ["main", "master", "develop"]: - result["source_version"] = tag_part - LOGGER.debug("Found source version from direct_url.json URL for plugin '%s': %s", plugin_name, tag_part) - break - except (json.JSONDecodeError, IOError) as e: - LOGGER.debug("Could not read direct_url.json for plugin '%s': %s", plugin_name, e) - - if "source_version" in result: - break - - # Last fallback: Try pip freeze to get installation source - if "source_version" not in result: - try: - pip_freeze_result = subprocess.run( - [sys.executable, "-m", "pip", "freeze"], - capture_output=True, - text=True, - check=False, - ) - if pip_freeze_result.returncode == 0: - for line in pip_freeze_result.stdout.split("\n"): - # Look for package installed from git - # Format: package==version @ git+https://github.com/...@tag#subdirectory=... - # or: package @ git+https://github.com/...@tag#subdirectory=... - line_lower = line.lower() - if (package_name_to_check.lower() in line_lower or - package_name_to_check.replace("-", "_").lower() in line_lower) and "@ git+" in line: - # Extract the git URL part - if "@ git+" in line: - git_part = line.split("@ git+")[1] - if "@" in git_part: - tag_part = git_part.split("@")[1].split("#")[0] if "#" in git_part.split("@")[1] else git_part.split("@")[1] - if "." in tag_part or tag_part in ["main", "master", "develop"]: - result["source_version"] = tag_part - LOGGER.debug("Found source version from pip freeze for plugin '%s': %s", plugin_name, tag_part) - break - except Exception as e: - LOGGER.debug("Could not get installation source from pip freeze for plugin '%s': %s", plugin_name, e) - - except Exception as e: - LOGGER.debug("Could not get installation source for plugin '%s': %s", plugin_name, e) - - # Return the result if we found at least package_version - if "package_version" in result: - return result - - except Exception as e: - LOGGER.debug("Error determining version for plugin '%s': %s", plugin_name, e) - LOGGER.debug("Could not determine version for plugin '%s' using any method", plugin_name) - return None + if not package_version: + return None + + result["package_version"] = package_version + + # Try to get source version (git tag) if we found a package name + if package_name: + source_version = self._get_source_version_from_dist_info(package_name) + if source_version: + result["source_version"] = source_version + + return result def _get_plugin_source(self, plugin_name: str) -> str: """Get the installation source for a plugin from acapy-plugins repository.""" - # Install from acapy-plugins repo - # Use provided version or current ACA-Py version version_to_use = self.plugin_version if self.plugin_version is not None else __version__ - plugin_source = ( + return ( f"git+https://github.com/openwallet-foundation/acapy-plugins@{version_to_use}" f"#subdirectory={plugin_name}" ) - LOGGER.info( - "Installing plugin '%s' from acapy-plugins repository (version %s)", - plugin_name, - version_to_use, - ) - LOGGER.debug("Installation source: %s", plugin_source) - return plugin_source def _install_plugin(self, plugin_source: str, plugin_name: str = None, upgrade: bool = False) -> bool: - """ - Install a plugin using pip. - - Args: - plugin_source: The pip installable source (package name, git URL, etc.) - plugin_name: Optional plugin name for logging - upgrade: Whether to upgrade/reinstall if already installed - - Returns: - True if installation succeeded, False otherwise - """ + """Install a plugin using pip.""" try: - # Extract version from source for logging - version_info = "" - if "@" in plugin_source: - # Extract version/tag from git URL or package version - parts = plugin_source.split("@") - if len(parts) > 1: - version_part = parts[1].split("#")[0] if "#" in parts[1] else parts[1] - version_info = f" (version: {version_part})" - - log_name = plugin_name if plugin_name else plugin_source + cmd = [sys.executable, "-m", "pip", "install", "--no-cache-dir"] if upgrade: - LOGGER.info("Upgrading plugin '%s'%s", log_name, version_info) - LOGGER.debug("Upgrade source: %s", plugin_source) - else: - LOGGER.info("Installing plugin '%s'%s", log_name, version_info) - LOGGER.debug("Installation source: %s", plugin_source) - - # Use pip programmatically to install - cmd = [ - sys.executable, - "-m", - "pip", - "install", - "--no-cache-dir", - ] - - if upgrade: - # Use --upgrade --force-reinstall to ensure correct version cmd.extend(["--upgrade", "--force-reinstall", "--no-deps"]) - cmd.append(plugin_source) - # Run pip install - result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=False, - ) - + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode == 0: - if upgrade: - LOGGER.info("Successfully upgraded plugin '%s'%s", log_name, version_info) - else: - LOGGER.info("Successfully installed plugin '%s'%s", log_name, version_info) + action = "Upgraded" if upgrade else "Installed" + LOGGER.info("%s plugin: %s", action, plugin_name or plugin_source) return True else: action = "upgrade" if upgrade else "install" - LOGGER.error( - "Failed to %s plugin '%s'%s: %s", action, log_name, version_info, result.stderr - ) + LOGGER.error("Failed to %s plugin '%s': %s", action, plugin_name or plugin_source, result.stderr) return False - except Exception as e: - LOGGER.error("Error installing plugin %s: %s", plugin_source, e) + LOGGER.error("Error installing plugin %s: %s", plugin_name or plugin_source, e) return False def ensure_plugin_installed(self, plugin_name: str) -> bool: @@ -362,91 +224,17 @@ def ensure_plugin_installed(self, plugin_name: str) -> bool: if plugin_exists and not self.plugin_version: # No explicit version specified - using current ACA-Py version - # If plugin exists and is importable, assume it's fine (might be from git/main) if installed_package_version: - # Try to match with ACA-Py version if possible normalized_installed = installed_package_version.split("+")[0].split("-")[0].strip() normalized_target = target_version.split("+")[0].split("-")[0].strip() if normalized_installed == normalized_target: - LOGGER.info( - "Plugin '%s' is already installed with correct version: %s", - plugin_name, - installed_package_version, - ) self.installed_plugins.add(plugin_name) return True - # Plugin exists but version doesn't match or can't be determined - # Since we're using current ACA-Py version, reinstall to be safe - LOGGER.info( - "Plugin '%s' exists but version check inconclusive. " - "Reinstalling to ensure correct version (%s)...", - plugin_name, - target_version, - ) elif plugin_exists and self.plugin_version: # Explicit version specified - check if source version matches if installed_source_version and installed_source_version == target_version: - # Source version matches - check if we should still reinstall - LOGGER.info( - "Plugin '%s' is already installed from source version %s (package version: %s). " - "Skipping reinstallation.", - plugin_name, - installed_source_version, - installed_package_version or "unknown", - ) self.installed_plugins.add(plugin_name) return True - elif installed_package_version: - # Try to compare package versions - normalized_installed = installed_package_version.split("+")[0].split("-")[0].strip() - normalized_target = target_version.split("+")[0].split("-")[0].strip() - # Check if it looks like a version number match (not a git ref) - try: - if (normalized_installed.count(".") >= 1 and - normalized_target.count(".") >= 1 and - normalized_installed == normalized_target): - # Package version matches, but source might be different - # Still reinstall to ensure correct git tag - LOGGER.info( - "Plugin '%s' package version matches (%s) but checking source version...", - plugin_name, - installed_package_version, - ) - except Exception: - pass - # Version mismatch or can't determine - upgrade - if installed_source_version: - LOGGER.info( - "Plugin '%s' source version mismatch: installed=%s, target=%s. " - "Upgrading to version %s...", - plugin_name, - installed_source_version, - target_version, - target_version, - ) - elif installed_package_version: - LOGGER.info( - "Plugin '%s' version mismatch: installed=%s, target=%s. " - "Upgrading to version %s...", - plugin_name, - installed_package_version, - target_version, - target_version, - ) - else: - LOGGER.info( - "Plugin '%s' is installed but version cannot be determined. " - "Upgrading to ensure correct version (%s)...", - plugin_name, - target_version, - ) - elif not plugin_exists: - # Plugin doesn't exist - install it - LOGGER.info( - "Plugin '%s' not found. Installing version %s...", - plugin_name, - target_version, - ) if not self.auto_install: LOGGER.warning( @@ -473,92 +261,9 @@ def ensure_plugin_installed(self, plugin_name: str) -> bool: ) return False - # Check version after successful import - verified_version_info = self._get_installed_plugin_version(plugin_name) - if verified_version_info: - verified_package_version = verified_version_info.get("package_version") - verified_source_version = verified_version_info.get("source_version") - - # Check if source version matches (for git-installed packages) - if verified_source_version and verified_source_version == target_version: - self.installed_plugins.add(plugin_name) - if is_upgrade: - LOGGER.info( - "Plugin '%s' successfully upgraded to source version %s (package version: %s)", - plugin_name, - verified_source_version, - verified_package_version or "unknown", - ) - else: - LOGGER.info( - "Plugin '%s' successfully installed (source version: %s, package version: %s)", - plugin_name, - verified_source_version, - verified_package_version or "unknown", - ) - return True - elif verified_package_version: - # Normalize package versions for comparison - normalized_installed = verified_package_version.split("+")[0].split("-")[0].strip() - normalized_target = target_version.split("+")[0].split("-")[0].strip() - - if normalized_installed == normalized_target: - self.installed_plugins.add(plugin_name) - if is_upgrade: - LOGGER.info( - "Plugin '%s' successfully upgraded (package version: %s)", - plugin_name, - verified_package_version, - ) - else: - LOGGER.info( - "Plugin '%s' successfully installed (package version: %s)", - plugin_name, - verified_package_version, - ) - return True - else: - LOGGER.warning( - "Plugin '%s' installed package version (%s) doesn't match target (%s), " - "but plugin is importable. Continuing with installed version.", - plugin_name, - verified_package_version, - target_version, - ) - self.installed_plugins.add(plugin_name) - return True - else: - # Version info available but no package or source version - self.installed_plugins.add(plugin_name) - if is_upgrade: - LOGGER.info( - "Plugin '%s' reinstalled successfully (version cannot be verified, target was %s)", - plugin_name, - target_version, - ) - else: - LOGGER.info( - "Plugin '%s' installed successfully (version cannot be verified, target was %s)", - plugin_name, - target_version, - ) - return True - else: - # Can't determine version, but plugin is importable - consider it successful - if is_upgrade: - LOGGER.info( - "Plugin '%s' reinstalled successfully (version cannot be verified, target was %s)", - plugin_name, - target_version, - ) - else: - LOGGER.info( - "Plugin '%s' installed successfully (version cannot be verified, target was %s)", - plugin_name, - target_version, - ) - self.installed_plugins.add(plugin_name) - return True + # Plugin installed and importable - success + self.installed_plugins.add(plugin_name) + return True else: LOGGER.error( "Failed to install plugin '%s' (version %s)", From e36155935c526cd2ce6cb047b7a0cbcf760c2bf4 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 17 Nov 2025 13:39:03 -0500 Subject: [PATCH 03/23] linting Signed-off-by: Patrick St-Louis --- acapy_agent/admin/routes.py | 35 ++--- acapy_agent/commands/start.py | 11 +- acapy_agent/config/argparse.py | 10 +- acapy_agent/utils/plugin_installer.py | 182 +++++++++++++++++--------- docs/features/PlugIns.md | 18 +-- scripts/check_plugin_versions.py | 31 ----- 6 files changed, 164 insertions(+), 123 deletions(-) delete mode 100644 scripts/check_plugin_versions.py diff --git a/acapy_agent/admin/routes.py b/acapy_agent/admin/routes.py index 0df1499c7a..91cf08c2f5 100644 --- a/acapy_agent/admin/routes.py +++ b/acapy_agent/admin/routes.py @@ -77,7 +77,7 @@ async def plugins_handler(request: web.BaseRequest): """ registry = request.app["context"].inject_or(PluginRegistry) plugins = registry and sorted(registry.plugin_names) or [] - + # Get versions for external plugins only (skip built-in acapy_agent plugins) external_plugins = [] for plugin_name in plugins: @@ -85,22 +85,25 @@ async def plugins_handler(request: web.BaseRequest): # External plugin - try to get version info version_info = get_plugin_version(plugin_name) if version_info: - external_plugins.append({ - "name": plugin_name, - "package_version": version_info.get("package_version"), - "source_version": version_info.get("source_version"), # Git tag used for installation - }) + external_plugins.append( + { + "name": plugin_name, + "package_version": version_info.get("package_version"), + "source_version": version_info.get( + "source_version" + ), # Git tag used for installation + } + ) else: - external_plugins.append({ - "name": plugin_name, - "package_version": None, - "source_version": None, - }) - - return web.json_response({ - "result": plugins, - "external": external_plugins - }) + external_plugins.append( + { + "name": plugin_name, + "package_version": None, + "source_version": None, + } + ) + + return web.json_response({"result": plugins, "external": external_plugins}) @docs(tags=["server"], summary="Fetch the server configuration") diff --git a/acapy_agent/commands/start.py b/acapy_agent/commands/start.py index 97e2187954..2993c1dc53 100644 --- a/acapy_agent/commands/start.py +++ b/acapy_agent/commands/start.py @@ -75,7 +75,11 @@ async def run_app(argv: Sequence[str] = None): else f"current ACA-Py version ({acapy_version})" ) # Always print to console for visibility - print(f"Auto-installing plugins from acapy-plugins repository: {', '.join(external_plugins)} ({version_info})") + plugins_str = ", ".join(external_plugins) + print( + f"Auto-installing plugins from acapy-plugins repository: " + f"{plugins_str} ({version_info})" + ) LOGGER.info( "Auto-installing plugins from acapy-plugins repository: %s (%s)", ", ".join(external_plugins), @@ -94,8 +98,9 @@ async def run_app(argv: Sequence[str] = None): ", ".join(failed_plugins), ) LOGGER.error( - "Please ensure these plugins are available in the acapy-plugins repository " - "or install them manually before starting ACA-Py." + "Please ensure these plugins are available in the " + "acapy-plugins repository or install them manually before " + "starting ACA-Py." ) sys.exit(1) diff --git a/acapy_agent/config/argparse.py b/acapy_agent/config/argparse.py index cbc0efe601..702c74b180 100644 --- a/acapy_agent/config/argparse.py +++ b/acapy_agent/config/argparse.py @@ -714,9 +714,10 @@ def add_arguments(self, parser: ArgumentParser): metavar="", env_var="ACAPY_AUTO_INSTALL_PLUGINS", help=( - "Automatically install missing plugins from the acapy-plugins repository. " - "If specified without a value, uses current ACA-Py version. " - "If a version is provided (e.g., 1.3.2), uses that version for plugin installation. " + "Automatically install missing plugins from the " + "acapy-plugins repository. If specified without a value, uses " + "current ACA-Py version. If a version is provided (e.g., 1.3.2), " + "uses that version for plugin installation. " "Default: false (disabled)." ), ) @@ -819,7 +820,8 @@ def get_settings(self, args: Namespace) -> dict: reduce(lambda v, k: {k: v}, key.split(".")[::-1], value), ) - # Auto-install plugins: can be True (use current version), version string (e.g., "1.3.2"), or False + # Auto-install plugins: can be True (use current version), + # version string (e.g., "1.3.2"), or False if hasattr(args, "auto_install_plugins"): auto_install_value = args.auto_install_plugins if auto_install_value is True: diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index ad7c4a58b7..ccdf3b279c 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -5,71 +5,94 @@ import logging import subprocess import sys +from importlib.metadata import ( + PackageNotFoundError, + distributions, +) +from importlib.metadata import ( + version as get_package_version, +) from pathlib import Path from typing import List, Optional, Set -from importlib.metadata import version as get_package_version, PackageNotFoundError, distributions - from ..version import __version__ LOGGER = logging.getLogger(__name__) class PluginInstaller: - """Handles dynamic installation of ACA-Py plugins from the acapy-plugins repository.""" + """Handles dynamic installation of ACA-Py plugins from acapy-plugins.""" def __init__( self, auto_install: bool = True, plugin_version: Optional[str] = None, ): - """ - Initialize the plugin installer. + """Initialize the plugin installer. Args: auto_install: Whether to automatically install missing plugins - plugin_version: Version to use for plugin installation. If None, uses current ACA-Py version. + plugin_version: Version to use for plugin installation. + If None, uses current ACA-Py version. + """ self.auto_install = auto_install self.plugin_version = plugin_version self.installed_plugins: Set[str] = set() - def _try_get_package_version(self, names: List[str]) -> tuple[Optional[str], Optional[str]]: - """Try to get package version from multiple name variations. Returns (version, package_name).""" + def _try_get_package_version( + self, names: List[str] + ) -> tuple[Optional[str], Optional[str]]: + """Try to get package version from multiple name variations. + + Returns: + (version, package_name) tuple + + """ for name in names: try: return get_package_version(name), name except PackageNotFoundError: continue return None, None - - def _extract_source_version_from_direct_url(self, direct_url_data: dict) -> Optional[str]: + + def _extract_source_version_from_direct_url( + self, direct_url_data: dict + ) -> Optional[str]: """Extract git tag/version from direct_url.json data.""" vcs_info = direct_url_data.get("vcs_info", {}) if vcs_info.get("vcs") == "git": revision = vcs_info.get("requested_revision") - if revision and ("." in revision or revision in ["main", "master", "develop"]): + if revision and ( + "." in revision or revision in ["main", "master", "develop"] + ): return revision - + url = direct_url_data.get("url", "") if "@" in url and "github.com" in url: tag = url.split("@")[1].split("#")[0] if "." in tag or tag in ["main", "master", "develop"]: return tag return None - + def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str]: """Get source version from pip's .dist-info/direct_url.json file.""" # Try pip show to find location try: result = subprocess.run( [sys.executable, "-m", "pip", "show", package_name], - capture_output=True, text=True, check=False + capture_output=True, + text=True, + check=False, ) if result.returncode == 0: location = next( - (line.split(":", 1)[1].strip() for line in result.stdout.split("\n") if line.startswith("Location:")), - None + ( + line.split(":", 1)[1].strip() + for line in result.stdout.split("\n") + if line.startswith("Location:") + ), + None, ) if location: for item in Path(location).iterdir(): @@ -78,14 +101,18 @@ def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str] if direct_url_file.exists(): try: with open(direct_url_file) as f: - source_version = self._extract_source_version_from_direct_url(json.load(f)) + source_version = ( + self._extract_source_version_from_direct_url( + json.load(f) + ) + ) if source_version: return source_version except (json.JSONDecodeError, IOError): pass except Exception: pass - + # Fallback: search distributions for dist in distributions(): if dist.metadata["Name"].lower() == package_name.lower(): @@ -100,17 +127,23 @@ def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str] if direct_url_file.exists(): try: with open(direct_url_file) as f: - source_version = self._extract_source_version_from_direct_url(json.load(f)) + source_version = ( + self._extract_source_version_from_direct_url( + json.load(f) + ) + ) if source_version: return source_version except (json.JSONDecodeError, IOError): pass - + # Last resort: pip freeze try: result = subprocess.run( [sys.executable, "-m", "pip", "freeze"], - capture_output=True, text=True, check=False + capture_output=True, + text=True, + check=False, ) if result.returncode == 0: for line in result.stdout.split("\n"): @@ -123,11 +156,14 @@ def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str] except Exception: pass return None - + def _get_installed_plugin_version(self, plugin_name: str) -> Optional[dict]: - """Get version info of an installed plugin (package version and source version/git tag).""" + """Get version info of an installed plugin. + + Returns package version and source version (git tag) if available. + """ result = {} - + # Try to get package version from various name variations name_variants = [ plugin_name, @@ -135,7 +171,7 @@ def _get_installed_plugin_version(self, plugin_name: str) -> Optional[dict]: f"acapy-plugin-{plugin_name.replace('_', '-')}", ] package_version, package_name = self._try_get_package_version(name_variants) - + if not package_version: # Try __version__ attribute try: @@ -144,29 +180,33 @@ def _get_installed_plugin_version(self, plugin_name: str) -> Optional[dict]: package_version = str(module.__version__) except (ImportError, AttributeError): pass - + if not package_version: return None - + result["package_version"] = package_version - + # Try to get source version (git tag) if we found a package name if package_name: source_version = self._get_source_version_from_dist_info(package_name) if source_version: result["source_version"] = source_version - + return result def _get_plugin_source(self, plugin_name: str) -> str: """Get the installation source for a plugin from acapy-plugins repository.""" - version_to_use = self.plugin_version if self.plugin_version is not None else __version__ + version_to_use = ( + self.plugin_version if self.plugin_version is not None else __version__ + ) return ( f"git+https://github.com/openwallet-foundation/acapy-plugins@{version_to_use}" f"#subdirectory={plugin_name}" ) - def _install_plugin(self, plugin_source: str, plugin_name: str = None, upgrade: bool = False) -> bool: + def _install_plugin( + self, plugin_source: str, plugin_name: str = None, upgrade: bool = False + ) -> bool: """Install a plugin using pip.""" try: cmd = [sys.executable, "-m", "pip", "install", "--no-cache-dir"] @@ -175,32 +215,43 @@ def _install_plugin(self, plugin_source: str, plugin_name: str = None, upgrade: cmd.append(plugin_source) result = subprocess.run(cmd, capture_output=True, text=True, check=False) - + if result.returncode == 0: action = "Upgraded" if upgrade else "Installed" LOGGER.info("%s plugin: %s", action, plugin_name or plugin_source) return True else: action = "upgrade" if upgrade else "install" - LOGGER.error("Failed to %s plugin '%s': %s", action, plugin_name or plugin_source, result.stderr) + LOGGER.error( + "Failed to %s plugin '%s': %s", + action, + plugin_name or plugin_source, + result.stderr, + ) return False except Exception as e: - LOGGER.error("Error installing plugin %s: %s", plugin_name or plugin_source, e) + LOGGER.error( + "Error installing plugin %s: %s", plugin_name or plugin_source, e + ) return False def ensure_plugin_installed(self, plugin_name: str) -> bool: - """ - Ensure a plugin is installed with the correct version. If not, or if version doesn't match, attempt to install it. + """Ensure a plugin is installed with the correct version. + + If not installed or version doesn't match, attempt to install it. Args: plugin_name: The name of the plugin module Returns: True if plugin is available (was already installed or successfully installed) + """ # Get target version - target_version = self.plugin_version if self.plugin_version is not None else __version__ - + target_version = ( + self.plugin_version if self.plugin_version is not None else __version__ + ) + # Check if plugin can be imported (exists) plugin_exists = False try: @@ -208,7 +259,7 @@ def ensure_plugin_installed(self, plugin_name: str) -> bool: plugin_exists = True except ImportError: plugin_exists = False - + # Get installed version info if plugin exists installed_version_info = None installed_package_version = None @@ -218,14 +269,17 @@ def ensure_plugin_installed(self, plugin_name: str) -> bool: if installed_version_info: installed_package_version = installed_version_info.get("package_version") installed_source_version = installed_version_info.get("source_version") - - # For git-installed packages, we check both package version and source version (git tag). - # When a version is explicitly specified, we check if the source version matches. - + + # For git-installed packages, we check both package version and source + # version (git tag). When a version is explicitly specified, we check + # if the source version matches. + if plugin_exists and not self.plugin_version: # No explicit version specified - using current ACA-Py version if installed_package_version: - normalized_installed = installed_package_version.split("+")[0].split("-")[0].strip() + normalized_installed = ( + installed_package_version.split("+")[0].split("-")[0].strip() + ) normalized_target = target_version.split("+")[0].split("-")[0].strip() if normalized_installed == normalized_target: self.installed_plugins.add(plugin_name) @@ -249,7 +303,9 @@ def ensure_plugin_installed(self, plugin_name: str) -> bool: plugin_source = self._get_plugin_source(plugin_name) # Attempt installation (with upgrade to ensure correct version) - if self._install_plugin(plugin_source, plugin_name=plugin_name, upgrade=is_upgrade): + if self._install_plugin( + plugin_source, plugin_name=plugin_name, upgrade=is_upgrade + ): # Verify installation - first check if it can be imported try: importlib.import_module(plugin_name) @@ -260,7 +316,7 @@ def ensure_plugin_installed(self, plugin_name: str) -> bool: e, ) return False - + # Plugin installed and importable - success self.installed_plugins.add(plugin_name) return True @@ -274,14 +330,14 @@ def ensure_plugin_installed(self, plugin_name: str) -> bool: return False def ensure_plugins_installed(self, plugin_names: List[str]) -> List[str]: - """ - Ensure multiple plugins are installed. + """Ensure multiple plugins are installed. Args: plugin_names: List of plugin module names Returns: List of plugin names that failed to install + """ failed = [] for plugin_name in plugin_names: @@ -296,16 +352,17 @@ def install_plugins_from_config( auto_install: bool = True, plugin_version: Optional[str] = None, ) -> List[str]: - """ - Install plugins from a list of plugin names. + """Install plugins from a list of plugin names. Args: plugin_names: List of plugin module names to install auto_install: Whether to automatically install missing plugins - plugin_version: Version to use for plugin installation. If None, uses current ACA-Py version. + plugin_version: Version to use for plugin installation. + If None, uses current ACA-Py version. Returns: List of plugin names that failed to install + """ if not plugin_names: return [] @@ -319,28 +376,33 @@ def install_plugins_from_config( def get_plugin_version(plugin_name: str) -> Optional[dict]: - """ - Get the installed version information of a plugin, including package version and installation source. + """Get the installed version information of a plugin. + + Includes package version and installation source. Args: plugin_name: The name of the plugin module Returns: - Dictionary with 'package_version' and optionally 'source_version' (git tag), or None if not found + Dictionary with 'package_version' and optionally 'source_version' + (git tag), or None if not found + """ installer = PluginInstaller(auto_install=False) return installer._get_installed_plugin_version(plugin_name) def list_plugin_versions(plugin_names: List[str] = None) -> dict: - """ - Get version information for a list of plugins, or all installed plugins. + """Get version information for a list of plugins, or all installed plugins. Args: - plugin_names: Optional list of plugin names to check. If None, attempts to detect installed plugins. + plugin_names: Optional list of plugin names to check. + If None, attempts to detect installed plugins. Returns: - Dictionary mapping plugin names to their version info dicts (or None if version cannot be determined) + Dictionary mapping plugin names to their version info dicts + (or None if version cannot be determined) + """ installer = PluginInstaller(auto_install=False) result = {} @@ -350,9 +412,9 @@ def list_plugin_versions(plugin_names: List[str] = None) -> dict: version_info = installer._get_installed_plugin_version(plugin_name) result[plugin_name] = version_info else: - # Try to detect installed plugins - this is limited without knowing what's installed + # Try to detect installed plugins - limited without knowing what's + # installed # For now, just return empty dict - callers should provide plugin names pass return result - diff --git a/docs/features/PlugIns.md b/docs/features/PlugIns.md index 940bf59305..556b706444 100644 --- a/docs/features/PlugIns.md +++ b/docs/features/PlugIns.md @@ -125,8 +125,12 @@ The response includes plugin versions: ```json { "result": ["webvh"], - "plugins": [ - {"name": "webvh", "version": "0.1.0"} + "external": [ + { + "name": "webvh", + "package_version": "0.1.0", + "source_version": "1.3.1" + } ] } ``` @@ -135,13 +139,9 @@ The response includes plugin versions: ```python from acapy_agent.utils.plugin_installer import get_plugin_version -version = get_plugin_version("webvh") -print(f"webvh version: {version}") -``` - -**3. Using the helper script:** -```bash -python scripts/check_plugin_versions.py webvh +version_info = get_plugin_version("webvh") +print(f"webvh package version: {version_info['package_version']}") +print(f"webvh source version: {version_info.get('source_version')}") ``` ## Loading ACA-Py Plug-Ins at Runtime diff --git a/scripts/check_plugin_versions.py b/scripts/check_plugin_versions.py deleted file mode 100644 index c3de94b0c6..0000000000 --- a/scripts/check_plugin_versions.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -"""Check installed plugin versions.""" - -import sys -from acapy_agent.utils.plugin_installer import get_plugin_version, list_plugin_versions - - -def main(): - """Check plugin versions.""" - if len(sys.argv) < 2: - print("Usage: python check_plugin_versions.py [plugin_name2 ...]") - print("\nExample:") - print(" python check_plugin_versions.py webvh") - print(" python check_plugin_versions.py webvh connection_update") - sys.exit(1) - - plugin_names = sys.argv[1:] - versions = list_plugin_versions(plugin_names) - - print("Installed plugin versions:") - print("-" * 50) - for plugin_name, version in versions.items(): - if version: - print(f"{plugin_name:30s} {version}") - else: - print(f"{plugin_name:30s} (version unknown)") - - -if __name__ == "__main__": - main() - From b0123c98daf288c7bc0fd197e7c8aba741ea831f Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 17 Nov 2025 13:41:01 -0500 Subject: [PATCH 04/23] linting Signed-off-by: Patrick St-Louis --- acapy_agent/admin/routes.py | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/acapy_agent/admin/routes.py b/acapy_agent/admin/routes.py index 91cf08c2f5..2c1f7ad885 100644 --- a/acapy_agent/admin/routes.py +++ b/acapy_agent/admin/routes.py @@ -83,25 +83,14 @@ async def plugins_handler(request: web.BaseRequest): for plugin_name in plugins: if not plugin_name.startswith("acapy_agent."): # External plugin - try to get version info - version_info = get_plugin_version(plugin_name) - if version_info: - external_plugins.append( - { - "name": plugin_name, - "package_version": version_info.get("package_version"), - "source_version": version_info.get( - "source_version" - ), # Git tag used for installation - } - ) - else: - external_plugins.append( - { - "name": plugin_name, - "package_version": None, - "source_version": None, - } - ) + version_info = get_plugin_version(plugin_name) or {} + external_plugins.append( + { + "name": plugin_name, + "package_version": version_info.get("package_version", None), + "source_version": version_info.get("source_version", None), + } + ) return web.json_response({"result": plugins, "external": external_plugins}) From 3523966033e23c82b3d878f0cac5962a3093530d Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 17 Nov 2025 13:41:45 -0500 Subject: [PATCH 05/23] linting Signed-off-by: Patrick St-Louis --- acapy_agent/admin/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acapy_agent/admin/server.py b/acapy_agent/admin/server.py index cf64f5d2fa..bd55141817 100644 --- a/acapy_agent/admin/server.py +++ b/acapy_agent/admin/server.py @@ -395,7 +395,7 @@ async def setup_context(request: web.Request, handler): server_routes = [ web.get("/", redirect_handler, allow_head=True), - web.get("/server/plugins", plugins_handler, allow_head=False), + web.get("/plugins", plugins_handler, allow_head=False), web.get("/status", status_handler, allow_head=False), web.get("/status/config", config_handler, allow_head=False), web.post("/status/reset", status_reset_handler), From e8dda25d1fa821c3a0f8451792f341d5d60e4199 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 17 Nov 2025 14:09:33 -0500 Subject: [PATCH 06/23] fix repo url parsing security issue Signed-off-by: Patrick St-Louis --- acapy_agent/utils/plugin_installer.py | 319 ++++++++++++++++++++++++-- 1 file changed, 299 insertions(+), 20 deletions(-) diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index ccdf3b279c..3b3d267aa3 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -3,6 +3,8 @@ import importlib import json import logging +import os +import re import subprocess import sys from importlib.metadata import ( @@ -13,12 +15,135 @@ version as get_package_version, ) from pathlib import Path +from shutil import which from typing import List, Optional, Set +from urllib.parse import urlparse from ..version import __version__ LOGGER = logging.getLogger(__name__) +# Valid plugin name pattern: alphanumeric, underscore, hyphen, dot +# Must start with letter or underscore +VALID_PLUGIN_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_.-]*$") +PLUGIN_REPO_URL = "https://github.com/openwallet-foundation/acapy-plugins" + + +def _validate_plugin_name(plugin_name: str) -> bool: + """Validate that a plugin name is safe for use in URLs and file paths. + + Args: + plugin_name: The plugin name to validate + + Returns: + True if valid, False otherwise + + """ + if not plugin_name or len(plugin_name) > 100: + return False + return bool(VALID_PLUGIN_NAME_PATTERN.match(plugin_name)) + + +def _sanitize_url_component(component: str) -> str: + """Sanitize a URL component by removing unsafe characters. + + Args: + component: The URL component to sanitize + + Returns: + Sanitized component + + """ + # Remove any characters that could be used for URL injection + return re.sub(r"[^a-zA-Z0-9_.-]", "", component) + + +def _detect_package_manager() -> Optional[str]: + """Detect which package manager is being used (poetry, pip, etc.). + + Returns: + "poetry" if Poetry is detected, None otherwise + + """ + # Check if poetry is available + if not which("poetry"): + return None + + # Check if we're in a Poetry-managed virtual environment + # Poetry typically sets VIRTUAL_ENV to a path containing ".venv" or in Poetry's cache + venv_path = os.environ.get("VIRTUAL_ENV") + if venv_path: + venv_path_obj = Path(venv_path) + # Check if this looks like a Poetry-managed venv + # Poetry venvs are often named like "project-name--py3.13" + if venv_path_obj.name.endswith(".venv") or "poetry" in str(venv_path).lower(): + # Check if pyproject.toml exists nearby (Poetry projects have it at root) + parent = venv_path_obj.parent + if (parent / "pyproject.toml").exists() or ( + venv_path_obj / ".." / ".." / ".." / "pyproject.toml" + ).exists(): + return "poetry" + + # Check if we're in a Poetry project by looking for pyproject.toml + # Look in current directory or project root + search_paths = [Path.cwd()] + + # Also check from the acapy_agent module location (project root) + try: + from .. import __file__ as module_file + + module_path = Path(module_file).resolve() + # Go up from acapy_agent/utils/plugin_installer.py to project root + project_root = module_path.parent.parent.parent + if project_root not in search_paths: + search_paths.append(project_root) + except Exception: + pass + + # Check each potential project root for pyproject.toml + for root_path in search_paths: + pyproject_file = root_path / "pyproject.toml" + if pyproject_file.exists(): + # Check if pyproject.toml has [tool.poetry] section + try: + with open(pyproject_file, "r") as f: + content = f.read() + if "[tool.poetry]" in content or '[tool."poetry.core"]' in content: + return "poetry" + except Exception: + continue + + return None + + +def _get_pip_command_base() -> List[str]: + """Get the appropriate pip command base for the current environment. + + Returns: + List of command parts to run pip + (e.g., ["poetry", "run", "pip"] or [sys.executable, "-m", "pip"]) + + """ + package_manager = _detect_package_manager() + if package_manager == "poetry": + # Use poetry run pip in Poetry environments + return ["poetry", "run", "pip"] + else: + # Use sys.executable -m pip for regular Python environments + return [sys.executable, "-m", "pip"] + + +def _get_pip_command() -> List[str]: + """Get the appropriate pip install command for the current environment. + + Returns: + List of command parts to run pip install + + """ + cmd = _get_pip_command_base() + cmd.append("install") + return cmd + class PluginInstaller: """Handles dynamic installation of ACA-Py plugins from acapy-plugins.""" @@ -68,19 +193,33 @@ def _extract_source_version_from_direct_url( ): return revision - url = direct_url_data.get("url", "") - if "@" in url and "github.com" in url: - tag = url.split("@")[1].split("#")[0] - if "." in tag or tag in ["main", "master", "develop"]: - return tag + if url := direct_url_data.get("url", ""): + try: + # Parse URL properly instead of using string splits + parsed = urlparse(url) + if parsed.scheme and "@" in parsed.netloc: + # Extract tag/revision from netloc (e.g., git+https://...@tag) + netloc_parts = parsed.netloc.rsplit("@", 1) + if len(netloc_parts) == 2: + tag = netloc_parts[1] + # Validate tag is safe + if "." in tag or tag in ["main", "master", "develop"]: + # Additional validation: tag should be alphanumeric + # or contain dots/hyphens + if re.match(r"^[a-zA-Z0-9._-]+$", tag): + return tag + except Exception: + LOGGER.debug("Failed to parse URL: %s", url) return None def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str]: """Get source version from pip's .dist-info/direct_url.json file.""" # Try pip show to find location try: + cmd = _get_pip_command_base() + cmd.extend(["show", package_name]) result = subprocess.run( - [sys.executable, "-m", "pip", "show", package_name], + cmd, capture_output=True, text=True, check=False, @@ -139,8 +278,10 @@ def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str] # Last resort: pip freeze try: + cmd = _get_pip_command_base() + cmd.append("freeze") result = subprocess.run( - [sys.executable, "-m", "pip", "freeze"], + cmd, capture_output=True, text=True, check=False, @@ -148,11 +289,28 @@ def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str] if result.returncode == 0: for line in result.stdout.split("\n"): if package_name.lower() in line.lower() and "@ git+" in line: - git_part = line.split("@ git+")[1] - if "@" in git_part: - tag = git_part.split("@")[1].split("#")[0] - if "." in tag or tag in ["main", "master", "develop"]: - return tag + # Parse git URL properly + try: + # Extract git URL from pip freeze line + # Format: package==version @ git+https://...@tag#subdirectory=... + if "@ git+" in line: + git_url_part = line.split("@ git+", 1)[1] + parsed = urlparse(f"git+{git_url_part}") + if "@" in parsed.netloc: + netloc_parts = parsed.netloc.rsplit("@", 1) + if len(netloc_parts) == 2: + tag = netloc_parts[1] + # Validate tag is safe + if ( + "." in tag + or tag in ["main", "master", "develop"] + ) and re.match(r"^[a-zA-Z0-9._-]+$", tag): + return tag + except Exception: + LOGGER.debug( + "Failed to parse git URL from pip freeze line: %s", line + ) + continue except Exception: pass return None @@ -195,21 +353,71 @@ def _get_installed_plugin_version(self, plugin_name: str) -> Optional[dict]: return result def _get_plugin_source(self, plugin_name: str) -> str: - """Get the installation source for a plugin from acapy-plugins repository.""" + """Get the installation source for a plugin from acapy-plugins repository. + + Args: + plugin_name: The plugin name (must be validated before calling) + + Returns: + Git URL for installing the plugin + + Raises: + ValueError: If plugin_name is invalid or unsafe + + """ + # Validate plugin name to prevent URL injection + if not _validate_plugin_name(plugin_name): + raise ValueError( + f"Invalid plugin name: '{plugin_name}'. " + "Plugin names must contain only alphanumeric characters, " + "underscores, hyphens, and dots, and must start with a letter " + "or underscore." + ) + + # Sanitize version if provided version_to_use = ( self.plugin_version if self.plugin_version is not None else __version__ ) + version_to_use = _sanitize_url_component(str(version_to_use)) + + # Sanitize plugin name (though it should already be valid) + sanitized_plugin_name = _sanitize_url_component(plugin_name) + + # Construct URL with validated and sanitized components return ( - f"git+https://github.com/openwallet-foundation/acapy-plugins@{version_to_use}" - f"#subdirectory={plugin_name}" + f"git+{PLUGIN_REPO_URL}@{version_to_use}#subdirectory={sanitized_plugin_name}" ) def _install_plugin( self, plugin_source: str, plugin_name: str = None, upgrade: bool = False ) -> bool: - """Install a plugin using pip.""" + """Install a plugin using pip or poetry run pip.""" try: - cmd = [sys.executable, "-m", "pip", "install", "--no-cache-dir"] + # Extract version from source for logging + version_info = "" + if "@" in plugin_source: + parts = plugin_source.split("@") + if len(parts) > 1: + version_part = parts[1].split("#")[0] if "#" in parts[1] else parts[1] + version_info = f" (version: {version_part})" + + # Log before installation + if upgrade: + LOGGER.info( + "Upgrading plugin '%s'%s", + plugin_name or plugin_source, + version_info, + ) + else: + LOGGER.info( + "Installing plugin '%s'%s", + plugin_name or plugin_source, + version_info, + ) + + # Detect package manager and use appropriate command + cmd = _get_pip_command() + cmd.extend(["--no-cache-dir"]) if upgrade: cmd.extend(["--upgrade", "--force-reinstall", "--no-deps"]) cmd.append(plugin_source) @@ -218,14 +426,20 @@ def _install_plugin( if result.returncode == 0: action = "Upgraded" if upgrade else "Installed" - LOGGER.info("%s plugin: %s", action, plugin_name or plugin_source) + LOGGER.info( + "Successfully %s plugin '%s'%s", + action.lower(), + plugin_name or plugin_source, + version_info, + ) return True else: action = "upgrade" if upgrade else "install" LOGGER.error( - "Failed to %s plugin '%s': %s", + "Failed to %s plugin '%s'%s: %s", action, plugin_name or plugin_source, + version_info, result.stderr, ) return False @@ -241,12 +455,24 @@ def ensure_plugin_installed(self, plugin_name: str) -> bool: If not installed or version doesn't match, attempt to install it. Args: - plugin_name: The name of the plugin module + plugin_name: The name of the plugin module (must be validated) Returns: True if plugin is available (was already installed or successfully installed) + Raises: + ValueError: If plugin_name is invalid or unsafe + """ + # Validate plugin name before processing + if not _validate_plugin_name(plugin_name): + raise ValueError( + f"Invalid plugin name: '{plugin_name}'. " + "Plugin names must contain only alphanumeric characters, " + "underscores, hyphens, and dots, and must start with a letter " + "or underscore." + ) + # Get target version target_version = ( self.plugin_version if self.plugin_version is not None else __version__ @@ -282,13 +508,66 @@ def ensure_plugin_installed(self, plugin_name: str) -> bool: ) normalized_target = target_version.split("+")[0].split("-")[0].strip() if normalized_installed == normalized_target: + LOGGER.debug( + "Plugin '%s' already installed with matching version: %s", + plugin_name, + installed_package_version, + ) self.installed_plugins.add(plugin_name) return True + # Version check inconclusive - reinstall to be safe + LOGGER.info( + "Plugin '%s' exists but version check inconclusive. " + "Reinstalling to ensure correct version (%s)...", + plugin_name, + target_version, + ) elif plugin_exists and self.plugin_version: # Explicit version specified - check if source version matches if installed_source_version and installed_source_version == target_version: + LOGGER.info( + "Plugin '%s' already installed from source version %s " + "(package version: %s)", + plugin_name, + installed_source_version, + installed_package_version or "unknown", + ) self.installed_plugins.add(plugin_name) return True + # Version mismatch detected - log upgrade details + if installed_source_version: + LOGGER.info( + "Plugin '%s' source version mismatch detected: " + "installed=%s, target=%s. Upgrading plugin...", + plugin_name, + installed_source_version, + target_version, + ) + elif installed_package_version: + # Source version not available, but package version exists + # Still upgrade since we want specific git tag/version + LOGGER.info( + "Plugin '%s' needs upgrade: current package version=%s, " + "target version=%s. Upgrading plugin...", + plugin_name, + installed_package_version, + target_version, + ) + else: + # Can't determine version, upgrade to ensure correct version + LOGGER.info( + "Plugin '%s' is installed but version cannot be determined. " + "Upgrading to ensure correct version (%s)...", + plugin_name, + target_version, + ) + elif not plugin_exists: + # Plugin doesn't exist - install it + LOGGER.info( + "Plugin '%s' not found. Installing version %s...", + plugin_name, + target_version, + ) if not self.auto_install: LOGGER.warning( From 4e79cd2ea0dec3065ba500b78af962ce90ef5e3c Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 17 Nov 2025 14:26:21 -0500 Subject: [PATCH 07/23] add unit tests Signed-off-by: Patrick St-Louis --- .../utils/tests/test_plugin_installer.py | 531 ++++++++++++++++++ 1 file changed, 531 insertions(+) create mode 100644 acapy_agent/utils/tests/test_plugin_installer.py diff --git a/acapy_agent/utils/tests/test_plugin_installer.py b/acapy_agent/utils/tests/test_plugin_installer.py new file mode 100644 index 0000000000..ce12acf5b2 --- /dev/null +++ b/acapy_agent/utils/tests/test_plugin_installer.py @@ -0,0 +1,531 @@ +"""Unit tests for plugin installer functionality.""" + +import sys +from importlib.metadata import PackageNotFoundError +from unittest import TestCase +from unittest.mock import MagicMock, Mock, mock_open, patch + +from ..plugin_installer import ( + PluginInstaller, + _detect_package_manager, + _get_pip_command, + _get_pip_command_base, + _sanitize_url_component, + _validate_plugin_name, + get_plugin_version, + install_plugins_from_config, +) + + +class TestValidatePluginName(TestCase): + """Test plugin name validation.""" + + def test_valid_plugin_names(self): + """Test that valid plugin names pass validation.""" + valid_names = [ + "webvh", + "plugin_name", + "plugin-name", + "plugin.name", + "Plugin123", + "plugin_123", + "_plugin", + "a" * 100, # Max length + ] + for name in valid_names: + with self.subTest(name=name): + self.assertTrue(_validate_plugin_name(name)) + + def test_invalid_plugin_names(self): + """Test that invalid plugin names fail validation.""" + invalid_names = [ + "", + None, + "plugin/name", # Contains slash + "plugin name", # Contains space + "plugin@name", # Contains @ + "plugin#name", # Contains # + "plugin?name", # Contains ? + "123plugin", # Starts with number + "-plugin", # Starts with hyphen + "a" * 101, # Too long + ] + for name in invalid_names: + with self.subTest(name=name): + self.assertFalse(_validate_plugin_name(name)) + + +class TestSanitizeUrlComponent(TestCase): + """Test URL component sanitization.""" + + def test_sanitize_valid_components(self): + """Test sanitization of valid components.""" + test_cases = [ + ("webvh", "webvh"), + ("plugin-name", "plugin-name"), + ("plugin_name", "plugin_name"), + ("1.3.2", "1.3.2"), + ("plugin.name", "plugin.name"), + ] + for input_val, expected in test_cases: + with self.subTest(input_val=input_val): + self.assertEqual(_sanitize_url_component(input_val), expected) + + def test_sanitize_unsafe_components(self): + """Test sanitization removes unsafe characters.""" + test_cases = [ + ("plugin/name", "pluginname"), + ("plugin name", "pluginname"), + ("plugin@name", "pluginname"), + ("plugin#name", "pluginname"), + ("plugin?name", "pluginname"), + ("plugin:name", "pluginname"), + ] + for input_val, expected in test_cases: + with self.subTest(input_val=input_val): + self.assertEqual(_sanitize_url_component(input_val), expected) + + +class TestDetectPackageManager(TestCase): + """Test package manager detection.""" + + @patch("acapy_agent.utils.plugin_installer.which") + @patch.dict("os.environ", {}, clear=True) + @patch("acapy_agent.utils.plugin_installer.Path") + def test_no_poetry_available(self, mock_path, mock_which): + """Test when Poetry is not available.""" + mock_which.return_value = None + result = _detect_package_manager() + self.assertIsNone(result) + + @patch("acapy_agent.utils.plugin_installer.which") + @patch.dict("os.environ", {"VIRTUAL_ENV": "/path/to/venv"}) + @patch("acapy_agent.utils.plugin_installer.Path") + def test_poetry_detected_from_venv(self, mock_path_class, mock_which): + """Test Poetry detection from virtual environment.""" + mock_which.return_value = "/usr/bin/poetry" + + # Mock Path for venv path + mock_venv_path = MagicMock() + mock_venv_path.name = "project-name-hash-py3.13" + mock_venv_path.parent = MagicMock() + mock_venv_path.parent.__truediv__ = MagicMock( + return_value=MagicMock(exists=lambda: True) + ) + + # Mock Path class to return venv path when called + mock_path_class.return_value = mock_venv_path + + # Mock reading pyproject.toml + with patch("builtins.open", mock_open(read_data="[tool.poetry]\nname = 'test'")): + result = _detect_package_manager() + self.assertEqual(result, "poetry") + + @patch("acapy_agent.utils.plugin_installer.which") + @patch.dict("os.environ", {}, clear=True) + @patch("acapy_agent.utils.plugin_installer.Path") + def test_poetry_detected_from_pyproject(self, mock_path_class, mock_which): + """Test Poetry detection from pyproject.toml.""" + mock_which.return_value = "/usr/bin/poetry" + + # Mock Path.cwd() to have pyproject.toml + mock_cwd = MagicMock() + mock_pyproject = MagicMock() + mock_pyproject.exists.return_value = True + mock_cwd.__truediv__ = MagicMock(return_value=mock_pyproject) + mock_path_class.cwd.return_value = mock_cwd + + # Mock reading pyproject.toml + with patch("builtins.open", mock_open(read_data="[tool.poetry]\nname = 'test'")): + result = _detect_package_manager() + self.assertEqual(result, "poetry") + + +class TestGetPipCommandBase(TestCase): + """Test pip command base construction.""" + + @patch("acapy_agent.utils.plugin_installer._detect_package_manager") + def test_poetry_command(self, mock_detect): + """Test Poetry command construction.""" + mock_detect.return_value = "poetry" + result = _get_pip_command_base() + self.assertEqual(result, ["poetry", "run", "pip"]) + + @patch("acapy_agent.utils.plugin_installer._detect_package_manager") + def test_regular_pip_command(self, mock_detect): + """Test regular pip command construction.""" + mock_detect.return_value = None + result = _get_pip_command_base() + self.assertEqual(result, [sys.executable, "-m", "pip"]) + + @patch("acapy_agent.utils.plugin_installer._get_pip_command_base") + def test_get_pip_command(self, mock_base): + """Test pip install command construction.""" + mock_base.return_value = [sys.executable, "-m", "pip"] + result = _get_pip_command() + self.assertEqual(result, [sys.executable, "-m", "pip", "install"]) + + +class TestPluginInstaller(TestCase): + """Test PluginInstaller class.""" + + def test_init_defaults(self): + """Test PluginInstaller initialization with defaults.""" + installer = PluginInstaller() + self.assertTrue(installer.auto_install) + self.assertIsNone(installer.plugin_version) + self.assertEqual(installer.installed_plugins, set()) + + def test_init_with_version(self): + """Test PluginInstaller initialization with version.""" + installer = PluginInstaller(auto_install=True, plugin_version="1.3.2") + self.assertTrue(installer.auto_install) + self.assertEqual(installer.plugin_version, "1.3.2") + + def test_get_plugin_source_default_version(self): + """Test plugin source URL construction with default version.""" + installer = PluginInstaller() + with patch("acapy_agent.utils.plugin_installer.__version__", "1.4.0"): + result = installer._get_plugin_source("webvh") + self.assertIn( + "git+https://github.com/openwallet-foundation/acapy-plugins", result + ) + self.assertIn("@1.4.0#subdirectory=webvh", result) + + def test_get_plugin_source_custom_version(self): + """Test plugin source URL construction with custom version.""" + installer = PluginInstaller(plugin_version="1.3.2") + result = installer._get_plugin_source("webvh") + self.assertIn("@1.3.2#subdirectory=webvh", result) + + def test_get_plugin_source_invalid_name(self): + """Test plugin source URL construction with invalid name.""" + installer = PluginInstaller() + with self.assertRaises(ValueError) as context: + installer._get_plugin_source("plugin/name") + self.assertIn("Invalid plugin name", str(context.exception)) + + def test_try_get_package_version_success(self): + """Test successful package version lookup.""" + installer = PluginInstaller() + with patch("acapy_agent.utils.plugin_installer.get_package_version") as mock_get: + mock_get.side_effect = [PackageNotFoundError(), "1.2.3"] + version, name = installer._try_get_package_version(["package1", "package2"]) + self.assertEqual(version, "1.2.3") + self.assertEqual(name, "package2") + + def test_try_get_package_version_not_found(self): + """Test package version lookup when not found.""" + installer = PluginInstaller() + with patch("acapy_agent.utils.plugin_installer.get_package_version") as mock_get: + mock_get.side_effect = PackageNotFoundError() + version, name = installer._try_get_package_version(["package1"]) + self.assertIsNone(version) + self.assertIsNone(name) + + def test_extract_source_version_from_direct_url_vcs_info(self): + """Test source version extraction from vcs_info.""" + installer = PluginInstaller() + direct_url_data = { + "vcs_info": { + "vcs": "git", + "requested_revision": "1.3.2", + }, + "url": "git+https://github.com/org/repo@1.3.2#subdirectory=plugin", + } + result = installer._extract_source_version_from_direct_url(direct_url_data) + self.assertEqual(result, "1.3.2") + + def test_extract_source_version_from_direct_url_from_url(self): + """Test source version extraction from URL.""" + installer = PluginInstaller() + # URL format with @ in netloc: git+https://github.com@1.3.2/org/repo + direct_url_data = { + "vcs_info": {"vcs": "git"}, + "url": "git+https://github.com@1.3.2/org/repo#subdirectory=plugin", + } + result = installer._extract_source_version_from_direct_url(direct_url_data) + # Should successfully extract version from netloc + self.assertEqual(result, "1.3.2") + + def test_extract_source_version_from_direct_url_invalid(self): + """Test source version extraction with invalid URL.""" + installer = PluginInstaller() + direct_url_data = { + "vcs_info": {"vcs": "git"}, + "url": "not-a-valid-url", + } + result = installer._extract_source_version_from_direct_url(direct_url_data) + self.assertIsNone(result) + + def test_extract_source_version_from_direct_url_branch(self): + """Test source version extraction with branch name.""" + installer = PluginInstaller() + direct_url_data = { + "vcs_info": { + "vcs": "git", + "requested_revision": "main", + }, + } + result = installer._extract_source_version_from_direct_url(direct_url_data) + self.assertEqual(result, "main") + + @patch("acapy_agent.utils.plugin_installer.subprocess.run") + @patch("acapy_agent.utils.plugin_installer._get_pip_command_base") + def test_get_source_version_from_dist_info_pip_show( + self, mock_cmd_base, mock_subprocess + ): + """Test source version extraction via pip show.""" + installer = PluginInstaller() + mock_cmd_base.return_value = ["pip"] + mock_subprocess.return_value = Mock( + returncode=0, + stdout="Location: /path/to/package\n", + ) + + mock_path = MagicMock() + mock_dist_info = MagicMock() + mock_dist_info.is_dir.return_value = True + mock_dist_info.name = "package-1.0.0.dist-info" + mock_direct_url_file = MagicMock() + mock_direct_url_file.exists.return_value = True + mock_dist_info.__truediv__ = MagicMock(return_value=mock_direct_url_file) + mock_path.iterdir.return_value = [mock_dist_info] + + with ( + patch("acapy_agent.utils.plugin_installer.Path", return_value=mock_path), + patch( + "builtins.open", + mock_open( + read_data='{"url": "git+https://github.com/org/repo@1.3.2#subdirectory=plugin"}' + ), + ), + patch.object( + installer, + "_extract_source_version_from_direct_url", + return_value="1.3.2", + ), + ): + result = installer._get_source_version_from_dist_info("package") + self.assertEqual(result, "1.3.2") + + def test_get_installed_plugin_version_not_found(self): + """Test version lookup when plugin not found.""" + installer = PluginInstaller() + with ( + patch.object( + installer, "_try_get_package_version", return_value=(None, None) + ), + patch( + "acapy_agent.utils.plugin_installer.importlib.import_module" + ) as mock_import, + ): + mock_import.side_effect = ImportError("No module named 'test_plugin'") + result = installer._get_installed_plugin_version("test_plugin") + self.assertIsNone(result) + + def test_get_installed_plugin_version_found(self): + """Test version lookup when plugin found.""" + installer = PluginInstaller() + with ( + patch.object( + installer, + "_try_get_package_version", + return_value=("1.2.3", "test-plugin"), + ), + patch.object( + installer, "_get_source_version_from_dist_info", return_value="1.3.2" + ), + ): + result = installer._get_installed_plugin_version("test_plugin") + self.assertEqual(result["package_version"], "1.2.3") + self.assertEqual(result["source_version"], "1.3.2") + + @patch("acapy_agent.utils.plugin_installer.subprocess.run") + @patch("acapy_agent.utils.plugin_installer._get_pip_command") + def test_install_plugin_success(self, mock_cmd, mock_subprocess): + """Test successful plugin installation.""" + installer = PluginInstaller() + mock_cmd.return_value = ["pip", "install"] + mock_subprocess.return_value = Mock(returncode=0, stderr="") + + result = installer._install_plugin( + "git+https://github.com/org/repo@1.3.2#subdirectory=plugin", + plugin_name="plugin", + ) + self.assertTrue(result) + mock_subprocess.assert_called_once() + + @patch("acapy_agent.utils.plugin_installer.subprocess.run") + @patch("acapy_agent.utils.plugin_installer._get_pip_command") + def test_install_plugin_failure(self, mock_cmd, mock_subprocess): + """Test failed plugin installation.""" + installer = PluginInstaller() + mock_cmd.return_value = ["pip", "install"] + mock_subprocess.return_value = Mock(returncode=1, stderr="Error occurred") + + result = installer._install_plugin( + "git+https://github.com/org/repo@1.3.2#subdirectory=plugin", + plugin_name="plugin", + ) + self.assertFalse(result) + + @patch("acapy_agent.utils.plugin_installer.subprocess.run") + @patch("acapy_agent.utils.plugin_installer._get_pip_command") + def test_install_plugin_upgrade(self, mock_cmd, mock_subprocess): + """Test plugin upgrade.""" + installer = PluginInstaller() + mock_cmd.return_value = ["pip", "install"] + mock_subprocess.return_value = Mock(returncode=0, stderr="") + + installer._install_plugin( + "git+https://github.com/org/repo@1.3.2#subdirectory=plugin", + plugin_name="plugin", + upgrade=True, + ) + + # Check that --upgrade flag was included + call_args = mock_subprocess.call_args[0][0] + self.assertIn("--upgrade", call_args) + self.assertIn("--force-reinstall", call_args) + self.assertIn("--no-deps", call_args) + + @patch("acapy_agent.utils.plugin_installer.importlib.import_module") + @patch.object(PluginInstaller, "_get_installed_plugin_version") + @patch.object(PluginInstaller, "_get_plugin_source") + @patch.object(PluginInstaller, "_install_plugin") + def test_ensure_plugin_installed_not_installed( + self, mock_install, mock_get_source, mock_get_version, mock_import + ): + """Test ensuring plugin is installed when not installed.""" + installer = PluginInstaller(auto_install=True) + # First call raises ImportError (not installed), second succeeds (after install) + mock_import.side_effect = [ + ImportError("No module named 'test_plugin'"), + MagicMock(), # After installation, import succeeds + ] + mock_get_version.return_value = None + mock_get_source.return_value = ( + "git+https://github.com/org/repo@1.3.2#subdirectory=plugin" + ) + mock_install.return_value = True + + result = installer.ensure_plugin_installed("test_plugin") + self.assertTrue(result) + mock_install.assert_called_once() + # Should be called twice: once to check if installed, once after install + self.assertEqual(mock_import.call_count, 2) + + @patch("acapy_agent.utils.plugin_installer.importlib.import_module") + @patch.object(PluginInstaller, "_get_installed_plugin_version") + def test_ensure_plugin_installed_already_installed_matching_version( + self, mock_get_version, mock_import + ): + """Test ensuring plugin when already installed with matching version.""" + installer = PluginInstaller(auto_install=True, plugin_version="1.3.2") + mock_import.return_value = MagicMock() + mock_get_version.return_value = { + "package_version": "1.0.0", + "source_version": "1.3.2", + } + + result = installer.ensure_plugin_installed("test_plugin") + self.assertTrue(result) + self.assertIn("test_plugin", installer.installed_plugins) + + @patch("acapy_agent.utils.plugin_installer.importlib.import_module") + @patch.object(PluginInstaller, "_get_installed_plugin_version") + @patch.object(PluginInstaller, "_get_plugin_source") + @patch.object(PluginInstaller, "_install_plugin") + def test_ensure_plugin_installed_version_mismatch( + self, mock_install, mock_get_source, mock_get_version, mock_import + ): + """Test ensuring plugin when version mismatch detected.""" + installer = PluginInstaller(auto_install=True, plugin_version="1.3.2") + mock_import.return_value = MagicMock() + mock_get_version.return_value = { + "package_version": "1.0.0", + "source_version": "1.3.1", # Different version + } + mock_get_source.return_value = ( + "git+https://github.com/org/repo@1.3.2#subdirectory=plugin" + ) + mock_install.return_value = True + + result = installer.ensure_plugin_installed("test_plugin") + self.assertTrue(result) + mock_install.assert_called_once() + + @patch("acapy_agent.utils.plugin_installer.importlib.import_module") + @patch.object(PluginInstaller, "_get_installed_plugin_version") + def test_ensure_plugin_installed_auto_install_disabled( + self, mock_get_version, mock_import + ): + """Test ensuring plugin when auto-install is disabled.""" + installer = PluginInstaller(auto_install=False) + mock_import.side_effect = ImportError("No module named 'test_plugin'") + mock_get_version.return_value = None + + result = installer.ensure_plugin_installed("test_plugin") + self.assertFalse(result) + + def test_ensure_plugin_installed_invalid_name(self): + """Test ensuring plugin with invalid name.""" + installer = PluginInstaller() + with self.assertRaises(ValueError): + installer.ensure_plugin_installed("plugin/name") + + def test_ensure_plugins_installed_success(self): + """Test ensuring multiple plugins are installed.""" + installer = PluginInstaller(auto_install=True) + with patch.object(installer, "ensure_plugin_installed", return_value=True): + failed = installer.ensure_plugins_installed(["plugin1", "plugin2"]) + self.assertEqual(failed, []) + + def test_ensure_plugins_installed_partial_failure(self): + """Test ensuring plugins when some fail.""" + installer = PluginInstaller(auto_install=True) + + def side_effect(plugin_name): + # Return False for plugin1 (fails), True for plugin2 (succeeds) + return plugin_name == "plugin2" + + with patch.object(installer, "ensure_plugin_installed", side_effect=side_effect): + failed = installer.ensure_plugins_installed(["plugin1", "plugin2"]) + self.assertEqual(failed, ["plugin1"]) + + +class TestTopLevelFunctions(TestCase): + """Test top-level functions.""" + + @patch("acapy_agent.utils.plugin_installer.PluginInstaller") + def test_install_plugins_from_config(self, mock_installer_class): + """Test install_plugins_from_config function.""" + mock_installer = MagicMock() + mock_installer.ensure_plugins_installed.return_value = [] + mock_installer_class.return_value = mock_installer + + result = install_plugins_from_config( + ["plugin1", "plugin2"], auto_install=True, plugin_version="1.3.2" + ) + self.assertEqual(result, []) + mock_installer_class.assert_called_once_with( + auto_install=True, plugin_version="1.3.2" + ) + mock_installer.ensure_plugins_installed.assert_called_once_with( + ["plugin1", "plugin2"] + ) + + @patch("acapy_agent.utils.plugin_installer.PluginInstaller") + def test_get_plugin_version(self, mock_installer_class): + """Test get_plugin_version function.""" + mock_installer = MagicMock() + mock_installer._get_installed_plugin_version.return_value = { + "package_version": "1.0.0", + "source_version": "1.3.2", + } + mock_installer_class.return_value = mock_installer + + result = get_plugin_version("test_plugin") + self.assertEqual(result["package_version"], "1.0.0") + self.assertEqual(result["source_version"], "1.3.2") From 991064a0b74896b53f66b86d51565957f0fed6c6 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 17 Nov 2025 14:37:33 -0500 Subject: [PATCH 08/23] more tests Signed-off-by: Patrick St-Louis --- .../utils/tests/test_plugin_installer.py | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) diff --git a/acapy_agent/utils/tests/test_plugin_installer.py b/acapy_agent/utils/tests/test_plugin_installer.py index ce12acf5b2..3e5e45ab8f 100644 --- a/acapy_agent/utils/tests/test_plugin_installer.py +++ b/acapy_agent/utils/tests/test_plugin_installer.py @@ -140,6 +140,50 @@ def test_poetry_detected_from_pyproject(self, mock_path_class, mock_which): result = _detect_package_manager() self.assertEqual(result, "poetry") + @patch("acapy_agent.utils.plugin_installer.which") + @patch.dict("os.environ", {"VIRTUAL_ENV": "/path/to/.venv"}) + @patch("acapy_agent.utils.plugin_installer.Path") + def test_poetry_detected_from_venv_parent_path(self, mock_path_class, mock_which): + """Test Poetry detection from venv parent path.""" + mock_which.return_value = "/usr/bin/poetry" + + # Mock venv path with .venv name + mock_venv_path = MagicMock() + mock_venv_path.name = ".venv" + mock_venv_path.parent = MagicMock() + mock_pyproject_parent = MagicMock() + mock_pyproject_parent.exists.return_value = True + mock_venv_path.parent.__truediv__ = MagicMock( + return_value=mock_pyproject_parent + ) + + mock_path_class.return_value = mock_venv_path + + with patch("builtins.open", mock_open(read_data="[tool.poetry]\nname = 'test'")): + result = _detect_package_manager() + self.assertEqual(result, "poetry") + + @patch("acapy_agent.utils.plugin_installer.which") + @patch.dict("os.environ", {}, clear=True) + @patch("acapy_agent.utils.plugin_installer.Path") + def test_poetry_detection_pyproject_read_exception( + self, mock_path_class, mock_which + ): + """Test Poetry detection when reading pyproject.toml raises exception.""" + mock_which.return_value = "/usr/bin/poetry" + + mock_cwd = MagicMock() + mock_pyproject = MagicMock() + mock_pyproject.exists.return_value = True + mock_cwd.__truediv__ = MagicMock(return_value=mock_pyproject) + mock_path_class.cwd.return_value = mock_cwd + + # Mock open to raise exception + with patch("builtins.open", side_effect=IOError("Permission denied")): + result = _detect_package_manager() + # Should continue searching other paths + self.assertIsNone(result) # No other paths configured in test + class TestGetPipCommandBase(TestCase): """Test pip command base construction.""" @@ -258,6 +302,18 @@ def test_extract_source_version_from_direct_url_invalid(self): result = installer._extract_source_version_from_direct_url(direct_url_data) self.assertIsNone(result) + def test_extract_source_version_from_direct_url_exception(self): + """Test source version extraction when URL parsing raises exception.""" + installer = PluginInstaller() + # Create a URL that will cause urlparse to work but rsplit to fail + direct_url_data = { + "vcs_info": {"vcs": "git"}, + "url": "git+https://github.com/org/repo", # No @ tag + } + result = installer._extract_source_version_from_direct_url(direct_url_data) + # Should return None when no version tag found + self.assertIsNone(result) + def test_extract_source_version_from_direct_url_branch(self): """Test source version extraction with branch name.""" installer = PluginInstaller() @@ -309,6 +365,80 @@ def test_get_source_version_from_dist_info_pip_show( result = installer._get_source_version_from_dist_info("package") self.assertEqual(result, "1.3.2") + @patch("acapy_agent.utils.plugin_installer.subprocess.run") + @patch("acapy_agent.utils.plugin_installer._get_pip_command_base") + def test_get_source_version_from_dist_info_pip_show_failure( + self, mock_cmd_base, mock_subprocess + ): + """Test source version extraction when pip show fails.""" + installer = PluginInstaller() + mock_cmd_base.return_value = ["pip"] + mock_subprocess.return_value = Mock(returncode=1, stdout="") + + # Test with distributions fallback + mock_dist = MagicMock() + mock_dist.metadata = {"Name": "package", "version": "1.0.0"} + mock_dist.location = "/path/to/dist" + mock_dist.version = "1.0.0" + + mock_dist_path = MagicMock() + mock_direct_url_file = MagicMock() + mock_direct_url_file.exists.return_value = True + mock_dist_path.__truediv__ = MagicMock(return_value=mock_direct_url_file) + + with ( + patch("acapy_agent.utils.plugin_installer.distributions", return_value=[mock_dist]), + patch("acapy_agent.utils.plugin_installer.Path") as mock_path_class, + patch( + "builtins.open", + mock_open( + read_data='{"vcs_info": {"vcs": "git", "requested_revision": "1.3.2"}}' + ), + ), + ): + mock_path_class.return_value = mock_dist_path + mock_dist_path.parent = mock_dist_path + result = installer._get_source_version_from_dist_info("package") + self.assertEqual(result, "1.3.2") + + @patch("acapy_agent.utils.plugin_installer.subprocess.run") + @patch("acapy_agent.utils.plugin_installer._get_pip_command_base") + def test_get_source_version_from_dist_info_pip_freeze( + self, mock_cmd_base, mock_subprocess + ): + """Test source version extraction via pip freeze.""" + installer = PluginInstaller() + mock_cmd_base.return_value = ["pip"] + # First call fails (pip show), second succeeds (pip freeze) + mock_subprocess.side_effect = [ + Mock(returncode=1, stdout=""), # pip show fails + Mock( + returncode=0, + stdout="package==1.0.0 @ git+https://github.com@1.3.2/org/repo#subdirectory=plugin\n", + ), # pip freeze succeeds with @ in netloc format + ] + + with patch("acapy_agent.utils.plugin_installer.distributions", return_value=[]): + result = installer._get_source_version_from_dist_info("package") + self.assertEqual(result, "1.3.2") + + @patch("acapy_agent.utils.plugin_installer.subprocess.run") + @patch("acapy_agent.utils.plugin_installer._get_pip_command_base") + def test_get_source_version_from_dist_info_pip_freeze_exception( + self, mock_cmd_base, mock_subprocess + ): + """Test source version extraction when pip freeze raises exception.""" + installer = PluginInstaller() + mock_cmd_base.return_value = ["pip"] + mock_subprocess.side_effect = [ + Mock(returncode=1, stdout=""), # pip show fails + Exception("Unexpected error"), # pip freeze raises exception + ] + + with patch("acapy_agent.utils.plugin_installer.distributions", return_value=[]): + result = installer._get_source_version_from_dist_info("package") + self.assertIsNone(result) + def test_get_installed_plugin_version_not_found(self): """Test version lookup when plugin not found.""" installer = PluginInstaller() @@ -341,6 +471,46 @@ def test_get_installed_plugin_version_found(self): self.assertEqual(result["package_version"], "1.2.3") self.assertEqual(result["source_version"], "1.3.2") + def test_get_installed_plugin_version_from_module(self): + """Test version lookup from module __version__ attribute.""" + installer = PluginInstaller() + mock_module = MagicMock() + mock_module.__version__ = "1.5.0" + + with ( + patch.object( + installer, "_try_get_package_version", return_value=(None, None) + ), + patch( + "acapy_agent.utils.plugin_installer.importlib.import_module", + return_value=mock_module, + ), + ): + result = installer._get_installed_plugin_version("test_plugin") + self.assertEqual(result["package_version"], "1.5.0") + # No package_name, so no source_version + self.assertNotIn("source_version", result) + + def test_get_installed_plugin_version_no_package_name(self): + """Test version lookup when package name is None.""" + installer = PluginInstaller() + mock_module = MagicMock() + mock_module.__version__ = "1.5.0" + + with ( + patch.object( + installer, "_try_get_package_version", return_value=("1.2.3", None) + ), + patch( + "acapy_agent.utils.plugin_installer.importlib.import_module", + return_value=mock_module, + ), + ): + result = installer._get_installed_plugin_version("test_plugin") + self.assertEqual(result["package_version"], "1.2.3") + # No package_name, so no source_version lookup + self.assertNotIn("source_version", result) + @patch("acapy_agent.utils.plugin_installer.subprocess.run") @patch("acapy_agent.utils.plugin_installer._get_pip_command") def test_install_plugin_success(self, mock_cmd, mock_subprocess): @@ -390,6 +560,20 @@ def test_install_plugin_upgrade(self, mock_cmd, mock_subprocess): self.assertIn("--force-reinstall", call_args) self.assertIn("--no-deps", call_args) + @patch("acapy_agent.utils.plugin_installer.subprocess.run") + @patch("acapy_agent.utils.plugin_installer._get_pip_command") + def test_install_plugin_exception(self, mock_cmd, mock_subprocess): + """Test plugin installation exception handling.""" + installer = PluginInstaller() + mock_cmd.return_value = ["pip", "install"] + mock_subprocess.side_effect = Exception("Unexpected error") + + result = installer._install_plugin( + "git+https://github.com/org/repo@1.3.2#subdirectory=plugin", + plugin_name="plugin", + ) + self.assertFalse(result) + @patch("acapy_agent.utils.plugin_installer.importlib.import_module") @patch.object(PluginInstaller, "_get_installed_plugin_version") @patch.object(PluginInstaller, "_get_plugin_source") @@ -469,6 +653,84 @@ def test_ensure_plugin_installed_auto_install_disabled( result = installer.ensure_plugin_installed("test_plugin") self.assertFalse(result) + @patch("acapy_agent.utils.plugin_installer.importlib.import_module") + @patch.object(PluginInstaller, "_get_installed_plugin_version") + @patch("acapy_agent.utils.plugin_installer.__version__", "1.4.0") + def test_ensure_plugin_installed_version_match_no_explicit_version( + self, mock_get_version, mock_import + ): + """Test ensuring plugin when version matches without explicit version.""" + installer = PluginInstaller(auto_install=True, plugin_version=None) + mock_import.return_value = MagicMock() + # Using current version (normalized) + mock_get_version.return_value = {"package_version": "1.4.0"} + + result = installer.ensure_plugin_installed("test_plugin") + self.assertTrue(result) + self.assertIn("test_plugin", installer.installed_plugins) + + @patch("acapy_agent.utils.plugin_installer.importlib.import_module") + @patch.object(PluginInstaller, "_get_installed_plugin_version") + @patch.object(PluginInstaller, "_get_plugin_source") + @patch.object(PluginInstaller, "_install_plugin") + def test_ensure_plugin_installed_import_fails_after_install( + self, mock_install, mock_get_source, mock_get_version, mock_import + ): + """Test ensuring plugin when import fails after installation.""" + installer = PluginInstaller(auto_install=True) + # First call: not installed, second call: still fails after install + mock_import.side_effect = ImportError("No module named 'test_plugin'") + mock_get_version.return_value = None + mock_get_source.return_value = ( + "git+https://github.com/org/repo@1.3.2#subdirectory=plugin" + ) + mock_install.return_value = True # Installation "succeeds" + + result = installer.ensure_plugin_installed("test_plugin") + self.assertFalse(result) + self.assertNotIn("test_plugin", installer.installed_plugins) + + @patch("acapy_agent.utils.plugin_installer.importlib.import_module") + @patch.object(PluginInstaller, "_get_installed_plugin_version") + @patch.object(PluginInstaller, "_get_plugin_source") + @patch.object(PluginInstaller, "_install_plugin") + def test_ensure_plugin_installed_installation_fails( + self, mock_install, mock_get_source, mock_get_version, mock_import + ): + """Test ensuring plugin when installation fails.""" + installer = PluginInstaller(auto_install=True) + mock_import.side_effect = ImportError("No module named 'test_plugin'") + mock_get_version.return_value = None + mock_get_source.return_value = ( + "git+https://github.com/org/repo@1.3.2#subdirectory=plugin" + ) + mock_install.return_value = False # Installation fails + + result = installer.ensure_plugin_installed("test_plugin") + self.assertFalse(result) + + @patch("acapy_agent.utils.plugin_installer.importlib.import_module") + @patch.object(PluginInstaller, "_get_installed_plugin_version") + @patch.object(PluginInstaller, "_get_plugin_source") + @patch.object(PluginInstaller, "_install_plugin") + @patch("acapy_agent.utils.plugin_installer.__version__", "1.4.0") + def test_ensure_plugin_installed_version_inconclusive( + self, mock_install, mock_get_source, mock_get_version, mock_import + ): + """Test ensuring plugin when version check is inconclusive.""" + installer = PluginInstaller(auto_install=True, plugin_version=None) + mock_import.return_value = MagicMock() + # Version doesn't match or is None + mock_get_version.return_value = {"package_version": "1.3.0"} + mock_get_source.return_value = ( + "git+https://github.com/org/repo@1.4.0#subdirectory=plugin" + ) + mock_install.return_value = True + + result = installer.ensure_plugin_installed("test_plugin") + # Should reinstall due to version mismatch + mock_install.assert_called_once() + def test_ensure_plugin_installed_invalid_name(self): """Test ensuring plugin with invalid name.""" installer = PluginInstaller() @@ -529,3 +791,30 @@ def test_get_plugin_version(self, mock_installer_class): result = get_plugin_version("test_plugin") self.assertEqual(result["package_version"], "1.0.0") self.assertEqual(result["source_version"], "1.3.2") + + def test_install_plugins_from_config_empty_list(self): + """Test install_plugins_from_config with empty list.""" + result = install_plugins_from_config([]) + self.assertEqual(result, []) + + def test_list_plugin_versions(self): + """Test list_plugin_versions function.""" + from ..plugin_installer import list_plugin_versions + + installer = PluginInstaller(auto_install=False) + with patch.object( + installer, "_get_installed_plugin_version", return_value={"package_version": "1.0.0"} + ), patch( + "acapy_agent.utils.plugin_installer.PluginInstaller", return_value=installer + ): + result = list_plugin_versions(["plugin1", "plugin2"]) + self.assertEqual(len(result), 2) + self.assertIn("plugin1", result) + self.assertIn("plugin2", result) + + def test_list_plugin_versions_no_names(self): + """Test list_plugin_versions with no plugin names.""" + from ..plugin_installer import list_plugin_versions + + result = list_plugin_versions(None) + self.assertEqual(result, {}) From 308c364b0050573379a2088356f829c7ccdd7c14 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 17 Nov 2025 14:39:19 -0500 Subject: [PATCH 09/23] handle plugin version exception Signed-off-by: Patrick St-Louis --- acapy_agent/admin/routes.py | 7 ++++++- acapy_agent/utils/plugin_installer.py | 13 ++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/acapy_agent/admin/routes.py b/acapy_agent/admin/routes.py index 2c1f7ad885..df9a58139c 100644 --- a/acapy_agent/admin/routes.py +++ b/acapy_agent/admin/routes.py @@ -83,7 +83,12 @@ async def plugins_handler(request: web.BaseRequest): for plugin_name in plugins: if not plugin_name.startswith("acapy_agent."): # External plugin - try to get version info - version_info = get_plugin_version(plugin_name) or {} + # Wrap in try/except to prevent failures from affecting the endpoint + try: + version_info = get_plugin_version(plugin_name) or {} + except Exception: + # If version lookup fails, just include plugin without version info + version_info = {} external_plugins.append( { "name": plugin_name, diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index 3b3d267aa3..c685e7ab6c 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -664,11 +664,18 @@ def get_plugin_version(plugin_name: str) -> Optional[dict]: Returns: Dictionary with 'package_version' and optionally 'source_version' - (git tag), or None if not found + (git tag), or None if not found. Returns None if any error occurs. """ - installer = PluginInstaller(auto_install=False) - return installer._get_installed_plugin_version(plugin_name) + try: + installer = PluginInstaller(auto_install=False) + return installer._get_installed_plugin_version(plugin_name) + except Exception: + # Silently fail version lookup - don't break plugin functionality + LOGGER.debug( + "Failed to get version info for plugin '%s'", plugin_name, exc_info=True + ) + return None def list_plugin_versions(plugin_names: List[str] = None) -> dict: From 21fdbc16261d581d59e70d3f3042c49da75272ef Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 17 Nov 2025 14:39:54 -0500 Subject: [PATCH 10/23] linting Signed-off-by: Patrick St-Louis --- .../utils/tests/test_plugin_installer.py | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/acapy_agent/utils/tests/test_plugin_installer.py b/acapy_agent/utils/tests/test_plugin_installer.py index 3e5e45ab8f..18191b843c 100644 --- a/acapy_agent/utils/tests/test_plugin_installer.py +++ b/acapy_agent/utils/tests/test_plugin_installer.py @@ -153,9 +153,7 @@ def test_poetry_detected_from_venv_parent_path(self, mock_path_class, mock_which mock_venv_path.parent = MagicMock() mock_pyproject_parent = MagicMock() mock_pyproject_parent.exists.return_value = True - mock_venv_path.parent.__truediv__ = MagicMock( - return_value=mock_pyproject_parent - ) + mock_venv_path.parent.__truediv__ = MagicMock(return_value=mock_pyproject_parent) mock_path_class.return_value = mock_venv_path @@ -166,9 +164,7 @@ def test_poetry_detected_from_venv_parent_path(self, mock_path_class, mock_which @patch("acapy_agent.utils.plugin_installer.which") @patch.dict("os.environ", {}, clear=True) @patch("acapy_agent.utils.plugin_installer.Path") - def test_poetry_detection_pyproject_read_exception( - self, mock_path_class, mock_which - ): + def test_poetry_detection_pyproject_read_exception(self, mock_path_class, mock_which): """Test Poetry detection when reading pyproject.toml raises exception.""" mock_which.return_value = "/usr/bin/poetry" @@ -387,7 +383,10 @@ def test_get_source_version_from_dist_info_pip_show_failure( mock_dist_path.__truediv__ = MagicMock(return_value=mock_direct_url_file) with ( - patch("acapy_agent.utils.plugin_installer.distributions", return_value=[mock_dist]), + patch( + "acapy_agent.utils.plugin_installer.distributions", + return_value=[mock_dist], + ), patch("acapy_agent.utils.plugin_installer.Path") as mock_path_class, patch( "builtins.open", @@ -802,10 +801,16 @@ def test_list_plugin_versions(self): from ..plugin_installer import list_plugin_versions installer = PluginInstaller(auto_install=False) - with patch.object( - installer, "_get_installed_plugin_version", return_value={"package_version": "1.0.0"} - ), patch( - "acapy_agent.utils.plugin_installer.PluginInstaller", return_value=installer + with ( + patch.object( + installer, + "_get_installed_plugin_version", + return_value={"package_version": "1.0.0"}, + ), + patch( + "acapy_agent.utils.plugin_installer.PluginInstaller", + return_value=installer, + ), ): result = list_plugin_versions(["plugin1", "plugin2"]) self.assertEqual(len(result), 2) From c7e08d0fed2348cc25b3cd0e3a3f988c99c14429 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 24 Nov 2025 13:40:20 -0500 Subject: [PATCH 11/23] fix scenario tests Signed-off-by: Patrick St-Louis --- acapy_agent/admin/routes.py | 11 ++++++++--- acapy_agent/utils/plugin_installer.py | 12 ++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/acapy_agent/admin/routes.py b/acapy_agent/admin/routes.py index df9a58139c..5f5b4c04b1 100644 --- a/acapy_agent/admin/routes.py +++ b/acapy_agent/admin/routes.py @@ -8,11 +8,16 @@ from ..core.plugin_registry import PluginRegistry from ..messaging.models.openapi import OpenAPISchema -from ..utils.plugin_installer import get_plugin_version from ..utils.stats import Collector from ..version import __version__ from .decorators.auth import admin_authentication +# Lazy import to avoid import-time issues +def _get_plugin_version(plugin_name: str): + """Lazy import wrapper for get_plugin_version.""" + from ..utils.plugin_installer import get_plugin_version + return get_plugin_version(plugin_name) + class AdminModulesSchema(OpenAPISchema): """Schema for the modules endpoint.""" @@ -85,7 +90,7 @@ async def plugins_handler(request: web.BaseRequest): # External plugin - try to get version info # Wrap in try/except to prevent failures from affecting the endpoint try: - version_info = get_plugin_version(plugin_name) or {} + version_info = _get_plugin_version(plugin_name) or {} except Exception: # If version lookup fails, just include plugin without version info version_info = {} @@ -116,7 +121,7 @@ async def config_handler(request: web.BaseRequest): config = { k: ( request.app["context"].settings[k] - if (isinstance(request.app["context"].settings[k], (str, int))) + if (isinstance(request.app["context"].settings[k], (str, int)) or request.app["context"].settings[k] is None) else request.app["context"].settings[k].copy() ) for k in request.app["context"].settings diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index c685e7ab6c..c68834bd10 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -332,11 +332,19 @@ def _get_installed_plugin_version(self, plugin_name: str) -> Optional[dict]: if not package_version: # Try __version__ attribute + # Note: We avoid importing the module if possible to prevent side effects + # Only try this as a last resort try: - module = importlib.import_module(plugin_name) + # Check if module is already loaded before importing + if plugin_name in sys.modules: + module = sys.modules[plugin_name] + else: + # Only import if not already loaded to avoid side effects + module = importlib.import_module(plugin_name) if hasattr(module, "__version__"): package_version = str(module.__version__) - except (ImportError, AttributeError): + except (ImportError, AttributeError, Exception): + # Catch all exceptions to prevent any side effects from breaking version lookup pass if not package_version: From 226398d251f207986d17c9d717431a07f84214ce Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 24 Nov 2025 13:44:25 -0500 Subject: [PATCH 12/23] formatting Signed-off-by: Patrick St-Louis --- acapy_agent/admin/routes.py | 7 ++++++- acapy_agent/utils/plugin_installer.py | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/acapy_agent/admin/routes.py b/acapy_agent/admin/routes.py index 5f5b4c04b1..ddcc1901ee 100644 --- a/acapy_agent/admin/routes.py +++ b/acapy_agent/admin/routes.py @@ -12,10 +12,12 @@ from ..version import __version__ from .decorators.auth import admin_authentication + # Lazy import to avoid import-time issues def _get_plugin_version(plugin_name: str): """Lazy import wrapper for get_plugin_version.""" from ..utils.plugin_installer import get_plugin_version + return get_plugin_version(plugin_name) @@ -121,7 +123,10 @@ async def config_handler(request: web.BaseRequest): config = { k: ( request.app["context"].settings[k] - if (isinstance(request.app["context"].settings[k], (str, int)) or request.app["context"].settings[k] is None) + if ( + isinstance(request.app["context"].settings[k], (str, int)) + or request.app["context"].settings[k] is None + ) else request.app["context"].settings[k].copy() ) for k in request.app["context"].settings diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index c68834bd10..ebace295d9 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -344,7 +344,8 @@ def _get_installed_plugin_version(self, plugin_name: str) -> Optional[dict]: if hasattr(module, "__version__"): package_version = str(module.__version__) except (ImportError, AttributeError, Exception): - # Catch all exceptions to prevent any side effects from breaking version lookup + # Catch all exceptions to prevent any side effects from breaking + # version lookup pass if not package_version: From fc11af7626108899196433d28df1470a0b03bf23 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:23:21 -0500 Subject: [PATCH 13/23] Update acapy_agent/utils/plugin_installer.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> --- acapy_agent/utils/plugin_installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index ebace295d9..bd1f1f9935 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -398,7 +398,7 @@ def _get_plugin_source(self, plugin_name: str) -> str: ) def _install_plugin( - self, plugin_source: str, plugin_name: str = None, upgrade: bool = False + self, plugin_source: str, plugin_name: str, upgrade: bool = False ) -> bool: """Install a plugin using pip or poetry run pip.""" try: From 83acc488676bc8dfe10f8dfa3a3857955a6d90c0 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:25:09 -0500 Subject: [PATCH 14/23] Update acapy_agent/utils/plugin_installer.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> --- acapy_agent/utils/plugin_installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index bd1f1f9935..6870f79aef 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -80,7 +80,7 @@ def _detect_package_manager() -> Optional[str]: # Check if pyproject.toml exists nearby (Poetry projects have it at root) parent = venv_path_obj.parent if (parent / "pyproject.toml").exists() or ( - venv_path_obj / ".." / ".." / ".." / "pyproject.toml" + (venv_path_obj.parent.parent.parent / "pyproject.toml") ).exists(): return "poetry" From 05438d1a402c836d89c0bfd25be4110226b4daf6 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:26:11 -0500 Subject: [PATCH 15/23] Update acapy_agent/utils/plugin_installer.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> --- acapy_agent/utils/plugin_installer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index 6870f79aef..ba21a5293c 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -311,8 +311,8 @@ def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str] "Failed to parse git URL from pip freeze line: %s", line ) continue - except Exception: - pass + except Exception as e: + LOGGER.debug("Exception occurred while running pip freeze to get source version for %s: %s", package_name, e, exc_info=True) return None def _get_installed_plugin_version(self, plugin_name: str) -> Optional[dict]: From 86ae5f91c22cf9ebd761a8d08bbd9bb4aed6b7af Mon Sep 17 00:00:00 2001 From: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:26:21 -0500 Subject: [PATCH 16/23] Update acapy_agent/utils/plugin_installer.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> --- acapy_agent/utils/plugin_installer.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index ba21a5293c..9edf3daef1 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -273,8 +273,10 @@ def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str] ) if source_version: return source_version - except (json.JSONDecodeError, IOError): - pass + except (json.JSONDecodeError, IOError) as e: + LOGGER.debug( + "Failed to read or parse direct_url.json for %s: %s", direct_url_file, e + ) # Last resort: pip freeze try: From 1cd952d03831417edffb9f565eb4fe40a3e31e32 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:26:28 -0500 Subject: [PATCH 17/23] Update acapy_agent/utils/plugin_installer.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> --- acapy_agent/utils/plugin_installer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index 9edf3daef1..05b23fe201 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -249,8 +249,8 @@ def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str] return source_version except (json.JSONDecodeError, IOError): pass - except Exception: - pass + except Exception as e: + LOGGER.exception(f"Error while trying to locate direct_url.json for package '{package_name}': {e}") # Fallback: search distributions for dist in distributions(): From 096d8eb27d61399b76811d24d1442ff380e4e8ab Mon Sep 17 00:00:00 2001 From: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:26:38 -0500 Subject: [PATCH 18/23] Update acapy_agent/utils/plugin_installer.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> --- acapy_agent/utils/plugin_installer.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index 05b23fe201..5a7812091d 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -247,8 +247,12 @@ def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str] ) if source_version: return source_version - except (json.JSONDecodeError, IOError): - pass + except (json.JSONDecodeError, IOError) as e: + LOGGER.warning( + "Failed to read or parse direct_url.json for package '%s': %s", + package_name, + e, + ) except Exception as e: LOGGER.exception(f"Error while trying to locate direct_url.json for package '{package_name}': {e}") From 218884282fc4b4abdd080f1abe43224664741d89 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:26:48 -0500 Subject: [PATCH 19/23] Update acapy_agent/utils/plugin_installer.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> --- acapy_agent/utils/plugin_installer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index 5a7812091d..d2b2139404 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -98,6 +98,8 @@ def _detect_package_manager() -> Optional[str]: if project_root not in search_paths: search_paths.append(project_root) except Exception: + # It is safe to ignore errors here; failure to import the module or resolve its path + # simply means we cannot add an extra search path for pyproject.toml detection. pass # Check each potential project root for pyproject.toml From d212cf4f1397c945e32d28658cd0a16bbb7c110a Mon Sep 17 00:00:00 2001 From: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:27:08 -0500 Subject: [PATCH 20/23] Update acapy_agent/utils/tests/test_plugin_installer.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Patrick St-Louis <43082425+PatStLouis@users.noreply.github.com> --- acapy_agent/utils/tests/test_plugin_installer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/acapy_agent/utils/tests/test_plugin_installer.py b/acapy_agent/utils/tests/test_plugin_installer.py index 18191b843c..79c5fd5480 100644 --- a/acapy_agent/utils/tests/test_plugin_installer.py +++ b/acapy_agent/utils/tests/test_plugin_installer.py @@ -727,6 +727,7 @@ def test_ensure_plugin_installed_version_inconclusive( mock_install.return_value = True result = installer.ensure_plugin_installed("test_plugin") + self.assertTrue(result) # Should reinstall due to version mismatch mock_install.assert_called_once() From 156e083fb6a0e785fcccfe3bba7457cde99eeeaf Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 24 Nov 2025 17:41:02 -0500 Subject: [PATCH 21/23] address pr comments Signed-off-by: Patrick St-Louis --- acapy_agent/admin/routes.py | 6 + acapy_agent/commands/start.py | 14 +- acapy_agent/utils/plugin_installer.py | 1010 ++++++++++++++++++------- 3 files changed, 730 insertions(+), 300 deletions(-) diff --git a/acapy_agent/admin/routes.py b/acapy_agent/admin/routes.py index ddcc1901ee..928098b973 100644 --- a/acapy_agent/admin/routes.py +++ b/acapy_agent/admin/routes.py @@ -1,5 +1,6 @@ """Admin server routes.""" +import logging import re from aiohttp import web @@ -12,6 +13,8 @@ from ..version import __version__ from .decorators.auth import admin_authentication +LOGGER = logging.getLogger(__name__) + # Lazy import to avoid import-time issues def _get_plugin_version(plugin_name: str): @@ -95,6 +98,9 @@ async def plugins_handler(request: web.BaseRequest): version_info = _get_plugin_version(plugin_name) or {} except Exception: # If version lookup fails, just include plugin without version info + LOGGER.debug( + "Failed to get version info for plugin %s", plugin_name, exc_info=True + ) version_info = {} external_plugins.append( { diff --git a/acapy_agent/commands/start.py b/acapy_agent/commands/start.py index 2993c1dc53..8d5a36b42f 100644 --- a/acapy_agent/commands/start.py +++ b/acapy_agent/commands/start.py @@ -74,12 +74,6 @@ async def run_app(argv: Sequence[str] = None): if plugin_version else f"current ACA-Py version ({acapy_version})" ) - # Always print to console for visibility - plugins_str = ", ".join(external_plugins) - print( - f"Auto-installing plugins from acapy-plugins repository: " - f"{plugins_str} ({version_info})" - ) LOGGER.info( "Auto-installing plugins from acapy-plugins repository: %s (%s)", ", ".join(external_plugins), @@ -94,13 +88,11 @@ async def run_app(argv: Sequence[str] = None): if failed_plugins: LOGGER.error( - "Failed to install the following plugins: %s", - ", ".join(failed_plugins), - ) - LOGGER.error( + "Failed to install the following plugins: %s. " "Please ensure these plugins are available in the " "acapy-plugins repository or install them manually before " - "starting ACA-Py." + "starting ACA-Py.", + ", ".join(failed_plugins), ) sys.exit(1) diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index d2b2139404..29548ff22c 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -16,6 +16,7 @@ ) from pathlib import Path from shutil import which +from subprocess import CompletedProcess from typing import List, Optional, Set from urllib.parse import urlparse @@ -27,6 +28,8 @@ # Must start with letter or underscore VALID_PLUGIN_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_.-]*$") PLUGIN_REPO_URL = "https://github.com/openwallet-foundation/acapy-plugins" +PYPROJECT_TOML = "pyproject.toml" +PIP_FREEZE_GIT_MARKER = "@ git+" def _validate_plugin_name(plugin_name: str) -> bool: @@ -58,34 +61,108 @@ def _sanitize_url_component(component: str) -> str: return re.sub(r"[^a-zA-Z0-9_.-]", "", component) -def _detect_package_manager() -> Optional[str]: - """Detect which package manager is being used (poetry, pip, etc.). +def _validate_plugin_source(plugin_source: str) -> bool: + """Validate that a plugin source URL is safe for use in subprocess calls. + + Args: + plugin_source: The plugin source URL to validate Returns: - "poetry" if Poetry is detected, None otherwise + True if valid, False otherwise """ - # Check if poetry is available - if not which("poetry"): - return None + if not plugin_source or len(plugin_source) > 500: + return False - # Check if we're in a Poetry-managed virtual environment - # Poetry typically sets VIRTUAL_ENV to a path containing ".venv" or in Poetry's cache - venv_path = os.environ.get("VIRTUAL_ENV") - if venv_path: - venv_path_obj = Path(venv_path) - # Check if this looks like a Poetry-managed venv - # Poetry venvs are often named like "project-name--py3.13" - if venv_path_obj.name.endswith(".venv") or "poetry" in str(venv_path).lower(): - # Check if pyproject.toml exists nearby (Poetry projects have it at root) - parent = venv_path_obj.parent - if (parent / "pyproject.toml").exists() or ( - (venv_path_obj.parent.parent.parent / "pyproject.toml") - ).exists(): - return "poetry" + # Check for shell injection characters + dangerous_chars = [";", "&", "|", "`", "$", "(", ")", "<", ">", "\n", "\r"] + if any(char in plugin_source for char in dangerous_chars): + return False - # Check if we're in a Poetry project by looking for pyproject.toml - # Look in current directory or project root + # Validate it's a git+ URL format (expected from _get_plugin_source) + # Format: git+https://github.com/...@version#subdirectory=... + # Or allow standard pip package names + if plugin_source.startswith("git+"): + # Parse and validate git URL structure + try: + # Extract the base URL part (after git+) + url_with_version = plugin_source[4:] # Remove "git+" + url_part = ( + url_with_version.split("@")[0] + if "@" in url_with_version + else url_with_version.split("#")[0] + ) + # Should start with http:// or https:// (prefer https for security) + if url_part.startswith("https://"): + parsed = urlparse(url_part) + # Must have a valid netloc (domain) + if parsed.netloc: + return True + # Also allow http:// for compatibility, but prefer https + elif url_part.startswith("http://"): + parsed = urlparse(url_part) + if parsed.netloc: + return True + except Exception: + return False + elif re.match(r"^[a-zA-Z0-9_.-]+$", plugin_source): + # Allow simple package names (alphanumeric, dots, hyphens, underscores) + return True + + return False + + +def _is_poetry_venv(venv_path: str) -> bool: + """Check if the virtual environment path indicates Poetry management. + + Args: + venv_path: Path to the virtual environment + + Returns: + True if this looks like a Poetry-managed venv + + """ + venv_path_obj = Path(venv_path) + # Check if this looks like a Poetry-managed venv + # Poetry venvs are often named like "project-name--py3.13" + if not (venv_path_obj.name.endswith(".venv") or "poetry" in str(venv_path).lower()): + return False + + # Check if pyproject.toml exists nearby (Poetry projects have it at root) + parent = venv_path_obj.parent + return (parent / PYPROJECT_TOML).exists() or ( + venv_path_obj.parent.parent.parent / PYPROJECT_TOML + ).exists() + + +def _is_poetry_pyproject(pyproject_file: Path) -> bool: + """Check if pyproject.toml file indicates Poetry usage. + + Args: + pyproject_file: Path to pyproject.toml file + + Returns: + True if pyproject.toml contains Poetry configuration + + """ + if not pyproject_file.exists(): + return False + + try: + with open(pyproject_file, "r") as f: + content = f.read() + return "[tool.poetry]" in content or '[tool."poetry.core"]' in content + except Exception: + return False + + +def _get_pyproject_search_paths() -> List[Path]: + """Get list of paths to search for pyproject.toml. + + Returns: + List of directory paths to check + + """ search_paths = [Path.cwd()] # Also check from the acapy_agent module location (project root) @@ -98,22 +175,35 @@ def _detect_package_manager() -> Optional[str]: if project_root not in search_paths: search_paths.append(project_root) except Exception: - # It is safe to ignore errors here; failure to import the module or resolve its path - # simply means we cannot add an extra search path for pyproject.toml detection. + # It is safe to ignore errors here; failure to import the module or + # resolve its path simply means we cannot add an extra search path + # for pyproject.toml detection. pass - # Check each potential project root for pyproject.toml - for root_path in search_paths: - pyproject_file = root_path / "pyproject.toml" - if pyproject_file.exists(): - # Check if pyproject.toml has [tool.poetry] section - try: - with open(pyproject_file, "r") as f: - content = f.read() - if "[tool.poetry]" in content or '[tool."poetry.core"]' in content: - return "poetry" - except Exception: - continue + return search_paths + + +def _detect_package_manager() -> Optional[str]: + """Detect which package manager is being used (poetry, pip, etc.). + + Returns: + "poetry" if Poetry is detected, None otherwise + + """ + # Check if poetry is available + if not which("poetry"): + return None + + # Check if we're in a Poetry-managed virtual environment + # Poetry typically sets VIRTUAL_ENV to a path containing ".venv" or in Poetry's cache + venv_path = os.environ.get("VIRTUAL_ENV") + if venv_path and _is_poetry_venv(venv_path): + return "poetry" + + # Check if we're in a Poetry project by looking for pyproject.toml + for root_path in _get_pyproject_search_paths(): + if _is_poetry_pyproject(root_path / PYPROJECT_TOML): + return "poetry" return None @@ -183,40 +273,116 @@ def _try_get_package_version( continue return None, None + def _is_valid_revision(self, revision: str) -> bool: + """Check if a revision/tag string is valid. + + Args: + revision: The revision or tag to validate + + Returns: + True if revision is valid + + """ + if not revision: + return False + return ( + "." in revision or revision in ["main", "master", "develop"] + ) and re.match(r"^[a-zA-Z0-9._-]+$", revision) + + def _extract_tag_from_path(self, parsed_url) -> Optional[str]: + """Extract tag from URL path (standard format). + + Args: + parsed_url: Parsed URL object + + Returns: + Tag string if found and valid, None otherwise + + """ + if "@" not in parsed_url.path: + return None + + path_parts = parsed_url.path.rsplit("@", 1) + if len(path_parts) != 2: + return None + + tag = path_parts[1].split("#")[0] # Remove fragment if present + return tag if self._is_valid_revision(tag) else None + + def _extract_tag_from_netloc(self, parsed_url) -> Optional[str]: + """Extract tag from URL netloc (non-standard format). + + Args: + parsed_url: Parsed URL object + + Returns: + Tag string if found and valid, None otherwise + + """ + if not parsed_url.scheme or "@" not in parsed_url.netloc: + return None + + netloc_parts = parsed_url.netloc.rsplit("@", 1) + if len(netloc_parts) != 2: + return None + + tag = netloc_parts[1] + return tag if self._is_valid_revision(tag) else None + + def _extract_version_from_url(self, url: str) -> Optional[str]: + """Extract version tag from a Git URL. + + Args: + url: The Git URL to parse + + Returns: + Version tag if found, None otherwise + + """ + try: + parsed = urlparse(url) + # Try standard format first: @version in path + tag = self._extract_tag_from_path(parsed) + if tag: + return tag + + # Fallback: non-standard format with @version in netloc + tag = self._extract_tag_from_netloc(parsed) + if tag: + return tag + except Exception: + LOGGER.debug("Failed to parse URL: %s", url) + return None + def _extract_source_version_from_direct_url( self, direct_url_data: dict ) -> Optional[str]: """Extract git tag/version from direct_url.json data.""" + # Try vcs_info first (primary method) vcs_info = direct_url_data.get("vcs_info", {}) if vcs_info.get("vcs") == "git": revision = vcs_info.get("requested_revision") - if revision and ( - "." in revision or revision in ["main", "master", "develop"] - ): + if self._is_valid_revision(revision): return revision - if url := direct_url_data.get("url", ""): - try: - # Parse URL properly instead of using string splits - parsed = urlparse(url) - if parsed.scheme and "@" in parsed.netloc: - # Extract tag/revision from netloc (e.g., git+https://...@tag) - netloc_parts = parsed.netloc.rsplit("@", 1) - if len(netloc_parts) == 2: - tag = netloc_parts[1] - # Validate tag is safe - if "." in tag or tag in ["main", "master", "develop"]: - # Additional validation: tag should be alphanumeric - # or contain dots/hyphens - if re.match(r"^[a-zA-Z0-9._-]+$", tag): - return tag - except Exception: - LOGGER.debug("Failed to parse URL: %s", url) + # Fallback: Try to extract version from URL if vcs_info didn't work + # This handles edge cases where vcs_info.requested_revision might be missing + url = direct_url_data.get("url", "") + if url: + return self._extract_version_from_url(url) + return None - def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str]: - """Get source version from pip's .dist-info/direct_url.json file.""" - # Try pip show to find location + def _get_location_from_pip_show(self, package_name: str) -> Optional[str]: + """Get package location using pip show. + + Args: + package_name: Name of the package + + Returns: + Location path if found, None otherwise + + """ try: cmd = _get_pip_command_base() cmd.extend(["show", package_name]) @@ -226,65 +392,116 @@ def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str] text=True, check=False, ) - if result.returncode == 0: - location = next( - ( - line.split(":", 1)[1].strip() - for line in result.stdout.split("\n") - if line.startswith("Location:") - ), - None, + if result.returncode != 0: + return None + + return next( + ( + line.split(":", 1)[1].strip() + for line in result.stdout.split("\n") + if line.startswith("Location:") + ), + None, + ) + except Exception as e: + LOGGER.exception( + f"Error while trying to locate package '{package_name}': {e}" + ) + return None + + def _read_direct_url_file(self, direct_url_file: Path) -> Optional[str]: + """Read and extract version from a direct_url.json file. + + Args: + direct_url_file: Path to direct_url.json file + + Returns: + Source version if found, None otherwise + + """ + if not direct_url_file.exists(): + return None + + try: + with open(direct_url_file) as f: + source_version = self._extract_source_version_from_direct_url( + json.load(f) ) - if location: - for item in Path(location).iterdir(): - if item.is_dir() and item.name.endswith(".dist-info"): - direct_url_file = item / "direct_url.json" - if direct_url_file.exists(): - try: - with open(direct_url_file) as f: - source_version = ( - self._extract_source_version_from_direct_url( - json.load(f) - ) - ) - if source_version: - return source_version - except (json.JSONDecodeError, IOError) as e: - LOGGER.warning( - "Failed to read or parse direct_url.json for package '%s': %s", - package_name, - e, - ) + return source_version + except (json.JSONDecodeError, IOError) as e: + LOGGER.debug( + "Failed to read or parse direct_url.json for %s: %s", + direct_url_file, + e, + ) + return None + + def _find_direct_url_in_location(self, location: str) -> Optional[str]: + """Find and read direct_url.json in a package location. + + Args: + location: Package installation location + + Returns: + Source version if found, None otherwise + + """ + try: + location_path = Path(location) + for item in location_path.iterdir(): + if item.is_dir() and item.name.endswith(".dist-info"): + direct_url_file = item / "direct_url.json" + source_version = self._read_direct_url_file(direct_url_file) + if source_version: + return source_version except Exception as e: - LOGGER.exception(f"Error while trying to locate direct_url.json for package '{package_name}': {e}") + LOGGER.warning( + "Failed to search location '%s' for direct_url.json: %s", + location, + e, + ) + return None - # Fallback: search distributions + def _search_distributions_for_direct_url(self, package_name: str) -> Optional[str]: + """Search installed distributions for direct_url.json. + + Args: + package_name: Name of the package to search for + + Returns: + Source version if found, None otherwise + + """ for dist in distributions(): - if dist.metadata["Name"].lower() == package_name.lower(): - dist_location = Path(dist.location) - pkg_name, pkg_version = dist.metadata["Name"], dist.version - for name_variant in [ - f"{pkg_name}-{pkg_version}.dist-info", - f"{pkg_name.replace('-', '_')}-{pkg_version}.dist-info", - f"{pkg_name.replace('.', '_')}-{pkg_version}.dist-info", - ]: - direct_url_file = dist_location / name_variant / "direct_url.json" - if direct_url_file.exists(): - try: - with open(direct_url_file) as f: - source_version = ( - self._extract_source_version_from_direct_url( - json.load(f) - ) - ) - if source_version: - return source_version - except (json.JSONDecodeError, IOError) as e: - LOGGER.debug( - "Failed to read or parse direct_url.json for %s: %s", direct_url_file, e - ) - - # Last resort: pip freeze + if dist.metadata["Name"].lower() != package_name.lower(): + continue + + dist_location = Path(dist.location) + pkg_name, pkg_version = dist.metadata["Name"], dist.version + name_variants = [ + f"{pkg_name}-{pkg_version}.dist-info", + f"{pkg_name.replace('-', '_')}-{pkg_version}.dist-info", + f"{pkg_name.replace('.', '_')}-{pkg_version}.dist-info", + ] + + for name_variant in name_variants: + direct_url_file = dist_location / name_variant / "direct_url.json" + source_version = self._read_direct_url_file(direct_url_file) + if source_version: + return source_version + + return None + + def _extract_version_from_pip_freeze(self, package_name: str) -> Optional[str]: + """Extract version from pip freeze output. + + Args: + package_name: Name of the package + + Returns: + Source version if found, None otherwise + + """ try: cmd = _get_pip_command_base() cmd.append("freeze") @@ -294,35 +511,59 @@ def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str] text=True, check=False, ) - if result.returncode == 0: - for line in result.stdout.split("\n"): - if package_name.lower() in line.lower() and "@ git+" in line: - # Parse git URL properly - try: - # Extract git URL from pip freeze line - # Format: package==version @ git+https://...@tag#subdirectory=... - if "@ git+" in line: - git_url_part = line.split("@ git+", 1)[1] - parsed = urlparse(f"git+{git_url_part}") - if "@" in parsed.netloc: - netloc_parts = parsed.netloc.rsplit("@", 1) - if len(netloc_parts) == 2: - tag = netloc_parts[1] - # Validate tag is safe - if ( - "." in tag - or tag in ["main", "master", "develop"] - ) and re.match(r"^[a-zA-Z0-9._-]+$", tag): - return tag - except Exception: - LOGGER.debug( - "Failed to parse git URL from pip freeze line: %s", line - ) - continue + if result.returncode != 0: + return None + + for line in result.stdout.split("\n"): + if ( + package_name.lower() not in line.lower() + or PIP_FREEZE_GIT_MARKER not in line + ): + continue + + # Extract git URL from pip freeze line + # Format: package==version @ git+https://github.com/org/repo@tag#subdirectory=... + try: + git_url_part = line.split(PIP_FREEZE_GIT_MARKER, 1)[1] + parsed = urlparse(f"git+{git_url_part}") + # Try standard format first, then fallback + tag = self._extract_tag_from_path(parsed) + if tag: + return tag + + tag = self._extract_tag_from_netloc(parsed) + if tag: + return tag + except Exception: + LOGGER.debug("Failed to parse git URL from pip freeze line: %s", line) + continue except Exception as e: - LOGGER.debug("Exception occurred while running pip freeze to get source version for %s: %s", package_name, e, exc_info=True) + LOGGER.debug( + "Exception occurred while running pip freeze to get source version " + "for %s: %s", + package_name, + e, + exc_info=True, + ) return None + def _get_source_version_from_dist_info(self, package_name: str) -> Optional[str]: + """Get source version from pip's .dist-info/direct_url.json file.""" + # Strategy 1: Use pip show to find location + location = self._get_location_from_pip_show(package_name) + if location: + source_version = self._find_direct_url_in_location(location) + if source_version: + return source_version + + # Strategy 2: Search distributions + source_version = self._search_distributions_for_direct_url(package_name) + if source_version: + return source_version + + # Strategy 3: Last resort - pip freeze + return self._extract_version_from_pip_freeze(package_name) + def _get_installed_plugin_version(self, plugin_name: str) -> Optional[dict]: """Get version info of an installed plugin. @@ -351,8 +592,8 @@ def _get_installed_plugin_version(self, plugin_name: str) -> Optional[dict]: module = importlib.import_module(plugin_name) if hasattr(module, "__version__"): package_version = str(module.__version__) - except (ImportError, AttributeError, Exception): - # Catch all exceptions to prevent any side effects from breaking + except (ImportError, AttributeError): + # Catch import/attribute errors to prevent side effects from breaking # version lookup pass @@ -405,67 +646,322 @@ def _get_plugin_source(self, plugin_name: str) -> str: f"git+{PLUGIN_REPO_URL}@{version_to_use}#subdirectory={sanitized_plugin_name}" ) + def _extract_version_info(self, plugin_source: str) -> str: + """Extract version information from plugin source URL for logging. + + Args: + plugin_source: The plugin source URL + + Returns: + Version info string (e.g., " (version: 1.0.0)") or empty string + + """ + if "@" not in plugin_source: + return "" + + parts = plugin_source.split("@") + if len(parts) <= 1: + return "" + + version_part = parts[1].split("#")[0] if "#" in parts[1] else parts[1] + return f" (version: {version_part})" + + def _log_installation_start( + self, plugin_name: str, plugin_source: str, version_info: str, upgrade: bool + ): + """Log the start of plugin installation. + + Args: + plugin_name: The plugin name + plugin_source: The plugin source URL + version_info: Version information string + upgrade: Whether this is an upgrade + + """ + display_name = plugin_name or plugin_source + if upgrade: + LOGGER.info("Upgrading plugin '%s'%s", display_name, version_info) + else: + LOGGER.info("Installing plugin '%s'%s", display_name, version_info) + + def _build_install_command(self, plugin_source: str, upgrade: bool) -> List[str]: + """Build the pip install command. + + Args: + plugin_source: The plugin source URL + upgrade: Whether to upgrade the plugin + + Returns: + List of command parts + + """ + cmd = _get_pip_command() + cmd.extend(["--no-cache-dir"]) + if upgrade: + cmd.extend(["--upgrade", "--force-reinstall", "--no-deps"]) + cmd.append(plugin_source) + return cmd + + def _handle_install_result( + self, + result: CompletedProcess, + plugin_name: str, + plugin_source: str, + version_info: str, + upgrade: bool, + ) -> bool: + """Handle the result of plugin installation. + + Args: + result: The subprocess result + plugin_name: The plugin name + plugin_source: The plugin source URL + version_info: Version information string + upgrade: Whether this was an upgrade + + Returns: + True if installation succeeded, False otherwise + + """ + display_name = plugin_name or plugin_source + + if result.returncode == 0: + action = "Upgraded" if upgrade else "Installed" + LOGGER.info( + "Successfully %s plugin '%s'%s", + action.lower(), + display_name, + version_info, + ) + return True + + action = "upgrade" if upgrade else "install" + LOGGER.error( + "Failed to %s plugin '%s'%s: %s", + action, + display_name, + version_info, + result.stderr, + ) + return False + def _install_plugin( self, plugin_source: str, plugin_name: str, upgrade: bool = False ) -> bool: - """Install a plugin using pip or poetry run pip.""" - try: - # Extract version from source for logging - version_info = "" - if "@" in plugin_source: - parts = plugin_source.split("@") - if len(parts) > 1: - version_part = parts[1].split("#")[0] if "#" in parts[1] else parts[1] - version_info = f" (version: {version_part})" - - # Log before installation - if upgrade: - LOGGER.info( - "Upgrading plugin '%s'%s", - plugin_name or plugin_source, - version_info, - ) - else: - LOGGER.info( - "Installing plugin '%s'%s", - plugin_name or plugin_source, - version_info, - ) + """Install a plugin using pip or poetry run pip. + + Args: + plugin_source: The plugin source URL (should come from _get_plugin_source) + plugin_name: The plugin name + upgrade: Whether to upgrade the plugin + + Returns: + True if installation succeeded, False otherwise - # Detect package manager and use appropriate command - cmd = _get_pip_command() - cmd.extend(["--no-cache-dir"]) - if upgrade: - cmd.extend(["--upgrade", "--force-reinstall", "--no-deps"]) - cmd.append(plugin_source) + Raises: + ValueError: If plugin_source is invalid or unsafe + """ + # Validate plugin_source to prevent command injection + if not _validate_plugin_source(plugin_source): + raise ValueError( + f"Invalid or unsafe plugin_source: '{plugin_source}'. " + "Plugin sources must be valid git+ URLs or safe package names. " + "Use _get_plugin_source() to generate safe plugin sources." + ) + + try: + version_info = self._extract_version_info(plugin_source) + self._log_installation_start( + plugin_name, plugin_source, version_info, upgrade + ) + + cmd = self._build_install_command(plugin_source, upgrade) result = subprocess.run(cmd, capture_output=True, text=True, check=False) - if result.returncode == 0: - action = "Upgraded" if upgrade else "Installed" - LOGGER.info( - "Successfully %s plugin '%s'%s", - action.lower(), - plugin_name or plugin_source, - version_info, - ) - return True - else: - action = "upgrade" if upgrade else "install" - LOGGER.error( - "Failed to %s plugin '%s'%s: %s", - action, - plugin_name or plugin_source, - version_info, - result.stderr, - ) - return False + return self._handle_install_result( + result, plugin_name, plugin_source, version_info, upgrade + ) except Exception as e: LOGGER.error( "Error installing plugin %s: %s", plugin_name or plugin_source, e ) return False + def _check_plugin_exists(self, plugin_name: str) -> bool: + """Check if a plugin can be imported (exists). + + Args: + plugin_name: The name of the plugin module + + Returns: + True if plugin can be imported, False otherwise + + """ + try: + importlib.import_module(plugin_name) + return True + except ImportError: + return False + + def _get_installed_version_info( + self, plugin_name: str + ) -> tuple[Optional[str], Optional[str]]: + """Get installed version information for a plugin. + + Args: + plugin_name: The name of the plugin module + + Returns: + Tuple of (package_version, source_version) + + """ + version_info = self._get_installed_plugin_version(plugin_name) + if not version_info: + return None, None + + return ( + version_info.get("package_version"), + version_info.get("source_version"), + ) + + def _normalize_version(self, version: str) -> str: + """Normalize version string by removing build/metadata suffixes. + + Args: + version: Version string to normalize + + Returns: + Normalized version string + + """ + return version.split("+")[0].split("-")[0].strip() + + def _check_version_matches_implicit( + self, + plugin_name: str, + installed_package_version: Optional[str], + target_version: str, + ) -> bool: + """Check if installed version matches target when no explicit version specified. + + Args: + plugin_name: The name of the plugin module + installed_package_version: Installed package version + target_version: Target version to match + + Returns: + True if versions match, False otherwise + + """ + if not installed_package_version: + return False + + normalized_installed = self._normalize_version(installed_package_version) + normalized_target = self._normalize_version(target_version) + + if normalized_installed == normalized_target: + LOGGER.debug( + "Plugin '%s' already installed with matching version: %s", + plugin_name, + installed_package_version, + ) + return True + + return False + + def _check_version_matches_explicit( + self, + plugin_name: str, + installed_source_version: Optional[str], + installed_package_version: Optional[str], + target_version: str, + ) -> bool: + """Check if installed version matches target when explicit version specified. + + Args: + plugin_name: The name of the plugin module + installed_source_version: Installed source version (git tag) + installed_package_version: Installed package version + target_version: Target version to match + + Returns: + True if versions match, False otherwise + + """ + if installed_source_version and installed_source_version == target_version: + LOGGER.info( + "Plugin '%s' already installed from source version %s " + "(package version: %s)", + plugin_name, + installed_source_version, + installed_package_version or "unknown", + ) + return True + + return False + + def _log_version_mismatch( + self, + plugin_name: str, + installed_source_version: Optional[str], + installed_package_version: Optional[str], + target_version: str, + ): + """Log version mismatch details. + + Args: + plugin_name: The name of the plugin module + installed_source_version: Installed source version (git tag) + installed_package_version: Installed package version + target_version: Target version + + """ + if installed_source_version: + LOGGER.info( + "Plugin '%s' source version mismatch detected: " + "installed=%s, target=%s. Upgrading plugin...", + plugin_name, + installed_source_version, + target_version, + ) + elif installed_package_version: + LOGGER.info( + "Plugin '%s' needs upgrade: current package version=%s, " + "target version=%s. Upgrading plugin...", + plugin_name, + installed_package_version, + target_version, + ) + else: + LOGGER.info( + "Plugin '%s' is installed but version cannot be determined. " + "Upgrading to ensure correct version (%s)...", + plugin_name, + target_version, + ) + + def _verify_plugin_import(self, plugin_name: str) -> bool: + """Verify that an installed plugin can be imported. + + Args: + plugin_name: The name of the plugin module + + Returns: + True if plugin can be imported, False otherwise + + """ + try: + importlib.import_module(plugin_name) + return True + except ImportError as e: + LOGGER.error( + "Plugin '%s' was installed but cannot be imported: %s", + plugin_name, + e, + ) + return False + def ensure_plugin_installed(self, plugin_name: str) -> bool: """Ensure a plugin is installed with the correct version. @@ -490,96 +986,49 @@ def ensure_plugin_installed(self, plugin_name: str) -> bool: "or underscore." ) - # Get target version target_version = ( self.plugin_version if self.plugin_version is not None else __version__ ) - # Check if plugin can be imported (exists) - plugin_exists = False - try: - importlib.import_module(plugin_name) - plugin_exists = True - except ImportError: - plugin_exists = False + plugin_exists = self._check_plugin_exists(plugin_name) + installed_package_version, installed_source_version = ( + self._get_installed_version_info(plugin_name) + if plugin_exists + else (None, None) + ) - # Get installed version info if plugin exists - installed_version_info = None - installed_package_version = None - installed_source_version = None + # Check if version matches (different logic for explicit vs implicit versions) if plugin_exists: - installed_version_info = self._get_installed_plugin_version(plugin_name) - if installed_version_info: - installed_package_version = installed_version_info.get("package_version") - installed_source_version = installed_version_info.get("source_version") - - # For git-installed packages, we check both package version and source - # version (git tag). When a version is explicitly specified, we check - # if the source version matches. - - if plugin_exists and not self.plugin_version: - # No explicit version specified - using current ACA-Py version - if installed_package_version: - normalized_installed = ( - installed_package_version.split("+")[0].split("-")[0].strip() - ) - normalized_target = target_version.split("+")[0].split("-")[0].strip() - if normalized_installed == normalized_target: - LOGGER.debug( - "Plugin '%s' already installed with matching version: %s", - plugin_name, - installed_package_version, - ) + if not self.plugin_version: + # No explicit version - check package version match + if self._check_version_matches_implicit( + plugin_name, installed_package_version, target_version + ): self.installed_plugins.add(plugin_name) return True - # Version check inconclusive - reinstall to be safe - LOGGER.info( - "Plugin '%s' exists but version check inconclusive. " - "Reinstalling to ensure correct version (%s)...", - plugin_name, - target_version, - ) - elif plugin_exists and self.plugin_version: - # Explicit version specified - check if source version matches - if installed_source_version and installed_source_version == target_version: LOGGER.info( - "Plugin '%s' already installed from source version %s " - "(package version: %s)", + "Plugin '%s' exists but version check inconclusive. " + "Reinstalling to ensure correct version (%s)...", plugin_name, - installed_source_version, - installed_package_version or "unknown", - ) - self.installed_plugins.add(plugin_name) - return True - # Version mismatch detected - log upgrade details - if installed_source_version: - LOGGER.info( - "Plugin '%s' source version mismatch detected: " - "installed=%s, target=%s. Upgrading plugin...", - plugin_name, - installed_source_version, target_version, ) - elif installed_package_version: - # Source version not available, but package version exists - # Still upgrade since we want specific git tag/version - LOGGER.info( - "Plugin '%s' needs upgrade: current package version=%s, " - "target version=%s. Upgrading plugin...", + else: + # Explicit version - check source version match + if self._check_version_matches_explicit( plugin_name, + installed_source_version, installed_package_version, target_version, - ) - else: - # Can't determine version, upgrade to ensure correct version - LOGGER.info( - "Plugin '%s' is installed but version cannot be determined. " - "Upgrading to ensure correct version (%s)...", + ): + self.installed_plugins.add(plugin_name) + return True + self._log_version_mismatch( plugin_name, + installed_source_version, + installed_package_version, target_version, ) - elif not plugin_exists: - # Plugin doesn't exist - install it + else: LOGGER.info( "Plugin '%s' not found. Installing version %s...", plugin_name, @@ -592,37 +1041,20 @@ def ensure_plugin_installed(self, plugin_name: str) -> bool: ) return False - # Determine if this is an upgrade (plugin exists) - is_upgrade = plugin_exists - - # Get installation source from acapy-plugins repo + # Attempt installation plugin_source = self._get_plugin_source(plugin_name) - - # Attempt installation (with upgrade to ensure correct version) if self._install_plugin( - plugin_source, plugin_name=plugin_name, upgrade=is_upgrade + plugin_source, plugin_name=plugin_name, upgrade=plugin_exists ): - # Verify installation - first check if it can be imported - try: - importlib.import_module(plugin_name) - except ImportError as e: - LOGGER.error( - "Plugin '%s' was installed but cannot be imported: %s", - plugin_name, - e, - ) - return False - - # Plugin installed and importable - success - self.installed_plugins.add(plugin_name) - return True - else: - LOGGER.error( - "Failed to install plugin '%s' (version %s)", - plugin_name, - target_version, - ) + if self._verify_plugin_import(plugin_name): + self.installed_plugins.add(plugin_name) + return True + LOGGER.error( + "Failed to install plugin '%s' (version %s)", + plugin_name, + target_version, + ) return False def ensure_plugins_installed(self, plugin_names: List[str]) -> List[str]: From 84ba30451e40bfdec9ac1bf6de68093308a26927 Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 24 Nov 2025 17:58:28 -0500 Subject: [PATCH 22/23] remove http Signed-off-by: Patrick St-Louis --- acapy_agent/utils/plugin_installer.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index 29548ff22c..219e78ed77 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -92,17 +92,12 @@ def _validate_plugin_source(plugin_source: str) -> bool: if "@" in url_with_version else url_with_version.split("#")[0] ) - # Should start with http:// or https:// (prefer https for security) + # Must start with https:// for security if url_part.startswith("https://"): parsed = urlparse(url_part) # Must have a valid netloc (domain) if parsed.netloc: return True - # Also allow http:// for compatibility, but prefer https - elif url_part.startswith("http://"): - parsed = urlparse(url_part) - if parsed.netloc: - return True except Exception: return False elif re.match(r"^[a-zA-Z0-9_.-]+$", plugin_source): From 10d4881d4821d68079a78f573f4ec7882175399f Mon Sep 17 00:00:00 2001 From: Patrick St-Louis Date: Mon, 24 Nov 2025 18:38:35 -0500 Subject: [PATCH 23/23] address sonarqube issues Signed-off-by: Patrick St-Louis --- acapy_agent/utils/plugin_installer.py | 142 +++++++++++++++++--------- 1 file changed, 93 insertions(+), 49 deletions(-) diff --git a/acapy_agent/utils/plugin_installer.py b/acapy_agent/utils/plugin_installer.py index 219e78ed77..74b0091962 100644 --- a/acapy_agent/utils/plugin_installer.py +++ b/acapy_agent/utils/plugin_installer.py @@ -957,6 +957,90 @@ def _verify_plugin_import(self, plugin_name: str) -> bool: ) return False + def _check_and_handle_existing_plugin( + self, + plugin_name: str, + installed_package_version: Optional[str], + installed_source_version: Optional[str], + target_version: str, + ) -> bool: + """Check if existing plugin version matches and handle accordingly. + + Args: + plugin_name: The name of the plugin module + installed_package_version: Installed package version + installed_source_version: Installed source version + target_version: Target version to match + + Returns: + True if plugin is already correctly installed, False if needs installation + + """ + if not self.plugin_version: + # No explicit version - check package version match + if self._check_version_matches_implicit( + plugin_name, installed_package_version, target_version + ): + self.installed_plugins.add(plugin_name) + return True + LOGGER.info( + "Plugin '%s' exists but version check inconclusive. " + "Reinstalling to ensure correct version (%s)...", + plugin_name, + target_version, + ) + else: + # Explicit version - check source version match + if self._check_version_matches_explicit( + plugin_name, + installed_source_version, + installed_package_version, + target_version, + ): + self.installed_plugins.add(plugin_name) + return True + self._log_version_mismatch( + plugin_name, + installed_source_version, + installed_package_version, + target_version, + ) + return False + + def _attempt_plugin_installation( + self, plugin_name: str, plugin_exists: bool, target_version: str + ) -> bool: + """Attempt to install or upgrade a plugin. + + Args: + plugin_name: The name of the plugin module + plugin_exists: Whether plugin already exists + target_version: Target version + + Returns: + True if installation succeeded, False otherwise + + """ + if not self.auto_install: + LOGGER.warning( + "Plugin '%s' is not installed and auto-install is disabled", plugin_name + ) + return False + + plugin_source = self._get_plugin_source(plugin_name) + if self._install_plugin( + plugin_source, plugin_name=plugin_name, upgrade=plugin_exists + ) and self._verify_plugin_import(plugin_name): + self.installed_plugins.add(plugin_name) + return True + + LOGGER.error( + "Failed to install plugin '%s' (version %s)", + plugin_name, + target_version, + ) + return False + def ensure_plugin_installed(self, plugin_name: str) -> bool: """Ensure a plugin is installed with the correct version. @@ -994,35 +1078,13 @@ def ensure_plugin_installed(self, plugin_name: str) -> bool: # Check if version matches (different logic for explicit vs implicit versions) if plugin_exists: - if not self.plugin_version: - # No explicit version - check package version match - if self._check_version_matches_implicit( - plugin_name, installed_package_version, target_version - ): - self.installed_plugins.add(plugin_name) - return True - LOGGER.info( - "Plugin '%s' exists but version check inconclusive. " - "Reinstalling to ensure correct version (%s)...", - plugin_name, - target_version, - ) - else: - # Explicit version - check source version match - if self._check_version_matches_explicit( - plugin_name, - installed_source_version, - installed_package_version, - target_version, - ): - self.installed_plugins.add(plugin_name) - return True - self._log_version_mismatch( - plugin_name, - installed_source_version, - installed_package_version, - target_version, - ) + if self._check_and_handle_existing_plugin( + plugin_name, + installed_package_version, + installed_source_version, + target_version, + ): + return True else: LOGGER.info( "Plugin '%s' not found. Installing version %s...", @@ -1030,27 +1092,9 @@ def ensure_plugin_installed(self, plugin_name: str) -> bool: target_version, ) - if not self.auto_install: - LOGGER.warning( - "Plugin '%s' is not installed and auto-install is disabled", plugin_name - ) - return False - - # Attempt installation - plugin_source = self._get_plugin_source(plugin_name) - if self._install_plugin( - plugin_source, plugin_name=plugin_name, upgrade=plugin_exists - ): - if self._verify_plugin_import(plugin_name): - self.installed_plugins.add(plugin_name) - return True - - LOGGER.error( - "Failed to install plugin '%s' (version %s)", - plugin_name, - target_version, + return self._attempt_plugin_installation( + plugin_name, plugin_exists, target_version ) - return False def ensure_plugins_installed(self, plugin_names: List[str]) -> List[str]: """Ensure multiple plugins are installed.