@pixelbyte-software/pixcode 1.53.28 → 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 (75) hide show
  1. package/dist/assets/{index-BBDYzJ7B.js → index-CLO8YURv.js} +148 -148
  2. package/dist/index.html +1 -1
  3. package/dist-server/server/claude-sdk.js +6 -0
  4. package/dist-server/server/claude-sdk.js.map +1 -1
  5. package/dist-server/server/cli.js +67 -0
  6. package/dist-server/server/cli.js.map +1 -1
  7. package/dist-server/server/index.js +278 -54
  8. package/dist-server/server/index.js.map +1 -1
  9. package/dist-server/server/middleware/account-lockout.js +101 -0
  10. package/dist-server/server/middleware/account-lockout.js.map +1 -0
  11. package/dist-server/server/middleware/auth.js +71 -9
  12. package/dist-server/server/middleware/auth.js.map +1 -1
  13. package/dist-server/server/middleware/rate-limiter.js +62 -0
  14. package/dist-server/server/middleware/rate-limiter.js.map +1 -0
  15. package/dist-server/server/routes/agent.js +3 -4
  16. package/dist-server/server/routes/agent.js.map +1 -1
  17. package/dist-server/server/routes/auth.js +75 -13
  18. package/dist-server/server/routes/auth.js.map +1 -1
  19. package/dist-server/server/routes/codex.js +1 -1
  20. package/dist-server/server/routes/codex.js.map +1 -1
  21. package/dist-server/server/routes/diagnostics.js +6 -3
  22. package/dist-server/server/routes/diagnostics.js.map +1 -1
  23. package/dist-server/server/routes/gemini.js +1 -1
  24. package/dist-server/server/routes/gemini.js.map +1 -1
  25. package/dist-server/server/routes/git.js +9 -9
  26. package/dist-server/server/routes/git.js.map +1 -1
  27. package/dist-server/server/routes/live-view.js +2 -2
  28. package/dist-server/server/routes/live-view.js.map +1 -1
  29. package/dist-server/server/routes/plugins.js +12 -12
  30. package/dist-server/server/routes/plugins.js.map +1 -1
  31. package/dist-server/server/routes/projects.js +2 -2
  32. package/dist-server/server/routes/projects.js.map +1 -1
  33. package/dist-server/server/routes/qwen.js +1 -1
  34. package/dist-server/server/routes/qwen.js.map +1 -1
  35. package/dist-server/server/routes/remote.js +1 -1
  36. package/dist-server/server/routes/remote.js.map +1 -1
  37. package/dist-server/server/routes/webhooks.js +1 -1
  38. package/dist-server/server/routes/webhooks.js.map +1 -1
  39. package/dist-server/server/services/startup-update.js +31 -6
  40. package/dist-server/server/services/startup-update.js.map +1 -1
  41. package/dist-server/server/services/telegram/bot.js +6 -4
  42. package/dist-server/server/services/telegram/bot.js.map +1 -1
  43. package/dist-server/server/setup-wizard.js +246 -0
  44. package/dist-server/server/setup-wizard.js.map +1 -0
  45. package/dist-server/server/utils/plugin-loader.js +3 -0
  46. package/dist-server/server/utils/plugin-loader.js.map +1 -1
  47. package/dist-server/server/utils/port-access.js +25 -8
  48. package/dist-server/server/utils/port-access.js.map +1 -1
  49. package/dist-server/server/utils/security-log.js +89 -0
  50. package/dist-server/server/utils/security-log.js.map +1 -0
  51. package/package.json +1 -1
  52. package/server/claude-sdk.js +7 -0
  53. package/server/cli.js +67 -0
  54. package/server/index.js +287 -55
  55. package/server/middleware/account-lockout.js +112 -0
  56. package/server/middleware/auth.js +72 -9
  57. package/server/middleware/rate-limiter.js +82 -0
  58. package/server/routes/agent.js +3 -4
  59. package/server/routes/auth.js +86 -13
  60. package/server/routes/codex.js +1 -1
  61. package/server/routes/diagnostics.js +6 -3
  62. package/server/routes/gemini.js +1 -1
  63. package/server/routes/git.js +9 -9
  64. package/server/routes/live-view.js +2 -2
  65. package/server/routes/plugins.js +12 -12
  66. package/server/routes/projects.js +2 -2
  67. package/server/routes/qwen.js +1 -1
  68. package/server/routes/remote.js +1 -1
  69. package/server/routes/webhooks.js +1 -1
  70. package/server/services/startup-update.js +31 -6
  71. package/server/services/telegram/bot.js +6 -4
  72. package/server/setup-wizard.js +263 -0
  73. package/server/utils/plugin-loader.js +4 -0
  74. package/server/utils/port-access.js +26 -8
  75. package/server/utils/security-log.js +84 -0
package/server/index.js CHANGED
@@ -19,6 +19,7 @@ import cors from 'cors';
19
19
  import { AppError, createNormalizedMessage } from '@/shared/utils.js';
