Skip to content

Commit 13e7c3d

Browse files
authored
refactor(plugin): split built-in and user plugin roots (#444)
* refactor(plugin): split built-in and user plugin roots Treat plugin.plugins as built-in packaged plugins and move user plugin discovery to the ConfigManager documents plugins directory. This avoids packaged namespace collisions while keeping plugin loading, config resolution, and extension injection working across both roots. Made-with: Cursor * fix(plugin): preserve legacy root semantics and skip builtin sys.path injection Avoid re-exposing built-in plugins through top-level import roots in packaged builds, and restore the old single-root compatibility behavior for legacy callers while steering new code toward explicit multi-root settings. Made-with: Cursor
1 parent 1271a54 commit 13e7c3d

16 files changed

Lines changed: 359 additions & 205 deletions

File tree

plugin/config/service.py

Lines changed: 28 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from fastapi import HTTPException
2121

22-
from plugin.settings import PLUGIN_CONFIG_ROOT
22+
from plugin.settings import PLUGIN_CONFIG_ROOTS
2323

2424
# Schema 验证开关(可通过环境变量禁用)
2525
_SCHEMA_VALIDATION_ENABLED = os.getenv("NEKO_CONFIG_SCHEMA_VALIDATION", "true").lower() in ("true", "1", "yes", "on")
@@ -147,44 +147,33 @@ def get_plugin_config_path(plugin_id: str) -> Path:
147147
detail=f"Invalid plugin_id: '{plugin_id}'. Only alphanumeric characters, underscores, and hyphens are allowed."
148148
)
149149

150-
# 构建配置文件路径
151-
config_file = PLUGIN_CONFIG_ROOT / plugin_id / "plugin.toml"
152-
153-
# 解析路径并验证它在安全目录内(防止路径遍历攻击)
154-
try:
155-
resolved_path = config_file.resolve()
156-
# Python 3.9+ 支持 is_relative_to
157-
if hasattr(resolved_path, 'is_relative_to'):
158-
if not resolved_path.is_relative_to(PLUGIN_CONFIG_ROOT.resolve()):
159-
raise HTTPException(
160-
status_code=400,
161-
detail=f"Invalid plugin_id: '{plugin_id}'. Path traversal detected."
162-
)
163-
else:
164-
# Python 3.8 兼容:使用 str.startswith 检查
165-
root_resolved = PLUGIN_CONFIG_ROOT.resolve()
166-
resolved_str = str(resolved_path)
167-
root_str = str(root_resolved)
168-
if not resolved_str.startswith(root_str):
169-
raise HTTPException(
170-
status_code=400,
171-
detail=f"Invalid plugin_id: '{plugin_id}'. Path traversal detected."
172-
)
173-
except (OSError, ValueError) as e:
174-
# 路径解析失败(例如包含无效字符)
175-
raise HTTPException(
176-
status_code=400,
177-
detail=f"Invalid plugin_id: '{plugin_id}'. {str(e)}"
178-
) from e
179-
180-
# 检查文件是否存在
181-
if not config_file.exists():
182-
raise HTTPException(
183-
status_code=404,
184-
detail=f"Plugin '{plugin_id}' configuration not found"
185-
)
186-
187-
return config_file
150+
for root in PLUGIN_CONFIG_ROOTS:
151+
config_file = root / plugin_id / "plugin.toml"
152+
153+
try:
154+
resolved_path = config_file.resolve()
155+
if hasattr(resolved_path, 'is_relative_to'):
156+
if not resolved_path.is_relative_to(root.resolve()):
157+
continue
158+
else:
159+
root_resolved = root.resolve()
160+
resolved_str = str(resolved_path)
161+
root_str = str(root_resolved)
162+
if not resolved_str.startswith(root_str):
163+
continue
164+
except (OSError, ValueError) as e:
165+
raise HTTPException(
166+
status_code=400,
167+
detail=f"Invalid plugin_id: '{plugin_id}'. {str(e)}"
168+
) from e
169+
170+
if config_file.exists():
171+
return config_file
172+
173+
raise HTTPException(
174+
status_code=404,
175+
detail=f"Plugin '{plugin_id}' configuration not found"
176+
)
188177

