乐于分享
好东西不私藏

OpenHands 源码-插件系统与扩展点

OpenHands 源码-插件系统与扩展点

OpenHands 源码解析系列

第 19 讲:插件系统与扩展点

基于 OpenHands 源码 · 2026-08-15

一、什么是插件系统

OpenHands 的插件系统(Plugin System)是平台可扩展性的核心。它让管理员、组织和用户能够在不同层级注册外部插件市场(Marketplace),动态加载 Skill、Hook 和 MCP 工具——而无需修改核心代码。整个系统围绕三个关键概念构建:

Marketplace:一个 Git 仓库,包含插件描述、Skill 文件和扩展点定义
MarketplaceRegistration:注册一个市场源的配置对象
Scope 层级:Instance → Org → User 三级继承与覆盖机制

本节深入源码,拆解插件系统从注册、组合、克隆到会话绑定的完整链路。

二、MarketplaceRegistration:插件市场的注册模型

一切始于 MarketplaceRegistration,它定义了一个插件市场的来源和加载策略:

📄 openhands/app_server/settings/settings_models.py(第 75-151 行)

class MarketplaceRegistration(BaseModel):
    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)."
        ),
    )
    auto_load: bool = Field(
        default=False,
        description=(
            'Auto-load behavior for this marketplace. '
            'True = load all plugins at conversation start. '
            'False = registered for resolution but not auto-loaded.'
        ),
    )
    scope: 'MarketplaceScope | None' = Field(
        default=None,
        description=(
            'Scope of this marketplace registration. '
            'Set automatically by backend based on storage layer'
        ),
    )

六个字段各司其职:name 是标识符,source 指定仓库来源,ref 锁定版本,repo_path 支持 monorepo 子目录,auto_load 控制是否在会话启动时自动加载,scope 由后端自动设置表示来源层级。

严格的输入校验

每个字段都有对应的 Pydantic validator,防止注入攻击和路径穿越:

📄 settings_models.py(第 153-214 行)

# name 校验:必须以字母开头,只允许字母数字横线下划线
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
    if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', v):
        raise ValueError('name must start with a letter...')

# source 校验:三种合法格式
_GITHUB_SOURCE_PATTERN = re.compile(r'^github:[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')
_GIT_URL_PATTERN = re.compile(
    r'^(https?://|git@|ssh://|git://)...')
_LOCAL_PATH_PATTERN = re.compile(r'^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$')

# repo_path 校验:禁止 .. 和绝对路径
@field_validator('repo_path')
@classmethod
def validate_repo_path(cls, v: str | None) -> str | None:
    if v and ('://' in v or v.startswith('/') or '..' in v):
        raise ValueError('repo_path must be a safe relative path')

source 支持三种格式:GitHub 短格式(github:owner/repo)、完整 Git URL(https/git/ssh)、以及相对本地路径。repo_path 禁止 .. 穿越和绝对路径——这是防止 SSRF 和路径注入的第一道防线。

三、三级 Scope 组合机制

OpenHands 的插件市场支持三级 Scope,从宽到窄依次是 Instance → Org → User。不同级别的注册可以同名覆盖,形成继承链:

📄 settings/marketplace_composition.py(第 206-251 行)

def compose_marketplaces(
    instance_marketplaces: Sequence[_RawMarketplace] | None,
    org_marketplaces: Sequence[_RawMarketplace] | None,
    user_marketplaces: Sequence[_RawMarketplace] | None,
) -> ComposedMarketplaces:
    """Compose marketplaces from the three scopes, keyed on ``name``.

    Precedence is Instance < Org < User for identity;
    org overrides an instance entry of the same name,
    and a user entry is dropped when its name already exists
    at a broader scope.
    """
    inherited: dict[str, MarketplaceRegistration] = {}
    # Instance 层:stamp 为 INSTANCE scope
    for raw in instance_marketplaces or []:
        reg = _coerce(raw)
        if reg is not None:
            inherited[reg.name] = _stamp(reg, MarketplaceScope.INSTANCE)
    # Org 层:同名覆盖 instance
    for raw in org_marketplaces or []:
        reg = _coerce(raw)
        if reg is not None:
            inherited[reg.name] = _stamp(reg, MarketplaceScope.ORG)
    # User 层:同名被拒绝(shadow),不同名加入 personal
    personal: dict[str, MarketplaceRegistration] = {}
    for raw in user_marketplaces or []:
        reg = _coerce(raw)
        if reg is None:
            continue
        if reg.name in inherited:
            logger.debug("User marketplace '%s' shadows...; ignoring", reg.name)
            continue
        personal[reg.name] = _stamp(reg, MarketplaceScope.PERSONAL)

    return ComposedMarketplaces(
        inherited=list(inherited.values()),
        personal=list(personal.values()),
    )
