From 2588851746f3ed4a143d12bdc6e5f5c0cc58b80f Mon Sep 17 00:00:00 2001 From: simosmik Date: Thu, 5 Mar 2026 11:58:04 +0000 Subject: [PATCH] feat(plugins): add backend subprocess support via RPC bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the plugin system so plugins can optionally declare a entry in their manifest. The host spawns a Node.js subprocess for that script, assigns it a random local port, and exposes an RPC proxy route () that the sandboxed iframe can call via postMessage — avoiding the need for the iframe to hold auth tokens. Changes: - Add plugin process manager to spawn/stop server subprocesses - Wire subprocess lifecycle into enable/disable and uninstall routes - Add RPC proxy route on the host server - Extend PluginsContext and PluginTabContent to handle ccui:rpc and ccui:rpc-response postMessage events - Add hello-world server.js as a reference subprocess implementation - Update manifest.json and README with server field documentation --- examples/plugins/hello-world/README.md | 188 +++++++++++++++----- examples/plugins/hello-world/index.html | 72 +++++++- examples/plugins/hello-world/manifest.json | 1 + examples/plugins/hello-world/server.js | 42 +++++ server/index.js | 14 ++ server/routes/plugins.js | 133 +++++++++++++- server/utils/plugin-loader.js | 1 + server/utils/plugin-process-manager.js | 162 +++++++++++++++++ src/components/plugins/PluginTabContent.tsx | 29 +++ src/contexts/PluginsContext.tsx | 2 + 10 files changed, 590 insertions(+), 54 deletions(-) create mode 100644 examples/plugins/hello-world/server.js create mode 100644 server/utils/plugin-process-manager.js diff --git a/examples/plugins/hello-world/README.md b/examples/plugins/hello-world/README.md index 6d0e527d..30377e01 100644 --- a/examples/plugins/hello-world/README.md +++ b/examples/plugins/hello-world/README.md @@ -1,48 +1,53 @@ # Hello World Plugin -A minimal example showing how to build a plugin for Claude Code UI. +A minimal example showing how to build a plugin for Claude Code UI, covering both frontend (iframe) and backend (server subprocess) capabilities. ## How plugins work -A plugin's UI runs client-side inside a sandboxed iframe. The backend handles plugin lifecycle (install, update, uninstall) and serves the plugin's files as static assets. +A plugin's UI runs client-side inside a sandboxed iframe. Plugins can optionally declare a `server` entry in their manifest — a Node.js script that the host runs as a subprocess. The iframe communicates with its server through a postMessage RPC bridge (the host proxies the calls because the sandboxed iframe has no auth token). ``` -┌─────────────────────────────────────────────────┐ -│ Backend (server) │ -│ │ -│ Lifecycle (spawns child processes): │ -│ git clone / git pull Install & update │ -│ npm install Dependency setup │ -│ │ -│ Runtime: │ -│ GET /api/plugins List plugins │ -│ GET /api/plugins/:name/assets/* Serve files │ -│ PUT /api/plugins/:name/enable Toggle on/off │ -│ DELETE /api/plugins/:name Uninstall │ -└──────────────────────┬──────────────────────────┘ - │ serves static files -┌──────────────────────▼──────────────────────────┐ -│ Frontend (browser) │ -│ │ -│ Plugin iframe ◄──postMessage──► Host app │ -│ (sandboxed) ccui:context │ -│ ccui:request-context │ -└─────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────┐ +│ Host server │ +│ │ +│ Lifecycle: │ +│ git clone / git pull Install & update │ +│ npm install Dependency setup │ +│ │ +│ Runtime: │ +│ GET /api/plugins List plugins │ +│ GET /api/plugins/:name/assets/* Serve static files │ +│ ALL /api/plugins/:name/rpc/* Proxy → subprocess │ +│ PUT /api/plugins/:name/enable Toggle + start/stop │ +│ DELETE /api/plugins/:name Uninstall + stop │ +│ │ +│ Plugin subprocess (server.js): │ +│ Runs as a child process with restricted env │ +│ Listens on random local port │ +│ Receives secrets via X-Plugin-Secret-* headers │ +└───────────┬─────────────────────────┬───────────────────┘ + │ serves static files │ proxies RPC +┌───────────▼─────────────────────────▼───────────────────┐ +│ Frontend (browser) │ +│ │ +│ Plugin iframe ◄──postMessage──► Host app │ +│ (sandboxed) ccui:context │ +│ ccui:request-context │ +│ ccui:rpc / ccui:rpc-response │ +└─────────────────────────────────────────────────────────┘ ``` ## Plugin structure -A plugin is a directory with at minimum two files: - ``` my-plugin/ manifest.json # Required — plugin metadata - index.html # Entry point (referenced by manifest.entry) - styles.css # Optional — any static assets alongside entry - app.js # Optional — JS loaded by your HTML + index.html # Frontend entry point (rendered in iframe) + server.js # Optional — backend entry point (runs as subprocess) + package.json # Optional — npm dependencies for server.js ``` -All files in the plugin directory are accessible via `/api/plugins/:name/assets/`. Use relative paths in your HTML to reference them (e.g., ``, ` diff --git a/examples/plugins/hello-world/manifest.json b/examples/plugins/hello-world/manifest.json index ca8e0c8b..c2553fcb 100644 --- a/examples/plugins/hello-world/manifest.json +++ b/examples/plugins/hello-world/manifest.json @@ -8,5 +8,6 @@ "type": "iframe", "slot": "tab", "entry": "index.html", + "server": "server.js", "permissions": [] } diff --git a/examples/plugins/hello-world/server.js b/examples/plugins/hello-world/server.js new file mode 100644 index 00000000..5ad71d62 --- /dev/null +++ b/examples/plugins/hello-world/server.js @@ -0,0 +1,42 @@ +const http = require('http'); + +let requestCount = 0; + +const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + + // Secrets are injected by the host as X-Plugin-Secret-* headers + const apiKey = req.headers['x-plugin-secret-apikey']; + + if (req.method === 'GET' && req.url === '/hello') { + requestCount++; + res.end(JSON.stringify({ + message: 'Hello from the plugin server!', + requestCount, + hasApiKey: Boolean(apiKey), + timestamp: new Date().toISOString(), + })); + return; + } + + if (req.method === 'POST' && req.url === '/echo') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let parsed; + try { parsed = JSON.parse(body); } catch { parsed = body; } + res.end(JSON.stringify({ echo: parsed })); + }); + return; + } + + res.writeHead(404); + res.end(JSON.stringify({ error: 'Not found' })); +}); + +// Listen on a random available port +server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + // Signal readiness to the host — this JSON line is required + console.log(JSON.stringify({ ready: true, port })); +}); diff --git a/server/index.js b/server/index.js index 76c2cadc..c112266f 100755 --- a/server/index.js +++ b/server/index.js @@ -65,6 +65,7 @@ import userRoutes from './routes/user.js'; import codexRoutes from './routes/codex.js'; import geminiRoutes from './routes/gemini.js'; import pluginsRoutes from './routes/plugins.js'; +import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js'; import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { IS_PLATFORM } from './constants/config.js'; @@ -2491,7 +2492,20 @@ async function startServer() { // Start watching the projects folder for changes await setupProjectsWatcher(); + + // Start server-side plugin processes for enabled plugins + startEnabledPluginServers().catch(err => { + console.error('[Plugins] Error during startup:', err.message); + }); }); + + // Clean up plugin processes on shutdown + const shutdownPlugins = () => { + stopAllPlugins(); + process.exit(0); + }; + process.on('SIGTERM', shutdownPlugins); + process.on('SIGINT', shutdownPlugins); } catch (error) { console.error('[ERROR] Failed to start server:', error); process.exit(1); diff --git a/server/routes/plugins.js b/server/routes/plugins.js index 1aa4d54d..2b9721b5 100644 --- a/server/routes/plugins.js +++ b/server/routes/plugins.js @@ -1,23 +1,34 @@ import express from 'express'; import path from 'path'; +import http from 'http'; import mime from 'mime-types'; import fs from 'fs'; import { scanPlugins, getPluginsConfig, savePluginsConfig, + getPluginDir, resolvePluginAssetPath, installPluginFromGit, updatePluginFromGit, uninstallPlugin, } from '../utils/plugin-loader.js'; +import { + startPluginServer, + stopPluginServer, + getPluginPort, + isPluginRunning, +} from '../utils/plugin-process-manager.js'; const router = express.Router(); -// GET / — List all installed plugins +// GET / — List all installed plugins (includes server running status) router.get('/', (req, res) => { try { - const plugins = scanPlugins(); + const plugins = scanPlugins().map(p => ({ + ...p, + serverRunning: p.server ? isPluginRunning(p.name) : false, + })); res.json({ plugins }); } catch (err) { res.status(500).json({ error: 'Failed to scan plugins', details: err.message }); @@ -57,8 +68,8 @@ router.get('/:name/assets/*', (req, res) => { fs.createReadStream(resolvedPath).pipe(res); }); -// PUT /:name/enable — Toggle plugin enabled/disabled -router.put('/:name/enable', (req, res) => { +// PUT /:name/enable — Toggle plugin enabled/disabled (starts/stops server if applicable) +router.put('/:name/enable', async (req, res) => { try { const { enabled } = req.body; if (typeof enabled !== 'boolean') { @@ -75,6 +86,22 @@ router.put('/:name/enable', (req, res) => { config[req.params.name] = { ...config[req.params.name], enabled }; savePluginsConfig(config); + // Start or stop the plugin server as needed + if (plugin.server) { + if (enabled && !isPluginRunning(plugin.name)) { + const pluginDir = getPluginDir(plugin.name); + if (pluginDir) { + try { + await startPluginServer(plugin.name, pluginDir, plugin.server); + } catch (err) { + console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message); + } + } + } else if (!enabled && isPluginRunning(plugin.name)) { + stopPluginServer(plugin.name); + } + } + res.json({ success: true, name: req.params.name, enabled }); } catch (err) { res.status(500).json({ error: 'Failed to update plugin', details: err.message }); @@ -95,13 +122,26 @@ router.post('/install', async (req, res) => { } const manifest = await installPluginFromGit(url); + + // Auto-start the server if the plugin has one (enabled by default) + if (manifest.server) { + const pluginDir = getPluginDir(manifest.name); + if (pluginDir) { + try { + await startPluginServer(manifest.name, pluginDir, manifest.server); + } catch (err) { + console.error(`[Plugins] Failed to start server for "${manifest.name}":`, err.message); + } + } + } + res.json({ success: true, plugin: manifest }); } catch (err) { res.status(400).json({ error: 'Failed to install plugin', details: err.message }); } }); -// POST /:name/update — Pull latest from git +// POST /:name/update — Pull latest from git (restarts server if running) router.post('/:name/update', async (req, res) => { try { const pluginName = req.params.name; @@ -110,14 +150,90 @@ router.post('/:name/update', async (req, res) => { return res.status(400).json({ error: 'Invalid plugin name' }); } + const wasRunning = isPluginRunning(pluginName); + if (wasRunning) { + stopPluginServer(pluginName); + } + const manifest = await updatePluginFromGit(pluginName); + + // Restart server if it was running before the update + if (wasRunning && manifest.server) { + const pluginDir = getPluginDir(pluginName); + if (pluginDir) { + try { + await startPluginServer(pluginName, pluginDir, manifest.server); + } catch (err) { + console.error(`[Plugins] Failed to restart server for "${pluginName}":`, err.message); + } + } + } + res.json({ success: true, plugin: manifest }); } catch (err) { res.status(400).json({ error: 'Failed to update plugin', details: err.message }); } }); -// DELETE /:name — Uninstall plugin +// ALL /:name/rpc/* — Proxy requests to plugin's server subprocess +router.all('/:name/rpc/*', (req, res) => { + const pluginName = req.params.name; + const rpcPath = req.params[0] || ''; + + if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) { + return res.status(400).json({ error: 'Invalid plugin name' }); + } + + const port = getPluginPort(pluginName); + if (!port) { + return res.status(503).json({ error: 'Plugin server is not running' }); + } + + // Inject configured secrets as headers + const config = getPluginsConfig(); + const pluginConfig = config[pluginName] || {}; + const secrets = pluginConfig.secrets || {}; + + const headers = { + 'content-type': req.headers['content-type'] || 'application/json', + }; + + // Add per-plugin secrets as X-Plugin-Secret-* headers + for (const [key, value] of Object.entries(secrets)) { + headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value); + } + + // Reconstruct query string + const qs = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : ''; + + const options = { + hostname: '127.0.0.1', + port, + path: `/${rpcPath}${qs}`, + method: req.method, + headers, + }; + + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + }); + + proxyReq.on('error', (err) => { + res.status(502).json({ error: 'Plugin server error', details: err.message }); + }); + + // Forward body (already parsed by express JSON middleware, so re-stringify) + if (req.body && Object.keys(req.body).length > 0) { + const bodyStr = JSON.stringify(req.body); + proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr)); + proxyReq.write(bodyStr); + } + + proxyReq.end(); +}); + +// DELETE /:name — Uninstall plugin (stops server first) router.delete('/:name', (req, res) => { try { const pluginName = req.params.name; @@ -127,6 +243,11 @@ router.delete('/:name', (req, res) => { return res.status(400).json({ error: 'Invalid plugin name' }); } + // Stop server if running + if (isPluginRunning(pluginName)) { + stopPluginServer(pluginName); + } + uninstallPlugin(pluginName); res.json({ success: true, name: pluginName }); } catch (err) { diff --git a/server/utils/plugin-loader.js b/server/utils/plugin-loader.js index 4339c960..c0725ef6 100644 --- a/server/utils/plugin-loader.js +++ b/server/utils/plugin-loader.js @@ -99,6 +99,7 @@ export function scanPlugins() { type: manifest.type || 'iframe', slot: manifest.slot || 'tab', entry: manifest.entry, + server: manifest.server || null, permissions: manifest.permissions || [], enabled: config[manifest.name]?.enabled !== false, // enabled by default dirName: entry.name, diff --git a/server/utils/plugin-process-manager.js b/server/utils/plugin-process-manager.js new file mode 100644 index 00000000..11f8fd2c --- /dev/null +++ b/server/utils/plugin-process-manager.js @@ -0,0 +1,162 @@ +import { spawn } from 'child_process'; +import path from 'path'; +import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js'; + +// Map +const runningPlugins = new Map(); + +/** + * Start a plugin's server subprocess. + * The plugin's server entry must print a JSON line with { ready: true, port: } + * to stdout within 10 seconds. + */ +export function startPluginServer(name, pluginDir, serverEntry) { + return new Promise((resolve, reject) => { + if (runningPlugins.has(name)) { + return resolve(runningPlugins.get(name).port); + } + + const serverPath = path.join(pluginDir, serverEntry); + + // Restricted env — only essentials, no host secrets + const pluginProcess = spawn('node', [serverPath], { + cwd: pluginDir, + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + NODE_ENV: process.env.NODE_ENV || 'production', + PLUGIN_NAME: name, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let resolved = false; + let stdout = ''; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + pluginProcess.kill(); + reject(new Error('Plugin server did not report ready within 10 seconds')); + } + }, 10000); + + pluginProcess.stdout.on('data', (data) => { + if (resolved) return; + stdout += data.toString(); + + // Look for the JSON ready line + const lines = stdout.split('\n'); + for (const line of lines) { + try { + const msg = JSON.parse(line.trim()); + if (msg.ready && typeof msg.port === 'number') { + clearTimeout(timeout); + resolved = true; + runningPlugins.set(name, { process: pluginProcess, port: msg.port }); + + pluginProcess.on('exit', () => { + runningPlugins.delete(name); + }); + + console.log(`[Plugins] Server started for "${name}" on port ${msg.port}`); + resolve(msg.port); + } + } catch { + // Not JSON yet, keep buffering + } + } + }); + + pluginProcess.stderr.on('data', (data) => { + console.warn(`[Plugin:${name}] ${data.toString().trim()}`); + }); + + pluginProcess.on('error', (err) => { + clearTimeout(timeout); + if (!resolved) { + resolved = true; + reject(new Error(`Failed to start plugin server: ${err.message}`)); + } + }); + + pluginProcess.on('exit', (code) => { + clearTimeout(timeout); + runningPlugins.delete(name); + if (!resolved) { + resolved = true; + reject(new Error(`Plugin server exited with code ${code} before reporting ready`)); + } + }); + }); +} + +/** + * Stop a plugin's server subprocess. + */ +export function stopPluginServer(name) { + const entry = runningPlugins.get(name); + if (!entry) return; + + entry.process.kill('SIGTERM'); + + // Force kill after 5 seconds if still running + const forceKillTimer = setTimeout(() => { + if (runningPlugins.has(name)) { + entry.process.kill('SIGKILL'); + runningPlugins.delete(name); + } + }, 5000); + + entry.process.on('exit', () => { + clearTimeout(forceKillTimer); + }); + + console.log(`[Plugins] Server stopped for "${name}"`); +} + +/** + * Get the port a running plugin server is listening on. + */ +export function getPluginPort(name) { + return runningPlugins.get(name)?.port ?? null; +} + +/** + * Check if a plugin's server is running. + */ +export function isPluginRunning(name) { + return runningPlugins.has(name); +} + +/** + * Stop all running plugin servers (called on host shutdown). + */ +export function stopAllPlugins() { + for (const [name] of runningPlugins) { + stopPluginServer(name); + } +} + +/** + * Start servers for all enabled plugins that have a server entry. + * Called once on host server boot. + */ +export async function startEnabledPluginServers() { + const plugins = scanPlugins(); + const config = getPluginsConfig(); + + for (const plugin of plugins) { + if (!plugin.server) continue; + if (config[plugin.name]?.enabled === false) continue; + + const pluginDir = getPluginDir(plugin.name); + if (!pluginDir) continue; + + try { + await startPluginServer(plugin.name, pluginDir, plugin.server); + } catch (err) { + console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message); + } + } +} diff --git a/src/components/plugins/PluginTabContent.tsx b/src/components/plugins/PluginTabContent.tsx index b38a4531..9b521989 100644 --- a/src/components/plugins/PluginTabContent.tsx +++ b/src/components/plugins/PluginTabContent.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; import { useTheme } from '../../contexts/ThemeContext'; +import { authenticatedFetch } from '../../utils/api'; import type { Project, ProjectSession } from '../../types/app'; type PluginTabContentProps = { @@ -82,6 +83,34 @@ export default function PluginTabContent({ ); break; } + case 'ccui:rpc': { + // Plugin is making an RPC call to its server subprocess. + // We bridge this because the sandboxed iframe has no auth token. + const { requestId, method, path: rpcPath, body } = event.data; + if (!requestId || !rpcPath) break; + + const cleanPath = String(rpcPath).replace(/^\//, ''); + const url = `/api/plugins/${encodeURIComponent(pluginName)}/rpc/${cleanPath}`; + + authenticatedFetch(url, { + method: method || 'GET', + ...(body ? { body: JSON.stringify(body) } : {}), + }) + .then(async (res) => { + const data = await res.json().catch(() => null); + iframeRef.current?.contentWindow?.postMessage( + { type: 'ccui:rpc-response', requestId, status: res.status, data }, + '*', + ); + }) + .catch((err) => { + iframeRef.current?.contentWindow?.postMessage( + { type: 'ccui:rpc-response', requestId, status: 500, error: (err as Error).message }, + '*', + ); + }); + break; + } default: break; } diff --git a/src/contexts/PluginsContext.tsx b/src/contexts/PluginsContext.tsx index 49f2eaa3..e433e38b 100644 --- a/src/contexts/PluginsContext.tsx +++ b/src/contexts/PluginsContext.tsx @@ -12,8 +12,10 @@ export type Plugin = { type: 'iframe' | 'react'; slot: 'tab'; entry: string; + server: string | null; permissions: string[]; enabled: boolean; + serverRunning: boolean; dirName: string; };