myagent-ai 1.47.24 → 1.47.26

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.
@@ -34,6 +34,7 @@ import json
34
34
  import os
35
35
  import shutil
36
36
  import subprocess
37
+ import sys
37
38
  import threading
38
39
  import time
39
40
  from pathlib import Path
@@ -880,12 +881,29 @@ class StealthBrowser:
880
881
  capture_output=True, text=True, timeout=5,
881
882
  )
882
883
  if result.returncode == 0 and result.stdout.strip():
883
- logger.info(f"检测到已有 Firefox 运行 (PID: {result.stdout.strip().split()[0]}),复用")
884
+ pid = result.stdout.strip().split()[0]
885
+ logger.info(f"检测到已有 Firefox 运行 (PID: {pid}),复用")
886
+
887
+ # [v1.47.26] 修正 profile 目录:已有 Firefox 可能用的是 VNC manager 启动的
888
+ # 默认 profile (~/.mozilla/firefox/default),而不是 browser_profile_manager 路径
889
+ # 读取 Firefox 的命令行参数来确定实际使用的 profile
890
+ actual_profile = self._detect_firefox_profile(pid)
891
+ if actual_profile:
892
+ logger.info(f"检测到已有 Firefox 使用的 profile: {actual_profile}")
893
+ self._firefox_profile_dir = actual_profile
894
+ else:
895
+ # 从 profiles.ini 推断默认 profile
896
+ default_profile = self._get_firefox_default_profile()
897
+ if default_profile:
898
+ logger.info(f"使用 Firefox 默认 profile: {default_profile}")
899
+ self._firefox_profile_dir = default_profile
900
+
884
901
  self._started = True
885
902
  return SkillResult(
886
903
  success=True,
887
- message=f"Firefox 已在 VNC 中运行 (Profile: {self.profile_name})",
888
- data={"profile": self.profile_name, "mode": "firefox_vnc", "reused": True},
904
+ message=f"Firefox 已在 VNC 中运行 (Profile: {self.profile_name}, 实际目录: {self._firefox_profile_dir})",
905
+ data={"profile": self.profile_name, "mode": "firefox_vnc", "reused": True,
906
+ "firefox_profile_dir": self._firefox_profile_dir},
889
907
  )
890
908
  except Exception:
891
909
  pass
@@ -2097,10 +2115,27 @@ class StealthBrowser:
2097
2115
  )
2098
2116
 
2099
2117
  def _firefox_screenshot(self, save_path: str = "") -> SkillResult:
2100
- """Firefox+VNC 模式下通过 xdotool + import 截图。"""
2118
+ """Firefox+VNC 模式下截图。
2119
+
2120
+ 截图方法优先级(纯 Python 优先,无需外部工具):
2121
+ 1. python-xlib + Pillow — 纯 Python X11 协议截图,proot 下最可靠
2122
+ 2. ImageMagick import — 外部命令,需安装 imagemagick
2123
+ 3. scrot — 轻量截图工具
2124
+ 4. xwd + convert — 需要 xwd + imagemagick
2125
+ 5. xwd + ffmpeg — 需要 xwd + ffmpeg
2126
+ 6. xwd + Pillow — 需要 xwd,Pillow 转换
2127
+ """
2101
2128
  display = os.environ.get("DISPLAY", ":99")
2102
2129
  env = {**os.environ, "DISPLAY": display}
2103
2130
 
2131
+ # [v1.47.26] 检测 python-xlib 是否可用(用于截图方法和错误报告)
2132
+ _xlib_available = False
2133
+ try:
2134
+ from Xlib import display as _xd # noqa: F401
2135
+ _xlib_available = True
2136
+ except ImportError:
2137
+ pass
2138
+
2104
2139
  try:
2105
2140
  if not save_path:
2106
2141
  from core.browser_profile import get_browser_profile_manager
@@ -2109,7 +2144,40 @@ class StealthBrowser:
2109
2144
  timestamp = time.strftime("%Y%m%d_%H%M%S")
2110
2145
  save_path = str(save_dir / f"firefox_{timestamp}.png")
2111
2146
 
