Files
sci-gui-agent-benchmark/scripts/core/jade_env.py
2026-01-12 18:30:12 +08:00

515 lines
20 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.

"""
JADE Benchmark 环境控制器
负责VM的重置、文件注入/收集、截图获取等操作
"""
import subprocess
import time
import os
import requests
from PIL import Image
import io
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class JadeEnv:
"""轻量级JADE虚拟机环境控制器"""
def __init__(self, vmx_path, snapshot_name="Jade_Ready", vm_ip="192.168.116.129",
vm_password=None, guest_username=None, guest_password=None):
"""
初始化JADE环境
Args:
vmx_path: 虚拟机.vmx文件路径
snapshot_name: 快照名称
vm_ip: 虚拟机IP地址用于HTTP通信
vm_password: 虚拟机文件加密密码(-vp参数
guest_username: 虚拟机内操作系统用户名(-gu参数
guest_password: 虚拟机内操作系统密码(-gp参数
"""
self.vmx_path = vmx_path
self.snapshot_name = snapshot_name
self.vm_ip = vm_ip
self.vm_url = f"http://{vm_ip}:5000"
# VMware认证参数
self.vm_password = vm_password
self.guest_username = guest_username
self.guest_password = guest_password
# VMware Fusion路径macOS
self.vmrun = "/Applications/VMware Fusion.app/Contents/Library/vmrun"
# 虚拟机内路径
self.guest_desktop = r"C:\Users\lzy\Desktop"
logger.info(f"JadeEnv初始化: VM={os.path.basename(vmx_path)}, Snapshot={snapshot_name}")
logger.info(f" 认证配置: vm_password={'已设置' if vm_password else '未设置'}, "
f"guest_user={'已设置' if guest_username else '未设置'}, "
f"guest_pass={'已设置' if guest_password else '未设置'}")
def _build_vmrun_cmd(self, *args):
"""构建vmrun命令"""
cmd = [self.vmrun, "-T", "fusion"]
# 添加认证参数
if self.vm_password:
cmd.extend(["-vp", self.vm_password])
if self.guest_username:
cmd.extend(["-gu", self.guest_username])
if self.guest_password:
cmd.extend(["-gp", self.guest_password])
cmd.extend(args)
return cmd
def _run_vmrun(self, *args, check=True, timeout=30):
"""执行vmrun命令"""
cmd = self._build_vmrun_cmd(*args)
# 打印完整命令(隐藏密码)
cmd_display = []
skip_next = False
for i, part in enumerate(cmd):
if skip_next:
cmd_display.append("***")
skip_next = False
elif part in ["-vp", "-gp"]:
cmd_display.append(part)
skip_next = True
else:
cmd_display.append(part)
logger.info(f"执行vmrun命令: {' '.join(cmd_display)}")
try:
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout
)
if check and result.returncode != 0:
error_msg = result.stderr or result.stdout
raise RuntimeError(f"vmrun命令执行失败: {error_msg}")
return result
except subprocess.TimeoutExpired:
logger.error(f"❌ vmrun命令超时{timeout}秒)")
raise RuntimeError(f"vmrun命令执行超时{timeout}秒)")
def _detect_and_update_ip(self):
"""
检测VM的IP地址如果变化则自动更新
Returns:
bool: IP是否发生变化
"""
logger.info("🔍 检测VM IP地址...")
try:
# 使用vmrun获取VM IP
cmd = self._build_vmrun_cmd("getGuestIPAddress", self.vmx_path)
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=10
)
if result.returncode == 0:
new_ip = result.stdout.strip()
if new_ip and new_ip != "":
if new_ip != self.vm_ip:
logger.info(f"⚠️ IP地址已变化: {self.vm_ip}{new_ip}")
logger.info(f" 自动更新IP地址...")
# 更新实例变量
self.vm_ip = new_ip
self.vm_url = f"http://{new_ip}:5000"
# 更新配置文件
try:
import json
from pathlib import Path
# 获取项目根目录jade_env.py在scripts/core/向上3级到项目根目录
project_root = Path(__file__).parent.parent.parent
config_path = project_root / "config.json"
if config_path.exists():
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
config["network"]["vm_ip"] = new_ip
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2, ensure_ascii=False)
logger.info(f"✅ 配置文件已更新: {config_path}")
except Exception as e:
logger.warning(f"⚠️ 更新配置文件失败: {e}(不影响使用)")
return True
else:
logger.info(f"✅ IP地址未变化: {self.vm_ip}")
return False
else:
logger.warning(f"⚠️ vmrun返回空IP地址")
return False
else:
error_msg = result.stderr or result.stdout
logger.warning(f"⚠️ 获取IP失败: {error_msg}将使用配置中的IP")
return False
except subprocess.TimeoutExpired:
logger.warning(f"⚠️ 获取IP超时将使用配置中的IP")
return False
except Exception as e:
logger.warning(f"⚠️ 检测IP异常: {e}将使用配置中的IP")
return False
def reset(self, wait_time=5):
"""
重置环境:恢复快照并启动虚拟机
Args:
wait_time: 启动后等待时间(秒)
"""
logger.info(f"正在恢复快照: {self.snapshot_name}...")
try:
# 1. 恢复快照
self._run_vmrun("revertToSnapshot", self.vmx_path, self.snapshot_name)
logger.info("✅ 快照恢复成功")
time.sleep(3)
# 2. 启动虚拟机(如果未运行)
logger.info("正在启动虚拟机...")
result = self._run_vmrun("start", self.vmx_path, check=False)
if result.returncode == 0:
logger.info("✅ 虚拟机启动成功")
else:
# 可能已经在运行
if "is already running" in result.stderr.lower():
logger.info("✅ 虚拟机已在运行")
else:
logger.warning(f"启动虚拟机警告: {result.stderr}")
# 3. 等待系统稳定快照恢复后agent_server已在运行
logger.info(f"等待系统稳定 ({wait_time}秒)...")
time.sleep(wait_time)
# 4. 检测并更新IP地址恢复快照后IP可能变化
self._detect_and_update_ip()
# 5. 验证HTTP服务可用
self._wait_for_http_service()
logger.info("✅ 环境重置完成")
except Exception as e:
logger.error(f"❌ 环境重置失败: {e}")
raise
def _wait_for_http_service(self, max_retries=10, retry_interval=3):
"""等待agent_server.py HTTP服务可用"""
logger.info(f"等待虚拟机HTTP服务... (URL: {self.vm_url})")
# 绕过代理避免Clash等代理工具干扰局域网访问
proxies = {
'http': None,
'https': None
}
for i in range(max_retries):
try:
logger.debug(f"尝试连接: {self.vm_url}/screen_info (timeout=5秒, 不使用代理)")
response = requests.get(f"{self.vm_url}/screen_info", timeout=5, proxies=proxies)
logger.debug(f"收到响应: status_code={response.status_code}")
if response.status_code == 200:
logger.info("✅ HTTP服务已就绪")
return True
else:
logger.warning(f"HTTP状态码异常: {response.status_code}")
except requests.exceptions.Timeout as e:
logger.info(f"HTTP服务未就绪超时重试 {i+1}/{max_retries}...")
if i < max_retries - 1:
time.sleep(retry_interval)
except requests.exceptions.ConnectionError as e:
logger.info(f"HTTP服务未就绪连接失败: {str(e)[:50]}),重试 {i+1}/{max_retries}...")
if i < max_retries - 1:
time.sleep(retry_interval)
except requests.exceptions.RequestException as e:
logger.warning(f"HTTP请求异常: {type(e).__name__}: {str(e)[:100]}")
if i < max_retries - 1:
logger.info(f"重试 {i+1}/{max_retries}...")
time.sleep(retry_interval)
else:
logger.error("❌ HTTP服务超时")
logger.error(f" 最后错误: {e}")
return False
logger.error("❌ HTTP服务超时请检查agent_server.py是否在VM中运行")
logger.info(" 在VM中运行: python agent_server.py")
return False
def inject_file(self, host_path, guest_filename=None):
"""
将文件从主机注入到虚拟机桌面
Args:
host_path: 主机文件路径
guest_filename: 虚拟机中的文件名(默认使用原文件名)
"""
if not os.path.exists(host_path):
raise FileNotFoundError(f"源文件不存在: {host_path}")
if guest_filename is None:
guest_filename = os.path.basename(host_path)
guest_path = f"{self.guest_desktop}\\{guest_filename}"
# 获取文件大小
file_size = os.path.getsize(host_path)
file_size_kb = file_size / 1024
logger.info(f"注入文件: {os.path.basename(host_path)} ({file_size_kb:.1f}KB) → 虚拟机桌面")
logger.info(f" 源路径: {host_path}")
logger.info(f" 目标路径: {guest_path}")
try:
start_time = time.time()
# 使用vmrun传输30秒超时
self._run_vmrun(
"copyFileFromHostToGuest",
self.vmx_path,
host_path,
guest_path,
timeout=30
)
elapsed = time.time() - start_time
logger.info(f"✅ 文件注入成功: {guest_filename} (耗时 {elapsed:.1f}秒)")
except Exception as e:
logger.error(f"❌ 文件注入失败: {e}")
raise
def collect_file(self, guest_filename, host_path):
"""
从虚拟机桌面收集文件到主机
Args:
guest_filename: 虚拟机桌面上的文件名
host_path: 主机保存路径
"""
guest_path = f"{self.guest_desktop}\\{guest_filename}"
logger.info(f"收集文件: {guest_filename}{os.path.basename(host_path)}")
try:
# 确保目标目录存在
os.makedirs(os.path.dirname(host_path), exist_ok=True)
# 方法1: 尝试使用vmrun
try:
self._run_vmrun(
"copyFileFromGuestToHost",
self.vmx_path,
guest_path,
host_path
)
logger.info(f"✅ 文件收集成功vmrun: {guest_filename}")
return
except RuntimeError as e:
logger.warning(f"vmrun收集失败尝试HTTP方式: {e}")
# 方法2: 尝试通过HTTP下载备用
try:
response = requests.get(
f"{self.vm_url}/download/{guest_filename}",
timeout=10
)
if response.status_code == 200:
with open(host_path, 'wb') as f:
f.write(response.content)
logger.info(f"✅ 文件收集成功HTTP: {guest_filename}")
else:
raise RuntimeError(f"HTTP下载失败: {response.status_code}")
except Exception as http_error:
logger.error(f"❌ HTTP收集也失败: {http_error}")
raise RuntimeError(f"文件收集失败(两种方法都失败)")
except Exception as e:
logger.error(f"❌ 文件收集失败: {e}")
raise
def get_screenshot(self, retry_with_ip_detect=True):
"""
获取虚拟机截图
Args:
retry_with_ip_detect: 如果连接失败是否尝试检测IP并重试
Returns:
PIL.Image对象
"""
try:
# 绕过代理
proxies = {'http': None, 'https': None}
response = requests.get(f"{self.vm_url}/screenshot", timeout=5, proxies=proxies)
if response.status_code == 200:
return Image.open(io.BytesIO(response.content))
else:
raise RuntimeError(f"截图失败: HTTP {response.status_code}")
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
if retry_with_ip_detect:
logger.warning(f"⚠️ 截图连接失败尝试检测并更新IP...")
if self._detect_and_update_ip():
# IP已更新重试一次
logger.info(f"🔄 使用新IP重试截图...")
return self.get_screenshot(retry_with_ip_detect=False)
logger.error(f"❌ 获取截图失败: {e}")
raise
except Exception as e:
logger.error(f"❌ 获取截图失败: {e}")
raise
def get_screen_info(self, retry_with_ip_detect=True):
"""
获取虚拟机屏幕信息分辨率、DPI等
Args:
retry_with_ip_detect: 如果连接失败是否尝试检测IP并重试
Returns:
dict: 包含screen_width, screen_height, dpi_scale等信息
"""
try:
# 绕过代理
proxies = {'http': None, 'https': None}
response = requests.get(f"{self.vm_url}/screen_info", timeout=5, proxies=proxies)
if response.status_code == 200:
return response.json()
else:
raise RuntimeError(f"获取屏幕信息失败: HTTP {response.status_code}")
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
if retry_with_ip_detect:
logger.warning(f"⚠️ 屏幕信息连接失败尝试检测并更新IP...")
if self._detect_and_update_ip():
# IP已更新重试一次
logger.info(f"🔄 使用新IP重试获取屏幕信息...")
return self.get_screen_info(retry_with_ip_detect=False)
logger.error(f"❌ 获取屏幕信息失败: {e}")
raise
except Exception as e:
logger.error(f"❌ 获取屏幕信息失败: {e}")
raise
def list_desktop_files(self):
"""
列出虚拟机桌面文件(用于调试)
Returns:
list: 文件名列表
"""
try:
# 绕过代理
proxies = {'http': None, 'https': None}
response = requests.get(f"{self.vm_url}/list_desktop", timeout=5, proxies=proxies)
if response.status_code == 200:
return response.json().get('files', [])
else:
raise RuntimeError(f"列出文件失败: HTTP {response.status_code}")
except Exception as e:
logger.warning(f"⚠️ 列出桌面文件失败: {e}")
return []
def send_action(self, action_type, **params):
"""
发送动作到虚拟机用于未来的Agent自动执行
Args:
action_type: 动作类型 (click/type/hotkey)
**params: 动作参数
"""
try:
# 绕过代理
proxies = {'http': None, 'https': None}
payload = {"type": action_type, **params}
response = requests.post(
f"{self.vm_url}/action",
json=payload,
timeout=5,
proxies=proxies
)
if response.status_code == 200:
logger.debug(f"动作执行成功: {action_type}")
else:
raise RuntimeError(f"动作执行失败: HTTP {response.status_code}")
except Exception as e:
logger.error(f"❌ 发送动作失败: {e}")
raise
def get_mouse_pos(self):
"""
从虚拟机获取当前鼠标物理坐标
Returns:
tuple: (x, y) 物理坐标,失败返回 (None, None)
"""
try:
# 绕过代理
proxies = {'http': None, 'https': None}
response = requests.get(f"{self.vm_url}/mouse_pos", timeout=2, proxies=proxies)
if response.status_code == 200:
data = response.json()
return data['x'], data['y']
return None, None
except Exception as e:
logger.debug(f"获取VM鼠标位置失败: {e}")
return None, None
if __name__ == "__main__":
# 测试代码
print("JadeEnv 测试")
print("=" * 60)
# 配置(需要根据实际情况修改)
VMX_PATH = "/Volumes/Castor/虚拟机/Jade_Win_11.vmwarevm/Windows 11 64 位 ARM 2.vmx"
SNAPSHOT = "Jade_Ready"
VM_PASSWORD = "lizhanyuan"
try:
env = JadeEnv(
vmx_path=VMX_PATH,
snapshot_name=SNAPSHOT,
vm_password=VM_PASSWORD,
guest_username="lzy",
guest_password="LIZHANYUAN"
)
# 测试重置
print("\n测试1: 重置环境")
env.reset()
# 测试获取屏幕信息
print("\n测试2: 获取屏幕信息")
info = env.get_screen_info()
print(f" 分辨率: {info['screen_width']}x{info['screen_height']}")
print(f" DPI缩放: {info['dpi_scale']}")
# 测试列出桌面文件
print("\n测试3: 列出桌面文件")
files = env.list_desktop_files()
print(f" 桌面文件: {files[:5]}..." if len(files) > 5 else f" 桌面文件: {files}")
print("\n✅ 所有测试通过!")
except Exception as e:
print(f"\n❌ 测试失败: {e}")
import traceback
traceback.print_exc()