@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
@@ -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
@@ -1011,8 +1053,8 @@ const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
1011
1053
  const SHELL_OUTPUT_FLUSH_MAX_CHARS = 128 * 1024;
1012
1054
  const SHELL_WS_BACKPRESSURE_LIMIT = 8 * 1024 * 1024;
1013
1055
  const PTY_SESSION_BUFFER_MAX_BYTES = Number.parseInt(process.env.PIXCODE_PTY_BUFFER_MAX_BYTES || '', 10) || (512 * 1024);
1014
- const SHELL_INPUT_CHUNK_CHARS = 4096;
1015
- const SHELL_PENDING_INPUT_MAX_BYTES = Number.parseInt(process.env.PIXCODE_SHELL_PENDING_INPUT_MAX_BYTES || '', 10) || (16 * 1024 * 1024);
1056
+ const SHELL_INPUT_CHUNK_CHARS = 16384;
1057
+ const SHELL_PENDING_INPUT_MAX_BYTES = Number.parseInt(process.env.PIXCODE_SHELL_PENDING_INPUT_MAX_BYTES || '', 10) || (256 * 1024 * 1024);
1016
1058
  const SHELL_CLI_PROVIDERS = new Set(['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode']);
1017
1059
  const FILE_TREE_MAX_ITEMS = Number.parseInt(process.env.PIXCODE_FILE_TREE_MAX_ITEMS || '', 10) || 5000;
1018
1060
  const FILE_TREE_MAX_DIRECTORIES = Number.parseInt(process.env.PIXCODE_FILE_TREE_MAX_DIRECTORIES || '', 10) || 1200;
@@ -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
  });