2112
- # 方法1: 使用 ImageMagick import 截取整个屏幕
2147
+ # 方法1: python-xlib + Pillow — 纯 Python X11 协议截图(无需外部工具)
2148
+ # [v1.47.26] 这是 proot 环境下最可靠的方法,不依赖 import/scrot/xwd 等外部命令
2149
+ try:
2150
+ from Xlib import display as xdisplay
2151
+ from PIL import Image as PILImage
2152
+
2153
+ disp = xdisplay.Display(os.environ.get("DISPLAY", ":99"))
2154
+ screen = disp.screen()
2155
+ root = screen.root
2156
+ geom = root.get_geometry()
2157
+ width = geom.width
2158
+ height = geom.height
2159
+
2160
+ # XGetImage 获取原始像素数据
2161
+ raw = root.get_image(0, 0, width, height, xdisplay.X.ZPixmap, 0xffffffff)
2162
+ # raw.data 是 bytes,格式为 BGRX(每像素4字节)
2163
+ img = PILImage.frombytes("RGB", (width, height), raw.data, "raw", "BGRX")
2164
+ img.save(save_path, "PNG")
2165
+
2166
+ if os.path.isfile(save_path):
2167
+ logger.info(f"Firefox 截图已保存 (python-xlib): {save_path}")
2168
+ return SkillResult(
2169
+ success=True,
2170
+ message="Firefox+VNC 截图已保存 (python-xlib)",
2171
+ data={"path": save_path},
2172
+ )
2173
+ else:
2174
+ logger.warning("[_firefox_screenshot] python-xlib 截图保存失败")
2175
+ except ImportError:
2176
+ logger.debug("[_firefox_screenshot] python-xlib 未安装,跳过纯 Python 截图")
2177
+ except Exception as xlib_err:
2178
+ logger.warning(f"[_firefox_screenshot] python-xlib 截图失败: {xlib_err}")
2179
+
2180
+ # 方法2: 使用 ImageMagick import 截取整个屏幕
2113
2181
  import_cmd = shutil.which("import")
2114
2182
  if import_cmd:
2115
2183
  result = subprocess.run(
@@ -2181,19 +2249,16 @@ class StealthBrowser:
2181
2249
  f"{result.stderr.decode(errors='ignore')[:200]}"
2182
2250
  )
2183
2251
 
2184
- # 方法4: 使用 xdotool + xwd + ffmpeg (proot 兼容)
2185
- xdotool_cmd = shutil.which("xdotool")
2252
+ # 方法4: 使用 xwd + ffmpeg (proot 兼容)
2186
2253
  ffmpeg_cmd = shutil.which("ffmpeg")
2187
- if xdotool_cmd and ffmpeg_cmd:
2254
+ if xwd_cmd and ffmpeg_cmd:
2188
2255
  xwd_tmp = save_path + ".xwd"
2189
- # 用 xdotool 获取活动窗口并截图
2190
2256
  result = subprocess.run(
2191
- [xwd_cmd or "xwd", "-root", "-display", display, "-out", xwd_tmp],
2257
+ [xwd_cmd, "-root", "-display", display, "-out", xwd_tmp],
2192
2258
  capture_output=True, timeout=10,
2193
2259
  env=env, start_new_session=True,
2194
2260
  )
2195
2261
  if result.returncode == 0 and os.path.isfile(xwd_tmp):
2196
- # 用 ffmpeg 转换 xwd 到 png
2197
2262
  result2 = subprocess.run(
2198
2263
  [ffmpeg_cmd, "-y", "-i", xwd_tmp, save_path],
2199
2264
  capture_output=True, timeout=10,
@@ -2210,22 +2275,213 @@ class StealthBrowser:
2210
2275
  message=f"Firefox+VNC 截图已保存",
2211
2276
  data={"path": save_path},
2212
2277
  )
2278
+ logger.warning(
2279
+ f"[_firefox_screenshot] xwd+ffmpeg 截图失败: xwd_rc={result.returncode}"
2280
+ )
2281
+
2282
+ # 方法5: 使用 Python Pillow + xwd
2283
+ try:
2284
+ from PIL import Image
2285
+ if xwd_cmd:
2286
+ xwd_tmp = save_path + ".xwd"
2287
+ result = subprocess.run(
2288
+ [xwd_cmd, "-root", "-display", display, "-out", xwd_tmp],
2289
+ capture_output=True, timeout=10,
2290
+ env=env, start_new_session=True,
2291
+ )
2292
+ if result.returncode == 0 and os.path.isfile(xwd_tmp):
2293
+ try:
2294
+ img = Image.open(xwd_tmp)
2295
+ img.save(save_path, "PNG")
2296
+ os.unlink(xwd_tmp)
2297
+ logger.info(f"Firefox 截图已保存 (xwd+Pillow): {save_path}")
2298
+ return SkillResult(
2299
+ success=True,
2300
+ message=f"Firefox+VNC 截图已保存",
2301
+ data={"path": save_path},
2302
+ )
2303
+ except Exception as pil_err:
2304
+ logger.warning(f"[_firefox_screenshot] Pillow 转换失败: {pil_err}")
2305
+ try: os.unlink(xwd_tmp)
2306
+ except Exception: pass
2307
+ except ImportError:
2308
+ pass
2213
2309
 
2214
2310
  _available = []
2311
+ _available.append("python-xlib" if _xlib_available else "python-xlib❌")
2215
2312
  if import_cmd: _available.append("import")
2216
2313
  if scrot_cmd: _available.append("scrot")
2217
2314
  if xwd_cmd and convert_cmd: _available.append("xwd+convert")
2218
- if xdotool_cmd and ffmpeg_cmd: _available.append("xdotool+ffmpeg")
2219
- _tried = ", ".join(_available) if _available else "无"
2315
+ if xwd_cmd and ffmpeg_cmd: _available.append("xwd+ffmpeg")
2316
+ _tried = ", ".join(_available)
2317
+
2318
+ # [v1.47.26] 自动安装 python-xlib(如果未安装)
2319
+ if not _xlib_available:
2320
+ logger.info("[_firefox_screenshot] 尝试自动安装 python-xlib ...")
2321
+ try:
2322
+ result = subprocess.run(
2323
+ [sys.executable, "-m", "pip", "install", "python-xlib", "-q"],
2324
+ capture_output=True, timeout=60,
2325
+ )
2326
+ if result.returncode == 0:
2327
+ logger.info("[_firefox_screenshot] python-xlib 安装成功,重试截图")
2328
+ try:
2329
+ from Xlib import display as xdisplay
2330
+ from PIL import Image as PILImage
2331
+ disp = xdisplay.Display(os.environ.get("DISPLAY", ":99"))
2332
+ screen = disp.screen()
2333
+ root = screen.root
2334
+ geom = root.get_geometry()
2335
+ raw = root.get_image(0, 0, geom.width, geom.height, xdisplay.X.ZPixmap, 0xffffffff)
2336
+ img = PILImage.frombytes("RGB", (geom.width, geom.height), raw.data, "raw", "BGRX")
2337
+ img.save(save_path, "PNG")
2338
+ if os.path.isfile(save_path):
2339
+ logger.info(f"Firefox 截图已保存 (python-xlib 安装后): {save_path}")
2340
+ return SkillResult(
2341
+ success=True,
2342
+ message="Firefox+VNC 截图已保存 (python-xlib)",
2343
+ data={"path": save_path},
2344
+ )
2345
+ except Exception as retry_err:
2346
+ logger.warning(f"[_firefox_screenshot] python-xlib 安装后截图仍失败: {retry_err}")
2347
+ else:
2348
+ logger.warning(f"[_firefox_screenshot] python-xlib 安装失败: {result.stderr.decode(errors='ignore')[:200]}")
2349
+ except Exception as pip_err:
2350
+ logger.warning(f"[_firefox_screenshot] pip install python-xlib 异常: {pip_err}")
2220
2351
 
2221
2352
  return SkillResult(
2222
2353
  success=False,
2223
2354
  error=f"Firefox+VNC 截图失败 (尝试过: {_tried}) "
2224
- f"— 需要 ImageMagick import / scrot / xwd+convert",
2355
+ f"— 需要 python-xlib (pip install python-xlib) 或 ImageMagick/scrot",
2225
2356
  )
2226
2357
  except Exception as e:
2227
2358
  return SkillResult(success=False, error=f"Firefox+VNC 截图失败: {e}")
2228
2359
 
