@pixelbyte-software/pixcode 1.54.0 → 1.54.2

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-DvudRU5X.css +32 -0
  2. package/dist/assets/{index-CEw93JQ7.js → index-lqiCTrCl.js} +205 -205
  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 +224 -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 +232 -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,49 @@ 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
+ // CORS: self-hosted tool accessed from various IPs/hostnames.
1559
+ // Reflect the requesting origin so IP-based access (e.g. http://85.235.74.198:3001)
1560
+ // works without configuration. Credentials needed for auth header passthrough.
1561
+ app.use(cors({
1562
+ origin: true,
1563
+ credentials: true,
1564
+ exposedHeaders: ['X-Refreshed-Token'],
1565
+ }));
1566
+ // Security headers middleware (replaces helmet which isn't installed).
1567
+ app.use((req, res, next) => {
1568
+ res.setHeader('X-Content-Type-Options', 'nosniff');
1569
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
1570
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
1571
+ res.setHeader('X-XSS-Protection', '1; mode=block');
1572
+ res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
1573
+ // Strict-Transport-Security only makes sense over HTTPS; skip for plain HTTP
1574
+ // so local dev doesn't pin an HSTS policy on localhost.
1575
+ if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
1576
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
1577
+ }
1578
+ // CSP for the SPA shell — relaxed for self-hosted tool accessed from
1579
+ // various IPs/hostnames. 'unsafe-inline' + 'unsafe-eval' needed for Vite.
1580
+ // ws:/wss: in connect-src allows WebSocket from any origin (IP access).
1581
+ res.setHeader('Content-Security-Policy', [
1582
+ "default-src 'self'",
1583
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
1584
+ "style-src 'self' 'unsafe-inline'",
1585
+ "img-src 'self' data: blob: https:",
1586
+ "font-src 'self' data:",
1587
+ "connect-src 'self' ws: wss: http: https:",
1588
+ "frame-ancestors 'self'",
1589
+ "base-uri 'self'",
1590
+ "form-action 'self'",
1591
+ ].join('; '));
1592
+ next();
1593
+ });
1510
1594
  app.use(express.json({
1511
1595
  limit: '50mb',
1512
1596
  type: (req) => {
@@ -1531,8 +1615,9 @@ app.get('/health', (req, res) => {
1531
1615
  lastAppliedUpdate: updateState.lastAppliedUpdate,
1532
1616
  });
1533
1617
  });
1534
- // Optional API key validation (if configured)
1618
+ // Optional API key validation (if configured) + rate limiting for all API routes
1535
1619
  app.use('/api', validateApiKey);
1620
+ app.use('/api', apiRateLimiter);
1536
1621
  app.post('/api/shell/sessions/terminate', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
1537
1622
  const provider = req.body?.provider || 'claude';
1538
1623
  const projectPath = req.body?.projectPath || os.homedir();
@@ -1914,6 +1999,12 @@ app.get('/api/system/update-state', authenticateToken, (req, res) => {
1914
1999
  });
1915
2000
  });
1916
2001
  app.post('/api/system/update-jobs', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
2002
+ securityLog('system_update_initiated', {
2003
+ ip: getClientIp(req),
2004
+ userId: req.user?.id,
2005
+ username: req.user?.username,
2006
+ endpoint: req.path,
2007
+ });
1917
2008
  const job = createSystemUpdateJob(req.user, {
1918
2009
  targetVersion: req.body?.targetVersion || req.body?.latestVersion,
1919
2010
  });
@@ -2025,6 +2116,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2025
2116
  const latestVersion = metadata['dist-tags']?.latest;
2026
2117
  const latestEntry = latestVersion ? metadata.versions?.[latestVersion] : null;
2027
2118
  const tarballUrl = latestEntry?.dist?.tarball;
2119
+ const tarballIntegrity = latestEntry?.dist?.integrity;
2028
2120
  if (!latestVersion || !tarballUrl)
2029
2121
  throw new Error('Registry response missing latest/tarball');
2030
2122
  send('log', { stream: 'meta', chunk: `Current: ${SERVER_VERSION} → Latest: ${latestVersion}\n` });
@@ -2038,15 +2130,29 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2038
2130
  endStream();
2039
2131
  return;
2040
2132
  }
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.
2133
+ // 2. Download the tarball to a buffer so we can verify integrity
2134
+ // before extracting. Doing the extract under `.staging` first
2135
+ // means the live runtime stays intact if the download fails.
2045
2136
  send('log', { stream: 'meta', chunk: `Downloading ${tarballUrl}\n` });
2046
2137
  const tarballRes = await fetch(tarballUrl);
2047
2138
  if (!tarballRes.ok || !tarballRes.body) {
2048
2139
  throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
2049
2140
  }
2141
+ const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
2142
+ // Verify integrity hash against registry-provided SRI string.
2143
+ if (tarballIntegrity) {
2144
+ try {
2145
+ verifyTarballIntegrity(tarballBuffer, tarballIntegrity);
2146
+ send('log', { stream: 'meta', chunk: 'Tarball integrity verified.\n' });
2147
+ }
2148
+ catch (verifyError) {
2149
+ send('log', { stream: 'stderr', chunk: `Integrity verification failed: ${verifyError.message}\n` });
2150
+ throw verifyError;
2151
+ }
2152
+ }
2153
+ else {
2154
+ send('log', { stream: 'meta', chunk: 'WARNING: No integrity hash from registry — extracting without verification.\n' });
2155
+ }
2050
2156
  const stagingDir = path.join(runtimeDir, '.staging');
2051
2157
  const backupDir = path.join(runtimeDir, '.previous');
2052
2158
  fs.rmSync(stagingDir, { recursive: true, force: true });
@@ -2060,10 +2166,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2060
2166
  // contents to the staging dir directly so paths match the
2061
2167
  // existing runtime layout.
2062
2168
  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;
2169
+ const nodeStream = Readable.from(tarballBuffer);
2067
2170
  const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
2068
2171
  nodeStream.pipe(extractor);
2069
2172
  extractor.on('finish', resolve);
@@ -2292,6 +2395,11 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2292
2395
  // (systemd/pm2/daemon manager) can bring the server back on the new code.
2293
2396
  // Foreground installs without a wrapper will simply stop; the UI reports this.
2294
2397
  app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
2398
+ securityLog('system_restart_requested', {
2399
+ ip: getClientIp(req),
2400
+ userId: req.user?.id,
2401
+ username: req.user?.username,
2402
+ });
2295
2403
  const forceRestart = req.body?.force === true || req.query.force === 'true';
2296
2404
  const activeWork = getActiveWorkSummary();
2297
2405
  if (!forceRestart && activeWork.hasActiveWork) {
@@ -2318,7 +2426,7 @@ app.get('/api/projects', authenticateToken, async (req, res) => {
2318
2426
  res.json(filterProjectsForUser(projects, req.user).map((project) => filterProjectSessionsForUser(project, req.user)));
2319
2427
  }
2320
2428
  catch (error) {
2321
- res.status(500).json({ error: error.message });
2429
+ res.status(500).json({ error: "Internal server error" });
2322
2430
  }
2323
2431
  });
2324
2432
  app.get('/api/projects/:projectName/sessions', authenticateToken, requireProjectAccess('viewFiles'), async (req, res) => {
@@ -2332,7 +2440,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, requireProject
2332
2440
  res.json(result);
2333
2441
  }
2334
2442
  catch (error) {
2335
- res.status(500).json({ error: error.message });
2443
+ res.status(500).json({ error: "Internal server error" });
2336
2444
  }
2337
2445
  });
