mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-10 15:55:53 +08:00
Plugin servers are started with a deliberately minimal env (PATH, HOME, NODE_ENV, PLUGIN_NAME). On Windows that drops system variables that child processes need to bootstrap. The one that bit me: without APPDATA, CPython cannot find the per-user site-packages, so a plugin that shells out to a pip install --user CLI launches the tool but it dies with ModuleNotFoundError. SystemRoot, PATHEXT and TEMP cause similar failures for other tools. On win32, pass through a small allowlist of non-secret system variables (SystemRoot, windir, SystemDrive, USERPROFILE, APPDATA, LOCALAPPDATA, TEMP, TMP, PATHEXT) when they are set. No change off Windows, and no host secrets are exposed.
214 lines
5.8 KiB
JavaScript
214 lines
5.8 KiB
JavaScript
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();
|
|
// Map<pluginName, Promise<port>> — in-flight start operations
|
|
const startingPlugins = new Map();
|
|
|
|
/**
|
|
* Build the environment handed to a plugin server subprocess.
|
|
*
|
|
* Intentionally minimal: only non-secret essentials, never the host's full
|
|
* environment. On Windows a handful of system variables are required for any
|
|
* child to bootstrap (Node itself, and any Python or CLI a plugin shells out
|
|
* to). Without APPDATA a `pip install --user` tool cannot locate its
|
|
* site-packages and fails to import; SystemRoot, PATHEXT and TEMP are needed to
|
|
* resolve system DLLs, executable extensions and a temp directory. None of
|
|
* these carry secrets, so the ones that are set get passed straight through.
|
|
*/
|
|
function buildPluginEnv(name) {
|
|
const env = {
|
|
PATH: process.env.PATH,
|
|
HOME: process.env.HOME,
|
|
NODE_ENV: process.env.NODE_ENV || 'production',
|
|
PLUGIN_NAME: name,
|
|
};
|
|
|
|
if (process.platform === 'win32') {
|
|
const WINDOWS_ESSENTIALS = [
|
|
'SystemRoot', 'windir', 'SystemDrive',
|
|
'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
|
|
'TEMP', 'TMP', 'PATHEXT',
|
|
];
|
|
for (const key of WINDOWS_ESSENTIALS) {
|
|
if (process.env[key] !== undefined) {
|
|
env[key] = process.env[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
if (runningPlugins.has(name)) {
|
|
return Promise.resolve(runningPlugins.get(name).port);
|
|
}
|
|
|
|
// Coalesce concurrent starts for the same plugin
|
|
if (startingPlugins.has(name)) {
|
|
return startingPlugins.get(name);
|
|
}
|
|
|
|
const startPromise = new Promise((resolve, reject) => {
|
|
|
|
const serverPath = path.join(pluginDir, serverEntry);
|
|
|
|
const pluginProcess = spawn('node', [serverPath], {
|
|
cwd: pluginDir,
|
|
env: buildPluginEnv(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`));
|
|
}
|
|
});
|
|
}).finally(() => {
|
|
startingPlugins.delete(name);
|
|
});
|
|
|
|
startingPlugins.set(name, startPromise);
|
|
return startPromise;
|
|
}
|
|
|
|
/**
|
|
* Stop a plugin's server subprocess.
|
|
* Returns a Promise that resolves when the process has fully exited.
|
|
*/
|
|
export function stopPluginServer(name) {
|
|
const entry = runningPlugins.get(name);
|
|
if (!entry) return Promise.resolve();
|
|
|
|
return new Promise((resolve) => {
|
|
const cleanup = () => {
|
|
clearTimeout(forceKillTimer);
|
|
runningPlugins.delete(name);
|
|
resolve();
|
|
};
|
|
|
|
entry.process.once('exit', cleanup);
|
|
|
|
entry.process.kill('SIGTERM');
|
|
|
|
// Force kill after 5 seconds if still running
|
|
const forceKillTimer = setTimeout(() => {
|
|
if (runningPlugins.has(name)) {
|
|
entry.process.kill('SIGKILL');
|
|
cleanup();
|
|
}
|
|
}, 5000);
|
|
|
|
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() {
|
|
const stops = [];
|
|
for (const [name] of runningPlugins) {
|
|
stops.push(stopPluginServer(name));
|
|
}
|
|
return Promise.all(stops);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
}
|