diff --git a/agent/tools/browser/browser_service.py b/agent/tools/browser/browser_service.py index e080fa6a..9797709f 100644 --- a/agent/tools/browser/browser_service.py +++ b/agent/tools/browser/browser_service.py @@ -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"), diff --git a/agent/tools/browser/chrome_launcher.py b/agent/tools/browser/chrome_launcher.py new file mode 100644 index 00000000..794ecd4e --- /dev/null +++ b/agent/tools/browser/chrome_launcher.py @@ -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}")