Scope来源覆盖规则
Instance系统默认配置最低优先级,可被 Org 覆盖
Org组织级别设置覆盖同名 Instance,User 不可 shadow
Personal用户个人设置只能添加新名称,不能覆盖 inherited

这个设计保证了管理员设置的 Instance 和 Org 市场不会被用户意外覆盖,同时用户仍然可以添加自己的个人市场。返回的 ComposedMarketplaces 将 inherited 和 personal 分开,方便下游区分来源。

实际加载由 load_composed_marketplaces() 串联:

📄 marketplace_composition.py(第 254-270 行)

async def load_composed_marketplaces(
    user_id: str | None,
    user_marketplaces: Sequence[_RawMarketplace] | None,
    settings_store: Any,
) -> ComposedMarketplaces:
    instance = get_instance_default_marketplaces()
    try:
        org = await settings_store.get_org_marketplaces(user_id)
    except Exception as e:
        logger.warning('Failed to load org marketplaces: %s', e)
        org = []
    return compose_marketplaces(instance, org, user_marketplaces)

org 加载失败不会中断流程——graceful degradation 保证系统可用性。

四、运行时插件加载:从 Market 到会话

插件市场的真正价值在于运行时加载。当用户启动一个新会话时,OpenHands 会把注册的 marketplace 克隆到临时目录,提取其中的 Skill 和 Hook,然后注入到 agent 上下文中。

4.1 会话启动时的市场解析

LiveStatusAppConversationService 在会话创建流程中调用 _resolve_registered_marketplaces

📄 live_status_app_conversation_service.py(第 2207-2225 行)

async def _resolve_registered_marketplaces(
    self, user: User | None
) -> list[MarketplaceRegistration] | None:
    """Resolve marketplace registrations for a conversation start."""
    if not marketplace_plugin_loading_enabled():
        return None
    if user is None:
        return None
    settings_store = self.injector.get_settings_store()
    return await load_composed_marketplaces(
        user_id, user.registered_marketplaces, settings_store
    )

关键开关 marketplace_plugin_loading_enabled() 来自 SDK 配置,允许管理员全局禁用插件加载——这在安全敏感的部署中是必要的。

4.2 Skill Loader 的克隆与认证

Marketplace 仓库的克隆逻辑在 skill_loader.py 中。核心流程:

📄 app_conversation/skill_loader.py(第 201-322 行)

async def _clone_marketplace_repo(
    marketplace: MarketplaceRegistration,
    user_context: UserContext,
) -> tuple[Path | None, str]:
    provider, repo_path = _parse_marketplace_source(marketplace.source)

    # Authenticate URL/scp sources against the provider
    authenticated_url = None
    try:
        matched = await _match_url_source_to_provider(
            marketplace.source, user_context)
        if matched is not None:
            matched_provider, matched_repo = matched
            handler = await user_context.get_provider_handler()
            authenticated_url = await handler.get_authenticated_git_url(
                matched_repo, specified_provider=matched_provider
            )
        elif '://' not in repo_path:
            # Bare owner/repo: resolve with provider tokens
            provider_tokens = await user_context.get_provider_tokens()
            if provider_tokens:
                client = ProviderHandler(...)
                authenticated_url = await client.get_authenticated_git_url(
                    repo_path)

认证分两条路径:URL 格式的 source 通过 _match_url_source_to_provider 匹配已配置的 Provider(包括 Bitbucket Data Center 等自托管实例),裸格式的 owner/repo 则使用用户已登录的 Provider Token。如果认证失败,降级为公开克隆。

克隆过程有完整的防御措施:

