Просмотр исходного кода

Merge pull request #1528 from afshawnlotfi/chrome-remote-webtools

Chrome remote webtools
Matt Rubens 9 месяцев назад
Родитель
Сommit
6cfc1cd4c9

+ 107 - 0
src/core/webview/ClineProvider.ts

@@ -30,6 +30,8 @@ import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
 import { McpHub } from "../../services/mcp/McpHub"
 import { McpServerManager } from "../../services/mcp/McpServerManager"
 import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
+import { BrowserSession } from "../../services/browser/BrowserSession"
+import { discoverChromeInstances } from "../../services/browser/browserDiscovery"
 import { fileExistsAtPath } from "../../utils/fs"
 import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
 import { singleCompletionHandler } from "../../utils/single-completion-handler"
@@ -1262,6 +1264,105 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 						await this.updateGlobalState("browserViewportSize", browserViewportSize)
 						await this.postStateToWebview()
 						break
+					case "remoteBrowserHost":
+						await this.updateGlobalState("remoteBrowserHost", message.text)
+						await this.postStateToWebview()
+						break
+					case "remoteBrowserEnabled":
+						// Store the preference in global state
+						// remoteBrowserEnabled now means "enable remote browser connection"
+						await this.updateGlobalState("remoteBrowserEnabled", message.bool ?? false)
+						// If disabling remote browser connection, clear the remoteBrowserHost
+						if (!message.bool) {
+							await this.updateGlobalState("remoteBrowserHost", undefined)
+						}
+						await this.postStateToWebview()
+						break
+					case "testBrowserConnection":
+						try {
+							const browserSession = new BrowserSession(this.context)
+							// If no text is provided, try auto-discovery
+							if (!message.text) {
+								try {
+									const discoveredHost = await discoverChromeInstances()
+									if (discoveredHost) {
+										// Test the connection to the discovered host
+										const result = await browserSession.testConnection(discoveredHost)
+										// Send the result back to the webview
+										await this.postMessageToWebview({
+											type: "browserConnectionResult",
+											success: result.success,
+											text: `Auto-discovered and tested connection to Chrome at ${discoveredHost}: ${result.message}`,
+											values: { endpoint: result.endpoint },
+										})
+									} else {
+										await this.postMessageToWebview({
+											type: "browserConnectionResult",
+											success: false,
+											text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
+										})
+									}
+								} catch (error) {
+									await this.postMessageToWebview({
+										type: "browserConnectionResult",
+										success: false,
+										text: `Error during auto-discovery: ${error instanceof Error ? error.message : String(error)}`,
+									})
+								}
+							} else {
+								// Test the provided URL
+								const result = await browserSession.testConnection(message.text)
+
+								// Send the result back to the webview
+								await this.postMessageToWebview({
+									type: "browserConnectionResult",
+									success: result.success,
+									text: result.message,
+									values: { endpoint: result.endpoint },
+								})
+							}
+						} catch (error) {
+							await this.postMessageToWebview({
+								type: "browserConnectionResult",
+								success: false,
+								text: `Error testing connection: ${error instanceof Error ? error.message : String(error)}`,
+							})
+						}
+						break
+					case "discoverBrowser":
+						try {
+							const discoveredHost = await discoverChromeInstances()
+
+							if (discoveredHost) {
+								// Don't update the remoteBrowserHost state when auto-discovering
+								// This way we don't override the user's preference
+
+								// Test the connection to get the endpoint
+								const browserSession = new BrowserSession(this.context)
+								const result = await browserSession.testConnection(discoveredHost)
+
+								// Send the result back to the webview
+								await this.postMessageToWebview({
+									type: "browserConnectionResult",
+									success: true,
+									text: `Successfully discovered and connected to Chrome at ${discoveredHost}`,
+									values: { endpoint: result.endpoint },
+								})
+							} else {
+								await this.postMessageToWebview({
+									type: "browserConnectionResult",
+									success: false,
+									text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
+								})
+							}
+						} catch (error) {
+							await this.postMessageToWebview({
+								type: "browserConnectionResult",
+								success: false,
+								text: `Error discovering browser: ${error instanceof Error ? error.message : String(error)}`,
+							})
+						}
+						break
 					case "fuzzyMatchThreshold":
 						await this.updateGlobalState("fuzzyMatchThreshold", message.value)
 						await this.postStateToWebview()
@@ -2197,6 +2298,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			soundVolume,
 			browserViewportSize,
 			screenshotQuality,
+			remoteBrowserHost,
+			remoteBrowserEnabled,
 			preferredLanguage,
 			writeDelayMs,
 			terminalOutputLimit,
@@ -2255,6 +2358,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			soundVolume: soundVolume ?? 0.5,
 			browserViewportSize: browserViewportSize ?? "900x600",
 			screenshotQuality: screenshotQuality ?? 75,
