myagent-ai 1.33.0 → 1.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,6 +33,7 @@ import asyncio
33
33
  import json
34
34
  import os
35
35
  import shutil
36
+ import subprocess
36
37
  import threading
37
38
  import time
38
39
  from pathlib import Path
@@ -44,6 +45,139 @@ from aiskills.base import Skill, SkillResult, SkillParameter
44
45
  logger = get_logger("myagent.skills.browser_stealth")
45
46
 
46
47
 
48
+ # ── Xvfb 虚拟显示管理 ──────────────────────────────────────
49
+
50
+ _xvfb_process: Optional[subprocess.Popen] = None
51
+ _xvfb_display: Optional[str] = None
52
+ _xvfb_lock = threading.Lock()
53
+
54
+
55
+ def _has_display() -> bool:
56
+ """检查当前环境是否有可用的 X11 显示"""
57
+ display = os.environ.get("DISPLAY", "").strip()
58
+ if not display:
59
+ return False
60
+ # DISPLAY 已设置,尝试检测是否真的可用
61
+ # 方法1: xdpyinfo
62
+ try:
63
+ result = subprocess.run(
64
+ ["xdpyinfo"],
65
+ capture_output=True, timeout=3,
66
+ env={**os.environ},
67
+ )
68
+ if result.returncode == 0:
69
+ return True
70
+ except Exception:
71
+ pass
72
+ # 方法2: xdpyinfo 不可用时,尝试用 Xvfb 的 -displayfd 验证
73
+ # 如果 DISPLAY 变量指向一个活跃的 X server,即使 xdpyinfo 不可用,
74
+ # 我们也可以认为有可用显示(例如 Xvfb 已经启动过了)
75
+ try:
76
+ # 尝试连接 X server(使用 xdotool 或 xprop 等轻量工具)
77
+ for cmd in (["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"],
78
+ ["xdotool", "getdisplaywidth"]):
79
+ result = subprocess.run(
80
+ cmd, capture_output=True, timeout=3,
81
+ env={**os.environ},
82
+ )
83
+ if result.returncode == 0:
84
+ return True
85
+ except Exception:
86
+ pass
87
+ # 方法3: 如果 DISPLAY 格式正确且 /tmp/.X11-unix/ 下有对应 socket,认为可用
88
+ try:
89
+ if display.startswith(":"):
90
+ num = display[1:].split(".")[0]
91
+ socket_path = f"/tmp/.X11-unix/X{num}"
92
+ if os.path.exists(socket_path):
93
+ return True
94
+ except Exception:
95
+ pass
96
+ return False
97
+
98
+
99
+ def _start_xvfb(display_num: int = 99) -> Optional[str]:
100
+ """
101
+ 启动 Xvfb 虚拟显示服务器。
102
+
103
+ Returns:
104
+ DISPLAY 环境变量值(如 :99),失败返回 None
105
+ """
106
+ global _xvfb_process, _xvfb_display
107
+
108
+ with _xvfb_lock:
109
+ # 如果已经启动,直接返回
110
+ if _xvfb_process and _xvfb_process.poll() is None:
111
+ return _xvfb_display
112
+
113
+ # 清理旧的
114
+ if _xvfb_process:
115
+ try:
116
+ _xvfb_process.terminate()
117
+ _xvfb_process.wait(timeout=3)
118
+ except Exception:
119
+ try:
120
+ _xvfb_process.kill()
121
+ except Exception:
122
+ pass
123
+ _xvfb_process = None
124
+
125
+ xvfb_path = shutil.which("Xvfb")
126
+ if not xvfb_path:
127
+ logger.warning("Xvfb 未安装,无法启动虚拟显示")
128
+ return None
129
+
130
+ display_str = f":{display_num}"
131
+ try:
132
+ # 先清理可能残留的 X11 锁文件
133
+ for lock_file in (f"/tmp/.X{display_num}-lock", f"/tmp/.X11-unix/X{display_num}"):
134
+ if os.path.exists(lock_file):
135
+ try:
136
+ os.unlink(lock_file)
137
+ except Exception:
138
+ pass
139
+
140
+ _xvfb_process = subprocess.Popen(
141
+ [xvfb_path, display_str, "-screen", "0", "1920x1080x24", "-ac", "-nolisten", "tcp"],
142
+ stdout=subprocess.DEVNULL,
143
+ stderr=subprocess.DEVNULL,
144
+ )
145
+ # 等待 Xvfb 启动
146
+ time.sleep(0.5)
147
+ if _xvfb_process.poll() is not None:
148
+ logger.error(f"Xvfb 启动后立即退出 (returncode={_xvfb_process.returncode})")
149
+ _xvfb_process = None
150
+ return None
151
+
152
+ _xvfb_display = display_str
153
+ os.environ["DISPLAY"] = display_str
154
+ logger.info(f"Xvfb 虚拟显示已启动: {display_str}")
155
+ return display_str
156
+ except Exception as e:
157
+ logger.error(f"启动 Xvfb 失败: {e}")
158
+ _xvfb_process = None
159
+ return None
160
+
161
+
162
+ def _stop_xvfb() -> None:
163
+ """停止 Xvfb 虚拟显示服务器"""
164
+ global _xvfb_process, _xvfb_display
165
+
166
+ with _xvfb_lock:
167
+ if _xvfb_process:
168
+ try:
169
+ _xvfb_process.terminate()
170
+ _xvfb_process.wait(timeout=5)
171
+ except Exception:
172
+ try:
173
+ _xvfb_process.kill()
174
+ except Exception:
175
+ pass
176
+ _xvfb_process = None
177
+ _xvfb_display = None
178
+ logger.info("Xvfb 虚拟显示已停止")
179
+
180
+
47
181
  # ── 反检测浏览器管理器 ──────────────────────────────────────
