diff --git a/modules/terminal_manager.py b/modules/terminal_manager.py index bfe5644..5aaadee 100644 --- a/modules/terminal_manager.py +++ b/modules/terminal_manager.py @@ -3,6 +3,7 @@ import json import time import shlex +import shutil from typing import Dict, List, Optional, Callable, TYPE_CHECKING from pathlib import Path from datetime import datetime @@ -122,6 +123,8 @@ class TerminalManager: if sandbox_options and sandbox_options.get("container_name"): self.sandbox_mode = "docker" self._apply_container_session(container_session) + # 终端命令超时工具(兼容 macOS 无 coreutils 的情况) + self._timeout_bin = shutil.which("timeout") or shutil.which("gtimeout") # 终端会话字典 self.terminals: Dict[str, PersistentTerminal] = {} @@ -532,11 +535,7 @@ class TerminalManager: base_timeout = timeout marker = f"__CMD_DONE__{int(time.time()*1000)}__" - # 1) timeout -k 2 : 超时后先发 SIGTERM,再 2 秒后 SIGKILL,只结束命令不关终端 - # 2) sh -c 'cmd; echo marker' 用标记区分正常结束;在终端侧过滤掉回显中的误判 - wrapped_inner = f"{command} ; echo {marker}" - wrapped_command = f"timeout -k 2 {int(timeout)}s sh -c {shlex.quote(wrapped_inner)}" - wait_timeout = timeout + 3 # 留出终止与收尾输出 + wrapped_command, wait_timeout = self._build_wrapped_command(command, marker, timeout) result = terminal.send_command( wrapped_command, @@ -548,6 +547,33 @@ class TerminalManager: result["timeout"] = base_timeout return result + + def _build_wrapped_command(self, command: str, marker: str, timeout: int) -> (str, int): + """ + 构造带超时与完成标记的包装命令。 + + - 优先使用 coreutils timeout / gtimeout + - 若不可用(如 macOS 默认环境),退化为 sleep+kill 方案 + """ + safe_timeout = max(1, int(timeout)) + wait_timeout = safe_timeout + 3 + + if self._timeout_bin: + wrapped_inner = f"{command} ; echo {marker}" + wrapped_command = f"{self._timeout_bin} -k 2 {safe_timeout}s sh -c {shlex.quote(wrapped_inner)}" + return wrapped_command, wait_timeout + + # fallback: 使用 sh + sleep/kill 实现超时(适用于缺少 timeout 的环境) + fallback_inner = ( + f"{command} & CMD_PID=$!; " + f"(sleep {safe_timeout} && kill -s INT $CMD_PID >/dev/null 2>&1 && sleep 2 && kill -s KILL $CMD_PID >/dev/null 2>&1) & " + f"WAITER=$!; " + f"wait $CMD_PID; CMD_STATUS=$?; kill $WAITER >/dev/null 2>&1; " + f"echo {marker}; exit $CMD_STATUS" + ) + wrapped_command = f"sh -c {shlex.quote(fallback_inner)}" + # 额外留 1s 收尾 + return wrapped_command, safe_timeout + 4 def get_terminal_output( self, diff --git a/modules/terminal_ops.py b/modules/terminal_ops.py index 303ff8b..419ea72 100644 --- a/modules/terminal_ops.py +++ b/modules/terminal_ops.py @@ -40,9 +40,11 @@ class TerminalOperator: def __init__(self, project_path: str, container_session: Optional["ContainerHandle"] = None): self.project_path = Path(project_path).resolve() self.process = None - # 自动检测Python命令,并记录虚拟环境变量(若存在) + # 自动检测Python命令,并记录虚拟环境变量(仅宿主机使用) self._python_env: Dict[str, str] = {} self.python_cmd = self._detect_python_runtime() + # Docker 容器内的 Python 命令(镜像内已将 /opt/agent-venv/bin 置于 PATH) + self.container_python_cmd = os.environ.get("CONTAINER_PYTHON_CMD", "python3") print(f"{OUTPUT_FORMATS['info']} 检测到Python命令: {self.python_cmd}") self._toolbox: Optional[ToolboxContainer] = None self.container_session: Optional["ContainerHandle"] = container_session @@ -220,9 +222,11 @@ class TerminalOperator: } # 每次执行前重置工具容器(保持隔离),但下面改用一次性子进程执行,仍保留重置以兼容后续逻辑 self._reset_toolbox() + execution_in_container = bool(self.container_session and getattr(self.container_session, "mode", None) == "docker") + python_rewrite = self.container_python_cmd if execution_in_container else self.python_cmd # 统一替换 python/python3,为保障虚拟环境命中只替换独立单词 if re.search(r"\bpython3?\b", command): - command = re.sub(r"\bpython3?\b", self.python_cmd, command) + command = re.sub(r"\bpython3?\b", python_rewrite, command) # 验证命令 valid, error = self._validate_command(command) @@ -507,9 +511,9 @@ class TerminalOperator: except ValueError: relative_temp = temp_file.as_posix() - # 使用检测到的Python命令执行文件(相对路径可兼容容器挂载路径) + # 使用通用 python3 占位,由 run_command 根据执行环境重写 result = await self.run_command( - f'{self.python_cmd} -u "{relative_temp}"', + f'python3 -u "{relative_temp}"', timeout=timeout ) @@ -579,8 +583,8 @@ class TerminalOperator: except ValueError: relative_path = full_path.as_posix() - # 使用检测到的Python命令构建命令 - command = f'{self.python_cmd} "{relative_path}"' + # 使用通用 python3 占位,由 run_command 根据执行环境重写 + command = f'python3 "{relative_path}"' if args: command += f" {args}" @@ -599,8 +603,8 @@ class TerminalOperator: """ print(f"{OUTPUT_FORMATS['terminal']} 安装包: {package}") - # 使用检测到的Python命令安装 - command = f'{self.python_cmd} -m pip install {package}' + # 使用通用 python3 占位,由 run_command 根据执行环境重写 + command = f'python3 -m pip install {package}' result = await self.run_command(command, timeout=120) @@ -625,7 +629,7 @@ class TerminalOperator: # 获取Python版本 version_result = await self.run_command( - f'{self.python_cmd} --version', + 'python3 --version', timeout=5 ) if version_result["success"]: @@ -633,7 +637,7 @@ class TerminalOperator: # 获取pip版本 pip_result = await self.run_command( - f'{self.python_cmd} -m pip --version', + 'python3 -m pip --version', timeout=5 ) if pip_result["success"]: @@ -641,7 +645,7 @@ class TerminalOperator: # 获取已安装的包 packages_result = await self.run_command( - f'{self.python_cmd} -m pip list --format=json', + 'python3 -m pip list --format=json', timeout=10 ) if packages_result["success"]: