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:
co-authored by
Claude Fable 5
parent
f77ee8dfcb
commit
9acf2025e4
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user