20
20
 
21
21
  import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
22
+ import { securityLog, getClientIp } from './utils/security-log.js';
22
23
 
23
24
 
24
25
 
@@ -94,7 +95,8 @@ function resolveMonacoAssetsPath() {
94
95
 
95
96
  import { c } from './utils/colors.js';
96
97
 
97
- console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
98
+ // Server port is logged after binding in startServer() avoid leaking
99
+ // env configuration to stdout on every import.
98
100
 
99
101
 
100
102
 
@@ -163,6 +165,7 @@ import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, appConfigD
163
165
  import { setNotificationWebSocketServer } from './services/notification-orchestrator.js';
164
166
  import { configureWebPush } from './services/vapid-keys.js';
165
167
  import { validateApiKey, authenticateToken, authenticateWebSocket, requireAdmin, requireApiScope } from './middleware/auth.js';
168
+ import { apiRateLimiter } from './middleware/rate-limiter.js';
166
169
  import { filterFileTreeForUser, filterProjectsForUser, userHasProjectAccess, userHasProjectPathAccess } from './services/platformization.js';
167
170
  import { IS_PLATFORM } from './constants/config.js';
168
171
 
@@ -368,6 +371,7 @@ async function readLatestPixcodePackageMetadata() {
368
371
  return {
369
372
  latestVersion,
370
373
  tarballUrl: latestEntry?.dist?.tarball || null,
374
+ tarballIntegrity: latestEntry?.dist?.integrity || null,
371
375
  };
372
376
  }
373
377
 
@@ -380,7 +384,33 @@ function buildPixcodeTarballUrl(version) {
380
384
  return `https://registry.npmjs.org/@pixelbyte-software/pixcode/-/pixcode-${version.trim()}.tgz`;
381
385
  }
382
386
 
383
- async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl) {
387
+ /**
388
+ * Verify a downloaded tarball's integrity against the registry-provided
389
+ * Subresource Integrity (SRI) string. Throws if the hash doesn't match.
390
+ * @param {Buffer} buffer - The downloaded tarball bytes
391
+ * @param {string} integrity - SRI string from npm registry (e.g. "sha512-...")
392
+ */
393
+ function verifyTarballIntegrity(buffer, integrity) {
394
+ if (!integrity || typeof integrity !== 'string') {
395
+ throw new Error('Tarball integrity hash missing from registry metadata — refusing to install unverified package.');
396
+ }
397
+ const dashIndex = integrity.indexOf('-');
398
+ if (dashIndex < 0) {
399
+ throw new Error('Malformed integrity string from registry.');
400
+ }
401
+ const algo = integrity.slice(0, dashIndex);
402
+ const expectedHash = integrity.slice(dashIndex + 1);
403
+ const validAlgos = ['sha512', 'sha384', 'sha256'];
404
+ if (!validAlgos.includes(algo)) {
405
+ throw new Error(`Unsupported integrity algorithm: ${algo}`);
406
+ }
407
+ const actualHash = crypto.createHash(algo).update(buffer).digest('base64');
408
+ if (actualHash !== expectedHash) {
409
+ throw new Error(`Tarball integrity check failed: expected ${algo}-${expectedHash.slice(0, 16)}…, got ${algo}-${actualHash.slice(0, 16)}…`);
410
+ }
411
+ }
412
+
413
+ async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl, tarballIntegrity) {
384
414
  appendUpdateJobLog(job, 'meta', `Update mode: runtime-dir\nRuntime: ${runtimeDir}\n`);
385
415
  appendUpdateJobLog(job, 'meta', `Downloading ${tarballUrl}\n`);
386
416
  const tarballRes = await fetch(tarballUrl);
@@ -388,6 +418,22 @@ async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl
388
418
  throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
389
419
  }
390
420
 
421
+ // Download tarball to a buffer first so we can verify integrity before extracting.
422
+ const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
423
+
424
+ // Verify integrity hash against registry-provided SRI string.
425
+ if (tarballIntegrity) {
426
+ try {
427
+ verifyTarballIntegrity(tarballBuffer, tarballIntegrity);
428
+ appendUpdateJobLog(job, 'meta', 'Tarball integrity verified.\n');
429
+ } catch (verifyError) {
430
+ appendUpdateJobLog(job, 'stderr', `Integrity verification failed: ${verifyError.message}\n`);
431
+ throw verifyError;
432
+ }
433
+ } else {
434
+ appendUpdateJobLog(job, 'meta', 'WARNING: No integrity hash from registry — extracting without verification.\n');
435
+ }
436
+
391
437
  const stagingDir = path.join(runtimeDir, '.staging');
392
438
  const backupDir = path.join(runtimeDir, '.previous');
393
439
  fs.rmSync(stagingDir, { recursive: true, force: true });
@@ -399,10 +445,7 @@ async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl
399
445
  if (!tarExtract) throw new Error('tar extractor not available');
400
446
 