2338
2446
  // Rename project endpoint
@@ -2343,7 +2451,7 @@ app.put('/api/projects/:projectName/rename', authenticateToken, requireProjectAc
2343
2451
  res.json({ success: true });
2344
2452
  }
2345
2453
  catch (error) {
2346
- res.status(500).json({ error: error.message });
2454
+ res.status(500).json({ error: "Internal server error" });
2347
2455
  }
2348
2456
  });
2349
2457
  // Delete session endpoint
@@ -2358,7 +2466,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
2358
2466
  }
2359
2467
  catch (error) {
2360
2468
  console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
2361
- res.status(500).json({ error: error.message });
2469
+ res.status(500).json({ error: "Internal server error" });
2362
2470
  }
2363
2471
  });
2364
2472
  // Rename session endpoint
@@ -2384,7 +2492,7 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
2384
2492
  }
2385
2493
  catch (error) {
2386
2494
  console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
2387
- res.status(500).json({ error: error.message });
2495
+ res.status(500).json({ error: "Internal server error" });
2388
2496
  }
2389
2497
  });
2390
2498
  // Delete project endpoint
@@ -2404,7 +2512,7 @@ app.delete('/api/projects/:projectName', authenticateToken, requireProjectAccess
2404
2512
  // catch it and prompt the user to pass `?force=true` (or clean
2405
2513
  // sessions first) instead of treating it like a crash.
2406
2514
  const conflict = typeof error?.message === 'string' && error.message.includes('existing sessions');
2407
- res.status(conflict ? 409 : 500).json({ error: error.message });
2515
+ res.status(conflict ? 409 : 500).json({ error: conflict ? error.message : 'Internal server error' });
2408
2516
  }
