Files
broswer-automation/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts
nasir@endelospay.com d97cad1736 first commit
2025-08-12 02:54:17 +05:00

1160 lines
43 KiB
TypeScript

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<string, string>; // Will be removed after common headers extraction
responseHeaders?: Record<string, string>; // 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<string, string>; // Headers unique to this request
specificResponseHeaders?: Record<string, string>; // 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<number, any> = new Map(); // tabId -> capture data
private captureTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> max capture timer
private inactivityTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> inactivity timer
private lastActivityTime: Map<number, number> = new Map(); // tabId -> timestamp of last network activity
private pendingResponseBodies: Map<string, Promise<any>> = new Map(); // requestId -> promise for getResponseBody
private requestCounters: Map<number, number> = 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;
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<void> {
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<any> {
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<NetworkRequestInfo> &
Pick<NetworkRequestInfo, 'requestId' | 'url' | 'method' | 'type' | 'status'> = { ...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<string, string> {
if (!requests || requests.length === 0) return {};
const headerValueCounts = new Map<string, Map<string, number>>(); // headerName -> (headerValue -> count)
let requestsWithHeadersCount = 0;
for (const req of requests) {
const headers = req[headerTypeKey] as Record<string, string> | 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<string, string> = {};
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<string, string> | 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<string, string>,
commonHeaders: Record<string, string>,
): Record<string, string> {
if (!headers || typeof headers !== 'object') return {};
const specificHeaders: Record<string, string> = {};
const commonHeadersLower: Record<string, string> = {};
// 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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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();