mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-12 17:37:24 +00:00
feat(plugins): add backend subprocess support via RPC bridge
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
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
162
server/utils/plugin-process-manager.js
Normal file
162
server/utils/plugin-process-manager.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';
|
||||
|
||||
// Map<pluginName, { process, port }>
|
||||
const runningPlugins = new Map();
|
||||
|
||||
/**
|
||||
* Start a plugin's server subprocess.
|
||||
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user