import { createErrorResponse, ToolResult } from '@/common/tool-handler'; import { BaseBrowserToolExecutor } from '../base-browser'; import { TOOL_NAMES } from 'chrome-mcp-shared'; interface NetworkDebuggerStartToolParams { url?: string; // URL to navigate to or focus. If not provided, uses active tab. maxCaptureTime?: number; inactivityTimeout?: number; // Inactivity timeout (milliseconds) includeStatic?: boolean; // if include static resources } // Network request object interface interface NetworkRequestInfo { requestId: string; url: string; method: string; requestHeaders?: Record; // Will be removed after common headers extraction responseHeaders?: Record; // Will be removed after common headers extraction requestTime?: number; // Timestamp of the request responseTime?: number; // Timestamp of the response type: string; // Resource type (e.g., Document, XHR, Fetch, Script, Stylesheet) status: string; // 'pending', 'complete', 'error' statusCode?: number; statusText?: string; requestBody?: string; responseBody?: string; base64Encoded?: boolean; // For responseBody encodedDataLength?: number; // Actual bytes received errorText?: string; // If loading failed canceled?: boolean; // If loading was canceled mimeType?: string; specificRequestHeaders?: Record; // Headers unique to this request specificResponseHeaders?: Record; // Headers unique to this response [key: string]: any; // Allow other properties from debugger events } // Static resource file extensions list const STATIC_RESOURCE_EXTENSIONS = [ '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg', '.ico', '.cur', '.css', '.woff', '.woff2', '.ttf', '.eot', '.otf', '.mp3', '.mp4', '.avi', '.mov', '.webm', '.ogg', '.wav', '.pdf', '.zip', '.rar', '.7z', '.iso', '.dmg', '.js', '.jsx', '.ts', '.tsx', '.map', // Source maps ]; // Ad and analytics domains list const AD_ANALYTICS_DOMAINS = [ 'google-analytics.com', 'googletagmanager.com', 'analytics.google.com', 'doubleclick.net', 'googlesyndication.com', 'googleads.g.doubleclick.net', 'facebook.com/tr', 'connect.facebook.net', 'bat.bing.com', 'linkedin.com', // Often for tracking pixels/insights 'analytics.twitter.com', 'static.hotjar.com', 'script.hotjar.com', 'stats.g.doubleclick.net', 'amazon-adsystem.com', 'adservice.google.com', 'pagead2.googlesyndication.com', 'ads-twitter.com', 'ads.yahoo.com', 'adroll.com', 'adnxs.com', 'criteo.com', 'quantserve.com', 'scorecardresearch.com', 'segment.io', 'amplitude.com', 'mixpanel.com', 'optimizely.com', 'crazyegg.com', 'clicktale.net', 'mouseflow.com', 'fullstory.com', 'clarity.ms', ]; const DEBUGGER_PROTOCOL_VERSION = '1.3'; const MAX_RESPONSE_BODY_SIZE_BYTES = 1 * 1024 * 1024; // 1MB const DEFAULT_MAX_CAPTURE_TIME_MS = 3 * 60 * 1000; // 3 minutes const DEFAULT_INACTIVITY_TIMEOUT_MS = 60 * 1000; // 1 minute /** * Network capture start tool - uses Chrome Debugger API to start capturing network requests */ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor { name = TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START; private captureData: Map = new Map(); // tabId -> capture data private captureTimers: Map = new Map(); // tabId -> max capture timer private inactivityTimers: Map = new Map(); // tabId -> inactivity timer private lastActivityTime: Map = new Map(); // tabId -> timestamp of last network activity private pendingResponseBodies: Map> = new Map(); // requestId -> promise for getResponseBody private requestCounters: Map = new Map(); // tabId -> count of captured requests (after filtering) private static MAX_REQUESTS_PER_CAPTURE = 100; // Max requests to store to prevent memory issues public static instance: NetworkDebuggerStartTool | null = null; constructor() { super(); if (NetworkDebuggerStartTool.instance) { return NetworkDebuggerStartTool.instance; } NetworkDebuggerStartTool.instance = this; // Only add listeners if chrome APIs are available (not during build) if (typeof chrome !== 'undefined' && chrome.debugger?.onEvent) { chrome.debugger.onEvent.addListener(this.handleDebuggerEvent.bind(this)); chrome.debugger.onDetach.addListener(this.handleDebuggerDetach.bind(this)); chrome.tabs.onRemoved.addListener(this.handleTabRemoved.bind(this)); chrome.tabs.onCreated.addListener(this.handleTabCreated.bind(this)); } } private handleTabRemoved(tabId: number) { if (this.captureData.has(tabId)) { console.log(`NetworkDebuggerStartTool: Tab ${tabId} was closed, cleaning up resources.`); this.cleanupCapture(tabId); } } /** * Handle tab creation events * If a new tab is opened from a tab that is currently capturing, automatically start capturing the new tab's requests */ private async handleTabCreated(tab: chrome.tabs.Tab) { try { // Check if there are any tabs currently capturing if (this.captureData.size === 0) return; // Get the openerTabId of the new tab (ID of the tab that opened this tab) const openerTabId = tab.openerTabId; if (!openerTabId) return; // Check if the opener tab is currently capturing if (!this.captureData.has(openerTabId)) return; // Get the new tab's ID const newTabId = tab.id; if (!newTabId) return; console.log( `NetworkDebuggerStartTool: New tab ${newTabId} created from capturing tab ${openerTabId}, will extend capture to it.`, ); // Get the opener tab's capture settings const openerCaptureInfo = this.captureData.get(openerTabId); if (!openerCaptureInfo) return; // Wait a short time to ensure the tab is ready await new Promise((resolve) => setTimeout(resolve, 500)); // Start capturing requests for the new tab await this.startCaptureForTab(newTabId, { maxCaptureTime: openerCaptureInfo.maxCaptureTime, inactivityTimeout: openerCaptureInfo.inactivityTimeout, includeStatic: openerCaptureInfo.includeStatic, }); console.log(`NetworkDebuggerStartTool: Successfully extended capture to new tab ${newTabId}`); } catch (error) { console.error(`NetworkDebuggerStartTool: Error extending capture to new tab:`, error); } } /** * Start network request capture for specified tab * @param tabId Tab ID * @param options Capture options */ private async startCaptureForTab( tabId: number, options: { maxCaptureTime: number; inactivityTimeout: number; includeStatic: boolean; }, ): Promise { const { maxCaptureTime, inactivityTimeout, includeStatic } = options; // If already capturing, stop first if (this.captureData.has(tabId)) { console.log( `NetworkDebuggerStartTool: Already capturing on tab ${tabId}. Stopping previous session.`, ); await this.stopCapture(tabId); } try { // Get tab information const tab = await chrome.tabs.get(tabId); // Check if debugger is already attached const targets = await chrome.debugger.getTargets(); const existingTarget = targets.find( (t) => t.tabId === tabId && t.attached && t.type === 'page', ); if (existingTarget && !existingTarget.extensionId) { throw new Error( `Debugger is already attached to tab ${tabId} by another tool (e.g., DevTools).`, ); } // Attach debugger try { await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION); } catch (error: any) { if (error.message?.includes('Cannot attach to the target with an attached client')) { throw new Error( `Debugger is already attached to tab ${tabId}. This might be DevTools or another extension.`, ); } throw error; } // Enable network tracking try { await chrome.debugger.sendCommand({ tabId }, 'Network.enable'); } catch (error: any) { await chrome.debugger .detach({ tabId }) .catch((e) => console.warn('Error detaching after failed enable:', e)); throw error; } // Initialize capture data this.captureData.set(tabId, { startTime: Date.now(), tabUrl: tab.url, tabTitle: tab.title, maxCaptureTime, inactivityTimeout, includeStatic, requests: {}, limitReached: false, }); // Initialize request counter this.requestCounters.set(tabId, 0); // Update last activity time this.updateLastActivityTime(tabId); console.log( `NetworkDebuggerStartTool: Started capture for tab ${tabId} (${tab.url}). Max requests: ${NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE}, Max time: ${maxCaptureTime}ms, Inactivity: ${inactivityTimeout}ms.`, ); // Set maximum capture time if (maxCaptureTime > 0) { this.captureTimers.set( tabId, setTimeout(async () => { console.log( `NetworkDebuggerStartTool: Max capture time (${maxCaptureTime}ms) reached for tab ${tabId}.`, ); await this.stopCapture(tabId, true); // Auto-stop due to max time }, maxCaptureTime), ); } } catch (error: any) { console.error(`NetworkDebuggerStartTool: Error starting capture for tab ${tabId}:`, error); // Clean up resources if (this.captureData.has(tabId)) { await chrome.debugger .detach({ tabId }) .catch((e) => console.warn('Cleanup detach error:', e)); this.cleanupCapture(tabId); } throw error; } } private handleDebuggerEvent(source: chrome.debugger.Debuggee, method: string, params?: any) { if (!source.tabId) return; const tabId = source.tabId; const captureInfo = this.captureData.get(tabId); if (!captureInfo) return; // Not capturing for this tab // Update last activity time for any relevant network event this.updateLastActivityTime(tabId); switch (method) { case 'Network.requestWillBeSent': this.handleRequestWillBeSent(tabId, params); break; case 'Network.responseReceived': this.handleResponseReceived(tabId, params); break; case 'Network.loadingFinished': this.handleLoadingFinished(tabId, params); break; case 'Network.loadingFailed': this.handleLoadingFailed(tabId, params); break; } } private handleDebuggerDetach(source: chrome.debugger.Debuggee, reason: string) { if (source.tabId && this.captureData.has(source.tabId)) { console.log( `NetworkDebuggerStartTool: Debugger detached from tab ${source.tabId}, reason: ${reason}. Cleaning up.`, ); // Potentially inform the user or log the result if the detachment was unexpected this.cleanupCapture(source.tabId); // Ensure cleanup happens } } private updateLastActivityTime(tabId: number) { this.lastActivityTime.set(tabId, Date.now()); const captureInfo = this.captureData.get(tabId); if (captureInfo && captureInfo.inactivityTimeout > 0) { if (this.inactivityTimers.has(tabId)) { clearTimeout(this.inactivityTimers.get(tabId)!); } this.inactivityTimers.set( tabId, setTimeout(() => this.checkInactivity(tabId), captureInfo.inactivityTimeout), ); } } private checkInactivity(tabId: number) { const captureInfo = this.captureData.get(tabId); if (!captureInfo) return; const lastActivity = this.lastActivityTime.get(tabId) || captureInfo.startTime; // Use startTime if no activity yet const now = Date.now(); const inactiveTime = now - lastActivity; if (inactiveTime >= captureInfo.inactivityTimeout) { console.log( `NetworkDebuggerStartTool: No activity for ${inactiveTime}ms (threshold: ${captureInfo.inactivityTimeout}ms), stopping capture for tab ${tabId}`, ); this.stopCaptureByInactivity(tabId); } else { // Reschedule check for the remaining time, this handles system sleep or other interruptions const remainingTime = Math.max(0, captureInfo.inactivityTimeout - inactiveTime); this.inactivityTimers.set( tabId, setTimeout(() => this.checkInactivity(tabId), remainingTime), ); } } private async stopCaptureByInactivity(tabId: number) { const captureInfo = this.captureData.get(tabId); if (!captureInfo) return; console.log(`NetworkDebuggerStartTool: Stopping capture due to inactivity for tab ${tabId}.`); // Potentially, we might want to notify the client/user that this happened. // For now, just stop and make the results available if StopTool is called. await this.stopCapture(tabId, true); // Pass a flag indicating it's an auto-stop } // Static resource MIME types list (used when includeStatic is false) private static STATIC_MIME_TYPES_TO_FILTER = [ 'image/', // all image types (image/png, image/jpeg, etc.) 'font/', // all font types (font/woff, font/ttf, etc.) 'audio/', // all audio types 'video/', // all video types 'text/css', // Note: text/javascript, application/javascript etc. are often filtered by extension. // If script files need to be filtered by MIME type as well, add them here. // 'application/javascript', // 'application/x-javascript', 'application/pdf', 'application/zip', 'application/octet-stream', // Often used for downloads or generic binary data ]; // API-like response MIME types (these are generally NOT filtered, and we might want their bodies) private static API_MIME_TYPES = [ 'application/json', 'application/xml', 'text/xml', // 'text/json' is not standard, but sometimes seen. 'application/json' is preferred. 'text/plain', // Can be API response, handle with care. Often captured. 'application/x-www-form-urlencoded', // Form submissions, can be API calls 'application/graphql', // Add other common API types if needed ]; private shouldFilterRequestByUrl(url: string): boolean { try { const urlObj = new URL(url); // Filter ad/analytics domains if (AD_ANALYTICS_DOMAINS.some((domain) => urlObj.hostname.includes(domain))) { // console.log(`NetworkDebuggerStartTool: Filtering ad/analytics domain: ${urlObj.hostname}`); return true; } return false; } catch (e) { // Invalid URL? Log and don't filter. console.error(`NetworkDebuggerStartTool: Error parsing URL for filtering: ${url}`, e); return false; } } private shouldFilterRequestByExtension(url: string, includeStatic: boolean): boolean { if (includeStatic) return false; // If including static, don't filter by extension try { const urlObj = new URL(url); const path = urlObj.pathname.toLowerCase(); if (STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext))) { // console.log(`NetworkDebuggerStartTool: Filtering static resource by extension: ${path}`); return true; } return false; } catch (e) { console.error( `NetworkDebuggerStartTool: Error parsing URL for extension filtering: ${url}`, e, ); return false; } } // MIME type-based filtering, called after response is received private shouldFilterByMimeType(mimeType: string, includeStatic: boolean): boolean { if (!mimeType) return false; // No MIME type, don't make a decision based on it here // If API_MIME_TYPES contains this mimeType, we explicitly DON'T want to filter it by MIME. if (NetworkDebuggerStartTool.API_MIME_TYPES.some((apiMime) => mimeType.startsWith(apiMime))) { return false; } // If we are NOT including static files, then check against the list of static MIME types. if (!includeStatic) { if ( NetworkDebuggerStartTool.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) => mimeType.startsWith(staticMime), ) ) { // console.log(`NetworkDebuggerStartTool: Filtering static resource by MIME type: ${mimeType}`); return true; } } // Default: don't filter by MIME type if no other rule matched return false; } private handleRequestWillBeSent(tabId: number, params: any) { const captureInfo = this.captureData.get(tabId); if (!captureInfo) return; const { requestId, request, timestamp, type, loaderId, frameId } = params; // Initial filtering by URL (ads, analytics) and extension (if !includeStatic) if ( this.shouldFilterRequestByUrl(request.url) || this.shouldFilterRequestByExtension(request.url, captureInfo.includeStatic) ) { return; } const currentCount = this.requestCounters.get(tabId) || 0; if (currentCount >= NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE) { // console.log(`NetworkDebuggerStartTool: Request limit (${NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE}) reached for tab ${tabId}. Ignoring: ${request.url}`); captureInfo.limitReached = true; // Mark that limit was hit return; } // Store initial request info // Ensure we don't overwrite if a redirect (same requestId) occurred, though usually loaderId changes if (!captureInfo.requests[requestId]) { // Or check based on loaderId as well if needed captureInfo.requests[requestId] = { requestId, url: request.url, method: request.method, requestHeaders: request.headers, // Temporary, will be processed requestTime: timestamp * 1000, // Convert seconds to milliseconds type: type || 'Other', status: 'pending', // Initial status loaderId, // Useful for tracking redirects frameId, // Useful for context }; if (request.postData) { captureInfo.requests[requestId].requestBody = request.postData; } // console.log(`NetworkDebuggerStartTool: Captured request for tab ${tabId}: ${request.method} ${request.url}`); } else { // This could be a redirect. Update URL and other relevant fields. // Chrome often issues a new `requestWillBeSent` for redirects with the same `requestId` but a new `loaderId`. // console.log(`NetworkDebuggerStartTool: Request ${requestId} updated (likely redirect) for tab ${tabId} to URL: ${request.url}`); const existingRequest = captureInfo.requests[requestId]; existingRequest.url = request.url; // Update URL due to redirect existingRequest.requestTime = timestamp * 1000; // Update time for the redirected request if (request.headers) existingRequest.requestHeaders = request.headers; if (request.postData) existingRequest.requestBody = request.postData; else delete existingRequest.requestBody; } } private handleResponseReceived(tabId: number, params: any) { const captureInfo = this.captureData.get(tabId); if (!captureInfo) return; const { requestId, response, timestamp, type } = params; // type here is resource type const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId]; if (!requestInfo) { // console.warn(`NetworkDebuggerStartTool: Received response for unknown requestId ${requestId} on tab ${tabId}`); return; } // Secondary filtering based on MIME type, now that we have it if (this.shouldFilterByMimeType(response.mimeType, captureInfo.includeStatic)) { // console.log(`NetworkDebuggerStartTool: Filtering request by MIME type (${response.mimeType}): ${requestInfo.url}`); delete captureInfo.requests[requestId]; // Remove from captured data // Note: We don't decrement requestCounter here as it's meant to track how many *potential* requests were processed up to MAX_REQUESTS. // Or, if MAX_REQUESTS is strictly for *stored* requests, then decrement. For now, let's assume it's for stored. // const currentCount = this.requestCounters.get(tabId) || 0; // if (currentCount > 0) this.requestCounters.set(tabId, currentCount -1); return; } // If not filtered by MIME, then increment actual stored request counter const currentStoredCount = Object.keys(captureInfo.requests).length; // A bit inefficient but accurate this.requestCounters.set(tabId, currentStoredCount); requestInfo.status = response.status === 0 ? 'pending' : 'complete'; // status 0 can mean pending or blocked requestInfo.statusCode = response.status; requestInfo.statusText = response.statusText; requestInfo.responseHeaders = response.headers; // Temporary requestInfo.mimeType = response.mimeType; requestInfo.responseTime = timestamp * 1000; // Convert seconds to milliseconds if (type) requestInfo.type = type; // Update resource type if provided by this event // console.log(`NetworkDebuggerStartTool: Received response for ${requestId} on tab ${tabId}: ${response.status}`); } private async handleLoadingFinished(tabId: number, params: any) { const captureInfo = this.captureData.get(tabId); if (!captureInfo) return; const { requestId, encodedDataLength } = params; const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId]; if (!requestInfo) { // console.warn(`NetworkDebuggerStartTool: LoadingFinished for unknown requestId ${requestId} on tab ${tabId}`); return; } requestInfo.encodedDataLength = encodedDataLength; if (requestInfo.status === 'pending') requestInfo.status = 'complete'; // Mark as complete if not already // requestInfo.responseTime is usually set by responseReceived, but this timestamp is later. // timestamp here is when the resource finished loading. Could be useful for duration calculation. if (this.shouldCaptureResponseBody(requestInfo)) { try { // console.log(`NetworkDebuggerStartTool: Attempting to get response body for ${requestId} (${requestInfo.url})`); const responseBodyData = await this.getResponseBody(tabId, requestId); if (responseBodyData) { if ( responseBodyData.body && responseBodyData.body.length > MAX_RESPONSE_BODY_SIZE_BYTES ) { requestInfo.responseBody = responseBodyData.body.substring(0, MAX_RESPONSE_BODY_SIZE_BYTES) + `\n\n... [Response truncated, total size: ${responseBodyData.body.length} bytes] ...`; } else { requestInfo.responseBody = responseBodyData.body; } requestInfo.base64Encoded = responseBodyData.base64Encoded; // console.log(`NetworkDebuggerStartTool: Successfully got response body for ${requestId}, size: ${requestInfo.responseBody?.length || 0} bytes`); } } catch (error) { // console.warn(`NetworkDebuggerStartTool: Failed to get response body for ${requestId}:`, error); requestInfo.errorText = (requestInfo.errorText || '') + ` Failed to get body: ${error instanceof Error ? error.message : String(error)}`; } } } private shouldCaptureResponseBody(requestInfo: NetworkRequestInfo): boolean { const mimeType = requestInfo.mimeType || ''; // Prioritize API MIME types for body capture if (NetworkDebuggerStartTool.API_MIME_TYPES.some((type) => mimeType.startsWith(type))) { return true; } // Heuristics for other potential API calls not perfectly matching MIME types const url = requestInfo.url.toLowerCase(); if ( /\/(api|service|rest|graphql|query|data|rpc|v[0-9]+)\//i.test(url) || url.includes('.json') || url.includes('json=') || url.includes('format=json') ) { // If it looks like an API call by URL structure, try to get body, // unless it's a known non-API MIME type that slipped through (e.g. a script from a /api/ path) if ( mimeType && NetworkDebuggerStartTool.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) => mimeType.startsWith(staticMime), ) ) { return false; // e.g. a CSS file served from an /api/ path } return true; } return false; } private handleLoadingFailed(tabId: number, params: any) { const captureInfo = this.captureData.get(tabId); if (!captureInfo) return; const { requestId, errorText, canceled, type } = params; const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId]; if (!requestInfo) { // console.warn(`NetworkDebuggerStartTool: LoadingFailed for unknown requestId ${requestId} on tab ${tabId}`); return; } requestInfo.status = 'error'; requestInfo.errorText = errorText; requestInfo.canceled = canceled; if (type) requestInfo.type = type; // timestamp here is when loading failed. // console.log(`NetworkDebuggerStartTool: Loading failed for ${requestId} on tab ${tabId}: ${errorText}`); } private async getResponseBody( tabId: number, requestId: string, ): Promise<{ body: string; base64Encoded: boolean } | null> { const pendingKey = `${tabId}_${requestId}`; if (this.pendingResponseBodies.has(pendingKey)) { return this.pendingResponseBodies.get(pendingKey)!; // Return existing promise } const responseBodyPromise = (async () => { try { // Check if debugger is still attached to this tabId const attachedTabs = await chrome.debugger.getTargets(); if (!attachedTabs.some((target) => target.tabId === tabId && target.attached)) { // console.warn(`NetworkDebuggerStartTool: Debugger not attached to tab ${tabId} when trying to get response body for ${requestId}.`); throw new Error(`Debugger not attached to tab ${tabId}`); } const result = (await chrome.debugger.sendCommand({ tabId }, 'Network.getResponseBody', { requestId, })) as { body: string; base64Encoded: boolean }; return result; } finally { this.pendingResponseBodies.delete(pendingKey); // Clean up after promise resolves or rejects } })(); this.pendingResponseBodies.set(pendingKey, responseBodyPromise); return responseBodyPromise; } private cleanupCapture(tabId: number) { if (this.captureTimers.has(tabId)) { clearTimeout(this.captureTimers.get(tabId)!); this.captureTimers.delete(tabId); } if (this.inactivityTimers.has(tabId)) { clearTimeout(this.inactivityTimers.get(tabId)!); this.inactivityTimers.delete(tabId); } this.lastActivityTime.delete(tabId); this.captureData.delete(tabId); this.requestCounters.delete(tabId); // Abort pending getResponseBody calls for this tab // Note: Promises themselves cannot be "aborted" externally in a standard way once created. // We can delete them from the map, so new calls won't use them, // and the original promise will eventually resolve or reject. const keysToDelete: string[] = []; this.pendingResponseBodies.forEach((_, key) => { if (key.startsWith(`${tabId}_`)) { keysToDelete.push(key); } }); keysToDelete.forEach((key) => this.pendingResponseBodies.delete(key)); console.log(`NetworkDebuggerStartTool: Cleaned up resources for tab ${tabId}.`); } // isAutoStop is true if stop was triggered by timeout, false if by user/explicit call async stopCapture(tabId: number, isAutoStop: boolean = false): Promise { const captureInfo = this.captureData.get(tabId); if (!captureInfo) { return { success: false, message: 'No capture in progress for this tab.' }; } console.log( `NetworkDebuggerStartTool: Stopping capture for tab ${tabId}. Auto-stop: ${isAutoStop}`, ); try { // Detach debugger first to prevent further events. // Check if debugger is attached before trying to send commands or detach const attachedTargets = await chrome.debugger.getTargets(); const isAttached = attachedTargets.some( (target) => target.tabId === tabId && target.attached, ); if (isAttached) { try { await chrome.debugger.sendCommand({ tabId }, 'Network.disable'); } catch (e) { console.warn( `NetworkDebuggerStartTool: Error disabling network for tab ${tabId} (possibly already detached):`, e, ); } try { await chrome.debugger.detach({ tabId }); } catch (e) { console.warn( `NetworkDebuggerStartTool: Error detaching debugger for tab ${tabId} (possibly already detached):`, e, ); } } else { console.log( `NetworkDebuggerStartTool: Debugger was not attached to tab ${tabId} at stopCapture.`, ); } } catch (error: any) { // Catch errors from getTargets or general logic console.error( 'NetworkDebuggerStartTool: Error during debugger interaction in stopCapture:', error, ); // Proceed to cleanup and data formatting } // Process data even if detach/disable failed, as some data might have been captured. const allRequests = Object.values(captureInfo.requests) as NetworkRequestInfo[]; const commonRequestHeaders = this.analyzeCommonHeaders(allRequests, 'requestHeaders'); const commonResponseHeaders = this.analyzeCommonHeaders(allRequests, 'responseHeaders'); const processedRequests = allRequests.map((req) => { const finalReq: Partial & Pick = { ...req }; if (finalReq.requestHeaders) { finalReq.specificRequestHeaders = this.filterOutCommonHeaders( finalReq.requestHeaders, commonRequestHeaders, ); delete finalReq.requestHeaders; // Remove original full headers } else { finalReq.specificRequestHeaders = {}; } if (finalReq.responseHeaders) { finalReq.specificResponseHeaders = this.filterOutCommonHeaders( finalReq.responseHeaders, commonResponseHeaders, ); delete finalReq.responseHeaders; // Remove original full headers } else { finalReq.specificResponseHeaders = {}; } return finalReq as NetworkRequestInfo; // Cast back to full type }); // Sort requests by requestTime processedRequests.sort((a, b) => (a.requestTime || 0) - (b.requestTime || 0)); const resultData = { captureStartTime: captureInfo.startTime, captureEndTime: Date.now(), totalDurationMs: Date.now() - captureInfo.startTime, commonRequestHeaders, commonResponseHeaders, requests: processedRequests, requestCount: processedRequests.length, // Actual stored requests totalRequestsReceivedBeforeLimit: captureInfo.limitReached ? NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE : processedRequests.length, requestLimitReached: !!captureInfo.limitReached, stoppedBy: isAutoStop ? this.lastActivityTime.get(tabId) ? 'inactivity_timeout' : 'max_capture_time' : 'user_request', tabUrl: captureInfo.tabUrl, tabTitle: captureInfo.tabTitle, }; console.log( `NetworkDebuggerStartTool: Capture stopped for tab ${tabId}. ${resultData.requestCount} requests processed. Limit reached: ${resultData.requestLimitReached}. Stopped by: ${resultData.stoppedBy}`, ); this.cleanupCapture(tabId); // Final cleanup of all internal states for this tab return { success: true, message: `Capture stopped. ${resultData.requestCount} requests.`, data: resultData, }; } private analyzeCommonHeaders( requests: NetworkRequestInfo[], headerTypeKey: 'requestHeaders' | 'responseHeaders', ): Record { if (!requests || requests.length === 0) return {}; const headerValueCounts = new Map>(); // headerName -> (headerValue -> count) let requestsWithHeadersCount = 0; for (const req of requests) { const headers = req[headerTypeKey] as Record | undefined; if (headers && Object.keys(headers).length > 0) { requestsWithHeadersCount++; for (const name in headers) { // Normalize header name to lowercase for consistent counting const lowerName = name.toLowerCase(); const value = headers[name]; if (!headerValueCounts.has(lowerName)) { headerValueCounts.set(lowerName, new Map()); } const values = headerValueCounts.get(lowerName)!; values.set(value, (values.get(value) || 0) + 1); } } } if (requestsWithHeadersCount === 0) return {}; const commonHeaders: Record = {}; headerValueCounts.forEach((values, name) => { values.forEach((count, value) => { if (count === requestsWithHeadersCount) { // This (name, value) pair is present in all requests that have this type of headers. // We need to find the original casing for the header name. // This is tricky as HTTP headers are case-insensitive. Let's pick the first encountered one. // A more robust way would be to store original names, but lowercase comparison is standard. // For simplicity, we'll use the lowercase name for commonHeaders keys. // Or, find one original casing: let originalName = name; for (const req of requests) { const hdrs = req[headerTypeKey] as Record | undefined; if (hdrs) { const foundName = Object.keys(hdrs).find((k) => k.toLowerCase() === name); if (foundName) { originalName = foundName; break; } } } commonHeaders[originalName] = value; } }); }); return commonHeaders; } private filterOutCommonHeaders( headers: Record, commonHeaders: Record, ): Record { if (!headers || typeof headers !== 'object') return {}; const specificHeaders: Record = {}; const commonHeadersLower: Record = {}; // Use Object.keys to avoid ESLint no-prototype-builtins warning Object.keys(commonHeaders).forEach((commonName) => { commonHeadersLower[commonName.toLowerCase()] = commonHeaders[commonName]; }); // Use Object.keys to avoid ESLint no-prototype-builtins warning Object.keys(headers).forEach((name) => { const lowerName = name.toLowerCase(); // If the header (by name, case-insensitively) is not in commonHeaders OR // if its value is different from the common one, then it's specific. if (!(lowerName in commonHeadersLower) || headers[name] !== commonHeadersLower[lowerName]) { specificHeaders[name] = headers[name]; } }); return specificHeaders; } async execute(args: NetworkDebuggerStartToolParams): Promise { const { url: targetUrl, maxCaptureTime = DEFAULT_MAX_CAPTURE_TIME_MS, inactivityTimeout = DEFAULT_INACTIVITY_TIMEOUT_MS, includeStatic = false, } = args; console.log( `NetworkDebuggerStartTool: Executing with args: url=${targetUrl}, maxTime=${maxCaptureTime}, inactivityTime=${inactivityTimeout}, includeStatic=${includeStatic}`, ); let tabToOperateOn: chrome.tabs.Tab | undefined; try { if (targetUrl) { const existingTabs = await chrome.tabs.query({ url: targetUrl.startsWith('http') ? targetUrl : `*://*/*${targetUrl}*`, }); // More specific query if (existingTabs.length > 0 && existingTabs[0]?.id) { tabToOperateOn = existingTabs[0]; // Ensure window gets focus and tab is truly activated await chrome.windows.update(tabToOperateOn.windowId, { focused: true }); await chrome.tabs.update(tabToOperateOn.id!, { active: true }); } else { tabToOperateOn = await chrome.tabs.create({ url: targetUrl, active: true }); // Wait for tab to be somewhat ready. A better way is to listen to tabs.onUpdated status='complete' // but for debugger attachment, it just needs the tabId. await new Promise((resolve) => setTimeout(resolve, 500)); // Short delay } } else { const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); if (activeTabs.length > 0 && activeTabs[0]?.id) { tabToOperateOn = activeTabs[0]; } else { return createErrorResponse('No active tab found and no URL provided.'); } } if (!tabToOperateOn?.id) { return createErrorResponse('Failed to identify or create a target tab.'); } const tabId = tabToOperateOn.id; // Use startCaptureForTab method to start capture try { await this.startCaptureForTab(tabId, { maxCaptureTime, inactivityTimeout, includeStatic, }); } catch (error: any) { return createErrorResponse( `Failed to start capture for tab ${tabId}: ${error.message || String(error)}`, ); } return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `Network capture started on tab ${tabId}. Waiting for stop command or timeout.`, tabId, url: tabToOperateOn.url, maxCaptureTime, inactivityTimeout, includeStatic, maxRequests: NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE, }), }, ], isError: false, }; } catch (error: any) { console.error('NetworkDebuggerStartTool: Critical error during execute:', error); // If a tabId was involved and debugger might be attached, try to clean up. const tabIdToClean = tabToOperateOn?.id; if (tabIdToClean && this.captureData.has(tabIdToClean)) { await chrome.debugger .detach({ tabId: tabIdToClean }) .catch((e) => console.warn('Cleanup detach error:', e)); this.cleanupCapture(tabIdToClean); } return createErrorResponse( `Error in NetworkDebuggerStartTool: ${error.message || String(error)}`, ); } } } /** * Network capture stop tool - stops capture and returns results for the active tab */ class NetworkDebuggerStopTool extends BaseBrowserToolExecutor { name = TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP; public static instance: NetworkDebuggerStopTool | null = null; constructor() { super(); if (NetworkDebuggerStopTool.instance) { return NetworkDebuggerStopTool.instance; } NetworkDebuggerStopTool.instance = this; } async execute(): Promise { console.log(`NetworkDebuggerStopTool: Executing command.`); const startTool = NetworkDebuggerStartTool.instance; if (!startTool) { return createErrorResponse( 'NetworkDebuggerStartTool instance not available. Cannot stop capture.', ); } // Get all tabs currently capturing const ongoingCaptures = Array.from(startTool['captureData'].keys()); console.log( `NetworkDebuggerStopTool: Found ${ongoingCaptures.length} ongoing captures: ${ongoingCaptures.join(', ')}`, ); if (ongoingCaptures.length === 0) { return createErrorResponse('No active network captures found in any tab.'); } // Get current active tab const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); const activeTabId = activeTabs[0]?.id; // Determine the primary tab to stop let primaryTabId: number; if (activeTabId && startTool['captureData'].has(activeTabId)) { // If current active tab is capturing, prioritize stopping it primaryTabId = activeTabId; console.log( `NetworkDebuggerStopTool: Active tab ${activeTabId} is capturing, will stop it first.`, ); } else if (ongoingCaptures.length === 1) { // If only one tab is capturing, stop it primaryTabId = ongoingCaptures[0]; console.log( `NetworkDebuggerStopTool: Only one tab ${primaryTabId} is capturing, stopping it.`, ); } else { // If multiple tabs are capturing but current active tab is not among them, stop the first one primaryTabId = ongoingCaptures[0]; console.log( `NetworkDebuggerStopTool: Multiple tabs capturing, active tab not among them. Stopping tab ${primaryTabId} first.`, ); } // Stop capture for the primary tab const result = await this.performStop(startTool, primaryTabId); // If multiple tabs are capturing, stop other tabs if (ongoingCaptures.length > 1) { const otherTabIds = ongoingCaptures.filter((id) => id !== primaryTabId); console.log( `NetworkDebuggerStopTool: Stopping ${otherTabIds.length} additional captures: ${otherTabIds.join(', ')}`, ); for (const tabId of otherTabIds) { try { await startTool.stopCapture(tabId); } catch (error) { console.error(`NetworkDebuggerStopTool: Error stopping capture on tab ${tabId}:`, error); } } } return result; } private async performStop( startTool: NetworkDebuggerStartTool, tabId: number, ): Promise { console.log(`NetworkDebuggerStopTool: Attempting to stop capture for tab ${tabId}.`); const stopResult = await startTool.stopCapture(tabId); if (!stopResult?.success) { return createErrorResponse( stopResult?.message || `Failed to stop network capture for tab ${tabId}. It might not have been capturing.`, ); } const resultData = stopResult.data || {}; // Get all tabs still capturing (there might be other tabs still capturing after stopping) const remainingCaptures = Array.from(startTool['captureData'].keys()); // Sort requests by time if (resultData.requests && Array.isArray(resultData.requests)) { resultData.requests.sort( (a: NetworkRequestInfo, b: NetworkRequestInfo) => (a.requestTime || 0) - (b.requestTime || 0), ); } return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `Capture for tab ${tabId} (${resultData.tabUrl || 'N/A'}) stopped. ${resultData.requestCount || 0} requests captured.`, tabId: tabId, tabUrl: resultData.tabUrl || 'N/A', tabTitle: resultData.tabTitle || 'Unknown Tab', requestCount: resultData.requestCount || 0, commonRequestHeaders: resultData.commonRequestHeaders || {}, commonResponseHeaders: resultData.commonResponseHeaders || {}, requests: resultData.requests || [], captureStartTime: resultData.captureStartTime, captureEndTime: resultData.captureEndTime, totalDurationMs: resultData.totalDurationMs, settingsUsed: resultData.settingsUsed || {}, remainingCaptures: remainingCaptures, totalRequestsReceived: resultData.totalRequestsReceived || resultData.requestCount || 0, requestLimitReached: resultData.requestLimitReached || false, }), }, ], isError: false, }; } } export const networkDebuggerStartTool = new NetworkDebuggerStartTool(); export const networkDebuggerStopTool = new NetworkDebuggerStopTool();