mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 21:07:01 +08:00
fix(agent): inherit deployment environment in Python kernels
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Python 内核的环境白名单、父进程死亡通知和整组进程终止。"""
|
||||
"""Python 内核的部署环境继承、父进程死亡通知和整组进程终止。"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
@@ -7,21 +7,12 @@ import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
_SECRET_PARTS = ('KEY', 'TOKEN', 'SECRET', 'PASSWORD', 'CREDENTIAL', 'PASSWD', 'AUTH', 'DSN',
|
||||
'WEBHOOK', 'CREDS', 'BEARER', 'APIKEY')
|
||||
_SAFE_PREFIXES = ('PATH', 'HOME', 'USER', 'LANG', 'LC_', 'TERM', 'TMPDIR', 'TMP', 'TEMP', 'SHELL',
|
||||
'LOGNAME', 'XDG_', 'VIRTUAL_ENV', 'CONDA')
|
||||
_WINDOWS_NAMES = frozenset({'SYSTEMROOT', 'SYSTEMDRIVE', 'WINDIR', 'COMSPEC', 'PATHEXT', 'OS',
|
||||
'APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH'})
|
||||
|
||||
|
||||
def child_environment(values: dict[str, str]) -> dict[str, str]:
|
||||
"""先拒绝敏感变量名,再允许必要的运行环境变量,不继承宿主 PYTHONPATH。"""
|
||||
environment = {
|
||||
key: value for key, value in values.items()
|
||||
if not any(part in key.upper() for part in _SECRET_PARTS)
|
||||
and (key.startswith(_SAFE_PREFIXES) or (os.name == 'nt' and key.upper() in _WINDOWS_NAMES))
|
||||
}
|
||||
"""管理员内核继承宿主部署配置和连接凭据,保留独立导入路径与 UTF-8 输出。"""
|
||||
# 内核与管理员 shell 同属宿主权限;过滤配置或密码会使脚本静默连接默认数据库。
|
||||
environment = values.copy()
|
||||
environment.pop('PYTHONPATH', None)
|
||||
environment.update(PYTHONDONTWRITEBYTECODE='1', PYTHONIOENCODING='utf-8', PYTHONUTF8='1')
|
||||
return environment
|
||||
|
||||
|
||||
@@ -194,7 +194,9 @@ print(pages)
|
||||
|
||||
stdout 内联50KB、stderr 10KB;超长 stdout 先脱敏,再把最多5MB正文存入当前用户的 `agent/runtime/code/users/<user-key>/output/`,可用 `read_file(file_path=..., start_line=..., end_line=...)` 续读。直接文件描述符输出和超出捕获上限的部分会明确标记不完整;spill 不保证包含未捕获正文。输出文件独立于内核目录,因此内核退出后仍可读取,七天后清理。空闲内核采用默认4个的 LRU 和1800秒过期;正在运行或排队的 cell 不被容量淘汰,因此高并发时可暂时超过4个。后台回收不依赖下一次调用,宿主 TaskRegistry 管理任务,文件操作使用现有 Agent 有界线程池。
|
||||
|
||||
任意 Python 与管理员 `execute_command` 一样具备宿主进程权限,环境变量过滤和只读 RPC **不是操作系统沙箱**。因此普通用户、只读子代理和没有宿主 cell 上下文的外部 HTTP/MCP 直接调用不能使用这项能力;它不通过外部 `tools/list` 发布。POSIX 使用私有 Unix socket、随机令牌和父死亡管道,Windows 使用回环 TCP、随机令牌和父进程对象句柄。每个 cell 重新绑定当前上下文,结束时撤销并取消遗留 RPC,不把持久 socket 当作永久身份。当前只实现本地主机内核,没有远程终端后端。
|
||||
内核启动时继承宿主部署环境,包括 `CONFIG_DIR`、`MOVIEPILOT_ROOT`、`DB_TYPE` 和数据库连接凭据,使配置读取和内核中启动的脚本与 shell 通道一致;不继承宿主 `PYTHONPATH`,并固定 UTF-8 输出。环境变量不会自动输出给模型。环境或 `app.env` 变更后,应重置内核以重新加载配置。
|
||||
|
||||
任意 Python 与管理员 `execute_command` 一样具备宿主进程权限,只读 RPC **不是操作系统沙箱**。因此普通用户、只读子代理和没有宿主 cell 上下文的外部 HTTP/MCP 直接调用不能使用这项能力;它不通过外部 `tools/list` 发布。POSIX 使用私有 Unix socket、随机令牌和父死亡管道,Windows 使用回环 TCP、随机令牌和父进程对象句柄。每个 cell 重新绑定当前上下文,结束时撤销并取消遗留 RPC,不把持久 socket 当作永久身份。当前只实现本地主机内核,没有远程终端后端。
|
||||
|
||||
## 验证范围
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ MCP 当前不会主动发送工具列表变更通知(`listChanged=false`)。
|
||||
|
||||
`execute_code` 仅在已绑定宿主会话的管理员 Agent 中提供,不发布到外部 HTTP/MCP 工具目录。内部只读 RPC 仍经过同一个 API/Skill/身份边界,默认直接执行,不要求用户逐次确认;外部客户端不能通过直接调用为自己创建 Python 会话身份。持久状态和回收语义见 [Python 只读工具编排](agent.md#python-只读工具编排)。
|
||||
|
||||
Python 内核继承宿主部署环境(含数据库配置与连接凭据),移除宿主 `PYTHONPATH` 并固定 UTF-8 输出。配置在内核启动时读取;变更后使用 `reset=true` 重新加载,环境变量不会自动返回给模型。
|
||||
|
||||
内置 Agent 通过渠道调用业务 API 时,QQ 的 `qq_userid` / `qq_openid`、飞书的
|
||||
`feishu_userid` / `feishu_openid` 都是候选绑定字段,任一字段与当前渠道标识一致即可
|
||||
解析到对应的启用用户,无需将两个字段填成相同值。同一标识命中多个启用用户时拒绝归属;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: moviepilot-api
|
||||
version: 49
|
||||
version: 50
|
||||
description: >-
|
||||
Use this skill for MoviePilot product operations such as media search, torrent
|
||||
search, downloads, subscriptions, library checks, sites, storage, workflows,
|
||||
|
||||
@@ -16,3 +16,8 @@ kernel loses that state. Each cell has 300 seconds and 50 tool calls. Inspect
|
||||
slices with `read_file(file_path=..., start_line=..., end_line=...)`; do not rerun
|
||||
the program solely to recover output. This administrator capability is host
|
||||
Python, not an OS sandbox, and is absent from external MCP/HTTP tool catalogs.
|
||||
|
||||
The kernel inherits the host deployment environment, including database
|
||||
configuration and connection credentials, without automatically printing it.
|
||||
Host `PYTHONPATH` is excluded and output uses UTF-8. Reset the kernel after
|
||||
deployment environment or `app.env` changes so scripts reload current settings.
|
||||
|
||||
@@ -160,15 +160,75 @@ async def test_private_files_and_parent_death_signal(kernel):
|
||||
assert kernel.process.returncode == -signal.SIGKILL
|
||||
|
||||
|
||||
def test_environment_drops_secrets_before_safe_prefixes():
|
||||
"""敏感名称优先于安全前缀;内核不能继承宿主导入路径或认证令牌。"""
|
||||
environment = child_environment({'PATH': '/bin', 'HOME': '/home/example', 'HOME_TOKEN': 'secret',
|
||||
'LANG': 'en_US.UTF-8', 'OPENAI_API_KEY': 'secret', 'PYTHONPATH': '/host'})
|
||||
assert environment['PATH'] == '/bin' and environment['HOME'] == '/home/example'
|
||||
assert not {'HOME_TOKEN', 'OPENAI_API_KEY', 'PYTHONPATH'} & environment.keys()
|
||||
def test_environment_preserves_deployment_configuration_without_mutating_parent():
|
||||
"""管理员内核继承完整部署配置,但不继承宿主导入路径或改写父环境。"""
|
||||
parent = {'PATH': '/bin', 'HOME': '/home/example', 'HOME_TOKEN': 'example-token',
|
||||
'LANG': 'en_US.UTF-8', 'OPENAI_API_KEY': 'example-key', 'PYTHONPATH': '/host',
|
||||
'CONFIG_DIR': '/config', 'DB_TYPE': 'postgresql',
|
||||
'DB_POSTGRESQL_PASSWORD': 'example-password', 'CUSTOM_SERVICE_URL': 'http://localhost',
|
||||
'PYTHONUTF8': '0', 'SystemRoot': 'C:\\Windows'}
|
||||
original = parent.copy()
|
||||
environment = child_environment(parent)
|
||||
assert environment == {**{key: value for key, value in parent.items() if key != 'PYTHONPATH'},
|
||||
'PYTHONDONTWRITEBYTECODE': '1', 'PYTHONIOENCODING': 'utf-8', 'PYTHONUTF8': '1'}
|
||||
assert parent == original
|
||||
assert environment['PYTHONUTF8'] == '1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('database_type', ['postgresql', 'sqlite'])
|
||||
async def test_kernel_and_nested_script_use_host_database_configuration(tmp_path, monkeypatch, database_type):
|
||||
"""遗留 SQLite 存在时,内核和后续脚本仍按宿主配置选择数据库且不输出凭据。"""
|
||||
config_path = tmp_path / 'config'
|
||||
config_path.mkdir()
|
||||
(config_path / 'user.db').touch()
|
||||
project_path = Path(__file__).resolve().parents[1]
|
||||
deployment = {'CONFIG_DIR': str(config_path), 'MOVIEPILOT_ROOT': str(project_path),
|
||||
'DB_TYPE': database_type, 'DB_POSTGRESQL_HOST': 'database.invalid',
|
||||
'DB_POSTGRESQL_PORT': '15432', 'DB_POSTGRESQL_DATABASE': 'kernel_test',
|
||||
'DB_POSTGRESQL_USERNAME': 'kernel_user', 'DB_POSTGRESQL_PASSWORD': 'test-pg-password'}
|
||||
for key, value in deployment.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
instance = PythonKernel(tmp_path / 'deployment-kernel', (), blocking=asyncio.to_thread, cwd=project_path)
|
||||
script = (
|
||||
'import os, runpy\n'
|
||||
'from pathlib import Path\n'
|
||||
'from sqlalchemy.engine import make_url\n'
|
||||
'script = runpy.run_path(str(Path(os.environ["MOVIEPILOT_ROOT"]) / '
|
||||
'"skills/database-operation/scripts/mp-db.py"))\n'
|
||||
'settings = script["_load_settings"]()\n'
|
||||
'assert settings.DB_TYPE == os.environ["DB_TYPE"]\n'
|
||||
'assert settings.CONFIG_PATH == Path(os.environ["CONFIG_DIR"])\n'
|
||||
'assert settings.DB_POSTGRESQL_PASSWORD == os.environ["DB_POSTGRESQL_PASSWORD"]\n'
|
||||
'script["_build_engine"].__globals__["create_engine"] = lambda url, **kwargs: make_url(url)\n'
|
||||
'url = script["_build_engine"]()\n'
|
||||
'assert url.get_backend_name() == os.environ["DB_TYPE"]\n'
|
||||
'if settings.DB_TYPE == "postgresql":\n'
|
||||
' assert url.host == "database.invalid" and url.port == 15432\n'
|
||||
' assert url.database == "kernel_test" and url.username == "kernel_user"\n'
|
||||
' assert url.password == os.environ["DB_POSTGRESQL_PASSWORD"]\n'
|
||||
'else:\n'
|
||||
' assert Path(url.database) == settings.CONFIG_PATH / "user.db"\n'
|
||||
'print("database configuration matched")\n'
|
||||
)
|
||||
try:
|
||||
await instance.start()
|
||||
result = await instance.execute(script, dispatch=_dispatch, timeout=15)
|
||||
assert result['status'] == 'ok', result.get('traceback')
|
||||
assert result['stdout'] == 'database configuration matched\n'
|
||||
nested = await instance.execute(
|
||||
'import subprocess, sys\n'
|
||||
f'child = subprocess.run([sys.executable, "-c", {script!r}], '
|
||||
'capture_output=True, text=True, timeout=15)\n'
|
||||
'assert child.returncode == 0, child.stderr\nprint(child.stdout, end="")',
|
||||
dispatch=_dispatch, timeout=20,
|
||||
)
|
||||
assert nested['status'] == 'ok', nested.get('traceback')
|
||||
assert nested['stdout'] == 'database configuration matched\n'
|
||||
finally:
|
||||
await instance.close()
|
||||
|
||||
|
||||
def test_bounded_output_preserves_readable_spill(tmp_path):
|
||||
"""预览按字节预算保留首尾,正文可续读且超过5MB时明确报告上限。"""
|
||||
text = 'A' * STDOUT_BYTES + '中' * SPILL_BYTES + 'THE_END'
|
||||
|
||||
@@ -117,7 +117,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
"command-dispatch": "2",
|
||||
"database-operation": "9",
|
||||
"feedback-issue": "11",
|
||||
"moviepilot-api": "49",
|
||||
"moviepilot-api": "50",
|
||||
"moviepilot-update": "5",
|
||||
"organize-files": "5",
|
||||
"transfer-failed-retry": "5",
|
||||
|
||||
Reference in New Issue
Block a user