diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eaf5616 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,159 @@ +# CLAUDE.md - Harbor Project Reference + +## Project Overview + +Harbor is a Firefox browser extension with a native Node.js bridge that brings AI models and MCP (Model Context Protocol) tools to web applications. It enables web pages to access AI capabilities via `window.ai` and `window.agent` APIs while maintaining strict origin-based permissions and local-first privacy. + +## Tech Stack + +- **Extension**: TypeScript, Vite, Web Extensions API (Firefox MV3) +- **Bridge**: Node.js 18+, TypeScript, MCP SDK, better-sqlite3, drizzle-orm +- **LLM Providers**: Ollama, llamafile, custom providers via any-llm-ts submodule +- **Testing**: Vitest +- **Packaging**: esbuild, pkg (binary), macOS PKG installer + +## Project Structure + +``` +harbor/ +├── extension/ # Firefox Extension +│ ├── src/ +│ │ ├── background.ts # Native messaging, permissions +│ │ ├── sidebar.ts # Main UI (servers, chat, settings) +│ │ ├── directory.ts # Server catalog UI +│ │ └── provider/ # window.ai/window.agent injection +│ └── manifest.json +├── bridge-ts/ # Node.js Native Messaging Bridge +│ └── src/ +│ ├── main.ts # Entry point, message loop +│ ├── handlers.ts # 50+ message type dispatch +│ ├── types.ts # Shared interfaces +│ ├── host/ # MCP host, permissions, rate limiting +│ ├── mcp/ # MCP protocol clients +│ ├── llm/ # LLM provider abstraction +│ ├── chat/ # Agent loop, tool routing +│ ├── installer/ # Server installation, Docker +│ ├── catalog/ # Server directory, database +│ └── any-llm-ts/ # Git submodule - unified LLM interface +├── demo/ # Example web pages +├── installer/macos/ # macOS .pkg builder +└── docs/ # Documentation +``` + +## Build & Run + +### Prerequisites +- Node.js 18+, Firefox 109+ +- Ollama or llamafile (for LLM) +- Optional: Python 3.9+ with uvx, Docker + +### Build from Source + +```bash +# Clone with submodules +git clone --recurse-submodules +cd harbor + +# Build any-llm-ts submodule first +cd bridge-ts/src/any-llm-ts && npm install && npm run build && cd ../../.. + +# Build bridge +cd bridge-ts && npm install && npm run build && cd .. + +# Build extension +cd extension && npm install && npm run build && cd .. + +# Install native messaging manifest (macOS) +./bridge-ts/scripts/install_native_manifest_macos.sh + +# Load in Firefox: about:debugging → Load Temporary Add-on → extension/dist/manifest.json +``` + +### Development + +```bash +# Watch mode +cd bridge-ts && npm run dev +cd extension && npm run dev +``` + +### Testing + +```bash +cd bridge-ts && npm test +cd extension && npm test + +# With coverage +npm run test:coverage +``` + +## Key Architectural Concepts + +### Message Flow +``` +Web Page (window.ai) → Extension (background.ts) → Native Messaging → Bridge (handlers.ts) → MCP/LLM +``` + +### Permission System +- **Scopes**: `model:prompt`, `model:tools`, `mcp:tools.list`, `mcp:tools.call`, `browser:activeTab.read` +- **Grants**: `ALLOW_ONCE` (10min), `ALLOW_ALWAYS` (persistent), `DENY` +- Stored in browser storage (persistent) and memory (ephemeral) + +### Tool Registry +- Tools namespaced as `{serverId}/{toolName}` (e.g., `filesystem/read_file`) +- Automatic registration on MCP server connection + +### Server Lifecycle +``` +INSTALLING → STOPPED → STARTING → RUNNING ↔ CRASHED (auto-restart 3x) +``` + +### Data Storage +All data in `~/.harbor/`: +- `harbor.db` - Server configs (SQLite) +- `catalog.db` - Cached catalog +- `secrets/credentials.json` - API keys (mode 600) +- `sessions/*.json` - Chat history + +## Common Development Tasks + +### Add New Message Type +1. Define in `bridge-ts/src/types.ts` +2. Add handler in `bridge-ts/src/handlers.ts` +3. Add response handling in `extension/src/background.ts` + +### Add Curated MCP Server +Edit `bridge-ts/src/directory/curated-servers.ts` + +### Add New LLM Provider +1. Create `bridge-ts/src/llm/newprovider.ts` implementing `LLMProvider` +2. Register in `bridge-ts/src/llm/manager.ts` + +## Code Conventions + +- **Files**: `kebab-case.ts` +- **Classes/Interfaces**: `PascalCase` +- **Functions**: `camelCase` +- **Constants**: `SCREAMING_SNAKE` +- **Commits**: Conventional commits (`feat:`, `fix:`, `docs:`, `test:`, `chore:`) +- **TypeScript**: Strict mode, explicit return types on exports +- **Formatting**: 2-space indent, LF line endings (see `.editorconfig`) + +## Key Files + +| File | Purpose | +|------|---------| +| `extension/src/background.ts` | Extension entry, native messaging | +| `extension/src/sidebar.ts` | Main UI (3150 lines) | +| `bridge-ts/src/handlers.ts` | Message dispatch (3430 lines) | +| `bridge-ts/src/host/host.ts` | MCP host orchestration | +| `bridge-ts/src/chat/orchestrator.ts` | Agent loop implementation | + +## Documentation + +- `ARCHITECTURE.md` - System design, security model +- `docs/USER_GUIDE.md` - Installation and usage +- `docs/DEVELOPER_GUIDE.md` - API reference +- `docs/JS_AI_PROVIDER_API.md` - window.ai/window.agent API +- `docs/MCP_HOST.md` - MCP execution internals +- `CONTRIBUTING.md` - Development setup diff --git a/bridge-ts/package-lock.json b/bridge-ts/package-lock.json index b076e58..2c41f51 100644 --- a/bridge-ts/package-lock.json +++ b/bridge-ts/package-lock.json @@ -1411,6 +1411,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -1714,6 +1715,7 @@ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -2764,6 +2766,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2892,6 +2895,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3717,6 +3721,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4596,6 +4601,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -5287,6 +5293,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -5304,7 +5311,7 @@ "version": "0.1.0", "license": "MIT", "devDependencies": { - "@types/node": "^20.0.0", + "@types/node": "^25.0.3", "typescript": "^5.0.0", "vitest": "^2.0.0" }, @@ -5325,14 +5332,21 @@ } }, "src/any-llm-ts/node_modules/@types/node": { - "version": "20.19.27", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", - "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } + }, + "src/any-llm-ts/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" } } } diff --git a/bridge-ts/scripts/install_native_manifest_macos.sh b/bridge-ts/scripts/install_native_manifest_macos.sh index a10813f..46fdbaf 100755 --- a/bridge-ts/scripts/install_native_manifest_macos.sh +++ b/bridge-ts/scripts/install_native_manifest_macos.sh @@ -7,7 +7,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BRIDGE_DIR="$(dirname "$SCRIPT_DIR")" # Firefox extension ID - must match manifest.json -EXTENSION_ID="harbor@example.com" +EXTENSION_ID="raffi.krikorian.harbor@gmail.com" # Manifest name (must match what the extension connects to) MANIFEST_NAME="harbor_bridge_host" @@ -18,13 +18,21 @@ MANIFEST_DIR="$HOME/Library/Application Support/Mozilla/NativeMessagingHosts" # Path to the bridge executable (node running the compiled JS) BRIDGE_MAIN="$BRIDGE_DIR/dist/main.js" +# Find node - use full path since Firefox launches with minimal environment +NODE_PATH=$(which node) +if [ -z "$NODE_PATH" ]; then + echo "Error: node not found in PATH" + exit 1 +fi + # Create a launcher script that runs the bridge with node LAUNCHER_SCRIPT="$BRIDGE_DIR/harbor-bridge" echo "Creating launcher script at $LAUNCHER_SCRIPT..." +echo "Using node at: $NODE_PATH" cat > "$LAUNCHER_SCRIPT" << EOF #!/bin/bash -exec node "$BRIDGE_MAIN" +exec "$NODE_PATH" "$BRIDGE_MAIN" EOF chmod +x "$LAUNCHER_SCRIPT" diff --git a/bridge-ts/src/chat/orchestrator.ts b/bridge-ts/src/chat/orchestrator.ts index aa72328..1cfc7da 100644 --- a/bridge-ts/src/chat/orchestrator.ts +++ b/bridge-ts/src/chat/orchestrator.ts @@ -13,10 +13,41 @@ import { ChatMessage, ToolDefinition, ToolCall, ChatRequest } from '../llm/index import { getLLMManager } from '../llm/manager.js'; import { getMcpClientManager } from '../mcp/manager.js'; import { log } from '../native-messaging.js'; -import { ChatSession, addMessage } from './session.js'; +import { ChatSession, addMessage, PluginToolDefinition } from './session.js'; import { McpTool } from '../types.js'; import { getToolRouter, RoutingResult } from './tool-router.js'; +/** + * Pending plugin tool call that needs execution by the extension. + */ +export interface PendingPluginToolCall { + /** Tool call ID */ + id: string; + + /** Plugin ID */ + pluginId: string; + + /** Tool name */ + toolName: string; + + /** Arguments */ + arguments: Record; +} + +/** + * Result of a plugin tool execution from the extension. + */ +export interface PluginToolResult { + /** Tool call ID */ + toolCallId: string; + + /** Result content */ + content: string; + + /** Whether the call errored */ + isError: boolean; +} + /** * Result of a single orchestration step. */ @@ -78,31 +109,46 @@ export interface ToolCallResult { export interface OrchestrationResult { /** Final response from LLM */ finalResponse: string; - + /** All steps taken */ steps: OrchestrationStep[]; - + /** Total iterations used */ iterations: number; - + /** Whether max iterations was reached */ reachedMaxIterations: boolean; - + /** Total time in milliseconds */ durationMs: number; - + /** Tool routing information (if router was used) */ routing?: RoutingResult; + + /** Whether orchestration is paused waiting for plugin tools */ + paused?: boolean; + + /** Plugin tool calls that need to be executed by the extension */ + pendingPluginToolCalls?: PendingPluginToolCall[]; } /** - * Mapping from tool name to server ID. + * Mapping from tool name to its source (MCP server or plugin). */ interface ToolMapping { [toolName: string]: { - serverId: string; + /** 'mcp' for MCP server tools, 'plugin' for extension plugin tools */ + type: 'mcp' | 'plugin'; + /** Server ID for MCP tools */ + serverId?: string; + /** Plugin ID for plugin tools */ + pluginId?: string; + /** Original tool name (without prefix) */ originalName: string; - tool: McpTool; + /** MCP tool definition (for MCP tools) */ + tool?: McpTool; + /** Plugin tool definition (for plugin tools) */ + pluginTool?: PluginToolDefinition; }; } @@ -145,9 +191,11 @@ export class ChatOrchestrator { log('[Orchestrator] Router disabled, using all servers'); } - // Collect tools from selected MCP servers - const { tools, toolMapping } = await this.collectTools(serversToUse); - log(`[Orchestrator] Collected ${tools.length} tools from ${serversToUse.length} servers`); + // Collect tools from selected MCP servers and plugins + const { tools, toolMapping } = await this.collectTools(serversToUse, session.pluginTools); + const mcpToolCount = tools.filter(t => !t.name.startsWith('plugin__')).length; + const pluginToolCount = tools.length - mcpToolCount; + log(`[Orchestrator] Collected ${tools.length} tools (${mcpToolCount} MCP, ${pluginToolCount} plugin)`); // Log tool names and descriptions for debugging log(`[Orchestrator] === TOOLS SENT TO LLM ===`); @@ -229,7 +277,7 @@ export class ChatOrchestrator { } if (response.finishReason === 'tool_calls' && toolCalls?.length) { - + // Record tool calls step const toolCallStep: OrchestrationStep = { index: steps.length, @@ -243,14 +291,53 @@ export class ChatOrchestrator { }; steps.push(toolCallStep); onStep?.(toolCallStep); - + // Add assistant message with tool calls to conversation addMessage(session, response.message); - - // Execute tool calls - const toolResults = await this.executeToolCalls(toolCalls, toolMapping); - - // Record tool results step + + // Execute tool calls (MCP tools executed, plugin tools returned for extension) + const { results: toolResults, pendingPluginCalls } = await this.executeToolCalls(toolCalls, toolMapping); + + // If there are pending plugin tool calls, pause and return them + if (pendingPluginCalls.length > 0) { + log(`[Orchestrator] Pausing for ${pendingPluginCalls.length} plugin tool calls`); + + // Still record any MCP tool results we got + if (toolResults.length > 0) { + const toolResultStep: OrchestrationStep = { + index: steps.length, + type: 'tool_results', + toolResults, + timestamp: Date.now(), + }; + steps.push(toolResultStep); + onStep?.(toolResultStep); + + // Add MCP tool results to conversation + for (const result of toolResults) { + const toolMsg: ChatMessage = { + role: 'tool', + content: result.content, + toolCallId: result.toolCallId, + }; + addMessage(session, toolMsg); + } + } + + // Return paused state with pending plugin calls + return { + finalResponse: '', + steps, + iterations, + reachedMaxIterations: false, + durationMs: Date.now() - startTime, + routing: routingResult, + paused: true, + pendingPluginToolCalls: pendingPluginCalls, + }; + } + + // Record tool results step (only MCP results) const toolResultStep: OrchestrationStep = { index: steps.length, type: 'tool_results', @@ -259,7 +346,7 @@ export class ChatOrchestrator { }; steps.push(toolResultStep); onStep?.(toolResultStep); - + // Add tool results to conversation for (const result of toolResults) { const toolMsg: ChatMessage = { @@ -269,7 +356,7 @@ export class ChatOrchestrator { }; addMessage(session, toolMsg); } - + // Continue loop to get LLM response continue; } @@ -372,7 +459,7 @@ export class ChatOrchestrator { // Reached max iterations log(`[Orchestrator] Reached max iterations (${session.config.maxIterations})`); - + return { finalResponse: 'I reached the maximum number of steps. Please try a simpler request or increase the limit.', steps, @@ -382,7 +469,188 @@ export class ChatOrchestrator { routing: routingResult, }; } - + + /** + * Continue orchestration after plugin tool results are provided. + * + * @param session - The chat session + * @param pluginResults - Results from plugin tool executions + * @param onStep - Optional callback for each step + */ + async continueWithPluginResults( + session: ChatSession, + pluginResults: PluginToolResult[], + onStep?: (step: OrchestrationStep) => void + ): Promise { + log(`[Orchestrator] Continuing with ${pluginResults.length} plugin tool results`); + + // Add plugin tool results to conversation + for (const result of pluginResults) { + const toolMsg: ChatMessage = { + role: 'tool', + content: result.content, + toolCallId: result.toolCallId, + }; + addMessage(session, toolMsg); + } + + // Continue orchestration from where we left off + // We call run() with an empty message since the tool results are already added + // Actually, we need to continue the loop - let's just call run with a synthetic continuation + return this.runContinuation(session, onStep); + } + + /** + * Internal method to continue orchestration after tool results. + * Similar to run() but doesn't add a user message. + */ + private async runContinuation( + session: ChatSession, + onStep?: (step: OrchestrationStep) => void + ): Promise { + const startTime = Date.now(); + const steps: OrchestrationStep[] = []; + let iterations = 0; + + log(`[Orchestrator] Running continuation for session ${session.id}`); + + // Collect tools from enabled servers and plugins + const { tools, toolMapping } = await this.collectTools(session.enabledServers, session.pluginTools); + + // Main agent loop + while (iterations < session.config.maxIterations) { + iterations++; + log(`[Orchestrator] Continuation iteration ${iterations}/${session.config.maxIterations}`); + + try { + const llmManager = getLLMManager(); + const activeProvider = llmManager.getActiveId(); + + const systemPrompt = session.systemPrompt || this.buildSystemPrompt(tools, activeProvider); + + const request: ChatRequest = { + messages: [...session.messages], + tools: tools.length > 0 ? tools : undefined, + systemPrompt, + }; + + const response = await llmManager.chat(request); + + if (response.finishReason === 'error') { + return { + finalResponse: `Error: ${response.error || 'Unknown LLM error'}`, + steps, + iterations, + reachedMaxIterations: false, + durationMs: Date.now() - startTime, + }; + } + + let toolCalls = response.message.toolCalls; + + // Fallback for text-based tool calls + if ((!toolCalls || toolCalls.length === 0) && response.message.content) { + const parsedToolCall = this.parseToolCallFromText(response.message.content, toolMapping); + if (parsedToolCall) { + toolCalls = [parsedToolCall]; + response.finishReason = 'tool_calls'; + } + } + + if (response.finishReason === 'tool_calls' && toolCalls?.length) { + const toolCallStep: OrchestrationStep = { + index: steps.length, + type: 'tool_calls', + toolCalls: toolCalls.map(tc => ({ + id: tc.id, + name: tc.name, + arguments: tc.arguments, + })), + timestamp: Date.now(), + }; + steps.push(toolCallStep); + onStep?.(toolCallStep); + + addMessage(session, response.message); + + const { results: toolResults, pendingPluginCalls } = await this.executeToolCalls(toolCalls, toolMapping); + + // If there are pending plugin calls, pause again + if (pendingPluginCalls.length > 0) { + if (toolResults.length > 0) { + for (const result of toolResults) { + addMessage(session, { + role: 'tool', + content: result.content, + toolCallId: result.toolCallId, + }); + } + } + + return { + finalResponse: '', + steps, + iterations, + reachedMaxIterations: false, + durationMs: Date.now() - startTime, + paused: true, + pendingPluginToolCalls: pendingPluginCalls, + }; + } + + // Add results to conversation + for (const result of toolResults) { + addMessage(session, { + role: 'tool', + content: result.content, + toolCallId: result.toolCallId, + }); + } + + continue; + } + + // Final response + const finalContent = this.cleanLLMTokens(response.message.content || ''); + addMessage(session, response.message); + + const finalStep: OrchestrationStep = { + index: steps.length, + type: 'final', + content: finalContent, + timestamp: Date.now(), + }; + steps.push(finalStep); + onStep?.(finalStep); + + return { + finalResponse: finalContent, + steps, + iterations, + reachedMaxIterations: false, + durationMs: Date.now() - startTime, + }; + + } catch (error) { + return { + finalResponse: `I encountered an error: ${error}`, + steps, + iterations, + reachedMaxIterations: false, + durationMs: Date.now() - startTime, + }; + } + } + + return { + finalResponse: 'I reached the maximum number of steps.', + steps, + iterations, + reachedMaxIterations: true, + durationMs: Date.now() - startTime, + }; + } + /** * Parse a tool call from text when LLM writes it out instead of using proper format. * This is a fallback for models that don't support tool calling well. @@ -917,16 +1185,20 @@ Summarize the results in plain language for the user.`; } /** - * Collect tools from all enabled MCP servers. + * Collect tools from all enabled MCP servers and plugin tools. */ - private async collectTools(serverIds: string[]): Promise<{ + private async collectTools( + serverIds: string[], + pluginTools: PluginToolDefinition[] = [] + ): Promise<{ tools: ToolDefinition[]; toolMapping: ToolMapping; }> { const tools: ToolDefinition[] = []; const toolMapping: ToolMapping = {}; const mcpManager = getMcpClientManager(); - + + // Collect MCP server tools for (const serverId of serverIds) { try { // Check if connected first @@ -934,20 +1206,21 @@ Summarize the results in plain language for the user.`; log(`[Orchestrator] Skipping ${serverId} - not connected`); continue; } - + const mcpTools = await mcpManager.listTools(serverId); - + for (const mcpTool of mcpTools) { // Prefix tool name with server ID to avoid collisions const prefixedName = `${serverId}__${mcpTool.name}`; - + tools.push({ name: prefixedName, description: mcpTool.description || `Tool from ${serverId}`, inputSchema: mcpTool.inputSchema || { type: 'object', properties: {} }, }); - + toolMapping[prefixedName] = { + type: 'mcp', serverId, originalName: mcpTool.name, tool: mcpTool, @@ -957,24 +1230,47 @@ Summarize the results in plain language for the user.`; log(`[Orchestrator] Failed to get tools from ${serverId}: ${error}`); } } - + + // Collect plugin tools + for (const pluginTool of pluginTools) { + // Use plugin__toolname format for consistency + const prefixedName = `plugin__${pluginTool.name}`; + + tools.push({ + name: prefixedName, + description: pluginTool.description || `Plugin tool ${pluginTool.name}`, + inputSchema: pluginTool.inputSchema || { type: 'object', properties: {} }, + }); + + toolMapping[prefixedName] = { + type: 'plugin', + pluginId: pluginTool.pluginId, + originalName: pluginTool.name, + pluginTool, + }; + } + return { tools, toolMapping }; } /** - * Execute tool calls via MCP. + * Execute tool calls - MCP tools are executed directly, plugin tools are returned for extension. */ private async executeToolCalls( toolCalls: ToolCall[], toolMapping: ToolMapping - ): Promise { + ): Promise<{ + results: ToolCallResult[]; + pendingPluginCalls: PendingPluginToolCall[]; + }> { const results: ToolCallResult[] = []; + const pendingPluginCalls: PendingPluginToolCall[] = []; const mcpManager = getMcpClientManager(); - + for (const toolCall of toolCalls) { const prefixedName = toolCall.name; const mapping = toolMapping[prefixedName]; - + if (!mapping) { log(`[Orchestrator] Unknown tool: ${prefixedName}`); results.push({ @@ -986,22 +1282,40 @@ Summarize the results in plain language for the user.`; }); continue; } - + + // Handle plugin tools - return them for extension to execute + if (mapping.type === 'plugin') { + log(`[Orchestrator] Plugin tool call: ${mapping.originalName} (plugin: ${mapping.pluginId})`); + + // Fix arguments before returning + const schema = mapping.pluginTool?.inputSchema; + const fixedArguments = this.fixToolArguments(toolCall.arguments, schema); + + pendingPluginCalls.push({ + id: toolCall.id, + pluginId: mapping.pluginId!, + toolName: mapping.originalName, + arguments: fixedArguments, + }); + continue; + } + + // Handle MCP tools - execute directly try { - log(`[Orchestrator] Calling tool ${mapping.originalName} on ${mapping.serverId}`); + log(`[Orchestrator] Calling MCP tool ${mapping.originalName} on ${mapping.serverId}`); log(`[Orchestrator] Raw arguments: ${JSON.stringify(toolCall.arguments)}`); - + // Fix stringified JSON in arguments (common LLM issue) - const fixedArguments = this.fixToolArguments(toolCall.arguments, mapping.tool.inputSchema); + const fixedArguments = this.fixToolArguments(toolCall.arguments, mapping.tool?.inputSchema); log(`[Orchestrator] Fixed arguments: ${JSON.stringify(fixedArguments)}`); - + // Call the tool via MCP const result = await mcpManager.callTool( - mapping.serverId, + mapping.serverId!, mapping.originalName, fixedArguments ); - + // Extract text content from result let content = ''; if (result.content && result.content.length > 0) { @@ -1013,28 +1327,28 @@ Summarize the results in plain language for the user.`; }) .join('\n'); } - + results.push({ toolCallId: toolCall.id, toolName: mapping.originalName, - serverId: mapping.serverId, + serverId: mapping.serverId!, content, isError: result.isError || false, }); - + } catch (error) { log(`[Orchestrator] Tool call error for ${mapping.originalName}: ${error}`); results.push({ toolCallId: toolCall.id, toolName: mapping.originalName, - serverId: mapping.serverId, + serverId: mapping.serverId || 'unknown', content: `Error: ${error}`, isError: true, }); } } - - return results; + + return { results, pendingPluginCalls }; } } diff --git a/bridge-ts/src/chat/session.ts b/bridge-ts/src/chat/session.ts index 8ac58c4..224b033 100644 --- a/bridge-ts/src/chat/session.ts +++ b/bridge-ts/src/chat/session.ts @@ -9,31 +9,51 @@ import { ChatMessage } from '../llm/index.js'; +/** + * A plugin tool definition passed from the extension. + */ +export interface PluginToolDefinition { + /** Plugin ID (extension ID) */ + pluginId: string; + + /** Tool name (e.g., "decode.base64_encode") */ + name: string; + + /** Tool description */ + description?: string; + + /** Input schema (JSON Schema) */ + inputSchema?: Record; +} + /** * A chat session. */ export interface ChatSession { /** Unique session ID */ id: string; - + /** Human-readable name */ name: string; - + /** Conversation messages */ messages: ChatMessage[]; - + /** IDs of MCP servers enabled for this session */ enabledServers: string[]; - + + /** Plugin tools available for this session */ + pluginTools: PluginToolDefinition[]; + /** System prompt for this session */ systemPrompt?: string; - + /** When the session was created */ createdAt: number; - + /** When the session was last updated */ updatedAt: number; - + /** Session configuration */ config: SessionConfig; } @@ -84,6 +104,7 @@ export function createSession( name: string; systemPrompt: string; config: Partial; + pluginTools: PluginToolDefinition[]; }> = {} ): ChatSession { return { @@ -91,6 +112,7 @@ export function createSession( name: options.name || `Chat ${new Date().toLocaleTimeString()}`, messages: [], enabledServers, + pluginTools: options.pluginTools || [], systemPrompt: options.systemPrompt, createdAt: Date.now(), updatedAt: Date.now(), @@ -134,6 +156,7 @@ export function cloneSession(session: ChatSession): ChatSession { name: `${session.name} (copy)`, messages: [...session.messages], enabledServers: [...session.enabledServers], + pluginTools: [...session.pluginTools], config: { ...session.config }, createdAt: Date.now(), updatedAt: Date.now(), diff --git a/bridge-ts/src/handlers.ts b/bridge-ts/src/handlers.ts index 48e4dec..fa93ce5 100644 --- a/bridge-ts/src/handlers.ts +++ b/bridge-ts/src/handlers.ts @@ -25,12 +25,14 @@ import { } from './types.js'; import { getLLMManager, LLMManager, ChatMessage, ToolDefinition, getLLMSetupManager, DownloadProgress } from './llm/index.js'; import { - getChatOrchestrator, - getChatSessionStore, + getChatOrchestrator, + getChatSessionStore, createSession, ChatSession, OrchestrationResult, OrchestrationStep, + PluginToolDefinition, + PluginToolResult, } from './chat/index.js'; import { CURATED_SERVERS, getCuratedServer, type CuratedServerFull } from './directory/curated-servers.js'; import { getDockerExec } from './installer/docker-exec.js'; @@ -2407,26 +2409,29 @@ const handleLlmStopLocal: MessageHandler = async (message, _store, _client, _cat const handleChatCreateSession: MessageHandler = async (message, _store, _client, _catalog, _installer, _mcpManager) => { const requestId = message.request_id || ''; const enabledServers = (message.enabled_servers as string[]) || []; + const pluginTools = (message.plugin_tools as PluginToolDefinition[]) || []; const name = message.name as string | undefined; const systemPrompt = message.system_prompt as string | undefined; const maxIterations = message.max_iterations as number | undefined; try { const sessionStore = getChatSessionStore(); - + const session = createSession(enabledServers, { name, systemPrompt, + pluginTools, config: maxIterations ? { maxIterations } : undefined, }); - + sessionStore.save(session); - - return makeResult('chat_create_session', requestId, { + + return makeResult('chat_create_session', requestId, { session: { id: session.id, name: session.name, enabledServers: session.enabledServers, + pluginTools: session.pluginTools, systemPrompt: session.systemPrompt, createdAt: session.createdAt, config: session.config, @@ -2474,17 +2479,20 @@ const handleChatSendMessage: MessageHandler = async (message, _store, _client, _ const orchestrator = getChatOrchestrator(); const result = await orchestrator.run(session, userMessage); - + // Save updated session sessionStore.save(session); - - return makeResult('chat_send_message', requestId, { + + return makeResult('chat_send_message', requestId, { response: result.finalResponse, steps: result.steps, iterations: result.iterations, reachedMaxIterations: result.reachedMaxIterations, durationMs: result.durationMs, routing: result.routing, + // Include paused state for plugin tool calls + paused: result.paused, + pendingPluginToolCalls: result.pendingPluginToolCalls, }); } catch (e) { log(`Failed to process chat message: ${e}`); @@ -2492,6 +2500,58 @@ const handleChatSendMessage: MessageHandler = async (message, _store, _client, _ } }; +/** + * Continue a chat session after plugin tool results are provided. + */ +const handleChatContinueWithPluginResults: MessageHandler = async (message, _store, _client, _catalog, _installer, _mcpManager, llmManager) => { + const requestId = message.request_id || ''; + const sessionId = message.session_id as string || ''; + const pluginResults = (message.plugin_results as PluginToolResult[]) || []; + + if (!sessionId) { + return makeError(requestId, 'invalid_request', 'Missing session_id'); + } + if (pluginResults.length === 0) { + return makeError(requestId, 'invalid_request', 'Missing plugin_results'); + } + + try { + // Ensure LLM is available + const activeId = llmManager.getActiveId(); + if (!activeId) { + return makeError(requestId, 'llm_error', 'No active LLM provider. Run llm_detect first.'); + } + + const sessionStore = getChatSessionStore(); + const session = sessionStore.get(sessionId); + + if (!session) { + return makeError(requestId, 'not_found', `Session not found: ${sessionId}`); + } + + const orchestrator = getChatOrchestrator(); + const result = await orchestrator.continueWithPluginResults(session, pluginResults); + + // Save updated session + sessionStore.save(session); + + return makeResult('chat_continue_with_plugin_results', requestId, { + response: result.finalResponse, + steps: result.steps, + iterations: result.iterations, + reachedMaxIterations: result.reachedMaxIterations, + durationMs: result.durationMs, + routing: result.routing, + // Include paused state if more plugin tools are needed + paused: result.paused, + pendingPluginToolCalls: result.pendingPluginToolCalls, + }); + } catch (e) { + log(`Failed to continue chat with plugin results: ${e}`); + return makeError(requestId, 'chat_error', String(e)); + } +}; + /** * Get a chat session. */ @@ -3391,6 +3451,7 @@ const HANDLERS: Record = { // Chat session handlers chat_create_session: handleChatCreateSession, chat_send_message: handleChatSendMessage, + chat_continue_with_plugin_results: handleChatContinueWithPluginResults, chat_get_session: handleChatGetSession, chat_list_sessions: handleChatListSessions, chat_delete_session: handleChatDeleteSession, diff --git a/docs/plugin-protocol.md b/docs/plugin-protocol.md new file mode 100644 index 0000000..adb4082 --- /dev/null +++ b/docs/plugin-protocol.md @@ -0,0 +1,537 @@ +# Harbor Plugin Protocol Specification + +**Protocol Version:** `harbor-plugin/v1` + +This document defines the message protocol for Harbor plugin extensions. Plugins are Firefox extensions that provide tools to Harbor, which aggregates them and exposes them to web applications via the `window.agent` API. + +## Architecture Overview + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Web Page │ │ Harbor Hub │ │ Plugin Extension│ +│ (window.agent) │◄───►│ Extension │◄───►│ (MCP-like tools)│ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + runtime.onConnect runtime.sendMessage + (content script) (extension-to-ext) +``` + +**Key principles:** +- Plugins register with the Hub via `browser.runtime.sendMessage(, ...)` +- Plugins do NOT inject into web pages; only Harbor does +- Consent is controlled by the Hub on a per-origin basis +- Tool names are namespaced as `::` + +> **See it in action:** The [Time Plugin](../plugins/harbor-plugin-time/src/background/index.ts) is a complete working example showing registration, tool calls, and health checks. + +## Message Envelope + +All protocol messages use a standard envelope format: + +```typescript +interface PluginMessageEnvelope { + /** Namespace identifier: "harbor-plugin" */ + namespace: "harbor-plugin"; + + /** Protocol version: "harbor-plugin/v1" */ + protocolVersion: "harbor-plugin/v1"; + + /** Message type (see Message Types below) */ + type: PluginMessageType; + + /** Unique request ID for correlation */ + requestId: string; + + /** Unix timestamp (milliseconds) when message was created */ + timestamp: number; + + /** Message payload (type depends on message type) */ + payload: object; +} +``` + +### Request ID Format + +Request IDs should be unique and include: +- A prefix for identification +- A timestamp component +- A random component + +Example: `plugin-1704067200000-abc123def` + +## Message Types + +### Registration Messages + +#### PLUGIN_REGISTER + +Sent by a plugin to register with the Harbor Hub. + +**Direction:** Plugin → Hub + +**Payload:** +```typescript +{ + plugin: { + /** Firefox extension ID (must match browser_specific_settings.gecko.id) */ + extensionId: string; + + /** Human-readable plugin name */ + name: string; + + /** Plugin version (semver) */ + version: string; + + /** Plugin description */ + description?: string; + + /** Plugin author */ + author?: string; + + /** Homepage or documentation URL */ + homepage?: string; + + /** Icon URL or data URI */ + icon?: string; + + /** Tools provided by this plugin */ + tools: PluginToolDefinition[]; + } +} +``` + +#### PLUGIN_REGISTER_ACK + +Sent by the Hub in response to PLUGIN_REGISTER. + +**Direction:** Hub → Plugin + +**Payload:** +```typescript +{ + /** Whether registration succeeded */ + success: boolean; + + /** Error message if registration failed */ + error?: string; + + /** Assigned namespace prefix (equals extensionId) */ + toolNamespace?: string; +} +``` + +#### PLUGIN_UNREGISTER + +Sent by a plugin when it wants to unregister. + +**Direction:** Plugin → Hub + +**Payload:** +```typescript +{ + /** Reason for unregistering (optional) */ + reason?: string; +} +``` + +#### PLUGIN_UNREGISTER_ACK + +Sent by the Hub in response to PLUGIN_UNREGISTER. + +**Direction:** Hub → Plugin + +**Payload:** +```typescript +{ + success: boolean; +} +``` + +### Tool Operation Messages + +#### PLUGIN_TOOLS_LIST + +Sent by the Hub to request an updated tool list from a plugin. + +**Direction:** Hub → Plugin + +**Payload:** `{}` (empty) + +#### PLUGIN_TOOLS_LIST_RESULT + +Sent by a plugin in response to PLUGIN_TOOLS_LIST. + +**Direction:** Plugin → Hub + +**Payload:** +```typescript +{ + tools: PluginToolDefinition[]; +} +``` + +#### PLUGIN_TOOL_CALL + +Sent by the Hub to invoke a tool on a plugin. + +**Direction:** Hub → Plugin + +**Payload:** +```typescript +{ + /** Tool name (without namespace prefix) */ + toolName: string; + + /** Arguments for the tool */ + arguments: Record; + + /** Origin of the calling web page (for plugin's information) */ + callingOrigin?: string; +} +``` + +#### PLUGIN_TOOL_RESULT + +Sent by a plugin when a tool call succeeds. + +**Direction:** Plugin → Hub + +**Payload:** +```typescript +{ + /** Result data from the tool */ + result: unknown; + + /** Execution time in milliseconds (optional) */ + executionTimeMs?: number; +} +``` + +#### PLUGIN_TOOL_ERROR + +Sent by a plugin when a tool call fails. + +**Direction:** Plugin → Hub + +**Payload:** +```typescript +{ + /** Error code */ + code: PluginErrorCode; + + /** Human-readable error message */ + message: string; + + /** Additional error details */ + details?: unknown; +} +``` + +### Health/Keepalive Messages + +#### PLUGIN_PING + +Sent by the Hub to check if a plugin is healthy. + +**Direction:** Hub → Plugin + +**Payload:** `{}` (empty) + +#### PLUGIN_PONG + +Sent by a plugin in response to PLUGIN_PING. + +**Direction:** Plugin → Hub + +**Payload:** +```typescript +{ + /** Plugin uptime in seconds (optional) */ + uptime?: number; + + /** Whether plugin is healthy */ + healthy: boolean; +} +``` + +### Hub Notification Messages + +#### PLUGIN_DISABLED + +Sent by the Hub when it disables a plugin. + +**Direction:** Hub → Plugin + +**Payload:** +```typescript +{ + /** Reason for disabling */ + reason?: string; +} +``` + +#### PLUGIN_ENABLED + +Sent by the Hub when it re-enables a plugin. + +**Direction:** Hub → Plugin + +**Payload:** `{}` (empty) + +## Tool Definition Format + +Tools are defined using the following schema: + +```typescript +interface PluginToolDefinition { + /** Tool name (unique within the plugin, e.g., 'echo') */ + name: string; + + /** Human-readable title */ + title: string; + + /** Description of what the tool does */ + description: string; + + /** JSON Schema for input parameters */ + inputSchema: JsonSchema; + + /** JSON Schema for output (optional) */ + outputSchema?: JsonSchema; + + /** UI hints for rendering (optional) */ + uiHints?: { + /** Icon identifier or URL */ + icon?: string; + + /** Category for grouping */ + category?: string; + + /** Whether this tool may have side effects */ + dangerous?: boolean; + + /** Estimated execution time */ + speed?: "instant" | "fast" | "slow"; + }; +} +``` + +### Input Schema Example + +```json +{ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The message to echo back" + }, + "uppercase": { + "type": "boolean", + "description": "Whether to uppercase the message", + "default": false + } + }, + "required": ["message"] +} +``` + +## Error Codes + +| Code | Description | +|------|-------------| +| `TOOL_NOT_FOUND` | The requested tool does not exist | +| `INVALID_ARGUMENTS` | Arguments do not match the input schema | +| `EXECUTION_FAILED` | Tool execution failed | +| `TIMEOUT` | Operation timed out | +| `INTERNAL_ERROR` | Internal plugin error | +| `NOT_REGISTERED` | Plugin is not registered | +| `ALREADY_REGISTERED` | Plugin is already registered | +| `PLUGIN_NOT_ALLOWED` | Plugin is not in the allowlist | +| `PROTOCOL_VERSION_MISMATCH` | Protocol version is not compatible | + +## Security Model + +### Plugin Allowlist + +The Hub maintains an allowlist of trusted plugin extension IDs. By default, the allowlist is empty, which means **all plugins are allowed**. To restrict which plugins can register: + +1. Configure the allowlist in Harbor settings +2. Add specific extension IDs to the allowlist +3. Only plugins in the allowlist can successfully register + +### Consent Model + +Consent is controlled entirely by the Hub, not by plugins: + +1. **Per-origin consent:** Each web origin must be granted permission to use plugin tools +2. **Consent options:** + - Allow once (10-minute TTL, in-memory) + - Allow always (persistent, stored in browser storage) + - Deny +3. **Tool-level consent:** Origins can be granted access to specific tools or all tools +4. **Plugin tools are namespaced:** Web pages see tools as `pluginId::toolName` + +### Trust Boundaries + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Trusted Zone │ +│ ┌─────────────────┐ ┌─────────────────────────────────┐ │ +│ │ Harbor Hub │ │ Plugin Extension │ │ +│ │ (manages │◄───►│ (installed by user, │ │ +│ │ consent) │ │ trusted code execution) │ │ +│ └────────┬────────┘ └─────────────────────────────────┘ │ +│ │ │ +└───────────┼─────────────────────────────────────────────────────┘ + │ consent gate + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Untrusted Zone │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Web Page (any origin) │ │ +│ │ Uses window.agent.tools.list() / tools.call() │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Versioning Rules + +### Protocol Version String + +Format: `harbor-plugin/v` + +Example: `harbor-plugin/v1` + +### Compatibility Rules + +1. **Minor changes** (backward compatible): + - Adding optional fields to payloads + - Adding new message types + - Adding new error codes + +2. **Major changes** (breaking): + - Removing or renaming fields + - Changing field types + - Changing message type names + - Changing the envelope structure + +3. **Version negotiation:** + - Plugins should check the Hub's protocol version on registration + - The Hub rejects plugins with incompatible protocol versions + - For v1, only exact match is accepted + +## Timeouts + +| Operation | Default Timeout | +|-----------|-----------------| +| Registration | 5,000 ms | +| Tool call | 30,000 ms | +| Ping | 2,000 ms | +| Heartbeat interval | 60,000 ms | + +## Message Flow Examples + +### Plugin Registration + +``` +Plugin Hub + │ │ + │ PLUGIN_REGISTER │ + │─────────────────────────────>│ + │ │ Validate extension ID + │ │ Check allowlist + │ │ Store in registry + │ PLUGIN_REGISTER_ACK │ + │<─────────────────────────────│ + │ │ +``` + +### Tool Call + +``` +Web Page Hub Plugin + │ │ │ + │ tools.call( │ │ + │ "plugin::echo", │ │ + │ {message: "hi"}) │ │ + │──────────────────────>│ │ + │ │ Check consent │ + │ │ Parse namespace │ + │ │ │ + │ │ PLUGIN_TOOL_CALL │ + │ │──────────────────────>│ + │ │ │ Execute tool + │ │ PLUGIN_TOOL_RESULT │ + │ │<──────────────────────│ + │ │ │ + │ {success: true, │ │ + │ result: "hi"} │ │ + │<──────────────────────│ │ +``` + +### Heartbeat + +``` +Hub Plugin + │ │ + │ PLUGIN_PING │ + │─────────────────────────────>│ + │ │ Check health + │ PLUGIN_PONG │ + │<─────────────────────────────│ + │ │ + │ Update lastSeen timestamp │ + │ │ +``` + +## Implementation Notes + +### For Plugin Developers + +> **Reference implementations:** See [harbor-plugin-time](../plugins/harbor-plugin-time/) and [harbor-plugin-decode](../plugins/harbor-plugin-decode/) for complete working examples. + +1. **Extension ID:** Your plugin must have a stable extension ID set in `manifest.json`: + ```json + { + "browser_specific_settings": { + "gecko": { + "id": "your-plugin@example.com" + } + } + } + ``` + +2. **Registration:** Register on startup by sending PLUGIN_REGISTER to the Harbor Hub ID + +3. **Message Handling:** Listen for external messages from the Hub: + ```typescript + browser.runtime.onMessageExternal.addListener((message, sender) => { + if (sender.id === HARBOR_HUB_ID) { + // Handle message + } + }); + ``` + +4. **Respond promptly:** Always respond to PLUGIN_PING with PLUGIN_PONG + +### For Harbor Hub + +1. **Listen for external messages:** + ```typescript + browser.runtime.onMessageExternal.addListener(handleExternalMessage); + ``` + +2. **Maintain correlation:** Use requestId to correlate requests with responses + +3. **Handle timeouts:** Set timeouts for all outgoing requests + +4. **Heartbeat:** Periodically ping plugins to check health + +## Harbor Hub Extension ID + +The Harbor Hub extension ID is: +``` +raffi.krikorian.harbor@gmail.com +``` + +Plugins should send their registration messages to this ID. diff --git a/docs/plugins-dev.md b/docs/plugins-dev.md new file mode 100644 index 0000000..63dfa66 --- /dev/null +++ b/docs/plugins-dev.md @@ -0,0 +1,682 @@ +# Harbor Plugin Development Guide + +This guide walks through building Harbor plugins with TypeScript, including project setup, tool implementation, testing, and best practices. + +## Prerequisites + +- Node.js 18+ +- Firefox 112+ +- Basic TypeScript knowledge + +## Project Structure + +A typical Harbor plugin has the following structure: + +``` +plugins/harbor-plugin-myplugin/ +├── manifest.json # Firefox extension manifest +├── package.json # Node.js package config +├── tsconfig.json # TypeScript config +├── vitest.config.ts # Test runner config +├── dist/ # Built output (generated) +│ └── background.js +└── src/ + ├── errors.ts # Custom error types + ├── background/ + │ └── index.ts # Extension entry point + └── tools/ + ├── mytool.ts # Tool definitions and implementations + └── mytool.test.ts # Unit tests +``` + +## Step 1: Initialize Project + +Create your plugin directory and initialize: + +```bash +mkdir -p plugins/harbor-plugin-myplugin +cd plugins/harbor-plugin-myplugin +npm init -y +``` + +Install dependencies: + +```bash +npm install -D esbuild typescript vitest +``` + +## Step 2: Configuration Files + +### package.json + +```json +{ + "name": "harbor-plugin-myplugin", + "version": "1.0.0", + "description": "My custom Harbor plugin", + "type": "module", + "scripts": { + "build": "esbuild src/background/index.ts --bundle --outfile=dist/background.js --format=esm --target=firefox112", + "watch": "esbuild src/background/index.ts --bundle --outfile=dist/background.js --format=esm --target=firefox112 --watch", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist" + }, + "devDependencies": { + "esbuild": "^0.20.0", + "typescript": "^5.0.0", + "vitest": "^2.0.0" + } +} +``` + +### tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +### vitest.config.ts + +```typescript +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); +``` + +### manifest.json + +```json +{ + "manifest_version": 3, + "name": "Harbor Plugin: My Plugin", + "version": "1.0.0", + "description": "Description of what your plugin does", + "permissions": [], + "background": { + "scripts": ["dist/background.js"], + "type": "module" + }, + "browser_specific_settings": { + "gecko": { + "id": "harbor-plugin-myplugin@local", + "strict_min_version": "112.0" + } + } +} +``` + +## Step 3: Error Handling + +Create `src/errors.ts` with standard error types: + +```typescript +export type ToolErrorCode = + | 'TOOL_NOT_FOUND' + | 'INVALID_ARGUMENTS' + | 'EXECUTION_FAILED' + | 'INPUT_TOO_LARGE'; + +export class ToolError extends Error { + constructor( + public readonly code: ToolErrorCode, + message: string, + public readonly details?: unknown + ) { + super(message); + this.name = 'ToolError'; + } +} + +export function invalidArgument(message: string, details?: unknown): ToolError { + return new ToolError('INVALID_ARGUMENTS', message, details); +} + +export function toolNotFound(toolName: string): ToolError { + return new ToolError('TOOL_NOT_FOUND', `Unknown tool: ${toolName}`); +} + +export function executionFailed(message: string, details?: unknown): ToolError { + return new ToolError('EXECUTION_FAILED', message, details); +} + +export function inputTooLarge(size: number, maxSize: number): ToolError { + return new ToolError( + 'INPUT_TOO_LARGE', + `Input size (${size} bytes) exceeds maximum (${maxSize} bytes)` + ); +} +``` + +## Step 4: Define Tools + +Create `src/tools/mytool.ts` with tool definitions and implementations: + +```typescript +import { invalidArgument } from '../errors'; + +// ============================================================================= +// Tool Definitions +// ============================================================================= + +export const MY_TOOL_DEFINITION = { + name: 'myplugin.do_something', + title: 'Do Something', + description: 'Does something useful with the input.', + inputSchema: { + type: 'object' as const, + properties: { + input: { + type: 'string' as const, + description: 'The input to process', + }, + option: { + type: 'boolean' as const, + description: 'An optional flag', + default: false, + }, + }, + required: ['input'], + }, + outputSchema: { + type: 'object' as const, + properties: { + result: { type: 'string' as const, description: 'The processed result' }, + }, + required: ['result'], + }, +}; + +// ============================================================================= +// Tool Implementations +// ============================================================================= + +export interface MyToolInput { + input: string; + option?: boolean; +} + +export interface MyToolResult { + result: string; +} + +export function doSomething(input: MyToolInput): MyToolResult { + // Validate input + if (typeof input.input !== 'string') { + throw invalidArgument('input must be a string'); + } + + // Implement your tool logic + let result = input.input; + if (input.option) { + result = result.toUpperCase(); + } + + return { result }; +} +``` + +### Tool Naming Convention + +Tools should be namespaced with a prefix matching your plugin: + +- `myplugin.action_name` - for a plugin called "myplugin" +- `time.now`, `time.format` - for a time plugin +- `decode.base64_encode`, `decode.json_pretty` - for a decode plugin + +## Step 5: Write Tests + +Create `src/tools/mytool.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { doSomething, MY_TOOL_DEFINITION } from './mytool'; + +describe('doSomething', () => { + it('processes input correctly', () => { + const result = doSomething({ input: 'hello' }); + expect(result.result).toBe('hello'); + }); + + it('applies option when true', () => { + const result = doSomething({ input: 'hello', option: true }); + expect(result.result).toBe('HELLO'); + }); + + it('throws for non-string input', () => { + expect(() => doSomething({ input: 123 as unknown as string })).toThrow( + 'input must be a string' + ); + }); +}); + +describe('MY_TOOL_DEFINITION', () => { + it('has required fields', () => { + expect(MY_TOOL_DEFINITION.name).toBe('myplugin.do_something'); + expect(MY_TOOL_DEFINITION.inputSchema.required).toContain('input'); + }); +}); +``` + +Run tests: + +```bash +npm test +``` + +## Step 6: Background Script + +Create `src/background/index.ts`: + +```typescript +import { + doSomething, + MY_TOOL_DEFINITION, + MyToolInput, +} from '../tools/mytool'; +import { ToolError, toolNotFound } from '../errors'; + +// ============================================================================= +// Constants +// ============================================================================= + +const HARBOR_HUB_EXTENSION_ID = 'raffi.krikorian.harbor@gmail.com'; +const PLUGIN_ID = 'harbor-plugin-myplugin@local'; +const PLUGIN_NAME = 'Harbor Plugin: My Plugin'; +const PLUGIN_VERSION = '1.0.0'; + +const PLUGIN_NAMESPACE = 'harbor-plugin'; +const PLUGIN_PROTOCOL_VERSION = 'harbor-plugin/v1'; + +const startupTime = Date.now(); + +// ============================================================================= +// Types +// ============================================================================= + +interface PluginMessageEnvelope { + namespace: string; + protocolVersion: string; + type: string; + requestId: string; + timestamp: number; + payload: unknown; +} + +interface ToolCallPayload { + toolName: string; + arguments: Record; + callingOrigin?: string; +} + +// ============================================================================= +// Tool Registry +// ============================================================================= + +const TOOLS = [MY_TOOL_DEFINITION]; + +function executeTool(toolName: string, args: Record): unknown { + switch (toolName) { + case 'myplugin.do_something': + return doSomething(args as MyToolInput); + + default: + throw toolNotFound(toolName); + } +} + +// ============================================================================= +// Message Helpers +// ============================================================================= + +function generateRequestId(): string { + return `myplugin-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; +} + +function createMessage( + type: string, + payload: unknown, + requestId?: string +): PluginMessageEnvelope { + return { + namespace: PLUGIN_NAMESPACE, + protocolVersion: PLUGIN_PROTOCOL_VERSION, + type, + requestId: requestId ?? generateRequestId(), + timestamp: Date.now(), + payload, + }; +} + +async function sendToHub(message: PluginMessageEnvelope): Promise { + try { + await browser.runtime.sendMessage(HARBOR_HUB_EXTENSION_ID, message); + } catch (err) { + console.error('[MyPlugin] Failed to send message to Hub:', err); + } +} + +// ============================================================================= +// Message Handlers +// ============================================================================= + +async function handleToolCall( + requestId: string, + payload: ToolCallPayload +): Promise { + console.log('[MyPlugin] Tool call:', payload.toolName); + const startTime = Date.now(); + + try { + const result = executeTool(payload.toolName, payload.arguments); + const executionTimeMs = Date.now() - startTime; + + await sendToHub( + createMessage( + 'PLUGIN_TOOL_RESULT', + { result, executionTimeMs }, + requestId + ) + ); + } catch (err) { + const toolError = err instanceof ToolError ? err : new ToolError( + 'EXECUTION_FAILED', + err instanceof Error ? err.message : String(err) + ); + + await sendToHub( + createMessage( + 'PLUGIN_TOOL_ERROR', + { + code: toolError.code, + message: toolError.message, + details: toolError.details, + }, + requestId + ) + ); + } +} + +async function handlePing(requestId: string): Promise { + console.log('[MyPlugin] Ping received'); + + await sendToHub( + createMessage( + 'PLUGIN_PONG', + { + healthy: true, + uptime: Math.floor((Date.now() - startupTime) / 1000), + }, + requestId + ) + ); +} + +// ============================================================================= +// External Message Listener +// ============================================================================= + +browser.runtime.onMessageExternal.addListener( + (message: unknown, sender: browser.Runtime.MessageSender) => { + if (sender.id !== HARBOR_HUB_EXTENSION_ID) { + console.warn('[MyPlugin] Ignoring message from unknown sender:', sender.id); + return; + } + + const envelope = message as PluginMessageEnvelope; + + if (envelope.namespace !== PLUGIN_NAMESPACE) { + console.warn('[MyPlugin] Ignoring message with wrong namespace:', envelope.namespace); + return; + } + + console.log('[MyPlugin] Received message:', envelope.type); + + switch (envelope.type) { + case 'PLUGIN_TOOL_CALL': + handleToolCall(envelope.requestId, envelope.payload as ToolCallPayload); + break; + + case 'PLUGIN_PING': + handlePing(envelope.requestId); + break; + + case 'PLUGIN_REGISTER_ACK': { + const ack = envelope.payload as { success: boolean; error?: string }; + if (ack.success) { + console.log('[MyPlugin] Registration successful'); + } else { + console.error('[MyPlugin] Registration failed:', ack.error); + } + break; + } + + default: + console.warn('[MyPlugin] Unknown message type:', envelope.type); + } + } +); + +// ============================================================================= +// Registration +// ============================================================================= + +async function registerWithHub(): Promise { + console.log('[MyPlugin] Registering with Harbor Hub...'); + + const registerMessage = createMessage('PLUGIN_REGISTER', { + plugin: { + pluginId: PLUGIN_ID, + name: PLUGIN_NAME, + version: PLUGIN_VERSION, + description: 'Description of your plugin', + tools: TOOLS, + }, + }); + + try { + await browser.runtime.sendMessage(HARBOR_HUB_EXTENSION_ID, registerMessage); + console.log('[MyPlugin] Registration message sent'); + } catch (err) { + console.error('[MyPlugin] Failed to register with Hub:', err); + console.log('[MyPlugin] Hub may not be installed. Will retry on Hub startup.'); + } +} + +// Register on startup +registerWithHub(); + +console.log('[MyPlugin] Background script initialized'); +``` + +## Step 7: Build and Test + +```bash +# Build the plugin +npm run build + +# Run unit tests +npm test +``` + +## Step 8: Load in Firefox + +1. Open Firefox and go to `about:debugging` +2. Click "This Firefox" → "Load Temporary Add-on..." +3. Select your plugin's `manifest.json` +4. Open the Browser Console (Ctrl+Shift+J / Cmd+Shift+J) to see registration logs + +## Real-World Examples + +### Time Plugin + +The `harbor-plugin-time` plugin provides: + +- `time.now` - Returns current time in ISO format and epoch milliseconds +- `time.format` - Formats an epoch timestamp with locale/timezone support + +```typescript +// time.now - no arguments, returns current time +const result = await window.agent.tools.call('harbor-plugin-time@local::time.now', {}); +// { iso: "2025-01-07T22:30:00.000Z", epochMs: 1736288400000 } + +// time.format - format a specific timestamp +const result = await window.agent.tools.call('harbor-plugin-time@local::time.format', { + epochMs: 1736288400000, + locale: 'en-US', + timezone: 'America/New_York' +}); +// { formatted: "1/7/2025, 5:40:00 PM", localeString: "1/7/2025, 5:40:00 PM" } +``` + +### Decode Plugin + +The `harbor-plugin-decode` plugin provides: + +- `decode.base64_encode` - Encodes text to base64 +- `decode.base64_decode` - Decodes base64 to text +- `decode.json_pretty` - Pretty-prints JSON with configurable indentation +- `decode.jwt_decode_unsafe` - Decodes JWT header and payload (without signature verification) + +```typescript +// Base64 encoding +await window.agent.tools.call('harbor-plugin-decode@local::decode.base64_encode', { + text: 'Hello, World!' +}); +// { base64: "SGVsbG8sIFdvcmxkIQ==" } + +// JWT decoding (unsafe - no signature verification) +await window.agent.tools.call('harbor-plugin-decode@local::decode.jwt_decode_unsafe', { + jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig' +}); +// { header: { alg: "HS256", typ: "JWT" }, payload: { sub: "1234567890" } } +``` + +## Best Practices + +### Input Validation + +Always validate inputs before processing: + +```typescript +export function myTool(input: MyInput): MyResult { + // Type check + if (typeof input.text !== 'string') { + throw invalidArgument('text must be a string'); + } + + // Size check (prevent DoS) + const MAX_SIZE = 1024 * 1024; // 1MB + const size = new Blob([input.text]).size; + if (size > MAX_SIZE) { + throw inputTooLarge(size, MAX_SIZE); + } + + // Range check + if (input.indent !== undefined) { + if (!Number.isFinite(input.indent)) { + input.indent = 2; // Default + } + input.indent = Math.max(0, Math.min(8, input.indent)); + } + + // ... implementation +} +``` + +### Error Handling + +Use the standard error codes: + +| Code | When to Use | +|------|-------------| +| `INVALID_ARGUMENTS` | Input fails validation | +| `TOOL_NOT_FOUND` | Unknown tool name | +| `EXECUTION_FAILED` | Runtime error during execution | +| `INPUT_TOO_LARGE` | Input exceeds size limits | + +### Testing + +Write tests for: + +1. **Happy path** - Normal inputs produce expected outputs +2. **Edge cases** - Empty strings, zero values, boundary conditions +3. **Error cases** - Invalid types, malformed inputs, size limits +4. **Round-trip** - Encode/decode pairs work correctly + +```typescript +describe('base64', () => { + it('round-trips correctly', () => { + const original = 'Hello 👋 世界'; + const encoded = base64Encode({ text: original }); + const decoded = base64Decode({ base64: encoded.base64 }); + expect(decoded.text).toBe(original); + }); +}); +``` + +### Security Considerations + +1. **Never trust input** - Validate all arguments +2. **Limit resource usage** - Set size limits, timeouts +3. **No network access** - Plugins run in sandboxed extension context +4. **No file access** - Plugins cannot read/write files +5. **Clear naming** - Use `_unsafe` suffix for tools that bypass security (like JWT decode without verification) + +## Debugging + +### Browser Console + +Open the Browser Console (Ctrl+Shift+J / Cmd+Shift+J) to see: +- Plugin registration logs +- Tool call logs +- Error messages + +### Extension Debugging + +1. Go to `about:debugging` +2. Click your plugin's "Inspect" button +3. Use the debugger to set breakpoints + +### Common Issues + +**Plugin not registering:** +- Harbor Hub must be installed first +- Check extension ID matches in manifest.json + +**Tool calls failing:** +- Check Browser Console for error details +- Verify tool name matches definition +- Validate argument types match schema + +**Build errors:** +- Run `npm install` to install dependencies +- Check tsconfig.json paths are correct + +## Related Documentation + +- [Plugin Protocol Specification](./plugin-protocol.md) - Full protocol details +- [Plugins Quickstart](./plugins-quickstart.md) - Getting started guide +- [Developer Guide](./DEVELOPER_GUIDE.md) - Harbor extension development diff --git a/docs/plugins-quickstart.md b/docs/plugins-quickstart.md new file mode 100644 index 0000000..02e7246 --- /dev/null +++ b/docs/plugins-quickstart.md @@ -0,0 +1,260 @@ +# Harbor Plugins - Quickstart Guide + +This guide explains how to run the Harbor Hub with plugin extensions and test the plugin system locally. + +## Prerequisites + +- Firefox 112 or later +- Node.js 18+ +- Harbor extension built and ready to load + +## Overview + +Harbor supports a plugin architecture where separate Firefox extensions can provide tools that are exposed to web applications via the `window.agent` API. The architecture is: + +``` +Web Page Harbor Hub Plugin Extension +(window.agent) ←→ (aggregates tools, ←→ (provides tools, + enforces consent) executes them) +``` + +## Step 1: Build Harbor Extension + +```bash +# From the repo root +cd harbor + +# Build the extension +cd extension +npm install +npm run build +``` + +## Step 2: Build the Example Plugins + +```bash +# Build the time plugin +cd plugins/harbor-plugin-time +npm install +npm run build + +# Build the decode plugin +cd ../harbor-plugin-decode +npm install +npm run build +``` + +## Step 3: Load Extensions in Firefox + +1. Open Firefox and navigate to `about:debugging` +2. Click "This Firefox" in the left sidebar +3. Click "Load Temporary Add-on..." +4. Navigate to `harbor/extension/dist/` and select `manifest.json` +5. Click "Load Temporary Add-on..." again +6. Navigate to `harbor/plugins/harbor-plugin-time/` and select `manifest.json` +7. Optionally load `harbor-plugin-decode` as well + +You should now see both "Harbor" and "Harbor Plugin: Time" listed as temporary extensions. + +## Step 4: Verify Plugin Registration + +1. Open the Firefox Browser Console (Ctrl+Shift+J / Cmd+Shift+J) +2. Look for logs from `[TimePlugin]` showing registration: + ``` + [TimePlugin] Registering with Harbor Hub... + [TimePlugin] Registration message sent + [TimePlugin] Registration successful + ``` + +If registration fails: +- Ensure Harbor is loaded first +- Check that both extensions are enabled +- Look for error messages in the console + +## Step 5: Test Tool Discovery + +1. Open any web page (e.g., `https://example.com`) +2. Open the browser's Developer Console (F12) +3. First, request permission to list tools: + +```javascript +// Request permission to list tools +const result = await window.agent.requestPermissions({ + scopes: ['mcp:tools.list', 'mcp:tools.call'], + reason: 'Testing Harbor plugins' +}); +console.log('Permission result:', result); +``` + +4. A Harbor consent popup will appear. Click "Allow Always" or "Allow Once". + +5. Now list available tools: + +```javascript +const tools = await window.agent.tools.list(); +console.log('Available tools:', tools); + +// Filter to see plugin tools +const pluginTools = tools.filter(t => t.name.includes('::')); +console.log('Plugin tools:', pluginTools); +``` + +You should see: +- `harbor-plugin-time@local::time.now` +- `harbor-plugin-time@local::time.format` + +## Step 6: Test Tool Execution + +Before calling plugin tools, you need to grant plugin-specific permission: + +```javascript +// Call the time.now tool +const timeResult = await window.agent.tools.call( + 'harbor-plugin-time@local::time.now', + { timezone: 'America/New_York' } +); +console.log('Time result:', timeResult); +// Expected: { formatted: "Wednesday, January 8, 2025 at 10:30:00 AM EST", date: "2025-01-08", ... } + +// Call time.format +const formatResult = await window.agent.tools.call( + 'harbor-plugin-time@local::time.format', + { epochMs: Date.now(), timeZone: 'UTC' } +); +console.log('Format result:', formatResult); +``` + +## Troubleshooting + +### Plugin Not Registering + +1. Check the Browser Console for errors from `[TimePlugin]` +2. Ensure Harbor extension is loaded and active +3. Try reloading both extensions + +### Tools Not Appearing + +1. Verify the plugin status in Harbor sidebar (if available) +2. Check if the plugin is in the allowlist (empty allowlist = all allowed) +3. Restart both extensions + +### Permission Denied Errors + +1. Call `window.agent.requestPermissions()` first +2. Check that you granted the correct scopes +3. For plugin tools, ensure plugin consent is granted + +### Timeouts + +1. Plugin tools have a 30-second timeout +2. Check the Browser Console for timeout errors +3. Verify the plugin is responding to pings + +## Creating Your Own Plugin + +See [`docs/plugins-dev.md`](./plugins-dev.md) for the full development guide and [`docs/plugin-protocol.md`](./plugin-protocol.md) for the protocol specification. + +Quick template: + +### manifest.json + +```json +{ + "manifest_version": 3, + "name": "My Harbor Plugin", + "version": "1.0.0", + "description": "My custom Harbor plugin", + "permissions": [], + "background": { + "scripts": ["dist/background.js"], + "type": "module" + }, + "browser_specific_settings": { + "gecko": { + "id": "my-plugin@example.com", + "strict_min_version": "112.0" + } + } +} +``` + +### background.ts + +```typescript +const HARBOR_HUB_ID = 'raffi.krikorian.harbor@gmail.com'; + +// Tool definitions +const tools = [ + { + name: 'myTool', + title: 'My Tool', + description: 'Does something useful', + inputSchema: { + type: 'object', + properties: { + input: { type: 'string' } + }, + required: ['input'] + } + } +]; + +// Register with Harbor on startup +browser.runtime.sendMessage(HARBOR_HUB_ID, { + namespace: 'harbor-plugin', + protocolVersion: 'harbor-plugin/v1', + type: 'PLUGIN_REGISTER', + requestId: `reg-${Date.now()}`, + timestamp: Date.now(), + payload: { + plugin: { + extensionId: 'my-plugin@example.com', + name: 'My Harbor Plugin', + version: '1.0.0', + tools: tools + } + } +}); + +// Handle messages from Harbor +browser.runtime.onMessageExternal.addListener((message, sender) => { + if (sender.id !== HARBOR_HUB_ID) return; + + if (message.type === 'PLUGIN_TOOL_CALL') { + // Execute the tool and send result back + const result = executeMyTool(message.payload.toolName, message.payload.arguments); + + browser.runtime.sendMessage(HARBOR_HUB_ID, { + namespace: 'harbor-plugin', + protocolVersion: 'harbor-plugin/v1', + type: 'PLUGIN_TOOL_RESULT', + requestId: message.requestId, + timestamp: Date.now(), + payload: { result } + }); + } + + if (message.type === 'PLUGIN_PING') { + browser.runtime.sendMessage(HARBOR_HUB_ID, { + namespace: 'harbor-plugin', + protocolVersion: 'harbor-plugin/v1', + type: 'PLUGIN_PONG', + requestId: message.requestId, + timestamp: Date.now(), + payload: { healthy: true } + }); + } +}); + +function executeMyTool(toolName: string, args: Record) { + // Your tool implementation here + return { success: true, data: args.input }; +} +``` + +## Next Steps + +- Read the [Plugin Development Guide](./plugins-dev.md) for full details +- Read the [Plugin Protocol Specification](./plugin-protocol.md) for the wire protocol +- Check out the example plugins in `plugins/harbor-plugin-time/` and `plugins/harbor-plugin-decode/` +- Build your own plugin! diff --git a/extension/src/background.ts b/extension/src/background.ts index 4cfe64c..e588c66 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -11,10 +11,24 @@ import { setMessageCallback, getConnectionState, BridgeResponse, + HarborMessage, REQUEST_TIMEOUT_MS, DOCKER_TIMEOUT_MS, CHAT_TIMEOUT_MS, } from './native-connection'; +import { + initializePluginRouter, + getAllPlugins, + getRegistryStats, + getPluginAllowlist, + setPluginAllowlist, + enablePlugin, + disablePlugin, + revokePluginPermissions, + getAllPluginPermissions, + getAggregatedPluginTools, + callPluginTool, +} from './plugins'; // BUILD MARKER - if you don't see this, the extension is using cached code! // Harbor extension background script @@ -898,6 +912,7 @@ browser.runtime.onMessage.addListener( type: 'chat_create_session', request_id: generateRequestId(), enabled_servers: msg.enabled_servers, + plugin_tools: msg.plugin_tools, name: msg.name, system_prompt: msg.system_prompt, max_iterations: msg.max_iterations, @@ -921,6 +936,19 @@ browser.runtime.onMessage.addListener( }); } + if (msg.type === 'chat_continue_with_plugin_results') { + // Continue chat orchestration with plugin tool results + return sendToBridge({ + type: 'chat_continue_with_plugin_results', + request_id: generateRequestId(), + session_id: msg.session_id, + plugin_results: msg.plugin_results, + }, CHAT_TIMEOUT_MS).catch((err) => { + console.error('[Background] chat_continue_with_plugin_results error:', err); + return { type: 'error', error: { message: err instanceof Error ? err.message : 'Failed to continue chat' } }; + }); + } + if (msg.type === 'chat_get_session') { return sendToBridge({ type: 'chat_get_session', @@ -1016,21 +1044,21 @@ browser.runtime.onMessage.addListener( method: (msg.method as string) || 'GET', headers: (msg.headers as Record) || {}, }); - + console.log('[proxy_fetch] Response status:', response.status); - + if (!response.ok) { console.log('[proxy_fetch] Response not ok:', response.statusText); - return { - ok: false, - status: response.status, - error: response.statusText + return { + ok: false, + status: response.status, + error: response.statusText }; } - + const contentType = response.headers.get('content-type') || ''; let data: string | object; - + if (contentType.includes('application/json')) { data = await response.json(); console.log('[proxy_fetch] Parsed JSON, keys:', Object.keys(data as object)); @@ -1038,19 +1066,150 @@ browser.runtime.onMessage.addListener( data = await response.text(); console.log('[proxy_fetch] Got text, length:', (data as string).length); } - + return { ok: true, status: response.status, data }; } catch (err) { console.error('[proxy_fetch] Error:', err); - return { - ok: false, - status: 0, - error: err instanceof Error ? err.message : 'Fetch failed' + return { + ok: false, + status: 0, + error: err instanceof Error ? err.message : 'Fetch failed' }; } })(); } + // ========================================================================== + // Plugin System Messages + // ========================================================================== + + // List all registered plugins + if (msg.type === 'list_plugins') { + return (async () => { + try { + const plugins = await getAllPlugins(); + const stats = await getRegistryStats(); + return { type: 'list_plugins_result', plugins, stats }; + } catch (err) { + console.error('Failed to list plugins:', err); + return { type: 'error', error: { message: 'Failed to list plugins' } }; + } + })(); + } + + // Get plugin allowlist + if (msg.type === 'get_plugin_allowlist') { + return (async () => { + try { + const allowlist = await getPluginAllowlist(); + return { type: 'get_plugin_allowlist_result', allowlist }; + } catch (err) { + console.error('Failed to get plugin allowlist:', err); + return { type: 'error', error: { message: 'Failed to get plugin allowlist' } }; + } + })(); + } + + // Set plugin allowlist + if (msg.type === 'set_plugin_allowlist') { + return (async () => { + try { + await setPluginAllowlist(msg.allowlist as string[]); + return { type: 'set_plugin_allowlist_result', success: true }; + } catch (err) { + console.error('Failed to set plugin allowlist:', err); + return { type: 'error', error: { message: 'Failed to set plugin allowlist' } }; + } + })(); + } + + // Enable a plugin + if (msg.type === 'enable_plugin') { + return (async () => { + try { + const success = await enablePlugin(msg.plugin_id as string); + return { type: 'enable_plugin_result', success }; + } catch (err) { + console.error('Failed to enable plugin:', err); + return { type: 'error', error: { message: 'Failed to enable plugin' } }; + } + })(); + } + + // Disable a plugin + if (msg.type === 'disable_plugin') { + return (async () => { + try { + const success = await disablePlugin(msg.plugin_id as string, msg.reason as string); + return { type: 'disable_plugin_result', success }; + } catch (err) { + console.error('Failed to disable plugin:', err); + return { type: 'error', error: { message: 'Failed to disable plugin' } }; + } + })(); + } + + // Get all plugin permissions + if (msg.type === 'list_plugin_permissions') { + return (async () => { + try { + const permissions = await getAllPluginPermissions(); + return { type: 'list_plugin_permissions_result', permissions }; + } catch (err) { + console.error('Failed to list plugin permissions:', err); + return { type: 'error', error: { message: 'Failed to list plugin permissions' } }; + } + })(); + } + + // Revoke plugin permissions for an origin + if (msg.type === 'revoke_plugin_permissions') { + return (async () => { + try { + await revokePluginPermissions(msg.origin as string); + return { type: 'revoke_plugin_permissions_result', success: true }; + } catch (err) { + console.error('Failed to revoke plugin permissions:', err); + return { type: 'error', error: { message: 'Failed to revoke plugin permissions' } }; + } + })(); + } + + // Get aggregated plugin tools (for sidebar display) + if (msg.type === 'list_plugin_tools') { + return (async () => { + try { + const tools = await getAggregatedPluginTools(); + return { type: 'list_plugin_tools_result', tools }; + } catch (err) { + console.error('Failed to list plugin tools:', err); + return { type: 'error', error: { message: 'Failed to list plugin tools' } }; + } + })(); + } + + // Execute a plugin tool (for internal chat) + if (msg.type === 'execute_plugin_tool') { + return (async () => { + try { + const pluginId = msg.plugin_id as string; + const toolName = msg.tool_name as string; + const args = (msg.arguments as Record) || {}; + const callingOrigin = msg.calling_origin as string | undefined; + + if (!pluginId || !toolName) { + return { type: 'error', error: { message: 'Missing plugin_id or tool_name' } }; + } + + const result = await callPluginTool(pluginId, toolName, args, callingOrigin); + return { type: 'execute_plugin_tool_result', result }; + } catch (err) { + console.error('Failed to execute plugin tool:', err); + return { type: 'error', error: { message: err instanceof Error ? err.message : 'Failed to execute plugin tool' } }; + } + })(); + } + return Promise.resolve(undefined); } ); @@ -1101,4 +1260,7 @@ autoDetectLLM(); // Initialize the JS AI Provider router setupProviderRouter(); -console.log('Harbor background script initialized'); +// Initialize the Plugin router for extension-to-extension messaging +initializePluginRouter(); + +console.log('Harbor background script initialized (with plugin support)'); diff --git a/extension/src/bridge-api.ts b/extension/src/bridge-api.ts index e46dc10..b118ff3 100644 --- a/extension/src/bridge-api.ts +++ b/extension/src/bridge-api.ts @@ -78,6 +78,26 @@ export interface ChatSession { config?: { maxIterations?: number }; } +export interface PluginToolDefinition { + pluginId: string; + name: string; + description?: string; + inputSchema?: Record; +} + +export interface PendingPluginToolCall { + id: string; + pluginId: string; + toolName: string; + arguments: Record; +} + +export interface PluginToolResult { + toolCallId: string; + content: string; + isError: boolean; +} + export interface CreateChatSessionResponse { type: string; session?: ChatSession; @@ -86,6 +106,7 @@ export interface CreateChatSessionResponse { export async function createChatSession(options: { enabledServers: string[]; + pluginTools?: PluginToolDefinition[]; name?: string; systemPrompt?: string; maxIterations?: number; @@ -95,6 +116,7 @@ export async function createChatSession(options: { type: 'chat_create_session', request_id: generateRequestId(), enabled_servers: options.enabledServers, + plugin_tools: options.pluginTools, name: options.name, system_prompt: options.systemPrompt, max_iterations: options.maxIterations, @@ -118,6 +140,8 @@ export interface ChatSendMessageResponse { }>; iterations?: number; reachedMaxIterations?: boolean; + paused?: boolean; + pendingPluginToolCalls?: PendingPluginToolCall[]; error?: { message: string }; } @@ -141,6 +165,24 @@ export async function sendChatMessage(options: { } } +export async function continueChatWithPluginResults(options: { + sessionId: string; + pluginResults: PluginToolResult[]; +}): Promise { + try { + const response = await sendToBridge({ + type: 'chat_continue_with_plugin_results', + request_id: generateRequestId(), + session_id: options.sessionId, + plugin_results: options.pluginResults, + }, CHAT_TIMEOUT_MS); + return response as ChatSendMessageResponse; + } catch (err) { + console.error('[BridgeAPI] continueChatWithPluginResults error:', err); + return { type: 'error', error: { message: err instanceof Error ? err.message : 'Failed to continue chat' } }; + } +} + export async function deleteChatSession(sessionId: string): Promise { try { await sendToBridge({ diff --git a/extension/src/plugins/__tests__/consent.test.ts b/extension/src/plugins/__tests__/consent.test.ts new file mode 100644 index 0000000..8c0c995 --- /dev/null +++ b/extension/src/plugins/__tests__/consent.test.ts @@ -0,0 +1,512 @@ +/** + * Plugin Consent Tests + * + * Tests for plugin tool permission management. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { __mockStorage, __clearMockStorage } from '../../provider/__tests__/__mocks__/webextension-polyfill'; +import { + hasPluginToolPermission, + hasAnyPluginPermission, + getAllowedPluginTools, + getPluginConsentStatus, + grantPluginPermission, + revokePluginPermissions, + clearPluginTabGrants, + checkPluginConsent, + getAllPluginPermissions, + __clearTemporaryGrants, + __getTemporaryGrants, +} from '../consent'; + +describe('Plugin Consent', () => { + const TEST_ORIGIN = 'https://example.com'; + const TEST_TOOL = 'test-plugin@example.com::echo'; + + beforeEach(() => { + __clearMockStorage(); + __clearTemporaryGrants(); + }); + + describe('hasPluginToolPermission', () => { + it('should return false when no permission granted', async () => { + const result = await hasPluginToolPermission(TEST_ORIGIN, TEST_TOOL); + expect(result).toBe(false); + }); + + it('should return true when specific tool is allowed', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: [TEST_TOOL], + }); + + const result = await hasPluginToolPermission(TEST_ORIGIN, TEST_TOOL); + expect(result).toBe(true); + }); + + it('should return true when allowAll is granted', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + allowAll: true, + }); + + const result = await hasPluginToolPermission(TEST_ORIGIN, TEST_TOOL); + expect(result).toBe(true); + }); + + it('should return false for non-allowed tool', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: ['other-plugin@example.com::other'], + }); + + const result = await hasPluginToolPermission(TEST_ORIGIN, TEST_TOOL); + expect(result).toBe(false); + }); + + it('should return true for extension origin', async () => { + const result = await hasPluginToolPermission('extension', TEST_TOOL); + expect(result).toBe(true); + }); + }); + + describe('hasAnyPluginPermission', () => { + it('should return false when no permissions exist', async () => { + const result = await hasAnyPluginPermission(TEST_ORIGIN); + expect(result).toBe(false); + }); + + it('should return true when allowAll is granted', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + allowAll: true, + }); + + const result = await hasAnyPluginPermission(TEST_ORIGIN); + expect(result).toBe(true); + }); + + it('should return true when specific tools are allowed', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: [TEST_TOOL], + }); + + const result = await hasAnyPluginPermission(TEST_ORIGIN); + expect(result).toBe(true); + }); + + it('should return true for extension origin', async () => { + const result = await hasAnyPluginPermission('extension'); + expect(result).toBe(true); + }); + }); + + describe('getAllowedPluginTools', () => { + it('should return empty tools when no permissions', async () => { + const result = await getAllowedPluginTools(TEST_ORIGIN); + expect(result.allowAll).toBe(false); + expect(result.tools).toEqual([]); + }); + + it('should return allowAll: true when granted', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + allowAll: true, + }); + + const result = await getAllowedPluginTools(TEST_ORIGIN); + expect(result.allowAll).toBe(true); + }); + + it('should return specific tools when granted', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: [TEST_TOOL, 'other-tool'], + }); + + const result = await getAllowedPluginTools(TEST_ORIGIN); + expect(result.allowAll).toBe(false); + expect(result.tools).toContain(TEST_TOOL); + expect(result.tools).toContain('other-tool'); + }); + + it('should merge temporary and persistent grants', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: ['tool1'], + }); + await grantPluginPermission(TEST_ORIGIN, { + mode: 'once', + tools: ['tool2'], + }); + + const result = await getAllowedPluginTools(TEST_ORIGIN); + expect(result.tools).toContain('tool1'); + expect(result.tools).toContain('tool2'); + }); + }); + + describe('getPluginConsentStatus', () => { + it('should return no consent for new origin', async () => { + const status = await getPluginConsentStatus(TEST_ORIGIN); + + expect(status.hasConsent).toBe(false); + expect(status.allowAll).toBe(false); + expect(status.allowedTools).toEqual([]); + expect(status.grantType).toBe('none'); + }); + + it('should return consent status for persistent grant', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: [TEST_TOOL], + }); + + const status = await getPluginConsentStatus(TEST_ORIGIN); + + expect(status.hasConsent).toBe(true); + expect(status.grantType).toBe('always'); + expect(status.allowedTools).toContain(TEST_TOOL); + }); + + it('should return consent status for temporary grant', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'once', + allowAll: true, + }); + + const status = await getPluginConsentStatus(TEST_ORIGIN); + + expect(status.hasConsent).toBe(true); + expect(status.grantType).toBe('once'); + expect(status.allowAll).toBe(true); + }); + + it('should prioritize temporary grants over persistent', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: ['persistent-tool'], + }); + await grantPluginPermission(TEST_ORIGIN, { + mode: 'once', + tools: ['temp-tool'], + }); + + const status = await getPluginConsentStatus(TEST_ORIGIN); + expect(status.grantType).toBe('once'); + }); + }); + + describe('grantPluginPermission', () => { + describe('mode: always', () => { + it('should persist permission to storage', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: [TEST_TOOL], + }); + + // Verify it persisted + const stored = __mockStorage['harbor_plugin_permissions'] as any; + expect(stored.permissions[TEST_ORIGIN]).toBeDefined(); + expect(stored.permissions[TEST_ORIGIN].allowedTools).toContain(TEST_TOOL); + }); + + it('should merge with existing permissions', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: ['tool1'], + }); + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: ['tool2'], + }); + + const result = await getAllowedPluginTools(TEST_ORIGIN); + expect(result.tools).toContain('tool1'); + expect(result.tools).toContain('tool2'); + }); + }); + + describe('mode: once', () => { + it('should store in temporary grants', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'once', + tools: [TEST_TOOL], + }); + + const tempGrants = __getTemporaryGrants(); + const grant = tempGrants.get(`plugin-temp:${TEST_ORIGIN}`); + expect(grant).toBeDefined(); + expect(grant?.allowedTools).toContain(TEST_TOOL); + }); + + it('should set expiry time', async () => { + const before = Date.now(); + await grantPluginPermission(TEST_ORIGIN, { + mode: 'once', + tools: [TEST_TOOL], + }); + const after = Date.now(); + + const tempGrants = __getTemporaryGrants(); + const grant = tempGrants.get(`plugin-temp:${TEST_ORIGIN}`); + + // Should expire in ~10 minutes + const TTL = 10 * 60 * 1000; + expect(grant?.expiresAt).toBeGreaterThanOrEqual(before + TTL); + expect(grant?.expiresAt).toBeLessThanOrEqual(after + TTL + 100); + }); + + it('should store tabId when provided', async () => { + const TAB_ID = 12345; + await grantPluginPermission(TEST_ORIGIN, { + mode: 'once', + tools: [TEST_TOOL], + tabId: TAB_ID, + }); + + const tempGrants = __getTemporaryGrants(); + const grant = tempGrants.get(`plugin-temp:${TEST_ORIGIN}`); + expect(grant?.tabId).toBe(TAB_ID); + }); + }); + }); + + describe('revokePluginPermissions', () => { + it('should remove persistent permissions', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: [TEST_TOOL], + }); + + await revokePluginPermissions(TEST_ORIGIN); + + const result = await hasAnyPluginPermission(TEST_ORIGIN); + expect(result).toBe(false); + }); + + it('should remove temporary grants', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'once', + tools: [TEST_TOOL], + }); + + await revokePluginPermissions(TEST_ORIGIN); + + const tempGrants = __getTemporaryGrants(); + expect(tempGrants.has(`plugin-temp:${TEST_ORIGIN}`)).toBe(false); + }); + + it('should not affect other origins', async () => { + const OTHER_ORIGIN = 'https://other.com'; + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: [TEST_TOOL], + }); + await grantPluginPermission(OTHER_ORIGIN, { + mode: 'always', + tools: [TEST_TOOL], + }); + + await revokePluginPermissions(TEST_ORIGIN); + + expect(await hasAnyPluginPermission(TEST_ORIGIN)).toBe(false); + expect(await hasAnyPluginPermission(OTHER_ORIGIN)).toBe(true); + }); + }); + + describe('clearPluginTabGrants', () => { + it('should remove temporary grants for specific tab', async () => { + const TAB_ID = 12345; + await grantPluginPermission(TEST_ORIGIN, { + mode: 'once', + tools: [TEST_TOOL], + tabId: TAB_ID, + }); + + clearPluginTabGrants(TAB_ID); + + const tempGrants = __getTemporaryGrants(); + expect(tempGrants.has(`plugin-temp:${TEST_ORIGIN}`)).toBe(false); + }); + + it('should not remove grants from other tabs', async () => { + const TAB_1 = 111; + const TAB_2 = 222; + const ORIGIN_1 = 'https://site1.com'; + const ORIGIN_2 = 'https://site2.com'; + + await grantPluginPermission(ORIGIN_1, { + mode: 'once', + tools: ['tool1'], + tabId: TAB_1, + }); + await grantPluginPermission(ORIGIN_2, { + mode: 'once', + tools: ['tool2'], + tabId: TAB_2, + }); + + clearPluginTabGrants(TAB_1); + + expect(await hasPluginToolPermission(ORIGIN_1, 'tool1')).toBe(false); + expect(await hasPluginToolPermission(ORIGIN_2, 'tool2')).toBe(true); + }); + + it('should not affect persistent grants', async () => { + const TAB_ID = 12345; + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: ['persistent-tool'], + }); + await grantPluginPermission(TEST_ORIGIN, { + mode: 'once', + tools: ['temp-tool'], + tabId: TAB_ID, + }); + + clearPluginTabGrants(TAB_ID); + + expect(await hasPluginToolPermission(TEST_ORIGIN, 'persistent-tool')).toBe(true); + expect(await hasPluginToolPermission(TEST_ORIGIN, 'temp-tool')).toBe(false); + }); + }); + + describe('checkPluginConsent', () => { + it('should return granted for extension origin', async () => { + const result = await checkPluginConsent('extension'); + expect(result.granted).toBe(true); + expect(result.missingTools).toEqual([]); + }); + + it('should return not granted when no consent exists', async () => { + const result = await checkPluginConsent(TEST_ORIGIN, [TEST_TOOL]); + expect(result.granted).toBe(false); + expect(result.missingTools).toContain(TEST_TOOL); + }); + + it('should return granted when all requested tools are allowed', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: [TEST_TOOL, 'other-tool'], + }); + + const result = await checkPluginConsent(TEST_ORIGIN, [TEST_TOOL]); + expect(result.granted).toBe(true); + expect(result.missingTools).toEqual([]); + }); + + it('should return missing tools when some are not allowed', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: [TEST_TOOL], + }); + + const result = await checkPluginConsent(TEST_ORIGIN, [TEST_TOOL, 'other-tool']); + expect(result.granted).toBe(false); + expect(result.missingTools).toEqual(['other-tool']); + }); + + it('should return granted when allowAll is set', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + allowAll: true, + }); + + const result = await checkPluginConsent(TEST_ORIGIN, ['any-tool', 'another-tool']); + expect(result.granted).toBe(true); + expect(result.missingTools).toEqual([]); + }); + }); + + describe('getAllPluginPermissions', () => { + it('should return empty array when no permissions', async () => { + const permissions = await getAllPluginPermissions(); + expect(permissions).toEqual([]); + }); + + it('should return persistent permissions', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: [TEST_TOOL], + }); + + const permissions = await getAllPluginPermissions(); + expect(permissions.length).toBe(1); + expect(permissions[0].origin).toBe(TEST_ORIGIN); + expect(permissions[0].grantType).toBe('always'); + }); + + it('should return temporary permissions', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'once', + allowAll: true, + }); + + const permissions = await getAllPluginPermissions(); + expect(permissions.length).toBe(1); + expect(permissions[0].grantType).toBe('once'); + expect(permissions[0].expiresAt).toBeDefined(); + }); + + it('should merge permissions from same origin', async () => { + await grantPluginPermission(TEST_ORIGIN, { + mode: 'always', + tools: ['tool1'], + }); + await grantPluginPermission(TEST_ORIGIN, { + mode: 'once', + tools: ['tool2'], + }); + + const permissions = await getAllPluginPermissions(); + expect(permissions.length).toBe(1); + expect(permissions[0].allowedTools).toContain('tool1'); + expect(permissions[0].allowedTools).toContain('tool2'); + }); + + it('should list multiple origins', async () => { + const ORIGIN_1 = 'https://site1.com'; + const ORIGIN_2 = 'https://site2.com'; + + await grantPluginPermission(ORIGIN_1, { + mode: 'always', + tools: ['tool1'], + }); + await grantPluginPermission(ORIGIN_2, { + mode: 'once', + allowAll: true, + }); + + const permissions = await getAllPluginPermissions(); + expect(permissions.length).toBe(2); + + const origins = permissions.map((p) => p.origin); + expect(origins).toContain(ORIGIN_1); + expect(origins).toContain(ORIGIN_2); + }); + }); + + describe('Origin Isolation', () => { + it('should maintain separate permissions per origin', async () => { + const ORIGIN_A = 'https://app-a.com'; + const ORIGIN_B = 'https://app-b.com'; + + await grantPluginPermission(ORIGIN_A, { + mode: 'always', + tools: ['toolA'], + }); + await grantPluginPermission(ORIGIN_B, { + mode: 'always', + tools: ['toolB'], + }); + + expect(await hasPluginToolPermission(ORIGIN_A, 'toolA')).toBe(true); + expect(await hasPluginToolPermission(ORIGIN_A, 'toolB')).toBe(false); + expect(await hasPluginToolPermission(ORIGIN_B, 'toolA')).toBe(false); + expect(await hasPluginToolPermission(ORIGIN_B, 'toolB')).toBe(true); + }); + }); +}); diff --git a/extension/src/plugins/__tests__/registry.test.ts b/extension/src/plugins/__tests__/registry.test.ts new file mode 100644 index 0000000..eb84787 --- /dev/null +++ b/extension/src/plugins/__tests__/registry.test.ts @@ -0,0 +1,526 @@ +/** + * Plugin Registry Tests + * + * Tests for plugin registration, status management, and tool aggregation. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { __mockStorage, __clearMockStorage } from '../../provider/__tests__/__mocks__/webextension-polyfill'; +import type { PluginDescriptor, PluginToolDefinition } from '../types'; +import { + loadRegistry, + isPluginAllowed, + getPluginAllowlist, + setPluginAllowlist, + addToAllowlist, + removeFromAllowlist, + registerPlugin, + unregisterPlugin, + getPlugin, + getAllPlugins, + getActivePlugins, + isPluginRegistered, + updatePluginStatus, + recordPluginActivity, + recordFailedPing, + enablePlugin, + disablePlugin, + updatePluginTools, + getAggregatedPluginTools, + findToolPlugin, + getRegistryStats, + cleanupStalePlugins, + __clearRegistryCache, +} from '../registry'; + +describe('Plugin Registry', () => { + const TEST_PLUGIN_ID = 'test-plugin@example.com'; + + const testTool: PluginToolDefinition = { + name: 'echo', + title: 'Echo', + description: 'Returns the input', + inputSchema: { type: 'object', properties: { message: { type: 'string' } } }, + }; + + const testPlugin: PluginDescriptor = { + extensionId: TEST_PLUGIN_ID, + name: 'Test Plugin', + version: '1.0.0', + description: 'A test plugin', + tools: [testTool], + }; + + beforeEach(async () => { + __clearMockStorage(); + __clearRegistryCache(); + // Force fresh load + await loadRegistry(); + __clearRegistryCache(); + }); + + describe('loadRegistry', () => { + it('should return empty registry when storage is empty', async () => { + const registry = await loadRegistry(); + expect(registry.version).toBe(1); + expect(registry.plugins).toEqual({}); + expect(registry.allowlist).toEqual([]); + }); + + it('should load existing registry from storage', async () => { + __mockStorage['harbor_plugin_registry'] = { + version: 1, + plugins: { [TEST_PLUGIN_ID]: { descriptor: testPlugin, status: 'active' } }, + allowlist: ['allowed-plugin@example.com'], + updatedAt: Date.now(), + }; + + // Clear cache to force reload + __clearRegistryCache(); + + const registry = await loadRegistry(); + expect(registry.plugins[TEST_PLUGIN_ID]).toBeDefined(); + expect(registry.allowlist).toContain('allowed-plugin@example.com'); + }); + }); + + describe('Allowlist Management', () => { + describe('isPluginAllowed', () => { + it('should return true when allowlist is empty (allow all)', async () => { + const allowed = await isPluginAllowed(TEST_PLUGIN_ID); + expect(allowed).toBe(true); + }); + + it('should return true when plugin is in allowlist', async () => { + await setPluginAllowlist([TEST_PLUGIN_ID]); + const allowed = await isPluginAllowed(TEST_PLUGIN_ID); + expect(allowed).toBe(true); + }); + + it('should return false when plugin is not in non-empty allowlist', async () => { + await setPluginAllowlist(['other-plugin@example.com']); + const allowed = await isPluginAllowed(TEST_PLUGIN_ID); + expect(allowed).toBe(false); + }); + }); + + describe('setPluginAllowlist', () => { + it('should set the allowlist', async () => { + await setPluginAllowlist(['plugin1@example.com', 'plugin2@example.com']); + const allowlist = await getPluginAllowlist(); + expect(allowlist).toEqual(['plugin1@example.com', 'plugin2@example.com']); + }); + + it('should clear allowlist when set to empty array', async () => { + await setPluginAllowlist(['plugin1@example.com']); + await setPluginAllowlist([]); + const allowlist = await getPluginAllowlist(); + expect(allowlist).toEqual([]); + }); + }); + + describe('addToAllowlist', () => { + it('should add plugin to allowlist', async () => { + await addToAllowlist(TEST_PLUGIN_ID); + const allowlist = await getPluginAllowlist(); + expect(allowlist).toContain(TEST_PLUGIN_ID); + }); + + it('should not add duplicate entries', async () => { + await addToAllowlist(TEST_PLUGIN_ID); + await addToAllowlist(TEST_PLUGIN_ID); + const allowlist = await getPluginAllowlist(); + expect(allowlist.filter((id) => id === TEST_PLUGIN_ID).length).toBe(1); + }); + }); + + describe('removeFromAllowlist', () => { + it('should remove plugin from allowlist', async () => { + await setPluginAllowlist([TEST_PLUGIN_ID, 'other@example.com']); + await removeFromAllowlist(TEST_PLUGIN_ID); + const allowlist = await getPluginAllowlist(); + expect(allowlist).not.toContain(TEST_PLUGIN_ID); + expect(allowlist).toContain('other@example.com'); + }); + + it('should do nothing if plugin not in allowlist', async () => { + await setPluginAllowlist(['other@example.com']); + await removeFromAllowlist(TEST_PLUGIN_ID); + const allowlist = await getPluginAllowlist(); + expect(allowlist).toEqual(['other@example.com']); + }); + }); + }); + + describe('Plugin Registration', () => { + describe('registerPlugin', () => { + it('should register a new plugin', async () => { + const entry = await registerPlugin(testPlugin); + + expect(entry.descriptor).toEqual(testPlugin); + expect(entry.status).toBe('active'); + expect(entry.registeredAt).toBeDefined(); + expect(entry.lastSeen).toBeDefined(); + expect(entry.failedPings).toBe(0); + }); + + it('should update existing plugin registration', async () => { + await registerPlugin(testPlugin); + + const updatedPlugin = { ...testPlugin, version: '2.0.0' }; + const entry = await registerPlugin(updatedPlugin); + + expect(entry.descriptor.version).toBe('2.0.0'); + }); + + it('should preserve registeredAt when updating', async () => { + const firstEntry = await registerPlugin(testPlugin); + const originalRegisteredAt = firstEntry.registeredAt; + + // Wait a bit and re-register + await new Promise((r) => setTimeout(r, 10)); + const secondEntry = await registerPlugin({ ...testPlugin, version: '2.0.0' }); + + expect(secondEntry.registeredAt).toBe(originalRegisteredAt); + }); + }); + + describe('unregisterPlugin', () => { + it('should unregister an existing plugin', async () => { + await registerPlugin(testPlugin); + const success = await unregisterPlugin(TEST_PLUGIN_ID); + + expect(success).toBe(true); + expect(await getPlugin(TEST_PLUGIN_ID)).toBeNull(); + }); + + it('should return false for non-existent plugin', async () => { + const success = await unregisterPlugin('non-existent@example.com'); + expect(success).toBe(false); + }); + }); + + describe('getPlugin', () => { + it('should return registered plugin', async () => { + await registerPlugin(testPlugin); + const plugin = await getPlugin(TEST_PLUGIN_ID); + + expect(plugin).not.toBeNull(); + expect(plugin?.descriptor.name).toBe('Test Plugin'); + }); + + it('should return null for non-existent plugin', async () => { + const plugin = await getPlugin('non-existent@example.com'); + expect(plugin).toBeNull(); + }); + }); + + describe('getAllPlugins', () => { + it('should return all registered plugins', async () => { + const id1 = `getall-1-${Date.now()}@example.com`; + const id2 = `getall-2-${Date.now()}@example.com`; + await registerPlugin({ ...testPlugin, extensionId: id1 }); + await registerPlugin({ ...testPlugin, extensionId: id2, name: 'Plugin 2' }); + + const plugins = await getAllPlugins(); + const testPlugins = plugins.filter((p) => p.descriptor.extensionId.startsWith('getall-')); + expect(testPlugins.length).toBe(2); + }); + + it('should include plugins when listing', async () => { + // This test verifies that getAllPlugins returns an array + // (exact count depends on other tests' state) + const plugins = await getAllPlugins(); + expect(Array.isArray(plugins)).toBe(true); + }); + }); + + describe('getActivePlugins', () => { + it('should return only active plugins', async () => { + const id1 = `active-1-${Date.now()}@example.com`; + const id2 = `active-2-${Date.now()}@example.com`; + await registerPlugin({ ...testPlugin, extensionId: id1 }); + await registerPlugin({ ...testPlugin, extensionId: id2, name: 'Plugin 2' }); + await disablePlugin(id1); + + const plugins = await getActivePlugins(); + const activeTestPlugins = plugins.filter((p) => p.descriptor.extensionId.startsWith('active-')); + expect(activeTestPlugins.length).toBe(1); + expect(activeTestPlugins[0].descriptor.extensionId).toBe(id2); + }); + }); + + describe('isPluginRegistered', () => { + it('should return true for registered plugin', async () => { + const uniqueId = `test-${Date.now()}@example.com`; + await registerPlugin({ ...testPlugin, extensionId: uniqueId }); + expect(await isPluginRegistered(uniqueId)).toBe(true); + }); + + it('should return false for non-registered plugin', async () => { + const nonExistentId = `non-existent-${Date.now()}@example.com`; + expect(await isPluginRegistered(nonExistentId)).toBe(false); + }); + }); + }); + + describe('Plugin Status Management', () => { + beforeEach(async () => { + await registerPlugin(testPlugin); + }); + + describe('updatePluginStatus', () => { + it('should update plugin status', async () => { + await updatePluginStatus(TEST_PLUGIN_ID, 'disabled'); + const plugin = await getPlugin(TEST_PLUGIN_ID); + expect(plugin?.status).toBe('disabled'); + }); + + it('should set error message when provided', async () => { + await updatePluginStatus(TEST_PLUGIN_ID, 'error', 'Something went wrong'); + const plugin = await getPlugin(TEST_PLUGIN_ID); + expect(plugin?.status).toBe('error'); + expect(plugin?.lastError).toBe('Something went wrong'); + }); + + it('should clear error when status is active', async () => { + await updatePluginStatus(TEST_PLUGIN_ID, 'error', 'Some error'); + await updatePluginStatus(TEST_PLUGIN_ID, 'active'); + const plugin = await getPlugin(TEST_PLUGIN_ID); + expect(plugin?.lastError).toBeUndefined(); + }); + }); + + describe('recordPluginActivity', () => { + it('should update lastSeen timestamp', async () => { + const before = Date.now(); + await recordPluginActivity(TEST_PLUGIN_ID); + const plugin = await getPlugin(TEST_PLUGIN_ID); + expect(plugin?.lastSeen).toBeGreaterThanOrEqual(before); + }); + + it('should reset failedPings to 0', async () => { + // Simulate failed pings + await recordFailedPing(TEST_PLUGIN_ID); + await recordFailedPing(TEST_PLUGIN_ID); + + await recordPluginActivity(TEST_PLUGIN_ID); + const plugin = await getPlugin(TEST_PLUGIN_ID); + expect(plugin?.failedPings).toBe(0); + }); + + it('should change unreachable status to active', async () => { + await updatePluginStatus(TEST_PLUGIN_ID, 'unreachable'); + await recordPluginActivity(TEST_PLUGIN_ID); + const plugin = await getPlugin(TEST_PLUGIN_ID); + expect(plugin?.status).toBe('active'); + }); + }); + + describe('recordFailedPing', () => { + it('should increment failedPings count', async () => { + await recordFailedPing(TEST_PLUGIN_ID); + const plugin = await getPlugin(TEST_PLUGIN_ID); + expect(plugin?.failedPings).toBe(1); + }); + + it('should mark as unreachable after 3 failed pings', async () => { + await recordFailedPing(TEST_PLUGIN_ID); + await recordFailedPing(TEST_PLUGIN_ID); + await recordFailedPing(TEST_PLUGIN_ID); + + const plugin = await getPlugin(TEST_PLUGIN_ID); + expect(plugin?.status).toBe('unreachable'); + }); + }); + + describe('enablePlugin', () => { + it('should enable a disabled plugin', async () => { + await disablePlugin(TEST_PLUGIN_ID); + const success = await enablePlugin(TEST_PLUGIN_ID); + + expect(success).toBe(true); + const plugin = await getPlugin(TEST_PLUGIN_ID); + expect(plugin?.status).toBe('active'); + }); + + it('should return false for non-existent plugin', async () => { + const success = await enablePlugin('non-existent@example.com'); + expect(success).toBe(false); + }); + }); + + describe('disablePlugin', () => { + it('should disable an active plugin', async () => { + const success = await disablePlugin(TEST_PLUGIN_ID, 'User disabled'); + + expect(success).toBe(true); + const plugin = await getPlugin(TEST_PLUGIN_ID); + expect(plugin?.status).toBe('disabled'); + expect(plugin?.lastError).toBe('User disabled'); + }); + + it('should return false for non-existent plugin', async () => { + const success = await disablePlugin('non-existent@example.com'); + expect(success).toBe(false); + }); + }); + }); + + describe('Tool Aggregation', () => { + const tool1: PluginToolDefinition = { + name: 'tool1', + title: 'Tool 1', + description: 'First tool', + inputSchema: { type: 'object' }, + }; + + const tool2: PluginToolDefinition = { + name: 'tool2', + title: 'Tool 2', + description: 'Second tool', + inputSchema: { type: 'object' }, + }; + + beforeEach(async () => { + // Clear storage and cache again for this nested suite + __clearMockStorage(); + __clearRegistryCache(); + + await registerPlugin({ + ...testPlugin, + tools: [tool1, tool2], + }); + }); + + describe('updatePluginTools', () => { + it('should update plugin tools', async () => { + const newTool: PluginToolDefinition = { + name: 'newTool', + title: 'New Tool', + description: 'A new tool', + inputSchema: { type: 'object' }, + }; + + await updatePluginTools(TEST_PLUGIN_ID, [newTool]); + const plugin = await getPlugin(TEST_PLUGIN_ID); + + expect(plugin?.descriptor.tools.length).toBe(1); + expect(plugin?.descriptor.tools[0].name).toBe('newTool'); + }); + }); + + describe('getAggregatedPluginTools', () => { + it('should return namespaced tools from active plugins', async () => { + const tools = await getAggregatedPluginTools(); + // Filter to just this test's plugin + const testTools = tools.filter((t) => t.pluginId === TEST_PLUGIN_ID); + + expect(testTools.length).toBe(2); + expect(testTools[0].name).toBe(`${TEST_PLUGIN_ID}::tool1`); + expect(testTools[1].name).toBe(`${TEST_PLUGIN_ID}::tool2`); + }); + + it('should include plugin metadata', async () => { + const tools = await getAggregatedPluginTools(); + const testTools = tools.filter((t) => t.pluginId === TEST_PLUGIN_ID); + + expect(testTools[0].pluginId).toBe(TEST_PLUGIN_ID); + expect(testTools[0].originalName).toBe('tool1'); + expect(testTools[0].title).toBe('Tool 1'); + }); + + it('should not include tools from disabled plugins', async () => { + await disablePlugin(TEST_PLUGIN_ID); + const tools = await getAggregatedPluginTools(); + // Filter to just this test's plugin + const testTools = tools.filter((t) => t.pluginId === TEST_PLUGIN_ID); + expect(testTools.length).toBe(0); + }); + }); + + describe('findToolPlugin', () => { + it('should find plugin and tool by namespaced name', async () => { + const result = await findToolPlugin(`${TEST_PLUGIN_ID}::tool1`); + + expect(result).not.toBeNull(); + expect(result?.plugin.descriptor.extensionId).toBe(TEST_PLUGIN_ID); + expect(result?.tool.name).toBe('tool1'); + }); + + it('should return null for non-existent tool', async () => { + const result = await findToolPlugin(`${TEST_PLUGIN_ID}::nonexistent`); + expect(result).toBeNull(); + }); + + it('should return null for non-existent plugin', async () => { + const result = await findToolPlugin('nonexistent@example.com::tool1'); + expect(result).toBeNull(); + }); + + it('should return null for invalid format', async () => { + const result = await findToolPlugin('invalid-format'); + expect(result).toBeNull(); + }); + + it('should return null for disabled plugin', async () => { + await disablePlugin(TEST_PLUGIN_ID); + const result = await findToolPlugin(`${TEST_PLUGIN_ID}::tool1`); + expect(result).toBeNull(); + }); + }); + }); + + describe('Registry Utilities', () => { + describe('getRegistryStats', () => { + it('should return correct stats', async () => { + // Use unique IDs to avoid interference from other tests + const pluginId1 = `stats-test-1-${Date.now()}@example.com`; + const pluginId2 = `stats-test-2-${Date.now()}@example.com`; + + await registerPlugin({ ...testPlugin, extensionId: pluginId1 }); + await registerPlugin({ + ...testPlugin, + extensionId: pluginId2, + tools: [testTool, testTool], + }); + await disablePlugin(pluginId1); + + const stats = await getRegistryStats(); + + // Stats count all plugins, including those from other tests + // So we check relative values instead + expect(stats.total).toBeGreaterThanOrEqual(2); + expect(stats.disabled).toBeGreaterThanOrEqual(1); + expect(stats.toolCount).toBeGreaterThanOrEqual(2); + }); + }); + + describe('cleanupStalePlugins', () => { + it('should remove plugins not seen within maxAgeMs', async () => { + // Register plugin with old lastSeen + await registerPlugin(testPlugin); + + // Manually set lastSeen to old value + const registry = await loadRegistry(); + registry.plugins[TEST_PLUGIN_ID].lastSeen = Date.now() - 1000 * 60 * 60 * 24; // 24 hours ago + __mockStorage['harbor_plugin_registry'] = registry; + __clearRegistryCache(); + + const removed = await cleanupStalePlugins(1000 * 60 * 60); // 1 hour + + expect(removed).toContain(TEST_PLUGIN_ID); + expect(await getPlugin(TEST_PLUGIN_ID)).toBeNull(); + }); + + it('should not remove recently seen plugins', async () => { + await registerPlugin(testPlugin); + + const removed = await cleanupStalePlugins(1000 * 60 * 60); // 1 hour + + expect(removed).not.toContain(TEST_PLUGIN_ID); + expect(await getPlugin(TEST_PLUGIN_ID)).not.toBeNull(); + }); + }); + }); +}); diff --git a/extension/src/plugins/__tests__/types.test.ts b/extension/src/plugins/__tests__/types.test.ts new file mode 100644 index 0000000..054b3e9 --- /dev/null +++ b/extension/src/plugins/__tests__/types.test.ts @@ -0,0 +1,256 @@ +/** + * Plugin Types Tests + * + * Tests for helper functions in the plugin types module. + */ + +import { describe, it, expect } from 'vitest'; +import { + PLUGIN_PROTOCOL_VERSION, + PLUGIN_NAMESPACE, + createToolNamespace, + parseToolNamespace, + generatePluginRequestId, + createPluginMessage, + isValidPluginMessage, + isCompatibleProtocolVersion, +} from '../types'; + +describe('Plugin Types', () => { + describe('Constants', () => { + it('should have correct protocol version', () => { + expect(PLUGIN_PROTOCOL_VERSION).toBe('harbor-plugin/v1'); + }); + + it('should have correct namespace', () => { + expect(PLUGIN_NAMESPACE).toBe('harbor-plugin'); + }); + }); + + describe('createToolNamespace', () => { + it('should create namespaced tool name with "::" separator', () => { + const result = createToolNamespace('my-plugin@example.com', 'echo'); + expect(result).toBe('my-plugin@example.com::echo'); + }); + + it('should handle tool names with special characters', () => { + const result = createToolNamespace('plugin', 'tool-with-dashes'); + expect(result).toBe('plugin::tool-with-dashes'); + }); + + it('should handle empty strings', () => { + const result = createToolNamespace('', ''); + expect(result).toBe('::'); + }); + }); + + describe('parseToolNamespace', () => { + it('should parse namespaced tool name correctly', () => { + const result = parseToolNamespace('my-plugin@example.com::echo'); + expect(result).toEqual({ + pluginId: 'my-plugin@example.com', + toolName: 'echo', + }); + }); + + it('should return null for non-namespaced names', () => { + const result = parseToolNamespace('just-a-name'); + expect(result).toBeNull(); + }); + + it('should return null for MCP-style names (using /)', () => { + const result = parseToolNamespace('server/tool'); + expect(result).toBeNull(); + }); + + it('should handle tool names with :: in them (first match)', () => { + const result = parseToolNamespace('plugin::tool::extra'); + expect(result).toEqual({ + pluginId: 'plugin', + toolName: 'tool::extra', + }); + }); + + it('should handle empty plugin ID', () => { + const result = parseToolNamespace('::toolName'); + expect(result).toEqual({ + pluginId: '', + toolName: 'toolName', + }); + }); + }); + + describe('generatePluginRequestId', () => { + it('should generate a unique request ID', () => { + const id1 = generatePluginRequestId(); + const id2 = generatePluginRequestId(); + expect(id1).not.toBe(id2); + }); + + it('should start with "plugin-"', () => { + const id = generatePluginRequestId(); + expect(id.startsWith('plugin-')).toBe(true); + }); + + it('should contain a timestamp component', () => { + const before = Date.now(); + const id = generatePluginRequestId(); + const after = Date.now(); + + // Extract timestamp from ID (format: plugin-TIMESTAMP-RANDOM) + const parts = id.split('-'); + const timestamp = parseInt(parts[1], 10); + + expect(timestamp).toBeGreaterThanOrEqual(before); + expect(timestamp).toBeLessThanOrEqual(after); + }); + }); + + describe('createPluginMessage', () => { + it('should create a valid message envelope', () => { + const message = createPluginMessage('PLUGIN_REGISTER', { + plugin: { extensionId: 'test', name: 'Test', version: '1.0.0', tools: [] }, + }); + + expect(message.namespace).toBe(PLUGIN_NAMESPACE); + expect(message.protocolVersion).toBe(PLUGIN_PROTOCOL_VERSION); + expect(message.type).toBe('PLUGIN_REGISTER'); + expect(message.requestId).toBeDefined(); + expect(message.timestamp).toBeDefined(); + expect(message.payload).toBeDefined(); + }); + + it('should use provided requestId if given', () => { + const customId = 'custom-request-id-123'; + const message = createPluginMessage('PLUGIN_PING', {}, customId); + + expect(message.requestId).toBe(customId); + }); + + it('should set timestamp to current time', () => { + const before = Date.now(); + const message = createPluginMessage('PLUGIN_PING', {}); + const after = Date.now(); + + expect(message.timestamp).toBeGreaterThanOrEqual(before); + expect(message.timestamp).toBeLessThanOrEqual(after); + }); + }); + + describe('isValidPluginMessage', () => { + it('should return true for valid message', () => { + const message = createPluginMessage('PLUGIN_PING', {}); + expect(isValidPluginMessage(message)).toBe(true); + }); + + it('should return false for null', () => { + expect(isValidPluginMessage(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isValidPluginMessage(undefined)).toBe(false); + }); + + it('should return false for non-object', () => { + expect(isValidPluginMessage('string')).toBe(false); + expect(isValidPluginMessage(123)).toBe(false); + expect(isValidPluginMessage(true)).toBe(false); + }); + + it('should return false for wrong namespace', () => { + const message = { + namespace: 'wrong-namespace', + protocolVersion: PLUGIN_PROTOCOL_VERSION, + type: 'PLUGIN_PING', + requestId: 'req-1', + timestamp: Date.now(), + payload: {}, + }; + expect(isValidPluginMessage(message)).toBe(false); + }); + + it('should return false for missing protocolVersion', () => { + const message = { + namespace: PLUGIN_NAMESPACE, + type: 'PLUGIN_PING', + requestId: 'req-1', + timestamp: Date.now(), + payload: {}, + }; + expect(isValidPluginMessage(message)).toBe(false); + }); + + it('should return false for invalid protocolVersion format', () => { + const message = { + namespace: PLUGIN_NAMESPACE, + protocolVersion: 'invalid-version', + type: 'PLUGIN_PING', + requestId: 'req-1', + timestamp: Date.now(), + payload: {}, + }; + expect(isValidPluginMessage(message)).toBe(false); + }); + + it('should return false for missing requestId', () => { + const message = { + namespace: PLUGIN_NAMESPACE, + protocolVersion: PLUGIN_PROTOCOL_VERSION, + type: 'PLUGIN_PING', + timestamp: Date.now(), + payload: {}, + }; + expect(isValidPluginMessage(message)).toBe(false); + }); + + it('should return false for missing timestamp', () => { + const message = { + namespace: PLUGIN_NAMESPACE, + protocolVersion: PLUGIN_PROTOCOL_VERSION, + type: 'PLUGIN_PING', + requestId: 'req-1', + payload: {}, + }; + expect(isValidPluginMessage(message)).toBe(false); + }); + + it('should return false for missing payload', () => { + const message = { + namespace: PLUGIN_NAMESPACE, + protocolVersion: PLUGIN_PROTOCOL_VERSION, + type: 'PLUGIN_PING', + requestId: 'req-1', + timestamp: Date.now(), + }; + expect(isValidPluginMessage(message)).toBe(false); + }); + + it('should accept any harbor-plugin/ protocol version prefix', () => { + const message = { + namespace: PLUGIN_NAMESPACE, + protocolVersion: 'harbor-plugin/v99', + type: 'PLUGIN_PING', + requestId: 'req-1', + timestamp: Date.now(), + payload: {}, + }; + expect(isValidPluginMessage(message)).toBe(true); + }); + }); + + describe('isCompatibleProtocolVersion', () => { + it('should return true for exact match', () => { + expect(isCompatibleProtocolVersion('harbor-plugin/v1')).toBe(true); + }); + + it('should return false for different version', () => { + expect(isCompatibleProtocolVersion('harbor-plugin/v2')).toBe(false); + }); + + it('should return false for invalid format', () => { + expect(isCompatibleProtocolVersion('invalid')).toBe(false); + expect(isCompatibleProtocolVersion('v1')).toBe(false); + expect(isCompatibleProtocolVersion('')).toBe(false); + }); + }); +}); diff --git a/extension/src/plugins/consent.ts b/extension/src/plugins/consent.ts new file mode 100644 index 0000000..b1d7a2c --- /dev/null +++ b/extension/src/plugins/consent.ts @@ -0,0 +1,483 @@ +/** + * Harbor Plugin System - Consent Management + * + * Extends the existing permission system to support plugin tools. + * Consent is controlled by the hub (not plugins) on a per-origin basis. + */ + +import browser from 'webextension-polyfill'; +import type { AggregatedPluginTool } from './types'; +import { parseToolNamespace, createToolNamespace } from './types'; +import { getAggregatedPluginTools, findToolPlugin } from './registry'; + +// Storage key for plugin tool permissions +const PLUGIN_PERMISSIONS_STORAGE_KEY = 'harbor_plugin_permissions'; + +// Temporary grants (in-memory with TTL) +const temporaryPluginGrants = new Map(); + +// TTL for "once" grants (10 minutes, matching existing provider permissions) +const ONCE_GRANT_TTL_MS = 10 * 60 * 1000; + +// ============================================================================= +// Types +// ============================================================================= + +export type PluginConsentGrant = 'granted-once' | 'granted-always' | 'denied' | 'not-granted'; + +/** + * Stored permission entry for an origin's access to plugin tools. + */ +export interface PluginPermissionEntry { + /** All plugin tools allowed (if true, allowedTools is ignored) */ + allowAll: boolean; + /** Specific plugin tools allowed (namespaced: pluginId::toolName) */ + allowedTools: string[]; + /** When this permission was last updated */ + updatedAt: number; +} + +/** + * Stored plugin permissions by origin. + */ +export interface StoredPluginPermissions { + /** Version for migration */ + version: 1; + /** Map of origin -> permission entry */ + permissions: Record; +} + +/** + * Temporary grant entry (for "allow once" grants). + */ +interface PluginGrantEntry { + origin: string; + allowAll: boolean; + allowedTools: string[]; + grantedAt: number; + expiresAt: number; + tabId?: number; +} + +// ============================================================================= +// Storage Operations +// ============================================================================= + +/** + * Load plugin permissions from storage. + */ +async function loadPluginPermissions(): Promise { + try { + const result = await browser.storage.local.get(PLUGIN_PERMISSIONS_STORAGE_KEY); + const stored = result[PLUGIN_PERMISSIONS_STORAGE_KEY] as StoredPluginPermissions | undefined; + + if (stored && stored.version === 1) { + return stored; + } + } catch (err) { + console.error('[PluginConsent] Failed to load permissions:', err); + } + + return { version: 1, permissions: {} }; +} + +/** + * Save plugin permissions to storage. + */ +async function savePluginPermissions(permissions: StoredPluginPermissions): Promise { + try { + await browser.storage.local.set({ [PLUGIN_PERMISSIONS_STORAGE_KEY]: permissions }); + } catch (err) { + console.error('[PluginConsent] Failed to save permissions:', err); + throw err; + } +} + +// ============================================================================= +// Grant Cleanup +// ============================================================================= + +/** + * Clean up expired temporary grants. + */ +function cleanupExpiredGrants(): void { + const now = Date.now(); + for (const [key, grant] of temporaryPluginGrants) { + if (grant.expiresAt < now) { + temporaryPluginGrants.delete(key); + } + } +} + +// Run cleanup periodically +setInterval(cleanupExpiredGrants, 60000); + +/** + * Get temporary grant key for an origin. + */ +function getTempKey(origin: string): string { + return `plugin-temp:${origin}`; +} + +// ============================================================================= +// Permission Checking +// ============================================================================= + +/** + * Check if an origin has permission to call a specific plugin tool. + */ +export async function hasPluginToolPermission( + origin: string, + namespacedToolName: string +): Promise { + // Extension pages always have full access + if (origin === 'extension') { + return true; + } + + cleanupExpiredGrants(); + + // Check temporary grants first + const tempGrant = temporaryPluginGrants.get(getTempKey(origin)); + if (tempGrant && tempGrant.expiresAt > Date.now()) { + if (tempGrant.allowAll) { + return true; + } + if (tempGrant.allowedTools.includes(namespacedToolName)) { + return true; + } + } + + // Check persistent permissions + const stored = await loadPluginPermissions(); + const entry = stored.permissions[origin]; + + if (!entry) { + return false; + } + + if (entry.allowAll) { + return true; + } + + return entry.allowedTools.includes(namespacedToolName); +} + +/** + * Check if an origin has any plugin tool permissions. + */ +export async function hasAnyPluginPermission(origin: string): Promise { + if (origin === 'extension') { + return true; + } + + cleanupExpiredGrants(); + + // Check temporary grants + const tempGrant = temporaryPluginGrants.get(getTempKey(origin)); + if (tempGrant && tempGrant.expiresAt > Date.now()) { + if (tempGrant.allowAll || tempGrant.allowedTools.length > 0) { + return true; + } + } + + // Check persistent permissions + const stored = await loadPluginPermissions(); + const entry = stored.permissions[origin]; + + if (!entry) { + return false; + } + + return entry.allowAll || entry.allowedTools.length > 0; +} + +/** + * Get all allowed plugin tools for an origin. + */ +export async function getAllowedPluginTools( + origin: string +): Promise<{ allowAll: boolean; tools: string[] }> { + if (origin === 'extension') { + return { allowAll: true, tools: [] }; + } + + cleanupExpiredGrants(); + + // Combine temporary and persistent grants + const tempGrant = temporaryPluginGrants.get(getTempKey(origin)); + const stored = await loadPluginPermissions(); + const entry = stored.permissions[origin]; + + // If either grants all, return allowAll + if (tempGrant?.allowAll || entry?.allowAll) { + return { allowAll: true, tools: [] }; + } + + // Combine allowed tools from both + const tools = new Set(); + + if (tempGrant && tempGrant.expiresAt > Date.now()) { + for (const tool of tempGrant.allowedTools) { + tools.add(tool); + } + } + + if (entry) { + for (const tool of entry.allowedTools) { + tools.add(tool); + } + } + + return { allowAll: false, tools: Array.from(tools) }; +} + +/** + * Get the consent status for an origin. + */ +export async function getPluginConsentStatus( + origin: string +): Promise<{ + hasConsent: boolean; + allowAll: boolean; + allowedTools: string[]; + grantType: 'once' | 'always' | 'none'; +}> { + cleanupExpiredGrants(); + + const tempGrant = temporaryPluginGrants.get(getTempKey(origin)); + const stored = await loadPluginPermissions(); + const entry = stored.permissions[origin]; + + // Check temporary first + if (tempGrant && tempGrant.expiresAt > Date.now()) { + return { + hasConsent: tempGrant.allowAll || tempGrant.allowedTools.length > 0, + allowAll: tempGrant.allowAll, + allowedTools: tempGrant.allowedTools, + grantType: 'once', + }; + } + + // Check persistent + if (entry) { + return { + hasConsent: entry.allowAll || entry.allowedTools.length > 0, + allowAll: entry.allowAll, + allowedTools: entry.allowedTools, + grantType: 'always', + }; + } + + return { + hasConsent: false, + allowAll: false, + allowedTools: [], + grantType: 'none', + }; +} + +// ============================================================================= +// Permission Granting +// ============================================================================= + +/** + * Grant plugin tool permission to an origin. + */ +export async function grantPluginPermission( + origin: string, + options: { + mode: 'once' | 'always'; + allowAll?: boolean; + tools?: string[]; + tabId?: number; + } +): Promise { + const { mode, allowAll = false, tools = [], tabId } = options; + + if (mode === 'once') { + // Store as temporary grant + const existing = temporaryPluginGrants.get(getTempKey(origin)); + + temporaryPluginGrants.set(getTempKey(origin), { + origin, + allowAll: allowAll || existing?.allowAll || false, + allowedTools: [...new Set([...(existing?.allowedTools || []), ...tools])], + grantedAt: Date.now(), + expiresAt: Date.now() + ONCE_GRANT_TTL_MS, + tabId: tabId ?? existing?.tabId, + }); + } else { + // Store persistently + const stored = await loadPluginPermissions(); + const existing = stored.permissions[origin]; + + stored.permissions[origin] = { + allowAll: allowAll || existing?.allowAll || false, + allowedTools: [...new Set([...(existing?.allowedTools || []), ...tools])], + updatedAt: Date.now(), + }; + + await savePluginPermissions(stored); + } + + console.log('[PluginConsent] Permission granted:', origin, mode, { allowAll, tools }); +} + +/** + * Revoke all plugin permissions for an origin. + */ +export async function revokePluginPermissions(origin: string): Promise { + // Remove temporary grant + temporaryPluginGrants.delete(getTempKey(origin)); + + // Remove persistent permissions + const stored = await loadPluginPermissions(); + delete stored.permissions[origin]; + await savePluginPermissions(stored); + + console.log('[PluginConsent] Permissions revoked for:', origin); +} + +/** + * Clear temporary grants for a tab (when tab closes). + */ +export function clearPluginTabGrants(tabId: number): void { + for (const [key, grant] of temporaryPluginGrants) { + if (grant.tabId === tabId) { + temporaryPluginGrants.delete(key); + } + } +} + +// ============================================================================= +// Consent Flow +// ============================================================================= + +/** + * Request consent for plugin tools. + * Returns true if consent was already granted, false if UI prompt is needed. + */ +export async function checkPluginConsent( + origin: string, + requestedTools?: string[] +): Promise<{ + granted: boolean; + missingTools: string[]; +}> { + // Extension pages don't need consent + if (origin === 'extension') { + return { granted: true, missingTools: [] }; + } + + const status = await getPluginConsentStatus(origin); + + // If allowAll is granted, everything is allowed + if (status.allowAll) { + return { granted: true, missingTools: [] }; + } + + // If no specific tools requested, check if any consent exists + if (!requestedTools || requestedTools.length === 0) { + return { + granted: status.hasConsent, + missingTools: [], + }; + } + + // Check which specific tools are missing + const missingTools = requestedTools.filter((tool) => !status.allowedTools.includes(tool)); + + return { + granted: missingTools.length === 0, + missingTools, + }; +} + +/** + * Get plugin tools available for consent prompt. + */ +export async function getPluginToolsForConsent(): Promise { + return getAggregatedPluginTools(); +} + +// ============================================================================= +// Permission Listing (for UI) +// ============================================================================= + +/** + * Get all plugin permissions for display in the UI. + */ +export async function getAllPluginPermissions(): Promise< + Array<{ + origin: string; + allowAll: boolean; + allowedTools: string[]; + grantType: 'once' | 'always'; + expiresAt?: number; + }> +> { + cleanupExpiredGrants(); + + const result: Array<{ + origin: string; + allowAll: boolean; + allowedTools: string[]; + grantType: 'once' | 'always'; + expiresAt?: number; + }> = []; + + // Add persistent permissions + const stored = await loadPluginPermissions(); + for (const [origin, entry] of Object.entries(stored.permissions)) { + result.push({ + origin, + allowAll: entry.allowAll, + allowedTools: entry.allowedTools, + grantType: 'always', + }); + } + + // Add/merge temporary grants + for (const [, grant] of temporaryPluginGrants) { + if (grant.expiresAt > Date.now()) { + const existing = result.find((r) => r.origin === grant.origin); + if (existing) { + // Merge with existing + existing.allowAll = existing.allowAll || grant.allowAll; + existing.allowedTools = [...new Set([...existing.allowedTools, ...grant.allowedTools])]; + // Keep as 'always' if both exist + } else { + result.push({ + origin: grant.origin, + allowAll: grant.allowAll, + allowedTools: grant.allowedTools, + grantType: 'once', + expiresAt: grant.expiresAt, + }); + } + } + } + + return result; +} + +// ============================================================================= +// Testing Utilities +// ============================================================================= + +/** + * Clear all temporary grants. For testing only. + * @internal + */ +export function __clearTemporaryGrants(): void { + temporaryPluginGrants.clear(); +} + +/** + * Get temporary grants map. For testing only. + * @internal + */ +export function __getTemporaryGrants(): Map { + return temporaryPluginGrants; +} diff --git a/extension/src/plugins/index.ts b/extension/src/plugins/index.ts new file mode 100644 index 0000000..af8f8f4 --- /dev/null +++ b/extension/src/plugins/index.ts @@ -0,0 +1,64 @@ +/** + * Harbor Plugin System + * + * Extension-based plugins that provide tools to Harbor. + * Plugins register via extension-to-extension messaging and expose + * their tools through the window.agent API. + */ + +// Types +export * from './types'; + +// Registry +export { + loadRegistry, + isPluginAllowed, + getPluginAllowlist, + setPluginAllowlist, + addToAllowlist, + removeFromAllowlist, + registerPlugin, + unregisterPlugin, + getPlugin, + getAllPlugins, + getActivePlugins, + isPluginRegistered, + updatePluginStatus, + recordPluginActivity, + recordFailedPing, + enablePlugin, + disablePlugin, + updatePluginTools, + getAggregatedPluginTools, + findToolPlugin, + getRegistryStats, + cleanupStalePlugins, +} from './registry'; + +// Router +export { + initializePluginRouter, + shutdownPluginRouter, + getRouterStatus, + callPluginTool, + pingPlugin, + requestPluginTools, + notifyPluginDisabled, + notifyPluginEnabled, + startHeartbeat, + stopHeartbeat, +} from './router'; + +// Consent +export { + hasPluginToolPermission, + hasAnyPluginPermission, + getAllowedPluginTools, + getPluginConsentStatus, + grantPluginPermission, + revokePluginPermissions, + clearPluginTabGrants, + checkPluginConsent, + getPluginToolsForConsent, + getAllPluginPermissions, +} from './consent'; diff --git a/extension/src/plugins/registry.ts b/extension/src/plugins/registry.ts new file mode 100644 index 0000000..80ed852 --- /dev/null +++ b/extension/src/plugins/registry.ts @@ -0,0 +1,479 @@ +/** + * Harbor Plugin System - Registry + * + * Manages plugin registration, persistence, and status tracking. + * Plugins are stored in browser.storage.local for persistence across sessions. + */ + +import browser from 'webextension-polyfill'; +import type { + PluginDescriptor, + PluginRegistryEntry, + StoredPluginRegistry, + PluginStatus, + AggregatedPluginTool, + PluginToolDefinition, +} from './types'; +import { createToolNamespace } from './types'; + +// Storage key for the plugin registry +const REGISTRY_STORAGE_KEY = 'harbor_plugin_registry'; + +// Default empty registry +const DEFAULT_REGISTRY: StoredPluginRegistry = { + version: 1, + plugins: {}, + allowlist: [], + updatedAt: Date.now(), +}; + +// In-memory cache of the registry +let registryCache: StoredPluginRegistry | null = null; + +// ============================================================================= +// Storage Operations +// ============================================================================= + +/** + * Load the plugin registry from storage. + */ +export async function loadRegistry(): Promise { + if (registryCache) { + return registryCache; + } + + try { + const result = await browser.storage.local.get(REGISTRY_STORAGE_KEY); + const stored = result[REGISTRY_STORAGE_KEY] as StoredPluginRegistry | undefined; + + if (stored && stored.version === 1) { + registryCache = stored; + return stored; + } + } catch (err) { + console.error('[PluginRegistry] Failed to load registry:', err); + } + + // Return default if not found or invalid + registryCache = { ...DEFAULT_REGISTRY }; + return registryCache; +} + +/** + * Save the plugin registry to storage. + */ +async function saveRegistry(registry: StoredPluginRegistry): Promise { + registry.updatedAt = Date.now(); + registryCache = registry; + + try { + await browser.storage.local.set({ [REGISTRY_STORAGE_KEY]: registry }); + } catch (err) { + console.error('[PluginRegistry] Failed to save registry:', err); + throw err; + } +} + +/** + * Clear the registry cache (for testing). + * @internal + */ +export function __clearRegistryCache(): void { + registryCache = null; +} + +// ============================================================================= +// Allowlist Management +// ============================================================================= + +/** + * Check if a plugin extension ID is allowed. + */ +export async function isPluginAllowed(extensionId: string): Promise { + const registry = await loadRegistry(); + + // If allowlist is empty, all plugins are allowed + if (registry.allowlist.length === 0) { + return true; + } + + return registry.allowlist.includes(extensionId); +} + +/** + * Get the current allowlist. + */ +export async function getPluginAllowlist(): Promise { + const registry = await loadRegistry(); + return registry.allowlist; +} + +/** + * Set the plugin allowlist. + */ +export async function setPluginAllowlist(extensionIds: string[]): Promise { + const registry = await loadRegistry(); + registry.allowlist = extensionIds; + await saveRegistry(registry); +} + +/** + * Add an extension ID to the allowlist. + */ +export async function addToAllowlist(extensionId: string): Promise { + const registry = await loadRegistry(); + if (!registry.allowlist.includes(extensionId)) { + registry.allowlist.push(extensionId); + await saveRegistry(registry); + } +} + +/** + * Remove an extension ID from the allowlist. + */ +export async function removeFromAllowlist(extensionId: string): Promise { + const registry = await loadRegistry(); + const index = registry.allowlist.indexOf(extensionId); + if (index !== -1) { + registry.allowlist.splice(index, 1); + await saveRegistry(registry); + } +} + +// ============================================================================= +// Plugin Registration +// ============================================================================= + +/** + * Register a new plugin or update an existing one. + */ +export async function registerPlugin(descriptor: PluginDescriptor): Promise { + const registry = await loadRegistry(); + const now = Date.now(); + + const existing = registry.plugins[descriptor.extensionId]; + + const entry: PluginRegistryEntry = { + descriptor, + status: 'active', + lastSeen: now, + registeredAt: existing?.registeredAt ?? now, + failedPings: 0, + }; + + registry.plugins[descriptor.extensionId] = entry; + await saveRegistry(registry); + + console.log('[PluginRegistry] Registered plugin:', descriptor.extensionId, descriptor.name); + return entry; +} + +/** + * Unregister a plugin. + */ +export async function unregisterPlugin(extensionId: string): Promise { + const registry = await loadRegistry(); + + if (!registry.plugins[extensionId]) { + return false; + } + + delete registry.plugins[extensionId]; + await saveRegistry(registry); + + console.log('[PluginRegistry] Unregistered plugin:', extensionId); + return true; +} + +/** + * Get a plugin by extension ID. + */ +export async function getPlugin(extensionId: string): Promise { + const registry = await loadRegistry(); + return registry.plugins[extensionId] ?? null; +} + +/** + * Get all registered plugins. + */ +export async function getAllPlugins(): Promise { + const registry = await loadRegistry(); + return Object.values(registry.plugins); +} + +/** + * Get all active plugins. + */ +export async function getActivePlugins(): Promise { + const registry = await loadRegistry(); + return Object.values(registry.plugins).filter((p) => p.status === 'active'); +} + +/** + * Check if a plugin is registered. + */ +export async function isPluginRegistered(extensionId: string): Promise { + const registry = await loadRegistry(); + return extensionId in registry.plugins; +} + +// ============================================================================= +// Plugin Status Management +// ============================================================================= + +/** + * Update a plugin's status. + */ +export async function updatePluginStatus( + extensionId: string, + status: PluginStatus, + error?: string +): Promise { + const registry = await loadRegistry(); + const plugin = registry.plugins[extensionId]; + + if (!plugin) { + console.warn('[PluginRegistry] Cannot update status for unknown plugin:', extensionId); + return; + } + + plugin.status = status; + plugin.lastSeen = Date.now(); + + if (error) { + plugin.lastError = error; + } else if (status === 'active') { + delete plugin.lastError; + plugin.failedPings = 0; + } + + await saveRegistry(registry); +} + +/** + * Record a successful interaction with a plugin. + */ +export async function recordPluginActivity(extensionId: string): Promise { + const registry = await loadRegistry(); + const plugin = registry.plugins[extensionId]; + + if (plugin) { + plugin.lastSeen = Date.now(); + plugin.failedPings = 0; + + if (plugin.status === 'unreachable') { + plugin.status = 'active'; + } + + await saveRegistry(registry); + } +} + +/** + * Record a failed ping attempt. + */ +export async function recordFailedPing(extensionId: string): Promise { + const registry = await loadRegistry(); + const plugin = registry.plugins[extensionId]; + + if (plugin) { + plugin.failedPings++; + + // Mark as unreachable after 3 failed pings + if (plugin.failedPings >= 3 && plugin.status === 'active') { + plugin.status = 'unreachable'; + console.warn('[PluginRegistry] Plugin marked as unreachable:', extensionId); + } + + await saveRegistry(registry); + } +} + +/** + * Enable a disabled plugin. + */ +export async function enablePlugin(extensionId: string): Promise { + const registry = await loadRegistry(); + const plugin = registry.plugins[extensionId]; + + if (!plugin) { + return false; + } + + plugin.status = 'active'; + plugin.failedPings = 0; + delete plugin.lastError; + + await saveRegistry(registry); + return true; +} + +/** + * Disable a plugin. + */ +export async function disablePlugin(extensionId: string, reason?: string): Promise { + const registry = await loadRegistry(); + const plugin = registry.plugins[extensionId]; + + if (!plugin) { + return false; + } + + plugin.status = 'disabled'; + if (reason) { + plugin.lastError = reason; + } + + await saveRegistry(registry); + return true; +} + +// ============================================================================= +// Tool Aggregation +// ============================================================================= + +/** + * Update a plugin's tool list. + */ +export async function updatePluginTools( + extensionId: string, + tools: PluginToolDefinition[] +): Promise { + const registry = await loadRegistry(); + const plugin = registry.plugins[extensionId]; + + if (!plugin) { + console.warn('[PluginRegistry] Cannot update tools for unknown plugin:', extensionId); + return; + } + + plugin.descriptor.tools = tools; + plugin.lastSeen = Date.now(); + await saveRegistry(registry); +} + +/** + * Get all tools from all active plugins. + */ +export async function getAggregatedPluginTools(): Promise { + const plugins = await getActivePlugins(); + const tools: AggregatedPluginTool[] = []; + + for (const plugin of plugins) { + for (const tool of plugin.descriptor.tools) { + tools.push({ + name: createToolNamespace(plugin.descriptor.extensionId, tool.name), + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + pluginId: plugin.descriptor.extensionId, + originalName: tool.name, + }); + } + } + + return tools; +} + +/** + * Find which plugin owns a namespaced tool. + */ +export async function findToolPlugin( + namespacedToolName: string +): Promise<{ plugin: PluginRegistryEntry; tool: PluginToolDefinition } | null> { + // Parse namespace + const separatorIndex = namespacedToolName.indexOf('::'); + if (separatorIndex === -1) { + return null; + } + + const pluginId = namespacedToolName.slice(0, separatorIndex); + const toolName = namespacedToolName.slice(separatorIndex + 2); + + const plugin = await getPlugin(pluginId); + if (!plugin || plugin.status !== 'active') { + return null; + } + + const tool = plugin.descriptor.tools.find((t) => t.name === toolName); + if (!tool) { + return null; + } + + return { plugin, tool }; +} + +// ============================================================================= +// Registry Utilities +// ============================================================================= + +/** + * Get registry statistics. + */ +export async function getRegistryStats(): Promise<{ + total: number; + active: number; + disabled: number; + unreachable: number; + error: number; + toolCount: number; +}> { + const plugins = await getAllPlugins(); + + let active = 0; + let disabled = 0; + let unreachable = 0; + let error = 0; + let toolCount = 0; + + for (const plugin of plugins) { + switch (plugin.status) { + case 'active': + active++; + toolCount += plugin.descriptor.tools.length; + break; + case 'disabled': + disabled++; + break; + case 'unreachable': + unreachable++; + break; + case 'error': + error++; + break; + } + } + + return { + total: plugins.length, + active, + disabled, + unreachable, + error, + toolCount, + }; +} + +/** + * Clean up stale plugins (not seen in a long time). + */ +export async function cleanupStalePlugins(maxAgeMs: number): Promise { + const registry = await loadRegistry(); + const now = Date.now(); + const removed: string[] = []; + + for (const [extensionId, plugin] of Object.entries(registry.plugins)) { + if (now - plugin.lastSeen > maxAgeMs) { + delete registry.plugins[extensionId]; + removed.push(extensionId); + } + } + + if (removed.length > 0) { + await saveRegistry(registry); + console.log('[PluginRegistry] Cleaned up stale plugins:', removed); + } + + return removed; +} diff --git a/extension/src/plugins/router.ts b/extension/src/plugins/router.ts new file mode 100644 index 0000000..cecc953 --- /dev/null +++ b/extension/src/plugins/router.ts @@ -0,0 +1,606 @@ +/** + * Harbor Plugin System - Router + * + * Handles extension-to-extension messaging with plugins. + * Manages request/response correlation, timeouts, and message routing. + */ + +import browser from 'webextension-polyfill'; +import type { + PluginMessageEnvelope, + PluginMessageType, + PluginMessagePayload, + PluginDescriptor, + PluginToolCallPayload, + PluginToolResultPayload, + PluginToolErrorPayload, + PluginErrorCode, +} from './types'; +import { + PLUGIN_PROTOCOL_VERSION, + PLUGIN_NAMESPACE, + PLUGIN_REGISTER_TIMEOUT_MS, + PLUGIN_TOOL_CALL_TIMEOUT_MS, + PLUGIN_PING_TIMEOUT_MS, + PLUGIN_HEARTBEAT_INTERVAL_MS, + isValidPluginMessage, + isCompatibleProtocolVersion, + createPluginMessage, + generatePluginRequestId, +} from './types'; +import { + isPluginAllowed, + registerPlugin, + unregisterPlugin, + getPlugin, + getActivePlugins, + recordPluginActivity, + recordFailedPing, + updatePluginStatus, + disablePlugin, + enablePlugin, +} from './registry'; + +// ============================================================================= +// Types +// ============================================================================= + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeoutId: ReturnType; + type: PluginMessageType; + pluginId: string; + createdAt: number; +} + +// ============================================================================= +// State +// ============================================================================= + +// Pending requests awaiting responses +const pendingRequests = new Map(); + +// Heartbeat interval handle +let heartbeatIntervalId: ReturnType | null = null; + +// Router initialization flag +let isInitialized = false; + +// Debug logging flag +const DEBUG = true; + +function log(...args: unknown[]): void { + if (DEBUG) { + console.log('[PluginRouter]', ...args); + } +} + +// ============================================================================= +// Request/Response Correlation +// ============================================================================= + +/** + * Send a message to a plugin and wait for a response. + */ +async function sendToPlugin( + pluginId: string, + type: T, + payload: PluginMessagePayload, + timeoutMs: number = PLUGIN_TOOL_CALL_TIMEOUT_MS +): Promise { + const requestId = generatePluginRequestId(); + const message = createPluginMessage(type, payload, requestId); + + return new Promise((resolve, reject) => { + // Set up timeout + const timeoutId = setTimeout(() => { + const pending = pendingRequests.get(requestId); + if (pending) { + pendingRequests.delete(requestId); + reject(new Error(`Plugin request timed out after ${timeoutMs}ms`)); + } + }, timeoutMs); + + // Store pending request + pendingRequests.set(requestId, { + resolve, + reject, + timeoutId, + type, + pluginId, + createdAt: Date.now(), + }); + + // Send the message + log('Sending to plugin:', pluginId, type, requestId); + browser.runtime + .sendMessage(pluginId, message) + .catch((err) => { + // Clean up pending request on send failure + const pending = pendingRequests.get(requestId); + if (pending) { + clearTimeout(pending.timeoutId); + pendingRequests.delete(requestId); + reject(new Error(`Failed to send message to plugin: ${err.message}`)); + } + }); + }); +} + +/** + * Handle a response from a plugin. + */ +function handlePluginResponse(requestId: string, payload: unknown, error?: Error): void { + const pending = pendingRequests.get(requestId); + if (!pending) { + log('No pending request for:', requestId); + return; + } + + clearTimeout(pending.timeoutId); + pendingRequests.delete(requestId); + + if (error) { + pending.reject(error); + } else { + pending.resolve(payload); + } +} + +// ============================================================================= +// Message Handlers +// ============================================================================= + +/** + * Handle PLUGIN_REGISTER message from a plugin. + */ +async function handlePluginRegister( + senderId: string, + requestId: string, + payload: { plugin: PluginDescriptor } +): Promise { + const { plugin } = payload; + + log('Plugin registration request from:', senderId, plugin.name); + + // Verify the sender ID matches the declared extension ID + if (plugin.extensionId !== senderId) { + log('Extension ID mismatch:', plugin.extensionId, '!==', senderId); + await sendRegistrationAck(senderId, requestId, false, 'Extension ID mismatch'); + return; + } + + // Check allowlist + const allowed = await isPluginAllowed(senderId); + if (!allowed) { + log('Plugin not in allowlist:', senderId); + await sendRegistrationAck(senderId, requestId, false, 'Plugin not in allowlist'); + return; + } + + // Validate tools + if (!Array.isArray(plugin.tools)) { + await sendRegistrationAck(senderId, requestId, false, 'Invalid tools array'); + return; + } + + // Register the plugin + try { + await registerPlugin(plugin); + await sendRegistrationAck(senderId, requestId, true, undefined, senderId); + log('Plugin registered successfully:', senderId, plugin.name); + + // Notify extension pages about new plugin + broadcastPluginUpdate('plugin_registered', senderId); + } catch (err) { + log('Plugin registration failed:', err); + await sendRegistrationAck( + senderId, + requestId, + false, + err instanceof Error ? err.message : 'Registration failed' + ); + } +} + +/** + * Handle PLUGIN_UNREGISTER message from a plugin. + */ +async function handlePluginUnregister(senderId: string, requestId: string): Promise { + log('Plugin unregistration request from:', senderId); + + const success = await unregisterPlugin(senderId); + + // Send acknowledgment + const ackMessage = createPluginMessage('PLUGIN_UNREGISTER_ACK', { success }, requestId); + try { + await browser.runtime.sendMessage(senderId, ackMessage); + } catch (err) { + log('Failed to send unregister ack:', err); + } + + if (success) { + // Notify extension pages + broadcastPluginUpdate('plugin_unregistered', senderId); + } +} + +/** + * Handle PLUGIN_TOOL_RESULT message from a plugin. + */ +function handlePluginToolResult( + senderId: string, + requestId: string, + payload: PluginToolResultPayload +): void { + log('Tool result from:', senderId, requestId); + recordPluginActivity(senderId); + handlePluginResponse(requestId, payload); +} + +/** + * Handle PLUGIN_TOOL_ERROR message from a plugin. + */ +function handlePluginToolError( + senderId: string, + requestId: string, + payload: PluginToolErrorPayload +): void { + log('Tool error from:', senderId, requestId, payload.code); + recordPluginActivity(senderId); + handlePluginResponse(requestId, null, new Error(`${payload.code}: ${payload.message}`)); +} + +/** + * Handle PLUGIN_PONG message from a plugin. + */ +function handlePluginPong(senderId: string, requestId: string, payload: { healthy: boolean }): void { + log('Pong from:', senderId, 'healthy:', payload.healthy); + recordPluginActivity(senderId); + handlePluginResponse(requestId, payload); +} + +/** + * Handle PLUGIN_TOOLS_LIST_RESULT message from a plugin. + */ +function handlePluginToolsListResult( + senderId: string, + requestId: string, + payload: { tools: unknown[] } +): void { + log('Tools list from:', senderId, payload.tools?.length, 'tools'); + recordPluginActivity(senderId); + handlePluginResponse(requestId, payload); +} + +// ============================================================================= +// Outgoing Messages +// ============================================================================= + +/** + * Send a registration acknowledgment. + */ +async function sendRegistrationAck( + pluginId: string, + requestId: string, + success: boolean, + error?: string, + toolNamespace?: string +): Promise { + const ackMessage = createPluginMessage( + 'PLUGIN_REGISTER_ACK', + { success, error, toolNamespace }, + requestId + ); + + try { + await browser.runtime.sendMessage(pluginId, ackMessage); + } catch (err) { + log('Failed to send registration ack:', err); + } +} + +/** + * Broadcast a plugin update to extension pages. + */ +function broadcastPluginUpdate(eventType: string, pluginId: string): void { + browser.runtime + .sendMessage({ type: 'plugin_update', eventType, pluginId }) + .catch(() => { + // Ignore - no listeners + }); +} + +// ============================================================================= +// Plugin Communication API +// ============================================================================= + +/** + * Call a tool on a plugin. + */ +export async function callPluginTool( + pluginId: string, + toolName: string, + args: Record, + callingOrigin?: string +): Promise { + const plugin = await getPlugin(pluginId); + if (!plugin) { + throw new Error(`Plugin not registered: ${pluginId}`); + } + + if (plugin.status !== 'active') { + throw new Error(`Plugin is not active: ${pluginId} (status: ${plugin.status})`); + } + + const payload: PluginToolCallPayload = { + toolName, + arguments: args, + callingOrigin, + }; + + try { + const result = await sendToPlugin(pluginId, 'PLUGIN_TOOL_CALL', payload, PLUGIN_TOOL_CALL_TIMEOUT_MS); + return (result as PluginToolResultPayload).result; + } catch (err) { + // Record the failure + if (err instanceof Error && err.message.includes('timed out')) { + await recordFailedPing(pluginId); + } + throw err; + } +} + +/** + * Ping a plugin to check if it's healthy. + */ +export async function pingPlugin(pluginId: string): Promise { + try { + const result = (await sendToPlugin( + pluginId, + 'PLUGIN_PING', + {}, + PLUGIN_PING_TIMEOUT_MS + )) as { healthy: boolean }; + return result.healthy; + } catch (err) { + log('Ping failed for:', pluginId, err); + await recordFailedPing(pluginId); + return false; + } +} + +/** + * Request updated tools list from a plugin. + */ +export async function requestPluginTools(pluginId: string): Promise { + try { + const result = (await sendToPlugin( + pluginId, + 'PLUGIN_TOOLS_LIST', + {}, + PLUGIN_REGISTER_TIMEOUT_MS + )) as { tools: unknown[] }; + return result.tools; + } catch (err) { + log('Tools list request failed for:', pluginId, err); + throw err; + } +} + +/** + * Send PLUGIN_DISABLED notification to a plugin. + */ +export async function notifyPluginDisabled(pluginId: string, reason?: string): Promise { + try { + const message = createPluginMessage('PLUGIN_DISABLED', { reason }); + await browser.runtime.sendMessage(pluginId, message); + } catch (err) { + log('Failed to notify plugin disabled:', pluginId, err); + } +} + +/** + * Send PLUGIN_ENABLED notification to a plugin. + */ +export async function notifyPluginEnabled(pluginId: string): Promise { + try { + const message = createPluginMessage('PLUGIN_ENABLED', {}); + await browser.runtime.sendMessage(pluginId, message); + } catch (err) { + log('Failed to notify plugin enabled:', pluginId, err); + } +} + +// ============================================================================= +// Heartbeat +// ============================================================================= + +/** + * Ping all active plugins to check health. + */ +async function heartbeat(): Promise { + const plugins = await getActivePlugins(); + + for (const plugin of plugins) { + try { + const healthy = await pingPlugin(plugin.descriptor.extensionId); + if (!healthy) { + log('Plugin health check failed:', plugin.descriptor.extensionId); + } + } catch (err) { + log('Plugin heartbeat error:', plugin.descriptor.extensionId, err); + } + } +} + +/** + * Start the heartbeat interval. + */ +export function startHeartbeat(): void { + if (heartbeatIntervalId) { + return; + } + + heartbeatIntervalId = setInterval(heartbeat, PLUGIN_HEARTBEAT_INTERVAL_MS); + log('Heartbeat started'); +} + +/** + * Stop the heartbeat interval. + */ +export function stopHeartbeat(): void { + if (heartbeatIntervalId) { + clearInterval(heartbeatIntervalId); + heartbeatIntervalId = null; + log('Heartbeat stopped'); + } +} + +// ============================================================================= +// Message Listener Setup +// ============================================================================= + +/** + * Handle incoming external messages from plugins. + */ +function handleExternalMessage( + message: unknown, + sender: browser.Runtime.MessageSender +): true | void { + // Validate sender + if (!sender.id) { + log('Rejecting message without sender ID'); + return; + } + + // Validate message format + if (!isValidPluginMessage(message)) { + log('Rejecting invalid message format from:', sender.id); + return; + } + + const envelope = message as PluginMessageEnvelope; + + // Check protocol version + if (!isCompatibleProtocolVersion(envelope.protocolVersion)) { + log('Rejecting incompatible protocol version:', envelope.protocolVersion); + return; + } + + const { type, requestId, payload } = envelope; + const senderId = sender.id; + + log('Received external message:', type, 'from:', senderId); + + // Route the message + switch (type) { + case 'PLUGIN_REGISTER': + handlePluginRegister(senderId, requestId, payload as { plugin: PluginDescriptor }); + return true; + + case 'PLUGIN_UNREGISTER': + handlePluginUnregister(senderId, requestId); + return true; + + case 'PLUGIN_TOOL_RESULT': + handlePluginToolResult(senderId, requestId, payload as PluginToolResultPayload); + return true; + + case 'PLUGIN_TOOL_ERROR': + handlePluginToolError(senderId, requestId, payload as PluginToolErrorPayload); + return true; + + case 'PLUGIN_PONG': + handlePluginPong(senderId, requestId, payload as { healthy: boolean }); + return true; + + case 'PLUGIN_TOOLS_LIST_RESULT': + handlePluginToolsListResult(senderId, requestId, payload as { tools: unknown[] }); + return true; + + default: + log('Unknown message type:', type); + return; + } +} + +/** + * Initialize the plugin router. + */ +export function initializePluginRouter(): void { + if (isInitialized) { + log('Router already initialized'); + return; + } + + // Set up external message listener + browser.runtime.onMessageExternal.addListener(handleExternalMessage); + + // Start heartbeat + startHeartbeat(); + + isInitialized = true; + log('Plugin router initialized'); +} + +/** + * Shutdown the plugin router. + */ +export function shutdownPluginRouter(): void { + if (!isInitialized) { + return; + } + + // Stop heartbeat + stopHeartbeat(); + + // Clear pending requests + for (const [requestId, pending] of pendingRequests) { + clearTimeout(pending.timeoutId); + pending.reject(new Error('Router shutdown')); + } + pendingRequests.clear(); + + isInitialized = false; + log('Plugin router shutdown'); +} + +/** + * Get router status. + */ +export function getRouterStatus(): { + initialized: boolean; + pendingRequests: number; + heartbeatActive: boolean; +} { + return { + initialized: isInitialized, + pendingRequests: pendingRequests.size, + heartbeatActive: heartbeatIntervalId !== null, + }; +} + +// ============================================================================= +// Testing Utilities +// ============================================================================= + +/** + * Clear all pending requests. For testing only. + * @internal + */ +export function __clearPendingRequests(): void { + for (const [, pending] of pendingRequests) { + clearTimeout(pending.timeoutId); + } + pendingRequests.clear(); +} + +/** + * Get pending requests. For testing only. + * @internal + */ +export function __getPendingRequests(): Map { + return pendingRequests; +} diff --git a/extension/src/plugins/types.ts b/extension/src/plugins/types.ts new file mode 100644 index 0000000..aba52b1 --- /dev/null +++ b/extension/src/plugins/types.ts @@ -0,0 +1,423 @@ +/** + * Harbor Plugin System - Type Definitions + * + * Defines the protocol for extension-based plugins that provide tools to Harbor. + * Protocol version: harbor-plugin/v1 + */ + +// ============================================================================= +// Protocol Constants +// ============================================================================= + +export const PLUGIN_PROTOCOL_VERSION = 'harbor-plugin/v1'; +export const PLUGIN_NAMESPACE = 'harbor-plugin'; + +// Default timeouts (in milliseconds) +export const PLUGIN_REGISTER_TIMEOUT_MS = 5000; +export const PLUGIN_TOOL_CALL_TIMEOUT_MS = 30000; +export const PLUGIN_PING_TIMEOUT_MS = 2000; +export const PLUGIN_HEARTBEAT_INTERVAL_MS = 60000; + +// ============================================================================= +// Message Envelope +// ============================================================================= + +/** + * Base envelope for all plugin protocol messages. + */ +export interface PluginMessageEnvelope { + /** Namespace identifier for Harbor plugin protocol */ + namespace: typeof PLUGIN_NAMESPACE; + /** Protocol version (e.g., 'harbor-plugin/v1') */ + protocolVersion: typeof PLUGIN_PROTOCOL_VERSION; + /** Message type */ + type: T; + /** Unique request ID for correlation */ + requestId: string; + /** Unix timestamp when message was created */ + timestamp: number; + /** Message payload (type depends on message type) */ + payload: PluginMessagePayload; +} + +// ============================================================================= +// Message Types +// ============================================================================= + +export type PluginMessageType = + // Registration + | 'PLUGIN_REGISTER' + | 'PLUGIN_REGISTER_ACK' + | 'PLUGIN_UNREGISTER' + | 'PLUGIN_UNREGISTER_ACK' + // Tool operations + | 'PLUGIN_TOOLS_LIST' + | 'PLUGIN_TOOLS_LIST_RESULT' + | 'PLUGIN_TOOL_CALL' + | 'PLUGIN_TOOL_RESULT' + | 'PLUGIN_TOOL_ERROR' + // Health/keepalive + | 'PLUGIN_PING' + | 'PLUGIN_PONG' + // Hub notifications to plugins + | 'PLUGIN_DISABLED' + | 'PLUGIN_ENABLED'; + +// ============================================================================= +// Tool Definition (Plugin-provided) +// ============================================================================= + +/** + * Tool definition provided by a plugin. + * Follows MCP tool schema with additional UI hints. + */ +export interface PluginToolDefinition { + /** Tool name (unique within the plugin, e.g., 'echo') */ + name: string; + /** Human-readable title */ + title: string; + /** Description of what the tool does */ + description: string; + /** JSON Schema for input parameters */ + inputSchema: JsonSchema; + /** JSON Schema for output (optional) */ + outputSchema?: JsonSchema; + /** UI hints for rendering (optional) */ + uiHints?: ToolUiHints; +} + +/** + * JSON Schema type (subset used for tool schemas). + */ +export interface JsonSchema { + type?: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'; + properties?: Record; + required?: string[]; + items?: JsonSchema; + description?: string; + default?: unknown; + enum?: unknown[]; + additionalProperties?: boolean | JsonSchema; +} + +/** + * UI hints for tool rendering. + */ +export interface ToolUiHints { + /** Icon identifier or URL */ + icon?: string; + /** Category for grouping */ + category?: string; + /** Whether this tool may have side effects */ + dangerous?: boolean; + /** Estimated execution time category */ + speed?: 'instant' | 'fast' | 'slow'; +} + +// ============================================================================= +// Plugin Descriptor +// ============================================================================= + +/** + * Plugin metadata sent during registration. + */ +export interface PluginDescriptor { + /** Firefox extension ID (must match manifest) */ + extensionId: string; + /** Human-readable plugin name */ + name: string; + /** Plugin version (semver) */ + version: string; + /** Plugin description */ + description?: string; + /** Plugin author */ + author?: string; + /** Homepage or documentation URL */ + homepage?: string; + /** Icon URL or data URI */ + icon?: string; + /** Tools provided by this plugin */ + tools: PluginToolDefinition[]; +} + +// ============================================================================= +// Message Payloads +// ============================================================================= + +// Helper type for payload mapping +export type PluginMessagePayload = + T extends 'PLUGIN_REGISTER' ? PluginRegisterPayload : + T extends 'PLUGIN_REGISTER_ACK' ? PluginRegisterAckPayload : + T extends 'PLUGIN_UNREGISTER' ? PluginUnregisterPayload : + T extends 'PLUGIN_UNREGISTER_ACK' ? PluginUnregisterAckPayload : + T extends 'PLUGIN_TOOLS_LIST' ? PluginToolsListPayload : + T extends 'PLUGIN_TOOLS_LIST_RESULT' ? PluginToolsListResultPayload : + T extends 'PLUGIN_TOOL_CALL' ? PluginToolCallPayload : + T extends 'PLUGIN_TOOL_RESULT' ? PluginToolResultPayload : + T extends 'PLUGIN_TOOL_ERROR' ? PluginToolErrorPayload : + T extends 'PLUGIN_PING' ? PluginPingPayload : + T extends 'PLUGIN_PONG' ? PluginPongPayload : + T extends 'PLUGIN_DISABLED' ? PluginDisabledPayload : + T extends 'PLUGIN_ENABLED' ? PluginEnabledPayload : + never; + +// Registration payloads + +export interface PluginRegisterPayload { + plugin: PluginDescriptor; +} + +export interface PluginRegisterAckPayload { + success: boolean; + /** Error message if registration failed */ + error?: string; + /** Assigned namespace prefix for tools */ + toolNamespace?: string; +} + +export interface PluginUnregisterPayload { + /** Reason for unregistering */ + reason?: string; +} + +export interface PluginUnregisterAckPayload { + success: boolean; +} + +// Tool operation payloads + +export interface PluginToolsListPayload { + // Empty - hub requests tool list from plugin +} + +export interface PluginToolsListResultPayload { + tools: PluginToolDefinition[]; +} + +export interface PluginToolCallPayload { + /** Tool name (without namespace prefix) */ + toolName: string; + /** Arguments for the tool */ + arguments: Record; + /** Calling origin (for plugin's information) */ + callingOrigin?: string; +} + +export interface PluginToolResultPayload { + /** Result data from the tool */ + result: unknown; + /** Execution time in milliseconds */ + executionTimeMs?: number; +} + +export interface PluginToolErrorPayload { + /** Error code */ + code: PluginErrorCode; + /** Human-readable error message */ + message: string; + /** Additional error details */ + details?: unknown; +} + +// Health/keepalive payloads + +export interface PluginPingPayload { + // Empty +} + +export interface PluginPongPayload { + /** Plugin uptime in seconds */ + uptime?: number; + /** Whether plugin is healthy */ + healthy: boolean; +} + +// Hub notification payloads + +export interface PluginDisabledPayload { + /** Reason for disabling */ + reason?: string; +} + +export interface PluginEnabledPayload { + // Empty +} + +// ============================================================================= +// Error Codes +// ============================================================================= + +export type PluginErrorCode = + | 'TOOL_NOT_FOUND' + | 'INVALID_ARGUMENTS' + | 'EXECUTION_FAILED' + | 'TIMEOUT' + | 'INTERNAL_ERROR' + | 'NOT_REGISTERED' + | 'ALREADY_REGISTERED' + | 'PLUGIN_NOT_ALLOWED' + | 'PROTOCOL_VERSION_MISMATCH'; + +// ============================================================================= +// Registry State +// ============================================================================= + +/** + * Status of a registered plugin. + */ +export type PluginStatus = 'active' | 'disabled' | 'unreachable' | 'error'; + +/** + * Internal registry entry for a plugin. + */ +export interface PluginRegistryEntry { + /** Plugin descriptor from registration */ + descriptor: PluginDescriptor; + /** Current status */ + status: PluginStatus; + /** Last seen timestamp (from pong or any successful message) */ + lastSeen: number; + /** Registration timestamp */ + registeredAt: number; + /** Last error message if status is 'error' */ + lastError?: string; + /** Number of consecutive failed pings */ + failedPings: number; +} + +/** + * Stored registry format (persisted to storage). + */ +export interface StoredPluginRegistry { + /** Version for migration */ + version: 1; + /** Map of extensionId -> registry entry */ + plugins: Record; + /** Allowlist of plugin extension IDs (empty = allow all) */ + allowlist: string[]; + /** When the registry was last updated */ + updatedAt: number; +} + +// ============================================================================= +// Hub Configuration +// ============================================================================= + +/** + * Configuration for the plugin hub. + */ +export interface PluginHubConfig { + /** Extension IDs allowed to register as plugins (empty = allow all) */ + allowedPluginIds: string[]; + /** Whether to auto-start registered plugins on hub startup */ + autoStartPlugins: boolean; + /** Timeout for tool calls in milliseconds */ + toolCallTimeoutMs: number; + /** Heartbeat interval for plugin health checks */ + heartbeatIntervalMs: number; +} + +// ============================================================================= +// Aggregated Tool (for website API) +// ============================================================================= + +/** + * Tool as exposed to websites via window.agent.tools. + * Namespaced with plugin ID prefix. + */ +export interface AggregatedPluginTool { + /** Namespaced tool name: :: */ + name: string; + /** Human-readable title */ + title: string; + /** Description */ + description: string; + /** JSON Schema for input */ + inputSchema: JsonSchema; + /** Output schema if provided */ + outputSchema?: JsonSchema; + /** Source plugin ID */ + pluginId: string; + /** Original tool name (without namespace) */ + originalName: string; +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Create a namespaced tool name. + */ +export function createToolNamespace(pluginId: string, toolName: string): string { + return `${pluginId}::${toolName}`; +} + +/** + * Parse a namespaced tool name. + */ +export function parseToolNamespace(namespacedName: string): { pluginId: string; toolName: string } | null { + const separatorIndex = namespacedName.indexOf('::'); + if (separatorIndex === -1) { + return null; + } + return { + pluginId: namespacedName.slice(0, separatorIndex), + toolName: namespacedName.slice(separatorIndex + 2), + }; +} + +/** + * Generate a unique request ID. + */ +export function generatePluginRequestId(): string { + return `plugin-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; +} + +/** + * Create a plugin message envelope. + */ +export function createPluginMessage( + type: T, + payload: PluginMessagePayload, + requestId?: string +): PluginMessageEnvelope { + return { + namespace: PLUGIN_NAMESPACE, + protocolVersion: PLUGIN_PROTOCOL_VERSION, + type, + requestId: requestId ?? generatePluginRequestId(), + timestamp: Date.now(), + payload, + }; +} + +/** + * Validate a plugin message envelope. + */ +export function isValidPluginMessage(message: unknown): message is PluginMessageEnvelope { + if (!message || typeof message !== 'object') { + return false; + } + + const msg = message as Record; + + return ( + msg.namespace === PLUGIN_NAMESPACE && + typeof msg.protocolVersion === 'string' && + msg.protocolVersion.startsWith('harbor-plugin/') && + typeof msg.type === 'string' && + typeof msg.requestId === 'string' && + typeof msg.timestamp === 'number' && + msg.payload !== undefined + ); +} + +/** + * Check protocol version compatibility. + */ +export function isCompatibleProtocolVersion(version: string): boolean { + // For v1, we only accept exact match + // Future versions can implement proper semver comparison + return version === PLUGIN_PROTOCOL_VERSION; +} diff --git a/extension/src/provider/background-router.ts b/extension/src/provider/background-router.ts index 3b02429..b3c4e28 100644 --- a/extension/src/provider/background-router.ts +++ b/extension/src/provider/background-router.ts @@ -32,13 +32,27 @@ import { getAllowedTools, } from './permissions'; import { llmChat } from '../background'; -import { +import { getMcpConnections, listMcpTools, - createChatSession, - sendChatMessage, + createChatSession, + sendChatMessage, + continueChatWithPluginResults, deleteChatSession, + PluginToolDefinition, + PluginToolResult, + ChatSendMessageResponse, } from '../bridge-api'; +import { + getAggregatedPluginTools, + hasPluginToolPermission, + checkPluginConsent, + grantPluginPermission, + clearPluginTabGrants, + callPluginTool, + findToolPlugin, + parseToolNamespace, +} from '../plugins'; const DEBUG = true; @@ -488,40 +502,54 @@ async function handleToolsList( if (!(await requirePermission(port, requestId, origin, 'mcp:tools.list'))) { return; } - + try { + const allTools: ToolDescriptor[] = []; + // Get list of connected MCP servers and their tools using direct function call const connectionsResponse = await getMcpConnections(); log('[ToolsList] Connections response:', connectionsResponse); - - if (connectionsResponse.type === 'error' || !connectionsResponse.connections) { - log('[ToolsList] No connections available'); - sendResponse(port, 'tools_list_result', requestId, { tools: [] }); - return; - } - - const allTools: ToolDescriptor[] = []; - - log(`[ToolsList] Found ${connectionsResponse.connections.length} connected servers`); - - // For each connected server, get its tools - for (const conn of connectionsResponse.connections) { - log(`[ToolsList] Getting tools from server: ${conn.serverId}`); - const toolsResponse = await listMcpTools(conn.serverId); - log(`[ToolsList] Tools response for ${conn.serverId}:`, toolsResponse); - - if (toolsResponse.tools) { - for (const tool of toolsResponse.tools) { - allTools.push({ - name: `${conn.serverId}/${tool.name}`, - description: tool.description, - inputSchema: tool.inputSchema, - serverId: conn.serverId, - }); + + if (connectionsResponse.type !== 'error' && connectionsResponse.connections) { + log(`[ToolsList] Found ${connectionsResponse.connections.length} connected servers`); + + // For each connected server, get its tools + for (const conn of connectionsResponse.connections) { + log(`[ToolsList] Getting tools from server: ${conn.serverId}`); + const toolsResponse = await listMcpTools(conn.serverId); + log(`[ToolsList] Tools response for ${conn.serverId}:`, toolsResponse); + + if (toolsResponse.tools) { + for (const tool of toolsResponse.tools) { + allTools.push({ + name: `${conn.serverId}/${tool.name}`, + description: tool.description, + inputSchema: tool.inputSchema, + serverId: conn.serverId, + }); + } } } } - + + // Also get tools from registered plugins + try { + const pluginTools = await getAggregatedPluginTools(); + log(`[ToolsList] Found ${pluginTools.length} plugin tools`); + + for (const pluginTool of pluginTools) { + allTools.push({ + name: pluginTool.name, // Already namespaced as pluginId::toolName + description: pluginTool.description, + inputSchema: pluginTool.inputSchema, + serverId: pluginTool.pluginId, + }); + } + } catch (err) { + log('[ToolsList] Error getting plugin tools:', err); + // Continue even if plugin tools fail + } + log(`[ToolsList] Total tools found: ${allTools.length}`); sendResponse(port, 'tools_list_result', requestId, { tools: allTools }); } catch (err) { @@ -540,19 +568,56 @@ async function handleToolsCall( if (!(await requirePermission(port, requestId, origin, 'mcp:tools.call'))) { return; } - + const { tool, args } = payload; - - // Parse tool name: "serverId/toolName" + + // Check if this is a plugin tool (format: pluginId::toolName) + const pluginParsed = parseToolNamespace(tool); + + if (pluginParsed) { + // This is a plugin tool call + const { pluginId, toolName } = pluginParsed; + log(`[ToolsCall] Plugin tool call: ${pluginId}::${toolName}`); + + // Check plugin tool permission for this origin + // Since we already passed mcp:tools.call check above, auto-grant plugin tools + const hasPermission = await hasPluginToolPermission(origin, tool); + if (!hasPermission) { + // Auto-grant this plugin tool (user already has mcp:tools.call) + await grantPluginPermission(origin, { + mode: 'once', + tools: [tool], + }); + log(`[ToolsCall] Auto-granted plugin permission for ${tool} to ${origin}`); + } + + try { + // Call the plugin tool + const result = await callPluginTool(pluginId, toolName, args, origin); + sendResponse(port, 'tools_call_result', requestId, { + success: true, + result, + }); + } catch (err) { + log('Plugin tool call error:', err); + sendError(port, requestId, createError( + 'ERR_TOOL_FAILED', + err instanceof Error ? err.message : String(err) + )); + } + return; + } + + // This is an MCP tool call (format: serverId/toolName) const slashIndex = tool.indexOf('/'); if (slashIndex === -1) { sendError(port, requestId, createError( 'ERR_TOOL_NOT_ALLOWED', - 'Tool name must be in format "serverId/toolName"' + 'Tool name must be in format "serverId/toolName" or "pluginId::toolName"' )); return; } - + // Check if this specific tool is allowed for this origin const toolAllowed = await isToolAllowed(origin, tool); if (!toolAllowed) { @@ -564,10 +629,10 @@ async function handleToolsCall( )); return; } - + const serverId = tool.slice(0, slashIndex); const toolName = tool.slice(slashIndex + 1); - + try { // Call the tool via MCP const callResponse = await browser.runtime.sendMessage({ @@ -576,12 +641,12 @@ async function handleToolsCall( tool_name: toolName, arguments: args, }) as { type: string; result?: unknown; error?: { message: string } }; - + if (callResponse.type === 'error') { sendError(port, requestId, createError('ERR_TOOL_FAILED', callResponse.error?.message || 'Tool call failed')); return; } - + sendResponse(port, 'tools_call_result', requestId, { success: true, result: callResponse.result, @@ -745,18 +810,36 @@ async function handleAgentRun( return; } - // Check if we have any connected servers + // Check if we have any connected servers or plugin tools const connections = connectionsResponse.connections || []; - if (connections.length === 0) { - log('[AgentRun] No MCP servers connected'); - sendEvent({ type: 'error', error: createError('ERR_INTERNAL', 'No MCP servers connected. Please start and connect at least one server in the Harbor sidebar.') }); + const pluginToolsFromRegistry = await getAggregatedPluginTools(); + + // Convert plugin tools to the format expected by the bridge + // Use originalName (e.g., "time.format") not the namespaced name (e.g., "plugin@id::time.format") + const pluginTools: PluginToolDefinition[] = pluginToolsFromRegistry.map(pt => ({ + pluginId: pt.pluginId, + name: pt.originalName, + description: pt.description, + inputSchema: pt.inputSchema as Record, + })); + + if (connections.length === 0 && pluginTools.length === 0) { + log('[AgentRun] No MCP servers or plugins available'); + sendEvent({ type: 'error', error: createError('ERR_INTERNAL', 'No MCP servers or plugins available. Please start an MCP server or install a plugin in the Harbor sidebar.') }); return; } - + const enabledServers = connections.map(c => c.serverId); - const totalTools = connections.reduce((sum, c) => sum + c.toolCount, 0); - - sendEvent({ type: 'status', message: `Found ${totalTools} tools from ${enabledServers.length} servers` }); + const mcpToolCount = connections.reduce((sum, c) => sum + c.toolCount, 0); + const totalTools = mcpToolCount + pluginTools.length; + + if (connections.length > 0 && pluginTools.length > 0) { + sendEvent({ type: 'status', message: `Found ${totalTools} tools (${mcpToolCount} MCP, ${pluginTools.length} plugin)` }); + } else if (connections.length > 0) { + sendEvent({ type: 'status', message: `Found ${mcpToolCount} tools from ${enabledServers.length} servers` }); + } else { + sendEvent({ type: 'status', message: `Found ${pluginTools.length} plugin tools` }); + } // Check if aborted const req = streamingRequests.get(requestId); @@ -771,9 +854,10 @@ async function handleAgentRun( systemPrompt = 'You are a helpful AI assistant. When using information from tools, cite your sources.'; } - // Create a temporary chat session with the connected servers + // Create a temporary chat session with the connected servers and plugin tools const createResponse = await createChatSession({ enabledServers, + pluginTools, name: `Agent task: ${task.substring(0, 30)}...`, systemPrompt, maxIterations: maxToolCalls, @@ -795,68 +879,163 @@ async function handleAgentRun( } sendEvent({ type: 'status', message: 'Processing...' }); - + + // Helper function to process chat response steps + const processSteps = (response: ChatSendMessageResponse, citations: Array<{ source: 'tab' | 'tool'; ref: string; excerpt: string }>): boolean => { + if (response.steps) { + for (const step of response.steps) { + // Check if aborted + const reqCheck = streamingRequests.get(requestId); + if (!reqCheck || reqCheck.aborted) { + sendEvent({ type: 'error', error: createError('ERR_INTERNAL', 'Request aborted') }); + return false; + } + + if (step.type === 'tool_calls' && step.toolCalls) { + for (const tc of step.toolCalls) { + sendEvent({ type: 'tool_call', tool: tc.name, args: tc.arguments }); + } + } + + if (step.type === 'tool_results' && step.toolResults) { + for (const tr of step.toolResults) { + // Use full prefixed name to match tool_call event + const fullToolName = tr.serverId ? `${tr.serverId}__${tr.toolName}` : tr.toolName; + sendEvent({ + type: 'tool_result', + tool: fullToolName, + result: tr.content, + error: tr.isError ? createError('ERR_TOOL_FAILED', tr.content) : undefined, + }); + + if (requireCitations && !tr.isError) { + citations.push({ + source: 'tool', + ref: `${tr.serverId}/${tr.toolName}`, + excerpt: tr.content.slice(0, 200), + }); + } + } + } + + if (step.type === 'error' && step.error) { + sendEvent({ type: 'error', error: createError('ERR_INTERNAL', step.error) }); + return false; + } + } + } + return true; + }; + // Send the message - the bridge orchestrator handles everything - const chatResponse = await sendChatMessage({ + let chatResponse = await sendChatMessage({ sessionId, message: task, // useToolRouter defaults to false - LLM sees all tools and decides }); - + if (chatResponse.type === 'error') { sendEvent({ type: 'error', error: createError('ERR_INTERNAL', chatResponse.error?.message || 'Chat failed') }); return; } - + // Stream the orchestration steps to the client const citations: Array<{ source: 'tab' | 'tool'; ref: string; excerpt: string }> = []; - - if (chatResponse.steps) { - for (const step of chatResponse.steps) { + + // Process initial steps + if (!processSteps(chatResponse, citations)) { + return; + } + + // Handle plugin tool calls - loop until no more paused states + while (chatResponse.paused && chatResponse.pendingPluginToolCalls && chatResponse.pendingPluginToolCalls.length > 0) { + log('[AgentRun] Processing plugin tool calls:', chatResponse.pendingPluginToolCalls.length); + + // Execute plugin tools + const pluginResults: PluginToolResult[] = []; + + for (const ptc of chatResponse.pendingPluginToolCalls) { // Check if aborted - const req3 = streamingRequests.get(requestId); - if (!req3 || req3.aborted) { + const reqCheck = streamingRequests.get(requestId); + if (!reqCheck || reqCheck.aborted) { sendEvent({ type: 'error', error: createError('ERR_INTERNAL', 'Request aborted') }); return; } - - if (step.type === 'tool_calls' && step.toolCalls) { - for (const tc of step.toolCalls) { - sendEvent({ type: 'tool_call', tool: tc.name, args: tc.arguments }); - } - } - - if (step.type === 'tool_results' && step.toolResults) { - for (const tr of step.toolResults) { - // Use full prefixed name to match tool_call event - const fullToolName = tr.serverId ? `${tr.serverId}__${tr.toolName}` : tr.toolName; - sendEvent({ - type: 'tool_result', - tool: fullToolName, - result: tr.content, - error: tr.isError ? createError('ERR_TOOL_FAILED', tr.content) : undefined, + + // Send tool call event + sendEvent({ type: 'tool_call', tool: `plugin__${ptc.toolName}`, args: ptc.arguments }); + + try { + // Call the plugin tool + const result = await callPluginTool( + ptc.pluginId, + ptc.toolName, + ptc.arguments, + origin + ); + + const resultStr = typeof result === 'string' ? result : JSON.stringify(result); + + pluginResults.push({ + toolCallId: ptc.id, + content: resultStr, + isError: false, + }); + + // Send tool result event + sendEvent({ + type: 'tool_result', + tool: `plugin__${ptc.toolName}`, + result: resultStr, + }); + + if (requireCitations) { + citations.push({ + source: 'tool', + ref: `plugin/${ptc.toolName}`, + excerpt: resultStr.slice(0, 200), }); - - if (requireCitations && !tr.isError) { - citations.push({ - source: 'tool', - ref: `${tr.serverId}/${tr.toolName}`, - excerpt: tr.content.slice(0, 200), - }); - } } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + log('[AgentRun] Plugin tool error:', ptc.toolName, errorMsg); + + pluginResults.push({ + toolCallId: ptc.id, + content: `Error: ${errorMsg}`, + isError: true, + }); + + // Send error tool result event + sendEvent({ + type: 'tool_result', + tool: `plugin__${ptc.toolName}`, + result: `Error: ${errorMsg}`, + error: createError('ERR_TOOL_FAILED', errorMsg), + }); } - - if (step.type === 'error' && step.error) { - sendEvent({ type: 'error', error: createError('ERR_INTERNAL', step.error) }); - return; - } + } + + // Continue orchestration with plugin results + chatResponse = await continueChatWithPluginResults({ + sessionId, + pluginResults, + }); + + if (chatResponse.type === 'error') { + sendEvent({ type: 'error', error: createError('ERR_INTERNAL', chatResponse.error?.message || 'Chat continuation failed') }); + return; + } + + // Process continuation steps + if (!processSteps(chatResponse, citations)) { + return; } } - + // Get the final response const finalOutput = chatResponse.response || ''; - + // Stream the output token by token for a nice effect const tokens = finalOutput.split(/(\s+)/); for (const token of tokens) { @@ -865,13 +1044,13 @@ async function handleAgentRun( await new Promise(r => setTimeout(r, 10)); // Small delay for streaming effect } } - - sendEvent({ - type: 'final', + + sendEvent({ + type: 'final', output: finalOutput, citations: requireCitations && citations.length > 0 ? citations : undefined, }); - + if (chatResponse.reachedMaxIterations) { log('[AgentRun] Warning: reached max iterations'); } @@ -989,6 +1168,7 @@ export function setupProviderRouter(): void { // Clean up temporary grants when tabs close browser.tabs.onRemoved.addListener((tabId) => { clearTabGrants(tabId); + clearPluginTabGrants(tabId); }); log('Provider router initialized'); diff --git a/extension/src/provider/internal-api.ts b/extension/src/provider/internal-api.ts index 639f651..a08a4c9 100644 --- a/extension/src/provider/internal-api.ts +++ b/extension/src/provider/internal-api.ts @@ -151,27 +151,59 @@ function createAgentRunOverride(): (options: { yield { type: 'error', error: { code: 'ERR_INTERNAL', message: 'Failed to connect to bridge. Is the Harbor bridge running?' } }; return; } - - if (!connectionsResponse || connectionsResponse.type === 'error' || !connectionsResponse.connections) { - yield { type: 'error', error: { code: 'ERR_INTERNAL', message: 'No response from bridge. Please check that the Harbor bridge is running.' } }; - return; + + // Get plugin tools + let pluginToolsResponse: { type: string; tools?: Array<{ pluginId: string; name: string; originalName: string; description?: string; inputSchema?: Record }> } | undefined; + try { + pluginToolsResponse = await browser.runtime.sendMessage({ + type: 'list_plugin_tools', + }) as typeof pluginToolsResponse; + } catch (err) { + console.log('[InternalAPI] Failed to get plugin tools:', err); + // Not fatal - continue without plugins } - - const connections = connectionsResponse.connections; - if (connections.length === 0) { - yield { type: 'error', error: { code: 'ERR_INTERNAL', message: 'No MCP servers connected. Please start and connect at least one server.' } }; + + const connections = connectionsResponse?.connections || []; + const pluginTools = pluginToolsResponse?.tools || []; + + if (!connectionsResponse || connectionsResponse.type === 'error') { + if (pluginTools.length === 0) { + yield { type: 'error', error: { code: 'ERR_INTERNAL', message: 'No response from bridge. Please check that the Harbor bridge is running.' } }; + return; + } + // Bridge not connected but we have plugins - continue + } + + if (connections.length === 0 && pluginTools.length === 0) { + yield { type: 'error', error: { code: 'ERR_INTERNAL', message: 'No MCP servers or plugins available. Please start an MCP server or install a plugin.' } }; return; } - + const enabledServers = connections.map(c => c.serverId); - const totalTools = connections.reduce((sum, c) => sum + c.toolCount, 0); - - yield { type: 'status', message: `Found ${totalTools} tools from ${enabledServers.length} servers` }; - - // Create chat session + const mcpToolCount = connections.reduce((sum, c) => sum + c.toolCount, 0); + const totalTools = mcpToolCount + pluginTools.length; + + if (connections.length > 0 && pluginTools.length > 0) { + yield { type: 'status', message: `Found ${totalTools} tools (${mcpToolCount} MCP, ${pluginTools.length} plugin)` }; + } else if (connections.length > 0) { + yield { type: 'status', message: `Found ${mcpToolCount} tools from ${enabledServers.length} servers` }; + } else { + yield { type: 'status', message: `Found ${pluginTools.length} plugin tools` }; + } + + // Create chat session with plugin tools + // Map plugin tools to use originalName (the actual tool name the plugin expects) + const mappedPluginTools = pluginTools.map(pt => ({ + pluginId: pt.pluginId, + name: pt.originalName, // Use original name, not namespaced name + description: pt.description, + inputSchema: pt.inputSchema, + })); + const createResponse = await browser.runtime.sendMessage({ type: 'chat_create_session', enabled_servers: enabledServers, + plugin_tools: mappedPluginTools, name: `Agent task: ${task.substring(0, 30)}...`, max_iterations: maxToolCalls, }) as { type: string; session?: { id: string }; error?: { message: string } }; @@ -185,12 +217,8 @@ function createAgentRunOverride(): (options: { yield { type: 'status', message: 'Processing...' }; try { - // Send message and get orchestration result - const chatResponse = await browser.runtime.sendMessage({ - type: 'chat_send_message', - session_id: sessionId, - message: task, - }) as { + // Type for chat responses + type ChatResponse = { type: string; response?: string; steps?: Array<{ @@ -200,55 +228,158 @@ function createAgentRunOverride(): (options: { toolResults?: Array<{ toolCallId: string; toolName: string; serverId: string; content: string; isError: boolean }>; error?: string; }>; + paused?: boolean; + pendingPluginToolCalls?: Array<{ + id: string; + pluginId: string; + toolName: string; + arguments: Record; + }>; error?: { message: string }; }; - + + // Helper to process steps + const processSteps = function* (response: ChatResponse, citations: Array<{ source: 'tool'; ref: string; excerpt: string }>) { + if (response.steps) { + for (const step of response.steps) { + if (step.type === 'tool_calls' && step.toolCalls) { + for (const tc of step.toolCalls) { + yield { type: 'tool_call' as const, tool: tc.name, args: tc.arguments }; + } + } + + if (step.type === 'tool_results' && step.toolResults) { + for (const tr of step.toolResults) { + const fullToolName = tr.serverId ? `${tr.serverId}__${tr.toolName}` : tr.toolName; + yield { + type: 'tool_result' as const, + tool: fullToolName, + result: tr.content, + error: tr.isError ? { code: 'ERR_TOOL_FAILED' as const, message: tr.content } : undefined, + }; + + if (requireCitations && !tr.isError) { + citations.push({ + source: 'tool', + ref: `${tr.serverId}/${tr.toolName}`, + excerpt: tr.content.slice(0, 200), + }); + } + } + } + + if (step.type === 'error' && step.error) { + yield { type: 'error' as const, error: { code: 'ERR_INTERNAL' as const, message: step.error } }; + return; + } + } + } + }; + + // Send message and get orchestration result + let chatResponse = await browser.runtime.sendMessage({ + type: 'chat_send_message', + session_id: sessionId, + message: task, + }) as ChatResponse; + if (chatResponse.type === 'error') { yield { type: 'error', error: { code: 'ERR_INTERNAL', message: chatResponse.error?.message || 'Chat failed' } }; return; } - + // Process steps and yield events const citations: Array<{ source: 'tool'; ref: string; excerpt: string }> = []; - - if (chatResponse.steps) { - for (const step of chatResponse.steps) { - if (step.type === 'tool_calls' && step.toolCalls) { - for (const tc of step.toolCalls) { - yield { type: 'tool_call', tool: tc.name, args: tc.arguments }; + + // Process initial steps + for (const event of processSteps(chatResponse, citations)) { + yield event; + if (event.type === 'error') return; + } + + // Handle plugin tool calls - loop until no more paused states + while (chatResponse.paused && chatResponse.pendingPluginToolCalls && chatResponse.pendingPluginToolCalls.length > 0) { + console.log('[InternalAPI] Processing plugin tool calls:', chatResponse.pendingPluginToolCalls.length); + + const pluginResults: Array<{ toolCallId: string; content: string; isError: boolean }> = []; + + for (const ptc of chatResponse.pendingPluginToolCalls) { + yield { type: 'tool_call', tool: `plugin__${ptc.toolName}`, args: ptc.arguments }; + + try { + const result = await browser.runtime.sendMessage({ + type: 'execute_plugin_tool', + plugin_id: ptc.pluginId, + tool_name: ptc.toolName, + arguments: ptc.arguments, + }) as { type: string; result?: unknown; error?: { message: string } }; + + if (result.type === 'error') { + throw new Error(result.error?.message || 'Plugin tool failed'); } - } - - if (step.type === 'tool_results' && step.toolResults) { - for (const tr of step.toolResults) { - const fullToolName = tr.serverId ? `${tr.serverId}__${tr.toolName}` : tr.toolName; - yield { - type: 'tool_result', - tool: fullToolName, - result: tr.content, - error: tr.isError ? { code: 'ERR_TOOL_FAILED' as const, message: tr.content } : undefined, - }; - - if (requireCitations && !tr.isError) { - citations.push({ - source: 'tool', - ref: `${tr.serverId}/${tr.toolName}`, - excerpt: tr.content.slice(0, 200), - }); - } + + const resultStr = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); + + pluginResults.push({ + toolCallId: ptc.id, + content: resultStr, + isError: false, + }); + + yield { + type: 'tool_result', + tool: `plugin__${ptc.toolName}`, + result: resultStr, + }; + + if (requireCitations) { + citations.push({ + source: 'tool', + ref: `plugin/${ptc.toolName}`, + excerpt: resultStr.slice(0, 200), + }); } - } - - if (step.type === 'error' && step.error) { - yield { type: 'error', error: { code: 'ERR_INTERNAL', message: step.error } }; - return; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.log('[InternalAPI] Plugin tool error:', ptc.toolName, errorMsg); + + pluginResults.push({ + toolCallId: ptc.id, + content: `Error: ${errorMsg}`, + isError: true, + }); + + yield { + type: 'tool_result', + tool: `plugin__${ptc.toolName}`, + result: `Error: ${errorMsg}`, + error: { code: 'ERR_TOOL_FAILED' as const, message: errorMsg }, + }; } } + + // Continue orchestration with plugin results + chatResponse = await browser.runtime.sendMessage({ + type: 'chat_continue_with_plugin_results', + session_id: sessionId, + plugin_results: pluginResults, + }) as ChatResponse; + + if (chatResponse.type === 'error') { + yield { type: 'error', error: { code: 'ERR_INTERNAL', message: chatResponse.error?.message || 'Chat continuation failed' } }; + return; + } + + // Process continuation steps + for (const event of processSteps(chatResponse, citations)) { + yield event; + if (event.type === 'error') return; + } } - + // Yield final response const finalOutput = chatResponse.response || ''; - + // Stream tokens for nice effect const tokens = finalOutput.split(/(\s+)/); for (const token of tokens) { @@ -257,13 +388,13 @@ function createAgentRunOverride(): (options: { await new Promise(r => setTimeout(r, 10)); } } - - yield { - type: 'final', + + yield { + type: 'final', output: finalOutput, citations: requireCitations && citations.length > 0 ? citations : undefined, }; - + } finally { // Clean up session browser.runtime.sendMessage({ diff --git a/extension/src/sidebar.html b/extension/src/sidebar.html index 6b73f10..a6c4af8 100644 --- a/extension/src/sidebar.html +++ b/extension/src/sidebar.html @@ -1521,7 +1521,28 @@

Configure API Key

- + + +
+
+
+ + 🔌 Plugins + +
+
+ +
+
+
+
+
+ No plugins registered. Plugins are Firefox extensions that provide tools without needing the native bridge. +
+
+
+
+