import { chromium, firefox, webkit, devices, type Browser, type BrowserContext, type Page, type Frame, type Dialog, type Request, type Route, type Locator, type CDPSession, type Video, } from 'playwright-core'; import path from 'node:path'; import os from 'node:os'; import { existsSync, mkdirSync, rmSync, readFileSync } from 'node:fs'; import { writeFile, mkdir } from 'node:fs/promises'; import type { LaunchCommand, TraceEvent } from './types.js'; import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js'; import { safeHeaderMerge } from './state-utils.js'; import { getEncryptionKey, isEncryptedPayload, decryptData, ENCRYPTION_KEY_ENV, } from './state-utils.js'; import { STEALTH_CHROMIUM_ARGS, applyStealthScripts, applyBrowserLevelStealth, type StealthScriptOptions, } from './stealth.js'; /** * Returns the default Playwright timeout in milliseconds for standard operations. * Can be overridden via the AGENT_BROWSER_DEFAULT_TIMEOUT environment variable. * Default is 25s, which is below the CLI's 30s IPC read timeout to ensure * Playwright errors are returned before the CLI gives up with EAGAIN. * CDP and recording contexts use a shorter fixed timeout (10s) and are not affected. */ export function getDefaultTimeout(): number { const envValue = process.env.AGENT_BROWSER_DEFAULT_TIMEOUT; if (envValue) { const parsed = parseInt(envValue, 10); if (!isNaN(parsed) && parsed >= 1000) { return parsed; } } return 25000; } // Screencast frame data from CDP export interface ScreencastFrame { data: string; // base64 encoded image metadata: { offsetTop: number; pageScaleFactor: number; deviceWidth: number; deviceHeight: number; scrollOffsetX: number; scrollOffsetY: number; timestamp?: number; }; sessionId: number; } // Screencast options export interface ScreencastOptions { format?: 'jpeg' | 'png'; quality?: number; // 0-100, only for jpeg maxWidth?: number; maxHeight?: number; everyNthFrame?: number; } interface TrackedRequest { url: string; method: string; headers: Record; timestamp: number; resourceType: string; } interface ConsoleMessage { type: string; text: string; timestamp: number; } interface PageError { message: string; timestamp: number; } type BrowserType = NonNullable; type StealthConnectionKind = | 'local' | 'cdp' | 'provider-browserbase' | 'provider-browseruse' | 'provider-kernel'; interface StealthPolicy { enabled: boolean; connectionKind: StealthConnectionKind; applyChromiumArgs: boolean; applyInitScripts: boolean; providerManaged: boolean; capabilities: string[]; } export interface StealthStatus { enabled: boolean; connectionKind: StealthConnectionKind; capabilities: string[]; providerManaged: boolean; } interface StealthContextDefaults { locale?: string; timezoneId?: string; extraHTTPHeaders?: Record; } const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/']; /** * Manages the Playwright browser lifecycle with multiple tabs/windows */ export class BrowserManager { private browser: Browser | null = null; private cdpEndpoint: string | null = null; // stores port number or full URL private isPersistentContext: boolean = false; private browserbaseSessionId: string | null = null; private browserbaseApiKey: string | null = null; private browserUseSessionId: string | null = null; private browserUseApiKey: string | null = null; private kernelSessionId: string | null = null; private kernelApiKey: string | null = null; private contexts: BrowserContext[] = []; private pages: Page[] = []; private activePageIndex: number = 0; private activeFrame: Frame | null = null; private dialogHandler: ((dialog: Dialog) => Promise) | null = null; private trackedRequests: TrackedRequest[] = []; private routes: Map Promise> = new Map(); private consoleMessages: ConsoleMessage[] = []; private pageErrors: PageError[] = []; private isRecordingHar: boolean = false; private refMap: RefMap = {}; private lastSnapshot: string = ''; private scopedHeaderRoutes: Map Promise> = new Map(); private colorScheme: 'light' | 'dark' | 'no-preference' | null = null; private stealthEnabled: boolean = true; private stealthConnectionKind: StealthConnectionKind = 'local'; private contextLocale: string | undefined = undefined; private contextTimezoneId: string | undefined = undefined; private contextHeaders: Record | undefined = undefined; private contextUserAgent: string | undefined = undefined; /** * Set the persistent color scheme preference. * Applied automatically to all new pages and contexts. */ setColorScheme(scheme: 'light' | 'dark' | 'no-preference' | null): void { this.colorScheme = scheme; } /** * Centralized stealth policy so launch mode semantics stay consistent. * Local Chromium gets args + init scripts; CDP/providers get init scripts only. */ private getStealthPolicy(browserType: BrowserType = 'chromium'): StealthPolicy { const applyChromiumArgs = this.stealthConnectionKind === 'local' && browserType === 'chromium'; const applyInitScripts = true; const providerManaged = this.stealthConnectionKind === 'provider-kernel'; const capabilities: string[] = []; if (applyChromiumArgs) { capabilities.push('chromium-launch-args'); } if (applyInitScripts) { capabilities.push('context-init-scripts'); } if (providerManaged) { capabilities.push('provider-managed-stealth'); } return { enabled: true, connectionKind: this.stealthConnectionKind, applyChromiumArgs, applyInitScripts, providerManaged, capabilities, }; } private logStealthPolicy(phase: string, browserType: BrowserType = 'chromium'): void { if (process.env.AGENT_BROWSER_DEBUG !== '1') return; const policy = this.getStealthPolicy(browserType); const capabilities = policy.capabilities.length > 0 ? policy.capabilities.join(', ') : 'none'; console.error( `[DEBUG] Stealth ${phase}: enabled=${policy.enabled} connection=${policy.connectionKind} capabilities=${capabilities}` ); } getStealthStatus(browserType: BrowserType = 'chromium'): StealthStatus { const policy = this.getStealthPolicy(browserType); return { enabled: policy.enabled, connectionKind: policy.connectionKind, capabilities: policy.capabilities, providerManaged: policy.providerManaged, }; } private normalizeLocaleTag(locale?: string): string | undefined { if (!locale) return undefined; const cleaned = locale.trim().split(',')[0]?.split(';')[0]?.replace(/_/g, '-'); if (!cleaned) return undefined; try { return new Intl.Locale(cleaned).toString(); } catch { return undefined; } } private buildAcceptLanguageHeader(locale: string): string { const baseLanguage = locale.split('-')[0]; if (!baseLanguage || baseLanguage === locale) { return `${locale};q=0.9`; } return `${locale},${baseLanguage};q=0.9`; } private getHeaderValue( headers: Record | undefined, name: string ): string | undefined { if (!headers) return undefined; const target = name.toLowerCase(); for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() === target) return value; } return undefined; } // TLD -> {locale, timezone} mapping for automatic region consistency private static readonly TLD_REGION_MAP: Record = { tw: { locale: 'zh-TW', timezone: 'Asia/Taipei' }, cn: { locale: 'zh-CN', timezone: 'Asia/Shanghai' }, hk: { locale: 'zh-HK', timezone: 'Asia/Hong_Kong' }, jp: { locale: 'ja-JP', timezone: 'Asia/Tokyo' }, kr: { locale: 'ko-KR', timezone: 'Asia/Seoul' }, th: { locale: 'th-TH', timezone: 'Asia/Bangkok' }, vn: { locale: 'vi-VN', timezone: 'Asia/Ho_Chi_Minh' }, sg: { locale: 'en-SG', timezone: 'Asia/Singapore' }, my: { locale: 'ms-MY', timezone: 'Asia/Kuala_Lumpur' }, id: { locale: 'id-ID', timezone: 'Asia/Jakarta' }, ph: { locale: 'en-PH', timezone: 'Asia/Manila' }, br: { locale: 'pt-BR', timezone: 'America/Sao_Paulo' }, mx: { locale: 'es-MX', timezone: 'America/Mexico_City' }, ar: { locale: 'es-AR', timezone: 'America/Argentina/Buenos_Aires' }, de: { locale: 'de-DE', timezone: 'Europe/Berlin' }, fr: { locale: 'fr-FR', timezone: 'Europe/Paris' }, uk: { locale: 'en-GB', timezone: 'Europe/London' }, ru: { locale: 'ru-RU', timezone: 'Europe/Moscow' }, in: { locale: 'hi-IN', timezone: 'Asia/Kolkata' }, au: { locale: 'en-AU', timezone: 'Australia/Sydney' }, }; // Target URL set during navigation, used for region auto-detection private targetUrl: string | undefined = undefined; /** * Set the target URL for region auto-detection. * Called from navigate/open commands so locale/timezone can adapt. * Applies CDP overrides to align locale/timezone with the target site's region. */ async setTargetUrl(url: string): Promise { this.targetUrl = url; const region = this.getRegionFromUrl(url); if (!region) return; // Skip if user has explicitly set locale/timezone via env const envLocale = process.env.AGENT_BROWSER_LOCALE; const envTimezone = process.env.AGENT_BROWSER_TIMEZONE || process.env.TZ; try { const page = this.getPage(); const cdp = await page.context().newCDPSession(page); if (!envTimezone) { await cdp .send('Emulation.setTimezoneOverride', { timezoneId: region.timezone }) .catch(() => {}); } if (!envLocale) { await cdp.send('Emulation.setLocaleOverride', { locale: region.locale }).catch(() => {}); // Update Accept-Language header to match const langHeader = this.buildAcceptLanguageHeader(region.locale); const context = page.context(); const currentHeaders = this.contextHeaders ?? {}; await context.setExtraHTTPHeaders({ ...currentHeaders, 'Accept-Language': langHeader }); } await cdp.detach().catch(() => {}); } catch { // CDP not available (non-Chromium), skip dynamic override } } private getRegionFromUrl(url?: string): { locale: string; timezone: string } | undefined { if (!url) return undefined; try { const hostname = new URL(url).hostname; const parts = hostname.split('.'); const tld = parts[parts.length - 1]; // Check compound TLDs like co.th, com.tw, co.id const secondLevel = parts.length >= 2 ? parts[parts.length - 2] : ''; const compoundTld = `${secondLevel}.${tld}`; // Try compound first (e.g., "co.th" -> "th", "com.tw" -> "tw") const compoundMatch = BrowserManager.TLD_REGION_MAP[tld]; if ( compoundMatch && (secondLevel === 'co' || secondLevel === 'com' || secondLevel === 'or' || secondLevel === 'org') ) { return compoundMatch; } // Then direct TLD if (BrowserManager.TLD_REGION_MAP[tld]) { return BrowserManager.TLD_REGION_MAP[tld]; } return undefined; } catch { return undefined; } } private resolveStealthLocale(headers?: Record): string { const headerLocale = this.getHeaderValue(headers, 'accept-language'); const normalizedHeaderLocale = this.normalizeLocaleTag(headerLocale); if (normalizedHeaderLocale) return normalizedHeaderLocale; // Explicit env var takes priority const envLocale = this.normalizeLocaleTag(process.env.AGENT_BROWSER_LOCALE); if (envLocale) return envLocale; // Auto-detect from target URL TLD const urlRegion = this.getRegionFromUrl(this.targetUrl); if (urlRegion) return urlRegion.locale; const candidates = [ process.env.LC_ALL, process.env.LC_MESSAGES, process.env.LANG, Intl.DateTimeFormat().resolvedOptions().locale, ]; for (const candidate of candidates) { const normalized = this.normalizeLocaleTag(candidate); if (normalized) return normalized; } return 'en-US'; } private resolveStealthTimezoneId(): string | undefined { // Explicit env var takes priority const envTz = process.env.AGENT_BROWSER_TIMEZONE?.trim() || process.env.TZ?.trim(); if (envTz && (envTz === 'UTC' || envTz.includes('/'))) return envTz; // Auto-detect from target URL TLD const urlRegion = this.getRegionFromUrl(this.targetUrl); if (urlRegion) return urlRegion.timezone; const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone; if (systemTz) return systemTz; return undefined; } private buildStealthContextDefaults( policy: StealthPolicy, headers?: Record ): StealthContextDefaults { if (!policy.enabled) { return { extraHTTPHeaders: headers }; } const locale = this.resolveStealthLocale(headers); const timezoneId = this.resolveStealthTimezoneId(); const hasAcceptLanguage = this.getHeaderValue(headers, 'accept-language') !== undefined; const extraHTTPHeaders = hasAcceptLanguage ? headers : { ...(headers ?? {}), 'Accept-Language': this.buildAcceptLanguageHeader(locale), }; return { locale, timezoneId, extraHTTPHeaders, }; } private extractChromiumVersion(versionText: string): string | undefined { const match = versionText.match(/(\d+\.\d+\.\d+\.\d+)/); return match?.[1]; } private buildStealthChromiumUserAgent(chromeVersion: string): string { const platform = os.platform(); let osToken = 'X11; Linux x86_64'; if (platform === 'darwin') { osToken = 'Macintosh; Intel Mac OS X 10_15_7'; } else if (platform === 'win32') { osToken = 'Windows NT 10.0; Win64; x64'; } return ( `Mozilla/5.0 (${osToken}) AppleWebKit/537.36 ` + `(KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36` ); } private getStealthUserAgentVersionHint(): string | undefined { const deviceUA = devices['Desktop Chrome']?.userAgent; if (!deviceUA) return undefined; return this.extractChromiumVersion(deviceUA); } /** * Apply context init-script stealth patches when policy allows. */ private async applyStealthIfEnabled( context: BrowserContext, options: StealthScriptOptions = {} ): Promise { const policy = this.getStealthPolicy(); if (!policy.applyInitScripts) return; await applyStealthScripts(context, { ...options, userAgent: this.contextUserAgent, }); this.logStealthPolicy('init-script applied'); } // CDP session for screencast and input injection private cdpSession: CDPSession | null = null; private screencastActive: boolean = false; private screencastSessionId: number = 0; private frameCallback: ((frame: ScreencastFrame) => void) | null = null; private screencastFrameHandler: ((params: any) => void) | null = null; // Video recording (Playwright native) private recordingContext: BrowserContext | null = null; private recordingPage: Page | null = null; private recordingOutputPath: string = ''; private recordingTempDir: string = ''; private launchWarnings: string[] = []; /** * Get and clear launch warnings (e.g., decryption failures) */ getAndClearWarnings(): string[] { const warnings = this.launchWarnings; this.launchWarnings = []; return warnings; } // CDP profiling state private static readonly MAX_PROFILE_EVENTS = 5_000_000; private profilingActive: boolean = false; private profileChunks: TraceEvent[] = []; private profileEventsDropped: boolean = false; private profileCompleteResolver: (() => void) | null = null; private profileDataHandler: ((params: { value?: TraceEvent[] }) => void) | null = null; private profileCompleteHandler: (() => void) | null = null; /** * Check if browser is launched */ isLaunched(): boolean { return this.browser !== null || this.isPersistentContext; } /** * Get enhanced snapshot with refs and cache the ref map */ async getSnapshot(options?: { interactive?: boolean; cursor?: boolean; maxDepth?: number; compact?: boolean; selector?: string; }): Promise { const page = this.getPage(); const snapshot = await getEnhancedSnapshot(page, options); this.refMap = snapshot.refs; this.lastSnapshot = snapshot.tree; return snapshot; } /** * Get the last snapshot tree text (empty string if no snapshot has been taken) */ getLastSnapshot(): string { return this.lastSnapshot; } /** * Update the stored snapshot (used by diff to keep the baseline current) */ setLastSnapshot(snapshot: string): void { this.lastSnapshot = snapshot; } /** * Get the cached ref map from last snapshot */ getRefMap(): RefMap { return this.refMap; } /** * Get a locator from a ref (e.g., "e1", "@e1", "ref=e1") * Returns null if ref doesn't exist or is invalid */ getLocatorFromRef(refArg: string): Locator | null { const ref = parseRef(refArg); if (!ref) return null; const refData = this.refMap[ref]; if (!refData) return null; const page = this.getPage(); // Check if this is a cursor-interactive element (uses CSS selector, not ARIA role) // These have pseudo-roles 'clickable' or 'focusable' and a CSS selector if (refData.role === 'clickable' || refData.role === 'focusable') { // The selector is a CSS selector, use it directly return page.locator(refData.selector); } // Build locator with exact: true to avoid substring matches let locator: Locator; if (refData.name) { locator = page.getByRole(refData.role as any, { name: refData.name, exact: true }); } else { locator = page.getByRole(refData.role as any); } // If an nth index is stored (for disambiguation), use it if (refData.nth !== undefined) { locator = locator.nth(refData.nth); } return locator; } /** * Check if a selector looks like a ref */ isRef(selector: string): boolean { return parseRef(selector) !== null; } /** * Get locator - supports both refs and regular selectors */ getLocator(selectorOrRef: string): Locator { // Check if it's a ref first const locator = this.getLocatorFromRef(selectorOrRef); if (locator) return locator; // Otherwise treat as regular selector const page = this.getPage(); return page.locator(selectorOrRef); } /** * Check if the browser has any usable pages */ hasPages(): boolean { return this.pages.length > 0; } private getSafePageUrl(page: Page): string { try { return page.url(); } catch { return ''; } } private isIgnoredCDPPageUrl(url: string): boolean { if (!url) return false; const normalizedUrl = url.toLowerCase(); return IGNORED_CDP_PAGE_URL_PREFIXES.some((prefix) => normalizedUrl.startsWith(prefix)); } private isUsableCDPPage(page: Page): boolean { if (page.isClosed()) return false; const url = this.getSafePageUrl(page); if (!url) return false; return !this.isIgnoredCDPPageUrl(url); } private collectUsableCDPPages(contexts: BrowserContext[]): Page[] { return contexts .flatMap((context) => context.pages()) .filter((page) => this.isUsableCDPPage(page)); } /** * Ensure at least one page exists. If the browser is launched but all pages * were closed (stale session), creates a new page on the existing context. * No-op if pages already exist. */ async ensurePage(): Promise { if (this.pages.length > 0) return; if (!this.browser && !this.isPersistentContext) return; // Use the last existing context, or create a new one let context: BrowserContext; if (this.contexts.length > 0) { context = this.contexts[this.contexts.length - 1]; } else if (this.browser) { context = await this.browser.newContext({ ...(this.contextHeaders && { extraHTTPHeaders: this.contextHeaders }), ...(this.contextUserAgent && { userAgent: this.contextUserAgent }), ...(this.contextLocale && { locale: this.contextLocale }), ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }), ...(this.colorScheme && { colorScheme: this.colorScheme }), }); await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.setupContextTracking(context); } else { return; } const page = await context.newPage(); if (!this.pages.includes(page)) { this.pages.push(page); this.setupPageTracking(page); } this.activePageIndex = this.pages.length - 1; } /** * Get the current active page, throws if not launched */ getPage(): Page { if (this.pages.length === 0) { throw new Error('Browser not launched. Call launch first.'); } const current = this.pages[this.activePageIndex]; if (current && this.isUsableCDPPage(current)) { return current; } const usableIndex = this.pages.findIndex((page) => this.isUsableCDPPage(page)); if (usableIndex !== -1) { this.activePageIndex = usableIndex; return this.pages[this.activePageIndex]; } const openIndex = this.pages.findIndex((page) => !page.isClosed()); if (openIndex !== -1) { this.activePageIndex = openIndex; return this.pages[this.activePageIndex]; } return this.pages[this.activePageIndex]; } /** * Get the current frame (or page's main frame if no frame is selected) */ getFrame(): Frame { if (this.activeFrame) { return this.activeFrame; } return this.getPage().mainFrame(); } /** * Switch to a frame by selector, name, or URL */ async switchToFrame(options: { selector?: string; name?: string; url?: string }): Promise { const page = this.getPage(); if (options.selector) { const frameElement = await page.$(options.selector); if (!frameElement) { throw new Error(`Frame not found: ${options.selector}`); } const frame = await frameElement.contentFrame(); if (!frame) { throw new Error(`Element is not a frame: ${options.selector}`); } this.activeFrame = frame; } else if (options.name) { const frame = page.frame({ name: options.name }); if (!frame) { throw new Error(`Frame not found with name: ${options.name}`); } this.activeFrame = frame; } else if (options.url) { const frame = page.frame({ url: options.url }); if (!frame) { throw new Error(`Frame not found with URL: ${options.url}`); } this.activeFrame = frame; } } /** * Switch back to main frame */ switchToMainFrame(): void { this.activeFrame = null; } /** * Set up dialog handler */ setDialogHandler(response: 'accept' | 'dismiss', promptText?: string): void { const page = this.getPage(); // Remove existing handler if any if (this.dialogHandler) { page.removeListener('dialog', this.dialogHandler); } this.dialogHandler = async (dialog: Dialog) => { if (response === 'accept') { await dialog.accept(promptText); } else { await dialog.dismiss(); } }; page.on('dialog', this.dialogHandler); } /** * Clear dialog handler */ clearDialogHandler(): void { if (this.dialogHandler) { const page = this.getPage(); page.removeListener('dialog', this.dialogHandler); this.dialogHandler = null; } } /** * Start tracking requests */ startRequestTracking(): void { const page = this.getPage(); page.on('request', (request: Request) => { this.trackedRequests.push({ url: request.url(), method: request.method(), headers: request.headers(), timestamp: Date.now(), resourceType: request.resourceType(), }); }); } /** * Get tracked requests */ getRequests(filter?: string): TrackedRequest[] { if (filter) { return this.trackedRequests.filter((r) => r.url.includes(filter)); } return this.trackedRequests; } /** * Clear tracked requests */ clearRequests(): void { this.trackedRequests = []; } /** * Add a route to intercept requests */ async addRoute( url: string, options: { response?: { status?: number; body?: string; contentType?: string; headers?: Record; }; abort?: boolean; } ): Promise { const page = this.getPage(); const handler = async (route: Route) => { if (options.abort) { await route.abort(); } else if (options.response) { await route.fulfill({ status: options.response.status ?? 200, body: options.response.body ?? '', contentType: options.response.contentType ?? 'text/plain', headers: options.response.headers, }); } else { await route.continue(); } }; this.routes.set(url, handler); await page.route(url, handler); } /** * Remove a route */ async removeRoute(url?: string): Promise { const page = this.getPage(); if (url) { const handler = this.routes.get(url); if (handler) { await page.unroute(url, handler); this.routes.delete(url); } } else { // Remove all routes for (const [routeUrl, handler] of this.routes) { await page.unroute(routeUrl, handler); } this.routes.clear(); } } /** * Set geolocation */ async setGeolocation(latitude: number, longitude: number, accuracy?: number): Promise { const context = this.contexts[0]; if (context) { await context.setGeolocation({ latitude, longitude, accuracy }); } } /** * Set permissions */ async setPermissions(permissions: string[], grant: boolean): Promise { const context = this.contexts[0]; if (context) { if (grant) { await context.grantPermissions(permissions); } else { await context.clearPermissions(); } } } /** * Set viewport */ async setViewport(width: number, height: number): Promise { const page = this.getPage(); await page.setViewportSize({ width, height }); } /** * Set device scale factor (devicePixelRatio) via CDP * This sets window.devicePixelRatio which affects how the page renders and responds to media queries * * Note: When using CDP to set deviceScaleFactor, screenshots will be at logical pixel dimensions * (viewport size), not physical pixel dimensions (viewport × scale). This is a Playwright limitation * when using CDP emulation on existing contexts. For true HiDPI screenshots with physical pixels, * deviceScaleFactor must be set at context creation time. * * Must be called after setViewport to work correctly */ async setDeviceScaleFactor( deviceScaleFactor: number, width: number, height: number, mobile: boolean = false ): Promise { const cdp = await this.getCDPSession(); await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor, mobile, }); } /** * Clear device metrics override to restore default devicePixelRatio */ async clearDeviceMetricsOverride(): Promise { const cdp = await this.getCDPSession(); await cdp.send('Emulation.clearDeviceMetricsOverride'); } /** * Get device descriptor */ getDevice(deviceName: string): (typeof devices)[keyof typeof devices] | undefined { return devices[deviceName as keyof typeof devices]; } /** * List available devices */ listDevices(): string[] { return Object.keys(devices); } /** * Start console message tracking */ startConsoleTracking(): void { const page = this.getPage(); page.on('console', (msg) => { this.consoleMessages.push({ type: msg.type(), text: msg.text(), timestamp: Date.now(), }); }); } /** * Get console messages */ getConsoleMessages(): ConsoleMessage[] { return this.consoleMessages; } /** * Clear console messages */ clearConsoleMessages(): void { this.consoleMessages = []; } /** * Start error tracking */ startErrorTracking(): void { const page = this.getPage(); page.on('pageerror', (error) => { this.pageErrors.push({ message: error.message, timestamp: Date.now(), }); }); } /** * Get page errors */ getPageErrors(): PageError[] { return this.pageErrors; } /** * Clear page errors */ clearPageErrors(): void { this.pageErrors = []; } /** * Start HAR recording */ async startHarRecording(): Promise { // HAR is started at context level, flag for tracking this.isRecordingHar = true; } /** * Check if HAR recording */ isHarRecording(): boolean { return this.isRecordingHar; } /** * Set offline mode */ async setOffline(offline: boolean): Promise { const context = this.contexts[0]; if (context) { await context.setOffline(offline); } } /** * Set extra HTTP headers (global - all requests) */ async setExtraHeaders(headers: Record): Promise { const context = this.contexts[0]; if (context) { await context.setExtraHTTPHeaders(headers); } } /** * Set scoped HTTP headers (only for requests matching the origin) * Uses route interception to add headers only to matching requests */ async setScopedHeaders(origin: string, headers: Record): Promise { const page = this.getPage(); // Build URL pattern from origin (e.g., "api.example.com" -> "**://api.example.com/**") // Handle both full URLs and just hostnames let urlPattern: string; try { const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`); // Match any protocol, the host, and any path urlPattern = `**://${url.host}/**`; } catch { // If parsing fails, treat as hostname pattern urlPattern = `**://${origin}/**`; } // Remove existing route for this origin if any const existingHandler = this.scopedHeaderRoutes.get(urlPattern); if (existingHandler) { await page.unroute(urlPattern, existingHandler); } // Create handler that adds headers to matching requests const handler = async (route: Route) => { const requestHeaders = route.request().headers(); await route.continue({ headers: safeHeaderMerge(requestHeaders, headers), }); }; // Store and register the route this.scopedHeaderRoutes.set(urlPattern, handler); await page.route(urlPattern, handler); } /** * Clear scoped headers for an origin (or all if no origin specified) */ async clearScopedHeaders(origin?: string): Promise { const page = this.getPage(); if (origin) { let urlPattern: string; try { const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`); urlPattern = `**://${url.host}/**`; } catch { urlPattern = `**://${origin}/**`; } const handler = this.scopedHeaderRoutes.get(urlPattern); if (handler) { await page.unroute(urlPattern, handler); this.scopedHeaderRoutes.delete(urlPattern); } } else { // Clear all scoped header routes for (const [pattern, handler] of this.scopedHeaderRoutes) { await page.unroute(pattern, handler); } this.scopedHeaderRoutes.clear(); } } /** * Start tracing */ async startTracing(options: { screenshots?: boolean; snapshots?: boolean }): Promise { const context = this.contexts[0]; if (context) { await context.tracing.start({ screenshots: options.screenshots ?? true, snapshots: options.snapshots ?? true, }); } } /** * Stop tracing and save */ async stopTracing(path?: string): Promise { const context = this.contexts[0]; if (context) { await context.tracing.stop(path ? { path } : undefined); } } /** * Get the current browser context (first context) */ getContext(): BrowserContext | null { return this.contexts[0] ?? null; } /** * Save storage state (cookies, localStorage, etc.) */ async saveStorageState(path: string): Promise { const context = this.contexts[0]; if (context) { await context.storageState({ path }); } } /** * Get all pages */ getPages(): Page[] { return this.pages; } /** * Get current page index */ getActiveIndex(): number { return this.activePageIndex; } /** * Get the current browser instance */ getBrowser(): Browser | null { return this.browser; } /** * Check if an existing CDP connection is still alive * by verifying we can access browser contexts and that at least one has pages */ private isCdpConnectionAlive(): boolean { if (!this.browser) return false; try { const contexts = this.browser.contexts(); if (contexts.length === 0) return false; return contexts.some((context) => context.pages().some((page) => this.isUsableCDPPage(page))); } catch { return false; } } /** * Check if CDP connection needs to be re-established */ private needsCdpReconnect(cdpEndpoint: string): boolean { if (!this.browser?.isConnected()) return true; if (this.cdpEndpoint !== cdpEndpoint) return true; if (!this.isCdpConnectionAlive()) return true; return false; } /** * Close a Browserbase session via API */ private async closeBrowserbaseSession(sessionId: string, apiKey: string): Promise { await fetch(`https://api.browserbase.com/v1/sessions/${sessionId}`, { method: 'DELETE', headers: { 'X-BB-API-Key': apiKey, }, }); } /** * Close a Browser Use session via API */ private async closeBrowserUseSession(sessionId: string, apiKey: string): Promise { const response = await fetch(`https://api.browser-use.com/api/v2/browsers/${sessionId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-Browser-Use-API-Key': apiKey, }, body: JSON.stringify({ action: 'stop' }), }); if (!response.ok) { throw new Error(`Failed to close Browser Use session: ${response.statusText}`); } } /** * Close a Kernel session via API */ private async closeKernelSession(sessionId: string, apiKey: string): Promise { const response = await fetch(`https://api.onkernel.com/browsers/${sessionId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, }, }); if (!response.ok) { throw new Error(`Failed to close Kernel session: ${response.statusText}`); } } /** * Connect to Browserbase remote browser via CDP. * Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables. */ private async connectToBrowserbase(): Promise { this.stealthConnectionKind = 'provider-browserbase'; const browserbaseApiKey = process.env.BROWSERBASE_API_KEY; const browserbaseProjectId = process.env.BROWSERBASE_PROJECT_ID; if (!browserbaseApiKey || !browserbaseProjectId) { throw new Error( 'BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID are required when using browserbase as a provider' ); } const response = await fetch('https://api.browserbase.com/v1/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-BB-API-Key': browserbaseApiKey, }, body: JSON.stringify({ projectId: browserbaseProjectId, }), }); if (!response.ok) { throw new Error(`Failed to create Browserbase session: ${response.statusText}`); } const session = (await response.json()) as { id: string; connectUrl: string }; const browser = await chromium.connectOverCDP(session.connectUrl).catch(() => { throw new Error('Failed to connect to Browserbase session via CDP'); }); try { const contexts = browser.contexts(); if (contexts.length === 0) { throw new Error('No browser context found in Browserbase session'); } const context = contexts[0]; await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); const pages = context.pages(); const page = pages[0] ?? (await context.newPage()); this.browserbaseSessionId = session.id; this.browserbaseApiKey = browserbaseApiKey; this.browser = browser; context.setDefaultTimeout(10000); this.contexts.push(context); this.setupContextTracking(context); this.pages.push(page); this.activePageIndex = 0; this.setupPageTracking(page); } catch (error) { await this.closeBrowserbaseSession(session.id, browserbaseApiKey).catch((sessionError) => { console.error('Failed to close Browserbase session during cleanup:', sessionError); }); throw error; } } /** * Find or create a Kernel profile by name. * Returns the profile object if successful. */ private async findOrCreateKernelProfile( profileName: string, apiKey: string ): Promise<{ name: string }> { // First, try to get the existing profile const getResponse = await fetch( `https://api.onkernel.com/profiles/${encodeURIComponent(profileName)}`, { method: 'GET', headers: { Authorization: `Bearer ${apiKey}`, }, } ); if (getResponse.ok) { // Profile exists, return it return { name: profileName }; } if (getResponse.status !== 404) { throw new Error(`Failed to check Kernel profile: ${getResponse.statusText}`); } // Profile doesn't exist, create it const createResponse = await fetch('https://api.onkernel.com/profiles', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ name: profileName }), }); if (!createResponse.ok) { throw new Error(`Failed to create Kernel profile: ${createResponse.statusText}`); } return { name: profileName }; } /** * Connect to Kernel remote browser via CDP. * Requires KERNEL_API_KEY environment variable. */ private async connectToKernel(): Promise { this.stealthConnectionKind = 'provider-kernel'; const kernelApiKey = process.env.KERNEL_API_KEY; if (!kernelApiKey) { throw new Error('KERNEL_API_KEY is required when using kernel as a provider'); } // Find or create profile if KERNEL_PROFILE_NAME is set const profileName = process.env.KERNEL_PROFILE_NAME; let profileConfig: { profile: { name: string; save_changes: boolean } } | undefined; if (profileName) { await this.findOrCreateKernelProfile(profileName, kernelApiKey); profileConfig = { profile: { name: profileName, save_changes: true, // Save cookies/state back to the profile when session ends }, }; } const response = await fetch('https://api.onkernel.com/browsers', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${kernelApiKey}`, }, body: JSON.stringify({ // Kernel browsers are headful by default with stealth mode available // The user can configure these via environment variables if needed headless: process.env.KERNEL_HEADLESS?.toLowerCase() === 'true', stealth: process.env.KERNEL_STEALTH?.toLowerCase() !== 'false', // Default to stealth mode timeout_seconds: parseInt(process.env.KERNEL_TIMEOUT_SECONDS || '300', 10), // Load and save to a profile if specified ...profileConfig, }), }); if (!response.ok) { throw new Error(`Failed to create Kernel session: ${response.statusText}`); } let session: { session_id: string; cdp_ws_url: string }; try { session = (await response.json()) as { session_id: string; cdp_ws_url: string }; } catch (error) { throw new Error( `Failed to parse Kernel session response: ${error instanceof Error ? error.message : String(error)}` ); } if (!session.session_id || !session.cdp_ws_url) { throw new Error( `Invalid Kernel session response: missing ${!session.session_id ? 'session_id' : 'cdp_ws_url'}` ); } const browser = await chromium.connectOverCDP(session.cdp_ws_url).catch(() => { throw new Error('Failed to connect to Kernel session via CDP'); }); try { const contexts = browser.contexts(); let context: BrowserContext; let page: Page; // Kernel browsers launch with a default context and page if (contexts.length === 0) { context = await browser.newContext(); await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); page = await context.newPage(); } else { context = contexts[0]; await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); const pages = context.pages(); page = pages[0] ?? (await context.newPage()); } this.kernelSessionId = session.session_id; this.kernelApiKey = kernelApiKey; this.browser = browser; context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.pages.push(page); this.activePageIndex = 0; this.setupPageTracking(page); this.setupContextTracking(context); } catch (error) { await this.closeKernelSession(session.session_id, kernelApiKey).catch((sessionError) => { console.error('Failed to close Kernel session during cleanup:', sessionError); }); throw error; } } /** * Connect to Browser Use remote browser via CDP. * Requires BROWSER_USE_API_KEY environment variable. */ private async connectToBrowserUse(): Promise { this.stealthConnectionKind = 'provider-browseruse'; const browserUseApiKey = process.env.BROWSER_USE_API_KEY; if (!browserUseApiKey) { throw new Error('BROWSER_USE_API_KEY is required when using browseruse as a provider'); } const response = await fetch('https://api.browser-use.com/api/v2/browsers', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Browser-Use-API-Key': browserUseApiKey, }, body: JSON.stringify({}), }); if (!response.ok) { throw new Error(`Failed to create Browser Use session: ${response.statusText}`); } let session: { id: string; cdpUrl: string }; try { session = (await response.json()) as { id: string; cdpUrl: string }; } catch (error) { throw new Error( `Failed to parse Browser Use session response: ${error instanceof Error ? error.message : String(error)}` ); } if (!session.id || !session.cdpUrl) { throw new Error( `Invalid Browser Use session response: missing ${!session.id ? 'id' : 'cdpUrl'}` ); } const browser = await chromium.connectOverCDP(session.cdpUrl).catch(() => { throw new Error('Failed to connect to Browser Use session via CDP'); }); try { const contexts = browser.contexts(); let context: BrowserContext; let page: Page; if (contexts.length === 0) { context = await browser.newContext(); await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); page = await context.newPage(); } else { context = contexts[0]; await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); const pages = context.pages(); page = pages[0] ?? (await context.newPage()); } this.browserUseSessionId = session.id; this.browserUseApiKey = browserUseApiKey; this.browser = browser; context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.pages.push(page); this.activePageIndex = 0; this.setupPageTracking(page); this.setupContextTracking(context); } catch (error) { await this.closeBrowserUseSession(session.id, browserUseApiKey).catch((sessionError) => { console.error('Failed to close Browser Use session during cleanup:', sessionError); }); throw error; } } /** * Launch the browser with the specified options * If already launched, this is a no-op (browser stays open) */ async launch(options: LaunchCommand): Promise { // Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined); const hasExtensions = !!options.extensions?.length; const hasStorageState = !!options.storageState; if (hasExtensions && cdpEndpoint) { throw new Error('Extensions cannot be used with CDP connection'); } if (hasStorageState && hasExtensions) { throw new Error( 'Storage state cannot be used with extensions (extensions require persistent context)' ); } if (this.isLaunched()) { const needsRelaunch = (!cdpEndpoint && !options.autoConnect && this.cdpEndpoint !== null) || (!!cdpEndpoint && this.needsCdpReconnect(cdpEndpoint)) || (!!options.autoConnect && !this.isCdpConnectionAlive()); if (needsRelaunch) { await this.close(); } else if (options.autoConnect && this.isCdpConnectionAlive()) { // Already connected via auto-connect, no need to reconnect return; } else { return; } } if (options.colorScheme) { this.colorScheme = options.colorScheme; } this.stealthEnabled = true; this.contextLocale = this.resolveStealthLocale(options.headers); this.contextTimezoneId = this.resolveStealthTimezoneId(); this.contextHeaders = undefined; this.contextUserAgent = options.userAgent; // -p flag takes precedence over AGENT_BROWSER_PROVIDER. const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER; if (cdpEndpoint || options.autoConnect) { this.stealthConnectionKind = 'cdp'; } else if (provider === 'browserbase') { this.stealthConnectionKind = 'provider-browserbase'; } else if (provider === 'browseruse') { this.stealthConnectionKind = 'provider-browseruse'; } else if (provider === 'kernel') { this.stealthConnectionKind = 'provider-kernel'; } else { this.stealthConnectionKind = 'local'; } this.logStealthPolicy('launch policy', options.browser ?? 'chromium'); if (cdpEndpoint) { await this.connectViaCDP(cdpEndpoint); return; } if (options.autoConnect) { await this.autoConnectViaCDP(); return; } // Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var if (provider === 'browserbase') { await this.connectToBrowserbase(); return; } if (provider === 'browseruse') { await this.connectToBrowserUse(); return; } // Kernel: requires explicit opt-in via -p kernel flag or AGENT_BROWSER_PROVIDER=kernel if (provider === 'kernel') { await this.connectToKernel(); return; } const browserType = options.browser ?? 'chromium'; if (hasExtensions && browserType !== 'chromium') { throw new Error('Extensions are only supported in Chromium'); } // allowFileAccess is only supported in Chromium if (options.allowFileAccess && browserType !== 'chromium') { throw new Error('allowFileAccess is only supported in Chromium'); } const launcher = browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium; // Chromium launches always use the Chrome channel unless a custom executable is provided. const chromeChannel = browserType === 'chromium' && !options.executablePath ? 'chrome' : undefined; const stealthPolicy = this.getStealthPolicy(browserType); const contextDefaults = this.buildStealthContextDefaults(stealthPolicy, options.headers); const extraHTTPHeaders = contextDefaults.extraHTTPHeaders; this.contextLocale = contextDefaults.locale; this.contextTimezoneId = contextDefaults.timezoneId; this.contextHeaders = contextDefaults.extraHTTPHeaders; let contextUserAgent = options.userAgent; if (!contextUserAgent && stealthPolicy.enabled && browserType === 'chromium') { const versionHint = this.getStealthUserAgentVersionHint(); if (versionHint) { contextUserAgent = this.buildStealthChromiumUserAgent(versionHint); } } this.contextUserAgent = contextUserAgent; // Build base args array with file access flags and stealth args when policy allows. const fileAccessArgs = options.allowFileAccess ? ['--allow-file-access-from-files', '--allow-file-access'] : []; const stealthArgs = stealthPolicy.applyChromiumArgs ? STEALTH_CHROMIUM_ARGS : []; const hasUserAgentArg = options.args?.some((arg) => arg.startsWith('--user-agent=')); const launchUserAgentArgs = !hasUserAgentArg && !options.userAgent && stealthPolicy.enabled && browserType === 'chromium' && contextUserAgent ? [`--user-agent=${contextUserAgent}`] : []; const implicitArgs = [...fileAccessArgs, ...stealthArgs, ...launchUserAgentArgs]; const baseArgs = options.args ? [...implicitArgs, ...options.args] : implicitArgs.length > 0 ? implicitArgs : undefined; // Auto-detect args that control window size and disable viewport emulation // so Playwright doesn't override the browser's own sizing behavior const hasWindowSizeArgs = baseArgs?.some( (arg) => arg === '--start-maximized' || arg.startsWith('--window-size=') ); const viewport = options.viewport !== undefined ? options.viewport : hasWindowSizeArgs ? null : { width: 1280, height: 720 }; let context: BrowserContext; if (hasExtensions) { // Extensions require persistent context in a temp directory const extPaths = options.extensions!.join(','); const session = process.env.AGENT_BROWSER_SESSION || 'default'; // Combine extension args with custom args and file access args const extArgs = [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`]; const allArgs = baseArgs ? [...extArgs, ...baseArgs] : extArgs; context = await launcher.launchPersistentContext( path.join(os.tmpdir(), `agent-browser-ext-${session}`), { headless: false, executablePath: options.executablePath, ...(chromeChannel && { channel: chromeChannel }), args: allArgs, viewport, extraHTTPHeaders, userAgent: contextUserAgent, ...(this.contextLocale && { locale: this.contextLocale }), ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }), ...(options.proxy && { proxy: options.proxy }), ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false, ...(this.colorScheme && { colorScheme: this.colorScheme }), } ); this.isPersistentContext = true; } else { // Regular ephemeral browser this.browser = await launcher.launch({ headless: options.headless ?? false, executablePath: options.executablePath, ...(chromeChannel && { channel: chromeChannel }), args: baseArgs, }); this.cdpEndpoint = null; if (stealthPolicy.enabled && browserType === 'chromium') { await applyBrowserLevelStealth(this.browser, { userAgent: contextUserAgent, }); } if (!options.userAgent && stealthPolicy.enabled && browserType === 'chromium') { const runtimeVersion = this.extractChromiumVersion(this.browser.version()); if (runtimeVersion) { contextUserAgent = this.buildStealthChromiumUserAgent(runtimeVersion); this.contextUserAgent = contextUserAgent; } } // Check for auto-load state file (supports encrypted files) let storageState: | string | { cookies: Array<{ name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean; sameSite: 'Strict' | 'Lax' | 'None'; }>; origins: Array<{ origin: string; localStorage: Array<{ name: string; value: string }>; }>; } | undefined = options.storageState ? options.storageState : undefined; if (!storageState && options.autoStateFilePath) { try { const fs = await import('fs'); if (fs.existsSync(options.autoStateFilePath)) { const content = fs.readFileSync(options.autoStateFilePath, 'utf8'); const parsed = JSON.parse(content); if (isEncryptedPayload(parsed)) { const key = getEncryptionKey(); if (key) { try { const decrypted = decryptData(parsed, key); storageState = JSON.parse(decrypted); if (process.env.AGENT_BROWSER_DEBUG === '1') { console.error( `[DEBUG] Auto-loading session state (decrypted): ${options.autoStateFilePath}` ); } } catch (decryptErr) { const warning = 'Failed to decrypt state file - wrong encryption key? Starting fresh.'; this.launchWarnings.push(warning); console.error(`[WARN] ${warning}`); if (process.env.AGENT_BROWSER_DEBUG === '1') { console.error(`[DEBUG] Decryption error:`, decryptErr); } } } else { const warning = `State file is encrypted but ${ENCRYPTION_KEY_ENV} not set - starting fresh`; this.launchWarnings.push(warning); console.error(`[WARN] ${warning}`); } } else { storageState = options.autoStateFilePath; if (process.env.AGENT_BROWSER_DEBUG === '1') { console.error(`[DEBUG] Auto-loading session state: ${options.autoStateFilePath}`); } } } } catch (err) { if (process.env.AGENT_BROWSER_DEBUG === '1') { console.error(`[DEBUG] Failed to load state file, starting fresh:`, err); } } } context = await this.browser.newContext({ viewport, extraHTTPHeaders, userAgent: contextUserAgent, storageState, ...(this.contextLocale && { locale: this.contextLocale }), ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }), ...(options.proxy && { proxy: options.proxy }), ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false, ...(this.colorScheme && { colorScheme: this.colorScheme }), }); } await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.setupContextTracking(context); const page = context.pages()[0] ?? (await context.newPage()); // Only add if not already tracked (setupContextTracking may have already added it via 'page' event) if (!this.pages.includes(page)) { this.pages.push(page); this.setupPageTracking(page); } this.activePageIndex = this.pages.length > 0 ? this.pages.length - 1 : 0; } /** * Connect to a running browser via CDP (Chrome DevTools Protocol) * @param cdpEndpoint Either a port number (as string) or a full WebSocket URL (ws:// or wss://) */ private async connectViaCDP( cdpEndpoint: string | undefined, options?: { timeout?: number } ): Promise { this.stealthConnectionKind = 'cdp'; if (!cdpEndpoint) { throw new Error('CDP endpoint is required for CDP connection'); } // Determine the connection URL: // - If it starts with ws://, wss://, http://, or https://, use it directly // - If it's a numeric string (e.g., "9222"), treat as port for localhost // - Otherwise, treat it as a port number for localhost let cdpUrl: string; if ( cdpEndpoint.startsWith('ws://') || cdpEndpoint.startsWith('wss://') || cdpEndpoint.startsWith('http://') || cdpEndpoint.startsWith('https://') ) { cdpUrl = cdpEndpoint; } else if (/^\d+$/.test(cdpEndpoint)) { // Numeric string - treat as port number (handles JSON serialization quirks) cdpUrl = `http://localhost:${cdpEndpoint}`; } else { // Unknown format - still try as port for backward compatibility cdpUrl = `http://localhost:${cdpEndpoint}`; } const browser = await chromium .connectOverCDP(cdpUrl, { timeout: options?.timeout }) .catch(() => { throw new Error( `Failed to connect via CDP to ${cdpUrl}. ` + (cdpUrl.includes('localhost') ? `Make sure the app is running with --remote-debugging-port=${cdpEndpoint}` : 'Make sure the remote browser is accessible and the URL is correct.') ); }); // Validate and set up state, cleaning up browser connection if anything fails try { const contexts = browser.contexts(); if (contexts.length === 0) { throw new Error('No browser context found. Make sure the app has an open window.'); } let allPages = this.collectUsableCDPPages(contexts); if (allPages.length === 0) { // Some Chrome instances (especially with custom UI pages) expose only internal/transient // pages over CDP. Create a fresh page so commands always have a stable target. let fallbackPage: Page | null = null; for (const context of contexts) { try { const page = await context.newPage(); if (!fallbackPage) { fallbackPage = page; } if (this.isUsableCDPPage(page)) { fallbackPage = page; break; } } catch { // Try next context } } if (!fallbackPage) { throw new Error('No page found. Make sure the app has loaded content.'); } allPages = [fallbackPage]; } // All validation passed - commit state this.browser = browser; this.cdpEndpoint = cdpEndpoint; for (const context of contexts) { await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); context.setDefaultTimeout(10000); this.contexts.push(context); this.setupContextTracking(context); } for (const page of allPages) { this.pages.push(page); this.setupPageTracking(page); } this.activePageIndex = 0; } catch (error) { // Clean up browser connection if validation or setup failed await browser.close().catch(() => {}); throw error; } } /** * Get Chrome's default user data directory paths for the current platform. * Returns an array of candidate paths to check (stable, then beta/canary). */ private getChromeUserDataDirs(): string[] { const home = os.homedir(); const platform = os.platform(); if (platform === 'darwin') { return [ path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'), path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'), path.join(home, 'Library', 'Application Support', 'Chromium'), ]; } else if (platform === 'win32') { const localAppData = process.env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local'); return [ path.join(localAppData, 'Google', 'Chrome', 'User Data'), path.join(localAppData, 'Google', 'Chrome SxS', 'User Data'), path.join(localAppData, 'Chromium', 'User Data'), ]; } else { // Linux return [ path.join(home, '.config', 'google-chrome'), path.join(home, '.config', 'google-chrome-unstable'), path.join(home, '.config', 'chromium'), ]; } } /** * Try to read the DevToolsActivePort file from a Chrome user data directory. * Returns { port, wsPath } if found, or null if not available. */ private readDevToolsActivePort(userDataDir: string): { port: number; wsPath: string } | null { const filePath = path.join(userDataDir, 'DevToolsActivePort'); try { if (!existsSync(filePath)) return null; const content = readFileSync(filePath, 'utf-8').trim(); const lines = content.split('\n'); if (lines.length < 2) return null; const port = parseInt(lines[0].trim(), 10); const wsPath = lines[1].trim(); if (isNaN(port) || port <= 0 || port > 65535) return null; if (!wsPath) return null; return { port, wsPath }; } catch { return null; } } /** * Try to discover a Chrome CDP endpoint by querying an HTTP debug port. * Returns the WebSocket debugger URL if available. */ private async probeDebugPort(port: number): Promise { try { const response = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(2000), }); if (!response.ok) return null; const data = (await response.json()) as { webSocketDebuggerUrl?: string }; return data.webSocketDebuggerUrl ?? null; } catch { return null; } } /** * Auto-discover and connect to a running Chrome/Chromium instance. * * Discovery strategy: * 1. Read DevToolsActivePort from Chrome's default user data directories * 2. If found, connect using the port and WebSocket path from that file * 3. If not found, probe common debugging ports (9222, 9229, 9333) * 4. If a port responds, connect via CDP */ private async autoConnectViaCDP(): Promise { // Strategy 1: Check DevToolsActivePort files const userDataDirs = this.getChromeUserDataDirs(); for (const dir of userDataDirs) { const activePort = this.readDevToolsActivePort(dir); if (activePort) { // Try HTTP discovery first (works with --remote-debugging-port mode) const wsUrl = await this.probeDebugPort(activePort.port); if (wsUrl) { await this.connectViaCDP(wsUrl); return; } // HTTP probe failed -- Chrome M144+ chrome://inspect remote debugging uses a // WebSocket-only server with no HTTP endpoints. Connect using the WebSocket // path read directly from DevToolsActivePort. const directWsUrl = `ws://127.0.0.1:${activePort.port}${activePort.wsPath}`; try { if (process.env.AGENT_BROWSER_DEBUG === '1') { console.error( `[DEBUG] HTTP probe failed on port ${activePort.port}, ` + `attempting direct WebSocket connection to ${directWsUrl}` ); } await this.connectViaCDP(directWsUrl, { timeout: 60_000 }); return; } catch { // Direct WebSocket also failed, try next directory } } } // Strategy 2: Probe common debugging ports const commonPorts = [9222, 9229, 9333]; for (const port of commonPorts) { const wsUrl = await this.probeDebugPort(port); if (wsUrl) { await this.connectViaCDP(wsUrl); return; } } // Nothing found const platform = os.platform(); let hint: string; if (platform === 'darwin') { hint = 'Start Chrome with: /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222\n' + 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging'; } else if (platform === 'win32') { hint = 'Start Chrome with: chrome.exe --remote-debugging-port=9222\n' + 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging'; } else { hint = 'Start Chrome with: google-chrome --remote-debugging-port=9222\n' + 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging'; } throw new Error(`No running Chrome instance with remote debugging found.\n${hint}`); } /** * Set up console, error, and close tracking for a page */ private setupPageTracking(page: Page): void { if (this.colorScheme) { page.emulateMedia({ colorScheme: this.colorScheme }).catch(() => {}); } page.on('console', (msg) => { this.consoleMessages.push({ type: msg.type(), text: msg.text(), timestamp: Date.now(), }); }); page.on('pageerror', (error) => { this.pageErrors.push({ message: error.message, timestamp: Date.now(), }); }); page.on('close', () => { const index = this.pages.indexOf(page); if (index !== -1) { this.pages.splice(index, 1); if (index < this.activePageIndex) { this.activePageIndex--; } if (this.activePageIndex >= this.pages.length) { this.activePageIndex = Math.max(0, this.pages.length - 1); } } }); } /** * Set up tracking for new pages in a context (for CDP connections and popups/new tabs) * This handles pages created externally (e.g., via target="_blank" links, window.open) */ private setupContextTracking(context: BrowserContext): void { context.on('page', (page) => { const pageUrl = this.getSafePageUrl(page); if (this.isIgnoredCDPPageUrl(pageUrl)) { return; } // Only add if not already tracked (avoids duplicates when newTab() creates pages) if (!this.pages.includes(page)) { this.pages.push(page); this.setupPageTracking(page); } // Auto-switch to the newly opened tab so subsequent commands target it. // For tabs created via newTab()/newWindow(), this is redundant (they set activePageIndex after), // but for externally opened tabs (window.open, target="_blank"), this ensures the active tab // stays in sync with the browser. const newIndex = this.pages.indexOf(page); if (newIndex !== -1 && newIndex !== this.activePageIndex) { this.activePageIndex = newIndex; // Invalidate CDP session since the active page changed this.invalidateCDPSession().catch(() => {}); } }); } /** * Create a new tab in the current context */ async newTab(): Promise<{ index: number; total: number }> { if (!this.browser || this.contexts.length === 0) { throw new Error('Browser not launched'); } // Invalidate CDP session since we're switching to a new page await this.invalidateCDPSession(); const context = this.contexts[0]; // Use first context for tabs const page = await context.newPage(); // Only add if not already tracked (setupContextTracking may have already added it via 'page' event) if (!this.pages.includes(page)) { this.pages.push(page); this.setupPageTracking(page); } this.activePageIndex = this.pages.length - 1; return { index: this.activePageIndex, total: this.pages.length }; } /** * Create a new window (new context) */ async newWindow(viewport?: { width: number; height: number } | null): Promise<{ index: number; total: number; }> { if (!this.browser) { throw new Error('Browser not launched'); } const context = await this.browser.newContext({ viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport, ...(this.contextHeaders && { extraHTTPHeaders: this.contextHeaders }), ...(this.contextUserAgent && { userAgent: this.contextUserAgent }), ...(this.contextLocale && { locale: this.contextLocale }), ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }), ...(this.colorScheme && { colorScheme: this.colorScheme }), }); await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.setupContextTracking(context); const page = await context.newPage(); // Only add if not already tracked (setupContextTracking may have already added it via 'page' event) if (!this.pages.includes(page)) { this.pages.push(page); this.setupPageTracking(page); } this.activePageIndex = this.pages.length - 1; return { index: this.activePageIndex, total: this.pages.length }; } /** * Invalidate the current CDP session (must be called before switching pages) * This ensures screencast and input injection work correctly after tab switch */ private async invalidateCDPSession(): Promise { // Stop screencast if active (it's tied to the current page's CDP session) if (this.screencastActive) { await this.stopScreencast(); } // Detach and clear the CDP session if (this.cdpSession) { await this.cdpSession.detach().catch(() => {}); this.cdpSession = null; } } /** * Switch to a specific tab/page by index */ async switchTo(index: number): Promise<{ index: number; url: string; title: string }> { if (index < 0 || index >= this.pages.length) { throw new Error(`Invalid tab index: ${index}. Available: 0-${this.pages.length - 1}`); } // Invalidate CDP session before switching (it's page-specific) if (index !== this.activePageIndex) { await this.invalidateCDPSession(); } this.activePageIndex = index; const page = this.pages[index]; return { index: this.activePageIndex, url: page.url(), title: '', // Title requires async, will be fetched separately }; } /** * Close a specific tab/page */ async closeTab(index?: number): Promise<{ closed: number; remaining: number }> { const targetIndex = index ?? this.activePageIndex; if (targetIndex < 0 || targetIndex >= this.pages.length) { throw new Error(`Invalid tab index: ${targetIndex}`); } if (this.pages.length === 1) { throw new Error('Cannot close the last tab. Use "close" to close the browser.'); } // If closing the active tab, invalidate CDP session first if (targetIndex === this.activePageIndex) { await this.invalidateCDPSession(); } const page = this.pages[targetIndex]; await page.close(); this.pages.splice(targetIndex, 1); // Adjust active index if needed if (this.activePageIndex >= this.pages.length) { this.activePageIndex = this.pages.length - 1; } else if (this.activePageIndex > targetIndex) { this.activePageIndex--; } return { closed: targetIndex, remaining: this.pages.length }; } /** * List all tabs with their info */ async listTabs(): Promise> { const tabs = await Promise.all( this.pages.map(async (page, index) => ({ index, url: page.url(), title: await page.title().catch(() => ''), active: index === this.activePageIndex, })) ); return tabs; } /** * Get or create a CDP session for the current page * Only works with Chromium-based browsers */ async getCDPSession(): Promise { if (this.cdpSession) { return this.cdpSession; } const page = this.getPage(); const context = page.context(); // Create a new CDP session attached to the page this.cdpSession = await context.newCDPSession(page); return this.cdpSession; } /** * Check if screencast is currently active */ isScreencasting(): boolean { return this.screencastActive; } /** * Start screencast - streams viewport frames via CDP * @param callback Function called for each frame * @param options Screencast options */ async startScreencast( callback: (frame: ScreencastFrame) => void, options?: ScreencastOptions ): Promise { if (this.screencastActive) { throw new Error('Screencast already active'); } const cdp = await this.getCDPSession(); this.frameCallback = callback; this.screencastActive = true; // Create and store the frame handler so we can remove it later this.screencastFrameHandler = async (params: any) => { const frame: ScreencastFrame = { data: params.data, metadata: params.metadata, sessionId: params.sessionId, }; // Acknowledge the frame to receive the next one await cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId }); // Call the callback with the frame if (this.frameCallback) { this.frameCallback(frame); } }; // Listen for screencast frames cdp.on('Page.screencastFrame', this.screencastFrameHandler); // Start the screencast await cdp.send('Page.startScreencast', { format: options?.format ?? 'jpeg', quality: options?.quality ?? 80, maxWidth: options?.maxWidth ?? 1280, maxHeight: options?.maxHeight ?? 720, everyNthFrame: options?.everyNthFrame ?? 1, }); } /** * Stop screencast */ async stopScreencast(): Promise { if (!this.screencastActive) { return; } try { const cdp = await this.getCDPSession(); await cdp.send('Page.stopScreencast'); // Remove the event listener to prevent accumulation if (this.screencastFrameHandler) { cdp.off('Page.screencastFrame', this.screencastFrameHandler); } } catch { // Ignore errors when stopping } this.screencastActive = false; this.frameCallback = null; this.screencastFrameHandler = null; } /** * Check if profiling is currently active */ isProfilingActive(): boolean { return this.profilingActive; } /** * Start CDP profiling (Tracing) */ async startProfiling(options?: { categories?: string[] }): Promise { if (this.profilingActive) { throw new Error('Profiling already active'); } const cdp = await this.getCDPSession(); const dataHandler = (params: { value?: TraceEvent[] }) => { if (params.value) { for (const evt of params.value) { if (this.profileChunks.length >= BrowserManager.MAX_PROFILE_EVENTS) { if (!this.profileEventsDropped) { this.profileEventsDropped = true; console.warn( `Profiling: exceeded ${BrowserManager.MAX_PROFILE_EVENTS} events, dropping further data` ); } return; } this.profileChunks.push(evt); } } }; const completeHandler = () => { if (this.profileCompleteResolver) { this.profileCompleteResolver(); } }; cdp.on('Tracing.dataCollected', dataHandler); cdp.on('Tracing.tracingComplete', completeHandler); const categories = options?.categories ?? [ 'devtools.timeline', 'disabled-by-default-devtools.timeline', 'disabled-by-default-devtools.timeline.frame', 'disabled-by-default-devtools.timeline.stack', 'v8.execute', 'disabled-by-default-v8.cpu_profiler', 'disabled-by-default-v8.cpu_profiler.hires', 'v8', 'disabled-by-default-v8.runtime_stats', 'blink', 'blink.user_timing', 'latencyInfo', 'renderer.scheduler', 'sequence_manager', 'toplevel', ]; try { await cdp.send('Tracing.start', { traceConfig: { includedCategories: categories, enableSampling: true, }, transferMode: 'ReportEvents', }); } catch (error) { cdp.off('Tracing.dataCollected', dataHandler); cdp.off('Tracing.tracingComplete', completeHandler); throw error; } // Only commit state after the CDP call succeeds this.profilingActive = true; this.profileChunks = []; this.profileEventsDropped = false; this.profileDataHandler = dataHandler; this.profileCompleteHandler = completeHandler; } /** * Stop CDP profiling and save to file */ async stopProfiling(outputPath: string): Promise<{ path: string; eventCount: number }> { if (!this.profilingActive) { throw new Error('No profiling session active'); } const cdp = await this.getCDPSession(); const TRACE_TIMEOUT_MS = 30_000; const completePromise = new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error('Profiling data collection timed out')), TRACE_TIMEOUT_MS ); this.profileCompleteResolver = () => { clearTimeout(timer); resolve(); }; }); await cdp.send('Tracing.end'); let chunks: TraceEvent[]; try { await completePromise; chunks = this.profileChunks; } finally { if (this.profileDataHandler) { cdp.off('Tracing.dataCollected', this.profileDataHandler); } if (this.profileCompleteHandler) { cdp.off('Tracing.tracingComplete', this.profileCompleteHandler); } this.profilingActive = false; this.profileChunks = []; this.profileEventsDropped = false; this.profileCompleteResolver = null; this.profileDataHandler = null; this.profileCompleteHandler = null; } const clockDomain = process.platform === 'linux' ? 'LINUX_CLOCK_MONOTONIC' : process.platform === 'darwin' ? 'MAC_MACH_ABSOLUTE_TIME' : undefined; const traceData: Record = { traceEvents: chunks, }; if (clockDomain) { traceData.metadata = { 'clock-domain': clockDomain }; } const dir = path.dirname(outputPath); await mkdir(dir, { recursive: true }); await writeFile(outputPath, JSON.stringify(traceData)); const eventCount = chunks.length; return { path: outputPath, eventCount }; } /** * Inject a mouse event via CDP */ async injectMouseEvent(params: { type: 'mousePressed' | 'mouseReleased' | 'mouseMoved' | 'mouseWheel'; x: number; y: number; button?: 'left' | 'right' | 'middle' | 'none'; clickCount?: number; deltaX?: number; deltaY?: number; modifiers?: number; // 1=Alt, 2=Ctrl, 4=Meta, 8=Shift }): Promise { const cdp = await this.getCDPSession(); const cdpButton = params.button === 'left' ? 'left' : params.button === 'right' ? 'right' : params.button === 'middle' ? 'middle' : 'none'; await cdp.send('Input.dispatchMouseEvent', { type: params.type, x: params.x, y: params.y, button: cdpButton, clickCount: params.clickCount ?? 1, deltaX: params.deltaX ?? 0, deltaY: params.deltaY ?? 0, modifiers: params.modifiers ?? 0, }); } /** * Inject a keyboard event via CDP */ async injectKeyboardEvent(params: { type: 'keyDown' | 'keyUp' | 'char'; key?: string; code?: string; text?: string; modifiers?: number; // 1=Alt, 2=Ctrl, 4=Meta, 8=Shift }): Promise { const cdp = await this.getCDPSession(); await cdp.send('Input.dispatchKeyEvent', { type: params.type, key: params.key, code: params.code, text: params.text, modifiers: params.modifiers ?? 0, }); } /** * Inject touch event via CDP (for mobile emulation) */ async injectTouchEvent(params: { type: 'touchStart' | 'touchEnd' | 'touchMove' | 'touchCancel'; touchPoints: Array<{ x: number; y: number; id?: number }>; modifiers?: number; }): Promise { const cdp = await this.getCDPSession(); await cdp.send('Input.dispatchTouchEvent', { type: params.type, touchPoints: params.touchPoints.map((tp, i) => ({ x: tp.x, y: tp.y, id: tp.id ?? i, })), modifiers: params.modifiers ?? 0, }); } /** * Check if video recording is currently active */ isRecording(): boolean { return this.recordingContext !== null; } /** * Start recording to a video file using Playwright's native video recording. * Creates a fresh browser context with video recording enabled. * Automatically captures current URL and transfers cookies/storage if no URL provided. * * @param outputPath - Path to the output video file (will be .webm) * @param url - Optional URL to navigate to (defaults to current page URL) */ async startRecording(outputPath: string, url?: string): Promise { if (this.recordingContext) { throw new Error( "Recording already in progress. Run 'record stop' first, or use 'record restart' to stop and start a new recording." ); } if (!this.browser) { throw new Error('Browser not launched. Call launch first.'); } // Check if output file already exists if (existsSync(outputPath)) { throw new Error(`Output file already exists: ${outputPath}`); } // Validate output path is .webm (Playwright native format) if (!outputPath.endsWith('.webm')) { throw new Error( 'Playwright native recording only supports WebM format. Please use a .webm extension.' ); } // Auto-capture current URL if none provided const currentPage = this.pages.length > 0 ? this.pages[this.activePageIndex] : null; const currentContext = this.contexts.length > 0 ? this.contexts[0] : null; if (!url && currentPage) { const currentUrl = currentPage.url(); if (currentUrl && currentUrl !== 'about:blank') { url = currentUrl; } } // Capture state from current context (cookies + storage) let storageState: | { cookies: Array<{ name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean; sameSite: 'Strict' | 'Lax' | 'None'; }>; origins: Array<{ origin: string; localStorage: Array<{ name: string; value: string }>; }>; } | undefined; if (currentContext) { try { storageState = await currentContext.storageState(); } catch { // Ignore errors - context might be closed or invalid } } // Create a temp directory for video recording const session = process.env.AGENT_BROWSER_SESSION || 'default'; this.recordingTempDir = path.join( os.tmpdir(), `agent-browser-recording-${session}-${Date.now()}` ); mkdirSync(this.recordingTempDir, { recursive: true }); this.recordingOutputPath = outputPath; // Create a new context with video recording enabled and restored state const viewport = { width: 1280, height: 720 }; this.recordingContext = await this.browser.newContext({ viewport, recordVideo: { dir: this.recordingTempDir, size: viewport, }, storageState, }); this.recordingContext.setDefaultTimeout(10000); // Create a page in the recording context this.recordingPage = await this.recordingContext.newPage(); // Add the recording context and page to our managed lists this.contexts.push(this.recordingContext); this.pages.push(this.recordingPage); this.activePageIndex = this.pages.length - 1; // Set up page tracking this.setupPageTracking(this.recordingPage); // Invalidate CDP session since we switched pages await this.invalidateCDPSession(); // Navigate to URL if provided or captured if (url) { await this.recordingPage.goto(url, { waitUntil: 'load' }); } } /** * Stop recording and save the video file * @returns Recording result with path */ async stopRecording(): Promise<{ path: string; frames: number; error?: string }> { if (!this.recordingContext || !this.recordingPage) { return { path: '', frames: 0, error: 'No recording in progress' }; } const outputPath = this.recordingOutputPath; try { // Get the video object before closing the page const video = this.recordingPage.video(); // Remove recording page/context from our managed lists before closing const pageIndex = this.pages.indexOf(this.recordingPage); if (pageIndex !== -1) { this.pages.splice(pageIndex, 1); } const contextIndex = this.contexts.indexOf(this.recordingContext); if (contextIndex !== -1) { this.contexts.splice(contextIndex, 1); } // Close the page to finalize the video await this.recordingPage.close(); // Save the video to the desired output path if (video) { await video.saveAs(outputPath); } // Clean up temp directory if (this.recordingTempDir) { rmSync(this.recordingTempDir, { recursive: true, force: true }); } // Close the recording context await this.recordingContext.close(); // Reset recording state this.recordingContext = null; this.recordingPage = null; this.recordingOutputPath = ''; this.recordingTempDir = ''; // Adjust active page index if (this.pages.length > 0) { this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1); } else { this.activePageIndex = 0; } // Invalidate CDP session since we may have switched pages await this.invalidateCDPSession(); return { path: outputPath, frames: 0 }; // Playwright doesn't expose frame count } catch (error) { // Clean up temp directory on error if (this.recordingTempDir) { rmSync(this.recordingTempDir, { recursive: true, force: true }); } // Reset state on error this.recordingContext = null; this.recordingPage = null; this.recordingOutputPath = ''; this.recordingTempDir = ''; const message = error instanceof Error ? error.message : String(error); return { path: outputPath, frames: 0, error: message }; } } /** * Restart recording - stops current recording (if any) and starts a new one. * Convenience method that combines stopRecording and startRecording. * * @param outputPath - Path to the output video file (must be .webm) * @param url - Optional URL to navigate to (defaults to current page URL) * @returns Result from stopping the previous recording (if any) */ async restartRecording( outputPath: string, url?: string ): Promise<{ previousPath?: string; stopped: boolean }> { let previousPath: string | undefined; let stopped = false; // Stop current recording if active if (this.recordingContext) { const result = await this.stopRecording(); previousPath = result.path; stopped = true; } // Start new recording await this.startRecording(outputPath, url); return { previousPath, stopped }; } /** * Close the browser and clean up */ async close(): Promise { // Stop recording if active (saves video) if (this.recordingContext) { await this.stopRecording(); } // Stop screencast if active if (this.screencastActive) { await this.stopScreencast(); } // Clean up profiling state if active (without saving) if (this.profilingActive) { const cdp = this.cdpSession; if (cdp) { if (this.profileDataHandler) { cdp.off('Tracing.dataCollected', this.profileDataHandler); } if (this.profileCompleteHandler) { cdp.off('Tracing.tracingComplete', this.profileCompleteHandler); } await cdp.send('Tracing.end').catch(() => {}); } this.profilingActive = false; this.profileChunks = []; this.profileEventsDropped = false; this.profileCompleteResolver = null; this.profileDataHandler = null; this.profileCompleteHandler = null; } // Clean up CDP session if (this.cdpSession) { await this.cdpSession.detach().catch(() => {}); this.cdpSession = null; } if (this.browserbaseSessionId && this.browserbaseApiKey) { await this.closeBrowserbaseSession(this.browserbaseSessionId, this.browserbaseApiKey).catch( (error) => { console.error('Failed to close Browserbase session:', error); } ); this.browser = null; } else if (this.browserUseSessionId && this.browserUseApiKey) { await this.closeBrowserUseSession(this.browserUseSessionId, this.browserUseApiKey).catch( (error) => { console.error('Failed to close Browser Use session:', error); } ); this.browser = null; } else if (this.kernelSessionId && this.kernelApiKey) { await this.closeKernelSession(this.kernelSessionId, this.kernelApiKey).catch((error) => { console.error('Failed to close Kernel session:', error); }); this.browser = null; } else if (this.cdpEndpoint !== null) { // CDP: only disconnect, don't close external app's pages if (this.browser) { await this.browser.close().catch(() => {}); this.browser = null; } } else { // Regular browser: close everything for (const page of this.pages) { await page.close().catch(() => {}); } for (const context of this.contexts) { await context.close().catch(() => {}); } if (this.browser) { await this.browser.close().catch(() => {}); this.browser = null; } } this.pages = []; this.contexts = []; this.cdpEndpoint = null; this.browserbaseSessionId = null; this.browserbaseApiKey = null; this.browserUseSessionId = null; this.browserUseApiKey = null; this.kernelSessionId = null; this.kernelApiKey = null; this.isPersistentContext = false; this.activePageIndex = 0; this.colorScheme = null; this.stealthEnabled = true; this.stealthConnectionKind = 'local'; this.contextLocale = undefined; this.contextTimezoneId = undefined; this.contextHeaders = undefined; this.contextUserAgent = undefined; this.refMap = {}; this.lastSnapshot = ''; this.frameCallback = null; } }