/
outbreak
/
kilocode
Обзор
Документация
Войти
/
outbreak
/
kilocode
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/services/browser/BrowserSession.ts
910 строк
27 KB
Matt Rubens
Add a way to save screenshots from the browser tool (#9963)
10 дек 2025, 03:12
Не верифицирован
10 дек 2025, 03:12
721b02e
Код
Авторство
О чём код?
import * as vscode from "vscode" import * as fs from "fs/promises" import * as path from "path" import { Browser, Page, ScreenshotOptions, TimeoutError, launch, connect, KeyInput } from "puppeteer-core" // @ts-ignore import PCR from "puppeteer-chromium-resolver" import pWaitFor from "p-wait-for" import delay from "delay" import { fileExistsAtPath } from "../../utils/fs" import { BrowserActionResult } from "../../shared/ExtensionMessage" import { discoverChromeHostUrl, tryChromeHostUrl } from "./browserDiscovery" // Timeout constants const BROWSER_NAVIGATION_TIMEOUT = 15_000 // 15 seconds interface PCRStats { puppeteer: { launch: typeof launch } executablePath: string } export class BrowserSession { private context: vscode.ExtensionContext private browser?: Browser private page?: Page private currentMousePosition?: string private lastConnectionAttempt?: number private isUsingRemoteBrowser: boolean = false private onStateChange?: (isActive: boolean) => void // Track last known viewport to surface in environment details private lastViewportWidth?: number private lastViewportHeight?: number constructor(context: vscode.ExtensionContext, onStateChange?: (isActive: boolean) => void) { this.context = context this.onStateChange = onStateChange } private async ensureChromiumExists(): Promise<PCRStats> { const globalStoragePath = this.context?.globalStorageUri?.fsPath if (!globalStoragePath) { throw new Error("Global storage uri is invalid") } const puppeteerDir = path.join(globalStoragePath, "puppeteer") const dirExists = await fileExistsAtPath(puppeteerDir) if (!dirExists) { await fs.mkdir(puppeteerDir, { recursive: true }) } // if chromium doesn't exist, this will download it to path.join(puppeteerDir, ".chromium-browser-snapshots") // if it does exist it will return the path to existing chromium const stats: PCRStats = await PCR({ downloadPath: puppeteerDir, }) return stats } /** * Gets the viewport size from global state or returns default */ private getViewport() { const size = (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600" const [width, height] = size.split("x").map(Number) return { width, height } } /** * Launches a local browser instance */ private async launchLocalBrowser(): Promise<void> { console.log("Launching 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: this.getViewport(), // headless: false, }) this.isUsingRemoteBrowser = false } /** * Connects to a browser using a WebSocket URL */ private async connectWithChromeHostUrl(chromeHostUrl: string): Promise<boolean> { try { this.browser = await connect({ browserURL: chromeHostUrl, defaultViewport: this.getViewport(), }) // Cache the successful endpoint console.log(`Connected to remote browser at ${chromeHostUrl}`) this.context.globalState.update("cachedChromeHostUrl", chromeHostUrl) this.lastConnectionAttempt = Date.now() this.isUsingRemoteBrowser = true return true } catch (error) { console.log(`Failed to connect using WebSocket endpoint: ${error}`) return false } } /** * Attempts to connect to a remote browser using various methods * Returns true if connection was successful, false otherwise */ private async connectToRemoteBrowser(): Promise<boolean> { let remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined let reconnectionAttempted = false // Try to connect with cached endpoint first if it exists and is recent (less than 1 hour old) const cachedChromeHostUrl = this.context.globalState.get("cachedChromeHostUrl") as string | undefined if (cachedChromeHostUrl && this.lastConnectionAttempt && Date.now() - this.lastConnectionAttempt < 3_600_000) { console.log(`Attempting to connect using cached Chrome Host Url: ${cachedChromeHostUrl}`) if (await this.connectWithChromeHostUrl(cachedChromeHostUrl)) { return true } console.log(`Failed to connect using cached Chrome Host Url: ${cachedChromeHostUrl}`) // Clear the cached endpoint since it's no longer valid this.context.globalState.update("cachedChromeHostUrl", 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 else if (remoteBrowserHost && !reconnectionAttempted) { console.log(`Attempting to connect to remote browser at ${remoteBrowserHost}`) try { const hostIsValid = await tryChromeHostUrl(remoteBrowserHost) if (!hostIsValid) { throw new Error("Could not find chromeHostUrl in the response") } console.log(`Found WebSocket endpoint: ${remoteBrowserHost}`) if (await this.connectWithChromeHostUrl(remoteBrowserHost)) { return true } } catch (error) { console.error(`Failed to connect to remote browser: ${error}`) // Fall back to auto-discovery if remote connection fails } } try { console.log("Attempting browser auto-discovery...") const chromeHostUrl = await discoverChromeHostUrl() if (chromeHostUrl && (await this.connectWithChromeHostUrl(chromeHostUrl))) { return true } } catch (error) { console.error(`Auto-discovery failed: ${error}`) // Fall back to local browser if auto-discovery fails } return false } async launchBrowser(): Promise<void> { console.log("launch browser called") // Check if remote browser connection is enabled const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as boolean | undefined if (!remoteBrowserEnabled) { console.log("Launching local browser") if (this.browser) { // throw new Error("Browser already launched") await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before } else { // If browser wasn't open, just reset the state this.resetBrowserState() } await this.launchLocalBrowser() } else { console.log("Connecting to remote browser") // Remote browser connection is enabled const remoteConnected = await this.connectToRemoteBrowser() // If all remote connection attempts fail, fall back to local browser if (!remoteConnected) { console.log("Falling back to local browser") await this.launchLocalBrowser() } } // Notify that browser session is now active if (this.browser && this.onStateChange) { this.onStateChange(true) } } /** * Closes the browser and resets browser state */ async closeBrowser(): Promise<BrowserActionResult> { const wasActive = !!(this.browser || this.page) if (wasActive) { if (this.isUsingRemoteBrowser && this.browser) { await this.browser.disconnect().catch(() => {}) } else { await this.browser?.close().catch(() => {}) } this.resetBrowserState() // Notify that browser session is now inactive if (this.onStateChange) { this.onStateChange(false) } } return {} } /** * Resets all browser state variables */ private resetBrowserState(): void { this.browser = undefined this.page = undefined this.currentMousePosition = undefined this.isUsingRemoteBrowser = false this.lastViewportWidth = undefined this.lastViewportHeight = undefined } async doAction(action: (page: Page) => Promise<void>): Promise<BrowserActionResult> { if (!this.page) { throw new Error( "Cannot perform browser action: no active browser session. The browser must be launched first using the 'launch' action before other browser actions can be performed.", ) } const logs: string[] = [] let lastLogTs = Date.now() const consoleListener = (msg: any) => { if (msg.type() === "log") { logs.push(msg.text()) } else { logs.push(`[${msg.type()}] ${msg.text()}`) } lastLogTs = Date.now() } const errorListener = (err: Error) => { logs.push(`[Page Error] ${err.toString()}`) lastLogTs = Date.now() } // Add the listeners this.page.on("console", consoleListener) this.page.on("pageerror", errorListener) try { await action(this.page) } catch (err) { if (!(err instanceof TimeoutError)) { logs.push(`[Error] ${err.toString()}`) } } // Wait for console inactivity, with a timeout await pWaitFor(() => Date.now() - lastLogTs >= 500, { timeout: 3_000, interval: 100, }).catch(() => {}) // Draw cursor indicator if we have a cursor position if (this.currentMousePosition) { await this.drawCursorIndicator(this.page, this.currentMousePosition) } let options: ScreenshotOptions = { encoding: "base64", // clip: { // x: 0, // y: 0, // width: 900, // height: 600, // }, } let screenshotBase64 = await this.page.screenshot({ ...options, type: "webp", quality: ((await this.context.globalState.get("screenshotQuality")) as number | undefined) ?? 75, }) let screenshot = `data:image/webp;base64,${screenshotBase64}` if (!screenshotBase64) { console.log("webp screenshot failed, trying png") screenshotBase64 = await this.page.screenshot({ ...options, type: "png", }) screenshot = `data:image/png;base64,${screenshotBase64}` } if (!screenshotBase64) { throw new Error("Failed to take screenshot.") } // Remove cursor indicator after taking screenshot if (this.currentMousePosition) { await this.removeCursorIndicator(this.page) } // this.page.removeAllListeners() <- causes the page to crash! this.page.off("console", consoleListener) this.page.off("pageerror", errorListener) // Get actual viewport dimensions const viewport = this.page.viewport() // Persist last known viewport dimensions this.lastViewportWidth = viewport?.width this.lastViewportHeight = viewport?.height return { screenshot, logs: logs.join("\n"), currentUrl: this.page.url(), currentMousePosition: this.currentMousePosition, viewportWidth: viewport?.width, viewportHeight: viewport?.height, } } /** * Extract the root domain from a URL * e.g., http://localhost:3000/path -> localhost:3000 * e.g., https://example.com/path -> example.com */ private getRootDomain(url: string): string { try { const urlObj = new URL(url) // Remove www. prefix if present return urlObj.host.replace(/^www\./, "") } catch (error) { // If URL parsing fails, return the original URL return url } } /** * Navigate to a URL with standard loading options */ private async navigatePageToUrl(page: Page, url: string): Promise<void> { await page.goto(url, { timeout: BROWSER_NAVIGATION_TIMEOUT, waitUntil: ["domcontentloaded", "networkidle2"] }) await this.waitTillHTMLStable(page) } /** * Creates a new tab and navigates to the specified URL */ private async createNewTab(url: string): Promise<BrowserActionResult> { if (!this.browser) { throw new Error("Browser is not launched") } // Create a new page const newPage = await this.browser.newPage() // Set the new page as the active page this.page = newPage // Navigate to the URL const result = await this.doAction(async (page) => { await this.navigatePageToUrl(page, url) }) return result } async navigateToUrl(url: string): Promise<BrowserActionResult> { if (!this.browser) { throw new Error("Browser is not launched") } // Remove trailing slash for comparison const normalizedNewUrl = url.replace(/\/$/, "") // Extract the root domain from the URL const rootDomain = this.getRootDomain(normalizedNewUrl) // Get all current pages const pages = await this.browser.pages() // Try to find a page with the same root domain let existingPage: Page | undefined for (const page of pages) { try { const pageUrl = page.url() if (pageUrl && this.getRootDomain(pageUrl) === rootDomain) { existingPage = page break } } catch (error) { // Skip pages that might have been closed or have errors console.log(`Error checking page URL: ${error}`) continue } } if (existingPage) { // Tab with the same root domain exists, switch to it console.log(`Tab with domain ${rootDomain} already exists, switching to it`) // Update the active page this.page = existingPage existingPage.bringToFront() // Navigate to the new URL if it's different] const currentUrl = existingPage.url().replace(/\/$/, "") // Remove trailing / if present if (this.getRootDomain(currentUrl) === rootDomain && currentUrl !== normalizedNewUrl) { console.log(`Navigating to new URL: ${normalizedNewUrl}`) console.log(`Current URL: ${currentUrl}`) console.log(`Root domain: ${this.getRootDomain(currentUrl)}`) console.log(`New URL: ${normalizedNewUrl}`) // Navigate to the new URL return this.doAction(async (page) => { await this.navigatePageToUrl(page, normalizedNewUrl) }) } else { console.log(`Tab with domain ${rootDomain} already exists, and URL is the same: ${normalizedNewUrl}`) // URL is the same, just reload the page to ensure it's up to date console.log(`Reloading page: ${normalizedNewUrl}`) console.log(`Current URL: ${currentUrl}`) console.log(`Root domain: ${this.getRootDomain(currentUrl)}`) console.log(`New URL: ${normalizedNewUrl}`) return this.doAction(async (page) => { await page.reload({ timeout: BROWSER_NAVIGATION_TIMEOUT, waitUntil: ["domcontentloaded", "networkidle2"], }) await this.waitTillHTMLStable(page) }) } } else { // No tab with this root domain exists, create a new one console.log(`No tab with domain ${rootDomain} exists, creating a new one`) return this.createNewTab(normalizedNewUrl) } } // page.goto { waitUntil: "networkidle0" } may not ever resolve, and not waiting could return page content too early before js has loaded // https://stackoverflow.com/questions/52497252/puppeteer-wait-until-page-is-completely-loaded/61304202#61304202 private async waitTillHTMLStable(page: Page, timeout = 5_000) { const checkDurationMsecs = 500 // 1000 const maxChecks = timeout / checkDurationMsecs let lastHTMLSize = 0 let checkCounts = 1 let countStableSizeIterations = 0 const minStableSizeIterations = 3 while (checkCounts++ <= maxChecks) { let html = await page.content() let currentHTMLSize = html.length // let bodyHTMLSize = await page.evaluate(() => document.body.innerHTML.length) console.log("last: ", lastHTMLSize, " <> curr: ", currentHTMLSize) if (lastHTMLSize !== 0 && currentHTMLSize === lastHTMLSize) { countStableSizeIterations++ } else { countStableSizeIterations = 0 //reset the counter } if (countStableSizeIterations >= minStableSizeIterations) { console.log("Page rendered fully...") break } lastHTMLSize = currentHTMLSize await delay(checkDurationMsecs) } } /** * Force links and window.open to navigate in the same tab. * This makes clicks on anchors with target="_blank" stay in the current page * and also intercepts window.open so SPA/open-in-new-tab patterns don't spawn popups. */ private async forceLinksToSameTab(page: Page): Promise<void> { try { await page.evaluate(() => { try { // Ensure we only install once per document if ((window as any).__ROO_FORCE_SAME_TAB__) return ;(window as any).__ROO_FORCE_SAME_TAB__ = true // Override window.open to navigate current tab instead of creating a new one const originalOpen = window.open window.open = function (url: string | URL, target?: string, features?: string) { try { const href = typeof url === "string" ? url : String(url) location.href = href } catch { // fall back to original if something unexpected occurs try { return originalOpen.apply(window, [url as any, "_self", features]) as any } catch {} } return null as any } as any // Rewrite anchors that explicitly open new tabs document.querySelectorAll('a[target="_blank"]').forEach((a) => { a.setAttribute("target", "_self") }) // Defensive capture: if an element still tries to open in a new tab, force same-tab document.addEventListener( "click", (ev) => { const el = (ev.target as HTMLElement | null)?.closest?.( 'a[target="_blank"]', ) as HTMLAnchorElement | null if (el && el.href) { ev.preventDefault() try { location.href = el.href } catch {} } }, { capture: true, passive: false }, ) } catch { // no-op; forcing same-tab is best-effort } }) } catch { // If evaluate fails (e.g., cross-origin/state), continue without breaking the action } } /** * Handles mouse interaction with network activity monitoring */ private async handleMouseInteraction( page: Page, coordinate: string, action: (x: number, y: number) => Promise<void>, ): Promise<void> { const [x, y] = coordinate.split(",").map(Number) // Force any new-tab behavior (target="_blank", window.open) to stay in the same tab await this.forceLinksToSameTab(page) // Set up network request monitoring let hasNetworkActivity = false const requestListener = () => { hasNetworkActivity = true } page.on("request", requestListener) // Perform the mouse action await action(x, y) this.currentMousePosition = coordinate // Small delay to check if action triggered any network activity await delay(100) if (hasNetworkActivity) { // If we detected network activity, wait for navigation/loading await page .waitForNavigation({ waitUntil: ["domcontentloaded", "networkidle2"], timeout: BROWSER_NAVIGATION_TIMEOUT, }) .catch(() => {}) await this.waitTillHTMLStable(page) } // Clean up listener page.off("request", requestListener) } async click(coordinate: string): Promise<BrowserActionResult> { return this.doAction(async (page) => { await this.handleMouseInteraction(page, coordinate, async (x, y) => { await page.mouse.click(x, y) }) }) } async type(text: string): Promise<BrowserActionResult> { return this.doAction(async (page) => { await page.keyboard.type(text) }) } async press(key: string): Promise<BrowserActionResult> { return this.doAction(async (page) => { // Parse key combinations (e.g., "Cmd+K", "Shift+Enter") const parts = key.split("+").map((k) => k.trim()) const modifiers: string[] = [] let mainKey = parts[parts.length - 1] // Identify modifiers for (let i = 0; i < parts.length - 1; i++) { const part = parts[i].toLowerCase() if (part === "cmd" || part === "command" || part === "meta") { modifiers.push("Meta") } else if (part === "ctrl" || part === "control") { modifiers.push("Control") } else if (part === "shift") { modifiers.push("Shift") } else if (part === "alt" || part === "option") { modifiers.push("Alt") } } // Map common key aliases to Puppeteer KeyInput values const mapping: Record<string, KeyInput | string> = { esc: "Escape", return: "Enter", escape: "Escape", enter: "Enter", tab: "Tab", space: "Space", arrowup: "ArrowUp", arrowdown: "ArrowDown", arrowleft: "ArrowLeft", arrowright: "ArrowRight", } mainKey = (mapping[mainKey.toLowerCase()] ?? mainKey) as string // Avoid new-tab behavior from Enter on links/buttons await this.forceLinksToSameTab(page) // Track inflight requests so we can detect brief network bursts let inflight = 0 const onRequest = () => { inflight++ } const onRequestDone = () => { inflight = Math.max(0, inflight - 1) } page.on("request", onRequest) page.on("requestfinished", onRequestDone) page.on("requestfailed", onRequestDone) // Start a short navigation wait in parallel; if no nav, it times out harmlessly const HARD_CAP_MS = 3000 const navPromise = page .waitForNavigation({ // domcontentloaded is enough to confirm a submit navigated waitUntil: ["domcontentloaded"], timeout: HARD_CAP_MS, }) .catch(() => undefined) // Press key combination if (modifiers.length > 0) { // Hold down modifiers for (const modifier of modifiers) { await page.keyboard.down(modifier as KeyInput) } // Press main key await page.keyboard.press(mainKey as KeyInput) // Release modifiers for (const modifier of modifiers) { await page.keyboard.up(modifier as KeyInput) } } else { // Single key press await page.keyboard.press(mainKey as KeyInput) } // Give time for any requests to kick off await delay(120) // Hard-cap the wait to avoid UI hangs await Promise.race([ navPromise, pWaitFor(() => inflight === 0, { timeout: HARD_CAP_MS, interval: 100 }).catch(() => {}), delay(HARD_CAP_MS), ]) // Stabilize DOM briefly before capturing screenshot (shorter cap) await this.waitTillHTMLStable(page, 2_000) // Cleanup page.off("request", onRequest) page.off("requestfinished", onRequestDone) page.off("requestfailed", onRequestDone) }) } /** * Scrolls the page by the specified amount */ private async scrollPage(page: Page, direction: "up" | "down"): Promise<void> { const { height } = this.getViewport() const scrollAmount = direction === "down" ? height : -height await page.evaluate((scrollHeight) => { window.scrollBy({ top: scrollHeight, behavior: "auto", }) }, scrollAmount) await delay(300) } async scrollDown(): Promise<BrowserActionResult> { return this.doAction(async (page) => { await this.scrollPage(page, "down") }) } async scrollUp(): Promise<BrowserActionResult> { return this.doAction(async (page) => { await this.scrollPage(page, "up") }) } async hover(coordinate: string): Promise<BrowserActionResult> { return this.doAction(async (page) => { await this.handleMouseInteraction(page, coordinate, async (x, y) => { await page.mouse.move(x, y) // Small delay to allow any hover effects to appear await delay(300) }) }) } async resize(size: string): Promise<BrowserActionResult> { return this.doAction(async (page) => { const [width, height] = size.split(",").map(Number) const session = await page.createCDPSession() await page.setViewport({ width, height }) const { windowId } = await session.send("Browser.getWindowForTarget") await session.send("Browser.setWindowBounds", { bounds: { width, height }, windowId, }) }) } /** * Determines image type from file extension */ private getImageTypeFromPath(filePath: string): "png" | "jpeg" | "webp" { const ext = path.extname(filePath).toLowerCase() if (ext === ".jpg" || ext === ".jpeg") return "jpeg" if (ext === ".webp") return "webp" return "png" } /** * Takes a screenshot and saves it to the specified file path. * @param filePath - The destination file path (relative to workspace) * @param cwd - Current working directory for resolving relative paths * @returns BrowserActionResult with screenshot data and saved file path * @throws Error if the resolved path escapes the workspace directory */ async saveScreenshot(filePath: string, cwd: string): Promise<BrowserActionResult> { // Always resolve the path against the workspace root const normalizedCwd = path.resolve(cwd) const fullPath = path.resolve(cwd, filePath) // Validate that the resolved path stays within the workspace (before calling doAction) if (!fullPath.startsWith(normalizedCwd + path.sep) && fullPath !== normalizedCwd) { throw new Error( `Screenshot path "${filePath}" resolves to "${fullPath}" which is outside the workspace "${normalizedCwd}". ` + `Paths must be relative to the workspace and cannot escape it.`, ) } return this.doAction(async (page) => { // Ensure directory exists await fs.mkdir(path.dirname(fullPath), { recursive: true }) // Determine image type from extension const imageType = this.getImageTypeFromPath(filePath) // Take screenshot directly to file (more efficient than base64 for file saving) await page.screenshot({ path: fullPath, type: imageType, quality: imageType === "png" ? undefined : ((this.context.globalState.get("screenshotQuality") as number | undefined) ?? 75), }) }) } /** * Draws a cursor indicator on the page at the specified position */ private async drawCursorIndicator(page: Page, coordinate: string): Promise<void> { const [x, y] = coordinate.split(",").map(Number) try { await page.evaluate( (cursorX: number, cursorY: number) => { // Create a cursor indicator element const cursor = document.createElement("div") cursor.id = "__roo_cursor_indicator__" cursor.style.cssText = ` position: fixed; left: ${cursorX}px; top: ${cursorY}px; width: 35px; height: 35px; pointer-events: none; z-index: 2147483647; ` // Create SVG cursor pointer const svg = ` <svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M5 3L5 17L9 13L12 19L14 18L11 12L17 12L5 3Z" fill="white" stroke="black" stroke-width="1.5"/> <path d="M5 3L5 17L9 13L12 19L14 18L11 12L17 12L5 3Z" fill="black" stroke="white" stroke-width=".5"/> </svg> ` cursor.innerHTML = svg document.body.appendChild(cursor) }, x, y, ) } catch (error) { console.error("Failed to draw cursor indicator:", error) } } /** * Removes the cursor indicator from the page */ private async removeCursorIndicator(page: Page): Promise<void> { try { await page.evaluate(() => { const cursor = document.getElementById("__roo_cursor_indicator__") if (cursor) { cursor.remove() } }) } catch (error) { console.error("Failed to remove cursor indicator:", error) } } /** * Returns whether a browser session is currently active */ isSessionActive(): boolean { return !!(this.browser && this.page) } /** * Returns the last known viewport size (if any) * * Prefer the live page viewport when available so we stay accurate after: * - browser_action resize * - manual window resizes (especially with remote browsers) * * Falls back to the configured default viewport when no prior information exists. */ getViewportSize(): { width?: number; height?: number } { // If we have an active page, ask Puppeteer for the current viewport. // This keeps us in sync with any resizes that happen outside of our own // browser_action lifecycle (e.g. user dragging the window). if (this.page) { const vp = this.page.viewport() if (vp?.width) this.lastViewportWidth = vp.width if (vp?.height) this.lastViewportHeight = vp.height } // If we've ever observed a viewport, use that. if (this.lastViewportWidth && this.lastViewportHeight) { return { width: this.lastViewportWidth, height: this.lastViewportHeight, } } // Otherwise fall back to the configured default so the tool can still // operate before the first screenshot-based action has run. const { width, height } = this.getViewport() return { width, height } } }