fix(browser): drive system Chrome via spawn+CDP

This commit is contained in:
zhayujie
2026-07-20 12:15:12 +08:00
parent 3cdc13d69d
commit b755d18e63
2 changed files with 227 additions and 3 deletions

View File

@@ -321,6 +321,14 @@ class BrowserService:
self._context = None
self._page = None
# When we drive a system Chrome/Edge, we spawn it ourselves with a
# debugging port and attach over CDP (see chrome_launcher). This avoids
# the macOS Automation prompt + multi-second stall that
# chromium.launch(channel=...) incurs. Holds the child process owner.
self._chrome_launcher = None
# Path to the system browser executable when using system-chrome mode.
self._system_exe: Optional[str] = None
# Launch mode: one of "fresh" | "persistent" | "cdp".
# - cdp: connect to an externally launched Chrome via CDP endpoint.
# - persistent: launch with launch_persistent_context using a user_data_dir
@@ -352,13 +360,29 @@ class BrowserService:
# Resolve which browser engine to drive (system Chrome vs downloaded
# Chromium). Deferred detection failures are surfaced at launch time.
#
# For a system Chrome/Edge we DON'T use chromium.launch(channel=...):
# that "takes over" another app and triggers the macOS Automation
# prompt + a long stall. Instead we spawn the browser ourselves with a
# debugging port and attach over CDP (self._launch_mode = "system-cdp").
# `self._system_exe` is the browser executable; the persistent
# user_data_dir keeps login state across sessions.
if self._launch_mode != "cdp":
try:
from agent.tools.browser.browser_env import resolve_engine
engine = resolve_engine(self._config)
if engine["mode"] == "system-chrome":
self._channel = engine["channel"]
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
self._system_exe = engine.get("path")
# Only switch to spawn+CDP when we actually know the exe
# path (macOS/Windows/Linux detection returns it). Persist
# login state in a dedicated profile dir.
if self._system_exe:
self._launch_mode = "system-cdp"
if not self._user_data_dir:
self._user_data_dir = expand_path(_DEFAULT_USER_DATA_DIR)
logger.info(f"[Browser] Engine resolved: {engine['reason']} "
f"(spawn+CDP={bool(self._system_exe)})")
elif engine["mode"] == "playwright-chromium":
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
else:
@@ -509,6 +533,8 @@ class BrowserService:
if self._launch_mode == "cdp":
self._connect_cdp(viewport)
elif self._launch_mode == "system-cdp":
self._launch_system_cdp(launch_args, viewport)
elif self._launch_mode == "persistent":
self._launch_persistent(launch_args, viewport, user_agent)
else:
@@ -577,6 +603,41 @@ class BrowserService:
self._page = pages[0] if pages else self._context.new_page()
self._wire_close_listeners()
def _launch_system_cdp(self, launch_args: List[str], viewport: Dict[str, int]):
"""Spawn the user's system Chrome/Edge with a debugging port, attach via CDP.
This is the default for system browsers. Unlike launch(channel=...), it
does not "take over" the browser app, so it avoids the macOS Automation
prompt / long stall. Login state persists in the isolated user_data_dir.
"""
from agent.tools.browser.chrome_launcher import ChromeLauncher
os.makedirs(self._user_data_dir, exist_ok=True)
logger.info(
f"[Browser] Launching system:{self._channel} via spawn+CDP "
f"(headless={self._headless}, profile={self._user_data_dir})"
)
self._chrome_launcher = ChromeLauncher(
executable=self._system_exe,
user_data_dir=self._user_data_dir,
extra_args=launch_args,
headless=self._headless,
)
endpoint = self._chrome_launcher.launch()
self._browser = self._playwright.chromium.connect_over_cdp(endpoint)
# The spawned Chrome opens its own default context (backed by
# user_data_dir); reuse it so cookies / logins persist.
contexts = self._browser.contexts
self._context = contexts[0] if contexts else self._browser.new_context(viewport=viewport)
pages = self._context.pages
self._page = pages[0] if pages else self._context.new_page()
try:
self._page.set_viewport_size(viewport)
except Exception:
pass
self._wire_close_listeners()
def _connect_cdp(self, viewport: Dict[str, int]):
"""Attach to an existing Chrome started with --remote-debugging-port."""
endpoint = self._cdp_endpoint
@@ -631,13 +692,27 @@ class BrowserService:
self._cancel_idle_timer()
if self._launch_mode == "cdp":
# For CDP, browser.close() only detaches the Playwright client;
# the user's Chrome process and its tabs stay alive.
# For external CDP, browser.close() only detaches the Playwright
# client; the user's Chrome process and its tabs stay alive.
try:
if self._browser:
self._browser.close()
except Exception as e:
logger.debug(f"[Browser] cdp disconnect error: {e}")
elif self._launch_mode == "system-cdp":
# We own the spawned Chrome: detach the CDP client, then kill the
# process we started so it doesn't linger.
try:
if self._browser:
self._browser.close()
except Exception as e:
logger.debug(f"[Browser] system-cdp disconnect error: {e}")
try:
if self._chrome_launcher:
self._chrome_launcher.close()
except Exception as e:
logger.debug(f"[Browser] chrome launcher close error: {e}")
self._chrome_launcher = None
else:
for obj, label in [
(self._context, "context"),