48
182
 
49
183
 
@@ -80,6 +214,7 @@ class StealthBrowser:
80
214
  self._browser = None # DrissionPage Chromium 实例
81
215
  self._started = False
82
216
  self._user_data_dir = ""
217
+ self._xvfb_started_by_us = False # 是否由本实例启动的 Xvfb
83
218
 
84
219
  def _get_user_data_dir(self) -> str:
85
220
  """获取用户数据目录路径"""
@@ -130,7 +265,21 @@ class StealthBrowser:
130
265
  co.set_argument("--disable-dev-shm-usage")
131
266
  co.set_argument("--disable-gpu")
132
267
 
133
- # 无头模式
268
+ # ── 无 DISPLAY 环境处理 ──
269
+ # 当 headless=False 但没有可用显示时,尝试启动 Xvfb 虚拟显示;
270
+ # 如果 Xvfb 不可用则自动降级为 headless 模式
271
+ if not self._headless and not _has_display():
272
+ xvfb_display = _start_xvfb()
273
+ if xvfb_display:
274
+ self._xvfb_started_by_us = True
275
+ logger.info(f"无 DISPLAY 环境,已启动 Xvfb 虚拟显示 ({xvfb_display})")
276
+ else:
277
+ self._headless = True
278
+ logger.warning(
279
+ "无 DISPLAY 环境且 Xvfb 不可用,自动降级为 headless 模式"
280
+ )
281
+
282
+ # 无头模式(co.headless() 内部设置 --headless=new)
134
283
  if self._headless:
135
284
  co.headless()
136
285
 
@@ -138,13 +287,33 @@ class StealthBrowser:
138
287
  browser_path = self._detect_browser()
139
288
  if browser_path:
140
289
  co.set_browser_path(browser_path)
290
+ else:
291
+ return SkillResult(
292
+ success=False,
293
+ error=(
294
+ "未找到 Chrome/Chromium 浏览器。"
295
+ "请安装 Chrome 或设置 CHROME_PATH 环境变量。"
296
+ ),
297
+ )
141
298
 
142
299
  # 设置窗口大小
143
300
  co.set_argument("--window-size=1920,1080")
144
301
 
145
- # 创建浏览器实例
146
- self._browser = Chromium(co)
147
- self._page = self._browser.latest_tab
302
+ # 创建浏览器实例(增加重试逻辑)
303
+ last_error = None
304
+ for attempt in range(3):
305
+ try:
306
+ self._browser = Chromium(co)
307
+ self._page = self._browser.latest_tab
308
+ break
309
+ except Exception as e:
310
+ last_error = e
311
+ logger.warning(f"浏览器启动第 {attempt + 1}/3 次尝试失败: {e}")
312
+ # 清理可能残留的 Chrome 进程
313
+ self._kill_stale_chrome(user_data)
314
+ time.sleep(2 * (attempt + 1))
315
+ else:
316
+ raise last_error
148
317
 
149
318
  # 如果浏览器已崩溃或无法获取 tab
150
319
  if not self._page:
@@ -195,11 +364,23 @@ class StealthBrowser:
195
364
  self._browser = None
196
365
  self._page = None
197
366
  logger.info("反检测浏览器已关闭")
198
- return SkillResult(success=True, message="浏览器已关闭")
199
367
  except Exception as e:
200
368
  logger.error(f"关闭浏览器异常: {e}")
201
369
  self._browser = None
202
370
  self._page = None
371
+
372
+ # 如果由本实例启动了 Xvfb,关闭浏览器后停止虚拟显示
373
+ if self._xvfb_started_by_us:
374
+ self._xvfb_started_by_us = False
375
+ # 检查是否还有其他浏览器实例在使用 Xvfb
376
+ with _browser_lock:
377
+ other_active = any(
378
+ b._started and b is not self
379
+ for b in _browsers.values()
380
+ )
381
+ if not other_active:
382
+ _stop_xvfb()
383
+
203
384
  return SkillResult(success=True, message="浏览器已关闭")