189178

190179
def load_plugin_config(plugin_id: str, *, validate: bool = True) -> Dict[str, Any]:

plugin/core/host.py

Lines changed: 85 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -110,100 +110,106 @@ def _inject_extensions(
110110
logger.debug("[Extension] tomllib/tomli not available, skipping extension injection")
111111
return
112112

113-
# 优先使用 settings 中的 PLUGIN_CONFIG_ROOT,回退到路径推导
113+
# 优先使用 settings 中的插件根目录集合,回退到路径推导
114114
try:
115-
from plugin.settings import PLUGIN_CONFIG_ROOT
116-
plugin_config_root = PLUGIN_CONFIG_ROOT
115+
from plugin.settings import PLUGIN_CONFIG_ROOTS
116+
plugin_config_roots = tuple(PLUGIN_CONFIG_ROOTS)
117117
except Exception:
118-
plugin_config_root = host_config_path.parent.parent
119-
120-
try:
121-
if not plugin_config_root.exists():
122-
return
123-
except Exception:
124-
return
118+
plugin_config_roots = (host_config_path.parent.parent,)
125119

126120
injected_count = 0
127-
for toml_path in plugin_config_root.glob("*/plugin.toml"):
121+
for plugin_config_root in plugin_config_roots:
128122
try:
129-
with toml_path.open("rb") as f:
130-
conf = tomllib.load(f)
131-
pdata = conf.get("plugin") or {}
123+
root = plugin_config_root.resolve()
124+
except Exception:
125+
root = plugin_config_root
132126

133-
# 只处理 type=extension
134-
if pdata.get("type") != "extension":
127+
try:
128+
if not root.exists():
135129
continue
130+
except Exception:
131+
continue
136132

137-
# 检查宿主匹配
138-
host_conf = pdata.get("host")
139-
if not isinstance(host_conf, dict):
140-
continue
141-
if host_conf.get("plugin_id") != host_plugin_id:
142-
continue
133+
for toml_path in root.glob("*/plugin.toml"):
134+
try:
135+
with toml_path.open("rb") as f:
136+
conf = tomllib.load(f)
137+
pdata = conf.get("plugin") or {}
143138

144-
# 检查 enabled
145-
runtime_cfg = conf.get("plugin_runtime")
146-
if isinstance(runtime_cfg, dict):
147-
from plugin.utils import parse_bool_config
148-
if not parse_bool_config(runtime_cfg.get("enabled"), default=True):
149-
logger.debug(
150-
"[Extension] Extension '{}' is disabled, skipping",
151-
pdata.get("id", "?"),
152-
)
139+
# 只处理 type=extension
140+
if pdata.get("type") != "extension":
153141
continue
154142

155-
ext_id = pdata.get("id", "unknown")
156-
ext_entry = pdata.get("entry")
157-
if not ext_entry or ":" not in ext_entry:
158-
logger.warning(
159-
"[Extension] Extension '{}' has invalid entry '{}', skipping",
160-
ext_id, ext_entry,
161-
)
162-
continue
143+
# 检查宿主匹配
144+
host_conf = pdata.get("host")
145+
if not isinstance(host_conf, dict):
146+
continue
147+
if host_conf.get("plugin_id") != host_plugin_id:
148+
continue
163149

164-
# 导入 Extension Router 类
165-
module_path, class_name = ext_entry.split(":", 1)
166-
try:
167-
mod = importlib.import_module(module_path)
168-
router_cls = getattr(mod, class_name)
169-
except (ImportError, ModuleNotFoundError) as e:
170-
logger.warning(
171-
"[Extension] Failed to import extension '{}' ({}): {}",
172-
ext_id, ext_entry, e,
173-
)
174-
continue
175-
except AttributeError as e:
176-
logger.warning(
177-
"[Extension] Class '{}' not found in module '{}' for extension '{}': {}",
178-
class_name, module_path, ext_id, e,
179-
)
180-
continue
150+
# 检查 enabled
151+
runtime_cfg = conf.get("plugin_runtime")
152+
if isinstance(runtime_cfg, dict):
153+
from plugin.utils import parse_bool_config
154+
if not parse_bool_config(runtime_cfg.get("enabled"), default=True):
155+
logger.debug(
156+
"[Extension] Extension '{}' is disabled, skipping",
157+
pdata.get("id", "?"),
158+
)
159+
continue
160+
161+
ext_id = pdata.get("id", "unknown")
162+
ext_entry = pdata.get("entry")
163+
if not ext_entry or ":" not in ext_entry:
164+
logger.warning(
165+
"[Extension] Extension '{}' has invalid entry '{}', skipping",
166+
ext_id, ext_entry,
167+
)
168+
continue
181169

182-
# 验证是 PluginRouter 子类
183-
if not (isinstance(router_cls, type) and issubclass(router_cls, PluginRouter)):
184-
logger.warning(
185-
"[Extension] Extension '{}' entry class '{}' is not a PluginRouter subclass, skipping",
186-
ext_id, class_name,
187-
)
188-
continue
170+
# 导入 Extension Router 类
171+
module_path, class_name = ext_entry.split(":", 1)
172+
try:
173+
mod = importlib.import_module(module_path)
174+
router_cls = getattr(mod, class_name)
175+
except (ImportError, ModuleNotFoundError) as e:
176+
logger.warning(
177+
"[Extension] Failed to import extension '{}' ({}): {}",
178+
ext_id, ext_entry, e,
179+
)
180+
continue
181+
except AttributeError as e:
182+
logger.warning(
183+
"[Extension] Class '{}' not found in module '{}' for extension '{}': {}",
184+
class_name, module_path, ext_id, e,
185+
)
186+
continue
189187

190-
# 实例化并注入
191-
prefix = host_conf.get("prefix", "")
192-
try:
193-
router_instance = router_cls(prefix=prefix, name=ext_id)
194-
instance.include_router(router_instance)
195-
injected_count += 1
196-
logger.info(
197-
"[Extension] Injected extension '{}' into host '{}' with prefix '{}'",
198-
ext_id, host_plugin_id, prefix,
199-
)
188+
# 验证是 PluginRouter 子类
189+
if not (isinstance(router_cls, type) and issubclass(router_cls, PluginRouter)):
190+
logger.warning(
191+
"[Extension] Extension '{}' entry class '{}' is not a PluginRouter subclass, skipping",
192+
ext_id, class_name,
193+
)
194+
continue
195+
196+
# 实例化并注入
197+
prefix = host_conf.get("prefix", "")
198+
try:
199+
router_instance = router_cls(prefix=prefix, name=ext_id)
200+
instance.include_router(router_instance)
201+
injected_count += 1
202+
logger.info(
203+
"[Extension] Injected extension '{}' into host '{}' with prefix '{}'",
204+
ext_id, host_plugin_id, prefix,
205+
)
206+
except Exception as e:
207+
logger.warning(
208+
"[Extension] Failed to inject extension '{}' into host '{}': {}",
209+
ext_id, host_plugin_id, e,
210+
)
200211
except Exception as e:
201-
logger.warning(
202-
"[Extension] Failed to inject extension '{}' into host '{}': {}",
203-
ext_id, host_plugin_id, e,
204-
)
205-
except Exception as e:
206-
logger.debug("[Extension] Error processing {}: {}", toml_path, e)
212+
logger.debug("[Extension] Error processing {}: {}", toml_path, e)
207213

208214
if injected_count > 0:
209215
logger.info(

0 commit comments

Comments
 (0)