+ Connect to a remote Chrome browser by providing the DevTools Protocol host address.
+ Roo will automatically fetch the WebSocket endpoint from this address. If provided,
+ Roo will use this browser instead of launching a local one. Leave empty to use the
+ built-in browser.
+
+
)}
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx
index 7cafbd6663d..0b8cce9b877 100644
--- a/webview-ui/src/components/settings/SettingsView.tsx
+++ b/webview-ui/src/components/settings/SettingsView.tsx
@@ -77,6 +77,7 @@ const SettingsView = forwardRef(({ onDone },
mcpEnabled,
rateLimitSeconds,
requestDelaySeconds,
+ remoteBrowserHost,
screenshotQuality,
soundEnabled,
soundVolume,
@@ -172,6 +173,7 @@ const SettingsView = forwardRef(({ 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: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 })
@@ -378,6 +380,7 @@ const SettingsView = forwardRef(({ onDone },
browserToolEnabled={browserToolEnabled}
browserViewportSize={browserViewportSize}
screenshotQuality={screenshotQuality}
+ remoteBrowserHost={remoteBrowserHost}
setCachedStateField={setCachedStateField}
/>
From 66e3b9610c3ee8658e2d0c2d6cfa3cb8fd5bd40b Mon Sep 17 00:00:00 2001
From: Afshawn Lotfi
Date: Mon, 10 Mar 2025 03:41:38 +0000
Subject: [PATCH 2/4] Add remote browser connection support and related state
management
---
src/core/webview/ClineProvider.ts | 100 +++++++
.../webview/__tests__/ClineProvider.test.ts | 223 +++++++++++++--
src/services/browser/BrowserSession.ts | 123 +++++++--
src/services/browser/browserDiscovery.ts | 253 ++++++++++++++++++
src/shared/ExtensionMessage.ts | 5 +
src/shared/WebviewMessage.ts | 4 +
src/shared/globalState.ts | 1 +
.../components/settings/BrowserSettings.tsx | 166 ++++++++++--
.../src/components/settings/SettingsView.tsx | 3 +
.../src/context/ExtensionStateContext.tsx | 3 +
10 files changed, 810 insertions(+), 71 deletions(-)
create mode 100644 src/services/browser/browserDiscovery.ts
diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts
index 3e8ed516687..9267682eb5d 100644
--- a/src/core/webview/ClineProvider.ts
+++ b/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"
@@ -1266,6 +1268,101 @@ export class ClineProvider implements vscode.WebviewViewProvider {
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()
@@ -2193,6 +2290,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
browserViewportSize,
screenshotQuality,
remoteBrowserHost,
+ remoteBrowserEnabled,
preferredLanguage,
writeDelayMs,
terminalOutputLimit,
@@ -2252,6 +2350,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
browserViewportSize: browserViewportSize ?? "900x600",
screenshotQuality: screenshotQuality ?? 75,
remoteBrowserHost,
+ remoteBrowserEnabled: remoteBrowserEnabled ?? false,
preferredLanguage: preferredLanguage ?? "English",
writeDelayMs: writeDelayMs ?? 1000,
terminalOutputLimit: terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT,
@@ -2406,6 +2505,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
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,
diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts
index f9fc5d3ece5..9a6c2c28f3f 100644
--- a/src/core/webview/__tests__/ClineProvider.test.ts
+++ b/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") {
@@ -1591,6 +1595,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", () => {
diff --git a/src/services/browser/BrowserSession.ts b/src/services/browser/BrowserSession.ts
index 6fe25ddf5b9..5c5f59ffebf 100644
--- a/src/services/browser/BrowserSession.ts
+++ b/src/services/browser/BrowserSession.ts
@@ -9,6 +9,7 @@ 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 }
@@ -20,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 {
const globalStoragePath = this.context?.globalStorageUri?.fsPath
if (!globalStoragePath) {
@@ -53,9 +63,59 @@ export class BrowserSession {
await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before
}
- const remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined
+ // 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
- if (remoteBrowserHost) {
+ // 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
@@ -63,7 +123,7 @@ export class BrowserSession {
console.log(`Fetching WebSocket endpoint from ${versionUrl}`)
const response = await axios.get(versionUrl)
- const browserWSEndpoint = response.data.webSocketDebuggerUrl
+ browserWSEndpoint = response.data.webSocketDebuggerUrl
if (!browserWSEndpoint) {
throw new Error("Could not find webSocketDebuggerUrl in the response")
@@ -71,34 +131,63 @@ export class BrowserSession {
console.log(`Found WebSocket endpoint: ${browserWSEndpoint}`)
+ // Cache the successful endpoint
+ this.cachedWebSocketEndpoint = browserWSEndpoint
+ this.lastConnectionAttempt = Date.now()
+
this.browser = await connect({
browserWSEndpoint,
- 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(),
})
this.page = await this.browser?.newPage()
return
} catch (error) {
console.error(`Failed to connect to remote browser: ${error}`)
- // Fall back to local browser if remote connection fails
+ // 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)
@@ -109,8 +198,8 @@ export class BrowserSession {
if (this.browser || this.page) {
console.log("closing browser...")
- const remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined
- if (remoteBrowserHost && this.browser) {
+ 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(() => {})
diff --git a/src/services/browser/browserDiscovery.ts b/src/services/browser/browserDiscovery.ts
new file mode 100644
index 00000000000..a29bab5b784
--- /dev/null
+++ b/src/services/browser/browserDiscovery.ts
@@ -0,0 +1,253 @@
+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 {
+ 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
+ }
+}
+
+/**
+ * Get Docker gateway IP
+ */
+export async function getDockerGatewayIP(): Promise {
+ try {
+ // Try to get the default gateway from the route table
+ if (process.platform === "linux") {
+ try {
+ const { stdout } = await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: "Checking Docker gateway IP",
+ cancellable: false,
+ },
+ async () => {
+ const result = await new Promise<{ stdout: string; stderr: string }>((resolve) => {
+ const cp = require("child_process")
+ cp.exec(
+ "ip route | grep default | awk '{print $3}'",
+ (err: any, stdout: string, stderr: string) => {
+ resolve({ stdout, stderr })
+ },
+ )
+ })
+ return result
+ },
+ )
+ 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 {
+ 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 {
+ 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 {
+ // 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
+ 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)}`,
+ }
+ }
+}
diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts
index 5b638e6534e..5e95c0f68ec 100644
--- a/src/shared/ExtensionMessage.ts
+++ b/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
}
export interface ApiConfigMeta {
@@ -124,6 +128,7 @@ export interface ExtensionState {
browserViewportSize?: string
screenshotQuality?: number
remoteBrowserHost?: string
+ remoteBrowserEnabled?: boolean
fuzzyMatchThreshold?: number
preferredLanguage: string
writeDelayMs: number
diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts
index df5c7d29f91..79bf5c74054 100644
--- a/src/shared/WebviewMessage.ts
+++ b/src/shared/WebviewMessage.ts
@@ -102,6 +102,10 @@ export interface WebviewMessage {
| "browserToolEnabled"
| "telemetrySetting"
| "showRooIgnoredFiles"
+ | "testBrowserConnection"
+ | "discoverBrowser"
+ | "browserConnectionResult"
+ | "remoteBrowserEnabled"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts
index 05f54bfb8b6..5f4b216f6d7 100644
--- a/src/shared/globalState.ts
+++ b/src/shared/globalState.ts
@@ -100,6 +100,7 @@ export const GLOBAL_STATE_KEYS = [
"lmStudioDraftModelId",
"telemetrySetting",
"showRooIgnoredFiles",
+ "remoteBrowserEnabled",
] as const
// Derive the type from the array - creates a union of string literals
diff --git a/webview-ui/src/components/settings/BrowserSettings.tsx b/webview-ui/src/components/settings/BrowserSettings.tsx
index ab4a88113a0..5c20a632c6d 100644
--- a/webview-ui/src/components/settings/BrowserSettings.tsx
+++ b/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 } from "@vscode/webview-ui-toolkit/react"
import { Dropdown, type DropdownOption } from "vscrui"
import { SquareMousePointer } from "lucide-react"
@@ -7,14 +7,20 @@ import { SetCachedStateField } from "./types"
import { sliderLabelStyle } from "./styles"
import { SectionHeader } from "./SectionHeader"
import { Section } from "./Section"
+import { vscode } from "../../utils/vscode"
type BrowserSettingsProps = HTMLAttributes & {
browserToolEnabled?: boolean
browserViewportSize?: string
screenshotQuality?: number
remoteBrowserHost?: string
+ remoteBrowserEnabled?: boolean
setCachedStateField: SetCachedStateField<
- "browserToolEnabled" | "browserViewportSize" | "screenshotQuality" | "remoteBrowserHost"
+ | "browserToolEnabled"
+ | "browserViewportSize"
+ | "screenshotQuality"
+ | "remoteBrowserHost"
+ | "remoteBrowserEnabled"
>
}
@@ -23,9 +29,74 @@ export const BrowserSettings = ({
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 (
- Connect to a remote Chrome browser by providing the DevTools Protocol host address.
- Roo will automatically fetch the WebSocket endpoint from this address. If provided,
- Roo will use this browser instead of launching a local one. Leave empty to use the
- built-in browser.
-
+
+ {
+ // 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)
+ }
+ }}>
+ Use remote browser connection
+
+
+ Connect to a Chrome browser running with remote debugging enabled
+ (--remote-debugging-port=9222).
+
+ Enter the DevTools Protocol host address or leave empty to auto-discover
+ Chrome instances on your network. The Test Connection button will try the
+ custom URL if provided, or auto-discover if the field is empty.
+
- Enter the DevTools Protocol host address or leave empty to auto-discover
- Chrome instances on your network. The Test Connection button will try the
- custom URL if provided, or auto-discover if the field is empty.
+
+ Enter the DevTools Protocol host address or
+ leave empty to auto-discover Chrome local instances.
+ The Test Connection button will try the custom URL if provided, or
+ auto-discover if the field is empty.
>
)}
From ce3e22ecfee6676ec78d4ae63c769c666e2fbf3f Mon Sep 17 00:00:00 2001
From: Afshawn Lotfi
Date: Mon, 10 Mar 2025 21:40:08 +0000
Subject: [PATCH 4/4] Refactor Docker gateway IP retrieval to use a dedicated
shell command execution function
---
src/services/browser/browserDiscovery.ts | 37 ++++++++++--------------
1 file changed, 15 insertions(+), 22 deletions(-)
diff --git a/src/services/browser/browserDiscovery.ts b/src/services/browser/browserDiscovery.ts
index a29bab5b784..187f90e2994 100644
--- a/src/services/browser/browserDiscovery.ts
+++ b/src/services/browser/browserDiscovery.ts
@@ -55,32 +55,25 @@ export async function tryConnect(ipAddress: string): Promise<{ endpoint: string;
}
/**
- * Get Docker gateway IP
+ * 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 {
try {
- // Try to get the default gateway from the route table
if (process.platform === "linux") {
try {
- const { stdout } = await vscode.window.withProgress(
- {
- location: vscode.ProgressLocation.Notification,
- title: "Checking Docker gateway IP",
- cancellable: false,
- },
- async () => {
- const result = await new Promise<{ stdout: string; stderr: string }>((resolve) => {
- const cp = require("child_process")
- cp.exec(
- "ip route | grep default | awk '{print $3}'",
- (err: any, stdout: string, stderr: string) => {
- resolve({ stdout, stderr })
- },
- )
- })
- return result
- },
- )
+ 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)
@@ -159,7 +152,7 @@ export async function discoverChromeInstances(): Promise {
ipAddresses.push("localhost")
ipAddresses.push("127.0.0.1")
- // Try to get Docker gateway IP
+ // Try to get Docker gateway IP (headless mode)
const gatewayIP = await getDockerGatewayIP()
if (gatewayIP) {
console.log("Found Docker gateway IP:", gatewayIP)