@pixelbyte-software/pixcode 1.54.0 → 1.54.1

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.
Files changed (74) hide show
  1. package/dist/assets/{index-CEw93JQ7.js → index-CLO8YURv.js} +205 -205
  2. package/dist/assets/index-DvudRU5X.css +32 -0
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/claude-sdk.js +6 -0
  5. package/dist-server/server/claude-sdk.js.map +1 -1
  6. package/dist-server/server/index.js +240 -46
  7. package/dist-server/server/index.js.map +1 -1
  8. package/dist-server/server/middleware/account-lockout.js +101 -0
  9. package/dist-server/server/middleware/account-lockout.js.map +1 -0
  10. package/dist-server/server/middleware/auth.js +71 -9
  11. package/dist-server/server/middleware/auth.js.map +1 -1
  12. package/dist-server/server/middleware/rate-limiter.js +62 -0
  13. package/dist-server/server/middleware/rate-limiter.js.map +1 -0
  14. package/dist-server/server/routes/agent.js +3 -4
  15. package/dist-server/server/routes/agent.js.map +1 -1
  16. package/dist-server/server/routes/auth.js +75 -13
  17. package/dist-server/server/routes/auth.js.map +1 -1
  18. package/dist-server/server/routes/codex.js +1 -1
  19. package/dist-server/server/routes/codex.js.map +1 -1
  20. package/dist-server/server/routes/diagnostics.js +6 -3
  21. package/dist-server/server/routes/diagnostics.js.map +1 -1
  22. package/dist-server/server/routes/gemini.js +1 -1
  23. package/dist-server/server/routes/gemini.js.map +1 -1
  24. package/dist-server/server/routes/git.js +9 -9
  25. package/dist-server/server/routes/git.js.map +1 -1
  26. package/dist-server/server/routes/live-view.js +2 -2
  27. package/dist-server/server/routes/live-view.js.map +1 -1
  28. package/dist-server/server/routes/plugins.js +12 -12
  29. package/dist-server/server/routes/plugins.js.map +1 -1
  30. package/dist-server/server/routes/projects.js +2 -2
  31. package/dist-server/server/routes/projects.js.map +1 -1
  32. package/dist-server/server/routes/qwen.js +1 -1
  33. package/dist-server/server/routes/qwen.js.map +1 -1
  34. package/dist-server/server/routes/remote.js +1 -1
  35. package/dist-server/server/routes/remote.js.map +1 -1
  36. package/dist-server/server/routes/webhooks.js +1 -1
  37. package/dist-server/server/routes/webhooks.js.map +1 -1
  38. package/dist-server/server/services/startup-update.js +31 -6
  39. package/dist-server/server/services/startup-update.js.map +1 -1
  40. package/dist-server/server/services/telegram/bot.js +6 -4
  41. package/dist-server/server/services/telegram/bot.js.map +1 -1
  42. package/dist-server/server/setup-wizard.js +12 -6
  43. package/dist-server/server/setup-wizard.js.map +1 -1
  44. package/dist-server/server/utils/plugin-loader.js +3 -0
  45. package/dist-server/server/utils/plugin-loader.js.map +1 -1
  46. package/dist-server/server/utils/port-access.js +25 -8
  47. package/dist-server/server/utils/port-access.js.map +1 -1
  48. package/dist-server/server/utils/security-log.js +89 -0
  49. package/dist-server/server/utils/security-log.js.map +1 -0
  50. package/package.json +1 -1
  51. package/server/claude-sdk.js +7 -0
  52. package/server/index.js +249 -46
  53. package/server/middleware/account-lockout.js +112 -0
  54. package/server/middleware/auth.js +72 -9
  55. package/server/middleware/rate-limiter.js +82 -0
  56. package/server/routes/agent.js +3 -4
  57. package/server/routes/auth.js +86 -13
  58. package/server/routes/codex.js +1 -1
  59. package/server/routes/diagnostics.js +6 -3
  60. package/server/routes/gemini.js +1 -1
  61. package/server/routes/git.js +9 -9
  62. package/server/routes/live-view.js +2 -2
  63. package/server/routes/plugins.js +12 -12
  64. package/server/routes/projects.js +2 -2
  65. package/server/routes/qwen.js +1 -1
  66. package/server/routes/remote.js +1 -1
  67. package/server/routes/webhooks.js +1 -1
  68. package/server/services/startup-update.js +31 -6
  69. package/server/services/telegram/bot.js +6 -4
  70. package/server/setup-wizard.js +12 -6
  71. package/server/utils/plugin-loader.js +4 -0
  72. package/server/utils/port-access.js +26 -8
  73. package/server/utils/security-log.js +84 -0
  74. package/dist/assets/index-B6Ssy_IG.css +0 -32
