mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-10 08:27: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 }));
|
||||
});
|
||||
Reference in New Issue
Block a user