""" 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"), }