@siteboon/claude-code-ui 1.23.2 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/index.js CHANGED
@@ -44,7 +44,7 @@ import pty from 'node-pty';
44
44
  import fetch from 'node-fetch';
45
45
  import mime from 'mime-types';
46
46
 
47
- import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
47
+ import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
48
48
  import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
49
49
  import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
50
50
  import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
@@ -64,6 +64,8 @@ import cliAuthRoutes from './routes/cli-auth.js';
64
64
  import userRoutes from './routes/user.js';
65
65
  import codexRoutes from './routes/codex.js';
66
66
  import geminiRoutes from './routes/gemini.js';
67
+ import pluginsRoutes from './routes/plugins.js';
68
+ import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js';
67
69
  import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
68
70
  import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
69
71
  import { IS_PLATFORM } from './constants/config.js';
@@ -324,7 +326,7 @@ const wss = new WebSocketServer({
324
326
  // Make WebSocket server available to routes
325
327
  app.locals.wss = wss;
326
328
 
327
- app.use(cors());
329
+ app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
328
330
  app.use(express.json({
329
331
  limit: '50mb',
330
332
  type: (req) => {
@@ -389,6 +391,9 @@ app.use('/api/codex', authenticateToken, codexRoutes);
389
391
  // Gemini API Routes (protected)
390
392
  app.use('/api/gemini', authenticateToken, geminiRoutes);
391
393
 
394
+ // Plugins API Routes (protected)
395
+ app.use('/api/plugins', authenticateToken, pluginsRoutes);
396
+
392
397
  // Agent API Routes (uses API key authentication)
393
398
  app.use('/api/agent', agentRoutes);
394
399
 
@@ -608,6 +613,51 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
608
613
  }
609
614
  });
610
615
 
616
+ // Search conversations content (SSE streaming)
617
+ app.get('/api/search/conversations', authenticateToken, async (req, res) => {
618
+ const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
619
+ const parsedLimit = Number.parseInt(String(req.query.limit), 10);
620
+ const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100));
621
+
622
+ if (query.length < 2) {
623
+ return res.status(400).json({ error: 'Query must be at least 2 characters' });
624
+ }
625
+
626
+ res.writeHead(200, {
627
+ 'Content-Type': 'text/event-stream',
628
+ 'Cache-Control': 'no-cache',
629
+ 'Connection': 'keep-alive',
630
+ 'X-Accel-Buffering': 'no',
631
+ });
632
+
633
+ let closed = false;
634
+ const abortController = new AbortController();
635
+ req.on('close', () => { closed = true; abortController.abort(); });
636
+
637
+ try {
638
+ await searchConversations(query, limit, ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {
639
+ if (closed) return;
640
+ if (projectResult) {
641
+ res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`);
642
+ } else {
643
+ res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`);
644
+ }
645
+ }, abortController.signal);
646
+ if (!closed) {
647
+ res.write(`event: done\ndata: {}\n\n`);
648
+ }
649
+ } catch (error) {
650
+ console.error('Error searching conversations:', error);
651
+ if (!closed) {
652
+ res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`);
653
+ }
654
+ } finally {
655
+ if (!closed) {
656
+ res.end();
657
+ }
658
+ }
659
+ });
660
+
611
661
  const expandWorkspacePath = (inputPath) => {
612
662
  if (!inputPath) return inputPath;
613
663
  if (inputPath === '~') {
@@ -1649,50 +1699,43 @@ function handleShellConnection(ws) {
1649
1699
  }));
1650
1700
 
1651
1701
  try {
1652
- // Prepare the shell command adapted to the platform and provider
1702
+ // Validate projectPath resolve to absolute and verify it exists
1703
+ const resolvedProjectPath = path.resolve(projectPath);
1704
+ try {
1705
+ const stats = fs.statSync(resolvedProjectPath);
1706
+ if (!stats.isDirectory()) {
1707
+ throw new Error('Not a directory');
1708
+ }
1709
+ } catch (pathErr) {
1710
+ ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
1711
+ return;
1712
+ }
1713
+
1714
+ // Validate sessionId — only allow safe characters
1715
+ const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
1716
+ if (sessionId && !safeSessionIdPattern.test(sessionId)) {
1717
+ ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
1718
+ return;
1719
+ }
1720
+
1721
+ // Build shell command — use cwd for project path (never interpolate into shell string)
1653
1722
  let shellCommand;
1654
1723
  if (isPlainShell) {
1655
- // Plain shell mode - just run the initial command in the project directory
1656
- if (os.platform() === 'win32') {
1657
- shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
1658
- } else {
1659
- shellCommand = `cd "${projectPath}" && ${initialCommand}`;
1660
- }
1724
+ // Plain shell mode - run the initial command in the project directory
1725
+ shellCommand = initialCommand;
1661
1726
  } else if (provider === 'cursor') {
1662
- // Use cursor-agent command
1663
- if (os.platform() === 'win32') {
1664
- if (hasSession && sessionId) {
1665
- shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
1666
- } else {
1667
- shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
1668
- }
1727
+ if (hasSession && sessionId) {
1728
+ shellCommand = `cursor-agent --resume="${sessionId}"`;
1669
1729
  } else {
1670
- if (hasSession && sessionId) {
1671
- shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
1672
- } else {
1673
- shellCommand = `cd "${projectPath}" && cursor-agent`;
1674
- }
1730
+ shellCommand = 'cursor-agent';
1675
1731
  }
1676
-
1677
1732
  } else if (provider === 'codex') {
1678
- // Use codex command
1679
- if (os.platform() === 'win32') {
1680
- if (hasSession && sessionId) {
1681
- // Try to resume session, but with fallback to a new session if it fails
1682
- shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
1683
- } else {
1684
- shellCommand = `Set-Location -Path "${projectPath}"; codex`;
1685
- }
1733
+ if (hasSession && sessionId) {
1734
+ shellCommand = `codex resume "${sessionId}" || codex`;
1686
1735
  } else {
1687
- if (hasSession && sessionId) {
1688
- // Try to resume session, but with fallback to a new session if it fails
1689
- shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`;
1690
- } else {
1691
- shellCommand = `cd "${projectPath}" && codex`;
1692
- }
1736
+ shellCommand = 'codex';
1693
1737
  }
1694
1738
  } else if (provider === 'gemini') {
1695
- // Use gemini command
1696
1739
  const command = initialCommand || 'gemini';
1697
1740
  let resumeId = sessionId;
1698
1741
  if (hasSession && sessionId) {
@@ -1703,41 +1746,28 @@ function handleShellConnection(ws) {
1703
1746
  const sess = sessionManager.getSession(sessionId);
1704
1747
  if (sess && sess.cliSessionId) {
1705
1748
  resumeId = sess.cliSessionId;
1749
+ // Validate the looked-up CLI session ID too
1750
+ if (!safeSessionIdPattern.test(resumeId)) {
1751
+ resumeId = null;
1752
+ }
1706
1753
  }
1707
1754
  } catch (err) {
1708
1755
  console.error('Failed to get Gemini CLI session ID:', err);
1709
1756
  }
1710
1757
  }
1711
1758
 
1712
- if (os.platform() === 'win32') {
1713
- if (hasSession && resumeId) {
1714
- shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`;
1715
- } else {
1716
- shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
1717
- }
1759
+ if (hasSession && resumeId) {
1760
+ shellCommand = `${command} --resume "${resumeId}"`;
1718
1761
  } else {
1719
- if (hasSession && resumeId) {
1720
- shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`;
1721
- } else {
1722
- shellCommand = `cd "${projectPath}" && ${command}`;
1723
- }
1762
+ shellCommand = command;
1724
1763
  }
1725
1764
  } else {
1726
- // Use claude command (default) or initialCommand if provided
1765
+ // Claude (default provider)
1727
1766
  const command = initialCommand || 'claude';
1728
- if (os.platform() === 'win32') {
1729
- if (hasSession && sessionId) {
1730
- // Try to resume session, but with fallback to new session if it fails
1731
- shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
1732
- } else {
1733
- shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
1734
- }
1767
+ if (hasSession && sessionId) {
1768
+ shellCommand = `claude --resume "${sessionId}" || claude`;
1735
1769
  } else {
1736
- if (hasSession && sessionId) {
1737
- shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
1738
- } else {
1739
- shellCommand = `cd "${projectPath}" && ${command}`;
1740
- }
1770
+ shellCommand = command;
1741
1771
  }
1742
1772
  }
1743
1773
 
@@ -1756,7 +1786,7 @@ function handleShellConnection(ws) {
1756
1786
  name: 'xterm-256color',
1757
1787
  cols: termCols,
1758
1788
  rows: termRows,
1759
- cwd: os.homedir(),
1789
+ cwd: resolvedProjectPath,
1760
1790
  env: {
1761
1791
  ...process.env,
1762
1792
  TERM: 'xterm-256color',
@@ -2487,7 +2517,20 @@ async function startServer() {
2487
2517
 
2488
2518
  // Start watching the projects folder for changes
2489
2519
  await setupProjectsWatcher();
2520
+
2521
+ // Start server-side plugin processes for enabled plugins
2522
+ startEnabledPluginServers().catch(err => {
2523
+ console.error('[Plugins] Error during startup:', err.message);
2524
+ });
2490
2525
  });
2526
+
2527
+ // Clean up plugin processes on shutdown
2528
+ const shutdownPlugins = async () => {
2529
+ await stopAllPlugins();
2530
+ process.exit(0);
2531
+ };
2532
+ process.on('SIGTERM', () => void shutdownPlugins());
2533
+ process.on('SIGINT', () => void shutdownPlugins());
2491
2534
  } catch (error) {
2492
2535
  console.error('[ERROR] Failed to start server:', error);
2493
2536
  process.exit(1);
@@ -1,9 +1,9 @@
1
1
  import jwt from 'jsonwebtoken';
2
- import { userDb } from '../database/db.js';
2
+ import { userDb, appConfigDb } from '../database/db.js';
3
3
  import { IS_PLATFORM } from '../constants/config.js';
4
4
 
5
- // Get JWT secret from environment or use default (for development)
6
- const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
5
+ // Use env var if set, otherwise auto-generate a unique secret per installation
6
+ const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
7
7
 
8
8
  // Optional API key middleware
9
9
  const validateApiKey = (req, res, next) => {
@@ -58,6 +58,16 @@ const authenticateToken = async (req, res, next) => {
58
58
  return res.status(401).json({ error: 'Invalid token. User not found.' });
59
59
  }
60
60
 
61
+ // Auto-refresh: if token is past halfway through its lifetime, issue a new one
62
+ if (decoded.exp && decoded.iat) {
63
+ const now = Math.floor(Date.now() / 1000);
64
+ const halfLife = (decoded.exp - decoded.iat) / 2;
65
+ if (now > decoded.iat + halfLife) {
66
+ const newToken = generateToken(user);
67
+ res.setHeader('X-Refreshed-Token', newToken);
68
+ }
69
+ }
70
+
61
71
  req.user = user;
62
72
  next();
63
73
  } catch (error) {
@@ -66,15 +76,15 @@ const authenticateToken = async (req, res, next) => {
66
76
  }
67
77
  };
68
78
 
69
- // Generate JWT token (never expires)
79
+ // Generate JWT token
70
80
  const generateToken = (user) => {
71
81
  return jwt.sign(
72
- {
73
- userId: user.id,
74
- username: user.username
82
+ {
83
+ userId: user.id,
84
+ username: user.username
75
85
  },
76
- JWT_SECRET
77
- // No expiration - token lasts forever
86
+ JWT_SECRET,
87
+ { expiresIn: '7d' }
78
88
  );
79
89
  };
80
90
 
@@ -101,7 +111,12 @@ const authenticateWebSocket = (token) => {
101
111
 
102
112
  try {
103
113
  const decoded = jwt.verify(token, JWT_SECRET);
104
- return decoded;
114
+ // Verify user actually exists in database (matches REST authenticateToken behavior)
115
+ const user = userDb.getUserById(decoded.userId);
116
+ if (!user) {
117
+ return null;
118
+ }
119
+ return { userId: user.id, username: user.username };
105
120
  } catch (error) {
106
121
  console.error('WebSocket token verification error:', error);
107
122
  return null;