mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-10 16:37:40 +00:00
* feat: new plugin system
* Potential fix for code scanning alert no. 312: Uncontrolled data used in path expression
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* Update manifest.json
* feat(plugins): add SVG icon support with authenticated inline rendering
* fix: coderabbit changes and new plugin name & repo
* fix: design changes to plugins settings tab
* fix(plugins): prevent git arg injection, add repo URL detection
* fix: lint errors and deleting plugin error on windows
* fix: coderabbit nitpick comments
* fix(plugins): harden path traversal and respect enabled state
Use realpathSync to canonicalize paths before the plugin asset
boundary check, preventing symlink-based traversal bypasses that
could escape the plugin directory.
PluginTabContent now guards on plugin.enabled before mounting the
plugin module, and re-mounts when the enabled state changes so
toggling a plugin takes effect without a page reload.
PluginIcon safely handles a missing iconFile prop and skips
processing non-OK fetch responses instead of attempting to parse
error bodies as SVG.
Register 'plugins' as a known main tab so the settings router
preserves the tab on navigation.
* fix(plugins): support concurrent plugin updates
Replace single updatingPlugin string state with a Set to allow
multiple plugins to update simultaneously. Also disable the update
button and show a descriptive tooltip when a plugin has no git
remote configured.
* fix(plugins): async shutdown and asset/RPC fixes
Await stopPluginServer/stopAllPlugins in signal handlers and route
handlers so process exit and state transitions wait for clean plugin
shutdown instead of racing ahead.
Validate asset paths are regular files before streaming to prevent
directory traversal returning unexpected content; add a stream error
handler to avoid unhandled crashes on read failures.
Fix RPC proxy body detection to use the content-length header instead
of Object.keys, so falsy but valid JSON payloads (null, false, 0, {})
are forwarded correctly to plugin servers.
Track in-flight start operations via a startingPlugins map to prevent
duplicate concurrent plugin starts.
* refactor(git-panel): simplify setCommitMessage with plain function
* fix(plugins): harden input validation and scan reliability
- Validate plugin names against [a-zA-Z0-9_-] allowlist in
manifest and asset routes to prevent path traversal via URL
- Strip embedded credentials (user:pass@) from git remote URLs
before exposing them to the client
- Skip .tmp-* directories during scan to avoid partial installs
from in-progress updates appearing as broken plugins
- Deduplicate plugins sharing the same manifest name to prevent
ambiguous state
- Guard RPC proxy error handler against writing to an already-sent
response, preventing uncaught exceptions on aborted requests
* fix(git-panel): reset changes view on project switch
* refactor: move plugin content to /view folder
* fix: resolve type error in MobileNav and PluginTabContent components
---------
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
Co-authored-by: Haileyesus <something@gmail.com>
409 lines
13 KiB
JavaScript
409 lines
13 KiB
JavaScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { spawn } from 'child_process';
|
|
|
|
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
|
|
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
|
|
|
const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
|
|
|
|
/** Strip embedded credentials from a repo URL before exposing it to the client. */
|
|
function sanitizeRepoUrl(raw) {
|
|
try {
|
|
const u = new URL(raw);
|
|
u.username = '';
|
|
u.password = '';
|
|
return u.toString().replace(/\/$/, '');
|
|
} catch {
|
|
// Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment
|
|
return raw.replace(/\/\/[^@/]+@/, '//');
|
|
}
|
|
}
|
|
const ALLOWED_TYPES = ['react', 'module'];
|
|
const ALLOWED_SLOTS = ['tab'];
|
|
|
|
export function getPluginsDir() {
|
|
if (!fs.existsSync(PLUGINS_DIR)) {
|
|
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
}
|
|
return PLUGINS_DIR;
|
|
}
|
|
|
|
export function getPluginsConfig() {
|
|
try {
|
|
if (fs.existsSync(PLUGINS_CONFIG_PATH)) {
|
|
return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8'));
|
|
}
|
|
} catch {
|
|
// Corrupted config, start fresh
|
|
}
|
|
return {};
|
|
}
|
|
|
|
export function savePluginsConfig(config) {
|
|
const dir = path.dirname(PLUGINS_CONFIG_PATH);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
}
|
|
fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
}
|
|
|
|
export function validateManifest(manifest) {
|
|
if (!manifest || typeof manifest !== 'object') {
|
|
return { valid: false, error: 'Manifest must be a JSON object' };
|
|
}
|
|
|
|
for (const field of REQUIRED_MANIFEST_FIELDS) {
|
|
if (!manifest[field] || typeof manifest[field] !== 'string') {
|
|
return { valid: false, error: `Missing or invalid required field: ${field}` };
|
|
}
|
|
}
|
|
|
|
// Sanitize name — only allow alphanumeric, hyphens, underscores
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) {
|
|
return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' };
|
|
}
|
|
|
|
if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) {
|
|
return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` };
|
|
}
|
|
|
|
if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) {
|
|
return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };
|
|
}
|
|
|
|
// Validate entry is a relative path without traversal
|
|
if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) {
|
|
return { valid: false, error: 'Entry must be a relative path without ".."' };
|
|
}
|
|
|
|
if (manifest.server !== undefined && manifest.server !== null) {
|
|
if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) {
|
|
return { valid: false, error: 'Server entry must be a relative path string without ".."' };
|
|
}
|
|
}
|
|
|
|
if (manifest.permissions !== undefined) {
|
|
if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) {
|
|
return { valid: false, error: 'Permissions must be an array of strings' };
|
|
}
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
export function scanPlugins() {
|
|
const pluginsDir = getPluginsDir();
|
|
const config = getPluginsConfig();
|
|
const plugins = [];
|
|
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
|
|
} catch {
|
|
return plugins;
|
|
}
|
|
|
|
const seenNames = new Set();
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
// Skip transient temp directories from in-progress installs
|
|
if (entry.name.startsWith('.tmp-')) continue;
|
|
|
|
const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
|
|
if (!fs.existsSync(manifestPath)) continue;
|
|
|
|
try {
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
const validation = validateManifest(manifest);
|
|
if (!validation.valid) {
|
|
console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`);
|
|
continue;
|
|
}
|
|
|
|
// Skip duplicate manifest names
|
|
if (seenNames.has(manifest.name)) {
|
|
console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`);
|
|
continue;
|
|
}
|
|
seenNames.add(manifest.name);
|
|
|
|
// Try to read git remote URL
|
|
let repoUrl = null;
|
|
try {
|
|
const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config');
|
|
if (fs.existsSync(gitConfigPath)) {
|
|
const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8');
|
|
const match = gitConfig.match(/url\s*=\s*(.+)/);
|
|
if (match) {
|
|
repoUrl = match[1].trim().replace(/\.git$/, '');
|
|
// Convert SSH URLs to HTTPS
|
|
if (repoUrl.startsWith('git@')) {
|
|
repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
|
|
}
|
|
// Strip embedded credentials (e.g. https://user:pass@host/...)
|
|
repoUrl = sanitizeRepoUrl(repoUrl);
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
plugins.push({
|
|
name: manifest.name,
|
|
displayName: manifest.displayName,
|
|
version: manifest.version || '0.0.0',
|
|
description: manifest.description || '',
|
|
author: manifest.author || '',
|
|
icon: manifest.icon || 'Puzzle',
|
|
type: manifest.type || 'module',
|
|
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,
|
|
repoUrl,
|
|
});
|
|
} catch (err) {
|
|
console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message);
|
|
}
|
|
}
|
|
|
|
return plugins;
|
|
}
|
|
|
|
export function getPluginDir(name) {
|
|
const plugins = scanPlugins();
|
|
const plugin = plugins.find(p => p.name === name);
|
|
if (!plugin) return null;
|
|
return path.join(getPluginsDir(), plugin.dirName);
|
|
}
|
|
|
|
export function resolvePluginAssetPath(name, assetPath) {
|
|
const pluginDir = getPluginDir(name);
|
|
if (!pluginDir) return null;
|
|
|
|
const resolved = path.resolve(pluginDir, assetPath);
|
|
|
|
// Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
|
|
if (!fs.existsSync(resolved)) return null;
|
|
|
|
const realResolved = fs.realpathSync(resolved);
|
|
const realPluginDir = fs.realpathSync(pluginDir);
|
|
if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) {
|
|
return null;
|
|
}
|
|
|
|
return realResolved;
|
|
}
|
|
|
|
export function installPluginFromGit(url) {
|
|
return new Promise((resolve, reject) => {
|
|
if (typeof url !== 'string' || !url.trim()) {
|
|
return reject(new Error('Invalid URL: must be a non-empty string'));
|
|
}
|
|
if (url.startsWith('-')) {
|
|
return reject(new Error('Invalid URL: must not start with "-"'));
|
|
}
|
|
|
|
// Extract repo name from URL for directory name
|
|
const urlClean = url.replace(/\.git$/, '').replace(/\/$/, '');
|
|
const repoName = urlClean.split('/').pop();
|
|
|
|
if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) {
|
|
return reject(new Error('Could not determine a valid directory name from the URL'));
|
|
}
|
|
|
|
const pluginsDir = getPluginsDir();
|
|
const targetDir = path.resolve(pluginsDir, repoName);
|
|
|
|
// Ensure the resolved target directory stays within the plugins directory
|
|
if (!targetDir.startsWith(pluginsDir + path.sep)) {
|
|
return reject(new Error('Invalid plugin directory path'));
|
|
}
|
|
|
|
if (fs.existsSync(targetDir)) {
|
|
return reject(new Error(`Plugin directory "${repoName}" already exists`));
|
|
}
|
|
|
|
// Clone into a temp directory so scanPlugins() never sees a partially-installed plugin
|
|
const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`));
|
|
|
|
const cleanupTemp = () => {
|
|
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
|
|
};
|
|
|
|
const finalize = (manifest) => {
|
|
try {
|
|
fs.renameSync(tempDir, targetDir);
|
|
} catch (err) {
|
|
cleanupTemp();
|
|
return reject(new Error(`Failed to move plugin into place: ${err.message}`));
|
|
}
|
|
resolve(manifest);
|
|
};
|
|
|
|
const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
let stderr = '';
|
|
gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
|
|
gitProcess.on('close', (code) => {
|
|
if (code !== 0) {
|
|
cleanupTemp();
|
|
return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
|
|
}
|
|
|
|
// Validate manifest exists
|
|
const manifestPath = path.join(tempDir, 'manifest.json');
|
|
if (!fs.existsSync(manifestPath)) {
|
|
cleanupTemp();
|
|
return reject(new Error('Cloned repository does not contain a manifest.json'));
|
|
}
|
|
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
} catch {
|
|
cleanupTemp();
|
|
return reject(new Error('manifest.json is not valid JSON'));
|
|
}
|
|
|
|
const validation = validateManifest(manifest);
|
|
if (!validation.valid) {
|
|
cleanupTemp();
|
|
return reject(new Error(`Invalid manifest: ${validation.error}`));
|
|
}
|
|
|
|
// Reject if another installed plugin already uses this name
|
|
const existing = scanPlugins().find(p => p.name === manifest.name);
|
|
if (existing) {
|
|
cleanupTemp();
|
|
return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`));
|
|
}
|
|
|
|
// Run npm install if package.json exists.
|
|
// --ignore-scripts prevents postinstall hooks from executing arbitrary code.
|
|
const packageJsonPath = path.join(tempDir, 'package.json');
|
|
if (fs.existsSync(packageJsonPath)) {
|
|
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
|
|
cwd: tempDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
npmProcess.on('close', (npmCode) => {
|
|
if (npmCode !== 0) {
|
|
cleanupTemp();
|
|
return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`));
|
|
}
|
|
finalize(manifest);
|
|
});
|
|
|
|
npmProcess.on('error', (err) => {
|
|
cleanupTemp();
|
|
reject(err);
|
|
});
|
|
} else {
|
|
finalize(manifest);
|
|
}
|
|
});
|
|
|
|
gitProcess.on('error', (err) => {
|
|
cleanupTemp();
|
|
reject(new Error(`Failed to spawn git: ${err.message}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
export function updatePluginFromGit(name) {
|
|
return new Promise((resolve, reject) => {
|
|
const pluginDir = getPluginDir(name);
|
|
if (!pluginDir) {
|
|
return reject(new Error(`Plugin "${name}" not found`));
|
|
}
|
|
|
|
// Only fast-forward to avoid silent divergence
|
|
const gitProcess = spawn('git', ['pull', '--ff-only', '--'], {
|
|
cwd: pluginDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
let stderr = '';
|
|
gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
|
|
gitProcess.on('close', (code) => {
|
|
if (code !== 0) {
|
|
return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`));
|
|
}
|
|
|
|
// Re-validate manifest after update
|
|
const manifestPath = path.join(pluginDir, 'manifest.json');
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
} catch {
|
|
return reject(new Error('manifest.json is not valid JSON after update'));
|
|
}
|
|
|
|
const validation = validateManifest(manifest);
|
|
if (!validation.valid) {
|
|
return reject(new Error(`Invalid manifest after update: ${validation.error}`));
|
|
}
|
|
|
|
// Re-run npm install if package.json exists
|
|
const packageJsonPath = path.join(pluginDir, 'package.json');
|
|
if (fs.existsSync(packageJsonPath)) {
|
|
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
|
|
cwd: pluginDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
npmProcess.on('close', (npmCode) => {
|
|
if (npmCode !== 0) {
|
|
return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`));
|
|
}
|
|
resolve(manifest);
|
|
});
|
|
npmProcess.on('error', (err) => reject(err));
|
|
} else {
|
|
resolve(manifest);
|
|
}
|
|
});
|
|
|
|
gitProcess.on('error', (err) => {
|
|
reject(new Error(`Failed to spawn git: ${err.message}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function uninstallPlugin(name) {
|
|
const pluginDir = getPluginDir(name);
|
|
if (!pluginDir) {
|
|
throw new Error(`Plugin "${name}" not found`);
|
|
}
|
|
|
|
// On Windows, file handles may be released slightly after process exit.
|
|
// Retry a few times with a short delay before giving up.
|
|
const MAX_RETRIES = 5;
|
|
const RETRY_DELAY_MS = 500;
|
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
try {
|
|
fs.rmSync(pluginDir, { recursive: true, force: true });
|
|
break;
|
|
} catch (err) {
|
|
if (err.code === 'EBUSY' && attempt < MAX_RETRIES) {
|
|
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove from config
|
|
const config = getPluginsConfig();
|
|
delete config[name];
|
|
savePluginsConfig(config);
|
|
}
|