browser-use--browser-harness
b05be93d60
Cut ipc.py from 127 to 68 lines (-46%) by removing restated docstrings and keeping only load-bearing inline comments (path-traversal guard, uv-Python AF_UNIX gating, Windows .port-file role). Same logic, same call sites. Rename ipc -> _ipc per Python convention for internal modules. The IPC plumbing is only called by daemon/admin/helpers; agents reading helpers.py should not be pulled into transport details. Callers do 'import _ipc as ipc' so internal ipc.foo references stay unchanged.
284 行
12 KiB
Python
284 行
12 KiB
Python
"""CDP WS holder + IPC relay (Unix socket on POSIX, TCP loopback on Windows). One daemon per BU_NAME."""
|
|
import asyncio, json, os, socket, sys, time, urllib.request
|
|
from collections import deque
|
|
from pathlib import Path
|
|
|
|
import _ipc as ipc
|
|
from cdp_use.client import CDPClient
|
|
|
|
|
|
def _load_env():
|
|
p = Path(__file__).parent / ".env"
|
|
if not p.exists():
|
|
return
|
|
for line in p.read_text().splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
k, v = line.split("=", 1)
|
|
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
|
|
|
|
|
_load_env()
|
|
|
|
NAME = os.environ.get("BU_NAME", "default")
|
|
SOCK = ipc.sock_addr(NAME)
|
|
LOG = str(ipc.log_path(NAME))
|
|
PID = str(ipc.pid_path(NAME))
|
|
BUF = 500
|
|
PROFILES = [
|
|
Path.home() / "Library/Application Support/Google/Chrome",
|
|
Path.home() / "Library/Application Support/Microsoft Edge",
|
|
Path.home() / "Library/Application Support/Microsoft Edge Beta",
|
|
Path.home() / "Library/Application Support/Microsoft Edge Dev",
|
|
Path.home() / "Library/Application Support/Microsoft Edge Canary",
|
|
Path.home() / "Library/Application Support/BraveSoftware/Brave-Browser",
|
|
Path.home() / ".config/google-chrome",
|
|
Path.home() / ".config/chromium",
|
|
Path.home() / ".config/chromium-browser",
|
|
Path.home() / ".config/microsoft-edge",
|
|
Path.home() / ".config/microsoft-edge-beta",
|
|
Path.home() / ".config/microsoft-edge-dev",
|
|
Path.home() / ".var/app/org.chromium.Chromium/config/chromium",
|
|
Path.home() / ".var/app/com.google.Chrome/config/google-chrome",
|
|
Path.home() / ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser",
|
|
Path.home() / ".var/app/com.microsoft.Edge/config/microsoft-edge",
|
|
Path.home() / "AppData/Local/Google/Chrome/User Data",
|
|
Path.home() / "AppData/Local/Chromium/User Data",
|
|
Path.home() / "AppData/Local/Microsoft/Edge/User Data",
|
|
Path.home() / "AppData/Local/Microsoft/Edge Beta/User Data",
|
|
Path.home() / "AppData/Local/Microsoft/Edge Dev/User Data",
|
|
Path.home() / "AppData/Local/Microsoft/Edge SxS/User Data",
|
|
]
|
|
INTERNAL = ("chrome://", "chrome-untrusted://", "devtools://", "chrome-extension://", "about:")
|
|
BU_API = "https://api.browser-use.com/api/v3"
|
|
REMOTE_ID = os.environ.get("BU_BROWSER_ID")
|
|
API_KEY = os.environ.get("BROWSER_USE_API_KEY")
|
|
|
|
|
|
def log(msg):
|
|
open(LOG, "a").write(f"{msg}\n")
|
|
|
|
|
|
def get_ws_url():
|
|
if url := os.environ.get("BU_CDP_WS"):
|
|
return url
|
|
if url := os.environ.get("BU_CDP_URL"):
|
|
# HTTP DevTools endpoint (e.g. http://127.0.0.1:9333) — resolve to ws via /json/version.
|
|
# Use this for a dedicated automation Chrome on a non-default profile, which avoids the
|
|
# M144 "Allow remote debugging" dialog and the M136 default-profile lockdown.
|
|
deadline = time.time() + 30
|
|
last_err = None
|
|
while time.time() < deadline:
|
|
try:
|
|
return json.loads(urllib.request.urlopen(f"{url}/json/version", timeout=5).read())["webSocketDebuggerUrl"]
|
|
except Exception as e:
|
|
last_err = e
|
|
time.sleep(1)
|
|
raise RuntimeError(f"BU_CDP_URL={url} unreachable after 30s: {last_err} -- is the dedicated automation Chrome running?")
|
|
for base in PROFILES:
|
|
try:
|
|
port, path = (base / "DevToolsActivePort").read_text().strip().split("\n", 1)
|
|
except (FileNotFoundError, NotADirectoryError):
|
|
continue
|
|
deadline = time.time() + 30
|
|
while True:
|
|
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
probe.settimeout(1)
|
|
try:
|
|
probe.connect(("127.0.0.1", int(port.strip())))
|
|
break
|
|
except OSError:
|
|
if time.time() >= deadline:
|
|
raise RuntimeError(
|
|
f"Chrome's remote-debugging page is open, but DevTools is not live yet on 127.0.0.1:{port.strip()} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown"
|
|
)
|
|
time.sleep(1)
|
|
finally:
|
|
probe.close()
|
|
return f"ws://127.0.0.1:{port.strip()}{path.strip()}"
|
|
for probe_port in (9222, 9223):
|
|
try:
|
|
with urllib.request.urlopen(f"http://127.0.0.1:{probe_port}/json/version", timeout=1) as r:
|
|
return json.loads(r.read())["webSocketDebuggerUrl"]
|
|
except (OSError, KeyError, ValueError):
|
|
continue
|
|
raise RuntimeError(f"DevToolsActivePort not found in {[str(p) for p in PROFILES]} — enable chrome://inspect/#remote-debugging, or set BU_CDP_WS for a remote browser")
|
|
|
|
|
|
def stop_remote():
|
|
if not REMOTE_ID or not API_KEY: return
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"{BU_API}/browsers/{REMOTE_ID}",
|
|
data=json.dumps({"action": "stop"}).encode(),
|
|
method="PATCH",
|
|
headers={"X-Browser-Use-API-Key": API_KEY, "Content-Type": "application/json"},
|
|
)
|
|
urllib.request.urlopen(req, timeout=15).read()
|
|
log(f"stopped remote browser {REMOTE_ID}")
|
|
except Exception as e:
|
|
log(f"stop_remote failed ({REMOTE_ID}): {e}")
|
|
|
|
|
|
def is_real_page(t):
|
|
return t["type"] == "page" and not t.get("url", "").startswith(INTERNAL)
|
|
|
|
|
|
class Daemon:
|
|
def __init__(self):
|
|
self.cdp = None
|
|
self.session = None
|
|
self.events = deque(maxlen=BUF)
|
|
self.dialog = None
|
|
self.stop = None # asyncio.Event, set inside start()
|
|
|
|
async def attach_first_page(self):
|
|
"""Attach to a real page (or any page). Sets self.session. Returns attached target or None."""
|
|
targets = (await self.cdp.send_raw("Target.getTargets"))["targetInfos"]
|
|
pages = [t for t in targets if is_real_page(t)]
|
|
if not pages:
|
|
# No real pages — create one instead of attaching to omnibox popup
|
|
tid = (await self.cdp.send_raw("Target.createTarget", {"url": "about:blank"}))["targetId"]
|
|
log(f"no real pages found, created about:blank ({tid})")
|
|
pages = [{"targetId": tid, "url": "about:blank", "type": "page"}]
|
|
self.session = (await self.cdp.send_raw(
|
|
"Target.attachToTarget", {"targetId": pages[0]["targetId"], "flatten": True}
|
|
))["sessionId"]
|
|
log(f"attached {pages[0]['targetId']} ({pages[0].get('url','')[:80]}) session={self.session}")
|
|
for d in ("Page", "DOM", "Runtime", "Network"):
|
|
try:
|
|
await asyncio.wait_for(
|
|
self.cdp.send_raw(f"{d}.enable", session_id=self.session),
|
|
timeout=5
|
|
)
|
|
except Exception as e:
|
|
log(f"enable {d}: {e}")
|
|
return pages[0]
|
|
|
|
async def start(self):
|
|
self.stop = asyncio.Event()
|
|
url = get_ws_url()
|
|
log(f"connecting to {url}")
|
|
self.cdp = CDPClient(url)
|
|
try:
|
|
await self.cdp.start()
|
|
except Exception as e:
|
|
if os.environ.get("BU_CDP_WS"):
|
|
raise RuntimeError(
|
|
f"CDP WS handshake failed: {e} -- remote browser WebSocket connection failed. "
|
|
"This can happen when network policy blocks the connection, the WS URL is wrong or expired, or the remote endpoint is down. "
|
|
"If you use Browser Use cloud, verify BROWSER_USE_API_KEY and get a fresh URL via start_remote_daemon()."
|
|
)
|
|
raise RuntimeError(f"CDP WS handshake failed: {e} -- click Allow in Chrome if prompted, then retry")
|
|
await self.attach_first_page()
|
|
orig = self.cdp._event_registry.handle_event
|
|
mark_js = "if(!document.title.startsWith('\U0001F7E2'))document.title='\U0001F7E2 '+document.title"
|
|
async def tap(method, params, session_id=None):
|
|
self.events.append({"method": method, "params": params, "session_id": session_id})
|
|
if method == "Page.javascriptDialogOpening":
|
|
self.dialog = params
|
|
elif method == "Page.javascriptDialogClosed":
|
|
self.dialog = None
|
|
elif method in ("Page.loadEventFired", "Page.domContentEventFired"):
|
|
try: await asyncio.wait_for(self.cdp.send_raw("Runtime.evaluate", {"expression": mark_js}, session_id=self.session), timeout=2)
|
|
except Exception: pass
|
|
return await orig(method, params, session_id)
|
|
self.cdp._event_registry.handle_event = tap
|
|
|
|
async def handle(self, req):
|
|
meta = req.get("meta")
|
|
if meta == "drain_events":
|
|
out = list(self.events); self.events.clear()
|
|
return {"events": out}
|
|
if meta == "session": return {"session_id": self.session}
|
|
if meta == "set_session":
|
|
self.session = req.get("session_id")
|
|
try:
|
|
await asyncio.wait_for(self.cdp.send_raw("Page.enable", session_id=self.session), timeout=3)
|
|
await asyncio.wait_for(self.cdp.send_raw("Runtime.evaluate", {"expression": "if(!document.title.startsWith('\U0001F7E2'))document.title='\U0001F7E2 '+document.title"}, session_id=self.session), timeout=2)
|
|
except Exception: pass
|
|
return {"session_id": self.session}
|
|
if meta == "pending_dialog": return {"dialog": self.dialog}
|
|
if meta == "shutdown": self.stop.set(); return {"ok": True}
|
|
|
|
method = req["method"]
|
|
params = req.get("params") or {}
|
|
# Browser-level Target.* calls must not use a session (stale or otherwise).
|
|
# For everything else, explicit session in req wins; else default.
|
|
sid = None if method.startswith("Target.") else (req.get("session_id") or self.session)
|
|
try:
|
|
return {"result": await self.cdp.send_raw(method, params, session_id=sid)}
|
|
except Exception as e:
|
|
msg = str(e)
|
|
if "Session with given id not found" in msg and sid == self.session and sid:
|
|
log(f"stale session {sid}, re-attaching")
|
|
if await self.attach_first_page():
|
|
return {"result": await self.cdp.send_raw(method, params, session_id=self.session)}
|
|
return {"error": msg}
|
|
|
|
|
|
async def serve(d):
|
|
async def handler(reader, writer):
|
|
try:
|
|
line = await reader.readline()
|
|
if not line: return
|
|
resp = await d.handle(json.loads(line))
|
|
writer.write((json.dumps(resp, default=str) + "\n").encode())
|
|
await writer.drain()
|
|
except Exception as e:
|
|
log(f"conn: {e}")
|
|
try:
|
|
writer.write((json.dumps({"error": str(e)}) + "\n").encode())
|
|
await writer.drain()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
writer.close()
|
|
|
|
serve_task = asyncio.create_task(ipc.serve(NAME, handler))
|
|
stop_task = asyncio.create_task(d.stop.wait())
|
|
await asyncio.sleep(0.05) # let serve() bind so sock_addr() resolves to the live endpoint
|
|
log(f"listening on {ipc.sock_addr(NAME)} (name={NAME}, remote={REMOTE_ID or 'local'})")
|
|
try:
|
|
await asyncio.wait({serve_task, stop_task}, return_when=asyncio.FIRST_COMPLETED)
|
|
if serve_task.done(): await serve_task # surfaces a serve crash
|
|
finally:
|
|
for t in (serve_task, stop_task):
|
|
t.cancel()
|
|
try: await t
|
|
except (asyncio.CancelledError, Exception): pass
|
|
ipc.cleanup_endpoint(NAME)
|
|
|
|
|
|
async def main():
|
|
d = Daemon()
|
|
await d.start()
|
|
await serve(d)
|
|
|
|
|
|
def already_running():
|
|
try:
|
|
c = ipc.connect(NAME, timeout=1.0); c.close(); return True
|
|
except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError):
|
|
return False
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if already_running():
|
|
print(f"daemon already running on {SOCK}", file=sys.stderr)
|
|
sys.exit(0)
|
|
open(LOG, "w").close()
|
|
open(PID, "w").write(str(os.getpid()))
|
|
try:
|
|
asyncio.run(main())
|
|
except KeyboardInterrupt:
|
|
pass
|
|
except Exception as e:
|
|
log(f"fatal: {e}")
|
|
sys.exit(1)
|
|
finally:
|
|
stop_remote()
|
|
try: os.unlink(PID)
|
|
except FileNotFoundError: pass
|