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."""
...
| Provider | Service 实现 | 备注 |
|---|---|---|
| GitHub | GithubServiceImpl | 默认 provider |
| GitLab | GitLabServiceImpl | 支持自定义 host |
| Bitbucket | BitBucketServiceImpl | Cloud 版本 |
| Bitbucket DC | BitBucketDCServiceImpl | 自托管版本 |
| Forgejo | ForgejoServiceImpl | Codeberg 等 |
| Azure DevOps | AzureDevOpsServiceImpl | 企业级 |
每个 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技术推荐官」获取更多源码解析内容
夜雨聆风