@@ -16,6 +16,7 @@ import { WebSocketServer, WebSocket } from 'ws';
16
16
  import cors from 'cors';
17
17
  import { AppError, createNormalizedMessage } from './shared/utils.js';
18
18
  import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
19
+ import { securityLog, getClientIp } from './utils/security-log.js';
19
20
  const __dirname = getModuleDir(import.meta.url);
20
21
  // The server source runs from /server, while the compiled output runs from /dist-server/server.
21
22
  // Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
@@ -85,7 +86,8 @@ function resolveMonacoAssetsPath() {
85
86
  return candidates.find((candidate) => fs.existsSync(path.join(candidate, 'loader.js'))) || null;
86
87
  }
87
88
  import { c } from './utils/colors.js';
88
- console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
89
+ // Server port is logged after binding in startServer() avoid leaking
90
+ // env configuration to stdout on every import.
89
91
  import pty from 'node-pty';
90
92
  import mime from 'mime-types';
91
93
  import { getProjects, getSessions, renameProject, deleteSession, deleteProject, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
@@ -131,6 +133,7 @@ import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, appConfigD
131
133
  import { setNotificationWebSocketServer } from './services/notification-orchestrator.js';
132
134
  import { configureWebPush } from './services/vapid-keys.js';
133
135
  import { validateApiKey, authenticateToken, authenticateWebSocket, requireAdmin, requireApiScope } from './middleware/auth.js';
136
+ import { apiRateLimiter } from './middleware/rate-limiter.js';
134
137
  import { filterFileTreeForUser, filterProjectsForUser, userHasProjectAccess, userHasProjectPathAccess } from './services/platformization.js';
135
138
  import { IS_PLATFORM } from './constants/config.js';
136
139
  import { getConnectableHost } from '../shared/networkHosts.js';
@@ -320,6 +323,7 @@ async function readLatestPixcodePackageMetadata() {
320
323
  return {
321
324
  latestVersion,
322
325
  tarballUrl: latestEntry?.dist?.tarball || null,
326
+ tarballIntegrity: latestEntry?.dist?.integrity || null,
323
327
  };
324
328
  }
325
329
  function isSafePackageVersion(version) {
@@ -330,13 +334,54 @@ function buildPixcodeTarballUrl(version) {
330
334
  return null;
331
335
  return `https://registry.npmjs.org/@pixelbyte-software/pixcode/-/pixcode-${version.trim()}.tgz`;
332
336
  }
333
- async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl) {
337
+ /**
338
+ * Verify a downloaded tarball's integrity against the registry-provided
339
+ * Subresource Integrity (SRI) string. Throws if the hash doesn't match.
340
+ * @param {Buffer} buffer - The downloaded tarball bytes
341
+ * @param {string} integrity - SRI string from npm registry (e.g. "sha512-...")
342
+ */
343
+ function verifyTarballIntegrity(buffer, integrity) {
344
+ if (!integrity || typeof integrity !== 'string') {
345
+ throw new Error('Tarball integrity hash missing from registry metadata — refusing to install unverified package.');
346
+ }
347
+ const dashIndex = integrity.indexOf('-');
348
+ if (dashIndex < 0) {
349
+ throw new Error('Malformed integrity string from registry.');
350
+ }
351
+ const algo = integrity.slice(0, dashIndex);
352
+ const expectedHash = integrity.slice(dashIndex + 1);
353
+ const validAlgos = ['sha512', 'sha384', 'sha256'];
354
+ if (!validAlgos.includes(algo)) {
355
+ throw new Error(`Unsupported integrity algorithm: ${algo}`);
356
+ }
357
+ const actualHash = crypto.createHash(algo).update(buffer).digest('base64');
358
+ if (actualHash !== expectedHash) {
359
+ throw new Error(`Tarball integrity check failed: expected ${algo}-${expectedHash.slice(0, 16)}…, got ${algo}-${actualHash.slice(0, 16)}…`);
360
+ }
361
+ }
362
+ async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl, tarballIntegrity) {
334
363
  appendUpdateJobLog(job, 'meta', `Update mode: runtime-dir\nRuntime: ${runtimeDir}\n`);
335
364
  appendUpdateJobLog(job, 'meta', `Downloading ${tarballUrl}\n`);
336
365
  const tarballRes = await fetch(tarballUrl);
337
366
  if (!tarballRes.ok || !tarballRes.body) {
338
367
  throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
339
368
  }
369
+ // Download tarball to a buffer first so we can verify integrity before extracting.
370
+ const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
371
+ // Verify integrity hash against registry-provided SRI string.
372
+ if (tarballIntegrity) {
373
+ try {
374
+ verifyTarballIntegrity(tarballBuffer, tarballIntegrity);
375
+ appendUpdateJobLog(job, 'meta', 'Tarball integrity verified.\n');
376
+ }
377
+ catch (verifyError) {
378
+ appendUpdateJobLog(job, 'stderr', `Integrity verification failed: ${verifyError.message}\n`);
379
+ throw verifyError;
380
+ }
381
+ }
382
+ else {
383
+ appendUpdateJobLog(job, 'meta', 'WARNING: No integrity hash from registry — extracting without verification.\n');
384
+ }
340
385
  const stagingDir = path.join(runtimeDir, '.staging');
341
386
  const backupDir = path.join(runtimeDir, '.previous');
342
387
  fs.rmSync(stagingDir, { recursive: true, force: true });
@@ -347,10 +392,7 @@ async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl
347
392
  if (!tarExtract)
348
393
  throw new Error('tar extractor not available');
349
394
  await new Promise((resolve, reject) => {
350
- const webStream = tarballRes.body;
351
- const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
352
- ? Readable.fromWeb(webStream)
353
- : webStream;
395
+ const nodeStream = Readable.from(tarballBuffer);
354
396
  const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
355
397
  nodeStream.pipe(extractor);
356
398
  extractor.on('finish', resolve);
@@ -464,7 +506,7 @@ function createSystemUpdateJob(actorUser, options = {}) {
464
506
  const gitUpdateScript = path.join(APP_ROOT, 'scripts', 'update-git-install.mjs');
465
507
  const latest = await readLatestPixcodePackageMetadata().catch((error) => {
466
508
  appendUpdateJobLog(job, 'stderr', `Registry precheck failed: ${error.message}\n`);
467
- return { latestVersion: null, tarballUrl: null };
509
+ return { latestVersion: null, tarballUrl: null, tarballIntegrity: null };
468
510
  });
469
511
  const requestedVersion = isSafePackageVersion(options.targetVersion) ? options.targetVersion.trim() : null;
470
512
  const resolvedVersion = latest.latestVersion || requestedVersion || null;
@@ -484,7 +526,7 @@ function createSystemUpdateJob(actorUser, options = {}) {
484
526
  if (!latest.latestVersion) {
485
527
  appendUpdateJobLog(job, 'meta', `Using requested target version ${resolvedVersion} after registry precheck failed.\n`);
486
528
  }
487
- job.toVersion = await runRuntimeDirUpdateJob(job, runtimeDir, resolvedVersion, resolvedTarballUrl);
529
+ job.toVersion = await runRuntimeDirUpdateJob(job, runtimeDir, resolvedVersion, resolvedTarballUrl, latest.tarballIntegrity);
488
530
  }
489
531
  else {
490
532
  const updateCommand = IS_PLATFORM
@@ -1506,7 +1548,65 @@ app.locals.wss = wss;
1506
1548
  app.locals.installMode = installMode;
1507
1549
  app.locals.serverVersion = SERVER_VERSION;
1508
1550
  setNotificationWebSocketServer(wss);
1509
- app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
1551
+ // ── Security hardening ──────────────────────────────────────────────
1552
+ // Disable the X-Powered-By header so the framework isn't advertised.
1553
+ app.disable('x-powered-by');
1554
+ // Trust the first proxy hop so X-Forwarded-* headers are respected when
1555
+ // running behind nginx/Caddy/etc. (needed for correct protocol detection
1556
+ // in resolvePublicBaseUrl and for rate-limiting middleware if added later).
1557
+ app.set('trust proxy', 1);
1558
+ // Restrict CORS to known origins instead of reflecting any requester.
1559
+ // In production the frontend is same-origin; in dev it's the Vite server.
1560
+ const ALLOWED_CORS_ORIGINS = (() => {
1561
+ const envOrigins = process.env.CORS_ALLOWED_ORIGINS;
1562
+ if (envOrigins) {
1563
+ return envOrigins.split(',').map((o) => o.trim()).filter(Boolean);
1564
+ }
1565
+ const devPort = process.env.VITE_PORT || 5173;
1566
+ return [
1567
+ `http://localhost:${devPort}`,
1568
+ `http://127.0.0.1:${devPort}`,
1569
+ ];
1570
+ })();
1571
+ app.use(cors({
1572
+ origin(origin, callback) {
1573
+ // Allow same-origin requests (no Origin header) and allowlisted origins.
1574
+ if (!origin || ALLOWED_CORS_ORIGINS.includes(origin)) {
1575
+ return callback(null, true);
1576
+ }
1577
+ return callback(null, false);
1578
+ },
1579
+ credentials: true,
1580
+ exposedHeaders: ['X-Refreshed-Token'],
1581
+ }));
1582
+ // Security headers middleware (replaces helmet which isn't installed).
1583
+ app.use((req, res, next) => {
1584
+ res.setHeader('X-Content-Type-Options', 'nosniff');
1585
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
1586
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
1587
+ res.setHeader('X-XSS-Protection', '1; mode=block');
1588
+ res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
1589
+ // Strict-Transport-Security only makes sense over HTTPS; skip for plain HTTP
1590
+ // so local dev doesn't pin an HSTS policy on localhost.
1591
+ if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
1592
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
1593
+ }
1594
+ // CSP for the SPA shell — API responses are JSON so this mainly guards
1595
+ // the served index.html. 'unsafe-inline' is needed for Vite's inline
1596
+ // module preload polyfill; style-src unsafe-inline for Tailwind injected styles.
1597
+ res.setHeader('Content-Security-Policy', [
1598
+ "default-src 'self'",
1599
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
1600
+ "style-src 'self' 'unsafe-inline'",
1601
+ "img-src 'self' data: blob: https:",
1602
+ "font-src 'self' data:",
1603
+ "connect-src 'self' ws: wss:",
1604
+ "frame-ancestors 'self'",
1605
+ "base-uri 'self'",
1606
+ "form-action 'self'",
1607
+ ].join('; '));
1608
+ next();
1609
+ });
1510
1610
  app.use(express.json({
1511
1611
  limit: '50mb',
1512
1612
  type: (req) => {
@@ -1531,8 +1631,9 @@ app.get('/health', (req, res) => {
1531
1631
  lastAppliedUpdate: updateState.lastAppliedUpdate,
1532
1632
  });
1533
1633
  });
1534
- // Optional API key validation (if configured)
1634
+ // Optional API key validation (if configured) + rate limiting for all API routes
1535
1635
  app.use('/api', validateApiKey);
1636
+ app.use('/api', apiRateLimiter);
1536
1637
  app.post('/api/shell/sessions/terminate', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
1537
1638
  const provider = req.body?.provider || 'claude';
1538
1639
  const projectPath = req.body?.projectPath || os.homedir();
@@ -1914,6 +2015,12 @@ app.get('/api/system/update-state', authenticateToken, (req, res) => {
1914
2015
  });
1915
2016
  });
1916
2017
  app.post('/api/system/update-jobs', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
2018
+ securityLog('system_update_initiated', {
2019
+ ip: getClientIp(req),
2020
+ userId: req.user?.id,
2021
+ username: req.user?.username,
2022
+ endpoint: req.path,
2023
+ });
1917
2024
  const job = createSystemUpdateJob(req.user, {
1918
2025
  targetVersion: req.body?.targetVersion || req.body?.latestVersion,
1919
2026
  });
@@ -2025,6 +2132,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2025
2132
  const latestVersion = metadata['dist-tags']?.latest;
2026
2133
  const latestEntry = latestVersion ? metadata.versions?.[latestVersion] : null;
2027
2134
  const tarballUrl = latestEntry?.dist?.tarball;
2135
+ const tarballIntegrity = latestEntry?.dist?.integrity;
2028
2136
  if (!latestVersion || !tarballUrl)
2029
2137
  throw new Error('Registry response missing latest/tarball');
2030
2138
  send('log', { stream: 'meta', chunk: `Current: ${SERVER_VERSION} → Latest: ${latestVersion}\n` });
@@ -2038,15 +2146,29 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2038
2146
  endStream();
2039
2147
  return;
2040
2148
  }
2041
- // 2. Download the tarball stream and pipe it through tar's
2042
- // extractor into a staging directory. Doing the extract
2043
- // under `.staging` first means the live runtime stays
2044
- // intact if the download fails partway through.
2149
+ // 2. Download the tarball to a buffer so we can verify integrity
2150
+ // before extracting. Doing the extract under `.staging` first
2151
+ // means the live runtime stays intact if the download fails.
2045
2152
  send('log', { stream: 'meta', chunk: `Downloading ${tarballUrl}\n` });
2046
2153
  const tarballRes = await fetch(tarballUrl);
2047
2154
  if (!tarballRes.ok || !tarballRes.body) {
2048
2155
  throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
2049
2156
  }
2157
+ const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
2158
+ // Verify integrity hash against registry-provided SRI string.
2159
+ if (tarballIntegrity) {
2160
+ try {
2161
+ verifyTarballIntegrity(tarballBuffer, tarballIntegrity);
2162
+ send('log', { stream: 'meta', chunk: 'Tarball integrity verified.\n' });
2163
+ }
2164
+ catch (verifyError) {
2165
+ send('log', { stream: 'stderr', chunk: `Integrity verification failed: ${verifyError.message}\n` });
2166
+ throw verifyError;
2167
+ }
2168
+ }
2169
+ else {
2170
+ send('log', { stream: 'meta', chunk: 'WARNING: No integrity hash from registry — extracting without verification.\n' });
2171
+ }
2050
2172
  const stagingDir = path.join(runtimeDir, '.staging');
2051
2173
  const backupDir = path.join(runtimeDir, '.previous');
2052
2174
  fs.rmSync(stagingDir, { recursive: true, force: true });
@@ -2060,10 +2182,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2060
2182
  // contents to the staging dir directly so paths match the
2061
2183
  // existing runtime layout.
2062
2184
  await new Promise((resolve, reject) => {
2063
- const webStream = tarballRes.body;
2064
- const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
2065
- ? Readable.fromWeb(webStream)
2066
- : webStream;
2185
+ const nodeStream = Readable.from(tarballBuffer);
2067
2186
  const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
2068
2187
  nodeStream.pipe(extractor);
2069
2188
  extractor.on('finish', resolve);
@@ -2292,6 +2411,11 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2292
2411
  // (systemd/pm2/daemon manager) can bring the server back on the new code.
2293
2412
  // Foreground installs without a wrapper will simply stop; the UI reports this.
2294
2413
  app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
2414
+ securityLog('system_restart_requested', {
2415
+ ip: getClientIp(req),
2416
+ userId: req.user?.id,
2417
+ username: req.user?.username,
2418
+ });
2295
2419
  const forceRestart = req.body?.force === true || req.query.force === 'true';
2296
2420
  const activeWork = getActiveWorkSummary();
2297
2421
  if (!forceRestart && activeWork.hasActiveWork) {
@@ -2318,7 +2442,7 @@ app.get('/api/projects', authenticateToken, async (req, res) => {
2318
2442
  res.json(filterProjectsForUser(projects, req.user).map((project) => filterProjectSessionsForUser(project, req.user)));
2319
2443
  }
2320
2444
  catch (error) {
2321
- res.status(500).json({ error: error.message });
2445
+ res.status(500).json({ error: "Internal server error" });
2322
2446
  }
2323
2447
  });
2324
2448
  app.get('/api/projects/:projectName/sessions', authenticateToken, requireProjectAccess('viewFiles'), async (req, res) => {
@@ -2332,7 +2456,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, requireProject
2332
2456
  res.json(result);
2333
2457
  }
2334
2458
  catch (error) {
2335
- res.status(500).json({ error: error.message });
2459
+ res.status(500).json({ error: "Internal server error" });
2336
2460
  }
2337
2461
  });
2338
2462
  // Rename project endpoint
@@ -2343,7 +2467,7 @@ app.put('/api/projects/:projectName/rename', authenticateToken, requireProjectAc
2343
2467
  res.json({ success: true });
2344
2468
  }
2345
2469
  catch (error) {
2346
- res.status(500).json({ error: error.message });
2470
+ res.status(500).json({ error: "Internal server error" });
2347
2471
  }
2348
2472
  });
2349
2473
  // Delete session endpoint
@@ -2358,7 +2482,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
2358
2482
  }
2359
2483
  catch (error) {
2360
2484
  console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
2361
- res.status(500).json({ error: error.message });
2485
+ res.status(500).json({ error: "Internal server error" });
2362
2486
  }
2363
2487
  });
2364
2488
  // Rename session endpoint
@@ -2384,7 +2508,7 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
2384
2508
  }
2385
2509
  catch (error) {
2386
2510
  console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
2387
- res.status(500).json({ error: error.message });
2511
+ res.status(500).json({ error: "Internal server error" });
2388
2512
  }
2389
2513
  });
