mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-11 17:07:40 +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:
@@ -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., `<link href="styles.css">`, `<script src="app.js">`).
|
||||
All files in the plugin directory are accessible via `/api/plugins/:name/assets/`. Use relative paths in your HTML (e.g., `<link href="styles.css">`).
|
||||
|
||||
## manifest.json
|
||||
|
||||
@@ -56,7 +61,8 @@ All files in the plugin directory are accessible via `/api/plugins/:name/assets/
|
||||
"icon": "Puzzle", // Lucide icon name (see available icons below)
|
||||
"type": "iframe", // Rendering method — only "iframe" is supported today
|
||||
"slot": "tab", // Where the plugin appears — only "tab" is supported today
|
||||
"entry": "index.html", // Path to the entry file, relative to the plugin directory
|
||||
"entry": "index.html", // Frontend entry file, relative to plugin directory
|
||||
"server": "server.js", // Optional — backend entry file, runs as Node.js subprocess
|
||||
"permissions": [] // Reserved for future use
|
||||
}
|
||||
```
|
||||
@@ -71,21 +77,68 @@ All files in the plugin directory are accessible via `/api/plugins/:name/assets/
|
||||
|
||||
**From git:** In Settings > Plugins, paste a git URL and click Install. The repo is cloned into the plugins directory.
|
||||
|
||||
## Backend details
|
||||
---
|
||||
|
||||
The backend manages the full plugin lifecycle. During **install and update**, it spawns child processes on the server:
|
||||
## Backend — Server subprocess
|
||||
|
||||
- **Install** — runs `git clone --depth 1` to clone the repo, validates `manifest.json`, then runs `npm install --production --ignore-scripts` if a `package.json` exists. Install scripts are blocked (`--ignore-scripts`) to prevent arbitrary code execution.
|
||||
- **Update** — runs `git pull --ff-only` in the plugin directory, re-validates the manifest, re-runs `npm install --production --ignore-scripts` if needed.
|
||||
- **Uninstall** — deletes the plugin directory recursively from disk.
|
||||
Plugins that need to make authenticated API calls, use npm packages, or run Node.js logic can declare a `"server"` entry in their manifest. The host manages the full lifecycle:
|
||||
|
||||
At **runtime** (after install), the backend:
|
||||
### How it works
|
||||
|
||||
- **Discovery** — scans `~/.claude-code-ui/plugins/` for directories containing a valid `manifest.json`
|
||||
- **Asset serving** — serves any file inside a plugin directory at `/api/plugins/:name/assets/*` with correct MIME types. Path traversal outside the plugin directory is blocked.
|
||||
- **Config** — per-plugin enabled/disabled state stored in `~/.claude-code-ui/plugins.json`
|
||||
1. When the plugin is enabled, the host spawns `node server.js` as a child process
|
||||
2. The subprocess **must** print a JSON line to stdout: `{"ready": true, "port": 12345}`
|
||||
3. The host records the port and proxies requests from `/api/plugins/:name/rpc/*` to it
|
||||
4. When the plugin is disabled or uninstalled, the host sends SIGTERM to the process
|
||||
|
||||
Plugins **cannot** register custom server routes, middleware, or execute their own backend code. The server only serves their files statically. If your plugin needs external data, fetch it from the iframe directly (subject to CORS).
|
||||
### Restricted environment
|
||||
|
||||
The subprocess runs with a **minimal env** — only `PATH`, `HOME`, `NODE_ENV`, and `PLUGIN_NAME`. It does **not** inherit the host's API keys, database URLs, or other secrets from `process.env`.
|
||||
|
||||
### Secrets
|
||||
|
||||
Per-plugin secrets are stored in `~/.claude-code-ui/plugins.json` and injected as HTTP headers on every proxied request:
|
||||
|
||||
```json
|
||||
{
|
||||
"hello-world": {
|
||||
"enabled": true,
|
||||
"secrets": {
|
||||
"apiKey": "sk-live-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The plugin's server receives these as `x-plugin-secret-apikey` headers — they are per-call, never stored in the subprocess env.
|
||||
|
||||
### Example server.js
|
||||
|
||||
```js
|
||||
const http = require('http');
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
// Read host-injected secrets
|
||||
const apiKey = req.headers['x-plugin-secret-apikey'];
|
||||
|
||||
if (req.method === 'GET' && req.url === '/hello') {
|
||||
res.end(JSON.stringify({ message: 'Hello!', hasApiKey: Boolean(apiKey) }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end(JSON.stringify({ error: 'Not found' }));
|
||||
});
|
||||
|
||||
// Listen on a random port and signal readiness
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address();
|
||||
console.log(JSON.stringify({ ready: true, port }));
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Context API
|
||||
|
||||
@@ -108,15 +161,64 @@ To request the current context on load:
|
||||
window.parent.postMessage({ type: 'ccui:request-context' }, '*');
|
||||
```
|
||||
|
||||
## Frontend — RPC bridge
|
||||
|
||||
The sandboxed iframe cannot make authenticated API calls directly. Instead, it sends RPC requests via `postMessage`, and the host proxies them to the plugin's server subprocess:
|
||||
|
||||
```js
|
||||
function callBackend(method, path, body) {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = Math.random().toString(36).slice(2);
|
||||
|
||||
function handler(event) {
|
||||
if (event.data?.type === 'ccui:rpc-response' && event.data.requestId === requestId) {
|
||||
window.removeEventListener('message', handler);
|
||||
resolve(event.data);
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', handler);
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'ccui:rpc',
|
||||
requestId,
|
||||
method,
|
||||
path,
|
||||
body: body || undefined,
|
||||
}, '*');
|
||||
});
|
||||
}
|
||||
|
||||
// Usage
|
||||
const result = await callBackend('GET', '/hello');
|
||||
// result = { type: 'ccui:rpc-response', requestId, status: 200, data: { message: 'Hello!' } }
|
||||
```
|
||||
|
||||
The host receives `ccui:rpc`, makes an authenticated fetch to `/api/plugins/:name/rpc/*`, and returns the response as `ccui:rpc-response`.
|
||||
|
||||
## Frontend — Mobile
|
||||
|
||||
On desktop, each enabled plugin gets its own tab in the tab bar. On mobile, plugins are grouped under a single "More" button in the bottom navigation to save space.
|
||||
|
||||
## Security
|
||||
|
||||
Plugins run in a sandboxed iframe with `allow-scripts allow-forms allow-popups`. They **cannot** access the host app's cookies, localStorage, or auth tokens (`allow-same-origin` is intentionally omitted). Any static assets (CSS, JS, images) can be placed alongside the entry file and referenced with relative paths.
|
||||
### Frontend sandbox
|
||||
|
||||
npm `postinstall` scripts are blocked during installation (`--ignore-scripts`), so plugins should ship pre-built assets.
|
||||
Plugins run in a sandboxed iframe with `allow-scripts allow-forms allow-popups`. They **cannot** access the host app's cookies, localStorage, or auth tokens (`allow-same-origin` is intentionally omitted).
|
||||
|
||||
### Server subprocess isolation
|
||||
|
||||
The subprocess runs as a separate OS process with:
|
||||
|
||||
- **Restricted env** — no host secrets inherited; only `PATH`, `HOME`, `NODE_ENV`, `PLUGIN_NAME`
|
||||
- **Per-call secrets** — injected as HTTP headers by the host proxy, never stored in process env
|
||||
- **Process boundary** — a crash in the plugin cannot crash the host
|
||||
- **Auth stripping** — the host removes `authorization` and `cookie` headers before proxying
|
||||
|
||||
The subprocess runs as the same OS user, so it has the same filesystem/network access. This matches the trust model of VS Code extensions, Grafana backend plugins, and Terraform providers — the user explicitly installs the plugin.
|
||||
|
||||
### Install-time protections
|
||||
|
||||
npm `postinstall` scripts are blocked during installation (`--ignore-scripts`). Plugins that need npm packages should ship pre-built or use packages that work without postinstall hooks.
|
||||
|
||||
## Try it
|
||||
|
||||
@@ -124,4 +226,4 @@ npm `postinstall` scripts are blocked during installation (`--ignore-scripts`),
|
||||
cp -r examples/plugins/hello-world ~/.claude-code-ui/plugins/
|
||||
```
|
||||
|
||||
Then open Settings > Plugins — "Hello World" should appear. Enable it and its tab will show up.
|
||||
Then open Settings > Plugins — "Hello World" should appear. Enable it, open its tab, and click the RPC buttons to test the server subprocess.
|
||||
|
||||
@@ -14,9 +14,32 @@
|
||||
body.dark { background: #1a1a2e; color: #e0e0e0; }
|
||||
body.light { background: #ffffff; color: #1a1a2e; }
|
||||
h1 { font-size: 1.4rem; margin-bottom: 8px; }
|
||||
h2 { font-size: 1.1rem; margin-top: 24px; margin-bottom: 8px; opacity: 0.8; }
|
||||
.context { font-size: 0.85rem; opacity: 0.7; margin-top: 16px; }
|
||||
.context dt { font-weight: 600; margin-top: 8px; }
|
||||
.context dd { margin-left: 12px; }
|
||||
button {
|
||||
margin-top: 12px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
button:hover { opacity: 1; }
|
||||
pre {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
body.dark pre { background: #2a2a3e; }
|
||||
body.light pre { background: #f0f0f4; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="light">
|
||||
@@ -29,24 +52,63 @@
|
||||
<dt>Session</dt><dd id="ctx-session">—</dd>
|
||||
</dl>
|
||||
|
||||
<h2>Server RPC</h2>
|
||||
<p style="font-size: 0.85rem; opacity: 0.7;">This calls the plugin's own Node.js server subprocess via the postMessage RPC bridge.</p>
|
||||
<button id="btn-hello">Call GET /hello</button>
|
||||
<button id="btn-echo">Call POST /echo</button>
|
||||
<pre id="rpc-result">Click a button to make an RPC call...</pre>
|
||||
|
||||
<script>
|
||||
// Listen for context updates from the host app.
|
||||
// ── RPC helper ──────────────────────────────────────────────────
|
||||
// Sends a request through the host's postMessage bridge, which
|
||||
// proxies it to this plugin's server subprocess.
|
||||
function callBackend(method, path, body) {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = Math.random().toString(36).slice(2);
|
||||
|
||||
function handler(event) {
|
||||
if (event.data?.type === 'ccui:rpc-response' && event.data.requestId === requestId) {
|
||||
window.removeEventListener('message', handler);
|
||||
resolve(event.data);
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', handler);
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'ccui:rpc',
|
||||
requestId,
|
||||
method,
|
||||
path,
|
||||
body: body || undefined,
|
||||
}, '*');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Context listener ────────────────────────────────────────────
|
||||
window.addEventListener('message', (event) => {
|
||||
if (!event.data || event.data.type !== 'ccui:context') return;
|
||||
|
||||
const { theme, project, session } = event.data;
|
||||
|
||||
// Apply theme
|
||||
document.body.className = theme || 'light';
|
||||
|
||||
// Display context
|
||||
document.getElementById('ctx-theme').textContent = theme || '—';
|
||||
document.getElementById('ctx-project').textContent = project ? project.name : '(none)';
|
||||
document.getElementById('ctx-session').textContent = session ? session.title || session.id : '(none)';
|
||||
});
|
||||
|
||||
// Ask the host for the current context on load.
|
||||
// Request context on load
|
||||
window.parent.postMessage({ type: 'ccui:request-context' }, '*');
|
||||
|
||||
// ── RPC demo buttons ────────────────────────────────────────────
|
||||
document.getElementById('btn-hello').addEventListener('click', async () => {
|
||||
const result = await callBackend('GET', '/hello');
|
||||
document.getElementById('rpc-result').textContent = JSON.stringify(result, null, 2);
|
||||
});
|
||||
|
||||
document.getElementById('btn-echo').addEventListener('click', async () => {
|
||||
const result = await callBackend('POST', '/echo', { greeting: 'Hello from the iframe!' });
|
||||
document.getElementById('rpc-result').textContent = JSON.stringify(result, null, 2);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
"type": "iframe",
|
||||
"slot": "tab",
|
||||
"entry": "index.html",
|
||||
"server": "server.js",
|
||||
"permissions": []
|
||||
}
|
||||
|
||||
42
examples/plugins/hello-world/server.js
Normal file
42
examples/plugins/hello-world/server.js
Normal file
@@ -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 }));
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user