401
447
  await new Promise((resolve, reject) => {
402
- const webStream = tarballRes.body;
403
- const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
404
- ? Readable.fromWeb(webStream)
405
- : webStream;
448
+ const nodeStream = Readable.from(tarballBuffer);
406
449
  const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
407
450
  nodeStream.pipe(extractor);
408
451
  extractor.on('finish', resolve);
@@ -514,7 +557,7 @@ function createSystemUpdateJob(actorUser, options = {}) {
514
557
  const gitUpdateScript = path.join(APP_ROOT, 'scripts', 'update-git-install.mjs');
515
558
  const latest = await readLatestPixcodePackageMetadata().catch((error) => {
516
559
  appendUpdateJobLog(job, 'stderr', `Registry precheck failed: ${error.message}\n`);
517
- return { latestVersion: null, tarballUrl: null };
560
+ return { latestVersion: null, tarballUrl: null, tarballIntegrity: null };
518
561
  });
519
562
  const requestedVersion = isSafePackageVersion(options.targetVersion) ? options.targetVersion.trim() : null;
520
563
  const resolvedVersion = latest.latestVersion || requestedVersion || null;
@@ -536,7 +579,7 @@ function createSystemUpdateJob(actorUser, options = {}) {
536
579
  if (!latest.latestVersion) {
537
580
  appendUpdateJobLog(job, 'meta', `Using requested target version ${resolvedVersion} after registry precheck failed.\n`);
538
581
  }
539
- job.toVersion = await runRuntimeDirUpdateJob(job, runtimeDir, resolvedVersion, resolvedTarballUrl);
582
+ job.toVersion = await runRuntimeDirUpdateJob(job, runtimeDir, resolvedVersion, resolvedTarballUrl, latest.tarballIntegrity);
540
583
  } else {
541
584
  const updateCommand = IS_PLATFORM
542
585
  ? 'npm run update:platform'
@@ -1119,8 +1162,8 @@ const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
1119
1162
  const SHELL_OUTPUT_FLUSH_MAX_CHARS = 128 * 1024;
1120
1163
  const SHELL_WS_BACKPRESSURE_LIMIT = 8 * 1024 * 1024;
1121
1164
  const PTY_SESSION_BUFFER_MAX_BYTES = Number.parseInt(process.env.PIXCODE_PTY_BUFFER_MAX_BYTES || '', 10) || (512 * 1024);
1122
- const SHELL_INPUT_CHUNK_CHARS = 4096;
1123
- const SHELL_PENDING_INPUT_MAX_BYTES = Number.parseInt(process.env.PIXCODE_SHELL_PENDING_INPUT_MAX_BYTES || '', 10) || (16 * 1024 * 1024);
1165
+ const SHELL_INPUT_CHUNK_CHARS = 16384;
1166
+ const SHELL_PENDING_INPUT_MAX_BYTES = Number.parseInt(process.env.PIXCODE_SHELL_PENDING_INPUT_MAX_BYTES || '', 10) || (256 * 1024 * 1024);
1124
1167
  const SHELL_CLI_PROVIDERS = new Set(['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode']);
1125
1168
  const FILE_TREE_MAX_ITEMS = Number.parseInt(process.env.PIXCODE_FILE_TREE_MAX_ITEMS || '', 10) || 5000;
1126
1169
  const FILE_TREE_MAX_DIRECTORIES = Number.parseInt(process.env.PIXCODE_FILE_TREE_MAX_DIRECTORIES || '', 10) || 1200;
@@ -1687,7 +1730,69 @@ app.locals.installMode = installMode;
1687
1730
  app.locals.serverVersion = SERVER_VERSION;
1688
1731
  setNotificationWebSocketServer(wss);
1689
1732
 
1690
- app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
1733
+ // ── Security hardening ──────────────────────────────────────────────
1734
+ // Disable the X-Powered-By header so the framework isn't advertised.
1735
+ app.disable('x-powered-by');
1736
+ // Trust the first proxy hop so X-Forwarded-* headers are respected when
1737
+ // running behind nginx/Caddy/etc. (needed for correct protocol detection
1738
+ // in resolvePublicBaseUrl and for rate-limiting middleware if added later).
1739
+ app.set('trust proxy', 1);
1740
+
1741
+ // Restrict CORS to known origins instead of reflecting any requester.
1742
+ // In production the frontend is same-origin; in dev it's the Vite server.
1743
+ const ALLOWED_CORS_ORIGINS = (() => {
1744
+ const envOrigins = process.env.CORS_ALLOWED_ORIGINS;
1745
+ if (envOrigins) {
1746
+ return envOrigins.split(',').map((o) => o.trim()).filter(Boolean);
1747
+ }
1748
+ const devPort = process.env.VITE_PORT || 5173;
1749
+ return [
1750
+ `http://localhost:${devPort}`,
1751
+ `http://127.0.0.1:${devPort}`,
1752
+ ];
1753
+ })();
1754
+
1755
+ app.use(cors({
1756
+ origin(origin, callback) {
1757
+ // Allow same-origin requests (no Origin header) and allowlisted origins.
1758
+ if (!origin || ALLOWED_CORS_ORIGINS.includes(origin)) {
1759
+ return callback(null, true);
1760
+ }
1761
+ return callback(null, false);
1762
+ },
1763
+ credentials: true,
1764
+ exposedHeaders: ['X-Refreshed-Token'],
1765
+ }));
1766
+
1767
+ // Security headers middleware (replaces helmet which isn't installed).
1768
+ app.use((req, res, next) => {
1769
+ res.setHeader('X-Content-Type-Options', 'nosniff');
1770
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
1771
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
1772
+ res.setHeader('X-XSS-Protection', '1; mode=block');
1773
+ res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
1774
+ // Strict-Transport-Security only makes sense over HTTPS; skip for plain HTTP
1775
+ // so local dev doesn't pin an HSTS policy on localhost.
1776
+ if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
1777
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
1778
+ }
1779
+ // CSP for the SPA shell — API responses are JSON so this mainly guards
1780
+ // the served index.html. 'unsafe-inline' is needed for Vite's inline
1781
+ // module preload polyfill; style-src unsafe-inline for Tailwind injected styles.
1782
+ res.setHeader('Content-Security-Policy', [
1783
+ "default-src 'self'",
1784
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
1785
+ "style-src 'self' 'unsafe-inline'",
1786
+ "img-src 'self' data: blob: https:",
1787
+ "font-src 'self' data:",
1788
+ "connect-src 'self' ws: wss:",
1789
+ "frame-ancestors 'self'",
1790
+ "base-uri 'self'",
1791
+ "form-action 'self'",
1792
+ ].join('; '));
1793
+ next();
1794
+ });
1795
+
1691
1796
  app.use(express.json({
1692
1797
  limit: '50mb',
1693
1798
  type: (req) => {
@@ -1714,8 +1819,9 @@ app.get('/health', (req, res) => {
1714
1819
  });
1715
1820
  });
1716
1821
 
1717
- // Optional API key validation (if configured)
1822
+ // Optional API key validation (if configured) + rate limiting for all API routes
1718
1823
  app.use('/api', validateApiKey);
1824
+ app.use('/api', apiRateLimiter);
1719
1825
 
1720
1826
  app.post('/api/shell/sessions/terminate', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
1721
1827
  const provider = req.body?.provider || 'claude';
@@ -2152,6 +2258,12 @@ app.get('/api/system/update-state', authenticateToken, (req, res) => {
2152
2258
  });
2153
2259
 
2154
2260
  app.post('/api/system/update-jobs', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
2261
+ securityLog('system_update_initiated', {
2262
+ ip: getClientIp(req),
2263
+ userId: req.user?.id,
2264
+ username: req.user?.username,
2265
+ endpoint: req.path,
2266
+ });
2155
2267
  const job = createSystemUpdateJob(req.user, {
2156
2268
  targetVersion: req.body?.targetVersion || req.body?.latestVersion,
2157
2269
  });
@@ -2268,6 +2380,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2268
2380
  const latestVersion = metadata['dist-tags']?.latest;
2269
2381
  const latestEntry = latestVersion ? metadata.versions?.[latestVersion] : null;
2270
2382
  const tarballUrl = latestEntry?.dist?.tarball;
2383
+ const tarballIntegrity = latestEntry?.dist?.integrity;
2271
2384
  if (!latestVersion || !tarballUrl) throw new Error('Registry response missing latest/tarball');
2272
2385
 
2273
2386
  send('log', { stream: 'meta', chunk: `Current: ${SERVER_VERSION} → Latest: ${latestVersion}\n` });
@@ -2283,16 +2396,30 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2283
2396
  return;
2284
2397
  }
2285
2398
 
2286
- // 2. Download the tarball stream and pipe it through tar's
2287
- // extractor into a staging directory. Doing the extract
2288
- // under `.staging` first means the live runtime stays
2289
- // intact if the download fails partway through.
2399
+ // 2. Download the tarball to a buffer so we can verify integrity
2400
+ // before extracting. Doing the extract under `.staging` first
2401
+ // means the live runtime stays intact if the download fails.
2290
2402
  send('log', { stream: 'meta', chunk: `Downloading ${tarballUrl}\n` });
2291
2403
  const tarballRes = await fetch(tarballUrl);
2292
2404
  if (!tarballRes.ok || !tarballRes.body) {
2293
2405
  throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
2294
2406
  }
2295
2407
 
2408
+ const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
2409
+
2410
+ // Verify integrity hash against registry-provided SRI string.
2411
+ if (tarballIntegrity) {
2412
+ try {
2413
+ verifyTarballIntegrity(tarballBuffer, tarballIntegrity);
2414
+ send('log', { stream: 'meta', chunk: 'Tarball integrity verified.\n' });
2415
+ } catch (verifyError) {
2416
+ send('log', { stream: 'stderr', chunk: `Integrity verification failed: ${verifyError.message}\n` });
2417
+ throw verifyError;
2418
+ }
2419
+ } else {
2420
+ send('log', { stream: 'meta', chunk: 'WARNING: No integrity hash from registry — extracting without verification.\n' });
2421
+ }
2422
+
2296
2423
  const stagingDir = path.join(runtimeDir, '.staging');
2297
2424
  const backupDir = path.join(runtimeDir, '.previous');
2298
2425
  fs.rmSync(stagingDir, { recursive: true, force: true });
@@ -2307,10 +2434,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2307
2434
  // contents to the staging dir directly so paths match the
2308
2435
  // existing runtime layout.
2309
2436
  await new Promise((resolve, reject) => {
2310
- const webStream = tarballRes.body;
2311
- const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
2312
- ? Readable.fromWeb(webStream)
2313
- : webStream;
2437
+ const nodeStream = Readable.from(tarballBuffer);
2314
2438
  const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
2315
2439
  nodeStream.pipe(extractor);
2316
2440
  extractor.on('finish', resolve);
@@ -2541,6 +2665,11 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2541
2665
  // (systemd/pm2/daemon manager) can bring the server back on the new code.
2542
2666
  // Foreground installs without a wrapper will simply stop; the UI reports this.
2543
2667
  app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
2668
+ securityLog('system_restart_requested', {
2669
+ ip: getClientIp(req),
2670
+ userId: req.user?.id,
2671
+ username: req.user?.username,
2672
+ });
2544
2673
  const forceRestart = req.body?.force === true || req.query.force === 'true';
2545
2674
  const activeWork = getActiveWorkSummary();
2546
2675
  if (!forceRestart && activeWork.hasActiveWork) {
@@ -2569,7 +2698,7 @@ app.get('/api/projects', authenticateToken, async (req, res) => {
2569
2698
  const projects = await getProjects(broadcastProgress);
2570
2699
  res.json(filterProjectsForUser(projects, req.user).map((project) => filterProjectSessionsForUser(project, req.user)));
2571
2700
  } catch (error) {
2572
- res.status(500).json({ error: error.message });
2701
+ res.status(500).json({ error: "Internal server error" });
2573
2702
  }
2574
2703
  });
2575
2704
 
@@ -2583,7 +2712,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, requireProject
2583
2712
  applyCustomSessionNames(result.sessions, 'claude');
2584
2713
  res.json(result);
2585
2714
  } catch (error) {
2586
- res.status(500).json({ error: error.message });
2715
+ res.status(500).json({ error: "Internal server error" });
2587
2716
  }
2588
2717
  });
2589
2718
 
@@ -2594,7 +2723,7 @@ app.put('/api/projects/:projectName/rename', authenticateToken, requireProjectAc
2594
2723
  await renameProject(req.params.projectName, displayName);
2595
2724
  res.json({ success: true });
2596
2725
  } catch (error) {
2597
- res.status(500).json({ error: error.message });
2726
+ res.status(500).json({ error: "Internal server error" });
2598
2727
  }
2599
2728
  });
2600
2729
 
@@ -2609,7 +2738,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
2609
2738
  res.json({ success: true });
2610
2739
  } catch (error) {
2611
2740
  console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
2612
- res.status(500).json({ error: error.message });
2741
+ res.status(500).json({ error: "Internal server error" });
2613
2742
  }
2614
2743
  });
