Skip to content

Commit ad9c78f

Browse files
committed
给依靠插件id命名逃避可能的插件冲突
2 parents 6056d83 + 69305c1 commit ad9c78f

9 files changed

Lines changed: 137 additions & 65 deletions

File tree

astrbot-sdk/src/astrbot_sdk/_internal/plugin_ids.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,26 @@ def validate_plugin_id(plugin_id: str) -> str:
4848
return normalized
4949

5050

51+
def plugin_capability_prefix(plugin_id: str) -> str:
52+
return f"{validate_plugin_id(plugin_id)}."
53+
54+
55+
def capability_belongs_to_plugin(capability_name: str, plugin_id: str) -> bool:
56+
return str(capability_name).strip().startswith(plugin_capability_prefix(plugin_id))
57+
58+
59+
def plugin_http_route_root(plugin_id: str) -> str:
60+
return f"/{validate_plugin_id(plugin_id)}"
61+
62+
63+
def http_route_belongs_to_plugin(route: str, plugin_id: str) -> bool:
64+
normalized_route = str(route).strip()
65+
route_root = plugin_http_route_root(plugin_id)
66+
return normalized_route == route_root or normalized_route.startswith(
67+
f"{route_root}/"
68+
)
69+
70+
5171
def resolve_plugin_data_dir(root: Path, plugin_id: str) -> Path:
5272
normalized = validate_plugin_id(plugin_id)
5373
resolved_root = root.resolve()