204
385
 
205
386
  async def navigate(self, url: str, wait: float = 2.0) -> SkillResult:
@@ -559,6 +740,34 @@ class StealthBrowser:
559
740
  logger.debug(f"页面检查失败 (profile={self.profile_name}),浏览器可能已关闭")
560
741
  return False
561
742
 
743
+ @staticmethod
744
+ def _kill_stale_chrome(user_data_dir: str = "") -> None:
745
+ """清理可能残留的 Chrome/Chromium 进程"""
746
+ try:
747
+ # 查找使用相同 user-data-dir 的残留 Chrome 进程
748
+ patterns = [
749
+ "chromium-browser.*--remote-debugging-port",
750
+ "chromium.*--remote-debugging-port",
751
+ "chrome.*--remote-debugging-port",
752
+ "google-chrome.*--remote-debugging-port",
753
+ "headless_shell.*--remote-debugging-port",
754
+ ]
755
+ for pattern in patterns:
756
+ result = subprocess.run(
757
+ ["pgrep", "-f", pattern],
758
+ capture_output=True, text=True, timeout=5,
759
+ )
760
+ if result.returncode == 0 and result.stdout.strip():
761
+ for pid_str in result.stdout.strip().split("\n"):
762
+ try:
763
+ pid = int(pid_str.strip())
764
+ os.kill(pid, 9) # SIGKILL
765
+ logger.info(f"已清理残留 Chrome 进程: PID {pid}")
766
+ except (ValueError, ProcessLookupError, PermissionError):
767
+ pass
768
+ except Exception as e:
769
+ logger.debug(f"清理残留 Chrome 进程时出错: {e}")
770
+
562
771
  @staticmethod
563
772
  def _detect_browser() -> Optional[str]:
564
773
  """自动检测 Chromium/Chrome 浏览器路径"""
@@ -693,6 +902,9 @@ def close_stealth_browser(profile_name: str = "") -> None:
693
902
  pass
694
903
  _browsers.clear()
695
904
 
905
+ # 所有浏览器关闭后,停止 Xvfb(如果有)
906
+ _stop_xvfb()
907
+
696
908
 
697
909
  async def close_stealth_browser_async(profile_name: str = "") -> None:
698
910
  """关闭浏览器实例(异步版本,供 async 上下文调用)"""
@@ -39,6 +39,132 @@ from aiskills.base import Skill, SkillResult, SkillParameter
39
39
 
40
40
  logger = get_logger("myagent.skills.chromedev_mcp")
41
41
 