2390
2514
  // Delete project endpoint
@@ -2404,7 +2528,7 @@ app.delete('/api/projects/:projectName', authenticateToken, requireProjectAccess
2404
2528
  // catch it and prompt the user to pass `?force=true` (or clean
2405
2529
  // sessions first) instead of treating it like a crash.
2406
2530
  const conflict = typeof error?.message === 'string' && error.message.includes('existing sessions');
2407
- res.status(conflict ? 409 : 500).json({ error: error.message });
2531
+ res.status(conflict ? 409 : 500).json({ error: conflict ? error.message : 'Internal server error' });
2408
2532
  }
2409
2533
  });
2410
2534
  // Search conversations content (SSE streaming)
@@ -2636,7 +2760,7 @@ app.get('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
2636
2760
  res.status(403).json({ error: 'Permission denied' });
2637
2761
  }
2638
2762
  else {
2639
- res.status(500).json({ error: error.message });
2763
+ res.status(500).json({ error: "Internal server error" });
2640
2764
  }
2641
2765
  }
2642
2766
  });
@@ -2688,7 +2812,7 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, requirePr
2688
2812
  catch (error) {
2689
2813
  console.error('Error serving binary file:', error);
2690
2814
  if (!res.headersSent) {
2691
- res.status(500).json({ error: error.message });
2815
+ res.status(500).json({ error: "Internal server error" });
2692
2816
  }
2693
2817
  }
