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:
simosmik
2026-03-05 11:58:04 +00:00
parent 6517ec4ecd
commit 2588851746
10 changed files with 590 additions and 54 deletions

View File

@@ -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.

View File

@@ -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>

View File

@@ -8,5 +8,6 @@
"type": "iframe",
"slot": "tab",
"entry": "index.html",
"server": "server.js",
"permissions": []
}

View 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 }));
});

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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,

View 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);
}
}
}

View File

@@ -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;
}

View File

@@ -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;
};