@pixelbyte-software/pixcode 1.51.3 → 1.51.5

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 (51) hide show
  1. package/dist/assets/{index-17CwxHSZ.js → index-CpbSjzK7.js} +179 -182
  2. package/dist/assets/index-oDq76nSq.css +32 -0
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/cli.js +1 -1
  5. package/dist-server/server/cli.js.map +1 -1
  6. package/dist-server/server/database/db.js +14 -2
  7. package/dist-server/server/database/db.js.map +1 -1
  8. package/dist-server/server/index.js +592 -55
  9. package/dist-server/server/index.js.map +1 -1
  10. package/dist-server/server/middleware/auth.js +35 -7
  11. package/dist-server/server/middleware/auth.js.map +1 -1
  12. package/dist-server/server/modules/providers/provider.routes.js +33 -17
  13. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  14. package/dist-server/server/routes/auth.js +12 -5
  15. package/dist-server/server/routes/auth.js.map +1 -1
  16. package/dist-server/server/routes/commands.js +20 -9
  17. package/dist-server/server/routes/commands.js.map +1 -1
  18. package/dist-server/server/routes/git.js +12 -0
  19. package/dist-server/server/routes/git.js.map +1 -1
  20. package/dist-server/server/routes/live-view.js +30 -7
  21. package/dist-server/server/routes/live-view.js.map +1 -1
  22. package/dist-server/server/routes/messages.js +30 -0
  23. package/dist-server/server/routes/messages.js.map +1 -1
  24. package/dist-server/server/routes/network.js +3 -2
  25. package/dist-server/server/routes/network.js.map +1 -1
  26. package/dist-server/server/routes/platformization.js +7 -6
  27. package/dist-server/server/routes/platformization.js.map +1 -1
  28. package/dist-server/server/routes/projects.js +4 -3
  29. package/dist-server/server/routes/projects.js.map +1 -1
  30. package/dist-server/server/routes/remote.js +2 -1
  31. package/dist-server/server/routes/remote.js.map +1 -1
  32. package/dist-server/server/services/platformization.js +131 -2
  33. package/dist-server/server/services/platformization.js.map +1 -1
  34. package/package.json +1 -1
  35. package/scripts/update-git-install.mjs +15 -10
  36. package/server/cli.js +4 -4
  37. package/server/database/db.js +39 -26
  38. package/server/index.js +618 -55
  39. package/server/middleware/auth.js +59 -20
  40. package/server/modules/providers/provider.routes.ts +91 -53
  41. package/server/routes/auth.js +25 -17
  42. package/server/routes/commands.js +43 -32
  43. package/server/routes/git.js +24 -9
  44. package/server/routes/live-view.js +47 -24
  45. package/server/routes/messages.js +47 -12
  46. package/server/routes/network.js +9 -8
  47. package/server/routes/platformization.js +22 -21
  48. package/server/routes/projects.js +7 -6
  49. package/server/routes/remote.js +6 -5
  50. package/server/services/platformization.js +169 -31
  51. package/dist/assets/index-B9N-gfOQ.css +0 -32
package/server/index.js CHANGED
@@ -7,6 +7,7 @@ import path from 'path';
7
7
  import os from 'os';
8
8
  import http from 'http';
9
9
  import net from 'node:net';
10
+ import crypto from 'node:crypto';
10
11
  import { createRequire } from 'node:module';
11
12
  import { spawn } from 'child_process';
12
13
 
@@ -128,10 +129,11 @@ import {
128
129
  import { primeCliBinPath } from './services/install-jobs.js';
129
130
  import { buildHermesPathEnv, primeHermesPath } from './services/hermes-install-jobs.js';
130
131
  import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
131
- import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, apiKeysDb } from './database/db.js';
132
+ import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, apiKeysDb, appConfigDb } from './database/db.js';
132
133
  import { setNotificationWebSocketServer } from './services/notification-orchestrator.js';
133
134
  import { configureWebPush } from './services/vapid-keys.js';
134
- import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
135
+ import { validateApiKey, authenticateToken, authenticateWebSocket, requireAdmin, requireApiScope } from './middleware/auth.js';
136
+ import { filterFileTreeForUser, filterProjectsForUser, userHasProjectAccess, userHasProjectPathAccess } from './services/platformization.js';
135
137
  import { IS_PLATFORM } from './constants/config.js';
136
138
 
137
139
  import { getConnectableHost } from '../shared/networkHosts.js';
@@ -139,6 +141,374 @@ import { getConnectableHost } from '../shared/networkHosts.js';
139
141
  import { buildDaemonCliCommand, handleDaemonCommand } from './daemon-manager.js';
140
142
 
141
143
  const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode'];
