hermes-agent/optional-skills/security/unbroker/scripts/cdp.py
SHL0MS a35ac25437 add cdp: launch/detect operator chrome over CDP for phase-2 browser + webmail
phase-2 work (sending webmail, clearing session-bound gates like peopleconnect guided-mode) needs
the operator's own logged-in browser, not a cloud browser. new `pdd.py cdp`:
- finds chrome/chromium/brave/edge (macos/linux/windows), launches it detached on a dedicated debug
  profile ($HERMES_HOME/chrome-debug) with --remote-debugging-port, waits for the port, prints the
  CDP endpoint (webSocketDebuggerUrl)
- `--check`: report whether a debug browser is already live (never double-launches)
- `--print`: emit the exact command for the operator to run themselves
- doctor, SKILL.md, and methods.md all point at it
- windows-safe detach (start_new_session on posix, DETACHED_PROCESS on windows); stdlib only

tests: standalone 98, PR 96 (+6 cdp); ruff + windows-footguns clean.
2026-07-03 13:16:06 -04:00

159 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Launch (or detect) the operator's local Chrome/Chromium over the DevTools Protocol (CDP).
Phase-2 work -- sending opt-out/CCPA email through the operator's logged-in webmail, and driving
session-bound multi-step opt-out gates (e.g. PeopleConnect guided-mode) -- must run in the
operator's OWN browser: real fingerprint, residential IP, and the operator's signed-in sessions.
A headless cloud browser (Browserbase) is the wrong tool there (it has no webmail session and is
itself anti-bot-gated on those exact flows). This module launches the operator's real Chrome with
remote debugging on a DEDICATED profile so Hermes's browser tools can attach at 127.0.0.1:<port>.
Stdlib only; cross-platform (macOS / Linux / Windows). Nothing here touches a password or PII.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
import paths
DEFAULT_PORT = 9222
# Chromium-family binaries we know how to drive, in preference order. Names first (works on any OS
# where one is on PATH), then per-OS absolute-path fallbacks below.
_PATH_NAMES = (
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
"brave-browser", "microsoft-edge", "microsoft-edge-stable", "chrome",
)
def default_profile() -> Path:
"""Dedicated debug profile dir, NOT the operator's Default Chrome profile.
Chrome refuses remote-debugging on a profile that is already open in another Chrome instance,
so we isolate the debug session in its own user-data-dir under HERMES_HOME.
"""
return paths.hermes_home() / "chrome-debug"
def _mac_candidates() -> list[str]:
return [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
]
def _windows_candidates() -> list[str]:
bases = [
os.environ.get("ProgramFiles", r"C:\Program Files"),
os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
os.environ.get("LOCALAPPDATA", ""),
]
rels = [
r"Google\Chrome\Application\chrome.exe",
r"Chromium\Application\chrome.exe",
r"BraveSoftware\Brave-Browser\Application\brave.exe",
r"Microsoft\Edge\Application\msedge.exe",
]
out: list[str] = []
for base in bases:
if not base:
continue
for rel in rels:
out.append(str(Path(base) / rel))
return out
def find_browser(override: str | None = None) -> str | None:
"""Return the first usable Chromium-family browser path/command, or None.
`override` (an explicit path, or a command on PATH) wins when it resolves.
"""
if override:
if Path(override).exists():
return override
return shutil.which(override) # may be None -> caller reports "not found"
for name in _PATH_NAMES:
found = shutil.which(name)
if found:
return found
if sys.platform == "darwin":
candidates = _mac_candidates()
elif sys.platform == "win32":
candidates = _windows_candidates()
else:
candidates = []
for cand in candidates:
if Path(cand).exists():
return cand
return None
def launch_command(browser: str, port: int = DEFAULT_PORT, profile: Path | None = None) -> list[str]:
"""The exact argv used to start the debug browser (also handy for `--print`)."""
profile = profile or default_profile()
return [
browser,
f"--remote-debugging-port={int(port)}",
f"--user-data-dir={profile}",
"--no-first-run",
"--no-default-browser-check",
]
def _http_get(url: str, timeout: float) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": "unbroker-cdp/1.0"})
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (localhost only)
return resp.read()
def endpoint_status(port: int = DEFAULT_PORT, host: str = "127.0.0.1",
timeout: float = 1.0) -> dict | None:
"""Return the CDP `/json/version` dict if a debuggable browser is live at host:port, else None.
(Chrome restricts this endpoint to localhost/IP Host headers, so we always hit 127.0.0.1.)
"""
url = f"http://{host}:{int(port)}/json/version"
try:
raw = _http_get(url, timeout)
except (urllib.error.URLError, TimeoutError, ConnectionError, OSError, ValueError):
return None
try:
data = json.loads(raw.decode("utf-8", errors="replace"))
except (ValueError, AttributeError):
return None
return data if isinstance(data, dict) else None
def launch(browser: str, port: int = DEFAULT_PORT, profile: Path | None = None) -> int:
"""Start the browser detached with remote debugging; return the child PID.
Detach so the browser outlives this short-lived CLI call. POSIX uses start_new_session (which
avoids referencing os.setsid, so there is no Windows import-time footgun); Windows uses
DETACHED_PROCESS + a new process group.
"""
profile = profile or default_profile()
profile.mkdir(parents=True, exist_ok=True)
cmd = launch_command(browser, port, profile)
kwargs: dict = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
}
if sys.platform == "win32":
kwargs["creationflags"] = (
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP # windows-footgun: ok
)
else:
kwargs["start_new_session"] = True
proc = subprocess.Popen(cmd, **kwargs)
return proc.pid