diff --git a/app/chrome-extension/entrypoints/background/native-host.ts b/app/chrome-extension/entrypoints/background/native-host.ts index 107231b..72a9df3 100644 --- a/app/chrome-extension/entrypoints/background/native-host.ts +++ b/app/chrome-extension/entrypoints/background/native-host.ts @@ -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; + } }); }; diff --git a/app/chrome-extension/entrypoints/background/tools/browser/file-upload.ts b/app/chrome-extension/entrypoints/background/tools/browser/file-upload.ts new file mode 100644 index 0000000..7e3caf1 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/file-upload.ts @@ -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 = 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 { + 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 { + // 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); + } + + /** + * Prepare file from URL or base64 data using native messaging host + */ + private async prepareFileFromRemote(options: { + fileUrl?: string; + base64Data?: string; + fileName: string; + }): Promise { + 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(); \ No newline at end of file diff --git a/app/chrome-extension/entrypoints/background/tools/browser/index.ts b/app/chrome-extension/entrypoints/background/tools/browser/index.ts index 2ad599a..05dd2ea 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/index.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/index.ts @@ -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'; diff --git a/app/native-server/src/file-handler.ts b/app/native-server/src/file-handler.ts new file mode 100644 index 0000000..5f86aee --- /dev/null +++ b/app/native-server/src/file-handler.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(); \ No newline at end of file diff --git a/app/native-server/src/native-messaging-host.ts b/app/native-server/src/native-messaging-host.ts index 47d32ff..dcf5a16 100644 --- a/app/native-server/src/native-messaging-host.ts +++ b/app/native-server/src/native-messaging-host.ts @@ -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 { + 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 diff --git a/packages/shared/src/tools.ts b/packages/shared/src/tools.ts index 82428b5..23a1af5 100644 --- a/packages/shared/src/tools.ts +++ b/packages/shared/src/tools.ts @@ -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'], + }, + }, ];