144
+ const SYSTEM_UPDATE_STATE_KEY = 'system_update_state';
145
+ const SESSION_OWNERSHIP_KEY = 'session_ownership';
146
+ const SYSTEM_UPDATE_LOG_LIMIT = 600;
147
+ const updateJobs = new Map();
148
+
149
+ function parseStoredJson(value, fallback = {}) {
150
+ if (!value) return fallback;
151
+ try {
152
+ return JSON.parse(value);
153
+ } catch {
154
+ return fallback;
155
+ }
156
+ }
157
+
158
+ function readSystemUpdateState() {
159
+ return parseStoredJson(appConfigDb.get(SYSTEM_UPDATE_STATE_KEY), {
160
+ pendingRestart: null,
161
+ lastAppliedUpdate: null,
162
+ });
163
+ }
164
+
165
+ function writeSystemUpdateState(state) {
166
+ appConfigDb.set(SYSTEM_UPDATE_STATE_KEY, JSON.stringify({
167
+ pendingRestart: state?.pendingRestart || null,
168
+ lastAppliedUpdate: state?.lastAppliedUpdate || null,
169
+ }));
170
+ }
171
+
172
+ function readSessionOwnership() {
173
+ return parseStoredJson(appConfigDb.get(SESSION_OWNERSHIP_KEY), {});
174
+ }
175
+
176
+ function writeSessionOwnership(ownership) {
177
+ appConfigDb.set(SESSION_OWNERSHIP_KEY, JSON.stringify(ownership || {}));
178
+ }
179
+
180
+ function sessionOwnershipKey(provider, sessionId) {
181
+ return `${provider || 'claude'}:${sessionId}`;
182
+ }
183
+
184
+ function recordSessionOwnership({ provider, sessionId, userId, projectName, projectPath }) {
185
+ if (!sessionId || !userId) return;
186
+ const ownership = readSessionOwnership();
187
+ const key = sessionOwnershipKey(provider, sessionId);
188
+ if (ownership[key]?.userId) return;
189
+ ownership[key] = {
190
+ provider: provider || 'claude',
191
+ sessionId,
192
+ userId,
193
+ projectName: projectName || null,
194
+ projectPath: projectPath || null,
195
+ createdAt: new Date().toISOString(),
196
+ };
197
+ writeSessionOwnership(ownership);
198
+ }
199
+
200
+ function canUserSeeSession(user, session, provider) {
201
+ if (['admin', 'owner'].includes(user?.role)) return true;
202
+ const sessionId = session?.id || session?.sessionId;
203
+ if (!sessionId) return false;
204
+ const owner = readSessionOwnership()[sessionOwnershipKey(provider, sessionId)];
205
+ return Boolean(owner?.userId && Number(owner.userId) === Number(user?.id ?? user?.userId));
206
+ }
207
+
208
+ function filterProjectSessionsForUser(project, user) {
209
+ if (['admin', 'owner'].includes(user?.role)) return project;
210
+ return {
211
+ ...project,
212
+ sessions: (project.sessions || []).filter((session) => canUserSeeSession(user, session, 'claude')),
213
+ cursorSessions: (project.cursorSessions || []).filter((session) => canUserSeeSession(user, session, 'cursor')),
214
+ codexSessions: (project.codexSessions || []).filter((session) => canUserSeeSession(user, session, 'codex')),
215
+ geminiSessions: (project.geminiSessions || []).filter((session) => canUserSeeSession(user, session, 'gemini')),
216
+ qwenSessions: (project.qwenSessions || []).filter((session) => canUserSeeSession(user, session, 'qwen')),
217
+ opencodeSessions: (project.opencodeSessions || []).filter((session) => canUserSeeSession(user, session, 'opencode')),
218
+ };
219
+ }
220
+
221
+ function reconcileAppliedUpdateStateOnBoot() {
222
+ const state = readSystemUpdateState();
223
+ const pending = state.pendingRestart;
224
+ if (!pending?.toVersion || pending.toVersion !== SERVER_VERSION) {
225
+ return;
226
+ }
227
+
228
+ writeSystemUpdateState({
229
+ pendingRestart: null,
230
+ lastAppliedUpdate: {
231
+ ...pending,
232
+ appliedAt: new Date().toISOString(),
233
+ currentVersion: SERVER_VERSION,
234
+ },
235
+ });
236
+ }
237
+
238
+ function appendUpdateJobLog(job, stream, chunk) {
239
+ const entry = {
240
+ stream,
241
+ chunk: String(chunk || ''),
242
+ timestamp: new Date().toISOString(),
243
+ };
244
+ job.logs.push(entry);
245
+ if (job.logs.length > SYSTEM_UPDATE_LOG_LIMIT) {
246
+ job.logs.splice(0, job.logs.length - SYSTEM_UPDATE_LOG_LIMIT);
247
+ }
248
+ job.updatedAt = entry.timestamp;
249
+ }
250
+
251
+ function snapshotUpdateJob(job) {
252
+ if (!job) return null;
253
+ return {
254
+ id: job.id,
255
+ status: job.status,
256
+ startedAt: job.startedAt,
257
+ updatedAt: job.updatedAt,
258
+ completedAt: job.completedAt || null,
259
+ fromVersion: job.fromVersion,
260
+ toVersion: job.toVersion || null,
261
+ installMode: job.installMode,
262
+ runtimeDir: job.runtimeDir || null,
263
+ alreadyLatest: Boolean(job.alreadyLatest),
264
+ pendingRestart: Boolean(job.pendingRestart),
265
+ error: job.error || null,
266
+ logs: job.logs,
267
+ };
268
+ }
269
+
270
+ function getActiveUpdateJob() {
271
+ for (const job of updateJobs.values()) {
272
+ if (job.status === 'running' || job.status === 'queued') {
273
+ return job;
274
+ }
275
+ }
276
+ return null;
277
+ }
278
+
279
+ async function readLatestPixcodePackageMetadata() {
280
+ const registryRes = await fetch('https://registry.npmjs.org/@pixelbyte-software/pixcode');
281
+ if (!registryRes.ok) throw new Error(`Registry returned HTTP ${registryRes.status}`);
282
+ const metadata = await registryRes.json();
283
+ const latestVersion = metadata['dist-tags']?.latest;
284
+ const latestEntry = latestVersion ? metadata.versions?.[latestVersion] : null;
285
+ return {
286
+ latestVersion,
287
+ tarballUrl: latestEntry?.dist?.tarball || null,
288
+ };
289
+ }
290
+
291
+ async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl) {
292
+ appendUpdateJobLog(job, 'meta', `Update mode: runtime-dir\nRuntime: ${runtimeDir}\n`);
293
+ appendUpdateJobLog(job, 'meta', `Downloading ${tarballUrl}\n`);
294
+ const tarballRes = await fetch(tarballUrl);
295
+ if (!tarballRes.ok || !tarballRes.body) {
296
+ throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
297
+ }
298
+
299
+ const stagingDir = path.join(runtimeDir, '.staging');
300
+ const backupDir = path.join(runtimeDir, '.previous');
301
+ fs.rmSync(stagingDir, { recursive: true, force: true });
302
+ fs.mkdirSync(stagingDir, { recursive: true });
303
+
304
+ const { Readable } = await import('node:stream');
305
+ const tarModule = await import('tar');
306
+ const tarExtract = tarModule.x || tarModule.default?.x;
307
+ if (!tarExtract) throw new Error('tar extractor not available');
308
+
309
+ await new Promise((resolve, reject) => {
310
+ const webStream = tarballRes.body;
311
+ const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
312
+ ? Readable.fromWeb(webStream)
313
+ : webStream;
314
+ const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
315
+ nodeStream.pipe(extractor);
316
+ extractor.on('finish', resolve);
317
+ extractor.on('error', reject);
318
+ nodeStream.on('error', reject);
319
+ });
320
+
321
+ appendUpdateJobLog(job, 'meta', 'Staging runtime update for next restart...\n');
322
+ fs.rmSync(backupDir, { recursive: true, force: true });
323
+ fs.mkdirSync(backupDir, { recursive: true });
324
+ for (const entry of fs.readdirSync(stagingDir)) {
325
+ const src = path.join(stagingDir, entry);
326
+ const dst = path.join(runtimeDir, entry);
327
+ if (fs.existsSync(dst)) {
328
+ fs.renameSync(dst, path.join(backupDir, entry));
329
+ }
330
+ fs.renameSync(src, dst);
331
+ }
332
+ fs.rmSync(stagingDir, { recursive: true, force: true });
333
+
334
+ const depsChanged = (() => {
335
+ try {
336
+ const prevPkg = JSON.parse(fs.readFileSync(path.join(backupDir, 'package.json'), 'utf8'));
337
+ const nextPkg = JSON.parse(fs.readFileSync(path.join(runtimeDir, 'package.json'), 'utf8'));
338
+ return JSON.stringify(prevPkg.dependencies || {}) !== JSON.stringify(nextPkg.dependencies || {});
339
+ } catch {
340
+ return true;
341
+ }
342
+ })();
343
+
344
+ if (!depsChanged) return latestVersion;
345
+
346
+ appendUpdateJobLog(job, 'meta', 'Reconciling node_modules with new package.json...\n');
347
+ await new Promise((resolve, reject) => {
348
+ const npmChild = spawn('npm', ['install', '--production', '--no-audit', '--no-fund', '--no-save'], {
349
+ cwd: runtimeDir,
350
+ env: process.env,
351
+ shell: true,
352
+ stdio: ['ignore', 'pipe', 'pipe'],
353
+ });
354
+ npmChild.stdout?.on('data', (chunk) => appendUpdateJobLog(job, 'stdout', chunk.toString()));
355
+ npmChild.stderr?.on('data', (chunk) => appendUpdateJobLog(job, 'stderr', chunk.toString()));
356
+ npmChild.on('error', reject);
357
+ npmChild.on('close', (code) => {
358
+ if (code === 0) resolve();
359
+ else reject(new Error(`npm install exited with code ${code}`));
360
+ });
361
+ });
362
+ return latestVersion;
363
+ }
364
+
365
+ async function runCommandUpdateJob(job, updateCommand, updateCwd) {
366
+ appendUpdateJobLog(job, 'meta', `Running: ${updateCommand}\n`);
367
+ await new Promise((resolve, reject) => {
368
+ const child = spawn(updateCommand, {
369
+ cwd: updateCwd,
370
+ env: process.env,
371
+ shell: true,
372
+ stdio: ['ignore', 'pipe', 'pipe'],
373
+ });
374
+ child.stdout?.on('data', (data) => appendUpdateJobLog(job, 'stdout', data.toString()));
375
+ child.stderr?.on('data', (data) => appendUpdateJobLog(job, 'stderr', data.toString()));
376
+ child.on('error', reject);
377
+ child.on('close', (code) => {
378
+ if (code === 0) resolve();
379
+ else reject(new Error(`Update command exited with code ${code}`));
380
+ });
381
+ });
382
+ }
383
+
384
+ function readCurrentPackageVersion() {
385
+ try {
386
+ const pkgRaw = fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8');
387
+ return JSON.parse(pkgRaw).version || SERVER_VERSION;
388
+ } catch {
389
+ return SERVER_VERSION;
390
+ }
391
+ }
392
+
393
+ function createSystemUpdateJob(actorUser) {
394
+ const activeJob = getActiveUpdateJob();
395
+ if (activeJob) return activeJob;
396
+
397
+ const job = {
398
+ id: crypto.randomUUID(),
399
+ status: 'queued',
400
+ startedAt: new Date().toISOString(),
401
+ updatedAt: new Date().toISOString(),
402
+ completedAt: null,
403
+ fromVersion: SERVER_VERSION,
404
+ toVersion: null,
405
+ installMode,
406
+ runtimeDir: process.env.PIXCODE_RUNTIME_DIR || null,
407
+ actorUserId: actorUser?.id ?? actorUser?.userId ?? null,
408
+ logs: [],
409
+ alreadyLatest: false,
410
+ pendingRestart: false,
411
+ error: null,
412
+ };
413
+ updateJobs.set(job.id, job);
414
+
415
+ void (async () => {
416
+ job.status = 'running';
417
+ appendUpdateJobLog(job, 'meta', `Background update job started at ${job.startedAt}\n`);
418
+ try {
419
+ const runtimeDir = job.runtimeDir;
420
+ const gitUpdateScript = path.join(APP_ROOT, 'scripts', 'update-git-install.mjs');
421
+ const latest = await readLatestPixcodePackageMetadata().catch((error) => {
422
+ appendUpdateJobLog(job, 'stderr', `Registry precheck failed: ${error.message}\n`);
423
+ return { latestVersion: null, tarballUrl: null };
424
+ });
425
+ job.toVersion = latest.latestVersion || null;
426
+
427
+ if (!IS_PLATFORM && installMode === 'npm' && latest.latestVersion && latest.latestVersion === SERVER_VERSION) {
428
+ job.status = 'completed';
429
+ job.alreadyLatest = true;
430
+ job.completedAt = new Date().toISOString();
431
+ appendUpdateJobLog(job, 'meta', `Already on ${SERVER_VERSION}.\n`);
432
+ return;
433
+ }
434
+
435
+ if (runtimeDir) {
436
+ if (!latest.latestVersion || !latest.tarballUrl) {
437
+ throw new Error('Registry response missing latest version or tarball URL.');
438
+ }
439
+ job.toVersion = await runRuntimeDirUpdateJob(job, runtimeDir, latest.latestVersion, latest.tarballUrl);
440
+ } else {
441
+ const updateCommand = IS_PLATFORM
442
+ ? 'npm run update:platform'
443
+ : installMode === 'git'
444
+ ? `${JSON.stringify(process.execPath)} ${JSON.stringify(gitUpdateScript)}`
445
+ : 'npm install -g @pixelbyte-software/pixcode@latest';
446
+ const updateCwd = IS_PLATFORM || installMode === 'git' ? APP_ROOT : os.homedir();
447
+ await runCommandUpdateJob(job, updateCommand, updateCwd);
448
+ if (installMode === 'git') {
449
+ job.toVersion = readCurrentPackageVersion();
450
+ }
451
+ }
452
+
453
+ job.status = 'completed';
454
+ job.completedAt = new Date().toISOString();
455
+ job.pendingRestart = true;
456
+ const state = readSystemUpdateState();
457
+ writeSystemUpdateState({
458
+ ...state,
459
+ pendingRestart: {
460
+ jobId: job.id,
461
+ fromVersion: job.fromVersion,
462
+ toVersion: job.toVersion || latest.latestVersion || SERVER_VERSION,
463
+ installMode: job.installMode,
464
+ completedAt: job.completedAt,
465
+ logs: job.logs.slice(-80),
466
+ },
467
+ });
468
+ appendUpdateJobLog(job, 'meta', 'Update is ready. Restart when convenient to apply it.\n');
469
+ } catch (error) {
470
+ job.status = 'failed';
471
+ job.completedAt = new Date().toISOString();
472
+ job.error = error instanceof Error ? error.message : String(error);
473
+ appendUpdateJobLog(job, 'stderr', `Update failed: ${job.error}\n`);
474
+ }
475
+ })();
476
+
477
+ return job;
478
+ }
479
+
480
+ reconcileAppliedUpdateStateOnBoot();
481
+
482
+ function requireProjectAccess(capability = 'viewFiles') {
483
+ return (req, res, next) => {
484
+ const projectName = req.params.projectName || req.query.project || req.body?.project;
485
+ if (!projectName) {
486
+ return next();
487
+ }
488
+
489
+ if (!userHasProjectAccess(req.user, { name: String(projectName), projectName: String(projectName) }, capability)) {
490
+ return res.status(403).json({ error: 'Project access denied.' });
491
+ }
492
+
493
+ next();
494
+ };
495
+ }
496
+
497
+ function requireProjectPathAccess(capability = 'viewFiles') {
498
+ return (req, res, next) => {
499
+ const projectPath = req.body?.projectPath || req.query.projectPath || os.homedir();
500
+ const resolvedProjectPath = path.resolve(String(projectPath));
501
+ if (!userHasProjectPathAccess(req.user, {
502
+ fullPath: resolvedProjectPath,
503
+ path: resolvedProjectPath,
504
+ projectPath: resolvedProjectPath,
505
+ }, resolvedProjectPath, capability)) {
506
+ return res.status(403).json({ error: 'Project access denied.' });
507
+ }
508
+
509
+ next();
510
+ };
511
+ }
142
512
 
