From 87561a90d16e9bc087225c72bbfebe16865b3baa Mon Sep 17 00:00:00 2001 From: Cat <86354789+MiCat-S@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:05:52 +0800 Subject: [PATCH] fix(agent): route agent command receipts to the signed channel user (#7228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Co-authored-by: jxxghp --- app/agent/api/executor.py | 25 ++- app/agent/tools/impl/api.py | 4 + app/api/endpoints/agent.py | 41 ++++- app/application/commands.py | 10 +- app/application/security/token.py | 19 +++ app/schemas/token.py | 8 + docs/architecture-overview.md | 2 +- docs/mcp-api.md | 5 + scripts/generate_agent_skill_docs.py | 2 +- skills/moviepilot-api/SKILL.md | 6 +- .../architecture/dependency-baseline.json | 9 +- tests/test_agent_api_executor.py | 44 ++++++ tests/test_agent_api_identity.py | 32 ++++ tests/test_agent_command_delivery.py | 148 ++++++++++++++++++ tests/test_agent_command_endpoint.py | 134 +++++++++++++--- tests/test_auth_contracts.py | 26 +++ tests/test_builtin_skill_boundaries.py | 2 +- 17 files changed, 485 insertions(+), 32 deletions(-) create mode 100644 tests/test_agent_command_delivery.py diff --git a/app/agent/api/executor.py b/app/agent/api/executor.py index 867e5c337..421d18823 100644 --- a/app/agent/api/executor.py +++ b/app/agent/api/executor.py @@ -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", diff --git a/app/agent/tools/impl/api.py b/app/agent/tools/impl/api.py index 1b3cb70c7..46b37a956 100644 --- a/app/agent/tools/impl/api.py +++ b/app/agent/tools/impl/api.py @@ -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, diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py index f8a65d2ac..f88828771 100644 --- a/app/api/endpoints/agent.py +++ b/app/api/endpoints/agent.py @@ -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, diff --git a/app/application/commands.py b/app/application/commands.py index f851a4210..0af923f6c 100644 --- a/app/application/commands.py +++ b/app/application/commands.py @@ -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: diff --git a/app/application/security/token.py b/app/application/security/token.py index d36994803..9504a10f9 100644 --- a/app/application/security/token.py +++ b/app/application/security/token.py @@ -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 diff --git a/app/schemas/token.py b/app/schemas/token.py index 6c2abb377..8c6c1a44a 100644 --- a/app/schemas/token.py +++ b/app/schemas/token.py @@ -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 签发令牌时绑定的渠道发起用户标识;仅宿主内部调用的令牌携带", + ) diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 9ef854226..e9515ed1a 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -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) | diff --git a/docs/mcp-api.md b/docs/mcp-api.md index b2d7d4409..2f2aa0e2d 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -77,6 +77,11 @@ ### 通知配置与交互身份 +`slash.run` 返回的是命令已触发,不代表后台命令或通知已完成。消息渠道发起的命令使用 +宿主签名的渠道用户身份;请求头不能指定其他用户,渠道身份缺失或不匹配时会拒绝执行。 +Web 会话沿用当前用户;无渠道调用不携带收件人,插件回执按既有管理员通知目标路由, +没有可用目标时不发送。 + `GET /api/v1/message/notification` 的可空 `poster` 字段提供独立媒体海报。 新通知从已有媒体上下文提取海报;`image` 继续保留原有渠道图片语义。 旧通知及没有海报的通知返回 `poster: null`,客户端不应将背景图裁切成海报。 diff --git a/scripts/generate_agent_skill_docs.py b/scripts/generate_agent_skill_docs.py index 639c791c1..14712bccf 100644 --- a/scripts/generate_agent_skill_docs.py +++ b/scripts/generate_agent_skill_docs.py @@ -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" diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 4bc11f8ea..5450dae2a 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 79292e700..29014278e 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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", diff --git a/tests/test_agent_api_executor.py b/tests/test_agent_api_executor.py index 8c8a28dd0..1e1282320 100644 --- a/tests/test_agent_api_executor.py +++ b/tests/test_agent_api_executor.py @@ -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) diff --git a/tests/test_agent_api_identity.py b/tests/test_agent_api_identity.py index d81244fb3..2fc935945 100644 --- a/tests/test_agent_api_identity.py +++ b/tests/test_agent_api_identity.py @@ -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 diff --git a/tests/test_agent_command_delivery.py b/tests/test_agent_command_delivery.py new file mode 100644 index 000000000..9b87bf0c6 --- /dev/null +++ b/tests/test_agent_command_delivery.py @@ -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() diff --git a/tests/test_agent_command_endpoint.py b/tests/test_agent_command_endpoint.py index 519daaf1d..1bb6405e0 100644 --- a/tests/test_agent_command_endpoint.py +++ b/tests/test_agent_command_endpoint.py @@ -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 diff --git a/tests/test_auth_contracts.py b/tests/test_auth_contracts.py index 3a9c201d6..52decae9f 100644 --- a/tests/test_auth_contracts.py +++ b/tests/test_auth_contracts.py @@ -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) diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 3738f415f..a2abd670b 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -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",