From 3e6fdeab0ddd08eb5289c599fbb2a7a0877b7271 Mon Sep 17 00:00:00 2001 From: Adam Musciano Date: Wed, 18 Feb 2026 20:08:44 -0500 Subject: [PATCH 01/19] MAESTRO: Document existing extension points for plugin system feasibility Catalog all architectural extension points in Maestro that a plugin system could leverage: ProcessManager events, process listener patterns, 35+ preload API namespaces, IPC handler registration, modal priority system, right panel tab system, marketplace manifest (prior art), stats API, and process API. Identifies 10 gaps requiring new infrastructure. Co-Authored-By: Claude Opus 4.6 --- .../plugin-feasibility/extension-points.md | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 docs/research/plugin-feasibility/extension-points.md diff --git a/docs/research/plugin-feasibility/extension-points.md b/docs/research/plugin-feasibility/extension-points.md new file mode 100644 index 000000000..773976e30 --- /dev/null +++ b/docs/research/plugin-feasibility/extension-points.md @@ -0,0 +1,351 @@ +--- +type: research +title: Existing Extension Points for Plugin System +created: 2026-02-18 +tags: + - plugin + - architecture + - feasibility +related: + - "[[concept-agent-dashboard]]" + - "[[concept-ai-auditor]]" + - "[[concept-agent-guardrails]]" + - "[[concept-notifications]]" + - "[[concept-external-integration]]" +--- + +# Existing Extension Points for Plugins + +This document catalogs all existing architectural extension points in Maestro that a plugin system could leverage. Each section maps a source file to the APIs, events, and patterns it exposes, with assessments of read/write access and plugin consumption feasibility. + +--- + +## 1. ProcessManager Events (`src/main/process-manager/types.ts`) + +The `ProcessManager` extends Node's `EventEmitter` and emits typed events defined by `ProcessManagerEvents`. These are the primary real-time data streams a plugin could subscribe to. + +### Events + +| Event | Signature | Data Exposed | Access | +|-------|-----------|-------------|--------| +| `data` | `(sessionId, data: string)` | Raw agent output text | Read-only | +| `stderr` | `(sessionId, data: string)` | Agent stderr output | Read-only | +| `exit` | `(sessionId, code: number)` | Process exit with code | Read-only | +| `command-exit` | `(sessionId, code: number)` | Shell command exit (non-PTY) | Read-only | +| `usage` | `(sessionId, stats: UsageStats)` | Token counts, cost, context window, reasoning tokens | Read-only | +| `session-id` | `(sessionId, agentSessionId: string)` | Provider session ID assignment | Read-only | +| `agent-error` | `(sessionId, error: AgentError)` | Error type, message, recoverability, raw stderr/stdout | Read-only | +| `thinking-chunk` | `(sessionId, text: string)` | Streaming partial thinking content | Read-only | +| `tool-execution` | `(sessionId, tool: ToolExecution)` | Tool name, state, timestamp | Read-only | +| `slash-commands` | `(sessionId, commands: unknown[])` | Discovered slash commands | Read-only | +| `query-complete` | `(sessionId, data: QueryCompleteData)` | Agent type, source, duration, project path, tab ID | Read-only | + +### Key Types + +- **`UsageStats`**: `inputTokens`, `outputTokens`, `cacheReadInputTokens`, `cacheCreationInputTokens`, `totalCostUsd`, `contextWindow`, `reasoningTokens?` +- **`ToolExecution`**: `toolName: string`, `state: unknown`, `timestamp: number` +- **`QueryCompleteData`**: `sessionId`, `agentType`, `source` ('user'|'auto'), `startTime`, `duration`, `projectPath?`, `tabId?` +- **`AgentError`**: `type` (AgentErrorType), `message`, `recoverable`, `agentId`, `sessionId?`, `timestamp`, `raw?` (exitCode, stderr, stdout, errorLine) + +### Plugin Consumption + +All events are **read-only observables**. A plugin could subscribe to any event via the ProcessManager's EventEmitter API in the main process, or via the forwarded IPC events in the renderer. No write access is provided through events — modifying agent behavior requires calling ProcessManager methods directly (`kill()`, `write()`, `interrupt()`). + +--- + +## 2. Process Listener Registration (`src/main/process-listeners/`) + +### Pattern + +The `setupProcessListeners()` function in `index.ts` is the single orchestration point. It takes a `ProcessManager` instance and a `ProcessListenerDependencies` object, then delegates to focused listener modules: + +| Module | Events Handled | Purpose | +|--------|---------------|---------| +| `forwarding-listeners` | `slash-commands`, `thinking-chunk`, `tool-execution`, `stderr`, `command-exit` | Forwards events to renderer via IPC | +| `data-listener` | `data` | Output with group chat buffering + web broadcast | +| `usage-listener` | `usage` | Usage stats with group chat participant/moderator updates | +| `session-id-listener` | `session-id` | Session ID with group chat storage | +| `error-listener` | `agent-error` | Error forwarding | +| `stats-listener` | `query-complete` | Stats database recording | +| `exit-listener` | `exit` | Group chat routing, recovery, synthesis | + +### `ProcessListenerDependencies` Interface + +This is the dependency injection contract. It provides access to: + +- **`getProcessManager()`** — access to ProcessManager +- **`getWebServer()`** — web server for broadcasting +- **`getAgentDetector()`** — agent type detection +- **`safeSend`** — safe IPC send to renderer +- **`powerManager`** — sleep prevention +- **`groupChatEmitters`** — group chat state/message emission +- **`groupChatRouter`** — moderator/agent response routing +- **`groupChatStorage`** — load/update group chats +- **`sessionRecovery`** — session recovery detection +- **`outputBuffer`** — group chat output buffering +- **`outputParser`** — text extraction, session ID parsing +- **`usageAggregator`** — context token calculation +- **`getStatsDB()`** — stats database +- **`debugLog()`** — debug logging +- **`patterns`** — regex patterns for session ID routing +- **`logger`** — structured logging + +### Plugin Consumption + +A plugin could register as an additional listener module by following the same pattern: a function that takes `(processManager, deps)` and attaches `.on()` handlers. However, there is no formal plugin registration mechanism — currently all listeners are hardcoded in `setupProcessListeners()`. **Gap: Need a plugin listener registration API.** + +### Group Chat Session Routing (Prior Art) + +The `GROUP_CHAT_PREFIX = 'group-chat-'` constant enables fast string-check routing of events to group chat handlers. This prefix-based routing is excellent prior art for plugin event filtering — plugins could use similar session ID prefix patterns or explicit filter registration. + +--- + +## 3. Preload API Surface (`src/main/preload/index.ts`) + +The preload script exposes `window.maestro.*` namespaces via Electron's `contextBridge`. This is the renderer-side API surface. There are **35 namespaces** total: + +### Namespace Catalog + +| Namespace | Source | Description | Access | +|-----------|--------|-------------|--------| +| `settings` | `settings.ts` | Read/write app settings | Read-write | +| `sessions` | `settings.ts` | Session persistence (load/save) | Read-write | +| `groups` | `settings.ts` | Group persistence | Read-write | +| `process` | `process.ts` | Spawn, write, kill, events | Read-write | +| `agentError` | `settings.ts` | Error state management | Read-write | +| `context` | `context.ts` | Context merge operations | Read-write | +| `web` | `web.ts` | Web interface management | Read-write | +| `git` | `git.ts` | Git operations (status, diff, log, worktrees) | Read-write | +| `fs` | `fs.ts` | Filesystem operations | Read-write | +| `webserver` | `web.ts` | Web server lifecycle | Read-write | +| `live` | `web.ts` | Live session management | Read-write | +| `agents` | `agents.ts` | Agent detection, config | Read (mostly) | +| `dialog` | `system.ts` | OS file/folder dialogs | Read-write | +| `fonts` | `system.ts` | System font listing | Read-only | +| `shells` | `system.ts` | Terminal shell detection | Read-only | +| `shell` | `system.ts` | Open URLs/paths externally | Write-only | +| `tunnel` | `system.ts` | Cloudflare tunnel management | Read-write | +| `sshRemote` | `sshRemote.ts` | SSH remote configuration | Read-write | +| `sync` | `system.ts` | Settings sync (import/export) | Read-write | +| `devtools` | `system.ts` | DevTools toggle | Write-only | +| `power` | `system.ts` | Sleep prevention | Read-write | +| `updates` | `system.ts` | App update management | Read-write | +| `logger` | `logger.ts` | Structured logging | Write-only | +| `claude` | `sessions.ts` | Claude Code sessions (DEPRECATED) | Read-write | +| `agentSessions` | `sessions.ts` | Agent session management | Read-write | +| `tempfile` | `files.ts` | Temp file operations | Read-write | +| `history` | `files.ts` | History entries | Read-write | +| `cli` | `files.ts` | CLI activity tracking | Read-only | +| `speckit` | `commands.ts` | Spec Kit commands | Read-write | +| `openspec` | `commands.ts` | OpenSpec commands | Read-write | +| `notification` | `notifications.ts` | OS notifications, TTS | Write-only | +| `attachments` | `attachments.ts` | Image/file attachments | Read-write | +| `autorun` | `autorun.ts` | Auto Run document management | Read-write | +| `playbooks` | `autorun.ts` | Playbook management | Read-write | +| `marketplace` | `autorun.ts` | Playbook Exchange | Read-only | +| `debug` | `debug.ts` | Debug package generation | Read-write | +| `documentGraph` | `debug.ts` | File watching, document graph | Read-write | +| `groupChat` | `groupChat.ts` | Group chat management | Read-write | +| `app` | `system.ts` | App lifecycle (quit, relaunch) | Write-only | +| `stats` | `stats.ts` | Usage analytics | Read-write | +| `leaderboard` | `leaderboard.ts` | Leaderboard registration | Read-write | +| `symphony` | `symphony.ts` | Token donations / OSS contributions | Read-write | +| `tabNaming` | `tabNaming.ts` | Auto tab name generation | Read-write | +| `directorNotes` | `directorNotes.ts` | Unified history + synopsis | Read-write | +| `wakatime` | `wakatime.ts` | WakaTime integration | Read-only | + +### Plugin Consumption + +These APIs are available to any code running in the renderer process. A renderer-side plugin could call these directly. However, there is **no sandboxing** — a plugin would have full access to all namespaces. **Gap: Need a scoped/sandboxed API surface for plugins that limits access to approved namespaces.** + +--- + +## 4. IPC Handler Registration (`src/main/ipc/handlers/index.ts`) + +### Pattern + +`registerAllHandlers(deps: HandlerDependencies)` is called once during app initialization. It registers ~25 handler modules, each setting up `ipcMain.handle()` calls for their domain. + +### `HandlerDependencies` Interface + +Provides access to core singletons: + +- `mainWindow` / `getMainWindow()` — BrowserWindow reference +- `app` — Electron App instance +- `getAgentDetector()` — agent detection +- `agentConfigsStore` — agent configuration persistence +- `getProcessManager()` — process management +- `settingsStore` — app settings +- `sessionsStore` / `groupsStore` — session/group persistence +- `getWebServer()` — web server reference +- `tunnelManager` — Cloudflare tunnels +- `claudeSessionOriginsStore` — session origin tracking + +### Registered Handler Modules + +Git, Autorun, Playbooks, History, Agents, Process, Persistence, System, Claude, AgentSessions, GroupChat, Debug, Speckit, OpenSpec, Context, Marketplace, Stats, DocumentGraph, SshRemote, Filesystem, Attachments, Web, Leaderboard, Notifications, Symphony, AgentError, TabNaming, DirectorNotes, Wakatime. + +### Plugin Consumption + +A plugin could register new IPC handlers following the same pattern: export a `registerXxxHandlers(deps)` function and call `ipcMain.handle()`. However, there is **no dynamic handler registration** — all handlers are registered at startup. **Gap: Need a runtime handler registration mechanism for plugins, or a plugin-specific IPC namespace.** + +--- + +## 5. Layer Stack / Modal Priority System (`src/renderer/constants/modalPriorities.ts`) + +### Pattern + +Modals and overlays use numeric priorities to determine stacking order and Escape key handling. The layer stack system ensures only the topmost layer handles Escape. + +### Priority Ranges + +| Range | Purpose | Examples | +|-------|---------|---------| +| 1000+ | Critical modals | Quit confirm (1020), Agent error (1010), Tour (1050) | +| 900–999 | High priority | Rename instance (900), Gist publish (980) | +| 700–899 | Standard modals | New instance (750), Quick actions (700), Batch runner (720) | +| 400–699 | Settings/info | Settings (450), Usage dashboard (540), About (600) | +| 100–399 | Overlays/previews | File preview (100), Git diff (200) | +| 1–99 | Autocomplete | Slash autocomplete (50), File tree filter (30) | + +### Plugin Consumption + +A plugin could register a modal or panel with its own priority value. The system is purely convention-based (numeric constants) — there's no enforcement mechanism. **Gap: Need a reserved priority range for plugin modals (e.g., 300–399) and a registration API so plugins don't collide with core priorities.** + +--- + +## 6. Right Panel Tab System (`src/renderer/types/index.ts`) + +### Current Definition + +```typescript +export type RightPanelTab = 'files' | 'history' | 'autorun'; +``` + +This is a string literal union type controlling which tab is active in the Right Bar. + +### Plugin Consumption + +Adding a plugin-provided tab would require extending this union type at runtime, which TypeScript's type system doesn't support dynamically. **Gap: Need to refactor `RightPanelTab` to allow dynamic registration of custom tabs (e.g., string-based with a registry, or a union with a `plugin:${string}` pattern).** + +--- + +## 7. Marketplace Manifest Structure (`src/shared/marketplace-types.ts`) + +### Prior Art for Plugin Manifests + +The `MarketplacePlaybook` interface is excellent prior art for a plugin manifest: + +| Field | Type | Plugin Analog | +|-------|------|---------------| +| `id` | `string` | Plugin slug ID | +| `title` | `string` | Display name | +| `description` | `string` | Short description | +| `category` | `string` | Plugin category | +| `author` | `string` | Plugin author | +| `authorLink?` | `string` | Author URL | +| `tags?` | `string[]` | Searchable tags | +| `lastUpdated` | `string` | Version date | +| `path` | `string` | Entry point / folder path | +| `documents` | `MarketplaceDocument[]` | Plugin files / components | +| `source?` | `PlaybookSource` | 'official' or 'local' | + +### Existing Infrastructure + +- **Manifest fetching** from GitHub (`https://raw.githubusercontent.com/...`) +- **Local cache** (`userData/marketplace-cache.json`) +- **Error handling** with typed errors: `MarketplaceFetchError`, `MarketplaceCacheError`, `MarketplaceImportError` +- **API response types** for manifest retrieval, document fetching, and import operations + +### Plugin Consumption + +A plugin manifest system could reuse much of this infrastructure: GitHub-hosted registry, local cache, import/install flow. The `MarketplaceManifest` structure maps well to a `PluginRegistry` structure. **Gap: Need a `PluginManifest` type with additional fields for permissions, entry points (main/renderer), API version requirements, and dependency declarations.** + +--- + +## 8. Stats Subscription API (`src/main/preload/stats.ts`) + +### API Methods + +| Method | Parameters | Returns | Access | +|--------|-----------|---------|--------| +| `recordQuery` | `QueryEvent` | `Promise` (ID) | Write | +| `startAutoRun` | `AutoRunSession` | `Promise` (ID) | Write | +| `endAutoRun` | `id, duration, tasksCompleted` | `Promise` | Write | +| `recordAutoTask` | `AutoRunTask` | `Promise` (ID) | Write | +| `getStats` | `range, filters?` | `Promise>` | Read | +| `getAutoRunSessions` | `range` | `Promise>` | Read | +| `getAutoRunTasks` | `autoRunSessionId` | `Promise>` | Read | +| `getAggregation` | `range` | `Promise` | Read | +| `exportCsv` | `range` | `Promise` | Read | +| `onStatsUpdate` | `callback` | Unsubscribe function | Read (subscription) | +| `clearOldData` | `olderThanDays` | `Promise` | Write | +| `getDatabaseSize` | — | `Promise` | Read | +| `getEarliestTimestamp` | — | `Promise` | Read | +| `recordSessionCreated` | `SessionCreatedEvent` | `Promise` | Write | +| `recordSessionClosed` | `sessionId, closedAt` | `Promise` | Write | +| `getSessionLifecycle` | `range` | `Promise>` | Read | + +### Key Types + +- **`StatsAggregation`**: `totalQueries`, `totalDuration`, `avgDuration`, `byAgent` (per-agent count/duration), `bySource` (user/auto counts), `byDay` (date/count/duration arrays) +- **`QueryEvent`**: `sessionId`, `agentType`, `source`, `startTime`, `duration`, `projectPath?`, `tabId?` + +### Plugin Consumption + +The stats API is well-suited for dashboard plugins. Read methods allow querying historical data with time range filters. `onStatsUpdate` enables real-time refresh. Write methods could let plugins record custom metrics. **Note: the stats database (SQLite via `stats-db.ts`) is not directly accessible from the renderer — all access goes through IPC handlers.** + +--- + +## 9. Process API (`src/main/preload/process.ts`) + +### Commands (Write) + +| Method | Description | +|--------|-------------| +| `spawn(config)` | Spawn a new agent or terminal process | +| `write(sessionId, data)` | Write to process stdin | +| `interrupt(sessionId)` | Send Ctrl+C (SIGINT) | +| `kill(sessionId)` | Kill a process | +| `resize(sessionId, cols, rows)` | Resize terminal | +| `runCommand(config)` | Run a single shell command | +| `getActiveProcesses()` | List active processes | + +### Event Subscriptions (Read) + +| Event | Callback Signature | +|-------|-------------------| +| `onData` | `(sessionId, data: string)` | +| `onExit` | `(sessionId, code: number)` | +| `onSessionId` | `(sessionId, agentSessionId: string)` | +| `onSlashCommands` | `(sessionId, commands: string[])` | +| `onThinkingChunk` | `(sessionId, content: string)` | +| `onToolExecution` | `(sessionId, toolEvent: ToolExecutionEvent)` | +| `onSshRemote` | `(sessionId, sshRemote: SshRemoteInfo \| null)` | +| `onUsage` | `(sessionId, usageStats: UsageStats)` | +| `onAgentError` | `(sessionId, error: AgentError)` | +| `onStderr` | `(sessionId, data: string)` | +| `onCommandExit` | `(sessionId, code: number)` | + +Plus remote-command event subscriptions for the web interface (`onRemoteCommand`, `onRemoteSwitchMode`, `onRemoteInterrupt`, `onRemoteSelectSession`, `onRemoteSelectTab`, `onRemoteNewTab`, `onRemoteCloseTab`, `onRemoteRenameTab`). + +### Plugin Consumption + +This is the most plugin-relevant API surface. A renderer-side plugin can subscribe to all events for real-time monitoring (dashboard, auditor) and invoke `kill()` / `interrupt()` for guardrails. The `getActiveProcesses()` method provides a snapshot of all running processes. **Gap: No session-scoped filtering in subscriptions — plugins receive all events for all sessions and must filter themselves.** + +--- + +## Summary of Identified Gaps + +| # | Gap | Severity | Blocks | +|---|-----|----------|--------| +| 1 | No plugin listener registration API (main process) | High | Guardrails, Auditor | +| 2 | No sandboxed/scoped renderer API surface | High | All renderer plugins | +| 3 | No runtime IPC handler registration for plugins | Medium | External Integration | +| 4 | No reserved modal priority range for plugins | Low | Dashboard (if modal) | +| 5 | Static `RightPanelTab` union — no dynamic tab registration | Medium | Dashboard | +| 6 | No `PluginManifest` type (permissions, entry points, API version) | High | All plugins | +| 7 | No session-scoped event filtering in process subscriptions | Low | Performance optimization | +| 8 | No plugin-scoped storage API | Medium | Auditor, Guardrails | +| 9 | No middleware/interception layer in ProcessManager event chain | High | Guardrails (approach A) | +| 10 | No plugin UI registration system (panels, tabs, widgets) | High | Dashboard, any UI plugin | From b0c7686d45d483a2e7c22c8de77cc9cfb9da3ff5 Mon Sep 17 00:00:00 2001 From: Adam Musciano Date: Wed, 18 Feb 2026 20:12:16 -0500 Subject: [PATCH 02/19] MAESTRO: Pressure-test Agent Dashboard Widget plugin concept feasibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified all 4 required APIs exist in preload/process.ts: getActiveProcesses(), onUsage(), onData(), onToolExecution(). Documented complete data model including ActiveProcess, UsageStats, ToolExecutionEvent, and StatsAggregation types. Key findings: - Plugin can work as purely read-only (no write APIs needed) - Recommended floating modal for v1, Right Panel tab for v2 - Feasibility: Trivial — all APIs exist, only shared plugin infrastructure needed Co-Authored-By: Claude Opus 4.6 --- .../concept-agent-dashboard.md | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/research/plugin-feasibility/concept-agent-dashboard.md diff --git a/docs/research/plugin-feasibility/concept-agent-dashboard.md b/docs/research/plugin-feasibility/concept-agent-dashboard.md new file mode 100644 index 000000000..9e5085fef --- /dev/null +++ b/docs/research/plugin-feasibility/concept-agent-dashboard.md @@ -0,0 +1,211 @@ +--- +type: research +title: "Plugin Concept: Agent Dashboard Widget" +created: 2026-02-18 +tags: + - plugin + - concept + - dashboard +related: + - "[[extension-points]]" + - "[[concept-ai-auditor]]" + - "[[concept-agent-guardrails]]" + - "[[concept-notifications]]" + - "[[concept-external-integration]]" +--- + +# Plugin Concept: Agent Dashboard Widget + +A real-time dashboard plugin that displays live metrics for all active agents: token usage, cost, context window utilization, tool executions, and process states. + +--- + +## API Mapping + +### Required APIs (All Verified) + +| API Call | Exists | Location | Purpose | +|----------|--------|----------|---------| +| `window.maestro.process.getActiveProcesses()` | Yes | `preload/process.ts:176` | Snapshot of all running processes (sessionId, toolType, pid, cwd, startTime) | +| `window.maestro.process.onUsage(cb)` | Yes | `preload/process.ts:407` | Real-time token/cost/context updates per agent response | +| `window.maestro.process.onData(cb)` | Yes | `preload/process.ts:184` | Raw agent output stream | +| `window.maestro.process.onToolExecution(cb)` | Yes | `preload/process.ts:236` | Tool call events (name, state, timestamp) | + +### Supplementary APIs (Useful, Not Required) + +| API Call | Purpose | +|----------|---------| +| `window.maestro.process.onExit(cb)` | Detect agent completion/crash | +| `window.maestro.process.onAgentError(cb)` | Error state monitoring | +| `window.maestro.process.onThinkingChunk(cb)` | Thinking activity indicator | +| `window.maestro.stats.getAggregation(range)` | Historical usage data (tokens by agent, by day) | +| `window.maestro.stats.onStatsUpdate(cb)` | Refresh trigger when stats DB changes | + +--- + +## Data Available + +### From `ActiveProcess` + +```typescript +interface ActiveProcess { + sessionId: string; // Agent identifier + toolType: string; // Agent type (claude-code, codex, etc.) + pid: number; // OS process ID + cwd: string; // Working directory + isTerminal: boolean; // Terminal vs AI process + isBatchMode: boolean; // Auto Run mode flag + startTime: number; // Epoch ms + command?: string; // Spawned command + args?: string[]; // Spawn args +} +``` + +### From `UsageStats` (per `onUsage` event) + +```typescript +interface UsageStats { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + totalCostUsd: number; + contextWindow: number; // Context window utilization (0-1 range or absolute) + reasoningTokens?: number; // Codex o3/o4-mini only +} +``` + +### From `ToolExecutionEvent` (per `onToolExecution` event) + +```typescript +interface ToolExecutionEvent { + toolName: string; // e.g., "Read", "Write", "Bash", "Glob" + state?: unknown; // Tool-specific state + timestamp: number; // Epoch ms +} +``` + +### From `StatsAggregation` (historical) + +```typescript +interface StatsAggregation { + totalQueries: number; + totalDuration: number; + avgDuration: number; + byAgent: Record; + bySource: { user: number; auto: number }; + byDay: Array<{ date: string; count: number; duration: number }>; +} +``` + +--- + +## Read-Only Assessment + +**Yes, this plugin can work as purely read-only.** Every API it needs is either a subscription (returns data, no side effects) or a read-only query. No write operations are required: + +- `getActiveProcesses()` — read-only snapshot +- `onUsage()` — passive event subscription +- `onData()` — passive event subscription +- `onToolExecution()` — passive event subscription +- `getAggregation()` — read-only database query +- `onStatsUpdate()` — passive notification + +The plugin never needs to call `spawn()`, `write()`, `kill()`, or any other mutating API. + +--- + +## UI Surface Options + +### Option A: Right Panel Tab + +**Pros:** +- Natural home — sits alongside Files, History, Auto Run tabs +- Always accessible without obscuring the main workspace +- Consistent with existing UI patterns + +**Cons:** +- `RightPanelTab` is a static union type (`'files' | 'history' | 'autorun'`) — requires type extension +- Tab rendering is hardcoded in `RightPanel.tsx` as a static array `['files', 'history', 'autorun'].map(...)` +- Content rendering uses conditional branches, not a registry +- Minimum width of 384px may constrain dashboard layout + +**Required changes (Gap #5, #10 from [[extension-points]]):** +1. Extend `RightPanelTab` to accept plugin-registered tab IDs (e.g., `plugin:${string}` pattern) +2. Refactor `RightPanel.tsx` tab rendering to use a registry instead of hardcoded array +3. Add a content rendering hook/slot for plugin-provided React components + +### Option B: Floating Modal/Panel + +**Pros:** +- No changes to `RightPanelTab` or `RightPanel.tsx` required +- Can be arbitrarily sized and positioned +- Layer Stack system already supports custom priorities +- Prior art: Usage Dashboard (priority 540), Process Monitor (priority 550) are similar concepts + +**Cons:** +- Obscures workspace when open +- Not "always visible" — user must open/close +- Adds yet another modal to the layer stack + +**Required changes:** +1. Reserve a modal priority for plugin panels (e.g., 300–399 range per Gap #4) +2. Plugin needs to register a keyboard shortcut or command palette entry to toggle visibility + +### Option C: Hybrid — Modal That Docks + +Start as a floating panel (quick to implement), graduate to a dockable Right Panel tab once the tab registration infrastructure (Gap #5, #10) is built. + +### Recommendation + +**Option B (floating modal) for v1, with Option A as a v2 upgrade.** Rationale: +- The floating modal approach requires zero changes to core RightPanel infrastructure +- Usage Dashboard and Process Monitor already prove this pattern works +- The Right Panel tab registration system (Gap #5, #10) is a larger infrastructure project that benefits all UI plugins, not just the dashboard — it should be built generically, not as a one-off for this concept + +--- + +## Feasibility Verdict + +### Rating: **Trivial** + +This is the simplest plugin concept of all. Every API it needs already exists and is accessible from the renderer process. No new infrastructure is required for a basic implementation. + +### Required New Infrastructure + +| Infrastructure | Needed For | Complexity | +|----------------|-----------|------------| +| Plugin manifest + loader | Loading the plugin | Medium (shared across all plugins) | +| Plugin UI registration | Mounting the React component | Medium (shared across all plugins) | +| Sandboxed API surface | Restricting to read-only APIs | Medium (shared across all plugins) | + +### Infrastructure NOT Required (Unlike Other Concepts) + +- No middleware/interception layer (it's purely observational) +- No plugin-scoped storage (it derives state from live events + stats queries) +- No main-process component (all APIs are available via preload bridge) +- No new IPC handlers (existing process and stats APIs suffice) + +### Implementation Sketch + +A minimal dashboard plugin would: + +1. On mount: call `getActiveProcesses()` for initial state +2. Subscribe to `onUsage()`, `onToolExecution()`, `onData()`, `onExit()`, `onAgentError()` +3. Maintain internal state: per-session token totals, tool execution counts, error counts +4. Render: agent cards with live metrics, cost ticker, context window bars +5. Optionally query `getAggregation('day')` for historical sparklines +6. On unmount: call all unsubscribe functions + +The only architectural decision is the UI surface (floating modal vs Right Panel tab), and the floating modal approach has zero core dependencies. + +--- + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Event volume overwhelms plugin renderer | Low | Medium | Debounce/throttle updates, batch state changes | +| No session-scoped filtering (Gap #7) | Certain | Low | Plugin filters events by sessionId internally; trivial | +| `onUsage` reports deltas not cumulatives for some agents | Medium | Low | Plugin maintains running totals per session | +| Context window value semantics vary by agent | Medium | Low | Normalize per agent type; document expected ranges | From d66167981874f3d68db30676914bea69751987cb Mon Sep 17 00:00:00 2001 From: Adam Musciano Date: Wed, 18 Feb 2026 20:15:13 -0500 Subject: [PATCH 03/19] MAESTRO: Pressure-test AI Auditor plugin concept feasibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analyze event subscriptions, tool-execution data model per agent, risky operation detection patterns, and storage requirements. Rating: Moderate — all event APIs exist but needs plugin-scoped storage API (Gap #8) and main-process component for SQLite. Co-Authored-By: Claude Opus 4.6 --- .../plugin-feasibility/concept-ai-auditor.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/research/plugin-feasibility/concept-ai-auditor.md diff --git a/docs/research/plugin-feasibility/concept-ai-auditor.md b/docs/research/plugin-feasibility/concept-ai-auditor.md new file mode 100644 index 000000000..3a6e5b5df --- /dev/null +++ b/docs/research/plugin-feasibility/concept-ai-auditor.md @@ -0,0 +1,239 @@ +--- +type: research +title: "Plugin Concept: AI Auditor" +created: 2026-02-18 +tags: + - plugin + - concept + - auditor +related: + - "[[extension-points]]" + - "[[concept-agent-dashboard]]" + - "[[concept-agent-guardrails]]" + - "[[concept-notifications]]" + - "[[concept-external-integration]]" +--- + +# Plugin Concept: AI Auditor + +A passive monitoring plugin that logs all agent actions (tool executions, file operations, shell commands), flags risky operations in real time, and provides a searchable audit trail. Unlike the [[concept-agent-guardrails|Guardrails]] concept, the Auditor is strictly observational — it never blocks or kills agents. + +--- + +## Event Subscriptions Needed + +### Primary Events + +| Event | API | Data Available | Auditor Use | +|-------|-----|---------------|-------------| +| `process:tool-execution` | `window.maestro.process.onToolExecution(cb)` | `toolName`, `state` (status, input, output), `timestamp` | Core audit log: what tool was used, what arguments were passed, when | +| `process:data` | `window.maestro.process.onData(cb)` | `sessionId`, raw output string | Parse agent output for file paths, commands, and context when tool-execution data is insufficient | +| `process:exit` | `window.maestro.process.onExit(cb)` | `sessionId`, exit code | Session lifecycle boundary; flag non-zero exits as incidents | + +### Supplementary Events + +| Event | API | Auditor Use | +|-------|-----|-------------| +| `agent:error` | `window.maestro.process.onAgentError(cb)` | Log error events (type, message, recoverability, raw stderr) | +| `process:usage` | `window.maestro.process.onUsage(cb)` | Track cumulative token spend per session for budget alerting | +| `process:stderr` | `window.maestro.process.onStderr(cb)` | Capture error output that may indicate failed dangerous operations | +| `process:thinking-chunk` | `window.maestro.process.onThinkingChunk(cb)` | Optional: log reasoning traces for post-hoc analysis | + +All events are verified to exist in `src/main/preload/process.ts` and are exposed via `window.maestro.process.*`. + +--- + +## ToolExecution Data Model + +From `src/main/process-manager/types.ts`: + +```typescript +interface ToolExecution { + toolName: string; // e.g., "Read", "Write", "Bash", "Glob", "Edit" + state: unknown; // Agent-specific; see below + timestamp: number; // Epoch ms +} +``` + +### State Structure by Agent + +The `state` field varies by agent type, but follows common patterns: + +| Agent | State Fields | Notes | +|-------|-------------|-------| +| **Claude Code** | `{ status: 'running', input: unknown }` | Tool use blocks from mixed content; only emitted at start, not completion | +| **Codex** | `{ status: 'running' \| 'completed', input?: Record, output?: string }` | Dual events: running (with input) then completed (with output) | +| **OpenCode** | `{ status, input?, output?, title?, metadata?, time? }` | Richest state model; includes timing and metadata | +| **Factory Droid** | — | Does not currently emit tool-execution events | + +### Tool Names Observed + +Tool names come from the underlying agent's tool system. Common examples: +- **File operations**: `Read`, `Write`, `Edit`, `Glob` +- **Shell execution**: `Bash`, `bash`, `shell` +- **Search/navigation**: `Grep`, `Search`, `ListFiles` +- **Agent-specific**: varies by provider + +--- + +## Risky Operation Detection + +The auditor can flag risky operations by analyzing `toolName` and `state.input` from tool-execution events. This is a pattern-matching problem, not a data availability problem. + +### Detection Rules (Examples) + +| Risk Category | Detection Signal | Confidence | +|---------------|-----------------|------------| +| **Destructive file operations** | `toolName` = "Bash" + `state.input` matches `rm -rf`, `rm -r`, `git clean -f` | High — input args are available in state | +| **Force pushes** | `toolName` = "Bash" + `state.input` matches `git push --force`, `git push -f` | High | +| **Broad glob patterns** | `toolName` = "Bash" + `state.input` matches `find . -delete`, `rm *`, `rm -rf /` | High | +| **Credential access** | `toolName` = "Read"/"Edit" + `state.input` path matches `.env`, `credentials.*`, `*.pem` | Medium — depends on state containing file path | +| **Package modification** | `toolName` = "Bash" + `state.input` matches `npm install`, `pip install` | Medium — may be legitimate | +| **Database operations** | `toolName` = "Bash" + `state.input` matches `DROP TABLE`, `DELETE FROM`, `TRUNCATE` | High | +| **System modifications** | `toolName` = "Bash" + `state.input` matches `chmod`, `chown`, `sudo` | Medium | +| **Network operations** | `toolName` = "Bash" + `state.input` matches `curl`, `wget`, `ssh` | Low — often legitimate | + +### Detection Limitations + +1. **Claude Code's `state.input` is typed as `unknown`** — the input structure varies. File paths may be embedded differently than in Codex/OpenCode. +2. **Factory Droid does not emit tool-execution events** — the auditor has no visibility into its actions. This is a known gap. +3. **Raw `process:data` parsing is fragile** — agent output formats change between versions. Tool-execution events are the preferred detection path. +4. **No output/completion event from Claude Code** — only the "running" state is emitted, so the auditor cannot confirm whether an operation succeeded or failed via tool-execution alone. `process:data` can supplement. + +--- + +## Storage Needs + +### Requirements + +The auditor needs persistent storage for: +1. **Audit log entries** — timestamped records of tool executions, risk flags, session metadata +2. **Configuration** — custom risk rules, severity thresholds, notification preferences +3. **Aggregate statistics** — risk event counts by category, session, time period + +### Recommended Approach: Plugin-Scoped JSON/SQLite in `userData/plugins//` + +| Option | Pros | Cons | +|--------|------|------| +| **JSON files** | Simple, no dependencies, human-readable | Poor query performance for large logs; no concurrent write safety | +| **SQLite** | Fast queries, indexes, aggregation; proven in Maestro (stats-db.ts) | Requires `better-sqlite3` or similar; main-process only | +| **In-memory + periodic flush** | Fastest; no I/O during operation | Data loss on crash; limited history | + +**Recommendation: SQLite via a main-process plugin component.** Rationale: +- Maestro already uses `better-sqlite3` for `stats-db.ts` — the dependency exists +- Audit logs grow unboundedly; SQLite handles this with minimal overhead +- Time-range queries, aggregations, and search are first-class in SQL +- The main process can handle writes without blocking the renderer + +### Storage API Gap + +Currently, there is **no plugin-scoped storage API** (Gap #8 from [[extension-points]]). Plugins have no sanctioned way to: +- Get a writable directory path under `userData` +- Create or open a SQLite database +- Read/write JSON configuration files + +This gap must be addressed before the Auditor can persist data. The required API surface is small: + +```typescript +// Proposed plugin storage API +interface PluginStorageApi { + getDataPath(): string; // Returns userData/plugins// + readJSON(filename: string): Promise; + writeJSON(filename: string, data: unknown): Promise; + openDatabase(filename: string): Database; // SQLite handle (main-process only) +} +``` + +--- + +## Can It Flag Risky Ops from Tool Execution Data? + +**Yes, with caveats.** + +### What Works Well + +1. **Tool name matching** — `toolName` is a reliable string across all agents that emit tool-execution events. "Bash", "Write", "Edit", "Read" are consistent. +2. **Input argument inspection** — for Codex and OpenCode, `state.input` is a structured object with clear fields (command text, file paths, etc.). Pattern matching against these is straightforward. +3. **Timestamp correlation** — events have millisecond timestamps, enabling timeline reconstruction and session-scoped grouping. +4. **Exit code monitoring** — `process:exit` with non-zero codes flags failures, while `agent:error` provides structured error details. + +### What Requires Workarounds + +1. **Claude Code's `state.input` is untyped** — the auditor must handle `unknown` gracefully, attempting to extract command/path strings from whatever structure is present. +2. **No output/result events from Claude Code** — the auditor only sees intent (tool invoked with input), not outcome (succeeded/failed). Supplement with `process:data` parsing for critical rules. +3. **Factory Droid blindspot** — no tool-execution events. The auditor could fall back to `process:data` (raw output parsing), but this is fragile. + +### Confidence Assessment + +| Agent | Detection Confidence | Notes | +|-------|---------------------|-------| +| **Codex** | High | Structured state with status, input, output | +| **OpenCode** | High | Richest state model with metadata | +| **Claude Code** | Medium | Input available but untyped; no completion events | +| **Factory Droid** | Low | No tool-execution events at all | + +--- + +## Feasibility Verdict + +### Rating: **Moderate** + +The core monitoring capability is straightforward — all the event subscriptions exist and provide rich data. The complexity comes from two infrastructure gaps: plugin-scoped storage (required for persistence) and the uneven tool-execution data model across agents. + +### Required New Infrastructure + +| Infrastructure | Needed For | Complexity | Shared? | +|----------------|-----------|------------|---------| +| Plugin manifest + loader | Loading the plugin | Medium | Yes — all plugins need this | +| Plugin UI registration | Mounting audit log viewer component | Medium | Yes — all UI plugins need this | +| Sandboxed API surface | Restricting to read-only process APIs + storage | Medium | Yes — all plugins need this | +| **Plugin-scoped storage API** | Persisting audit logs and config | Medium | Yes — Auditor + Guardrails need this | +| **Main-process plugin component** | SQLite access for audit database | Medium | Partial — only plugins needing main-process access | + +### Infrastructure NOT Required + +- No middleware/interception layer (the Auditor only observes, never blocks) +- No new IPC handlers for events (all process events already forward to renderer) +- No process control APIs beyond what exists (no kill/interrupt needed) + +### Implementation Sketch + +A minimal AI Auditor plugin would: + +1. **Renderer component** (audit log viewer): + - On mount: subscribe to `onToolExecution()`, `onExit()`, `onAgentError()`, `onData()`, `onUsage()` + - Filter events by sessionId for the active agent or show all + - Apply risk detection rules to each tool-execution event + - Display a live-updating log with severity-colored entries + - On unmount: call all unsubscribe functions + +2. **Main-process component** (audit storage): + - Open SQLite database at `userData/plugins/ai-auditor/audit.db` + - Create tables: `audit_log` (id, sessionId, toolName, state, riskLevel, timestamp), `risk_rules` (pattern, severity) + - Expose IPC handlers: `auditor:log-event`, `auditor:query-log`, `auditor:get-risk-summary` + +3. **Risk engine** (shared logic): + - Pattern-match `toolName` + `state.input` against configurable risk rules + - Assign severity levels: info, warning, critical + - Emit flagged events to both renderer (for live display) and storage (for persistence) + +### Comparison to Agent Dashboard + +The Auditor is moderately harder than the [[concept-agent-dashboard|Dashboard]] because: +- Dashboard is **purely renderer-side**; Auditor needs a **main-process component** for SQLite +- Dashboard derives state from **live events only**; Auditor must **persist** an unbounded audit trail +- Dashboard needs no **risk detection logic**; Auditor needs a **pattern matching engine** (albeit simple) +- Dashboard works identically across agents; Auditor must handle **agent-specific state formats** + +--- + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| High event volume overwhelms storage writes | Medium | Medium | Batch inserts, write-ahead log, periodic flush instead of per-event writes | +| Agent-specific `state.input` formats break rules | Medium | Medium | Defensive parsing; type-narrowing utilities per agent; regression tests | +| Factory Droid has no tool-execution events | Certain | Low | Document limitation; raw output parsing as fallback; file issue for Factory Droid parser | +| Claude Code changes output format | Low | Medium | Version-pinned parsers; integration tests against known output samples | +| Audit log grows without bound | Certain | Low | Configurable retention policy (auto-prune entries older than N days); matches `stats.clearOldData()` pattern | +| Plugin storage API not designed yet | Certain | High | This is the primary blocker — must be resolved in the plugin infrastructure phase before Auditor can ship | From abaaf94d9c9fdbb7e65b1c16f1519cd3d11141c8 Mon Sep 17 00:00:00 2001 From: Adam Musciano Date: Wed, 18 Feb 2026 20:19:29 -0500 Subject: [PATCH 04/19] MAESTRO: Pressure-test Agent Guardrails plugin concept feasibility Co-Authored-By: Claude Opus 4.6 --- .../concept-agent-guardrails.md | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 docs/research/plugin-feasibility/concept-agent-guardrails.md diff --git a/docs/research/plugin-feasibility/concept-agent-guardrails.md b/docs/research/plugin-feasibility/concept-agent-guardrails.md new file mode 100644 index 000000000..925cb4bf8 --- /dev/null +++ b/docs/research/plugin-feasibility/concept-agent-guardrails.md @@ -0,0 +1,330 @@ +--- +type: research +title: "Plugin Concept: Agent Guardrails" +created: 2026-02-18 +tags: + - plugin + - concept + - guardrails +related: + - "[[extension-points]]" + - "[[concept-ai-auditor]]" + - "[[concept-agent-dashboard]]" + - "[[concept-notifications]]" + - "[[concept-external-integration]]" +--- + +# Plugin Concept: Agent Guardrails + +An active enforcement plugin that can intercept and block agent actions, enforce token budgets, and kill or pause agents that violate policy. Unlike the [[concept-ai-auditor|AI Auditor]], which is purely observational, Guardrails requires the ability to **intervene** in the event pipeline — making it the hardest plugin concept in this feasibility study. + +--- + +## Core Requirement: Interception, Not Just Observation + +The Auditor listens to events after they've fired. Guardrails must either: +- **Prevent** an event from reaching downstream listeners (true interception), or +- **React** to an event by killing/pausing the agent before damage completes (reactive kill) + +This distinction drives the entire architectural analysis below. + +--- + +## Architecture Analysis + +### ProcessManager Event Emission Chain + +From `src/main/process-manager/ProcessManager.ts`: + +``` +Agent stdout → StdoutHandler.handleData() + → StdoutHandler.processLine() → handleParsedEvent() + → this.emitter.emit('tool-execution', sessionId, toolExecution) + → this.emitter.emit('thinking-chunk', sessionId, text) + → bufferManager.emitDataBuffered(sessionId, data) + → DataBufferManager accumulates (max 8KB or 50ms) + → flushDataBuffer() → emitter.emit('data', sessionId, data) +``` + +Key insight: by the time `tool-execution` is emitted, the **agent has already decided** to use the tool. For Claude Code and similar agents, the tool use block indicates intent — the agent is waiting for tool output, meaning the tool execution may already be in progress. This limits the value of pre-forwarding interception for some agents, but not all. + +### ProcessManager.kill() and interrupt() Behavior + +```typescript +kill(sessionId: string): boolean // Flushes data buffer, sends SIGTERM, removes from map +interrupt(sessionId: string): boolean // Sends SIGINT, escalates to SIGTERM after 2000ms +``` + +Both are synchronous boolean returns. `kill()` is immediate and final. `interrupt()` gives the agent a chance to clean up but guarantees termination within 2 seconds. Neither is async — a guardrail plugin can invoke them without awaiting. + +No `pause()` API exists. The only intervention options are interrupt (graceful stop with 2s timeout) and kill (immediate). + +--- + +## Approach A: Event Middleware (Intercept Before Forwarding) + +### How It Would Work + +Insert a guard function between ProcessManager event emission and `forwarding-listeners.ts` forwarding to renderer: + +```typescript +// In forwarding-listeners.ts (current): +processManager.on('tool-execution', (sessionId, toolEvent) => { + safeSend('process:tool-execution', sessionId, toolEvent); +}); + +// With middleware (proposed): +processManager.on('tool-execution', async (sessionId, toolEvent) => { + const decision = await pluginGuard.evaluateToolExecution(sessionId, toolEvent); + if (decision === 'allow') { + safeSend('process:tool-execution', sessionId, toolEvent); + } else { + processManager.kill(sessionId); + safeSend('process:guardrail-blocked', sessionId, { + toolEvent, + reason: decision.reason, + }); + } +}); +``` + +### Insertion Point: `forwarding-listeners.ts` + +`src/main/process-listeners/forwarding-listeners.ts` is a thin forwarder — it registers five `processManager.on(...)` handlers that call `safeSend()`. This is the **cleanest insertion point** because: + +1. It sits between ProcessManager events and the renderer +2. It already depends on `ProcessListenerDependencies.safeSend` — adding a guard dependency is natural +3. No other listeners depend on it — blocking here doesn't break the main process event chain + +### Critical Limitation: EventEmitter Has No Cancellation + +Node.js `EventEmitter.on()` has no built-in way to cancel event propagation. All registered listeners fire in registration order, unconditionally. This means: + +- **A middleware at the forwarding layer cannot prevent other listeners from seeing the event.** The ProcessManager emits `tool-execution`, and all registered listeners (forwarding, stats, group-chat routing) all fire. The middleware can only prevent the `safeSend()` call, suppressing renderer notification. +- **Other main-process listeners (stats recording, group chat routing) would still process the event.** This is acceptable for most guardrail use cases — the goal is to stop the agent, not pretend the event never happened. + +### Would Modifying EventEmitter.emit() Break Tests? + +Wrapping `EventEmitter.emit()` to support cancellation is possible but **inadvisable**: + +1. ProcessManager is used throughout the codebase — any behavioral change to `emit()` risks breaking existing listeners +2. The existing test suite (verified via process-manager tests) relies on standard EventEmitter behavior +3. A custom `emit()` wrapper would be a non-standard pattern that surprises future developers + +**Verdict: Do not modify EventEmitter.emit().** The forwarding-listener insertion point is sufficient. + +### Alternative: StdoutHandler.handleParsedEvent() Hook + +A deeper insertion point exists at `StdoutHandler.handleParsedEvent()` — this is where tool-execution events are first created from parsed output. A hook here would fire **before** the event reaches any listener. However: + +- `StdoutHandler` is a private implementation detail of ProcessManager, not an extension point +- Refactoring it to accept plugin callbacks adds complexity to a hot path (every line of agent output) +- The benefit over forwarding-listener interception is marginal: both fire within the same event loop tick + +**Not recommended for v1.** Consider only if forwarding-level interception proves too late. + +--- + +## Approach B: Observer + Reactive Kill + +### How It Would Work + +The guardrail plugin subscribes to events using the same `processManager.on(...)` API as any other listener. When it detects a policy violation, it calls `processManager.kill(sessionId)` or `processManager.interrupt(sessionId)`. + +```typescript +processManager.on('tool-execution', (sessionId, toolExecution) => { + if (guardrailRules.isViolation(toolExecution)) { + processManager.interrupt(sessionId); // graceful stop + auditLog.record(sessionId, 'blocked', toolExecution); + } +}); + +processManager.on('usage', (sessionId, usageStats) => { + if (usageStats.totalCostUsd > budget.maxCostPerSession) { + processManager.kill(sessionId); // hard stop + auditLog.record(sessionId, 'budget-exceeded', usageStats); + } +}); +``` + +### Latency Analysis + +**Question: What's the latency between observing a tool-execution event and the kill taking effect?** + +1. `tool-execution` event emitted by StdoutHandler → guardrail listener fires (same event loop tick, ~0ms) +2. Guardrail evaluates rules (synchronous pattern matching, ~0ms) +3. `processManager.kill(sessionId)` called → sends SIGTERM to PTY/child process (synchronous, ~0ms) +4. OS delivers SIGTERM → agent process terminates (OS-dependent, typically <10ms) + +**Total latency: sub-millisecond from event to kill signal, <10ms to process termination.** + +However, the relevant question is: **what has the agent already done by the time the tool-execution event fires?** + +- **For Claude Code (PTY mode):** The tool-execution event fires when the agent *announces* it will use a tool. Claude Code's tools are executed by the Claude Code CLI itself, not by Maestro. By the time Maestro sees the tool-execution event, the CLI may have already begun executing (or completed) the tool. The kill signal stops future actions but **cannot undo the current tool execution**. +- **For Codex (stream-JSON mode):** Codex emits `status: 'running'` when a tool starts and `status: 'completed'` when it finishes. The guardrail can react to `running` before completion, but the tool is already executing. +- **For OpenCode:** Similar to Codex — the `running` event fires as execution begins. + +**Verdict: Reactive kill is fast enough to prevent *subsequent* tool executions but cannot guarantee prevention of the *current* tool execution.** This is acceptable for most guardrail use cases: stopping a chain of dangerous operations rather than preventing a single atomic action. + +### Is This Fast Enough to Prevent Damage? + +For multi-step destructive operations (e.g., `rm -rf` across multiple directories, a series of force-pushes), reactive kill is highly effective — the first violation triggers termination before the second action begins. + +For single atomic destructive operations (e.g., one `rm -rf /important-dir`), the damage is done by the time the event fires. The guardrail can only log the violation and prevent further harm. + +**Mitigation:** Pair the reactive guardrail with agent-native safety features (e.g., Claude Code's `--allowedTools` flag, per-project `.claude/settings.json` deny rules). Agent-native controls operate *before* tool execution; the plugin guardrail operates *after* the event. + +--- + +## Token Budget Enforcement + +### Data Available via `onUsage()` + +```typescript +interface UsageStats { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + totalCostUsd: number; // Cumulative USD cost + contextWindow: number; // Context window fill percentage (0-1) + reasoningTokens?: number; +} +``` + +`onUsage()` fires after each agent response. `totalCostUsd` is **cumulative per session** — no aggregation needed. + +### Implementation + +```typescript +processManager.on('usage', (sessionId, stats) => { + const budget = pluginConfig.getBudget(sessionId); + + // Hard cost cap + if (stats.totalCostUsd > budget.maxCostUsd) { + processManager.kill(sessionId); + notifyUser(`Agent killed: exceeded $${budget.maxCostUsd} budget`); + } + + // Token limit (input + output) + const totalTokens = stats.inputTokens + stats.outputTokens; + if (totalTokens > budget.maxTokens) { + processManager.interrupt(sessionId); + notifyUser(`Agent interrupted: exceeded ${budget.maxTokens} token limit`); + } + + // Context window warning (approaching limit) + if (stats.contextWindow > budget.contextWarningThreshold) { + notifyUser(`Warning: agent at ${Math.round(stats.contextWindow * 100)}% context window`); + } +}); +``` + +The `kill` API is already exposed to the renderer via `window.maestro.process.kill(sessionId)`, so a renderer-only plugin could also enforce budgets. However, a main-process plugin would have lower latency and could enforce budgets even if the renderer is unresponsive. + +--- + +## Group Chat Session Filtering + +### Prior Art: `GROUP_CHAT_PREFIX` Pattern + +From `src/main/process-listeners/types.ts`: + +```typescript +export const GROUP_CHAT_PREFIX = 'group-chat-'; +``` + +Every process listener uses a two-tier filter: +1. `sessionId.startsWith('group-chat-')` — O(1) skip for the common case +2. Regex matching (`REGEX_MODERATOR_SESSION`, `REGEX_BATCH_SESSION`, etc.) — only when needed + +A guardrail plugin should follow this pattern to: +- **Apply guardrails to user-facing agents** (standard session IDs) +- **Optionally apply to group chat participants** (session IDs matching `group-chat-*`) +- **Skip internal sessions** (`REGEX_BATCH_SESSION`, `REGEX_SYNOPSIS_SESSION`) — guardrails should not interfere with background operations + +The `patterns` object from `ProcessListenerDependencies` provides all necessary regexes. A guardrail plugin registered via `setupPluginListeners(processManager, deps)` would receive these through the same dependency injection pattern. + +--- + +## Recommended Approach + +### Approach B (Observer + Reactive Kill) for v1 + +**Rationale:** + +| Criterion | Approach A (Middleware) | Approach B (Observer + Kill) | +|-----------|------------------------|------------------------------| +| Core architecture changes | Requires modifying forwarding-listeners.ts | Zero changes to core — standard event listener | +| Risk to existing behavior | Medium — safeSend wrapping could affect event ordering | None — additive listener only | +| Interception granularity | Can suppress renderer notification | Can kill process but can't suppress events | +| Implementation complexity | Medium — async middleware in synchronous emit chain | Low — standard pattern matching + kill call | +| Latency to intervention | ~0ms (suppresses forward) | ~0ms (sends kill signal) | +| Effectiveness | Blocks UI display but agent still runs until killed | Kills agent; UI sees event + kill notification | +| Test impact | Must verify forwarding still works correctly | No impact on existing tests | + +**Approach B is recommended because:** + +1. **Zero core changes.** The guardrail registers as a standard listener — no modification to ProcessManager, forwarding-listeners, or EventEmitter behavior. This is critical for plugin system safety. +2. **Sufficient for real-world use.** The primary value of guardrails is stopping agents that are in a dangerous loop, not preventing a single atomic action. Reactive kill handles this well. +3. **Natural progression from Auditor.** The [[concept-ai-auditor|AI Auditor]] plugin establishes the event observation pattern. Guardrails adds `kill()`/`interrupt()` calls — a small delta, not a new architecture. +4. **Main-process component provides lowest latency.** A guardrail listener registered directly on ProcessManager fires in the same event loop tick as the event, with synchronous access to `kill()`. + +### Consider Approach A for v2 + +If users require pre-execution blocking (e.g., showing a confirmation dialog before a destructive tool runs), Approach A's forwarding-listener middleware becomes necessary. This would: +1. Hold the `safeSend` call pending user approval +2. The agent process is suspended (via `interrupt()`) while waiting +3. On approval, forward the event and resume; on denial, kill + +This requires async middleware in the forwarding pipeline, which is a more significant change. Defer until v1 usage validates demand. + +--- + +## Feasibility Verdict + +### Rating: **Moderate-Hard** + +The reactive guardrail (Approach B) is straightforward to implement with existing APIs. The difficulty comes from: (1) the inherent limitation that tool execution may complete before the kill signal arrives, (2) the need for a main-process plugin component for lowest-latency enforcement, and (3) the configuration complexity of defining useful guardrail rules. + +### Required New Infrastructure + +| Infrastructure | Needed For | Complexity | Shared? | +|----------------|-----------|------------|---------| +| Plugin manifest + loader | Loading the plugin | Medium | Yes — all plugins need this | +| Plugin UI registration | Mounting guardrail config panel and alert UI | Medium | Yes — all UI plugins need this | +| Sandboxed API surface | Restricting to read-only events + kill/interrupt | Medium | Yes — with **write** access to kill/interrupt APIs | +| **Main-process plugin listener registration** | Registering guardrail listener on ProcessManager | Medium | Yes — Auditor also benefits from this | +| **Plugin-scoped storage API** | Persisting guardrail rules and violation log | Medium | Yes — shared with Auditor (Gap #8 from [[extension-points]]) | +| **Process control API for plugins** | Exposing `kill()` and `interrupt()` to plugin code | Low | Partial — only enforcement plugins need write access to process control | + +### Infrastructure NOT Required (for v1) + +- No EventEmitter modification or custom emit wrapper +- No middleware/interception layer in forwarding-listeners +- No new event types (existing tool-execution, usage, exit are sufficient) +- No pause/resume API (kill and interrupt are sufficient) + +### Comparison to Other Concepts + +| Concept | Rating | Key Differentiator | +|---------|--------|-------------------| +| [[concept-agent-dashboard\|Agent Dashboard]] | Trivial | Purely read-only renderer component | +| [[concept-ai-auditor\|AI Auditor]] | Moderate | Read-only but needs storage | +| **Agent Guardrails** | **Moderate-Hard** | Needs process control (kill) + storage + session filtering | +| [[concept-notifications\|Notifications]] | TBD | Needs outbound HTTP | +| [[concept-external-integration\|External Integration]] | TBD | Needs web server routes | + +--- + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Kill signal arrives after tool execution completes | Certain for single-action | Medium | Document limitation; recommend pairing with agent-native deny rules; effective for multi-step chains | +| False positive kills interrupt legitimate work | Medium | High | Conservative default rules; require explicit opt-in for kill-on-violation; warning mode before kill mode | +| Guardrail rules too complex for users to configure | Medium | Medium | Ship sensible defaults; provide rule presets (e.g., "no force pushes", "no recursive deletes", "cost cap") | +| Plugin kill() access is a security concern | Low | High | Restrict to approved plugins only; require explicit user consent for process-control permission | +| Token budget enforcement races with multiple rapid usage events | Low | Low | `totalCostUsd` is cumulative — each event has the latest total; no aggregation race possible | +| Group chat / batch sessions incorrectly subject to guardrails | Medium | Medium | Follow existing `GROUP_CHAT_PREFIX` + regex pattern for session filtering; skip internal sessions by default | +| Main-process plugin component adds crash risk | Low | High | Plugin code runs in try/catch; errors captured by Sentry; plugin crash should not crash ProcessManager | From 96bb7bca3a6630d499e8b87fedb4cdda09b069f0 Mon Sep 17 00:00:00 2001 From: Adam Musciano Date: Wed, 18 Feb 2026 20:24:06 -0500 Subject: [PATCH 05/19] MAESTRO: Pressure-test Third-Party Notifications plugin concept feasibility Co-Authored-By: Claude Opus 4.6 --- .../concept-notifications.md | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 docs/research/plugin-feasibility/concept-notifications.md diff --git a/docs/research/plugin-feasibility/concept-notifications.md b/docs/research/plugin-feasibility/concept-notifications.md new file mode 100644 index 000000000..308fca73f --- /dev/null +++ b/docs/research/plugin-feasibility/concept-notifications.md @@ -0,0 +1,324 @@ +--- +type: research +title: "Plugin Concept: Third-Party Notifications" +created: 2026-02-18 +tags: + - plugin + - concept + - notifications +related: + - "[[extension-points]]" + - "[[concept-ai-auditor]]" + - "[[concept-agent-dashboard]]" + - "[[concept-agent-guardrails]]" + - "[[concept-external-integration]]" +--- + +# Plugin Concept: Third-Party Notifications + +A plugin that sends notifications to external services (Slack, Discord, email) when significant events occur: agent completion, failures, auto-run batch completion, budget thresholds, etc. This is a "fan-out" plugin — it subscribes to internal events and pushes outbound HTTP requests to third-party APIs. + +--- + +## Event Subscriptions Needed + +### Primary Events + +| Event | API | Data Available | Notification Use | +|-------|-----|---------------|-----------------| +| `process:exit` | `window.maestro.process.onExit(cb)` | `sessionId`, exit code | Agent task completion (code 0) or failure (non-zero). Core trigger for "agent finished" notifications | +| `agent:error` | `window.maestro.process.onAgentError(cb)` | `sessionId`, `AgentError { type, message, recoverable, agentId, timestamp, raw? }` | Alert on failures: auth expired, token exhaustion, rate limits, crashes. Include error type and message in notification | +| `process:usage` | `window.maestro.process.onUsage(cb)` | `sessionId`, `UsageStats { inputTokens, outputTokens, cacheRead/Creation, totalCostUsd, contextWindow, reasoningTokens? }` | Budget threshold alerts: "Agent X has spent $Y" when cumulative cost exceeds configured limit | + +### Auto-Run Batch Events + +| Event Source | Observable? | How | Notification Use | +|-------------|------------|-----|-----------------| +| Batch run completion | **Partially** | No dedicated IPC event. Renderer-side only: `batchRunStates[sessionId].isRunning` transitions `true` → `false` in Zustand store, or `processingState` transitions to `'IDLE'` | "Batch run completed: X/Y tasks done in Z minutes" | +| Batch run started | **Partially** | Zustand store: `START_BATCH` action sets `processingState: 'INITIALIZING'` | "Batch run started with X documents" | +| Batch error/pause | **Partially** | Zustand store: `SET_ERROR` action sets `processingState: 'PAUSED_ERROR'`, populates `error` field | "Batch run paused: error on task X" | +| Loop iteration | **Partially** | Zustand store: `INCREMENT_LOOP` action increments `loopIteration` | "Loop N completed, starting loop N+1" | +| Individual task progress | **Partially** | Zustand store: `UPDATE_PROGRESS` action updates `currentDocTasksCompleted` | Optional: per-task progress (likely too noisy for external notifications) | + +### Auto-Run File Changes + +| Event | API | Data Available | Notification Use | +|-------|-----|---------------|-----------------| +| `autorun:fileChanged` | `window.maestro.autorun.onFileChanged(cb)` | `{ folderPath, filename, eventType: 'rename' | 'change' }` | Optional: notify when auto-run documents are modified (useful for team awareness) | + +### Supplementary Events + +| Event | API | Notification Use | +|-------|-----|-----------------| +| `process:tool-execution` | `window.maestro.process.onToolExecution(cb)` | Optional: alert on specific tool executions (e.g., "Agent ran Bash command") — likely too noisy for most users | +| `process:data` | `window.maestro.process.onData(cb)` | Optional: forward agent output snippets — high volume, not recommended for notifications | + +--- + +## Auto-Run State Observability Assessment + +### What's Available + +The auto-run (batch run) state is managed entirely in the renderer via a Zustand store (`useBatchStore` in `src/renderer/stores/batchStore.ts`). The state model is rich: + +```typescript +interface BatchRunState { + isRunning: boolean; + isStopping: boolean; + processingState: 'IDLE' | 'INITIALIZING' | 'RUNNING' | 'STOPPING' | 'PAUSED_ERROR' | 'COMPLETING'; + documents: string[]; + currentDocumentIndex: number; + totalTasksAcrossAllDocs: number; + completedTasksAcrossAllDocs: number; + loopEnabled: boolean; + loopIteration: number; + startTime?: number; + error?: AgentError; + // ... additional fields +} +``` + +### Key Finding: No IPC-Level Batch Events + +**Batch run lifecycle events are NOT emitted via IPC.** There is no `process:batch-complete`, `autorun:started`, or similar channel. Completion is handled entirely within the renderer: + +1. The `useBatchProcessor` hook dispatches `COMPLETE_BATCH` to the Zustand reducer +2. An `onComplete` callback fires with `BatchCompleteInfo { sessionId, sessionName, completedTasks, totalTasks, wasStopped, elapsedTimeMs }` +3. `broadcastAutoRunState(sessionId, null)` is called to notify web/mobile clients +4. The built-in `window.maestro.notification.show()` is called for OS-level completion notifications + +### How a Plugin Would Observe Batch State + +A renderer-side plugin has two options: + +1. **Zustand store subscription** (preferred): `useBatchStore.subscribe(state => ...)` — receives every state update, can detect transitions (RUNNING → IDLE = completion, RUNNING → PAUSED_ERROR = failure) +2. **Polling `getActiveProcesses()`**: Less efficient, doesn't capture batch-level metadata + +For a main-process plugin, batch state is **not directly observable**. The plugin would need either: +- A new IPC channel that the renderer fires on batch state transitions (Gap #11 proposal below) +- Or the plugin runs its renderer component, which subscribes to Zustand and forwards relevant events to the main process via IPC + +--- + +## Outbound HTTP Assessment + +### Slack Webhooks + +| Aspect | Detail | +|--------|--------| +| Protocol | HTTPS POST to `https://hooks.slack.com/services/T.../B.../xxx` | +| Auth | URL contains the token (no headers needed) | +| Payload | JSON: `{ "text": "message", "blocks": [...] }` | +| CORS | **No CORS headers** — Slack webhook endpoints do not send `Access-Control-Allow-Origin` | +| Renderer feasibility | **Blocked by CORS** — `fetch()` from Electron renderer will fail for Slack webhooks | +| Main process feasibility | **Works** — Node.js `fetch()` / `https.request()` has no CORS restrictions | + +### Discord Webhooks + +| Aspect | Detail | +|--------|--------| +| Protocol | HTTPS POST to `https://discord.com/api/webhooks//` | +| Auth | URL contains the token | +| Payload | JSON: `{ "content": "message", "embeds": [...] }` | +| CORS | **No CORS headers** for webhook endpoints | +| Renderer feasibility | **Blocked by CORS** | +| Main process feasibility | **Works** | + +### Email via SMTP + +| Aspect | Detail | +|--------|--------| +| Protocol | SMTP (TCP, not HTTP) | +| Libraries | `nodemailer` or similar | +| Renderer feasibility | **Impossible** — renderer has no TCP socket access | +| Main process feasibility | **Works** — Node.js has full network access; `nodemailer` is a standard dependency | + +### Generic Webhooks (Custom URLs) + +| Aspect | Detail | +|--------|--------| +| Protocol | HTTPS POST to user-configured URL | +| CORS | **Depends on endpoint** — most internal/self-hosted endpoints don't set CORS headers | +| Renderer feasibility | **Unreliable** — works only if endpoint sends appropriate CORS headers | +| Main process feasibility | **Works** for all endpoints | + +### Summary: Main Process Required for HTTP + +| Channel | Renderer | Main Process | +|---------|----------|-------------| +| Slack webhook | CORS blocked | Works | +| Discord webhook | CORS blocked | Works | +| Email (SMTP) | Impossible | Works | +| Custom webhook | Unreliable | Works | +| OS notification | Works (existing API) | N/A | + +**Conclusion: The notification plugin MUST have a main-process component for outbound HTTP.** The renderer cannot reliably reach third-party webhook endpoints due to CORS restrictions. Electron's renderer runs in a browser-like sandbox where `fetch()` respects CORS policies. + +--- + +## Existing Notification Infrastructure + +Maestro already has a `window.maestro.notification` API (`src/main/preload/notifications.ts`): + +| Method | Purpose | Plugin Relevance | +|--------|---------|-----------------| +| `show(title, body)` | OS-native notification via `Notification` API | Could be used as a local fallback alongside external notifications | +| `speak(text, command?)` | Execute custom notification command (TTS, logging) | Extensible — a plugin could register a custom command that sends webhooks | +| `stopSpeak(notificationId)` | Stop a running notification command | Process management for long-running commands | +| `onCommandCompleted(handler)` | Subscribe to command completion | Lifecycle management | + +This API handles **local** notifications only. The plugin would extend this with **external** (Slack, Discord, email) delivery. + +--- + +## Plugin Architecture: Renderer vs Main Process + +### Verdict: Needs Both Renderer AND Main Process Components + +``` +┌─────────────────────────────────────────────────────────┐ +│ RENDERER (event observation + UI) │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Event Listeners │ │ +│ │ • process.onExit() → agent completion │ │ +│ │ • process.onAgentError() → failure alerts │ │ +│ │ • process.onUsage() → budget tracking │ │ +│ │ • batchStore.subscribe() → auto-run lifecycle │ │ +│ └──────────────────┬──────────────────────────────┘ │ +│ │ IPC: notifications:send │ +│ ┌──────────────────┴──────────────────────────────┐ │ +│ │ Settings UI (configuration panel) │ │ +│ │ • Webhook URLs, channel selections │ │ +│ │ • Event filter checkboxes │ │ +│ │ • Budget threshold inputs │ │ +│ └─────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ + IPC bridge + │ +┌─────────────────────────────────────────────────────────┐ +│ MAIN PROCESS (HTTP delivery) │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Notification Dispatcher │ │ +│ │ • Receives structured events via IPC │ │ +│ │ • Formats messages per channel (Slack, Discord) │ │ +│ │ • Sends HTTPS POST (no CORS restrictions) │ │ +│ │ • Handles retries, rate limits, failures │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Config Storage │ │ +│ │ • Webhook URLs, tokens (encrypted at rest) │ │ +│ │ • Per-event notification preferences │ │ +│ │ • Delivery history (last N notifications) │ │ +│ └─────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### Why Not Renderer-Only? + +1. **CORS blocks outbound webhooks** — the primary purpose of this plugin cannot be achieved from the renderer alone +2. **Credentials should not live in renderer memory** — webhook URLs contain auth tokens; main process is more secure +3. **Retry/queue logic belongs in a persistent process** — the renderer may be in the background or have its view destroyed + +### Why Not Main-Process-Only? + +1. **Batch state is only in renderer** — the Zustand store with `BatchRunState` is not accessible from the main process. The renderer must observe it and forward events. +2. **Settings UI must render in the app** — configuration needs a React component for webhook URL input, event selection, test buttons +3. **Process events already forward to renderer** — subscribing to `process:exit`, `agent:error`, etc. is easiest from the preload API + +--- + +## Required New Infrastructure + +| Infrastructure | Needed For | Complexity | Shared With Other Plugins? | +|----------------|-----------|------------|---------------------------| +| Plugin manifest + loader | Loading the plugin | Medium | Yes — all plugins | +| Plugin UI registration | Settings panel / config component | Medium | Yes — all UI plugins | +| Sandboxed API surface | Restricting renderer access | Medium | Yes — all plugins | +| **Main-process plugin component** | Outbound HTTP dispatch | Medium | Yes — Auditor, Guardrails need this too | +| **Plugin-scoped storage API** (Gap #8) | Persisting webhook configs and delivery history | Medium | Yes — Auditor, Guardrails | +| **Plugin-to-main IPC bridge** | Renderer event listener → main process HTTP dispatch | Low | Partial — any plugin with split architecture | + +### Infrastructure NOT Required + +- No middleware/interception layer (notifications only observe, never block) +- No new process control APIs (no kill/interrupt needed) +- No modifications to the event pipeline +- No new event channels for process events (all needed events already exist) + +### New Gap Identified + +| # | Gap | Severity | Blocks | +|---|-----|----------|--------| +| 11 (proposed) | No IPC-level auto-run batch lifecycle events | Medium | Notifications (batch completion), any main-process consumer of batch state | + +Currently, batch run completion is only observable in the renderer (Zustand store). For main-process plugins to react to batch completion, either: +- (A) The renderer plugin component subscribes to Zustand and fires a plugin IPC event, or +- (B) Core Maestro emits batch lifecycle events via IPC (cleaner, benefits all main-process consumers) + +Option (A) is sufficient for v1; option (B) is the proper long-term solution. + +--- + +## Feasibility Verdict + +### Rating: **Moderate** + +The notification plugin is architecturally straightforward — all needed events exist, and the delivery mechanism (outbound HTTP) is well-understood. The complexity comes from requiring both renderer and main-process components to bridge event observation with CORS-free HTTP dispatch. + +### Comparison to Other Concepts + +| Concept | Rating | Renderer-Only? | Main-Process Needed? | New Infra | +|---------|--------|----------------|---------------------|-----------| +| [[concept-agent-dashboard|Dashboard]] | Trivial | Yes | No | Minimal | +| **Notifications** | **Moderate** | **No** | **Yes (HTTP dispatch)** | **Plugin IPC bridge, storage** | +| [[concept-ai-auditor|Auditor]] | Moderate | No | Yes (SQLite) | Storage API | +| [[concept-agent-guardrails|Guardrails]] | Moderate-Hard | No | Yes (process control) | Process control API, storage | + +Notifications is slightly easier than the Auditor because: +- No unbounded storage (delivery history can be capped or omitted) +- No pattern-matching engine (just event → message formatting) +- Outbound HTTP is simpler than SQLite schema management + +But harder than the Dashboard because: +- Requires a main-process component (CORS) +- Requires credential storage (webhook URLs) +- Requires the renderer ↔ main process bridge for batch events + +### Implementation Sketch + +A minimal Third-Party Notifications plugin would: + +1. **Renderer component** (event observer + settings UI): + - On mount: subscribe to `onExit()`, `onAgentError()`, `onUsage()`, `batchStore.subscribe()` + - Detect significant transitions (agent done, error, batch complete, budget exceeded) + - Forward structured notification payloads to main process via plugin IPC + - Settings panel: webhook URL inputs, per-event toggles, test button + +2. **Main-process component** (HTTP dispatcher): + - Receive notification payloads via IPC + - Format for target service (Slack Block Kit, Discord embeds, email HTML) + - Send HTTPS POST with retry logic (exponential backoff, 3 retries) + - Log delivery results to plugin storage + +3. **Configuration storage** (via plugin storage API): + - Webhook URLs (encrypted at rest) + - Enabled channels per event type + - Budget threshold values + - Delivery history (last 100 entries, auto-pruned) + +--- + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Webhook URL contains credentials — leak risk | Medium | High | Store in main process only; never expose in renderer; use Electron's `safeStorage` API for encryption at rest | +| Notification spam from high-frequency events | Medium | Medium | Debounce/throttle: max 1 notification per event type per N seconds; batch completion summary instead of per-task | +| Slack/Discord API rate limits (1 req/sec for Slack) | Medium | Low | Queue with rate limiting; batch multiple events into single message when possible | +| Auto-run batch state not observable from main process | Certain | Medium | v1: renderer subscribes to Zustand, forwards to main; v2: add IPC batch lifecycle events to core | +| Webhook endpoint is unreachable | Medium | Low | Retry with backoff; surface delivery failures in settings UI; don't block agent operations | +| User configures webhook URL incorrectly | Medium | Low | "Test notification" button in settings; validate URL format; show last delivery status | From cb43fa02cc100392bb1bbec0ce9f196d2cc7d97c Mon Sep 17 00:00:00 2001 From: Adam Musciano Date: Wed, 18 Feb 2026 20:28:13 -0500 Subject: [PATCH 06/19] MAESTRO: Pressure-test External Tool Integration plugin concept feasibility Analyzed existing Fastify web server architecture against external integration needs. Found most high-value scenarios (dashboards, log sync, CI triggers) work via core web server enhancements rather than plugin infrastructure. Identified 4 core gaps (tool execution broadcasts, stats endpoints, session creation, auto-run triggers) and 1 plugin-specific gap (route registration). Co-Authored-By: Claude Opus 4.6 --- .../concept-external-integration.md | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 docs/research/plugin-feasibility/concept-external-integration.md diff --git a/docs/research/plugin-feasibility/concept-external-integration.md b/docs/research/plugin-feasibility/concept-external-integration.md new file mode 100644 index 000000000..e32b70c81 --- /dev/null +++ b/docs/research/plugin-feasibility/concept-external-integration.md @@ -0,0 +1,202 @@ +--- +type: research +title: "Plugin Concept: External Tool Integration" +created: 2026-02-18 +tags: + - plugin + - concept + - external-integration + - web-server + - api +related: + - "[[extension-points]]" + - "[[concept-agent-dashboard]]" + - "[[concept-ai-auditor]]" + - "[[concept-agent-guardrails]]" + - "[[concept-notifications]]" +--- + +# External Tool Integration Plugin Concept + +## Overview + +This plugin enables external tools (Obsidian, Notion, local dev tools, CI/CD systems, custom dashboards) to consume Maestro data and interact with agents. Examples: syncing agent output to Obsidian vaults, pushing usage stats to Notion databases, triggering agents from CI pipelines, or building custom monitoring dashboards. + +## What External Tools Would Want + +### Data Available for Consumption + +| Data Category | Source | Current API | Access Level | +|---|---|---|---| +| Agent output (stdout/stderr) | `process:data` events | WebSocket `session_state_change` (partial), REST `GET /api/session/:id` (logs) | Read-only, already exposed | +| Usage stats (tokens, cost, context window) | `process:usage` events | REST `GET /api/sessions` (per-session), Stats DB (aggregated) | Read-only, partial exposure | +| File changes | `process:tool-execution` events | Not exposed via web server | **Gap** | +| Auto Run progress | Zustand store → `web.broadcastAutoRunState()` | WebSocket `autorun_state` broadcast | Read-only, already exposed | +| Session lifecycle (create/delete/state) | IPC events | WebSocket `session_added`, `session_removed`, `session_state_change` | Read-only, already exposed | +| Tool executions (tool name, state, input) | `process:tool-execution` events | Not exposed via web server | **Gap** | +| Aggregated stats (by agent, by day, by source) | Stats SQLite DB | `stats:get-aggregation` IPC only | **Gap** — not on web server | +| History entries | History store | REST `GET /api/history` | Read-only, already exposed | +| Theme | Settings store | REST `GET /api/theme`, WebSocket `theme` broadcast | Read-only, already exposed | + +### Write Operations External Tools Would Want + +| Operation | Current API | Notes | +|---|---|---| +| Send command to agent | REST `POST /api/session/:id/send`, WebSocket `send_command` | Already exposed, token-gated | +| Interrupt agent | REST `POST /api/session/:id/interrupt`, WebSocket `switch_mode` | Already exposed | +| Create new session | Not exposed | **Gap** | +| Trigger Auto Run | Not exposed | **Gap** | + +## Existing Web Server Analysis + +### Architecture + +Maestro already has a Fastify-based web server (`src/main/web-server/`) with: + +- **REST API** at `/$TOKEN/api/*` — 6 endpoints (sessions, session detail, send, interrupt, theme, history) +- **WebSocket** at `/$TOKEN/ws` — bidirectional real-time communication with 10+ inbound message types and 12+ outbound broadcast types +- **Security** — UUID token regenerated per app launch, required in all URLs +- **Rate limiting** — 100 req/min GET, 30 req/min POST, per-IP +- **CORS** — enabled via `@fastify/cors` +- **On-demand startup** — server only runs when user enables the web interface + +### What Already Works + +A significant portion of external integration is **already possible** through the existing web server: + +1. **Session monitoring**: `GET /api/sessions` returns all sessions with state, usage stats, and tabs +2. **Session detail + logs**: `GET /api/session/:id` returns AI/shell logs, usage, state +3. **Real-time updates**: WebSocket broadcasts session state changes, auto-run progress, theme changes +4. **Command execution**: `POST /api/session/:id/send` sends commands to agents +5. **History**: `GET /api/history` returns conversation history entries + +An external tool like Obsidian could already poll `/api/sessions` and `/api/session/:id` to sync agent output, or connect via WebSocket for real-time updates. + +### What's Missing for a Complete Integration Story + +#### Gap A: No Tool Execution Events on WebSocket + +The WebSocket broadcasts session state changes but **not individual tool executions**. The `process:tool-execution` events (tool name, state, input/output) are only available via the renderer's preload API. External tools wanting to monitor file edits, command runs, or other tool activity have no access. + +**Proposed fix**: Add a `tool_execution` WebSocket broadcast type. The `forwarding-listeners.ts` already forwards these to the renderer; a parallel forward to the web server's broadcast service would be minimal. + +#### Gap B: No Stats/Analytics Endpoints + +The Stats API (`stats:get-aggregation`, `stats:get-stats`, etc.) is only available via IPC. External dashboards wanting usage analytics, cost tracking, or session lifecycle data cannot access it. + +**Proposed fix**: Add `GET /api/stats/aggregation?range=week` and `GET /api/stats/sessions?range=month` REST endpoints that proxy to the stats DB. + +#### Gap C: No Session Creation Endpoint + +External tools cannot create new agents/sessions via the web server. This limits CI/CD integration scenarios. + +**Proposed fix**: Add `POST /api/sessions` endpoint that triggers session creation via the renderer callback pattern (similar to `executeCommand`). + +#### Gap D: No Auto Run Trigger Endpoint + +External tools cannot start Auto Run batches. This limits automation scenarios where CI/CD pipelines want to trigger playbook execution. + +**Proposed fix**: Add `POST /api/session/:id/autorun` endpoint. + +#### Gap E: No Plugin Route Registration + +The Fastify routes are hardcoded in `apiRoutes.ts`. A plugin cannot register its own REST or WebSocket endpoints on the web server. + +**Proposed fix**: Expose a route registration API that plugins can call to add custom endpoints under `/$TOKEN/api/plugins//*`. Fastify natively supports dynamic route registration via `server.register()` with prefix scoping. + +## Plugin Architecture Assessment + +### Can This Work via the Existing Web Server? + +**Yes, substantially.** The existing web server is the correct integration surface. The question is whether a plugin *extends* it or merely *consumes* it. + +Three integration patterns emerge: + +#### Pattern 1: External Tool as Consumer (No Plugin Needed) + +External tools connect to Maestro's existing web server as clients. This works **today** for: +- Session monitoring and log sync +- Real-time state subscriptions via WebSocket +- Command execution +- History retrieval + +**No plugin system required.** The gaps (A–D) are core web server enhancements, not plugin features. + +#### Pattern 2: Plugin as Data Enricher (Main-Process Plugin) + +A plugin runs in the main process, subscribes to `ProcessManager` events, and either: +- Enriches existing web server responses (e.g., adds tool execution data to session detail) +- Writes derived data to plugin-scoped storage for external consumption + +This requires: Gap #1 (main-process listener registration), Gap #8 (plugin-scoped storage). + +#### Pattern 3: Plugin as Route Provider (Main-Process Plugin) + +A plugin registers custom REST/WebSocket endpoints on the web server for external tool consumption. Examples: +- `/api/plugins/obsidian/sync` — returns agent output formatted for Obsidian vault import +- `/api/plugins/notion/webhook` — receives Notion webhook callbacks +- `/api/plugins/metrics/prometheus` — returns Prometheus-format metrics + +This requires: Gap E (plugin route registration), plus Gap #1 and Gap #8. + +### Recommended Approach + +**v1: Core web server enhancements (Pattern 1) — no plugin system needed.** + +The highest-value integration scenarios (monitoring dashboards, log syncing, CI triggers) work by adding missing endpoints to the core web server. This benefits all external tools without requiring them to install plugins. + +Specific v1 additions to the core web server: +1. `tool_execution` WebSocket broadcast (Gap A) +2. Stats REST endpoints (Gap B) +3. Session creation endpoint (Gap C) — stretch goal + +**v2: Plugin route registration (Pattern 3) — for custom integrations.** + +Once the plugin system exists, allow plugins to register routes on the web server under a scoped prefix. This enables tool-specific formatters (Obsidian, Notion, Prometheus) without bloating the core API. + +## Feasibility by Integration Target + +| Target | Feasibility | Approach | Notes | +|---|---|---|---| +| Custom dashboard | **Trivial** | Pattern 1 — consume existing REST + WebSocket | Works today with minor gaps | +| Obsidian vault sync | **Easy** | Pattern 1 + Gap A (tool executions) | Poll `/api/session/:id` for logs, format as markdown | +| Notion database | **Moderate** | Pattern 2 or 3 — needs main-process HTTP out | Notion API requires OAuth + server-side calls; similar to [[concept-notifications]] CORS constraint | +| CI/CD trigger | **Moderate** | Pattern 1 + Gap C + D | Needs session creation and Auto Run trigger endpoints | +| Prometheus metrics | **Easy** | Pattern 3 — plugin registers `/metrics` endpoint | Transforms stats data to Prometheus exposition format | +| Grafana | **Trivial** | Pattern 1 — Grafana polls REST endpoints | Grafana's HTTP datasource plugin consumes any JSON API | + +## Required Infrastructure + +### Shared with Other Plugins +- Gap #1: Main-process plugin listener registration (shared with [[concept-agent-guardrails]], [[concept-ai-auditor]]) +- Gap #6: Plugin manifest type (shared, all plugins) +- Gap #8: Plugin-scoped storage (shared with [[concept-ai-auditor]], [[concept-agent-guardrails]], [[concept-notifications]]) + +### Unique to This Concept +- **Gap E (new): Plugin route registration on web server** — allow plugins to register Fastify routes under `/$TOKEN/api/plugins//`. Low complexity: Fastify's `register()` with prefix handles scoping natively. + +### Core Enhancements (Not Plugin Infrastructure) +- Gap A: Tool execution WebSocket broadcast — benefits all web clients, not just plugins +- Gap B: Stats REST endpoints — benefits all web clients +- Gap C: Session creation REST endpoint — benefits all web clients +- Gap D: Auto Run trigger REST endpoint — benefits all web clients + +## Security Considerations + +- The existing security token model is sufficient for external tool auth +- Plugin-registered routes inherit the token requirement automatically (scoped under `/$TOKEN/`) +- Rate limiting applies uniformly to plugin routes +- Plugins should NOT be able to modify or remove core routes +- Plugin routes should be namespaced (`/api/plugins//`) to prevent collisions + +## Verdict + +**Feasibility: Easy-to-Moderate** (depends on integration target) + +The existing web server is a strong foundation. Most external integration scenarios work today or with minor core enhancements (Gaps A–D). The unique plugin infrastructure needed (Gap E: route registration) is low complexity thanks to Fastify's native plugin/prefix system. + +**Key insight**: Unlike the other plugin concepts, the External Tool Integration story is primarily about **core web server completeness**, not plugin infrastructure. The most valuable integrations (dashboards, log sync, CI triggers) need zero plugin system — they need the web server to expose more of the data Maestro already has internally. + +Plugin route registration (Gap E) becomes valuable only for **tool-specific formatting** (Obsidian markdown, Prometheus metrics, Notion OAuth) where the transformation logic shouldn't live in core Maestro. + +**Dependency ordering**: Core web server enhancements (v1) can proceed independently of the plugin system. Plugin route registration (v2) depends on the shared plugin manifest/loader from Phase 02. From d74af4c7133e6882d87c0c965d82f42cf566e3f9 Mon Sep 17 00:00:00 2001 From: Adam Musciano Date: Wed, 18 Feb 2026 20:30:52 -0500 Subject: [PATCH 07/19] MAESTRO: Create consolidated plugin system feasibility summary Synthesizes findings from all 6 concept assessments into a single source-of-truth document covering feasibility ratings, 16 identified gaps ranked by difficulty, v1/v2 scope recommendations, dependency graph, reusable infrastructure inventory, and risk summary. Co-Authored-By: Claude Opus 4.6 --- docs/research/plugin-feasibility/README.md | 259 +++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/research/plugin-feasibility/README.md diff --git a/docs/research/plugin-feasibility/README.md b/docs/research/plugin-feasibility/README.md new file mode 100644 index 000000000..a10feb43e --- /dev/null +++ b/docs/research/plugin-feasibility/README.md @@ -0,0 +1,259 @@ +--- +type: report +title: Plugin System Feasibility Summary +created: 2026-02-18 +tags: + - plugin + - architecture + - feasibility + - summary +related: + - "[[extension-points]]" + - "[[concept-agent-dashboard]]" + - "[[concept-ai-auditor]]" + - "[[concept-agent-guardrails]]" + - "[[concept-notifications]]" + - "[[concept-external-integration]]" +--- + +# Plugin System Feasibility Summary + +This document consolidates findings from the Phase 01 architectural feasibility study. It is the single source of truth for all subsequent plugin system phases. + +--- + +## Plugin Concept Feasibility Ratings + +Six plugin concepts were pressure-tested against Maestro's existing architecture. Each was evaluated for API availability, infrastructure gaps, and implementation complexity. + +| Concept | Rating | Renderer-Only? | Main-Process Needed? | Primary Blocker | +|---------|--------|----------------|---------------------|-----------------| +| [[concept-agent-dashboard\|Agent Dashboard Widget]] | **Trivial** | Yes | No | None — all APIs exist | +| [[concept-external-integration\|External Tool Integration]] | **Easy-to-Moderate** | N/A (web server) | Yes (route registration) | Core web server completeness (Gaps A–D) | +| [[concept-notifications\|Third-Party Notifications]] | **Moderate** | No | Yes (CORS-free HTTP) | Plugin IPC bridge + credential storage | +| [[concept-ai-auditor\|AI Auditor]] | **Moderate** | No | Yes (SQLite storage) | Plugin-scoped storage API (Gap #8) | +| [[concept-agent-guardrails\|Agent Guardrails]] | **Moderate-Hard** | No | Yes (process control) | Process control API + storage + latency constraints | + +### Key Observations + +1. **Only one concept (Dashboard) works renderer-only.** Every other concept needs a main-process component — for storage, HTTP dispatch, or process control. The plugin system must support main-process plugins from v1. + +2. **The hardest concept (Guardrails) is still feasible.** The recommended approach (Observer + Reactive Kill) requires zero core architecture changes. Middleware interception is deferred to v2. + +3. **External Integration is mostly a core web server problem, not a plugin problem.** The highest-value integrations (dashboards, log sync, CI triggers) need web server enhancements, not plugin infrastructure. + +4. **Event APIs are comprehensive.** All 11 ProcessManager events are accessible from both main process and renderer. No new event types are needed for v1. + +5. **Agent-specific data inconsistency is a persistent concern.** Tool execution state varies across agents (Claude Code: untyped input; Codex: structured; OpenCode: richest; Factory Droid: no events). Plugins must handle this variation defensively. + +--- + +## Identified Gaps + +### Infrastructure Gaps (Plugin System) + +These gaps must be addressed by the plugin system itself. They are ordered by dependency (earlier gaps unblock later ones). + +| # | Gap | Severity | Needed By | Complexity | +|---|-----|----------|-----------|------------| +| 6 | **Plugin manifest type** (permissions, entry points, API version, dependencies) | High | All plugins | Medium | +| 1 | **Main-process plugin listener registration API** | High | Auditor, Guardrails, Notifications | Medium | +| 2 | **Sandboxed/scoped renderer API surface** | High | All renderer plugins | Medium | +| 10 | **Plugin UI registration system** (panels, tabs, widgets) | High | Dashboard, any UI plugin | Medium | +| 8 | **Plugin-scoped storage API** (`userData/plugins//`) | Medium | Auditor, Guardrails, Notifications | Medium | +| 3 | **Runtime IPC handler registration** for plugins | Medium | External Integration | Medium | +| 5 | **Dynamic Right Panel tab registration** (refactor static `RightPanelTab` union) | Medium | Dashboard (v2) | Medium | +| 9 | **Middleware/interception layer** in ProcessManager event chain | High | Guardrails (v2 only) | High | +| 4 | **Reserved modal priority range** for plugins (e.g., 300–399) | Low | Dashboard (if modal) | Low | +| 7 | **Session-scoped event filtering** in process subscriptions | Low | Performance optimization | Low | + +### Core Enhancement Gaps (Not Plugin Infrastructure) + +These improve Maestro's core capabilities and benefit all consumers, not just plugins. + +| # | Gap | Severity | Needed By | Complexity | +|---|-----|----------|-----------|------------| +| A | **Tool execution WebSocket broadcast** | Medium | External Integration, web clients | Low | +| B | **Stats/analytics REST endpoints** | Medium | External Integration, dashboards | Low | +| C | **Session creation REST endpoint** | Low | CI/CD integration | Medium | +| D | **Auto Run trigger REST endpoint** | Low | CI/CD integration | Medium | +| 11 | **IPC-level batch lifecycle events** | Medium | Notifications (main-process), any main-process batch consumer | Medium | + +### Plugin-Specific Gap + +| # | Gap | Severity | Needed By | Complexity | +|---|-----|----------|-----------|------------| +| E | **Plugin route registration on web server** | Low | External Integration (v2) | Low (Fastify native prefix scoping) | + +--- + +## Gap Ranking by Implementation Difficulty + +### Tier 1: Low Complexity (Quick Wins) + +| Gap | Description | Effort | +|-----|-------------|--------| +| #4 | Reserved modal priority range | Convention only — define numeric range in docs | +| #7 | Session-scoped event filtering | Optional optimization, plugins can self-filter | +| Gap A | Tool execution WebSocket broadcast | Add broadcast call in `forwarding-listeners.ts` | +| Gap E | Plugin route registration | Fastify `register()` with prefix | + +### Tier 2: Medium Complexity (Core Plugin Infrastructure) + +| Gap | Description | Effort | +|-----|-------------|--------| +| #6 | Plugin manifest type | TypeScript interface + validation + loader | +| #8 | Plugin-scoped storage API | Directory creation + JSON read/write + optional SQLite | +| #1 | Main-process listener registration | Extension of `setupProcessListeners()` pattern | +| #2 | Sandboxed renderer API | Proxy wrapper around `window.maestro.*` with allowlists | +| #10 | Plugin UI registration | React component registry + mount points | +| #3 | Runtime IPC handler registration | Namespaced `ipcMain.handle()` for plugins | +| #5 | Dynamic Right Panel tab registration | Refactor `RightPanelTab` to use registry pattern | +| Gap B | Stats REST endpoints | Proxy to existing `stats-db.ts` queries | +| Gap C | Session creation endpoint | IPC-backed endpoint with renderer callback | +| Gap D | Auto Run trigger endpoint | IPC-backed endpoint | +| #11 | IPC batch lifecycle events | Add IPC emit calls to batch store transitions | + +### Tier 3: High Complexity (Deferred) + +| Gap | Description | Effort | +|-----|-------------|--------| +| #9 | Middleware/interception layer | EventEmitter wrapping or forwarding-listener refactor; risk to core stability | + +--- + +## Recommended Plugin System Scope + +### v1: Minimum Viable Plugin System + +Build the smallest surface area that enables the three simplest concepts: **Dashboard**, **Auditor**, and **Notifications**. + +#### v1 Infrastructure (Must Build) + +| Component | Gaps Addressed | Enables | +|-----------|---------------|---------| +| **Plugin manifest + loader** | #6 | All plugins — defines entry points, permissions, metadata | +| **Main-process plugin component** | #1 | Auditor, Guardrails, Notifications — listener registration on ProcessManager | +| **Renderer plugin sandbox** | #2 | All renderer plugins — scoped access to `window.maestro.*` subsets | +| **Plugin UI registration** | #10 | Dashboard, Auditor — mount React components in modal or panel slots | +| **Plugin-scoped storage** | #8 | Auditor, Notifications — persistent config and data in `userData/plugins//` | +| **Plugin IPC bridge** | #3 (partial) | Notifications — renderer ↔ main process communication for split-architecture plugins | + +#### v1 Core Enhancements (Should Build) + +| Enhancement | Gap | Rationale | +|-------------|-----|-----------| +| Tool execution WebSocket broadcast | A | Low effort, high value for web clients and External Integration | +| Stats REST endpoints | B | Low effort, enables external dashboards without plugin system | +| Reserved modal priority range | #4 | Convention-only, zero code | + +#### v1 Deferred + +| Item | Why Deferred | +|------|-------------| +| Dynamic Right Panel tabs (#5) | Dashboard works as floating modal; tab registration is a larger refactor | +| Middleware/interception layer (#9) | Guardrails v1 uses reactive kill (no middleware needed) | +| Session creation endpoint (C) | Stretch goal; not required by any v1 plugin | +| Auto Run trigger endpoint (D) | Stretch goal; not required by any v1 plugin | +| Plugin route registration (E) | External Integration v1 uses core web server enhancements | +| IPC batch lifecycle events (#11) | Notifications v1 uses renderer Zustand subscription + IPC forward | +| Session-scoped event filtering (#7) | Plugins self-filter; optimization can come later | + +### v2: Full Plugin Ecosystem + +After v1 validates the architecture with internal/first-party plugins: + +| Component | Gaps Addressed | Enables | +|-----------|---------------|---------| +| Dynamic Right Panel tabs | #5 | Dashboard as dockable tab, custom plugin tabs | +| Plugin route registration | E | External Integration plugins (Obsidian, Prometheus, Notion formatters) | +| Middleware/interception layer | #9 | Guardrails pre-execution blocking with user confirmation dialogs | +| IPC batch lifecycle events | #11 | Main-process observation of auto-run state without renderer bridge | +| Session creation + Auto Run endpoints | C, D | Full CI/CD integration via web server | +| Plugin marketplace | — | Community plugin discovery, install, and update flow (reuse Playbook Exchange infrastructure) | + +--- + +## Dependency Graph + +``` +Phase 02: Plugin Manifest + Loader (#6) + │ + ├── Phase 03: Renderer Sandbox (#2) + UI Registration (#10) + │ │ + │ └── Agent Dashboard Widget [TRIVIAL] + │ + ├── Phase 04: Main-Process Plugin Component (#1) + Plugin IPC Bridge (#3 partial) + │ │ + │ ├── Plugin-Scoped Storage (#8) + │ │ │ + │ │ ├── AI Auditor [MODERATE] + │ │ │ + │ │ └── Third-Party Notifications [MODERATE] + │ │ + │ └── Process Control API (kill/interrupt exposure) + │ │ + │ └── Agent Guardrails [MODERATE-HARD] + │ + └── Phase 05 (v2): Advanced Infrastructure + │ + ├── Dynamic Right Panel Tabs (#5) + ├── Middleware/Interception Layer (#9) + ├── Plugin Route Registration (Gap E) + └── Plugin Marketplace + │ + └── External Tool Integration [EASY-TO-MODERATE] + +Independent (can proceed in parallel with any phase): + ├── Core Web Server Enhancements (Gaps A, B, C, D) + ├── Reserved Modal Priority Range (#4) + └── IPC Batch Lifecycle Events (#11) +``` + +### Critical Path + +The minimum path to a working plugin: + +1. **Plugin manifest + loader** → defines how plugins are discovered and initialized +2. **Renderer sandbox + UI registration** → enables the Dashboard (simplest plugin, validates the architecture) +3. **Main-process component + storage** → enables Auditor and Notifications (validates split-architecture plugins) +4. **Process control API** → enables Guardrails (validates enforcement plugins) + +Each phase produces a working plugin concept, providing incremental validation of the architecture. + +--- + +## Existing Infrastructure to Reuse + +| Existing System | Plugin System Reuse | +|----------------|-------------------| +| **Marketplace manifest** (`MarketplacePlaybook`) | Template for `PluginManifest` type (id, title, author, tags, path) | +| **Marketplace fetch + cache** | Plugin registry fetch, version checking, local cache | +| **Process listener pattern** (`setupProcessListeners`) | Plugin listener registration follows same `(processManager, deps)` signature | +| **Layer Stack** (modal priorities) | Plugin modals use reserved priority range | +| **Stats DB** (`better-sqlite3`) | Plugin-scoped SQLite databases reuse same dependency | +| **Fastify web server** | Plugin route registration via native `register()` with prefix | +| **Electron `safeStorage`** | Encrypt plugin credentials (webhook URLs, API keys) | +| **Group chat `GROUP_CHAT_PREFIX`** | Prior art for session-scoped event routing in plugins | + +--- + +## Risk Summary + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|-----------|------------| +| Plugin crashes affect core Maestro | High | Medium | Isolate plugin code in try/catch; main-process plugins run in separate error boundary; Sentry captures plugin errors with plugin ID tag | +| Unsandboxed renderer plugins access all `window.maestro.*` APIs | High | Certain (until #2 resolved) | v1 ships with trusted/first-party plugins only; sandbox is v1 prerequisite for third-party plugins | +| Agent-specific tool execution data breaks plugin assumptions | Medium | Medium | Define normalized `ToolExecution` interface for plugins; adapter layer per agent type | +| Plugin storage grows unbounded | Low | Medium | Require retention policies in plugin manifest; provide `clearOldData()` utility (mirrors stats pattern) | +| Performance degradation from plugin event listeners | Medium | Low | Limit active plugin listener count; debounce guidance in plugin SDK docs; profile before optimizing (#7) | +| Guardrails kill signal arrives after tool execution completes | Medium | Certain (for single-action) | Document limitation; recommend pairing with agent-native deny rules; effective for multi-step chains | +| CORS blocks renderer-only plugins from external HTTP | High | Certain | Main-process component is required for outbound HTTP — documented in concept reports | + +--- + +## Conclusion + +Maestro's existing architecture provides a strong foundation for a plugin system. The ProcessManager event system, preload API surface, and web server cover the data access needs of all five plugin concepts. The primary work is building the **infrastructure layer** — manifest, loader, sandbox, storage, and UI registration — that turns these raw capabilities into a safe, structured extension API. + +The recommended approach is incremental: start with the Dashboard (validates renderer plugins), then Auditor/Notifications (validates main-process plugins), then Guardrails (validates enforcement plugins). Each step builds on the previous one and produces a usable plugin, avoiding the trap of building a large framework before validating it. From 22300c0a69d0b3456722722db80e20df22ce21e3 Mon Sep 17 00:00:00 2001 From: Adam Musciano Date: Wed, 18 Feb 2026 21:12:14 -0500 Subject: [PATCH 08/19] MAESTRO: Implement plugin manifest format, loader, and IPC handlers (Phase 02) Adds the foundational plugin infrastructure: - PluginManifest types with permissions, UI config, and settings schema - Plugin discovery from userData/plugins/ with manifest validation - PluginManager class with singleton pattern for lifecycle orchestration - IPC handlers (plugins:getAll, enable, disable, getDir, refresh) - Preload bridge (window.maestro.plugins namespace) - 31 tests covering manifest validation and plugin discovery Co-Authored-By: Claude Opus 4.6 --- src/__tests__/main/plugin-loader.test.ts | 298 +++++++++++++++++++++++ src/main/ipc/handlers/index.ts | 7 + src/main/ipc/handlers/plugins.ts | 122 ++++++++++ src/main/plugin-loader.ts | 180 ++++++++++++++ src/main/plugin-manager.ts | 142 +++++++++++ src/main/preload/index.ts | 10 + src/main/preload/plugins.ts | 36 +++ src/shared/plugin-types.ts | 131 ++++++++++ src/shared/types.ts | 11 + 9 files changed, 937 insertions(+) create mode 100644 src/__tests__/main/plugin-loader.test.ts create mode 100644 src/main/ipc/handlers/plugins.ts create mode 100644 src/main/plugin-loader.ts create mode 100644 src/main/plugin-manager.ts create mode 100644 src/main/preload/plugins.ts create mode 100644 src/shared/plugin-types.ts diff --git a/src/__tests__/main/plugin-loader.test.ts b/src/__tests__/main/plugin-loader.test.ts new file mode 100644 index 000000000..eff443857 --- /dev/null +++ b/src/__tests__/main/plugin-loader.test.ts @@ -0,0 +1,298 @@ +/** + * Tests for Plugin Manifest Validation and Discovery + * + * Covers: + * - validateManifest() type guard + * - discoverPlugins() directory scanning + * - loadPlugin() manifest reading + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'path'; + +// Mock electron +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn() }, + app: { getPath: vi.fn(() => '/mock/userData') }, +})); + +// Mock logger +vi.mock('../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock fs/promises +const mockReadFile = vi.fn(); +const mockReaddir = vi.fn(); +const mockStat = vi.fn(); +const mockMkdir = vi.fn(); + +vi.mock('fs/promises', () => ({ + default: { + readFile: (...args: unknown[]) => mockReadFile(...args), + readdir: (...args: unknown[]) => mockReaddir(...args), + stat: (...args: unknown[]) => mockStat(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), + }, + readFile: (...args: unknown[]) => mockReadFile(...args), + readdir: (...args: unknown[]) => mockReaddir(...args), + stat: (...args: unknown[]) => mockStat(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), +})); + +import { validateManifest, discoverPlugins, loadPlugin } from '../../main/plugin-loader'; + +/** + * Helper to create a valid manifest object for testing. + */ +function validManifest(overrides: Record = {}) { + return { + id: 'test-plugin', + name: 'Test Plugin', + version: '1.0.0', + description: 'A test plugin', + author: 'Test Author', + main: 'index.js', + permissions: ['stats:read'], + ...overrides, + }; +} + +describe('validateManifest', () => { + it('accepts a valid manifest', () => { + expect(validateManifest(validManifest())).toBe(true); + }); + + it('accepts a valid manifest with all optional fields', () => { + const manifest = validManifest({ + authorLink: 'https://example.com', + minMaestroVersion: '1.0.0', + renderer: 'renderer.js', + ui: { rightPanelTabs: [{ id: 'tab1', label: 'Tab 1' }], settingsSection: true }, + settings: [{ key: 'enabled', type: 'boolean', label: 'Enabled', default: true }], + tags: ['dashboard', 'monitoring'], + }); + expect(validateManifest(manifest)).toBe(true); + }); + + it('rejects null', () => { + expect(validateManifest(null)).toBe(false); + }); + + it('rejects non-object', () => { + expect(validateManifest('string')).toBe(false); + }); + + it('rejects manifest missing required field: id', () => { + const { id, ...rest } = validManifest(); + expect(validateManifest(rest)).toBe(false); + }); + + it('rejects manifest missing required field: name', () => { + const { name, ...rest } = validManifest(); + expect(validateManifest(rest)).toBe(false); + }); + + it('rejects manifest missing required field: version', () => { + const { version, ...rest } = validManifest(); + expect(validateManifest(rest)).toBe(false); + }); + + it('rejects manifest missing required field: description', () => { + const { description, ...rest } = validManifest(); + expect(validateManifest(rest)).toBe(false); + }); + + it('rejects manifest missing required field: author', () => { + const { author, ...rest } = validManifest(); + expect(validateManifest(rest)).toBe(false); + }); + + it('rejects manifest missing required field: main', () => { + const { main, ...rest } = validManifest(); + expect(validateManifest(rest)).toBe(false); + }); + + it('rejects manifest with empty string for required field', () => { + expect(validateManifest(validManifest({ id: '' }))).toBe(false); + expect(validateManifest(validManifest({ name: ' ' }))).toBe(false); + }); + + it('rejects manifest with invalid slug format (uppercase)', () => { + expect(validateManifest(validManifest({ id: 'TestPlugin' }))).toBe(false); + }); + + it('rejects manifest with invalid slug format (spaces)', () => { + expect(validateManifest(validManifest({ id: 'test plugin' }))).toBe(false); + }); + + it('rejects manifest with invalid slug format (underscores)', () => { + expect(validateManifest(validManifest({ id: 'test_plugin' }))).toBe(false); + }); + + it('rejects manifest with invalid slug format (leading hyphen)', () => { + expect(validateManifest(validManifest({ id: '-test' }))).toBe(false); + }); + + it('accepts valid slug formats', () => { + expect(validateManifest(validManifest({ id: 'my-plugin' }))).toBe(true); + expect(validateManifest(validManifest({ id: 'plugin123' }))).toBe(true); + expect(validateManifest(validManifest({ id: 'a' }))).toBe(true); + }); + + it('rejects manifest with missing permissions array', () => { + const { permissions, ...rest } = validManifest(); + expect(validateManifest(rest)).toBe(false); + }); + + it('rejects manifest with permissions as non-array', () => { + expect(validateManifest(validManifest({ permissions: 'stats:read' }))).toBe(false); + }); + + it('rejects unknown permissions', () => { + expect(validateManifest(validManifest({ permissions: ['unknown:perm'] }))).toBe(false); + }); + + it('accepts empty permissions array', () => { + expect(validateManifest(validManifest({ permissions: [] }))).toBe(true); + }); + + it('accepts all known permissions', () => { + const allPerms = [ + 'process:read', 'process:write', 'stats:read', + 'settings:read', 'settings:write', 'notifications', + 'network', 'storage', 'middleware', + ]; + expect(validateManifest(validManifest({ permissions: allPerms }))).toBe(true); + }); + + it('does not fail on extra unknown fields (forward compatibility)', () => { + const manifest = validManifest({ futureField: 'some value', anotherField: 42 }); + expect(validateManifest(manifest)).toBe(true); + }); +}); + +describe('loadPlugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('loads a valid plugin as discovered', async () => { + const manifest = validManifest(); + mockReadFile.mockResolvedValue(JSON.stringify(manifest)); + + const result = await loadPlugin('/plugins/test-plugin'); + + expect(result.state).toBe('discovered'); + expect(result.manifest.id).toBe('test-plugin'); + expect(result.path).toBe('/plugins/test-plugin'); + expect(result.error).toBeUndefined(); + }); + + it('returns error state when manifest.json is missing', async () => { + mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const result = await loadPlugin('/plugins/broken'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('Failed to read manifest.json'); + }); + + it('returns error state for invalid JSON', async () => { + mockReadFile.mockResolvedValue('not valid json {{{'); + + const result = await loadPlugin('/plugins/bad-json'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('Invalid JSON'); + }); + + it('returns error state for manifest that fails validation', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ id: 'BAD ID' })); + + const result = await loadPlugin('/plugins/bad-manifest'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('validation failed'); + }); +}); + +describe('discoverPlugins', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockMkdir.mockResolvedValue(undefined); + }); + + it('returns empty array for empty directory', async () => { + mockReaddir.mockResolvedValue([]); + + const result = await discoverPlugins('/plugins'); + + expect(result).toEqual([]); + }); + + it('discovers valid plugins from subdirectories', async () => { + mockReaddir.mockResolvedValue(['plugin-a', 'plugin-b']); + mockStat.mockResolvedValue({ isDirectory: () => true }); + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.includes('plugin-a')) { + return Promise.resolve(JSON.stringify(validManifest({ id: 'plugin-a' }))); + } + return Promise.resolve(JSON.stringify(validManifest({ id: 'plugin-b' }))); + }); + + const result = await discoverPlugins('/plugins'); + + expect(result).toHaveLength(2); + expect(result[0].state).toBe('discovered'); + expect(result[1].state).toBe('discovered'); + }); + + it('returns error state for plugins with invalid manifests', async () => { + mockReaddir.mockResolvedValue(['good-plugin', 'bad-plugin']); + mockStat.mockResolvedValue({ isDirectory: () => true }); + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.includes('good-plugin')) { + return Promise.resolve(JSON.stringify(validManifest({ id: 'good-plugin' }))); + } + return Promise.resolve('not json'); + }); + + const result = await discoverPlugins('/plugins'); + + expect(result).toHaveLength(2); + const good = result.find((p) => p.manifest.id === 'good-plugin'); + const bad = result.find((p) => p.manifest.id !== 'good-plugin'); + expect(good?.state).toBe('discovered'); + expect(bad?.state).toBe('error'); + }); + + it('skips non-directory entries', async () => { + mockReaddir.mockResolvedValue(['file.txt', 'plugin-dir']); + mockStat.mockImplementation((entryPath: string) => { + if (entryPath.includes('file.txt')) { + return Promise.resolve({ isDirectory: () => false }); + } + return Promise.resolve({ isDirectory: () => true }); + }); + mockReadFile.mockResolvedValue(JSON.stringify(validManifest({ id: 'plugin-dir' }))); + + const result = await discoverPlugins('/plugins'); + + expect(result).toHaveLength(1); + expect(result[0].manifest.id).toBe('plugin-dir'); + }); + + it('creates the plugins directory if it does not exist', async () => { + mockReaddir.mockResolvedValue([]); + + await discoverPlugins('/plugins'); + + expect(mockMkdir).toHaveBeenCalledWith('/plugins', { recursive: true }); + }); +}); diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index ba41c326b..1ca8572e5 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -53,6 +53,7 @@ import { registerAgentErrorHandlers } from './agent-error'; import { registerTabNamingHandlers, TabNamingHandlerDependencies } from './tabNaming'; import { registerDirectorNotesHandlers, DirectorNotesHandlerDependencies } from './director-notes'; import { registerWakatimeHandlers } from './wakatime'; +import { registerPluginHandlers, PluginHandlerDependencies } from './plugins'; import { AgentDetector } from '../../agents'; import { ProcessManager } from '../../process-manager'; import { WebServer } from '../../web-server'; @@ -97,6 +98,8 @@ export type { TabNamingHandlerDependencies }; export { registerDirectorNotesHandlers }; export type { DirectorNotesHandlerDependencies }; export { registerWakatimeHandlers }; +export { registerPluginHandlers }; +export type { PluginHandlerDependencies }; export type { AgentsHandlerDependencies }; export type { ProcessHandlerDependencies }; export type { PersistenceHandlerDependencies }; @@ -280,6 +283,10 @@ export function registerAllHandlers(deps: HandlerDependencies): void { getProcessManager: deps.getProcessManager, getAgentDetector: deps.getAgentDetector, }); + // Register plugin system handlers + registerPluginHandlers({ + app: deps.app, + }); // Setup logger event forwarding to renderer setupLoggerEventForwarding(deps.getMainWindow); } diff --git a/src/main/ipc/handlers/plugins.ts b/src/main/ipc/handlers/plugins.ts new file mode 100644 index 000000000..7a2eb4f72 --- /dev/null +++ b/src/main/ipc/handlers/plugins.ts @@ -0,0 +1,122 @@ +/** + * Plugin IPC Handlers + * + * Provides handlers for querying and managing plugins from the renderer process. + */ + +import { ipcMain, App } from 'electron'; +import { logger } from '../../utils/logger'; +import { createIpcHandler, type CreateHandlerOptions } from '../../utils/ipcHandler'; +import { getPluginManager, createPluginManager } from '../../plugin-manager'; + +const LOG_CONTEXT = '[Plugins]'; + +// ============================================================================ +// Dependencies Interface +// ============================================================================ + +export interface PluginHandlerDependencies { + app: App; +} + +/** + * Helper to create handler options with consistent context. + */ +const handlerOpts = (operation: string, logSuccess = true): CreateHandlerOptions => ({ + context: LOG_CONTEXT, + operation, + logSuccess, +}); + +/** + * Get the PluginManager, throwing if not initialized. + */ +function requirePluginManager() { + const manager = getPluginManager(); + if (!manager) { + throw new Error('Plugin manager not initialized'); + } + return manager; +} + +// ============================================================================ +// Handler Registration +// ============================================================================ + +/** + * Register all Plugin-related IPC handlers. + */ +export function registerPluginHandlers(deps: PluginHandlerDependencies): void { + const { app } = deps; + + // Ensure PluginManager is created and initialized + let manager = getPluginManager(); + if (!manager) { + manager = createPluginManager(app); + } + + // Initialize asynchronously (discover plugins) + manager.initialize().catch((err) => { + logger.error(`Failed to initialize plugin manager: ${err}`, LOG_CONTEXT); + }); + + // ------------------------------------------------------------------------- + // plugins:getAll — returns all LoadedPlugin[] + // ------------------------------------------------------------------------- + ipcMain.handle( + 'plugins:getAll', + createIpcHandler(handlerOpts('getAll', false), async () => { + const pm = requirePluginManager(); + return { plugins: pm.getPlugins() }; + }) + ); + + // ------------------------------------------------------------------------- + // plugins:enable — enables a plugin by ID + // ------------------------------------------------------------------------- + ipcMain.handle( + 'plugins:enable', + createIpcHandler(handlerOpts('enable'), async (id: string) => { + const pm = requirePluginManager(); + const result = await pm.enablePlugin(id); + return { enabled: result }; + }) + ); + + // ------------------------------------------------------------------------- + // plugins:disable — disables a plugin by ID + // ------------------------------------------------------------------------- + ipcMain.handle( + 'plugins:disable', + createIpcHandler(handlerOpts('disable'), async (id: string) => { + const pm = requirePluginManager(); + const result = await pm.disablePlugin(id); + return { disabled: result }; + }) + ); + + // ------------------------------------------------------------------------- + // plugins:getDir — returns the plugins directory path + // ------------------------------------------------------------------------- + ipcMain.handle( + 'plugins:getDir', + createIpcHandler(handlerOpts('getDir', false), async () => { + const pm = requirePluginManager(); + return { dir: pm.getPluginsDir() }; + }) + ); + + // ------------------------------------------------------------------------- + // plugins:refresh — re-scans plugins directory + // ------------------------------------------------------------------------- + ipcMain.handle( + 'plugins:refresh', + createIpcHandler(handlerOpts('refresh'), async () => { + const pm = requirePluginManager(); + await pm.initialize(); + return { plugins: pm.getPlugins() }; + }) + ); + + logger.debug(`${LOG_CONTEXT} Plugin IPC handlers registered`); +} diff --git a/src/main/plugin-loader.ts b/src/main/plugin-loader.ts new file mode 100644 index 000000000..ce7f97d24 --- /dev/null +++ b/src/main/plugin-loader.ts @@ -0,0 +1,180 @@ +/** + * Plugin Discovery and Loader + * + * Discovers plugins from the userData/plugins/ directory, reads and validates + * their manifest.json files, and returns LoadedPlugin objects. + * + * Plugins with invalid manifests are returned with state 'error' rather than + * throwing, so that other plugins can still load. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import type { App } from 'electron'; +import { logger } from './utils/logger'; +import type { PluginManifest, LoadedPlugin } from '../shared/plugin-types'; +import { KNOWN_PERMISSIONS } from '../shared/plugin-types'; + +const LOG_CONTEXT = '[Plugins]'; + +/** + * Valid slug pattern: lowercase alphanumeric and hyphens only. + */ +const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * Returns the plugins directory path under userData. + */ +export function getPluginsDir(app: App): string { + return path.join(app.getPath('userData'), 'plugins'); +} + +/** + * Type guard that validates an unknown value is a valid PluginManifest. + * Checks required fields, slug format, and permissions. + * Logs warnings for unknown fields (forward compatibility). + */ +export function validateManifest(manifest: unknown): manifest is PluginManifest { + if (!manifest || typeof manifest !== 'object') { + return false; + } + + const obj = manifest as Record; + + // Required string fields + const requiredStrings = ['id', 'name', 'version', 'description', 'author', 'main'] as const; + for (const field of requiredStrings) { + if (typeof obj[field] !== 'string' || (obj[field] as string).trim() === '') { + logger.debug(`Manifest validation failed: missing or empty required field '${field}'`, LOG_CONTEXT); + return false; + } + } + + // Validate id is a valid slug + if (!SLUG_REGEX.test(obj.id as string)) { + logger.debug(`Manifest validation failed: invalid slug format for id '${obj.id}'`, LOG_CONTEXT); + return false; + } + + // Validate permissions array + if (!Array.isArray(obj.permissions)) { + logger.debug('Manifest validation failed: permissions must be an array', LOG_CONTEXT); + return false; + } + + const knownSet = new Set(KNOWN_PERMISSIONS); + for (const perm of obj.permissions) { + if (typeof perm !== 'string' || !knownSet.has(perm)) { + logger.debug(`Manifest validation failed: unknown permission '${perm}'`, LOG_CONTEXT); + return false; + } + } + + // Log warnings for unknown top-level fields (forward compatibility) + const knownFields = new Set([ + 'id', 'name', 'version', 'description', 'author', 'authorLink', + 'minMaestroVersion', 'main', 'renderer', 'permissions', 'ui', + 'settings', 'tags', + ]); + for (const key of Object.keys(obj)) { + if (!knownFields.has(key)) { + logger.debug(`Manifest contains unknown field '${key}' (ignored for forward compatibility)`, LOG_CONTEXT); + } + } + + return true; +} + +/** + * Loads a single plugin from a directory path. + * Reads manifest.json, validates it, and returns a LoadedPlugin. + * On validation failure, returns a LoadedPlugin with state 'error'. + */ +export async function loadPlugin(pluginPath: string): Promise { + const manifestPath = path.join(pluginPath, 'manifest.json'); + + // Create a minimal error manifest for failure cases + const errorPlugin = (error: string): LoadedPlugin => ({ + manifest: { + id: path.basename(pluginPath), + name: path.basename(pluginPath), + version: '0.0.0', + description: '', + author: '', + main: '', + permissions: [], + }, + state: 'error', + path: pluginPath, + error, + }); + + let raw: string; + try { + raw = await fs.readFile(manifestPath, 'utf-8'); + } catch (err) { + const message = `Failed to read manifest.json: ${err instanceof Error ? err.message : String(err)}`; + logger.warn(message, LOG_CONTEXT, { pluginPath }); + return errorPlugin(message); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + const message = `Invalid JSON in manifest.json: ${err instanceof Error ? err.message : String(err)}`; + logger.warn(message, LOG_CONTEXT, { pluginPath }); + return errorPlugin(message); + } + + if (!validateManifest(parsed)) { + const message = 'Manifest validation failed: check required fields, id format, and permissions'; + logger.warn(message, LOG_CONTEXT, { pluginPath }); + return errorPlugin(message); + } + + return { + manifest: parsed, + state: 'discovered', + path: pluginPath, + }; +} + +/** + * Scans the plugins directory for subdirectories and loads each one. + * Creates the plugins directory if it doesn't exist. + * Non-directory entries are skipped. + */ +export async function discoverPlugins(pluginsDir: string): Promise { + // Ensure plugins directory exists + await fs.mkdir(pluginsDir, { recursive: true }); + + let entries: string[]; + try { + entries = await fs.readdir(pluginsDir); + } catch (err) { + logger.error(`Failed to read plugins directory: ${err instanceof Error ? err.message : String(err)}`, LOG_CONTEXT); + return []; + } + + const plugins: LoadedPlugin[] = []; + + for (const entry of entries) { + const entryPath = path.join(pluginsDir, entry); + + try { + const stat = await fs.stat(entryPath); + if (!stat.isDirectory()) { + continue; + } + } catch { + continue; + } + + const plugin = await loadPlugin(entryPath); + plugins.push(plugin); + } + + logger.info(`Discovered ${plugins.length} plugin(s) in ${pluginsDir}`, LOG_CONTEXT); + return plugins; +} diff --git a/src/main/plugin-manager.ts b/src/main/plugin-manager.ts new file mode 100644 index 000000000..352f4d286 --- /dev/null +++ b/src/main/plugin-manager.ts @@ -0,0 +1,142 @@ +/** + * Plugin Manager + * + * Orchestrates the plugin lifecycle: discovery, enabling, and disabling. + * Uses a singleton-via-getter pattern consistent with other Maestro managers. + */ + +import type { App } from 'electron'; +import { logger } from './utils/logger'; +import { getPluginsDir, discoverPlugins } from './plugin-loader'; +import type { LoadedPlugin } from '../shared/plugin-types'; + +const LOG_CONTEXT = '[Plugins]'; + +/** + * Manages the lifecycle of all plugins. + */ +export class PluginManager { + private plugins: Map = new Map(); + private pluginsDir: string; + + constructor(app: App) { + this.pluginsDir = getPluginsDir(app); + } + + /** + * Discover and load all plugins from the plugins directory. + */ + async initialize(): Promise { + const discovered = await discoverPlugins(this.pluginsDir); + + this.plugins.clear(); + for (const plugin of discovered) { + this.plugins.set(plugin.manifest.id, plugin); + } + + const errorCount = discovered.filter((p) => p.state === 'error').length; + const okCount = discovered.length - errorCount; + logger.info( + `Plugin system initialized: ${okCount} valid, ${errorCount} with errors`, + LOG_CONTEXT + ); + } + + /** + * Returns all discovered plugins. + */ + getPlugins(): LoadedPlugin[] { + return Array.from(this.plugins.values()); + } + + /** + * Returns a specific plugin by ID. + */ + getPlugin(id: string): LoadedPlugin | undefined { + return this.plugins.get(id); + } + + /** + * Returns plugins with state 'active'. + */ + getActivePlugins(): LoadedPlugin[] { + return this.getPlugins().filter((p) => p.state === 'active'); + } + + /** + * Transitions a plugin from 'discovered' to 'active'. + * Actual activation logic will be added in Phase 03. + */ + async enablePlugin(id: string): Promise { + const plugin = this.plugins.get(id); + if (!plugin) { + logger.warn(`Cannot enable unknown plugin '${id}'`, LOG_CONTEXT); + return false; + } + + if (plugin.state !== 'discovered' && plugin.state !== 'disabled') { + logger.warn( + `Cannot enable plugin '${id}' in state '${plugin.state}'`, + LOG_CONTEXT + ); + return false; + } + + plugin.state = 'active'; + logger.info(`Plugin '${id}' enabled`, LOG_CONTEXT); + return true; + } + + /** + * Transitions a plugin from 'active' to 'disabled'. + */ + async disablePlugin(id: string): Promise { + const plugin = this.plugins.get(id); + if (!plugin) { + logger.warn(`Cannot disable unknown plugin '${id}'`, LOG_CONTEXT); + return false; + } + + if (plugin.state !== 'active') { + logger.warn( + `Cannot disable plugin '${id}' in state '${plugin.state}'`, + LOG_CONTEXT + ); + return false; + } + + plugin.state = 'disabled'; + logger.info(`Plugin '${id}' disabled`, LOG_CONTEXT); + return true; + } + + /** + * Returns the plugins directory path. + */ + getPluginsDir(): string { + return this.pluginsDir; + } +} + +// ============================================================================ +// Singleton access (consistent with other Maestro managers) +// ============================================================================ + +let pluginManagerInstance: PluginManager | null = null; + +/** + * Get the PluginManager singleton. + * Returns null if not yet initialized via createPluginManager(). + */ +export function getPluginManager(): PluginManager | null { + return pluginManagerInstance; +} + +/** + * Create and store the PluginManager singleton. + * Call this once during app initialization. + */ +export function createPluginManager(app: App): PluginManager { + pluginManagerInstance = new PluginManager(app); + return pluginManagerInstance; +} diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index e91a1f1f8..75c88cbce 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -50,6 +50,7 @@ import { createSymphonyApi } from './symphony'; import { createTabNamingApi } from './tabNaming'; import { createDirectorNotesApi } from './directorNotes'; import { createWakatimeApi } from './wakatime'; +import { createPluginsApi } from './plugins'; // Expose protected methods that allow the renderer process to use // the ipcRenderer without exposing the entire object @@ -191,6 +192,9 @@ contextBridge.exposeInMainWorld('maestro', { // WakaTime API (CLI check, API key validation) wakatime: createWakatimeApi(), + + // Plugins API (plugin discovery and management) + plugins: createPluginsApi(), }); // Re-export factory functions for external consumers (e.g., tests) @@ -264,6 +268,8 @@ export { createDirectorNotesApi, // WakaTime createWakatimeApi, + // Plugins + createPluginsApi, }; // Re-export types for TypeScript consumers @@ -472,3 +478,7 @@ export type { // From wakatime WakatimeApi, } from './wakatime'; +export type { + // From plugins + PluginsApi, +} from './plugins'; diff --git a/src/main/preload/plugins.ts b/src/main/preload/plugins.ts new file mode 100644 index 000000000..0746267e4 --- /dev/null +++ b/src/main/preload/plugins.ts @@ -0,0 +1,36 @@ +/** + * Preload API for Plugin operations + * + * Provides the window.maestro.plugins namespace for: + * - Listing all discovered plugins + * - Enabling/disabling plugins + * - Getting the plugins directory path + * - Refreshing the plugin list + */ + +import { ipcRenderer } from 'electron'; + +export interface PluginsApi { + getAll: () => Promise; + enable: (id: string) => Promise; + disable: (id: string) => Promise; + getDir: () => Promise; + refresh: () => Promise; +} + +/** + * Creates the Plugins API object for preload exposure + */ +export function createPluginsApi(): PluginsApi { + return { + getAll: () => ipcRenderer.invoke('plugins:getAll'), + + enable: (id: string) => ipcRenderer.invoke('plugins:enable', id), + + disable: (id: string) => ipcRenderer.invoke('plugins:disable', id), + + getDir: () => ipcRenderer.invoke('plugins:getDir'), + + refresh: () => ipcRenderer.invoke('plugins:refresh'), + }; +} diff --git a/src/shared/plugin-types.ts b/src/shared/plugin-types.ts new file mode 100644 index 000000000..3112b9ce1 --- /dev/null +++ b/src/shared/plugin-types.ts @@ -0,0 +1,131 @@ +/** + * Plugin System Types + * + * Type definitions for the Maestro plugin system. + * Plugins are discovered from userData/plugins/ and registered at startup. + */ + +// ============================================================================ +// Plugin Manifest Types +// ============================================================================ + +/** + * Permissions a plugin can request. + * Each permission grants access to specific Maestro capabilities. + * 'middleware' is included in the type system but deferred to v2 implementation. + */ +export type PluginPermission = + | 'process:read' + | 'process:write' + | 'stats:read' + | 'settings:read' + | 'settings:write' + | 'notifications' + | 'network' + | 'storage' + | 'middleware'; + +/** + * All known plugin permissions for validation. + */ +export const KNOWN_PERMISSIONS: readonly PluginPermission[] = [ + 'process:read', + 'process:write', + 'stats:read', + 'settings:read', + 'settings:write', + 'notifications', + 'network', + 'storage', + 'middleware', +] as const; + +/** + * Definition for a tab a plugin can register in the Right Bar. + */ +export interface PluginTabDefinition { + id: string; + label: string; + icon?: string; +} + +/** + * UI surface registrations for a plugin. + */ +export interface PluginUIConfig { + rightPanelTabs?: PluginTabDefinition[]; + settingsSection?: boolean; + floatingPanel?: boolean; +} + +/** + * A configurable setting that a plugin exposes. + */ +export interface PluginSettingDefinition { + key: string; + type: 'boolean' | 'string' | 'number' | 'select'; + label: string; + default: unknown; + options?: { label: string; value: unknown }[]; +} + +/** + * Plugin manifest describing a plugin's metadata, entry points, and capabilities. + * Modeled after the marketplace manifest pattern from marketplace-types.ts. + */ +export interface PluginManifest { + /** Unique slug identifier (lowercase alphanumeric + hyphens, e.g., "agent-dashboard") */ + id: string; + /** Display name */ + name: string; + /** Semver version string */ + version: string; + /** Short description */ + description: string; + /** Plugin author name */ + author: string; + /** Optional URL to author's website/profile */ + authorLink?: string; + /** Minimum Maestro version required for compatibility */ + minMaestroVersion?: string; + /** Main process entry point file relative to plugin dir (e.g., "index.js") */ + main: string; + /** Optional renderer process entry point (e.g., "renderer.js") */ + renderer?: string; + /** Declared permissions the plugin needs */ + permissions: PluginPermission[]; + /** UI surface registrations */ + ui?: PluginUIConfig; + /** Configurable settings schema */ + settings?: PluginSettingDefinition[]; + /** Searchable keyword tags */ + tags?: string[]; +} + +// ============================================================================ +// Plugin State Types +// ============================================================================ + +/** + * Lifecycle state of a plugin. + * - discovered: manifest read and validated, not yet activated + * - loaded: code loaded into memory + * - active: running and providing functionality + * - error: failed to load or activate + * - disabled: manually disabled by user + */ +export type PluginState = 'discovered' | 'loaded' | 'active' | 'error' | 'disabled'; + +/** + * A plugin that has been discovered and loaded (or failed to load). + */ +export interface LoadedPlugin { + /** The plugin's manifest */ + manifest: PluginManifest; + /** Current lifecycle state */ + state: PluginState; + /** Absolute path to the plugin directory */ + path: string; + /** Error message if state is 'error' */ + error?: string; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index da00aa305..f132797d3 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -391,3 +391,14 @@ export interface GlobalAgentStats { /** Per-provider breakdown */ byProvider: Record; } + +// ============================================================================ +// Plugin Types (re-exported from plugin-types.ts) +// ============================================================================ + +export type { + PluginManifest, + PluginPermission, + PluginState, + LoadedPlugin, +} from './plugin-types'; From a55ec3148a7d2f5dd7fd8dd01c3c13af843b830f Mon Sep 17 00:00:00 2001 From: Adam Musciano Date: Wed, 18 Feb 2026 21:24:59 -0500 Subject: [PATCH 09/19] MAESTRO: Implement plugin API surface and sandboxing (Phase 03) Add PluginHost class that provides permission-scoped API objects to plugins. Each plugin receives only the API namespaces it declared in its manifest permissions, preventing unauthorized access to Maestro internals. Key components: - PluginAPI types (process, processControl, stats, settings, storage, notifications, maestro) - PluginHost with factory methods that check permissions before exposing APIs - Settings namespaced to plugin:id:key to prevent cross-plugin interference - Storage with path traversal prevention for plugin-scoped file access - Event subscription tracking with automatic cleanup on plugin disable - Integration with PluginManager enable/disable lifecycle - Wired into main process startup after ProcessManager creation Co-Authored-By: Claude Opus 4.6 --- src/__tests__/main/plugin-host.test.ts | 393 +++++++++++++++++++++++++ src/main/index.ts | 24 ++ src/main/ipc/handlers/plugins.ts | 10 +- src/main/plugin-host.ts | 374 +++++++++++++++++++++++ src/main/plugin-manager.ts | 17 ++ src/shared/plugin-types.ts | 115 ++++++++ 6 files changed, 927 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/main/plugin-host.test.ts create mode 100644 src/main/plugin-host.ts diff --git a/src/__tests__/main/plugin-host.test.ts b/src/__tests__/main/plugin-host.test.ts new file mode 100644 index 000000000..18376bcc1 --- /dev/null +++ b/src/__tests__/main/plugin-host.test.ts @@ -0,0 +1,393 @@ +/** + * Tests for Plugin Host API Sandboxing + * + * Covers: + * - Permission-based API scoping + * - Settings namespacing + * - Storage path traversal prevention + * - Event subscription cleanup on destroy + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'path'; +import type { LoadedPlugin } from '../../shared/plugin-types'; + +// Mock electron +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn() }, + app: { getPath: vi.fn(() => '/mock/userData'), getVersion: vi.fn(() => '1.0.0') }, + Notification: vi.fn().mockImplementation(() => ({ + show: vi.fn(), + })), +})); + +// Mock logger +vi.mock('../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock fs/promises +const mockReadFile = vi.fn(); +const mockWriteFile = vi.fn(); +const mockReaddir = vi.fn(); +const mockMkdir = vi.fn(); +const mockUnlink = vi.fn(); + +vi.mock('fs/promises', () => ({ + default: { + readFile: (...args: unknown[]) => mockReadFile(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), + readdir: (...args: unknown[]) => mockReaddir(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), + unlink: (...args: unknown[]) => mockUnlink(...args), + }, + readFile: (...args: unknown[]) => mockReadFile(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), + readdir: (...args: unknown[]) => mockReaddir(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), + unlink: (...args: unknown[]) => mockUnlink(...args), +})); + +// Mock stats/singleton +vi.mock('../../main/stats/singleton', () => ({ + getStatsDB: vi.fn(() => ({ + getAggregation: vi.fn().mockResolvedValue({ totalQueries: 42 }), + })), +})); + +import { PluginHost, type PluginHostDependencies } from '../../main/plugin-host'; + +/** + * Helper to create a LoadedPlugin for testing. + */ +function makePlugin(overrides: Partial & { permissions?: string[] } = {}): LoadedPlugin { + const { permissions, ...rest } = overrides; + return { + manifest: { + id: 'test-plugin', + name: 'Test Plugin', + version: '1.0.0', + description: 'A test plugin', + author: 'Test Author', + main: 'index.js', + permissions: (permissions ?? []) as any, + }, + state: 'discovered', + path: '/mock/plugins/test-plugin', + ...rest, + }; +} + +/** + * Helper to create mock dependencies. + */ +function makeDeps(overrides: Partial = {}): PluginHostDependencies { + const eventHandlers = new Map void>>(); + + const mockProcessManager = { + getAll: vi.fn(() => [ + { sessionId: 's1', toolType: 'claude-code', pid: 1234, startTime: 1000, cwd: '/test' }, + ]), + kill: vi.fn(() => true), + write: vi.fn(() => true), + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + if (!eventHandlers.has(event)) eventHandlers.set(event, new Set()); + eventHandlers.get(event)!.add(handler); + return mockProcessManager; + }), + removeListener: vi.fn((event: string, handler: (...args: any[]) => void) => { + eventHandlers.get(event)?.delete(handler); + return mockProcessManager; + }), + // Expose for test assertions + _eventHandlers: eventHandlers, + }; + + const storeData: Record = {}; + const mockSettingsStore = { + get: vi.fn((key: string) => storeData[key]), + set: vi.fn((key: string, value: unknown) => { + storeData[key] = value; + }), + store: storeData, + }; + + const mockApp = { + getPath: vi.fn(() => '/mock/userData'), + getVersion: vi.fn(() => '2.0.0'), + }; + + return { + getProcessManager: () => mockProcessManager as any, + getMainWindow: () => null, + settingsStore: mockSettingsStore as any, + app: mockApp as any, + ...overrides, + }; +} + +describe('PluginHost', () => { + let host: PluginHost; + let deps: PluginHostDependencies; + + beforeEach(() => { + vi.clearAllMocks(); + deps = makeDeps(); + host = new PluginHost(deps); + }); + + describe('permission-based API scoping', () => { + it('provides only maestro API when no permissions declared', () => { + const plugin = makePlugin({ permissions: [] }); + const ctx = host.createPluginContext(plugin); + + expect(ctx.api.maestro).toBeDefined(); + expect(ctx.api.maestro.pluginId).toBe('test-plugin'); + expect(ctx.api.process).toBeUndefined(); + expect(ctx.api.processControl).toBeUndefined(); + expect(ctx.api.stats).toBeUndefined(); + expect(ctx.api.settings).toBeUndefined(); + expect(ctx.api.storage).toBeUndefined(); + expect(ctx.api.notifications).toBeUndefined(); + }); + + it('provides process API with process:read permission', () => { + const plugin = makePlugin({ permissions: ['process:read'] }); + const ctx = host.createPluginContext(plugin); + + expect(ctx.api.process).toBeDefined(); + expect(ctx.api.processControl).toBeUndefined(); + }); + + it('provides processControl API with process:write permission', () => { + const plugin = makePlugin({ permissions: ['process:write'] }); + const ctx = host.createPluginContext(plugin); + + expect(ctx.api.processControl).toBeDefined(); + expect(ctx.api.process).toBeUndefined(); + }); + + it('provides stats API with stats:read permission', () => { + const plugin = makePlugin({ permissions: ['stats:read'] }); + const ctx = host.createPluginContext(plugin); + + expect(ctx.api.stats).toBeDefined(); + }); + + it('provides settings API with settings:read permission', () => { + const plugin = makePlugin({ permissions: ['settings:read'] }); + const ctx = host.createPluginContext(plugin); + + expect(ctx.api.settings).toBeDefined(); + }); + + it('provides storage API with storage permission', () => { + const plugin = makePlugin({ permissions: ['storage'] }); + const ctx = host.createPluginContext(plugin); + + expect(ctx.api.storage).toBeDefined(); + }); + + it('provides notifications API with notifications permission', () => { + const plugin = makePlugin({ permissions: ['notifications'] }); + const ctx = host.createPluginContext(plugin); + + expect(ctx.api.notifications).toBeDefined(); + }); + }); + + describe('maestro API', () => { + it('provides correct metadata', () => { + const plugin = makePlugin(); + const ctx = host.createPluginContext(plugin); + + expect(ctx.api.maestro.version).toBe('2.0.0'); + expect(ctx.api.maestro.platform).toBe(process.platform); + expect(ctx.api.maestro.pluginId).toBe('test-plugin'); + expect(ctx.api.maestro.pluginDir).toBe('/mock/plugins/test-plugin'); + expect(ctx.api.maestro.dataDir).toBe( + path.join('/mock/userData', 'plugins', 'test-plugin', 'data') + ); + }); + }); + + describe('process API', () => { + it('getActiveProcesses returns safe fields only', async () => { + const plugin = makePlugin({ permissions: ['process:read'] }); + const ctx = host.createPluginContext(plugin); + + const processes = await ctx.api.process!.getActiveProcesses(); + expect(processes).toEqual([ + { sessionId: 's1', toolType: 'claude-code', pid: 1234, startTime: 1000 }, + ]); + }); + + it('onData subscribes to data events', () => { + const plugin = makePlugin({ permissions: ['process:read'] }); + const ctx = host.createPluginContext(plugin); + + const callback = vi.fn(); + const unsub = ctx.api.process!.onData(callback); + + expect(typeof unsub).toBe('function'); + const pm = deps.getProcessManager()!; + expect(pm.on).toHaveBeenCalledWith('data', expect.any(Function)); + }); + }); + + describe('processControl API', () => { + it('kill delegates to ProcessManager and logs', () => { + const plugin = makePlugin({ permissions: ['process:write'] }); + const ctx = host.createPluginContext(plugin); + + const result = ctx.api.processControl!.kill('s1'); + expect(result).toBe(true); + expect(deps.getProcessManager()!.kill).toHaveBeenCalledWith('s1'); + }); + + it('write delegates to ProcessManager and logs', () => { + const plugin = makePlugin({ permissions: ['process:write'] }); + const ctx = host.createPluginContext(plugin); + + const result = ctx.api.processControl!.write('s1', 'hello'); + expect(result).toBe(true); + expect(deps.getProcessManager()!.write).toHaveBeenCalledWith('s1', 'hello'); + }); + }); + + describe('settings API', () => { + it('namespaces keys with plugin ID prefix', async () => { + const plugin = makePlugin({ permissions: ['settings:read', 'settings:write'] }); + const ctx = host.createPluginContext(plugin); + + await ctx.api.settings!.set('refreshRate', 5000); + expect(deps.settingsStore.set).toHaveBeenCalledWith( + 'plugin:test-plugin:refreshRate', + 5000 + ); + + await ctx.api.settings!.get('refreshRate'); + expect(deps.settingsStore.get).toHaveBeenCalledWith('plugin:test-plugin:refreshRate'); + }); + + it('settings:read without settings:write throws on set', async () => { + const plugin = makePlugin({ permissions: ['settings:read'] }); + const ctx = host.createPluginContext(plugin); + + await expect(ctx.api.settings!.set('key', 'value')).rejects.toThrow( + "does not have 'settings:write' permission" + ); + }); + + it('getAll returns only namespaced keys', async () => { + const d = makeDeps(); + // Populate store with mixed keys + (d.settingsStore as any).store['plugin:test-plugin:a'] = 1; + (d.settingsStore as any).store['plugin:test-plugin:b'] = 2; + (d.settingsStore as any).store['plugin:other-plugin:c'] = 3; + (d.settingsStore as any).store['someGlobalSetting'] = 'x'; + + const h = new PluginHost(d); + const plugin = makePlugin({ permissions: ['settings:read'] }); + const ctx = h.createPluginContext(plugin); + + const all = await ctx.api.settings!.getAll(); + expect(all).toEqual({ a: 1, b: 2 }); + }); + }); + + describe('storage API', () => { + it('prevents path traversal with ..', async () => { + const plugin = makePlugin({ permissions: ['storage'] }); + const ctx = host.createPluginContext(plugin); + + await expect(ctx.api.storage!.read('../../../etc/passwd')).rejects.toThrow( + 'Path traversal is not allowed' + ); + }); + + it('prevents absolute paths', async () => { + const plugin = makePlugin({ permissions: ['storage'] }); + const ctx = host.createPluginContext(plugin); + + await expect(ctx.api.storage!.read('/etc/passwd')).rejects.toThrow( + 'Absolute paths are not allowed' + ); + }); + + it('read returns null for non-existent files', async () => { + mockReadFile.mockRejectedValueOnce(new Error('ENOENT')); + const plugin = makePlugin({ permissions: ['storage'] }); + const ctx = host.createPluginContext(plugin); + + const result = await ctx.api.storage!.read('config.json'); + expect(result).toBeNull(); + }); + + it('write creates directory on first write', async () => { + mockMkdir.mockResolvedValueOnce(undefined); + mockWriteFile.mockResolvedValueOnce(undefined); + + const plugin = makePlugin({ permissions: ['storage'] }); + const ctx = host.createPluginContext(plugin); + + await ctx.api.storage!.write('config.json', '{}'); + expect(mockMkdir).toHaveBeenCalledWith( + expect.stringContaining(path.join('plugins', 'test-plugin', 'data')), + { recursive: true } + ); + }); + + it('list returns empty array when directory does not exist', async () => { + mockReaddir.mockRejectedValueOnce(new Error('ENOENT')); + const plugin = makePlugin({ permissions: ['storage'] }); + const ctx = host.createPluginContext(plugin); + + const files = await ctx.api.storage!.list(); + expect(files).toEqual([]); + }); + }); + + describe('destroyPluginContext', () => { + it('cleans up all event subscriptions', () => { + const plugin = makePlugin({ permissions: ['process:read'] }); + const ctx = host.createPluginContext(plugin); + + // Subscribe to multiple events + ctx.api.process!.onData(vi.fn()); + ctx.api.process!.onExit(vi.fn()); + ctx.api.process!.onUsage(vi.fn()); + + // 3 event subscriptions + expect(ctx.eventSubscriptions.length).toBe(3); + + // Destroy the context + host.destroyPluginContext('test-plugin'); + + // Subscriptions array should be cleared + expect(ctx.eventSubscriptions.length).toBe(0); + + // removeListener should have been called for each + const pm = deps.getProcessManager()!; + expect(pm.removeListener).toHaveBeenCalledTimes(3); + }); + + it('does not crash when destroying non-existent context', () => { + expect(() => host.destroyPluginContext('non-existent')).not.toThrow(); + }); + + it('removes context from internal map', () => { + const plugin = makePlugin({ permissions: [] }); + host.createPluginContext(plugin); + expect(host.getPluginContext('test-plugin')).toBeDefined(); + + host.destroyPluginContext('test-plugin'); + expect(host.getPluginContext('test-plugin')).toBeUndefined(); + }); + }); +}); diff --git a/src/main/index.ts b/src/main/index.ts index 2794e1dc5..69e52a018 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -53,6 +53,7 @@ import { registerAgentErrorHandlers, registerDirectorNotesHandlers, registerWakatimeHandlers, + registerPluginHandlers, setupLoggerEventForwarding, cleanupAllGroomingSessions, getActiveGroomingSessionCount, @@ -91,6 +92,8 @@ import { } from './constants'; // initAutoUpdater is now used by window-manager.ts (Phase 4 refactoring) import { checkWslEnvironment } from './utils/wslDetector'; +import { createPluginManager } from './plugin-manager'; +import { PluginHost } from './plugin-host'; // Extracted modules (Phase 1 refactoring) import { parseParticipantSessionId } from './group-chat/session-parser'; import { extractTextFromStreamJson } from './group-chat/output-parser'; @@ -368,6 +371,24 @@ app.whenReady().then(async () => { logger.debug('Setting up process event listeners', 'Startup'); setupProcessListeners(); + // Initialize plugin system + logger.info('Initializing plugin system', 'Startup'); + try { + const pluginManager = createPluginManager(app); + const pluginHost = new PluginHost({ + getProcessManager: () => processManager, + getMainWindow: () => mainWindow, + settingsStore: store, + app, + }); + pluginManager.setHost(pluginHost); + await pluginManager.initialize(); + logger.info('Plugin system initialized', 'Startup'); + } catch (error) { + logger.error(`Failed to initialize plugin system: ${error}`, 'Startup'); + logger.warn('Continuing without plugins - plugin features will be unavailable', 'Startup'); + } + // Create main window logger.info('Creating main window', 'Startup'); createWindow(); @@ -660,6 +681,9 @@ function setupIpcHandlers() { // Register WakaTime handlers (CLI check, API key validation) registerWakatimeHandlers(wakatimeManager); + + // Register Plugin system IPC handlers + registerPluginHandlers({ app }); } // Handle process output streaming (set up after initialization) diff --git a/src/main/ipc/handlers/plugins.ts b/src/main/ipc/handlers/plugins.ts index 7a2eb4f72..0b592c6fd 100644 --- a/src/main/ipc/handlers/plugins.ts +++ b/src/main/ipc/handlers/plugins.ts @@ -49,17 +49,15 @@ function requirePluginManager() { export function registerPluginHandlers(deps: PluginHandlerDependencies): void { const { app } = deps; - // Ensure PluginManager is created and initialized + // Ensure PluginManager is created (initialization happens in main startup) let manager = getPluginManager(); if (!manager) { manager = createPluginManager(app); + manager.initialize().catch((err) => { + logger.error(`Failed to initialize plugin manager: ${err}`, LOG_CONTEXT); + }); } - // Initialize asynchronously (discover plugins) - manager.initialize().catch((err) => { - logger.error(`Failed to initialize plugin manager: ${err}`, LOG_CONTEXT); - }); - // ------------------------------------------------------------------------- // plugins:getAll — returns all LoadedPlugin[] // ------------------------------------------------------------------------- diff --git a/src/main/plugin-host.ts b/src/main/plugin-host.ts new file mode 100644 index 000000000..bcd95e603 --- /dev/null +++ b/src/main/plugin-host.ts @@ -0,0 +1,374 @@ +/** + * Plugin Host + * + * Manages plugin lifecycle and provides scoped API objects to plugins. + * Each plugin receives a PluginAPI object with only the namespaces + * permitted by its declared permissions. + */ + +import path from 'path'; +import fs from 'fs/promises'; +import { Notification, type App, type BrowserWindow } from 'electron'; +import { logger } from './utils/logger'; +import type { ProcessManager } from './process-manager'; +import type Store from 'electron-store'; +import type { MaestroSettings } from './stores/types'; +import type { + LoadedPlugin, + PluginAPI, + PluginContext, + PluginProcessAPI, + PluginProcessControlAPI, + PluginStatsAPI, + PluginSettingsAPI, + PluginStorageAPI, + PluginNotificationsAPI, + PluginMaestroAPI, +} from '../shared/plugin-types'; +import type { StatsAggregation } from '../shared/stats-types'; +import { getStatsDB } from './stats/singleton'; + +const LOG_CONTEXT = '[Plugins]'; + +// ============================================================================ +// Dependencies Interface +// ============================================================================ + +export interface PluginHostDependencies { + getProcessManager: () => ProcessManager | null; + getMainWindow: () => BrowserWindow | null; + settingsStore: Store; + app: App; +} + +// ============================================================================ +// PluginHost +// ============================================================================ + +export class PluginHost { + private deps: PluginHostDependencies; + private pluginContexts: Map = new Map(); + + constructor(deps: PluginHostDependencies) { + this.deps = deps; + } + + /** + * Creates a scoped API based on the plugin's declared permissions. + */ + createPluginContext(plugin: LoadedPlugin): PluginContext { + const eventSubscriptions: Array<() => void> = []; + + const api: PluginAPI = { + process: this.createProcessAPI(plugin, eventSubscriptions), + processControl: this.createProcessControlAPI(plugin), + stats: this.createStatsAPI(plugin, eventSubscriptions), + settings: this.createSettingsAPI(plugin), + storage: this.createStorageAPI(plugin), + notifications: this.createNotificationsAPI(plugin), + maestro: this.createMaestroAPI(plugin), + }; + + const context: PluginContext = { + pluginId: plugin.manifest.id, + api, + cleanup: () => { + for (const unsub of eventSubscriptions) { + unsub(); + } + eventSubscriptions.length = 0; + }, + eventSubscriptions, + }; + + this.pluginContexts.set(plugin.manifest.id, context); + logger.info(`Plugin context created for '${plugin.manifest.id}'`, LOG_CONTEXT); + return context; + } + + /** + * Cleans up event listeners, timers, etc. for a plugin. + */ + destroyPluginContext(pluginId: string): void { + const context = this.pluginContexts.get(pluginId); + if (!context) { + logger.warn(`No context to destroy for plugin '${pluginId}'`, LOG_CONTEXT); + return; + } + + context.cleanup(); + this.pluginContexts.delete(pluginId); + logger.info(`Plugin context destroyed for '${pluginId}'`, LOG_CONTEXT); + } + + /** + * Returns a plugin context by ID, if one exists. + */ + getPluginContext(pluginId: string): PluginContext | undefined { + return this.pluginContexts.get(pluginId); + } + + // ======================================================================== + // Private API Factory Methods + // ======================================================================== + + private hasPermission(plugin: LoadedPlugin, permission: string): boolean { + return plugin.manifest.permissions.includes(permission as any); + } + + private createProcessAPI( + plugin: LoadedPlugin, + eventSubscriptions: Array<() => void> + ): PluginProcessAPI | undefined { + if (!this.hasPermission(plugin, 'process:read')) { + return undefined; + } + + const getProcessManager = this.deps.getProcessManager; + + return { + getActiveProcesses: async () => { + const pm = getProcessManager(); + if (!pm) return []; + return pm.getAll().map((p) => ({ + sessionId: p.sessionId, + toolType: p.toolType, + pid: p.pid, + startTime: p.startTime, + })); + }, + + onData: (callback) => { + const pm = getProcessManager(); + if (!pm) return () => {}; + const handler = (sessionId: string, data: string) => callback(sessionId, data); + pm.on('data', handler); + const unsub = () => pm.removeListener('data', handler); + eventSubscriptions.push(unsub); + return unsub; + }, + + onUsage: (callback) => { + const pm = getProcessManager(); + if (!pm) return () => {}; + const handler = (sessionId: string, stats: any) => callback(sessionId, stats); + pm.on('usage', handler); + const unsub = () => pm.removeListener('usage', handler); + eventSubscriptions.push(unsub); + return unsub; + }, + + onToolExecution: (callback) => { + const pm = getProcessManager(); + if (!pm) return () => {}; + const handler = (sessionId: string, tool: any) => + callback(sessionId, { toolName: tool.toolName, state: tool.state, timestamp: tool.timestamp }); + pm.on('tool-execution', handler); + const unsub = () => pm.removeListener('tool-execution', handler); + eventSubscriptions.push(unsub); + return unsub; + }, + + onExit: (callback) => { + const pm = getProcessManager(); + if (!pm) return () => {}; + const handler = (sessionId: string, code: number) => callback(sessionId, code); + pm.on('exit', handler); + const unsub = () => pm.removeListener('exit', handler); + eventSubscriptions.push(unsub); + return unsub; + }, + + onThinkingChunk: (callback) => { + const pm = getProcessManager(); + if (!pm) return () => {}; + const handler = (sessionId: string, text: string) => callback(sessionId, text); + pm.on('thinking-chunk', handler); + const unsub = () => pm.removeListener('thinking-chunk', handler); + eventSubscriptions.push(unsub); + return unsub; + }, + }; + } + + private createProcessControlAPI(plugin: LoadedPlugin): PluginProcessControlAPI | undefined { + if (!this.hasPermission(plugin, 'process:write')) { + return undefined; + } + + const getProcessManager = this.deps.getProcessManager; + const pluginId = plugin.manifest.id; + + return { + kill: (sessionId: string) => { + const pm = getProcessManager(); + if (!pm) return false; + logger.info(`[Plugin:${pluginId}] killed session ${sessionId}`, LOG_CONTEXT); + return pm.kill(sessionId); + }, + + write: (sessionId: string, data: string) => { + const pm = getProcessManager(); + if (!pm) return false; + logger.info(`[Plugin:${pluginId}] wrote to session ${sessionId}`, LOG_CONTEXT); + return pm.write(sessionId, data); + }, + }; + } + + private createStatsAPI( + plugin: LoadedPlugin, + eventSubscriptions: Array<() => void> + ): PluginStatsAPI | undefined { + if (!this.hasPermission(plugin, 'stats:read')) { + return undefined; + } + + const getMainWindow = this.deps.getMainWindow; + + return { + getAggregation: async (range: string): Promise => { + const db = getStatsDB(); + if (!db) { + throw new Error('Stats database not available'); + } + return db.getAggregation(range as any); + }, + + onStatsUpdate: (callback) => { + const win = getMainWindow(); + if (!win) return () => {}; + const handler = () => callback(); + win.webContents.on('ipc-message', (_event, channel) => { + if (channel === 'stats:updated') handler(); + }); + const unsub = () => {}; + eventSubscriptions.push(unsub); + return unsub; + }, + }; + } + + private createSettingsAPI(plugin: LoadedPlugin): PluginSettingsAPI | undefined { + const canRead = this.hasPermission(plugin, 'settings:read'); + const canWrite = this.hasPermission(plugin, 'settings:write'); + + if (!canRead && !canWrite) { + return undefined; + } + + const store = this.deps.settingsStore; + const prefix = `plugin:${plugin.manifest.id}:`; + + return { + get: async (key: string) => { + return store.get(`${prefix}${key}` as any); + }, + + set: async (key: string, value: unknown) => { + if (!canWrite) { + throw new Error(`Plugin '${plugin.manifest.id}' does not have 'settings:write' permission`); + } + store.set(`${prefix}${key}` as any, value as any); + }, + + getAll: async () => { + const all = store.store; + const result: Record = {}; + for (const [k, v] of Object.entries(all)) { + if (k.startsWith(prefix)) { + result[k.slice(prefix.length)] = v; + } + } + return result; + }, + }; + } + + private createStorageAPI(plugin: LoadedPlugin): PluginStorageAPI | undefined { + if (!this.hasPermission(plugin, 'storage')) { + return undefined; + } + + const pluginsDir = path.join(this.deps.app.getPath('userData'), 'plugins'); + const storageDir = path.join(pluginsDir, plugin.manifest.id, 'data'); + + const validateFilename = (filename: string): void => { + if (path.isAbsolute(filename)) { + throw new Error('Absolute paths are not allowed'); + } + if (filename.includes('..')) { + throw new Error('Path traversal is not allowed'); + } + const resolved = path.resolve(storageDir, filename); + if (!resolved.startsWith(storageDir)) { + throw new Error('Path traversal is not allowed'); + } + }; + + return { + read: async (filename: string) => { + validateFilename(filename); + try { + return await fs.readFile(path.join(storageDir, filename), 'utf-8'); + } catch { + return null; + } + }, + + write: async (filename: string, data: string) => { + validateFilename(filename); + await fs.mkdir(storageDir, { recursive: true }); + await fs.writeFile(path.join(storageDir, filename), data, 'utf-8'); + }, + + list: async () => { + try { + return await fs.readdir(storageDir); + } catch { + return []; + } + }, + + delete: async (filename: string) => { + validateFilename(filename); + try { + await fs.unlink(path.join(storageDir, filename)); + } catch { + // Ignore if file doesn't exist + } + }, + }; + } + + private createNotificationsAPI(plugin: LoadedPlugin): PluginNotificationsAPI | undefined { + if (!this.hasPermission(plugin, 'notifications')) { + return undefined; + } + + return { + show: async (title: string, body: string) => { + new Notification({ title, body }).show(); + }, + + playSound: async (sound: string) => { + const win = this.deps.getMainWindow(); + if (win) { + win.webContents.send('plugin:playSound', sound); + } + }, + }; + } + + private createMaestroAPI(plugin: LoadedPlugin): PluginMaestroAPI { + const pluginsDir = path.join(this.deps.app.getPath('userData'), 'plugins'); + + return { + version: this.deps.app.getVersion(), + platform: process.platform, + pluginId: plugin.manifest.id, + pluginDir: plugin.path, + dataDir: path.join(pluginsDir, plugin.manifest.id, 'data'), + }; + } +} diff --git a/src/main/plugin-manager.ts b/src/main/plugin-manager.ts index 352f4d286..37609ab16 100644 --- a/src/main/plugin-manager.ts +++ b/src/main/plugin-manager.ts @@ -9,6 +9,7 @@ import type { App } from 'electron'; import { logger } from './utils/logger'; import { getPluginsDir, discoverPlugins } from './plugin-loader'; import type { LoadedPlugin } from '../shared/plugin-types'; +import type { PluginHost } from './plugin-host'; const LOG_CONTEXT = '[Plugins]'; @@ -18,11 +19,19 @@ const LOG_CONTEXT = '[Plugins]'; export class PluginManager { private plugins: Map = new Map(); private pluginsDir: string; + private host: PluginHost | null = null; constructor(app: App) { this.pluginsDir = getPluginsDir(app); } + /** + * Sets the PluginHost used to create/destroy plugin contexts. + */ + setHost(host: PluginHost): void { + this.host = host; + } + /** * Discover and load all plugins from the plugins directory. */ @@ -82,6 +91,10 @@ export class PluginManager { return false; } + if (this.host) { + this.host.createPluginContext(plugin); + } + plugin.state = 'active'; logger.info(`Plugin '${id}' enabled`, LOG_CONTEXT); return true; @@ -105,6 +118,10 @@ export class PluginManager { return false; } + if (this.host) { + this.host.destroyPluginContext(id); + } + plugin.state = 'disabled'; logger.info(`Plugin '${id}' disabled`, LOG_CONTEXT); return true; diff --git a/src/shared/plugin-types.ts b/src/shared/plugin-types.ts index 3112b9ce1..650e13d40 100644 --- a/src/shared/plugin-types.ts +++ b/src/shared/plugin-types.ts @@ -129,3 +129,118 @@ export interface LoadedPlugin { /** Error message if state is 'error' */ error?: string; } + +// ============================================================================ +// Plugin API Types (Phase 03) +// ============================================================================ + +import type { UsageStats } from './types'; +import type { StatsAggregation } from './stats-types'; + +/** + * Simplified tool execution data exposed to plugins. + * Mirrors the relevant fields from process-manager ToolExecution. + */ +export interface PluginToolExecution { + toolName: string; + state: unknown; + timestamp: number; +} + +/** + * Read-only access to process data and events. + * Requires 'process:read' permission. + */ +export interface PluginProcessAPI { + getActiveProcesses(): Promise>; + onData(callback: (sessionId: string, data: string) => void): () => void; + onUsage(callback: (sessionId: string, stats: UsageStats) => void): () => void; + onToolExecution(callback: (sessionId: string, tool: PluginToolExecution) => void): () => void; + onExit(callback: (sessionId: string, code: number) => void): () => void; + onThinkingChunk(callback: (sessionId: string, text: string) => void): () => void; +} + +/** + * Write access to control processes. + * Requires 'process:write' permission. + */ +export interface PluginProcessControlAPI { + kill(sessionId: string): boolean; + write(sessionId: string, data: string): boolean; +} + +/** + * Read-only access to usage statistics. + * Requires 'stats:read' permission. + */ +export interface PluginStatsAPI { + getAggregation(range: string): Promise; + onStatsUpdate(callback: () => void): () => void; +} + +/** + * Plugin-scoped settings access. + * Requires 'settings:read' or 'settings:write' permission. + * Keys are namespaced to `plugin::`. + */ +export interface PluginSettingsAPI { + get(key: string): Promise; + set(key: string, value: unknown): Promise; + getAll(): Promise>; +} + +/** + * Plugin-scoped file storage. + * Requires 'storage' permission. + * Files are stored under `userData/plugins//data/`. + */ +export interface PluginStorageAPI { + read(filename: string): Promise; + write(filename: string, data: string): Promise; + list(): Promise; + delete(filename: string): Promise; +} + +/** + * Desktop notification capabilities. + * Requires 'notifications' permission. + */ +export interface PluginNotificationsAPI { + show(title: string, body: string): Promise; + playSound(sound: string): Promise; +} + +/** + * Always-available Maestro metadata API. No permission required. + */ +export interface PluginMaestroAPI { + version: string; + platform: string; + pluginId: string; + pluginDir: string; + dataDir: string; +} + +/** + * The scoped API object provided to plugins. + * Optional namespaces are present only when the plugin has the required permission. + */ +export interface PluginAPI { + process?: PluginProcessAPI; + processControl?: PluginProcessControlAPI; + stats?: PluginStatsAPI; + settings?: PluginSettingsAPI; + storage?: PluginStorageAPI; + notifications?: PluginNotificationsAPI; + maestro: PluginMaestroAPI; +} + +/** + * Per-plugin runtime context managed by PluginHost. + */ +export interface PluginContext { + pluginId: string; + api: PluginAPI; + cleanup: () => void; + eventSubscriptions: Array<() => void>; +} From 24ef6237283e2f8d57e4929fac45ea56ee733778 Mon Sep 17 00:00:00 2001 From: Adam Musciano Date: Wed, 18 Feb 2026 21:35:18 -0500 Subject: [PATCH 10/19] MAESTRO: Implement main-process plugin activation, storage, and IPC bridge (Phase 04) Adds plugin module loading/activation lifecycle with Sentry error reporting, PluginStorage class with path traversal prevention, PluginIpcBridge for split-architecture plugins, auto-enable for first-party plugins, and preload bridge API. 26 new tests covering activation, storage, and IPC bridge. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/main/plugin-activation.test.ts | 433 +++++++++++++++++++ src/main/ipc/handlers/plugins.ts | 30 +- src/main/plugin-host.ts | 139 ++++-- src/main/plugin-ipc-bridge.ts | 103 +++++ src/main/plugin-loader.ts | 2 +- src/main/plugin-manager.ts | 59 ++- src/main/plugin-storage.ts | 114 +++++ src/main/preload/plugins.ts | 14 + src/shared/plugin-types.ts | 24 + 9 files changed, 869 insertions(+), 49 deletions(-) create mode 100644 src/__tests__/main/plugin-activation.test.ts create mode 100644 src/main/plugin-ipc-bridge.ts create mode 100644 src/main/plugin-storage.ts diff --git a/src/__tests__/main/plugin-activation.test.ts b/src/__tests__/main/plugin-activation.test.ts new file mode 100644 index 000000000..72b68b0f8 --- /dev/null +++ b/src/__tests__/main/plugin-activation.test.ts @@ -0,0 +1,433 @@ +/** + * Tests for Main-Process Plugin Activation, Storage, and IPC Bridge + * + * Covers: + * - activatePlugin() calls module's activate(api) + * - deactivatePlugin() calls deactivate() and cleans up + * - Plugin that throws during activation gets state 'error' + * - Plugin that throws during deactivation is logged but doesn't propagate + * - PluginStorage read/write/list/delete operations + * - PluginStorage path traversal prevention + * - IPC bridge routes messages to correct plugin + * - unregisterAll() removes all channels for a plugin + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'path'; +import type { LoadedPlugin } from '../../shared/plugin-types'; + +// Mock electron +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn() }, + app: { getPath: vi.fn(() => '/mock/userData'), getVersion: vi.fn(() => '1.0.0') }, + Notification: vi.fn().mockImplementation(() => ({ + show: vi.fn(), + })), +})); + +// Mock logger +vi.mock('../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock sentry +const mockCaptureException = vi.fn(); +vi.mock('../../main/utils/sentry', () => ({ + captureException: (...args: unknown[]) => mockCaptureException(...args), +})); + +// Mock fs/promises +const mockReadFile = vi.fn(); +const mockWriteFile = vi.fn(); +const mockReaddir = vi.fn(); +const mockMkdir = vi.fn(); +const mockUnlink = vi.fn(); +const mockAccess = vi.fn(); + +vi.mock('fs/promises', () => ({ + default: { + readFile: (...args: unknown[]) => mockReadFile(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), + readdir: (...args: unknown[]) => mockReaddir(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), + unlink: (...args: unknown[]) => mockUnlink(...args), + access: (...args: unknown[]) => mockAccess(...args), + }, + readFile: (...args: unknown[]) => mockReadFile(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), + readdir: (...args: unknown[]) => mockReaddir(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), + unlink: (...args: unknown[]) => mockUnlink(...args), + access: (...args: unknown[]) => mockAccess(...args), +})); + +// Mock stats/singleton +vi.mock('../../main/stats/singleton', () => ({ + getStatsDB: vi.fn(() => ({ + getAggregation: vi.fn().mockResolvedValue({ totalQueries: 42 }), + })), +})); + +// Track require calls for plugin modules +const mockPluginModules: Record = {}; +vi.mock('../../main/plugin-storage', async () => { + const actual = await vi.importActual('../../main/plugin-storage'); + return actual; +}); + +import { PluginHost, type PluginHostDependencies } from '../../main/plugin-host'; +import { PluginStorage } from '../../main/plugin-storage'; +import { PluginIpcBridge } from '../../main/plugin-ipc-bridge'; +import { logger } from '../../main/utils/logger'; + +/** + * Helper to create a LoadedPlugin for testing. + */ +function makePlugin(overrides: Partial & { permissions?: string[] } = {}): LoadedPlugin { + const { permissions, ...rest } = overrides; + return { + manifest: { + id: 'test-plugin', + name: 'Test Plugin', + version: '1.0.0', + description: 'A test plugin', + author: 'Test Author', + main: 'index.js', + permissions: (permissions ?? ['storage']) as any, + }, + state: 'discovered', + path: '/mock/plugins/test-plugin', + ...rest, + }; +} + +/** + * Helper to create mock dependencies. + */ +function makeDeps(overrides: Partial = {}): PluginHostDependencies { + const mockProcessManager = { + getAll: vi.fn(() => []), + kill: vi.fn(() => true), + write: vi.fn(() => true), + on: vi.fn(() => mockProcessManager), + removeListener: vi.fn(() => mockProcessManager), + }; + + const storeData: Record = {}; + const mockSettingsStore = { + get: vi.fn((key: string) => storeData[key]), + set: vi.fn((key: string, value: unknown) => { + storeData[key] = value; + }), + store: storeData, + }; + + const mockApp = { + getPath: vi.fn(() => '/mock/userData'), + getVersion: vi.fn(() => '2.0.0'), + }; + + return { + getProcessManager: () => mockProcessManager as any, + getMainWindow: () => null, + settingsStore: mockSettingsStore as any, + app: mockApp as any, + ...overrides, + }; +} + +// ============================================================================ +// Plugin Activation Tests +// ============================================================================ + +describe('PluginHost activation', () => { + let host: PluginHost; + let deps: PluginHostDependencies; + + beforeEach(() => { + vi.clearAllMocks(); + // Clear the require cache for mock plugin modules + for (const key of Object.keys(mockPluginModules)) { + delete mockPluginModules[key]; + } + deps = makeDeps(); + host = new PluginHost(deps); + }); + + it('sets state to error when entry point does not exist', async () => { + const plugin = makePlugin(); + mockAccess.mockRejectedValueOnce(new Error('ENOENT')); + + await host.activatePlugin(plugin); + + expect(plugin.state).toBe('error'); + expect(plugin.error).toContain('Plugin entry point not found'); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(Error), + { pluginId: 'test-plugin' } + ); + }); + + it('sets state to error and reports to Sentry when require() fails', async () => { + const plugin = makePlugin(); + // fs.access passes, but require() will fail since the file doesn't actually exist + mockAccess.mockResolvedValueOnce(undefined); + + await host.activatePlugin(plugin); + + expect(plugin.state).toBe('error'); + expect(plugin.error).toBeDefined(); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(Error), + { pluginId: 'test-plugin' } + ); + }); + + it('deactivatePlugin calls deactivate() and cleans up', async () => { + const plugin = makePlugin({ permissions: ['process:read'] }); + + // Create context manually (simulating a previously activated plugin) + host.createPluginContext(plugin); + expect(host.getPluginContext('test-plugin')).toBeDefined(); + + // Deactivate should clean up context + await host.deactivatePlugin('test-plugin'); + expect(host.getPluginContext('test-plugin')).toBeUndefined(); + }); + + it('deactivation errors are logged but do not propagate', async () => { + const plugin = makePlugin(); + + // Create a context + host.createPluginContext(plugin); + + // Deactivate a plugin that was never actually module-loaded (no module to call deactivate on) + // This should complete without throwing + await expect(host.deactivatePlugin('test-plugin')).resolves.not.toThrow(); + expect(host.getPluginContext('test-plugin')).toBeUndefined(); + }); +}); + +// ============================================================================ +// Plugin Storage Tests +// ============================================================================ + +describe('PluginStorage', () => { + let storage: PluginStorage; + + beforeEach(() => { + vi.clearAllMocks(); + storage = new PluginStorage('test-plugin', '/mock/userData/plugins/test-plugin/data'); + }); + + describe('read', () => { + it('returns file contents on success', async () => { + mockReadFile.mockResolvedValueOnce('{"key": "value"}'); + + const result = await storage.read('config.json'); + expect(result).toBe('{"key": "value"}'); + expect(mockReadFile).toHaveBeenCalledWith( + path.join('/mock/userData/plugins/test-plugin/data', 'config.json'), + 'utf-8' + ); + }); + + it('returns null for non-existent files', async () => { + mockReadFile.mockRejectedValueOnce(new Error('ENOENT')); + + const result = await storage.read('missing.json'); + expect(result).toBeNull(); + }); + }); + + describe('write', () => { + it('creates directory and writes file', async () => { + mockMkdir.mockResolvedValueOnce(undefined); + mockWriteFile.mockResolvedValueOnce(undefined); + + await storage.write('config.json', '{"key": "value"}'); + + expect(mockMkdir).toHaveBeenCalledWith( + '/mock/userData/plugins/test-plugin/data', + { recursive: true } + ); + expect(mockWriteFile).toHaveBeenCalledWith( + path.join('/mock/userData/plugins/test-plugin/data', 'config.json'), + '{"key": "value"}', + 'utf-8' + ); + }); + }); + + describe('list', () => { + it('returns files in directory', async () => { + mockReaddir.mockResolvedValueOnce(['config.json', 'data.db']); + + const files = await storage.list(); + expect(files).toEqual(['config.json', 'data.db']); + }); + + it('returns empty array when directory does not exist', async () => { + mockReaddir.mockRejectedValueOnce(new Error('ENOENT')); + + const files = await storage.list(); + expect(files).toEqual([]); + }); + }); + + describe('delete', () => { + it('deletes a file', async () => { + mockUnlink.mockResolvedValueOnce(undefined); + + await storage.delete('config.json'); + expect(mockUnlink).toHaveBeenCalledWith( + path.join('/mock/userData/plugins/test-plugin/data', 'config.json') + ); + }); + + it('silently ignores missing files', async () => { + mockUnlink.mockRejectedValueOnce(new Error('ENOENT')); + + await expect(storage.delete('missing.json')).resolves.not.toThrow(); + }); + }); + + describe('path traversal prevention', () => { + it('rejects filenames with ..', async () => { + await expect(storage.read('../../../etc/passwd')).rejects.toThrow( + 'Path traversal is not allowed' + ); + }); + + it('rejects absolute paths', async () => { + await expect(storage.read('/etc/passwd')).rejects.toThrow( + 'Absolute paths are not allowed' + ); + }); + + it('rejects filenames with null bytes', async () => { + await expect(storage.read('file\0.txt')).rejects.toThrow( + 'Filename contains null bytes' + ); + }); + + it('rejects filenames with forward slashes', async () => { + await expect(storage.read('sub/file.txt')).rejects.toThrow( + 'Path separators are not allowed' + ); + }); + + it('rejects filenames with backslashes', async () => { + await expect(storage.read('sub\\file.txt')).rejects.toThrow( + 'Path separators are not allowed' + ); + }); + + it('applies validation to write operations', async () => { + await expect(storage.write('../escape.txt', 'data')).rejects.toThrow( + 'Path traversal is not allowed' + ); + }); + + it('applies validation to delete operations', async () => { + await expect(storage.delete('/etc/shadow')).rejects.toThrow( + 'Absolute paths are not allowed' + ); + }); + }); +}); + +// ============================================================================ +// Plugin IPC Bridge Tests +// ============================================================================ + +describe('PluginIpcBridge', () => { + let bridge: PluginIpcBridge; + + beforeEach(() => { + bridge = new PluginIpcBridge(); + }); + + it('register and invoke routes to correct handler', async () => { + const handler = vi.fn().mockReturnValue('result'); + + bridge.register('my-plugin', 'getData', handler); + const result = await bridge.invoke('my-plugin', 'getData', 'arg1', 'arg2'); + + expect(handler).toHaveBeenCalledWith('arg1', 'arg2'); + expect(result).toBe('result'); + }); + + it('invoke throws when no handler registered', async () => { + await expect(bridge.invoke('unknown', 'channel')).rejects.toThrow( + "No handler registered for channel 'plugin:unknown:channel'" + ); + }); + + it('send fires handler without waiting for result', () => { + const handler = vi.fn(); + bridge.register('my-plugin', 'notify', handler); + + bridge.send('my-plugin', 'notify', 'event-data'); + expect(handler).toHaveBeenCalledWith('event-data'); + }); + + it('send silently ignores missing handlers', () => { + expect(() => bridge.send('unknown', 'channel', 'data')).not.toThrow(); + }); + + it('send logs errors from handler without propagating', () => { + const handler = vi.fn().mockImplementation(() => { + throw new Error('handler boom'); + }); + + bridge.register('my-plugin', 'bad', handler); + expect(() => bridge.send('my-plugin', 'bad')).not.toThrow(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('handler boom'), + expect.any(String) + ); + }); + + it('register returns unsubscribe function', async () => { + const handler = vi.fn().mockReturnValue('ok'); + const unsub = bridge.register('my-plugin', 'temp', handler); + + // Handler should be reachable + expect(bridge.hasHandler('my-plugin', 'temp')).toBe(true); + + // Unsubscribe + unsub(); + expect(bridge.hasHandler('my-plugin', 'temp')).toBe(false); + + // Should throw now + await expect(bridge.invoke('my-plugin', 'temp')).rejects.toThrow(); + }); + + it('unregisterAll removes all channels for a specific plugin', () => { + bridge.register('plugin-a', 'ch1', vi.fn()); + bridge.register('plugin-a', 'ch2', vi.fn()); + bridge.register('plugin-b', 'ch1', vi.fn()); + + bridge.unregisterAll('plugin-a'); + + expect(bridge.hasHandler('plugin-a', 'ch1')).toBe(false); + expect(bridge.hasHandler('plugin-a', 'ch2')).toBe(false); + expect(bridge.hasHandler('plugin-b', 'ch1')).toBe(true); + }); + + it('does not affect other plugins when unregistering', () => { + bridge.register('plugin-a', 'shared-name', vi.fn()); + bridge.register('plugin-b', 'shared-name', vi.fn()); + + bridge.unregisterAll('plugin-a'); + + expect(bridge.hasHandler('plugin-a', 'shared-name')).toBe(false); + expect(bridge.hasHandler('plugin-b', 'shared-name')).toBe(true); + }); +}); diff --git a/src/main/ipc/handlers/plugins.ts b/src/main/ipc/handlers/plugins.ts index 0b592c6fd..19e86f614 100644 --- a/src/main/ipc/handlers/plugins.ts +++ b/src/main/ipc/handlers/plugins.ts @@ -8,6 +8,7 @@ import { ipcMain, App } from 'electron'; import { logger } from '../../utils/logger'; import { createIpcHandler, type CreateHandlerOptions } from '../../utils/ipcHandler'; import { getPluginManager, createPluginManager } from '../../plugin-manager'; +import type { PluginIpcBridge } from '../../plugin-ipc-bridge'; const LOG_CONTEXT = '[Plugins]'; @@ -17,6 +18,7 @@ const LOG_CONTEXT = '[Plugins]'; export interface PluginHandlerDependencies { app: App; + ipcBridge?: PluginIpcBridge; } /** @@ -47,7 +49,7 @@ function requirePluginManager() { * Register all Plugin-related IPC handlers. */ export function registerPluginHandlers(deps: PluginHandlerDependencies): void { - const { app } = deps; + const { app, ipcBridge } = deps; // Ensure PluginManager is created (initialization happens in main startup) let manager = getPluginManager(); @@ -116,5 +118,31 @@ export function registerPluginHandlers(deps: PluginHandlerDependencies): void { }) ); + // ------------------------------------------------------------------------- + // plugins:bridge:invoke — invoke a handler registered by a main-process plugin + // ------------------------------------------------------------------------- + ipcMain.handle( + 'plugins:bridge:invoke', + createIpcHandler(handlerOpts('bridge:invoke', false), async (pluginId: string, channel: string, ...args: unknown[]) => { + if (!ipcBridge) { + throw new Error('Plugin IPC bridge not initialized'); + } + return ipcBridge.invoke(pluginId, channel, ...args); + }) + ); + + // ------------------------------------------------------------------------- + // plugins:bridge:send — fire-and-forget message to a main-process plugin + // ------------------------------------------------------------------------- + ipcMain.handle( + 'plugins:bridge:send', + createIpcHandler(handlerOpts('bridge:send', false), async (pluginId: string, channel: string, ...args: unknown[]) => { + if (!ipcBridge) { + throw new Error('Plugin IPC bridge not initialized'); + } + ipcBridge.send(pluginId, channel, ...args); + }) + ); + logger.debug(`${LOG_CONTEXT} Plugin IPC handlers registered`); } diff --git a/src/main/plugin-host.ts b/src/main/plugin-host.ts index bcd95e603..e1cc4a5b7 100644 --- a/src/main/plugin-host.ts +++ b/src/main/plugin-host.ts @@ -10,6 +10,7 @@ import path from 'path'; import fs from 'fs/promises'; import { Notification, type App, type BrowserWindow } from 'electron'; import { logger } from './utils/logger'; +import { captureException } from './utils/sentry'; import type { ProcessManager } from './process-manager'; import type Store from 'electron-store'; import type { MaestroSettings } from './stores/types'; @@ -17,6 +18,7 @@ import type { LoadedPlugin, PluginAPI, PluginContext, + PluginModule, PluginProcessAPI, PluginProcessControlAPI, PluginStatsAPI, @@ -24,9 +26,12 @@ import type { PluginStorageAPI, PluginNotificationsAPI, PluginMaestroAPI, + PluginIpcBridgeAPI, } from '../shared/plugin-types'; import type { StatsAggregation } from '../shared/stats-types'; import { getStatsDB } from './stats/singleton'; +import { PluginStorage } from './plugin-storage'; +import type { PluginIpcBridge } from './plugin-ipc-bridge'; const LOG_CONTEXT = '[Plugins]'; @@ -39,6 +44,7 @@ export interface PluginHostDependencies { getMainWindow: () => BrowserWindow | null; settingsStore: Store; app: App; + ipcBridge?: PluginIpcBridge; } // ============================================================================ @@ -48,11 +54,80 @@ export interface PluginHostDependencies { export class PluginHost { private deps: PluginHostDependencies; private pluginContexts: Map = new Map(); + /** + * Stores loaded plugin module references for deactivation. + * TRUST BOUNDARY: Plugin modules run in the same Node.js process as Maestro. + * For v1, this is acceptable because we only ship trusted/first-party plugins. + * Third-party sandboxing (e.g., vm2, worker threads) is a v2 concern. + */ + private pluginModules: Map = new Map(); + private pluginStorages: Map = new Map(); constructor(deps: PluginHostDependencies) { this.deps = deps; } + /** + * Activates a plugin by loading its main entry point and calling activate(). + * The plugin receives a scoped PluginAPI based on its declared permissions. + */ + async activatePlugin(plugin: LoadedPlugin): Promise { + const pluginId = plugin.manifest.id; + + try { + const entryPoint = path.join(plugin.path, plugin.manifest.main); + + // Verify the entry point exists + try { + await fs.access(entryPoint); + } catch { + throw new Error(`Plugin entry point not found: ${plugin.manifest.main}`); + } + + // Load the module using require() — plugins are Node.js modules for v1 simplicity + // eslint-disable-next-line @typescript-eslint/no-var-requires + const pluginModule: PluginModule = require(entryPoint); + + // Create context and activate + const context = this.createPluginContext(plugin); + + if (typeof pluginModule.activate === 'function') { + await pluginModule.activate(context.api); + } + + this.pluginModules.set(pluginId, pluginModule); + plugin.state = 'active'; + logger.info(`Plugin '${pluginId}' activated`, LOG_CONTEXT); + } catch (err) { + plugin.state = 'error'; + plugin.error = err instanceof Error ? err.message : String(err); + logger.error(`Plugin '${pluginId}' failed to activate: ${plugin.error}`, LOG_CONTEXT); + await captureException(err, { pluginId }); + } + } + + /** + * Deactivates a plugin by calling its deactivate() function and cleaning up. + * Deactivation errors are logged but never propagated. + */ + async deactivatePlugin(pluginId: string): Promise { + try { + const pluginModule = this.pluginModules.get(pluginId); + if (pluginModule && typeof pluginModule.deactivate === 'function') { + await pluginModule.deactivate(); + } + } catch (err) { + logger.error( + `Plugin '${pluginId}' threw during deactivation: ${err instanceof Error ? err.message : String(err)}`, + LOG_CONTEXT + ); + } + + this.pluginModules.delete(pluginId); + this.pluginStorages.delete(pluginId); + this.destroyPluginContext(pluginId); + } + /** * Creates a scoped API based on the plugin's declared permissions. */ @@ -67,6 +142,7 @@ export class PluginHost { storage: this.createStorageAPI(plugin), notifications: this.createNotificationsAPI(plugin), maestro: this.createMaestroAPI(plugin), + ipcBridge: this.createIpcBridgeAPI(plugin), }; const context: PluginContext = { @@ -290,52 +366,35 @@ export class PluginHost { return undefined; } - const pluginsDir = path.join(this.deps.app.getPath('userData'), 'plugins'); - const storageDir = path.join(pluginsDir, plugin.manifest.id, 'data'); + const storageDir = path.join(this.deps.app.getPath('userData'), 'plugins', plugin.manifest.id, 'data'); + const storage = new PluginStorage(plugin.manifest.id, storageDir); + this.pluginStorages.set(plugin.manifest.id, storage); - const validateFilename = (filename: string): void => { - if (path.isAbsolute(filename)) { - throw new Error('Absolute paths are not allowed'); - } - if (filename.includes('..')) { - throw new Error('Path traversal is not allowed'); - } - const resolved = path.resolve(storageDir, filename); - if (!resolved.startsWith(storageDir)) { - throw new Error('Path traversal is not allowed'); - } + return { + read: (filename: string) => storage.read(filename), + write: (filename: string, data: string) => storage.write(filename, data), + list: () => storage.list(), + delete: (filename: string) => storage.delete(filename), }; + } - return { - read: async (filename: string) => { - validateFilename(filename); - try { - return await fs.readFile(path.join(storageDir, filename), 'utf-8'); - } catch { - return null; - } - }, + private createIpcBridgeAPI(plugin: LoadedPlugin): PluginIpcBridgeAPI | undefined { + const bridge = this.deps.ipcBridge; + if (!bridge) { + return undefined; + } - write: async (filename: string, data: string) => { - validateFilename(filename); - await fs.mkdir(storageDir, { recursive: true }); - await fs.writeFile(path.join(storageDir, filename), data, 'utf-8'); - }, + const pluginId = plugin.manifest.id; + const getMainWindow = this.deps.getMainWindow; - list: async () => { - try { - return await fs.readdir(storageDir); - } catch { - return []; - } + return { + onMessage: (channel: string, handler: (...args: unknown[]) => unknown) => { + return bridge.register(pluginId, channel, handler); }, - - delete: async (filename: string) => { - validateFilename(filename); - try { - await fs.unlink(path.join(storageDir, filename)); - } catch { - // Ignore if file doesn't exist + sendToRenderer: (channel: string, ...args: unknown[]) => { + const win = getMainWindow(); + if (win) { + win.webContents.send(`plugin:${pluginId}:${channel}`, ...args); } }, }; diff --git a/src/main/plugin-ipc-bridge.ts b/src/main/plugin-ipc-bridge.ts new file mode 100644 index 000000000..0965f82ad --- /dev/null +++ b/src/main/plugin-ipc-bridge.ts @@ -0,0 +1,103 @@ +/** + * Plugin IPC Bridge + * + * Enables split-architecture plugins where a renderer component sends data + * to its main-process component via IPC. This is what makes patterns like + * Notifications work: renderer subscribes to Zustand batch state, forwards + * events via bridge, and the main-process component dispatches webhooks. + */ + +import { logger } from './utils/logger'; + +const LOG_CONTEXT = '[Plugins]'; + +/** + * Routes IPC messages between renderer and main-process plugin components. + * Channels are namespaced as `plugin::`. + */ +export class PluginIpcBridge { + /** Handlers keyed by `plugin::` */ + private handlers: Map unknown> = new Map(); + + /** + * Builds the internal channel key. + */ + private channelKey(pluginId: string, channel: string): string { + return `plugin:${pluginId}:${channel}`; + } + + /** + * Registers a handler for a specific plugin channel. + * Returns an unsubscribe function. + */ + register(pluginId: string, channel: string, handler: (...args: unknown[]) => unknown): () => void { + const key = this.channelKey(pluginId, channel); + this.handlers.set(key, handler); + logger.debug(`IPC bridge handler registered: ${key}`, LOG_CONTEXT); + + return () => { + this.handlers.delete(key); + }; + } + + /** + * Invokes a registered handler and returns its result. + * Throws if no handler is registered for the channel. + */ + async invoke(pluginId: string, channel: string, ...args: unknown[]): Promise { + const key = this.channelKey(pluginId, channel); + const handler = this.handlers.get(key); + if (!handler) { + throw new Error(`No handler registered for channel '${key}'`); + } + return handler(...args); + } + + /** + * Sends a one-way message to a registered handler (fire-and-forget). + * Silently ignores if no handler is registered. + */ + send(pluginId: string, channel: string, ...args: unknown[]): void { + const key = this.channelKey(pluginId, channel); + const handler = this.handlers.get(key); + if (handler) { + try { + handler(...args); + } catch (err) { + logger.error( + `IPC bridge send error on '${key}': ${err instanceof Error ? err.message : String(err)}`, + LOG_CONTEXT + ); + } + } + } + + /** + * Removes all handlers for a given plugin. + */ + unregisterAll(pluginId: string): void { + const prefix = `plugin:${pluginId}:`; + const keysToDelete: string[] = []; + + for (const key of this.handlers.keys()) { + if (key.startsWith(prefix)) { + keysToDelete.push(key); + } + } + + for (const key of keysToDelete) { + this.handlers.delete(key); + } + + if (keysToDelete.length > 0) { + logger.debug(`IPC bridge: removed ${keysToDelete.length} handler(s) for plugin '${pluginId}'`, LOG_CONTEXT); + } + } + + /** + * Returns whether a handler is registered for a channel. + */ + hasHandler(pluginId: string, channel: string): boolean { + return this.handlers.has(this.channelKey(pluginId, channel)); + } +} diff --git a/src/main/plugin-loader.ts b/src/main/plugin-loader.ts index ce7f97d24..91cf7faa3 100644 --- a/src/main/plugin-loader.ts +++ b/src/main/plugin-loader.ts @@ -74,7 +74,7 @@ export function validateManifest(manifest: unknown): manifest is PluginManifest const knownFields = new Set([ 'id', 'name', 'version', 'description', 'author', 'authorLink', 'minMaestroVersion', 'main', 'renderer', 'permissions', 'ui', - 'settings', 'tags', + 'settings', 'tags', 'firstParty', ]); for (const key of Object.keys(obj)) { if (!knownFields.has(key)) { diff --git a/src/main/plugin-manager.ts b/src/main/plugin-manager.ts index 37609ab16..9dcc95b2f 100644 --- a/src/main/plugin-manager.ts +++ b/src/main/plugin-manager.ts @@ -6,10 +6,12 @@ */ import type { App } from 'electron'; +import type Store from 'electron-store'; import { logger } from './utils/logger'; import { getPluginsDir, discoverPlugins } from './plugin-loader'; import type { LoadedPlugin } from '../shared/plugin-types'; import type { PluginHost } from './plugin-host'; +import type { MaestroSettings } from './stores/types'; const LOG_CONTEXT = '[Plugins]'; @@ -20,6 +22,7 @@ export class PluginManager { private plugins: Map = new Map(); private pluginsDir: string; private host: PluginHost | null = null; + private settingsStore: Store | null = null; constructor(app: App) { this.pluginsDir = getPluginsDir(app); @@ -32,8 +35,16 @@ export class PluginManager { this.host = host; } + /** + * Sets the settings store for tracking user-explicit disables. + */ + setSettingsStore(store: Store): void { + this.settingsStore = store; + } + /** * Discover and load all plugins from the plugins directory. + * First-party plugins are auto-enabled unless explicitly disabled by user. */ async initialize(): Promise { const discovered = await discoverPlugins(this.pluginsDir); @@ -49,6 +60,31 @@ export class PluginManager { `Plugin system initialized: ${okCount} valid, ${errorCount} with errors`, LOG_CONTEXT ); + + // Auto-enable first-party plugins that haven't been explicitly disabled + for (const plugin of discovered) { + if (plugin.state !== 'discovered') continue; + if (!this.isFirstParty(plugin)) continue; + if (this.isUserDisabled(plugin.manifest.id)) continue; + + logger.info(`Auto-enabling first-party plugin '${plugin.manifest.id}'`, LOG_CONTEXT); + await this.enablePlugin(plugin.manifest.id); + } + } + + /** + * Checks if a plugin is first-party (auto-enable candidate). + */ + private isFirstParty(plugin: LoadedPlugin): boolean { + return plugin.manifest.firstParty === true || plugin.manifest.author === 'Maestro Core'; + } + + /** + * Checks if a user has explicitly disabled a plugin. + */ + private isUserDisabled(pluginId: string): boolean { + if (!this.settingsStore) return false; + return this.settingsStore.get(`plugin:${pluginId}:userDisabled` as any) === true; } /** @@ -73,8 +109,8 @@ export class PluginManager { } /** - * Transitions a plugin from 'discovered' to 'active'. - * Actual activation logic will be added in Phase 03. + * Transitions a plugin from 'discovered' or 'disabled' to 'active'. + * Calls PluginHost.activatePlugin() which loads and runs the module's activate(). */ async enablePlugin(id: string): Promise { const plugin = this.plugins.get(id); @@ -92,16 +128,19 @@ export class PluginManager { } if (this.host) { - this.host.createPluginContext(plugin); + await this.host.activatePlugin(plugin); + // activatePlugin sets state to 'active' or 'error' + } else { + plugin.state = 'active'; } - plugin.state = 'active'; - logger.info(`Plugin '${id}' enabled`, LOG_CONTEXT); - return true; + logger.info(`Plugin '${id}' enabled (state: ${plugin.state})`, LOG_CONTEXT); + return plugin.state === 'active'; } /** * Transitions a plugin from 'active' to 'disabled'. + * Calls PluginHost.deactivatePlugin() which runs deactivate() and cleans up. */ async disablePlugin(id: string): Promise { const plugin = this.plugins.get(id); @@ -119,10 +158,16 @@ export class PluginManager { } if (this.host) { - this.host.destroyPluginContext(id); + await this.host.deactivatePlugin(id); } plugin.state = 'disabled'; + + // Track user-explicit disable + if (this.settingsStore) { + this.settingsStore.set(`plugin:${id}:userDisabled` as any, true as any); + } + logger.info(`Plugin '${id}' disabled`, LOG_CONTEXT); return true; } diff --git a/src/main/plugin-storage.ts b/src/main/plugin-storage.ts new file mode 100644 index 000000000..e241a4a20 --- /dev/null +++ b/src/main/plugin-storage.ts @@ -0,0 +1,114 @@ +/** + * Plugin-Scoped Storage + * + * Provides file-based storage scoped to each plugin. + * Files are stored under `userData/plugins//data/`. + * All filenames are validated to prevent path traversal attacks. + */ + +import path from 'path'; +import fs from 'fs/promises'; +import { logger } from './utils/logger'; + +const LOG_CONTEXT = '[Plugins]'; + +/** + * Validates a filename to prevent path traversal. + * Rejects filenames containing '..', '/', '\', null bytes, or absolute paths. + */ +function validateFilename(filename: string, baseDir: string): void { + if (!filename || typeof filename !== 'string') { + throw new Error('Filename must be a non-empty string'); + } + + if (filename.includes('\0')) { + throw new Error('Filename contains null bytes'); + } + + if (path.isAbsolute(filename)) { + throw new Error('Absolute paths are not allowed'); + } + + if (filename.includes('..')) { + throw new Error('Path traversal is not allowed'); + } + + if (filename.includes('/') || filename.includes('\\')) { + throw new Error('Path separators are not allowed in filenames'); + } + + const resolved = path.resolve(baseDir, filename); + if (!resolved.startsWith(baseDir)) { + throw new Error('Path traversal is not allowed'); + } +} + +/** + * Plugin-scoped file storage. + * Each plugin gets its own isolated storage directory. + */ +export class PluginStorage { + private pluginId: string; + private baseDir: string; + + constructor(pluginId: string, baseDir: string) { + this.pluginId = pluginId; + this.baseDir = baseDir; + } + + /** + * Reads a file from the plugin's storage directory. + * Returns null if the file does not exist. + */ + async read(filename: string): Promise { + validateFilename(filename, this.baseDir); + try { + return await fs.readFile(path.join(this.baseDir, filename), 'utf-8'); + } catch { + return null; + } + } + + /** + * Writes data to a file in the plugin's storage directory. + * Creates the directory on first write (lazy creation). + */ + async write(filename: string, data: string): Promise { + validateFilename(filename, this.baseDir); + await fs.mkdir(this.baseDir, { recursive: true }); + await fs.writeFile(path.join(this.baseDir, filename), data, 'utf-8'); + logger.debug(`[Plugin:${this.pluginId}] wrote file '${filename}'`, LOG_CONTEXT); + } + + /** + * Lists all files in the plugin's storage directory. + * Returns an empty array if the directory doesn't exist. + */ + async list(): Promise { + try { + return await fs.readdir(this.baseDir); + } catch { + return []; + } + } + + /** + * Deletes a file from the plugin's storage directory. + * No-op if the file doesn't exist. + */ + async delete(filename: string): Promise { + validateFilename(filename, this.baseDir); + try { + await fs.unlink(path.join(this.baseDir, filename)); + } catch { + // Ignore if file doesn't exist + } + } + + /** + * Returns the base directory for this plugin's storage. + */ + getBaseDir(): string { + return this.baseDir; + } +} diff --git a/src/main/preload/plugins.ts b/src/main/preload/plugins.ts index 0746267e4..489aaa430 100644 --- a/src/main/preload/plugins.ts +++ b/src/main/preload/plugins.ts @@ -10,12 +10,18 @@ import { ipcRenderer } from 'electron'; +export interface PluginBridgeApi { + invoke: (pluginId: string, channel: string, ...args: unknown[]) => Promise; + send: (pluginId: string, channel: string, ...args: unknown[]) => void; +} + export interface PluginsApi { getAll: () => Promise; enable: (id: string) => Promise; disable: (id: string) => Promise; getDir: () => Promise; refresh: () => Promise; + bridge: PluginBridgeApi; } /** @@ -32,5 +38,13 @@ export function createPluginsApi(): PluginsApi { getDir: () => ipcRenderer.invoke('plugins:getDir'), refresh: () => ipcRenderer.invoke('plugins:refresh'), + + bridge: { + invoke: (pluginId: string, channel: string, ...args: unknown[]) => + ipcRenderer.invoke('plugins:bridge:invoke', pluginId, channel, ...args), + send: (pluginId: string, channel: string, ...args: unknown[]) => { + ipcRenderer.invoke('plugins:bridge:send', pluginId, channel, ...args); + }, + }, }; } diff --git a/src/shared/plugin-types.ts b/src/shared/plugin-types.ts index 650e13d40..508f855fc 100644 --- a/src/shared/plugin-types.ts +++ b/src/shared/plugin-types.ts @@ -100,6 +100,8 @@ export interface PluginManifest { settings?: PluginSettingDefinition[]; /** Searchable keyword tags */ tags?: string[]; + /** Whether this is a first-party Maestro plugin (auto-enabled on discovery) */ + firstParty?: boolean; } // ============================================================================ @@ -210,6 +212,17 @@ export interface PluginNotificationsAPI { playSound(sound: string): Promise; } +/** + * IPC bridge API for split-architecture plugins. + * Allows main-process plugin components to communicate with renderer components. + */ +export interface PluginIpcBridgeAPI { + /** Register a handler for messages from the renderer component */ + onMessage(channel: string, handler: (...args: unknown[]) => unknown): () => void; + /** Send a message to the renderer component */ + sendToRenderer(channel: string, ...args: unknown[]): void; +} + /** * Always-available Maestro metadata API. No permission required. */ @@ -233,6 +246,17 @@ export interface PluginAPI { storage?: PluginStorageAPI; notifications?: PluginNotificationsAPI; maestro: PluginMaestroAPI; + ipcBridge?: PluginIpcBridgeAPI; +} + +/** + * Interface that plugin modules must conform to. + * The activate() function is called when the plugin is enabled. + * The deactivate() function is called when the plugin is disabled. + */ +export interface PluginModule { + activate(api: PluginAPI): void | Promise; + deactivate?(): void | Promise; } /** From fbe03d144e36fdc7e3f701290d61d105e095826a Mon Sep 17 00:00:00 2001 From: Adam Musciano Date: Wed, 18 Feb 2026 22:00:21 -0500 Subject: [PATCH 11/19] MAESTRO: Implement plugin UI registration system (Phase 05) - Create usePluginRegistry hook for renderer-side plugin state management - Create PluginManager modal with enable/disable, permissions badges, refresh - Create PluginTabContent component with sandboxed iframe rendering - Extend RightPanel with dynamic plugin tab support via pluginTabs prop - Wire Plugin Manager into App.tsx and SettingsModal with modalStore integration - Add PLUGIN_MANAGER priority (455), pluginManager ModalId to modal system - Fix RightPanelTab type to accept plugin tab IDs (string & {}) - Fix useAutoRunHandlers to use RightPanelTab type instead of narrow union - Fix plugin-host.ts StatsDB method name (getAggregatedStats) - Fix plugin IPC bridge handlers to return Record - Add 24 renderer tests (PluginManager, PluginTabContent, usePluginRegistry) Co-Authored-By: Claude Opus 4.6 --- .../components/PluginManager.test.tsx | 206 ++++++++++++++ .../components/PluginTabContent.test.tsx | 132 +++++++++ .../renderer/hooks/usePluginRegistry.test.ts | 162 +++++++++++ src/main/ipc/handlers/plugins.ts | 4 +- src/main/plugin-host.ts | 2 +- src/renderer/App.tsx | 28 +- src/renderer/components/PluginManager.tsx | 266 ++++++++++++++++++ src/renderer/components/PluginTabContent.tsx | 72 +++++ src/renderer/components/RightPanel.tsx | 41 +++ src/renderer/components/SettingsModal.tsx | 26 ++ src/renderer/constants/modalPriorities.ts | 3 + src/renderer/global.d.ts | 13 + .../hooks/batch/useAutoRunHandlers.ts | 4 +- src/renderer/hooks/usePluginRegistry.ts | 97 +++++++ src/renderer/stores/modalStore.ts | 12 +- src/renderer/types/index.ts | 2 +- 16 files changed, 1063 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/renderer/components/PluginManager.test.tsx create mode 100644 src/__tests__/renderer/components/PluginTabContent.test.tsx create mode 100644 src/__tests__/renderer/hooks/usePluginRegistry.test.ts create mode 100644 src/renderer/components/PluginManager.tsx create mode 100644 src/renderer/components/PluginTabContent.tsx create mode 100644 src/renderer/hooks/usePluginRegistry.ts diff --git a/src/__tests__/renderer/components/PluginManager.test.tsx b/src/__tests__/renderer/components/PluginManager.test.tsx new file mode 100644 index 000000000..39d2a38d0 --- /dev/null +++ b/src/__tests__/renderer/components/PluginManager.test.tsx @@ -0,0 +1,206 @@ +/** + * Tests for PluginManager modal component + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { PluginManager } from '../../../renderer/components/PluginManager'; +import type { Theme } from '../../../renderer/types'; +import type { LoadedPlugin } from '../../../shared/plugin-types'; + +// Mock the Modal component to simplify testing +vi.mock('../../../renderer/components/ui/Modal', () => ({ + Modal: ({ + children, + title, + onClose, + }: { + children: React.ReactNode; + title: string; + onClose: () => void; + }) => ( +
+ + {children} +
+ ), +})); + +const mockTheme: Theme = { + id: 'dark', + name: 'Dark', + mode: 'dark', + colors: { + bgMain: '#1a1a2e', + bgSidebar: '#16213e', + bgActivity: '#0f3460', + border: '#333', + textMain: '#e4e4e7', + textDim: '#a1a1aa', + accent: '#6366f1', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, +}; + +const mockPlugins: LoadedPlugin[] = [ + { + manifest: { + id: 'active-plugin', + name: 'Active Plugin', + version: '1.0.0', + description: 'An active plugin', + author: 'Test Author', + main: 'index.js', + permissions: ['stats:read', 'process:write'], + }, + state: 'active', + path: '/plugins/active-plugin', + }, + { + manifest: { + id: 'disabled-plugin', + name: 'Disabled Plugin', + version: '0.5.0', + description: 'A disabled plugin', + author: 'Other Author', + main: 'index.js', + permissions: ['settings:read'], + }, + state: 'disabled', + path: '/plugins/disabled-plugin', + }, + { + manifest: { + id: 'error-plugin', + name: 'Error Plugin', + version: '0.1.0', + description: 'A broken plugin', + author: 'Bug Author', + main: 'index.js', + permissions: ['middleware'], + }, + state: 'error', + path: '/plugins/error-plugin', + error: 'Failed to load: missing dependency', + }, +]; + +describe('PluginManager', () => { + const defaultProps = { + theme: mockTheme, + plugins: mockPlugins, + loading: false, + onClose: vi.fn(), + onEnablePlugin: vi.fn().mockResolvedValue(undefined), + onDisablePlugin: vi.fn().mockResolvedValue(undefined), + onRefresh: vi.fn().mockResolvedValue(undefined), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders plugin list with names and versions', () => { + render(); + + expect(screen.getByText('Active Plugin')).toBeInTheDocument(); + expect(screen.getByText('v1.0.0')).toBeInTheDocument(); + expect(screen.getByText('Disabled Plugin')).toBeInTheDocument(); + expect(screen.getByText('Error Plugin')).toBeInTheDocument(); + }); + + it('shows plugin count', () => { + render(); + + expect(screen.getByText('3 plugins discovered')).toBeInTheDocument(); + }); + + it('shows loading state', () => { + render(); + + expect(screen.getByText('Loading plugins...')).toBeInTheDocument(); + }); + + it('shows empty state when no plugins', () => { + render(); + + expect(screen.getByText('No plugins installed')).toBeInTheDocument(); + }); + + it('shows error message for error-state plugins', () => { + render(); + + expect(screen.getByText('Failed to load: missing dependency')).toBeInTheDocument(); + }); + + it('shows permission badges', () => { + render(); + + expect(screen.getByText('stats:read')).toBeInTheDocument(); + expect(screen.getByText('process:write')).toBeInTheDocument(); + expect(screen.getByText('settings:read')).toBeInTheDocument(); + expect(screen.getByText('middleware')).toBeInTheDocument(); + }); + + it('shows author names', () => { + render(); + + expect(screen.getByText('by Test Author')).toBeInTheDocument(); + expect(screen.getByText('by Other Author')).toBeInTheDocument(); + }); + + it('calls onDisablePlugin when toggling active plugin', async () => { + render(); + + const toggleButtons = screen.getAllByTitle('Disable plugin'); + fireEvent.click(toggleButtons[0]); + + await waitFor(() => { + expect(defaultProps.onDisablePlugin).toHaveBeenCalledWith('active-plugin'); + }); + }); + + it('calls onEnablePlugin when toggling disabled plugin', async () => { + render(); + + const toggleButtons = screen.getAllByTitle('Enable plugin'); + fireEvent.click(toggleButtons[0]); + + await waitFor(() => { + expect(defaultProps.onEnablePlugin).toHaveBeenCalledWith('disabled-plugin'); + }); + }); + + it('calls onRefresh when Refresh button is clicked', async () => { + render(); + + const refreshButton = screen.getByText('Refresh'); + fireEvent.click(refreshButton); + + await waitFor(() => { + expect(defaultProps.onRefresh).toHaveBeenCalledOnce(); + }); + }); + + it('calls shell.showItemInFolder when Open Folder is clicked', async () => { + render(); + + const openFolderButton = screen.getByText('Open Folder'); + fireEvent.click(openFolderButton); + + await waitFor(() => { + expect(window.maestro.plugins.getDir).toHaveBeenCalled(); + expect(window.maestro.shell.showItemInFolder).toHaveBeenCalledWith('/tmp/plugins'); + }); + }); + + it('singular plugin text when only one plugin', () => { + render(); + + expect(screen.getByText('1 plugin discovered')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/PluginTabContent.test.tsx b/src/__tests__/renderer/components/PluginTabContent.test.tsx new file mode 100644 index 000000000..4c8ea8a3e --- /dev/null +++ b/src/__tests__/renderer/components/PluginTabContent.test.tsx @@ -0,0 +1,132 @@ +/** + * Tests for PluginTabContent component + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { PluginTabContent } from '../../../renderer/components/PluginTabContent'; +import type { Theme } from '../../../renderer/types'; +import type { LoadedPlugin } from '../../../shared/plugin-types'; + +const mockTheme: Theme = { + id: 'dark', + name: 'Dark', + mode: 'dark', + colors: { + bgMain: '#1a1a2e', + bgSidebar: '#16213e', + bgActivity: '#0f3460', + border: '#333', + textMain: '#e4e4e7', + textDim: '#a1a1aa', + accent: '#6366f1', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, +}; + +const mockPlugins: LoadedPlugin[] = [ + { + manifest: { + id: 'ui-plugin', + name: 'UI Plugin', + version: '1.0.0', + description: 'Plugin with UI', + author: 'Test', + main: 'index.js', + renderer: 'renderer.html', + permissions: [], + }, + state: 'active', + path: '/plugins/ui-plugin', + }, + { + manifest: { + id: 'no-ui-plugin', + name: 'No UI Plugin', + version: '1.0.0', + description: 'Plugin without UI', + author: 'Test', + main: 'index.js', + permissions: [], + }, + state: 'active', + path: '/plugins/no-ui-plugin', + }, +]; + +describe('PluginTabContent', () => { + it('renders iframe for plugin with renderer entry', () => { + const { container } = render( + + ); + + const iframe = container.querySelector('iframe'); + expect(iframe).toBeTruthy(); + expect(iframe?.getAttribute('src')).toBe('file:///plugins/ui-plugin/renderer.html'); + expect(iframe?.getAttribute('sandbox')).toBe('allow-scripts'); + expect(iframe?.getAttribute('title')).toContain('UI Plugin'); + }); + + it('shows "no UI" message for plugin without renderer', () => { + render( + + ); + + expect(screen.getByText('No UI Plugin')).toBeInTheDocument(); + expect(screen.getByText('This plugin has no UI')).toBeInTheDocument(); + }); + + it('shows "not found" message for unknown plugin', () => { + render( + + ); + + expect(screen.getByText('Plugin not found: unknown-plugin')).toBeInTheDocument(); + }); + + it('sets data attributes on wrapper', () => { + const { container } = render( + + ); + + const wrapper = container.querySelector('[data-plugin-id="ui-plugin"]'); + expect(wrapper).toBeTruthy(); + expect(wrapper?.getAttribute('data-tab-id')).toBe('dashboard'); + }); + + it('iframe does not have allow-same-origin in sandbox', () => { + const { container } = render( + + ); + + const iframe = container.querySelector('iframe'); + expect(iframe?.getAttribute('sandbox')).not.toContain('allow-same-origin'); + }); +}); diff --git a/src/__tests__/renderer/hooks/usePluginRegistry.test.ts b/src/__tests__/renderer/hooks/usePluginRegistry.test.ts new file mode 100644 index 000000000..b93bddcf2 --- /dev/null +++ b/src/__tests__/renderer/hooks/usePluginRegistry.test.ts @@ -0,0 +1,162 @@ +/** + * Tests for usePluginRegistry hook + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { usePluginRegistry } from '../../../renderer/hooks/usePluginRegistry'; +import type { LoadedPlugin } from '../../../shared/plugin-types'; + +const mockPlugins: LoadedPlugin[] = [ + { + manifest: { + id: 'test-plugin', + name: 'Test Plugin', + version: '1.0.0', + description: 'A test plugin', + author: 'Test', + main: 'index.js', + permissions: ['stats:read'], + ui: { + rightPanelTabs: [{ id: 'test-tab', label: 'Test Tab', icon: 'chart' }], + }, + }, + state: 'active', + path: '/plugins/test-plugin', + }, + { + manifest: { + id: 'disabled-plugin', + name: 'Disabled Plugin', + version: '0.1.0', + description: 'A disabled plugin', + author: 'Test', + main: 'index.js', + permissions: [], + }, + state: 'disabled', + path: '/plugins/disabled-plugin', + }, +]; + +describe('usePluginRegistry', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(window.maestro.plugins.getAll).mockResolvedValue(mockPlugins); + vi.mocked(window.maestro.plugins.enable).mockResolvedValue(undefined); + vi.mocked(window.maestro.plugins.disable).mockResolvedValue(undefined); + }); + + it('loads plugins on mount', async () => { + const { result } = renderHook(() => usePluginRegistry()); + + expect(result.current.loading).toBe(true); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.plugins).toEqual(mockPlugins); + expect(window.maestro.plugins.getAll).toHaveBeenCalledOnce(); + }); + + it('getActivePlugins filters to active plugins', async () => { + const { result } = renderHook(() => usePluginRegistry()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const active = result.current.getActivePlugins(); + expect(active).toHaveLength(1); + expect(active[0].manifest.id).toBe('test-plugin'); + }); + + it('getPluginTabs collects tabs from active plugins', async () => { + const { result } = renderHook(() => usePluginRegistry()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const tabs = result.current.getPluginTabs(); + expect(tabs).toHaveLength(1); + expect(tabs[0]).toEqual({ + pluginId: 'test-plugin', + tabId: 'test-tab', + label: 'Test Tab', + icon: 'chart', + }); + }); + + it('enablePlugin calls IPC and refreshes', async () => { + const { result } = renderHook(() => usePluginRegistry()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.enablePlugin('disabled-plugin'); + }); + + expect(window.maestro.plugins.enable).toHaveBeenCalledWith('disabled-plugin'); + // Should have called getAll twice: once on mount, once after enable + expect(window.maestro.plugins.getAll).toHaveBeenCalledTimes(2); + }); + + it('disablePlugin calls IPC and refreshes', async () => { + const { result } = renderHook(() => usePluginRegistry()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.disablePlugin('test-plugin'); + }); + + expect(window.maestro.plugins.disable).toHaveBeenCalledWith('test-plugin'); + expect(window.maestro.plugins.getAll).toHaveBeenCalledTimes(2); + }); + + it('refreshPlugins re-fetches from main process', async () => { + const { result } = renderHook(() => usePluginRegistry()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.refreshPlugins(); + }); + + expect(window.maestro.plugins.getAll).toHaveBeenCalledTimes(2); + }); + + it('returns empty tabs when no plugins have UI', async () => { + vi.mocked(window.maestro.plugins.getAll).mockResolvedValue([ + { + manifest: { + id: 'no-ui', + name: 'No UI Plugin', + version: '1.0.0', + description: 'No UI', + author: 'Test', + main: 'index.js', + permissions: [], + }, + state: 'active', + path: '/plugins/no-ui', + }, + ]); + + const { result } = renderHook(() => usePluginRegistry()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.getPluginTabs()).toHaveLength(0); + }); +}); diff --git a/src/main/ipc/handlers/plugins.ts b/src/main/ipc/handlers/plugins.ts index 19e86f614..76a07b25a 100644 --- a/src/main/ipc/handlers/plugins.ts +++ b/src/main/ipc/handlers/plugins.ts @@ -127,7 +127,8 @@ export function registerPluginHandlers(deps: PluginHandlerDependencies): void { if (!ipcBridge) { throw new Error('Plugin IPC bridge not initialized'); } - return ipcBridge.invoke(pluginId, channel, ...args); + const result = await ipcBridge.invoke(pluginId, channel, ...args); + return { result } as Record; }) ); @@ -141,6 +142,7 @@ export function registerPluginHandlers(deps: PluginHandlerDependencies): void { throw new Error('Plugin IPC bridge not initialized'); } ipcBridge.send(pluginId, channel, ...args); + return {} as Record; }) ); diff --git a/src/main/plugin-host.ts b/src/main/plugin-host.ts index e1cc4a5b7..fce196477 100644 --- a/src/main/plugin-host.ts +++ b/src/main/plugin-host.ts @@ -308,7 +308,7 @@ export class PluginHost { if (!db) { throw new Error('Stats database not available'); } - return db.getAggregation(range as any); + return db.getAggregatedStats(range as any); }, onStatsUpdate: (callback) => { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 873e54c9d..1c7608e50 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -61,6 +61,9 @@ const DocumentGraphView = lazy(() => const DirectorNotesModal = lazy(() => import('./components/DirectorNotes').then((m) => ({ default: m.DirectorNotesModal })) ); +const PluginManagerModal = lazy(() => + import('./components/PluginManager').then((m) => ({ default: m.PluginManager })) +); // Re-import the type for SymphonyContributionData (types don't need lazy loading) import type { SymphonyContributionData } from './components/SymphonyModal'; @@ -133,6 +136,7 @@ import { import type { TabCompletionSuggestion } from './hooks'; import { useMainPanelProps, useSessionListProps, useRightPanelProps } from './hooks/props'; import { useAgentListeners } from './hooks/agent/useAgentListeners'; +import { usePluginRegistry } from './hooks/usePluginRegistry'; // Import contexts import { useLayerStack } from './contexts/LayerStackContext'; @@ -380,8 +384,14 @@ function MaestroConsoleInner() { // Director's Notes Modal directorNotesOpen, setDirectorNotesOpen, + // Plugin Manager Modal + pluginManagerOpen, + setPluginManagerOpen, } = useModalActions(); + // --- PLUGIN REGISTRY --- + const pluginRegistry = usePluginRegistry(); + // --- MOBILE LANDSCAPE MODE (reading-only view) --- const isMobileLandscape = useMobileLandscape(); @@ -8248,6 +8258,21 @@ You are taking over this conversation. Based on the context above, provide a bri )} + {/* --- PLUGIN MANAGER MODAL (lazy-loaded) --- */} + {pluginManagerOpen && ( + + setPluginManagerOpen(false)} + onEnablePlugin={pluginRegistry.enablePlugin} + onDisablePlugin={pluginRegistry.disablePlugin} + onRefresh={pluginRegistry.refreshPlugins} + /> + + )} + {/* --- GIST PUBLISH MODAL --- */} {/* Supports both file preview tabs and tab context gist publishing */} {gistPublishModalOpen && (activeFileTab || tabGistContent) && ( @@ -8541,7 +8566,7 @@ You are taking over this conversation. Based on the context above, provide a bri {/* --- RIGHT PANEL (hidden in mobile landscape, when no sessions, group chat is active, or log viewer is open) --- */} {!isMobileLandscape && sessions.length > 0 && !activeGroupChatId && !logViewerOpen && ( - + )} @@ -8628,6 +8653,7 @@ You are taking over this conversation. Based on the context above, provide a bri hasNoAgents={hasNoAgents} onThemeImportError={(msg) => setFlashNotification(msg)} onThemeImportSuccess={(msg) => setFlashNotification(msg)} + onOpenPluginManager={() => setPluginManagerOpen(true)} /> )} diff --git a/src/renderer/components/PluginManager.tsx b/src/renderer/components/PluginManager.tsx new file mode 100644 index 000000000..665457b14 --- /dev/null +++ b/src/renderer/components/PluginManager.tsx @@ -0,0 +1,266 @@ +/** + * PluginManager - Modal for browsing, enabling, and configuring plugins. + * + * Shows all discovered plugins with their state, permissions, and toggle controls. + */ + +import { useState, useCallback } from 'react'; +import { + Puzzle, + RefreshCw, + FolderOpen, + ToggleLeft, + ToggleRight, + AlertCircle, + Loader2, +} from 'lucide-react'; +import type { Theme } from '../types'; +import type { LoadedPlugin, PluginPermission } from '../../shared/plugin-types'; +import { Modal } from './ui/Modal'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; + +interface PluginManagerProps { + theme: Theme; + plugins: LoadedPlugin[]; + loading: boolean; + onClose: () => void; + onEnablePlugin: (id: string) => Promise; + onDisablePlugin: (id: string) => Promise; + onRefresh: () => Promise; +} + +/** Returns a color for a permission badge based on its risk level */ +function getPermissionColor( + permission: PluginPermission, + theme: Theme +): { bg: string; text: string } { + if (permission === 'middleware') { + return { bg: `${theme.colors.error}20`, text: theme.colors.error }; + } + if (permission.endsWith(':write') || permission === 'process:write' || permission === 'settings:write') { + return { bg: `${theme.colors.warning}20`, text: theme.colors.warning }; + } + return { bg: `${theme.colors.success}20`, text: theme.colors.success }; +} + +export function PluginManager({ + theme, + plugins, + loading, + onClose, + onEnablePlugin, + onDisablePlugin, + onRefresh, +}: PluginManagerProps) { + const [togglingIds, setTogglingIds] = useState>(new Set()); + const [refreshing, setRefreshing] = useState(false); + + const handleToggle = useCallback( + async (plugin: LoadedPlugin) => { + const id = plugin.manifest.id; + setTogglingIds((prev) => new Set(prev).add(id)); + try { + if (plugin.state === 'active' || plugin.state === 'loaded') { + await onDisablePlugin(id); + } else { + await onEnablePlugin(id); + } + } finally { + setTogglingIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + } + }, + [onEnablePlugin, onDisablePlugin] + ); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + try { + await onRefresh(); + } finally { + setRefreshing(false); + } + }, [onRefresh]); + + const handleOpenFolder = useCallback(async () => { + try { + const dir = await window.maestro.plugins.getDir(); + await window.maestro.shell.showItemInFolder(dir); + } catch (err) { + console.error('Failed to open plugins folder:', err); + } + }, []); + + const isEnabled = (plugin: LoadedPlugin) => + plugin.state === 'active' || plugin.state === 'loaded'; + + return ( + } + > +
+ {/* Toolbar */} +
+ + {plugins.length} plugin{plugins.length !== 1 ? 's' : ''} discovered + +
+ + +
+
+ + {/* Plugin List */} + {loading ? ( +
+ + + Loading plugins... + +
+ ) : plugins.length === 0 ? ( +
+ +

No plugins installed

+

+ Place plugin folders in the plugins directory to get started. +

+
+ ) : ( +
+ {plugins.map((plugin) => { + const toggling = togglingIds.has(plugin.manifest.id); + const enabled = isEnabled(plugin); + + return ( +
+ {/* Header row */} +
+
+ + {plugin.manifest.name} + + + v{plugin.manifest.version} + +
+ + {/* Toggle */} + +
+ + {/* Author */} +
+ by {plugin.manifest.author} +
+ + {/* Description */} +
+ {plugin.manifest.description} +
+ + {/* Permissions */} + {plugin.manifest.permissions.length > 0 && ( +
+ {plugin.manifest.permissions.map((perm) => { + const colors = getPermissionColor(perm, theme); + return ( + + {perm} + + ); + })} +
+ )} + + {/* Error message */} + {plugin.state === 'error' && plugin.error && ( +
+ + {plugin.error} +
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/renderer/components/PluginTabContent.tsx b/src/renderer/components/PluginTabContent.tsx new file mode 100644 index 000000000..703eb907b --- /dev/null +++ b/src/renderer/components/PluginTabContent.tsx @@ -0,0 +1,72 @@ +/** + * PluginTabContent - Renders plugin UI within the Right Panel. + * + * Uses an iframe to load the plugin's renderer entry point, providing natural + * sandboxing for untrusted UI code. The iframe uses sandbox="allow-scripts" + * without allow-same-origin to prevent the plugin from accessing the parent + * frame's DOM or IPC bridge. + */ + +import type { Theme } from '../types'; +import type { LoadedPlugin } from '../../shared/plugin-types'; +import { Puzzle } from 'lucide-react'; + +interface PluginTabContentProps { + pluginId: string; + tabId: string; + theme: Theme; + plugins: LoadedPlugin[]; +} + +export function PluginTabContent({ pluginId, tabId, theme, plugins }: PluginTabContentProps) { + const plugin = plugins.find((p) => p.manifest.id === pluginId); + + if (!plugin) { + return ( +
+ + Plugin not found: {pluginId} +
+ ); + } + + const rendererEntry = plugin.manifest.renderer; + + if (!rendererEntry) { + return ( +
+ + + {plugin.manifest.name} + + This plugin has no UI +
+ ); + } + + const iframeSrc = `file://${plugin.path}/${rendererEntry}`; + + return ( +
+ {/* iframe provides natural sandboxing for untrusted plugin UI code. + sandbox="allow-scripts" lets JS run but without allow-same-origin + the plugin cannot access the parent frame's DOM or IPC bridge. */} +