📄 skill_loader.py(第 270-322 行)

    # Create unique temporary directory
    clone_dir = Path(tempfile.mkdtemp(
        prefix=f'openhands_marketplace_{marketplace.name}_'))

    # Reject leading '-' to prevent argument injection
    if clone_url.startswith('-'):
        _cleanup_clone_dir(clone_dir)
        return None, f'Invalid clone URL: {clone_url}'

    result = subprocess.run(
        ['git', 'clone', '--', clone_url, str(clone_dir)],
        capture_output=True, text=True, timeout=120,
    )

    if result.returncode != 0:
        _cleanup_clone_dir(clone_dir)
        return None, f'Git clone failed: {result.stderr}'

    # Checkout ref if specified
    if marketplace.ref:
        if marketplace.ref.startswith('-'):
            _cleanup_clone_dir(clone_dir)
            return None, f'Invalid ref: {marketplace.ref}'
        checkout_result = subprocess.run(
            ['git', '-C', str(clone_dir), 'checkout', marketplace.ref],
            capture_output=True, text=True, timeout=60,
        )

    # Navigate to repo_path if specified
    if marketplace.repo_path:
        skills_path = clone_dir / marketplace.repo_path
        if not skills_path.exists():
            _cleanup_clone_dir(clone_dir)
            return None, f'Repo path not found: {marketplace.repo_path}'
        return skills_path, ''

    return clone_dir, ''

注意 -- 参数隔离和 - 前缀拒绝——这是防止 git 命令注入的标准做法。120 秒超时防止恶意仓库挂起服务器。克隆失败自动清理临时目录。

4.3 Marketplace 清单解析

克隆完成后,Skill Loader 尝试解析 marketplace manifest(.plugin/marketplace.json.claude-plugin/marketplace.json):

📄 skills_router.py(第 381-448 行)

            loaded_marketplace = Marketplace.load(clone_path)

            if loaded_marketplace is not None:
                # Plugin-level: expose plugins and standalone skills
                for plugin_entry in loaded_marketplace.plugins:
                    plugins.append(MarketplacePluginPreview(
                        name=plugin_entry.name,
                        description=plugin_entry.description,
                        source=marketplace.source,
                        marketplace=marketplace.name,
                    ))
                for skill_entry in loaded_marketplace.skills:
                    all_skills.append(SkillInfo(
                        name=skill_entry.name,
                        type='knowledge',
                        source=f'marketplace:{marketplace.name}',
                    ))
            else:
                # No manifest: fall back to loose skill scan
                skills_dirs = [
                    d for d in (clone_path / 'skills',
                                clone_path / '.skills')
                    if d.is_dir()
                ]
                for skills_dir in skills_dirs:
                    for skill in _load_skills_from_dir(
                            skills_dir, marketplace.source):
                        all_skills.append(...)

两种模式:有 manifest 的 marketplace 以 Plugin 为单位暴露,没有 manifest 的仓库则退化为扫描 skills/.skills/ 目录的松散 Skill 文件。

五、Hook 系统:会话级扩展点

除了 Skill,OpenHands 还支持 Hook——在会话生命周期特定阶段执行的扩展逻辑。Hook 定义在项目目录的 .openhands/hooks.json 中:

📄 app_conversation/hook_loader.py(第 1-60 行)

from enum import Enum

class HookType(str, Enum):
    POST_TASK = 'post_task'
    POST_ACTION = 'post_action'
    PRE_ACTION = 'pre_action'
    POST_STEP = 'post_step'
    PRE_STEP = 'pre_step'


class Hook(BaseModel):
    type: HookType
    command: str
    timeout: int = 30
    description: str | None = None


async def fetch_hooks_from_agent_server(
    agent_server_url: str,
    session_id: str,
    raise_for_error: bool = True,
) -> list[Hook]:
    """Fetch hooks from the agent server for a given session."""
    response = await httpx.AsyncClient().post(
        f'{agent_server_url}/hooks',
        json={'session_id': session_id},
        timeout=10,
    )
    if raise_for_error:
        response.raise_for_status()
    data = response.json()
    return [Hook(**h) for h in data.get('hooks', [])]

Hook 类型覆盖了 agent 执行的关键节点:任务前后、动作前后、步骤前后。每个 Hook 是一个命令字符串,在沙箱中执行,有独立的超时控制。

Hook 的加载时机在会话路由中:

📄 app_conversation_router.py(第 1471-1542 行)

@router.get('/{conversation_id}/hooks')
async def get_conversation_hooks(
    conversation_id: str,
    user_context: UserContext = user_context_dependency,
):
    """Get hooks currently configured in the workspace."""
    project_dir = get_project_dir_for_hooks(
        conversation_id, user_context, sandbox_service)

    hook_config = await fetch_hooks_from_agent_server(
        agent_server_url, session_id,
        raise_for_error=True,
    )

    return JSONResponse(content={'hooks': hook_config})

Hook 从 agent server 实时获取,反映项目目录的最新状态——这意味着运行时修改 hooks.json 会立即生效。

六、Provider 集成层:插件的认证基础

