diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md new file mode 100644 index 0000000000..bdc1ecfed4 --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md @@ -0,0 +1,40 @@ +# Multiple Marketplace Registrations + +Register multiple marketplaces and load plugins on-demand. + +## Usage + +```bash +python main.py +``` + +## Key Concepts + +```python +# Configure marketplaces in AgentContext +agent_context = AgentContext( + registered_marketplaces=[ + MarketplaceRegistration( + name="company", + source="github:company/plugins", + auto_load="all", # Load all plugins at conversation start + ), + MarketplaceRegistration( + name="experimental", + source="github:company/experimental", + # auto_load=None - registered but not auto-loaded + ), + ], +) + +# Create agent and conversation +agent = Agent(llm=llm, tools=tools, agent_context=agent_context) +conversation = Conversation(agent=agent, workspace=workspace) + +# Load a plugin on-demand from registered marketplace +conversation.load_plugin("beta-tool@experimental") +``` + +## Related + +- [43_mixed_marketplace_skills](../../01_standalone_sdk/43_mixed_marketplace_skills/) - Example marketplace used here diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/.plugin/marketplace.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/.plugin/marketplace.json new file mode 100644 index 0000000000..f77b951d0b --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/.plugin/marketplace.json @@ -0,0 +1,6 @@ +{ + "name": "auto-marketplace", + "owner": {"name": "Demo"}, + "plugins": [{"name": "helper", "source": "./plugins/helper"}], + "skills": [] +} diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/plugins/helper/.plugin/plugin.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/plugins/helper/.plugin/plugin.json new file mode 100644 index 0000000000..973dc3aa5f --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/plugins/helper/.plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "helper", + "version": "1.0.0", + "description": "A helper plugin with utility skills" +} diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/plugins/helper/skills/SKILL.md b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/plugins/helper/skills/SKILL.md new file mode 100644 index 0000000000..9871e44e9f --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/plugins/helper/skills/SKILL.md @@ -0,0 +1,6 @@ +--- +name: helper-skill +description: Provides helpful utilities and tips +--- +# Helper Skill +When asked for help or tips, provide clear and useful guidance. diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/.plugin/marketplace.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/.plugin/marketplace.json new file mode 100644 index 0000000000..7283b8c401 --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/.plugin/marketplace.json @@ -0,0 +1,6 @@ +{ + "name": "demo-marketplace", + "owner": {"name": "Demo"}, + "plugins": [{"name": "greeter", "source": "./plugins/greeter"}], + "skills": [] +} diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/plugins/greeter/.plugin/plugin.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/plugins/greeter/.plugin/plugin.json new file mode 100644 index 0000000000..f21faea2a6 --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/plugins/greeter/.plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "greeter", + "version": "1.0.0", + "description": "A greeting plugin" +} diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/plugins/greeter/skills/SKILL.md b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/plugins/greeter/skills/SKILL.md new file mode 100644 index 0000000000..cbeb08b370 --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/plugins/greeter/skills/SKILL.md @@ -0,0 +1,6 @@ +--- +name: greeter-skill +description: Generates friendly greetings +--- +# Greeter Skill +When asked to greet someone, respond with a warm, friendly greeting. diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py new file mode 100644 index 0000000000..a25eafedd0 --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py @@ -0,0 +1,74 @@ +"""Example: Multiple Marketplace Registrations + +Demonstrates two loading strategies for marketplace plugins: + +- auto_load="all": Plugins loaded automatically at conversation start +- auto_load=None: Plugins loaded on-demand via conversation.load_plugin() + +This example uses pre-created marketplaces in: +- ./auto_marketplace/ - auto-loaded at conversation start +- ./demo_marketplace/ - loaded on-demand +""" + +import os +from pathlib import Path + +from openhands.sdk import LLM, Agent, AgentContext, Conversation +from openhands.sdk.plugin import MarketplaceRegistration + + +SCRIPT_DIR = Path(__file__).parent + + +def main(): + llm = LLM( + model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"), + api_key=os.getenv("LLM_API_KEY"), + base_url=os.getenv("LLM_BASE_URL"), + ) + + # Register two marketplaces with different loading strategies + agent_context = AgentContext( + registered_marketplaces=[ + # Auto-loaded: plugins available immediately when conversation starts + MarketplaceRegistration( + name="auto", + source=str(SCRIPT_DIR / "auto_marketplace"), + auto_load="all", + ), + # On-demand: registered but not loaded until explicitly requested + MarketplaceRegistration( + name="demo", + source=str(SCRIPT_DIR / "demo_marketplace"), + # auto_load=None (default) - use load_plugin() to load + ), + ], + ) + + agent = Agent(llm=llm, tools=[], agent_context=agent_context) + conversation = Conversation(agent=agent, workspace=os.getcwd()) + + # The "auto" marketplace plugins are already loaded + # Now load an additional plugin on-demand from "demo" marketplace + # Format: "plugin-name@marketplace-name" (same as Claude Code plugin syntax) + conversation.load_plugin("greeter@demo") + + resolved = conversation.resolved_plugins + if resolved: + print(f"Loaded {len(resolved)} plugin(s):") + for plugin in resolved: + print(f" - {plugin.source}") + + # Use skills from both plugins + conversation.send_message("Give me a tip, then greet me!") + conversation.run() + + print(f"\nEXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}") + + +if __name__ == "__main__": + if not os.getenv("LLM_API_KEY"): + print("Set LLM_API_KEY to run this example") + print("EXAMPLE_COST: 0") + else: + main() diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py index 12f52f46dc..2f28c7bec2 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_router.py +++ b/openhands-agent-server/openhands/agent_server/conversation_router.py @@ -19,6 +19,7 @@ ConversationSortOrder, GenerateTitleRequest, GenerateTitleResponse, + LoadPluginRequest, SendMessageRequest, SetConfirmationPolicyRequest, SetSecurityAnalyzerRequest, @@ -338,3 +339,21 @@ async def condense_conversation( if not success: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Conversation not found") return Success() + + +@conversation_router.post( + "/{conversation_id}/plugins/load", + responses={404: {"description": "Item not found"}}, +) +async def load_plugin( + conversation_id: UUID, + request: LoadPluginRequest, + conversation_service: ConversationService = Depends(get_conversation_service), +) -> Success: + """Load a plugin from a registered marketplace into the conversation.""" + success = await conversation_service.load_plugin( + conversation_id, request.plugin_ref + ) + if not success: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Conversation not found") + return Success() diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py index 3cc2bcbb68..7531050b8a 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py @@ -659,6 +659,18 @@ async def condense(self, conversation_id: UUID) -> bool: await event_service.condense() return True + async def load_plugin(self, conversation_id: UUID, plugin_ref: str) -> bool: + """Load a plugin from a registered marketplace into the conversation.""" + if self._event_services is None: + raise ValueError("inactive_service") + event_service = self._event_services.get(conversation_id) + if event_service is None: + return False + + # Delegate to EventService to access conversation internals + await event_service.load_plugin(plugin_ref) + return True + async def __aenter__(self): self.conversations_dir.mkdir(parents=True, exist_ok=True) self._event_services = {} diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py index 3295175de2..bcd966a00f 100644 --- a/openhands-agent-server/openhands/agent_server/event_service.py +++ b/openhands-agent-server/openhands/agent_server/event_service.py @@ -705,6 +705,19 @@ async def condense(self) -> None: loop = asyncio.get_running_loop() return await loop.run_in_executor(None, self._conversation.condense) + async def load_plugin(self, plugin_ref: str) -> None: + """Load a plugin from a registered marketplace. + + Delegates to LocalConversation in an executor to avoid blocking the event loop. + """ + if not self._conversation: + raise ValueError("inactive_service") + + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, self._conversation.load_plugin, plugin_ref + ) + async def get_state(self) -> ConversationState: if not self._conversation: raise ValueError("inactive_service") diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py index c331cd5df6..0411faf5d9 100644 --- a/openhands-agent-server/openhands/agent_server/models.py +++ b/openhands-agent-server/openhands/agent_server/models.py @@ -459,6 +459,17 @@ class AskAgentResponse(BaseModel): response: str = Field(description="The agent's response to the question") +class LoadPluginRequest(BaseModel): + """Payload to load a plugin from a registered marketplace.""" + + plugin_ref: str = Field( + description=( + "Plugin reference to resolve and load. " + "Can be 'plugin-name@marketplace-name' or just 'plugin-name'." + ) + ) + + class BashEventBase(DiscriminatedUnionMixin, ABC): """Base class for all bash event types""" diff --git a/openhands-agent-server/openhands/agent_server/skills_router.py b/openhands-agent-server/openhands/agent_server/skills_router.py index b01c21b893..7ebddedb52 100644 --- a/openhands-agent-server/openhands/agent_server/skills_router.py +++ b/openhands-agent-server/openhands/agent_server/skills_router.py @@ -4,6 +4,7 @@ Business logic is delegated to skills_service.py. """ +import warnings from typing import Literal from fastapi import APIRouter @@ -15,6 +16,7 @@ sync_public_skills, ) from openhands.sdk.context.skills.skill import DEFAULT_MARKETPLACE_PATH +from openhands.sdk.plugin.types import MarketplaceRegistration skills_router = APIRouter(prefix="/skills", tags=["Skills"]) @@ -66,11 +68,22 @@ class SkillsRequest(BaseModel): load_org: bool = Field(default=True, description="Load organization-level skills") marketplace_path: str | None = Field( default=DEFAULT_MARKETPLACE_PATH, + deprecated=True, description=( + "DEPRECATED: Use registered_marketplaces instead. " "Relative marketplace JSON path for public skills. " "Set to null to load all public skills." ), ) + registered_marketplaces: list[MarketplaceRegistration] = Field( + default_factory=list, + description=( + "List of marketplace registrations for skill loading. " + "Each registration specifies a marketplace source and whether to " + "auto-load its skills. When provided, this takes precedence over " + "marketplace_path for public skill filtering." + ), + ) project_dir: str | None = Field( default=None, description="Workspace directory path for project skills" ) @@ -143,6 +156,26 @@ def get_skills(request: SkillsRequest) -> SkillsResponse: org_repo_url = request.org_config.org_repo_url org_name = request.org_config.org_name + # Handle deprecation: prefer registered_marketplaces over marketplace_path + marketplace_path = request.marketplace_path + if request.registered_marketplaces: + # New behavior: use registered_marketplaces + # For now, we extract marketplace_path from the first 'public' registration + # with auto_load='all' for backward compatibility with the existing service + for reg in request.registered_marketplaces: + if reg.name == "public" and reg.auto_load == "all": + # Use repo_path as marketplace_path if set + marketplace_path = reg.repo_path + break + elif request.marketplace_path is not None: + # Emit deprecation warning when using old field + warnings.warn( + "SkillsRequest.marketplace_path is deprecated. " + "Use registered_marketplaces instead.", + DeprecationWarning, + stacklevel=2, + ) + # Call the service result = load_all_skills( load_public=request.load_public, @@ -153,7 +186,8 @@ def get_skills(request: SkillsRequest) -> SkillsResponse: org_repo_url=org_repo_url, org_name=org_name, sandbox_exposed_urls=sandbox_urls, - marketplace_path=request.marketplace_path, + marketplace_path=marketplace_path, + registered_marketplaces=request.registered_marketplaces, ) # Convert Skill objects to SkillInfo for response diff --git a/openhands-agent-server/openhands/agent_server/skills_service.py b/openhands-agent-server/openhands/agent_server/skills_service.py index 68bc390512..0998192817 100644 --- a/openhands-agent-server/openhands/agent_server/skills_service.py +++ b/openhands-agent-server/openhands/agent_server/skills_service.py @@ -9,16 +9,20 @@ - Project skills: {workspace}/.openhands/skills/, .cursorrules, agents.md - Organization skills: {org}/.openhands or {org}/openhands-config - Sandbox skills: Exposed URLs from sandbox environment +- Registered marketplaces: Multiple marketplace sources with auto-load support Precedence (later overrides earlier): -sandbox < public < user < org < project +sandbox < public < user < org < project < marketplace-plugins """ +from __future__ import annotations + import shutil import subprocess import tempfile from dataclasses import dataclass from pathlib import Path +from typing import TYPE_CHECKING from openhands.sdk.context.skills import ( Skill, @@ -38,6 +42,10 @@ from openhands.sdk.utils import sanitized_env +if TYPE_CHECKING: + from openhands.sdk.plugin.types import MarketplaceRegistration + + logger = get_logger(__name__) @@ -286,6 +294,7 @@ def load_all_skills( org_name: str | None = None, sandbox_exposed_urls: list[ExposedUrlData] | None = None, marketplace_path: str | None = DEFAULT_MARKETPLACE_PATH, + registered_marketplaces: list[MarketplaceRegistration] | None = None, ) -> SkillLoadResult: """Load and merge skills from all configured sources. @@ -296,6 +305,7 @@ def load_all_skills( 3. User skills - From ~/.openhands/skills/ 4. Organization skills - From {org}/.openhands or equivalent 5. Project skills (highest) - From {workspace}/.openhands/skills/ + 6. Marketplace plugins (highest) - From registered marketplaces with auto_load Args: load_public: Whether to load public skills from OpenHands/extensions repo. @@ -306,8 +316,10 @@ def load_all_skills( org_repo_url: Pre-authenticated Git URL for org skills. org_name: Organization name for org skills. sandbox_exposed_urls: List of exposed URLs from sandbox. - marketplace_path: Relative marketplace JSON path for public skills. + marketplace_path: DEPRECATED - Relative marketplace JSON path for public skills. Pass None to load all public skills without marketplace filtering. + registered_marketplaces: List of marketplace registrations. When provided, + marketplaces with auto_load='all' will have their skills loaded. Returns: SkillLoadResult containing merged skills and source counts. @@ -349,7 +361,7 @@ def load_all_skills( sources["org"] = len(org_skills) skill_lists.append(org_skills) - # 5. Load project skills (highest precedence) + # 5. Load project skills project_skills = load_available_skills( work_dir=project_dir if load_project else None, include_user=False, @@ -359,6 +371,13 @@ def load_all_skills( sources["project"] = len(project_skills) skill_lists.append(list(project_skills.values())) + # 6. Load skills from registered marketplaces with auto_load='all' (highest) + marketplace_skills: list[Skill] = [] + if registered_marketplaces: + marketplace_skills = _load_marketplace_skills(registered_marketplaces) + sources["marketplace"] = len(marketplace_skills) + skill_lists.append(marketplace_skills) + # Merge all skills with precedence all_skills = merge_skills(skill_lists) @@ -369,6 +388,72 @@ def load_all_skills( return SkillLoadResult(skills=all_skills, sources=sources) +def _load_marketplace_skills( + registrations: list[MarketplaceRegistration], +) -> list[Skill]: + """Load skills from registered marketplaces with auto_load='all'. + + This function iterates through marketplace registrations, fetches those + marked for auto-loading, and loads all their plugin skills. + + Args: + registrations: List of marketplace registrations. + + Returns: + List of skills loaded from auto-load marketplaces. + """ + from openhands.sdk.plugin import ( + MarketplaceRegistry, + Plugin, + fetch_plugin_with_resolution, + ) + + all_skills: list[Skill] = [] + + # Create registry and get auto-load registrations + registry = MarketplaceRegistry(registrations) + auto_load_regs = registry.get_auto_load_registrations() + + for reg in auto_load_regs: + try: + marketplace, repo_path = registry.get_marketplace(reg.name) + logger.info( + f"Loading skills from {len(marketplace.plugins)} plugin(s) in " + f"marketplace '{reg.name}'" + ) + + for plugin_entry in marketplace.plugins: + try: + # Resolve and fetch the plugin + source, ref, subpath = marketplace.resolve_plugin_source( + plugin_entry + ) + path, resolved_ref = fetch_plugin_with_resolution( + source=source, + ref=ref, + repo_path=subpath, + ) + + # Load the plugin and extract skills + plugin = Plugin.load(path) + if plugin.skills: + all_skills.extend(plugin.skills) + logger.debug( + f"Loaded {len(plugin.skills)} skill(s) from plugin " + f"'{plugin.manifest.name}' in marketplace '{reg.name}'" + ) + except Exception as e: + logger.warning( + f"Failed to load plugin '{plugin_entry.name}' " + f"from marketplace '{reg.name}': {e}" + ) + + except Exception as e: + logger.warning(f"Failed to load marketplace '{reg.name}': {e}") + + return all_skills + + def sync_public_skills() -> tuple[bool, str]: """Force refresh of public skills from GitHub repository. diff --git a/openhands-sdk/openhands/sdk/context/agent_context.py b/openhands-sdk/openhands/sdk/context/agent_context.py index 9c07613321..116902f447 100644 --- a/openhands-sdk/openhands/sdk/context/agent_context.py +++ b/openhands-sdk/openhands/sdk/context/agent_context.py @@ -1,8 +1,10 @@ from __future__ import annotations import pathlib +import warnings from collections.abc import Mapping from datetime import datetime +from typing import TYPE_CHECKING from pydantic import BaseModel, Field, field_validator, model_validator @@ -17,11 +19,19 @@ from openhands.sdk.llm import Message, TextContent from openhands.sdk.llm.utils.model_prompt_spec import get_model_prompt_spec from openhands.sdk.logger import get_logger +from openhands.sdk.plugin.types import MarketplaceRegistration from openhands.sdk.secret import SecretSource, SecretValue +if TYPE_CHECKING: + pass + logger = get_logger(__name__) +# Constants for default marketplace +PUBLIC_SKILLS_REPO = "github:OpenHands/extensions" +PUBLIC_SKILLS_BRANCH = "main" + PROMPT_DIR = pathlib.Path(__file__).parent / "prompts" / "templates" @@ -74,11 +84,22 @@ class AgentContext(BaseModel): ) marketplace_path: str | None = Field( default=DEFAULT_MARKETPLACE_PATH, + deprecated=True, description=( + "DEPRECATED: Use registered_marketplaces instead. " "Relative marketplace JSON path within the public skills repository. " "Set to None to load all public skills without marketplace filtering." ), ) + registered_marketplaces: list[MarketplaceRegistration] = Field( + default_factory=list, + description=( + "List of marketplace registrations for plugin resolution. " + "Marketplaces with auto_load='all' will have their plugins loaded " + "automatically at conversation start. " + "See MarketplaceRegistration for details." + ), + ) secrets: Mapping[str, SecretValue] | None = Field( default=None, description=( @@ -114,10 +135,63 @@ def _validate_skills(cls, v: list[Skill], _info): @model_validator(mode="after") def _load_auto_skills(self): - """Load user and/or public skills if enabled.""" + """Load user and/or public skills if enabled. + + When `registered_marketplaces` is set, skills are loaded from marketplaces + with `auto_load='all'`. This is the new, preferred approach. + + When only `marketplace_path` is set (deprecated), falls back to the legacy + `load_available_skills` path for backward compatibility. + """ if not self.load_user_skills and not self.load_public_skills: return self + existing_names = {skill.name for skill in self.skills} + + # New path: use registered_marketplaces as source of truth + if self.registered_marketplaces: + # Load user skills via legacy path if requested (user skills are local) + if self.load_user_skills: + user_skills = load_available_skills( + work_dir=None, + include_user=True, + include_project=False, + include_public=False, + marketplace_path=None, + ) + for name, skill in user_skills.items(): + if name not in existing_names: + self.skills.append(skill) + existing_names.add(name) + else: + logger.warning( + f"Skipping user skill '{name}' (already in explicit skills)" + ) + + # Load public skills from registered marketplaces with auto_load='all' + if self.load_public_skills: + marketplace_skills = self._load_skills_from_marketplaces() + for skill in marketplace_skills: + if skill.name not in existing_names: + self.skills.append(skill) + existing_names.add(skill.name) + else: + logger.warning( + f"Skipping marketplace skill '{skill.name}' " + "(already in explicit skills)" + ) + + return self + + # Legacy path: use marketplace_path (deprecated) + if self.marketplace_path is not None: + warnings.warn( + "AgentContext.marketplace_path is deprecated. " + "Use registered_marketplaces instead.", + DeprecationWarning, + stacklevel=2, + ) + auto_skills = load_available_skills( work_dir=None, include_user=self.load_user_skills, @@ -126,7 +200,6 @@ def _load_auto_skills(self): marketplace_path=self.marketplace_path, ) - existing_names = {skill.name for skill in self.skills} for name, skill in auto_skills.items(): if name not in existing_names: self.skills.append(skill) @@ -137,6 +210,57 @@ def _load_auto_skills(self): return self + def _load_skills_from_marketplaces(self) -> list[Skill]: + """Load skills from registered marketplaces with auto_load='all'. + + Returns: + List of skills loaded from auto-load marketplaces. + """ + # Import at runtime to avoid circular import + from openhands.sdk.plugin import ( + MarketplaceRegistry, + Plugin, + fetch_plugin_with_resolution, + ) + + skills: list[Skill] = [] + registry = MarketplaceRegistry(self.registered_marketplaces) + + for reg in registry.get_auto_load_registrations(): + try: + marketplace, repo_path = registry.get_marketplace(reg.name) + logger.info( + f"Loading skills from marketplace '{reg.name}' " + f"({len(marketplace.plugins)} plugins)" + ) + + for plugin_entry in marketplace.plugins: + try: + source, ref, subpath = marketplace.resolve_plugin_source( + plugin_entry + ) + path, resolved_ref = fetch_plugin_with_resolution( + source=source, + ref=ref, + repo_path=subpath, + ) + plugin = Plugin.load(path) + plugin_skills = plugin.get_all_skills() + skills.extend(plugin_skills) + logger.debug( + f"Loaded {len(plugin_skills)} skill(s) from plugin " + f"'{plugin_entry.name}'" + ) + except Exception as e: + logger.warning( + f"Failed to load plugin '{plugin_entry.name}' " + f"from marketplace '{reg.name}': {e}" + ) + except Exception as e: + logger.warning(f"Failed to fetch marketplace '{reg.name}': {e}") + + return skills + def get_secret_infos(self) -> list[dict[str, str | None]]: """Get secret information (name and description) from the secrets field. diff --git a/openhands-sdk/openhands/sdk/context/skills/skill.py b/openhands-sdk/openhands/sdk/context/skills/skill.py index c402ffe688..78d807bb57 100644 --- a/openhands-sdk/openhands/sdk/context/skills/skill.py +++ b/openhands-sdk/openhands/sdk/context/skills/skill.py @@ -1,6 +1,7 @@ import io import json import re +import warnings from pathlib import Path from typing import Annotated, ClassVar, Literal, Union from xml.sax.saxutils import escape as xml_escape @@ -950,6 +951,11 @@ def load_public_skills( ) -> list[Skill]: """Load skills from the public OpenHands skills repository. + .. deprecated:: + The `marketplace_path` parameter is deprecated. Use + `AgentContext.registered_marketplaces` with `MarketplaceRegistration` + for more flexible marketplace configuration. + This function maintains a local git clone of the public skills registry at https://github.com/OpenHands/extensions. On first run, it clones the repository to ~/.openhands/skills-cache/. On subsequent runs, it pulls the latest changes @@ -969,8 +975,9 @@ def load_public_skills( repo_url: URL of the skills repository. Defaults to the official OpenHands skills repository. branch: Branch name to load skills from. Defaults to 'main'. - marketplace_path: Relative path to the marketplace JSON file within the - repository. Pass None to load all public skills without filtering. + marketplace_path: DEPRECATED - Relative path to the marketplace JSON file + within the repository. Pass None to load all public skills without + filtering. Use registered_marketplaces instead. Returns: List of Skill objects loaded from the public repository. @@ -986,6 +993,15 @@ def load_public_skills( >>> # Use with AgentContext >>> context = AgentContext(skills=public_skills) """ + # Emit deprecation warning when marketplace_path is explicitly provided + if marketplace_path is not None: + warnings.warn( + "load_public_skills(marketplace_path=...) is deprecated. " + "Use AgentContext.registered_marketplaces instead.", + DeprecationWarning, + stacklevel=2, + ) + all_skills = [] try: @@ -1082,6 +1098,11 @@ def load_available_skills( ) -> dict[str, Skill]: """Load and merge skills from SDK-level sources with consistent precedence. + .. deprecated:: + The `marketplace_path` parameter is deprecated. Use + `AgentContext.registered_marketplaces` with `MarketplaceRegistration` + for more flexible marketplace configuration. + Precedence (later overrides earlier via dict updates): public (lowest) → user → project (highest) @@ -1095,19 +1116,33 @@ def load_available_skills( include_user: Load user-level skills (~/.agents/skills, etc.). include_project: Load project-level skills (requires *work_dir*). include_public: Load public skills from the OpenHands extensions repo. - marketplace_path: Relative marketplace JSON path to use for public skills. - Pass None to load all public skills without marketplace filtering. + marketplace_path: DEPRECATED - Relative marketplace JSON path to use for + public skills. Pass None to load all public skills without marketplace + filtering. Use registered_marketplaces instead. Returns: Dict mapping skill name → Skill, with higher-precedence sources overriding lower ones. """ + # Emit deprecation warning when marketplace_path is explicitly provided + if marketplace_path is not None: + warnings.warn( + "load_available_skills(marketplace_path=...) is deprecated. " + "Use AgentContext.registered_marketplaces instead.", + DeprecationWarning, + stacklevel=2, + ) + available: dict[str, Skill] = {} if include_public: try: - for s in load_public_skills(marketplace_path=marketplace_path): - available[s.name] = s + # Suppress the nested deprecation warning from load_public_skills + # since we already warned above + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + for s in load_public_skills(marketplace_path=marketplace_path): + available[s.name] = s except Exception as e: logger.warning(f"Failed to load public skills: {e}") diff --git a/openhands-sdk/openhands/sdk/conversation/base.py b/openhands-sdk/openhands/sdk/conversation/base.py index f131889d1e..96db857d88 100644 --- a/openhands-sdk/openhands/sdk/conversation/base.py +++ b/openhands-sdk/openhands/sdk/conversation/base.py @@ -304,6 +304,29 @@ def execute_tool(self, tool_name: str, action: Action) -> Observation: """ ... + @abstractmethod + def load_plugin(self, plugin_ref: str) -> None: + """Load a plugin from a registered marketplace. + + Resolves the plugin reference against the agent's registered marketplaces + and loads the plugin into the conversation. The plugin's skills, hooks, + and MCP configuration will be merged into the agent. + + Plugin references can be: + - "plugin-name@marketplace-name" - Explicit marketplace qualifier + - "plugin-name" - Search all registered marketplaces (errors if ambiguous) + + Args: + plugin_ref: Plugin reference to resolve and load. + + Raises: + PluginNotFoundError: If the plugin is not found. + AmbiguousPluginError: If the plugin name matches multiple marketplaces. + MarketplaceNotFoundError: If a specified marketplace is not registered. + ValueError: If no marketplaces are registered. + """ + ... + @staticmethod def compose_callbacks(callbacks: Iterable[CallbackType]) -> CallbackType: """Compose multiple callbacks into a single callback function. diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index a171a3576f..cf9ad8c3f8 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -43,6 +43,7 @@ from openhands.sdk.logger import get_logger from openhands.sdk.observability.laminar import observe from openhands.sdk.plugin import ( + MarketplaceRegistry, Plugin, PluginSource, ResolvedPluginSource, @@ -83,6 +84,7 @@ class LocalConversation(BaseConversation): _resolved_plugins: list[ResolvedPluginSource] | None _plugins_loaded: bool _pending_hook_config: HookConfig | None # Hook config to combine with plugin hooks + _marketplace_registry: MarketplaceRegistry | None # Lazy-init from agent_context def __init__( self, @@ -160,6 +162,7 @@ def __init__( self._plugins_loaded = False self._pending_hook_config = hook_config # Will be combined with plugin hooks self._agent_ready = False # Agent initialized lazily after plugins loaded + self._marketplace_registry = None # Lazy-initialized from agent_context self.agent = agent if isinstance(workspace, (str, Path)): @@ -316,11 +319,12 @@ def _ensure_plugins_loaded(self) -> None: The method: 1. Fetches plugins from their sources (network IO for remote sources) - 2. Resolves refs to commit SHAs for deterministic resume - 3. Loads plugin contents (skills, MCP config, hooks) - 4. Merges plugin contents into the agent - 5. Sets up hook processor with combined hooks (explicit + plugin) - 6. Runs session_start hooks + 2. Auto-loads plugins from registered marketplaces with auto_load='all' + 3. Resolves refs to commit SHAs for deterministic resume + 4. Loads plugin contents (skills, MCP config, hooks) + 5. Merges plugin contents into the agent + 6. Sets up hook processor with combined hooks (explicit + plugin) + 7. Runs session_start hooks """ if self._plugins_loaded: return @@ -328,16 +332,67 @@ def _ensure_plugins_loaded(self) -> None: all_plugin_hooks: list[HookConfig] = [] all_plugin_agents: list[AgentDefinition] = [] - # Load plugins if specified + # Collect plugins from both explicit specs and auto-load marketplaces + plugins_to_load: list[tuple[PluginSource, str]] = [] # (spec, source_desc) + + # Add explicit plugin specs first if self._plugin_specs: - logger.info(f"Loading {len(self._plugin_specs)} plugin(s)...") + for spec in self._plugin_specs: + plugins_to_load.append((spec, f"explicit: {spec.source}")) + + # Auto-load plugins from registered marketplaces with auto_load='all' + agent_context = self.agent.agent_context + if agent_context and agent_context.registered_marketplaces: + # Initialize marketplace registry if needed + if self._marketplace_registry is None: + self._marketplace_registry = MarketplaceRegistry( + agent_context.registered_marketplaces + ) + + # Get marketplaces that should auto-load + auto_load_registrations = ( + self._marketplace_registry.get_auto_load_registrations() + ) + for reg in auto_load_registrations: + try: + marketplace, repo_path = self._marketplace_registry.get_marketplace( + reg.name + ) + logger.info( + f"Auto-loading {len(marketplace.plugins)} plugin(s) from " + f"marketplace '{reg.name}'" + ) + for plugin_entry in marketplace.plugins: + source, ref, subpath = marketplace.resolve_plugin_source( + plugin_entry + ) + plugin_source = PluginSource( + source=source, + ref=ref, + repo_path=subpath, + ) + plugins_to_load.append( + ( + plugin_source, + f"marketplace:{reg.name}/{plugin_entry.name}", + ) + ) + except Exception as e: + logger.warning( + f"Failed to auto-load plugins from marketplace " + f"'{reg.name}': {e}" + ) + + # Load all collected plugins + if plugins_to_load: + logger.info(f"Loading {len(plugins_to_load)} plugin(s)...") self._resolved_plugins = [] # Start with agent's existing context and MCP config merged_context = self.agent.agent_context merged_mcp = dict(self.agent.mcp_config) if self.agent.mcp_config else {} - for spec in self._plugin_specs: + for spec, source_desc in plugins_to_load: # Fetch plugin and get resolved commit SHA path, resolved_ref = fetch_plugin_with_resolution( source=spec.source, @@ -352,7 +407,7 @@ def _ensure_plugins_loaded(self) -> None: # Load the plugin plugin = Plugin.load(path) logger.debug( - f"Loaded plugin '{plugin.manifest.name}' from {spec.source}" + f"Loaded plugin '{plugin.manifest.name}' from {source_desc}" + (f" @ {resolved_ref[:8]}" if resolved_ref else "") ) @@ -380,7 +435,7 @@ def _ensure_plugins_loaded(self) -> None: with self._state: self._state.agent = self.agent - logger.info(f"Loaded {len(self._plugin_specs)} plugin(s) via Conversation") + logger.info(f"Loaded {len(plugins_to_load)} plugin(s) via Conversation") # Register file-based agents defined in plugins if all_plugin_agents: @@ -1143,6 +1198,147 @@ def execute_tool(self, tool_name: str, action: Action) -> Observation: raise NotImplementedError(f"Tool '{tool_name}' has no executor") return tool(action, self) + def load_plugin(self, plugin_ref: str) -> None: + """Load a plugin from a registered marketplace. + + Resolves the plugin reference against the agent's registered marketplaces + and loads the plugin into the conversation. The plugin's skills, hooks, + MCP configuration, and agent definitions will be merged into the + conversation. + + Plugin references can be: + - "plugin-name@marketplace-name" - Explicit marketplace qualifier + - "plugin-name" - Search all registered marketplaces (errors if ambiguous) + + Args: + plugin_ref: Plugin reference to resolve and load. + + Raises: + PluginNotFoundError: If the plugin is not found. + AmbiguousPluginError: If the plugin name matches multiple marketplaces. + MarketplaceNotFoundError: If a specified marketplace is not registered. + ValueError: If no marketplaces are registered. + """ + # Ensure plugins loaded first (initializes agent context and hook processor) + self._ensure_plugins_loaded() + + # Lazy-initialize the marketplace registry from agent_context + if self._marketplace_registry is None: + agent_context = self.agent.agent_context + if agent_context is None: + raise ValueError( + "No agent context available. Configure agent_context " + "with registered_marketplaces to use load_plugin()." + ) + registrations = agent_context.registered_marketplaces + if not registrations: + raise ValueError( + "No marketplaces registered. Configure registered_marketplaces " + "in AgentContext to use load_plugin()." + ) + self._marketplace_registry = MarketplaceRegistry(registrations) + + # Resolve the plugin reference + resolved_source = self._marketplace_registry.resolve_plugin(plugin_ref) + + # Fetch and load the plugin + path, resolved_ref = fetch_plugin_with_resolution( + source=resolved_source.source, + ref=resolved_source.ref, + repo_path=resolved_source.repo_path, + ) + + plugin = Plugin.load(path) + logger.info( + f"Loaded plugin '{plugin.manifest.name}' from {resolved_source.source}" + + (f" @ {resolved_ref[:8]}" if resolved_ref else "") + ) + + # Merge plugin contents into agent + merged_context = plugin.add_skills_to(self.agent.agent_context) + merged_mcp = plugin.add_mcp_config_to( + dict(self.agent.mcp_config) if self.agent.mcp_config else {} + ) + + # Update agent and state atomically + # Create new agent first, then update both references together + new_agent = self.agent.model_copy( + update={ + "agent_context": merged_context, + "mcp_config": merged_mcp, + } + ) + with self._state: + self.agent = new_agent + self._state.agent = new_agent + + # Register plugin agents if any + if plugin.agents: + register_plugin_agents( + agents=plugin.agents, + work_dir=self.workspace.working_dir, + ) + logger.debug( + f"Registered {len(plugin.agents)} agent(s) from plugin " + f"'{plugin.manifest.name}'" + ) + + # Merge plugin hooks into existing hook processor + if plugin.hooks and not plugin.hooks.is_empty(): + self._merge_plugin_hooks(plugin.hooks) + logger.debug(f"Merged hooks from plugin '{plugin.manifest.name}'") + + # Track resolved plugin + resolved = ResolvedPluginSource.from_plugin_source( + resolved_source, resolved_ref + ) + if self._resolved_plugins is None: + self._resolved_plugins = [] + self._resolved_plugins.append(resolved) + + def _merge_plugin_hooks(self, plugin_hooks: HookConfig) -> None: + """Merge plugin hooks into the existing hook processor. + + If no hook processor exists, creates one with the plugin hooks. + If a hook processor exists, merges the new hooks (plugin hooks + run after existing hooks). + + Args: + plugin_hooks: HookConfig from the loaded plugin. + """ + if self._hook_processor is None: + # No existing hook processor - create one with plugin hooks + self._hook_processor, self._on_event = create_hook_callback( + hook_config=plugin_hooks, + working_dir=str(self.workspace.working_dir), + session_id=str(self._state.id), + original_callback=self._base_callback, + ) + self._hook_processor.set_conversation_state(self._state) + # Note: We don't run session_start here since the session is + # already started. The plugin's session_start hooks will miss + # the initial session start, which is expected for dynamically + # loaded plugins. + self._state.hook_config = plugin_hooks + else: + # Merge with existing hooks + existing_config = self._state.hook_config + if existing_config is not None: + merged_config = HookConfig.merge([existing_config, plugin_hooks]) + else: + merged_config = plugin_hooks + + if merged_config is not None: + # Recreate hook processor with merged config + self._hook_processor, self._on_event = create_hook_callback( + hook_config=merged_config, + working_dir=str(self.workspace.working_dir), + session_id=str(self._state.id), + original_callback=self._base_callback, + ) + self._hook_processor.set_conversation_state(self._state) + self._state.hook_config = merged_config + def __del__(self) -> None: """Ensure cleanup happens when conversation is destroyed.""" try: diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py index 7398ec7ab7..73743876a6 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py @@ -1318,6 +1318,33 @@ def execute_tool(self, tool_name: str, action: "Action") -> "Observation": "tool execution." ) + def load_plugin(self, plugin_ref: str) -> None: + """Load a plugin from a registered marketplace. + + Resolves the plugin reference against the agent's registered marketplaces + on the server and loads the plugin into the conversation. + + Plugin references can be: + - "plugin-name@marketplace-name" - Explicit marketplace qualifier + - "plugin-name" - Search all registered marketplaces (errors if ambiguous) + + Args: + plugin_ref: Plugin reference to resolve and load. + + Raises: + PluginNotFoundError: If the plugin is not found. + AmbiguousPluginError: If the plugin name matches multiple marketplaces. + MarketplaceNotFoundError: If a specified marketplace is not registered. + ValueError: If no marketplaces are registered. + """ + payload = {"plugin_ref": plugin_ref} + _send_request( + self._client, + "POST", + f"{self._conversation_action_base_path}/{self._id}/plugins/load", + json=payload, + ) + def close(self) -> None: """Close the conversation and clean up resources. diff --git a/openhands-sdk/openhands/sdk/plugin/__init__.py b/openhands-sdk/openhands/sdk/plugin/__init__.py index 7b67494994..2ff77f0044 100644 --- a/openhands-sdk/openhands/sdk/plugin/__init__.py +++ b/openhands-sdk/openhands/sdk/plugin/__init__.py @@ -29,6 +29,13 @@ ) from openhands.sdk.plugin.loader import load_plugins from openhands.sdk.plugin.plugin import Plugin +from openhands.sdk.plugin.registry import ( + AmbiguousPluginError, + MarketplaceNotFoundError, + MarketplaceRegistry, + PluginNotFoundError, + PluginResolutionError, +) from openhands.sdk.plugin.source import ( GitHubURLComponents, is_local_path, @@ -44,6 +51,7 @@ MarketplaceOwner, MarketplacePluginEntry, MarketplacePluginSource, + MarketplaceRegistration, PluginAuthor, PluginManifest, PluginSource, @@ -70,6 +78,13 @@ "MarketplacePluginEntry", "MarketplacePluginSource", "MarketplaceMetadata", + # Marketplace registration and resolution + "MarketplaceRegistration", + "MarketplaceRegistry", + "PluginResolutionError", + "PluginNotFoundError", + "AmbiguousPluginError", + "MarketplaceNotFoundError", # Source path utilities "GitHubURLComponents", "parse_github_url", diff --git a/openhands-sdk/openhands/sdk/plugin/registry.py b/openhands-sdk/openhands/sdk/plugin/registry.py new file mode 100644 index 0000000000..972d74377c --- /dev/null +++ b/openhands-sdk/openhands/sdk/plugin/registry.py @@ -0,0 +1,325 @@ +"""Marketplace registry for managing registered marketplaces and plugin resolution.""" + +from __future__ import annotations + +from pathlib import Path + +from openhands.sdk.logger import get_logger +from openhands.sdk.plugin.fetch import fetch_plugin_with_resolution +from openhands.sdk.plugin.types import ( + Marketplace, + MarketplaceRegistration, + PluginSource, +) + + +logger = get_logger(__name__) + + +class PluginResolutionError(Exception): + """Raised when a plugin cannot be resolved from registered marketplaces.""" + + pass + + +class AmbiguousPluginError(PluginResolutionError): + """Raised when a plugin name matches multiple marketplaces.""" + + def __init__(self, plugin_name: str, matching_marketplaces: list[str]): + self.plugin_name = plugin_name + self.matching_marketplaces = matching_marketplaces + super().__init__( + f"Plugin '{plugin_name}' is ambiguous - found in multiple marketplaces: " + f"{', '.join(matching_marketplaces)}. " + f"Use explicit format: '{plugin_name}@'" + ) + + +class PluginNotFoundError(PluginResolutionError): + """Raised when a plugin cannot be found in any registered marketplace.""" + + def __init__( + self, + plugin_name: str, + marketplace_name: str | None = None, + fetch_errors: dict[str, Exception] | None = None, + ): + self.plugin_name = plugin_name + self.marketplace_name = marketplace_name + self.fetch_errors = fetch_errors or {} + + if marketplace_name: + msg = ( + f"Plugin '{plugin_name}' not found in marketplace '{marketplace_name}'" + ) + elif fetch_errors: + # All marketplaces failed to fetch - show the actual errors + error_details = "; ".join( + f"'{name}': {err}" for name, err in fetch_errors.items() + ) + msg = ( + f"Plugin '{plugin_name}' not found. " + f"All {len(fetch_errors)} marketplace(s) failed to fetch: " + f"{error_details}" + ) + else: + msg = f"Plugin '{plugin_name}' not found in any registered marketplace" + super().__init__(msg) + + +class MarketplaceNotFoundError(PluginResolutionError): + """Raised when a referenced marketplace is not registered.""" + + def __init__(self, marketplace_name: str): + self.marketplace_name = marketplace_name + super().__init__(f"Marketplace '{marketplace_name}' is not registered") + + +class MarketplaceRegistry: + """Manages registered marketplaces with lazy fetching and plugin resolution. + + The registry stores marketplace registrations and provides: + - Lazy fetching: Marketplaces are only fetched when first needed + - Caching: Fetched marketplaces are cached for the session + - Plugin resolution: Resolve plugin references like 'plugin-name@marketplace' + + Example: + >>> registry = MarketplaceRegistry([ + ... MarketplaceRegistration( + ... name="public", + ... source="github:OpenHands/skills", + ... auto_load="all" + ... ), + ... MarketplaceRegistration( + ... name="team", + ... source="github:acme/plugins" + ... ), + ... ]) + >>> # Resolve a plugin from a specific marketplace + >>> source = registry.resolve_plugin("formatter@team") + >>> # Resolve a plugin, searching all marketplaces + >>> source = registry.resolve_plugin("git") + """ + + def __init__(self, registrations: list[MarketplaceRegistration] | None = None): + """Initialize the registry with marketplace registrations. + + Args: + registrations: List of marketplace registrations. Can be empty or None. + """ + self._registrations: dict[str, MarketplaceRegistration] = {} + # Maps name to (marketplace, path) + self._cache: dict[str, tuple[Marketplace, Path]] = {} + + if registrations: + for reg in registrations: + self._registrations[reg.name] = reg + + @property + def registrations(self) -> dict[str, MarketplaceRegistration]: + """Get all registered marketplaces.""" + return self._registrations.copy() + + def get_auto_load_registrations(self) -> list[MarketplaceRegistration]: + """Get registrations with auto_load='all'.""" + return [reg for reg in self._registrations.values() if reg.auto_load == "all"] + + def _fetch_marketplace( + self, reg: MarketplaceRegistration + ) -> tuple[Marketplace, Path]: + """Fetch a marketplace and return (Marketplace, repo_path). + + This is the internal method that does the actual fetching. + Results are cached to avoid repeated fetches. + """ + if reg.name in self._cache: + return self._cache[reg.name] + + logger.info(f"Fetching marketplace '{reg.name}' from {reg.source}") + + # Fetch the marketplace repository + repo_path, resolved_ref = fetch_plugin_with_resolution( + source=reg.source, + ref=reg.ref, + repo_path=reg.repo_path, + ) + + # Load the marketplace manifest + marketplace = Marketplace.load(repo_path) + + logger.debug( + f"Loaded marketplace '{reg.name}' with {len(marketplace.plugins)} plugins" + + (f" @ {resolved_ref[:8]}" if resolved_ref else "") + ) + + # Cache the result + self._cache[reg.name] = (marketplace, Path(repo_path)) + return marketplace, Path(repo_path) + + def get_marketplace(self, name: str) -> tuple[Marketplace, Path]: + """Get a marketplace by name, fetching lazily if needed. + + Args: + name: The marketplace registration name. + + Returns: + Tuple of (Marketplace, repo_path). + + Raises: + MarketplaceNotFoundError: If the marketplace is not registered. + """ + if name not in self._registrations: + raise MarketplaceNotFoundError(name) + + return self._fetch_marketplace(self._registrations[name]) + + def prefetch_all(self) -> None: + """Eagerly fetch all registered marketplaces. + + This is useful for validation or pre-warming the cache. + Any fetch errors are logged but not raised. + """ + for name, reg in self._registrations.items(): + try: + self._fetch_marketplace(reg) + except Exception as e: + logger.warning(f"Failed to prefetch marketplace '{name}': {e}") + + def _parse_plugin_ref(self, plugin_ref: str) -> tuple[str, str | None]: + """Parse a plugin reference into (plugin_name, marketplace_name). + + Formats: + - 'plugin-name' -> ('plugin-name', None) + - 'plugin-name@marketplace' -> ('plugin-name', 'marketplace') + """ + if "@" in plugin_ref: + parts = plugin_ref.rsplit("@", 1) + return parts[0], parts[1] + return plugin_ref, None + + def resolve_plugin(self, plugin_ref: str) -> PluginSource: + """Resolve a plugin reference to a PluginSource. + + Args: + plugin_ref: Plugin reference in format 'plugin-name' or + 'plugin-name@marketplace-name'. + + Returns: + PluginSource that can be used to load the plugin. + + Raises: + PluginNotFoundError: If the plugin is not found. + AmbiguousPluginError: If the plugin name matches multiple marketplaces. + MarketplaceNotFoundError: If a specified marketplace is not registered. + """ + plugin_name, marketplace_name = self._parse_plugin_ref(plugin_ref) + + if marketplace_name: + # Explicit marketplace specified + return self._resolve_from_marketplace(plugin_name, marketplace_name) + else: + # Search all registered marketplaces + return self._resolve_from_all(plugin_name) + + def _resolve_from_marketplace( + self, plugin_name: str, marketplace_name: str + ) -> PluginSource: + """Resolve a plugin from a specific marketplace.""" + marketplace, repo_path = self.get_marketplace(marketplace_name) + + plugin_entry = marketplace.get_plugin(plugin_name) + if plugin_entry is None: + raise PluginNotFoundError(plugin_name, marketplace_name) + + # Resolve the plugin source + source, ref, subpath = marketplace.resolve_plugin_source(plugin_entry) + + return PluginSource( + source=source, + ref=ref, + repo_path=subpath, + ) + + def _resolve_from_all(self, plugin_name: str) -> PluginSource: + """Resolve a plugin by searching all registered marketplaces.""" + matches: list[tuple[str, PluginSource]] = [] + fetch_errors: dict[str, Exception] = {} + searched_count = 0 + + for name, reg in self._registrations.items(): + try: + marketplace, repo_path = self._fetch_marketplace(reg) + searched_count += 1 + plugin_entry = marketplace.get_plugin(plugin_name) + + if plugin_entry is not None: + source, ref, subpath = marketplace.resolve_plugin_source( + plugin_entry + ) + plugin_source = PluginSource( + source=source, + ref=ref, + repo_path=subpath, + ) + matches.append((name, plugin_source)) + + except Exception as e: + fetch_errors[name] = e + logger.warning( + f"Error searching marketplace '{name}' " + f"for plugin '{plugin_name}': {e}" + ) + + if not matches: + # If all marketplaces failed to fetch, include errors in exception + if fetch_errors and searched_count == 0: + raise PluginNotFoundError(plugin_name, fetch_errors=fetch_errors) + raise PluginNotFoundError(plugin_name) + + if len(matches) > 1: + raise AmbiguousPluginError( + plugin_name, + [name for name, _ in matches], + ) + + return matches[0][1] + + def list_plugins(self, marketplace_name: str | None = None) -> list[str]: + """List available plugins from registered marketplaces. + + Args: + marketplace_name: If provided, list plugins from this marketplace only. + If None, list plugins from all registered marketplaces. + + Returns: + List of plugin names (may include duplicates if searching all). + + Raises: + PluginResolutionError: If all marketplaces fail to fetch when listing all. + """ + plugin_names: list[str] = [] + + if marketplace_name: + marketplace, _ = self.get_marketplace(marketplace_name) + plugin_names.extend(p.name for p in marketplace.plugins) + else: + fetch_errors: dict[str, Exception] = {} + for name, reg in self._registrations.items(): + try: + marketplace, _ = self._fetch_marketplace(reg) + plugin_names.extend(p.name for p in marketplace.plugins) + except Exception as e: + fetch_errors[name] = e + logger.warning(f"Error listing plugins from '{name}': {e}") + + # If all marketplaces failed, raise with details + if fetch_errors and not plugin_names and self._registrations: + error_details = "; ".join( + f"'{name}': {err}" for name, err in fetch_errors.items() + ) + raise PluginResolutionError( + f"Failed to list plugins. " + f"All {len(fetch_errors)} marketplace(s) failed: {error_details}" + ) + + return plugin_names diff --git a/openhands-sdk/openhands/sdk/plugin/types.py b/openhands-sdk/openhands/sdk/plugin/types.py index 8ff2e904d3..4877086a65 100644 --- a/openhands-sdk/openhands/sdk/plugin/types.py +++ b/openhands-sdk/openhands/sdk/plugin/types.py @@ -3,8 +3,9 @@ from __future__ import annotations import json +import os from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal import frontmatter from pydantic import BaseModel, Field, field_validator, model_validator @@ -15,6 +16,99 @@ MARKETPLACE_MANIFEST_FILE = "marketplace.json" +class MarketplaceRegistration(BaseModel): + """Registration for a plugin marketplace. + + Represents a marketplace that can be registered for plugin resolution. + Marketplaces can be auto-loaded (plugins loaded at conversation start) + or registered only (available for explicit plugin references). + + Examples: + >>> # Auto-load all plugins from a marketplace + >>> MarketplaceRegistration( + ... name="public", + ... source="github:OpenHands/skills", + ... auto_load="all" + ... ) + + >>> # Register marketplace without auto-loading + >>> MarketplaceRegistration( + ... name="experimental", + ... source="github:acme/experimental" + ... ) + + >>> # Marketplace in monorepo subdirectory + >>> MarketplaceRegistration( + ... name="team", + ... source="github:acme/monorepo", + ... repo_path="marketplaces/internal", + ... auto_load="all" + ... ) + """ + + name: str = Field(description="Identifier for this marketplace registration") + source: str = Field( + description="Marketplace source: 'github:owner/repo', git URL, or local path" + ) + ref: str | None = Field( + default=None, + description="Optional branch, tag, or commit (only for git sources)", + ) + repo_path: str | None = Field( + default=None, + description=( + "Subdirectory path within the git repository containing the marketplace " + "(e.g., 'marketplaces/internal' for monorepos). " + "Only relevant for git sources, not local paths." + ), + ) + auto_load: Literal["all"] | None = Field( + default=None, + description=( + "Auto-load behavior for this marketplace. " + "'all' = load all plugins at conversation start. " + "None = registered for resolution but not auto-loaded." + ), + ) + + @field_validator("repo_path") + @classmethod + def validate_repo_path(cls, v: str | None) -> str | None: + """Validate repo_path is a safe relative path within the repository.""" + return _validate_repo_path(v) + + +def _validate_repo_path(v: str | None) -> str | None: + """Validate that a repo_path is a safe relative path. + + Ensures the path: + - Is relative (not absolute) + - Does not escape the repository root via '..' traversal + - Normalizes to a path within the repo even after resolution + """ + if v is None: + return v + + # Must be relative (no absolute paths) + if v.startswith("/"): + raise ValueError("repo_path must be relative, not absolute") + + # Normalize the path to catch tricks like 'safe/../../../etc' + # Use a dummy root to resolve against, then check if result stays inside + dummy_root = Path("/repo") + normalized = os.path.normpath(os.path.join(str(dummy_root), v)) + normalized_path = Path(normalized) + + # Check if the normalized path is still under dummy_root + # This catches paths like 'a/../../etc' which normalize to '/etc' + try: + normalized_path.relative_to(dummy_root) + except ValueError: + raise ValueError(f"repo_path '{v}' escapes repository root after normalization") + + return v + + class PluginSource(BaseModel): """Specification for a plugin to load. @@ -55,17 +149,7 @@ class PluginSource(BaseModel): @classmethod def validate_repo_path(cls, v: str | None) -> str | None: """Validate repo_path is a safe relative path within the repository.""" - if v is None: - return v - # Must be relative (no absolute paths) - if v.startswith("/"): - raise ValueError("repo_path must be relative, not absolute") - # No parent directory traversal - if ".." in Path(v).parts: - raise ValueError( - "repo_path cannot contain '..' (parent directory traversal)" - ) - return v + return _validate_repo_path(v) @property def source_url(self) -> str | None: diff --git a/tests/sdk/conversation/test_base_span_management.py b/tests/sdk/conversation/test_base_span_management.py index 01c1ddb536..4094a7962b 100644 --- a/tests/sdk/conversation/test_base_span_management.py +++ b/tests/sdk/conversation/test_base_span_management.py @@ -68,6 +68,10 @@ def execute_tool(self, tool_name: str, action: Action) -> Observation: """Mock implementation of execute_tool method.""" raise NotImplementedError("Mock execute_tool not implemented") + def load_plugin(self, plugin_ref: str) -> None: + """Mock implementation of load_plugin method.""" + pass + def test_base_conversation_span_management(): """Test that BaseConversation properly manages span state to prevent double-ending.""" # noqa: E501 diff --git a/tests/sdk/plugin/test_marketplace_registry.py b/tests/sdk/plugin/test_marketplace_registry.py new file mode 100644 index 0000000000..52405216c0 --- /dev/null +++ b/tests/sdk/plugin/test_marketplace_registry.py @@ -0,0 +1,453 @@ +"""Tests for MarketplaceRegistry and MarketplaceRegistration.""" + +import json +from unittest.mock import patch + +import pytest + +from openhands.sdk.plugin import ( + AmbiguousPluginError, + Marketplace, + MarketplaceNotFoundError, + MarketplaceRegistration, + MarketplaceRegistry, + PluginNotFoundError, + PluginResolutionError, +) + + +class TestMarketplaceRegistration: + """Tests for MarketplaceRegistration model.""" + + def test_basic_registration(self): + """Test creating a basic marketplace registration.""" + reg = MarketplaceRegistration( + name="test-marketplace", + source="github:owner/repo", + ) + assert reg.name == "test-marketplace" + assert reg.source == "github:owner/repo" + assert reg.ref is None + assert reg.repo_path is None + assert reg.auto_load is None + + def test_registration_with_auto_load(self): + """Test registration with auto_load='all'.""" + reg = MarketplaceRegistration( + name="public", + source="github:OpenHands/skills", + auto_load="all", + ) + assert reg.auto_load == "all" + + def test_registration_with_ref(self): + """Test registration with specific ref.""" + reg = MarketplaceRegistration( + name="versioned", + source="github:owner/repo", + ref="v1.0.0", + ) + assert reg.ref == "v1.0.0" + + def test_registration_with_repo_path(self): + """Test registration with repo_path for monorepos.""" + reg = MarketplaceRegistration( + name="monorepo-marketplace", + source="github:acme/monorepo", + repo_path="marketplaces/internal", + ) + assert reg.repo_path == "marketplaces/internal" + + def test_repo_path_validation_rejects_absolute(self): + """Test that absolute repo_path is rejected.""" + with pytest.raises(ValueError, match="must be relative"): + MarketplaceRegistration( + name="test", + source="github:owner/repo", + repo_path="/absolute/path", + ) + + def test_repo_path_validation_rejects_traversal(self): + """Test that parent directory traversal is rejected.""" + with pytest.raises(ValueError, match="escapes repository root"): + MarketplaceRegistration( + name="test", + source="github:owner/repo", + repo_path="../escape/path", + ) + + +class TestMarketplaceRegistry: + """Tests for MarketplaceRegistry.""" + + def test_empty_registry(self): + """Test creating an empty registry.""" + registry = MarketplaceRegistry() + assert registry.registrations == {} + assert registry.get_auto_load_registrations() == [] + + def test_registry_with_registrations(self): + """Test creating a registry with registrations.""" + regs = [ + MarketplaceRegistration(name="a", source="github:owner/a", auto_load="all"), + MarketplaceRegistration(name="b", source="github:owner/b"), + ] + registry = MarketplaceRegistry(regs) + + assert len(registry.registrations) == 2 + assert "a" in registry.registrations + assert "b" in registry.registrations + + def test_get_auto_load_registrations(self): + """Test filtering auto-load registrations.""" + regs = [ + MarketplaceRegistration(name="a", source="github:owner/a", auto_load="all"), + MarketplaceRegistration(name="b", source="github:owner/b"), + MarketplaceRegistration(name="c", source="github:owner/c", auto_load="all"), + ] + registry = MarketplaceRegistry(regs) + + auto_load = registry.get_auto_load_registrations() + assert len(auto_load) == 2 + assert all(r.auto_load == "all" for r in auto_load) + + def test_marketplace_not_found_error(self): + """Test error when marketplace not registered.""" + registry = MarketplaceRegistry() + + with pytest.raises(MarketplaceNotFoundError) as exc_info: + registry.get_marketplace("nonexistent") + + assert exc_info.value.marketplace_name == "nonexistent" + + def test_parse_plugin_ref_simple(self): + """Test parsing simple plugin reference.""" + registry = MarketplaceRegistry() + name, marketplace = registry._parse_plugin_ref("my-plugin") + assert name == "my-plugin" + assert marketplace is None + + def test_parse_plugin_ref_with_marketplace(self): + """Test parsing plugin reference with marketplace.""" + registry = MarketplaceRegistry() + name, marketplace = registry._parse_plugin_ref("my-plugin@team-tools") + assert name == "my-plugin" + assert marketplace == "team-tools" + + def test_parse_plugin_ref_with_at_in_name(self): + """Test parsing plugin reference with @ in plugin name.""" + registry = MarketplaceRegistry() + # If plugin name has @, last @ is the delimiter + name, marketplace = registry._parse_plugin_ref("plugin@1.0@marketplace") + assert name == "plugin@1.0" + assert marketplace == "marketplace" + + +@pytest.fixture +def mock_marketplace_dir(tmp_path): + """Create a mock marketplace directory structure.""" + # Create .plugin/marketplace.json + plugin_dir = tmp_path / ".plugin" + plugin_dir.mkdir() + + marketplace_data = { + "name": "test-marketplace", + "owner": {"name": "Test Owner"}, + "plugins": [ + {"name": "plugin-a", "source": "./plugins/a", "description": "Plugin A"}, + {"name": "plugin-b", "source": "./plugins/b", "description": "Plugin B"}, + ], + "skills": [], + } + + (plugin_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) + + # Create plugin directories + (tmp_path / "plugins" / "a").mkdir(parents=True) + (tmp_path / "plugins" / "b").mkdir(parents=True) + + return tmp_path + + +class TestMarketplaceRegistryResolution: + """Tests for plugin resolution in MarketplaceRegistry.""" + + def test_resolve_plugin_from_specific_marketplace(self, mock_marketplace_dir): + """Test resolving a plugin from a specific marketplace.""" + reg = MarketplaceRegistration( + name="test", + source=str(mock_marketplace_dir), + ) + registry = MarketplaceRegistry([reg]) + + # Mock fetch to return the local path + with patch.object(registry, "_fetch_marketplace") as mock_fetch: + marketplace = Marketplace.load(mock_marketplace_dir) + mock_fetch.return_value = (marketplace, mock_marketplace_dir) + + source = registry.resolve_plugin("plugin-a@test") + + assert source.source == str(mock_marketplace_dir / "plugins" / "a") + + def test_resolve_plugin_not_found_in_marketplace(self, mock_marketplace_dir): + """Test error when plugin not found in specified marketplace.""" + reg = MarketplaceRegistration( + name="test", + source=str(mock_marketplace_dir), + ) + registry = MarketplaceRegistry([reg]) + + with patch.object(registry, "_fetch_marketplace") as mock_fetch: + marketplace = Marketplace.load(mock_marketplace_dir) + mock_fetch.return_value = (marketplace, mock_marketplace_dir) + + with pytest.raises(PluginNotFoundError) as exc_info: + registry.resolve_plugin("nonexistent@test") + + assert exc_info.value.plugin_name == "nonexistent" + assert exc_info.value.marketplace_name == "test" + + def test_resolve_plugin_marketplace_not_registered(self): + """Test error when referenced marketplace is not registered.""" + registry = MarketplaceRegistry() + + with pytest.raises(MarketplaceNotFoundError) as exc_info: + registry.resolve_plugin("plugin@unknown") + + assert exc_info.value.marketplace_name == "unknown" + + def test_resolve_plugin_search_all_marketplaces(self, mock_marketplace_dir): + """Test resolving a plugin by searching all marketplaces.""" + reg = MarketplaceRegistration( + name="test", + source=str(mock_marketplace_dir), + ) + registry = MarketplaceRegistry([reg]) + + with patch.object(registry, "_fetch_marketplace") as mock_fetch: + marketplace = Marketplace.load(mock_marketplace_dir) + mock_fetch.return_value = (marketplace, mock_marketplace_dir) + + source = registry.resolve_plugin("plugin-a") + + assert source.source == str(mock_marketplace_dir / "plugins" / "a") + + def test_resolve_plugin_not_found_anywhere(self, mock_marketplace_dir): + """Test error when plugin not found in any marketplace.""" + reg = MarketplaceRegistration( + name="test", + source=str(mock_marketplace_dir), + ) + registry = MarketplaceRegistry([reg]) + + with patch.object(registry, "_fetch_marketplace") as mock_fetch: + marketplace = Marketplace.load(mock_marketplace_dir) + mock_fetch.return_value = (marketplace, mock_marketplace_dir) + + with pytest.raises(PluginNotFoundError) as exc_info: + registry.resolve_plugin("nonexistent") + + assert exc_info.value.plugin_name == "nonexistent" + assert exc_info.value.marketplace_name is None + + def test_resolve_plugin_ambiguous(self, tmp_path): + """Test error when plugin found in multiple marketplaces.""" + # Create two marketplace directories with same plugin name + for name in ["marketplace1", "marketplace2"]: + mp_dir = tmp_path / name + mp_dir.mkdir() + plugin_dir = mp_dir / ".plugin" + plugin_dir.mkdir() + + marketplace_data = { + "name": name, + "owner": {"name": "Owner"}, + "plugins": [ + {"name": "common-plugin", "source": "./plugins/common"}, + ], + } + (plugin_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) + (mp_dir / "plugins" / "common").mkdir(parents=True) + + regs = [ + MarketplaceRegistration(name="mp1", source=str(tmp_path / "marketplace1")), + MarketplaceRegistration(name="mp2", source=str(tmp_path / "marketplace2")), + ] + registry = MarketplaceRegistry(regs) + + def mock_fetch(reg): + mp_path = tmp_path / ( + "marketplace1" if reg.name == "mp1" else "marketplace2" + ) + marketplace = Marketplace.load(mp_path) + return (marketplace, mp_path) + + with patch.object(registry, "_fetch_marketplace", side_effect=mock_fetch): + with pytest.raises(AmbiguousPluginError) as exc_info: + registry.resolve_plugin("common-plugin") + + assert exc_info.value.plugin_name == "common-plugin" + assert set(exc_info.value.matching_marketplaces) == {"mp1", "mp2"} + + def test_list_plugins_from_marketplace(self, mock_marketplace_dir): + """Test listing plugins from a specific marketplace.""" + reg = MarketplaceRegistration( + name="test", + source=str(mock_marketplace_dir), + ) + registry = MarketplaceRegistry([reg]) + + with patch.object(registry, "_fetch_marketplace") as mock_fetch: + marketplace = Marketplace.load(mock_marketplace_dir) + mock_fetch.return_value = (marketplace, mock_marketplace_dir) + + plugins = registry.list_plugins("test") + + assert set(plugins) == {"plugin-a", "plugin-b"} + + def test_list_plugins_from_all(self, mock_marketplace_dir): + """Test listing plugins from all marketplaces.""" + reg = MarketplaceRegistration( + name="test", + source=str(mock_marketplace_dir), + ) + registry = MarketplaceRegistry([reg]) + + with patch.object(registry, "_fetch_marketplace") as mock_fetch: + marketplace = Marketplace.load(mock_marketplace_dir) + mock_fetch.return_value = (marketplace, mock_marketplace_dir) + + plugins = registry.list_plugins() + + assert set(plugins) == {"plugin-a", "plugin-b"} + + +class TestErrorAccumulation: + """Tests for error accumulation when marketplaces fail.""" + + def test_resolve_plugin_all_marketplaces_fail_shows_errors(self): + """Test that when all marketplaces fail, errors are included in exception.""" + regs = [ + MarketplaceRegistration(name="mp1", source="github:owner/repo1"), + MarketplaceRegistration(name="mp2", source="github:owner/repo2"), + ] + registry = MarketplaceRegistry(regs) + + # Mock _fetch_marketplace to always fail + error1 = ConnectionError("Network unreachable for mp1") + error2 = TimeoutError("Timeout connecting to mp2") + + def mock_fetch(reg): + if reg.name == "mp1": + raise error1 + raise error2 + + with patch.object(registry, "_fetch_marketplace", side_effect=mock_fetch): + with pytest.raises(PluginNotFoundError) as exc_info: + registry.resolve_plugin("some-plugin") + + # Error should mention all marketplace failures + assert exc_info.value.fetch_errors is not None + assert len(exc_info.value.fetch_errors) == 2 + assert "mp1" in exc_info.value.fetch_errors + assert "mp2" in exc_info.value.fetch_errors + # Exception message should contain details + assert "All 2 marketplace(s) failed" in str(exc_info.value) + assert "Network unreachable" in str(exc_info.value) + assert "Timeout" in str(exc_info.value) + + def test_resolve_plugin_partial_failures_dont_show_errors( + self, mock_marketplace_dir + ): + """Test that partial failures (some succeed) don't include fetch_errors.""" + regs = [ + MarketplaceRegistration(name="failing", source="github:owner/bad"), + MarketplaceRegistration(name="working", source=str(mock_marketplace_dir)), + ] + registry = MarketplaceRegistry(regs) + + marketplace = Marketplace.load(mock_marketplace_dir) + + def mock_fetch(reg): + if reg.name == "failing": + raise ConnectionError("Network error") + return (marketplace, mock_marketplace_dir) + + with patch.object(registry, "_fetch_marketplace", side_effect=mock_fetch): + # Plugin not in the marketplace that succeeded + with pytest.raises(PluginNotFoundError) as exc_info: + registry.resolve_plugin("nonexistent-plugin") + + # Since one marketplace was searched successfully, we get normal error + assert "not found in any registered marketplace" in str(exc_info.value) + # fetch_errors should be empty (not all failed) + assert not exc_info.value.fetch_errors + + def test_list_plugins_all_marketplaces_fail_raises_error(self): + """Test that list_plugins raises error with details when all fail.""" + regs = [ + MarketplaceRegistration(name="mp1", source="github:owner/repo1"), + MarketplaceRegistration(name="mp2", source="github:owner/repo2"), + ] + registry = MarketplaceRegistry(regs) + + def mock_fetch(reg): + raise ConnectionError(f"Failed to fetch {reg.name}") + + with patch.object(registry, "_fetch_marketplace", side_effect=mock_fetch): + with pytest.raises(PluginResolutionError) as exc_info: + registry.list_plugins() + + # Error message should show all failures + assert "All 2 marketplace(s) failed" in str(exc_info.value) + assert "mp1" in str(exc_info.value) + assert "mp2" in str(exc_info.value) + + +class TestPathValidation: + """Tests for repo_path validation security.""" + + def test_repo_path_rejects_traversal_via_normalization(self): + """Test that paths like 'safe/../../../etc' are rejected.""" + with pytest.raises(ValueError, match="escapes repository root"): + MarketplaceRegistration( + name="test", + source="github:owner/repo", + repo_path="safe/../../../etc/passwd", + ) + + def test_repo_path_allows_valid_nested_path(self): + """Test that valid nested paths are allowed.""" + reg = MarketplaceRegistration( + name="test", + source="github:owner/repo", + repo_path="marketplaces/internal/plugins", + ) + assert reg.repo_path == "marketplaces/internal/plugins" + + def test_repo_path_allows_simple_path(self): + """Test that simple paths without any tricks work.""" + reg = MarketplaceRegistration( + name="test", + source="github:owner/repo", + repo_path="plugins", + ) + assert reg.repo_path == "plugins" + + def test_repo_path_rejects_absolute_path(self): + """Test that absolute paths are rejected.""" + with pytest.raises(ValueError, match="must be relative"): + MarketplaceRegistration( + name="test", + source="github:owner/repo", + repo_path="/etc/passwd", + ) + + def test_repo_path_rejects_simple_parent_traversal(self): + """Test that simple '..' traversal is rejected.""" + with pytest.raises(ValueError, match="escapes repository root"): + MarketplaceRegistration( + name="test", + source="github:owner/repo", + repo_path="../outside", + ) diff --git a/tests/sdk/plugin/test_marketplace_registry_integration.py b/tests/sdk/plugin/test_marketplace_registry_integration.py new file mode 100644 index 0000000000..d2111a69d1 --- /dev/null +++ b/tests/sdk/plugin/test_marketplace_registry_integration.py @@ -0,0 +1,622 @@ +"""Integration tests for MarketplaceRegistry - full registration and resolution flow.""" + +import json +from pathlib import Path + +import pytest + +from openhands.sdk.plugin import ( + AmbiguousPluginError, + MarketplaceRegistration, + MarketplaceRegistry, + Plugin, + PluginNotFoundError, +) + + +def create_marketplace( + base_path: Path, + name: str, + plugins: list[dict], + skills: list[dict] | None = None, +) -> Path: + """Helper to create a complete marketplace directory structure. + + Args: + base_path: Parent directory for the marketplace + name: Marketplace name + plugins: List of plugin definitions with 'name' and optional 'description' + skills: Optional list of skill definitions + + Returns: + Path to the marketplace directory + """ + marketplace_dir = base_path / name + marketplace_dir.mkdir(parents=True, exist_ok=True) + + # Create .plugin/marketplace.json + plugin_meta_dir = marketplace_dir / ".plugin" + plugin_meta_dir.mkdir(exist_ok=True) + + # Build plugin entries with sources pointing to local directories + plugin_entries = [] + for plugin in plugins: + plugin_name = plugin["name"] + plugin_entries.append( + { + "name": plugin_name, + "source": f"./plugins/{plugin_name}", + "description": plugin.get("description", f"Plugin {plugin_name}"), + } + ) + + # Create plugin directory with plugin.json + plugin_dir = marketplace_dir / "plugins" / plugin_name / ".plugin" + plugin_dir.mkdir(parents=True, exist_ok=True) + + plugin_manifest = { + "name": plugin_name, + "version": plugin.get("version", "1.0.0"), + "description": plugin.get("description", f"Plugin {plugin_name}"), + } + (plugin_dir / "plugin.json").write_text(json.dumps(plugin_manifest, indent=2)) + + # Create a sample skill in the plugin + skills_dir = plugin_dir / "skills" + skills_dir.mkdir(exist_ok=True) + skill_content = f"""--- +name: {plugin_name}-skill +description: A skill from {plugin_name} +--- + +# {plugin_name} Skill + +This is a skill provided by the {plugin_name} plugin. +""" + (skills_dir / "SKILL.md").write_text(skill_content) + + marketplace_data = { + "name": name, + "owner": {"name": "Test Owner", "email": "test@example.com"}, + "description": f"Test marketplace: {name}", + "plugins": plugin_entries, + "skills": skills or [], + } + + (plugin_meta_dir / "marketplace.json").write_text( + json.dumps(marketplace_data, indent=2) + ) + + return marketplace_dir + + +class TestMarketplaceRegistryIntegration: + """Integration tests for the full marketplace registration and resolution flow.""" + + def test_single_marketplace_registration_and_resolution(self, tmp_path): + """Test registering a single marketplace and resolving plugins from it.""" + # Create a marketplace with two plugins + marketplace_dir = create_marketplace( + tmp_path, + name="company-tools", + plugins=[ + {"name": "formatter", "description": "Code formatter"}, + {"name": "linter", "description": "Code linter"}, + ], + ) + + # Register the marketplace + registry = MarketplaceRegistry( + [ + MarketplaceRegistration( + name="company", + source=str(marketplace_dir), + auto_load="all", + ), + ] + ) + + # Verify registration + assert "company" in registry.registrations + assert registry.registrations["company"].auto_load == "all" + + # Resolve plugin with explicit marketplace + source = registry.resolve_plugin("formatter@company") + assert source.source == str(marketplace_dir / "plugins" / "formatter") + + # Resolve plugin without marketplace (search all) + source = registry.resolve_plugin("linter") + assert source.source == str(marketplace_dir / "plugins" / "linter") + + # List all plugins + plugins = registry.list_plugins("company") + assert set(plugins) == {"formatter", "linter"} + + def test_multiple_marketplace_registration(self, tmp_path): + """Test registering multiple marketplaces with different plugins.""" + # Create two marketplaces + public_dir = create_marketplace( + tmp_path, + name="public-marketplace", + plugins=[ + {"name": "git", "description": "Git utilities"}, + {"name": "docker", "description": "Docker utilities"}, + ], + ) + + team_dir = create_marketplace( + tmp_path, + name="team-marketplace", + plugins=[ + {"name": "deploy", "description": "Deployment tools"}, + {"name": "monitor", "description": "Monitoring tools"}, + ], + ) + + # Register both marketplaces + registry = MarketplaceRegistry( + [ + MarketplaceRegistration( + name="public", + source=str(public_dir), + auto_load="all", + ), + MarketplaceRegistration( + name="team", + source=str(team_dir), + auto_load="all", + ), + ] + ) + + # Resolve plugins from specific marketplaces + git_source = registry.resolve_plugin("git@public") + assert "public-marketplace" in git_source.source + + deploy_source = registry.resolve_plugin("deploy@team") + assert "team-marketplace" in deploy_source.source + + # Resolve unique plugin without marketplace qualifier + docker_source = registry.resolve_plugin("docker") + assert "docker" in docker_source.source + + # List all plugins from all marketplaces + all_plugins = registry.list_plugins() + assert set(all_plugins) == {"git", "docker", "deploy", "monitor"} + + def test_auto_load_vs_registered_only(self, tmp_path): + """Test that auto_load setting is correctly tracked.""" + public_dir = create_marketplace( + tmp_path, + name="public", + plugins=[{"name": "common"}], + ) + + experimental_dir = create_marketplace( + tmp_path, + name="experimental", + plugins=[{"name": "beta-tool"}], + ) + + registry = MarketplaceRegistry( + [ + MarketplaceRegistration( + name="public", + source=str(public_dir), + auto_load="all", # Auto-load + ), + MarketplaceRegistration( + name="experimental", + source=str(experimental_dir), + # auto_load=None (default) - registered but not auto-loaded + ), + ] + ) + + # Check auto_load registrations + auto_load_regs = registry.get_auto_load_registrations() + assert len(auto_load_regs) == 1 + assert auto_load_regs[0].name == "public" + + # Both marketplaces can still resolve plugins + common_source = registry.resolve_plugin("common@public") + assert common_source is not None + + beta_source = registry.resolve_plugin("beta-tool@experimental") + assert beta_source is not None + + def test_ambiguous_plugin_error(self, tmp_path): + """Test that ambiguous plugin names raise appropriate error.""" + # Create two marketplaces with a plugin of the same name + mp1_dir = create_marketplace( + tmp_path, + name="marketplace1", + plugins=[{"name": "shared-plugin", "description": "Version from MP1"}], + ) + + mp2_dir = create_marketplace( + tmp_path, + name="marketplace2", + plugins=[{"name": "shared-plugin", "description": "Version from MP2"}], + ) + + registry = MarketplaceRegistry( + [ + MarketplaceRegistration(name="mp1", source=str(mp1_dir)), + MarketplaceRegistration(name="mp2", source=str(mp2_dir)), + ] + ) + + # Resolving without qualifier should fail + with pytest.raises(AmbiguousPluginError) as exc_info: + registry.resolve_plugin("shared-plugin") + + assert exc_info.value.plugin_name == "shared-plugin" + assert set(exc_info.value.matching_marketplaces) == {"mp1", "mp2"} + + # But explicit qualification should work + source1 = registry.resolve_plugin("shared-plugin@mp1") + assert "marketplace1" in source1.source + + source2 = registry.resolve_plugin("shared-plugin@mp2") + assert "marketplace2" in source2.source + + def test_plugin_not_found_error(self, tmp_path): + """Test that missing plugins raise appropriate error.""" + marketplace_dir = create_marketplace( + tmp_path, + name="test-marketplace", + plugins=[{"name": "existing-plugin"}], + ) + + registry = MarketplaceRegistry( + [ + MarketplaceRegistration(name="test", source=str(marketplace_dir)), + ] + ) + + # Non-existent plugin in specific marketplace + with pytest.raises(PluginNotFoundError) as exc_info: + registry.resolve_plugin("nonexistent@test") + assert exc_info.value.plugin_name == "nonexistent" + assert exc_info.value.marketplace_name == "test" + + # Non-existent plugin searching all + with pytest.raises(PluginNotFoundError) as exc_info: + registry.resolve_plugin("nonexistent") + assert exc_info.value.plugin_name == "nonexistent" + assert exc_info.value.marketplace_name is None + + def test_marketplace_caching(self, tmp_path): + """Test that marketplace fetching is cached.""" + marketplace_dir = create_marketplace( + tmp_path, + name="cached-marketplace", + plugins=[{"name": "plugin-a"}, {"name": "plugin-b"}], + ) + + registry = MarketplaceRegistry( + [ + MarketplaceRegistration(name="cached", source=str(marketplace_dir)), + ] + ) + + # First resolution - should fetch and cache + registry.resolve_plugin("plugin-a@cached") + + # Check that marketplace is now cached + assert "cached" in registry._cache + + # Second resolution - should use cache + registry.resolve_plugin("plugin-b@cached") + + # Cache should still have just one entry + assert len(registry._cache) == 1 + + def test_prefetch_all_marketplaces(self, tmp_path): + """Test eagerly prefetching all registered marketplaces.""" + mp1_dir = create_marketplace(tmp_path, "mp1", [{"name": "p1"}]) + mp2_dir = create_marketplace(tmp_path, "mp2", [{"name": "p2"}]) + + registry = MarketplaceRegistry( + [ + MarketplaceRegistration(name="mp1", source=str(mp1_dir)), + MarketplaceRegistration(name="mp2", source=str(mp2_dir)), + ] + ) + + # Cache should be empty initially + assert len(registry._cache) == 0 + + # Prefetch all + registry.prefetch_all() + + # Both should now be cached + assert len(registry._cache) == 2 + assert "mp1" in registry._cache + assert "mp2" in registry._cache + + def test_monorepo_marketplace_with_repo_path(self, tmp_path): + """Test marketplace in a monorepo subdirectory.""" + # Create a monorepo structure + monorepo_dir = tmp_path / "monorepo" + monorepo_dir.mkdir() + + # Create marketplace in a subdirectory + marketplace_subdir = monorepo_dir / "packages" / "marketplace" + marketplace_subdir.mkdir(parents=True) + + # Create .plugin structure in the subdirectory + plugin_meta_dir = marketplace_subdir / ".plugin" + plugin_meta_dir.mkdir() + + # Create plugin directory + plugin_dir = marketplace_subdir / "plugins" / "monorepo-plugin" / ".plugin" + plugin_dir.mkdir(parents=True) + + plugin_manifest = {"name": "monorepo-plugin", "version": "1.0.0"} + (plugin_dir / "plugin.json").write_text(json.dumps(plugin_manifest)) + + marketplace_data = { + "name": "monorepo-marketplace", + "owner": {"name": "Test"}, + "plugins": [ + {"name": "monorepo-plugin", "source": "./plugins/monorepo-plugin"}, + ], + } + (plugin_meta_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) + + # Register with repo_path pointing to the subdirectory + # Note: For local paths, repo_path isn't used the same way as git repos + # The source should point directly to the marketplace directory + registry = MarketplaceRegistry( + [ + MarketplaceRegistration( + name="monorepo", + source=str(marketplace_subdir), + ), + ] + ) + + # Should be able to resolve the plugin + source = registry.resolve_plugin("monorepo-plugin@monorepo") + assert "monorepo-plugin" in source.source + + def test_full_plugin_load_flow(self, tmp_path): + """Test the complete flow from registration to plugin loading.""" + # Create a marketplace directory manually with proper plugin structure + marketplace_dir = tmp_path / "full-test-marketplace" + marketplace_dir.mkdir() + + # Create marketplace manifest + mp_meta_dir = marketplace_dir / ".plugin" + mp_meta_dir.mkdir() + + marketplace_data = { + "name": "full-test-marketplace", + "owner": {"name": "Test"}, + "plugins": [ + {"name": "test-plugin", "source": "./plugins/test-plugin"}, + ], + } + (mp_meta_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) + + # Create plugin with skills directory at root level (not inside .plugin) + plugin_dir = marketplace_dir / "plugins" / "test-plugin" + plugin_dir.mkdir(parents=True) + + # Plugin manifest in .plugin/ + plugin_meta_dir = plugin_dir / ".plugin" + plugin_meta_dir.mkdir() + (plugin_meta_dir / "plugin.json").write_text( + json.dumps( + { + "name": "test-plugin", + "version": "1.0.0", + "description": "A complete test plugin", + } + ) + ) + + # Skills directory at plugin root level + skills_dir = plugin_dir / "skills" + skills_dir.mkdir() + skill_content = """--- +name: test-plugin-skill +description: A skill from test-plugin +--- + +# Test Plugin Skill + +This is a skill provided by the test-plugin plugin. +""" + (skills_dir / "SKILL.md").write_text(skill_content) + + # Register the marketplace + registry = MarketplaceRegistry( + [ + MarketplaceRegistration( + name="fulltest", + source=str(marketplace_dir), + auto_load="all", + ), + ] + ) + + # Resolve the plugin + plugin_source = registry.resolve_plugin("test-plugin@fulltest") + + # Load the plugin using the resolved source + plugin = Plugin.load(plugin_source.source) + + # Verify plugin was loaded correctly + assert plugin.manifest.name == "test-plugin" + assert plugin.manifest.description == "A complete test plugin" + + # Check that skills were loaded + assert len(plugin.skills) > 0 + assert any("test-plugin" in s.name for s in plugin.skills) + + def test_marketplace_with_claude_plugin_directory(self, tmp_path): + """Test marketplace using .claude-plugin directory (fallback).""" + marketplace_dir = tmp_path / "claude-compat-marketplace" + marketplace_dir.mkdir() + + # Use .claude-plugin instead of .plugin + plugin_meta_dir = marketplace_dir / ".claude-plugin" + plugin_meta_dir.mkdir() + + # Create plugin + plugin_dir = marketplace_dir / "plugins" / "claude-plugin" / ".claude-plugin" + plugin_dir.mkdir(parents=True) + + plugin_manifest = {"name": "claude-plugin", "version": "1.0.0"} + (plugin_dir / "plugin.json").write_text(json.dumps(plugin_manifest)) + + marketplace_data = { + "name": "claude-compat", + "owner": {"name": "Test"}, + "plugins": [ + {"name": "claude-plugin", "source": "./plugins/claude-plugin"}, + ], + } + (plugin_meta_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) + + # Register and resolve + registry = MarketplaceRegistry( + [ + MarketplaceRegistration( + name="claude", + source=str(marketplace_dir), + ), + ] + ) + + source = registry.resolve_plugin("claude-plugin@claude") + assert "claude-plugin" in source.source + + +class TestConversationLoadPlugin: + """Test Conversation.load_plugin() integration with MarketplaceRegistry.""" + + def test_load_plugin_from_marketplace(self, tmp_path): + """Test loading a plugin via conversation.load_plugin().""" + from openhands.sdk import LLM, Agent, AgentContext + from openhands.sdk.conversation import Conversation + + # Create a marketplace with a plugin + marketplace_dir = create_marketplace( + tmp_path, + name="test-marketplace", + plugins=[{"name": "test-plugin", "description": "A test plugin"}], + ) + + # Create agent with registered marketplace (use dummy LLM - won't make calls) + llm = LLM(model="test/model", api_key="test-key") + + agent_context = AgentContext( + registered_marketplaces=[ + MarketplaceRegistration( + name="test", + source=str(marketplace_dir), + ), + ], + ) + + agent = Agent( + llm=llm, + tools=[], + agent_context=agent_context, + ) + + # Create conversation + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + + conversation = Conversation( + agent=agent, + workspace=str(workspace_dir), + ) + + # Verify no resolved_plugins yet + assert conversation.resolved_plugins is None + + # Load the plugin + conversation.load_plugin("test-plugin@test") + + # Verify resolved_plugins was updated + assert conversation.resolved_plugins is not None + assert len(conversation.resolved_plugins) == 1 + expected_source = f"{marketplace_dir}/plugins/test-plugin" + assert conversation.resolved_plugins[0].source == expected_source + + conversation.close() + + def test_load_plugin_no_marketplaces_registered(self, tmp_path): + """Test that load_plugin raises ValueError when no marketplaces registered.""" + from openhands.sdk import LLM, Agent, AgentContext + from openhands.sdk.conversation import Conversation + + # Create agent without registered marketplaces + llm = LLM(model="test/model", api_key="test-key") + + agent = Agent( + llm=llm, + tools=[], + agent_context=AgentContext(), + ) + + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + + conversation = Conversation( + agent=agent, + workspace=str(workspace_dir), + ) + + with pytest.raises(ValueError, match="No marketplaces registered"): + conversation.load_plugin("some-plugin") + + conversation.close() + + def test_load_plugin_not_found(self, tmp_path): + """Test that load_plugin raises PluginNotFoundError for missing plugins.""" + from openhands.sdk import LLM, Agent, AgentContext + from openhands.sdk.conversation import Conversation + + # Create a marketplace with a plugin + marketplace_dir = create_marketplace( + tmp_path, + name="test-marketplace", + plugins=[{"name": "existing-plugin"}], + ) + + llm = LLM(model="test/model", api_key="test-key") + + agent_context = AgentContext( + registered_marketplaces=[ + MarketplaceRegistration( + name="test", + source=str(marketplace_dir), + ), + ], + ) + + agent = Agent( + llm=llm, + tools=[], + agent_context=agent_context, + ) + + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + + conversation = Conversation( + agent=agent, + workspace=str(workspace_dir), + ) + + with pytest.raises(PluginNotFoundError): + conversation.load_plugin("nonexistent-plugin@test") + + conversation.close()