2409
2517
  });
2410
2518
  // Search conversations content (SSE streaming)
@@ -2636,7 +2744,7 @@ app.get('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
2636
2744
  res.status(403).json({ error: 'Permission denied' });
2637
2745
  }
2638
2746
  else {
2639
- res.status(500).json({ error: error.message });
2747
+ res.status(500).json({ error: "Internal server error" });
2640
2748
  }
2641
2749
  }
2642
2750
  });
@@ -2688,7 +2796,7 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, requirePr
2688
2796
  catch (error) {
2689
2797
  console.error('Error serving binary file:', error);
2690
2798
  if (!res.headersSent) {
2691
- res.status(500).json({ error: error.message });
2799
+ res.status(500).json({ error: "Internal server error" });
2692
2800
  }
2693
2801
  }
2694
2802
  });
@@ -2736,7 +2844,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
2736
2844
  res.status(403).json({ error: 'Permission denied' });
2737
2845
  }
2738
2846
  else {
2739
- res.status(500).json({ error: error.message });
2847
+ res.status(500).json({ error: "Internal server error" });
2740
2848
  }
2741
2849
  }
2742
2850
  });
@@ -2774,7 +2882,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, requireProjectAcc
2774
2882
  }
2775
2883
  catch (error) {
2776
2884
  console.error('[ERROR] File tree error:', error.message);
2777
- res.status(500).json({ error: error.message });
2885
+ res.status(500).json({ error: "Internal server error" });
2778
2886
  }
2779
2887
  });
2780
2888
  // ============================================================================
@@ -2893,7 +3001,7 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, requirePr
2893
3001
  res.status(404).json({ error: 'Parent directory not found' });
2894
3002
  }
2895
3003
  else {
2896
- res.status(500).json({ error: error.message });
3004
+ res.status(500).json({ error: "Internal server error" });
2897
3005
  }
2898
3006
  }
2899
3007
  });
@@ -2971,7 +3079,7 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
2971
3079
  res.status(400).json({ error: 'Cannot move across different filesystems' });
2972
3080
  }
2973
3081
  else {
2974
- res.status(500).json({ error: error.message });
3082
+ res.status(500).json({ error: "Internal server error" });
2975
3083
  }
2976
3084
  }
2977
3085
  });
@@ -3036,7 +3144,7 @@ app.delete('/api/projects/:projectName/files', authenticateToken, requireProject
3036
3144
  res.status(400).json({ error: 'Directory is not empty' });
3037
3145
  }
3038
3146
  else {
3039
- res.status(500).json({ error: error.message });
3147
+ res.status(500).json({ error: "Internal server error" });
3040
3148
  }
3041
3149
  }
3042
3150
  });
@@ -3053,7 +3161,7 @@ const uploadFilesHandler = async (req, res) => {
3053
3161
  filename: (req, file, cb) => {
3054
3162
  // Use a unique temp name, but preserve original name in file.originalname
3055
3163
  // Note: file.originalname may contain path separators for folder uploads
3056
- const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
3164
+ const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
3057
3165
  // For temp file, just use a safe unique name without the path
3058
3166
  cb(null, `upload-${uniqueSuffix}`);
3059
3167
  }
@@ -3191,7 +3299,7 @@ const uploadFilesHandler = async (req, res) => {
3191
3299
  res.status(403).json({ error: 'Permission denied' });
3192
3300
  }
3193
3301
  else {
3194
- res.status(500).json({ error: error.message });
3302
+ res.status(500).json({ error: "Internal server error" });
3195
3303
  }
3196
3304
  }
