Coverage for src/ai_shell/local_chrome.py: 73%
126 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 22:06 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 22:06 +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 subprocess
22import time
23from collections.abc import Callable
24from hashlib import sha1
25from http.client import HTTPConnection, HTTPException
26from pathlib import Path
28from ai_shell.defaults import unique_project_name
30logger = logging.getLogger(__name__)
32CHROME_DEBUG_HOST = "host.docker.internal"
33CHROME_HOST_PROBE_TIMEOUT_SECONDS = 20.0
34CHROME_CONTAINER_PROBE_TIMEOUT_SECONDS = 10.0
35CHROME_PROBE_INTERVAL_SECONDS = 0.5
36CHROME_DEBUG_PORT_RANGE_START = 40000
37CHROME_DEBUG_PORT_RANGE_SIZE = 20000
39MCP_CONFIG_FILENAME = "chrome-mcp.json"
41# User-data-dir for ai-shell project-specific Chrome profiles (keeps them
42# separate from the user's normal browsing and from other repos).
43_CHROME_PROFILE_ROOT_DIR_NAME = "ai-shell"
45# Well-known Chrome install paths on Windows
46_CHROME_CANDIDATES = [
47 r"C:\Program Files\Google\Chrome\Application\chrome.exe",
48 r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
49]
51# Inline Node.js TCP proxy template -- forwards localhost:PORT to
52# host.docker.internal:PORT so Chrome sees Host: localhost.
53_NODE_PROXY_TEMPLATE = (
54 "const net=require('net');"
55 "net.createServer(c=>{{"
56 "const s=net.connect({port},'host.docker.internal',()=>{{c.pipe(s);s.pipe(c)}});"
57 "s.on('error',()=>c.destroy());c.on('error',()=>s.destroy())"
58 "}}).listen({port},'127.0.0.1')"
59)
62class LocalChromeUnavailable(Exception):
63 """Raised when the host Chrome debug port is not reachable."""
66def find_chrome() -> str | None:
67 """Locate chrome.exe on the Windows host.
69 Checks well-known install paths. Returns the path as a string
70 or ``None`` if Chrome is not found.
71 """
72 if platform.system() != "Windows":
73 return None
74 for candidate in _CHROME_CANDIDATES:
75 if Path(candidate).exists():
76 return candidate
77 # Fallback: per-user Chrome install
78 local_app = os.environ.get("LOCALAPPDATA", "")
79 if local_app:
80 user_chrome = Path(local_app) / "Google" / "Chrome" / "Application" / "chrome.exe"
81 if user_chrome.exists():
82 return str(user_chrome)
83 return None
86def _project_slug(project_name: str, project_dir: str | Path | None = None) -> str:
87 """Return a stable slug for project-scoped Chrome state."""
88 if project_dir is not None:
89 try:
90 return unique_project_name(Path(project_dir), project_name)
91 except (TypeError, ValueError):
92 logger.debug("Falling back to project-name-only slug for %s", project_name)
93 slug = "".join(ch if ch.isalnum() or ch == "-" else "-" for ch in project_name.lower())
94 slug = "-".join(part for part in slug.split("-") if part)
95 return slug or "project"
98def _chrome_profile_dir(project_name: str, project_dir: str | Path | None = None) -> str:
99 """Return the user-data-dir path for a project's ai-shell debug Chrome."""
100 slug = _project_slug(project_name, project_dir)
101 local_app = os.environ.get("LOCALAPPDATA", "")
102 if local_app:
103 return str(Path(local_app) / "Google" / "Chrome" / _CHROME_PROFILE_ROOT_DIR_NAME / slug)
104 return str(Path.home() / ".config" / "google-chrome" / _CHROME_PROFILE_ROOT_DIR_NAME / slug)
107def _project_debug_port(project_name: str, project_dir: str | Path | None = None) -> int:
108 """Return a stable per-project Chrome remote debugging port."""
109 slug = _project_slug(project_name, project_dir)
110 # nosemgrep: python.lang.security.insecure-hash-algorithms.insecure-hash-algorithm-sha1
111 digest = sha1(slug.encode("utf-8"), usedforsecurity=False).hexdigest()
112 return CHROME_DEBUG_PORT_RANGE_START + (int(digest[:8], 16) % CHROME_DEBUG_PORT_RANGE_SIZE)
115def _build_setup_instructions(project_name: str, profile_dir: str, port: int) -> str:
116 """Return manual setup instructions for the project's Chrome profile."""
117 return f"""\
118Chrome could not be found or launched automatically, and the debug port \
119is not reachable.
121To fix, launch Chrome manually with these flags:
123 chrome.exe --remote-debugging-port={port} \\
124 --remote-debugging-address=127.0.0.1 \\
125 --remote-allow-origins=* \\
126 --user-data-dir="{profile_dir}"
128Then re-run this command for project '{project_name}'.
129See README.md "Attaching to your Windows Chrome" for details."""
132def launch_chrome(
133 port: int,
134 *,
135 project_name: str,
136 project_dir: str | Path | None = None,
137) -> bool:
138 """Launch Chrome on the host with the debug port enabled.
140 Returns ``True`` if Chrome was launched, ``False`` if Chrome could
141 not be found. The process is started detached so it outlives the CLI.
142 """
143 chrome_path = find_chrome()
144 if chrome_path is None:
145 return False
147 profile_dir = _chrome_profile_dir(project_name, project_dir)
148 args = [
149 chrome_path,
150 f"--remote-debugging-port={port}",
151 "--remote-debugging-address=127.0.0.1",
152 "--remote-allow-origins=*",
153 f"--user-data-dir={profile_dir}",
154 ]
155 logger.info("Launching Chrome: %s", " ".join(args))
157 # Start detached so Chrome outlives the CLI process.
158 creation_flags = 0
159 if platform.system() == "Windows":
160 creation_flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(
161 subprocess, "DETACHED_PROCESS", 0
162 )
163 subprocess.Popen( # noqa: S603
164 args,
165 stdout=subprocess.DEVNULL,
166 stderr=subprocess.DEVNULL,
167 stdin=subprocess.DEVNULL,
168 creationflags=creation_flags,
169 )
170 return True
173def probe_chrome_port(container_name: str, port: int) -> bool:
174 """Check whether a Chrome debug port is reachable from the container.
176 Returns ``True`` if reachable, ``False`` otherwise.
177 """
178 probe_url = f"http://{CHROME_DEBUG_HOST}:{port}/json/version"
179 args = [
180 "docker",
181 "exec",
182 container_name,
183 "curl",
184 "-sS",
185 "--max-time",
186 "3",
187 "-H",
188 f"Host: localhost:{port}",
189 probe_url,
190 ]
191 logger.debug("Probing Chrome debug port: %s", " ".join(args))
192 result = subprocess.run(args, capture_output=True, text=True)
193 if result.returncode != 0 or not result.stdout.strip():
194 return False
195 logger.info("Chrome debug port reachable: %s", result.stdout.strip()[:120])
196 return True
199def probe_host_chrome_port(port: int) -> bool:
200 """Check whether a Chrome debug port is reachable on the host."""
201 connection = HTTPConnection("127.0.0.1", port, timeout=2)
202 try:
203 connection.request("GET", "/json/version")
204 response = connection.getresponse()
205 return response.status == 200 and bool(response.read().strip())
206 except (OSError, HTTPException):
207 return False
208 finally:
209 connection.close()
212def _wait_until_ready(
213 probe_fn: Callable[..., bool],
214 *args: object,
215 timeout_seconds: float,
216 interval_seconds: float = CHROME_PROBE_INTERVAL_SECONDS,
217) -> bool:
218 """Poll until a probe succeeds or the timeout expires."""
219 deadline = time.monotonic() + timeout_seconds
220 while True:
221 if probe_fn(*args):
222 return True
223 remaining = deadline - time.monotonic()
224 if remaining <= 0:
225 return False
226 time.sleep(min(interval_seconds, remaining))
229def ensure_host_chrome(
230 container_name: str,
231 *,
232 project_name: str,
233 project_dir: str | Path | None = None,
234) -> int:
235 """Ensure Chrome is running with a debug port reachable from the container.
237 Each project gets its own debug profile directory and a stable debug port,
238 so different repos can keep separate logged-in Chrome instances alive.
240 Returns the port number Chrome is listening on.
241 """
242 port = _project_debug_port(project_name, project_dir)
243 profile_dir = _chrome_profile_dir(project_name, project_dir)
245 if probe_chrome_port(container_name, port):
246 return port
248 logger.info("Chrome for project %s not found on port %d, launching it", project_name, port)
250 if not launch_chrome(port, project_name=project_name, project_dir=project_dir):
251 raise LocalChromeUnavailable(_build_setup_instructions(project_name, profile_dir, port))
253 if not _wait_until_ready(
254 probe_host_chrome_port,
255 port,
256 timeout_seconds=CHROME_HOST_PROBE_TIMEOUT_SECONDS,
257 ):
258 raise LocalChromeUnavailable(
259 f"Chrome was launched for project '{project_name}' on port {port}, but the "
260 f"debug port did not open on localhost within "
261 f"{int(CHROME_HOST_PROBE_TIMEOUT_SECONDS)} seconds.\n\n"
262 "If another ai-shell Chrome window for this project is already open, "
263 "close it and retry."
264 )
266 if _wait_until_ready(
267 probe_chrome_port,
268 container_name,
269 port,
270 timeout_seconds=CHROME_CONTAINER_PROBE_TIMEOUT_SECONDS,
271 ):
272 logger.info("Chrome ready on port %d for project %s", port, project_name)
273 return port
275 raise LocalChromeUnavailable(
276 f"Chrome is listening on localhost:{port} for project '{project_name}', but the "
277 "debug port did not become reachable from the dev container within "
278 f"{int(CHROME_CONTAINER_PROBE_TIMEOUT_SECONDS)} seconds.\n\n"
279 "Check that Docker Desktop can reach the host via host.docker.internal."
280 )
283def start_chrome_proxy(container_name: str, port: int) -> None:
284 """Start a TCP proxy inside the container: localhost:<port> -> host.docker.internal:<port>.
286 Chrome rejects DevTools Protocol requests with a non-localhost Host
287 header. This proxy lets the MCP server connect to ``localhost:<port>``
288 so Chrome sees ``Host: localhost``.
290 The proxy runs as a detached background process via ``docker exec -d``.
291 It's idempotent -- if the port is already in use (previous proxy still
292 running), the new one fails silently and the existing one keeps working.
293 """
294 script = _NODE_PROXY_TEMPLATE.format(port=port)
295 args = [
296 "docker",
297 "exec",
298 "-d",
299 container_name,
300 "node",
301 "-e",
302 script,
303 ]
304 logger.debug("Starting Chrome proxy: %s", " ".join(args))
305 result = subprocess.run(args, capture_output=True, text=True)
306 if result.returncode != 0:
307 logger.warning(
308 "Chrome proxy start returned %d: %s", result.returncode, result.stderr.strip()
309 )
312def write_mcp_config(port: int, config_dir: Path | None = None) -> Path:
313 """Write the chrome-devtools-mcp server config JSON.
315 Returns the path to the written file. The file lives under
316 ``~/.config/ai-shell/`` by default so it persists across sessions
317 without polluting the project directory.
319 The config points at ``localhost:<port>`` (the in-container proxy),
320 not ``host.docker.internal:<port>``, because Chrome rejects the latter.
321 """
322 if config_dir is None:
323 config_dir = Path.home() / ".config" / "ai-shell"
324 config_dir.mkdir(parents=True, exist_ok=True)
326 mcp_config = {
327 "mcpServers": {
328 "chrome-devtools": {
329 "command": "npx",
330 "args": [
331 "-y",
332 "chrome-devtools-mcp@latest",
333 "--browserUrl",
334 f"http://localhost:{port}",
335 ],
336 }
337 }
338 }
340 path = config_dir / MCP_CONFIG_FILENAME
341 path.write_text(json.dumps(mcp_config, indent=2) + "\n", encoding="utf-8")
342 logger.debug("Wrote MCP config: %s", path)
343 return path