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

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 socket 

22import subprocess 

23import time 

24from pathlib import Path 

25 

26logger = logging.getLogger(__name__) 

27 

28CHROME_DEBUG_HOST = "host.docker.internal" 

29DEFAULT_CHROME_DEBUG_PORT = 9222 

30 

31MCP_CONFIG_FILENAME = "chrome-mcp.json" 

32 

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" 

36 

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] 

42 

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) 

52 

53SETUP_INSTRUCTIONS = """\ 

54Chrome could not be found or launched automatically, and the debug port \ 

55is not reachable. 

56 

57To fix, launch Chrome manually with these flags: 

58 

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" 

63 

64Then re-run this command. 

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

66 

67 

68class LocalChromeUnavailable(Exception): 

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

70 

71 

72def find_chrome() -> str | None: 

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

74 

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 

90 

91 

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) 

98 

99 

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 

106 

107 

108def launch_chrome(port: int) -> bool: 

109 """Launch Chrome on the host with the debug port enabled. 

110 

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 

117 

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

127 

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 

142 

143 

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

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

146 

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 

168 

169 

170def ensure_host_chrome(container_name: str) -> int: 

171 """Ensure Chrome is running with a debug port reachable from the container. 

172 

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. 

176 

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 

182 

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 ) 

188 

189 if not launch_chrome(port): 

190 raise LocalChromeUnavailable(SETUP_INSTRUCTIONS) 

191 

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 

198 

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 ) 

204 

205 

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

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

208 

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

212 

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 ) 

233 

234 

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

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

237 

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. 

241 

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) 

248 

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 } 

262 

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