diff --git a/app/adapters/system/code/process.py b/app/adapters/system/code/process.py index 509da428b..582c9d20c 100644 --- a/app/adapters/system/code/process.py +++ b/app/adapters/system/code/process.py @@ -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 diff --git a/docs/agent.md b/docs/agent.md index b246c0c7c..eed7197eb 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -194,7 +194,9 @@ print(pages) stdout 内联50KB、stderr 10KB;超长 stdout 先脱敏,再把最多5MB正文存入当前用户的 `agent/runtime/code/users//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 当作永久身份。当前只实现本地主机内核,没有远程终端后端。 ## 验证范围 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index aa2296707..35fe54bc7 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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` 都是候选绑定字段,任一字段与当前渠道标识一致即可 解析到对应的启用用户,无需将两个字段填成相同值。同一标识命中多个启用用户时拒绝归属; diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index ae91fb3df..329ec9530 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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, diff --git a/skills/moviepilot-api/code-execution.md b/skills/moviepilot-api/code-execution.md index 86806663f..a6a0d2072 100644 --- a/skills/moviepilot-api/code-execution.md +++ b/skills/moviepilot-api/code-execution.md @@ -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. diff --git a/tests/test_agent_code_kernel.py b/tests/test_agent_code_kernel.py index 6edc67cfb..e15879592 100644 --- a/tests/test_agent_code_kernel.py +++ b/tests/test_agent_code_kernel.py @@ -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' diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 1c4201d24..f61bea765 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": "49", + "moviepilot-api": "50", "moviepilot-update": "5", "organize-files": "5", "transfer-failed-retry": "5",