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

1"""Local Chrome bridge for attaching chrome-devtools-mcp to a host browser. 

2 

3Probes the Windows host's Chrome debug port from inside the container and 

4writes a minimal MCP config JSON that Claude Code can consume. 

5 

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""" 

14 

15from __future__ import annotations 

16 

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 

27 

28from ai_shell.defaults import unique_project_name 

29 

30logger = logging.getLogger(__name__) 

31 

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 

38 

39MCP_CONFIG_FILENAME = "chrome-mcp.json" 

40 

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" 

44 

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] 

50 

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) 

60 

61 

62class LocalChromeUnavailable(Exception): 

63 """Raised when the host Chrome debug port is not reachable.""" 

64 

65 

66def find_chrome() -> str | None: 

67 """Locate chrome.exe on the Windows host. 

68 

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 

84 

85 

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" 

96 

97 

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) 

105 

106 

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) 

113 

114 

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. 

120 

121To fix, launch Chrome manually with these flags: 

122 

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}" 

127 

128Then re-run this command for project '{project_name}'. 

129See README.md "Attaching to your Windows Chrome" for details.""" 

130 

131 

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. 

139 

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 

146 

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)) 

156 

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 

171 

172 

173def probe_chrome_port(container_name: str, port: int) -> bool: 

174 """Check whether a Chrome debug port is reachable from the container. 

175 

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 

197 

198 

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() 

210 

211 

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)) 

227 

228 

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. 

236 

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. 

239 

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) 

244 

245 if probe_chrome_port(container_name, port): 

246 return port 

247 

248 logger.info("Chrome for project %s not found on port %d, launching it", project_name, port) 

249 

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)) 

252 

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 ) 

265 

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 

274 

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 ) 

281 

282 

283def start_chrome_proxy(container_name: str, port: int) -> None: 

284 """Start a TCP proxy inside the container: localhost:<port> -> host.docker.internal:<port>. 

285 

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``. 

289 

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 ) 

310 

311 

312def write_mcp_config(port: int, config_dir: Path | None = None) -> Path: 

313 """Write the chrome-devtools-mcp server config JSON. 

314 

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. 

318 

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) 

325 

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 } 

339 

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