2615
2744
 
@@ -2635,7 +2764,7 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
2635
2764
  res.json({ success: true });
2636
2765
  } catch (error) {
2637
2766
  console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
2638
- res.status(500).json({ error: error.message });
2767
+ res.status(500).json({ error: "Internal server error" });
2639
2768
  }
2640
2769
  });
2641
2770
 
@@ -2655,7 +2784,7 @@ app.delete('/api/projects/:projectName', authenticateToken, requireProjectAccess
2655
2784
  // catch it and prompt the user to pass `?force=true` (or clean
2656
2785
  // sessions first) instead of treating it like a crash.
2657
2786
  const conflict = typeof error?.message === 'string' && error.message.includes('existing sessions');
2658
- res.status(conflict ? 409 : 500).json({ error: error.message });
2787
+ res.status(conflict ? 409 : 500).json({ error: conflict ? error.message : 'Internal server error' });
2659
2788
  }
2660
2789
  });
2661
2790
 
@@ -2892,7 +3021,7 @@ app.get('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
2892
3021
  } else if (error.code === 'EACCES') {
2893
3022
  res.status(403).json({ error: 'Permission denied' });
2894
3023
  } else {
2895
- res.status(500).json({ error: error.message });
3024
+ res.status(500).json({ error: "Internal server error" });
2896
3025
  }
