Files
MoviePilot/app/agent/shell.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

401 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Agent 命令解释器选择与子进程编码策略。
"""
from __future__ import annotations
import ntpath
import os
import posixpath
import shlex
import shutil
from dataclasses import dataclass
from pathlib import Path, PureWindowsPath
from typing import Any, Callable, Mapping, Optional
WINDOWS_UTF8_ENVIRONMENT = {
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1",
}
_POWERSHELL_UTF8_PREFIX = (
"$utf8 = [System.Text.UTF8Encoding]::new($false); "
"[Console]::InputEncoding = $utf8; "
"[Console]::OutputEncoding = $utf8; "
"$OutputEncoding = $utf8; "
"$PSDefaultParameterValues['*:Encoding'] = 'utf8'; "
"chcp.com 65001 | Out-Null;"
)
CommandFinder = Callable[[str], Optional[str]]
@dataclass(frozen=True)
class AgentShell:
"""
描述 Agent 实际使用的解释器、登录模式及与其一致的命令参数。
:param kind: 解释器类别,用于选择正确语法和平台提示
:param executable: 实际启动的解释器可执行文件路径
:param arguments: 传入命令文本之前的固定解释器参数
:param command_prefix: 执行用户命令前附加的环境初始化文本;没有前缀时为空
:param git_available: 当前解释器环境是否可使用 Git
:param login: 是否使用解释器登录模式
"""
kind: str
executable: str
arguments: tuple[str, ...]
command_prefix: str = ""
git_available: bool = False
login: bool = False
def build_argv(self, command: str) -> list[str]:
"""
生成无需再次经过系统默认 shell 的命令参数。
"""
command_text = f"{self.command_prefix}\n{command}" if self.command_prefix else command
return [self.executable, *self.arguments, command_text]
def prompt_guidance(self) -> str:
"""
指导模型使用实际解释器语法,不泄露可执行文件的安装路径。
"""
selection_guidance = "可通过 `shell` 和 `login` 显式选择解释器及登录模式,命令语法须匹配本次选择。"
if self.kind in _POSIX_SHELL_NAMES:
name = _POSIX_SHELL_NAMES[self.kind]
mode = "登录模式" if self.login else "非登录模式"
return (f"- Agent 命令环境: 默认使用 {name}({mode})及 {name} 语法。"
f"{selection_guidance}`run`、后台 pipe 和 PTY 会话共享相同选择策略。")
if self.kind == "git-bash":
return (
"- Windows Agent 命令环境: 默认使用 Git Bash(UTF-8)及 Git Bash/POSIX 语法;"
f"涉及仓库、源码状态或版本控制时优先使用 `git`。{selection_guidance}"
)
if self.kind in {"pwsh", "powershell"}:
name = "PowerShell 7 (`pwsh`, UTF-8)" if self.kind == "pwsh" else "Windows PowerShell (`powershell`, UTF-8)"
git_guidance = ";仓库和版本控制操作仍优先使用 `git`" if self.git_available else ""
return (
f"- Windows Agent 命令环境: 默认使用 {name} 及 PowerShell 语法"
f"{git_guidance}。{selection_guidance}"
)
git_guidance = ";仓库和版本控制操作优先使用 `git`" if self.git_available else ""
return (
"- Windows Agent 命令环境: 默认使用 cmd.exe(UTF-8)及 cmd 语法"
f"{git_guidance}。{selection_guidance}"
)
_POSIX_SHELL_NAMES = {
"sh": "POSIX sh", "bash": "Bash", "zsh": "Zsh", "dash": "Dash", "ash": "Ash",
"ksh": "KornShell", "mksh": "MirBSD KornShell", "fish": "Fish", "csh": "C shell", "tcsh": "Tcsh",
}
def _shell_kind(executable: str, *, windows: bool) -> str:
"""
按启动名称识别语法,未知别名只有解析到已知解释器的符号链接才可使用。
"""
name = PureWindowsPath(executable).name.lower().removesuffix(".exe") if windows else Path(executable).name
if name in _POSIX_SHELL_NAMES or (windows and name in {"pwsh", "powershell", "cmd"}):
return name
if not windows:
resolved_name = Path(executable).resolve().name
if resolved_name in _POSIX_SHELL_NAMES:
return resolved_name
raise ValueError(f"不支持的命令解释器: {name}")
def _find_executable(executable: str, finder: CommandFinder, *, windows: bool) -> str:
"""
解析单个可执行文件,拒绝空值、命令行参数和不可执行路径后再启动进程。
"""
if not isinstance(executable, str) or not executable.strip() or "\x00" in executable:
raise ValueError("shell 必须是单个已安装解释器的名称或路径")
located = finder(str(Path(executable).expanduser()) if not windows else executable)
if not located:
raise FileNotFoundError(f"命令解释器不存在或不可执行: {executable}")
return located
def _posix_policy(
kind: str,
executable: str,
*,
login: bool,
git_available: bool = False,
command_prefix: str = "",
) -> AgentShell:
"""
POSIX 类解释器使用统一 exec 参数;C shell 不支持登录与命令执行组合。
"""
if login and kind in {"csh", "tcsh"}:
raise ValueError(f"{kind} 不支持 login=True 与命令执行组合")
arguments = ("-lc",) if login else ("-c",)
return AgentShell(
kind, executable, arguments, command_prefix=command_prefix,
git_available=git_available, login=login,
)
def _project_python_alias_prefix(environment: Mapping[str, str], *, windows: bool) -> str:
"""
让 POSIX Agent 命令中的裸 Python 使用已选定的项目运行时入口。
"""
if windows or not environment.get("MOVIEPILOT_PYTHON"):
return ""
# shell 函数只存在于本次子进程,避免改写虚拟环境中的解释器或用户文件。
runtime = shlex.quote(environment["MOVIEPILOT_PYTHON"])
return (
f"python() {{ {runtime} \"$@\"; }}\n"
f"python3() {{ {runtime} \"$@\"; }}"
)
def _windows_policy(kind: str, executable: str, *, login: Optional[bool], git_available: bool,
legacy_git_bash: bool = False) -> AgentShell:
"""
保留 Windows 默认调用合同,显式登录只在支持登录 shell 的解释器上启用。
"""
if kind in _POSIX_SHELL_NAMES or kind == "git-bash":
if legacy_git_bash and login is None:
return AgentShell("git-bash", executable, ("--noprofile", "--norc", "-lc"), git_available=True, login=True)
policy = _posix_policy("bash" if kind == "git-bash" else kind, executable,
login=bool(login), git_available=git_available)
return AgentShell(kind, policy.executable, policy.arguments, git_available=git_available, login=policy.login)
if login:
raise ValueError(f"Windows {kind} 不支持 login=True;请使用非登录命令模式")
if kind in {"pwsh", "powershell"}:
return AgentShell(kind, executable, ("-NoLogo", "-NoProfile", "-Command"),
command_prefix=_POWERSHELL_UTF8_PREFIX, git_available=git_available)
return AgentShell("cmd", executable, ("/d", "/s", "/c"), command_prefix="chcp 65001>nul &", git_available=git_available)
def _find_git_bash(git_path: str, command_finder: CommandFinder) -> Optional[str]:
"""
从 Git for Windows 常见布局中定位同一安装目录下的 bash.exe。
"""
git_executable = Path(git_path)
git_root = git_executable.parent.parent
candidates = (
git_executable.parent / "bash.exe",
git_root / "bin" / "bash.exe",
git_root / "usr" / "bin" / "bash.exe",
)
for candidate in candidates:
if candidate.is_file():
return str(candidate)
bash_path = command_finder("bash") or command_finder("bash.exe")
if not bash_path:
return None
bash_executable = Path(bash_path)
if bash_executable.parent == git_executable.parent:
return str(bash_executable)
if bash_executable.parent.parent == git_root:
return str(bash_executable)
return None
def _launch_path(value: str, cwd: str, *, windows: bool) -> str:
"""
相对路径以即将启动的目录为基准,Windows 跨盘符相对路径必须显式补全。
"""
if windows:
target, base = PureWindowsPath(value), PureWindowsPath(cwd)
if target.drive and not target.root and target.drive.casefold() != base.drive.casefold():
raise ValueError("跨盘符的相对 shell/PATH 路径需要改为绝对路径")
return value if ntpath.isabs(value) else ntpath.normpath(ntpath.join(cwd, value))
return value if posixpath.isabs(value) else posixpath.normpath(posixpath.join(cwd, value))
def _shell_finder(environment: Mapping[str, str], supplied: Optional[CommandFinder], *,
cwd: str, windows: bool) -> CommandFinder:
"""
查找全部以 launch cwd 定位,Windows 裸命令也不借用父进程的隐式当前目录。
"""
paths = ntpath if windows else posixpath
entries = [_launch_path(entry, cwd, windows=windows)
for entry in environment.get("PATH", paths.defpath).split(paths.pathsep)]
search_path = paths.pathsep.join(entries)
def find(command: str) -> Optional[str]:
"""
注入 finder 的一参数接口保持不变,明确的相对路径先转换成实际绝对目标。
"""
selected = _launch_path(command, cwd, windows=windows) if paths.dirname(command) else command
if supplied is not None:
located = supplied(selected)
elif not windows or paths.dirname(selected):
located = shutil.which(selected, path=search_path)
else:
located = next((found for entry in entries
if (found := shutil.which(paths.join(entry, selected), path=search_path))), None)
if located is None:
return None
return _launch_path(located, cwd, windows=windows)
return find
def resolve_agent_shell(
*,
platform_name: Optional[str] = None,
command_finder: Optional[CommandFinder] = None,
environment: Optional[Mapping[str, str]] = None,
executable: Optional[str] = None,
login: Optional[bool] = None,
cwd: Optional[str] = None,
) -> AgentShell:
"""
为一次性命令、后台 pipe 和 PTY 统一解析实际解释器及登录参数。
:param platform_name: 用于测试覆盖的 os.name,默认读取当前平台
:param command_finder: 可注入命令定位函数;默认按实际子进程 PATH 查找
:param environment: 完整子进程环境;未传时继承宿主,POSIX 的 SHELL 未设置时再使用宿主 SHELL
:param executable: 明确指定解释器名称或单个可执行路径,优先于平台默认策略
:param login: 明确启停登录模式;POSIX 默认关闭,Windows 未指定时保留既有调用参数
:param cwd: 启动目录,相对 shell 与 PATH 项都以此为基准;未传时使用父进程当前目录
:return: 可以直接交给 exec 的解释器策略,未知语言或无效组合在启动前拒绝
"""
if login is not None and not isinstance(login, bool):
raise ValueError("login 必须为布尔值")
if cwd is not None and not isinstance(cwd, str):
raise ValueError("shell cwd 必须是目录路径字符串")
current_environment = environment if environment is not None else os.environ
windows = (platform_name or os.name) == "nt"
launch_cwd = _launch_path(cwd or os.getcwd(), os.getcwd(), windows=windows)
finder = _shell_finder(current_environment, command_finder, cwd=launch_cwd, windows=windows)
if not windows:
selected = executable if executable is not None else current_environment.get("SHELL") or os.environ.get("SHELL") or "/bin/sh"
path = _find_executable(selected, finder, windows=False)
return _posix_policy(
_shell_kind(path, windows=False), path, login=bool(login),
command_prefix=_project_python_alias_prefix(current_environment, windows=False),
)
git_path = finder("git") or finder("git.exe")
if executable is not None:
path = _find_executable(executable, finder, windows=True)
return _windows_policy(_shell_kind(path, windows=True), path, login=login, git_available=bool(git_path))
if git_path:
bash_path = _find_git_bash(git_path, finder)
if bash_path:
return _windows_policy("git-bash", bash_path, login=login, git_available=True, legacy_git_bash=True)
pwsh_path = finder("pwsh") or finder("pwsh.exe")
if pwsh_path:
return _windows_policy("pwsh", pwsh_path, login=login, git_available=bool(git_path))
cmd_path = current_environment.get("COMSPEC") or finder("cmd") or finder("cmd.exe") or "cmd.exe"
if ntpath.dirname(cmd_path):
cmd_path = _launch_path(cmd_path, launch_cwd, windows=True)
if _shell_kind(cmd_path, windows=True) != "cmd":
raise ValueError("COMSPEC 必须指向 cmd.exe;其他解释器请通过 shell 明确指定")
return _windows_policy("cmd", cmd_path, login=login, git_available=bool(git_path))
def resolve_agent_cwd(cwd: Optional[str], *, root_path: Path) -> str:
"""
统一默认根目录、相对目录和用户目录展开,并在启动前验证实际目录存在。
"""
if cwd is not None and not isinstance(cwd, str):
raise ValueError("cwd 必须是目录路径字符串")
path = Path(cwd).expanduser() if cwd else root_path.expanduser()
if not path.is_absolute():
path = root_path / path if cwd else path
path = path.resolve()
if not path.exists():
raise FileNotFoundError(f"工作目录不存在: {path}")
if not path.is_dir():
raise NotADirectoryError(f"工作目录不是目录: {path}")
return str(path)
def build_agent_subprocess_env(
overrides: Optional[Mapping[str, Any]] = None,
*,
platform_name: Optional[str] = None,
) -> dict[str, str]:
"""
构造 Agent 子进程环境,并在 Windows 上强制覆盖为 UTF-8 编解码。
:param overrides: 调用方附加的环境变量
:param platform_name: 用于测试覆盖的 os.name,默认读取当前平台
:return: 可直接传给 subprocess 的字符串环境变量
"""
environment = os.environ.copy()
for key, value in (overrides or {}).items():
if value is not None:
environment[str(key)] = str(value)
if (platform_name or os.name) == "nt":
# Python 会把该变量的任意非空值视为启用,必须移除而不是写入 "0"。
environment.pop("PYTHONLEGACYWINDOWSSTDIO", None)
environment.update(WINDOWS_UTF8_ENVIRONMENT)
return environment
def bind_agent_project_environment(
environment: Mapping[str, str],
*,
project_root: Path,
platform_name: Optional[str] = None,
) -> dict[str, str]:
"""
将已安装项目的虚拟环境绑定到 Agent 命令子进程。
:param environment: 已完成调用方覆盖和平台编码处理的环境变量
:param project_root: MoviePilot 项目根目录
:param platform_name: 用于测试覆盖的 os.name,默认读取当前平台
:return: 优先使用项目虚拟环境的子进程环境变量
"""
bound_environment = dict(environment)
windows = (platform_name or os.name) == "nt"
python_names = ("python.exe", "python3.exe") if windows else ("python", "python3")
project_runtime_name = "moviepilot-python.exe" if windows else "moviepilot-python"
venv_paths = [Path(project_root).expanduser() / "venv"]
configured_venv = bound_environment.get("VENV_PATH")
if configured_venv:
venv_paths.append(Path(configured_venv).expanduser())
selected_venv = next(
(
venv_path
for venv_path in venv_paths
for name in (project_runtime_name, *python_names)
if (venv_path / ("Scripts" if windows else "bin") / name).is_file()
),
None,
)
if selected_venv is None:
return bound_environment
bin_path = selected_venv / ("Scripts" if windows else "bin")
python_path = next(
bin_path / name for name in (project_runtime_name, *python_names)
if (bin_path / name).is_file()
)
path_separator = ";" if windows else os.pathsep
existing_path = bound_environment.get("PATH", "")
bound_environment["PATH"] = path_separator.join(
part for part in (str(bin_path), existing_path) if part
)
bound_environment["VIRTUAL_ENV"] = str(selected_venv)
# CLI 同样优先这个入口;macOS 上它可能保留与标准 venv Python 不同的网络权限身份。
bound_environment["MOVIEPILOT_PYTHON"] = str(python_path)
return bound_environment
def agent_text_subprocess_kwargs(
*, platform_name: Optional[str] = None
) -> dict[str, Any]:
"""
为 Windows Agent 文本子进程补充 UTF-8 解码参数,POSIX 返回空参数。
"""
if (platform_name or os.name) != "nt":
return {}
return {
"encoding": "utf-8",
"errors": "replace",
"env": build_agent_subprocess_env(platform_name="nt"),
}