2694
2818
  });
@@ -2736,7 +2860,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
2736
2860
  res.status(403).json({ error: 'Permission denied' });
2737
2861
  }
2738
2862
  else {
2739
- res.status(500).json({ error: error.message });
2863
+ res.status(500).json({ error: "Internal server error" });
2740
2864
  }
2741
2865
  }
2742
2866
  });
@@ -2774,7 +2898,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, requireProjectAcc
2774
2898
  }
2775
2899
  catch (error) {
2776
2900
  console.error('[ERROR] File tree error:', error.message);
2777
- res.status(500).json({ error: error.message });
2901
+ res.status(500).json({ error: "Internal server error" });
2778
2902
  }
2779
2903
  });
2780
2904
  // ============================================================================
@@ -2893,7 +3017,7 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, requirePr
2893
3017
  res.status(404).json({ error: 'Parent directory not found' });
2894
3018
  }
2895
3019
  else {
2896
- res.status(500).json({ error: error.message });
3020
+ res.status(500).json({ error: "Internal server error" });
2897
3021
  }
2898
3022
  }
2899
3023
  });
@@ -2971,7 +3095,7 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
2971
3095
  res.status(400).json({ error: 'Cannot move across different filesystems' });
2972
3096
  }
2973
3097
  else {
2974
- res.status(500).json({ error: error.message });
3098
+ res.status(500).json({ error: "Internal server error" });
2975
3099
  }
2976
3100
  }
2977
3101
  });
@@ -3036,7 +3160,7 @@ app.delete('/api/projects/:projectName/files', authenticateToken, requireProject
3036
3160
  res.status(400).json({ error: 'Directory is not empty' });
3037
3161
  }
3038
3162
  else {
3039
- res.status(500).json({ error: error.message });
3163
+ res.status(500).json({ error: "Internal server error" });
3040
3164
  }
3041
3165
  }
3042
3166
  });
@@ -3053,7 +3177,7 @@ const uploadFilesHandler = async (req, res) => {
3053
3177
  filename: (req, file, cb) => {
3054
3178
  // Use a unique temp name, but preserve original name in file.originalname
3055
3179
  // Note: file.originalname may contain path separators for folder uploads
3056
- const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
3180
+ const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
3057
3181
  // For temp file, just use a safe unique name without the path
3058
3182
  cb(null, `upload-${uniqueSuffix}`);
3059
3183
  }
@@ -3191,7 +3315,7 @@ const uploadFilesHandler = async (req, res) => {
3191
3315
  res.status(403).json({ error: 'Permission denied' });
3192
3316
  }
3193
3317
  else {
3194
- res.status(500).json({ error: error.message });
3318
+ res.status(500).json({ error: "Internal server error" });
3195
3319
  }
3196
3320
  }