143
513
  // File system watchers for provider project/session folders
144
514
  const PROVIDER_WATCH_PATHS = [
@@ -230,19 +600,21 @@ async function setupProjectsWatcher() {
230
600
  // Get updated projects list
231
601
  const updatedProjects = await getProjects(broadcastProgress);
232
602
 
233
- // Notify all connected clients about the project changes
234
- const updateMessage = JSON.stringify({
603
+ const updatePayload = {
235
604
  type: 'projects_updated',
236
- projects: updatedProjects,
237
605
  timestamp: new Date().toISOString(),
238
606
  changeType: eventType,
239
607
  changedFile: path.relative(rootPath, filePath),
240
608
  watchProvider: provider
241
- });
609
+ };
242
610
 
611
+ // Notify all connected clients about project changes, scoped to their access.
243
612
  connectedClients.forEach(client => {
244
613
  if (client.readyState === WebSocket.OPEN) {
245
- client.send(updateMessage);
614
+ client.send(JSON.stringify({
615
+ ...updatePayload,
616
+ projects: filterProjectsForUser(updatedProjects, client.user),
617
+ }));
246
618
  }
247
619
  });
248
620
 
@@ -315,6 +687,7 @@ const workspaceWatchers = new Map(); // projectName -> { watcher, subscribers, d
315
687
 
316
688
  async function subscribeToWorkspace(ws, projectName) {
317
689
  if (!projectName || typeof projectName !== 'string') return;
690
+ if (!userHasProjectAccess(ws.user, { name: projectName, projectName }, 'viewFiles')) return;
318
691
 
319
692
  const existing = workspaceWatchers.get(projectName);
320
693
  if (existing) {
@@ -449,12 +822,13 @@ function terminatePtySession(sessionKey, session, reason) {
449
822
  return true;
450
823
  }
451
824
 
452
- function killProviderPtySessions(projectPath, provider) {
825
+ function killProviderPtySessions(projectPath, provider, userId = null) {
453
826
  let killed = 0;
454
827
  for (const [sessionKey, session] of ptySessionsMap.entries()) {
455
828
  if (
456
829
  session?.projectPath === projectPath &&
457
830
  session?.provider === provider &&
831
+ (!userId || session?.userId === userId) &&
458
832
  !session?.isPlainShell
459
833
  ) {
460
834
  killed += terminatePtySession(sessionKey, session, 'fresh provider session') ? 1 : 0;
@@ -727,6 +1101,25 @@ function buildProviderShellCommand(command, permissionFlags = []) {
727
1101
  return flags.length > 0 ? `${command} ${flags.join(' ')}` : command;
728
1102
  }
729
1103
 
1104
+ function hideProviderApprovalChoiceLines(output) {
1105
+ const approvalChoicePattern = /(?:^|\s)(?:[0-9]+[.)]\s*)?(?:yes,?\s+proceed|yes,?\s+and\s+don['’]?t|no,?\s+and\s+(?:tell|keep|cancel)|no,?\s+do\s+not)/iu;
1106
+ return String(output || '')
1107
+ .split(/(\r\n|\n|\r)/u)
1108
+ .reduce((parts, part, index, chunks) => {
1109
+ if (part === '\r\n' || part === '\n' || part === '\r') {
1110
+ const previousWasHidden = chunks[index - 1] && approvalChoicePattern.test(stripAnsiSequences(chunks[index - 1]));
1111
+ if (!previousWasHidden) parts.push(part);
1112
+ return parts;
1113
+ }
1114
+
1115
+ if (!approvalChoicePattern.test(stripAnsiSequences(part))) {
1116
+ parts.push(part);
1117
+ }
1118
+ return parts;
1119
+ }, [])
1120
+ .join('');
1121
+ }
1122
+
730
1123
  function resolvePublicBaseUrl(request) {
731
1124
  const headers = request?.headers || {};
732
1125
  const forwardedProto = String(headers['x-forwarded-proto'] || '').split(',')[0].trim();
@@ -828,15 +1221,11 @@ const wss = new WebSocketServer({
828
1221
  verifyClient: (info) => {
829
1222
  console.log('WebSocket connection attempt to:', info.req.url);
830
1223
 
831
- // Platform mode: always allow connection
832
- if (IS_PLATFORM) {
833
- const user = authenticateWebSocket(null); // Will return first user
834
- if (!user) {
835
- console.log('[WARN] Platform mode: No user found in database');
836
- return false;
837
- }
1224
+ const platformBypassUser = IS_PLATFORM ? authenticateWebSocket(null) : null;
1225
+ if (platformBypassUser) {
1226
+ const user = platformBypassUser;
838
1227
  info.req.user = user;
839
- console.log('[OK] Platform mode WebSocket authenticated for user:', user.username);
1228
+ console.log('[OK] Platform mode WebSocket authenticated via explicit bypass for user:', user.username);
840
1229
  return true;
841
1230
  }
842
1231
 
@@ -882,18 +1271,21 @@ app.use(express.urlencoded({ limit: '50mb', extended: true }));
882
1271
 
883
1272
  // Public health check endpoint (no authentication required)
884
1273
  app.get('/health', (req, res) => {
1274
+ const updateState = readSystemUpdateState();
885
1275
  res.json({
886
1276
  status: 'ok',
887
1277
  timestamp: new Date().toISOString(),
888
1278
  installMode,
889
- version: SERVER_VERSION
1279
+ version: SERVER_VERSION,
1280
+ pendingRestart: updateState.pendingRestart,
1281
+ lastAppliedUpdate: updateState.lastAppliedUpdate,
890
1282
  });
891
1283
  });
892
1284
 
893
1285
  // Optional API key validation (if configured)
894
1286
  app.use('/api', validateApiKey);
895
1287
 
896
- app.post('/api/shell/sessions/terminate', authenticateToken, (req, res) => {
1288
+ app.post('/api/shell/sessions/terminate', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
897
1289
  const provider = req.body?.provider || 'claude';
898
1290
  const projectPath = req.body?.projectPath || os.homedir();
899
1291
 
@@ -901,11 +1293,15 @@ app.post('/api/shell/sessions/terminate', authenticateToken, (req, res) => {
901
1293
  return res.status(400).json({ error: 'Unsupported provider' });
902
1294
  }
903
1295
 
904
- const killedSessions = killProviderPtySessions(projectPath, provider);
1296
+ const killedSessions = killProviderPtySessions(
1297
+ projectPath,
1298
+ provider,
1299
+ ['admin', 'owner'].includes(req.user?.role) ? null : (req.user?.id ?? req.user?.userId ?? null),
1300
+ );
905
1301
  res.json({ success: true, killedSessions });
906
1302
  });
907
1303
 
908
- app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) => {
1304
+ app.get('/api/shell/sessions/provider-output', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
909
1305
  const provider = String(req.query.provider || 'claude');
910
1306
  const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.trim()
911
1307
  ? req.query.projectPath.trim()
@@ -922,11 +1318,14 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) =>
922
1318
  }
923
1319
 
924
1320
  const requestedProjectPath = projectPath ? path.resolve(projectPath) : null;
1321
+ const requestUserId = req.user?.id ?? req.user?.userId ?? null;
1322
+ const canReadAnyShellSession = ['admin', 'owner'].includes(req.user?.role);
925
1323
  let matchedSession = null;
926
1324
  for (const session of ptySessionsMap.values()) {
927
1325
  if (
928
1326
  session?.provider === provider &&
929
1327
  !session?.isPlainShell &&
1328
+ (canReadAnyShellSession || session?.userId === requestUserId) &&
930
1329
  (!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
931
1330
  (!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)
932
1331
  ) {
@@ -962,7 +1361,7 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) =>
962
1361
  });
963
1362
  });
964
1363
 
965
- app.post('/api/shell/sessions/provider-input', authenticateToken, (req, res) => {
1364
+ app.post('/api/shell/sessions/provider-input', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
966
1365
  const provider = String(req.body?.provider || 'claude');
967
1366
  const projectPath = typeof req.body?.projectPath === 'string' && req.body.projectPath.trim()
968
1367
  ? req.body.projectPath.trim()
@@ -977,6 +1376,8 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, (req, res) =>
977
1376
  }
978
1377
 
979
1378
  const requestedProjectPath = projectPath ? path.resolve(projectPath) : null;
1379
+ const requestUserId = req.user?.id ?? req.user?.userId ?? null;
1380
+ const canWriteAnyShellSession = ['admin', 'owner'].includes(req.user?.role);
980
1381
  let matchedSession = null;
981
1382
  for (const session of ptySessionsMap.values()) {
982
1383
  if (
@@ -984,6 +1385,7 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, (req, res) =>
984
1385
  !session?.isPlainShell &&
985
1386
  session?.pty &&
986
1387
  session.lifecycleState === 'running' &&
1388
+ (canWriteAnyShellSession || session?.userId === requestUserId) &&
987
1389
  (!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
988
1390
  (!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)
989
1391
  ) {
@@ -1082,8 +1484,8 @@ app.use('/api/webhooks', authenticateToken, webhooksRoutes);
1082
1484
  // Production agent loop APIs (protected)
1083
1485
  app.use('/api/production-agent-loop', authenticateToken, productionAgentLoopRoutes);
1084
1486
 
1085
- // Platform control plane APIs (protected)
1086
- app.use('/api/platformization', authenticateToken, platformizationRoutes);
1487
+ // Platform control plane APIs (admin-only)
1488
+ app.use('/api/platformization', authenticateToken, requireAdmin, platformizationRoutes);
1087
1489
 
1088
1490
  // Project Live View (protected control API + public share proxy)
1089
1491
  app.use('/api/live-view', authenticateToken, liveViewRoutes);
@@ -1100,14 +1502,14 @@ adapterRegistry.register(new QwenA2AAdapter());
1100
1502
  adapterRegistry.register(new OpenCodeA2AAdapter());
1101
1503
  adapterRegistry.register(new JsonEventA2AAdapter());
1102
1504
  app.use('/hermes', createHermesTaskRouter());
1103
- app.use('/preview', authenticateToken, createPreviewProxyRouter());
1104
- app.use('/api/orchestration', authenticateToken, createOrchestrationTaskRouter());
1105
- app.use('/api/orchestration/hermes', authenticateToken, createHermesRouter({
1505
+ app.use('/preview', authenticateToken, requireAdmin, createPreviewProxyRouter());
1506
+ app.use('/api/orchestration', authenticateToken, requireAdmin, createOrchestrationTaskRouter());
1507
+ app.use('/api/orchestration/hermes', authenticateToken, requireAdmin, createHermesRouter({
1106
1508
  appRoot: APP_ROOT,
1107
1509
  createHermesApiKey: getOrCreateHermesApiKey,
1108
1510
  resolvePublicBaseUrl,
1109
1511
  }));
1110
- app.use('/api/orchestration', authenticateToken, createWorkflowRouter());
1512
+ app.use('/api/orchestration', authenticateToken, requireAdmin, createWorkflowRouter());
1111
1513
  app.use('/live', createLiveViewPublicRouter());
1112
1514
 
1113
1515
  // Network discovery / QR endpoints (protected)
@@ -1158,6 +1560,38 @@ app.use(express.static(path.join(APP_ROOT, 'public'), {
1158
1560
  // /api/config endpoint removed - no longer needed
1159
1561
  // Frontend now uses window.location for WebSocket URLs
1160
1562
 
1563
+ app.get('/api/system/update-state', authenticateToken, (req, res) => {
1564
+ res.json({
1565
+ success: true,
1566
+ state: readSystemUpdateState(),
1567
+ activeJob: snapshotUpdateJob(getActiveUpdateJob()),
1568
+ currentVersion: SERVER_VERSION,
1569
+ installMode,
1570
+ capabilities: {
1571
+ canBackgroundUpdate: true,
1572
+ canRestart: true,
1573
+ startupUpdateDefault: false,
1574
+ },
1575
+ });
1576
+ });
1577
+
1578
+ app.post('/api/system/update-jobs', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
1579
+ const job = createSystemUpdateJob(req.user);
1580
+ res.status(job.status === 'queued' ? 202 : 200).json({
1581
+ success: true,
1582
+ job: snapshotUpdateJob(job),
1583
+ });
1584
+ });
1585
+
1586
+ app.get('/api/system/update-jobs/:jobId', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
1587
+ const job = updateJobs.get(req.params.jobId);
1588
+ if (!job) {
1589
+ return res.status(404).json({ success: false, error: 'Update job not found' });
1590
+ }
1591
+
1592
+ res.json({ success: true, job: snapshotUpdateJob(job) });
1593
+ });
1594
+
1161
1595
  // System update endpoint — streams live output via Server-Sent Events so the
1162
1596
  // UI sees npm/git progress in real time instead of waiting ~2 minutes for the
1163
1597
  // buffered response.
@@ -1171,7 +1605,7 @@ app.use(express.static(path.join(APP_ROOT, 'public'), {
1171
1605
  // checkout state before pulling so source installs do not fail on local
1172
1606
  // modified files left by older releases or manual edits.
1173
1607
  // 3. fallback → `npm install -g …` (classic npm-distributed install).
1174
- app.post('/api/system/update', authenticateToken, async (req, res) => {
1608
+ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope('system:update'), async (req, res) => {
1175
1609
  const projectRoot = APP_ROOT;
1176
1610
  console.log('Starting system update from directory:', projectRoot);
1177
1611
 
@@ -1528,7 +1962,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
1528
1962
  // Restart endpoint — exits the current process so an external wrapper
1529
1963
  // (systemd/pm2/daemon manager) can bring the server back on the new code.
1530
1964
  // Foreground installs without a wrapper will simply stop; the UI reports this.
1531
- app.post('/api/system/restart', authenticateToken, (req, res) => {
1965
+ app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
1532
1966
  res.json({
1533
1967
  success: true,
1534
1968
  version: SERVER_VERSION,
@@ -1545,16 +1979,19 @@ app.post('/api/system/restart', authenticateToken, (req, res) => {
1545
1979
  app.get('/api/projects', authenticateToken, async (req, res) => {
1546
1980
  try {
1547
1981
  const projects = await getProjects(broadcastProgress);
1548
- res.json(projects);
1982
+ res.json(filterProjectsForUser(projects, req.user).map((project) => filterProjectSessionsForUser(project, req.user)));
1549
1983
  } catch (error) {
1550
1984
  res.status(500).json({ error: error.message });
1551
1985
  }
1552
1986
  });
1553
1987
 
1554
- app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => {
1988
+ app.get('/api/projects/:projectName/sessions', authenticateToken, requireProjectAccess('viewFiles'), async (req, res) => {
1555
1989
  try {
1556
1990
  const { limit = 5, offset = 0 } = req.query;
1557
1991
  const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
1992
+ if (!['admin', 'owner'].includes(req.user?.role)) {
1993
+ result.sessions = (result.sessions || []).filter((session) => canUserSeeSession(req.user, session, 'claude'));
1994
+ }
1558
1995
  applyCustomSessionNames(result.sessions, 'claude');
1559
1996
  res.json(result);
1560
1997
  } catch (error) {
@@ -1563,7 +2000,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, re
1563
2000
  });
1564
2001
 
1565
2002
  // Rename project endpoint
1566
- app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
2003
+ app.put('/api/projects/:projectName/rename', authenticateToken, requireProjectAccess('manageProjectSettings'), async (req, res) => {
1567
2004
  try {
1568
2005
  const { displayName } = req.body;
1569
2006
  await renameProject(req.params.projectName, displayName);
@@ -1574,7 +2011,7 @@ app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res)
1574
2011
  });
1575
2012
 
1576
2013
  // Delete session endpoint
1577
- app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
2014
+ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, requireProjectAccess('editFiles'), async (req, res) => {
1578
2015
  try {
1579
2016
  const { projectName, sessionId } = req.params;
1580
2017
  console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
@@ -1617,7 +2054,7 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
1617
2054
  // Delete project endpoint
1618
2055
  // force=true to allow removal even when sessions exist
1619
2056
  // deleteData=true to also delete session/memory files on disk (destructive)
1620
- app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
2057
+ app.delete('/api/projects/:projectName', authenticateToken, requireProjectAccess('manageProjectSettings'), async (req, res) => {
1621
2058
  try {
1622
2059
  const { projectName } = req.params;
1623
2060
  const force = req.query.force === 'true';
@@ -1635,7 +2072,7 @@ app.delete('/api/projects/:projectName', authenticateToken, async (req, res) =>
1635
2072
  });
1636
2073
 
1637
2074
  // Search conversations content (SSE streaming)
1638
- app.get('/api/search/conversations', authenticateToken, async (req, res) => {
2075
+ app.get('/api/search/conversations', authenticateToken, requireAdmin, async (req, res) => {
1639
2076
  const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
1640
2077
  const parsedLimit = Number.parseInt(String(req.query.limit), 10);
1641
2078
  const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100));
@@ -1700,7 +2137,7 @@ const expandBrowsePath = (inputPath) => {
1700
2137
  };
1701
2138
 
1702
2139
  // Browse filesystem endpoint for project suggestions - uses existing getFileTree
1703
- app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
2140
+ app.get('/api/browse-filesystem', authenticateToken, requireAdmin, async (req, res) => {
1704
2141
  try {
1705
2142
  const { path: dirPath } = req.query;
1706
2143
 
@@ -1789,7 +2226,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
1789
2226
  }
1790
2227
  });
1791
2228
 
1792
- app.post('/api/create-folder', authenticateToken, async (req, res) => {
2229
+ app.post('/api/create-folder', authenticateToken, requireAdmin, async (req, res) => {
1793
2230
  try {
1794
2231
  const { path: folderPath } = req.body;
1795
2232
  if (!folderPath) {
@@ -1830,7 +2267,7 @@ app.post('/api/create-folder', authenticateToken, async (req, res) => {
1830
2267
  });
1831
2268
 
1832
2269
  // Read file content endpoint
1833
- app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
2270
+ app.get('/api/projects/:projectName/file', authenticateToken, requireProjectAccess('viewFiles'), async (req, res) => {
1834
2271
  try {
1835
2272
  const { projectName } = req.params;
1836
2273
  const { filePath } = req.query;
@@ -1854,6 +2291,9 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) =
1854
2291
  if (!resolved.startsWith(normalizedRoot)) {
1855
2292
  return res.status(403).json({ error: 'Path must be under project root' });
1856
2293
  }
2294
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolved, 'viewFiles')) {
2295
+ return res.status(403).json({ error: 'Folder access denied' });
2296
+ }
1857
2297
 
1858
2298
  const content = await fsPromises.readFile(resolved, 'utf8');
1859
2299
  res.json({ content, path: resolved });
@@ -1870,7 +2310,7 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) =
1870
2310
  });
1871
2311
 
1872
2312
  // Serve raw file bytes for previews and downloads.
1873
- app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
2313
+ app.get('/api/projects/:projectName/files/content', authenticateToken, requireProjectAccess('viewFiles'), async (req, res) => {
1874
2314
  try {
1875
2315
  const { projectName } = req.params;
1876
2316
  const { path: filePath } = req.query;
@@ -1895,6 +2335,9 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
1895
2335
  if (!resolved.startsWith(normalizedRoot)) {
1896
2336
  return res.status(403).json({ error: 'Path must be under project root' });
1897
2337
  }
2338
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolved, 'viewFiles')) {
2339
+ return res.status(403).json({ error: 'Folder access denied' });
2340
+ }
1898
2341
 
1899
2342
  // Check if file exists
1900
2343
  try {
@@ -1927,7 +2370,7 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
1927
2370
  });
1928
2371
 
1929
2372
  // Save file content endpoint
1930
- app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
2373
+ app.put('/api/projects/:projectName/file', authenticateToken, requireProjectAccess('editFiles'), async (req, res) => {
1931
2374
  try {
1932
2375
  const { projectName } = req.params;
1933
2376
  const { filePath, content } = req.body;
@@ -1955,6 +2398,9 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
1955
2398
  if (!resolved.startsWith(normalizedRoot)) {
1956
2399
  return res.status(403).json({ error: 'Path must be under project root' });
1957
2400
  }
2401
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolved, 'editFiles')) {
2402
+ return res.status(403).json({ error: 'Folder access denied' });
2403
+ }
1958
2404
 
1959
2405
  // Write the new content
1960
2406
  await fsPromises.writeFile(resolved, content, 'utf8');
@@ -1976,7 +2422,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
1976
2422
  }
1977
2423
  });
1978
2424
 
1979
- app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
2425
+ app.get('/api/projects/:projectName/files', authenticateToken, requireProjectAccess('viewFiles'), async (req, res) => {
1980
2426
  try {
1981
2427
 
1982
2428
  // Using fsPromises from import
@@ -2003,7 +2449,12 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
2003
2449
  }
2004
2450
 
2005
2451
  const files = await getFileTree(actualPath, 10, 0, true);
2006
- res.json(files);
2452
+ res.json(filterFileTreeForUser(files, req.user, {
2453
+ name: req.params.projectName,
2454
+ projectName: req.params.projectName,
2455
+ fullPath: actualPath,
2456
+ path: actualPath,
2457
+ }, 'viewFiles'));
2007
2458
  } catch (error) {
2008
2459
  console.error('[ERROR] File tree error:', error.message);
2009
2460
  res.status(500).json({ error: error.message });
@@ -2058,7 +2509,7 @@ function validateFilename(name) {
2058
2509
  }
2059
2510
 
2060
2511
  // POST /api/projects/:projectName/files/create - Create new file or directory
2061
- app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => {
2512
+ app.post('/api/projects/:projectName/files/create', authenticateToken, requireProjectAccess('editFiles'), async (req, res) => {
2062
2513
  try {
2063
2514
  const { projectName } = req.params;
2064
2515
  const { path: parentPath, type, name } = req.body;
@@ -2092,6 +2543,9 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, async (re
2092
2543
  }
2093
2544
 
2094
2545
  const resolvedPath = validation.resolved;
2546
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedPath, 'editFiles')) {
2547
+ return res.status(403).json({ error: 'Folder access denied' });
2548
+ }
2095
2549
 
2096
2550
  // Check if already exists
2097
2551
  try {
@@ -2135,7 +2589,7 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, async (re
2135
2589
  });
2136
2590
 
2137
2591
  // PUT /api/projects/:projectName/files/rename - Rename file or directory
2138
- app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => {
2592
+ app.put('/api/projects/:projectName/files/rename', authenticateToken, requireProjectAccess('editFiles'), async (req, res) => {
2139
2593
  try {
2140
2594
  const { projectName } = req.params;
2141
2595
  const { oldPath, newName } = req.body;
@@ -2163,6 +2617,9 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req
2163
2617
  }
2164
2618
 
2165
2619
  const resolvedOldPath = oldValidation.resolved;
2620
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedOldPath, 'editFiles')) {
2621
+ return res.status(403).json({ error: 'Folder access denied' });
2622
+ }
2166
2623
 
2167
2624
  // Check if old path exists
2168
2625
  try {
@@ -2178,6 +2635,9 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req
2178
2635
  if (!newValidation.valid) {
2179
2636
  return res.status(403).json({ error: newValidation.error });
2180
2637
  }
2638
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedNewPath, 'editFiles')) {
2639
+ return res.status(403).json({ error: 'Folder access denied' });
2640
+ }
2181
2641
 
2182
2642
  // Check if new path already exists
2183
2643
  try {
@@ -2212,7 +2672,7 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req
2212
2672
  });
2213
2673
 
2214
2674
  // DELETE /api/projects/:projectName/files - Delete file or directory
2215
- app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
2675
+ app.delete('/api/projects/:projectName/files', authenticateToken, requireProjectAccess('editFiles'), async (req, res) => {
2216
2676
  try {
2217
2677
  const { projectName } = req.params;
2218
2678
  const { path: targetPath, type } = req.body;
@@ -2235,6 +2695,9 @@ app.delete('/api/projects/:projectName/files', authenticateToken, async (req, re
2235
2695
  }
2236
2696
 
2237
2697
  const resolvedPath = validation.resolved;
2698
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedPath, 'editFiles')) {
2699
+ return res.status(403).json({ error: 'Folder access denied' });
2700
+ }
2238
2701
 
2239
2702
  // Check if path exists and get stats
2240
2703
  let stats;
@@ -2368,6 +2831,9 @@ const uploadFilesHandler = async (req, res) => {
2368
2831
  resolvedTargetDir = validation.resolved;
2369
2832
  console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
2370
2833
  }
2834
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedTargetDir, 'editFiles')) {
2835
+ return res.status(403).json({ error: 'Folder access denied' });
2836
+ }
2371
2837
 
2372
2838
  // Ensure target directory exists
2373
2839
  try {
@@ -2394,6 +2860,10 @@ const uploadFilesHandler = async (req, res) => {
2394
2860
  await fsPromises.unlink(file.path).catch(() => {});
2395
2861
  continue;
2396
2862
  }
2863
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, destPath, 'editFiles')) {
2864
+ await fsPromises.unlink(file.path).catch(() => {});
2865
+ continue;
2866
+ }
2397
2867
 
2398
2868
  // Ensure parent directory exists (for nested files from folder upload)
2399
2869
  const parentDir = path.dirname(destPath);
@@ -2438,7 +2908,7 @@ const uploadFilesHandler = async (req, res) => {
2438
2908
  });
2439
2909
  };
2440
2910
 
2441
- app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler);
2911
+ app.post('/api/projects/:projectName/files/upload', authenticateToken, requireProjectAccess('editFiles'), uploadFilesHandler);
2442
2912
 
2443
2913
  /**
2444
2914
  * Proxy an authenticated client WebSocket to a plugin's internal WS server.
@@ -2517,15 +2987,35 @@ class WebSocketWriter {
2517
2987
  this.ws = ws;
2518
2988
  this.sessionId = null;
2519
2989
  this.userId = userId;
2990
+ this.provider = 'claude';
2991
+ this.projectName = null;
2992
+ this.projectPath = null;
2520
2993
  this.isWebSocketWriter = true; // Marker for transport detection
2521
2994
  }
2522
2995
 
2523
2996
  send(data) {
2997
+ const nextSessionId = data?.sessionId || data?.session_id || data?.session?.id;
2998
+ if (nextSessionId) {
2999
+ this.setSessionId(nextSessionId);
3000
+ recordSessionOwnership({
3001
+ provider: data.provider || this.provider,
3002
+ sessionId: nextSessionId,
3003
+ userId: this.userId,
3004
+ projectName: this.projectName,
3005
+ projectPath: this.projectPath,
3006
+ });
3007
+ }
2524
3008
  if (this.ws.readyState === 1) { // WebSocket.OPEN
2525
3009
  this.ws.send(JSON.stringify(data));
2526
3010
  }
2527
3011
  }
2528
3012
 
3013
+ setContext({ provider, projectName, projectPath } = {}) {
3014
+ if (provider) this.provider = provider;
3015
+ if (projectName) this.projectName = projectName;
3016
+ if (projectPath) this.projectPath = projectPath;
3017
+ }
3018
+
2529
3019
  updateWebSocket(newRawWs) {
2530
3020
  this.ws = newRawWs;
2531
3021
  }
@@ -2545,10 +3035,50 @@ function handleChatConnection(ws, request) {
2545
3035
 
2546
3036
  // Add to connected clients for project updates
2547
3037
  ws.userId = request?.user?.id ?? request?.user?.userId ?? null;
3038
+ ws.user = request?.user ?? null;
2548
3039
  connectedClients.add(ws);
2549
3040
 
2550
3041
  // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
2551
3042
  const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null);
3043
+ const isAdminChatUser = () => ['admin', 'owner'].includes(request?.user?.role);
3044
+ const requireAdminChatAction = (action) => {
3045
+ if (!isAdminChatUser()) {
3046
+ throw new Error(`${action} requires admin access until session ownership metadata is available.`);
3047
+ }
3048
+ };
3049
+ const assertChatCommandAccess = (data) => {
3050
+ const options = data?.options || {};
3051
+ if ((options.permissionMode === 'bypassPermissions' || options.skipPermissions === true) && !isAdminChatUser()) {
3052
+ throw new Error('Bypass/skip permission modes require admin access.');
3053
+ }
3054
+
3055
+ const projectName = typeof options.projectName === 'string' ? options.projectName : '';
3056
+ const projectPath = typeof options.projectPath === 'string'
3057
+ ? options.projectPath
3058
+ : (typeof options.cwd === 'string' ? options.cwd : '');
3059
+ const projectRef = projectName
3060
+ ? { name: projectName, projectName }
3061
+ : (projectPath ? { fullPath: path.resolve(projectPath), path: path.resolve(projectPath), projectPath: path.resolve(projectPath) } : null);
3062
+
3063
+ if (!projectRef) {
3064
+ if (isAdminChatUser()) return;
3065
+ throw new Error('Project context is required for agent commands.');
3066
+ }
3067
+
3068
+ if (!userHasProjectAccess(request.user, projectRef, 'chatAgents')) {
3069
+ throw new Error('Project access denied.');
3070
+ }
3071
+ };
3072
+ const setWriterCommandContext = (provider, data) => {
3073
+ const options = data?.options || {};
3074
+ writer.setContext({
3075
+ provider,
3076
+ projectName: typeof options.projectName === 'string' ? options.projectName : null,
3077
+ projectPath: typeof options.projectPath === 'string'
3078
+ ? options.projectPath
3079
+ : (typeof options.cwd === 'string' ? options.cwd : null),
3080
+ });
3081
+ };
2552
3082
 
2553
3083
  ws.on('message', async (message) => {
2554
3084
  try {
@@ -2565,25 +3095,38 @@ function handleChatConnection(ws, request) {
2565
3095
  : () => {};
2566
3096
 
2567
3097
  if (data.type === 'claude-command') {
3098
+ assertChatCommandAccess(data);
3099
+ setWriterCommandContext('claude', data);
2568
3100
  chatDebug('claude', data);
2569
3101
  // Use Claude Agents SDK
2570
3102
  await queryClaudeSDK(data.command, data.options, writer);
2571
3103
  } else if (data.type === 'cursor-command') {
3104
+ assertChatCommandAccess(data);
3105
+ setWriterCommandContext('cursor', data);
2572
3106
  chatDebug('cursor', data);
2573
3107
  await spawnCursor(data.command, data.options, writer);
2574
3108
  } else if (data.type === 'codex-command') {
3109
+ assertChatCommandAccess(data);
3110
+ setWriterCommandContext('codex', data);
2575
3111
  chatDebug('codex', data);
2576
3112
  await queryCodex(data.command, data.options, writer);
2577
3113
  } else if (data.type === 'gemini-command') {
3114
+ assertChatCommandAccess(data);
3115
+ setWriterCommandContext('gemini', data);
2578
3116
  chatDebug('gemini', data);
2579
3117
  await spawnGemini(data.command, data.options, writer);
2580
3118
  } else if (data.type === 'qwen-command') {
3119
+ assertChatCommandAccess(data);
3120
+ setWriterCommandContext('qwen', data);
2581
3121
  chatDebug('qwen', data);
2582
3122
  await spawnQwen(data.command, data.options, writer);
2583
3123
  } else if (data.type === 'opencode-command') {
3124
+ assertChatCommandAccess(data);
3125
+ setWriterCommandContext('opencode', data);
2584
3126
  chatDebug('opencode', data);
2585
3127
  await spawnOpencode(data.command, data.options, writer);
2586
3128
  } else if (data.type === 'cursor-resume') {
3129
+ assertChatCommandAccess({ options: { cwd: data.options?.cwd } });
2587
3130
  // Backward compatibility: treat as cursor-command with resume and no prompt
2588
3131
  console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
2589
3132
  await spawnCursor('', {
@@ -2592,6 +3135,7 @@ function handleChatConnection(ws, request) {
2592
3135
  cwd: data.options?.cwd
2593
3136
  }, writer);
2594
3137
  } else if (data.type === 'abort-session') {
3138
+ requireAdminChatAction('Aborting sessions');
2595
3139
  console.log('[DEBUG] Abort session request:', data.sessionId);
2596
3140
  const provider = data.provider || 'claude';
2597
3141
  let success;
@@ -2613,6 +3157,7 @@ function handleChatConnection(ws, request) {
2613
3157
 
2614
3158
  writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider }));
2615
3159
  } else if (data.type === 'claude-permission-response') {
3160
+ requireAdminChatAction('Resolving tool approvals');
2616
3161
  // Relay UI approval decisions back into the SDK control flow.
2617
3162
  // This does not persist permissions; it only resolves the in-flight request,
2618
3163
  // introduced so the SDK can resume once the user clicks Allow/Deny.
@@ -2625,10 +3170,12 @@ function handleChatConnection(ws, request) {
2625
3170
  });
2626
3171
  }
2627
3172
  } else if (data.type === 'cursor-abort') {
3173
+ requireAdminChatAction('Aborting sessions');
2628
3174
  console.log('[DEBUG] Abort Cursor session:', data.sessionId);
2629
3175
  const success = abortCursorSession(data.sessionId);
2630
3176
  writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider: 'cursor' }));
2631
3177
  } else if (data.type === 'check-session-status') {
3178
+ requireAdminChatAction('Checking global session status');
2632
3179
  // Check if a specific session is currently processing
2633
3180
  const provider = data.provider || 'claude';
2634
3181
  const sessionId = data.sessionId;
@@ -2661,6 +3208,7 @@ function handleChatConnection(ws, request) {
2661
3208
  isProcessing: isActive
2662
3209
  });
2663
3210
  } else if (data.type === 'get-pending-permissions') {
3211
+ requireAdminChatAction('Reading pending permissions');
2664
3212
  // Return pending permission requests for a session
2665
3213
  const sessionId = data.sessionId;
2666
3214
  if (sessionId && isClaudeSDKSessionActive(sessionId)) {
@@ -2679,6 +3227,7 @@ function handleChatConnection(ws, request) {
2679
3227
  } else if (data.type === 'unwatch-project') {
2680
3228
  unsubscribeFromWorkspace(ws, data.projectName || null);
2681
3229
  } else if (data.type === 'get-active-sessions') {
3230
+ requireAdminChatAction('Listing active sessions');
2682
3231
  // Get all currently active sessions
2683
3232
  const activeSessions = {
2684
3233
  claude: getActiveClaudeSDKSessions(),
@@ -2739,6 +3288,15 @@ function handleShellConnection(ws, request) {
2739
3288
  // is writable, has a git-friendly cwd, and matches where
2740
3289
  // every provider already stores its config (~/.codex etc.).
2741
3290
  const projectPath = data.projectPath || os.homedir();
3291
+ const requestedProjectPath = path.resolve(projectPath);
3292
+ if (!userHasProjectPathAccess(request.user, {
3293
+ fullPath: requestedProjectPath,
3294
+ path: requestedProjectPath,
3295
+ projectPath: requestedProjectPath,
3296
+ }, requestedProjectPath, 'useShell')) {
3297
+ ws.send(JSON.stringify({ type: 'error', message: 'Shell access denied for this project' }));
3298
+ return;
3299
+ }
2742
3300
  const sessionId = data.sessionId;
2743
3301
  const hasSession = data.hasSession;
2744
3302
  const provider = data.provider || 'claude';
@@ -2788,7 +3346,8 @@ function handleShellConnection(ws, request) {
2788
3346
  // which collided across providers and made every disconnect →
2789
3347
  // switch-provider flow reopen the previously-running CLI.
2790
3348
  const providerSuffix = isPlainShell ? '' : `_${provider}`;
2791
- ptySessionKey = `${projectPath}_${sessionId || 'default'}${providerSuffix}${commandSuffix}`;
3349
+ const ownerUserId = request.user?.id ?? request.user?.userId ?? 'anonymous';
3350
+ ptySessionKey = `${ownerUserId}_${projectPath}_${sessionId || 'default'}${providerSuffix}${commandSuffix}`;
2792
3351
 
2793
3352
  // Kill any existing login session before starting fresh
2794
3353
  if (isLoginCommand) {
@@ -2803,7 +3362,7 @@ function handleShellConnection(ws, request) {
2803
3362
  terminatePtySession(ptySessionKey, oldSession, 'fresh plain shell session');
2804
3363
  }
2805
3364
  } else {
2806
- const killedSessions = killProviderPtySessions(projectPath, provider);
3365
+ const killedSessions = killProviderPtySessions(projectPath, provider, ownerUserId);
2807
3366
  if (killedSessions > 0) {
2808
3367
  console.log(`🧹 Fresh ${provider} session requested; terminated ${killedSessions} cached PTY session(s).`);
2809
3368
  }
@@ -2876,7 +3435,7 @@ function handleShellConnection(ws, request) {
2876
3435
 
2877
3436
  try {
2878
3437
  // Validate projectPath — resolve to absolute and verify it exists
2879
- const resolvedProjectPath = path.resolve(projectPath);
3438
+ const resolvedProjectPath = requestedProjectPath;
2880
3439
  try {
2881
3440
  const stats = fs.statSync(resolvedProjectPath);
2882
3441
  if (!stats.isDirectory()) {
@@ -3039,6 +3598,7 @@ function handleShellConnection(ws, request) {
3039
3598
  ws: ws,
3040
3599
  buffer: [],
3041
3600
  timeoutId: null,
3601
+ userId: ownerUserId,
3042
3602
  projectPath,
3043
3603
  sessionId,
3044
3604
  hermesLaunchId,
@@ -3076,6 +3636,7 @@ function handleShellConnection(ws, request) {
3076
3636
  /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
3077
3637
  '[INFO] Opening in browser: $1'
3078
3638
  );
3639
+ outputData = hideProviderApprovalChoiceLines(outputData);
3079
3640
 
3080
3641
  const emitAuthUrl = (detectedUrl, autoOpen = false) => {
3081
3642
  const normalizedUrl = normalizeDetectedUrl(detectedUrl);
@@ -3112,10 +3673,12 @@ function handleShellConnection(ws, request) {
3112
3673
  }
3113
3674
 
3114
3675
  // Send regular output
3115
- session.ws.send(JSON.stringify({
3116
- type: 'output',
3117
- data: outputData
3118
- }));
3676
+ if (outputData) {
3677
+ session.ws.send(JSON.stringify({
3678
+ type: 'output',
3679
+ data: outputData
3680
+ }));
3681
+ }
3119
3682
  }
3120
3683
  });
3121
3684
 
@@ -3234,7 +3797,7 @@ function handleShellConnection(ws, request) {
3234
3797
  });
3235
3798
  }
3236
3799
  // Image upload endpoint
3237
- app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
3800
+ app.post('/api/projects/:projectName/upload-images', authenticateToken, requireProjectAccess('editFiles'), async (req, res) => {
3238
3801
  try {
3239
3802
  const multer = (await import('multer')).default;
3240
3803
  const path = (await import('path')).default;
@@ -3319,7 +3882,7 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
3319
3882
  });
3320
3883
 
3321
3884
  // Get token usage for a specific session
3322
- app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
3885
+ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, requireProjectAccess('viewFiles'), async (req, res) => {
3323
3886
  try {
3324
3887
  const { projectName, sessionId } = req.params;
3325
3888
  const { provider = 'claude' } = req.query;