diff --git a/README.md b/README.md index 3ef2711..e0bd28d 100644 --- a/README.md +++ b/README.md @@ -621,6 +621,16 @@ http.createServer(async (req, res) => { +- **browser_install_extension** + - Title: Install Chrome extension + - Description: Install a Chrome extension in the current browser session. Only works with Chromium browser. For best results, use pure Chromium without the "chrome" channel. The extension must be an unpacked directory containing manifest.json. + - Parameters: + - `path` (string): Path to the Chrome extension directory (containing manifest.json) + - `name` (string, optional): Optional friendly name for the extension + - Read-only: **false** + + + - **browser_list_devices** - Title: List available devices for emulation - Description: Get a list of all available device emulation profiles including mobile phones, tablets, and desktop browsers. Each device includes viewport, user agent, and capabilities information. @@ -629,6 +639,14 @@ http.createServer(async (req, res) => { +- **browser_list_extensions** + - Title: List installed Chrome extensions + - Description: List all Chrome extensions currently installed in the browser session. Only works with Chromium browser. + - Parameters: None + - Read-only: **true** + + + - **browser_navigate** - Title: Navigate to a URL - Description: Navigate to a URL @@ -752,6 +770,15 @@ http.createServer(async (req, res) => { +- **browser_uninstall_extension** + - Title: Uninstall Chrome extension + - Description: Uninstall a Chrome extension from the current browser session. Only works with Chromium browser. + - Parameters: + - `path` (string): Path to the Chrome extension directory to uninstall + - Read-only: **false** + + + - **browser_wait_for** - Title: Wait for - Description: Wait for text to appear or disappear or a specified time to pass diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts index 7e17b78..a81ef50 100644 --- a/src/browserContextFactory.ts +++ b/src/browserContextFactory.ts @@ -36,7 +36,7 @@ export function contextFactory(browserConfig: FullConfig['browser']): BrowserCon } export interface BrowserContextFactory { - createContext(clientInfo: { name: string, version: string }): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }>; + createContext(clientInfo: { name: string, version: string }, extensionPaths?: string[]): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }>; } class BaseContextFactory implements BrowserContextFactory { @@ -68,14 +68,14 @@ class BaseContextFactory implements BrowserContextFactory { throw new Error('Not implemented'); } - async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + async createContext(clientInfo: { name: string, version: string }, extensionPaths?: string[]): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { testDebug(`create browser context (${this.name})`); const browser = await this._obtainBrowser(); - const browserContext = await this._doCreateContext(browser); + const browserContext = await this._doCreateContext(browser, extensionPaths); return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) }; } - protected async _doCreateContext(browser: playwright.Browser): Promise { + protected async _doCreateContext(browser: playwright.Browser, extensionPaths?: string[]): Promise { throw new Error('Not implemented'); } @@ -92,25 +92,52 @@ class BaseContextFactory implements BrowserContextFactory { } class IsolatedContextFactory extends BaseContextFactory { + private _extensionPaths: string[] = []; + constructor(browserConfig: FullConfig['browser']) { super('isolated', browserConfig); } + async createContext(clientInfo: { name: string, version: string }, extensionPaths?: string[]): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + // Update extension paths and recreate browser if extensions changed + const hasExtensionsChanged = JSON.stringify(this._extensionPaths) !== JSON.stringify(extensionPaths || []); + + if (hasExtensionsChanged) { + this._extensionPaths = extensionPaths || []; + // Force browser recreation with new extensions + this._browserPromise = undefined; + } + + return super.createContext(clientInfo, extensionPaths); + } + protected override async _doObtainBrowser(): Promise { await injectCdpPort(this.browserConfig); const browserType = playwright[this.browserConfig.browserName]; - return browserType.launch({ + + const launchOptions = { ...this.browserConfig.launchOptions, handleSIGINT: false, handleSIGTERM: false, - }).catch(error => { + }; + + // Add Chrome extension support for Chromium + if (this.browserConfig.browserName === 'chromium' && this._extensionPaths.length > 0) { + testDebug(`Launching browser with ${this._extensionPaths.length} Chrome extensions: ${this._extensionPaths.join(', ')}`); + launchOptions.args = [ + ...(launchOptions.args || []), + ...this._extensionPaths.map(path => `--load-extension=${path}`) + ]; + } + + return browserType.launch(launchOptions).catch(error => { if (error.message.includes('Executable doesn\'t exist')) throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`); throw error; }); } - protected override async _doCreateContext(browser: playwright.Browser): Promise { + protected override async _doCreateContext(browser: playwright.Browser, extensionPaths?: string[]): Promise { return browser.newContext(this.browserConfig.contextOptions); } } @@ -124,7 +151,9 @@ class CdpContextFactory extends BaseContextFactory { return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!); } - protected override async _doCreateContext(browser: playwright.Browser): Promise { + protected override async _doCreateContext(browser: playwright.Browser, extensionPaths?: string[]): Promise { + if (extensionPaths && extensionPaths.length > 0) + testDebug('Warning: Chrome extensions are not supported with CDP connections'); return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0]; } } @@ -142,7 +171,9 @@ class RemoteContextFactory extends BaseContextFactory { return playwright[this.browserConfig.browserName].connect(String(url)); } - protected override async _doCreateContext(browser: playwright.Browser): Promise { + protected override async _doCreateContext(browser: playwright.Browser, extensionPaths?: string[]): Promise { + if (extensionPaths && extensionPaths.length > 0) + testDebug('Warning: Chrome extensions are not supported with remote browser connections'); return browser.newContext(); } } @@ -155,7 +186,7 @@ class PersistentContextFactory implements BrowserContextFactory { this.browserConfig = browserConfig; } - async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + async createContext(clientInfo: { name: string, version: string }, extensionPaths?: string[]): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { await injectCdpPort(this.browserConfig); testDebug('create browser context (persistent)'); const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir(); @@ -163,15 +194,26 @@ class PersistentContextFactory implements BrowserContextFactory { this._userDataDirs.add(userDataDir); testDebug('lock user data dir', userDataDir); + const launchOptions = { + ...this.browserConfig.launchOptions, + ...this.browserConfig.contextOptions, + handleSIGINT: false, + handleSIGTERM: false, + }; + + // Add Chrome extension support for Chromium + if (this.browserConfig.browserName === 'chromium' && extensionPaths && extensionPaths.length > 0) { + testDebug(`Loading ${extensionPaths.length} Chrome extensions in persistent context: ${extensionPaths.join(', ')}`); + launchOptions.args = [ + ...(launchOptions.args || []), + ...extensionPaths.map(path => `--load-extension=${path}`) + ]; + } + const browserType = playwright[this.browserConfig.browserName]; for (let i = 0; i < 5; i++) { try { - const browserContext = await browserType.launchPersistentContext(userDataDir, { - ...this.browserConfig.launchOptions, - ...this.browserConfig.contextOptions, - handleSIGINT: false, - handleSIGTERM: false, - }); + const browserContext = await browserType.launchPersistentContext(userDataDir, launchOptions); const close = () => this._closeBrowserContext(browserContext, userDataDir); return { browserContext, close }; } catch (error: any) { diff --git a/src/context.ts b/src/context.ts index d5d356b..26c48a3 100644 --- a/src/context.ts +++ b/src/context.ts @@ -48,6 +48,9 @@ export class Context { readonly sessionId: string; private _sessionStartTime: number; + // Chrome extension management + private _installedExtensions: Array<{ path: string; name: string; version?: string }> = []; + constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory, environmentIntrospector?: EnvironmentIntrospector) { this.tools = tools; this.config = config; @@ -250,7 +253,7 @@ export class Context { result = await this._createVideoEnabledContext(); } else { // Use the standard browser context factory - result = await this._browserContextFactory.createContext(this.clientVersion!); + result = await this._browserContextFactory.createContext(this.clientVersion!, this._getExtensionPaths()); } const { browserContext } = result; await this._setupRequestInterception(browserContext); @@ -275,12 +278,24 @@ export class Context { // Get environment-specific browser options const envOptions = this._environmentIntrospector.getRecommendedBrowserOptions(); - const browser = await browserType.launch({ + const launchOptions = { ...this.config.browser.launchOptions, ...envOptions, // Include environment-detected options handleSIGINT: false, handleSIGTERM: false, - }); + }; + + // Add Chrome extension support for Chromium + const extensionPaths = this._getExtensionPaths(); + if (this.config.browser.browserName === 'chromium' && extensionPaths.length > 0) { + testDebug(`Loading ${extensionPaths.length} Chrome extensions in video context: ${extensionPaths.join(', ')}`); + launchOptions.args = [ + ...(launchOptions.args || []), + ...extensionPaths.map(path => `--load-extension=${path}`) + ]; + } + + const browser = await browserType.launch(launchOptions); // Use environment-specific video directory if available const videoConfig = envOptions.recordVideo ? @@ -451,4 +466,81 @@ export class Context { return videoPaths; } + + // Chrome Extension Management + + async installExtension(extensionPath: string, extensionName: string): Promise { + if (this.config.browser.browserName !== 'chromium') + throw new Error('Chrome extensions are only supported with Chromium browser.'); + + // Check if extension is already installed + const existingExtension = this._installedExtensions.find(ext => ext.path === extensionPath); + if (existingExtension) + throw new Error(`Extension is already installed: ${extensionName} (${extensionPath})`); + + // Read extension manifest to get version info + const fs = await import('fs'); + const path = await import('path'); + const manifestPath = path.join(extensionPath, 'manifest.json'); + + let version: string | undefined; + try { + const manifestContent = fs.readFileSync(manifestPath, 'utf8'); + const manifest = JSON.parse(manifestContent); + version = manifest.version; + } catch (error) { + testDebug('Could not read extension version:', error); + } + + // Add to installed extensions list + this._installedExtensions.push({ + path: extensionPath, + name: extensionName, + version + }); + + testDebug(`Installing Chrome extension: ${extensionName} from ${extensionPath}`); + + // Restart browser with updated extension list + await this._restartBrowserWithExtensions(); + } + + getInstalledExtensions(): Array<{ path: string; name: string; version?: string }> { + return [...this._installedExtensions]; + } + + async uninstallExtension(extensionPath: string): Promise<{ path: string; name: string; version?: string } | null> { + const extensionIndex = this._installedExtensions.findIndex(ext => ext.path === extensionPath); + + if (extensionIndex === -1) + return null; + + const removedExtension = this._installedExtensions.splice(extensionIndex, 1)[0]; + + testDebug(`Uninstalling Chrome extension: ${removedExtension.name} from ${extensionPath}`); + + // Restart browser with updated extension list + await this._restartBrowserWithExtensions(); + + return removedExtension; + } + + private async _restartBrowserWithExtensions(): Promise { + // Close existing browser context if open + if (this._browserContextPromise) { + const { close } = await this._browserContextPromise; + await close(); + this._browserContextPromise = undefined; + } + + // Clear all tabs as they will be recreated + this._tabs = []; + this._currentTab = undefined; + + testDebug(`Restarting browser with ${this._installedExtensions.length} extensions`); + } + + private _getExtensionPaths(): string[] { + return this._installedExtensions.map(ext => ext.path); + } } diff --git a/src/tools/configure.ts b/src/tools/configure.ts index 9c38d0c..3a59cf8 100644 --- a/src/tools/configure.ts +++ b/src/tools/configure.ts @@ -48,6 +48,17 @@ const configureArtifactsSchema = z.object({ sessionId: z.string().optional().describe('Custom session ID for artifact organization (auto-generated if not provided)') }); +const installExtensionSchema = z.object({ + path: z.string().describe('Path to the Chrome extension directory (containing manifest.json)'), + name: z.string().optional().describe('Optional friendly name for the extension') +}); + +const listExtensionsSchema = z.object({}); + +const uninstallExtensionSchema = z.object({ + path: z.string().describe('Path to the Chrome extension directory to uninstall') +}); + export default [ defineTool({ capability: 'core', @@ -267,7 +278,7 @@ export default [ } else { // Show current status - re-check after potential changes const currentManager = currentSessionId ? registry.getManager(currentSessionId) : undefined; - + if (currentManager && currentSessionId) { const stats = currentManager.getSessionStats(); response.addResult( @@ -304,4 +315,147 @@ export default [ } }, }), + defineTool({ + capability: 'core', + schema: { + name: 'browser_install_extension', + title: 'Install Chrome extension', + description: 'Install a Chrome extension in the current browser session. Only works with Chromium browser. For best results, use pure Chromium without the "chrome" channel. The extension must be an unpacked directory containing manifest.json.', + inputSchema: installExtensionSchema, + type: 'destructive', + }, + handle: async (context: Context, params: z.output, response: Response) => { + try { + // Validate that we're using Chromium + if (context.config.browser.browserName !== 'chromium') + throw new Error('Chrome extensions are only supported with Chromium browser. Use browser_configure to switch to chromium.'); + + // Additional validation for Chrome channel + const hasChannel = context.config.browser.launchOptions.channel; + if (hasChannel === 'chrome') { + response.addResult( + '⚠️ **Important**: You are using Chrome via the "chrome" channel.\n\n' + + 'Chrome extensions work best with pure Chromium (no channel).\n' + + 'If extensions don\'t load properly, consider:\n\n' + + '1. Installing pure Chromium: `sudo apt install chromium-browser` (Linux)\n' + + '2. Using browser_configure to remove the chrome channel\n' + + '3. Ensuring unpacked extensions are enabled in your browser settings\n\n' + + 'Continuing with Chrome channel (extensions may not load)...\n' + ); + } + + // Validate extension path exists and contains manifest.json + const fs = await import('fs'); + const path = await import('path'); + + if (!fs.existsSync(params.path)) + throw new Error(`Extension directory not found: ${params.path}`); + + const manifestPath = path.join(params.path, 'manifest.json'); + if (!fs.existsSync(manifestPath)) + throw new Error(`manifest.json not found in extension directory: ${params.path}`); + + // Read and validate manifest + const manifestContent = fs.readFileSync(manifestPath, 'utf8'); + let manifest; + try { + manifest = JSON.parse(manifestContent); + } catch (error) { + throw new Error(`Invalid manifest.json: ${error}`); + } + + if (!manifest.name) + throw new Error('Extension manifest must contain a "name" field'); + + // Install the extension by updating browser configuration + await context.installExtension(params.path, params.name || manifest.name); + + response.addResult( + `✅ Chrome extension installed successfully!\n\n` + + `Extension: ${params.name || manifest.name}\n` + + `Path: ${params.path}\n` + + `Manifest version: ${manifest.manifest_version || 'unknown'}\n\n` + + `The browser has been restarted with the extension loaded.\n` + + `Use browser_list_extensions to see all installed extensions.` + ); + + } catch (error) { + throw new Error(`Failed to install Chrome extension: ${error}`); + } + }, + }), + defineTool({ + capability: 'core', + schema: { + name: 'browser_list_extensions', + title: 'List installed Chrome extensions', + description: 'List all Chrome extensions currently installed in the browser session. Only works with Chromium browser.', + inputSchema: listExtensionsSchema, + type: 'readOnly', + }, + handle: async (context: Context, params: z.output, response: Response) => { + try { + // Validate that we're using Chromium + if (context.config.browser.browserName !== 'chromium') + throw new Error('Chrome extensions are only supported with Chromium browser.'); + + const extensions = context.getInstalledExtensions(); + + if (extensions.length === 0) { + response.addResult('No Chrome extensions are currently installed.\n\nUse browser_install_extension to install extensions.'); + return; + } + + let result = `Installed Chrome extensions (${extensions.length}):\n\n`; + + extensions.forEach((ext, index) => { + result += `${index + 1}. **${ext.name}**\n`; + result += ` Path: ${ext.path}\n`; + if (ext.version) + result += ` Version: ${ext.version}\n`; + result += '\n'; + }); + + result += 'Use browser_uninstall_extension to remove extensions.'; + + response.addResult(result); + + } catch (error) { + throw new Error(`Failed to list Chrome extensions: ${error}`); + } + }, + }), + defineTool({ + capability: 'core', + schema: { + name: 'browser_uninstall_extension', + title: 'Uninstall Chrome extension', + description: 'Uninstall a Chrome extension from the current browser session. Only works with Chromium browser.', + inputSchema: uninstallExtensionSchema, + type: 'destructive', + }, + handle: async (context: Context, params: z.output, response: Response) => { + try { + // Validate that we're using Chromium + if (context.config.browser.browserName !== 'chromium') + throw new Error('Chrome extensions are only supported with Chromium browser.'); + + const removedExtension = await context.uninstallExtension(params.path); + + if (!removedExtension) + throw new Error(`Extension not found: ${params.path}`); + + response.addResult( + `✅ Chrome extension uninstalled successfully!\n\n` + + `Extension: ${removedExtension.name}\n` + + `Path: ${params.path}\n\n` + + `The browser has been restarted without this extension.\n` + + `Use browser_list_extensions to see remaining extensions.` + ); + + } catch (error) { + throw new Error(`Failed to uninstall Chrome extension: ${error}`); + } + }, + }), ];