astrbot-sdk/src/astrbot_sdk/clients/http.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
# 插件声明处理 HTTP 请求的 capability
2020
@provide_capability(
2121
name="my_plugin.http_handler",
22-
description="处理 /my-api 的 HTTP 请求",
22+
description="处理 /my_plugin/api 的 HTTP 请求",
2323
input_schema={...},
2424
output_schema={...}
2525
)
@@ -28,7 +28,7 @@ async def handle_http_request(request_id: str, payload: dict, cancel_token):
2828
2929
# 注册路由 → capability 映射
3030
await ctx.http.register_api(
31-
route="/my-api",
31+
route="/my_plugin/api",
3232
methods=["GET", "POST"],
3333
handler_capability="my_plugin.http_handler",
3434
description="我的 API"
@@ -99,15 +99,15 @@ async def register_api(
9999
"""注册 Web API 端点。
100100
101101
Args:
102-
route: API 路由路径( "/my-api")
102+
route: API 路由路径(必须使用 "/{plugin_id}" 或 "/{plugin_id}/...")
103103
handler_capability: 处理此路由的 capability 名称
104104
handler: 使用 @provide_capability 标记的方法引用
105105
methods: HTTP 方法列表,默认 ["GET"]
106106
description: API 描述
107107
108108
示例:
109109
await ctx.http.register_api(
110-
route="/my-api",
110+
route="/my_plugin/api",
111111
handler_capability="my_plugin.http_handler",
112112
methods=["GET", "POST"],
113113
description="我的 API"
@@ -137,7 +137,7 @@ async def unregister_api(
137137
methods: HTTP 方法列表,None 表示所有方法
138138
139139
示例:
140-
await ctx.http.unregister_api("/my-api")
140+
await ctx.http.unregister_api("/my_plugin/api")
141141
"""
142142
if methods is None:
143143
methods = []

astrbot-sdk/src/astrbot_sdk/decorators.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1079,7 +1079,7 @@ def provide_capability(
10791079
支持使用 JSON Schema 或 pydantic 模型定义输入输出。
10801080
10811081
Args:
1082-
name: capability 名称(不能使用保留命名空间)
1082+
name: capability 名称(不能使用保留命名空间,且运行时必须以当前 plugin_id 为前缀
10831083
description: 能力描述
10841084
input_schema: 输入 JSON Schema
10851085
output_schema: 输出 JSON Schema

astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/http.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
import re
44
from typing import Any
55

6+
from ...._internal.plugin_ids import (
7+
capability_belongs_to_plugin,
8+
http_route_belongs_to_plugin,
9+
plugin_capability_prefix,
10+
plugin_http_route_root,
11+
)
612
from ....errors import AstrBotError
713
from ..bridge_base import CapabilityRouterBridgeBase
814

@@ -32,6 +38,31 @@ def _validate_route(route: str, capability_name: str) -> None:
3238
)
3339

3440

41+
def _validate_plugin_route_namespace(route: str, plugin_id: str) -> None:
42+
if http_route_belongs_to_plugin(route, plugin_id):
43+
return
44+
route_root = plugin_http_route_root(plugin_id)
45+
raise AstrBotError.invalid_input(
46+
"http.register_api 要求 route 使用当前插件的公开命名空间前缀:"
47+
f" route={route!r}, plugin_id={plugin_id!r}, expected={route_root!r} "
48+
f"或 {route_root + '/...'}"
49+
)
50+
51+
52+
def _validate_handler_capability_namespace(
53+
handler_capability: str,
54+
plugin_id: str,
55+
) -> None:
56+
if capability_belongs_to_plugin(handler_capability, plugin_id):
57+
return
58+
expected_prefix = plugin_capability_prefix(plugin_id)
59+
raise AstrBotError.invalid_input(
60+
"http.register_api 要求 handler_capability 属于当前插件:"
61+
f" capability={handler_capability!r}, plugin_id={plugin_id!r}, "
62+
f"expected_prefix={expected_prefix!r}"
63+
)
64+
65+
3566
class HttpCapabilityMixin(CapabilityRouterBridgeBase):
3667
async def _http_register_api(
3768
self, _request_id: str, payload: dict[str, Any], _token
@@ -51,6 +82,8 @@ async def _http_register_api(
5182
)
5283
_validate_route(route, "http.register_api")
5384
plugin_name = self._require_caller_plugin_id("http.register_api")
85+
_validate_plugin_route_namespace(route, plugin_name)
86+
_validate_handler_capability_namespace(handler_capability, plugin_name)
5487
methods = sorted({method.upper() for method in methods_payload if method})
5588
entry: dict[str, Any] = {
5689
"route": route,

astrbot-sdk/src/astrbot_sdk/runtime/environment_groups.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
这个模块负责“多个插件,共享较少数量 Python 环境”的策略。核心约束是:
44
55
- 插件仍然独立发现、独立加载
6-
- Worker 进程仍然保持一插件一进程
6+
- Worker 运行时既可以是一插件一进程,也可以由 GroupWorkerRuntime 在同一进程承载多个插件
77
- 只有在依赖兼容时才共享 Python 环境
88
99
整体流程如下:

astrbot-sdk/src/astrbot_sdk/runtime/loader.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@
7070

7171
from .._internal.command_model import resolve_command_model_param
7272
from .._internal.injected_params import is_framework_injected_parameter
73-
from .._internal.plugin_ids import validate_plugin_id
73+
from .._internal.plugin_ids import (
74+
capability_belongs_to_plugin,
75+
plugin_capability_prefix,
76+
validate_plugin_id,
77+
)
7478
from .._internal.typing_utils import unwrap_optional
7579
from ..decorators import (
7680
ConversationMeta,
@@ -223,6 +227,37 @@ def _iter_discoverable_names(instance: Any) -> list[str]:
223227
return [*handler_names, *extra_names]
224228

225229

230+
def _validate_loaded_capability_namespace(
231+
plugin: PluginSpec,
232+
*,
233+
resolved_component: _ResolvedComponent,
234+
attribute_name: str,
235+
capability_name: str,
236+
) -> None:
237+
if capability_belongs_to_plugin(capability_name, plugin.name):
238+
return
239+
expected_prefix = plugin_capability_prefix(plugin.name)
240+
raise ValueError(
241+
f"{_component_context(plugin, class_path=resolved_component.class_path, index=resolved_component.index)} "
242+
f"方法 {attribute_name!r} 导出的 capability {capability_name!r} 必须使用当前插件名前缀 "
243+
f"{expected_prefix!r},例如 {expected_prefix}<action>"
244+
)
245+
246+
247+
def _register_loaded_capability_name(
248+
seen_capability_sources: dict[str, str],
249+
*,
250+
capability_name: str,
251+
source_ref: str,
252+
) -> None:
253+
existing_source = seen_capability_sources.get(capability_name)
254+
if existing_source is not None:
255+
raise ValueError(
256+
f"capability {capability_name!r} 重复定义:{existing_source}{source_ref}"
257+
)
258+
seen_capability_sources[capability_name] = source_ref
259+
260+
226261
def _is_injected_parameter(annotation: Any, parameter_name: str) -> bool:
227262
return is_framework_injected_parameter(parameter_name, annotation)
228263

@@ -890,6 +925,7 @@ def load_plugin(plugin: PluginSpec) -> LoadedPlugin:
890925
llm_tools: list[LoadedLLMTool] = []
891926
agents: list[LoadedAgent] = []
892927
seen_agents: set[str] = set()
928+
seen_capability_sources: dict[str, str] = {}
893929

894930
for resolved_component in _plugin_component_classes(plugin):
895931
component_cls = resolved_component.cls
@@ -930,6 +966,18 @@ def load_plugin(plugin: PluginSpec) -> LoadedPlugin:
930966
continue
931967
if capability is not None:
932968
bound, meta = capability
969+
capability_name = meta.descriptor.name
970+
_validate_loaded_capability_namespace(
971+
plugin,
972+
resolved_component=resolved_component,
973+
attribute_name=name,
974+
capability_name=capability_name,
975+
)
976+
_register_loaded_capability_name(
977+
seen_capability_sources,
978+
capability_name=capability_name,
979+
source_ref=f"{resolved_component.class_path}.{name}",
980+
)
933981
capabilities.append(
934982
LoadedCapability(
935983
descriptor=meta.descriptor.model_copy(deep=True),

astrbot-sdk/src/astrbot_sdk/runtime/supervisor.py

Lines changed: 23 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88
|
99
+-- WorkerSession (插件 A) -- StdioTransport -- PluginWorkerRuntime (子进程)
1010
|
11-
+-- WorkerSession (插件 B) -- StdioTransport -- PluginWorkerRuntime (子进程)
11+
+-- WorkerSession (插件 B, 插件 C) -- StdioTransport -- GroupWorkerRuntime (子进程)
1212
|
13-
+-- WorkerSession (插件 C) -- StdioTransport -- PluginWorkerRuntime (子进程)
13+
+-- WorkerSession (插件 D) -- StdioTransport -- PluginWorkerRuntime (子进程)
1414
1515
核心类:
1616
SupervisorRuntime: 监管者运行时
1717
- 发现并加载所有插件
18-
- 为每个插件启动 Worker 进程
18+
- 为单个插件或兼容插件组启动 Worker 进程
1919
- 聚合所有 handler 并向 Core 注册
2020
- 路由 Core 的调用请求到对应 Worker
2121
- 处理 Worker 进程崩溃和重连
@@ -45,6 +45,10 @@
4545

4646
from loguru import logger
4747

48+
from .._internal.plugin_ids import (
49+
capability_belongs_to_plugin,
50+
plugin_capability_prefix,
51+
)
4852
from ..errors import AstrBotError
4953
from ..protocol.descriptors import CapabilityDescriptor
5054
from ..protocol.messages import EventMessage, InitializeOutput, PeerInfo
@@ -533,60 +537,25 @@ def _register_plugin_capability(
533537
session: WorkerSession,
534538
plugin_name: str,
535539
) -> None:
536-
"""注册插件 capability,处理命名冲突。
537-
538-
当 capability 名称冲突时:
539-
- 如果是保留命名空间(handler/system/internal),跳过并警告
540-
- 否则,使用插件名作为前缀重新命名,例如:
541-
- 插件 'my_plugin' 注册 'demo.echo' 冲突
542-
- 自动重命名为 'my_plugin.demo.echo'
543-
"""
540+
"""注册插件 capability。"""
544541
capability_name = descriptor.name
545-
546-
if not self.capability_router.contains(capability_name):
547-
# 无冲突,直接注册
548-
self._do_register_capability(
549-
descriptor, session, capability_name, plugin_name
550-
)
551-
return
552-
553-
# 检查是否在保留命名空间内
554-
if capability_name.startswith(("handler.", "system.", "internal.")):
555-
logger.warning(
556-
"Capability '{}' 在保留命名空间内,跳过插件 '{}' 的注册。"
557-
"保留命名空间不允许插件覆盖。",
558-
capability_name,
559-
plugin_name,
542+
if not capability_belongs_to_plugin(capability_name, plugin_name):
543+
expected_prefix = plugin_capability_prefix(plugin_name)
544+
raise ValueError(
545+
"插件导出的 capability 必须使用 plugin_id 作为公开命名空间前缀:"
546+
f" plugin={plugin_name!r}, capability={capability_name!r}, "
547+
f"expected_prefix={expected_prefix!r}"
560548
)
561-
return
562-
563-
# 尝试添加插件前缀解决冲突
564-
prefixed_name = f"{plugin_name}.{capability_name}"
565-
if self.capability_router.contains(prefixed_name):
566-
logger.warning(
567-
"Capability '{}' 和 '{}.{}' 均已存在,"
568-
"跳过插件 '{}' 的注册。请考虑使用更唯一的命名。",
569-
capability_name,
570-
plugin_name,
571-
capability_name,
572-
plugin_name,
549+
# Worker 侧 loader 已经做过命名空间校验;这里若还能撞名,说明协议数据
550+
# 与本地路由状态不一致,继续静默改名只会掩盖问题。
551+
if self.capability_router.contains(capability_name):
552+
existing_plugin = self._capability_sources.get(capability_name, "<unknown>")
553+
raise RuntimeError(
554+
"duplicate capability registration detected after worker load validation: "
555+
f"{capability_name!r} already registered by {existing_plugin!r}, "
556+
f"cannot register again for {plugin_name!r}"
573557
)
574-
return
575-
576-
# 使用前缀名称注册
577-
prefixed_descriptor = descriptor.model_copy(deep=True)
578-
prefixed_descriptor.name = prefixed_name
579-
logger.info(
580-
"Capability '{}' 与已注册能力冲突,自动重命名为 '{}' (插件: {})。",
581-
capability_name,
582-
prefixed_name,
583-
plugin_name,
584-
)
585-
self._do_register_capability(
586-
prefixed_descriptor, session, prefixed_name, plugin_name
587-
)
588-
# 记录原始名称到前缀名称的映射,便于调试
589-
self._capability_sources[f"_original:{prefixed_name}"] = capability_name
558+
self._do_register_capability(descriptor, session, capability_name, plugin_name)
590559

591560
def _do_register_capability(
592561
self,

astrbot-sdk/src/astrbot_sdk/templates/project_notes/AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@
77
- Validate the generated plugin with `astrbot-sdk validate --plugin-dir .` before packaging or sharing it.
88
- Run `python -m pytest tests/test_plugin.py -v` after changing plugin behavior so the sample harness contract stays honest.
99
- `astrbot-sdk build --plugin-dir .` should create the release zip without development-only files such as `AGENTS.md`, `CLAUDE.md`, `.claude/`, `.agents/`, or `.opencode/`.
10+
- Exported capabilities should use `<plugin_id>.<action>`, and HTTP routes should use `/{plugin_id}` or `/{plugin_id}/...` so the plugin stays collision-safe inside `GroupWorkerRuntime`.
1011

11-
- 除非有充分理由,插件的直接依赖应声明已验证的最低兼容版本。若已知存在不兼容的大版本或问题版本,应同时补充上界或排除约束
12+
- 除非有充分理由,插件的直接依赖应声明已验证的最低兼容版本。若已知存在不兼容的大版本或问题版本,应同时补充上界或排除约束
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# AGENTS.md
1+
# CLAUDE.md
22

33
## AstrBot Plugin Notes
44

@@ -7,5 +7,6 @@
77
- Validate the generated plugin with `astrbot-sdk validate --plugin-dir .` before packaging or sharing it.
88
- Run `python -m pytest tests/test_plugin.py -v` after changing plugin behavior so the sample harness contract stays honest.
99
- `astrbot-sdk build --plugin-dir .` should create the release zip without development-only files such as `AGENTS.md`, `CLAUDE.md`, `.claude/`, `.agents/`, or `.opencode/`.
10+
- Exported capabilities should use `<plugin_id>.<action>`, and HTTP routes should use `/{plugin_id}` or `/{plugin_id}/...` so the plugin stays collision-safe inside `GroupWorkerRuntime`.
1011

11-
- 除非有充分理由,插件的直接依赖应声明已验证的最低兼容版本。若已知存在不兼容的大版本或问题版本,应同时补充上界或排除约束
12+
- 除非有充分理由,插件的直接依赖应声明已验证的最低兼容版本。若已知存在不兼容的大版本或问题版本,应同时补充上界或排除约束

0 commit comments

Comments
 (0)