From f6cb4c0cefdbd167f197e29173047f77f5e22d00 Mon Sep 17 00:00:00 2001 From: hangwin Date: Thu, 9 Oct 2025 07:05:07 +0000 Subject: [PATCH] refactor(extension): migrate file-upload & console to shared CDP manager --- .../background/tools/browser/console.ts | 117 +++++++-- .../background/tools/browser/file-upload.ts | 239 ++++++------------ 2 files changed, 172 insertions(+), 184 deletions(-) diff --git a/app/chrome-extension/entrypoints/background/tools/browser/console.ts b/app/chrome-extension/entrypoints/background/tools/browser/console.ts index 8af45d0..b65d03f 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/console.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/console.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'; const DEBUGGER_PROTOCOL_VERSION = '1.3'; const DEFAULT_MAX_MESSAGES = 100; @@ -16,6 +17,7 @@ interface ConsoleMessage { level: string; text: string; args?: any[]; + argsSerialized?: any[]; source?: string; url?: string; lineNumber?: number; @@ -177,28 +179,8 @@ class ConsoleTool 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 + await cdpSessionManager.attach(tabId, 'console'); // Set up event listener to collect messages const collectedMessages: any[] = []; @@ -235,15 +217,90 @@ class ConsoleTool extends BaseBrowserToolExecutor { try { // Enable Runtime domain first to capture console API calls and exceptions - await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); + await cdpSessionManager.sendCommand(tabId, 'Runtime.enable'); // Also enable Log domain to capture other log entries - await chrome.debugger.sendCommand({ tabId }, 'Log.enable'); + await cdpSessionManager.sendCommand(tabId, 'Log.enable'); // Wait for all messages to be flushed await new Promise((resolve) => setTimeout(resolve, 2000)); // Process collected messages + // Helper to deeply serialize console arguments when possible + const serializeArg = async (arg: any): Promise => { + try { + if (!arg) return arg; + if (Object.prototype.hasOwnProperty.call(arg, 'unserializableValue')) { + return arg.unserializableValue; + } + if (Object.prototype.hasOwnProperty.call(arg, 'value')) { + return arg.value; + } + if (arg.objectId) { + const resp = (await cdpSessionManager.sendCommand(tabId, 'Runtime.callFunctionOn', { + objectId: arg.objectId, + functionDeclaration: + 'function(maxDepth, maxProps){\n' + + ' const seen=new WeakSet();\n' + + ' function S(v,d){\n' + + ' try{\n' + + ' if(d<0) return "[MaxDepth]";\n' + + ' if(v===null) return null;\n' + + ' const t=typeof v;\n' + + ' if(t!=="object"){\n' + + ' if(t==="bigint") return v.toString()+"n";\n' + + ' return v;\n' + + ' }\n' + + ' if(seen.has(v)) return "[Circular]";\n' + + ' seen.add(v);\n' + + ' if(Array.isArray(v)){\n' + + ' const out=[];\n' + + ' for(let i=0;i=maxProps){ out.push("[...truncated]"); break; }\n' + + ' out.push(S(v[i], d-1));\n' + + ' }\n' + + ' return out;\n' + + ' }\n' + + ' if(v instanceof Date) return {__type:"Date", value:v.toISOString()};\n' + + ' if(v instanceof RegExp) return {__type:"RegExp", value:String(v)};\n' + + ' if(v instanceof Map){\n' + + ' const out={__type:"Map", entries:[]}; let c=0;\n' + + ' for(const [k,val] of v.entries()){\n' + + ' if(c++>=maxProps){ out.entries.push(["[...truncated]","[...truncated]"]); break; }\n' + + ' out.entries.push([S(k,d-1), S(val,d-1)]);\n' + + ' }\n' + + ' return out;\n' + + ' }\n' + + ' if(v instanceof Set){\n' + + ' const out={__type:"Set", values:[]}; let c=0;\n' + + ' for(const val of v.values()){\n' + + ' if(c++>=maxProps){ out.values.push("[...truncated]"); break; }\n' + + ' out.values.push(S(val,d-1));\n' + + ' }\n' + + ' return out;\n' + + ' }\n' + + ' const out={}; let c=0;\n' + + ' for(const key in v){\n' + + ' if(c++>=maxProps){ out.__truncated__=true; break; }\n' + + ' try{ out[key]=S(v[key], d-1); }catch(e){ out[key]="[Thrown]"; }\n' + + ' }\n' + + ' return out;\n' + + ' }catch(e){ return "[Unserializable]" }\n' + + ' }\n' + + ' return S(this, maxDepth);\n' + + '}', + arguments: [{ value: 3 }, { value: 100 }], + silent: true, + returnByValue: true, + })) as any; + return resp?.result?.value ?? '[Unavailable]'; + } + return '[Unknown]'; + } catch (e) { + return '[SerializeError]'; + } + }; + for (const entry of collectedMessages) { if (messages.length >= maxMessages) { limitReached = true; @@ -265,6 +322,12 @@ class ConsoleTool extends BaseBrowserToolExecutor { if (entry.args && Array.isArray(entry.args)) { message.args = entry.args; + // Attempt deep serialization for better fidelity + const serialized: any[] = []; + for (const a of entry.args) { + serialized.push(await serializeArg(a)); + } + message.argsSerialized = serialized; } messages.push(message); @@ -294,19 +357,19 @@ class ConsoleTool extends BaseBrowserToolExecutor { chrome.debugger.onEvent.removeListener(eventListener); try { - await chrome.debugger.sendCommand({ tabId }, 'Runtime.disable'); + await cdpSessionManager.sendCommand(tabId, 'Runtime.disable'); } catch (e) { console.warn(`ConsoleTool: Error disabling Runtime for tab ${tabId}:`, e); } try { - await chrome.debugger.sendCommand({ tabId }, 'Log.disable'); + await cdpSessionManager.sendCommand(tabId, 'Log.disable'); } catch (e) { console.warn(`ConsoleTool: Error disabling Log for tab ${tabId}:`, e); } try { - await chrome.debugger.detach({ tabId }); + await cdpSessionManager.detach(tabId, 'console'); } catch (e) { console.warn(`ConsoleTool: Error detaching debugger for tab ${tabId}:`, e); } diff --git a/app/chrome-extension/entrypoints/background/tools/browser/file-upload.ts b/app/chrome-extension/entrypoints/background/tools/browser/file-upload.ts index 7e3caf1..c7efe3f 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/file-upload.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/file-upload.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 FileUploadToolParams { selector: string; // CSS selector for the file input element @@ -17,16 +18,8 @@ interface FileUploadToolParams { */ class FileUploadTool extends BaseBrowserToolExecutor { name = TOOL_NAMES.BROWSER.FILE_UPLOAD; - private activeDebuggers: Map = new Map(); - constructor() { super(); - // Clean up debuggers on tab removal - chrome.tabs.onRemoved.addListener((tabId) => { - if (this.activeDebuggers.has(tabId)) { - this.cleanupDebugger(tabId); - } - }); } /** @@ -43,12 +36,10 @@ class FileUploadTool extends BaseBrowserToolExecutor { } if (!filePath && !fileUrl && !base64Data) { - return createErrorResponse( - 'One of filePath, fileUrl, or base64Data must be provided', - ); + return createErrorResponse('One of filePath, fileUrl, or base64Data must be provided'); } - let tabId: number | undefined; + let tabId: number; try { // Get current tab @@ -56,7 +47,7 @@ class FileUploadTool extends BaseBrowserToolExecutor { if (!tabs[0]?.id) { return createErrorResponse('No active tab found'); } - tabId = tabs[0].id; + tabId = tabs[0].id!; // Prepare file paths let files: string[] = []; @@ -78,75 +69,59 @@ class FileUploadTool extends BaseBrowserToolExecutor { files = [tempFilePath]; } - // Attach debugger to the tab - await this.attachDebugger(tabId); + // Use shared CDP session manager to attach/do work/detach safely + await cdpSessionManager.withSession(tabId, 'file-upload', async () => { + // Enable necessary CDP domains + await cdpSessionManager.sendCommand(tabId, 'DOM.enable', {}); + await cdpSessionManager.sendCommand(tabId, 'Runtime.enable', {}); - // Enable necessary CDP domains - await chrome.debugger.sendCommand({ tabId }, 'DOM.enable', {}); - await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable', {}); + // Get the document + const { root } = (await cdpSessionManager.sendCommand(tabId, 'DOM.getDocument', { + depth: -1, + pierce: true, + })) as { root: { nodeId: number } }; - // Get the document - const { root } = await chrome.debugger.sendCommand( - { tabId }, - 'DOM.getDocument', - { depth: -1, pierce: true }, - ) as { root: { nodeId: number } }; - - // Find the file input element using the selector - const { nodeId } = await chrome.debugger.sendCommand( - { tabId }, - 'DOM.querySelector', - { + // Find the file input element using the selector + const { nodeId } = (await cdpSessionManager.sendCommand(tabId, 'DOM.querySelector', { nodeId: root.nodeId, selector: selector, - }, - ) as { nodeId: number }; + })) as { nodeId: number }; - if (!nodeId || nodeId === 0) { - throw new Error(`Element with selector "${selector}" not found`); - } - - // Verify it's actually a file input - const { node } = await chrome.debugger.sendCommand( - { tabId }, - 'DOM.describeNode', - { nodeId }, - ) as { node: { nodeName: string; attributes?: string[] } }; - - if (node.nodeName !== 'INPUT') { - throw new Error(`Element with selector "${selector}" is not an input element`); - } - - // Check if it's a file input by looking for type="file" in attributes - const attributes = node.attributes || []; - let isFileInput = false; - for (let i = 0; i < attributes.length; i += 2) { - if (attributes[i] === 'type' && attributes[i + 1] === 'file') { - isFileInput = true; - break; + if (!nodeId || nodeId === 0) { + throw new Error(`Element with selector "${selector}" not found`); } - } - if (!isFileInput) { - throw new Error(`Element with selector "${selector}" is not a file input (type="file")`); - } + // Verify it's actually a file input + const { node } = (await cdpSessionManager.sendCommand(tabId, 'DOM.describeNode', { + nodeId, + })) as { node: { nodeName: string; attributes?: string[] } }; - // Set the files on the input element - // This is the key CDP command that Playwright and Puppeteer use - await chrome.debugger.sendCommand( - { tabId }, - 'DOM.setFileInputFiles', - { - nodeId: nodeId, - files: files, - }, - ); + if (node.nodeName !== 'INPUT') { + throw new Error(`Element with selector "${selector}" is not an input element`); + } - // Trigger change event to ensure the page reacts to the file upload - await chrome.debugger.sendCommand( - { tabId }, - 'Runtime.evaluate', - { + // Check if it's a file input by looking for type="file" in attributes + const attributes = node.attributes || []; + let isFileInput = false; + for (let i = 0; i < attributes.length; i += 2) { + if (attributes[i] === 'type' && attributes[i + 1] === 'file') { + isFileInput = true; + break; + } + } + + if (!isFileInput) { + throw new Error(`Element with selector "${selector}" is not a file input (type="file")`); + } + + // Set the files on the input element + await cdpSessionManager.sendCommand(tabId, 'DOM.setFileInputFiles', { + nodeId, + files, + }); + + // Trigger change event to ensure the page reacts to the file upload + await cdpSessionManager.sendCommand(tabId, 'Runtime.evaluate', { expression: ` (function() { const element = document.querySelector('${selector.replace(/'/g, "\\'")}'); @@ -158,11 +133,8 @@ class FileUploadTool extends BaseBrowserToolExecutor { return false; })() `, - }, - ); - - // Clean up debugger - await this.detachDebugger(tabId); + }); + }); return { content: [ @@ -181,11 +153,8 @@ class FileUploadTool extends BaseBrowserToolExecutor { }; } catch (error) { console.error('Error in file upload operation:', error); - - // Clean up debugger if attached - if (tabId !== undefined && this.activeDebuggers.has(tabId)) { - await this.detachDebugger(tabId); - } + + // Session manager handles detach; nothing extra needed here return createErrorResponse( `Error uploading file: ${error instanceof Error ? error.message : String(error)}`, @@ -193,58 +162,7 @@ class FileUploadTool extends BaseBrowserToolExecutor { } } - /** - * Attach debugger to a tab - */ - private async attachDebugger(tabId: number): Promise { - // Check if debugger is already attached - const targets = await chrome.debugger.getTargets(); - const existingTarget = targets.find( - (t) => t.tabId === tabId && t.attached, - ); - - if (existingTarget) { - if (existingTarget.extensionId === chrome.runtime.id) { - // Our extension already attached - console.log('Debugger already attached by this extension'); - return; - } else { - throw new Error( - 'Debugger is already attached to this tab by another extension or DevTools', - ); - } - } - - // Attach debugger - await chrome.debugger.attach({ tabId }, '1.3'); - this.activeDebuggers.set(tabId, true); - console.log(`Debugger attached to tab ${tabId}`); - } - - /** - * Detach debugger from a tab - */ - private async detachDebugger(tabId: number): Promise { - if (!this.activeDebuggers.has(tabId)) { - return; - } - - try { - await chrome.debugger.detach({ tabId }); - console.log(`Debugger detached from tab ${tabId}`); - } catch (error) { - console.warn(`Error detaching debugger from tab ${tabId}:`, error); - } finally { - this.activeDebuggers.delete(tabId); - } - } - - /** - * Clean up debugger connection - */ - private cleanupDebugger(tabId: number): void { - this.activeDebuggers.delete(tabId); - } + // All debugger attach/detach is centrally managed by cdpSessionManager /** * Prepare file from URL or base64 data using native messaging host @@ -265,15 +183,20 @@ class FileUploadTool extends BaseBrowserToolExecutor { // Create listener for the response const handleMessage = (message: any) => { - if (message.type === 'file_operation_response' && - message.responseToRequestId === requestId) { + if ( + message.type === 'file_operation_response' && + message.responseToRequestId === requestId + ) { clearTimeout(timeout); chrome.runtime.onMessage.removeListener(handleMessage); - + if (message.payload?.success && message.payload?.filePath) { resolve(message.payload.filePath); } else { - console.error('Native host failed to prepare file:', message.error || message.payload?.error); + console.error( + 'Native host failed to prepare file:', + message.error || message.payload?.error, + ); resolve(null); } } @@ -283,26 +206,28 @@ class FileUploadTool extends BaseBrowserToolExecutor { chrome.runtime.onMessage.addListener(handleMessage); // Send message to background script to forward to native host - chrome.runtime.sendMessage({ - type: 'forward_to_native', - message: { - type: 'file_operation', - requestId: requestId, - payload: { - action: 'prepareFile', - fileUrl, - base64Data, - fileName, + chrome.runtime + .sendMessage({ + type: 'forward_to_native', + message: { + type: 'file_operation', + requestId: requestId, + payload: { + action: 'prepareFile', + fileUrl, + base64Data, + fileName, + }, }, - }, - }).catch((error) => { - console.error('Error sending message to background:', error); - clearTimeout(timeout); - chrome.runtime.onMessage.removeListener(handleMessage); - resolve(null); - }); + }) + .catch((error) => { + console.error('Error sending message to background:', error); + clearTimeout(timeout); + chrome.runtime.onMessage.removeListener(handleMessage); + resolve(null); + }); }); } } -export const fileUploadTool = new FileUploadTool(); \ No newline at end of file +export const fileUploadTool = new FileUploadTool();