@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
@@ -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
  import express from 'express';
@@ -94,14 +95,365 @@ import { applyAllStoredCredentialsToEnv, } from './services/provider-credentials
94
95
  import { primeCliBinPath } from './services/install-jobs.js';
95
96
  import { buildHermesPathEnv, primeHermesPath } from './services/hermes-install-jobs.js';
96
97
  import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
97
- import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, apiKeysDb } from './database/db.js';
98
+ import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, apiKeysDb, appConfigDb } from './database/db.js';
98
99
  import { setNotificationWebSocketServer } from './services/notification-orchestrator.js';
99
100
  import { configureWebPush } from './services/vapid-keys.js';
100
- import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
101
+ import { validateApiKey, authenticateToken, authenticateWebSocket, requireAdmin, requireApiScope } from './middleware/auth.js';
102
+ import { filterFileTreeForUser, filterProjectsForUser, userHasProjectAccess, userHasProjectPathAccess } from './services/platformization.js';
101
103
  import { IS_PLATFORM } from './constants/config.js';
102
104
  import { getConnectableHost } from '../shared/networkHosts.js';
103
105
  import { buildDaemonCliCommand, handleDaemonCommand } from './daemon-manager.js';
104
106
  const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode'];
107
+ const SYSTEM_UPDATE_STATE_KEY = 'system_update_state';
108
+ const SESSION_OWNERSHIP_KEY = 'session_ownership';
109
+ const SYSTEM_UPDATE_LOG_LIMIT = 600;
110
+ const updateJobs = new Map();
111
+ function parseStoredJson(value, fallback = {}) {
112
+ if (!value)
113
+ return fallback;
114
+ try {
115
+ return JSON.parse(value);
116
+ }
117
+ catch {
118
+ return fallback;
119
+ }
120
+ }
121
+ function readSystemUpdateState() {
122
+ return parseStoredJson(appConfigDb.get(SYSTEM_UPDATE_STATE_KEY), {
123
+ pendingRestart: null,
124
+ lastAppliedUpdate: null,
125
+ });
126
+ }
127
+ function writeSystemUpdateState(state) {
128
+ appConfigDb.set(SYSTEM_UPDATE_STATE_KEY, JSON.stringify({
129
+ pendingRestart: state?.pendingRestart || null,
130
+ lastAppliedUpdate: state?.lastAppliedUpdate || null,
131
+ }));
132
+ }
133
+ function readSessionOwnership() {
134
+ return parseStoredJson(appConfigDb.get(SESSION_OWNERSHIP_KEY), {});
135
+ }
136
+ function writeSessionOwnership(ownership) {
137
+ appConfigDb.set(SESSION_OWNERSHIP_KEY, JSON.stringify(ownership || {}));
138
+ }
139
+ function sessionOwnershipKey(provider, sessionId) {
140
+ return `${provider || 'claude'}:${sessionId}`;
141
+ }
142
+ function recordSessionOwnership({ provider, sessionId, userId, projectName, projectPath }) {
143
+ if (!sessionId || !userId)
144
+ return;
145
+ const ownership = readSessionOwnership();
146
+ const key = sessionOwnershipKey(provider, sessionId);
147
+ if (ownership[key]?.userId)
148
+ return;
149
+ ownership[key] = {
150
+ provider: provider || 'claude',
151
+ sessionId,
152
+ userId,
153
+ projectName: projectName || null,
154
+ projectPath: projectPath || null,
155
+ createdAt: new Date().toISOString(),
156
+ };
157
+ writeSessionOwnership(ownership);
158
+ }
159
+ function canUserSeeSession(user, session, provider) {
160
+ if (['admin', 'owner'].includes(user?.role))
161
+ return true;
162
+ const sessionId = session?.id || session?.sessionId;
163
+ if (!sessionId)
164
+ return false;
165
+ const owner = readSessionOwnership()[sessionOwnershipKey(provider, sessionId)];
166
+ return Boolean(owner?.userId && Number(owner.userId) === Number(user?.id ?? user?.userId));
167
+ }
168
+ function filterProjectSessionsForUser(project, user) {
169
+ if (['admin', 'owner'].includes(user?.role))
170
+ return project;
171
+ return {
172
+ ...project,
173
+ sessions: (project.sessions || []).filter((session) => canUserSeeSession(user, session, 'claude')),
174
+ cursorSessions: (project.cursorSessions || []).filter((session) => canUserSeeSession(user, session, 'cursor')),
175
+ codexSessions: (project.codexSessions || []).filter((session) => canUserSeeSession(user, session, 'codex')),
176
+ geminiSessions: (project.geminiSessions || []).filter((session) => canUserSeeSession(user, session, 'gemini')),
177
+ qwenSessions: (project.qwenSessions || []).filter((session) => canUserSeeSession(user, session, 'qwen')),
178
+ opencodeSessions: (project.opencodeSessions || []).filter((session) => canUserSeeSession(user, session, 'opencode')),
179
+ };
180
+ }
181
+ function reconcileAppliedUpdateStateOnBoot() {
182
+ const state = readSystemUpdateState();
183
+ const pending = state.pendingRestart;
184
+ if (!pending?.toVersion || pending.toVersion !== SERVER_VERSION) {
185
+ return;
186
+ }
187
+ writeSystemUpdateState({
188
+ pendingRestart: null,
189
+ lastAppliedUpdate: {
190
+ ...pending,
191
+ appliedAt: new Date().toISOString(),
192
+ currentVersion: SERVER_VERSION,
193
+ },
194
+ });
195
+ }
196
+ function appendUpdateJobLog(job, stream, chunk) {
197
+ const entry = {
198
+ stream,
199
+ chunk: String(chunk || ''),
200
+ timestamp: new Date().toISOString(),
201
+ };
202
+ job.logs.push(entry);
203
+ if (job.logs.length > SYSTEM_UPDATE_LOG_LIMIT) {
204
+ job.logs.splice(0, job.logs.length - SYSTEM_UPDATE_LOG_LIMIT);
205
+ }
206
+ job.updatedAt = entry.timestamp;
207
+ }
208
+ function snapshotUpdateJob(job) {
209
+ if (!job)
210
+ return null;
211
+ return {
212
+ id: job.id,
213
+ status: job.status,
214
+ startedAt: job.startedAt,
215
+ updatedAt: job.updatedAt,
216
+ completedAt: job.completedAt || null,
217
+ fromVersion: job.fromVersion,
218
+ toVersion: job.toVersion || null,
219
+ installMode: job.installMode,
220
+ runtimeDir: job.runtimeDir || null,
221
+ alreadyLatest: Boolean(job.alreadyLatest),
222
+ pendingRestart: Boolean(job.pendingRestart),
223
+ error: job.error || null,
224
+ logs: job.logs,
225
+ };
226
+ }
227
+ function getActiveUpdateJob() {
228
+ for (const job of updateJobs.values()) {
229
+ if (job.status === 'running' || job.status === 'queued') {
230
+ return job;
231
+ }
232
+ }
233
+ return null;
234
+ }
235
+ async function readLatestPixcodePackageMetadata() {
236
+ const registryRes = await fetch('https://registry.npmjs.org/@pixelbyte-software/pixcode');
237
+ if (!registryRes.ok)
238
+ throw new Error(`Registry returned HTTP ${registryRes.status}`);
239
+ const metadata = await registryRes.json();
240
+ const latestVersion = metadata['dist-tags']?.latest;
241
+ const latestEntry = latestVersion ? metadata.versions?.[latestVersion] : null;
242
+ return {
243
+ latestVersion,
244
+ tarballUrl: latestEntry?.dist?.tarball || null,
245
+ };
246
+ }
247
+ async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl) {
248
+ appendUpdateJobLog(job, 'meta', `Update mode: runtime-dir\nRuntime: ${runtimeDir}\n`);
249
+ appendUpdateJobLog(job, 'meta', `Downloading ${tarballUrl}\n`);
250
+ const tarballRes = await fetch(tarballUrl);
251
+ if (!tarballRes.ok || !tarballRes.body) {
252
+ throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
253
+ }
254
+ const stagingDir = path.join(runtimeDir, '.staging');
255
+ const backupDir = path.join(runtimeDir, '.previous');
256
+ fs.rmSync(stagingDir, { recursive: true, force: true });
257
+ fs.mkdirSync(stagingDir, { recursive: true });
258
+ const { Readable } = await import('node:stream');
259
+ const tarModule = await import('tar');
260
+ const tarExtract = tarModule.x || tarModule.default?.x;
261
+ if (!tarExtract)
262
+ throw new Error('tar extractor not available');
263
+ await new Promise((resolve, reject) => {
264
+ const webStream = tarballRes.body;
265
+ const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
266
+ ? Readable.fromWeb(webStream)
267
+ : webStream;
268
+ const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
269
+ nodeStream.pipe(extractor);
270
+ extractor.on('finish', resolve);
271
+ extractor.on('error', reject);
272
+ nodeStream.on('error', reject);
273
+ });
274
+ appendUpdateJobLog(job, 'meta', 'Staging runtime update for next restart...\n');
275
+ fs.rmSync(backupDir, { recursive: true, force: true });
276
+ fs.mkdirSync(backupDir, { recursive: true });
277
+ for (const entry of fs.readdirSync(stagingDir)) {
278
+ const src = path.join(stagingDir, entry);
279
+ const dst = path.join(runtimeDir, entry);
280
+ if (fs.existsSync(dst)) {
281
+ fs.renameSync(dst, path.join(backupDir, entry));
282
+ }
283
+ fs.renameSync(src, dst);
284
+ }
285
+ fs.rmSync(stagingDir, { recursive: true, force: true });
286
+ const depsChanged = (() => {
287
+ try {
288
+ const prevPkg = JSON.parse(fs.readFileSync(path.join(backupDir, 'package.json'), 'utf8'));
289
+ const nextPkg = JSON.parse(fs.readFileSync(path.join(runtimeDir, 'package.json'), 'utf8'));
290
+ return JSON.stringify(prevPkg.dependencies || {}) !== JSON.stringify(nextPkg.dependencies || {});
291
+ }
292
+ catch {
293
+ return true;
294
+ }
295
+ })();
296
+ if (!depsChanged)
297
+ return latestVersion;
298
+ appendUpdateJobLog(job, 'meta', 'Reconciling node_modules with new package.json...\n');
299
+ await new Promise((resolve, reject) => {
300
+ const npmChild = spawn('npm', ['install', '--production', '--no-audit', '--no-fund', '--no-save'], {
301
+ cwd: runtimeDir,
302
+ env: process.env,
303
+ shell: true,
304
+ stdio: ['ignore', 'pipe', 'pipe'],
305
+ });
306
+ npmChild.stdout?.on('data', (chunk) => appendUpdateJobLog(job, 'stdout', chunk.toString()));
307
+ npmChild.stderr?.on('data', (chunk) => appendUpdateJobLog(job, 'stderr', chunk.toString()));
308
+ npmChild.on('error', reject);
309
+ npmChild.on('close', (code) => {
310
+ if (code === 0)
311
+ resolve();
312
+ else
313
+ reject(new Error(`npm install exited with code ${code}`));
314
+ });
315
+ });
316
+ return latestVersion;
317
+ }
318
+ async function runCommandUpdateJob(job, updateCommand, updateCwd) {
319
+ appendUpdateJobLog(job, 'meta', `Running: ${updateCommand}\n`);
320
+ await new Promise((resolve, reject) => {
321
+ const child = spawn(updateCommand, {
322
+ cwd: updateCwd,
323
+ env: process.env,
324
+ shell: true,
325
+ stdio: ['ignore', 'pipe', 'pipe'],
326
+ });
327
+ child.stdout?.on('data', (data) => appendUpdateJobLog(job, 'stdout', data.toString()));
328
+ child.stderr?.on('data', (data) => appendUpdateJobLog(job, 'stderr', data.toString()));
329
+ child.on('error', reject);
330
+ child.on('close', (code) => {
331
+ if (code === 0)
332
+ resolve();
333
+ else
334
+ reject(new Error(`Update command exited with code ${code}`));
335
+ });
336
+ });
337
+ }
338
+ function readCurrentPackageVersion() {
339
+ try {
340
+ const pkgRaw = fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8');
341
+ return JSON.parse(pkgRaw).version || SERVER_VERSION;
342
+ }
343
+ catch {
344
+ return SERVER_VERSION;
345
+ }
346
+ }
347
+ function createSystemUpdateJob(actorUser) {
348
+ const activeJob = getActiveUpdateJob();
349
+ if (activeJob)
350
+ return activeJob;
351
+ const job = {
352
+ id: crypto.randomUUID(),
353
+ status: 'queued',
354
+ startedAt: new Date().toISOString(),
355
+ updatedAt: new Date().toISOString(),
356
+ completedAt: null,
357
+ fromVersion: SERVER_VERSION,
358
+ toVersion: null,
359
+ installMode,
360
+ runtimeDir: process.env.PIXCODE_RUNTIME_DIR || null,
361
+ actorUserId: actorUser?.id ?? actorUser?.userId ?? null,
362
+ logs: [],
363
+ alreadyLatest: false,
364
+ pendingRestart: false,
365
+ error: null,
366
+ };
367
+ updateJobs.set(job.id, job);
368
+ void (async () => {
369
+ job.status = 'running';
370
+ appendUpdateJobLog(job, 'meta', `Background update job started at ${job.startedAt}\n`);
371
+ try {
372
+ const runtimeDir = job.runtimeDir;
373
+ const gitUpdateScript = path.join(APP_ROOT, 'scripts', 'update-git-install.mjs');
374
+ const latest = await readLatestPixcodePackageMetadata().catch((error) => {
375
+ appendUpdateJobLog(job, 'stderr', `Registry precheck failed: ${error.message}\n`);
376
+ return { latestVersion: null, tarballUrl: null };
377
+ });
378
+ job.toVersion = latest.latestVersion || null;
379
+ if (!IS_PLATFORM && installMode === 'npm' && latest.latestVersion && latest.latestVersion === SERVER_VERSION) {
380
+ job.status = 'completed';
381
+ job.alreadyLatest = true;
382
+ job.completedAt = new Date().toISOString();
383
+ appendUpdateJobLog(job, 'meta', `Already on ${SERVER_VERSION}.\n`);
384
+ return;
385
+ }
386
+ if (runtimeDir) {
387
+ if (!latest.latestVersion || !latest.tarballUrl) {
388
+ throw new Error('Registry response missing latest version or tarball URL.');
389
+ }
390
+ job.toVersion = await runRuntimeDirUpdateJob(job, runtimeDir, latest.latestVersion, latest.tarballUrl);
391
+ }
392
+ else {
393
+ const updateCommand = IS_PLATFORM
394
+ ? 'npm run update:platform'
395
+ : installMode === 'git'
396
+ ? `${JSON.stringify(process.execPath)} ${JSON.stringify(gitUpdateScript)}`
397
+ : 'npm install -g @pixelbyte-software/pixcode@latest';
398
+ const updateCwd = IS_PLATFORM || installMode === 'git' ? APP_ROOT : os.homedir();
399
+ await runCommandUpdateJob(job, updateCommand, updateCwd);
400
+ if (installMode === 'git') {
401
+ job.toVersion = readCurrentPackageVersion();
402
+ }
403
+ }
404
+ job.status = 'completed';
405
+ job.completedAt = new Date().toISOString();
406
+ job.pendingRestart = true;
407
+ const state = readSystemUpdateState();
408
+ writeSystemUpdateState({
409
+ ...state,
410
+ pendingRestart: {
411
+ jobId: job.id,
412
+ fromVersion: job.fromVersion,
413
+ toVersion: job.toVersion || latest.latestVersion || SERVER_VERSION,
414
+ installMode: job.installMode,
415
+ completedAt: job.completedAt,
416
+ logs: job.logs.slice(-80),
417
+ },
418
+ });
419
+ appendUpdateJobLog(job, 'meta', 'Update is ready. Restart when convenient to apply it.\n');
420
+ }
421
+ catch (error) {
422
+ job.status = 'failed';
423
+ job.completedAt = new Date().toISOString();
424
+ job.error = error instanceof Error ? error.message : String(error);
425
+ appendUpdateJobLog(job, 'stderr', `Update failed: ${job.error}\n`);
426
+ }
427
+ })();
428
+ return job;
429
+ }
430
+ reconcileAppliedUpdateStateOnBoot();
431
+ function requireProjectAccess(capability = 'viewFiles') {
432
+ return (req, res, next) => {
433
+ const projectName = req.params.projectName || req.query.project || req.body?.project;
434
+ if (!projectName) {
435
+ return next();
436
+ }
437
+ if (!userHasProjectAccess(req.user, { name: String(projectName), projectName: String(projectName) }, capability)) {
438
+ return res.status(403).json({ error: 'Project access denied.' });
439
+ }
440
+ next();
441
+ };
442
+ }
443
+ function requireProjectPathAccess(capability = 'viewFiles') {
444
+ return (req, res, next) => {
445
+ const projectPath = req.body?.projectPath || req.query.projectPath || os.homedir();
446
+ const resolvedProjectPath = path.resolve(String(projectPath));
447
+ if (!userHasProjectPathAccess(req.user, {
448
+ fullPath: resolvedProjectPath,
449
+ path: resolvedProjectPath,
450
+ projectPath: resolvedProjectPath,
451
+ }, resolvedProjectPath, capability)) {
452
+ return res.status(403).json({ error: 'Project access denied.' });
453
+ }
454
+ next();
455
+ };
456
+ }
105
457
  // File system watchers for provider project/session folders
