refactor(extension): migrate file-upload & console to shared CDP manager

This commit is contained in:
hangwin
2025-10-09 07:05:07 +00:00
parent 9fca59a674
commit f6cb4c0cef
2 changed files with 172 additions and 184 deletions
@@ -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<any> => {
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<v.length;i++){\n' +
' if(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);
}
@@ -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<number, boolean> = 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<void> {
// 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<void> {
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();
export const fileUploadTool = new FileUploadTool();