2897
3026
  }
2898
3027
  });
@@ -2952,7 +3081,7 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, requirePr
2952
3081
  } catch (error) {
2953
3082
  console.error('Error serving binary file:', error);
2954
3083
  if (!res.headersSent) {
2955
- res.status(500).json({ error: error.message });
3084
+ res.status(500).json({ error: "Internal server error" });
2956
3085
  }
2957
3086
  }
2958
3087
  });
@@ -3005,7 +3134,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
3005
3134
  } else if (error.code === 'EACCES') {
3006
3135
  res.status(403).json({ error: 'Permission denied' });
3007
3136
  } else {
3008
- res.status(500).json({ error: error.message });
3137
+ res.status(500).json({ error: "Internal server error" });
3009
3138
  }
3010
3139
  }
3011
3140
  });
@@ -3045,7 +3174,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, requireProjectAcc
3045
3174
  }, 'viewFiles'));
3046
3175
  } catch (error) {
3047
3176
  console.error('[ERROR] File tree error:', error.message);
3048
- res.status(500).json({ error: error.message });
3177
+ res.status(500).json({ error: "Internal server error" });
3049
3178
  }
3050
3179
  });
3051
3180
 
@@ -3171,7 +3300,7 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, requirePr
3171
3300
  } else if (error.code === 'ENOENT') {
3172
3301
  res.status(404).json({ error: 'Parent directory not found' });
3173
3302
  } else {
3174
- res.status(500).json({ error: error.message });
3303
+ res.status(500).json({ error: "Internal server error" });
3175
3304
  }