@@ -3600,22 +3729,37 @@ function handleShellConnection(ws, request) {
3600
3729
  const chunks = splitTerminalInput(data);
3601
3730
  if (chunks.length === 0)
3602
3731
  return;
3603
- let droppedBytes = 0;
3604
3732
  for (const chunk of chunks) {
3605
- const chunkBytes = Buffer.byteLength(chunk, 'utf8');
3606
- if (pendingInputBytes + chunkBytes > SHELL_PENDING_INPUT_MAX_BYTES) {
3607
- droppedBytes += chunkBytes;
3733
+ // Filter terminal-generated response sequences that xterm.js sends back
3734
+ // via onData. Without this, DA2/mouse/DSR responses get echoed by the PTY,
3735
+ // xterm.js interprets the echo as a new query, and an infinite loop starts.
3736
+ // This is the server-side guard complementing the frontend sanitizer.
3737
+ const filteredChunk = chunk
3738
+ .replace(/\x1b\[>\d+[;:\d]*c/g, '') // DA2 response (self-looping)
3739
+ .replace(/\x1b\[\?\d+[;:\d]*c/g, '') // DA1 response
3740
+ .replace(/\x1b\[\d+;\d+R/g, '') // DSR cursor
3741
+ .replace(/\x1b\[\?\d+;\d+R/g, '') // DSR private cursor
3742
+ .replace(/\x1b\[\d+n/g, '') // DSR status
3743
+ .replace(/\x1b\[M[\s\S]{3}/g, '') // Mouse report (default)
3744
+ .replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, '') // Mouse report (SGR)
3745
+ .replace(/\x1b\[IO]/g, '') // Focus events
3746
+ .replace(/\x1b\[\d+;\d+;\d+t/g, '') // Window size report
3747
+ .replace(/\x1b\[\d+;\d+\$y/g, ''); // DECRQM response
3748
+ if (!filteredChunk)
3608
3749
  continue;
3750
+ const chunkBytes = Buffer.byteLength(filteredChunk, 'utf8');
3751
+ if (pendingInputBytes + chunkBytes > SHELL_PENDING_INPUT_MAX_BYTES) {
3752
+ // Drop only the oldest queued chunk to make room, not the new one.
3753
+ // This prevents the queue from growing unbounded without silently
3754
+ // discarding the user's latest paste.
3755
+ while (pendingInputQueue.length > 0 && pendingInputBytes + chunkBytes > SHELL_PENDING_INPUT_MAX_BYTES) {
3756
+ const dropped = pendingInputQueue.shift();
3757
+ pendingInputBytes = Math.max(0, pendingInputBytes - Buffer.byteLength(dropped, 'utf8'));
3758
+ }
3609
3759
  }
3610
- pendingInputQueue.push(chunk);
3760
+ pendingInputQueue.push(filteredChunk);
3611
3761
  pendingInputBytes += chunkBytes;
3612
3762
  }
3613
- if (droppedBytes > 0 && ws.readyState === WebSocket.OPEN) {
3614
- ws.send(JSON.stringify({
3615
- type: 'output',
3616
- 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`,
3617
- }));
3618
- }
3619
3763
  scheduleInputFlush();
3620
3764
  }
3621
3765
  function flushOutputBuffer() {
@@ -3976,11 +4120,16 @@ function handleShellConnection(ws, request) {
3976
4120
  const termCols = data.cols || 80;
3977
4121
  const termRows = data.rows || 24;
3978
4122
  console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
4123
+ const isRunningAsRoot = typeof process.getuid === 'function' && process.getuid() === 0;
3979
4124
  const shellEnv = {
3980
4125
  ...process.env,
3981
4126
  TERM: 'xterm-256color',
3982
4127
  COLORTERM: 'truecolor',
3983
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' } : {}),
3984
4133
  };
3985
4134
  shellProcess = pty.spawn(shell, shellArgs, {
3986
4135
  name: 'xterm-256color',
@@ -4093,7 +4242,7 @@ function handleShellConnection(ws, request) {
4093
4242
  console.error('[ERROR] Error spawning process:', spawnError);
4094
4243
  ws.send(JSON.stringify({
4095
4244
  type: 'output',
4096
- 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`
4097
4246
  }));
4098
4247
  }
4099
4248
  }
@@ -4110,11 +4259,11 @@ function handleShellConnection(ws, request) {
4110
4259
  }
4111
4260
  }
4112
4261
  catch (error) {
4113
- console.error('[ERROR] Shell WebSocket error:', error.message);
4262
+ console.error('[ERROR] Shell WebSocket error:', error?.message || error);
4114
4263
  if (ws.readyState === WebSocket.OPEN) {
4115
4264
  ws.send(JSON.stringify({
4116
4265
  type: 'output',
4117
- 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`
4118
4267
  }));
4119
4268
  }
4120
4269
  }
@@ -4170,7 +4319,7 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, requireP
4170
4319
  cb(null, uploadDir);
4171
4320
  },
4172
4321
  filename: (req, file, cb) => {
4173
- const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
4322
+ const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
4174
4323
  const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
4175
4324
  cb(null, uniqueSuffix + '-' + sanitizedName);
4176
4325
  }
@@ -4502,8 +4651,16 @@ app.get(/.*/, (req, res) => {
4502
4651
  }
4503
4652
  });
4504
4653
  // global error middleware must be last
4505
- app.use((err, req, res, next) => {
4654
+ app.use((err, req, res, _next) => {
4506
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
+ });
4507
4664
  return res.status(err.statusCode).json({
4508
4665
  success: false,
4509
4666
  error: {
@@ -4513,7 +4670,35 @@ app.use((err, req, res, next) => {
4513
4670
  },
4514
4671
  });
4515
4672
  }
4516
- 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
+ });
4517
4702
  return res.status(500).json({
4518
4703
  success: false,
4519
4704
  error: {
@@ -4786,6 +4971,24 @@ async function maybeAutoDaemonBootstrapFromIndex() {
4786
4971
  }
4787
4972
  }
4788
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
+ });
4789
4992
  // Initialize database and start server
4790
4993
  async function startServer() {
4791
4994
  try {
@@ -4854,6 +5057,27 @@ async function startServer() {
4854
5057
  restoreRequestedTunnel({ port: Number(SERVER_PORT) }).catch((err) => {
4855
5058
  console.warn('[external-access] tunnel restore failed:', err?.message || err);
4856
5059
  });
5060
+ // Auto-open browser unless explicitly disabled
5061
+ if (process.env.PIXCODE_NO_BROWSER !== '1') {
5062
+ const openUrl = `http://${DISPLAY_HOST}:${SERVER_PORT}`;
5063
+ try {
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();
5075
+ console.log(`${c.ok('[OK]')} Opening browser at ${c.bright(openUrl)}`);
5076
+ }
5077
+ catch {
5078
+ console.log(`${c.tip('[TIP]')} Open ${c.bright(openUrl)} in your browser to start using Pixcode.`);
5079
+ }
5080
+ }
4857
5081
  console.log(`${c.tip('[TIP]')} Run "pixcode status" for full configuration details`);
4858
5082
  console.log('');
4859
5083
  // Start watching the projects folder for changes