2360
+ @staticmethod
2361
+ def _detect_firefox_profile(pid: str) -> Optional[str]:
2362
+ """[v1.47.26] 从运行中 Firefox 进程的命令行参数检测其 profile 目录。
2363
+
2364
+ 读取 /proc/<pid>/cmdline 获取 Firefox 启动参数,查找 --profile 或
2365
+ 从 --user-data-dir 推断 profile 位置。
2366
+
2367
+ Args:
2368
+ pid: Firefox 进程 PID
2369
+
2370
+ Returns:
2371
+ Firefox profile 目录路径,检测失败返回 None
2372
+ """
2373
+ try:
2374
+ cmdline_path = f"/proc/{pid}/cmdline"
2375
+ if not os.path.isfile(cmdline_path):
2376
+ return None
2377
+
2378
+ with open(cmdline_path, "rb") as f:
2379
+ cmdline_data = f.read()
2380
+
2381
+ # /proc/pid/cmdline 用 \0 分隔参数
2382
+ args = cmdline_data.decode("utf-8", errors="replace").split("\0")
2383
+ args = [a for a in args if a.strip()]
2384
+
2385
+ # 查找 --profile 参数
2386
+ for i, arg in enumerate(args):
2387
+ if arg == "--profile" and i + 1 < len(args):
2388
+ profile_dir = args[i + 1]
2389
+ if os.path.isdir(profile_dir):
2390
+ logger.info(f"[_detect_firefox_profile] 从 cmdline 找到 --profile: {profile_dir}")
2391
+ return profile_dir
2392
+ elif arg.startswith("--profile="):
2393
+ profile_dir = arg.split("=", 1)[1]
2394
+ if os.path.isdir(profile_dir):
2395
+ logger.info(f"[_detect_firefox_profile] 从 cmdline 找到 --profile=: {profile_dir}")
2396
+ return profile_dir
2397
+
2398
+ # 没有 --profile 参数 → Firefox 使用 profiles.ini 中的默认 profile
2399
+ return None
2400
+ except Exception as e:
2401
+ logger.debug(f"[_detect_firefox_profile] 检测失败: {e}")
2402
+ return None
2403
+
2404
+ @staticmethod
2405
+ def _get_firefox_default_profile() -> Optional[str]:
2406
+ """[v1.47.26] 从 profiles.ini 获取 Firefox 默认 profile 目录路径。
2407
+
2408
+ Firefox 的 profiles.ini 格式:
2409
+ [Profile0]
2410
+ Name=default
2411
+ IsRelative=1
2412
+ Path=default
2413
+ Default=1
2414
+
2415
+ [General]
2416
+ StartWithLastProfile=1
2417
+
2418
+ 如果 profiles.ini 不存在或解析失败,尝试直接检测
2419
+ ~/.mozilla/firefox/default 目录。
2420
+
2421
+ Returns:
2422
+ Firefox 默认 profile 目录的绝对路径,失败返回 None
2423
+ """
2424
+ ff_base = os.path.expanduser("~/.mozilla/firefox")
2425
+ profiles_ini = os.path.join(ff_base, "profiles.ini")
2426
+
2427
+ # 方法1: 解析 profiles.ini
2428
+ if os.path.isfile(profiles_ini):
2429
+ try:
2430
+ import configparser
2431
+ config = configparser.ConfigParser()
2432
+ config.read(profiles_ini)
2433
+
2434
+ # 查找标记为 Default=1 的 profile
2435
+ for section in config.sections():
2436
+ if section.startswith("Profile"):
2437
+ is_default = config.get(section, "Default", fallback="")
2438
+ is_relative = config.get(section, "IsRelative", fallback="1")
2439
+ path = config.get(section, "Path", fallback="")
2440
+
2441
+ if is_default == "1" and path:
2442
+ if is_relative == "1":
2443
+ full_path = os.path.join(ff_base, path)
2444
+ else:
2445
+ full_path = path
2446
+ if os.path.isdir(full_path):
2447
+ logger.info(f"[_get_firefox_default_profile] profiles.ini Default profile: {full_path}")
2448
+ return full_path
2449
+
2450
+ # 没有 Default=1,取第一个 Profile section
2451
+ for section in config.sections():
2452
+ if section.startswith("Profile"):
2453
+ path = config.get(section, "Path", fallback="")
2454
+ is_relative = config.get(section, "IsRelative", fallback="1")
2455
+ if path:
2456
+ full_path = os.path.join(ff_base, path) if is_relative == "1" else path
2457
+ if os.path.isdir(full_path):
2458
+ logger.info(f"[_get_firefox_default_profile] profiles.ini 第一个 profile: {full_path}")
2459
+ return full_path
2460
+ except Exception as e:
2461
+ logger.debug(f"[_get_firefox_default_profile] profiles.ini 解析失败: {e}")
2462
+
2463
+ # 方法2: 直接检测 ~/.mozilla/firefox/default 目录
2464
+ default_dir = os.path.join(ff_base, "default")
2465
+ if os.path.isdir(default_dir):
2466
+ logger.info(f"[_get_firefox_default_profile] 直接找到 default 目录: {default_dir}")
2467
+ return default_dir
2468
+
2469
+ # 方法3: 扫描所有 Firefox profile 子目录
2470
+ if os.path.isdir(ff_base):
2471
+ try:
2472
+ for entry in sorted(os.listdir(ff_base)):
2473
+ entry_path = os.path.join(ff_base, entry)
2474
+ if not os.path.isdir(entry_path):
2475
+ continue
2476
+ # Firefox profile 目录特征:包含 prefs.js
2477
+ if os.path.isfile(os.path.join(entry_path, "prefs.js")):
2478
+ logger.info(f"[_get_firefox_default_profile] 扫描找到 profile: {entry_path}")
2479
+ return entry_path
2480
+ except Exception:
2481
+ pass
2482
+
2483
+ return None
2484
+
2229
2485
  def _firefox_read_sessionstore(self, max_wait: float = 5.0) -> dict:
2230
2486
  """[v1.47.20] 读取 Firefox sessionstore 获取当前标签页 URL/标题。
2231
2487
 
@@ -2242,8 +2498,33 @@ class StealthBrowser:
2242
2498
 
2243
2499
  # 搜索可能的 sessionstore 路径
2244
2500
  search_dirs = []
2245
- if self._firefox_profile_dir:
2501
+
2502
+ # [v1.47.26] 优先搜索 Firefox 实际使用的 profile 目录
2503
+ # 先尝试从运行中 Firefox 进程检测实际 profile
2504
+ actual_profile = None
2505
+ try:
2506
+ result = subprocess.run(
2507
+ ["pgrep", "-f", "firefox"],
2508
+ capture_output=True, text=True, timeout=3,
2509
+ )
2510
+ if result.returncode == 0 and result.stdout.strip():
2511
+ pid = result.stdout.strip().split()[0]
2512
+ actual_profile = self._detect_firefox_profile(pid)
2513
+ except Exception:
2514
+ pass
2515
+
2516
+ if actual_profile and os.path.isdir(actual_profile) and actual_profile not in search_dirs:
2517
+ search_dirs.append(actual_profile)
2518
+ logger.info(f"[_firefox_read_sessionstore] 搜索实际 Firefox profile: {actual_profile}")
2519
+
2520
+ # 也从 profiles.ini 获取默认 profile
2521
+ default_profile = self._get_firefox_default_profile()
2522
+ if default_profile and os.path.isdir(default_profile) and default_profile not in search_dirs:
2523
+ search_dirs.append(default_profile)
2524
+
2525
+ if self._firefox_profile_dir and os.path.isdir(self._firefox_profile_dir) and self._firefox_profile_dir not in search_dirs:
2246
2526
  search_dirs.append(self._firefox_profile_dir)
2527
+
2247
2528
  # 也搜索 Firefox 默认 profile 和 vnc_manager 启动的 profile
2248
2529
  ff_base = os.path.expanduser("~/.mozilla/firefox")
2249
2530
  for extra in [
@@ -2252,7 +2533,8 @@ class StealthBrowser:
2252
2533
  ]:
2253
2534
  if os.path.isdir(extra) and extra not in search_dirs:
2254
2535
  search_dirs.append(extra)
2255
- # [v1.47.24] 搜索所有 Firefox profile 子目录(如 xxx.default-release)
2536
+
2537
+ # 搜索所有 Firefox profile 子目录(如 xxx.default-release)
2256
2538
  try:
2257
2539
  if os.path.isdir(ff_base):
2258
2540
  for entry in os.listdir(ff_base):
@@ -2265,6 +2547,12 @@ class StealthBrowser:
2265
2547
  except Exception:
2266
2548
  pass
2267
2549
 
2550
+ # [v1.47.26] 详细记录搜索路径
2551
+ logger.info(
2552
+ f"[_firefox_read_sessionstore] 搜索 {len(search_dirs)} 个目录: "
2553
+ f"{[d.replace(os.path.expanduser('~'), '~') for d in search_dirs]}"
2554
+ )
2555
+
2268
2556
  # [v1.47.24] 等待 Firefox 写入 sessionstore(刚导航完可能还没写入)
2269
2557
  _start_time = time.time()
2270
2558
  while True:
@@ -189,55 +189,27 @@ def _ensure_display() -> Optional[str]:
189
189
  logger.info(f"VNC 远程桌面已在运行,复用显示: {display}")
190
190
  return display
191
191
 
192
- # [v1.47.13] VNC 未运行 → 尝试自动启动
193
- # 修复: 不能在 ThreadPoolExecutor + asyncio.run 中直接调用 vnc.start()!
194
- # 因为 vnc.start() 内部用 asyncio.Lock(),跨 event loop 会导致死锁。
195
- # 正确做法:在单独线程中用 asyncio.run 启动,然后轮询等待 VNC 就绪。
192
+ # VNC 未运行 → 尝试自动启动
196
193
  try:
197
194
  import asyncio
198
- import concurrent.futures
199
-
200
- def _start_vnc_in_thread():
201
- try:
202
- return asyncio.run(vnc.start())
203
- except Exception as e:
204
- return {"success": False, "message": str(e)}
205
-
206
- with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
207
- future = pool.submit(_start_vnc_in_thread)
208
-
209
- max_wait = 60
210
- check_interval = 2
211
- elapsed = 0
212
- while elapsed < max_wait:
213
- if vnc.is_running:
214
- display = vnc.display
215
- os.environ["DISPLAY"] = display
216
- logger.info(f"VNC 远程桌面已启动,复用显示: {display}")
217
- return display
218
-
219
- try:
220
- result = future.result(timeout=0.1)
221
- if result.get("success") or vnc.is_running:
222
- display = vnc.display
223
- os.environ["DISPLAY"] = display
224
- logger.info(f"VNC 远程桌面已启动,显示: {display}")
225
- return display
226
- else:
227
- logger.warning(f"VNC 启动失败: {result.get('message', '')}")
228
- break
229
- except concurrent.futures.TimeoutError:
230
- pass
231
-
232
- import time
233
- time.sleep(check_interval)
234
- elapsed += check_interval
235
-
236
- if vnc.is_running:
237
- display = vnc.display
238
- os.environ["DISPLAY"] = display
239
- return display
240
-
195
+ try:
196
+ loop = asyncio.get_event_loop()
197
+ if loop.is_running():
198
+ import concurrent.futures
199
+ with concurrent.futures.ThreadPoolExecutor() as pool:
200
+ result = pool.submit(asyncio.run, vnc.start()).result(timeout=30)
201
+ else:
202
+ result = asyncio.run(vnc.start())
203
+ except RuntimeError:
204
+ result = asyncio.run(vnc.start())
205
+
206
+ if result.get("success"):
207
+ display = vnc.display
208
+ os.environ["DISPLAY"] = display
209
+ logger.info(f"VNC 远程桌面已自动启动,显示: {display}")
210
+ return display
211
+ else:
212
+ logger.warning(f"VNC 自动启动失败: {result.get('message', '')}")
241
213
  except Exception as e:
242
214
  logger.warning(f"VNC 自动启动异常: {e}")
243
215
 
@@ -1372,26 +1344,6 @@ class BrowserOpenSkill(Skill):
1372
1344
  if not url:
1373
1345
  return SkillResult(success=False, error="缺少必需参数: url")
1374
1346
 
1375
- # [v1.47.20] VNC 模式下:Chromium 不可用时回退到 stealth_browser_navigate
1376
- try:
1377
- from core.vnc_manager import get_vnc_manager
1378
- vnc_mgr = get_vnc_manager()
1379
- if vnc_mgr.is_running:
1380
- # 检查是否有可用的 Chromium
1381
- import shutil
1382
- has_chrome = bool(shutil.which("chromium-browser") or shutil.which("chromium")
1383
- or shutil.which("google-chrome"))
1384
- if not has_chrome:
1385
- # VNC 模式下无 Chromium,回退到 stealth_browser
1386
- return SkillResult(
1387
- success=False,
1388
- error="VNC 远程桌面模式下没有 Chromium 浏览器,无法使用 browser_open。"
1389
- "请改用 stealth_browser_start + stealth_browser_navigate 操作 Firefox。"
1390
- "Firefox 在 VNC 模式下已可用。",
1391
- )
1392
- except ImportError:
1393
- pass # vnc_manager 不可用,跳过检测
1394
-
1395
1347
  # 检查依赖
1396
1348
  dep_err = await asyncio.get_event_loop().run_in_executor(None, _ensure_node_deps)
1397
1349
  if dep_err: