Coverage for src / ai_shell / local_chrome.py: 64%
90 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 22:12 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 22:12 +0000
1"""Local Chrome bridge for attaching chrome-devtools-mcp to a host browser.
3Probes the Windows host's Chrome debug port from inside the container and
4writes a minimal MCP config JSON that Claude Code can consume.
6Chrome's DevTools Protocol rejects HTTP requests whose ``Host`` header is
7not ``localhost`` or an IP address. Because the MCP server inside the
8container connects via ``host.docker.internal``, Chrome returns HTTP 500.
9To work around this, we start a small Node.js TCP proxy inside the
10container that forwards ``localhost:<port>`` to ``host.docker.internal:<port>``.
11The MCP server then connects to ``localhost:<port>`` and Chrome sees a
12``Host: localhost`` header.
13"""
15from __future__ import annotations
17import json
18import logging
19import os
20import platform
21import socket
22import subprocess
23import time
24from pathlib import Path
26logger = logging.getLogger(__name__)
28CHROME_DEBUG_HOST = "host.docker.internal"
29DEFAULT_CHROME_DEBUG_PORT = 9222
31MCP_CONFIG_FILENAME = "chrome-mcp.json"
33# User-data-dir for the ai-shell debug Chrome profile (keeps it separate from
34# the user's normal browsing).
35_CHROME_PROFILE_DIR_NAME = "ai-debug-profile"
37# Well-known Chrome install paths on Windows
38_CHROME_CANDIDATES = [
39 r"C:\Program Files\Google\Chrome\Application\chrome.exe",
40 r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
41]
43# Inline Node.js TCP proxy template -- forwards localhost:PORT to
44# host.docker.internal:PORT so Chrome sees Host: localhost.
45_NODE_PROXY_TEMPLATE = (
46 "const net=require('net');"
47 "net.createServer(c=>{{"
48 "const s=net.connect({port},'host.docker.internal',()=>{{c.pipe(s);s.pipe(c)}});"
49 "s.on('error',()=>c.destroy());c.on('error',()=>s.destroy())"
50 "}}).listen({port},'127.0.0.1')"
51)
53SETUP_INSTRUCTIONS = """\
54Chrome could not be found or launched automatically, and the debug port \
55is not reachable.
57To fix, launch Chrome manually with these flags:
59 chrome.exe --remote-debugging-port=9222 \\
60 --remote-debugging-address=127.0.0.1 \\
61 --remote-allow-origins=* \\
62 --user-data-dir="%LOCALAPPDATA%\\Google\\Chrome\\ai-debug-profile"
64Then re-run this command.
65See README.md "Attaching to your Windows Chrome" for details."""
68class LocalChromeUnavailable(Exception):
69 """Raised when the host Chrome debug port is not reachable."""
72def find_chrome() -> str | None:
73 """Locate chrome.exe on the Windows host.
75 Checks well-known install paths. Returns the path as a string
76 or ``None`` if Chrome is not found.
77 """
78 if platform.system() != "Windows":
79 return None
80 for candidate in _CHROME_CANDIDATES:
81 if Path(candidate).exists():
82 return candidate
83 # Fallback: per-user Chrome install
84 local_app = os.environ.get("LOCALAPPDATA", "")
85 if local_app:
86 user_chrome = Path(local_app) / "Google" / "Chrome" / "Application" / "chrome.exe"
87 if user_chrome.exists():
88 return str(user_chrome)
89 return None
92def _chrome_profile_dir() -> str:
93 """Return the user-data-dir path for the ai-shell debug Chrome profile."""
94 local_app = os.environ.get("LOCALAPPDATA", "")
95 if local_app:
96 return str(Path(local_app) / "Google" / "Chrome" / _CHROME_PROFILE_DIR_NAME)
97 return str(Path.home() / ".config" / "google-chrome" / _CHROME_PROFILE_DIR_NAME)
100def _find_free_port() -> int:
101 """Find a free TCP port on the host by briefly binding to port 0."""
102 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
103 s.bind(("127.0.0.1", 0))
104 port: int = s.getsockname()[1]
105 return port
108def launch_chrome(port: int) -> bool:
109 """Launch Chrome on the host with the debug port enabled.
111 Returns ``True`` if Chrome was launched, ``False`` if Chrome could
112 not be found. The process is started detached so it outlives the CLI.
113 """
114 chrome_path = find_chrome()
115 if chrome_path is None:
116 return False
118 profile_dir = _chrome_profile_dir()
119 args = [
120 chrome_path,
121 f"--remote-debugging-port={port}",
122 "--remote-debugging-address=127.0.0.1",
123 "--remote-allow-origins=*",
124 f"--user-data-dir={profile_dir}",
125 ]
126 logger.info("Launching Chrome: %s", " ".join(args))
128 # Start detached so Chrome outlives the CLI process.
129 creation_flags = 0
130 if platform.system() == "Windows":
131 creation_flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(
132 subprocess, "DETACHED_PROCESS", 0
133 )
134 subprocess.Popen( # noqa: S603
135 args,
136 stdout=subprocess.DEVNULL,
137 stderr=subprocess.DEVNULL,
138 stdin=subprocess.DEVNULL,
139 creationflags=creation_flags,
140 )
141 return True
144def probe_chrome_port(container_name: str, port: int) -> bool:
145 """Check whether a Chrome debug port is reachable from the container.
147 Returns ``True`` if reachable, ``False`` otherwise.
148 """
149 probe_url = f"http://{CHROME_DEBUG_HOST}:{port}/json/version"
150 args = [
151 "docker",
152 "exec",
153 container_name,
154 "curl",
155 "-sS",
156 "--max-time",
157 "3",
158 "-H",
159 f"Host: localhost:{port}",
160 probe_url,
161 ]
162 logger.debug("Probing Chrome debug port: %s", " ".join(args))
163 result = subprocess.run(args, capture_output=True, text=True)
164 if result.returncode != 0 or not result.stdout.strip():
165 return False
166 logger.info("Chrome debug port reachable: %s", result.stdout.strip()[:120])
167 return True
170def ensure_host_chrome(container_name: str) -> int:
171 """Ensure Chrome is running with a debug port reachable from the container.
173 1. Probe the default port (9222) -- if Chrome is already running, return it.
174 2. Find a free port, launch Chrome on it, wait briefly for startup.
175 3. Raise :class:`LocalChromeUnavailable` if Chrome can't be found or started.
177 Returns the port number Chrome is listening on.
178 """
179 # Try the default port first -- user may have Chrome open already
180 if probe_chrome_port(container_name, DEFAULT_CHROME_DEBUG_PORT):
181 return DEFAULT_CHROME_DEBUG_PORT
183 # Launch Chrome on a fresh port
184 port = _find_free_port()
185 logger.info(
186 "Chrome not found on port %d, launching on port %d", DEFAULT_CHROME_DEBUG_PORT, port
187 )
189 if not launch_chrome(port):
190 raise LocalChromeUnavailable(SETUP_INSTRUCTIONS)
192 # Brief wait for Chrome to start (typically <2s)
193 for attempt in range(5):
194 time.sleep(1)
195 if probe_chrome_port(container_name, port):
196 logger.info("Chrome ready on port %d after %ds", port, attempt + 1)
197 return port
199 raise LocalChromeUnavailable(
200 f"Chrome was launched on port {port} but the debug port did not become "
201 "reachable within 5 seconds.\n\n"
202 "Check that Docker Desktop can reach the host via host.docker.internal."
203 )
206def start_chrome_proxy(container_name: str, port: int) -> None:
207 """Start a TCP proxy inside the container: localhost:<port> -> host.docker.internal:<port>.
209 Chrome rejects DevTools Protocol requests with a non-localhost Host
210 header. This proxy lets the MCP server connect to ``localhost:<port>``
211 so Chrome sees ``Host: localhost``.
213 The proxy runs as a detached background process via ``docker exec -d``.
214 It's idempotent -- if the port is already in use (previous proxy still
215 running), the new one fails silently and the existing one keeps working.
216 """
217 script = _NODE_PROXY_TEMPLATE.format(port=port)
218 args = [
219 "docker",
220 "exec",
221 "-d",
222 container_name,
223 "node",
224 "-e",
225 script,
226 ]
227 logger.debug("Starting Chrome proxy: %s", " ".join(args))
228 result = subprocess.run(args, capture_output=True, text=True)
229 if result.returncode != 0:
230 logger.warning(
231 "Chrome proxy start returned %d: %s", result.returncode, result.stderr.strip()
232 )
235def write_mcp_config(port: int, config_dir: Path | None = None) -> Path:
236 """Write the chrome-devtools-mcp server config JSON.
238 Returns the path to the written file. The file lives under
239 ``~/.config/ai-shell/`` by default so it persists across sessions
240 without polluting the project directory.
242 The config points at ``localhost:<port>`` (the in-container proxy),
243 not ``host.docker.internal:<port>``, because Chrome rejects the latter.
244 """
245 if config_dir is None:
246 config_dir = Path.home() / ".config" / "ai-shell"
247 config_dir.mkdir(parents=True, exist_ok=True)
249 mcp_config = {
250 "mcpServers": {
251 "chrome-devtools": {
252 "command": "npx",
253 "args": [
254 "-y",
255 "chrome-devtools-mcp@latest",
256 "--browserUrl",
257 f"http://localhost:{port}",
258 ],
259 }
260 }
261 }
263 path = config_dir / MCP_CONFIG_FILENAME
264 path.write_text(json.dumps(mcp_config, indent=2) + "\n", encoding="utf-8")
265 logger.debug("Wrote MCP config: %s", path)
266 return path