fix(agent): route API calls to the local backend (#6875)

This commit is contained in:
jxxghp
2026-10-04 09:05:32 +08:00
parent fcf12a1e2f
commit 4d74d4c2e9
5 changed files with 76 additions and 9 deletions

View File

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

View File

@@ -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 用户身份,渠道管理员也不会借用超级管理员身份。创建时订阅归属绑定用户,
普通用户只能修改或删除自己的订阅;未绑定或绑定用户已停用时拒绝执行。三项操作仍保留确认机制。

View File

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

View File

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

View File

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