3176
3305
  }
3177
3306
  });
@@ -3254,7 +3383,7 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
3254
3383
  } else if (error.code === 'EXDEV') {
3255
3384
  res.status(400).json({ error: 'Cannot move across different filesystems' });
3256
3385
  } else {
3257
- res.status(500).json({ error: error.message });
3386
+ res.status(500).json({ error: "Internal server error" });
3258
3387
  }
3259
3388
  }
3260
3389
  });
@@ -3322,7 +3451,7 @@ app.delete('/api/projects/:projectName/files', authenticateToken, requireProject
3322
3451
  } else if (error.code === 'ENOTEMPTY') {
3323
3452
  res.status(400).json({ error: 'Directory is not empty' });
3324
3453
  } else {
3325
- res.status(500).json({ error: error.message });
3454
+ res.status(500).json({ error: "Internal server error" });
3326
3455
  }
3327
3456
  }
3328
3457
  });
@@ -3341,7 +3470,7 @@ const uploadFilesHandler = async (req, res) => {
3341
3470
  filename: (req, file, cb) => {
3342
3471
  // Use a unique temp name, but preserve original name in file.originalname
3343
3472
  // Note: file.originalname may contain path separators for folder uploads
3344
- const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
3473
+ const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
3345
3474
  // For temp file, just use a safe unique name without the path
3346
3475
  cb(null, `upload-${uniqueSuffix}`);
3347
3476
  }
@@ -3490,7 +3619,7 @@ const uploadFilesHandler = async (req, res) => {
3490
3619
  if (error.code === 'EACCES') {
3491
3620
  res.status(403).json({ error: 'Permission denied' });
3492
3621
  } else {
3493
- res.status(500).json({ error: error.message });
3622
+ res.status(500).json({ error: "Internal server error" });
3494
3623
  }
3495
3624
  }
3496
3625
  });
@@ -3831,10 +3960,15 @@ function handleChatConnection(ws, request) {
3831
3960
  });
3832
3961
  }
3833
3962
  } catch (error) {
3834
- console.error('[ERROR] Chat WebSocket error:', error.message);
3963
+ console.error('[ERROR] Chat WebSocket error:', error?.message || error);
3964
+ securityLog('ws_chat_error', {
3965
+ userId: request?.user?.id,
3966
+ username: request?.user?.username,
3967
+ reason: error?.name || 'UnknownError',
3968
+ });
3835
3969
  writer.send({
3836
3970
  type: 'error',
3837
- error: error.message
3971
+ error: 'An error occurred while processing your request.'
3838
3972
  });
3839
3973
  }
3840
3974
  });
