diff --git a/v2/src-tauri/src/commands/plugins.rs b/v2/src-tauri/src/commands/plugins.rs new file mode 100644 index 0000000..1c50743 --- /dev/null +++ b/v2/src-tauri/src/commands/plugins.rs @@ -0,0 +1,20 @@ +// Plugin discovery and file access commands + +use crate::AppState; +use crate::plugins; + +#[tauri::command] +pub fn plugins_discover(state: tauri::State<'_, AppState>) -> Vec { + let plugins_dir = state.app_config.plugins_dir(); + plugins::discover_plugins(&plugins_dir) +} + +#[tauri::command] +pub fn plugin_read_file( + state: tauri::State<'_, AppState>, + plugin_id: String, + filename: String, +) -> Result { + let plugins_dir = state.app_config.plugins_dir(); + plugins::read_plugin_file(&plugins_dir, &plugin_id, &filename) +} diff --git a/v2/src-tauri/src/plugins.rs b/v2/src-tauri/src/plugins.rs new file mode 100644 index 0000000..488abae --- /dev/null +++ b/v2/src-tauri/src/plugins.rs @@ -0,0 +1,265 @@ +// Plugin discovery and file reading. +// Scans ~/.config/bterminal/plugins/ for plugin.json manifest files. +// Each plugin lives in its own subdirectory with a plugin.json manifest. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Plugin manifest — parsed from plugin.json +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginMeta { + pub id: String, + pub name: String, + pub version: String, + #[serde(default)] + pub description: String, + /// Entry JS file relative to plugin directory + pub main: String, + /// Permission strings: "palette", "btmsg:read", "bttask:read", "events" + #[serde(default)] + pub permissions: Vec, +} + +const VALID_PERMISSIONS: &[&str] = &["palette", "btmsg:read", "bttask:read", "events"]; + +/// Validate plugin ID: alphanumeric + hyphens only, 1-64 chars +fn is_valid_plugin_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') +} + +/// Discover all plugins in the given plugins directory. +/// Each plugin must have a plugin.json manifest file. +pub fn discover_plugins(plugins_dir: &Path) -> Vec { + let mut plugins = Vec::new(); + + let entries = match std::fs::read_dir(plugins_dir) { + Ok(e) => e, + Err(_) => return plugins, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let manifest_path = path.join("plugin.json"); + if !manifest_path.exists() { + continue; + } + + let content = match std::fs::read_to_string(&manifest_path) { + Ok(c) => c, + Err(e) => { + log::warn!( + "Failed to read plugin manifest {}: {e}", + manifest_path.display() + ); + continue; + } + }; + + let meta: PluginMeta = match serde_json::from_str(&content) { + Ok(m) => m, + Err(e) => { + log::warn!( + "Invalid plugin manifest {}: {e}", + manifest_path.display() + ); + continue; + } + }; + + // Validate plugin ID + if !is_valid_plugin_id(&meta.id) { + log::warn!( + "Plugin at {} has invalid ID '{}' — skipping", + path.display(), + meta.id + ); + continue; + } + + // Validate permissions + for perm in &meta.permissions { + if !VALID_PERMISSIONS.contains(&perm.as_str()) { + log::warn!( + "Plugin '{}' requests unknown permission '{}' — skipping", + meta.id, + perm + ); + continue; + } + } + + plugins.push(meta); + } + + plugins +} + +/// Read a file from a plugin directory, with path traversal prevention. +/// Only files within the plugin's own directory are accessible. +pub fn read_plugin_file( + plugins_dir: &Path, + plugin_id: &str, + filename: &str, +) -> Result { + if !is_valid_plugin_id(plugin_id) { + return Err("Invalid plugin ID".to_string()); + } + + let plugin_dir = plugins_dir.join(plugin_id); + if !plugin_dir.is_dir() { + return Err(format!("Plugin directory not found: {}", plugin_id)); + } + + // Canonicalize the plugin directory to resolve symlinks + let canonical_plugin_dir = plugin_dir + .canonicalize() + .map_err(|e| format!("Failed to resolve plugin directory: {e}"))?; + + let target = plugin_dir.join(filename); + let canonical_target = target + .canonicalize() + .map_err(|e| format!("Failed to resolve file path: {e}"))?; + + // Path traversal prevention: target must be within plugin directory + if !canonical_target.starts_with(&canonical_plugin_dir) { + return Err("Access denied: path is outside plugin directory".to_string()); + } + + std::fs::read_to_string(&canonical_target) + .map_err(|e| format!("Failed to read plugin file: {e}")) +} + +/// Get the plugins directory path from a config directory +pub fn plugins_dir(config_dir: &Path) -> PathBuf { + config_dir.join("plugins") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_valid_plugin_ids() { + assert!(is_valid_plugin_id("my-plugin")); + assert!(is_valid_plugin_id("hello123")); + assert!(is_valid_plugin_id("a")); + assert!(!is_valid_plugin_id("")); + assert!(!is_valid_plugin_id("my_plugin")); // underscore not allowed + assert!(!is_valid_plugin_id("my plugin")); // space not allowed + assert!(!is_valid_plugin_id("../evil")); // path traversal chars + assert!(!is_valid_plugin_id(&"a".repeat(65))); // too long + } + + #[test] + fn test_discover_plugins_empty_dir() { + let dir = tempfile::tempdir().unwrap(); + let plugins = discover_plugins(dir.path()); + assert!(plugins.is_empty()); + } + + #[test] + fn test_discover_plugins_nonexistent_dir() { + let plugins = discover_plugins(Path::new("/nonexistent/path")); + assert!(plugins.is_empty()); + } + + #[test] + fn test_discover_plugins_valid_manifest() { + let dir = tempfile::tempdir().unwrap(); + let plugin_dir = dir.path().join("test-plugin"); + fs::create_dir(&plugin_dir).unwrap(); + fs::write( + plugin_dir.join("plugin.json"), + r#"{ + "id": "test-plugin", + "name": "Test Plugin", + "version": "1.0.0", + "description": "A test plugin", + "main": "index.js", + "permissions": ["palette"] + }"#, + ) + .unwrap(); + fs::write(plugin_dir.join("index.js"), "// test").unwrap(); + + let plugins = discover_plugins(dir.path()); + assert_eq!(plugins.len(), 1); + assert_eq!(plugins[0].id, "test-plugin"); + assert_eq!(plugins[0].name, "Test Plugin"); + assert_eq!(plugins[0].permissions, vec!["palette"]); + } + + #[test] + fn test_discover_plugins_invalid_id_skipped() { + let dir = tempfile::tempdir().unwrap(); + let plugin_dir = dir.path().join("bad_plugin"); + fs::create_dir(&plugin_dir).unwrap(); + fs::write( + plugin_dir.join("plugin.json"), + r#"{ + "id": "bad_plugin", + "name": "Bad", + "version": "1.0.0", + "main": "index.js" + }"#, + ) + .unwrap(); + + let plugins = discover_plugins(dir.path()); + assert!(plugins.is_empty()); + } + + #[test] + fn test_read_plugin_file_success() { + let dir = tempfile::tempdir().unwrap(); + let plugin_dir = dir.path().join("my-plugin"); + fs::create_dir(&plugin_dir).unwrap(); + fs::write(plugin_dir.join("index.js"), "console.log('hello');").unwrap(); + + let result = read_plugin_file(dir.path(), "my-plugin", "index.js"); + assert_eq!(result.unwrap(), "console.log('hello');"); + } + + #[test] + fn test_read_plugin_file_path_traversal_blocked() { + let dir = tempfile::tempdir().unwrap(); + let plugin_dir = dir.path().join("my-plugin"); + fs::create_dir(&plugin_dir).unwrap(); + fs::write(plugin_dir.join("index.js"), "ok").unwrap(); + + let result = read_plugin_file(dir.path(), "my-plugin", "../../../etc/passwd"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("outside plugin directory") || err.contains("Failed to resolve")); + } + + #[test] + fn test_read_plugin_file_invalid_id() { + let dir = tempfile::tempdir().unwrap(); + let result = read_plugin_file(dir.path(), "../evil", "index.js"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid plugin ID")); + } + + #[test] + fn test_read_plugin_file_nonexistent_plugin() { + let dir = tempfile::tempdir().unwrap(); + let result = read_plugin_file(dir.path(), "nonexistent", "index.js"); + assert!(result.is_err()); + } + + #[test] + fn test_plugins_dir_path() { + let config = Path::new("/home/user/.config/bterminal"); + assert_eq!(plugins_dir(config), PathBuf::from("/home/user/.config/bterminal/plugins")); + } +} diff --git a/v2/src/lib/adapters/plugins-bridge.ts b/v2/src/lib/adapters/plugins-bridge.ts new file mode 100644 index 0000000..5bf67ee --- /dev/null +++ b/v2/src/lib/adapters/plugins-bridge.ts @@ -0,0 +1,22 @@ +// Plugin discovery and file access — Tauri IPC adapter + +import { invoke } from '@tauri-apps/api/core'; + +export interface PluginMeta { + id: string; + name: string; + version: string; + description: string; + main: string; + permissions: string[]; +} + +/** Discover all plugins in ~/.config/bterminal/plugins/ */ +export async function discoverPlugins(): Promise { + return invoke('plugins_discover'); +} + +/** Read a file from a plugin's directory (path-traversal safe) */ +export async function readPluginFile(pluginId: string, filename: string): Promise { + return invoke('plugin_read_file', { pluginId, filename }); +} diff --git a/v2/src/lib/stores/plugins.svelte.ts b/v2/src/lib/stores/plugins.svelte.ts new file mode 100644 index 0000000..fa10463 --- /dev/null +++ b/v2/src/lib/stores/plugins.svelte.ts @@ -0,0 +1,203 @@ +/** + * Plugin store — tracks plugin commands, event bus, and plugin state. + * Uses Svelte 5 runes for reactivity. + */ + +import type { PluginMeta } from '../adapters/plugins-bridge'; +import { discoverPlugins } from '../adapters/plugins-bridge'; +import { getSetting, setSetting } from '../adapters/settings-bridge'; +import { loadPlugin, unloadPlugin, unloadAllPlugins, getLoadedPlugins } from '../plugins/plugin-host'; +import type { GroupId, AgentId } from '../types/ids'; + +// --- Plugin command registry (for CommandPalette) --- + +export interface PluginCommand { + pluginId: string; + label: string; + callback: () => void; +} + +let commands = $state([]); + +/** Get all plugin-registered commands (reactive). */ +export function getPluginCommands(): PluginCommand[] { + return commands; +} + +/** Register a command from a plugin. Called by plugin-host. */ +export function addPluginCommand(pluginId: string, label: string, callback: () => void): void { + commands = [...commands, { pluginId, label, callback }]; +} + +/** Remove all commands registered by a specific plugin. Called on unload. */ +export function removePluginCommands(pluginId: string): void { + commands = commands.filter(c => c.pluginId !== pluginId); +} + +// --- Plugin event bus (simple pub/sub) --- + +type EventCallback = (data: unknown) => void; + +class PluginEventBusImpl { + private listeners = new Map>(); + + on(event: string, callback: EventCallback): void { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(callback); + } + + off(event: string, callback: EventCallback): void { + const set = this.listeners.get(event); + if (set) { + set.delete(callback); + if (set.size === 0) this.listeners.delete(event); + } + } + + emit(event: string, data?: unknown): void { + const set = this.listeners.get(event); + if (!set) return; + for (const cb of set) { + try { + cb(data); + } catch (e) { + console.error(`Plugin event handler error for '${event}':`, e); + } + } + } + + clear(): void { + this.listeners.clear(); + } +} + +export const pluginEventBus = new PluginEventBusImpl(); + +// --- Plugin discovery and lifecycle --- + +export type PluginStatus = 'discovered' | 'loaded' | 'error' | 'disabled'; + +export interface PluginEntry { + meta: PluginMeta; + status: PluginStatus; + error?: string; +} + +let pluginEntries = $state([]); + +/** Get all discovered plugins with their status (reactive). */ +export function getPluginEntries(): PluginEntry[] { + return pluginEntries; +} + +/** Settings key for plugin enabled state */ +function pluginEnabledKey(pluginId: string): string { + return `plugin_enabled_${pluginId}`; +} + +/** Check if a plugin is enabled in settings (default: true for new plugins) */ +async function isPluginEnabled(pluginId: string): Promise { + const val = await getSetting(pluginEnabledKey(pluginId)); + if (val === null || val === undefined) return true; // enabled by default + return val === 'true' || val === '1'; +} + +/** Set plugin enabled state */ +export async function setPluginEnabled(pluginId: string, enabled: boolean): Promise { + await setSetting(pluginEnabledKey(pluginId), enabled ? 'true' : 'false'); + + // Update in-memory state + if (enabled) { + const entry = pluginEntries.find(e => e.meta.id === pluginId); + if (entry && entry.status === 'disabled') { + await loadSinglePlugin(entry); + } + } else { + unloadPlugin(pluginId); + pluginEntries = pluginEntries.map(e => + e.meta.id === pluginId ? { ...e, status: 'disabled' as PluginStatus, error: undefined } : e, + ); + } +} + +/** Load a single plugin entry, updating its status */ +async function loadSinglePlugin( + entry: PluginEntry, + groupId?: GroupId, + agentId?: AgentId, +): Promise { + const gid = groupId ?? ('' as GroupId); + const aid = agentId ?? ('admin' as AgentId); + + try { + await loadPlugin(entry.meta, gid, aid); + pluginEntries = pluginEntries.map(e => + e.meta.id === entry.meta.id ? { ...e, status: 'loaded' as PluginStatus, error: undefined } : e, + ); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + console.error(`Failed to load plugin '${entry.meta.id}':`, errorMsg); + pluginEntries = pluginEntries.map(e => + e.meta.id === entry.meta.id ? { ...e, status: 'error' as PluginStatus, error: errorMsg } : e, + ); + } +} + +/** + * Discover and load all enabled plugins. + * Called at app startup or when reloading plugins. + */ +export async function loadAllPlugins(groupId?: GroupId, agentId?: AgentId): Promise { + // Unload any currently loaded plugins first + unloadAllPlugins(); + pluginEventBus.clear(); + commands = []; + + let discovered: PluginMeta[]; + try { + discovered = await discoverPlugins(); + } catch (e) { + console.error('Failed to discover plugins:', e); + pluginEntries = []; + return; + } + + // Build entries with initial status + const entries: PluginEntry[] = []; + for (const meta of discovered) { + const enabled = await isPluginEnabled(meta.id); + entries.push({ + meta, + status: enabled ? 'discovered' : 'disabled', + }); + } + pluginEntries = entries; + + // Load enabled plugins + for (const entry of pluginEntries) { + if (entry.status === 'discovered') { + await loadSinglePlugin(entry, groupId, agentId); + } + } +} + +/** + * Reload all plugins (re-discover and re-load). + */ +export async function reloadAllPlugins(groupId?: GroupId, agentId?: AgentId): Promise { + await loadAllPlugins(groupId, agentId); +} + +/** + * Clean up all plugins and state. + */ +export function destroyAllPlugins(): void { + unloadAllPlugins(); + pluginEventBus.clear(); + commands = []; + pluginEntries = []; +}