42
+
43
+ # ── Xvfb 虚拟显示管理 ──────────────────────────────────────
44
+
45
+ _xvfb_process: Optional[subprocess.Popen] = None
46
+ _xvfb_display: Optional[str] = None
47
+
48
+
49
+ def _has_display() -> bool:
50
+ """检查当前环境是否有可用的 X11 显示"""
51
+ display = os.environ.get("DISPLAY", "").strip()
52
+ if not display:
53
+ return False
54
+ # DISPLAY 已设置,尝试检测是否真的可用
55
+ # 方法1: xdpyinfo
56
+ try:
57
+ result = subprocess.run(
58
+ ["xdpyinfo"], capture_output=True, timeout=3,
59
+ env={**os.environ},
60
+ )
61
+ if result.returncode == 0:
62
+ return True
63
+ except Exception:
64
+ pass
65
+ # 方法2: 用 xprop/xdotool 等轻量工具验证
66
+ try:
67
+ for cmd in (["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"],
68
+ ["xdotool", "getdisplaywidth"]):
69
+ result = subprocess.run(
70
+ cmd, capture_output=True, timeout=3,
71
+ env={**os.environ},
72
+ )
73
+ if result.returncode == 0:
74
+ return True
75
+ except Exception:
76
+ pass
77
+ # 方法3: 检查 /tmp/.X11-unix/ 下的 socket 文件
78
+ try:
79
+ if display.startswith(":"):
80
+ num = display[1:].split(".")[0]
81
+ socket_path = f"/tmp/.X11-unix/X{num}"
82
+ if os.path.exists(socket_path):
83
+ return True
84
+ except Exception:
85
+ pass
86
+ return False
87
+
88
+
89
+ def _start_xvfb(display_num: int = 98) -> Optional[str]:
90
+ """
91
+ 启动 Xvfb 虚拟显示服务器。
92
+
93
+ Returns:
94
+ DISPLAY 环境变量值(如 :98),失败返回 None
95
+ """
96
+ global _xvfb_process, _xvfb_display
97
+
98
+ # 如果已经启动,直接返回
99
+ if _xvfb_process and _xvfb_process.poll() is None:
100
+ return _xvfb_display
101
+
102
+ # 清理旧的
103
+ if _xvfb_process:
104
+ try:
105
+ _xvfb_process.terminate()
106
+ _xvfb_process.wait(timeout=3)
107
+ except Exception:
108
+ try:
109
+ _xvfb_process.kill()
110
+ except Exception:
111
+ pass
112
+ _xvfb_process = None
113
+
114
+ xvfb_path = shutil.which("Xvfb")
115
+ if not xvfb_path:
116
+ logger.warning("Xvfb 未安装,无法启动虚拟显示")
117
+ return None
118
+
119
+ display_str = f":{display_num}"
120
+ try:
121
+ # 清理可能残留的 X11 锁文件
122
+ for lock_file in (f"/tmp/.X{display_num}-lock", f"/tmp/.X11-unix/X{display_num}"):
123
+ if os.path.exists(lock_file):
124
+ try:
125
+ os.unlink(lock_file)
126
+ except Exception:
127
+ pass
128
+
129
+ _xvfb_process = subprocess.Popen(
130
+ [xvfb_path, display_str, "-screen", "0", "1920x1080x24", "-ac", "-nolisten", "tcp"],
131
+ stdout=subprocess.DEVNULL,
132
+ stderr=subprocess.DEVNULL,
133
+ )
134
+ time.sleep(0.5)
135
+ if _xvfb_process.poll() is not None:
136
+ logger.error(f"Xvfb 启动后立即退出 (returncode={_xvfb_process.returncode})")
137
+ _xvfb_process = None
138
+ return None
139
+
140
+ _xvfb_display = display_str
141
+ os.environ["DISPLAY"] = display_str
142
+ logger.info(f"Xvfb 虚拟显示已启动 (chromedev_mcp): {display_str}")
143
+ return display_str
144
+ except Exception as e:
145
+ logger.error(f"启动 Xvfb 失败: {e}")
146
+ _xvfb_process = None
147
+ return None
148
+
149
+
150
+ def _stop_xvfb() -> None:
151
+ """停止 Xvfb 虚拟显示服务器"""
152
+ global _xvfb_process, _xvfb_display
153
+
154
+ if _xvfb_process:
155
+ try:
156
+ _xvfb_process.terminate()
157
+ _xvfb_process.wait(timeout=5)
158
+ except Exception:
159
+ try:
160
+ _xvfb_process.kill()
161
+ except Exception:
162
+ pass
163
+ _xvfb_process = None
164
+ _xvfb_display = None
165
+ logger.info("Xvfb 虚拟显示已停止 (chromedev_mcp)")
166
+
167
+
42
168
  # ── MCP 通信常量 ──────────────────────────────────────────────
43
169
 
44
170
  # chrome-devtools-mcp 支持的完整工具列表 (v0.21.0)
@@ -154,9 +280,22 @@ class MCPClient:
154
280
  args.extend(["--chromeArg", "--no-sandbox", "--chromeArg", "--disable-setuid-sandbox"])
155
281
 
156
282
  # [v1.17.0] 有头模式: 设置 DISPLAY 环境变量
157
- if not self._headless and self._display_override:
158
- env["DISPLAY"] = self._display_override
159
- logger.info(f"有头模式: DISPLAY={self._display_override}")
283
+ if not self._headless:
284
+ # 优先使用用户指定的 DISPLAY
285
+ if self._display_override:
286
+ env["DISPLAY"] = self._display_override
287
+ logger.info(f"有头模式: DISPLAY={self._display_override}")
288
+ # 无可用显示时自动启动 Xvfb
289
+ elif not _has_display():
290
+ xvfb_display = _start_xvfb()
291
+ if xvfb_display:
292
+ env["DISPLAY"] = xvfb_display
293
+ logger.info(f"无 DISPLAY 环境,已启动 Xvfb: {xvfb_display}")
294
+ else:
295
+ # Xvfb 不可用,降级为 headless 模式
296
+ self._headless = True
297
+ args.append("--headless")
298
+ logger.warning("无 DISPLAY 环境且 Xvfb 不可用,自动降级为 headless 模式")
160
299
 
161
300
  logger.info(f"启动 chrome-devtools-mcp: {' '.join(args)}")
162
301
 
@@ -629,6 +768,9 @@ class MCPClient:
629
768
  # chrome-devtools-mcp 启动的 Chrome 可能不会随 MCP 进程一起退出
630
769
  self._kill_stale_chrome()
631
770
 
771
+ # 清理 Xvfb 虚拟显示
772
+ _stop_xvfb()
773
+
632
774
  def _kill_stale_chrome(self):
633
775
  """清理残留的 Chrome/Chromium 进程
634
776
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.33.0",
3
+ "version": "1.33.1",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {