feat: Add file upload capability using Chrome DevTools Protocol

- Implement FileUploadTool using CDP's DOM.setFileInputFiles
- Add FileHandler for native host to handle URL downloads and base64 data
- Support three input methods: local file path, URL download, base64 data
- Integrate with existing native messaging host infrastructure
- Follow existing tool patterns from network-capture-debugger

This enables programmatic file uploads to web forms, addressing the limitation
identified in issue #145.
This commit is contained in:
Tiger Kaovilai
2025-08-08 18:41:54 -04:00
parent 78e5c16a87
commit bf77546ab1
6 changed files with 628 additions and 0 deletions
@@ -145,6 +145,11 @@ export function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT) {
console.log(SUCCESS_MESSAGES.SERVER_STOPPED);
} else if (message.type === NativeMessageType.ERROR_FROM_NATIVE_HOST) {
console.error('Error from native host:', message.payload?.message || 'Unknown error');
} else if (message.type === 'file_operation_response') {
// Forward file operation response back to the requesting tool
chrome.runtime.sendMessage(message).catch(() => {
// Ignore if no listeners
});
}
});
@@ -233,5 +238,16 @@ export const initNativeHostListener = () => {
});
return true;
}
// Forward file operation messages to native host
if (message.type === 'forward_to_native' && message.message) {
if (nativePort) {
nativePort.postMessage(message.message);
sendResponse({ success: true });
} else {
sendResponse({ success: false, error: 'Native host not connected' });
}
return true;
}
});
};
@@ -0,0 +1,308 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
interface FileUploadToolParams {
selector: string; // CSS selector for the file input element
filePath?: string; // Local file path
fileUrl?: string; // URL to download file from
base64Data?: string; // Base64 encoded file data
fileName?: string; // Optional filename when using base64 or URL
multiple?: boolean; // Whether to allow multiple files
}
/**
* Tool for uploading files to web forms using Chrome DevTools Protocol
* Similar to Playwright's setInputFiles implementation
*/
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);
}
});
}
/**
* Execute file upload operation using Chrome DevTools Protocol
*/
async execute(args: FileUploadToolParams): Promise<ToolResult> {
const { selector, filePath, fileUrl, base64Data, fileName, multiple = false } = args;
console.log(`Starting file upload operation with options:`, args);
// Validate input
if (!selector) {
return createErrorResponse('Selector is required for file upload');
}
if (!filePath && !fileUrl && !base64Data) {
return createErrorResponse(
'One of filePath, fileUrl, or base64Data must be provided',
);
}
let tabId: number | undefined;
try {
// Get current tab
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tabs[0]?.id) {
return createErrorResponse('No active tab found');
}
tabId = tabs[0].id;
// Prepare file paths
let files: string[] = [];
if (filePath) {
// Direct file path provided
files = [filePath];
} else if (fileUrl || base64Data) {
// For URL or base64, we need to use the native messaging host
// to download or save the file temporarily
const tempFilePath = await this.prepareFileFromRemote({
fileUrl,
base64Data,
fileName: fileName || 'uploaded-file',
});
if (!tempFilePath) {
return createErrorResponse('Failed to prepare file for upload');
}
files = [tempFilePath];
}
// Attach debugger to the tab
await this.attachDebugger(tabId);
// Enable necessary CDP domains
await chrome.debugger.sendCommand({ tabId }, 'DOM.enable', {});
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable', {});
// 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',
{
nodeId: root.nodeId,
selector: selector,
},
) 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 (!isFileInput) {
throw new Error(`Element with selector "${selector}" is not a file input (type="file")`);
}
// 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,
},
);
// Trigger change event to ensure the page reacts to the file upload
await chrome.debugger.sendCommand(
{ tabId },
'Runtime.evaluate',
{
expression: `
(function() {
const element = document.querySelector('${selector.replace(/'/g, "\\'")}');
if (element) {
const event = new Event('change', { bubbles: true });
element.dispatchEvent(event);
return true;
}
return false;
})()
`,
},
);
// Clean up debugger
await this.detachDebugger(tabId);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: 'File(s) uploaded successfully',
files: files,
selector: selector,
fileCount: files.length,
}),
},
],
isError: false,
};
} 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);
}
return createErrorResponse(
`Error uploading file: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* 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);
}
/**
* Prepare file from URL or base64 data using native messaging host
*/
private async prepareFileFromRemote(options: {
fileUrl?: string;
base64Data?: string;
fileName: string;
}): Promise<string | null> {
const { fileUrl, base64Data, fileName } = options;
return new Promise((resolve) => {
const requestId = `file-upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const timeout = setTimeout(() => {
console.error('File preparation request timed out');
resolve(null);
}, 30000); // 30 second timeout
// Create listener for the response
const handleMessage = (message: any) => {
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);
resolve(null);
}
}
};
// Add listener
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,
},
},
}).catch((error) => {
console.error('Error sending message to background:', error);
clearTimeout(timeout);
chrome.runtime.onMessage.removeListener(handleMessage);
resolve(null);
});
});
}
}
export const fileUploadTool = new FileUploadTool();
@@ -12,3 +12,4 @@ export { historyTool } from './history';
export { bookmarkSearchTool, bookmarkAddTool, bookmarkDeleteTool } from './bookmark';
export { injectScriptTool, sendCommandToInjectScriptTool } from './inject-script';
export { consoleTool } from './console';
export { fileUploadTool } from './file-upload';
+225
View File
@@ -0,0 +1,225 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as crypto from 'crypto';
import fetch from 'node-fetch';
/**
* File handler for managing file uploads through the native messaging host
*/
export class FileHandler {
private tempDir: string;
constructor() {
// Create a temp directory for file operations
this.tempDir = path.join(os.tmpdir(), 'chrome-mcp-uploads');
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
}
/**
* Handle file preparation request from the extension
*/
async handleFileRequest(request: any): Promise<any> {
const { action, fileUrl, base64Data, fileName, filePath } = request;
try {
switch (action) {
case 'prepareFile':
if (fileUrl) {
return await this.downloadFile(fileUrl, fileName);
} else if (base64Data) {
return await this.saveBase64File(base64Data, fileName);
} else if (filePath) {
return await this.verifyFile(filePath);
}
break;
case 'cleanupFile':
return await this.cleanupFile(filePath);
default:
return {
success: false,
error: `Unknown file action: ${action}`,
};
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Download a file from URL and save to temp directory
*/
private async downloadFile(fileUrl: string, fileName?: string): Promise<any> {
try {
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`);
}
// Generate filename if not provided
const finalFileName = fileName || this.generateFileName(fileUrl);
const filePath = path.join(this.tempDir, finalFileName);
// Get the file buffer
const buffer = await response.buffer();
// Save to file
fs.writeFileSync(filePath, buffer);
return {
success: true,
filePath: filePath,
fileName: finalFileName,
size: buffer.length,
};
} catch (error) {
throw new Error(`Failed to download file from URL: ${error}`);
}
}
/**
* Save base64 data as a file
*/
private async saveBase64File(base64Data: string, fileName?: string): Promise<any> {
try {
// Remove data URL prefix if present
const base64Content = base64Data.replace(/^data:.*?;base64,/, '');
// Convert base64 to buffer
const buffer = Buffer.from(base64Content, 'base64');
// Generate filename if not provided
const finalFileName = fileName || `upload-${Date.now()}.bin`;
const filePath = path.join(this.tempDir, finalFileName);
// Save to file
fs.writeFileSync(filePath, buffer);
return {
success: true,
filePath: filePath,
fileName: finalFileName,
size: buffer.length,
};
} catch (error) {
throw new Error(`Failed to save base64 file: ${error}`);
}
}
/**
* Verify that a file exists and is accessible
*/
private async verifyFile(filePath: string): Promise<any> {
try {
// Check if file exists
if (!fs.existsSync(filePath)) {
throw new Error(`File does not exist: ${filePath}`);
}
// Get file stats
const stats = fs.statSync(filePath);
// Check if it's actually a file
if (!stats.isFile()) {
throw new Error(`Path is not a file: ${filePath}`);
}
// Check if file is readable
fs.accessSync(filePath, fs.constants.R_OK);
return {
success: true,
filePath: filePath,
fileName: path.basename(filePath),
size: stats.size,
};
} catch (error) {
throw new Error(`Failed to verify file: ${error}`);
}
}
/**
* Clean up a temporary file
*/
private async cleanupFile(filePath: string): Promise<any> {
try {
// Only allow cleanup of files in our temp directory
if (!filePath.startsWith(this.tempDir)) {
return {
success: false,
error: 'Can only cleanup files in temp directory',
};
}
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
return {
success: true,
message: 'File cleaned up successfully',
};
} catch (error) {
return {
success: false,
error: `Failed to cleanup file: ${error}`,
};
}
}
/**
* Generate a filename from URL or create a unique one
*/
private generateFileName(url?: string): string {
if (url) {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const basename = path.basename(pathname);
if (basename && basename !== '/') {
// Add random suffix to avoid collisions
const ext = path.extname(basename);
const name = path.basename(basename, ext);
const randomSuffix = crypto.randomBytes(4).toString('hex');
return `${name}-${randomSuffix}${ext}`;
}
} catch {
// Invalid URL, fall through to generate random name
}
}
// Generate random filename
return `upload-${crypto.randomBytes(8).toString('hex')}.bin`;
}
/**
* Clean up old temporary files (older than 1 hour)
*/
cleanupOldFiles(): void {
try {
const now = Date.now();
const oneHour = 60 * 60 * 1000;
const files = fs.readdirSync(this.tempDir);
for (const file of files) {
const filePath = path.join(this.tempDir, file);
const stats = fs.statSync(filePath);
if (now - stats.mtimeMs > oneHour) {
fs.unlinkSync(filePath);
console.log(`Cleaned up old temp file: ${file}`);
}
}
} catch (error) {
console.error('Error cleaning up old files:', error);
}
}
}
export default new FileHandler();
@@ -3,6 +3,7 @@ import { Server } from './server';
import { v4 as uuidv4 } from 'uuid';
import { NativeMessageType } from 'chrome-mcp-shared';
import { TIMEOUTS } from './constant';
import fileHandler from './file-handler';
interface PendingRequest {
resolve: (value: any) => void;
@@ -102,6 +103,9 @@ export class NativeMessagingHost {
case 'ping_from_extension':
this.sendMessage({ type: 'pong_to_extension' });
break;
case 'file_operation':
await this.handleFileOperation(message);
break;
default:
// Double check when message type is not supported
if (!message.responseToRequestId) {
@@ -115,6 +119,45 @@ export class NativeMessagingHost {
}
}
/**
* Handle file operations from the extension
*/
private async handleFileOperation(message: any): Promise<void> {
try {
const result = await fileHandler.handleFileRequest(message.payload);
if (message.requestId) {
// Send response back with the request ID
this.sendMessage({
type: 'file_operation_response',
responseToRequestId: message.requestId,
payload: result,
});
} else {
// No request ID, just send result
this.sendMessage({
type: 'file_operation_result',
payload: result,
});
}
} catch (error: any) {
const errorResponse = {
success: false,
error: error.message || 'Unknown error during file operation',
};
if (message.requestId) {
this.sendMessage({
type: 'file_operation_response',
responseToRequestId: message.requestId,
error: errorResponse.error,
});
} else {
this.sendError(`File operation failed: ${errorResponse.error}`);
}
}
}
/**
* Send request to Chrome and wait for response
* @param messagePayload Data to send to Chrome
+35
View File
@@ -25,6 +25,7 @@ export const TOOL_NAMES = {
INJECT_SCRIPT: 'chrome_inject_script',
SEND_COMMAND_TO_INJECT_SCRIPT: 'chrome_send_command_to_inject_script',
CONSOLE: 'chrome_console',
FILE_UPLOAD: 'chrome_upload_file',
},
};
@@ -534,4 +535,38 @@ export const TOOL_SCHEMAS: Tool[] = [
required: [],
},
},
{
name: TOOL_NAMES.BROWSER.FILE_UPLOAD,
description: 'Upload files to web forms with file input elements using Chrome DevTools Protocol',
inputSchema: {
type: 'object',
properties: {
selector: {
type: 'string',
description: 'CSS selector for the file input element (input[type="file"])',
},
filePath: {
type: 'string',
description: 'Local file path to upload',
},
fileUrl: {
type: 'string',
description: 'URL to download file from before uploading',
},
base64Data: {
type: 'string',
description: 'Base64 encoded file data to upload',
},
fileName: {
type: 'string',
description: 'Optional filename when using base64 or URL (default: "uploaded-file")',
},
multiple: {
type: 'boolean',
description: 'Whether the input accepts multiple files (default: false)',
},
},
required: ['selector'],
},
},
];