此提交包含多项核心功能迭代与问题修复: 1. 新增多操作权限配置,完善权限控制覆盖范围 2. 重构出站流水线适配器参数,优化组件依赖关系 3. 新增渠道账号配置变更事件,支持动态重建传输 worker 4. 实现全链路 trace_id 透传,优化调试追踪能力 5. 新增传输任务最大重启次数配置,避免无限崩溃循环 6. 优化幂等冲突错误体系,重构错误继承与提示信息 7. 增强 bridge_url 安全校验,新增 SSRF 防护与 HTTPS 强制校验 8. 完善 SSE 断线补全逻辑,新增分页与消息数限制 9. 重构 WeChatWoc 插件生命周期与资源管理,优化连接池与缓存清理 10. 修复多处代码逻辑bug,提升系统稳定性与可维护性
378 lines
15 KiB
Python
378 lines
15 KiB
Python
"""清单加载器。
|
||
|
||
提供 ``load_manifest_from_dir`` 工具函数,从插件目录读取 ``manifest.json``
|
||
并解析为 ``ChannelManifest``。合并原 ``PluginLoader._parseManifestForInstall``
|
||
与 ``PluginLifecycleManager._parse_manifest`` 的重复解析逻辑,作为 manifest
|
||
单真相源的统一解析入口(F-03)。
|
||
|
||
异常处理保留原始错误上下文(INV-7),使用 ``raise ... from e`` 保留 Python
|
||
异常链。必填字段缺失或值非法时抛 ``ValidationError``,不使用原生异常。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import dataclasses
|
||
import json
|
||
import os
|
||
from typing import Any
|
||
|
||
from yuxi.channels.contract.dtos.capability import ChannelCapabilities
|
||
from yuxi.channels.contract.dtos.channel import ChannelType
|
||
from yuxi.channels.contract.dtos.config import ConfigField, ConfigScope
|
||
from yuxi.channels.contract.dtos.plugin import EnvVar
|
||
from yuxi.channels.contract.errors.client import ValidationError
|
||
from yuxi.channels.contract.plugin.manifest import (
|
||
AcquireMethod,
|
||
ChannelManifest,
|
||
CredentialStrategy,
|
||
CredentialType,
|
||
FailurePolicy,
|
||
PluginDependency,
|
||
ResourceQuota,
|
||
)
|
||
|
||
__all__ = ["load_manifest_from_dir"]
|
||
|
||
|
||
def load_manifest_from_dir(plugin_dir: str) -> ChannelManifest:
|
||
"""从插件目录加载 ``manifest.json`` 并解析为 ``ChannelManifest``。
|
||
|
||
读取 ``plugin_dir/manifest.json``,按字段映射构造 ``ChannelManifest``,
|
||
处理枚举转换与嵌套类型解析。必填字段缺失或值非法时抛 ``ValidationError``,
|
||
保留原始错误上下文(INV-7)。
|
||
|
||
参数:
|
||
plugin_dir: 插件目录路径,``manifest.json`` 位于该目录下。
|
||
|
||
返回:
|
||
解析后的渠道清单。
|
||
|
||
抛出:
|
||
ValidationError: JSON 解析失败、必填字段缺失或字段值非法。
|
||
"""
|
||
manifest_path = os.path.join(plugin_dir, "manifest.json")
|
||
try:
|
||
with open(manifest_path, encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
except (OSError, json.JSONDecodeError) as e:
|
||
raise ValidationError(
|
||
field="manifest.json",
|
||
message=f"清单文件解析失败: {manifest_path}: {e}",
|
||
) from e
|
||
|
||
if not isinstance(data, dict):
|
||
raise ValidationError(
|
||
field="manifest.json",
|
||
message=f"清单根节点必须为对象: {manifest_path}",
|
||
)
|
||
|
||
# 必填字段校验(FR21-P0-1: capabilities 与 config_schema 为必填;
|
||
# LFE-P0-002: provides/lifecycle/compatibility/failure_policy 为必填)
|
||
required_fields = [
|
||
"id",
|
||
"name",
|
||
"version",
|
||
"channel_type",
|
||
"entry_module",
|
||
"capabilities",
|
||
"config_schema",
|
||
"provides",
|
||
"lifecycle",
|
||
"compatibility",
|
||
"failure_policy",
|
||
]
|
||
for field_name in required_fields:
|
||
if field_name not in data:
|
||
raise ValidationError(
|
||
field=field_name,
|
||
message=f"清单缺少必填字段: {field_name}",
|
||
)
|
||
|
||
# 解析 channel_type(ChannelType 为 str 子类,校验非空字符串)
|
||
channel_type_raw = data["channel_type"]
|
||
if not isinstance(channel_type_raw, str) or not channel_type_raw:
|
||
raise ValidationError(
|
||
field="channel_type",
|
||
message="channel_type must be a non-empty string",
|
||
)
|
||
channel_type = ChannelType(channel_type_raw)
|
||
|
||
# 解析 failure_policy 枚举
|
||
failure_policy_raw = data["failure_policy"]
|
||
try:
|
||
failure_policy = FailurePolicy(failure_policy_raw)
|
||
except ValueError as e:
|
||
raise ValidationError(
|
||
field="failure_policy",
|
||
message=f"未知的失败策略: {failure_policy_raw}",
|
||
) from e
|
||
|
||
capabilities = _parse_capabilities(data.get("capabilities"))
|
||
config_schema = tuple(_parse_config_field(cf) for cf in data.get("config_schema", []))
|
||
capability_requirements = _parse_capability_requirements(
|
||
data.get("capability_requirements"),
|
||
{f.name for f in dataclasses.fields(ChannelCapabilities)},
|
||
)
|
||
depends = tuple(_parse_dependency(dep) for dep in data.get("depends", []))
|
||
resource_quota = _parse_resource_quota(data.get("resource_quota"))
|
||
env_vars = tuple(_parse_env_var(ev) for ev in data.get("env_vars", []))
|
||
|
||
# 解析 credential_strategy(缺失时为 None,下游按 static/manual 兜底)
|
||
cs_data = data.get("credential_strategy")
|
||
credential_strategy = (
|
||
CredentialStrategy(
|
||
type=CredentialType(cs_data["type"]),
|
||
acquire_method=AcquireMethod(cs_data["acquire_method"]),
|
||
required_fields=tuple(cs_data.get("required_fields", [])),
|
||
optional_fields=tuple(cs_data.get("optional_fields", [])),
|
||
supports_rotation=cs_data.get("supports_rotation", False),
|
||
supports_revocation=cs_data.get("supports_revocation", False),
|
||
)
|
||
if cs_data
|
||
else None
|
||
)
|
||
|
||
# 校验 compatibility 类型(dict 或 None,避免字符串/列表静默透传违反类型契约)
|
||
compatibility_raw = data["compatibility"]
|
||
if compatibility_raw is not None and not isinstance(compatibility_raw, dict):
|
||
raise ValidationError(
|
||
field="compatibility",
|
||
message=(
|
||
f"compatibility 必须为对象或 null,实际类型为 {type(compatibility_raw).__name__}: {compatibility_raw!r}"
|
||
),
|
||
)
|
||
|
||
# 解析 max_message_length(捕获 int() 转换异常,封装为 ValidationError,INV-7)
|
||
max_message_length_raw = data.get("max_message_length", 4096)
|
||
try:
|
||
max_message_length = int(max_message_length_raw)
|
||
except (TypeError, ValueError) as e:
|
||
raise ValidationError(
|
||
field="max_message_length",
|
||
message=(f"max_message_length 必须为整数,实际值为 {max_message_length_raw!r}"),
|
||
) from e
|
||
|
||
# 解析 transport_mode(Task 11.3):缺失时默认 "both",非法值抛 ValidationError
|
||
transport_mode_raw = data.get("transport_mode", "both")
|
||
if transport_mode_raw not in ("pull", "stream", "both"):
|
||
raise ValidationError(
|
||
field="transport_mode",
|
||
message=(f"transport_mode 必须为 pull/stream/both,实际值为 {transport_mode_raw!r}"),
|
||
)
|
||
transport_mode = transport_mode_raw
|
||
|
||
# 解析 max_restart_attempts(P0-1 SubTask 4.7):缺失时默认 None(无限重启),
|
||
# 声明后作为 TransportConfig 的初始默认值,ConfigPort 可运行时覆盖
|
||
max_restart_attempts_raw = data.get("max_restart_attempts")
|
||
max_restart_attempts: int | None = None
|
||
if max_restart_attempts_raw is not None:
|
||
try:
|
||
max_restart_attempts = int(max_restart_attempts_raw)
|
||
except (TypeError, ValueError) as e:
|
||
raise ValidationError(
|
||
field="max_restart_attempts",
|
||
message=(f"max_restart_attempts 必须为整数,实际值为 {max_restart_attempts_raw!r}"),
|
||
) from e
|
||
|
||
return ChannelManifest(
|
||
id=data["id"],
|
||
name=data["name"],
|
||
version=data["version"],
|
||
channel_type=channel_type,
|
||
provides=_to_tuple(data["provides"], "provides"),
|
||
entry_module=data["entry_module"],
|
||
capabilities=capabilities,
|
||
config_schema=config_schema,
|
||
capability_requirements=capability_requirements,
|
||
depends=depends,
|
||
lifecycle=_to_tuple(data["lifecycle"], "lifecycle"),
|
||
compatibility=compatibility_raw,
|
||
failure_policy=failure_policy,
|
||
resource_quota=resource_quota,
|
||
accessible_ports=_to_tuple(data.get("accessible_ports", []), "accessible_ports"),
|
||
injectable_pipelines=_to_tuple(data.get("injectable_pipelines", []), "injectable_pipelines"),
|
||
skills=_to_tuple(data.get("skills", []), "skills"),
|
||
env_vars=env_vars,
|
||
critical=bool(data.get("critical", False)),
|
||
requires_dm_pairing=bool(data.get("requires_dm_pairing", True)),
|
||
requires_outbound_delivery=bool(data.get("requires_outbound_delivery", True)),
|
||
credential_strategy=credential_strategy,
|
||
max_message_length=max_message_length,
|
||
supports_credential_cloning=bool(data.get("supports_credential_cloning", False)),
|
||
icon=data.get("icon"),
|
||
transport_mode=transport_mode,
|
||
max_restart_attempts=max_restart_attempts,
|
||
)
|
||
|
||
|
||
def _parse_capability_requirements(
|
||
data: list[Any] | None,
|
||
valid_fields: set[str],
|
||
) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
||
"""解析能力运行时依赖声明。
|
||
|
||
每项格式为 ``[能力字段名, [依赖配置键清单]]``,校验能力字段名必须是
|
||
``ChannelCapabilities`` 的合法字段,依赖键必须是字符串数组。
|
||
非法结构抛 ``ValidationError`` 并保留字段名上下文(INV-7)。
|
||
"""
|
||
if data is None:
|
||
return ()
|
||
if not isinstance(data, list):
|
||
raise ValidationError(
|
||
field="capability_requirements",
|
||
message="capability_requirements 必须为数组",
|
||
)
|
||
result: list[tuple[str, tuple[str, ...]]] = []
|
||
for idx, item in enumerate(data):
|
||
if not isinstance(item, (list, tuple)) or len(item) != 2:
|
||
raise ValidationError(
|
||
field="capability_requirements",
|
||
message=f"第 {idx} 项必须为 [capability, [keys]] 结构: {item!r}",
|
||
)
|
||
field_name, keys = item
|
||
if field_name not in valid_fields:
|
||
raise ValidationError(
|
||
field="capability_requirements",
|
||
message=f"第 {idx} 项引用未知能力字段: {field_name}",
|
||
)
|
||
if not isinstance(keys, list) or not all(isinstance(k, str) for k in keys):
|
||
raise ValidationError(
|
||
field="capability_requirements",
|
||
message=f"能力 {field_name} 的依赖项必须为字符串数组: {keys!r}",
|
||
)
|
||
result.append((field_name, tuple(keys)))
|
||
return tuple(result)
|
||
|
||
|
||
def _parse_capabilities(
|
||
data: dict[str, Any] | None,
|
||
) -> ChannelCapabilities | None:
|
||
"""解析能力集合 JSON 为 ``ChannelCapabilities``。
|
||
|
||
完整映射 ``ChannelCapabilities`` 的 26 个 bool 能力声明字段
|
||
(基础消息能力 + 消息操作子能力 + 媒体能力 + 扩展能力 + 生命周期
|
||
与协作能力),未声明的字段默认 ``False``。``data`` 为 ``None`` 时
|
||
返回 ``None``。
|
||
"""
|
||
if data is None:
|
||
return None
|
||
return ChannelCapabilities(
|
||
rich_message=data.get("rich_message", False),
|
||
streaming=data.get("streaming", False),
|
||
typing_indicator=data.get("typing_indicator", False),
|
||
message_edit=data.get("message_edit", False),
|
||
message_recall=data.get("message_recall", False),
|
||
supports_reaction=data.get("supports_reaction", False),
|
||
supports_pin=data.get("supports_pin", False),
|
||
supports_card_update=data.get("supports_card_update", False),
|
||
supports_card_update_streaming=data.get("supports_card_update_streaming", False),
|
||
supports_image_inbound=data.get("supports_image_inbound", False),
|
||
supports_video_inbound=data.get("supports_video_inbound", False),
|
||
supports_image_outbound=data.get("supports_image_outbound", False),
|
||
supports_video_outbound=data.get("supports_video_outbound", False),
|
||
mention=data.get("mention", False),
|
||
command=data.get("command", False),
|
||
directory=data.get("directory", False),
|
||
doctor=data.get("doctor", False),
|
||
whitelist=data.get("whitelist", False),
|
||
wizard=data.get("wizard", False),
|
||
tools=data.get("tools", False),
|
||
status=data.get("status", False),
|
||
probeable=data.get("probeable", False),
|
||
identity_resolver=data.get("identity_resolver", False),
|
||
lifecycle=data.get("lifecycle", False),
|
||
supports_qr_login=data.get("supports_qr_login", False),
|
||
agent_collaboration=data.get("agent_collaboration", False),
|
||
)
|
||
|
||
|
||
def _parse_config_field(data: dict[str, Any]) -> ConfigField:
|
||
"""解析配置字段 JSON 为 ``ConfigField``。
|
||
|
||
解析 ``sensitive`` 顶层字段(F-01),缺失时默认 ``False``(向后兼容)。
|
||
旧声明 ``constraints={"sensitive": true}`` 不再被消费,应迁移至顶层
|
||
``"sensitive": true`` 字段(与 ``ConfigField.sensitive`` 对齐)。
|
||
|
||
解析 ``scope`` 顶层字段(F-02),缺失或为 ``None`` 时默认 ``None``(向后
|
||
兼容)。值为字符串时通过 ``ConfigScope`` 枚举转换,非法值抛
|
||
``ValidationError`` 保留原始错误上下文(INV-7)。
|
||
"""
|
||
scope_raw = data.get("scope")
|
||
scope: ConfigScope | None = None
|
||
if scope_raw is not None:
|
||
try:
|
||
scope = ConfigScope(scope_raw)
|
||
except ValueError as e:
|
||
raise ValidationError(
|
||
field="scope",
|
||
message=f"未知的配置作用域: {scope_raw}",
|
||
) from e
|
||
return ConfigField(
|
||
key=data["key"],
|
||
type=data["type"],
|
||
required=data.get("required", False),
|
||
default=data.get("default"),
|
||
hot_reloadable=data.get("hot_reloadable", True),
|
||
sensitive=bool(data.get("sensitive", False)),
|
||
scope=scope,
|
||
constraints=data.get("constraints"),
|
||
)
|
||
|
||
|
||
def _parse_dependency(data: dict[str, Any]) -> PluginDependency:
|
||
"""解析插件依赖 JSON 为 ``PluginDependency``。"""
|
||
return PluginDependency(
|
||
plugin_id=data["plugin_id"],
|
||
version_range=data["version_range"],
|
||
)
|
||
|
||
|
||
def _parse_resource_quota(
|
||
data: dict[str, Any] | None,
|
||
) -> ResourceQuota | None:
|
||
"""解析资源配额 JSON 为 ``ResourceQuota``。"""
|
||
if data is None:
|
||
return None
|
||
return ResourceQuota(
|
||
max_cpu=data.get("max_cpu"),
|
||
max_memory=data.get("max_memory"),
|
||
max_connections=data.get("max_connections"),
|
||
max_calls_per_sec=data.get("max_calls_per_sec"),
|
||
)
|
||
|
||
|
||
def _parse_env_var(data: dict[str, Any]) -> EnvVar:
|
||
"""解析环境变量 JSON 为 ``EnvVar``。"""
|
||
return EnvVar(
|
||
name=data["name"],
|
||
description=data.get("description", ""),
|
||
required=data.get("required", True),
|
||
default=data.get("default"),
|
||
sensitive=data.get("sensitive", False),
|
||
)
|
||
|
||
|
||
def _to_tuple(value: Any, field_name: str) -> tuple:
|
||
"""将清单数组字段转换为 tuple,校验 list 类型。
|
||
|
||
若 ``value`` 非 list,抛 ``ValidationError``,避免字符串被静默拆分为
|
||
字符元组(如 ``tuple("init")`` → ``('i','n','i','t')``)导致下游
|
||
``verifyLifecycleConsistency`` 报出困惑错误。
|
||
|
||
参数:
|
||
value: manifest 中的字段值。
|
||
field_name: 字段名,用于错误信息定位。
|
||
|
||
返回:
|
||
转换后的 tuple。
|
||
|
||
抛出:
|
||
ValidationError: ``value`` 非 list 类型。
|
||
"""
|
||
if not isinstance(value, list):
|
||
raise ValidationError(
|
||
field=field_name,
|
||
message=(f"{field_name} 必须为数组,实际类型为 {type(value).__name__}: {value!r}"),
|
||
)
|
||
return tuple(value)
|