diff --git a/app/agent/loader.py b/app/agent/loader.py index 4e0d66864..2adf30760 100644 --- a/app/agent/loader.py +++ b/app/agent/loader.py @@ -23,7 +23,6 @@ from app.agent.capabilities.adapter import ( from app.runtime.capabilities.model import CapabilityMaterializationState from app.runtime.capabilities.runtime import CapabilityRuntime - _runtime_lock = threading.RLock() _agent_runtime: CapabilityRuntime | None = None @@ -135,7 +134,7 @@ def is_tool_factory_materialized() -> bool: async def close_materialized_terminal_sessions() -> None: """关闭已物化的终端会话管理器,不触发新的 Agent 工具导入。""" - module = sys.modules.get("app.agent.tools.impl._terminal_session") + module = sys.modules.get("app.agent.terminal.manager") manager = getattr(module, "terminal_session_manager", None) if module else None close = getattr(manager, "close", None) if callable(close): diff --git a/app/agent/prompt/__init__.py b/app/agent/prompt/__init__.py index ccc0126ae..61c56a819 100644 --- a/app/agent/prompt/__init__.py +++ b/app/agent/prompt/__init__.py @@ -266,9 +266,14 @@ class PromptManager: f"- 运行环境: {SystemUtils.platform} {'docker' if SystemUtils.is_docker() else ''}", "- 详细运行状态和数据库通过 `query_doctor_report` 或 `execute_command` 查询;配置值先加载对应 Skill,再通过 `moviepilot_api` 的配置 operation 查询。", ] - shell = resolve_agent_shell() - if shell: - info_lines.append(shell.prompt_guidance()) + try: + shell = resolve_agent_shell(cwd=str(get_runtime_setting("ROOT_PATH"))) + except (OSError, ValueError): + # 命令解释器配置错误不应阻断其余业务工具和对话。 + info_lines.append("- 默认命令解释器不可用;需要执行命令时,在 `execute_command` 中显式选择可用的 `shell`。") + else: + if shell: + info_lines.append(shell.prompt_guidance()) path_lines = self._get_runtime_path_lines() if path_lines: info_lines.extend( diff --git a/app/agent/shell.py b/app/agent/shell.py index 3d7a7436c..31b66cd70 100644 --- a/app/agent/shell.py +++ b/app/agent/shell.py @@ -2,10 +2,12 @@ from __future__ import annotations +import ntpath import os +import posixpath import shutil from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import Any, Callable, Mapping, Optional WINDOWS_UTF8_ENVIRONMENT = { @@ -29,13 +31,14 @@ CommandFinder = Callable[[str], Optional[str]] @dataclass(frozen=True) class AgentShell: - """描述 Windows Agent 实际使用的命令解释器及模型指导。""" + """描述 Agent 实际使用的解释器、登录模式及与其一致的命令参数。""" 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 的命令参数。""" @@ -43,27 +46,85 @@ class AgentShell: return [self.executable, *self.arguments, command_text] def prompt_guidance(self) -> str: - """返回不包含可执行文件绝对路径的 Windows 命令指导。""" + """指导模型使用实际解释器语法,不泄露可执行文件的安装路径。""" + 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)。所有 `execute_command` " - "命令统一使用 Git Bash/POSIX 语法;涉及仓库、源码状态或版本控制时优先使用 " - "`git`,不要混用 cmd.exe 或 PowerShell 语法。" + "- Windows Agent 命令环境: 默认使用 Git Bash(UTF-8)及 Git Bash/POSIX 语法;" + f"涉及仓库、源码状态或版本控制时优先使用 `git`。{selection_guidance}" ) - if self.kind == "pwsh": + 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 ( - "- Windows Agent 命令环境: PowerShell 7 (`pwsh`, UTF-8)。所有 " - f"`execute_command` 命令统一使用 PowerShell 语法{git_guidance},不要混用 " - "cmd.exe、Windows PowerShell 或 POSIX shell 语法。" + 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 兼容回退" - f"{git_guidance}。仅在 Git Bash 和 PowerShell 7 不可用时使用 cmd 语法。" + "- 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) -> 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, git_available=git_available, login=login) + + +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) @@ -88,59 +149,106 @@ def _find_git_bash(git_path: str, command_finder: CommandFinder) -> Optional[str 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, -) -> Optional[AgentShell]: + executable: Optional[str] = None, + login: Optional[bool] = None, + cwd: Optional[str] = None, +) -> AgentShell: """ - 解析 Windows Agent 命令解释器,非 Windows 返回 None 以保留原有 shell 行为。 + 为一次性命令、后台 pipe 和 PTY 统一解析实际解释器及登录参数。 :param platform_name: 用于测试覆盖的 os.name,默认读取当前平台 - :param command_finder: 命令定位函数,默认使用 shutil.which - :param environment: 用于读取 COMSPEC 的环境变量 - :return: Windows 命令解释器策略,非 Windows 返回 None + :param command_finder: 可注入命令定位函数;默认按实际子进程 PATH 查找 + :param environment: 完整子进程环境;未传时继承宿主,POSIX 的 SHELL 未设置时再使用宿主 SHELL + :param executable: 明确指定解释器名称或单个可执行路径,优先于平台默认策略 + :param login: 明确启停登录模式;POSIX 默认关闭,Windows 未指定时保留既有调用参数 + :param cwd: 启动目录,相对 shell 与 PATH 项都以此为基准;未传时使用父进程当前目录 + :return: 可以直接交给 exec 的解释器策略,未知语言或无效组合在启动前拒绝 """ - if (platform_name or os.name) != "nt": - return None - - finder = command_finder or shutil.which + 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)) + 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 AgentShell( - kind="git-bash", - executable=bash_path, - arguments=("--noprofile", "--norc", "-lc"), - git_available=True, - ) - + 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 AgentShell( - kind="pwsh", - executable=pwsh_path, - arguments=("-NoLogo", "-NoProfile", "-Command"), - command_prefix=_POWERSHELL_UTF8_PREFIX, - git_available=bool(git_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)) - cmd_path = ( - current_environment.get("COMSPEC") - or finder("cmd") - or finder("cmd.exe") - or "cmd.exe" - ) - return AgentShell( - kind="cmd", - executable=cmd_path, - arguments=("/d", "/s", "/c"), - command_prefix="chcp 65001>nul &", - 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( diff --git a/app/agent/terminal/__init__.py b/app/agent/terminal/__init__.py new file mode 100644 index 000000000..a41e386a1 --- /dev/null +++ b/app/agent/terminal/__init__.py @@ -0,0 +1 @@ +"""Agent 终端会话的状态、进程和输入输出管理。""" diff --git a/app/agent/tools/impl/_terminal_session.py b/app/agent/terminal/manager.py similarity index 75% rename from app/agent/tools/impl/_terminal_session.py rename to app/agent/terminal/manager.py index 17a3228a4..fb22a1fd9 100644 --- a/app/agent/tools/impl/_terminal_session.py +++ b/app/agent/terminal/manager.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import codecs import errno import json import os @@ -11,26 +10,27 @@ import signal import subprocess import time import uuid -from dataclasses import dataclass, field -from pathlib import Path +from types import ModuleType from typing import Any, Optional -from app.agent.shell import build_agent_subprocess_env, resolve_agent_shell +from app.agent.shell import AgentShell, build_agent_subprocess_env, resolve_agent_cwd, resolve_agent_shell +from app.agent.terminal.session import TERMINAL_RETENTION_SECONDS, _TerminalSession from app.agent.tools.impl._command_safety import validate_command_safety from app.runtime.log import logger from app.runtime.settings import get_runtime_setting if os.name == "posix": - import fcntl as _fcntl - import pty as _pty + import fcntl as _posix_fcntl + import pty as _posix_pty + + _fcntl: Optional[ModuleType] = _posix_fcntl + _pty: Optional[ModuleType] = _posix_pty else: _fcntl = None _pty = None TERMINAL_CONCURRENCY_LIMIT = 4 -TERMINAL_RETENTION_SECONDS = 30 * 60 -TERMINAL_MAX_RETAINED_BYTES = 1024 * 1024 TERMINAL_DEFAULT_READ_BYTES = 10 * 1024 TERMINAL_MAX_READ_BYTES = 64 * 1024 TERMINAL_READ_CHUNK_SIZE = 4096 @@ -40,6 +40,7 @@ TERMINAL_WAIT_MAX_MS = 60 * 1000 TERMINAL_YIELD_DEFAULT_MS = 250 TERMINAL_YIELD_MAX_MS = 10 * 1000 TERMINAL_KILL_GRACE_SECONDS = 3 +_KILL_SIGNAL = getattr(signal, "SIGKILL", 9) class TerminalOutputError(ValueError): @@ -52,133 +53,6 @@ class TerminalOutputError(ValueError): self.minimum_read_bytes = minimum_read_bytes -@dataclass -class _TerminalChunk: - """记录终端输出分片,供增量读取时按 seq 过滤。""" - - seq: int - stream: str - text: str - byte_size: int - created_at: float - - -@dataclass -class _TerminalSession: - """保存一个后台命令会话的进程、输出和状态。""" - - session_id: str - command: str - cwd: str - pid: int - use_pty: bool - created_at: float = field(default_factory=time.time) - updated_at: float = field(default_factory=time.time) - status: str = "running" - exit_code: Optional[int] = None - process: Optional[asyncio.subprocess.Process] = None - master_fd: Optional[int] = None - chunks: list[_TerminalChunk] = field(default_factory=list) - next_seq: int = 1 - retained_from_seq: int = 1 - retained_bytes: int = 0 - kill_requested: bool = False - error: Optional[str] = None - reader_tasks: list[asyncio.Task] = field(default_factory=list) - wait_task: Optional[asyncio.Task] = None - changed_event: asyncio.Event = field(default_factory=asyncio.Event) - output_complete: bool = False - output_lost: bool = False - _decoders: dict[str, Any] = field(default_factory=dict, repr=False) - _finished_streams: set[str] = field(default_factory=set, repr=False) - _last_stream: Optional[str] = None - - def notify_changed(self) -> None: - """轮换事件并唤醒全部旧等待者,避免 clear 造成检查与等待之间丢失通知。""" - previous = self.changed_event - self.changed_event = asyncio.Event() - previous.set() - - def append_output(self, stream: str, data: bytes) -> None: - """每个流独立增量解码,跨 OS 读取的半个字符不会提前替换。""" - if not data or stream in self._finished_streams: - return - decoder = self._decoders.setdefault(stream, codecs.getincrementaldecoder("utf-8")(errors="replace")) - self._append_text(stream, decoder.decode(data)) - - def finish_stream(self, stream: str, *, complete: bool = True) -> None: - """流收尾时冲洗最后字符,非 EOF 收尾显式记录输出可能丢失。""" - if stream in self._finished_streams: - return - self._finished_streams.add(stream) - if not complete: - self.output_lost = True - decoder = self._decoders.get(stream) - if decoder is not None: - self._append_text(stream, decoder.decode(b"", final=True)) - self.notify_changed() - - def _append_text(self, stream: str, text: str) -> None: - """把流标签与正文固定成不可变显示分片,游标也覆盖标签的字节。""" - if not text: - return - if not self.use_pty and stream != self._last_stream: - title = "标准输出" if stream == "stdout" else "错误输出" - text = f"\n[{title}]\n{text}" - self._last_stream = stream - chunk = _TerminalChunk( - seq=self.next_seq, - stream=stream, - text=text, - byte_size=len(text.encode("utf-8")), - created_at=time.time(), - ) - self.next_seq += 1 - self.chunks.append(chunk) - self.retained_bytes += chunk.byte_size - self.updated_at = chunk.created_at - self._trim_output() - self.notify_changed() - - def finish_output(self) -> None: - """读取器全部停止后发布最终边界,进程退出不能提前代表日志收齐。""" - for stream in self._decoders: - self.finish_stream(stream, complete=stream in self._finished_streams) - self.output_complete = True - self.notify_changed() - - def _trim_output(self) -> None: - """移除超出保留上限的旧输出分片。""" - while self.retained_bytes > TERMINAL_MAX_RETAINED_BYTES and self.chunks: - removed = self.chunks.pop(0) - self.retained_bytes -= removed.byte_size - self.retained_from_seq = removed.seq + 1 - - def mark_finished(self, exit_code: Optional[int]) -> None: - """标记进程已经结束,并记录退出码。""" - self.exit_code = exit_code - self.status = "killed" if self.kill_requested else "exited" - self.updated_at = time.time() - self.notify_changed() - - def mark_error(self, message: str) -> None: - """标记会话异常,保留错误信息供后续读取。""" - self.error = message - self.status = "error" - self.updated_at = time.time() - self.notify_changed() - - def close_pty(self) -> None: - """关闭父进程持有的 PTY master fd。""" - if self.master_fd is None: - return - try: - os.close(self.master_fd) - except OSError: - pass - self.master_fd = None - - class _TerminalSessionManager: """管理 Agent 后台终端会话的生命周期。""" @@ -203,27 +77,6 @@ class _TerminalSessionManager: return value.strip().lower() not in {"false", "0", "no", "off"} return bool(value) - @staticmethod - def _normalize_cwd(cwd: Optional[str]) -> str: - """解析工作目录,未传入时默认使用 MoviePilot 项目根目录。""" - if not cwd: - return str(get_runtime_setting('ROOT_PATH')) - path = Path(cwd).expanduser() - if not path.is_absolute(): - path = (get_runtime_setting('ROOT_PATH') / path).resolve() - else: - path = path.resolve() - if not path.exists(): - raise FileNotFoundError(f"工作目录不存在: {path}") - if not path.is_dir(): - raise NotADirectoryError(f"工作目录不是目录: {path}") - return str(path) - - @staticmethod - def _build_env(env: Optional[dict[str, Any]]) -> dict[str, str]: - """合并环境变量,Windows 强制覆盖为 UTF-8 编解码。""" - return build_agent_subprocess_env(env) - @staticmethod def _validate_command(command: str, *, confirmed: bool = False) -> None: """拒绝明显危险或空白命令。""" @@ -248,7 +101,7 @@ class _TerminalSessionManager: if os.name == "posix": kwargs["start_new_session"] = True elif os.name == "nt": - kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP") return kwargs async def start( @@ -263,6 +116,8 @@ class _TerminalSessionManager: max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES, since_offset: Optional[int] = None, max_output_chars: Optional[int] = None, + shell: Optional[str] = None, + login: Optional[bool] = None, ) -> dict[str, Any]: """启动后台命令,在首个输出、完成或首次等待预算到期时交付会话。""" self._validate_command(command, confirmed=confirm_dangerous) @@ -270,8 +125,9 @@ class _TerminalSessionManager: if since_offset is not None and (type(since_offset) is not int or since_offset != 0): raise TerminalOutputError("新会话的 since_offset 只能为 0 或 null") initial_wait = self._normalize_yield_timeout(yield_time_ms) - normalized_cwd = self._normalize_cwd(cwd) - normalized_env = self._build_env(env) + normalized_cwd = resolve_agent_cwd(cwd, root_path=get_runtime_setting('ROOT_PATH')) + normalized_env = build_agent_subprocess_env(env) + shell_policy = resolve_agent_shell(executable=shell, login=login, environment=normalized_env, cwd=normalized_cwd) should_use_pty = self._normalize_bool(use_pty, default=True) and os.name == "posix" async with self._lock: @@ -294,10 +150,10 @@ class _TerminalSessionManager: session_released = False try: session = ( - await self._start_pty_session(command, normalized_cwd, normalized_env) + await self._start_pty_session(command, normalized_cwd, normalized_env, shell_policy=shell_policy) if should_use_pty else await self._start_pipe_session( - command, normalized_cwd, normalized_env + command, normalized_cwd, normalized_env, shell_policy=shell_policy ) ) @@ -315,8 +171,8 @@ class _TerminalSessionManager: session_released = True raise RuntimeError("终端会话管理器已关闭") logger.info( - "启动后台终端会话: session_id=%s, pid=%s, use_pty=%s, command=%s", - session.session_id, session.pid, session.use_pty, command, + f"启动后台终端会话: session_id={session.session_id}, pid={session.pid}, " + f"use_pty={session.use_pty}, command={command}" ) payload = await self._wait_for_output( session, timeout_ms=initial_wait, since_seq=0, since_offset=since_offset, @@ -349,18 +205,21 @@ class _TerminalSessionManager: self._sessions.pop(session.session_id) async def _start_pty_session( - self, command: str, cwd: str, env: dict[str, str] + self, command: str, cwd: str, env: dict[str, str], *, shell_policy: Optional[AgentShell] = None, ) -> _TerminalSession: """通过 PTY fork 启动交互式命令会话。""" if _pty is None: raise RuntimeError("当前平台不支持 PTY 会话") + shell_policy = shell_policy or resolve_agent_shell(environment=env, cwd=cwd) + argv = shell_policy.build_argv(command) pid, master_fd = _pty.fork() if pid == 0: - os.chdir(cwd) - os.environ.clear() - os.environ.update(env) - shell = os.environ.get("SHELL") or "/bin/sh" - os.execl(shell, shell, "-lc", command) + try: + os.chdir(cwd) + os.execvpe(argv[0], argv, env) + finally: + # exec 失败的子进程不能回到继承的事件循环或执行父进程清理逻辑。 + os._exit(127) self._set_nonblocking(master_fd) session = _TerminalSession( @@ -370,30 +229,20 @@ class _TerminalSessionManager: pid=pid, use_pty=True, master_fd=master_fd, + shell_policy=shell_policy, ) session.reader_tasks.append(asyncio.create_task(self._read_pty(session))) session.wait_task = asyncio.create_task(self._wait_pty_process(session)) return session async def _start_pipe_session( - self, command: str, cwd: str, env: dict[str, str] + self, command: str, cwd: str, env: dict[str, str], *, shell_policy: Optional[AgentShell] = None, ) -> _TerminalSession: """通过普通 stdin/stdout/stderr 管道启动命令会话。""" - shell = resolve_agent_shell() - if shell: - process = await asyncio.create_subprocess_exec( - *shell.build_argv(command), - cwd=cwd, - env=env, - **self._pipe_subprocess_kwargs(), - ) - else: - process = await asyncio.create_subprocess_shell( - command, - cwd=cwd, - env=env, - **self._pipe_subprocess_kwargs(), - ) + shell_policy = shell_policy or resolve_agent_shell(environment=env, cwd=cwd) + process = await asyncio.create_subprocess_exec( + *shell_policy.build_argv(command), cwd=cwd, env=env, **self._pipe_subprocess_kwargs(), + ) session = _TerminalSession( session_id=f"term_{uuid.uuid4().hex[:12]}", command=command, @@ -401,6 +250,7 @@ class _TerminalSessionManager: pid=process.pid or 0, use_pty=False, process=process, + shell_policy=shell_policy, ) if process.stdout: session.reader_tasks.append( @@ -570,33 +420,90 @@ class _TerminalSessionManager: async def write( self, *, session_id: str, input_text: str, since_seq: Optional[int] = None, since_offset: Optional[int] = None, max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES, - max_output_chars: Optional[int] = None, + max_output_chars: Optional[int] = None, close_stdin: bool = False, ) -> dict[str, Any]: - """向会话 stdin 写入文本,PTY 模式下写入 master fd。""" + """串行写入输入和可选管道 EOF;关闭 stdin 不影响输出读取器。""" session = self.get_session(session_id) self._validate_output_budget(max_output_chars) self._resolve_cursor(session, since_seq=since_seq, since_offset=since_offset) - if session.status != "running": - raise RuntimeError(f"会话已结束,当前状态: {session.status}") - + if type(close_stdin) is not bool: + raise ValueError("close_stdin 必须为布尔值") + if close_stdin and session.use_pty: + raise ValueError("PTY 不支持独立关闭 stdin;输出端保持打开,请使用程序自己的结束命令") data = (input_text or "").encode("utf-8") - if session.use_pty: - if session.master_fd is None: - raise RuntimeError("PTY 已关闭") - await asyncio.to_thread(os.write, session.master_fd, data) - else: - if not session.process or not session.process.stdin: - raise RuntimeError("进程 stdin 不可写") - session.process.stdin.write(data) - await session.process.stdin.drain() + written = 0 + async with session.input_lock: + if session.stdin_closed: + if data or not close_stdin: + raise RuntimeError("会话 stdin 已关闭,不能再写入输入") + elif session.status != "running": + raise RuntimeError(f"会话已结束,当前状态: {session.status}") + elif session.use_pty: + while written < len(data): + if session.master_fd is None: + raise RuntimeError("PTY 已关闭") + try: + # master 已设为非阻塞;同一事件循环完成写入,避免线程持有关闭前的 fd。 + count = os.write(session.master_fd, memoryview(data)[written:]) + except BlockingIOError: + await asyncio.sleep(TERMINAL_PTY_POLL_INTERVAL) + continue + if count <= 0: + raise BrokenPipeError("PTY 未接受后续输入") + written += count + else: + await self._write_pipe_input(session, data, close_stdin=close_stdin) + written = len(data) session.updated_at = time.time() payload = self._read_payload( session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=True, - max_output_chars=max_output_chars, extra_fields={"written_bytes": len(data)}, + max_output_chars=max_output_chars, extra_fields={"written_bytes": written}, ) return payload + @staticmethod + async def _write_pipe_input(session: _TerminalSession, data: bytes, *, close_stdin: bool) -> None: + """在会话输入锁内先排空末段,再半关闭并等待写端收尾。""" + writer = session.process.stdin if session.process else None + if writer is None: + raise RuntimeError("进程 stdin 不可写") + if data: + writer.write(data) + await writer.drain() + if close_stdin: + writer.close() + session.stdin_closed = True + await writer.wait_closed() + + async def interrupt( + self, *, session_id: str, since_seq: Optional[int] = None, since_offset: Optional[int] = None, + max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES, max_output_chars: Optional[int] = None, + ) -> dict[str, Any]: + """只发送一次真实中断信号,不改变终止意图,也不等待或升级成强杀。""" + session = self.get_session(session_id) + self._validate_output_budget(max_output_chars) + self._resolve_cursor(session, since_seq=since_seq, since_offset=since_offset) + event = getattr(signal, "CTRL_BREAK_EVENT", None) + sender = getattr(session.process, "send_signal", None) + if os.name != "posix" and (event is None or not callable(sender)): + raise NotImplementedError("当前平台不支持可独立发送的控制台中断,未终止进程") + sent = False + if session.status == "running": + try: + if os.name == "posix": + os.killpg(session.pid, signal.SIGINT) + elif callable(sender): + sender(event) + sent = True + except ProcessLookupError: + pass + return self._read_payload( + session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=True, + max_output_chars=max_output_chars, + extra_fields={"signal": "SIGINT" if os.name == "posix" else "CTRL_BREAK_EVENT", "signal_sent": sent}, + ) + async def kill( self, *, @@ -611,25 +518,12 @@ class _TerminalSessionManager: session = self.get_session(session_id) self._validate_output_budget(max_output_chars) self._resolve_cursor(session, since_seq=since_seq, since_offset=since_offset) - if session.status != "running": - return self._read_payload( - session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=True, - max_output_chars=max_output_chars, - ) - - session.kill_requested = True signal_number = self._resolve_signal(sig) - self._send_signal(session, signal_number) - - if session.wait_task and not session.wait_task.done(): - try: - await asyncio.wait_for( - asyncio.shield(session.wait_task), - timeout=TERMINAL_KILL_GRACE_SECONDS, - ) - except asyncio.TimeoutError: - force_signal = getattr(signal, "SIGKILL", signal.SIGTERM) - self._send_signal(session, force_signal) + if session.status == "running": + session.kill_requested = True + self._send_signal(session, signal_number) + if not await self._wait_for_exit(session): + self._send_signal(session, _KILL_SIGNAL) return self._read_payload( session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=True, @@ -663,27 +557,10 @@ class _TerminalSessionManager: session.kill_requested = True self._send_signal(session, signal.SIGTERM) - wait_task = session.wait_task - if wait_task and not wait_task.done(): - try: - await asyncio.wait_for( - asyncio.shield(wait_task), - timeout=TERMINAL_KILL_GRACE_SECONDS, - ) - except asyncio.TimeoutError: - force_signal = getattr(signal, "SIGKILL", signal.SIGTERM) - self._send_signal(session, force_signal) - try: - await asyncio.wait_for( - asyncio.shield(wait_task), - timeout=TERMINAL_KILL_GRACE_SECONDS, - ) - except asyncio.TimeoutError: - logger.error( - "终端会话关闭超时: session_id=%s, pid=%s", - session.session_id, - session.pid, - ) + if not await self._wait_for_exit(session): + self._send_signal(session, _KILL_SIGNAL) + if not await self._wait_for_exit(session): + logger.error(f"终端会话关闭超时: session_id={session.session_id}, pid={session.pid}") for task in session.reader_tasks: if not task.done(): @@ -693,6 +570,17 @@ class _TerminalSessionManager: session.finish_output() session.close_pty() + @staticmethod + async def _wait_for_exit(session: _TerminalSession) -> bool: + """复用终止与关闭的有界等待,超时不取消会话原本的进程收尾任务。""" + if session.wait_task is None or session.wait_task.done(): + return True + try: + await asyncio.wait_for(asyncio.shield(session.wait_task), timeout=TERMINAL_KILL_GRACE_SECONDS) + except asyncio.TimeoutError: + return False + return True + def get_session(self, session_id: str) -> _TerminalSession: """按 ID 获取会话,不存在时抛出清晰错误。""" session = self._sessions.get(session_id) @@ -876,15 +764,22 @@ class _TerminalSessionManager: @staticmethod def _resolve_signal(sig: Optional[str | int]) -> int: - """解析字符串或数字形式的信号名。""" - if isinstance(sig, int): - return sig - signal_name = str(sig or "TERM").strip().upper() - if signal_name.isdigit(): - return int(signal_name) - if not signal_name.startswith("SIG"): - signal_name = f"SIG{signal_name}" - return int(getattr(signal, signal_name, signal.SIGTERM)) + """只接受已知有效且当前终止路径可真实发送的信号,绝不把拼写错误当 TERM。""" + if isinstance(sig, bool) or (sig is not None and not isinstance(sig, (str, int))): + raise ValueError("signal 必须为已知名称或有效正整数") + number: Optional[int] = int(sig) if isinstance(sig, int) else None + if not isinstance(sig, int): + name = "TERM" if sig is None else sig.strip().upper() + label = name if name.startswith("SIG") else f"SIG{name}" + number = int(name) if name.isdigit() else signal.Signals.__members__.get(label) + if label == "SIGKILL" and os.name == "nt": + number = _KILL_SIGNAL + valid = {int(value) for value in signal.valid_signals()} + if os.name == "nt": + valid = {int(signal.SIGTERM), int(_KILL_SIGNAL)} + if not isinstance(number, int) or number <= 0 or number not in valid: + raise ValueError("未知、无效或当前平台不支持的终止信号;非终止中断请使用 interrupt") + return int(number) @staticmethod def _send_signal(session: _TerminalSession, sig: int) -> None: @@ -893,10 +788,12 @@ class _TerminalSessionManager: if os.name == "posix": os.killpg(session.pid, sig) elif session.process: - if sig == getattr(signal, "SIGKILL", None): + if sig == _KILL_SIGNAL: session.process.kill() - else: + elif sig == signal.SIGTERM: session.process.terminate() + else: + raise ValueError("当前平台没有该信号的真实发送映射") except ProcessLookupError: pass @@ -938,6 +835,8 @@ class _TerminalSessionManager: command = _TerminalSessionManager._text_preview(session.command) cwd = _TerminalSessionManager._text_preview(session.cwd) error = _TerminalSessionManager._text_preview(session.error, 512) if session.error else session.error + shell = session.shell_policy.executable if session.shell_policy else None + shell_preview = _TerminalSessionManager._text_preview(shell, 256) if shell else shell if session.status == "running": outcome = "pending" elif session.status == "error": @@ -955,6 +854,8 @@ class _TerminalSessionManager: "exit_code": session.exit_code, "execution_outcome": outcome, "use_pty": session.use_pty, + "shell": shell_preview, "shell_truncated": shell_preview != shell, + "login": session.shell_policy.login if session.shell_policy else None, "stdin_closed": session.stdin_closed, "last_seq": session.next_seq - 1, "retained_from_seq": session.retained_from_seq, "output_complete": session.output_complete, diff --git a/app/agent/terminal/session.py b/app/agent/terminal/session.py new file mode 100644 index 000000000..e749ae185 --- /dev/null +++ b/app/agent/terminal/session.py @@ -0,0 +1,147 @@ +"""终端会话状态、增量文本和有界输出保留。""" + +from __future__ import annotations + +import asyncio +import codecs +import os +import time +from dataclasses import dataclass, field +from typing import Any, Optional + +from app.agent.shell import AgentShell + +TERMINAL_RETENTION_SECONDS = 30 * 60 +TERMINAL_MAX_RETAINED_BYTES = 1024 * 1024 + + +@dataclass +class _TerminalChunk: + """记录终端输出分片,供增量读取时按 seq 过滤。""" + + seq: int + stream: str + text: str + byte_size: int + created_at: float + + +@dataclass +class _TerminalSession: + """保存一个后台命令会话的进程、输出和状态。""" + + session_id: str + command: str + cwd: str + pid: int + use_pty: bool + shell_policy: Optional[AgentShell] = None + stdin_closed: bool = False + input_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + status: str = "running" + exit_code: Optional[int] = None + process: Optional[asyncio.subprocess.Process] = None + master_fd: Optional[int] = None + chunks: list[_TerminalChunk] = field(default_factory=list) + next_seq: int = 1 + retained_from_seq: int = 1 + retained_bytes: int = 0 + kill_requested: bool = False + error: Optional[str] = None + reader_tasks: list[asyncio.Task[None]] = field(default_factory=list) + wait_task: Optional[asyncio.Task[None]] = None + changed_event: asyncio.Event = field(default_factory=asyncio.Event) + output_complete: bool = False + output_lost: bool = False + _decoders: dict[str, Any] = field(default_factory=dict, repr=False) + _finished_streams: set[str] = field(default_factory=set, repr=False) + _last_stream: Optional[str] = None + + def notify_changed(self) -> None: + """轮换事件并唤醒全部旧等待者,避免 clear 造成检查与等待之间丢失通知。""" + previous = self.changed_event + self.changed_event = asyncio.Event() + previous.set() + + def append_output(self, stream: str, data: bytes) -> None: + """每个流独立增量解码,跨 OS 读取的半个字符不会提前替换。""" + if not data or stream in self._finished_streams: + return + decoder = self._decoders.setdefault(stream, codecs.getincrementaldecoder("utf-8")(errors="replace")) + self._append_text(stream, decoder.decode(data)) + + def finish_stream(self, stream: str, *, complete: bool = True) -> None: + """流收尾时冲洗最后字符,非 EOF 收尾显式记录输出可能丢失。""" + if stream in self._finished_streams: + return + self._finished_streams.add(stream) + if not complete: + self.output_lost = True + decoder = self._decoders.get(stream) + if decoder is not None: + self._append_text(stream, decoder.decode(b"", final=True)) + self.notify_changed() + + def _append_text(self, stream: str, text: str) -> None: + """把流标签与正文固定成不可变显示分片,游标也覆盖标签的字节。""" + if not text: + return + if not self.use_pty and stream != self._last_stream: + title = "标准输出" if stream == "stdout" else "错误输出" + text = f"\n[{title}]\n{text}" + self._last_stream = stream + chunk = _TerminalChunk( + seq=self.next_seq, + stream=stream, + text=text, + byte_size=len(text.encode("utf-8")), + created_at=time.time(), + ) + self.next_seq += 1 + self.chunks.append(chunk) + self.retained_bytes += chunk.byte_size + self.updated_at = chunk.created_at + self._trim_output() + self.notify_changed() + + def finish_output(self) -> None: + """读取器全部停止后发布最终边界,进程退出不能提前代表日志收齐。""" + for stream in self._decoders: + self.finish_stream(stream, complete=stream in self._finished_streams) + self.output_complete = True + self.notify_changed() + + def _trim_output(self) -> None: + """移除超出保留上限的旧输出分片。""" + while self.retained_bytes > TERMINAL_MAX_RETAINED_BYTES and self.chunks: + removed = self.chunks.pop(0) + self.retained_bytes -= removed.byte_size + self.retained_from_seq = removed.seq + 1 + + def mark_finished(self, exit_code: Optional[int]) -> None: + """标记进程已经结束,并记录退出码。""" + self.exit_code = exit_code + self.status = "killed" if self.kill_requested else "exited" + if not self.use_pty: + self.stdin_closed = True + self.updated_at = time.time() + self.notify_changed() + + def mark_error(self, message: str) -> None: + """标记会话异常,保留错误信息供后续读取。""" + self.error = message + self.status = "error" + self.updated_at = time.time() + self.notify_changed() + + def close_pty(self) -> None: + """关闭父进程持有的 PTY master fd。""" + if self.master_fd is None: + return + try: + os.close(self.master_fd) + except OSError: + pass + self.master_fd = None diff --git a/app/agent/tools/impl/execute_command.py b/app/agent/tools/impl/execute_command.py index 421d25a12..b8718ea3b 100644 --- a/app/agent/tools/impl/execute_command.py +++ b/app/agent/tools/impl/execute_command.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import codecs import json import os import signal @@ -14,10 +15,8 @@ from typing import Any, Literal, Optional, TextIO, Type from pydantic import BaseModel, Field -from app.agent.shell import build_agent_subprocess_env, resolve_agent_shell -from app.agent.tools.base import DEFAULT_TOOL_RESULT_MAX_CHARS, MoviePilotTool -from app.agent.tools.impl._command_safety import validate_command_safety -from app.agent.tools.impl._terminal_session import ( +from app.agent.shell import build_agent_subprocess_env, resolve_agent_cwd, resolve_agent_shell +from app.agent.terminal.manager import ( TERMINAL_DEFAULT_READ_BYTES, TERMINAL_MAX_READ_BYTES, TERMINAL_WAIT_DEFAULT_MS, @@ -25,8 +24,11 @@ from app.agent.tools.impl._terminal_session import ( TerminalOutputError, get_terminal_session_manager, ) +from app.agent.tools.base import DEFAULT_TOOL_RESULT_MAX_CHARS, MoviePilotTool +from app.agent.tools.impl._command_safety import validate_command_safety from app.agent.tools.tags import ToolTag from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting DEFAULT_TIMEOUT_SECONDS = 60 MAX_TIMEOUT_SECONDS = 300 @@ -58,7 +60,7 @@ class _CommandOutput: """按 UTF-8 字节数截断文本,避免截断后出现非法字符。""" if byte_limit <= 0: return "" - return text.encode("utf-8")[:byte_limit].decode("utf-8", errors="replace") + return text.encode("utf-8")[:byte_limit].decode("utf-8", errors="ignore") def _write_chunk(self, stream_name: str, text: str) -> None: """把输出分片按 stdout/stderr 分段写入临时文件。""" @@ -198,11 +200,12 @@ class _CommandOutput: class ExecuteCommandInput(BaseModel): """执行 Shell 命令工具的输入参数模型。""" - action: Optional[Literal["start", "read", "wait", "write", "kill", "run"]] = Field( + action: Optional[Literal["start", "read", "wait", "write", "interrupt", "kill", "run"]] = Field( "start", description=( "Command action. start launches a managed background session and returns " - "session_id. read/wait/write/kill operate on that session. run executes " + "session_id. read/wait/write/interrupt/kill operate on that session. interrupt sends one " + "interrupt without escalating to a forced kill; kill terminates the session. run executes " "once and waits until completion or timeout." ), ) @@ -216,15 +219,37 @@ class ExecuteCommandInput(BaseModel): ) input_text: Optional[str] = Field( None, - description="Text to send to stdin for action=write. Use \\u0003 for Ctrl+C.", + description=( + "Text to send to stdin for action=write. In pipe mode, \\u0003/\\u0004 are ordinary bytes. " + "In PTY mode their Ctrl+C/EOF behavior depends on terminal settings. Use action=interrupt for a signal." + ), + ) + close_stdin: Optional[bool] = Field( + False, strict=True, + description=( + "For action=write in pipe mode, send optional final input then close stdin so the command receives EOF. " + "Output stays readable. Empty input alone does not send EOF. PTY half-close is not supported." + ), ) signal_name: Optional[str] = Field( "TERM", - description="Signal for action=kill, such as TERM, INT, KILL, or 15.", + description="Signal for action=kill, such as TERM, INT, KILL, or 15. Invalid or unsupported signals are rejected.", ) cwd: Optional[str] = Field( None, - description="Working directory for action=start or action=run.", + description="Launch directory for start/run. Omitted or relative paths use the MoviePilot root directory; ~ is expanded.", + ) + shell: Optional[str] = Field( + None, + description="Shell executable for start/run. All pipe and PTY launches use the same selection policy and report the selected shell.", + ) + login: Optional[bool] = Field( + None, strict=True, + description=( + "For start/run, explicitly enable or disable login-shell startup where supported. " + "POSIX defaults to non-login; Windows preserves its configured shell defaults. " + "Login startup files can change environment and directory." + ), ) env: Optional[dict[str, Any]] = Field( None, @@ -236,7 +261,7 @@ class ExecuteCommandInput(BaseModel): ) since_seq: Optional[int] = Field( None, ge=0, strict=True, - description="For read/wait/write/kill, last fully delivered output seq. Resume with output_until_seq, never last_seq.", + description="For read/wait/write/interrupt/kill, last fully delivered output seq. Resume with output_until_seq, never last_seq.", ) since_offset: Optional[int] = Field( None, ge=0, strict=True, @@ -247,7 +272,7 @@ class ExecuteCommandInput(BaseModel): ) max_bytes: Optional[int] = Field( TERMINAL_DEFAULT_READ_BYTES, - description="For start/read/wait/write/kill, maximum output bytes to return.", + description="For start/read/wait/write/interrupt/kill, maximum output bytes to return.", ) timeout_ms: Optional[int] = Field( TERMINAL_WAIT_DEFAULT_MS, @@ -284,8 +309,10 @@ class ExecuteCommandTool(MoviePilotTool): "launches a background session, waits briefly for initial output, then returns its session_id and output cursor. " "Continue with both output_until_seq and output_until_offset; last_seq is not a consumed cursor. " "Call the same tool with action=read, wait, " - "write, or kill to poll output, wait in short segments, send stdin, or " - "terminate it. Use action=run only when a one-shot bounded command result " + "write, interrupt, or kill to poll output, wait in short segments, send stdin, " + "send one interrupt, or terminate it. write(close_stdin=true) sends EOF in pipe mode after optional final input; " + "PTY input and output cannot be half-closed. start/run share cwd, shell and login selection. " + "Use action=run only when a one-shot bounded command result " "is preferred. run returns JSON with exit_code, timed_out, execution_outcome, " "output preview and optional output_file. Only a normal zero exit is success; " "a timeout does not undo any side effects." @@ -306,7 +333,11 @@ class ExecuteCommandTool(MoviePilotTool): if action == "wait": return f"等待命令会话: {session_id or ''}" if action == "write": + if kwargs.get("close_stdin"): + return f"关闭命令输入: {session_id or ''}" return f"写入命令输入: {session_id or ''}" + if action == "interrupt": + return f"中断命令会话: {session_id or ''}" if action == "kill": return f"终止命令会话: {session_id or ''}" return f"处理命令会话: {session_id or command or ''}" @@ -372,12 +403,14 @@ class ExecuteCommandTool(MoviePilotTool): stream_name: str, output: _CommandOutput, ) -> None: - """按块读取一次性命令输出,保留 32KB 头尾预览。""" + """每个流增量解码 UTF-8,跨读取边界的字符不能被提前替换。""" + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") while True: chunk = await stream.read(READ_CHUNK_SIZE) if not chunk: + output.append(stream_name, decoder.decode(b"", final=True)) break - output.append(stream_name, chunk.decode("utf-8", errors="replace")) + output.append(stream_name, decoder.decode(chunk)) @staticmethod def _terminate_process(process: Any, sig: int) -> None: @@ -443,6 +476,9 @@ class ExecuteCommandTool(MoviePilotTool): timeout: int, timed_out: bool, timeout_note: Optional[str], + cwd: Optional[str] = None, + shell: Optional[str] = None, + login: bool = False, ) -> str: """分开返回机器可判定的执行状态与有界输出,不能靠完成提示推断成功。""" if exit_code is None: @@ -473,6 +509,7 @@ class ExecuteCommandTool(MoviePilotTool): "action": "run", "success": succeeded, "execution_outcome": outcome, "status": "unknown" if exit_code is None else ("timed_out" if timed_out else "exited"), "exit_code": exit_code, "timed_out": timed_out, "timeout": timeout, + "cwd": cwd, "shell": shell, "login": login, "stdin_closed": True, "output_truncated": output.preview_truncated, "output_file": output.temp_file_path, "output": output.combined_preview, "message": result, }) @@ -484,28 +521,22 @@ class ExecuteCommandTool(MoviePilotTool): timeout: Optional[int], cwd: Optional[str] = None, env: Optional[dict[str, Any]] = None, + shell: Optional[str] = None, + login: Optional[bool] = None, confirm_dangerous: bool = False, ) -> str: """一次性执行命令并返回结构化终态;退出路径都必须释放读取任务和归档句柄。""" self._validate_command(command, confirmed=confirm_dangerous) normalized_timeout, timeout_note = self._normalize_timeout(timeout) + normalized_cwd = resolve_agent_cwd(cwd, root_path=get_runtime_setting("ROOT_PATH")) + normalized_env = build_agent_subprocess_env(env) + shell_policy = resolve_agent_shell(executable=shell, login=login, environment=normalized_env, cwd=normalized_cwd) async with _command_semaphore: - shell = resolve_agent_shell() - if shell: - process = await asyncio.create_subprocess_exec( - *shell.build_argv(command), - cwd=cwd, - env=build_agent_subprocess_env(env), - **self._subprocess_kwargs(), - ) - else: - process = await asyncio.create_subprocess_shell( - command, - cwd=cwd, - env=build_agent_subprocess_env(env), - **self._subprocess_kwargs(), - ) + process = await asyncio.create_subprocess_exec( + *shell_policy.build_argv(command), cwd=normalized_cwd, env=normalized_env, + **self._subprocess_kwargs(), + ) output = _CommandOutput(preview_limit_bytes=MAX_OUTPUT_PREVIEW_BYTES) wait_task = asyncio.create_task(process.wait()) reader_tasks = [ @@ -537,6 +568,7 @@ class ExecuteCommandTool(MoviePilotTool): timeout=normalized_timeout, timed_out=timed_out, timeout_note=timeout_note, + cwd=normalized_cwd, shell=shell_policy.executable, login=shell_policy.login, ) async def run( @@ -545,9 +577,12 @@ class ExecuteCommandTool(MoviePilotTool): command: Optional[str] = None, session_id: Optional[str] = None, input_text: Optional[str] = None, + close_stdin: Optional[bool] = False, signal_name: Optional[str] = "TERM", cwd: Optional[str] = None, env: Optional[dict[str, Any]] = None, + shell: Optional[str] = None, + login: Optional[bool] = None, use_pty: Optional[bool] = True, since_seq: Optional[int] = None, since_offset: Optional[int] = None, @@ -558,7 +593,7 @@ class ExecuteCommandTool(MoviePilotTool): confirm_dangerous: Optional[bool] = False, **kwargs, ) -> str: - """执行命令动作:默认后台启动,也支持读取、等待、写入、终止和一次性执行。""" + """按同一启动策略执行命令,区分写入/EOF、一次中断与会话终止。""" normalized_action = (action or "start").strip().lower() logger.info( f"执行工具: {self.name}, action={normalized_action}, " @@ -580,6 +615,8 @@ class ExecuteCommandTool(MoviePilotTool): command=start_command, cwd=cwd, env=env, + shell=shell, + login=login, use_pty=use_pty, confirm_dangerous=bool(confirm_dangerous), yield_time_ms=yield_time_ms, @@ -614,6 +651,17 @@ class ExecuteCommandTool(MoviePilotTool): payload = await terminal_session_manager.write( session_id=self._require_session_id(session_id), input_text=input_text or "", + close_stdin=False if close_stdin is None else close_stdin, + since_seq=since_seq, + since_offset=since_offset, + max_bytes=max_bytes, + max_output_chars=output_budget, + ) + return self._dump(payload) + + if normalized_action == "interrupt": + payload = await terminal_session_manager.interrupt( + session_id=self._require_session_id(session_id), since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, @@ -638,6 +686,8 @@ class ExecuteCommandTool(MoviePilotTool): timeout=timeout, cwd=cwd, env=env, + shell=shell, + login=login, confirm_dangerous=bool(confirm_dangerous), ) @@ -650,4 +700,5 @@ class ExecuteCommandTool(MoviePilotTool): }) except Exception as err: logger.error(f"执行命令 action 失败: {err}", exc_info=True) - return self._dump({"error": str(err), "status": "error", "action": normalized_action}) + return self._dump({"error": str(err), "status": "error", "action": normalized_action, + "success": False, "execution_outcome": "failed"}) diff --git a/docs/agent.md b/docs/agent.md index 13e2e62a6..50d8009d7 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -28,11 +28,17 @@ MoviePilot Agent 通过模型、Skills、工具和会话状态共同完成任务 超长结果在当前会话图中临时保存,预览带 `result_id` 和 `next_offset`。Agent 使用 `read_tool_result` 按 Unicode 字符位置续读,无需重新执行原工具。每条完整结果最多 1 MiB,每个图最多 8 条、合计 4 MiB,15 分钟后过期;容量超限、图重建和进程退出也会使编号失效。无法保存的结果明确要求缩小查询范围。不同图线程不能互读,管理员结果在降权后不能续读。结果原文只短期驻留内存,不归档到磁盘或日志。 -`execute_command(action="run")` 返回结构化 JSON:`exit_code`、`timed_out`、`execution_outcome` 和 `status` 表示实际执行结果,`output` 保存输出预览,`output_file` 指向超长输出的临时归档。仅正常退出且退出码为 0 时成功;非零退出或已停止的超时命令为失败,无法确认进程结束时为未知,不能凭“有输出”判断成功。超时和取消不会撤销命令已经产生的外部副作用。取消继续向外传播,同时回收输出读取任务、关闭归档文件;`run` 与 `start` 都支持 `env`,并保留指定工作目录。 +`execute_command(action="run")` 返回结构化 JSON:`exit_code`、`timed_out`、`execution_outcome` 和 `status` 表示实际执行结果,`output` 保存输出预览,`output_file` 指向超长输出的临时归档。仅正常退出且退出码为 0 时成功;非零退出或已停止的超时命令为失败,无法确认进程结束时为未知,不能凭“有输出”判断成功。超时和取消不会撤销命令已经产生的外部副作用。取消继续向外传播,同时回收输出读取任务、关闭归档文件;`run` 与 `start` 都支持 `env`,并采用同一工作目录与解释器策略。 -后台命令 `start` 默认最多等待 250ms 的首次输出,可用 `yield_time_ms=0` 立即返回。后续 `read/wait/write/kill` 用返回的 `output_until_seq` 和 `output_until_offset` 一起续读:前者是完整交付的最后一个分片,后者是下一分片中已交付的 UTF-8 字节位置。首次传 `since_offset=0` 开启分片内分页;不传 offset 时保持完整分片模式,小页装不下一个分片会明确提示调整限额。`last_seq` 只表示已经产生的输出,不能用于跳过未读内容。 +`run`、后台 pipe 和 PTY 共用 `cwd`、`shell`、`login` 解析。省略 `cwd` 使用 MoviePilot 根目录,相对路径也相对该目录,并支持 `~`;错误目录在进程启动前拒绝。POSIX 默认使用配置的 `SHELL`(未配置时 `/bin/sh`)且不启动登录模式,显式 `shell` 可选择已安装解释器,`login=true/false` 控制其支持的登录行为。登录启动文件可能改变目录或环境;PTY 本身不再隐式开启登录模式。Windows 未显式指定时保留 Git Bash → PowerShell 7 → cmd 的选择顺序与 UTF-8 策略;不支持的解释器/登录组合明确失败。回包的 `shell`、`login` 说明实际策略,默认解释器不可用不会阻断其它 Agent 业务能力。 -`wait` 在有未读输出时立即返回,等待中产生新输出或读取结束也会唤醒;`timeout_ms=0` 只查询,不终止命令。进程退出后仍可能有尾部输出,获取完整日志应继续到游标读完且 `output_complete=true`;退出码缺失时仍为 `unknown`。缓冲保留窗口之外的缺口由 `output_lost=true` 明确标记。若 `start/write/kill` 已执行动作但返回 `output_error`,会保留会话 ID 和未消费游标;只需调整 `max_bytes` 后用 `read` 获取输出,不应重新执行动作。首次 `start` 尚未交付会话 ID 时被取消会回收该进程,取消已有会话的 `wait` 则保留进程。 +管道会话可用 `write(input_text="末段输入", close_stdin=true)` 在交付末段后关闭输入,或用空输入显式关闭;输出仍通过 `read/wait` 读取,`stdin_closed` 表示输入已关闭。普通空 `write` 不发送 EOF;输入关闭后不能再追加数据,重复空关闭可确认已有状态。`run` 的 stdin 始终为 EOF。PTY 的输入和输出共用端点,因此拒绝 `close_stdin=true`,也不会先写入附带输入或关闭输出;PTY 控制字节的行为取决于终端模式,pipe 中 `\u0003`、`\u0004` 只是普通字节。 + +`interrupt` 仅发送一次 POSIX `SIGINT` 或支持的 Windows `CTRL_BREAK_EVENT`,不等待升级强杀,也不把会话标记为主动终止;实际命令可以处理信号后继续运行,也可能自行退出。回包含实际 `signal` 和 `signal_sent`。不具备该控制能力时明确失败;`kill` 仍负责终止并在需要时强杀,但未知名称、无效编号和没有真实映射的信号会在任何状态修改前拒绝。两种输入控制均不增加只读子代理权限。 + +后台命令 `start` 默认最多等待 250ms 的首次输出,可用 `yield_time_ms=0` 立即返回。后续 `read/wait/write/interrupt/kill` 用返回的 `output_until_seq` 和 `output_until_offset` 一起续读:前者是完整交付的最后一个分片,后者是下一分片中已交付的 UTF-8 字节位置。首次传 `since_offset=0` 开启分片内分页;不传 offset 时保持完整分片模式,小页装不下一个分片会明确提示调整限额。`last_seq` 只表示已经产生的输出,不能用于跳过未读内容。 + +`wait` 在有未读输出时立即返回,等待中产生新输出或读取结束也会唤醒;`timeout_ms=0` 只查询,不终止命令。进程退出后仍可能有尾部输出,获取完整日志应继续到游标读完且 `output_complete=true`;退出码缺失时仍为 `unknown`。缓冲保留窗口之外的缺口由 `output_lost=true` 明确标记。若 `start/write/interrupt/kill` 已执行动作但返回 `output_error`,会保留会话 ID 和未消费游标;只需调整 `max_bytes` 后用 `read` 获取输出,不应重新执行动作。首次 `start` 尚未交付会话 ID 时被取消会回收该进程,取消已有会话的 `wait` 则保留进程。 终端分页还受最终 JSON 字符预算约束:必要时减少本页正文并重新计算游标,避免 JSON 转义后又被通用工具预览截断。过长的命令、目录和错误回显会带显式截断标记;会话内部保留完整命令。 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index be403123f..c983935ac 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -756,8 +756,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 999 | -| 内部导入边 | 8,484 | +| Python 模块 | 1001 | +| 内部导入边 | 8,490 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index 4ac659c65..9b59716c2 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 999 / 8,484 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉与 AMLL 歌词模块的受控依赖 | +| 宿主 Python 模块 / 内部依赖边 | 1001 / 8,490 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与 AMLL 歌词模块的受控依赖 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -102,8 +102,8 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | -| 全量 mypy 历史债务 | 9,406 / 510 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | -| Ruff 历史诊断 | 530 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| 全量 mypy 历史债务 | 9,401 / 509 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | +| Ruff 历史诊断 | 529 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 1d56ec7bd..daa0f95b7 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -524,11 +524,18 @@ MCP、HTTP 工具管理接口、本地 CLI 和内置 Agent 都从同一严格目 `success`、`execution_outcome`、`status`、`exit_code`、`timed_out`、`timeout`、 `output_truncated`、`output_file`、`output` 和 `message`。正常退出码 0 才是成功, 非零退出及已停止的超时执行为失败,无法确认进程结束时为 unknown。输出预览与状态分开, -不再通过中文完成提示判断成功。后台 `start/read/wait/write/kill` 保持会话状态与游标协议; +不再通过中文完成提示判断成功。后台 `start/read/wait/write/interrupt/kill` 保持会话状态与游标协议; `env` 同时适用于 `start` 和 `run`,此工具仍不通过 MCP 暴露。 +`run`、pipe 和 PTY 共享 `cwd/shell/login`:默认及相对 `cwd` 使用 MoviePilot 根目录, +POSIX 默认非登录;Windows 未指定时保留已有解释器/UTF-8 策略。回包包含实际 `shell/login`。 +`write(close_stdin=true)` 仅在 pipe 模式支持末段输入后 EOF,回包包含 `stdin_closed`;输出可继续读取。 +PTY 会在写入之前拒绝 half-close,空 `write` 不代表 EOF,控制字节在 pipe 中也不等于信号。 +新增 `interrupt` 只发送一次平台支持的中断并返回 `signal/signal_sent`,不会升级强杀; +`kill` 保留终止语义但提前拒绝无效信号。上述能力仍为内置管理员 Agent 工具,不扩大外部 MCP 目录。 + 后台命令的 `start` 新增 `yield_time_ms`(默认 250、上限 10000,0 不等待)。 -`read/wait/write/kill` 接受 `since_seq` 和 `since_offset`:一起传回上次响应的 +`read/wait/write/interrupt/kill` 接受 `since_seq` 和 `since_offset`:一起传回上次响应的 `output_until_seq/output_until_offset`,后者表示下一分片内的 UTF-8 字节位置; 首次显式传 offset=0 开启部分分片读取。不传 offset 的旧调用只返回完整分片, 页预算过小时返回 `read_limit_too_small` 和 `minimum_read_bytes`。 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index a1c031302..4eaecd605 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -94,7 +94,8 @@ to make the directory tree look symmetrical. | `app/agent/middleware/vision.py` | Request-only tool-image observations after compaction; complete tool-reply batches, per-invocation visual fallback and unchanged user authorization | | `app/agent/tools/result.py` | Pure interpretation of explicit tool outcomes and portable tool-image history; image observation copies never replace original user attachments | | `app/agent/api/arguments.py` | Canonical API request fingerprints from the generated operation schema and the executor's GET projection; no endpoint imports or live discovery | -| `app/agent/shell.py` | Agent command-shell selection and subprocess text-encoding policy; Windows prefers Git Bash, then PowerShell 7, while POSIX keeps native shell/PTY behavior | +| `app/agent/shell.py` | Shared command interpreter, login mode, launch directory and subprocess text-encoding policy for run, pipe and PTY; preserves Windows default priority and UTF-8 behavior | +| `app/agent/terminal/` | `session.py` owns terminal state, input serialization, retained output and UTF-8 stream decoding; `manager.py` owns process launch, read/write/EOF, signals, paging and bounded shutdown. Package root contains no implementation exports | | `app/agent/policy/api.py` | Fixed `moviepilot_api` operation registry, HTTP route templates and per-operation authorization/effect policy; no arbitrary URL or method input | | `app/agent/policy/mcp.py` | Generated external MCP input-contract builder for the fixed API registry; owns exact English oneOf parameter projection, not runtime authorization | | `app/agent/tools/impl/service.py` | Admin-only external MCP wrappers for downloader, media-server and database Skill scripts; synchronous scripts run only through the Agent blocking executor | @@ -1089,7 +1090,8 @@ driven workflow registration. | `app/agent/lifecycle.py` | Agent manager admission, startup, idle collection and bounded shutdown owner | | `app/agent/tasks.py` | Background prompt, scheduled task and heartbeat execution owner | | `app/agent/orchestrator.py` | Per-session `MoviePilotAgent` execution and LLM/tool/middleware orchestration only | -| `app/agent/shell.py` | Agent command-shell selection and subprocess UTF-8 policy, with Windows-only Git Bash/PowerShell 7 routing | +| `app/agent/shell.py` | Agent command interpreter/login/directory and subprocess UTF-8 policy shared by run, pipe and PTY | +| `app/agent/terminal/` | Terminal session state and process/input/output lifecycle; lazy shutdown resolves the materialized manager module without importing it | | `app/agent/loader.py` | Agent-specific capability discovery and canonical entrypoint/service materialization; reuses the generic Capability Runtime while keeping Agent ownership under `app/agent/` | | `app/agent/__init__.py` | Implementation-free package root; exact historical Agent symbols are supplied by the Compat overlay only, while host callers import `orchestrator.py` or the relevant owner directly | | `app/agent/llm/__init__.py` | Implementation-free package root; only the verified historical `LLMHelper` symbol is supplied by exact Compat routing | diff --git a/tests/fixtures/architecture/concurrency-baseline.json b/tests/fixtures/architecture/concurrency-baseline.json index 989de8cea..e1f88c9d6 100644 --- a/tests/fixtures/architecture/concurrency-baseline.json +++ b/tests/fixtures/architecture/concurrency-baseline.json @@ -160,13 +160,7 @@ }, "target": "asyncio.create_task" }, - "app/agent/tools/base.py:concurrent.futures.ThreadPoolExecutor": { - "owners": { - "_submit_blocking_call": 1 - }, - "target": "concurrent.futures.ThreadPoolExecutor" - }, - "app/agent/tools/impl/_terminal_session.py:asyncio.create_task": { + "app/agent/terminal/manager.py:asyncio.create_task": { "owners": { "_TerminalSessionManager._start_pipe_session": 3, "_TerminalSessionManager._start_pty_session": 2, @@ -174,13 +168,18 @@ }, "target": "asyncio.create_task" }, - "app/agent/tools/impl/_terminal_session.py:asyncio.to_thread": { + "app/agent/terminal/manager.py:asyncio.to_thread": { "owners": { - "_TerminalSessionManager._wait_pty_process": 1, - "_TerminalSessionManager.write": 1 + "_TerminalSessionManager._wait_pty_process": 1 }, "target": "asyncio.to_thread" }, + "app/agent/tools/base.py:concurrent.futures.ThreadPoolExecutor": { + "owners": { + "_submit_blocking_call": 1 + }, + "target": "concurrent.futures.ThreadPoolExecutor" + }, "app/agent/tools/impl/execute_command.py:asyncio.create_task": { "owners": { "ExecuteCommandTool._run_once": 3 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index c673b0c5b..37fd92b35 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1074,8 +1074,8 @@ "runtime_only": true } }, - "edge_count": 8484, - "edge_sha256": "3d2b553b8cd0d336ecaa29a80ad5503a20b29d29f83afb9379547ddba1c01122", + "edge_count": 8490, + "edge_sha256": "14a35db36cee8f0f6266a112086efb547f7f3cf5abb67e0f632612654ed3a05c", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -1703,6 +1703,18 @@ "app.agent.tasks -> app.schemas", "app.agent.tasks -> app.schemas.message", "app.agent.tasks -> app.schemas.types", + "app.agent.terminal.manager -> app.agent", + "app.agent.terminal.manager -> app.agent.shell", + "app.agent.terminal.manager -> app.agent.terminal", + "app.agent.terminal.manager -> app.agent.terminal.session", + "app.agent.terminal.manager -> app.agent.tools", + "app.agent.terminal.manager -> app.agent.tools.impl", + "app.agent.terminal.manager -> app.agent.tools.impl._command_safety", + "app.agent.terminal.manager -> app.runtime", + "app.agent.terminal.manager -> app.runtime.log", + "app.agent.terminal.manager -> app.runtime.settings", + "app.agent.terminal.session -> app.agent", + "app.agent.terminal.session -> app.agent.shell", "app.agent.tools.base -> app.agent", "app.agent.tools.base -> app.agent.callback", "app.agent.tools.base -> app.agent.policy", @@ -1759,14 +1771,6 @@ "app.agent.tools.factory -> app.schemas", "app.agent.tools.factory -> app.schemas.notification", "app.agent.tools.factory -> app.schemas.types", - "app.agent.tools.impl._terminal_session -> app.agent", - "app.agent.tools.impl._terminal_session -> app.agent.shell", - "app.agent.tools.impl._terminal_session -> app.agent.tools", - "app.agent.tools.impl._terminal_session -> app.agent.tools.impl", - "app.agent.tools.impl._terminal_session -> app.agent.tools.impl._command_safety", - "app.agent.tools.impl._terminal_session -> app.runtime", - "app.agent.tools.impl._terminal_session -> app.runtime.log", - "app.agent.tools.impl._terminal_session -> app.runtime.settings", "app.agent.tools.impl.agent_task -> app.agent", "app.agent.tools.impl.agent_task -> app.agent.tools", "app.agent.tools.impl.agent_task -> app.agent.tools.base", @@ -1837,14 +1841,16 @@ "app.agent.tools.impl.edit_file -> app.runtime.log", "app.agent.tools.impl.execute_command -> app.agent", "app.agent.tools.impl.execute_command -> app.agent.shell", + "app.agent.tools.impl.execute_command -> app.agent.terminal", + "app.agent.tools.impl.execute_command -> app.agent.terminal.manager", "app.agent.tools.impl.execute_command -> app.agent.tools", "app.agent.tools.impl.execute_command -> app.agent.tools.base", "app.agent.tools.impl.execute_command -> app.agent.tools.impl", "app.agent.tools.impl.execute_command -> app.agent.tools.impl._command_safety", - "app.agent.tools.impl.execute_command -> app.agent.tools.impl._terminal_session", "app.agent.tools.impl.execute_command -> app.agent.tools.tags", "app.agent.tools.impl.execute_command -> app.runtime", "app.agent.tools.impl.execute_command -> app.runtime.log", + "app.agent.tools.impl.execute_command -> app.runtime.settings", "app.agent.tools.impl.mcp -> app.agent", "app.agent.tools.impl.mcp -> app.agent.mcp", "app.agent.tools.impl.mcp -> app.agent.tools", @@ -9562,7 +9568,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 999, + "module_count": 1001, "modules": [ "app", "app.adapters", @@ -9672,6 +9678,9 @@ "app.agent.skills.metadata", "app.agent.skills.registry", "app.agent.tasks", + "app.agent.terminal", + "app.agent.terminal.manager", + "app.agent.terminal.session", "app.agent.tools", "app.agent.tools.base", "app.agent.tools.catalog", @@ -9679,7 +9688,6 @@ "app.agent.tools.impl", "app.agent.tools.impl._command_safety", "app.agent.tools.impl._file_write_utils", - "app.agent.tools.impl._terminal_session", "app.agent.tools.impl.agent_task", "app.agent.tools.impl.api", "app.agent.tools.impl.apply_patch", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index a2858d3b1..e9a1e37d7 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -254,11 +254,6 @@ "no-untyped-def": 1, "type-arg": 3 }, - "app/agent/tools/impl/_terminal_session.py": { - "assignment": 2, - "attr-defined": 1, - "type-arg": 2 - }, "app/agent/tools/impl/apply_patch.py": { "arg-type": 5, "misc": 1, diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 5f7ccb1e2..e7c7593ca 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -17,9 +17,6 @@ "app/agent/llm/tools.py": { "I001": 1 }, - "app/agent/loader.py": { - "I001": 1 - }, "app/agent/middleware/memory.py": { "I001": 1 }, diff --git a/tests/test_agent_command_io.py b/tests/test_agent_command_io.py new file mode 100644 index 000000000..4eb66b691 --- /dev/null +++ b/tests/test_agent_command_io.py @@ -0,0 +1,234 @@ +"""从真实命令工具入口验证统一启动目录、解释器及跨分片文本输入输出。""" + +import asyncio +import json +import os +import shlex +import sys +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio + +from app.agent.loader import close_materialized_terminal_sessions +from app.agent.terminal import manager as terminal_module +from app.agent.terminal.manager import _TerminalSessionManager +from app.agent.tools.impl import execute_command as command_module +from app.agent.tools.impl.execute_command import ExecuteCommandTool, _CommandOutput + + +def _python(code: str) -> str: + """使用当前解释器和逐参数转义,真实程序仅接触临时目录及标准流。""" + return shlex.join([sys.executable, "-c", code]) + + +@pytest_asyncio.fixture +async def command_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[tuple[ExecuteCommandTool, Path]]: + """父进程 cwd 与项目根明确分离,所有后台进程由独立 manager 统一回收。""" + root_dir, parent_dir = tmp_path / "project", tmp_path / "parent" + root_dir.mkdir() + parent_dir.mkdir() + (root_dir / "relative").mkdir() + monkeypatch.chdir(parent_dir) + original_setting = command_module.get_runtime_setting + + def setting(name: str) -> Any: + """只替换目录配置,其他运行契约仍走测试已经隔离的宿主配置。""" + return root_dir if name == "ROOT_PATH" else original_setting(name) + + manager = _TerminalSessionManager() + monkeypatch.setattr(command_module, "get_terminal_session_manager", lambda: manager) + monkeypatch.setattr(command_module, "get_runtime_setting", setting) + monkeypatch.setattr(terminal_module, "get_runtime_setting", setting) + monkeypatch.setattr(command_module, "_command_semaphore", asyncio.Semaphore(2)) + tool = ExecuteCommandTool(session_id="command-io", user_id="command-io-owner") + try: + yield tool, root_dir + finally: + await manager.close() + + +async def _finish(tool: ExecuteCommandTool, payload: dict[str, Any]) -> dict[str, Any]: + """持续按已消费字节游标读取,直到进程与读取器都收尾。""" + chunks = [payload["output"]] + async with asyncio.timeout(5): + while not (payload["output_complete"] and payload["output_until_seq"] == payload["last_seq"] + and payload["output_until_offset"] == 0): + payload = json.loads(await tool.run( + action="wait", session_id=payload["session_id"], timeout_ms=1000, + since_seq=payload["output_until_seq"], since_offset=payload["output_until_offset"], + )) + chunks.append(payload["output"]) + return {**payload, "output": "".join(chunks)} + + +@pytest.mark.skipif(os.name != "posix", reason="真实 POSIX 解释器与 PTY 合同") +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["run", "pipe", "pty"]) +@pytest.mark.parametrize("directory", ["default", "relative", "absolute"]) +async def test_all_launch_modes_share_directory_shell_and_environment(command_environment, mode: str, directory: str) -> None: + """相同参数不能因为是否保留会话或使用 PTY 而切换目录、解释器或环境。""" + tool, root_dir = command_environment + cwd = {"default": None, "relative": "relative", "absolute": str(root_dir / "relative")}[directory] + expected_cwd = root_dir if directory == "default" else root_dir / "relative" + code = "import os; print('DIRECTORY=' + os.getcwd()); print('MARKER=' + os.environ['MOVIEPILOT_IO_PROBE'])" + payload = json.loads(await tool.run( + action="run" if mode == "run" else "start", command=_python(code), cwd=cwd, + env={"SHELL": "/bin/sh", "MOVIEPILOT_IO_PROBE": "launch-value"}, + use_pty=mode == "pty", timeout=5, since_offset=0, + )) + if mode != "run": + payload = await _finish(tool, payload) + assert payload["execution_outcome"] == "succeeded" + assert payload["cwd"] == str(expected_cwd.resolve()) + assert Path(payload["shell"]).resolve() == Path("/bin/sh").resolve() + assert payload["login"] is False + assert f"DIRECTORY={expected_cwd.resolve()}" in payload["output"] + assert "MARKER=launch-value" in payload["output"] + assert "MOVIEPILOT_IO_PROBE" not in os.environ + + +@pytest.mark.skipif(os.name != "posix", reason="真实 POSIX 显式解释器合同") +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["run", "pipe", "pty"]) +async def test_explicit_shell_wins_over_environment_without_implicit_login(command_environment, mode: str) -> None: + """显式 bash 能运行其语言特性,不能被 env.SHELL 或 PTY 的旧登录路径替换。""" + tool, _ = command_environment + payload = json.loads(await tool.run( + action="run" if mode == "run" else "start", command="items=(one two); printf 'ARRAY:%s\\n' \"${items[1]}\"", + env={"SHELL": "/bin/sh"}, shell="/bin/bash", login=False, use_pty=mode == "pty", since_offset=0, + )) + if mode != "run": + payload = await _finish(tool, payload) + assert payload["exit_code"] == 0 + assert "ARRAY:two" in payload["output"] + assert Path(payload["shell"]).resolve() == Path("/bin/bash").resolve() + assert payload["login"] is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["run", "start"]) +async def test_invalid_directory_is_rejected_before_process_creation(command_environment, monkeypatch, mode: str) -> None: + """目录参数错误必须保持零进程,不允许先启动后再用回包补救。""" + tool, root_dir = command_environment + create = AsyncMock(side_effect=AssertionError("invalid cwd created a process")) + monkeypatch.setattr(command_module.asyncio, "create_subprocess_exec", create) + payload = json.loads(await tool.run(action=mode, command="echo never", cwd=str(root_dir / "absent"), use_pty=False)) + assert payload["success"] is False and payload["execution_outcome"] == "failed" + create.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("ending", [b"\xad", b""]) +async def test_run_decodes_split_utf8_at_eof_without_replacing_complete_characters(ending: bytes) -> None: + """Event 固定两次真实 StreamReader 读取边界,完整字符拼接和末尾残缺各有明确结果。""" + reader = asyncio.StreamReader() + first_read = asyncio.Event() + original_read = reader.read + + async def read(size: int) -> bytes: + """读取器消费首段后才允许测试送第二段,排除同批合并造成的空过。""" + data = await original_read(size) + if data == b"\xe4\xb8": + first_read.set() + return data + + reader.read = read + output = _CommandOutput(preview_limit_bytes=1024) + task = asyncio.create_task(ExecuteCommandTool._read_stream(reader, "stdout", output)) + try: + reader.feed_data(b"\xe4\xb8") + await asyncio.wait_for(first_read.wait(), timeout=2) + assert output.stdout == "" + if ending: + reader.feed_data(ending) + reader.feed_eof() + await asyncio.wait_for(task, timeout=2) + assert output.stdout == ("中" if ending else "\ufffd") + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + output.close() + + +@pytest.mark.skipif(os.name != "posix", reason="真实 POSIX shell 与 pipe EOF") +@pytest.mark.asyncio +async def test_tool_write_close_stdin_delivers_final_input_and_retains_output(command_environment) -> None: + """模型通过工具写末段和 EOF 后仍能读取最终计算结果与退出状态。""" + tool, _ = command_environment + initial = json.loads(await tool.run( + action="start", command=_python("import sys; data=sys.stdin.read(); print('RECEIVED=' + data, flush=True)"), + env={"SHELL": "/bin/sh"}, use_pty=False, since_offset=0, yield_time_ms=0, + )) + closed = json.loads(await tool.run(action="write", session_id=initial["session_id"], input_text="完整输入", close_stdin=True, + since_seq=0, since_offset=0)) + assert closed["stdin_closed"] is True + assert closed["written_bytes"] == len("完整输入".encode("utf-8")) + final = await _finish(tool, closed) + assert final["execution_outcome"] == "succeeded" and final["output_complete"] is True + assert "RECEIVED=完整输入" in final["output"] + + +@pytest.mark.asyncio +async def test_tool_interrupt_routes_to_one_shot_control_without_kill(monkeypatch) -> None: + """新工具动作必须调用独立中断路径,不能复用会升级强杀的 kill。""" + manager = _TerminalSessionManager() + manager.interrupt = AsyncMock(return_value={"signal_sent": True, "signal": "SIGINT"}) + manager.kill = AsyncMock(side_effect=AssertionError("interrupt called kill")) + monkeypatch.setattr(command_module, "get_terminal_session_manager", lambda: manager) + result = json.loads(await ExecuteCommandTool(session_id="io", user_id="owner").run(action="interrupt", session_id="term-test")) + assert result["signal_sent"] is True + manager.interrupt.assert_awaited_once() + manager.kill.assert_not_awaited() + + +def test_run_preview_boundary_keeps_valid_unicode_and_complete_archive(tmp_path: Path, monkeypatch) -> None: + """预览字节限额切到完整字符中间时不制造替换字,原始字符仍可在尾部和归档读取。""" + original = command_module.NamedTemporaryFile + + def archive(**kwargs: Any) -> Any: + """把完整输出归档限制在当前测试目录。""" + return original(dir=tmp_path, **kwargs) + + monkeypatch.setattr(command_module, "NamedTemporaryFile", archive) + output = _CommandOutput(preview_limit_bytes=2) + try: + output.append("stdout", "中文") + output.close() + assert output.preview_truncated is True + assert output.captured_bytes <= 2 + assert "\ufffd" not in output.combined_preview + assert "中文" in output.combined_preview + assert "中文" in Path(output.temp_file_path).read_text(encoding="utf-8") + finally: + output.close() + + +@pytest.mark.skipif(os.name != "posix", reason="真实 POSIX 管道关停") +@pytest.mark.asyncio +async def test_lazy_shutdown_closes_the_materialized_terminal_package_owner(command_environment, monkeypatch) -> None: + """模块迁移后宿主关停仍必须找到实际 manager 并回收已启动进程。""" + tool, _ = command_environment + manager = command_module.get_terminal_session_manager() + monkeypatch.setattr(terminal_module, "terminal_session_manager", manager) + payload = json.loads(await tool.run( + action="start", command=_python("print('READY', flush=True); input()"), use_pty=False, + env={"SHELL": "/bin/sh"}, since_offset=0, + )) + session = manager.get_session(payload["session_id"]) + assert session.process is not None and session.process.returncode is None + await close_materialized_terminal_sessions() + assert manager._closed is True and manager._sessions == {} + assert session.process.returncode is not None + + +@pytest.mark.asyncio +async def test_lazy_shutdown_does_not_import_an_unmaterialized_terminal_manager(monkeypatch) -> None: + """未使用命令工具时,关停查表不能顺带创建新的终端管理器。""" + monkeypatch.delitem(sys.modules, "app.agent.terminal.manager", raising=False) + await close_materialized_terminal_sessions() + assert "app.agent.terminal.manager" not in sys.modules diff --git a/tests/test_agent_command_outcome.py b/tests/test_agent_command_outcome.py index 5c2e07ed5..26bcf86b3 100644 --- a/tests/test_agent_command_outcome.py +++ b/tests/test_agent_command_outcome.py @@ -224,12 +224,15 @@ async def test_windows_shell_branch_receives_run_environment(monkeypatch: pytest class Shell: """提供与 Windows shell 策略相同的参数构造边界。""" + executable = sys.executable + login = False + @staticmethod def build_argv(_command_text: str) -> list[str]: """真实子进程已在隔离环境启动,此处只检验启动参数。""" return [sys.executable, "-c", "pass"] - monkeypatch.setattr(command_module, "resolve_agent_shell", lambda: Shell()) + monkeypatch.setattr(command_module, "resolve_agent_shell", lambda **_kwargs: Shell()) monkeypatch.setattr(command_module.asyncio, "create_subprocess_exec", create_process) result = await _tool().run(action="run", command="unused", env={"MOVIEPILOT_COMMAND_TEST_VALUE": "shell-env-marker"}, timeout=5) assert create_process.call_args.kwargs["env"]["MOVIEPILOT_COMMAND_TEST_VALUE"] == "shell-env-marker" diff --git a/tests/test_agent_prompt_secrets.py b/tests/test_agent_prompt_secrets.py index bf672c59a..512a6e689 100644 --- a/tests/test_agent_prompt_secrets.py +++ b/tests/test_agent_prompt_secrets.py @@ -52,7 +52,7 @@ def test_moviepilot_info_lists_command_names_without_paths(monkeypatch) -> None: monkeypatch.setattr( "app.agent.prompt.shutil.which", - lambda command: command_paths.get(command), + lambda command, **_kwargs: command_paths.get(command), ) manager = PromptManager() diff --git a/tests/test_agent_shell.py b/tests/test_agent_shell.py index 08d93a44a..cf9684c1f 100644 --- a/tests/test_agent_shell.py +++ b/tests/test_agent_shell.py @@ -1,5 +1,10 @@ +"""Agent 解释器、登录模式、工作目录及跨平台子进程契约。""" + import asyncio import json +import ntpath +import os +import shutil import subprocess from pathlib import Path from types import SimpleNamespace @@ -14,9 +19,10 @@ from app.agent.shell import ( AgentShell, agent_text_subprocess_kwargs, build_agent_subprocess_env, + resolve_agent_cwd, resolve_agent_shell, ) -from app.agent.tools.impl._terminal_session import _TerminalSessionManager +from app.agent.terminal.manager import _TerminalSessionManager from app.agent.tools.impl.execute_command import ExecuteCommandTool from app.agent.tools.impl.service import _run_service_script from app.schemas.agent import AgentMcpServerConfig @@ -54,26 +60,21 @@ def _fake_process(stdout: bytes = b"", stderr: bytes = b""): return process -def test_windows_shell_prefers_git_bash(tmp_path: Path) -> None: +def test_windows_shell_prefers_git_bash() -> None: """Windows 检测到 Git for Windows 时应优先使用同目录 Git Bash。""" - git_root = tmp_path / "Git" - git_path = git_root / "cmd" / "git.exe" - bash_path = git_root / "bin" / "bash.exe" - git_path.parent.mkdir(parents=True) - bash_path.parent.mkdir(parents=True) - git_path.touch() - bash_path.touch() - - shell = resolve_agent_shell( - platform_name="nt", - command_finder=_finder({"git": str(git_path), "pwsh": "ignored"}), - environment={}, - ) + git_path = "C:/Git/cmd/git.exe" + bash_path = "C:/Git/bin/bash.exe" + with patch("app.agent.shell.Path.is_file", lambda candidate: str(candidate).replace("\\", "/") == bash_path): + shell = resolve_agent_shell( + platform_name="nt", cwd="C:/project", + command_finder=_finder({"git": git_path, "pwsh": "ignored"}), + environment={}, + ) assert shell is not None assert shell.kind == "git-bash" assert shell.build_argv("git status") == [ - str(bash_path), + str(Path(bash_path)), "--noprofile", "--norc", "-lc", @@ -139,7 +140,9 @@ def test_posix_shell_policy_preserves_native_shell_and_environment(monkeypatch) """Linux/macOS 不应被 Windows shell 选择或 UTF-8 环境覆盖改变。""" monkeypatch.setenv("PYTHONIOENCODING", "locale-default") - assert resolve_agent_shell(platform_name="posix") is None + shell = resolve_agent_shell(platform_name="posix", environment={"SHELL": "/bin/sh"}) + assert shell.kind == "sh" and shell.login is False + assert shell.build_argv("pwd") == ["/bin/sh", "-c", "pwd"] environment = build_agent_subprocess_env( {"CUSTOM": "value"}, platform_name="posix" ) @@ -237,15 +240,15 @@ async def test_terminal_session_uses_selected_windows_shell() -> None: with ( patch( - "app.agent.tools.impl._terminal_session.resolve_agent_shell", + "app.agent.terminal.manager.resolve_agent_shell", return_value=shell, ), patch( - "app.agent.tools.impl._terminal_session.asyncio.create_subprocess_exec", + "app.agent.terminal.manager.asyncio.create_subprocess_exec", create_exec, ), patch( - "app.agent.tools.impl._terminal_session.asyncio.create_subprocess_shell" + "app.agent.terminal.manager.asyncio.create_subprocess_shell" ) as create_shell, ): session = await manager._start_pipe_session( @@ -363,3 +366,296 @@ def test_audio_ffmpeg_uses_windows_utf8_text_subprocess_kwargs(tmp_path: Path) - assert runner.call_args.kwargs["encoding"] == "utf-8" assert runner.call_args.kwargs["errors"] == "replace" assert runner.call_args.kwargs["env"] == {"PYTHONUTF8": "1"} + + +def test_posix_explicit_shell_overrides_environment_and_defaults_to_non_login() -> None: + """显式解释器优先于环境,默认登录模式不得因后台或 PTY 路径发生变化。""" + shell = resolve_agent_shell(platform_name="posix", executable="bash", environment={"SHELL": "/bin/zsh"}, + command_finder=_finder({"bash": "/custom/bin/bash"})) + assert shell.kind == "bash" and shell.executable == "/custom/bin/bash" and shell.login is False + assert shell.build_argv("printf '%s' value") == ["/custom/bin/bash", "-c", "printf '%s' value"] + assert "Bash" in shell.prompt_guidance() and "非登录模式" in shell.prompt_guidance() + assert shell.executable not in shell.prompt_guidance() + + +def test_posix_shell_uses_child_environment_then_host_then_sh(monkeypatch) -> None: + """子进程指定 SHELL 优先,未指定时沿用宿主,双方缺失才使用 /bin/sh。""" + finder = _finder({"/bin/zsh": "/bin/zsh", "/bin/bash": "/bin/bash", "/bin/sh": "/bin/sh"}) + monkeypatch.setenv("SHELL", "/bin/bash") + assert resolve_agent_shell(platform_name="posix", environment={"SHELL": "/bin/zsh"}, command_finder=finder).kind == "zsh" + assert resolve_agent_shell(platform_name="posix", environment={}, command_finder=finder).kind == "bash" + monkeypatch.delenv("SHELL") + assert resolve_agent_shell(platform_name="posix", environment={}, command_finder=finder).kind == "sh" + + +@pytest.mark.parametrize("kind", ["sh", "bash", "zsh", "dash", "ash", "ksh", "mksh", "fish"]) +def test_posix_login_is_explicit_and_preserves_command_as_single_argument(kind: str) -> None: + """登录选项改变解释器 argv,用户命令仍是原样单个参数,不经过第二层 shell。""" + command = "printf '%s\\n' '$HOME; literal'" + shell = resolve_agent_shell(platform_name="posix", executable=kind, login=True, + command_finder=_finder({kind: f"/shells/{kind}"}), environment={}) + assert shell.login is True and shell.build_argv(command) == [f"/shells/{kind}", "-lc", command] + assert "登录模式" in shell.prompt_guidance() + + +@pytest.mark.parametrize("kind", ["csh", "tcsh"]) +def test_c_shell_rejects_login_command_combination_before_start(kind: str) -> None: + """C shell 登录参数不能与 -c 组合,应在 fork/exec 前明确失败。""" + finder = _finder({kind: f"/shells/{kind}"}) + assert resolve_agent_shell(platform_name="posix", executable=kind, command_finder=finder).login is False + with pytest.raises(ValueError, match="不支持 login=True"): + resolve_agent_shell(platform_name="posix", executable=kind, login=True, command_finder=finder) + + +@pytest.mark.parametrize("shell", ["", " ", "missing-shell", "bash -l", "bad\x00path"]) +def test_invalid_explicit_shell_is_rejected_before_process_creation(shell: str) -> None: + """缺失解释器与带参数字符串不能回退到另一种命令语言执行。""" + with pytest.raises((ValueError, FileNotFoundError)): + resolve_agent_shell(platform_name="posix", executable=shell, command_finder=_finder({})) + + +@pytest.mark.parametrize("login", ["true", 1, [], {}]) +def test_invalid_login_type_cannot_be_coerced_into_profile_execution(login) -> None: + """底层公共策略只接受明确布尔值,避免字符串或容器意外启用登录脚本。""" + with pytest.raises(ValueError, match="布尔值"): + resolve_agent_shell(platform_name="posix", executable="sh", login=login, command_finder=_finder({"sh": "/bin/sh"})) + + +def test_unknown_interpreter_language_is_not_guessed() -> None: + """即使程序可找到,也不能把 Python 等任意可执行文件当 POSIX shell 使用。""" + with pytest.raises(ValueError, match="不支持的命令解释器"): + resolve_agent_shell(platform_name="posix", executable="python", command_finder=_finder({"python": "/custom/python"})) + + +@pytest.mark.skipif(os.name != "posix", reason="验证 POSIX 可执行权限及符号链接") +def test_posix_lookup_uses_exact_child_path_and_rejects_nonexecutable_file(tmp_path: Path, monkeypatch) -> None: + """实际 PATH 查找不能拾取宿主的同名命令;缺少执行权限须在启动前拒绝。""" + selected = tmp_path / "child bin" / "bash" + selected.parent.mkdir() + selected.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + monkeypatch.setenv("PATH", "/host-only") + with pytest.raises(FileNotFoundError): + resolve_agent_shell(platform_name="posix", executable="bash", environment={"PATH": str(selected.parent)}) + selected.chmod(0o755) + shell = resolve_agent_shell(platform_name="posix", executable="bash", environment={"PATH": str(selected.parent)}) + assert shell.executable == str(selected) and shell.login is False + assert resolve_agent_shell(platform_name="posix", executable=str(selected), environment={"PATH": ""}).executable == str(selected) + with pytest.raises(FileNotFoundError): + resolve_agent_shell(platform_name="posix", executable="bash", environment={"PATH": ""}) + + +@pytest.mark.skipif(os.name != "posix", reason="验证 POSIX 符号链接解释器") +def test_posix_named_alias_resolves_to_known_shell(tmp_path: Path) -> None: + """明确的符号链接别名可按真实解释器指导模型,仍保留调用者选择的执行路径。""" + alias = tmp_path / "chosen-shell" + alias.symlink_to("/bin/sh") + shell = resolve_agent_shell(platform_name="posix", executable=str(alias), environment={"PATH": ""}) + assert shell.kind in {"sh", "bash", "dash"} and shell.executable == str(alias) + + +@pytest.mark.skipif(os.name != "posix", reason="验证真实 POSIX 子进程 argv") +def test_real_bash_non_login_avoids_login_profile_and_preserves_argument_quoting(tmp_path: Path) -> None: + """默认 bash 不读取登录 profile,变量、空格和分号只由已选择的解释器解析一次。""" + bash = shutil.which("bash", path=os.defpath) + if not bash: + pytest.skip("系统没有 bash") + profile_marker = tmp_path / "login-profile-read" + (tmp_path / ".bash_profile").write_text(f"touch '{profile_marker}'\n", encoding="utf-8") + environment = {"HOME": str(tmp_path), "PATH": os.defpath, "SHELL": bash, "TASK_VALUE": "space ; literal"} + shell = resolve_agent_shell(platform_name="posix", environment=environment) + result = subprocess.run(shell.build_argv('printf "%s" "$TASK_VALUE"; shopt -q login_shell && exit 9; exit 0'), + env=environment, cwd=tmp_path, capture_output=True, text=True, timeout=5, check=False) + assert result.returncode == 0 and result.stdout == "space ; literal" + assert not profile_marker.exists() + + +def test_windows_explicit_interpreter_overrides_git_and_keeps_utf8() -> None: + """Windows 显式选择 pwsh 优先于 Git,且保留本项目 UTF-8 初始化。""" + shell = resolve_agent_shell(platform_name="nt", executable="pwsh", login=False, environment={}, + command_finder=_finder({"git": "C:/Git/cmd/git.exe", "pwsh": "C:/PowerShell/pwsh.exe"})) + assert shell.kind == "pwsh" and shell.login is False and shell.git_available is True + assert shell.arguments == ("-NoLogo", "-NoProfile", "-Command") and "chcp.com" in shell.command_prefix + + +@pytest.mark.parametrize("name", ["pwsh", "powershell", "cmd"]) +def test_windows_interpreters_reject_unsupported_login_mode(name: str) -> None: + """Windows 命令宿主不能把 login=True 偷换成不读取 profile 的普通执行。""" + finder = _finder({name: f"C:/commands/{name}.exe"}) + with pytest.raises(ValueError, match="不支持 login=True"): + resolve_agent_shell(platform_name="nt", executable=name, login=True, command_finder=finder, environment={}) + assert resolve_agent_shell(platform_name="nt", executable=name, login=False, command_finder=finder).login is False + + +def test_windows_explicit_powershell_is_not_described_as_powershell_seven() -> None: + """明确指定 Windows PowerShell 时,模型须知道真实语言宿主而非冒称 pwsh。""" + shell = resolve_agent_shell(platform_name="nt", executable="powershell", command_finder=_finder({"powershell": "C:/Windows/powershell.exe"})) + assert "Windows PowerShell" in shell.prompt_guidance() and "PowerShell 7" not in shell.prompt_guidance() + + +def test_windows_git_bash_preserves_legacy_default_but_honors_explicit_login() -> None: + """未指定登录选项保持旧 argv,明确选项则真实启停 bash 登录模式。""" + git = "C:/Git/cmd/git.exe" + bash = "C:/Git/bin/bash.exe" + finder = _finder({"git": git}) + with patch("app.agent.shell.Path.is_file", lambda candidate: str(candidate).replace("\\", "/") == bash): + default = resolve_agent_shell(platform_name="nt", command_finder=finder, environment={}, cwd="C:/project") + non_login = resolve_agent_shell(platform_name="nt", command_finder=finder, environment={}, login=False, cwd="C:/project") + login = resolve_agent_shell(platform_name="nt", command_finder=finder, environment={}, login=True, cwd="C:/project") + assert default.arguments == ("--noprofile", "--norc", "-lc") and default.login is True + assert non_login.arguments == ("-c",) and non_login.login is False + assert login.arguments == ("-lc",) and login.login is True + + +def test_windows_lookup_uses_provided_path_and_comspec(monkeypatch) -> None: + """即使测试宿主不是 Windows,默认查找也必须使用传给子进程的 PATH/COMSPEC。""" + paths = [] + + def which(command: str, *, path: str): + """记录待执行环境的查找路径,不读取本机安装情况。""" + paths.append((command, path)) + return None + + monkeypatch.setattr("app.agent.shell.shutil.which", which) + shell = resolve_agent_shell(platform_name="nt", environment={"PATH": "child-path", "COMSPEC": "C:/child/cmd.exe"}, cwd="C:/launch") + assert paths and all(path == "C:\\launch\\child-path" for _, path in paths) + assert shell.executable == "C:/child/cmd.exe" and shell.arguments == ("/d", "/s", "/c") + + +def test_cwd_resolves_root_relative_absolute_and_parent_paths(tmp_path: Path) -> None: + """三个命令入口统一以 ROOT_PATH 为默认基准,仍允许明确指定根目录之外的位置。""" + root = tmp_path / "project" + child = root / "nested" + child.mkdir(parents=True) + assert resolve_agent_cwd(None, root_path=root) == str(root) + assert resolve_agent_cwd("", root_path=root) == str(root) + assert resolve_agent_cwd("nested", root_path=root) == str(child) + assert resolve_agent_cwd(str(child), root_path=root) == str(child) + assert resolve_agent_cwd("..", root_path=root) == str(tmp_path) + + +def test_cwd_expands_user_home_and_resolves_symlink(tmp_path: Path, monkeypatch) -> None: + """用户目录与符号链接展开规则应与原 manager 一致,结果始终是存在的绝对目录。""" + sample_home = tmp_path / "user" + nested = sample_home / "nested" + nested.mkdir(parents=True) + monkeypatch.setenv("HOME", str(sample_home)) + if os.name == "nt": + monkeypatch.setenv("USERPROFILE", str(sample_home)) + assert resolve_agent_cwd("~/nested", root_path=tmp_path) == str(nested) + if os.name == "posix": + alias = tmp_path / "alias" + alias.symlink_to(nested) + assert resolve_agent_cwd("alias", root_path=tmp_path) == str(nested) + + +def test_cwd_rejects_missing_paths_files_and_missing_default_root(tmp_path: Path) -> None: + """目录有效性校验包含默认 ROOT_PATH,避免启动后才得到含糊的子进程错误。""" + filename = tmp_path / "file" + filename.touch() + with pytest.raises(FileNotFoundError, match="工作目录不存在"): + resolve_agent_cwd("missing", root_path=tmp_path) + with pytest.raises(FileNotFoundError, match="工作目录不存在"): + resolve_agent_cwd(None, root_path=tmp_path / "missing-root") + with pytest.raises(NotADirectoryError, match="工作目录不是目录"): + resolve_agent_cwd("file", root_path=tmp_path) + with pytest.raises(ValueError, match="cwd 必须"): + resolve_agent_cwd(42, root_path=tmp_path) + + +def test_windows_comspec_cannot_silently_change_command_language() -> None: + """COMSPEC 若指向另一种语言,不能继续附加 cmd 参数后宣称正确执行。""" + with pytest.raises(ValueError, match="COMSPEC 必须指向 cmd.exe"): + resolve_agent_shell(platform_name="nt", environment={"COMSPEC": "C:/bin/pwsh.exe"}, command_finder=_finder({})) + + +@pytest.mark.parametrize("error_type", [OSError, ValueError]) +def test_prompt_keeps_business_guidance_when_default_shell_is_invalid(error_type: type[Exception]) -> None: + """默认解释器错误不能阻断业务对话,提示保留恢复入口且不泄露异常中的私有路径。""" + private_path = "/private/account/secret-shell" + with patch("app.agent.prompt.resolve_agent_shell", side_effect=error_type(private_path)): + information = PromptManager()._get_moviepilot_info() + assert "默认命令解释器不可用" in information and "显式选择可用的 `shell`" in information + assert "当前日期" in information and "运行环境" in information and "query_doctor_report" in information + assert "moviepilot_api" in information and "关键运行路径" in information + assert private_path not in information + + +@pytest.mark.skipif(os.name != "posix", reason="真实 POSIX PATH 与符号链接定位") +@pytest.mark.parametrize("selection", ["relative-executable", "relative-path"]) +def test_relative_shell_search_uses_launch_cwd_without_changing_parent(tmp_path: Path, monkeypatch, selection: str) -> None: + """父目录和启动目录均有同名程序时,显式路径与 PATH 都必须选中启动目录的解释器。""" + parent_dir, launch_dir = tmp_path / "parent", tmp_path / "launch" + for folder in (parent_dir, launch_dir): + (folder / "bin").mkdir(parents=True) + (folder / "bin" / "bash").symlink_to("/bin/bash") + monkeypatch.chdir(parent_dir) + environment = {"PATH": "bin", "SHELL": "bash"} + executable = "./bin/bash" if selection == "relative-executable" else None + shell = resolve_agent_shell(platform_name="posix", executable=executable, cwd=str(launch_dir), environment=environment) + assert shell.executable == str(launch_dir / "bin" / "bash") and shell.kind == "bash" + assert Path.cwd() == parent_dir and environment == {"PATH": "bin", "SHELL": "bash"} + result = subprocess.run(shell.build_argv("printf '%s' resolved-launch-shell"), cwd=launch_dir, env=environment, + capture_output=True, text=True, timeout=5, check=False) + assert result.returncode == 0 and result.stdout == "resolved-launch-shell" + + +@pytest.mark.skipif(os.name != "posix", reason="真实 POSIX 空 PATH 项解析") +def test_empty_path_entry_searches_launch_cwd_and_preserves_parent(tmp_path: Path, monkeypatch) -> None: + """PATH 空项表示当前目录,规范后应为子进程的当前目录而非宿主目录。""" + parent_dir, launch_dir = tmp_path / "parent", tmp_path / "launch" + parent_dir.mkdir() + launch_dir.mkdir() + (launch_dir / "bash").symlink_to("/bin/bash") + monkeypatch.chdir(parent_dir) + shell = resolve_agent_shell(platform_name="posix", executable="bash", cwd=str(launch_dir), environment={"PATH": ":missing"}) + assert shell.executable == str(launch_dir / "bash") and Path.cwd() == parent_dir + + +def test_windows_relative_path_entries_use_launch_drive_and_never_parent_lookup(monkeypatch) -> None: + """Windows PATH 按分号、盘符和根路径解析,which 只接收明确绝对候选地址。""" + calls = [] + expected_path = r"C:\launch\bin;D:\tools;C:\launch;C:\launch\local;C:\rooted" + + def which(command: str, *, path: str): + """模拟 .exe 扩展解析,同时记录实际查找目录。""" + calls.append((command, path)) + return r"C:\launch\bin\pwsh.exe" if command == r"C:\launch\bin\pwsh" else None + + monkeypatch.setattr("app.agent.shell.shutil.which", which) + shell = resolve_agent_shell(platform_name="nt", executable="pwsh", cwd=r"C:\launch", + environment={"PATH": r"bin;D:\tools;;C:local;\rooted"}) + assert shell.executable == r"C:\launch\bin\pwsh.exe" + assert calls and all(ntpath.isabs(command) and path == expected_path for command, path in calls) + + +@pytest.mark.parametrize(("executable", "expected"), [ + (r".\bin\pwsh.exe", r"C:\launch\bin\pwsh.exe"), + (r"C:bin\pwsh.exe", r"C:\launch\bin\pwsh.exe"), + (r"\tools\pwsh.exe", r"C:\tools\pwsh.exe"), +]) +def test_windows_relative_executable_uses_launch_cwd_with_injected_finder(executable: str, expected: str) -> None: + """注入 finder 仍只接收一个名称参数,但带目录的相对解释器已确定为实际启动目标。""" + calls = [] + + def finder(command: str): + """仅暴露指定候选路径,防止回退到宿主隐式默认目录。""" + calls.append(command) + return command if command == expected else None + + shell = resolve_agent_shell(platform_name="nt", executable=executable, cwd=r"C:\launch", + environment={}, command_finder=finder) + assert shell.executable == expected and expected in calls + + +def test_windows_cross_drive_relative_path_is_rejected() -> None: + """另一盘符的隐藏当前目录无法从 launch cwd 推导,必须要求明确的绝对地址。""" + with pytest.raises(ValueError, match="跨盘符"): + resolve_agent_shell(platform_name="nt", executable=r"D:bin\pwsh.exe", cwd=r"C:\launch", + environment={}, command_finder=_finder({})) + + +def test_windows_relative_comspec_uses_launch_cwd() -> None: + """显式相对 COMSPEC 不得在切换到另一目录后变成不同 cmd 入口。""" + shell = resolve_agent_shell(platform_name="nt", cwd=r"C:\launch", command_finder=_finder({}), + environment={"COMSPEC": r".\tools\cmd.exe"}) + assert shell.executable == r"C:\launch\tools\cmd.exe" diff --git a/tests/test_agent_side_effect_boundaries.py b/tests/test_agent_side_effect_boundaries.py index 5c78c0be6..606493cd9 100644 --- a/tests/test_agent_side_effect_boundaries.py +++ b/tests/test_agent_side_effect_boundaries.py @@ -21,12 +21,10 @@ from app.agent.policy.contracts import ( ToolPolicyContext, ) from app.agent.policy.orchestrator import DEFAULT_TOOL_POLICY_ORCHESTRATOR +from app.agent.terminal.manager import _TerminalSessionManager +from app.agent.terminal.session import _TerminalSession from app.agent.tools.base import MoviePilotTool from app.agent.tools.catalog import ToolCatalogSnapshot -from app.agent.tools.impl._terminal_session import ( - _TerminalSession, - _TerminalSessionManager, -) class _SlowWriteTool(MoviePilotTool): @@ -129,7 +127,8 @@ async def test_terminal_manager_close_waits_for_starting_session() -> None: use_pty=False, ) - async def _start_session(*_args) -> _TerminalSession: + async def _start_session(*_args, **_kwargs) -> _TerminalSession: + """启动握手保留会话,允许传入已解析解释器而不改变关闭竞态。""" start_entered.set() await allow_start.wait() return session @@ -181,17 +180,20 @@ async def test_terminal_manager_cancellation_terminates_unregistered_session() - ) async def _hold_registration_lock() -> None: + """精确阻塞会话登记,供取消路径验证尚未交付的进程归属。""" await session_created.wait() async with manager._lock: registration_locked.set() await release_registration.wait() - async def _start_session(*_args) -> _TerminalSession: + async def _start_session(*_args, **_kwargs) -> _TerminalSession: + """返回已创建但尚未登记的会话,解释器注入不影响该时序。""" session_created.set() await registration_locked.wait() return session async def _terminate_session(_session: _TerminalSession) -> None: + """标记取消收尾已开始,避免用固定等待猜测执行顺序。""" termination_started.set() lock_holder = asyncio.create_task(_hold_registration_lock()) diff --git a/tests/test_agent_subagent_permissions.py b/tests/test_agent_subagent_permissions.py index 9aafca495..5ee32ac7d 100644 --- a/tests/test_agent_subagent_permissions.py +++ b/tests/test_agent_subagent_permissions.py @@ -116,7 +116,7 @@ async def test_mcp_compatibility_read_tag_cannot_authorize_child_operation(): @pytest.mark.asyncio @pytest.mark.parametrize("name,action,allowed", [ ("execute_command", action, action in {"read", "wait"}) - for action in ("start", "run", "read", "wait", "write", "kill") + for action in ("start", "run", "read", "wait", "write", "interrupt", "kill") ] + [ ("browse_webpage", action, action in {"snapshot", "get_content", "screenshot", "wait", "list_tabs"}) for action in ("goto", "snapshot", "get_content", "screenshot", "click", "click_ref", "fill", "fill_ref", @@ -151,6 +151,19 @@ async def test_browser_read_cannot_change_shared_session_identity(extra): handler.assert_not_awaited() +@pytest.mark.asyncio +async def test_readonly_child_cannot_close_terminal_input(): + """读取标签不能授权向已知会话写入最终输入或触发 EOF。""" + handler = AsyncMock() + permitted, result = await AgentPolicyMiddleware(context=_context()).execute_tool_call( + tool=SimpleNamespace(name="execute_command", tags=["read"]), + arguments={"action": "write", "session_id": "known-session", "input_text": "last", "close_stdin": True}, + handler=handler, invocation_id="terminal-eof", enforce_decision=False, + ) + assert permitted is False and result.status == "error" + handler.assert_not_awaited() + + @pytest.mark.asyncio @pytest.mark.parametrize("action", [None, {}, [], 1]) async def test_invalid_mixed_action_is_denied_without_interrupting_graph(action): diff --git a/tests/test_agent_terminal_cursor.py b/tests/test_agent_terminal_cursor.py index abfa579a1..09f5d2849 100644 --- a/tests/test_agent_terminal_cursor.py +++ b/tests/test_agent_terminal_cursor.py @@ -8,8 +8,9 @@ from typing import Any, Optional import pytest -from app.agent.tools.impl import _terminal_session as terminal -from app.agent.tools.impl._terminal_session import TerminalOutputError, _TerminalSession, _TerminalSessionManager +from app.agent.terminal import session as terminal_state +from app.agent.terminal.manager import TerminalOutputError, _TerminalSessionManager +from app.agent.terminal.session import _TerminalSession from app.agent.tools.result import inspect_tool_result @@ -196,7 +197,7 @@ async def test_exact_end_offset_is_normalized_to_next_chunk() -> None: async def test_retention_gap_resets_partial_offset_and_reports_loss(monkeypatch: pytest.MonkeyPatch) -> None: """部分读取的分片被淘汰后,重新从当前保留窗口起点读并明确历史缺口。""" manager, session = _world() - monkeypatch.setattr(terminal, "TERMINAL_MAX_RETAINED_BYTES", 8) + monkeypatch.setattr(terminal_state, "TERMINAL_MAX_RETAINED_BYTES", 8) session.append_output("pty", b"first") first = await manager.read(session_id=session.session_id, since_seq=0, since_offset=0, max_bytes=2) session.append_output("pty", b"second") @@ -231,7 +232,7 @@ async def test_action_output_error_preserves_handle_and_unconsumed_cursor(tmp_pa session.mark_finished(0) session.finish_output() - async def start_session(*_args: Any) -> _TerminalSession: + async def start_session(*_args: Any, **_kwargs: Any) -> _TerminalSession: """返回独立内存会话,避免此纯分页测试创建外部进程。""" return session @@ -384,7 +385,7 @@ async def test_capture_loss_at_complete_tail_returns_completed() -> None: async def test_new_retention_gap_wakes_once_then_updated_cursor_can_wait(monkeypatch: pytest.MonkeyPatch) -> None: """请求位置刚被保留窗口淘汰时立即报告缺口,新消费游标不会再次触发同一缺口。""" manager, session = _world() - monkeypatch.setattr(terminal, "TERMINAL_MAX_RETAINED_BYTES", 1) + monkeypatch.setattr(terminal_state, "TERMINAL_MAX_RETAINED_BYTES", 1) session.append_output("pty", b"discarded") async def forbidden_wait() -> bool: diff --git a/tests/test_agent_terminal_input.py b/tests/test_agent_terminal_input.py new file mode 100644 index 000000000..669220cea --- /dev/null +++ b/tests/test_agent_terminal_input.py @@ -0,0 +1,341 @@ +"""真实终端输入、EOF、中断与平台信号合同;测试只拥有自身创建的进程和描述符。""" + +import asyncio +import hashlib +import os +import shlex +import subprocess +import sys +from collections.abc import AsyncIterator +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, Mock + +import pytest +import pytest_asyncio + +from app.agent.shell import AgentShell +from app.agent.terminal import manager as terminal +from app.agent.terminal.manager import TerminalOutputError, _TerminalSessionManager +from app.agent.terminal.session import _TerminalSession + + +def _command(code: str) -> str: + """使用当前虚拟环境,POSIX exec 让被测程序直接拥有会话进程与信号处理器。""" + args = [sys.executable, "-u", "-c", code] + return subprocess.list2cmdline(args) if os.name == "nt" else "exec " + shlex.join(args) + + +def _cursor(payload: dict[str, Any]) -> dict[str, int]: + """只续传已经交付的位置,不把输入/信号回执的高水位当成消费证据。""" + return {"since_seq": payload["output_until_seq"], "since_offset": payload["output_until_offset"]} + + +@pytest_asyncio.fixture +async def manager() -> AsyncIterator[_TerminalSessionManager]: + """每个测试拥有独立管理器,失败与取消时也终止全部测试子进程。""" + current = _TerminalSessionManager() + try: + yield current + finally: + await asyncio.wait_for(current.close(), 10) + + +async def _ready(manager: _TerminalSessionManager, code: str, *, use_pty: bool = False) -> dict[str, Any]: + """等待程序显式发布 READY,不启动用户登录 shell,也不靠固定休眠猜就绪。""" + options = {"shell": "/bin/sh", "login": False} if os.name == "posix" else {} + payload = await asyncio.wait_for(manager.start( + command=_command(code), use_pty=use_pty, yield_time_ms=10000, since_offset=0, **options, + ), 5) + assert "READY" in payload["output"] + return payload + + +async def _read_to(manager: _TerminalSessionManager, current: dict[str, Any], marker: str = "") -> tuple[str, dict[str, Any]]: + """读到指定程序握手或输出最终收尾,超时仅用于测试失败保护。""" + output = current["output"] + async with asyncio.timeout(5): + while (marker and marker not in output) or (not marker and not current["output_complete"]): + current = await manager.wait(session_id=current["session_id"], timeout_ms=1000, **_cursor(current)) + output += current["output"] + return output, current + + +@pytest.mark.asyncio +async def test_pipe_last_input_and_half_close_deliver_all_bytes(manager: _TerminalSessionManager) -> None: + """依赖 EOF 的真实程序必须收到全部末段输入,并能继续读完最终输出。""" + payload = await _ready(manager, "import sys,hashlib\nprint('READY',flush=True)\ndata=sys.stdin.buffer.read()\nprint('RESULT:'+str(len(data))+':'+hashlib.sha256(data).hexdigest(),flush=True)") + text = "\x03\x04" + "完整末段😀\n" * 6000 + data = text.encode() + empty = await manager.write(session_id=payload["session_id"], input_text="", **_cursor(payload)) + assert empty["written_bytes"] == 0 and empty["stdin_closed"] is False + result = await manager.write(session_id=payload["session_id"], input_text=text, close_stdin=True, **_cursor(empty)) + assert result["written_bytes"] == len(data) and result["stdin_closed"] is True + output, final = await _read_to(manager, result) + assert f"RESULT:{len(data)}:{hashlib.sha256(data).hexdigest()}" in output + assert final["exit_code"] == 0 and final["output_complete"] is True + repeated = await manager.write(session_id=payload["session_id"], input_text="", close_stdin=True, **_cursor(final)) + assert repeated["written_bytes"] == 0 and repeated["stdin_closed"] is True + with pytest.raises(RuntimeError, match="stdin 已关闭"): + await manager.write(session_id=payload["session_id"], input_text="late", **_cursor(repeated)) + with pytest.raises(RuntimeError, match="stdin 已关闭"): + await manager.write(session_id=payload["session_id"], input_text="", **_cursor(repeated)) + + +@pytest.mark.asyncio +@pytest.mark.skipif(os.name != "posix", reason="本例验证 POSIX PTY 的双向描述符") +async def test_pty_half_close_rejects_before_input_and_keeps_output_readable(manager: _TerminalSessionManager) -> None: + """不支持的组合不能先写入 BAD,也不能通过关闭 master 破坏输出。""" + payload = await _ready(manager, "import sys,tty\ntty.setraw(0)\nprint('READY',flush=True)\ndata=sys.stdin.buffer.read(1)\nprint('GOT:'+str(data[0]),flush=True)", use_pty=True) + session = manager.get_session(payload["session_id"]) + descriptor = session.master_fd + with pytest.raises(ValueError, match="PTY 不支持"): + await manager.write(session_id=session.session_id, input_text="BAD", close_stdin=True, **_cursor(payload)) + assert session.master_fd == descriptor and session.status == "running" and not session.stdin_closed + result = await manager.write(session_id=session.session_id, input_text="Z", **_cursor(payload)) + output, final = await _read_to(manager, result) + assert "GOT:90" in output and final["exit_code"] == 0 + + +@pytest.mark.asyncio +@pytest.mark.skipif(os.name != "posix", reason="本例验证 POSIX 非阻塞 PTY 短写和回压") +async def test_large_raw_pty_input_retries_short_writes_without_losing_tail( + manager: _TerminalSessionManager, monkeypatch: pytest.MonkeyPatch, +) -> None: + """独立控制管道暂停读者,确定性填满 PTY 后再释放,验证真实短写不会丢尾。""" + control_read, control_write = os.pipe() + os.set_inheritable(control_read, True) + text = "0123456789abcdef" * 4096 + data = text.encode() + loop = asyncio.get_running_loop() + blocked = asyncio.Event() + writes: list[tuple[int, int]] = [] + original_write = os.write + task = None + try: + code = ("import os,sys,tty,hashlib\ntty.setraw(0)\nprint('READY',flush=True)\n" + f"os.read({control_read},1)\nos.close({control_read})\ndata=sys.stdin.buffer.read({len(data)})\n" + "print('RESULT:'+str(len(data))+':'+hashlib.sha256(data).hexdigest(),flush=True)") + payload = await _ready(manager, code, use_pty=True) + descriptor = manager.get_session(payload["session_id"]).master_fd + + def observe_write(fd: int, content: Any) -> int: + """只观察该测试 PTY 的真实写入计数与回压,不替换内核返回值。""" + try: + count = original_write(fd, content) + except BlockingIOError: + if fd == descriptor: + loop.call_soon_threadsafe(blocked.set) + raise + if fd == descriptor: + writes.append((len(content), count)) + return count + + monkeypatch.setattr(terminal.os, "write", observe_write) + task = asyncio.create_task(manager.write(session_id=payload["session_id"], input_text=text, **_cursor(payload))) + await asyncio.wait_for(blocked.wait(), 5) + original_write(control_write, b"g") + result = await asyncio.wait_for(task, 5) + assert any(count < requested for requested, count in writes) + assert result["written_bytes"] == len(data) + output, final = await _read_to(manager, result) + assert f"RESULT:{len(data)}:{hashlib.sha256(data).hexdigest()}" in output + assert final["exit_code"] == 0 + finally: + if task is not None and not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + os.close(control_read) + os.close(control_write) + + +@pytest.mark.asyncio +@pytest.mark.skipif(os.name != "posix", reason="本例验证 POSIX SIGINT 处理器") +async def test_interrupt_calls_handler_once_and_program_remains_interactive(manager: _TerminalSessionManager) -> None: + """SIGINT 不设置 kill_requested,也不在等待后升级;处理器返回后仍能输入。""" + code = ("import signal,sys\nsignal.signal(signal.SIGINT,lambda *_:print('INTERRUPTED',flush=True))\n" + "print('READY',flush=True)\nfor line in sys.stdin:\n print('ECHO:'+line.strip(),flush=True)\n if line.strip()=='quit':break\n") + payload = await _ready(manager, code) + result = await manager.interrupt(session_id=payload["session_id"], **_cursor(payload)) + assert result["signal"] == "SIGINT" and result["signal_sent"] is True + output, after = await _read_to(manager, result, "INTERRUPTED") + assert output.count("INTERRUPTED") == 1 + session = manager.get_session(payload["session_id"]) + assert session.status == "running" and session.kill_requested is False + reply = await manager.write(session_id=session.session_id, input_text="quit\n", **_cursor(after)) + output, final = await _read_to(manager, reply) + assert "ECHO:quit" in output and final["exit_code"] == 0 and final["status"] == "exited" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("value", ["NOT_A_SIGNAL", "", 0, "0", True, False, -1, 99999, "SIG_IGN", "SIG_UNBLOCK"]) +async def test_invalid_signal_has_no_os_or_kill_intent_effect(value: Any, monkeypatch: pytest.MonkeyPatch) -> None: + """未知信号、处理器常量和无效编号必须在任何状态变化之前拒绝。""" + current = _TerminalSessionManager() + session = _TerminalSession(session_id="signal-validation", command="memory-only", cwd=".", pid=0, use_pty=False) + current._sessions[session.session_id] = session + send = Mock() + monkeypatch.setattr(current, "_send_signal", send) + with pytest.raises(ValueError): + await current.kill(session_id=session.session_id, sig=value) + send.assert_not_called() + assert session.kill_requested is False and session.status == "running" + + +@pytest.mark.asyncio +async def test_concurrent_pipe_write_then_close_serializes_final_segment( + manager: _TerminalSessionManager, monkeypatch: pytest.MonkeyPatch, +) -> None: + """真实管道首段尚未 drain 时,close 请求必须等待输入锁并完整交付末段。""" + payload = await _ready(manager, "import sys\nprint('READY',flush=True)\nprint('DATA:'+sys.stdin.read(),flush=True)") + session = manager.get_session(payload["session_id"]) + writer = session.process.stdin + original_drain = writer.drain + entered, release = asyncio.Event(), asyncio.Event() + count = 0 + + async def drain() -> None: + """通过事件放大真实 drain 的持锁窗口,不伪造实际字节交付。""" + nonlocal count + count += 1 + if count == 1: + entered.set() + await release.wait() + await original_drain() + + monkeypatch.setattr(writer, "drain", drain) + first = asyncio.create_task(manager.write(session_id=session.session_id, input_text="first-", **_cursor(payload))) + close = None + try: + await asyncio.wait_for(entered.wait(), 5) + close = asyncio.create_task(manager.write(session_id=session.session_id, input_text="last", close_stdin=True, **_cursor(payload))) + assert session.input_lock.locked() and not session.stdin_closed + release.set() + await asyncio.wait_for(first, 5) + result = await asyncio.wait_for(close, 5) + output, final = await _read_to(manager, result) + assert "DATA:first-last" in output and final["exit_code"] == 0 + finally: + release.set() + for task in (first, close): + if task is not None and not task.done(): + task.cancel() + await asyncio.gather(*(task for task in (first, close) if task is not None), return_exceptions=True) + + +@pytest.mark.asyncio +async def test_concurrent_input_after_close_is_rejected_before_writer_receives_it() -> None: + """关闭写端等待收尾期间,后续输入不能插入 close 与完成确认之间。""" + current = _TerminalSessionManager() + entered, release = asyncio.Event(), asyncio.Event() + values: list[bytes] = [] + + async def wait_closed() -> None: + """确定性保持关闭请求持有输入锁,测试不启动虚构 PID。""" + entered.set() + await release.wait() + + writer = SimpleNamespace(write=values.append, drain=AsyncMock(), close=Mock(), wait_closed=wait_closed) + session = _TerminalSession(session_id="close-race", command="memory-only", cwd=".", pid=0, use_pty=False, + process=SimpleNamespace(stdin=writer)) + current._sessions[session.session_id] = session + close = asyncio.create_task(current.write(session_id=session.session_id, input_text="tail", close_stdin=True)) + late = None + try: + await entered.wait() + late = asyncio.create_task(current.write(session_id=session.session_id, input_text="late")) + release.set() + result = await close + with pytest.raises(RuntimeError, match="stdin 已关闭"): + await late + assert values == [b"tail"] and result["stdin_closed"] is True + finally: + release.set() + await asyncio.gather(*(task for task in (close, late) if task is not None), return_exceptions=True) + + +@pytest.mark.asyncio +async def test_input_control_preserves_cursor_validation_and_action_output_error(manager: _TerminalSessionManager) -> None: + """非法游标先于 stdin 动作,已发生 half-close 后的小页错误保留会话和输入事实。""" + payload = await _ready(manager, "import sys\nprint('READY',flush=True)\nprint('DATA:'+sys.stdin.read(),flush=True)") + session = manager.get_session(payload["session_id"]) + with pytest.raises(TerminalOutputError): + await manager.write(session_id=session.session_id, input_text="wrong", close_stdin=True, since_seq=999) + assert session.stdin_closed is False + result = await manager.write(session_id=session.session_id, input_text="ok", close_stdin=True, since_seq=0, max_bytes=1) + assert result["stdin_closed"] is True and result["written_bytes"] == 2 + assert result["output_error"]["code"] == "read_limit_too_small" + assert result["session_id"] == session.session_id + + +@pytest.mark.asyncio +async def test_windows_interrupt_uses_actual_break_event_without_terminate(monkeypatch: pytest.MonkeyPatch) -> None: + """Windows 控制事件与终止 API 分开,仅报告实际调用的 CTRL_BREAK_EVENT。""" + current = _TerminalSessionManager() + process = SimpleNamespace(send_signal=Mock(), terminate=Mock(), kill=Mock()) + session = _TerminalSession(session_id="windows-interrupt", command="memory-only", cwd=".", pid=0, use_pty=False, process=process) + current._sessions[session.session_id] = session + with monkeypatch.context() as scoped: + scoped.setattr(terminal.os, "name", "nt") + scoped.setattr(terminal.signal, "CTRL_BREAK_EVENT", 1, raising=False) + result = await current.interrupt(session_id=session.session_id) + process.send_signal.assert_called_once_with(1) + process.terminate.assert_not_called() + process.kill.assert_not_called() + assert result["signal"] == "CTRL_BREAK_EVENT" and session.kill_requested is False + + +@pytest.mark.asyncio +async def test_windows_missing_control_event_and_unmapped_kill_never_terminate(monkeypatch: pytest.MonkeyPatch) -> None: + """缺少控制事件或请求未映射的信号时明确失败,不能静默执行 terminate。""" + current = _TerminalSessionManager() + process = SimpleNamespace(send_signal=Mock(), terminate=Mock(), kill=Mock()) + session = _TerminalSession(session_id="windows-unavailable", command="memory-only", cwd=".", pid=0, use_pty=False, process=process) + current._sessions[session.session_id] = session + with monkeypatch.context() as scoped: + scoped.setattr(terminal.os, "name", "nt") + scoped.delattr(terminal.signal, "CTRL_BREAK_EVENT", raising=False) + with pytest.raises(NotImplementedError): + await current.interrupt(session_id=session.session_id) + with pytest.raises(ValueError): + await current.kill(session_id=session.session_id, sig="INT") + current._send_signal(session, current._resolve_signal("TERM")) + current._send_signal(session, current._resolve_signal("KILL")) + process.send_signal.assert_not_called() + process.terminate.assert_called_once() + process.kill.assert_called_once() + assert session.kill_requested is False + + +@pytest.mark.asyncio +@pytest.mark.skipif(os.name != "posix", reason="验证 POSIX fork 后 exec 失败分支") +async def test_pty_exec_failure_exits_child_without_parent_logic(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """以模拟 fork 子分支验证失败必须 _exit,不能登记会话或回到父事件循环。""" + current = _TerminalSessionManager() + policy = AgentShell(kind="sh", executable="/bin/sh", arguments=("-c",), login=False) + + class ChildExited(BaseException): + """只用于替代不应真的退出 pytest 的 os._exit。""" + + exit_child = Mock(side_effect=ChildExited()) + monkeypatch.setattr(terminal._pty, "fork", lambda: (0, 12345)) + monkeypatch.setattr(terminal.os, "chdir", Mock()) + monkeypatch.setattr(terminal.os, "execvpe", Mock(side_effect=OSError("exec failed"))) + monkeypatch.setattr(terminal.os, "_exit", exit_child) + nonblocking = Mock() + monkeypatch.setattr(current, "_set_nonblocking", nonblocking) + with pytest.raises(ChildExited): + await current._start_pty_session("echo test", str(tmp_path), {}, shell_policy=policy) + exit_child.assert_called_once_with(127) + nonblocking.assert_not_called() + assert current._sessions == {} + + +@pytest.mark.asyncio +async def test_pipe_shell_policy_metadata_matches_explicit_non_login_execution(manager: _TerminalSessionManager) -> None: + """真实 pipe 命令回显由同一 AgentShell 合同选择的解释器和登录模式。""" + payload = await _ready(manager, "import sys\nprint('READY',flush=True)\nsys.stdin.read()") + if os.name == "posix": + assert payload["shell"] == "/bin/sh" and payload["login"] is False + assert manager.get_session(payload["session_id"]).shell_policy is not None diff --git a/tests/test_agent_terminal_output.py b/tests/test_agent_terminal_output.py index dda0522b1..d669dd110 100644 --- a/tests/test_agent_terminal_output.py +++ b/tests/test_agent_terminal_output.py @@ -12,7 +12,8 @@ from unittest.mock import AsyncMock import pytest import pytest_asyncio -from app.agent.tools.impl._terminal_session import _TerminalSession, _TerminalSessionManager +from app.agent.terminal.manager import _TerminalSessionManager +from app.agent.terminal.session import _TerminalSession _DEADLINE = 5 _INTERACTIVE = """import sys @@ -154,9 +155,9 @@ async def test_zero_yield_and_zero_wait_never_await_output_event(monkeypatch, te original_start = terminal_manager._start_pipe_session event_wait = AsyncMock(side_effect=AssertionError("零窗口不得等待输出事件")) - async def start(*args: Any) -> _TerminalSession: + async def start(*args: Any, **kwargs: Any) -> _TerminalSession: """真实启动静默交互进程,在首次返回前装入不应被调用的等待探针。""" - session = await original_start(*args) + session = await original_start(*args, **kwargs) monkeypatch.setattr(session.changed_event, "wait", event_wait) return session @@ -267,9 +268,9 @@ async def test_cancelled_initial_yield_reclaims_undelivered_session(monkeypatch, entered = asyncio.Event() captured = [] - async def start(*args: Any) -> _TerminalSession: + async def start(*args: Any, **kwargs: Any) -> _TerminalSession: """保留真实启动,在静默会话首次 await 输出时发出取消握手。""" - session = await original_start(*args) + session = await original_start(*args, **kwargs) captured.append(session) original_wait = session.changed_event.wait diff --git a/tests/test_agent_terminal_tool.py b/tests/test_agent_terminal_tool.py index 436b44974..13d33267e 100644 --- a/tests/test_agent_terminal_tool.py +++ b/tests/test_agent_terminal_tool.py @@ -11,9 +11,10 @@ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from app.agent.middleware.output import ToolOutputMiddleware from app.agent.middleware.policy import AgentPolicyMiddleware from app.agent.policy.contracts import AuthSource, PrincipalType, ToolOrigin, ToolPolicyContext +from app.agent.terminal.manager import _TerminalSessionManager +from app.agent.terminal.session import _TerminalSession from app.agent.tools.base import DEFAULT_TOOL_RESULT_MAX_CHARS from app.agent.tools.impl import execute_command as command_module -from app.agent.tools.impl._terminal_session import _TerminalSession, _TerminalSessionManager from app.agent.tools.impl.execute_command import ExecuteCommandInput, ExecuteCommandTool diff --git a/tests/test_execute_command_tool.py b/tests/test_execute_command_tool.py index 00c03fcf5..7d41cdb7d 100644 --- a/tests/test_execute_command_tool.py +++ b/tests/test_execute_command_tool.py @@ -1,3 +1,5 @@ +"""命令工具的输出归档、超时、取消和交互回归,逐测试隔离终端 owner。""" + import asyncio import json import os @@ -5,289 +7,231 @@ import shlex import subprocess import sys import time -import unittest -from unittest.mock import patch +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any -from app.agent.tools.impl.execute_command import ( - MAX_OUTPUT_PREVIEW_BYTES, - ExecuteCommandTool, -) +import pytest +import pytest_asyncio + +from app.agent.terminal.manager import _TerminalSessionManager +from app.agent.tools.impl import execute_command as command_module +from app.agent.tools.impl.execute_command import MAX_OUTPUT_PREVIEW_BYTES, ExecuteCommandTool + +pytestmark = pytest.mark.asyncio def _python_command(code: str) -> str: - """生成当前解释器可执行的 shell 命令,避免依赖系统 python 名称。""" + """生成当前虚拟环境解释器的 shell 命令,避免依赖系统 python 名称。""" args = [sys.executable, "-c", code] - if os.name == "nt": - return subprocess.list2cmdline(args) - return " ".join(shlex.quote(arg) for arg in args) + return subprocess.list2cmdline(args) if os.name == "nt" else shlex.join(args) -class TestExecuteCommandTool(unittest.TestCase): - """验证一次性命令的输出保留、超时收尾与参数安全。""" - - def _temp_file_path_from_result(self, result: str) -> str: - """从结构化回执读取完整归档路径,不再解析人类提示文案。""" - path = json.loads(result)["output_file"] - self.assertTrue(path) - return path - - def _run_command(self, command: str, timeout: int = 60) -> str: - """按一次性执行模式运行命令,兼容旧测试断言。""" - tool = ExecuteCommandTool(session_id="session-1", user_id="10001") - return asyncio.run(tool.run(action="run", command=command, timeout=timeout)) - - def test_large_output_is_truncated_before_returning_to_agent(self): - """大输出一次性命令返回头尾预览,并把完整内容写到临时文件。""" - command = _python_command( - "import sys; sys.stdout.write('HEAD-' + 'x' * 200000 + '-TAIL'); sys.stdout.flush()" - ) - - result = self._run_command(command) - temp_file_path = self._temp_file_path_from_result(result) - - self.addCleanup(lambda: os.path.exists(temp_file_path) and os.unlink(temp_file_path)) - self.assertIn("命令输出超过 32KB", result) - self.assertIn("仅展示前后各 16KB 内容", result) - self.assertIn("如需完整内容,请继续读取该文件", result) - self.assertIn("HEAD-", result) - self.assertIn("-TAIL", result) - self.assertLess(len(result), MAX_OUTPUT_PREVIEW_BYTES + 1200) - - with open(temp_file_path, encoding="utf-8") as file_handle: - file_content = file_handle.read() - - self.assertIn("[标准输出]", file_content) - self.assertIn("HEAD-", file_content) - self.assertIn("-TAIL", file_content) - self.assertGreater(len(file_content), 100000) - - def test_timeout_returns_partial_output_promptly(self): - """一次性命令超时后应及时返回已经读取到的部分输出。""" - command = _python_command( - "import time; print('started', flush=True); time.sleep(5)" - ) - - started_at = time.monotonic() - result = self._run_command(command, timeout=1) - duration = time.monotonic() - started_at - - self.assertLess(duration, 4) - self.assertIn("命令执行超时", result) - self.assertIn("started", result) - - def test_cancelled_run_cleans_up_process(self): - """外层取消 action=run 时应同步清理已经启动的子进程。""" - async def _run_and_cancel(): - tool = ExecuteCommandTool(session_id="session-1", user_id="10001") - command = _python_command("import time; time.sleep(20)") - original_create = asyncio.create_subprocess_shell - process_holder = {} - - async def wrapped_create(*args, **kwargs): - process = await original_create(*args, **kwargs) - process_holder["process"] = process - return process - - with patch( - "app.agent.tools.impl.execute_command.asyncio.create_subprocess_shell", - side_effect=wrapped_create, - ): - task = asyncio.create_task( - tool.run(action="run", command=command, timeout=60) - ) - for _ in range(50): - if "process" in process_holder: - break - await asyncio.sleep(0.02) - self.assertIn("process", process_holder) - task.cancel() - with self.assertRaises(asyncio.CancelledError): - await task - return process_holder["process"] - - process = asyncio.run(_run_and_cancel()) - self.assertIsNotNone(process.returncode) - - def test_timeout_with_large_output_writes_partial_full_log_to_temp_file(self): - """超时且输出较大时,终止前完整输出应写入临时文件。""" - command = _python_command( - "import sys, time; sys.stdout.write('x' * 60000); sys.stdout.flush(); time.sleep(5)" - ) - - result = self._run_command(command, timeout=1) - temp_file_path = self._temp_file_path_from_result(result) - - self.addCleanup(lambda: os.path.exists(temp_file_path) and os.unlink(temp_file_path)) - self.assertIn("命令执行超时", result) - self.assertIn("截至命令终止前的完整输出已写入临时文件", result) - - with open(temp_file_path, encoding="utf-8") as file_handle: - file_content = file_handle.read() - - self.assertIn("[标准输出]", file_content) - self.assertGreaterEqual(file_content.count("x"), 60000) - - def test_timeout_is_capped(self): - """一次性执行的 timeout 参数超过上限时应自动限幅。""" - command = _python_command("print('ok')") - - result = self._run_command(command, timeout=9999) - - self.assertIn("timeout 参数超过上限", result) - self.assertIn("ok", result) - - def test_forbidden_command_is_rejected(self): - """明显危险命令在进入 shell 前应被拒绝。""" - result = self._run_command("echo ok && rm -rf /") - - payload = json.loads(result) - self.assertEqual(payload["status"], "error") - # rm -rf / 命中高危命令防护;断言拒绝且提示需要显式确认,避免锁死单一文案。 - self.assertIn("confirm_dangerous=true", payload["error"]) - - def test_dangerous_command_requires_explicit_confirmation(self): - """高危命令只有携带显式确认参数时才允许进入执行层。""" - tool = ExecuteCommandTool(session_id="session-1", user_id="10001") - - rejected = asyncio.run( - tool.run(action="run", command="echo ok && shutdown now", timeout=1) - ) - allowed = asyncio.run( - tool.run( - action="run", - command=_python_command("print('shutdown now confirmed')"), - timeout=1, - confirm_dangerous=True, - ) - ) - - rejected_payload = json.loads(rejected) - self.assertEqual("error", rejected_payload["status"]) - self.assertIn("confirm_dangerous=true", rejected_payload["error"]) - self.assertIn("shutdown now confirmed", allowed) +def _archive_path(result: str) -> Path: + """从结构化回执取得归档路径,缺失归档不能被当作完整输出。""" + path = json.loads(result)["output_file"] + assert path + return Path(path) -class TestExecuteCommandSessionTool(unittest.IsolatedAsyncioTestCase): - """验证后台终端会话的启动、增量读取、交互和终止。""" +@pytest_asyncio.fixture +async def command_tool(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> AsyncIterator[ExecuteCommandTool]: + """每个测试独占 manager、并发信号量和归档文件,退出时完整回收。""" + manager = _TerminalSessionManager() + archives = [] + original_temporary_file = command_module.NamedTemporaryFile - async def asyncSetUp(self): - """创建每个测试复用的统一命令工具。""" - self.tool = ExecuteCommandTool(session_id="session-1", user_id="10001") - self._created_sessions: list[str] = [] + def temporary_file(*args: Any, **kwargs: Any) -> Any: + """仅改变归档存放位置,保留真实文件和生产归档写入逻辑。""" + kwargs["dir"] = tmp_path + archive = original_temporary_file(*args, **kwargs) + archives.append(archive) + return archive - async def asyncTearDown(self): - """清理测试中残留的后台会话,避免影响后续用例。""" - for session_id in self._created_sessions: - await self.tool.run(action="kill", session_id=session_id) + monkeypatch.setattr(command_module, "get_terminal_session_manager", lambda: manager) + monkeypatch.setattr(command_module, "_command_semaphore", asyncio.Semaphore(command_module.COMMAND_CONCURRENCY_LIMIT)) + monkeypatch.setattr(command_module, "NamedTemporaryFile", temporary_file) + tool = ExecuteCommandTool(session_id="session-1", user_id="10001") + tool.set_agent_context({"is_admin": True, "should_dispatch_reply": False}) + try: + yield tool + finally: + try: + await asyncio.wait_for(manager.close(), timeout=10) + finally: + for archive in archives: + archive.close() + Path(archive.name).unlink(missing_ok=True) - @staticmethod - def _loads(result: str) -> dict: - """解析 execute_command 返回的 JSON 字符串。""" - return json.loads(result) - async def _start(self, command: str, *, use_pty: bool = False) -> dict: - """通过 execute_command 启动后台会话并记录 ID。""" - payload = self._loads( - await self.tool.run(action="start", command=command, use_pty=use_pty) - ) - session_id = payload.get("session_id") - if session_id: - self._created_sessions.append(session_id) - return payload +async def _start(tool: ExecuteCommandTool, command: str, *, use_pty: bool = False) -> dict[str, Any]: + """经工具入口启动真实会话,生命周期由测试独占的 manager 回收。""" + return json.loads(await tool.run(action="start", command=command, use_pty=use_pty)) - async def _wait_until_complete(self, session_id: str, since_seq: int = 0) -> dict: - """按真实消费游标排空尾部,不把第一批新输出误当成进程和读取器已退出。""" - output = [] - offset = 0 - for _ in range(10): - payload = self._loads(await self.tool.run( - action="wait", session_id=session_id, timeout_ms=3000, - since_seq=since_seq, since_offset=offset, - )) - output.append(payload["output"]) - since_seq, offset = payload["output_until_seq"], payload["output_until_offset"] - if payload["output_complete"] and since_seq == payload["last_seq"] and offset == 0: - return {**payload, "output": "".join(output)} - self.fail("命令未在有限读取次数内完成输出") - async def test_default_action_starts_session_promptly(self): - """不传 action 时应默认后台启动,并快速返回会话 ID。""" - command = _python_command( - "print('ready', flush=True); input()" - ) +async def _write_input(tool: ExecuteCommandTool, session_id: str, text: str) -> None: + """确认 stdin 写入已被接收,避免把接口错误掩盖成后续读取超时。""" + payload = json.loads(await tool.run(action="write", session_id=session_id, input_text=text)) + assert payload.get("written_bytes") == len(text.encode("utf-8")), payload - started_at = time.monotonic() - start_payload = self._loads(await self.tool.run(command=command, use_pty=False)) - duration = time.monotonic() - started_at - self._created_sessions.append(start_payload["session_id"]) - self.assertLess(duration, 0.8) - self.assertEqual(start_payload["status"], "running") - self.assertIn("session_id", start_payload) +async def _wait_until_complete( + tool: ExecuteCommandTool, session_id: str, since_seq: int = 0, since_offset: int = 0, +) -> dict[str, Any]: + """按真实消费游标排空尾部,不把首批输出误当成进程和读取器均已结束。""" + output = [] + for _ in range(10): + payload = json.loads(await tool.run( + action="wait", session_id=session_id, timeout_ms=3000, + since_seq=since_seq, since_offset=since_offset, + )) + output.append(payload["output"]) + since_seq, since_offset = payload["output_until_seq"], payload["output_until_offset"] + if payload["output_complete"] and since_seq == payload["last_seq"] and since_offset == 0: + return {**payload, "output": "".join(output)} + raise AssertionError("命令未在有限读取次数内完成输出") - async def test_read_and_wait_get_incremental_output(self): - """同一个 execute_command 工具应能分段等待并读取增量输出。""" - command = _python_command( - "print('ready', flush=True); input(); print('done', flush=True)" - ) - start_payload = await self._start(command) - wait_payload = self._loads( - await self.tool.run( - action="wait", - session_id=start_payload["session_id"], - timeout_ms=200, - since_seq=0, - ) - ) +async def test_large_output_is_truncated_before_returning_to_agent(command_tool: ExecuteCommandTool) -> None: + """大输出保留头尾预览,并将完整内容归档到真实文件。""" + command = _python_command("import sys; sys.stdout.write('HEAD-' + 'x' * 200000 + '-TAIL'); sys.stdout.flush()") + result = await command_tool.run(action="run", command=command, timeout=60) + path = _archive_path(result) + assert "命令输出超过 32KB" in result + assert "仅展示前后各 16KB 内容" in result + assert "如需完整内容,请继续读取该文件" in result + assert "HEAD-" in result and "-TAIL" in result + assert len(result) < MAX_OUTPUT_PREVIEW_BYTES + 1200 + content = path.read_text(encoding="utf-8") + assert "[标准输出]" in content + assert "HEAD-" in content and "-TAIL" in content + assert len(content) > 100000 - self.assertEqual(wait_payload["status"], "running") - self.assertIn("ready", wait_payload["output"]) - await self.tool.run(action="write", session_id=start_payload["session_id"], input_text="continue\n") - final_payload = await self._wait_until_complete( - start_payload["session_id"], since_seq=wait_payload["output_until_seq"], - ) +async def test_timeout_returns_partial_output_promptly(command_tool: ExecuteCommandTool) -> None: + """真实执行超时应及时返回终止前输出,保留原有四秒上限断言。""" + command = _python_command("import time; print('started', flush=True); time.sleep(5)") + started_at = time.monotonic() + result = await command_tool.run(action="run", command=command, timeout=1) + assert time.monotonic() - started_at < 4 + assert "命令执行超时" in result + assert "started" in result - self.assertEqual(final_payload["status"], "exited") - self.assertEqual(final_payload["exit_code"], 0) - self.assertIn("done", final_payload["output"]) - async def test_write_sends_input_to_running_process(self): - """write 动作应能向后台进程 stdin 写入交互输入。""" - command = _python_command( - "line = input('name: '); print('hello ' + line, flush=True)" - ) - start_payload = await self._start(command) +async def test_cancelled_run_cleans_up_process( + command_tool: ExecuteCommandTool, monkeypatch: pytest.MonkeyPatch, +) -> None: + """真正的 exec 子进程创建后用 Event 取消,返回取消前必须清理该进程。""" + original_create = asyncio.create_subprocess_exec + created = asyncio.Event() + processes = [] - await self.tool.run( - action="write", - session_id=start_payload["session_id"], - input_text="moviepilot\n", - ) - wait_payload = await self._wait_until_complete(start_payload["session_id"]) + async def create_process(*args: Any, **kwargs: Any) -> asyncio.subprocess.Process: + """保留真实启动,仅在拿到进程对象后通知取消测试。""" + process = await original_create(*args, **kwargs) + processes.append(process) + created.set() + return process - self.assertEqual(wait_payload["status"], "exited") - self.assertIn("hello moviepilot", wait_payload["output"]) + monkeypatch.setattr(command_module.asyncio, "create_subprocess_exec", create_process) + task = asyncio.create_task(command_tool.run( + action="run", command=_python_command("import time; time.sleep(20)"), timeout=60, + )) + try: + await asyncio.wait_for(created.wait(), timeout=5) + assert processes + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=5) + assert processes[0].returncode is not None + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) - async def test_kill_stops_long_running_process(self): - """kill 动作应能终止长时间运行的后台命令会话。""" - command = _python_command( - "import time; print('started', flush=True); time.sleep(20)" - ) - start_payload = await self._start(command) - read_payload = self._loads( - await self.tool.run( - action="wait", - session_id=start_payload["session_id"], - timeout_ms=500, - since_seq=0, - ) - ) - kill_payload = self._loads( - await self.tool.run(action="kill", session_id=start_payload["session_id"]) - ) +async def test_timeout_with_large_output_writes_partial_full_log_to_temp_file(command_tool: ExecuteCommandTool) -> None: + """超时大输出仍把终止前的完整内容归档,不能只留下截断预览。""" + command = _python_command("import sys, time; sys.stdout.write('x' * 60000); sys.stdout.flush(); time.sleep(5)") + result = await command_tool.run(action="run", command=command, timeout=1) + path = _archive_path(result) + assert "命令执行超时" in result + assert "截至命令终止前的完整输出已写入临时文件" in result + content = path.read_text(encoding="utf-8") + assert "[标准输出]" in content + assert content.count("x") >= 60000 - self.assertIn("started", read_payload["output"]) - self.assertIn(kill_payload["status"], {"killed", "exited"}) + +async def test_timeout_is_capped(command_tool: ExecuteCommandTool) -> None: + """超过上限的 timeout 限幅,同时真实短命令仍正常执行。""" + result = await command_tool.run(action="run", command=_python_command("print('ok')"), timeout=9999) + assert "timeout 参数超过上限" in result + assert "ok" in result + + +async def test_forbidden_command_is_rejected(command_tool: ExecuteCommandTool) -> None: + """根目录删除命令必须在启动前拒绝,并保留显式确认提示。""" + result = await command_tool.run(action="run", command="echo ok && rm -rf /", timeout=60) + payload = json.loads(result) + assert payload["status"] == "error" + assert "confirm_dangerous=true" in payload["error"] + + +async def test_dangerous_command_requires_explicit_confirmation(command_tool: ExecuteCommandTool) -> None: + """拒绝真实高危命令;确认分支仅运行打印文案的无害 Python。""" + rejected = await command_tool.run(action="run", command="echo ok && shutdown now", timeout=1) + allowed = await command_tool.run( + action="run", command=_python_command("print('shutdown now confirmed')"), timeout=1, confirm_dangerous=True, + ) + payload = json.loads(rejected) + assert payload["status"] == "error" + assert "confirm_dangerous=true" in payload["error"] + assert "shutdown now confirmed" in allowed + + +async def test_default_action_starts_session_promptly(command_tool: ExecuteCommandTool) -> None: + """省略 action 时快速返回运行中会话,保留原有 0.8 秒行为上限。""" + command = _python_command("print('ready', flush=True); input()") + started_at = time.monotonic() + payload = json.loads(await command_tool.run(command=command, use_pty=False)) + assert time.monotonic() - started_at < 0.8 + assert payload["status"] == "running" + assert "session_id" in payload + + +async def test_read_and_wait_get_incremental_output(command_tool: ExecuteCommandTool) -> None: + """READY 握手后再放行最后输出,验证同一工具的分段等待和消费位置。""" + initial = await _start(command_tool, _python_command("print('ready', flush=True); input(); print('done', flush=True)")) + waiting = json.loads(await command_tool.run( + action="wait", session_id=initial["session_id"], timeout_ms=200, since_seq=0, + )) + assert waiting["status"] == "running" + assert "ready" in waiting["output"] + await _write_input(command_tool, initial["session_id"], "continue\n") + final = await _wait_until_complete( + command_tool, initial["session_id"], + since_seq=waiting["output_until_seq"], since_offset=waiting["output_until_offset"], + ) + assert final["status"] == "exited" + assert final["exit_code"] == 0 + assert "done" in final["output"] + + +async def test_write_sends_input_to_running_process(command_tool: ExecuteCommandTool) -> None: + """write 向真实 stdin 发送交互输入,尾部结果可完整读取。""" + initial = await _start(command_tool, _python_command("line = input('name: '); print('hello ' + line, flush=True)")) + await _write_input(command_tool, initial["session_id"], "moviepilot\n") + final = await _wait_until_complete(command_tool, initial["session_id"]) + assert final["status"] == "exited" + assert "hello moviepilot" in final["output"] + + +async def test_kill_stops_long_running_process(command_tool: ExecuteCommandTool) -> None: + """读到启动标记后终止长命令,保持原有终态断言。""" + initial = await _start(command_tool, _python_command("import time; print('started', flush=True); time.sleep(20)")) + observed = json.loads(await command_tool.run( + action="wait", session_id=initial["session_id"], timeout_ms=500, since_seq=0, + )) + killed = json.loads(await command_tool.run(action="kill", session_id=initial["session_id"])) + assert "started" in observed["output"] + assert killed["status"] in {"killed", "exited"}