""" 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()