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:
Cat
2026-10-28 19:05:52 +08:00
committed by GitHub
parent 252b702875
commit 87561a90d1
17 changed files with 485 additions and 32 deletions

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,

View File

@@ -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:

View File

@@ -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

View File

@@ -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 签发令牌时绑定的渠道发起用户标识;仅宿主内部调用的令牌携带",
)

View File

@@ -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) |

View File

@@ -77,6 +77,11 @@
### 通知配置与交互身份
`slash.run` 返回的是命令已触发,不代表后台命令或通知已完成。消息渠道发起的命令使用
宿主签名的渠道用户身份;请求头不能指定其他用户,渠道身份缺失或不匹配时会拒绝执行。
Web 会话沿用当前用户;无渠道调用不携带收件人,插件回执按既有管理员通知目标路由,
没有可用目标时不发送。
`GET /api/v1/message/notification` 的可空 `poster` 字段提供独立媒体海报。
新通知从已有媒体上下文提取海报;`image` 继续保留原有渠道图片语义。
旧通知及没有海报的通知返回 `poster: null`,客户端不应将背景图裁切成海报。

View File

@@ -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"

View File

@@ -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

View File

@@ -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",

View File

@@ -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)

View File

@@ -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

View 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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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",