diff --git a/app/chrome-extension/entrypoints/background/tools/browser/computer.ts b/app/chrome-extension/entrypoints/background/tools/browser/computer.ts index 17d2c66..cc3d644 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/computer.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/computer.ts @@ -7,6 +7,7 @@ import { clickTool, fillTool } from './interaction'; import { keyboardTool } from './keyboard'; import { screenshotTool } from './screenshot'; import { screenshotContextManager, scaleCoordinates } from '@/utils/screenshot-context'; +import { cdpSessionManager } from '@/utils/cdp-session-manager'; type MouseButton = 'left' | 'right' | 'middle'; @@ -48,49 +49,16 @@ interface ComputerParams { // Minimal CDP helper encapsulated here to avoid scattering CDP code class CDPHelper { - private static active = new Set(); - static async attach(tabId: number): Promise { - // If already attached by us, skip - const targets = await chrome.debugger.getTargets(); - const existing = targets.find((t) => t.tabId === tabId && t.attached); - if (existing) { - if (existing.extensionId === chrome.runtime.id) { - this.active.add(tabId); - return; - } - throw new Error( - `Debugger is already attached to tab ${tabId} by another tool (e.g., DevTools/extension)`, - ); - } - await chrome.debugger.attach({ tabId }, '1.3'); - this.active.add(tabId); + await cdpSessionManager.attach(tabId, 'computer'); } static async detach(tabId: number): Promise { - if (!this.active.has(tabId)) return; - try { - await chrome.debugger.detach({ tabId }); - } finally { - this.active.delete(tabId); - } + await cdpSessionManager.detach(tabId, 'computer'); } static async send(tabId: number, method: string, params?: object): Promise { - try { - return await chrome.debugger.sendCommand({ tabId }, method, params); - } catch (e: any) { - // Try reattach once if lost - if ( - String(e?.message || e) - .toLowerCase() - .includes('not attached') - ) { - await this.attach(tabId); - return await chrome.debugger.sendCommand({ tabId }, method, params); - } - throw e; - } + return await cdpSessionManager.sendCommand(tabId, method, params); } static async dispatchMouseEvent(tabId: number, opts: any) { @@ -259,18 +227,14 @@ class ComputerTool extends BaseBrowserToolExecutor { // Prefer precise CDP emulation await CDPHelper.attach(tab.id); try { - await chrome.debugger.sendCommand( - { tabId: tab.id }, - 'Emulation.setDeviceMetricsOverride', - { - width: Math.round(w), - height: Math.round(h), - deviceScaleFactor: 0, - mobile: false, - screenWidth: Math.round(w), - screenHeight: Math.round(h), - }, - ); + await CDPHelper.send(tab.id, 'Emulation.setDeviceMetricsOverride', { + width: Math.round(w), + height: Math.round(h), + deviceScaleFactor: 0, + mobile: false, + screenWidth: Math.round(w), + screenHeight: Math.round(h), + }); } finally { await CDPHelper.detach(tab.id); } diff --git a/app/chrome-extension/entrypoints/background/tools/browser/dialog.ts b/app/chrome-extension/entrypoints/background/tools/browser/dialog.ts index 274562a..b30fca3 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/dialog.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/dialog.ts @@ -1,6 +1,7 @@ import { createErrorResponse, ToolResult } from '@/common/tool-handler'; import { BaseBrowserToolExecutor } from '../base-browser'; import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { cdpSessionManager } from '@/utils/cdp-session-manager'; interface HandleDialogParams { action: 'accept' | 'dismiss'; @@ -22,40 +23,16 @@ class HandleDialogTool extends BaseBrowserToolExecutor { try { const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!activeTab?.id) return createErrorResponse('No active tab found'); + const tabId = activeTab.id!; - // Attach debugger and try handling the dialog - try { - await chrome.debugger.attach({ tabId: activeTab.id }, '1.3'); - } catch (e: any) { - if (String(e?.message || '').includes('attached')) { - // If already attached by us, proceed; otherwise fail with clear message - const targets = await chrome.debugger.getTargets(); - const existing = targets.find((t) => t.tabId === activeTab.id && t.attached); - if (!existing || existing.extensionId !== chrome.runtime.id) { - return createErrorResponse( - `Debugger already attached to tab ${activeTab.id} by another client (e.g., DevTools). Close it and retry.`, - ); - } - } else { - throw e; - } - } - - try { - // Enable Page domain to be safe - await chrome.debugger.sendCommand({ tabId: activeTab.id }, 'Page.enable'); - await chrome.debugger.sendCommand({ tabId: activeTab.id }, 'Page.handleJavaScriptDialog', { + // Use shared CDP session manager for safe attach/detach with refcount + await cdpSessionManager.withSession(tabId, 'dialog', async () => { + await cdpSessionManager.sendCommand(tabId, 'Page.enable'); + await cdpSessionManager.sendCommand(tabId, 'Page.handleJavaScriptDialog', { accept: action === 'accept', promptText: action === 'accept' ? promptText : undefined, }); - } finally { - // Best-effort detach if we were the owners - try { - await chrome.debugger.detach({ tabId: activeTab.id }); - } catch { - // ignore - } - } + }); return { content: [ diff --git a/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts b/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts index c6adc54..907c4ee 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts @@ -1,6 +1,7 @@ import { createErrorResponse, ToolResult } from '@/common/tool-handler'; import { BaseBrowserToolExecutor } from '../base-browser'; import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { cdpSessionManager } from '@/utils/cdp-session-manager'; interface NetworkDebuggerStartToolParams { url?: string; // URL to navigate to or focus. If not provided, uses active tab. @@ -218,35 +219,15 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor { // 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; - } + // Attach via shared manager (handles conflicts and refcount) + await cdpSessionManager.attach(tabId, 'network-capture'); // Enable network tracking try { - await chrome.debugger.sendCommand({ tabId }, 'Network.enable'); + await cdpSessionManager.sendCommand(tabId, 'Network.enable'); } catch (error: any) { - await chrome.debugger - .detach({ tabId }) + await cdpSessionManager + .detach(tabId, 'network-capture') .catch((e) => console.warn('Error detaching after failed enable:', e)); throw error; } @@ -290,8 +271,8 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor { // Clean up resources if (this.captureData.has(tabId)) { - await chrome.debugger - .detach({ tabId }) + await cdpSessionManager + .detach(tabId, 'network-capture') .catch((e) => console.warn('Cleanup detach error:', e)); this.cleanupCapture(tabId); } @@ -673,14 +654,8 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor { 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', { + // Will attach temporarily if needed + const result = (await cdpSessionManager.sendCommand(tabId, 'Network.getResponseBody', { requestId, })) as { body: string; base64Encoded: boolean }; return result; @@ -734,33 +709,21 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor { ); 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.`, + // Attempt to disable network and detach via manager; it will no-op if others own the session + try { + await cdpSessionManager.sendCommand(tabId, 'Network.disable'); + } catch (e) { + console.warn( + `NetworkDebuggerStartTool: Error disabling network for tab ${tabId} (possibly already detached):`, + e, + ); + } + try { + await cdpSessionManager.detach(tabId, 'network-capture'); + } catch (e) { + console.warn( + `NetworkDebuggerStartTool: Error detaching debugger for tab ${tabId} (possibly already detached):`, + e, ); } } catch (error: any) { @@ -1003,8 +966,8 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor { // 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 }) + await cdpSessionManager + .detach(tabIdToClean, 'network-capture') .catch((e) => console.warn('Cleanup detach error:', e)); this.cleanupCapture(tabIdToClean); } diff --git a/app/chrome-extension/utils/cdp-session-manager.ts b/app/chrome-extension/utils/cdp-session-manager.ts new file mode 100644 index 0000000..8f04c23 --- /dev/null +++ b/app/chrome-extension/utils/cdp-session-manager.ts @@ -0,0 +1,109 @@ +import { TOOL_NAMES } from 'chrome-mcp-shared'; + +type OwnerTag = string; + +interface TabSessionState { + refCount: number; + owners: Set; + attachedByUs: boolean; +} + +const DEBUGGER_PROTOCOL_VERSION = '1.3'; + +class CDPSessionManager { + private sessions = new Map(); + + private getState(tabId: number): TabSessionState | undefined { + return this.sessions.get(tabId); + } + + private setState(tabId: number, state: TabSessionState) { + this.sessions.set(tabId, state); + } + + async attach(tabId: number, owner: OwnerTag = 'unknown'): Promise { + const state = this.getState(tabId); + if (state && state.attachedByUs) { + state.refCount += 1; + state.owners.add(owner); + return; + } + + // Check existing attachments + const targets = await chrome.debugger.getTargets(); + const existing = targets.find((t) => t.tabId === tabId && t.attached); + if (existing) { + if (existing.extensionId === chrome.runtime.id) { + // Already attached by us (e.g., previous tool). Adopt and refcount. + this.setState(tabId, { + refCount: state ? state.refCount + 1 : 1, + owners: new Set([...(state?.owners || []), owner]), + attachedByUs: true, + }); + return; + } + // Another client (DevTools/other extension) is attached + throw new Error( + `Debugger is already attached to tab ${tabId} by another client (e.g., DevTools/extension)`, + ); + } + + // Attach freshly + await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION); + this.setState(tabId, { refCount: 1, owners: new Set([owner]), attachedByUs: true }); + } + + async detach(tabId: number, owner: OwnerTag = 'unknown'): Promise { + const state = this.getState(tabId); + if (!state) return; // Nothing to do + + // Update ownership/refcount + if (state.owners.has(owner)) state.owners.delete(owner); + state.refCount = Math.max(0, state.refCount - 1); + + if (state.refCount > 0) { + // Still in use by other owners + return; + } + + // We are the last owner + try { + if (state.attachedByUs) { + await chrome.debugger.detach({ tabId }); + } + } catch (e) { + // Best-effort detach; ignore + } finally { + this.sessions.delete(tabId); + } + } + + /** + * Convenience wrapper: ensures attach before fn, and balanced detach after. + */ + async withSession(tabId: number, owner: OwnerTag, fn: () => Promise): Promise { + await this.attach(tabId, owner); + try { + return await fn(); + } finally { + await this.detach(tabId, owner); + } + } + + /** + * Send a CDP command. Requires that this manager has attached to the tab. + * If not attached by us, will attempt a one-shot attach around the call. + */ + async sendCommand(tabId: number, method: string, params?: object): Promise { + const state = this.getState(tabId); + if (state && state.attachedByUs) { + return (await chrome.debugger.sendCommand({ tabId }, method, params)) as T; + } + // Fallback: temporary session + return await this.withSession(tabId, `send:${method}`, async () => { + return (await chrome.debugger.sendCommand({ tabId }, method, params)) as T; + }); + } +} + +export const cdpSessionManager = new CDPSessionManager();