106
458
  const PROVIDER_WATCH_PATHS = [
107
459
  { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
@@ -181,18 +533,20 @@ async function setupProjectsWatcher() {
181
533
  clearProjectDirectoryCache();
182
534
  // Get updated projects list
183
535
  const updatedProjects = await getProjects(broadcastProgress);
184
- // Notify all connected clients about the project changes
185
- const updateMessage = JSON.stringify({
536
+ const updatePayload = {
186
537
  type: 'projects_updated',
187
- projects: updatedProjects,
188
538
  timestamp: new Date().toISOString(),
189
539
  changeType: eventType,
190
540
  changedFile: path.relative(rootPath, filePath),
191
541
  watchProvider: provider
192
- });
542
+ };
543
+ // Notify all connected clients about project changes, scoped to their access.
193
544
  connectedClients.forEach(client => {
194
545
  if (client.readyState === WebSocket.OPEN) {
195
- client.send(updateMessage);
546
+ client.send(JSON.stringify({
547
+ ...updatePayload,
548
+ projects: filterProjectsForUser(updatedProjects, client.user),
549
+ }));
196
550
  }
197
551
  });
198
552
  }
@@ -261,6 +615,8 @@ const workspaceWatchers = new Map(); // projectName -> { watcher, subscribers, d
261
615
  async function subscribeToWorkspace(ws, projectName) {
262
616
  if (!projectName || typeof projectName !== 'string')
263
617
  return;
618
+ if (!userHasProjectAccess(ws.user, { name: projectName, projectName }, 'viewFiles'))
619
+ return;
264
620
  const existing = workspaceWatchers.get(projectName);
265
621
  if (existing) {
266
622
  existing.subscribers.add(ws);
@@ -382,11 +738,12 @@ function terminatePtySession(sessionKey, session, reason) {
382
738
  ptySessionsMap.delete(sessionKey);
383
739
  return true;
384
740
  }
385
- function killProviderPtySessions(projectPath, provider) {
741
+ function killProviderPtySessions(projectPath, provider, userId = null) {
386
742
  let killed = 0;
387
743
  for (const [sessionKey, session] of ptySessionsMap.entries()) {
388
744
  if (session?.projectPath === projectPath &&
389
745
  session?.provider === provider &&
746
+ (!userId || session?.userId === userId) &&
390
747
  !session?.isPlainShell) {
391
748
  killed += terminatePtySession(sessionKey, session, 'fresh provider session') ? 1 : 0;
392
749
  }
@@ -618,6 +975,24 @@ function buildProviderShellCommand(command, permissionFlags = []) {
618
975
  const flags = Array.isArray(permissionFlags) ? permissionFlags.filter(Boolean) : [];
619
976
  return flags.length > 0 ? `${command} ${flags.join(' ')}` : command;
620
977
  }
978
+ function hideProviderApprovalChoiceLines(output) {
979
+ 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;
980
+ return String(output || '')
981
+ .split(/(\r\n|\n|\r)/u)
982
+ .reduce((parts, part, index, chunks) => {
983
+ if (part === '\r\n' || part === '\n' || part === '\r') {
984
+ const previousWasHidden = chunks[index - 1] && approvalChoicePattern.test(stripAnsiSequences(chunks[index - 1]));
985
+ if (!previousWasHidden)
986
+ parts.push(part);
987
+ return parts;
988
+ }
989
+ if (!approvalChoicePattern.test(stripAnsiSequences(part))) {
990
+ parts.push(part);
991
+ }
992
+ return parts;
993
+ }, [])
994
+ .join('');
995
+ }
621
996
  function resolvePublicBaseUrl(request) {
622
997
  const headers = request?.headers || {};
623
998
  const forwardedProto = String(headers['x-forwarded-proto'] || '').split(',')[0].trim();
@@ -707,15 +1082,11 @@ const wss = new WebSocketServer({
707
1082
  server,
708
1083
  verifyClient: (info) => {
709
1084
  console.log('WebSocket connection attempt to:', info.req.url);
710
- // Platform mode: always allow connection
711
- if (IS_PLATFORM) {
712
- const user = authenticateWebSocket(null); // Will return first user
713
- if (!user) {
714
- console.log('[WARN] Platform mode: No user found in database');
715
- return false;
716
- }
1085
+ const platformBypassUser = IS_PLATFORM ? authenticateWebSocket(null) : null;
1086
+ if (platformBypassUser) {
1087
+ const user = platformBypassUser;
717
1088
  info.req.user = user;
718
- console.log('[OK] Platform mode WebSocket authenticated for user:', user.username);
1089
+ console.log('[OK] Platform mode WebSocket authenticated via explicit bypass for user:', user.username);
719
1090
  return true;
720
1091
  }
721
1092
  // Normal mode: verify token
@@ -755,25 +1126,28 @@ app.use(express.json({
755
1126
  app.use(express.urlencoded({ limit: '50mb', extended: true }));
756
1127
  // Public health check endpoint (no authentication required)
757
1128
  app.get('/health', (req, res) => {
1129
+ const updateState = readSystemUpdateState();
758
1130
  res.json({
759
1131
  status: 'ok',
760
1132
  timestamp: new Date().toISOString(),
761
1133
  installMode,
762
- version: SERVER_VERSION
1134
+ version: SERVER_VERSION,
1135
+ pendingRestart: updateState.pendingRestart,
1136
+ lastAppliedUpdate: updateState.lastAppliedUpdate,
763
1137
  });
764
1138
  });
765
1139
  // Optional API key validation (if configured)
766
1140
  app.use('/api', validateApiKey);
767
- app.post('/api/shell/sessions/terminate', authenticateToken, (req, res) => {
1141
+ app.post('/api/shell/sessions/terminate', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
768
1142
  const provider = req.body?.provider || 'claude';
769
1143
  const projectPath = req.body?.projectPath || os.homedir();
770
1144
  if (!SHELL_CLI_PROVIDERS.has(provider)) {
771
1145
  return res.status(400).json({ error: 'Unsupported provider' });
772
1146
  }
773
- const killedSessions = killProviderPtySessions(projectPath, provider);
1147
+ const killedSessions = killProviderPtySessions(projectPath, provider, ['admin', 'owner'].includes(req.user?.role) ? null : (req.user?.id ?? req.user?.userId ?? null));
774
1148
  res.json({ success: true, killedSessions });
775
1149
  });
776
- app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) => {
1150
+ app.get('/api/shell/sessions/provider-output', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
777
1151
  const provider = String(req.query.provider || 'claude');
778
1152
  const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.trim()
779
1153
  ? req.query.projectPath.trim()
@@ -785,10 +1159,13 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) =>
785
1159
  return res.status(400).json({ error: 'Unsupported provider' });
786
1160
  }
787
1161
  const requestedProjectPath = projectPath ? path.resolve(projectPath) : null;
1162
+ const requestUserId = req.user?.id ?? req.user?.userId ?? null;
1163
+ const canReadAnyShellSession = ['admin', 'owner'].includes(req.user?.role);
788
1164
  let matchedSession = null;
789
1165
  for (const session of ptySessionsMap.values()) {
790
1166
  if (session?.provider === provider &&
791
1167
  !session?.isPlainShell &&
1168
+ (canReadAnyShellSession || session?.userId === requestUserId) &&
792
1169
  (!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
793
1170
  (!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)) {
794
1171
  if (!matchedSession || (session.updatedAt || 0) > (matchedSession.updatedAt || 0)) {
@@ -820,7 +1197,7 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) =>
820
1197
  output,
821
1198
  });
822
1199
  });
823
- app.post('/api/shell/sessions/provider-input', authenticateToken, (req, res) => {
1200
+ app.post('/api/shell/sessions/provider-input', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
824
1201
  const provider = String(req.body?.provider || 'claude');
825
1202
  const projectPath = typeof req.body?.projectPath === 'string' && req.body.projectPath.trim()
826
1203
  ? req.body.projectPath.trim()
@@ -833,12 +1210,15 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, (req, res) =>
833
1210
  return res.status(400).json({ error: 'Unsupported provider' });
834
1211
  }
835
1212
  const requestedProjectPath = projectPath ? path.resolve(projectPath) : null;
1213
+ const requestUserId = req.user?.id ?? req.user?.userId ?? null;
1214
+ const canWriteAnyShellSession = ['admin', 'owner'].includes(req.user?.role);
836
1215
  let matchedSession = null;
837
1216
  for (const session of ptySessionsMap.values()) {
838
1217
  if (session?.provider === provider &&
839
1218
  !session?.isPlainShell &&
840
1219
  session?.pty &&
841
1220
  session.lifecycleState === 'running' &&
1221
+ (canWriteAnyShellSession || session?.userId === requestUserId) &&
842
1222
  (!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
843
1223
  (!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)) {
844
1224
  if (!matchedSession || (session.updatedAt || 0) > (matchedSession.updatedAt || 0)) {
@@ -916,8 +1296,8 @@ app.use('/api/public', authenticateToken, publicApiRoutes);
916
1296
  app.use('/api/webhooks', authenticateToken, webhooksRoutes);
917
1297
  // Production agent loop APIs (protected)
918
1298
  app.use('/api/production-agent-loop', authenticateToken, productionAgentLoopRoutes);
919
- // Platform control plane APIs (protected)
920
- app.use('/api/platformization', authenticateToken, platformizationRoutes);
1299
+ // Platform control plane APIs (admin-only)
1300
+ app.use('/api/platformization', authenticateToken, requireAdmin, platformizationRoutes);
921
1301
  // Project Live View (protected control API + public share proxy)
922
1302
  app.use('/api/live-view', authenticateToken, liveViewRoutes);
923
1303
  // Unified provider MCP routes (protected)
@@ -931,14 +1311,14 @@ adapterRegistry.register(new QwenA2AAdapter());
931
1311
  adapterRegistry.register(new OpenCodeA2AAdapter());
932
1312
  adapterRegistry.register(new JsonEventA2AAdapter());
933
1313
  app.use('/hermes', createHermesTaskRouter());
934
- app.use('/preview', authenticateToken, createPreviewProxyRouter());
935
- app.use('/api/orchestration', authenticateToken, createOrchestrationTaskRouter());
936
- app.use('/api/orchestration/hermes', authenticateToken, createHermesRouter({
1314
+ app.use('/preview', authenticateToken, requireAdmin, createPreviewProxyRouter());
1315
+ app.use('/api/orchestration', authenticateToken, requireAdmin, createOrchestrationTaskRouter());
1316
+ app.use('/api/orchestration/hermes', authenticateToken, requireAdmin, createHermesRouter({
937
1317
  appRoot: APP_ROOT,
938
1318
  createHermesApiKey: getOrCreateHermesApiKey,
939
1319
  resolvePublicBaseUrl,
940
1320
  }));
941
- app.use('/api/orchestration', authenticateToken, createWorkflowRouter());
1321
+ app.use('/api/orchestration', authenticateToken, requireAdmin, createWorkflowRouter());
942
1322
  app.use('/live', createLiveViewPublicRouter());
943
1323
  // Network discovery / QR endpoints (protected)
944
1324
  app.use('/api/network', authenticateToken, networkRoutes);
@@ -983,6 +1363,34 @@ app.use(express.static(path.join(APP_ROOT, 'public'), {
983
1363
  // API Routes (protected)
984
1364
  // /api/config endpoint removed - no longer needed
985
1365
  // Frontend now uses window.location for WebSocket URLs
1366
+ app.get('/api/system/update-state', authenticateToken, (req, res) => {
1367
+ res.json({
1368
+ success: true,
1369
+ state: readSystemUpdateState(),
1370
+ activeJob: snapshotUpdateJob(getActiveUpdateJob()),
1371
+ currentVersion: SERVER_VERSION,
1372
+ installMode,
1373
+ capabilities: {
1374
+ canBackgroundUpdate: true,
1375
+ canRestart: true,
1376
+ startupUpdateDefault: false,
1377
+ },
1378
+ });
1379
+ });
1380
+ app.post('/api/system/update-jobs', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
1381
+ const job = createSystemUpdateJob(req.user);
1382
+ res.status(job.status === 'queued' ? 202 : 200).json({
1383
+ success: true,
1384
+ job: snapshotUpdateJob(job),
1385
+ });
1386
+ });
1387
+ app.get('/api/system/update-jobs/:jobId', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
1388
+ const job = updateJobs.get(req.params.jobId);
1389
+ if (!job) {
1390
+ return res.status(404).json({ success: false, error: 'Update job not found' });
1391
+ }
1392
+ res.json({ success: true, job: snapshotUpdateJob(job) });
1393
+ });
986
1394
  // System update endpoint — streams live output via Server-Sent Events so the
987
1395
  // UI sees npm/git progress in real time instead of waiting ~2 minutes for the
988
1396
  // buffered response.
@@ -996,7 +1404,7 @@ app.use(express.static(path.join(APP_ROOT, 'public'), {
996
1404
  // checkout state before pulling so source installs do not fail on local
997
1405
  // modified files left by older releases or manual edits.
998
1406
  // 3. fallback → `npm install -g …` (classic npm-distributed install).
999
- app.post('/api/system/update', authenticateToken, async (req, res) => {
1407
+ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope('system:update'), async (req, res) => {
1000
1408
  const projectRoot = APP_ROOT;
1001
1409
  console.log('Starting system update from directory:', projectRoot);
1002
1410
  const runtimeDir = process.env.PIXCODE_RUNTIME_DIR || null;
@@ -1345,7 +1753,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
1345
1753
  // Restart endpoint — exits the current process so an external wrapper
1346
1754
  // (systemd/pm2/daemon manager) can bring the server back on the new code.
1347
1755
  // Foreground installs without a wrapper will simply stop; the UI reports this.
1348
- app.post('/api/system/restart', authenticateToken, (req, res) => {
1756
+ app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
1349
1757
  res.json({
1350
1758
  success: true,
1351
1759
  version: SERVER_VERSION,
@@ -1360,16 +1768,19 @@ app.post('/api/system/restart', authenticateToken, (req, res) => {
1360
1768
  app.get('/api/projects', authenticateToken, async (req, res) => {
1361
1769
  try {
1362
1770
  const projects = await getProjects(broadcastProgress);
1363
- res.json(projects);
1771
+ res.json(filterProjectsForUser(projects, req.user).map((project) => filterProjectSessionsForUser(project, req.user)));
1364
1772
  }
1365
1773
  catch (error) {
1366
1774
  res.status(500).json({ error: error.message });
1367
1775
  }
1368
1776
  });
1369
- app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => {
1777
+ app.get('/api/projects/:projectName/sessions', authenticateToken, requireProjectAccess('viewFiles'), async (req, res) => {
1370
1778
  try {
1371
1779
  const { limit = 5, offset = 0 } = req.query;
1372
1780
  const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
1781
+ if (!['admin', 'owner'].includes(req.user?.role)) {
1782
+ result.sessions = (result.sessions || []).filter((session) => canUserSeeSession(req.user, session, 'claude'));
1783
+ }
1373
1784
  applyCustomSessionNames(result.sessions, 'claude');
1374
1785
  res.json(result);
1375
1786
  }
@@ -1378,7 +1789,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, re
1378
1789
  }
1379
1790
  });
1380
1791
  // Rename project endpoint
1381
- app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
1792
+ app.put('/api/projects/:projectName/rename', authenticateToken, requireProjectAccess('manageProjectSettings'), async (req, res) => {
1382
1793
  try {
1383
1794
  const { displayName } = req.body;
1384
1795
  await renameProject(req.params.projectName, displayName);
@@ -1389,7 +1800,7 @@ app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res)
1389
1800
  }
1390
1801
  });
1391
1802
  // Delete session endpoint
1392
- app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
1803
+ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, requireProjectAccess('editFiles'), async (req, res) => {
1393
1804
  try {
1394
1805
  const { projectName, sessionId } = req.params;
1395
1806
  console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
@@ -1432,7 +1843,7 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
1432
1843
  // Delete project endpoint
1433
1844
  // force=true to allow removal even when sessions exist
1434
1845
  // deleteData=true to also delete session/memory files on disk (destructive)
1435
- app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
1846
+ app.delete('/api/projects/:projectName', authenticateToken, requireProjectAccess('manageProjectSettings'), async (req, res) => {
1436
1847
  try {
1437
1848
  const { projectName } = req.params;
1438
1849
  const force = req.query.force === 'true';
@@ -1450,7 +1861,7 @@ app.delete('/api/projects/:projectName', authenticateToken, async (req, res) =>
1450
1861
  }
1451
1862
  });
1452
1863
  // Search conversations content (SSE streaming)
1453
- app.get('/api/search/conversations', authenticateToken, async (req, res) => {
1864
+ app.get('/api/search/conversations', authenticateToken, requireAdmin, async (req, res) => {
1454
1865
  const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
1455
1866
  const parsedLimit = Number.parseInt(String(req.query.limit), 10);
1456
1867
  const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100));
@@ -1515,7 +1926,7 @@ const expandBrowsePath = (inputPath) => {
1515
1926
  return path.resolve(trimmed);
1516
1927
  };
1517
1928
  // Browse filesystem endpoint for project suggestions - uses existing getFileTree
1518
- app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
1929
+ app.get('/api/browse-filesystem', authenticateToken, requireAdmin, async (req, res) => {
1519
1930
  try {
1520
1931
  const { path: dirPath } = req.query;
1521
1932
  console.log('[API] Browse filesystem request for path:', dirPath);
@@ -1599,7 +2010,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
1599
2010
  res.status(500).json({ error: 'Failed to browse filesystem' });
1600
2011
  }
1601
2012
  });
1602
- app.post('/api/create-folder', authenticateToken, async (req, res) => {
2013
+ app.post('/api/create-folder', authenticateToken, requireAdmin, async (req, res) => {
1603
2014
  try {
1604
2015
  const { path: folderPath } = req.body;
1605
2016
  if (!folderPath) {
@@ -1643,7 +2054,7 @@ app.post('/api/create-folder', authenticateToken, async (req, res) => {
1643
2054
  }
1644
2055
  });
1645
2056
  // Read file content endpoint
1646
- app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
2057
+ app.get('/api/projects/:projectName/file', authenticateToken, requireProjectAccess('viewFiles'), async (req, res) => {
1647
2058
  try {
1648
2059
  const { projectName } = req.params;
1649
2060
  const { filePath } = req.query;
@@ -1663,6 +2074,9 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) =
1663
2074
  if (!resolved.startsWith(normalizedRoot)) {
1664
2075
  return res.status(403).json({ error: 'Path must be under project root' });
1665
2076
  }
2077
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolved, 'viewFiles')) {
2078
+ return res.status(403).json({ error: 'Folder access denied' });
2079
+ }
1666
2080
  const content = await fsPromises.readFile(resolved, 'utf8');
1667
2081
  res.json({ content, path: resolved });
1668
2082
  }
@@ -1680,7 +2094,7 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) =
1680
2094
  }
1681
2095
  });
1682
2096
  // Serve raw file bytes for previews and downloads.
1683
- app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
2097
+ app.get('/api/projects/:projectName/files/content', authenticateToken, requireProjectAccess('viewFiles'), async (req, res) => {
1684
2098
  try {
1685
2099
  const { projectName } = req.params;
1686
2100
  const { path: filePath } = req.query;
@@ -1701,6 +2115,9 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
1701
2115
  if (!resolved.startsWith(normalizedRoot)) {
1702
2116
  return res.status(403).json({ error: 'Path must be under project root' });
1703
2117
  }
2118
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolved, 'viewFiles')) {
2119
+ return res.status(403).json({ error: 'Folder access denied' });
2120
+ }
1704
2121
  // Check if file exists
1705
2122
  try {
1706
2123
  await fsPromises.access(resolved);
@@ -1729,7 +2146,7 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
1729
2146
  }
1730
2147
  });
1731
2148
  // Save file content endpoint
1732
- app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
2149
+ app.put('/api/projects/:projectName/file', authenticateToken, requireProjectAccess('editFiles'), async (req, res) => {
1733
2150
  try {
1734
2151
  const { projectName } = req.params;
1735
2152
  const { filePath, content } = req.body;
@@ -1752,6 +2169,9 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
1752
2169
  if (!resolved.startsWith(normalizedRoot)) {
1753
2170
  return res.status(403).json({ error: 'Path must be under project root' });
1754
2171
  }
2172
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolved, 'editFiles')) {
2173
+ return res.status(403).json({ error: 'Folder access denied' });
2174
+ }
1755
2175
  // Write the new content
1756
2176
  await fsPromises.writeFile(resolved, content, 'utf8');
1757
2177
  res.json({
@@ -1773,7 +2193,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
1773
2193
  }
1774
2194
  }
1775
2195
  });
1776
- app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
2196
+ app.get('/api/projects/:projectName/files', authenticateToken, requireProjectAccess('viewFiles'), async (req, res) => {
1777
2197
  try {
1778
2198
  // Using fsPromises from import
1779
2199
  // Use extractProjectDirectory to get the actual project path
@@ -1798,7 +2218,12 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
1798
2218
  return res.status(404).json({ error: `Project path not found: ${actualPath}` });
1799
2219
  }
1800
2220
  const files = await getFileTree(actualPath, 10, 0, true);
1801
- res.json(files);
2221
+ res.json(filterFileTreeForUser(files, req.user, {
2222
+ name: req.params.projectName,
2223
+ projectName: req.params.projectName,
2224
+ fullPath: actualPath,
2225
+ path: actualPath,
2226
+ }, 'viewFiles'));
1802
2227
  }
1803
2228
  catch (error) {
1804
2229
  console.error('[ERROR] File tree error:', error.message);
@@ -1850,7 +2275,7 @@ function validateFilename(name) {
1850
2275
  return { valid: true };
1851
2276
  }
1852
2277
  // POST /api/projects/:projectName/files/create - Create new file or directory
1853
- app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => {
2278
+ app.post('/api/projects/:projectName/files/create', authenticateToken, requireProjectAccess('editFiles'), async (req, res) => {
1854
2279
  try {
1855
2280
  const { projectName } = req.params;
1856
2281
  const { path: parentPath, type, name } = req.body;
@@ -1878,6 +2303,9 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, async (re
1878
2303
  return res.status(403).json({ error: validation.error });
1879
2304
  }
1880
2305
  const resolvedPath = validation.resolved;
2306
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedPath, 'editFiles')) {
2307
+ return res.status(403).json({ error: 'Folder access denied' });
2308
+ }
1881
2309
  // Check if already exists
1882
2310
  try {
1883
2311
  await fsPromises.access(resolvedPath);
@@ -1923,7 +2351,7 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, async (re
1923
2351
  }
1924
2352
  });
1925
2353
  // PUT /api/projects/:projectName/files/rename - Rename file or directory
1926
- app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => {
2354
+ app.put('/api/projects/:projectName/files/rename', authenticateToken, requireProjectAccess('editFiles'), async (req, res) => {
1927
2355
  try {
1928
2356
  const { projectName } = req.params;
1929
2357
  const { oldPath, newName } = req.body;
@@ -1946,6 +2374,9 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req
1946
2374
  return res.status(403).json({ error: oldValidation.error });
1947
2375
  }
1948
2376
  const resolvedOldPath = oldValidation.resolved;
2377
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedOldPath, 'editFiles')) {
2378
+ return res.status(403).json({ error: 'Folder access denied' });
2379
+ }
1949
2380
  // Check if old path exists
1950
2381
  try {
1951
2382
  await fsPromises.access(resolvedOldPath);
@@ -1960,6 +2391,9 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req
1960
2391
  if (!newValidation.valid) {
1961
2392
  return res.status(403).json({ error: newValidation.error });
1962
2393
  }
2394
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedNewPath, 'editFiles')) {
2395
+ return res.status(403).json({ error: 'Folder access denied' });
2396
+ }
1963
2397
  // Check if new path already exists
1964
2398
  try {
1965
2399
  await fsPromises.access(resolvedNewPath);
@@ -1995,7 +2429,7 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req
1995
2429
  }
1996
2430
  });
1997
2431
  // DELETE /api/projects/:projectName/files - Delete file or directory
1998
- app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
2432
+ app.delete('/api/projects/:projectName/files', authenticateToken, requireProjectAccess('editFiles'), async (req, res) => {
1999
2433
  try {
2000
2434
  const { projectName } = req.params;
2001
2435
  const { path: targetPath, type } = req.body;
@@ -2014,6 +2448,9 @@ app.delete('/api/projects/:projectName/files', authenticateToken, async (req, re
2014
2448
  return res.status(403).json({ error: validation.error });
2015
2449
  }
2016
2450
  const resolvedPath = validation.resolved;
2451
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedPath, 'editFiles')) {
2452
+ return res.status(403).json({ error: 'Folder access denied' });
2453
+ }
2017
2454
  // Check if path exists and get stats
2018
2455
  let stats;
2019
2456
  try {
@@ -2139,6 +2576,9 @@ const uploadFilesHandler = async (req, res) => {
2139
2576
  resolvedTargetDir = validation.resolved;
2140
2577
  console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
2141
2578
  }
2579
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedTargetDir, 'editFiles')) {
2580
+ return res.status(403).json({ error: 'Folder access denied' });
2581
+ }
2142
2582
  // Ensure target directory exists
2143
2583
  try {
2144
2584
  await fsPromises.access(resolvedTargetDir);
@@ -2163,6 +2603,10 @@ const uploadFilesHandler = async (req, res) => {
2163
2603
  await fsPromises.unlink(file.path).catch(() => { });
2164
2604
  continue;
2165
2605
  }
2606
+ if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, destPath, 'editFiles')) {
2607
+ await fsPromises.unlink(file.path).catch(() => { });
2608
+ continue;
2609
+ }
2166
2610
  // Ensure parent directory exists (for nested files from folder upload)
2167
2611
  const parentDir = path.dirname(destPath);
2168
2612
  try {
@@ -2205,7 +2649,7 @@ const uploadFilesHandler = async (req, res) => {
2205
2649
  }
2206
2650
  });
2207
2651
  };
2208
- app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler);
2652
+ app.post('/api/projects/:projectName/files/upload', authenticateToken, requireProjectAccess('editFiles'), uploadFilesHandler);
2209
2653
  /**
2210
2654
  * Proxy an authenticated client WebSocket to a plugin's internal WS server.
2211
2655
  * Auth is enforced by verifyClient before this function is reached.
@@ -2282,13 +2726,35 @@ class WebSocketWriter {
2282
2726
  this.ws = ws;
2283
2727
  this.sessionId = null;
2284
2728
  this.userId = userId;
2729
+ this.provider = 'claude';
2730
+ this.projectName = null;
2731
+ this.projectPath = null;
2285
2732
  this.isWebSocketWriter = true; // Marker for transport detection
2286
2733
  }
2287
2734
  send(data) {
2735
+ const nextSessionId = data?.sessionId || data?.session_id || data?.session?.id;
2736
+ if (nextSessionId) {
2737
+ this.setSessionId(nextSessionId);
2738
+ recordSessionOwnership({
2739
+ provider: data.provider || this.provider,
2740
+ sessionId: nextSessionId,
2741
+ userId: this.userId,
2742
+ projectName: this.projectName,
2743
+ projectPath: this.projectPath,
2744
+ });
2745
+ }
2288
2746
  if (this.ws.readyState === 1) { // WebSocket.OPEN
2289
2747
  this.ws.send(JSON.stringify(data));
2290
2748
  }
2291
2749
  }
2750
+ setContext({ provider, projectName, projectPath } = {}) {
2751
+ if (provider)
2752
+ this.provider = provider;
2753
+ if (projectName)
2754
+ this.projectName = projectName;
2755
+ if (projectPath)
2756
+ this.projectPath = projectPath;
2757
+ }
2292
2758
  updateWebSocket(newRawWs) {
2293
2759
  this.ws = newRawWs;
2294
2760
  }
@@ -2304,9 +2770,47 @@ function handleChatConnection(ws, request) {
2304
2770
  console.log('[INFO] Chat WebSocket connected');
2305
2771
  // Add to connected clients for project updates
2306
2772
  ws.userId = request?.user?.id ?? request?.user?.userId ?? null;
2773
+ ws.user = request?.user ?? null;
2307
2774
  connectedClients.add(ws);
2308
2775
  // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
2309
2776
  const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null);
2777
+ const isAdminChatUser = () => ['admin', 'owner'].includes(request?.user?.role);
2778
+ const requireAdminChatAction = (action) => {
2779
+ if (!isAdminChatUser()) {
2780
+ throw new Error(`${action} requires admin access until session ownership metadata is available.`);
2781
+ }
2782
+ };
2783
+ const assertChatCommandAccess = (data) => {
2784
+ const options = data?.options || {};
2785
+ if ((options.permissionMode === 'bypassPermissions' || options.skipPermissions === true) && !isAdminChatUser()) {
2786
+ throw new Error('Bypass/skip permission modes require admin access.');
2787
+ }
2788
+ const projectName = typeof options.projectName === 'string' ? options.projectName : '';
2789
+ const projectPath = typeof options.projectPath === 'string'
2790
+ ? options.projectPath
2791
+ : (typeof options.cwd === 'string' ? options.cwd : '');
2792
+ const projectRef = projectName
2793
+ ? { name: projectName, projectName }
2794
+ : (projectPath ? { fullPath: path.resolve(projectPath), path: path.resolve(projectPath), projectPath: path.resolve(projectPath) } : null);
2795
+ if (!projectRef) {
2796
+ if (isAdminChatUser())
2797
+ return;
2798
+ throw new Error('Project context is required for agent commands.');
2799
+ }
2800
+ if (!userHasProjectAccess(request.user, projectRef, 'chatAgents')) {
2801
+ throw new Error('Project access denied.');
2802
+ }
2803
+ };
2804
+ const setWriterCommandContext = (provider, data) => {
2805
+ const options = data?.options || {};
2806
+ writer.setContext({
2807
+ provider,
2808
+ projectName: typeof options.projectName === 'string' ? options.projectName : null,
2809
+ projectPath: typeof options.projectPath === 'string'
2810
+ ? options.projectPath
2811
+ : (typeof options.cwd === 'string' ? options.cwd : null),
2812
+ });
2813
+ };
2310
2814
  ws.on('message', async (message) => {
2311
2815
  try {
2312
2816
  const data = JSON.parse(message);
@@ -2317,31 +2821,44 @@ function handleChatConnection(ws, request) {
2317
2821
  ? (provider, d) => console.log(`[${provider}]`, d.command?.slice(0, 60) || '[resume]', d.options?.projectPath || d.options?.cwd || '', d.options?.sessionId ? 'resume' : 'new')
2318
2822
  : () => { };
2319
2823
  if (data.type === 'claude-command') {
2824
+ assertChatCommandAccess(data);
2825
+ setWriterCommandContext('claude', data);
2320
2826
  chatDebug('claude', data);
2321
2827
  // Use Claude Agents SDK
2322
2828
  await queryClaudeSDK(data.command, data.options, writer);
2323
2829
  }
2324
2830
  else if (data.type === 'cursor-command') {
2831
+ assertChatCommandAccess(data);
2832
+ setWriterCommandContext('cursor', data);
2325
2833
  chatDebug('cursor', data);
2326
2834
  await spawnCursor(data.command, data.options, writer);
2327
2835
  }
2328
2836
  else if (data.type === 'codex-command') {
2837
+ assertChatCommandAccess(data);
2838
+ setWriterCommandContext('codex', data);
2329
2839
  chatDebug('codex', data);
2330
2840
  await queryCodex(data.command, data.options, writer);
2331
2841
  }
2332
2842
  else if (data.type === 'gemini-command') {
2843
+ assertChatCommandAccess(data);
2844
+ setWriterCommandContext('gemini', data);
2333
2845
  chatDebug('gemini', data);
2334
2846
  await spawnGemini(data.command, data.options, writer);
2335
2847
  }
2336
2848
  else if (data.type === 'qwen-command') {
2849
+ assertChatCommandAccess(data);
2850
+ setWriterCommandContext('qwen', data);
2337
2851
  chatDebug('qwen', data);
2338
2852
  await spawnQwen(data.command, data.options, writer);
2339
2853
  }
2340
2854
  else if (data.type === 'opencode-command') {
2855
+ assertChatCommandAccess(data);
2856
+ setWriterCommandContext('opencode', data);
2341
2857
  chatDebug('opencode', data);
2342
2858
  await spawnOpencode(data.command, data.options, writer);
2343
2859
  }
2344
2860
  else if (data.type === 'cursor-resume') {
2861
+ assertChatCommandAccess({ options: { cwd: data.options?.cwd } });
2345
2862
  // Backward compatibility: treat as cursor-command with resume and no prompt
2346
2863
  console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
2347
2864
  await spawnCursor('', {
@@ -2351,6 +2868,7 @@ function handleChatConnection(ws, request) {
2351
2868
  }, writer);
2352
2869
  }
2353
2870
  else if (data.type === 'abort-session') {
2871
+ requireAdminChatAction('Aborting sessions');
2354
2872
  console.log('[DEBUG] Abort session request:', data.sessionId);
2355
2873
  const provider = data.provider || 'claude';
2356
2874
  let success;
@@ -2376,6 +2894,7 @@ function handleChatConnection(ws, request) {
2376
2894
  writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider }));
2377
2895
  }
2378
2896
  else if (data.type === 'claude-permission-response') {
2897
+ requireAdminChatAction('Resolving tool approvals');
2379
2898
  // Relay UI approval decisions back into the SDK control flow.
2380
2899
  // This does not persist permissions; it only resolves the in-flight request,
2381
2900
  // introduced so the SDK can resume once the user clicks Allow/Deny.
@@ -2389,11 +2908,13 @@ function handleChatConnection(ws, request) {
2389
2908
  }
2390
2909
  }
2391
2910
  else if (data.type === 'cursor-abort') {
2911
+ requireAdminChatAction('Aborting sessions');
2392
2912
  console.log('[DEBUG] Abort Cursor session:', data.sessionId);
2393
2913
  const success = abortCursorSession(data.sessionId);
2394
2914
  writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider: 'cursor' }));
2395
2915
  }
2396
2916
  else if (data.type === 'check-session-status') {
2917
+ requireAdminChatAction('Checking global session status');
2397
2918
  // Check if a specific session is currently processing
2398
2919
  const provider = data.provider || 'claude';
2399
2920
  const sessionId = data.sessionId;
@@ -2430,6 +2951,7 @@ function handleChatConnection(ws, request) {
2430
2951
  });
2431
2952
  }
2432
2953
  else if (data.type === 'get-pending-permissions') {
2954
+ requireAdminChatAction('Reading pending permissions');
2433
2955
  // Return pending permission requests for a session
2434
2956
  const sessionId = data.sessionId;
2435
2957
  if (sessionId && isClaudeSDKSessionActive(sessionId)) {
@@ -2451,6 +2973,7 @@ function handleChatConnection(ws, request) {
2451
2973
  unsubscribeFromWorkspace(ws, data.projectName || null);
2452
2974
  }
2453
2975
  else if (data.type === 'get-active-sessions') {
2976
+ requireAdminChatAction('Listing active sessions');
2454
2977
  // Get all currently active sessions
2455
2978
  const activeSessions = {
2456
2979
  claude: getActiveClaudeSDKSessions(),
@@ -2508,6 +3031,15 @@ function handleShellConnection(ws, request) {
2508
3031
  // is writable, has a git-friendly cwd, and matches where
2509
3032
  // every provider already stores its config (~/.codex etc.).
2510
3033
  const projectPath = data.projectPath || os.homedir();
3034
+ const requestedProjectPath = path.resolve(projectPath);
3035
+ if (!userHasProjectPathAccess(request.user, {
3036
+ fullPath: requestedProjectPath,
3037
+ path: requestedProjectPath,
3038
+ projectPath: requestedProjectPath,
3039
+ }, requestedProjectPath, 'useShell')) {
3040
+ ws.send(JSON.stringify({ type: 'error', message: 'Shell access denied for this project' }));
3041
+ return;
3042
+ }
2511
3043
  const sessionId = data.sessionId;
2512
3044
  const hasSession = data.hasSession;
2513
3045
  const provider = data.provider || 'claude';
@@ -2553,7 +3085,8 @@ function handleShellConnection(ws, request) {
2553
3085
  // which collided across providers and made every disconnect →
2554
3086
  // switch-provider flow reopen the previously-running CLI.
2555
3087
  const providerSuffix = isPlainShell ? '' : `_${provider}`;
2556
- ptySessionKey = `${projectPath}_${sessionId || 'default'}${providerSuffix}${commandSuffix}`;
3088
+ const ownerUserId = request.user?.id ?? request.user?.userId ?? 'anonymous';
3089
+ ptySessionKey = `${ownerUserId}_${projectPath}_${sessionId || 'default'}${providerSuffix}${commandSuffix}`;
2557
3090
  // Kill any existing login session before starting fresh
2558
3091
  if (isLoginCommand) {
2559
3092
  const oldSession = ptySessionsMap.get(ptySessionKey);
@@ -2569,7 +3102,7 @@ function handleShellConnection(ws, request) {
2569
3102
  }
2570
3103
  }
2571
3104
  else {
2572
- const killedSessions = killProviderPtySessions(projectPath, provider);
3105
+ const killedSessions = killProviderPtySessions(projectPath, provider, ownerUserId);
2573
3106
  if (killedSessions > 0) {
2574
3107
  console.log(`🧹 Fresh ${provider} session requested; terminated ${killedSessions} cached PTY session(s).`);
2575
3108
  }
@@ -2634,7 +3167,7 @@ function handleShellConnection(ws, request) {
2634
3167
  }));
2635
3168
  try {
2636
3169
  // Validate projectPath — resolve to absolute and verify it exists
2637
- const resolvedProjectPath = path.resolve(projectPath);
3170
+ const resolvedProjectPath = requestedProjectPath;
2638
3171
  try {
2639
3172
  const stats = fs.statSync(resolvedProjectPath);
2640
3173
  if (!stats.isDirectory()) {
@@ -2805,6 +3338,7 @@ function handleShellConnection(ws, request) {
2805
3338
  ws: ws,
2806
3339
  buffer: [],
2807
3340
  timeoutId: null,
3341
+ userId: ownerUserId,
2808
3342
  projectPath,
2809
3343
  sessionId,
2810
3344
  hermesLaunchId,
@@ -2835,6 +3369,7 @@ function handleShellConnection(ws, request) {
2835
3369
  const cleanChunk = stripAnsiSequences(data);
2836
3370
  urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
2837
3371
  outputData = outputData.replace(/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g, '[INFO] Opening in browser: $1');
3372
+ outputData = hideProviderApprovalChoiceLines(outputData);
2838
3373
  const emitAuthUrl = (detectedUrl, autoOpen = false) => {
2839
3374
  const normalizedUrl = normalizeDetectedUrl(detectedUrl);
2840
3375
  if (!normalizedUrl)
@@ -2860,10 +3395,12 @@ function handleShellConnection(ws, request) {
2860
3395
  emitAuthUrl(bestUrl, true);
2861
3396
  }
2862
3397
  // Send regular output
2863
- session.ws.send(JSON.stringify({
2864
- type: 'output',
2865
- data: outputData
2866
- }));
3398
+ if (outputData) {
3399
+ session.ws.send(JSON.stringify({
3400
+ type: 'output',
3401
+ data: outputData
3402
+ }));
3403
+ }
2867
3404
  }
2868
3405
  });
2869
3406
  // Handle process exit
@@ -2980,7 +3517,7 @@ function handleShellConnection(ws, request) {
2980
3517
  });
2981
3518
  }
2982
3519
  // Image upload endpoint
2983
- app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
3520
+ app.post('/api/projects/:projectName/upload-images', authenticateToken, requireProjectAccess('editFiles'), async (req, res) => {
2984
3521
  try {
2985
3522
  const multer = (await import('multer')).default;
2986
3523
  const path = (await import('path')).default;
@@ -3056,7 +3593,7 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
3056
3593
  }
3057
3594
  });
3058
3595
  // Get token usage for a specific session
3059
- app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
3596
+ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, requireProjectAccess('viewFiles'), async (req, res) => {
3060
3597
  try {
3061
3598
  const { projectName, sessionId } = req.params;
3062
3599
  const { provider = 'claude' } = req.query;