3197
3305
  });
@@ -3537,10 +3645,15 @@ function handleChatConnection(ws, request) {
3537
3645
  }
3538
3646
  }
3539
3647
  catch (error) {
3540
- console.error('[ERROR] Chat WebSocket error:', error.message);
3648
+ console.error('[ERROR] Chat WebSocket error:', error?.message || error);
3649
+ securityLog('ws_chat_error', {
3650
+ userId: request?.user?.id,
3651
+ username: request?.user?.username,
3652
+ reason: error?.name || 'UnknownError',
3653
+ });
3541
3654
  writer.send({
3542
3655
  type: 'error',
3543
- error: error.message
3656
+ error: 'An error occurred while processing your request.'
3544
3657
  });
3545
3658
  }
3546
3659
  });
@@ -3991,11 +4104,16 @@ function handleShellConnection(ws, request) {
3991
4104
  const termCols = data.cols || 80;
3992
4105
  const termRows = data.rows || 24;
3993
4106
  console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
4107
+ const isRunningAsRoot = typeof process.getuid === 'function' && process.getuid() === 0;
3994
4108
  const shellEnv = {
3995
4109
  ...process.env,
3996
4110
  TERM: 'xterm-256color',
3997
4111
  COLORTERM: 'truecolor',
3998
4112
  FORCE_COLOR: '3',
4113
+ // When running as root, Claude CLI refuses --dangerously-skip-permissions
4114
+ // for security reasons. Setting IS_SANDBOX=1 tells it the environment is
4115
+ // already sandboxed, so the flag is safe to use.
4116
+ ...(isRunningAsRoot ? { IS_SANDBOX: '1' } : {}),
3999
4117
  };
4000
4118
  shellProcess = pty.spawn(shell, shellArgs, {
4001
4119
  name: 'xterm-256color',
@@ -4108,7 +4226,7 @@ function handleShellConnection(ws, request) {
4108
4226
  console.error('[ERROR] Error spawning process:', spawnError);
4109
4227
  ws.send(JSON.stringify({
4110
4228
  type: 'output',
4111
- data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
4229
+ data: `\r\n\x1b[31mError: Failed to start terminal process.\x1b[0m\r\n`
4112
4230
  }));
4113
4231
  }
4114
4232
  }
@@ -4125,11 +4243,11 @@ function handleShellConnection(ws, request) {
4125
4243
  }
4126
4244
  }
4127
4245
  catch (error) {
4128
- console.error('[ERROR] Shell WebSocket error:', error.message);
4246
+ console.error('[ERROR] Shell WebSocket error:', error?.message || error);
4129
4247
  if (ws.readyState === WebSocket.OPEN) {
4130
4248
  ws.send(JSON.stringify({
4131
4249
  type: 'output',
4132
- data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
4250
+ data: `\r\n\x1b[31mError: An internal error occurred.\x1b[0m\r\n`
4133
4251
  }));
4134
4252
  }
4135
4253
  }
@@ -4185,7 +4303,7 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, requireP
4185
4303
  cb(null, uploadDir);
4186
4304
  },
4187
4305
  filename: (req, file, cb) => {
4188
- const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
4306
+ const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
4189
4307
  const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
4190
4308
  cb(null, uniqueSuffix + '-' + sanitizedName);
4191
4309
  }
@@ -4517,8 +4635,16 @@ app.get(/.*/, (req, res) => {
4517
4635
  }
4518
4636
  });
4519
4637
  // global error middleware must be last
