mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 21:07:01 +08:00
fix(agent): route agent command receipts to the signed channel user (#7228)
- Agent 代渠道用户调用内部 API 时,把会话渠道与渠道用户写入宿主签发令牌的签名 声明(agent_channel、agent_channel_user),不再新增可伪造的请求头。 - /commands/run 对 Telegram 等通知渠道只使用与请求渠道一致的签名渠道用户执行命令 和投递回执;缺少该声明时明确拒绝,不再把 MoviePilot 用户表主键当作渠道 chat id。 Web、WebAgent 与无渠道调用仍按 MoviePilot 用户 ID 处理。 - 只读公共资料借用宿主身份,不携带渠道发起人声明。 - 同步依赖基线与架构总览中的内部导入边数量。 Refs #7225 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: jxxghp <jxxghp@gmail.com>
This commit is contained in:
@@ -52,6 +52,9 @@ class ApiExecutionContext:
|
||||
:param channel: 调用所属消息渠道;直接 API 调用可为 None
|
||||
:param source: 渠道内的消息来源标识;没有来源时为 None
|
||||
:param public_read_only: 是否仅允许使用宿主身份查询公共资料;不能复用于其他操作
|
||||
:param channel_user_id: 发起会话的用户在消息渠道内的原始标识,如 Telegram 用户 ID;
|
||||
与渠道一起写入宿主令牌的签名声明,供命令回执等需要回复原渠道的场景使用,
|
||||
不参与 API 用户鉴权;无渠道时为 None
|
||||
"""
|
||||
|
||||
user_id: str
|
||||
@@ -61,6 +64,7 @@ class ApiExecutionContext:
|
||||
channel: str | None = None
|
||||
source: str | None = None
|
||||
public_read_only: bool = False
|
||||
channel_user_id: str | None = None
|
||||
|
||||
|
||||
class MoviePilotApiExecutor:
|
||||
@@ -141,11 +145,22 @@ class MoviePilotApiExecutor:
|
||||
user_id = str(self._context.user_id or "")
|
||||
if not user_id.isdigit():
|
||||
raise ApiExecutionError("当前 Agent 身份没有可用于 API 鉴权的用户 ID")
|
||||
token = create_access_token(
|
||||
userid=int(user_id),
|
||||
username=self._context.username or user_id,
|
||||
super_user=self._context.is_admin,
|
||||
)
|
||||
username = self._context.username or user_id
|
||||
if self._context.channel and self._context.channel_user_id and not self._context.public_read_only:
|
||||
# 渠道发起人写入宿主签名声明而非请求头,外部调用者无法伪造命令回执和会话操作的目标。
|
||||
token = create_access_token(
|
||||
userid=int(user_id),
|
||||
username=username,
|
||||
super_user=self._context.is_admin,
|
||||
agent_channel=self._context.channel,
|
||||
agent_channel_user=self._context.channel_user_id,
|
||||
)
|
||||
else:
|
||||
token = create_access_token(
|
||||
userid=int(user_id),
|
||||
username=username,
|
||||
super_user=self._context.is_admin,
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
|
||||
@@ -396,6 +396,10 @@ class MoviePilotApiTool(MoviePilotTool):
|
||||
channel=self._channel,
|
||||
source=self._source,
|
||||
public_read_only=public_read_only,
|
||||
# 公共资料使用宿主身份只读查询,不绑定渠道发起人
|
||||
channel_user_id=(
|
||||
str(self._user_id) if self._channel and self._user_id and not public_read_only else None
|
||||
),
|
||||
)
|
||||
),
|
||||
is_admin,
|
||||
|
||||
@@ -20,6 +20,7 @@ from fastapi import (
|
||||
)
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from app.adapters.web.security.access import verify_token
|
||||
from app.agent.mcp import agent_mcp_manager
|
||||
from app.api.dependencies.agent import (
|
||||
get_agent_chat_persistence,
|
||||
@@ -61,6 +62,7 @@ from app.schemas.agent import AgentWebCommandInfo as _SchemaAgentWebCommandInfo
|
||||
from app.schemas.message import AgentWebChatRequest as _SchemaAgentWebChatRequest
|
||||
from app.schemas.message import AgentWebChoiceRequest as _SchemaAgentWebChoiceRequest
|
||||
from app.schemas.response import Response as _SchemaResponse
|
||||
from app.schemas.token import TokenPayload as _SchemaTokenPayload
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
router = ResponseAPIRouter()
|
||||
@@ -92,6 +94,33 @@ def _parse_agent_channel_header(value: Optional[str]) -> Optional[NotificationCh
|
||||
return None
|
||||
|
||||
|
||||
def _command_user_id(
|
||||
channel: Optional[NotificationChannel],
|
||||
token_data: _SchemaTokenPayload,
|
||||
current_user_id: object,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
确定命令执行与回执所属的用户标识。
|
||||
|
||||
Web 与 WebAgent 以 MoviePilot 用户 ID 标识会话。无渠道时不携带收件人,避免插件将
|
||||
内部主键发往其他渠道;通知路由可按已有管理员目标回退。Telegram 等通知渠道按渠道用户
|
||||
执行会话操作并投递回执,该标识只取宿主签发令牌的签名声明,不信任请求头。
|
||||
|
||||
:param channel: 宿主透传的消息渠道;直接 API 调用为 None
|
||||
:param token_data: 已验证签名的当前请求令牌载荷
|
||||
:param current_user_id: 当前认证用户的 MoviePilot 用户 ID
|
||||
:return: 命令事件使用的用户标识;无渠道时为 None
|
||||
:raises ValueError: 通知渠道调用缺少与该渠道一致的签名渠道用户声明
|
||||
"""
|
||||
if channel is None:
|
||||
return None
|
||||
if channel in (NotificationChannel.Web, NotificationChannel.WebAgent):
|
||||
return str(current_user_id)
|
||||
if token_data.agent_channel == channel.value and token_data.agent_channel_user:
|
||||
return token_data.agent_channel_user
|
||||
raise ValueError("无法确认命令发起人在该渠道的身份,请在对应渠道的 Agent 会话中执行命令")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/mcp/servers",
|
||||
summary="查询 Agent MCP 服务器配置",
|
||||
@@ -354,11 +383,21 @@ async def list_web_agent_commands(
|
||||
async def run_agent_command(
|
||||
payload: _SchemaAgentCommandRunRequest,
|
||||
current_user: ApiPrincipal = Depends(get_current_active_user),
|
||||
token_data: _SchemaTokenPayload = Depends(verify_token),
|
||||
agent_channel: Optional[str] = Header(None, alias="X-MoviePilot-Agent-Channel"),
|
||||
agent_source: Optional[str] = Header(None, alias="X-MoviePilot-Agent-Source"),
|
||||
) -> _SchemaResponse[Any]:
|
||||
"""
|
||||
以当前认证用户和宿主透传渠道触发已注册命令。
|
||||
|
||||
通知渠道的命令按令牌签名声明中的渠道用户执行和回执;没有渠道时不指定收件人。
|
||||
|
||||
:param payload: 包含命令及参数的请求正文
|
||||
:param current_user: 已认证的当前用户,必须为超级管理员
|
||||
:param token_data: 认证后验证过签名的令牌声明
|
||||
:param agent_channel: 宿主透传的渠道名称,可为 URL 编码的枚举值
|
||||
:param agent_source: 宿主透传的渠道实例名称,可为 URL 编码的文本
|
||||
:return: 命令触发结果;命令无效或渠道身份不可验证时返回失败信息
|
||||
"""
|
||||
_ensure_superuser(current_user)
|
||||
channel = _parse_agent_channel_header(agent_channel)
|
||||
@@ -366,7 +405,7 @@ async def run_agent_command(
|
||||
try:
|
||||
data = web_agent_application.dispatch_command(
|
||||
payload.command,
|
||||
user_id=str(current_user.id),
|
||||
user_id=_command_user_id(channel, token_data, current_user.id),
|
||||
channel=channel,
|
||||
source=agent_source,
|
||||
publish_event=eventmanager.send_event,
|
||||
|
||||
@@ -70,13 +70,21 @@ def init_commands(plugin_id: Optional[str] = None) -> None:
|
||||
def dispatch_command(
|
||||
command: str,
|
||||
*,
|
||||
user_id: str,
|
||||
user_id: Optional[str],
|
||||
channel: Optional[NotificationChannel] = None,
|
||||
source: Optional[str] = None,
|
||||
publish_event: Callable[[EventType, dict[str, Any]], Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
校验斜杠命令并通过注入的事件端口异步触发执行。
|
||||
|
||||
:param command: 包含可选参数的命令文本
|
||||
:param user_id: 渠道用户标识或 Web 会话用户标识;无真实目标时为 None
|
||||
:param channel: 命令所属渠道;无渠道时为 None
|
||||
:param source: 渠道来源实例;无来源时为 None
|
||||
:param publish_event: 注入的命令事件发布入口
|
||||
:return: 已触发命令的描述和可选插件标识
|
||||
:raises ValueError: 命令为空或未注册
|
||||
"""
|
||||
normalized = command.strip()
|
||||
if not normalized:
|
||||
|
||||
@@ -83,9 +83,25 @@ def create_access_token(
|
||||
expires_delta: Optional[timedelta] = None,
|
||||
level: Optional[int] = 1,
|
||||
purpose: Optional[str] = "authentication",
|
||||
agent_channel: Optional[str] = None,
|
||||
agent_channel_user: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
创建带身份、权限等级和用途声明的 JWT 访问令牌。
|
||||
|
||||
宿主 Agent 代渠道用户调用内部 API 时,可把发起会话的渠道及渠道用户标识写入签名声明;
|
||||
两项需同时提供且令牌用途为认证时才会写入,资源令牌不携带这组声明。
|
||||
|
||||
:param userid: 令牌主体的 MoviePilot 用户 ID
|
||||
:param username: 令牌主体的用户名
|
||||
:param super_user: 是否为超级用户
|
||||
:param expires_delta: 自定义有效期;None 时按用途使用默认有效期
|
||||
:param level: 权限级别
|
||||
:param purpose: 令牌用途,authentication 或 resource
|
||||
:param agent_channel: 宿主 Agent 会话所属的消息渠道
|
||||
:param agent_channel_user: 发起该会话的用户在渠道内的原始标识
|
||||
:return: 已签名的 JWT 字符串
|
||||
:raises ValueError: 自定义有效期不是正数
|
||||
"""
|
||||
config = _token_config()
|
||||
if purpose == "resource":
|
||||
@@ -114,6 +130,9 @@ def create_access_token(
|
||||
"level": level,
|
||||
"purpose": purpose,
|
||||
}
|
||||
if purpose == "authentication" and agent_channel and agent_channel_user:
|
||||
payload["agent_channel"] = agent_channel
|
||||
payload["agent_channel_user"] = agent_channel_user
|
||||
encoded: str = jwt.encode(payload, secret_key, algorithm=ALGORITHM)
|
||||
return encoded
|
||||
|
||||
|
||||
@@ -42,3 +42,11 @@ class TokenPayload(BaseModel):
|
||||
super_user: Optional[bool] = Field(default=None, description="超级用户")
|
||||
level: Optional[int] = Field(default=None, description="权限级别")
|
||||
purpose: Optional[str] = Field(default=None, description="令牌用途 authentication\\resource")
|
||||
agent_channel: Optional[str] = Field(
|
||||
default=None,
|
||||
description="宿主 Agent 签发令牌时绑定的消息渠道;仅宿主内部调用的令牌携带",
|
||||
)
|
||||
agent_channel_user: Optional[str] = Field(
|
||||
default=None,
|
||||
description="宿主 Agent 签发令牌时绑定的渠道发起用户标识;仅宿主内部调用的令牌携带",
|
||||
)
|
||||
|
||||
@@ -817,7 +817,7 @@ flowchart LR
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 1142 |
|
||||
| 内部导入边 | 9,852 |
|
||||
| 内部导入边 | 9,857 |
|
||||
| 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) |
|
||||
|
||||
@@ -77,6 +77,11 @@
|
||||
|
||||
### 通知配置与交互身份
|
||||
|
||||
`slash.run` 返回的是命令已触发,不代表后台命令或通知已完成。消息渠道发起的命令使用
|
||||
宿主签名的渠道用户身份;请求头不能指定其他用户,渠道身份缺失或不匹配时会拒绝执行。
|
||||
Web 会话沿用当前用户;无渠道调用不携带收件人,插件回执按既有管理员通知目标路由,
|
||||
没有可用目标时不发送。
|
||||
|
||||
`GET /api/v1/message/notification` 的可空 `poster` 字段提供独立媒体海报。
|
||||
新通知从已有媒体上下文提取海报;`image` 继续保留原有渠道图片语义。
|
||||
旧通知及没有海报的通知返回 `poster: null`,客户端不应将背景图裁切成海报。
|
||||
|
||||
@@ -17,7 +17,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
MOVIEPILOT_API_SKILL_VERSION = "68"
|
||||
MOVIEPILOT_API_SKILL_VERSION = "69"
|
||||
DATABASE_OPERATION_SKILL_VERSION = "16"
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: moviepilot-api
|
||||
version: 68
|
||||
version: 69
|
||||
description: >-
|
||||
Use this skill for MoviePilot product operations such as media search, torrent
|
||||
search, downloads, subscriptions, library checks, sites, storage, workflows,
|
||||
@@ -91,6 +91,10 @@ only `operation_id`, `path_params`, `query`, and `body`. The host chooses the
|
||||
fixed HTTP method and path, creates the current user's authentication token,
|
||||
applies authorization and confirmation policy, and returns the API response.
|
||||
|
||||
`slash.run` confirms that a command was triggered, not that execution finished.
|
||||
Channel receipts use the host-verified sender; calls without a channel use the
|
||||
existing administrator notification route and may have no delivery target.
|
||||
|
||||
The gateway connects directly to the local backend listener using `HOST` and
|
||||
`PORT`, bypassing environment proxies. `APP_DOMAIN` is for public URLs such as
|
||||
Passkey origins and is not the gateway base URL. Keep it configured even when
|
||||
|
||||
@@ -959,8 +959,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 9852,
|
||||
"edge_sha256": "281f9cb108557ce8bb78b1c4a7249fcc983d0b0b53b9691f0b6f72de79e7d61c",
|
||||
"edge_count": 9857,
|
||||
"edge_sha256": "8a215afb9059a74a9fc192f580dcfa0afd49ff63afc1f19ed6ee12787eac750f",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -2337,6 +2337,10 @@
|
||||
"app.api.deps -> app.api.dependencies.site",
|
||||
"app.api.deps -> app.api.dependencies.subscription",
|
||||
"app.api.deps -> app.api.dependencies.workflow",
|
||||
"app.api.endpoints.agent -> app.adapters",
|
||||
"app.api.endpoints.agent -> app.adapters.web",
|
||||
"app.api.endpoints.agent -> app.adapters.web.security",
|
||||
"app.api.endpoints.agent -> app.adapters.web.security.access",
|
||||
"app.api.endpoints.agent -> app.agent",
|
||||
"app.api.endpoints.agent -> app.agent.mcp",
|
||||
"app.api.endpoints.agent -> app.api",
|
||||
@@ -2362,6 +2366,7 @@
|
||||
"app.api.endpoints.agent -> app.schemas.agent",
|
||||
"app.api.endpoints.agent -> app.schemas.message",
|
||||
"app.api.endpoints.agent -> app.schemas.response",
|
||||
"app.api.endpoints.agent -> app.schemas.token",
|
||||
"app.api.endpoints.agent -> app.schemas.types",
|
||||
"app.api.endpoints.anilist -> app.adapters",
|
||||
"app.api.endpoints.anilist -> app.adapters.web",
|
||||
|
||||
@@ -345,3 +345,47 @@ def test_executor_keeps_ascii_session_header_verbatim() -> None:
|
||||
headers = _build_context_headers(session_id)
|
||||
|
||||
assert headers["X-MoviePilot-Agent-Session"] == session_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channel,channel_user_id", [
|
||||
(NotificationChannel.Telegram.value, "123456789"),
|
||||
(NotificationChannel.Wechat.value, "张三"),
|
||||
(NotificationChannel.Telegram.value, None),
|
||||
(None, "123456789"),
|
||||
])
|
||||
def test_executor_signs_channel_user_into_host_token(channel, channel_user_id) -> None:
|
||||
"""
|
||||
渠道发起人只写入宿主令牌的签名声明,不再以可伪造的请求头透传。
|
||||
"""
|
||||
executor = MoviePilotApiExecutor(
|
||||
context=ApiExecutionContext(
|
||||
user_id="1",
|
||||
username="admin",
|
||||
is_admin=True,
|
||||
channel=channel,
|
||||
channel_user_id=channel_user_id,
|
||||
),
|
||||
request_factory=MagicMock(return_value=SimpleNamespace(request=AsyncMock())),
|
||||
)
|
||||
|
||||
with patch("app.agent.api.executor.create_access_token", return_value="token") as create_token:
|
||||
headers = executor._build_headers()
|
||||
|
||||
signed = bool(channel and channel_user_id)
|
||||
assert create_token.call_args.kwargs.get("agent_channel") == (channel if signed else None)
|
||||
assert create_token.call_args.kwargs.get("agent_channel_user") == (channel_user_id if signed else None)
|
||||
assert not any(name.lower() == "x-moviepilot-agent-user" for name in headers)
|
||||
|
||||
|
||||
def test_public_executor_never_signs_channel_user() -> None:
|
||||
"""
|
||||
公共只读执行器即使收到渠道上下文,也不能把个人发起人绑定到宿主身份。
|
||||
"""
|
||||
executor = MoviePilotApiExecutor(context=ApiExecutionContext(
|
||||
user_id="1", username="admin", is_admin=True, public_read_only=True,
|
||||
channel=NotificationChannel.Telegram.value, channel_user_id="123456789",
|
||||
))
|
||||
with patch("app.agent.api.executor.create_access_token", return_value="token") as create_token:
|
||||
executor._build_headers()
|
||||
|
||||
create_token.assert_called_once_with(userid=1, username="admin", super_user=True)
|
||||
|
||||
@@ -346,3 +346,35 @@ def test_identity_scopes_cover_every_operation_and_match_rest_dependencies():
|
||||
if spec.identity_scope is ApiIdentityScope.PUBLIC:
|
||||
assert dependencies.get("verify_token") == "_", spec.operation_id
|
||||
assert not any("current_user" in name or "active_" in name for name in dependencies), spec.operation_id
|
||||
|
||||
|
||||
def test_channel_executor_carries_sender_id_for_command_receipts(db):
|
||||
"""
|
||||
通知渠道调用 API 时以绑定用户鉴权,同时保留渠道发送者 ID 供命令回执投递。
|
||||
"""
|
||||
user = db.add(User(
|
||||
name="alice", is_active=True, is_superuser=True,
|
||||
settings={"telegram_userid": "123456789"},
|
||||
))
|
||||
users = TransactionalUserRepository(sync_session=SessionFactory, async_session=async_session_scope)
|
||||
gateway = MoviePilotApiTool(session_id="session", user_id="123456789", data=SimpleNamespace(users=users))
|
||||
gateway.set_message_attr(channel=NotificationChannel.Telegram.value, source="bot", username="alice")
|
||||
|
||||
executor, _ = asyncio.run(gateway._get_executor())
|
||||
|
||||
assert executor._context.user_id == str(user.id)
|
||||
assert executor._context.channel_user_id == "123456789"
|
||||
|
||||
|
||||
def test_background_executor_has_no_channel_user(db):
|
||||
"""
|
||||
无渠道的后台任务没有可回复的渠道用户。
|
||||
"""
|
||||
user = db.add(User(name="alice", is_active=True, is_superuser=True))
|
||||
users = TransactionalUserRepository(sync_session=SessionFactory, async_session=async_session_scope)
|
||||
gateway = MoviePilotApiTool(session_id="session", user_id=str(user.id), data=SimpleNamespace(users=users))
|
||||
gateway.set_message_attr(channel=None, source=None, username=user.name)
|
||||
|
||||
executor, _ = asyncio.run(gateway._get_executor())
|
||||
|
||||
assert executor._context.channel_user_id is None
|
||||
|
||||
148
tests/test_agent_command_delivery.py
Normal file
148
tests/test_agent_command_delivery.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
验证 Agent 命令经插件事件投递到 Telegram 的收件人边界。
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.adapters.web.security import access
|
||||
from app.agent.api.executor import ApiExecutionContext, MoviePilotApiExecutor
|
||||
from app.api.endpoints import agent as agent_endpoint
|
||||
from app.application import commands
|
||||
from app.application.security.token import create_access_token, decode_access_token
|
||||
from app.chain._messaging import MessageProcessingMixin, NotificationMixin
|
||||
from app.command import Command
|
||||
from app.modules.telegram.module import TelegramModule
|
||||
from app.runtime.events import Event
|
||||
from app.schemas.message import Message
|
||||
from app.schemas.types import EventType, NotificationChannel
|
||||
|
||||
|
||||
class _ReceiptChain(MessageProcessingMixin, NotificationMixin):
|
||||
"""
|
||||
使用真实通知路由,隔离用户设置、历史及通知队列边界。
|
||||
"""
|
||||
|
||||
def __init__(self, targets: dict[str, str], telegram: TelegramModule) -> None:
|
||||
"""
|
||||
注入管理员目标,并同步送入已替换网络客户端的 Telegram 模块。
|
||||
"""
|
||||
self.user_repository = SimpleNamespace(get_notification_settings=Mock(return_value=targets))
|
||||
self.runtime_config = SimpleNamespace(superuser="admin")
|
||||
self.messageoper = SimpleNamespace(add=Mock())
|
||||
self.eventmanager = SimpleNamespace(send_event=Mock())
|
||||
self.messagequeue = SimpleNamespace(
|
||||
send_message=Mock(side_effect=lambda _method, **kwargs: telegram.post_message(kwargs["message"])),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"channel,channel_user,targets,expected",
|
||||
[
|
||||
(NotificationChannel.Telegram, "123456789", {}, "123456789"),
|
||||
(NotificationChannel.Telegram, "1", {}, "1"),
|
||||
(NotificationChannel.WebAgent, "1", {"telegram_userid": "987654321"}, None),
|
||||
(None, None, {"telegram_userid": "987654321"}, "987654321"),
|
||||
(None, None, {}, None),
|
||||
],
|
||||
ids=["signed-channel", "numeric-channel-id", "web-agent", "no-channel-bound", "no-channel-unbound"],
|
||||
)
|
||||
def test_agent_plugin_command_uses_real_telegram_target(
|
||||
monkeypatch, channel, channel_user, targets, expected,
|
||||
) -> None:
|
||||
"""
|
||||
签名渠道用户及无渠道回退须经过真实命令、插件载荷和通知路由,不能泄漏内部主键。
|
||||
"""
|
||||
telegram = TelegramModule()
|
||||
telegram._channel = NotificationChannel.Telegram
|
||||
client = SimpleNamespace(send_msg=Mock(return_value={"success": True}))
|
||||
monkeypatch.setattr(telegram, "get_configs", lambda: {"bot": SimpleNamespace(name="bot")})
|
||||
monkeypatch.setattr(telegram, "get_instance", lambda _name: client)
|
||||
monkeypatch.setattr(telegram, "get_config", lambda _name: None)
|
||||
receipt_chain = _ReceiptChain(targets, telegram)
|
||||
received_events = []
|
||||
|
||||
def plugin_command(data):
|
||||
"""
|
||||
按插件命令约定把事件渠道和用户用于回执。
|
||||
"""
|
||||
received_events.append(data)
|
||||
receipt_chain.post_message(Message(channel=data["channel"], userid=data["user"], title="插件更新完成"))
|
||||
|
||||
registry = object.__new__(Command)
|
||||
registry._commands = {
|
||||
"/plugin_update": {
|
||||
"func": plugin_command,
|
||||
"description": "插件更新",
|
||||
"data": {"data": {"action": "plugin_update"}},
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr(commands, "_command_class", lambda: registry)
|
||||
|
||||
def publish_event(etype, data):
|
||||
"""
|
||||
同步执行实际命令事件,使测试不依赖后台线程。
|
||||
"""
|
||||
assert etype == EventType.CommandExcute
|
||||
registry.command_event(Event(etype, data))
|
||||
|
||||
monkeypatch.setattr(agent_endpoint.eventmanager, "send_event", publish_event)
|
||||
monkeypatch.setattr(access, "_token_decoder", decode_access_token)
|
||||
monkeypatch.setattr(access, "_token_encoder", create_access_token)
|
||||
monkeypatch.setattr(access, "_token_identity_validator", lambda _payload: None)
|
||||
app = FastAPI()
|
||||
app.include_router(agent_endpoint.router)
|
||||
app.dependency_overrides[agent_endpoint.get_current_active_user] = lambda: SimpleNamespace(id=1, is_superuser=True)
|
||||
executor = MoviePilotApiExecutor(context=ApiExecutionContext(
|
||||
user_id="1", username="admin", is_admin=True,
|
||||
channel=channel.value if channel else None, channel_user_id=channel_user,
|
||||
))
|
||||
|
||||
with TestClient(app) as api_client:
|
||||
response = api_client.post(
|
||||
"/commands/run", json={"command": "/plugin_update"}, headers=executor._build_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
assert received_events[0]["user"] == channel_user
|
||||
if expected is None:
|
||||
client.send_msg.assert_not_called()
|
||||
else:
|
||||
client.send_msg.assert_called_once()
|
||||
assert client.send_msg.call_args.kwargs["userid"] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("claim_channel,claim_user", [(None, None), ("微信", "other-user")])
|
||||
def test_http_command_rejects_forged_or_mismatched_channel_identity(monkeypatch, claim_channel, claim_user):
|
||||
"""
|
||||
普通令牌或其他渠道的签名声明不能被请求头冒充成 Telegram 发起人。
|
||||
"""
|
||||
monkeypatch.setattr(access, "_token_decoder", decode_access_token)
|
||||
monkeypatch.setattr(access, "_token_encoder", create_access_token)
|
||||
monkeypatch.setattr(access, "_token_identity_validator", lambda _payload: None)
|
||||
dispatch = Mock()
|
||||
monkeypatch.setattr(agent_endpoint.web_agent_application, "dispatch_command", dispatch)
|
||||
app = FastAPI()
|
||||
app.include_router(agent_endpoint.router)
|
||||
app.dependency_overrides[agent_endpoint.get_current_active_user] = lambda: SimpleNamespace(id=1, is_superuser=True)
|
||||
token = create_access_token(
|
||||
userid=1, username="admin", super_user=True,
|
||||
agent_channel=claim_channel, agent_channel_user=claim_user,
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/commands/run", json={"command": "/plugin_update"}, headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-MoviePilot-Agent-Channel": "Telegram",
|
||||
"X-MoviePilot-Agent-User": "123456789",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is False
|
||||
dispatch.assert_not_called()
|
||||
@@ -3,6 +3,7 @@
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -10,37 +11,132 @@ import pytest
|
||||
|
||||
from app.api.endpoints.agent import run_agent_command
|
||||
from app.schemas.agent import AgentCommandRunRequest
|
||||
from app.schemas.token import TokenPayload
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
_COMMAND_RESULT = {
|
||||
"message": "命令已触发",
|
||||
"command": "/demo",
|
||||
"command_desc": "示例命令",
|
||||
}
|
||||
|
||||
|
||||
async def _run_command(
|
||||
channel: Optional[NotificationChannel],
|
||||
*,
|
||||
source: Optional[str] = None,
|
||||
claim_channel: Optional[NotificationChannel] = None,
|
||||
claim_user: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
以用户表主键为 1 的超级管理员调用命令端点,返回响应和命令分发替身。
|
||||
|
||||
:param channel: 请求头中的消息渠道
|
||||
:param source: 请求头中 URL 编码的消息来源
|
||||
:param claim_channel: 宿主令牌签名声明中的渠道;None 表示普通登录或 API 令牌
|
||||
:param claim_user: 宿主令牌签名声明中的渠道用户标识
|
||||
"""
|
||||
user = SimpleNamespace(id=1, is_superuser=True)
|
||||
token_data = TokenPayload(
|
||||
sub=1,
|
||||
super_user=True,
|
||||
agent_channel=claim_channel.value if claim_channel else None,
|
||||
agent_channel_user=claim_user,
|
||||
)
|
||||
with patch(
|
||||
"app.api.endpoints.agent.web_agent_application.dispatch_command",
|
||||
return_value=_COMMAND_RESULT,
|
||||
) as dispatch_command:
|
||||
response = await run_agent_command(
|
||||
AgentCommandRunRequest(command="/demo"),
|
||||
current_user=user,
|
||||
token_data=token_data,
|
||||
agent_channel=quote(channel.value, safe="") if channel else None,
|
||||
agent_source=source,
|
||||
)
|
||||
return response, dispatch_command
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_command_endpoint_decodes_internal_channel_headers() -> None:
|
||||
"""
|
||||
命令端点应解析 ASCII 编码的渠道,并还原编码后的来源名称。
|
||||
命令端点应解析 ASCII 编码的渠道与来源,并使用令牌声明中的渠道用户。
|
||||
"""
|
||||
user = SimpleNamespace(id=1, is_superuser=True)
|
||||
command_result = {
|
||||
"message": "命令已触发",
|
||||
"command": "/demo",
|
||||
"command_desc": "示例命令",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.api.endpoints.agent.web_agent_application.dispatch_command",
|
||||
return_value=command_result,
|
||||
) as dispatch_command:
|
||||
response = await run_agent_command(
|
||||
AgentCommandRunRequest(command="/demo"),
|
||||
current_user=user,
|
||||
agent_channel=quote(NotificationChannel.Wechat.value, safe=""),
|
||||
agent_source="%E4%BC%81%E4%B8%9A%E5%BE%AE%E4%BF%A1",
|
||||
)
|
||||
response, dispatch_command = await _run_command(
|
||||
NotificationChannel.Wechat,
|
||||
source="%E4%BC%81%E4%B8%9A%E5%BE%AE%E4%BF%A1",
|
||||
claim_channel=NotificationChannel.Wechat,
|
||||
claim_user="张三",
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
dispatch_command.assert_called_once_with(
|
||||
"/demo",
|
||||
user_id="1",
|
||||
user_id="张三",
|
||||
channel=NotificationChannel.Wechat,
|
||||
source="企业微信",
|
||||
publish_event=dispatch_command.call_args.kwargs["publish_event"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_command_receipt_targets_signed_channel_user() -> None:
|
||||
"""
|
||||
Telegram 等通知渠道的命令按签名声明中的渠道用户执行与回执,不能把用户表主键当作 chat id。
|
||||
"""
|
||||
response, dispatch_command = await _run_command(
|
||||
NotificationChannel.Telegram,
|
||||
claim_channel=NotificationChannel.Telegram,
|
||||
claim_user="123456789",
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
assert dispatch_command.call_args.kwargs["user_id"] == "123456789"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("claim_channel,claim_user", [
|
||||
(None, None),
|
||||
(NotificationChannel.Wechat, "123456789"),
|
||||
(NotificationChannel.Telegram, None),
|
||||
], ids=["no-claim", "other-channel-claim", "missing-user"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_command_rejects_unverified_channel_identity(claim_channel, claim_user) -> None:
|
||||
"""
|
||||
通知渠道缺少与该渠道一致的签名渠道用户时拒绝执行,不信任请求头也不回退到用户表主键。
|
||||
"""
|
||||
response, dispatch_command = await _run_command(
|
||||
NotificationChannel.Telegram,
|
||||
claim_channel=claim_channel,
|
||||
claim_user=claim_user,
|
||||
)
|
||||
|
||||
assert response.success is False
|
||||
assert "无法确认命令发起人" in response.message
|
||||
dispatch_command.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channel", [NotificationChannel.Web, NotificationChannel.WebAgent])
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_command_web_receipt_keeps_moviepilot_user_id(channel) -> None:
|
||||
"""
|
||||
Web 与 WebAgent 按 MoviePilot 用户 ID 执行,忽略令牌中的渠道用户声明。
|
||||
"""
|
||||
response, dispatch_command = await _run_command(
|
||||
channel,
|
||||
claim_channel=NotificationChannel.Telegram,
|
||||
claim_user="123456789",
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
assert dispatch_command.call_args.kwargs["user_id"] == "1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_command_without_channel_has_no_recipient() -> None:
|
||||
"""
|
||||
无渠道调用没有真实渠道用户,不能把内部主键交给插件作为通知目标。
|
||||
"""
|
||||
response, dispatch_command = await _run_command(None)
|
||||
|
||||
assert response.success is True
|
||||
assert dispatch_command.call_args.kwargs["user_id"] is None
|
||||
|
||||
@@ -758,6 +758,9 @@ def test_auth_exchange_sets_resource_cookie():
|
||||
"super_user": user.is_superuser,
|
||||
"level": token.level,
|
||||
"purpose": "resource",
|
||||
# 资源令牌不携带宿主 Agent 的渠道声明
|
||||
"agent_channel": None,
|
||||
"agent_channel_user": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -834,3 +837,26 @@ def test_auth_providers_response_model_keeps_plugin_clone_source_id():
|
||||
if provider["type"] == "plugin"
|
||||
)
|
||||
assert plugin_provider["remote"]["source_plugin_id"] == "DemoPlugin"
|
||||
|
||||
|
||||
def test_access_token_round_trips_agent_channel_claims():
|
||||
"""
|
||||
宿主 Agent 令牌的渠道声明经签名往返保持不变;只给出其中一项时不写入,普通令牌不携带。
|
||||
"""
|
||||
signed = decode_access_token(create_access_token(
|
||||
userid=1, username="admin", super_user=True,
|
||||
agent_channel="Telegram", agent_channel_user="123456789",
|
||||
))
|
||||
partial = decode_access_token(create_access_token(
|
||||
userid=1, username="admin", super_user=True, agent_channel="Telegram",
|
||||
))
|
||||
plain = decode_access_token(create_access_token(userid=1, username="admin", super_user=True))
|
||||
resource = decode_access_token(create_access_token(
|
||||
userid=1, username="admin", super_user=True, purpose="resource",
|
||||
agent_channel="Telegram", agent_channel_user="123456789",
|
||||
), purpose="resource")
|
||||
|
||||
assert (signed.agent_channel, signed.agent_channel_user) == ("Telegram", "123456789")
|
||||
assert (partial.agent_channel, partial.agent_channel_user) == (None, None)
|
||||
assert (plain.agent_channel, plain.agent_channel_user) == (None, None)
|
||||
assert (resource.agent_channel, resource.agent_channel_user) == (None, None)
|
||||
|
||||
@@ -130,7 +130,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
"database-operation": "16",
|
||||
"feedback-issue": "12",
|
||||
"anysearch": "3",
|
||||
"moviepilot-api": "68",
|
||||
"moviepilot-api": "69",
|
||||
"moviepilot-update": "7",
|
||||
"organize-files": "6",
|
||||
"transfer-failed-retry": "6",
|
||||
|
||||
Reference in New Issue
Block a user