mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-23 04:17:45 +00:00
Co-authored-by: Douglas <douglas.ym.lai@gmail.com> Co-authored-by: Douglas Lai <115660088+Douglasymlai@users.noreply.github.com>
382 lines
12 KiB
Python
382 lines
12 KiB
Python
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
|
|
"""
|
|
Browser launcher for Web mode: ensures a CDP-capable browser is running
|
|
when Electron is not available (e.g. web + brain mode).
|
|
|
|
Previously, Electron provided the CDP browser via remote-debugging-port.
|
|
In web mode, Brain launches Chrome/Chromium directly.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import socket
|
|
import subprocess
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
logger = logging.getLogger("browser_launcher")
|
|
|
|
# Default CDP port (must match browser_port in Chat model)
|
|
DEFAULT_CDP_PORT = 9222
|
|
FALLBACK_CDP_PORT_START = 9223
|
|
FALLBACK_CDP_PORT_END = 9299
|
|
LOCAL_CDP_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
|
|
|
|
def is_local_cdp_host(host: str | None) -> bool:
|
|
"""Return whether the CDP endpoint host points at the local machine."""
|
|
if not host:
|
|
return True
|
|
return host.lower() in LOCAL_CDP_HOSTS
|
|
|
|
|
|
def normalize_cdp_url(
|
|
cdp_url: str,
|
|
*,
|
|
default_host: str = "127.0.0.1",
|
|
default_port: int = DEFAULT_CDP_PORT,
|
|
) -> tuple[str, str, int]:
|
|
"""Normalize a CDP endpoint into ``scheme://host:port`` form."""
|
|
raw_url = cdp_url.strip()
|
|
if raw_url.isdigit():
|
|
port = int(raw_url)
|
|
return f"http://{default_host}:{port}", default_host, port
|
|
|
|
parsed = urlparse(raw_url if "://" in raw_url else f"http://{raw_url}")
|
|
scheme = parsed.scheme or "http"
|
|
host = parsed.hostname or default_host
|
|
port = parsed.port or default_port
|
|
return f"{scheme}://{host}:{port}", host, port
|
|
|
|
|
|
def is_cdp_url_available(cdp_url: str) -> bool:
|
|
"""Check whether a CDP endpoint is reachable."""
|
|
normalized, host, port = normalize_cdp_url(cdp_url)
|
|
if is_local_cdp_host(host):
|
|
return _is_cdp_available(port)
|
|
|
|
try:
|
|
import httpx
|
|
|
|
r = httpx.get(f"{normalized}/json/version", timeout=2.0)
|
|
if r.status_code != 200:
|
|
return False
|
|
return _is_supported_cdp_version(r.json(), normalized)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _is_port_in_use(port: int) -> bool:
|
|
"""Check if a port is in use."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
return s.connect_ex(("127.0.0.1", port)) == 0
|
|
|
|
|
|
def _is_cdp_available(port: int) -> bool:
|
|
"""Check if a Playwright-compatible CDP browser is listening."""
|
|
try:
|
|
import httpx
|
|
|
|
r = httpx.get(f"http://127.0.0.1:{port}/json/version", timeout=2.0)
|
|
if r.status_code != 200:
|
|
return False
|
|
return _is_supported_cdp_version(r.json(), f"http://127.0.0.1:{port}")
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _is_supported_cdp_version(data: dict, endpoint: str) -> bool:
|
|
"""Reject CDP endpoints that Playwright cannot manage."""
|
|
browser = str(data.get("Browser") or "")
|
|
user_agent = str(data.get("User-Agent") or "")
|
|
websocket_url = data.get("webSocketDebuggerUrl")
|
|
combined = f"{browser} {user_agent}"
|
|
|
|
if not websocket_url:
|
|
logger.debug(
|
|
"[BROWSER LAUNCHER] CDP endpoint has no browser websocket"
|
|
)
|
|
return False
|
|
|
|
if "Electron" in combined:
|
|
logger.warning(
|
|
"[BROWSER LAUNCHER] Ignoring Electron DevTools endpoint at %s; "
|
|
"Browser Agent requires a managed Chrome/Chromium CDP browser.",
|
|
endpoint,
|
|
)
|
|
return False
|
|
|
|
if not any(
|
|
token in combined
|
|
for token in ("Chrome/", "Chromium", "HeadlessChrome/")
|
|
):
|
|
logger.warning(
|
|
"[BROWSER LAUNCHER] Ignoring unsupported CDP endpoint at %s: %s",
|
|
endpoint,
|
|
browser or user_agent or "unknown browser",
|
|
)
|
|
return False
|
|
|
|
if not _supports_browser_context_management(str(websocket_url), endpoint):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _supports_browser_context_management(
|
|
websocket_url: str,
|
|
endpoint: str,
|
|
) -> bool:
|
|
"""Probe the browser-level CDP socket for Playwright compatibility."""
|
|
try:
|
|
from websockets.sync.client import connect
|
|
|
|
command = {
|
|
"id": 1,
|
|
"method": "Browser.setDownloadBehavior",
|
|
"params": {"behavior": "default"},
|
|
}
|
|
with connect(websocket_url, open_timeout=2, close_timeout=1) as ws:
|
|
ws.send(json.dumps(command))
|
|
response = json.loads(ws.recv(timeout=2))
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"[BROWSER LAUNCHER] Could not validate CDP capabilities at %s: %s",
|
|
endpoint,
|
|
exc,
|
|
)
|
|
return False
|
|
|
|
error = response.get("error")
|
|
if error:
|
|
message = error.get("message") if isinstance(error, dict) else error
|
|
logger.warning(
|
|
"[BROWSER LAUNCHER] Ignoring CDP endpoint at %s; "
|
|
"it does not support browser context management: %s",
|
|
endpoint,
|
|
message,
|
|
)
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _candidate_ports(preferred_port: int):
|
|
yield preferred_port
|
|
for port in range(FALLBACK_CDP_PORT_START, FALLBACK_CDP_PORT_END + 1):
|
|
if port != preferred_port:
|
|
yield port
|
|
|
|
|
|
def _find_chrome_executable() -> str | None:
|
|
"""Find Chrome or Chromium executable for launching with CDP."""
|
|
system = platform.system()
|
|
|
|
# 1. Try Playwright's Chromium (most reliable, cross-platform)
|
|
try:
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
with sync_playwright() as p:
|
|
path = p.chromium.executable_path
|
|
if path and Path(path).exists():
|
|
logger.debug(f"Using Playwright Chromium: {path}")
|
|
return path
|
|
except Exception as e:
|
|
logger.debug(f"Playwright Chromium not available: {e}")
|
|
|
|
# 2. Platform-specific paths
|
|
if system == "Darwin":
|
|
candidates = [
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
"/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
|
]
|
|
elif system == "Linux":
|
|
candidates = [
|
|
"/usr/bin/google-chrome",
|
|
"/usr/bin/google-chrome-stable",
|
|
"/usr/bin/chromium",
|
|
"/usr/bin/chromium-browser",
|
|
]
|
|
elif system == "Windows":
|
|
candidates = [
|
|
os.path.expandvars(
|
|
r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"
|
|
),
|
|
os.path.expandvars(
|
|
r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"
|
|
),
|
|
]
|
|
else:
|
|
candidates = []
|
|
|
|
for path in candidates:
|
|
if path and Path(path).exists():
|
|
logger.debug(f"Using system Chrome: {path}")
|
|
return path
|
|
|
|
# 3. Try executable from PATH
|
|
for name in ("google-chrome", "chromium", "chromium-browser", "chrome"):
|
|
exe = _which(name)
|
|
if exe:
|
|
return exe
|
|
|
|
return None
|
|
|
|
|
|
def _which(name: str) -> str | None:
|
|
"""Find executable in PATH."""
|
|
for path in os.environ.get("PATH", "").split(os.pathsep):
|
|
exe = Path(path) / name
|
|
if exe.exists():
|
|
return str(exe)
|
|
return None
|
|
|
|
|
|
def _launch_browser(
|
|
executable: str, port: int, user_data_dir: str
|
|
) -> subprocess.Popen | None:
|
|
"""Launch browser with CDP enabled. Returns process or None on failure."""
|
|
profile_dir = Path(user_data_dir).expanduser()
|
|
profile_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
args = [
|
|
executable,
|
|
f"--remote-debugging-port={port}",
|
|
f"--user-data-dir={profile_dir}",
|
|
"--no-first-run",
|
|
"--no-default-browser-check",
|
|
"--disable-blink-features=AutomationControlled",
|
|
"about:blank",
|
|
]
|
|
|
|
try:
|
|
proc = subprocess.Popen(
|
|
args,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
logger.info(
|
|
f"[BROWSER LAUNCHER] Launched browser on port {port} (PID={proc.pid})"
|
|
)
|
|
return proc
|
|
except Exception as e:
|
|
logger.error(
|
|
f"[BROWSER LAUNCHER] Failed to launch: {e}", exc_info=True
|
|
)
|
|
return None
|
|
|
|
|
|
def ensure_cdp_browser_available(port: int = DEFAULT_CDP_PORT) -> bool:
|
|
"""
|
|
Ensure a CDP-capable browser is running on the given port.
|
|
|
|
If no browser is listening, attempts to launch Chrome/Chromium.
|
|
Used in web mode when Electron is not available to provide CDP.
|
|
|
|
Returns:
|
|
True if CDP is available (either already running or newly launched),
|
|
False otherwise.
|
|
"""
|
|
# Check if auto-launch is disabled
|
|
if os.environ.get("EIGENT_BRAIN_LAUNCH_BROWSER", "true").lower() in (
|
|
"false",
|
|
"0",
|
|
"no",
|
|
):
|
|
logger.debug("[BROWSER LAUNCHER] Auto-launch disabled by env")
|
|
return _is_cdp_available(port)
|
|
|
|
# Already available
|
|
if _is_cdp_available(port):
|
|
logger.debug(
|
|
f"[BROWSER LAUNCHER] CDP already available on port {port}"
|
|
)
|
|
return True
|
|
|
|
# Port in use but not CDP (e.g. another service)
|
|
if _is_port_in_use(port):
|
|
logger.warning(
|
|
f"[BROWSER LAUNCHER] Port {port} in use but not CDP. "
|
|
"Another service may be using it."
|
|
)
|
|
return False
|
|
|
|
# Launch browser
|
|
executable = _find_chrome_executable()
|
|
if not executable:
|
|
logger.error(
|
|
"[BROWSER LAUNCHER] No Chrome/Chromium found. "
|
|
"Run: playwright install chromium"
|
|
)
|
|
return False
|
|
|
|
user_data_dir = os.path.expanduser(
|
|
f"~/.eigent/browser_profiles/cdp_brain_{port}"
|
|
)
|
|
proc = _launch_browser(executable, port, user_data_dir)
|
|
if not proc:
|
|
return False
|
|
|
|
# Poll for readiness (max 10s)
|
|
import time
|
|
|
|
for _ in range(20):
|
|
time.sleep(0.5)
|
|
if _is_cdp_available(port):
|
|
logger.info(f"[BROWSER LAUNCHER] CDP ready on port {port}")
|
|
return True
|
|
|
|
logger.warning(
|
|
"[BROWSER LAUNCHER] Browser launched but CDP not ready after 10s"
|
|
)
|
|
return False
|
|
|
|
|
|
def ensure_cdp_browser_endpoint(
|
|
preferred_port: int = DEFAULT_CDP_PORT,
|
|
) -> str | None:
|
|
"""
|
|
Ensure a managed CDP browser exists and return its endpoint.
|
|
|
|
If the preferred port is occupied by Electron's own DevTools endpoint, use
|
|
the next available local port instead of handing that endpoint to
|
|
Playwright.
|
|
"""
|
|
for port in _candidate_ports(preferred_port):
|
|
if _is_cdp_available(port):
|
|
return f"http://127.0.0.1:{port}"
|
|
|
|
if _is_port_in_use(port):
|
|
logger.warning(
|
|
"[BROWSER LAUNCHER] Port %s is occupied by a non-managed "
|
|
"or unsupported CDP endpoint; trying another port.",
|
|
port,
|
|
)
|
|
continue
|
|
|
|
if ensure_cdp_browser_available(port):
|
|
return f"http://127.0.0.1:{port}"
|
|
|
|
logger.error(
|
|
"[BROWSER LAUNCHER] No available CDP browser port in %s-%s",
|
|
preferred_port,
|
|
FALLBACK_CDP_PORT_END,
|
|
)
|
|
return None
|