Compare commits

..

4 Commits

Author SHA1 Message Date
Haileyesus
77fb193598 fix: resolve type error in MobileNav and PluginTabContent components 2026-03-09 12:59:52 +03:00
Haileyesus
ff45a1cfd7 refactor: move plugin content to /view folder 2026-03-09 12:51:48 +03:00
simosmik
951db47a91 fix(git-panel): reset changes view on project switch 2026-03-09 08:18:58 +00:00
simosmik
ca16342a20 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
2026-03-09 07:59:46 +00:00
10 changed files with 111 additions and 32 deletions

View File

@@ -39,6 +39,9 @@ router.get('/', (req, res) => {
// GET /:name/manifest — Get single plugin manifest
router.get('/:name/manifest', (req, res) => {
try {
if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
const plugins = scanPlugins();
const plugin = plugins.find(p => p.name === req.params.name);
if (!plugin) {
@@ -53,6 +56,9 @@ router.get('/:name/manifest', (req, res) => {
// GET /:name/assets/* — Serve plugin static files
router.get('/:name/assets/*', (req, res) => {
const pluginName = req.params.name;
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
const assetPath = req.params[0];
if (!assetPath) {
@@ -252,7 +258,11 @@ router.all('/:name/rpc/*', async (req, res) => {
});
proxyReq.on('error', (err) => {
res.status(502).json({ error: 'Plugin server error', details: err.message });
if (!res.headersSent) {
res.status(502).json({ error: 'Plugin server error', details: err.message });
} else {
res.end();
}
});
// Forward body (already parsed by express JSON middleware, so re-stringify).

View File

@@ -7,6 +7,19 @@ 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'];
@@ -92,8 +105,12 @@ export function scanPlugins() {
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;
@@ -106,6 +123,13 @@ export function scanPlugins() {
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 {
@@ -119,6 +143,8 @@ export function scanPlugins() {
if (repoUrl.startsWith('git@')) {
repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
}
// Strip embedded credentials (e.g. https://user:pass@host/...)
repoUrl = sanitizeRepoUrl(repoUrl);
}
}
} catch { /* ignore */ }

View File

@@ -1,13 +1,35 @@
import { useState, useRef, useEffect, Dispatch, SetStateAction } from 'react';
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck, Ellipsis, Puzzle, Box, Database, Globe, Wrench, Zap, BarChart3 } from 'lucide-react';
import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
import {
MessageSquare,
Folder,
Terminal,
GitBranch,
ClipboardCheck,
Ellipsis,
Puzzle,
Box,
Database,
Globe,
Wrench,
Zap,
BarChart3,
type LucideIcon,
} from 'lucide-react';
import { useTasksSettings } from '../../contexts/TasksSettingsContext';
import { usePlugins } from '../../contexts/PluginsContext';
import { AppTab } from '../../types/app';
const PLUGIN_ICON_MAP = {
const PLUGIN_ICON_MAP: Record<string, LucideIcon> = {
Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
};
type CoreTabId = Exclude<AppTab, `plugin:${string}` | 'preview'>;
type CoreNavItem = {
id: CoreTabId;
icon: LucideIcon;
label: string;
};
type MobileNavProps = {
activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>;
@@ -19,7 +41,7 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const { plugins } = usePlugins();
const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef(null);
const moreRef = useRef<HTMLDivElement | null>(null);
const enabledPlugins = plugins.filter((p) => p.enabled);
const hasPlugins = enabledPlugins.length > 0;
@@ -28,8 +50,9 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
// Close the menu on outside tap
useEffect(() => {
if (!moreOpen) return;
const handleTap = (e) => {
if (moreRef.current && !moreRef.current.contains(e.target)) {
const handleTap = (e: PointerEvent) => {
const target = e.target;
if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) {
setMoreOpen(false);
}
};
@@ -38,18 +61,21 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
}, [moreOpen]);
// Close menu when a plugin tab is selected
const selectPlugin = (name) => {
setActiveTab(`plugin:${name}`);
const selectPlugin = (name: string) => {
const pluginTab = `plugin:${name}` as AppTab;
setActiveTab(pluginTab);
setMoreOpen(false);
};
const coreItems = [
const baseCoreItems: CoreNavItem[] = [
{ id: 'chat', icon: MessageSquare, label: 'Chat' },
{ id: 'shell', icon: Terminal, label: 'Shell' },
{ id: 'files', icon: Folder, label: 'Files' },
{ id: 'git', icon: GitBranch, label: 'Git' },
...(shouldShowTasksTab ? [{ id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }] : []),
];
const coreItems: CoreNavItem[] = shouldShowTasksTab
? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }]
: baseCoreItems;
return (
<div

View File

@@ -107,6 +107,7 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
{activeView === 'changes' && (
<ChangesView
key={selectedProject.fullPath}
isMobile={isMobile}
projectPath={selectedProject.fullPath}
gitStatus={gitStatus}

View File

@@ -3,7 +3,7 @@ import ChatInterface from '../../chat/view/ChatInterface';
import FileTree from '../../file-tree/view/FileTree';
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
import GitPanel from '../../git-panel/view/GitPanel';
import PluginTabContent from '../../plugins/PluginTabContent';
import PluginTabContent from '../../plugins/view/PluginTabContent';
import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from '../../../../shared/view/ui';
import type { AppTab } from '../../../../types/app';
import { usePlugins } from '../../../../contexts/PluginsContext';
import PluginIcon from '../../../plugins/PluginIcon';
import PluginIcon from '../../../plugins/view/PluginIcon';
type MainContentTabSwitcherProps = {
activeTab: AppTab;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { authenticatedFetch } from '../../utils/api';
import { authenticatedFetch } from '../../../utils/api';
type Props = {
pluginName: string;

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
import { usePlugins } from '../../contexts/PluginsContext';
import type { Plugin } from '../../contexts/PluginsContext';
import { usePlugins } from '../../../contexts/PluginsContext';
import type { Plugin } from '../../../contexts/PluginsContext';
import PluginIcon from './PluginIcon';
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
@@ -312,8 +312,13 @@ export default function PluginSettingsTab() {
setConfirmUninstall(name);
return;
}
await uninstallPlugin(name);
setConfirmUninstall(null);
const result = await uninstallPlugin(name);
if (result.success) {
setConfirmUninstall(null);
} else {
setInstallError(result.error || 'Uninstall failed');
setConfirmUninstall(null);
}
};
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
@@ -350,6 +355,7 @@ export default function PluginSettingsTab() {
setInstallError(null);
}}
placeholder="https://github.com/user/my-plugin"
aria-label="Plugin git repository URL"
className="flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
onKeyDown={(e) => {
if (e.key === 'Enter') void handleInstall();
@@ -399,7 +405,7 @@ export default function PluginSettingsTab() {
key={plugin.name}
plugin={plugin}
index={index}
onToggle={(enabled) => void togglePlugin(plugin.name, enabled)}
onToggle={(enabled) => void togglePlugin(plugin.name, enabled).then(r => { if (!r.success) setInstallError(r.error || 'Toggle failed'); })}
onUpdate={() => void handleUpdate(plugin.name)}
onUninstall={() => void handleUninstall(plugin.name)}
updating={updatingPlugins.has(plugin.name)}

View File

@@ -1,8 +1,8 @@
import { useEffect, useRef } from 'react';
import { useTheme } from '../../contexts/ThemeContext';
import { authenticatedFetch } from '../../utils/api';
import { usePlugins } from '../../contexts/PluginsContext';
import type { Project, ProjectSession } from '../../types/app';
import { useTheme } from '../../../contexts/ThemeContext';
import { authenticatedFetch } from '../../../utils/api';
import { usePlugins } from '../../../contexts/PluginsContext';
import type { Project, ProjectSession } from '../../../types/app';
type PluginTabContentProps = {
pluginName: string;
@@ -24,10 +24,16 @@ function buildContext(
return {
theme: isDarkMode ? 'dark' : 'light',
project: selectedProject
? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path }
? {
name: selectedProject.name,
path: selectedProject.fullPath || selectedProject.path || '',
}
: null,
session: selectedSession
? { id: selectedSession.id, title: selectedSession.title }
? {
id: selectedSession.id,
title: selectedSession.title || selectedSession.name || selectedSession.id,
}
: null,
};
}
@@ -44,7 +50,7 @@ export default function PluginTabContent({
// Stable refs so effects don't need context values in their dep arrays
const contextRef = useRef<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession));
const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set());
const moduleRef = useRef<any>(null);
const plugin = plugins.find(p => p.name === pluginName);
@@ -65,12 +71,13 @@ export default function PluginTabContent({
let active = true;
const container = containerRef.current;
const entryFile = plugin?.entry ?? 'index.js';
const contextCallbacks = contextCallbacksRef.current;
(async () => {
try {
// Fetch the plugin JS with auth headers (Cloudflare Worker requires auth on all routes).
// Then import it via a Blob URL so the browser never makes an unauthenticated request.
const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${entryFile}`;
const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(entryFile)}`;
const res = await authenticatedFetch(assetUrl);
if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`);
const jsText = await res.text();
@@ -86,8 +93,8 @@ export default function PluginTabContent({
get context(): PluginContext { return contextRef.current; },
onContextChange(cb: (ctx: PluginContext) => void): () => void {
contextCallbacksRef.current.add(cb);
return () => contextCallbacksRef.current.delete(cb);
contextCallbacks.add(cb);
return () => contextCallbacks.delete(cb);
},
async rpc(method: string, path: string, body?: unknown): Promise<unknown> {
@@ -114,7 +121,10 @@ export default function PluginTabContent({
if (!active) return;
console.error(`[Plugin:${pluginName}] Failed to load:`, err);
if (containerRef.current) {
containerRef.current.innerHTML = `<div style="padding:16px;font-size:13px;color:#dc2626">Plugin failed to load: ${String(err)}</div>`;
const errDiv = document.createElement('div');
errDiv.style.cssText = 'padding:16px;font-size:13px;color:#dc2626';
errDiv.textContent = `Plugin failed to load: ${String(err)}`;
containerRef.current.replaceChildren(errDiv);
}
}
})();
@@ -122,7 +132,7 @@ export default function PluginTabContent({
return () => {
active = false;
try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ }
contextCallbacksRef.current.clear();
contextCallbacks.clear();
moduleRef.current = null;
};
}, [pluginName, plugin?.entry, plugin?.enabled]); // re-mount when plugin or enabled state changes

View File

@@ -10,7 +10,7 @@ import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import PluginSettingsTab from '../../plugins/PluginSettingsTab';
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
import { useSettingsController } from '../hooks/useSettingsController';
import type { SettingsProps } from '../types/types';