@semantest/chrome-extension
Version:
Browser extension for ChatGPT-buddy - AI automation extension built on Web-Buddy framework
741 lines (630 loc) • 24.5 kB
text/typescript
/**
* @fileoverview Plugin Loader implementation for Web-Buddy plugin system
* @description Handles dynamic plugin loading, validation, and sandboxing
*/
import {
WebBuddyPlugin,
PluginManifest,
PluginPackage,
PluginSecurityPolicy,
PluginLoadError,
PluginInitializationError,
PluginSecurityError,
PluginDependencyError,
PluginError
} from './plugin-interface';
/**
* Plugin loading options
*/
interface PluginLoadOptions {
skipDependencies?: boolean;
skipValidation?: boolean;
allowReload?: boolean;
timeout?: number;
sandboxed?: boolean;
securityPolicy?: PluginSecurityPolicy;
}
/**
* Plugin loading result
*/
interface PluginLoadResult {
plugin: WebBuddyPlugin;
manifest: PluginManifest;
loadTime: number;
warnings: string[];
dependencies: string[];
}
/**
* Plugin validation result
*/
interface PluginValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
securityIssues: string[];
}
/**
* Plugin sandbox configuration
*/
interface PluginSandboxConfig {
allowedGlobals: string[];
allowedModules: string[];
maxMemoryUsage?: number;
maxExecutionTime?: number;
restrictedAPIs: string[];
}
/**
* Core plugin loader implementation
*/
export class PluginLoader {
private loadedPlugins: Map<string, PluginLoadResult> = new Map();
private dependencyCache: Map<string, any> = new Map();
private sandboxGlobals: Map<string, any> = new Map();
private defaultSecurityPolicy: PluginSecurityPolicy;
constructor(defaultSecurityPolicy?: PluginSecurityPolicy) {
this.defaultSecurityPolicy = defaultSecurityPolicy || {
allowedDomains: ['*'],
allowedPermissions: ['storage', 'tabs'],
allowedAPIs: ['chrome.runtime', 'chrome.tabs', 'chrome.storage'],
sandboxed: true,
trustedSource: false,
maxMemoryUsage: 10 * 1024 * 1024, // 10MB
maxExecutionTime: 5000 // 5 seconds
};
this.setupSandboxGlobals();
}
/**
* Load a plugin from a manifest
*/
async loadFromManifest(manifest: PluginManifest, options: PluginLoadOptions = {}): Promise<PluginLoadResult> {
const startTime = Date.now();
const pluginId = manifest.metadata.id;
try {
console.log(`🔄 Loading plugin: ${pluginId}`);
// Check if already loaded
if (this.loadedPlugins.has(pluginId) && !options.allowReload) {
throw new PluginLoadError(`Plugin ${pluginId} is already loaded`, pluginId);
}
// Validate manifest
if (!options.skipValidation) {
const validation = await this.validateManifest(manifest);
if (!validation.valid) {
throw new PluginLoadError(`Plugin validation failed: ${validation.errors.join(', ')}`, pluginId);
}
}
// Check dependencies
if (!options.skipDependencies) {
await this.checkDependencies(manifest.dependencies || []);
}
// Apply security policy
const securityPolicy = options.securityPolicy || this.defaultSecurityPolicy;
await this.enforceSecurityPolicy(manifest, securityPolicy);
// Load plugin dependencies
const dependencies = await this.loadDependencies(manifest.dependencies || []);
// Create plugin instance
const plugin = await this.createPluginInstance(manifest, options);
// Build result
const loadResult: PluginLoadResult = {
plugin,
manifest,
loadTime: Date.now() - startTime,
warnings: [],
dependencies: manifest.dependencies || []
};
// Cache the result
this.loadedPlugins.set(pluginId, loadResult);
console.log(`✅ Plugin loaded successfully: ${pluginId} (${loadResult.loadTime}ms)`);
return loadResult;
} catch (error) {
const loadError = error instanceof PluginError ? error :
new PluginLoadError(`Failed to load plugin ${pluginId}: ${(error as Error).message}`, pluginId, error);
console.error(`❌ Plugin loading failed: ${pluginId}`, loadError);
throw loadError;
}
}
/**
* Load a plugin from a package
*/
async loadFromPackage(pluginPackage: PluginPackage, options: PluginLoadOptions = {}): Promise<PluginLoadResult> {
try {
// Validate package integrity
await this.validatePackage(pluginPackage);
// Extract scripts and resources
await this.extractPackageResources(pluginPackage);
// Load from the extracted manifest
return this.loadFromManifest(pluginPackage.manifest, options);
} catch (error) {
throw new PluginLoadError(`Failed to load plugin package: ${(error as Error).message}`, 'unknown', error);
}
}
/**
* Load a plugin from a URL
*/
async loadFromURL(url: string, options: PluginLoadOptions = {}): Promise<PluginLoadResult> {
try {
console.log(`🌐 Loading plugin from URL: ${url}`);
// Fetch the plugin package
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const packageData = await response.json();
const pluginPackage: PluginPackage = this.parsePackageData(packageData);
return this.loadFromPackage(pluginPackage, options);
} catch (error) {
throw new PluginLoadError(`Failed to load plugin from URL ${url}: ${(error as Error).message}`, 'unknown', error);
}
}
/**
* Unload a plugin
*/
async unload(pluginId: string): Promise<void> {
const loadResult = this.loadedPlugins.get(pluginId);
if (!loadResult) {
throw new PluginError(`Plugin ${pluginId} is not loaded`, pluginId, 'PLUGIN_NOT_LOADED');
}
try {
// Destroy the plugin instance
if (typeof loadResult.plugin.destroy === 'function') {
await loadResult.plugin.destroy();
}
// Clear from cache
this.loadedPlugins.delete(pluginId);
// Clean up dependencies if no other plugins use them
await this.cleanupUnusedDependencies(loadResult.dependencies);
console.log(`🗑️ Plugin unloaded: ${pluginId}`);
} catch (error) {
throw new PluginError(`Failed to unload plugin ${pluginId}: ${(error as Error).message}`, pluginId, 'UNLOAD_ERROR');
}
}
/**
* Get loaded plugin
*/
getLoadedPlugin(pluginId: string): PluginLoadResult | null {
return this.loadedPlugins.get(pluginId) || null;
}
/**
* Get all loaded plugins
*/
getAllLoadedPlugins(): PluginLoadResult[] {
return Array.from(this.loadedPlugins.values());
}
/**
* Validate a plugin manifest
*/
async validateManifest(manifest: PluginManifest): Promise<PluginValidationResult> {
const result: PluginValidationResult = {
valid: true,
errors: [],
warnings: [],
securityIssues: []
};
try {
// Validate metadata
if (!manifest.metadata) {
result.errors.push('Manifest missing metadata');
result.valid = false;
} else {
if (!manifest.metadata.id || typeof manifest.metadata.id !== 'string') {
result.errors.push('Plugin ID is required and must be a string');
result.valid = false;
}
if (!manifest.metadata.name || typeof manifest.metadata.name !== 'string') {
result.errors.push('Plugin name is required and must be a string');
result.valid = false;
}
if (!manifest.metadata.version || typeof manifest.metadata.version !== 'string') {
result.errors.push('Plugin version is required and must be a string');
result.valid = false;
}
// Validate version format (semantic versioning)
if (manifest.metadata.version && !this.isValidVersion(manifest.metadata.version)) {
result.warnings.push('Plugin version should follow semantic versioning (e.g., 1.0.0)');
}
}
// Validate capabilities
if (!manifest.capabilities) {
result.errors.push('Manifest missing capabilities');
result.valid = false;
} else {
if (!Array.isArray(manifest.capabilities.supportedDomains)) {
result.errors.push('Supported domains must be an array');
result.valid = false;
}
if (!Array.isArray(manifest.capabilities.contractDefinitions)) {
result.errors.push('Contract definitions must be an array');
result.valid = false;
}
}
// Validate entry point
if (!manifest.entry) {
result.errors.push('Manifest missing entry point');
result.valid = false;
} else {
if (!manifest.entry.script || typeof manifest.entry.script !== 'string') {
result.errors.push('Entry script is required and must be a string');
result.valid = false;
}
}
// Security validation
await this.validateSecurity(manifest, result);
// Dependency validation
if (manifest.dependencies && manifest.dependencies.length > 0) {
await this.validateDependencies(manifest.dependencies, result);
}
} catch (error) {
result.errors.push(`Validation error: ${(error as Error).message}`);
result.valid = false;
}
return result;
}
/**
* Get plugin loader statistics
*/
getStatistics() {
const stats = {
loadedPlugins: this.loadedPlugins.size,
totalLoadTime: 0,
averageLoadTime: 0,
dependenciesInCache: this.dependencyCache.size,
sandboxedPlugins: 0
};
const loadTimes: number[] = [];
for (const result of this.loadedPlugins.values()) {
loadTimes.push(result.loadTime);
stats.totalLoadTime += result.loadTime;
}
if (loadTimes.length > 0) {
stats.averageLoadTime = stats.totalLoadTime / loadTimes.length;
}
return stats;
}
// Private helper methods
private async createPluginInstance(manifest: PluginManifest, options: PluginLoadOptions): Promise<WebBuddyPlugin> {
const { entry } = manifest;
const pluginId = manifest.metadata.id;
try {
// Load the plugin script
const script = await this.loadPluginScript(entry.script);
// Create execution context
const context = options.sandboxed !== false ?
this.createSandboxedContext(pluginId, script, options) :
this.createDirectContext(pluginId, script, options);
// Execute the plugin script in the context
const PluginClass = await this.executePluginScript(context, entry);
// Create and validate plugin instance
const plugin = new PluginClass();
await this.validatePluginInstance(plugin, manifest);
return plugin;
} catch (error) {
throw new PluginInitializationError(
`Failed to create plugin instance for ${pluginId}: ${(error as Error).message}`,
pluginId,
error
);
}
}
private async loadPluginScript(scriptPath: string): Promise<string> {
// In a real implementation, this would fetch the script from the extension's resources
// For now, we'll return a placeholder
throw new PluginLoadError('Plugin script loading not implemented', 'unknown');
}
private createSandboxedContext(pluginId: string, script: string, options: PluginLoadOptions): any {
// Create a sandboxed execution context
const sandboxConfig: PluginSandboxConfig = {
allowedGlobals: ['console', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'],
allowedModules: ['plugin-interface'],
maxMemoryUsage: options.securityPolicy?.maxMemoryUsage,
maxExecutionTime: options.securityPolicy?.maxExecutionTime,
restrictedAPIs: ['eval', 'Function', 'document', 'window']
};
// Create a restricted global context
const sandbox = {
console: {
log: (...args: any[]) => console.log(`[${pluginId}]`, ...args),
error: (...args: any[]) => console.error(`[${pluginId}]`, ...args),
warn: (...args: any[]) => console.warn(`[${pluginId}]`, ...args),
debug: (...args: any[]) => console.debug(`[${pluginId}]`, ...args)
},
setTimeout: (callback: Function, delay: number) => setTimeout(callback, delay),
clearTimeout: (id: number) => clearTimeout(id),
setInterval: (callback: Function, delay: number) => setInterval(callback, delay),
clearInterval: (id: number) => clearInterval(id),
require: (moduleName: string) => this.requireModule(moduleName, sandboxConfig),
exports: {},
module: { exports: {} }
};
return sandbox;
}
private createDirectContext(pluginId: string, script: string, options: PluginLoadOptions): any {
// Create a direct execution context (less secure but more performant)
return {
console: console,
require: (moduleName: string) => this.requireModule(moduleName),
exports: {},
module: { exports: {} }
};
}
private async executePluginScript(context: any, entry: PluginManifest['entry']): Promise<any> {
// This is a simplified implementation
// In a real scenario, you'd use a proper JavaScript execution environment
try {
// Create a function that executes the plugin script in the given context
const scriptFunction = new Function(
'console', 'require', 'exports', 'module',
`
// Plugin script would be executed here
// For now, we'll return a mock plugin class
return class MockPlugin {
constructor() {
this.id = 'mock-plugin';
this.name = 'Mock Plugin';
this.version = '1.0.0';
this.description = 'Mock plugin for testing';
this.author = 'System';
this.metadata = { id: this.id, name: this.name, version: this.version, description: this.description, author: this.author };
this.capabilities = { supportedDomains: [], contractDefinitions: [], permissions: [], requiredAPIs: [] };
this.state = 'uninitialized';
}
async initialize(context) { this.state = 'initialized'; }
async activate() { this.state = 'active'; }
async deactivate() { this.state = 'inactive'; }
async destroy() { this.state = 'destroyed'; }
getContracts() { return []; }
async executeCapability(capability, params) { return { success: true }; }
async validateCapability(capability, params) { return { valid: true, errors: [] }; }
getUIComponents() { return []; }
getMenuItems() { return []; }
async onEvent(event) { }
getDefaultConfig() { return { enabled: true, settings: {}, domains: [], permissions: [], uiPreferences: {} }; }
async onConfigChange(config) { }
async healthCheck() { return { healthy: true, issues: [] }; }
async getMetrics() { return {}; }
};
`
);
const PluginClass = scriptFunction(
context.console,
context.require,
context.exports,
context.module
);
return PluginClass;
} catch (error) {
throw new PluginExecutionError(
`Failed to execute plugin script: ${(error as Error).message}`,
'unknown',
error
);
}
}
private async validatePluginInstance(plugin: WebBuddyPlugin, manifest: PluginManifest): Promise<void> {
// Validate that the plugin instance matches the manifest
if (plugin.id !== manifest.metadata.id) {
throw new PluginValidationError(
`Plugin ID mismatch: expected ${manifest.metadata.id}, got ${plugin.id}`,
plugin.id
);
}
if (plugin.version !== manifest.metadata.version) {
throw new PluginValidationError(
`Plugin version mismatch: expected ${manifest.metadata.version}, got ${plugin.version}`,
plugin.id
);
}
// Validate required methods exist
const requiredMethods = [
'initialize', 'activate', 'deactivate', 'destroy',
'getContracts', 'executeCapability', 'validateCapability',
'getUIComponents', 'getMenuItems', 'onEvent',
'getDefaultConfig', 'onConfigChange', 'healthCheck', 'getMetrics'
];
for (const method of requiredMethods) {
if (typeof (plugin as any)[method] !== 'function') {
throw new PluginValidationError(
`Plugin missing required method: ${method}`,
plugin.id
);
}
}
}
private requireModule(moduleName: string, sandboxConfig?: PluginSandboxConfig): any {
// Implement module loading with security restrictions
if (sandboxConfig && !sandboxConfig.allowedModules.includes(moduleName)) {
throw new PluginSecurityError(
`Module ${moduleName} is not allowed in sandbox`,
'unknown',
'MODULE_NOT_ALLOWED'
);
}
// Return cached dependency if available
if (this.dependencyCache.has(moduleName)) {
return this.dependencyCache.get(moduleName);
}
// Mock module loading - in reality, this would load actual modules
switch (moduleName) {
case 'plugin-interface':
return require('./plugin-interface');
default:
throw new Error(`Module not found: ${moduleName}`);
}
}
private async checkDependencies(dependencies: string[]): Promise<void> {
for (const depId of dependencies) {
if (!this.loadedPlugins.has(depId)) {
throw new PluginDependencyError(`Required dependency ${depId} is not loaded`, depId);
}
}
}
private async loadDependencies(dependencies: string[]): Promise<string[]> {
const loadedDeps: string[] = [];
for (const depId of dependencies) {
if (!this.dependencyCache.has(depId)) {
// In a real implementation, this would load the actual dependency
this.dependencyCache.set(depId, { id: depId, loaded: true });
}
loadedDeps.push(depId);
}
return loadedDeps;
}
private async cleanupUnusedDependencies(dependencies: string[]): Promise<void> {
for (const depId of dependencies) {
// Check if any other loaded plugins still use this dependency
const stillInUse = Array.from(this.loadedPlugins.values())
.some(result => result.dependencies.includes(depId));
if (!stillInUse) {
this.dependencyCache.delete(depId);
}
}
}
private async enforceSecurityPolicy(manifest: PluginManifest, policy: PluginSecurityPolicy): Promise<void> {
// Validate domain restrictions
if (policy.allowedDomains && policy.allowedDomains.length > 0 && !policy.allowedDomains.includes('*')) {
const supportedDomains = manifest.capabilities.supportedDomains;
for (const domain of supportedDomains) {
if (!policy.allowedDomains.some(allowed => domain.match(new RegExp(allowed.replace(/\*/g, '.*'))))) {
throw new PluginSecurityError(
`Domain ${domain} is not allowed by security policy`,
manifest.metadata.id,
'DOMAIN_NOT_ALLOWED'
);
}
}
}
// Validate permissions
if (policy.allowedPermissions && policy.allowedPermissions.length > 0) {
const requestedPermissions = manifest.capabilities.permissions || [];
for (const permission of requestedPermissions) {
if (!policy.allowedPermissions.includes(permission)) {
throw new PluginSecurityError(
`Permission ${permission} is not allowed by security policy`,
manifest.metadata.id,
'PERMISSION_NOT_ALLOWED'
);
}
}
}
// Validate API access
if (policy.allowedAPIs && policy.allowedAPIs.length > 0) {
const requiredAPIs = manifest.capabilities.requiredAPIs || [];
for (const api of requiredAPIs) {
if (!policy.allowedAPIs.includes(api)) {
throw new PluginSecurityError(
`API ${api} is not allowed by security policy`,
manifest.metadata.id,
'API_NOT_ALLOWED'
);
}
}
}
}
private async validateSecurity(manifest: PluginManifest, result: PluginValidationResult): Promise<void> {
// Check for suspicious permissions
const suspiciousPermissions = ['debugger', 'management', 'privacy', 'proxy'];
const requestedPermissions = manifest.capabilities.permissions || [];
for (const permission of requestedPermissions) {
if (suspiciousPermissions.includes(permission)) {
result.securityIssues.push(`Suspicious permission requested: ${permission}`);
}
}
// Check for overly broad domain access
const supportedDomains = manifest.capabilities.supportedDomains || [];
if (supportedDomains.includes('*') || supportedDomains.includes('<all_urls>')) {
result.securityIssues.push('Plugin requests access to all domains');
}
// Check for dangerous APIs
const dangerousAPIs = ['chrome.debugger', 'chrome.management', 'chrome.privacy'];
const requiredAPIs = manifest.capabilities.requiredAPIs || [];
for (const api of requiredAPIs) {
if (dangerousAPIs.includes(api)) {
result.securityIssues.push(`Dangerous API requested: ${api}`);
}
}
}
private async validateDependencies(dependencies: string[], result: PluginValidationResult): Promise<void> {
// Check for circular dependencies
const dependencyGraph = new Map<string, string[]>();
dependencyGraph.set('current', dependencies);
if (this.hasCircularDependencies(dependencyGraph, 'current', new Set())) {
result.errors.push('Circular dependency detected');
result.valid = false;
}
// Validate dependency availability
for (const depId of dependencies) {
if (!this.isDependencyAvailable(depId)) {
result.warnings.push(`Dependency ${depId} may not be available`);
}
}
}
private hasCircularDependencies(graph: Map<string, string[]>, node: string, visited: Set<string>): boolean {
if (visited.has(node)) {
return true;
}
visited.add(node);
const deps = graph.get(node) || [];
for (const dep of deps) {
if (this.hasCircularDependencies(graph, dep, new Set(visited))) {
return true;
}
}
return false;
}
private isDependencyAvailable(dependencyId: string): boolean {
// Check if dependency is available in the system
return this.loadedPlugins.has(dependencyId) || this.dependencyCache.has(dependencyId);
}
private isValidVersion(version: string): boolean {
// Simplified semantic version validation
const semverRegex = /^\d+\.\d+\.\d+(?:-[a-zA-Z0-9-]+)?(?:\+[a-zA-Z0-9-]+)?$/;
return semverRegex.test(version);
}
private async validatePackage(pluginPackage: PluginPackage): Promise<void> {
// Validate package integrity
if (pluginPackage.checksum) {
// Verify checksum
// Implementation would depend on the checksum algorithm used
}
if (pluginPackage.signature) {
// Verify digital signature
// Implementation would depend on the signing algorithm used
}
}
private async extractPackageResources(pluginPackage: PluginPackage): Promise<void> {
// Extract and prepare package resources
// This would typically involve extracting scripts and resources to a temporary location
}
private parsePackageData(packageData: any): PluginPackage {
// Parse and validate package data format
if (!packageData.manifest) {
throw new Error('Package missing manifest');
}
return {
manifest: packageData.manifest,
scripts: packageData.scripts || {},
resources: packageData.resources || {},
signature: packageData.signature,
checksum: packageData.checksum
};
}
private setupSandboxGlobals(): void {
// Setup global objects available in the sandbox
this.sandboxGlobals.set('console', console);
this.sandboxGlobals.set('setTimeout', setTimeout);
this.sandboxGlobals.set('clearTimeout', clearTimeout);
this.sandboxGlobals.set('setInterval', setInterval);
this.sandboxGlobals.set('clearInterval', clearInterval);
}
}
// Plugin error classes
class PluginValidationError extends PluginError {
constructor(message: string, pluginId: string, details?: any) {
super(message, pluginId, 'PLUGIN_VALIDATION_ERROR', details);
this.name = 'PluginValidationError';
}
}
class PluginExecutionError extends PluginError {
constructor(message: string, pluginId: string, details?: any) {
super(message, pluginId, 'PLUGIN_EXECUTION_ERROR', details);
this.name = 'PluginExecutionError';
}
}