agent-Specialization/modules/terminal_ops/python.py
JOJO f661dc6456 refactor(server,modules,utils,frontend): split oversized modules into packages and fix regressions
- Split server/chat.py, server/status.py, server/tasks.py into sub-packages
- Split utils/context_manager.py, utils/conversation_manager.py into mixin packages
- Split modules/file_manager.py, persistent_terminal.py, terminal_ops.py, mcp_client_manager.py into packages
- Split core/main_terminal_parts/context.py into base + mixins
- Split static/src/app/methods/{ui,message,upload,taskPolling,conversation} into sub-modules
- Fix flattened method exports, dynamic import paths, missing cross-module imports
- Add missing permission/network/execution mode constants and _PERMISSION_MODE_LABEL
- Update AGENTS.md, CLAUDE.md and project memories to reflect new structure
2026-06-20 15:54:48 +08:00

201 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# modules/terminal_ops.py - 终端操作模块修复Python命令检测
import os
import sys
import asyncio
import subprocess
import shutil
import time
import signal
import re
from pathlib import Path
from typing import Dict, Optional, Tuple, TYPE_CHECKING
from types import SimpleNamespace
try:
from config import (
TERMINAL_COMMAND_TIMEOUT,
FORBIDDEN_COMMANDS,
OUTPUT_FORMATS,
MAX_RUN_COMMAND_CHARS,
TOOLBOX_TERMINAL_IDLE_SECONDS,
HOST_SANDBOX_NETWORK_PERMISSION,
)
except ImportError:
project_root = Path(__file__).resolve().parents[1]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from config import (
TERMINAL_COMMAND_TIMEOUT,
FORBIDDEN_COMMANDS,
OUTPUT_FORMATS,
MAX_RUN_COMMAND_CHARS,
TOOLBOX_TERMINAL_IDLE_SECONDS,
HOST_SANDBOX_NETWORK_PERMISSION,
)
from modules.toolbox_container import ToolboxContainer
from modules.host_sandbox_runner import (
HostSandboxError,
NETWORK_PERMISSION_RESTRICTED,
build_host_sandbox_plan,
build_host_sandbox_readonly_plan,
host_sandbox_enabled,
)
if TYPE_CHECKING:
from modules.user_container_manager import ContainerHandle
from modules.terminal_manager import TerminalManager
class PythonMixin:
"""TerminalOperator python 能力 mixin。"""
def _detect_python_runtime(self) -> str:
"""
自动检测可用的 Python 命令,优先使用预装虚拟环境。
1) 优先查找 AGENT_TOOLBOX_VENV /opt/agent-venv / 当前进程 VIRTUAL_ENV。
2) 找不到预装虚拟环境时,回退到系统可执行文件。
"""
preferred = self._detect_preinstalled_python()
if preferred:
# 记录虚拟环境相关环境变量,供宿主机子进程复用
self._python_env = self._build_python_env(preferred)
return preferred
return self._detect_system_python()
@staticmethod
def _derive_pip_from_python(python_path: str) -> str:
"""
根据 python 可执行文件推导匹配的 pip可避免“python 在 venv、pip 却指向系统”。
若同目录下找不到 pip3/pip则回退为 `<python> -m pip`。
"""
try:
bin_dir = Path(python_path).resolve().parent
for name in ("pip3", "pip"):
cand = bin_dir / name
if cand.exists() and os.access(cand, os.X_OK):
return str(cand)
except Exception:
pass
return f"{python_path} -m pip"
def _detect_preinstalled_python(self) -> Optional[str]:
"""尝试定位预装虚拟环境的 python 可执行文件。"""
# 允许显式指定可执行文件(优先级最高)
explicit_python = os.environ.get("AGENT_PYTHON_BIN")
if explicit_python:
p = Path(explicit_python).expanduser()
if p.exists() and os.access(p, os.X_OK) and not self._is_blocked_python_candidate(str(p)):
try:
return str(p.resolve())
except Exception:
return str(p)
candidates = []
if sys.platform == "darwin":
# 优先使用非 shim 的系统 Python 路径(避免 /usr/bin/python3 xcode shim
mac_python_candidates = [
"/Library/Developer/CommandLineTools/usr/bin/python3",
"/opt/homebrew/bin/python3",
"/usr/local/bin/python3",
]
for raw_py in mac_python_candidates:
p = Path(raw_py)
if p.exists() and os.access(p, os.X_OK) and not self._is_blocked_python_candidate(str(p)):
try:
return str(p.resolve())
except Exception:
return str(p)
# 环境变量优先Dockerfile 已设置 AGENT_TOOLBOX_VENV=/opt/agent-venv
env_venv = os.environ.get("AGENT_TOOLBOX_VENV")
if env_venv:
candidates.append(env_venv)
# 默认预装路径
candidates.append("/opt/agent-venv")
# 当前进程若已激活虚拟环境,也纳入候选
current_venv = os.environ.get("VIRTUAL_ENV")
if current_venv:
candidates.append(current_venv)
# 去重并依次检查
seen = set()
for raw in candidates:
if not raw or raw in seen:
continue
seen.add(raw)
root = Path(raw).expanduser()
bin_dir = root / ("Scripts" if sys.platform == "win32" else "bin")
for name in ("python3", "python"):
candidate = bin_dir / name
if candidate.exists() and os.access(candidate, os.X_OK):
return str(candidate.resolve())
return None
def _detect_system_python(self) -> str:
"""在系统 PATH 中探测可用的 Python3 可执行文件。"""
commands_to_try = ["python", "py", "python3"] if sys.platform == "win32" else ["python3", "python"]
for cmd in commands_to_try:
resolved = shutil.which(cmd)
if not resolved:
continue
if self._is_blocked_python_candidate(resolved):
continue
try:
result = subprocess.run(
[resolved, "--version"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
output = result.stdout + result.stderr
if "Python 3" in output or "Python 2" not in output:
return resolved
except Exception:
continue
fallback = "python" if sys.platform == "win32" else "python3"
fallback_resolved = shutil.which(fallback) or fallback
if sys.platform == "darwin" and self._is_blocked_python_candidate(fallback_resolved):
raise RuntimeError(
"未找到可用的 Python3 可执行文件:检测到 /usr/bin/python3 (xcode shim) 在沙箱中不可用。"
"请设置 AGENT_PYTHON_BIN 或安装非 shim 的 python3。"
)
return fallback
def _is_blocked_python_candidate(self, candidate: str) -> bool:
"""过滤已知在当前沙箱下不可靠的 Python 候选。"""
if sys.platform != "darwin":
return False
try:
resolved = str(Path(candidate).expanduser().resolve())
except Exception:
resolved = candidate
# macOS 的 /usr/bin/python3 常为 xcode shim会调用 xcodebuild 并触发 /dev/null 拦截
if resolved == "/usr/bin/python3":
return True
return False
def _build_python_env(self, python_path: str) -> Dict[str, str]:
"""构造与预装虚拟环境匹配的环境变量(仅作用于宿主机子进程)。"""
env: Dict[str, str] = {}
try:
py_path = Path(python_path).resolve()
bin_dir = py_path.parent
venv_dir = bin_dir.parent
env["VIRTUAL_ENV"] = str(venv_dir)
current_path = os.environ.get("PATH", "")
# 避免重复添加
prefix = str(bin_dir)
if current_path.startswith(prefix + os.pathsep) or current_path == prefix:
env["PATH"] = current_path
else:
env["PATH"] = f"{prefix}{os.pathsep}{current_path}" if current_path else prefix
except Exception:
pass
return env