diff --git a/app/agent/api/executor.py b/app/agent/api/executor.py index fb2fec0b5..dee4c3991 100644 --- a/app/agent/api/executor.py +++ b/app/agent/api/executor.py @@ -64,13 +64,12 @@ class MoviePilotApiExecutor: @staticmethod def _resolve_base_url() -> str: - """解析本机 API 基址,避免把任意用户输入当成请求目标。""" - configured_domain = str(get_runtime_setting("APP_DOMAIN", "") or "").strip() - if configured_domain.startswith(("http://", "https://")): - return configured_domain.rstrip("/") - host = str(get_runtime_setting("HOST", "127.0.0.1") or "127.0.0.1") - if host in {"0.0.0.0", "::", "[::]"}: - host = "127.0.0.1" + """按后端监听地址直连本机 API,不依赖对外域名和反向代理。""" + host = str(get_runtime_setting("HOST", "127.0.0.1") or "127.0.0.1").strip("[]") + # 通配监听地址按相同地址族回环,兼容只监听 IPv6 的部署。 + host = {"0.0.0.0": "127.0.0.1", "::": "::1"}.get(host, host) + if ":" in host: + host = f"[{host}]" port = int(get_runtime_setting("PORT", 3001)) return f"http://{host}:{port}" diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 66cdc0c3c..4b94bf976 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -922,6 +922,9 @@ SDK method。普通 MCP 客户端如需这些 provider 原生能力,应使用 宿主按 `operation_id` 决定固定 method 与 path,使用真实持久化管理员身份为 API KEY 集成签发短期本机令牌,并按 operation 执行权限、确认、结果脱敏和恢复策略。 调用方不能注入 host、URL、认证头或 API Token。 +网关按后端监听配置 `HOST` / `PORT` 通过 HTTP 直连本机 API,并绕过环境代理。 +`0.0.0.0` / `::` 通配监听分别使用 `127.0.0.1` / `::1` 回环地址,显式 IPv6 地址按 URL 规范加方括号。 +`APP_DOMAIN` 用于 Passkey 等对外地址,不作为网关基址;反代域名在容器内不可达时无需清空该配置。 通知渠道的 `subscription.add`、`subscription.update`、`subscription.delete` 使用渠道账号绑定的 有效 MoviePilot 用户身份,渠道管理员也不会借用超级管理员身份。创建时订阅归属绑定用户, 普通用户只能修改或删除自己的订阅;未绑定或绑定用户已停用时拒绝执行。三项操作仍保留确认机制。 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 323149046..6a292b028 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 42 +version: 43 description: >- Use this skill for MoviePilot product operations such as media search, torrent search, downloads, subscriptions, library checks, sites, storage, workflows, @@ -78,6 +78,11 @@ 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. +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 +the public reverse proxy is unreachable from inside the container. + `subscription.add`, `subscription.update`, and `subscription.delete` require authorization for the action and scope and use the active MoviePilot user bound to the channel account, including for channel administrators. Creation belongs to that user; ordinary diff --git a/tests/test_agent_api_executor.py b/tests/test_agent_api_executor.py index 83b3cf436..7d1c3d076 100644 --- a/tests/test_agent_api_executor.py +++ b/tests/test_agent_api_executor.py @@ -4,12 +4,72 @@ from types import SimpleNamespace from typing import Optional from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from app.agent.api.executor import ApiExecutionContext, MoviePilotApiExecutor from app.agent.tools.base import format_tool_result_for_agent from app.agent.tools.manager import MoviePilotToolsManager from app.schemas.types import NotificationChannel +@pytest.mark.parametrize("app_domain", ["", "https://custom.domain.example:5443/moviepilot/"]) +@pytest.mark.parametrize( + ("host", "port", "base_url"), + [ + (None, 3001, "http://127.0.0.1:3001"), + ("", 3001, "http://127.0.0.1:3001"), + ("0.0.0.0", 13001, "http://127.0.0.1:13001"), + ("127.0.0.1", 3001, "http://127.0.0.1:3001"), + ("192.0.2.10", 13001, "http://192.0.2.10:13001"), + ("localhost", 13001, "http://localhost:13001"), + ("::", 13001, "http://[::1]:13001"), + ("[::]", 13001, "http://[::1]:13001"), + ("::1", 3001, "http://[::1]:3001"), + ("[::1]", 3001, "http://[::1]:3001"), + ("2001:db8::10", 13001, "http://[2001:db8::10]:13001"), + ("[2001:db8::10]", 13001, "http://[2001:db8::10]:13001"), + ], +) +def test_executor_requests_local_listener_independently_of_public_domain( + app_domain: str, + host: Optional[str], + port: int, + base_url: str, +) -> None: + """配置对外反代域名后,API 仍须按本机监听地址和端口发送且绕过环境代理。""" + runtime_settings = {"APP_DOMAIN": app_domain, "HOST": host, "PORT": port} + response = SimpleNamespace( + status_code=200, + headers={}, + json=lambda: {"success": True, "data": []}, + aclose=AsyncMock(), + ) + request = AsyncMock(return_value=response) + request_factory = MagicMock(return_value=SimpleNamespace(request=request)) + executor = MoviePilotApiExecutor( + context=ApiExecutionContext(user_id="1", username="admin", is_admin=True), + request_factory=request_factory, + ) + + with ( + patch("app.agent.api.executor.get_runtime_setting", side_effect=runtime_settings.get), + patch("app.agent.api.executor.create_access_token", return_value="token"), + ): + result = asyncio.run(executor.execute("subscription.list", query={"page": 1})) + + assert json.loads(result) == {"success": True, "data": []} + request.assert_awaited_once_with( + method="GET", + url=f"{base_url}/api/v1/subscribe/", + params={"page": 1}, + json=None, + raise_exception=True, + ) + assert request_factory.call_args.kwargs["headers"]["Authorization"] == "Bearer token" + assert request_factory.call_args.kwargs["trust_env"] is False + response.aclose.assert_awaited_once() + + def _execute_with_headers(headers: dict[str, str]) -> tuple[dict, AsyncMock]: """用内存 HTTP 响应执行一次固定 API operation。""" response = SimpleNamespace( diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 80e187ea3..7e108728f 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -117,7 +117,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: "command-dispatch": "2", "database-operation": "9", "feedback-issue": "11", - "moviepilot-api": "42", + "moviepilot-api": "43", "moviepilot-update": "5", "organize-files": "5", "transfer-failed-retry": "5",