插件系统依赖 Provider 集成层完成私有仓库的认证克隆。ProviderHandler 统一管理多个 Git 平台的 Token:

📄 integrations/provider.py(第 102-135 行)

PROVIDER_TOKEN_TYPE = Mapping[ProviderType, ProviderToken]

PROVIDER_DOMAINS: dict[ProviderType, str] = {
    ProviderType.GITHUB: 'github.com',
    ProviderType.GITLAB: GITLAB_HOST,
    ProviderType.BITBUCKET: 'bitbucket.org',
    ProviderType.FORGEJO: 'codeberg.org',
    ProviderType.AZURE_DEVOPS: 'dev.azure.com',
}

class ProviderHandler:
    def __init__(
        self,
        provider_tokens: PROVIDER_TOKEN_TYPE,
        external_auth_id: str | None = None,
    ):
        self.service_class_map: dict[ProviderType, type[GitService]] = {
            ProviderType.GITHUB: GithubServiceImpl,
            ProviderType.GITLAB: GitLabServiceImpl,
            ProviderType.BITBUCKET: BitBucketServiceImpl,
            ProviderType.BITBUCKET_DATA_CENTER: BitBucketDCServiceImpl,
            ProviderType.FORGEJO: ForgejoServiceImpl,
            ProviderType.AZURE_DEVOPS: AzureDevOpsServiceImpl,
        }

    async def get_authenticated_git_url(
        self, repo: str,
        specified_provider: ProviderType | None = None,
    ) -> str | None:
        """Return an authenticated git clone URL for the repo."""
        ...
ProviderService 实现备注
GitHubGithubServiceImpl默认 provider
GitLabGitLabServiceImpl支持自定义 host
BitbucketBitBucketServiceImplCloud 版本
Bitbucket DCBitBucketDCServiceImpl自托管版本
ForgejoForgejoServiceImplCodeberg 等
Azure DevOpsAzureDevOpsServiceImpl企业级

每个 Provider 有独立的 service 实现类,通过 service_class_map 映射。插件系统通过 _match_url_source_to_provider 自动识别 source 中的 host 并路由到正确的 Provider 获取认证 URL。

七、完整的插件加载架构

插件系统加载流程

[用户启动会话] ──→ LiveStatusAppConversationService

  │

  ├─ marketplace_plugin_loading_enabled()?

  │  └─ NO → 跳过插件加载

  │

  └─ YES → _resolve_registered_marketplaces()

      │

      ├─ load_composed_marketplaces()

      │  ├─ Instance defaults

      │  ├─ Org overrides (同名覆盖)

      │  └─ User personal (仅新增)

      │

      └─ Skill Loader 处理每个 auto_load=True 的市场

          ├─ ProviderHandler 获取认证 URL

          ├─ git clone 到临时目录

          ├─ Marketplace.load() 解析 manifest

          ├─ 提取 Plugin + Skill 列表

          └─ 注入 Agent 上下文

            │

            └─ Hook Loader 加载 hooks.json

整个插件加载链路是异步的,每个 marketplace 独立克隆,单个失败不影响其他市场。临时目录在 finally 块中清理,确保不会泄漏磁盘空间。

八、安全设计总结

OpenHands 插件系统的安全设计值得单独拎出来:

🔹 Source 白名单校验:正则限制 source 格式,拒绝非法输入

🔹 路径穿越防护repo_path 禁止 ..、绝对路径和 URL

🔹 SSRF 防护:未识别的 host 直接拒绝克隆,不尝试未知域名

🔹 Git 注入防护-- 参数隔离 + - 前缀拒绝

🔹 超时保护:clone 120s、checkout 60s、hook 10s

🔹 资源清理:每次失败/成功都清理临时目录

🔹 全局开关marketplace_plugin_loading_enabled() 可一键禁用

🔹 Graceful degradation:Org 加载失败不影响 Instance + User

九、总结

OpenHands 的插件系统是一个精心设计的可扩展架构。三级 Scope 组合机制提供了灵活的权限模型,Skill Loader 的认证克隆支持私有仓库,Hook 系统在关键执行点提供扩展能力,而 Provider 集成层为所有外部交互提供了统一的认证抽象。

下一讲也是本系列最后一讲,我们将总结 OpenHands 的架构最佳实践,回顾 20 讲的核心知识点。

📚 系列导航

← 第 18 讲:安全机制

→ 第 20 讲:总结与最佳实践

关注公众号「AI技术推荐官」获取更多源码解析内容