4520
- app.use((err, req, res, next) => {
4638
+ app.use((err, req, res, _next) => {
4521
4639
  if (err instanceof AppError) {
4640
+ securityLog('app_error', {
4641
+ ip: getClientIp(req),
4642
+ endpoint: req.path,
4643
+ method: req.method,
4644
+ statusCode: err.statusCode,
4645
+ userId: req.user?.id,
4646
+ reason: err.code,
4647
+ });
4522
4648
  return res.status(err.statusCode).json({
4523
4649
  success: false,
4524
4650
  error: {
@@ -4528,7 +4654,35 @@ app.use((err, req, res, next) => {
4528
4654
  },
4529
4655
  });
4530
4656
  }
4531
- console.error(err);
4657
+ // Log the error internally but never expose stack traces, file paths,
4658
+ // or internal IPs to the client.
4659
+ if (err?.type === 'entity.too.large') {
4660
+ return res.status(413).json({
4661
+ success: false,
4662
+ error: {
4663
+ code: 'PAYLOAD_TOO_LARGE',
4664
+ message: 'Request payload exceeds size limit.',
4665
+ },
4666
+ });
4667
+ }
4668
+ if (err?.type === 'entity.parse.failed' || err?.type === 'entity.parse.failed.utf8') {
4669
+ return res.status(400).json({
4670
+ success: false,
4671
+ error: {
4672
+ code: 'INVALID_JSON',
4673
+ message: 'Request body contains invalid JSON.',
4674
+ },
4675
+ });
4676
+ }
4677
+ console.error('[UNHANDLED ERROR]', err?.message || err);
4678
+ securityLog('unhandled_error', {
4679
+ ip: getClientIp(req),
4680
+ endpoint: req.path,
4681
+ method: req.method,
4682
+ statusCode: 500,
4683
+ userId: req.user?.id,
4684
+ reason: err?.name || 'UnknownError',
4685
+ });
4532
4686
  return res.status(500).json({
4533
4687
  success: false,
4534
4688
  error: {
@@ -4801,6 +4955,24 @@ async function maybeAutoDaemonBootstrapFromIndex() {
4801
4955
  }
4802
4956
  }
4803
4957
  }
4958
+ // Process-level error handlers to prevent silent crashes and log security events.
4959
+ // These catch errors that escape Express's own error middleware (e.g. from
4960
+ // timers, WebSocket handlers, or un-awaited promises in route handlers).
4961
+ process.on('unhandledRejection', (reason, promise) => {
4962
+ console.error('[FATAL] Unhandled promise rejection:', reason);
4963
+ securityLog('unhandled_rejection', {
4964
+ reason: reason instanceof Error ? reason.name : String(reason).slice(0, 200),
4965
+ });
4966
+ });
4967
+ process.on('uncaughtException', (error) => {
4968
+ console.error('[FATAL] Uncaught exception:', error?.message || error);
4969
+ securityLog('uncaught_exception', {
4970
+ reason: error?.name || 'UnknownError',
4971
+ });
4972
+ // Give the security log time to flush, then exit. A clean exit lets the
4973
+ // daemon manager (systemd/pm2) restart the server automatically.
4974
+ setTimeout(() => process.exit(1), 100);
4975
+ });
4804
4976
  // Initialize database and start server
4805
4977
  async function startServer() {
4806
4978
  try {
@@ -4873,11 +5045,17 @@ async function startServer() {
4873
5045
  if (process.env.PIXCODE_NO_BROWSER !== '1') {
4874
5046
  const openUrl = `http://${DISPLAY_HOST}:${SERVER_PORT}`;
4875
5047
  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 });
5048
+ // Use spawn with an argument array instead of exec with a
5049
+ // shell string to prevent command injection through the
5050
+ // host/port values (which come from env vars).
5051
+ const { spawn: spawnBrowser } = await import('node:child_process');
5052
+ const browserBin = process.platform === 'darwin' ? 'open'
5053
+ : process.platform === 'win32' ? 'cmd'
5054
+ : 'xdg-open';
5055
+ const browserArgs = process.platform === 'win32'
5056
+ ? ['/c', 'start', '', openUrl]
5057
+ : [openUrl];
5058
+ spawnBrowser(browserBin, browserArgs, { stdio: 'ignore', timeout: 3000, detached: true, shell: false }).unref();
4881
5059
  console.log(`${c.ok('[OK]')} Opening browser at ${c.bright(openUrl)}`);
4882
5060
  }
4883
5061
  catch {