feat: auto-start a debug browser for agent-browser on Windows

On Windows agent-browser cannot launch Chrome by itself: the Chrome
launcher process hands off to a child and exits, which agent-browser
mistakes for a crash ("Chrome exited early without writing
DevToolsActivePort"). On such machines the export never gets a browser.

Before creating the session, resolve a CDP endpoint (Windows only):

1. honor AGENT_BROWSER_CDP if it answers /json/version;
2. else reuse a live endpoint on port 9337;
3. else launch Chrome/Edge with --remote-debugging-port and a fixed
   profile, and pass the port to agent-browser via AGENT_BROWSER_CDP.

The instance is intentionally left running: relaunching with the same
profile joins the existing browser, so repeated exports reuse one
instance instead of piling up processes. Non-Windows platforms are
untouched.

Discussed in #4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
yoruuuchan
2026-08-06 21:43:02 +09:00
co-authored by Claude Fable 5
parent f77ee8dfcb
commit 9acf2025e4
2 changed files with 118 additions and 2 deletions
+95 -2
View File
@@ -21,6 +21,7 @@ import sys
import tempfile import tempfile
import threading import threading
import time import time
import urllib.request
import uuid import uuid
import zipfile import zipfile
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@@ -243,6 +244,88 @@ def ensure_agent_browser() -> str:
return executable return executable
DEBUG_CHROME_PORT = 9337
CHROME_CANDIDATES = (
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
str(Path.home() / "AppData" / "Local" / "Google" / "Chrome" / "Application" / "chrome.exe"),
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
)
def cdp_alive(port: int) -> bool:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=2):
return True
except OSError:
return False
def ensure_debug_chrome() -> Optional[int]:
"""Return a CDP port for agent-browser to connect to (Windows only).
On Windows agent-browser cannot launch Chrome itself: the Chrome launcher
process hands off to a child and exits, which agent-browser mistakes for a
crash ("Chrome exited early without writing DevToolsActivePort"). The
export therefore always drives an externally started browser. An
already-working AGENT_BROWSER_CDP wins; otherwise a dedicated debug
instance is started (or reused) on port 9337. The instance is left running
on purpose: relaunching with the same profile joins the existing browser,
so repeated exports reuse one instance instead of piling up processes.
"""
if sys.platform != "win32":
return None
explicit = os.environ.get("AGENT_BROWSER_CDP")
if explicit:
try:
if cdp_alive(int(explicit)):
return int(explicit)
except ValueError:
pass
log(f"AGENT_BROWSER_CDP={explicit} is not answering; starting a debug browser instead")
port = DEBUG_CHROME_PORT
if cdp_alive(port):
return port
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1", port)) == 0:
# Port taken by something that is not a CDP endpoint.
with socket.socket() as spare:
spare.bind(("127.0.0.1", 0))
port = spare.getsockname()[1]
executable = next((c for c in CHROME_CANDIDATES if Path(c).is_file()), None)
if executable is None:
raise ExportError(
"no Chrome or Edge found to drive the export; install Google Chrome, "
"or start a browser with --remote-debugging-port yourself and set "
"AGENT_BROWSER_CDP to that port"
)
profile = Path(tempfile.gettempdir()) / "okp-cdp-profile"
log(f"starting debug browser on port {port}: {executable}")
subprocess.Popen(
[
executable,
f"--user-data-dir={profile}",
f"--remote-debugging-port={port}",
"--no-first-run",
"--no-default-browser-check",
"--window-position=-2400,0",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if cdp_alive(port):
return port
time.sleep(0.5)
raise ExportError(f"debug browser did not open CDP port {port} within 20s")
def find_manifest(source: Path) -> Path: def find_manifest(source: Path) -> Path:
source = source.expanduser().resolve() source = source.expanduser().resolve()
if source.is_file(): if source.is_file():
@@ -352,7 +435,14 @@ def json_result(output: str) -> Dict[str, Any]:
class BrowserSession: class BrowserSession:
def __init__(self, executable: str, session: str, cwd: Path, download_dir: Path): def __init__(
self,
executable: str,
session: str,
cwd: Path,
download_dir: Path,
cdp_port: Optional[int] = None,
):
self.executable = executable self.executable = executable
self.session = session self.session = session
self.cwd = cwd self.cwd = cwd
@@ -362,6 +452,8 @@ class BrowserSession:
self.env = os.environ.copy() self.env = os.environ.copy()
self.env.setdefault("AGENT_BROWSER_DEFAULT_TIMEOUT", "60000") self.env.setdefault("AGENT_BROWSER_DEFAULT_TIMEOUT", "60000")
self.env.setdefault("AGENT_BROWSER_IDLE_TIMEOUT_MS", "180000") self.env.setdefault("AGENT_BROWSER_IDLE_TIMEOUT_MS", "180000")
if cdp_port is not None:
self.env["AGENT_BROWSER_CDP"] = str(cdp_port)
def run( def run(
self, self,
@@ -633,6 +725,7 @@ def export_pptx(
if output.exists() and not force: if output.exists() and not force:
raise ExportError(f"output already exists (pass --force to replace it): {output}") raise ExportError(f"output already exists (pass --force to replace it): {output}")
agent_browser = ensure_agent_browser() agent_browser = ensure_agent_browser()
cdp_port = ensure_debug_chrome()
log(f"manifest: {manifest}") log(f"manifest: {manifest}")
log( log(
@@ -649,7 +742,7 @@ def export_pptx(
) )
server, thread, url = serve(temp_dir) server, thread, url = serve(temp_dir)
session = f"open-kimi-ppt-export-{os.getpid()}-{uuid.uuid4().hex[:8]}" session = f"open-kimi-ppt-export-{os.getpid()}-{uuid.uuid4().hex[:8]}"
browser = BrowserSession(agent_browser, session, temp_dir, download_dir) browser = BrowserSession(agent_browser, session, temp_dir, download_dir, cdp_port)
downloads = default_downloads_dir() downloads = default_downloads_dir()
try: try:
log("opening the public Kimi slide editor") log("opening the public Kimi slide editor")
@@ -177,6 +177,29 @@ class ExportPptxTests(unittest.TestCase):
timeout=90, timeout=90,
) )
def test_ensure_debug_chrome_is_windows_only(self):
with patch.object(MODULE.sys, "platform", "linux"):
self.assertIsNone(MODULE.ensure_debug_chrome())
@patch.object(MODULE, "cdp_alive", return_value=True)
def test_ensure_debug_chrome_prefers_working_explicit_port(self, cdp_alive):
with patch.object(MODULE.sys, "platform", "win32"), \
patch.dict(MODULE.os.environ, {"AGENT_BROWSER_CDP": "9444"}):
self.assertEqual(MODULE.ensure_debug_chrome(), 9444)
cdp_alive.assert_called_once_with(9444)
def test_browser_session_exports_cdp_port_to_env(self):
with patch.dict(MODULE.os.environ, {}, clear=False):
MODULE.os.environ.pop("AGENT_BROWSER_CDP", None)
with_port = MODULE.BrowserSession(
"/bin/agent-browser", "s", Path("."), Path("/tmp/d"), cdp_port=9337
)
self.assertEqual(with_port.env["AGENT_BROWSER_CDP"], "9337")
without_port = MODULE.BrowserSession(
"/bin/agent-browser", "s", Path("."), Path("/tmp/d")
)
self.assertNotIn("AGENT_BROWSER_CDP", without_port.env)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()