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._context = None
self._page = 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". # Launch mode: one of "fresh" | "persistent" | "cdp".
# - cdp: connect to an externally launched Chrome via CDP endpoint. # - cdp: connect to an externally launched Chrome via CDP endpoint.
# - persistent: launch with launch_persistent_context using a user_data_dir # - 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 # Resolve which browser engine to drive (system Chrome vs downloaded
# Chromium). Deferred detection failures are surfaced at launch time. # 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": if self._launch_mode != "cdp":
try: try:
from agent.tools.browser.browser_env import resolve_engine from agent.tools.browser.browser_env import resolve_engine
engine = resolve_engine(self._config) engine = resolve_engine(self._config)
if engine["mode"] == "system-chrome": if engine["mode"] == "system-chrome":
self._channel = engine["channel"] 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": elif engine["mode"] == "playwright-chromium":
logger.info(f"[Browser] Engine resolved: {engine['reason']}") logger.info(f"[Browser] Engine resolved: {engine['reason']}")
else: else:
@@ -509,6 +533,8 @@ class BrowserService:
if self._launch_mode == "cdp": if self._launch_mode == "cdp":
self._connect_cdp(viewport) self._connect_cdp(viewport)
elif self._launch_mode == "system-cdp":
self._launch_system_cdp(launch_args, viewport)
elif self._launch_mode == "persistent": elif self._launch_mode == "persistent":
self._launch_persistent(launch_args, viewport, user_agent) self._launch_persistent(launch_args, viewport, user_agent)
else: else:
@@ -577,6 +603,41 @@ class BrowserService:
self._page = pages[0] if pages else self._context.new_page() self._page = pages[0] if pages else self._context.new_page()
self._wire_close_listeners() 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]): def _connect_cdp(self, viewport: Dict[str, int]):
"""Attach to an existing Chrome started with --remote-debugging-port.""" """Attach to an existing Chrome started with --remote-debugging-port."""
endpoint = self._cdp_endpoint endpoint = self._cdp_endpoint
@@ -631,13 +692,27 @@ class BrowserService:
self._cancel_idle_timer() self._cancel_idle_timer()
if self._launch_mode == "cdp": if self._launch_mode == "cdp":
# For CDP, browser.close() only detaches the Playwright client; # For external CDP, browser.close() only detaches the Playwright
# the user's Chrome process and its tabs stay alive. # client; the user's Chrome process and its tabs stay alive.
try: try:
if self._browser: if self._browser:
self._browser.close() self._browser.close()
except Exception as e: except Exception as e:
logger.debug(f"[Browser] cdp disconnect error: {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: else:
for obj, label in [ for obj, label in [
(self._context, "context"), (self._context, "context"),

View File

@@ -0,0 +1,149 @@
"""Spawn a system Chrome/Edge with a DevTools debugging port for CDP control.
Why this exists: driving a system browser via Playwright's
``chromium.launch(channel="chrome")`` makes the app *take over* another app's
process, which on macOS triggers a TCC "Automation" permission prompt and a
multi-second (sometimes 100s+) stall on first use. Launching Chrome ourselves
with ``--remote-debugging-port`` and attaching via ``connect_over_cdp`` avoids
that entirely — from the OS's view it's just a process listening on a local
port — and matches how Codex / Claude Code drive the user's real browser.
The launched process uses an isolated ``--user-data-dir`` so it never fights
the user's day-to-day browser profile, while still persisting login state
across sessions inside that dir.
"""
import os
import sys
import time
import socket
import subprocess
import urllib.request
from typing import Optional, List
from common.log import logger
class ChromeLauncher:
"""Own the lifecycle of a debugging-enabled Chrome/Edge child process."""
def __init__(self, executable: str, user_data_dir: str,
extra_args: Optional[List[str]] = None,
headless: bool = False):
self._executable = executable
self._user_data_dir = user_data_dir
self._extra_args = extra_args or []
self._headless = headless
self._proc: Optional[subprocess.Popen] = None
self._port: Optional[int] = None
@property
def endpoint(self) -> str:
"""CDP HTTP endpoint (only valid after a successful launch())."""
return f"http://127.0.0.1:{self._port}" if self._port else ""
@staticmethod
def _free_port() -> int:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
finally:
s.close()
def launch(self, ready_timeout: float = 25.0) -> str:
"""Spawn Chrome and block until its CDP endpoint answers.
Returns the CDP endpoint URL. Raises RuntimeError if the endpoint never
comes up (the child process is killed in that case).
"""
os.makedirs(self._user_data_dir, exist_ok=True)
self._port = self._free_port()
args = [
self._executable,
f"--remote-debugging-port={self._port}",
f"--user-data-dir={self._user_data_dir}",
# Trim first-run overhead and background chatter for faster starts.
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-component-update",
"--disable-features=Translate,OptimizationHints",
# A blank first tab keeps startup cheap and predictable.
"about:blank",
]
if self._headless:
args.insert(1, "--headless=new")
args[1:1] = self._extra_args
popen_kwargs = {}
if sys.platform == "win32":
# Detach from any console and never flash a window on Windows.
popen_kwargs["creationflags"] = (
getattr(subprocess, "CREATE_NO_WINDOW", 0)
| getattr(subprocess, "DETACHED_PROCESS", 0)
)
else:
# New session so the child isn't tied to the parent's controlling
# terminal / process group (clean teardown, no signal bleed).
popen_kwargs["start_new_session"] = True
logger.info(f"[Browser] Spawning {os.path.basename(self._executable)} "
f"on CDP port {self._port} (profile={self._user_data_dir})")
self._proc = subprocess.Popen(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
**popen_kwargs,
)
if not self._wait_ready(ready_timeout):
self.close()
raise RuntimeError(
f"Chrome did not expose a CDP endpoint on port {self._port} "
f"within {ready_timeout:.0f}s"
)
return self.endpoint
def _wait_ready(self, timeout: float) -> bool:
"""Poll DevTools /json/version until Chrome is listening (or times out)."""
deadline = time.time() + timeout
url = f"http://127.0.0.1:{self._port}/json/version"
while time.time() < deadline:
# Bail out early if the process died on startup.
if self._proc and self._proc.poll() is not None:
logger.error(
f"[Browser] Chrome exited early (code={self._proc.returncode}) "
"before opening the CDP port"
)
return False
try:
with urllib.request.urlopen(url, timeout=1) as r:
if r.status == 200:
return True
except Exception:
time.sleep(0.15)
return False
def is_alive(self) -> bool:
return self._proc is not None and self._proc.poll() is None
def close(self):
"""Terminate the spawned Chrome process (idempotent)."""
proc = self._proc
self._proc = None
self._port = None
if proc is None:
return
if proc.poll() is not None:
return
try:
proc.terminate()
try:
proc.wait(timeout=8)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
except Exception as e:
logger.debug(f"[Browser] error terminating Chrome process: {e}")