@@ -3899,24 +4033,38 @@ function handleShellConnection(ws, request) {
3899
4033
  const chunks = splitTerminalInput(data);
3900
4034
  if (chunks.length === 0) return;
3901
4035
 
3902
- let droppedBytes = 0;
3903
4036
  for (const chunk of chunks) {
3904
- const chunkBytes = Buffer.byteLength(chunk, 'utf8');
4037
+ // Filter terminal-generated response sequences that xterm.js sends back
4038
+ // via onData. Without this, DA2/mouse/DSR responses get echoed by the PTY,
4039
+ // xterm.js interprets the echo as a new query, and an infinite loop starts.
4040
+ // This is the server-side guard complementing the frontend sanitizer.
4041
+ const filteredChunk = chunk
4042
+ .replace(/\x1b\[>\d+[;:\d]*c/g, '') // DA2 response (self-looping)
4043
+ .replace(/\x1b\[\?\d+[;:\d]*c/g, '') // DA1 response
4044
+ .replace(/\x1b\[\d+;\d+R/g, '') // DSR cursor
4045
+ .replace(/\x1b\[\?\d+;\d+R/g, '') // DSR private cursor
4046
+ .replace(/\x1b\[\d+n/g, '') // DSR status
4047
+ .replace(/\x1b\[M[\s\S]{3}/g, '') // Mouse report (default)
4048
+ .replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, '') // Mouse report (SGR)
4049
+ .replace(/\x1b\[IO]/g, '') // Focus events
4050
+ .replace(/\x1b\[\d+;\d+;\d+t/g, '') // Window size report
4051
+ .replace(/\x1b\[\d+;\d+\$y/g, ''); // DECRQM response
4052
+ if (!filteredChunk) continue;
4053
+
4054
+ const chunkBytes = Buffer.byteLength(filteredChunk, 'utf8');
3905
4055
  if (pendingInputBytes + chunkBytes > SHELL_PENDING_INPUT_MAX_BYTES) {
3906
- droppedBytes += chunkBytes;
3907
- continue;
4056
+ // Drop only the oldest queued chunk to make room, not the new one.
4057
+ // This prevents the queue from growing unbounded without silently
4058
+ // discarding the user's latest paste.
4059
+ while (pendingInputQueue.length > 0 && pendingInputBytes + chunkBytes > SHELL_PENDING_INPUT_MAX_BYTES) {
4060
+ const dropped = pendingInputQueue.shift();
4061
+ pendingInputBytes = Math.max(0, pendingInputBytes - Buffer.byteLength(dropped, 'utf8'));
4062
+ }
3908
4063
  }
3909
- pendingInputQueue.push(chunk);
4064
+ pendingInputQueue.push(filteredChunk);
3910
4065
  pendingInputBytes += chunkBytes;
3911
4066
  }
3912
4067
 
3913
- if (droppedBytes > 0 && ws.readyState === WebSocket.OPEN) {
3914
- ws.send(JSON.stringify({
3915
- type: 'output',
3916
- data: `\r\n\x1b[33m[Pixcode] ${droppedBytes} bytes of pasted terminal input were not sent because the input queue limit was reached. Split very large pastes into smaller chunks.\x1b[0m\r\n`,
3917
- }));
3918
- }
3919
-
3920
4068
  scheduleInputFlush();
3921
4069
  }
3922
4070
 
@@ -4299,11 +4447,16 @@ function handleShellConnection(ws, request) {
4299
4447
  const termCols = data.cols || 80;
4300
4448
  const termRows = data.rows || 24;
4301
4449
  console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
4450
+ const isRunningAsRoot = typeof process.getuid === 'function' && process.getuid() === 0;
4302
4451
  const shellEnv = {
4303
4452
  ...process.env,
4304
4453
  TERM: 'xterm-256color',
4305
4454
  COLORTERM: 'truecolor',
4306
4455
  FORCE_COLOR: '3',
4456
+ // When running as root, Claude CLI refuses --dangerously-skip-permissions
4457
+ // for security reasons. Setting IS_SANDBOX=1 tells it the environment is
4458
+ // already sandboxed, so the flag is safe to use.
4459
+ ...(isRunningAsRoot ? { IS_SANDBOX: '1' } : {}),
4307
4460
  };
4308
4461
 
4309
4462
  shellProcess = pty.spawn(shell, shellArgs, {
@@ -4421,7 +4574,7 @@ function handleShellConnection(ws, request) {
4421
4574
  console.error('[ERROR] Error spawning process:', spawnError);
4422
4575
  ws.send(JSON.stringify({
4423
4576
  type: 'output',
4424
- data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
4577
+ data: `\r\n\x1b[31mError: Failed to start terminal process.\x1b[0m\r\n`
4425
4578
  }));
4426
4579
  }
4427
4580
 
@@ -4436,11 +4589,11 @@ function handleShellConnection(ws, request) {
4436
4589
  }
4437
4590
  }
4438
4591
  } catch (error) {
4439
- console.error('[ERROR] Shell WebSocket error:', error.message);
4592
+ console.error('[ERROR] Shell WebSocket error:', error?.message || error);
4440
4593
  if (ws.readyState === WebSocket.OPEN) {
4441
4594
  ws.send(JSON.stringify({
4442
4595
  type: 'output',
4443
- data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
4596
+ data: `\r\n\x1b[31mError: An internal error occurred.\x1b[0m\r\n`
4444
4597
  }));
4445
4598
  }
4446
4599
  }
@@ -4503,7 +4656,7 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, requireP
4503
4656
  cb(null, uploadDir);
4504
4657
  },
4505
4658
  filename: (req, file, cb) => {
4506
- const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
4659
+ const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
4507
4660
  const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
4508
4661
  cb(null, uniqueSuffix + '-' + sanitizedName);
4509
4662
  }
@@ -4868,8 +5021,16 @@ app.get(/.*/, (req, res) => {
4868
5021
  });
4869
5022
 
4870
5023
  // global error middleware must be last
4871
- app.use((err, req, res, next) => {
5024
+ app.use((err, req, res, _next) => {
4872
5025
  if (err instanceof AppError) {
5026
+ securityLog('app_error', {
5027
+ ip: getClientIp(req),
5028
+ endpoint: req.path,
5029
+ method: req.method,
5030
+ statusCode: err.statusCode,
5031
+ userId: req.user?.id,
5032
+ reason: err.code,
5033
+ });
4873
5034
  return res.status(err.statusCode).json({
4874
5035
  success: false,
4875
5036
  error: {
@@ -4880,7 +5041,37 @@ app.use((err, req, res, next) => {
4880
5041
  });
4881
5042
  }
4882
5043
 
4883
- console.error(err);
5044
+ // Log the error internally but never expose stack traces, file paths,
5045
+ // or internal IPs to the client.
5046
+ if (err?.type === 'entity.too.large') {
5047
+ return res.status(413).json({
5048
+ success: false,
5049
+ error: {
5050
+ code: 'PAYLOAD_TOO_LARGE',
5051
+ message: 'Request payload exceeds size limit.',
5052
+ },
5053
+ });
5054
+ }
5055
+
5056
+ if (err?.type === 'entity.parse.failed' || err?.type === 'entity.parse.failed.utf8') {
5057
+ return res.status(400).json({
5058
+ success: false,
5059
+ error: {
5060
+ code: 'INVALID_JSON',
5061
+ message: 'Request body contains invalid JSON.',
5062
+ },
5063
+ });
5064
+ }
5065
+
5066
+ console.error('[UNHANDLED ERROR]', err?.message || err);
5067
+ securityLog('unhandled_error', {
5068
+ ip: getClientIp(req),
5069
+ endpoint: req.path,
5070
+ method: req.method,
5071
+ statusCode: 500,
5072
+ userId: req.user?.id,
5073
+ reason: err?.name || 'UnknownError',
5074
+ });
4884
5075
 
4885
5076
  return res.status(500).json({
4886
5077
  success: false,
@@ -5214,6 +5405,26 @@ async function maybeAutoDaemonBootstrapFromIndex() {
5214
5405
  }
5215
5406
  }
5216
5407
 
5408
+ // Process-level error handlers to prevent silent crashes and log security events.
5409
+ // These catch errors that escape Express's own error middleware (e.g. from
5410
+ // timers, WebSocket handlers, or un-awaited promises in route handlers).
5411
+ process.on('unhandledRejection', (reason, promise) => {
5412
+ console.error('[FATAL] Unhandled promise rejection:', reason);
5413
+ securityLog('unhandled_rejection', {
5414
+ reason: reason instanceof Error ? reason.name : String(reason).slice(0, 200),
5415
+ });
5416
+ });
5417
+
5418
+ process.on('uncaughtException', (error) => {
5419
+ console.error('[FATAL] Uncaught exception:', error?.message || error);
5420
+ securityLog('uncaught_exception', {
5421
+ reason: error?.name || 'UnknownError',
5422
+ });
5423
+ // Give the security log time to flush, then exit. A clean exit lets the
5424
+ // daemon manager (systemd/pm2) restart the server automatically.
5425
+ setTimeout(() => process.exit(1), 100);
5426
+ });
5427
+
5217
5428
  // Initialize database and start server
5218
5429
  async function startServer() {
5219
5430
  try {
@@ -5293,6 +5504,27 @@ async function startServer() {
5293
5504
  console.warn('[external-access] tunnel restore failed:', err?.message || err);
5294
5505
  });
5295
5506
 
5507
+ // Auto-open browser unless explicitly disabled
5508
+ if (process.env.PIXCODE_NO_BROWSER !== '1') {
5509
+ const openUrl = `http://${DISPLAY_HOST}:${SERVER_PORT}`;
5510
+ try {
5511
+ // Use spawn with an argument array instead of exec with a
5512
+ // shell string to prevent command injection through the
5513
+ // host/port values (which come from env vars).
5514
+ const { spawn: spawnBrowser } = await import('node:child_process');
5515
+ const browserBin = process.platform === 'darwin' ? 'open'
5516
+ : process.platform === 'win32' ? 'cmd'
5517
+ : 'xdg-open';
5518
+ const browserArgs = process.platform === 'win32'
5519
+ ? ['/c', 'start', '', openUrl]
5520
+ : [openUrl];
5521
+ spawnBrowser(browserBin, browserArgs, { stdio: 'ignore', timeout: 3000, detached: true, shell: false }).unref();
5522
+ console.log(`${c.ok('[OK]')} Opening browser at ${c.bright(openUrl)}`);
5523
+ } catch {
5524
+ console.log(`${c.tip('[TIP]')} Open ${c.bright(openUrl)} in your browser to start using Pixcode.`);
5525
+ }
5526
+ }
5527
+
5296
5528
  console.log(`${c.tip('[TIP]')} Run "pixcode status" for full configuration details`);
5297
5529
  console.log('');
5298
5530