+			remoteBrowserHost,
+			remoteBrowserEnabled: remoteBrowserEnabled ?? false,
 			preferredLanguage: preferredLanguage ?? "English",
 			writeDelayMs: writeDelayMs ?? 1000,
 			terminalOutputLimit: terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT,
@@ -2408,6 +2513,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			soundVolume: stateValues.soundVolume,
 			browserViewportSize: stateValues.browserViewportSize ?? "900x600",
 			screenshotQuality: stateValues.screenshotQuality ?? 75,
+			remoteBrowserHost: stateValues.remoteBrowserHost,
+			remoteBrowserEnabled: stateValues.remoteBrowserEnabled ?? false,
 			fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0,
 			writeDelayMs: stateValues.writeDelayMs ?? 1000,
 			terminalOutputLimit: stateValues.terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT,

+ 197 - 26
src/core/webview/__tests__/ClineProvider.test.ts

@@ -55,6 +55,34 @@ jest.mock("../../contextProxy", () => {
 // Mock dependencies
 jest.mock("vscode")
 jest.mock("delay")
+
+// Mock BrowserSession
+jest.mock("../../../services/browser/BrowserSession", () => ({
+	BrowserSession: jest.fn().mockImplementation(() => ({
+		testConnection: jest.fn().mockImplementation(async (url) => {
+			if (url === "http://localhost:9222") {
+				return {
+					success: true,
+					message: "Successfully connected to Chrome",
+					endpoint: "ws://localhost:9222/devtools/browser/123",
+				}
+			} else {
+				return {
+					success: false,
+					message: "Failed to connect to Chrome",
+					endpoint: undefined,
+				}
+			}
+		}),
+	})),
+}))
+
+// Mock browserDiscovery
+jest.mock("../../../services/browser/browserDiscovery", () => ({
+	discoverChromeInstances: jest.fn().mockImplementation(async () => {
+		return "http://localhost:9222"
+	}),
+}))
 jest.mock(
 	"@modelcontextprotocol/sdk/types.js",
 	() => ({
@@ -94,31 +122,7 @@ jest.mock("delay", () => {
 	return delayFn
 })
 
-// Mock MCP-related modules
-jest.mock(
-	"@modelcontextprotocol/sdk/types.js",
-	() => ({
-		CallToolResultSchema: {},
-		ListResourcesResultSchema: {},
-		ListResourceTemplatesResultSchema: {},
-		ListToolsResultSchema: {},
-		ReadResourceResultSchema: {},
-		ErrorCode: {
-			InvalidRequest: "InvalidRequest",
-			MethodNotFound: "MethodNotFound",
-			InternalError: "InternalError",
-		},
-		McpError: class McpError extends Error {
-			code: string
-			constructor(code: string, message: string) {
-				super(message)
-				this.code = code
-				this.name = "McpError"
-			}
-		},
-	}),
-	{ virtual: true },
-)
+// MCP-related modules are mocked once above (lines 87-109)
 
 jest.mock(
 	"@modelcontextprotocol/sdk/client/index.js",
@@ -598,7 +602,7 @@ describe("ClineProvider", () => {
 		expect(mockPostMessage).toHaveBeenCalled()
 	})
 
-	test("requestDelaySeconds defaults to 5 seconds", async () => {
+	test("requestDelaySeconds defaults to 10 seconds", async () => {
 		// Mock globalState.get to return undefined for requestDelaySeconds
 		;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => {
 			if (key === "requestDelaySeconds") {
@@ -1781,6 +1785,173 @@ describe("ClineProvider", () => {
 			])
 		})
 	})
+
+	describe("browser connection features", () => {
+		beforeEach(async () => {
+			// Reset mocks
+			jest.clearAllMocks()
+			await provider.resolveWebviewView(mockWebviewView)
+		})
+
+		// Mock BrowserSession and discoverChromeInstances
+		jest.mock("../../../services/browser/BrowserSession", () => ({
+			BrowserSession: jest.fn().mockImplementation(() => ({
+				testConnection: jest.fn().mockImplementation(async (url) => {
+					if (url === "http://localhost:9222") {
+						return {
+							success: true,
+							message: "Successfully connected to Chrome",
+							endpoint: "ws://localhost:9222/devtools/browser/123",
+						}
+					} else {
+						return {
+							success: false,
+							message: "Failed to connect to Chrome",
+							endpoint: undefined,
+						}
+					}
+				}),
+			})),
+		}))
+
+		jest.mock("../../../services/browser/browserDiscovery", () => ({
+			discoverChromeInstances: jest.fn().mockImplementation(async () => {
+				return "http://localhost:9222"
+			}),
+		}))
+
+		test("handles testBrowserConnection with provided URL", async () => {
+			// Get the message handler
+			const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+
+			// Test with valid URL
+			await messageHandler({
+				type: "testBrowserConnection",
+				text: "http://localhost:9222",
+			})
+
+			// Verify postMessage was called with success result
+			expect(mockPostMessage).toHaveBeenCalledWith(
+				expect.objectContaining({
+					type: "browserConnectionResult",
+					success: true,
+					text: expect.stringContaining("Successfully connected to Chrome"),
+				}),
+			)
+
+			// Reset mock
+			mockPostMessage.mockClear()
+
+			// Test with invalid URL
+			await messageHandler({
+				type: "testBrowserConnection",
+				text: "http://inlocalhost:9222",
+			})
+
+			// Verify postMessage was called with failure result
+			expect(mockPostMessage).toHaveBeenCalledWith(
+				expect.objectContaining({
+					type: "browserConnectionResult",
+					success: false,
+					text: expect.stringContaining("Failed to connect to Chrome"),
+				}),
+			)
+		})
+
+		test("handles testBrowserConnection with auto-discovery", async () => {
+			// Get the message handler
+			const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+
+			// Test auto-discovery (no URL provided)
+			await messageHandler({
+				type: "testBrowserConnection",
+			})
+
+			// Verify discoverChromeInstances was called
+			const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery")
+			expect(discoverChromeInstances).toHaveBeenCalled()
+
+			// Verify postMessage was called with success result
+			expect(mockPostMessage).toHaveBeenCalledWith(
+				expect.objectContaining({
+					type: "browserConnectionResult",
+					success: true,
+					text: expect.stringContaining("Auto-discovered and tested connection to Chrome"),
+				}),
+			)
+		})
+
+		test("handles discoverBrowser message", async () => {
+			// Get the message handler
+			const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+
+			// Test browser discovery
+			await messageHandler({
+				type: "discoverBrowser",
+			})
+
+			// Verify discoverChromeInstances was called
+			const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery")
+			expect(discoverChromeInstances).toHaveBeenCalled()
+
+			// Verify postMessage was called with success result
+			expect(mockPostMessage).toHaveBeenCalledWith(
+				expect.objectContaining({
+					type: "browserConnectionResult",
+					success: true,
+					text: expect.stringContaining("Successfully discovered and connected to Chrome"),
+				}),
+			)
+		})
+
+		test("handles errors during browser discovery", async () => {
+			// Mock discoverChromeInstances to throw an error
+			const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery")
+			discoverChromeInstances.mockImplementationOnce(() => {
+				throw new Error("Discovery error")
+			})
+
+			// Get the message handler
+			const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+
+			// Test browser discovery with error
+			await messageHandler({
+				type: "discoverBrowser",
+			})
+
+			// Verify postMessage was called with error result
+			expect(mockPostMessage).toHaveBeenCalledWith(
+				expect.objectContaining({
+					type: "browserConnectionResult",
+					success: false,
+					text: expect.stringContaining("Error discovering browser"),
+				}),
+			)
+		})
+
+		test("handles case when no browsers are discovered", async () => {
+			// Mock discoverChromeInstances to return null (no browsers found)
+			const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery")
+			discoverChromeInstances.mockImplementationOnce(() => null)
+
+			// Get the message handler
+			const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+
+			// Test browser discovery with no browsers found
+			await messageHandler({
+				type: "discoverBrowser",
+			})
+
+			// Verify postMessage was called with failure result
+			expect(mockPostMessage).toHaveBeenCalledWith(
+				expect.objectContaining({
+					type: "browserConnectionResult",
+					success: false,
+					text: expect.stringContaining("No Chrome instances found"),
+				}),
+			)
+		})
+	})
 })
 
 describe("ContextProxy integration", () => {

+ 139 - 7
src/services/browser/BrowserSession.ts

@@ -1,13 +1,15 @@
 import * as vscode from "vscode"
 import * as fs from "fs/promises"
 import * as path from "path"
-import { Browser, Page, ScreenshotOptions, TimeoutError, launch } from "puppeteer-core"
+import { Browser, Page, ScreenshotOptions, TimeoutError, launch, connect } from "puppeteer-core"
 // @ts-ignore
 import PCR from "puppeteer-chromium-resolver"
 import pWaitFor from "p-wait-for"
 import delay from "delay"
+import axios from "axios"
 import { fileExistsAtPath } from "../../utils/fs"
 import { BrowserActionResult } from "../../shared/ExtensionMessage"
+import { discoverChromeInstances, testBrowserConnection } from "./browserDiscovery"
 
 interface PCRStats {
 	puppeteer: { launch: typeof launch }
@@ -19,11 +21,20 @@ export class BrowserSession {
 	private browser?: Browser
 	private page?: Page
 	private currentMousePosition?: string
+	private cachedWebSocketEndpoint?: string
+	private lastConnectionAttempt: number = 0
 
 	constructor(context: vscode.ExtensionContext) {
 		this.context = context
 	}
 
+	/**
+	 * Test connection to a remote browser
+	 */
+	async testConnection(host: string): Promise<{ success: boolean; message: string; endpoint?: string }> {
+		return testBrowserConnection(host)
+	}
+
 	private async ensureChromiumExists(): Promise<PCRStats> {
 		const globalStoragePath = this.context?.globalStorageUri?.fsPath
 		if (!globalStoragePath) {
@@ -52,17 +63,131 @@ export class BrowserSession {
 			await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before
 		}
 
+		// Function to get viewport size
+		const getViewport = () => {
+			const size = (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600"
+			const [width, height] = size.split("x").map(Number)
+			return { width, height }
+		}
+
+		// Check if remote browser connection is enabled
+		const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as boolean | undefined
+
+		// If remote browser connection is not enabled, use local browser
+		if (!remoteBrowserEnabled) {
+			console.log("Remote browser connection is disabled, using local browser")
+			const stats = await this.ensureChromiumExists()
+			this.browser = await stats.puppeteer.launch({
+				args: [
+					"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
+				],
+				executablePath: stats.executablePath,
+				defaultViewport: getViewport(),
+				// headless: false,
+			})
+			this.page = await this.browser?.newPage()
+			return
+		}
+		// Remote browser connection is enabled
+		let remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined
+		let browserWSEndpoint: string | undefined = this.cachedWebSocketEndpoint
+		let reconnectionAttempted = false
+
+		// Try to connect with cached endpoint first if it exists and is recent (less than 1 hour old)
+		if (browserWSEndpoint && Date.now() - this.lastConnectionAttempt < 3600000) {
+			try {
+				console.log(`Attempting to connect using cached WebSocket endpoint: ${browserWSEndpoint}`)
+				this.browser = await connect({
+					browserWSEndpoint,
+					defaultViewport: getViewport(),
+				})
+				this.page = await this.browser?.newPage()
+				return
+			} catch (error) {
+				console.log(`Failed to connect using cached endpoint: ${error}`)
+				// Clear the cached endpoint since it's no longer valid
+				this.cachedWebSocketEndpoint = undefined
+				// User wants to give up after one reconnection attempt
+				if (remoteBrowserHost) {
+					reconnectionAttempted = true
+				}
+			}
+		}
+
+		// If user provided a remote browser host, try to connect to it
+		if (remoteBrowserHost && !reconnectionAttempted) {
+			console.log(`Attempting to connect to remote browser at ${remoteBrowserHost}`)
+			try {
+				// Fetch the WebSocket endpoint from the Chrome DevTools Protocol
+				const versionUrl = `${remoteBrowserHost.replace(/\/$/, "")}/json/version`
+				console.log(`Fetching WebSocket endpoint from ${versionUrl}`)
+
+				const response = await axios.get(versionUrl)
+				browserWSEndpoint = response.data.webSocketDebuggerUrl
+
+				if (!browserWSEndpoint) {
+					throw new Error("Could not find webSocketDebuggerUrl in the response")
+				}
+
+				console.log(`Found WebSocket endpoint: ${browserWSEndpoint}`)
+
+				// Cache the successful endpoint
+				this.cachedWebSocketEndpoint = browserWSEndpoint
+				this.lastConnectionAttempt = Date.now()
+
+				this.browser = await connect({
+					browserWSEndpoint,
+					defaultViewport: getViewport(),
+				})
+				this.page = await this.browser?.newPage()
+				return
+			} catch (error) {
+				console.error(`Failed to connect to remote browser: ${error}`)
+				// Fall back to auto-discovery if remote connection fails
+			}
+		}
+
+		// Always try auto-discovery if no custom URL is specified or if connection failed
+		try {
+			console.log("Attempting auto-discovery...")
+			const discoveredHost = await discoverChromeInstances()
+
+			if (discoveredHost) {
+				console.log(`Auto-discovered Chrome at ${discoveredHost}`)
+
+				// Don't save the discovered host to global state to avoid overriding user preference
+				// We'll just use it for this session
+
+				// Try to connect to the discovered host
+				const testResult = await testBrowserConnection(discoveredHost)
+
+				if (testResult.success && testResult.endpoint) {
+					// Cache the successful endpoint
+					this.cachedWebSocketEndpoint = testResult.endpoint
+					this.lastConnectionAttempt = Date.now()
+
+					this.browser = await connect({
+						browserWSEndpoint: testResult.endpoint,
+						defaultViewport: getViewport(),
+					})
+					this.page = await this.browser?.newPage()
+					return
+				}
+			}
+		} catch (error) {
+			console.error(`Auto-discovery failed: ${error}`)
+			// Fall back to local browser if auto-discovery fails
+		}
+
+		// If all remote connection attempts fail, fall back to local browser
+		console.log("Falling back to local browser")
 		const stats = await this.ensureChromiumExists()
 		this.browser = await stats.puppeteer.launch({
 			args: [
 				"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
 			],
 			executablePath: stats.executablePath,
-			defaultViewport: (() => {
-				const size = (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600"
-				const [width, height] = size.split("x").map(Number)
-				return { width, height }
-			})(),
+			defaultViewport: getViewport(),
 			// headless: false,
 		})
 		// (latest version of puppeteer does not add headless to user agent)
@@ -72,7 +197,14 @@ export class BrowserSession {
 	async closeBrowser(): Promise<BrowserActionResult> {
 		if (this.browser || this.page) {
 			console.log("closing browser...")
-			await this.browser?.close().catch(() => {})
+
+			const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as string | undefined
+			if (remoteBrowserEnabled && this.browser) {
+				await this.browser.disconnect().catch(() => {})
+			} else {
+				await this.browser?.close().catch(() => {})
+			}
+
 			this.browser = undefined
 			this.page = undefined
 			this.currentMousePosition = undefined

+ 246 - 0
src/services/browser/browserDiscovery.ts

@@ -0,0 +1,246 @@
+import * as vscode from "vscode"
+import * as os from "os"
+import * as net from "net"
+import axios from "axios"
+
+/**
+ * Check if a port is open on a given host
+ */
+export async function isPortOpen(host: string, port: number, timeout = 1000): Promise<boolean> {
+	return new Promise((resolve) => {
+		const socket = new net.Socket()
+		let status = false
+
+		// Set timeout
+		socket.setTimeout(timeout)
+
+		// Handle successful connection
+		socket.on("connect", () => {
+			status = true
+			socket.destroy()
+		})
+
+		// Handle any errors
+		socket.on("error", () => {
+			socket.destroy()
+		})
+
+		// Handle timeout
+		socket.on("timeout", () => {
+			socket.destroy()
+		})
+
+		// Handle close
+		socket.on("close", () => {
+			resolve(status)
+		})
+
+		// Attempt to connect
+		socket.connect(port, host)
+	})
+}
+
+/**
+ * Try to connect to Chrome at a specific IP address
+ */
+export async function tryConnect(ipAddress: string): Promise<{ endpoint: string; ip: string } | null> {
+	try {
+		console.log(`Trying to connect to Chrome at: http://${ipAddress}:9222/json/version`)
+		const response = await axios.get(`http://${ipAddress}:9222/json/version`, { timeout: 1000 })
+		const data = response.data
+		return { endpoint: data.webSocketDebuggerUrl, ip: ipAddress }
+	} catch (error) {
+		return null
+	}
+}
+
+/**
+ * Execute a shell command and return stdout and stderr
+ */
+export async function executeShellCommand(command: string): Promise<{ stdout: string; stderr: string }> {
+	return new Promise<{ stdout: string; stderr: string }>((resolve) => {
+		const cp = require("child_process")
+		cp.exec(command, (err: any, stdout: string, stderr: string) => {
+			resolve({ stdout, stderr })
+		})
+	})
+}
+
+/**
+ * Get Docker gateway IP without UI feedback
+ */
+export async function getDockerGatewayIP(): Promise<string | null> {
+	try {
+		if (process.platform === "linux") {
+			try {
+				const { stdout } = await executeShellCommand("ip route | grep default | awk '{print $3}'")
+				return stdout.trim()
+			} catch (error) {
+				console.log("Could not determine Docker gateway IP:", error)
+			}
+		}
+		return null
+	} catch (error) {
+		console.log("Could not determine Docker gateway IP:", error)
+		return null
+	}
+}
+
+/**
+ * Get Docker host IP
+ */
+export async function getDockerHostIP(): Promise<string | null> {
+	try {
+		// Try to resolve host.docker.internal (works on Docker Desktop)
+		return new Promise((resolve) => {
+			const dns = require("dns")
+			dns.lookup("host.docker.internal", (err: any, address: string) => {
+				if (err) {
+					resolve(null)
+				} else {
+					resolve(address)
+				}
+			})
+		})
+	} catch (error) {
+		console.log("Could not determine Docker host IP:", error)
+		return null
+	}
+}
+
+/**
+ * Scan a network range for Chrome debugging port
+ */
+export async function scanNetworkForChrome(baseIP: string): Promise<string | null> {
+	if (!baseIP || !baseIP.match(/^\d+\.\d+\.\d+\./)) {
+		return null
+	}
+
+	// Extract the network prefix (e.g., "192.168.65.")
+	const networkPrefix = baseIP.split(".").slice(0, 3).join(".") + "."
+
+	// Common Docker host IPs to try first
+	const priorityIPs = [
+		networkPrefix + "1", // Common gateway
+		networkPrefix + "2", // Common host
+		networkPrefix + "254", // Common host in some Docker setups
+	]
+
+	console.log(`Scanning priority IPs in network ${networkPrefix}*`)
+
+	// Check priority IPs first
+	for (const ip of priorityIPs) {
+		const isOpen = await isPortOpen(ip, 9222)
+		if (isOpen) {
+			console.log(`Found Chrome debugging port open on ${ip}`)
+			return ip
+		}
+	}
+
+	return null
+}
+
+/**
+ * Discover Chrome instances on the network
+ */
+export async function discoverChromeInstances(): Promise<string | null> {
+	// Get all network interfaces
+	const networkInterfaces = os.networkInterfaces()
+	const ipAddresses = []
+
+	// Always try localhost first
+	ipAddresses.push("localhost")
+	ipAddresses.push("127.0.0.1")
+
+	// Try to get Docker gateway IP (headless mode)
+	const gatewayIP = await getDockerGatewayIP()
+	if (gatewayIP) {
+		console.log("Found Docker gateway IP:", gatewayIP)
+		ipAddresses.push(gatewayIP)
+	}
+
+	// Try to get Docker host IP
+	const hostIP = await getDockerHostIP()
+	if (hostIP) {
+		console.log("Found Docker host IP:", hostIP)
+		ipAddresses.push(hostIP)
+	}
+
+	// Add all local IP addresses from network interfaces
+	const localIPs: string[] = []
+	Object.values(networkInterfaces).forEach((interfaces) => {
+		if (!interfaces) return
+		interfaces.forEach((iface) => {
+			// Only consider IPv4 addresses
+			if (iface.family === "IPv4" || iface.family === (4 as any)) {
+				localIPs.push(iface.address)
+			}
+		})
+	})
+
+	// Add local IPs to the list
+	ipAddresses.push(...localIPs)
+
+	// Scan network for Chrome debugging port
+	for (const ip of localIPs) {
+		const chromeIP = await scanNetworkForChrome(ip)
+		if (chromeIP && !ipAddresses.includes(chromeIP)) {
+			console.log("Found potential Chrome host via network scan:", chromeIP)
+			ipAddresses.push(chromeIP)
+		}
+	}
+
+	// Remove duplicates
+	const uniqueIPs = [...new Set(ipAddresses)]
+	console.log("IP Addresses to try:", uniqueIPs)
+
+	// Try connecting to each IP address
+	for (const ip of uniqueIPs) {
+		const connection = await tryConnect(ip)
+		if (connection) {
+			console.log(`Successfully connected to Chrome at: ${connection.ip}`)
+			// Store the successful IP for future use
+			console.log(`✅ Found Chrome at ${connection.ip} - You can hardcode this IP if needed`)
+
+			// Return the host URL and endpoint
+			return `http://${connection.ip}:9222`
+		}
+	}
+
+	return null
+}
+
+/**
+ * Test connection to a remote browser
+ */
+export async function testBrowserConnection(
+	host: string,
+): Promise<{ success: boolean; message: string; endpoint?: string }> {
+	try {
+		// Fetch the WebSocket endpoint from the Chrome DevTools Protocol
+		const versionUrl = `${host.replace(/\/$/, "")}/json/version`
+		console.log(`Testing connection to ${versionUrl}`)
+
+		const response = await axios.get(versionUrl, { timeout: 3000 })
+		const browserWSEndpoint = response.data.webSocketDebuggerUrl
+
+		if (!browserWSEndpoint) {
+			return {
+				success: false,
+				message: "Could not find webSocketDebuggerUrl in the response",
+			}
+		}
+
+		return {
+			success: true,
+			message: "Successfully connected to Chrome browser",
+			endpoint: browserWSEndpoint,
+		}
+	} catch (error) {
+		console.error(`Failed to connect to remote browser: ${error}`)
+		return {
+			success: false,
+			message: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`,
+		}
+	}
+}

+ 6 - 0
src/shared/ExtensionMessage.ts

@@ -51,6 +51,8 @@ export interface ExtensionMessage {
 		| "humanRelayResponse"
 		| "humanRelayCancel"
 		| "browserToolEnabled"
+		| "browserConnectionResult"
+		| "remoteBrowserEnabled"
 	text?: string
 	action?:
 		| "chatButtonClicked"
@@ -83,6 +85,8 @@ export interface ExtensionMessage {
 	mode?: Mode
 	customMode?: ModeConfig
 	slug?: string
+	success?: boolean
+	values?: Record<string, any>
 }
 
 export interface ApiConfigMeta {
@@ -123,6 +127,8 @@ export interface ExtensionState {
 	checkpointStorage: CheckpointStorage
 	browserViewportSize?: string
 	screenshotQuality?: number
+	remoteBrowserHost?: string
+	remoteBrowserEnabled?: boolean
 	fuzzyMatchThreshold?: number
 	preferredLanguage: string
 	writeDelayMs: number

+ 5 - 0
src/shared/WebviewMessage.ts

@@ -57,6 +57,7 @@ export interface WebviewMessage {
 		| "checkpointStorage"
 		| "browserViewportSize"
 		| "screenshotQuality"
+		| "remoteBrowserHost"
 		| "openMcpSettings"
 		| "restartMcpServer"
 		| "toggleToolAlwaysAllow"
@@ -102,6 +103,10 @@ export interface WebviewMessage {
 		| "browserToolEnabled"
 		| "telemetrySetting"
 		| "showRooIgnoredFiles"
+		| "testBrowserConnection"
+		| "discoverBrowser"
+		| "browserConnectionResult"
+		| "remoteBrowserEnabled"
 	text?: string
 	disabled?: boolean
 	askResponse?: ClineAskResponse

+ 2 - 0
src/shared/globalState.ts

@@ -66,6 +66,7 @@ export const GLOBAL_STATE_KEYS = [
 	"checkpointStorage",
 	"browserViewportSize",
 	"screenshotQuality",
+	"remoteBrowserHost",
 	"fuzzyMatchThreshold",
 	"preferredLanguage", // Language setting for Cline's communication
 	"writeDelayMs",
@@ -100,6 +101,7 @@ export const GLOBAL_STATE_KEYS = [
 	"lmStudioDraftModelId",
 	"telemetrySetting",
 	"showRooIgnoredFiles",
+	"remoteBrowserEnabled",
 ] as const
 
 // Derive the type from the array - creates a union of string literals

+ 136 - 3
webview-ui/src/components/settings/BrowserSettings.tsx

@@ -1,5 +1,5 @@
-import { HTMLAttributes } from "react"
-import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
+import React, { HTMLAttributes, useState, useEffect } from "react"
+import { VSCodeButton, VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 import { Dropdown, type DropdownOption } from "vscrui"
 import { SquareMousePointer } from "lucide-react"
 
@@ -7,21 +7,96 @@ import { SetCachedStateField } from "./types"
 import { sliderLabelStyle } from "./styles"
 import { SectionHeader } from "./SectionHeader"
 import { Section } from "./Section"
+import { vscode } from "../../utils/vscode"
 
 type BrowserSettingsProps = HTMLAttributes<HTMLDivElement> & {
 	browserToolEnabled?: boolean
 	browserViewportSize?: string
 	screenshotQuality?: number
-	setCachedStateField: SetCachedStateField<"browserToolEnabled" | "browserViewportSize" | "screenshotQuality">
+	remoteBrowserHost?: string
+	remoteBrowserEnabled?: boolean
+	setCachedStateField: SetCachedStateField<
+		| "browserToolEnabled"
+		| "browserViewportSize"
+		| "screenshotQuality"
+		| "remoteBrowserHost"
+		| "remoteBrowserEnabled"
+	>
 }
 
 export const BrowserSettings = ({
 	browserToolEnabled,
 	browserViewportSize,
 	screenshotQuality,
+	remoteBrowserHost,
+	remoteBrowserEnabled,
 	setCachedStateField,
 	...props
 }: BrowserSettingsProps) => {
+	const [testingConnection, setTestingConnection] = useState(false)
+	const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
+	const [discovering, setDiscovering] = useState(false)
+	// We don't need a local state for useRemoteBrowser since we're using the enableRemoteBrowser prop directly
+	// This ensures the checkbox always reflects the current global state
+
+	// Set up message listener for browser connection results
+	useEffect(() => {
+		const handleMessage = (event: MessageEvent) => {
+			const message = event.data
+
+			if (message.type === "browserConnectionResult") {
+				setTestResult({
+					success: message.success,
+					message: message.text,
+				})
+				setTestingConnection(false)
+				setDiscovering(false)
+			}
+		}
+
+		window.addEventListener("message", handleMessage)
+
+		return () => {
+			window.removeEventListener("message", handleMessage)
+		}
+	}, [])
+
+	const testConnection = async () => {
+		setTestingConnection(true)
+		setTestResult(null)
+
+		try {
+			// Send a message to the extension to test the connection
+			vscode.postMessage({
+				type: "testBrowserConnection",
+				text: remoteBrowserHost,
+			})
+		} catch (error) {
+			setTestResult({
+				success: false,
+				message: `Error: ${error instanceof Error ? error.message : String(error)}`,
+			})
+			setTestingConnection(false)
+		}
+	}
+
+	const discoverBrowser = async () => {
+		setDiscovering(true)
+		setTestResult(null)
+
+		try {
+			// Send a message to the extension to discover Chrome instances
+			vscode.postMessage({
+				type: "discoverBrowser",
+			})
+		} catch (error) {
+			setTestResult({
+				success: false,
+				message: `Error: ${error instanceof Error ? error.message : String(error)}`,
+			})
+			setDiscovering(false)
+		}
+	}
 	return (
 		<div {...props}>
 			<SectionHeader>
@@ -96,6 +171,64 @@ export const BrowserSettings = ({
 									screenshots but increase token usage.
 								</p>
 							</div>
+							<div className="mt-4">
+								<div className="mb-2">
+									<VSCodeCheckbox
+										checked={remoteBrowserEnabled}
+										onChange={(e: any) => {
+											// Update the global state - remoteBrowserEnabled now means "enable remote browser connection"
+											setCachedStateField("remoteBrowserEnabled", e.target.checked)
+											if (!e.target.checked) {
+												// If disabling remote browser, clear the custom URL
+												setCachedStateField("remoteBrowserHost", undefined)
+											}
+										}}>
+										<span className="font-medium">Use remote browser connection</span>
+									</VSCodeCheckbox>
+									<p className="text-vscode-descriptionForeground text-sm mt-0 ml-6">
+										Connect to a Chrome browser running with remote debugging enabled
+										(--remote-debugging-port=9222).
+									</p>
+								</div>
+								{remoteBrowserEnabled && (
+									<>
+										<div style={{ display: "flex", gap: "5px", marginTop: "10px" }}>
+											<VSCodeTextField
+												value={remoteBrowserHost ?? ""}
+												onChange={(e: any) =>
+													setCachedStateField(
+														"remoteBrowserHost",
+														e.target.value || undefined,
+													)
+												}
+												placeholder="Custom URL (e.g., http://localhost:9222)"
+												style={{ flexGrow: 1 }}
+											/>
+											<VSCodeButton
+												disabled={testingConnection}
+												onClick={remoteBrowserHost ? testConnection : discoverBrowser}>
+												{testingConnection || discovering ? "Testing..." : "Test Connection"}
+											</VSCodeButton>
+										</div>
+										{testResult && (
+											<div
+												className={`p-2 mt-2 mb-2 rounded text-sm ${
+													testResult.success
+														? "bg-green-800/20 text-green-400"
+														: "bg-red-800/20 text-red-400"
+												}`}>
+												{testResult.message}
+											</div>
+										)}
+										<p className="text-vscode-descriptionForeground text-sm mt-2">
+											Enter the DevTools Protocol host address or
+											<strong> leave empty to auto-discover Chrome local instances.</strong>
+											The Test Connection button will try the custom URL if provided, or
+											auto-discover if the field is empty.
+										</p>
+									</>
+								)}
+							</div>
 						</div>
 					)}
 				</div>

+ 6 - 0
webview-ui/src/components/settings/SettingsView.tsx

@@ -78,6 +78,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 		mcpEnabled,
 		rateLimitSeconds,
 		requestDelaySeconds,
+		remoteBrowserHost,
 		screenshotQuality,
 		soundEnabled,
 		soundVolume,
@@ -85,6 +86,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 		terminalOutputLimit,
 		writeDelayMs,
 		showRooIgnoredFiles,
+		remoteBrowserEnabled,
 	} = cachedState
 
 	// Make sure apiConfiguration is initialized and managed by SettingsView.
@@ -173,6 +175,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 			vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints })
 			vscode.postMessage({ type: "checkpointStorage", text: checkpointStorage })
 			vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
+			vscode.postMessage({ type: "remoteBrowserHost", text: remoteBrowserHost })
+			vscode.postMessage({ type: "remoteBrowserEnabled", bool: remoteBrowserEnabled })
 			vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
 			vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
 			vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 })
@@ -367,6 +371,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 						browserToolEnabled={browserToolEnabled}
 						browserViewportSize={browserViewportSize}
 						screenshotQuality={screenshotQuality}
+						remoteBrowserHost={remoteBrowserHost}
+						remoteBrowserEnabled={remoteBrowserEnabled}
 						setCachedStateField={setCachedStateField}
 					/>
 				</div>

+ 3 - 0
webview-ui/src/context/ExtensionStateContext.tsx

@@ -75,6 +75,8 @@ export interface ExtensionStateContextType extends ExtensionState {
 	setCustomModes: (value: ModeConfig[]) => void
 	setMaxOpenTabsContext: (value: number) => void
 	setTelemetrySetting: (value: TelemetrySetting) => void
+	remoteBrowserEnabled?: boolean
+	setRemoteBrowserEnabled: (value: boolean) => void
 	machineId?: string
 }
 
@@ -286,6 +288,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		setBrowserToolEnabled: (value) => setState((prevState) => ({ ...prevState, browserToolEnabled: value })),
 		setTelemetrySetting: (value) => setState((prevState) => ({ ...prevState, telemetrySetting: value })),
 		setShowRooIgnoredFiles: (value) => setState((prevState) => ({ ...prevState, showRooIgnoredFiles: value })),
+		setRemoteBrowserEnabled: (value) => setState((prevState) => ({ ...prevState, remoteBrowserEnabled: value })),
 	}
 
 	return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>