3197
3321
  });
@@ -3537,10 +3661,15 @@ function handleChatConnection(ws, request) {
3537
3661
  }
3538
3662
  }
3539
3663
  catch (error) {
3540
- console.error('[ERROR] Chat WebSocket error:', error.message);
3664
+ console.error('[ERROR] Chat WebSocket error:', error?.message || error);
3665
+ securityLog('ws_chat_error', {
3666
+ userId: request?.user?.id,
3667
+ username: request?.user?.username,
3668
+ reason: error?.name || 'UnknownError',
3669
+ });
3541
3670
  writer.send({
3542
3671
  type: 'error',
3543
- error: error.message
3672
+ error: 'An error occurred while processing your request.'
3544
3673
  });
3545
3674
  }
3546
3675
  });
@@ -3991,11 +4120,16 @@ function handleShellConnection(ws, request) {
3991
4120
  const termCols = data.cols || 80;
3992
4121
  const termRows = data.rows || 24;
3993
4122
  console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
4123
+ const isRunningAsRoot = typeof process.getuid === 'function' && process.getuid() === 0;
3994
4124
  const shellEnv = {
3995
4125
  ...process.env,
3996
4126
  TERM: 'xterm-256color',
3997
4127
  COLORTERM: 'truecolor',
3998
4128
  FORCE_COLOR: '3',
4129
+ // When running as root, Claude CLI refuses --dangerously-skip-permissions
4130
+ // for security reasons. Setting IS_SANDBOX=1 tells it the environment is
4131
+ // already sandboxed, so the flag is safe to use.
4132
+ ...(isRunningAsRoot ? { IS_SANDBOX: '1' } : {}),
3999
4133
  };
4000
4134
  shellProcess = pty.spawn(shell, shellArgs, {
4001
4135
  name: 'xterm-256color',
@@ -4108,7 +4242,7 @@ function handleShellConnection(ws, request) {
4108
4242
  console.error('[ERROR] Error spawning process:', spawnError);
4109
4243
  ws.send(JSON.stringify({
4110
4244
  type: 'output',
4111
- data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
4245
+ data: `\r\n\x1b[31mError: Failed to start terminal process.\x1b[0m\r\n`
4112
4246
  }));
4113
4247
  }
4114
4248
  }
@@ -4125,11 +4259,11 @@ function handleShellConnection(ws, request) {
4125
4259
  }
4126
4260
  }
4127
4261
  catch (error) {
4128
- console.error('[ERROR] Shell WebSocket error:', error.message);
4262
+ console.error('[ERROR] Shell WebSocket error:', error?.message || error);
4129
4263
  if (ws.readyState === WebSocket.OPEN) {
4130
4264
  ws.send(JSON.stringify({
4131
4265
  type: 'output',
4132
- data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
4266
+ data: `\r\n\x1b[31mError: An internal error occurred.\x1b[0m\r\n`
4133
4267
  }));
4134
4268
  }
4135
4269
  }
@@ -4185,7 +4319,7 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, requireP
4185
4319
  cb(null, uploadDir);
4186
4320
  },
4187
4321
  filename: (req, file, cb) => {
4188
- const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
4322
+ const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
4189
4323
  const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
4190
4324
  cb(null, uniqueSuffix + '-' + sanitizedName);
4191
4325
  }
@@ -4517,8 +4651,16 @@ app.get(/.*/, (req, res) => {
4517
4651
  }
4518
4652
  });
4519
4653
  // global error middleware must be last
4520
- app.use((err, req, res, next) => {
4654
+ app.use((err, req, res, _next) => {
4521
4655
  if (err instanceof AppError) {
4656
+ securityLog('app_error', {
4657
+ ip: getClientIp(req),
4658
+ endpoint: req.path,
4659
+ method: req.method,
4660
+ statusCode: err.statusCode,
4661
+ userId: req.user?.id,
4662
+ reason: err.code,
4663
+ });
4522
4664
  return res.status(err.statusCode).json({
4523
4665
  success: false,
4524
4666
  error: {
@@ -4528,7 +4670,35 @@ app.use((err, req, res, next) => {
4528
4670
  },
4529
4671
  });
4530
4672
  }
4531
- console.error(err);
4673
+ // Log the error internally but never expose stack traces, file paths,
4674
+ // or internal IPs to the client.
4675
+ if (err?.type === 'entity.too.large') {
4676
+ return res.status(413).json({
4677
+ success: false,
4678
+ error: {
4679
+ code: 'PAYLOAD_TOO_LARGE',
4680
+ message: 'Request payload exceeds size limit.',
4681
+ },
4682
+ });
4683
+ }
4684
+ if (err?.type === 'entity.parse.failed' || err?.type === 'entity.parse.failed.utf8') {
4685
+ return res.status(400).json({
4686
+ success: false,
4687
+ error: {
4688
+ code: 'INVALID_JSON',
4689
+ message: 'Request body contains invalid JSON.',
4690
+ },
4691
+ });
4692
+ }
4693
+ console.error('[UNHANDLED ERROR]', err?.message || err);
4694
+ securityLog('unhandled_error', {
4695
+ ip: getClientIp(req),
4696
+ endpoint: req.path,
4697
+ method: req.method,
4698
+ statusCode: 500,
4699
+ userId: req.user?.id,
4700
+ reason: err?.name || 'UnknownError',
4701
+ });
4532
4702
  return res.status(500).json({
4533
4703
  success: false,
4534
4704
  error: {
@@ -4801,6 +4971,24 @@ async function maybeAutoDaemonBootstrapFromIndex() {
4801
4971
  }
4802
4972
  }
4803
4973
  }
4974
+ // Process-level error handlers to prevent silent crashes and log security events.
4975
+ // These catch errors that escape Express's own error middleware (e.g. from
4976
+ // timers, WebSocket handlers, or un-awaited promises in route handlers).
4977
+ process.on('unhandledRejection', (reason, promise) => {
4978
+ console.error('[FATAL] Unhandled promise rejection:', reason);
4979
+ securityLog('unhandled_rejection', {
4980
+ reason: reason instanceof Error ? reason.name : String(reason).slice(0, 200),
4981
+ });
4982
+ });
4983
+ process.on('uncaughtException', (error) => {
4984
+ console.error('[FATAL] Uncaught exception:', error?.message || error);
4985
+ securityLog('uncaught_exception', {
4986
+ reason: error?.name || 'UnknownError',
4987
+ });
4988
+ // Give the security log time to flush, then exit. A clean exit lets the
4989
+ // daemon manager (systemd/pm2) restart the server automatically.
4990
+ setTimeout(() => process.exit(1), 100);
4991
+ });
4804
4992
  // Initialize database and start server
4805
4993
  async function startServer() {
4806
4994
  try {
@@ -4873,11 +5061,17 @@ async function startServer() {
4873
5061
  if (process.env.PIXCODE_NO_BROWSER !== '1') {
4874
5062
  const openUrl = `http://${DISPLAY_HOST}:${SERVER_PORT}`;
4875
5063
  try {
4876
- const { exec } = await import('node:child_process');
4877
- const opener = process.platform === 'darwin' ? `open "${openUrl}"`
4878
- : process.platform === 'win32' ? `start "" "${openUrl}"`
4879
- : `xdg-open "${openUrl}"`;
4880
- exec(opener, { stdio: 'ignore', timeout: 3000 });
5064
+ // Use spawn with an argument array instead of exec with a
5065
+ // shell string to prevent command injection through the
5066
+ // host/port values (which come from env vars).
5067
+ const { spawn: spawnBrowser } = await import('node:child_process');
5068
+ const browserBin = process.platform === 'darwin' ? 'open'
5069
+ : process.platform === 'win32' ? 'cmd'
5070
+ : 'xdg-open';
5071
+ const browserArgs = process.platform === 'win32'
5072
+ ? ['/c', 'start', '', openUrl]
5073
+ : [openUrl];
5074
+ spawnBrowser(browserBin, browserArgs, { stdio: 'ignore', timeout: 3000, detached: true, shell: false }).unref();
4881
5075
  console.log(`${c.ok('[OK]')} Opening browser at ${c.bright(openUrl)}`);
4882
5076
  }
4883
5077
  catch {